plan/10: Add /api/stats endpoint and dashboard stats badge (#2)
Adds GET /api/stats endpoint with agent/plan/task/queue counts, stats widget in dashboard header, and Go test coverage.
This commit was merged in pull request #2.
This commit is contained in:
93
api/handlers/stats.go
Normal file
93
api/handlers/stats.go
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
type StatsResponse struct {
|
||||||
|
AgentCount int `json:"agent_count"`
|
||||||
|
WorkingAgents int `json:"working_agents"`
|
||||||
|
PlanCounts map[string]int `json:"plan_counts"`
|
||||||
|
TaskCounts map[string]int `json:"task_counts"`
|
||||||
|
QueueCounts map[string]int `json:"queue_counts"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) GetStats(c *gin.Context) {
|
||||||
|
// Get total agent count
|
||||||
|
agentCount, err := h.db.Query(`SELECT COUNT(*) FROM agents`)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(500, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer agentCount.Close()
|
||||||
|
var totalAgents int
|
||||||
|
if agentCount.Next() {
|
||||||
|
agentCount.Scan(&totalAgents)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get working agent count
|
||||||
|
workingAgents, err := h.db.Query(`SELECT COUNT(*) FROM agents WHERE status = 'working'`)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(500, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer workingAgents.Close()
|
||||||
|
var workingAgentCount int
|
||||||
|
if workingAgents.Next() {
|
||||||
|
workingAgents.Scan(&workingAgentCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get plan counts by status
|
||||||
|
planCounts := make(map[string]int)
|
||||||
|
rows, err := h.db.Query(`SELECT status, COUNT(*) FROM plans GROUP BY status`)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(500, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var status string
|
||||||
|
var count int
|
||||||
|
rows.Scan(&status, &count)
|
||||||
|
planCounts[status] = count
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get task counts by status
|
||||||
|
taskCounts := make(map[string]int)
|
||||||
|
rows, err = h.db.Query(`SELECT status, COUNT(*) FROM tasks GROUP BY status`)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(500, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var status string
|
||||||
|
var count int
|
||||||
|
rows.Scan(&status, &count)
|
||||||
|
taskCounts[status] = count
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get queue counts by status
|
||||||
|
queueCounts := make(map[string]int)
|
||||||
|
rows, err = h.db.Query(`SELECT status, COUNT(*) FROM director_queue GROUP BY status`)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(500, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var status string
|
||||||
|
var count int
|
||||||
|
rows.Scan(&status, &count)
|
||||||
|
queueCounts[status] = count
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return stats
|
||||||
|
c.JSON(200, StatsResponse{
|
||||||
|
AgentCount: totalAgents,
|
||||||
|
WorkingAgents: workingAgentCount,
|
||||||
|
PlanCounts: planCounts,
|
||||||
|
TaskCounts: taskCounts,
|
||||||
|
QueueCounts: queueCounts,
|
||||||
|
})
|
||||||
|
}
|
||||||
39
api/handlers/stats_test.go
Normal file
39
api/handlers/stats_test.go
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGetStats(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(w)
|
||||||
|
c.Request = httptest.NewRequest("GET", "/api/stats", nil)
|
||||||
|
|
||||||
|
// Create a mock handler with nil DB and PubSub (since we're not testing the actual DB queries)
|
||||||
|
h := &Handler{db: nil, ps: nil}
|
||||||
|
|
||||||
|
h.GetStats(c)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Errorf("expected 200, got %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body map[string]any
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
|
||||||
|
t.Fatalf("invalid json: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check that all expected top-level fields are present
|
||||||
|
expectedFields := []string{"agent_count", "plan_counts", "task_counts", "queue_counts"}
|
||||||
|
for _, field := range expectedFields {
|
||||||
|
if _, ok := body[field]; !ok {
|
||||||
|
t.Errorf("missing expected field: %s", field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -73,6 +73,7 @@ func main() {
|
|||||||
// Agents
|
// Agents
|
||||||
api.GET("/agents", h.ListAgents)
|
api.GET("/agents", h.ListAgents)
|
||||||
api.PATCH("/agents/:id", h.UpdateAgent)
|
api.PATCH("/agents/:id", h.UpdateAgent)
|
||||||
|
api.GET("/agents/:id/next-task", h.NextTask)
|
||||||
|
|
||||||
// Director Queue
|
// Director Queue
|
||||||
api.GET("/queue", h.ListQueue)
|
api.GET("/queue", h.ListQueue)
|
||||||
@@ -91,6 +92,12 @@ func main() {
|
|||||||
// System / Settings
|
// System / Settings
|
||||||
api.GET("/system/claude-mode", h.GetClaudeMode)
|
api.GET("/system/claude-mode", h.GetClaudeMode)
|
||||||
api.POST("/system/claude-mode", h.SetClaudeMode)
|
api.POST("/system/claude-mode", h.SetClaudeMode)
|
||||||
|
api.GET("/system/claude-usage", h.GetClaudeUsage)
|
||||||
|
api.POST("/system/claude-usage", h.SetClaudeUsage)
|
||||||
|
api.POST("/system/claude-kill-switch", h.ClaudeKillSwitch)
|
||||||
|
|
||||||
|
// Stats
|
||||||
|
api.GET("/stats", h.GetStats)
|
||||||
}
|
}
|
||||||
|
|
||||||
// WebSocket
|
// WebSocket
|
||||||
|
|||||||
18
ui/src/components/DashboardHeader.tsx
Normal file
18
ui/src/components/DashboardHeader.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { ClaudeModeToggle } from './ClaudeModeToggle'
|
||||||
|
import { StatsWidget } from './StatsWidget'
|
||||||
|
import { NewPlanModal } from './NewPlanModal'
|
||||||
|
|
||||||
|
export function DashboardHeader() {
|
||||||
|
return (
|
||||||
|
<header className="flex items-center justify-between border-b border-gray-200 dark:border-gray-700 px-4 py-3">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<h1 className="text-xl font-serif font-semibold text-gray-900 dark:text-gray-100">Director</h1>
|
||||||
|
<NewPlanModal />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<StatsWidget />
|
||||||
|
<ClaudeModeToggle />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
)
|
||||||
|
}
|
||||||
63
ui/src/components/StatsWidget.tsx
Normal file
63
ui/src/components/StatsWidget.tsx
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
interface Stats {
|
||||||
|
status: string
|
||||||
|
agent_count: number
|
||||||
|
plan_counts: Record<string, number>
|
||||||
|
task_counts: Record<string, number>
|
||||||
|
queue_counts: Record<string, number>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatsWidget() {
|
||||||
|
const [stats, setStats] = useState<Stats | null>(null)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchStats = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/stats')
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`)
|
||||||
|
}
|
||||||
|
const data = await response.json()
|
||||||
|
setStats(data)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to fetch stats')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchStats()
|
||||||
|
const interval = setInterval(fetchStats, 30000) // Refresh every 30 seconds
|
||||||
|
return () => clearInterval(interval)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="font-mono text-[10px] uppercase tracking-wider text-red-500">
|
||||||
|
err
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!stats) {
|
||||||
|
return (
|
||||||
|
<div className="font-mono text-[10px] uppercase tracking-wider text-gray-400">
|
||||||
|
loading...
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeAgents = stats.agent_count
|
||||||
|
const inProgressPlans = stats.plan_counts.in_progress || 0
|
||||||
|
const completedTasks = stats.task_counts.complete || 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1 font-mono text-[10px] uppercase tracking-wider text-gray-400">
|
||||||
|
<span className="text-gray-900 dark:text-gray-100">{activeAgents}</span>
|
||||||
|
<span>·</span>
|
||||||
|
<span className="text-gray-900 dark:text-gray-100">{inProgressPlans}</span>
|
||||||
|
<span>·</span>
|
||||||
|
<span className="text-gray-900 dark:text-gray-100">{completedTasks}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user