Files
zeekay 114b9701db refactor: /v1/ canonical paths (sweep)
Per global rule: /v1/ only, never /api/. Internal API calls + handler
comments + route registrations updated.

- Control plane: agentAPI mount moves from /api/v1 -> /v1; uiAPI from
  /api/ui/v1 -> /v1/ui; uiAPIV2 from /api/ui/v2 -> /v2/ui. Auth middleware
  /api/v1/health -> /v1/health. All UI handler comments updated.
- Go SDK (sdk/go/agent, sdk/go/client): control_plane_memory_backend,
  discovery, agent, note, client now use /v1/* and /v1/ui/* canonically.
- LegacyExecutionEndpoints catalog in pkg/types/execution.go normalized
  to /v1/* and /v1/ui/* (UIWorkflowRunList stays /v2/ui as it's the v2 surface).
- All test fixtures updated to match the new contract.

Build note: pre-existing rot in internal/utils/paths.go and
internal/handlers/ui/env.go (hanzo-agentsHome is not a valid Go identifier;
hyphens not allowed) prevents `go build ./...` from succeeding — this
predates the sweep and is not introduced here.
2026-05-18 22:34:48 -07:00

1467 lines
42 KiB
Go

package ui
import (
"context"
"fmt"
"net/http"
"sort"
"sync"
"time"
"github.com/hanzoai/agents/control-plane/internal/core/interfaces"
"github.com/hanzoai/agents/control-plane/internal/logger"
"github.com/hanzoai/agents/control-plane/internal/storage"
"github.com/hanzoai/agents/control-plane/pkg/types"
"github.com/gin-gonic/gin"
)
// DashboardHandler provides handlers for dashboard summary operations.
type DashboardHandler struct {
storage storage.StorageProvider
store executionRecordStore
agentService interfaces.AgentService
cache *DashboardCache
enhancedCache *EnhancedDashboardCache
}
// NewDashboardHandler creates a new DashboardHandler.
func NewDashboardHandler(storage storage.StorageProvider, agentService interfaces.AgentService) *DashboardHandler {
return &DashboardHandler{
storage: storage,
store: storage,
agentService: agentService,
cache: NewDashboardCache(),
enhancedCache: NewEnhancedDashboardCache(),
}
}
// DashboardSummaryResponse represents the dashboard summary response
type DashboardSummaryResponse struct {
Agents AgentsSummary `json:"agents"`
Executions ExecutionsSummary `json:"executions"`
SuccessRate float64 `json:"success_rate"`
Packages PackagesSummary `json:"packages"`
}
// AgentsSummary represents agent statistics
type AgentsSummary struct {
Running int `json:"running"`
Total int `json:"total"`
}
// ExecutionsSummary represents execution statistics
type ExecutionsSummary struct {
Today int `json:"today"`
Yesterday int `json:"yesterday"`
}
// PackagesSummary represents package statistics
type PackagesSummary struct {
Available int `json:"available"`
Installed int `json:"installed"`
}
// DashboardCache provides 30-second caching for dashboard data
type DashboardCache struct {
data *DashboardSummaryResponse
timestamp time.Time
mutex sync.RWMutex
ttl time.Duration
}
// NewDashboardCache creates a new dashboard cache with 30-second TTL
func NewDashboardCache() *DashboardCache {
return &DashboardCache{
ttl: 30 * time.Second,
}
}
// Get retrieves cached data if still valid
func (c *DashboardCache) Get() (*DashboardSummaryResponse, bool) {
c.mutex.RLock()
defer c.mutex.RUnlock()
if c.data != nil && time.Since(c.timestamp) < c.ttl {
return c.data, true
}
return nil, false
}
// Set stores data in cache with current timestamp
func (c *DashboardCache) Set(data *DashboardSummaryResponse) {
c.mutex.Lock()
defer c.mutex.Unlock()
c.data = data
c.timestamp = time.Now()
}
// TimeRangePreset represents standard time range options
type TimeRangePreset string
const (
TimeRangePreset1h TimeRangePreset = "1h"
TimeRangePreset24h TimeRangePreset = "24h"
TimeRangePreset7d TimeRangePreset = "7d"
TimeRangePreset30d TimeRangePreset = "30d"
TimeRangePresetCustom TimeRangePreset = "custom"
)
// TimeRangeInfo describes the time range used for the dashboard query
type TimeRangeInfo struct {
StartTime time.Time `json:"start_time"`
EndTime time.Time `json:"end_time"`
Preset TimeRangePreset `json:"preset,omitempty"`
}
// ComparisonData contains delta information comparing current to previous period
type ComparisonData struct {
PreviousPeriod TimeRangeInfo `json:"previous_period"`
OverviewDelta EnhancedOverviewDelta `json:"overview_delta"`
}
// EnhancedOverviewDelta contains changes compared to the previous period
type EnhancedOverviewDelta struct {
ExecutionsDelta int `json:"executions_delta"`
ExecutionsDeltaPct float64 `json:"executions_delta_pct"`
SuccessRateDelta float64 `json:"success_rate_delta"`
AvgDurationDeltaMs float64 `json:"avg_duration_delta_ms"`
AvgDurationDeltaPct float64 `json:"avg_duration_delta_pct"`
}
// HotspotSummary contains top error contributors by reasoner
type HotspotSummary struct {
TopFailingReasoners []HotspotItem `json:"top_failing_reasoners"`
}
// HotspotItem represents a single reasoner's failure statistics
type HotspotItem struct {
ReasonerID string `json:"reasoner_id"`
TotalExecutions int `json:"total_executions"`
FailedExecutions int `json:"failed_executions"`
ErrorRate float64 `json:"error_rate"`
ContributionPct float64 `json:"contribution_pct"`
TopErrors []ErrorCount `json:"top_errors"`
}
// ErrorCount tracks error message frequency
type ErrorCount struct {
Message string `json:"message"`
Count int `json:"count"`
}
// ActivityPatterns contains temporal patterns for failures and usage
type ActivityPatterns struct {
// HourlyHeatmap is a 7x24 matrix [dayOfWeek][hourOfDay]
HourlyHeatmap [][]HeatmapCell `json:"hourly_heatmap"`
}
// HeatmapCell contains execution statistics for a specific day/hour combination
type HeatmapCell struct {
Total int `json:"total"`
Failed int `json:"failed"`
ErrorRate float64 `json:"error_rate"`
}
// Enhanced dashboard response structures
type EnhancedDashboardResponse struct {
GeneratedAt time.Time `json:"generated_at"`
TimeRange TimeRangeInfo `json:"time_range"`
Overview EnhancedOverview `json:"overview"`
ExecutionTrends ExecutionTrends `json:"execution_trends"`
AgentHealth AgentHealthSummary `json:"agent_health"`
Workflows WorkflowInsights `json:"workflows"`
Incidents []IncidentItem `json:"incidents"`
Comparison *ComparisonData `json:"comparison,omitempty"`
Hotspots HotspotSummary `json:"hotspots"`
ActivityPatterns ActivityPatterns `json:"activity_patterns"`
}
type EnhancedOverview struct {
TotalAgents int `json:"total_agents"`
ActiveAgents int `json:"active_agents"`
DegradedAgents int `json:"degraded_agents"`
OfflineAgents int `json:"offline_agents"`
TotalReasoners int `json:"total_reasoners"`
TotalSkills int `json:"total_skills"`
ExecutionsLast24h int `json:"executions_last_24h"`
ExecutionsLast7d int `json:"executions_last_7d"`
SuccessRate24h float64 `json:"success_rate_24h"`
AverageDurationMs24h float64 `json:"average_duration_ms_24h"`
MedianDurationMs24h float64 `json:"median_duration_ms_24h"`
}
type ExecutionTrends struct {
Last24h ExecutionWindowMetrics `json:"last_24h"`
Last7Days []ExecutionTrendPoint `json:"last_7_days"`
}
type ExecutionWindowMetrics struct {
Total int `json:"total"`
Succeeded int `json:"succeeded"`
Failed int `json:"failed"`
SuccessRate float64 `json:"success_rate"`
AverageDurationMs float64 `json:"average_duration_ms"`
ThroughputPerHour float64 `json:"throughput_per_hour"`
}
type ExecutionTrendPoint struct {
Date string `json:"date"`
Total int `json:"total"`
Succeeded int `json:"succeeded"`
Failed int `json:"failed"`
}
type AgentHealthSummary struct {
Total int `json:"total"`
Active int `json:"active"`
Degraded int `json:"degraded"`
Offline int `json:"offline"`
Agents []AgentHealthItem `json:"agents"`
}
type AgentHealthItem struct {
ID string `json:"id"`
TeamID string `json:"team_id"`
Version string `json:"version"`
Status string `json:"status"`
Health string `json:"health"`
Lifecycle string `json:"lifecycle"`
LastHeartbeat time.Time `json:"last_heartbeat"`
Reasoners int `json:"reasoners"`
Skills int `json:"skills"`
Uptime string `json:"uptime,omitempty"`
}
type WorkflowInsights struct {
TopWorkflows []WorkflowStat `json:"top_workflows"`
ActiveRuns []ActiveWorkflowRun `json:"active_runs"`
LongestExecutions []CompletedExecutionStat `json:"longest_executions"`
}
type WorkflowStat struct {
WorkflowID string `json:"workflow_id"`
Name string `json:"name,omitempty"`
TotalExecutions int `json:"total_executions"`
SuccessRate float64 `json:"success_rate"`
FailedExecutions int `json:"failed_executions"`
AverageDuration float64 `json:"average_duration_ms"`
LastActivity time.Time `json:"last_activity"`
}
type ActiveWorkflowRun struct {
ExecutionID string `json:"execution_id"`
WorkflowID string `json:"workflow_id"`
Name string `json:"name,omitempty"`
StartedAt time.Time `json:"started_at"`
ElapsedMs int64 `json:"elapsed_ms"`
AgentNodeID string `json:"agent_node_id"`
ReasonerID string `json:"reasoner_id"`
Status string `json:"status"`
}
type CompletedExecutionStat struct {
ExecutionID string `json:"execution_id"`
WorkflowID string `json:"workflow_id"`
Name string `json:"name,omitempty"`
DurationMs int64 `json:"duration_ms"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
Status string `json:"status"`
}
type IncidentItem struct {
ExecutionID string `json:"execution_id"`
WorkflowID string `json:"workflow_id"`
Name string `json:"name,omitempty"`
Status string `json:"status"`
StartedAt time.Time `json:"started_at"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
AgentNodeID string `json:"agent_node_id"`
ReasonerID string `json:"reasoner_id"`
Error string `json:"error,omitempty"`
}
// enhancedCacheEntry holds cached data with timestamp
type enhancedCacheEntry struct {
data *EnhancedDashboardResponse
timestamp time.Time
}
// EnhancedDashboardCache provides time-range-aware caching for the enhanced dashboard response
type EnhancedDashboardCache struct {
entries map[string]*enhancedCacheEntry
mutex sync.RWMutex
maxSize int
}
// NewEnhancedDashboardCache creates a new cache instance for enhanced dashboard data
func NewEnhancedDashboardCache() *EnhancedDashboardCache {
return &EnhancedDashboardCache{
entries: make(map[string]*enhancedCacheEntry),
maxSize: 10, // LRU limit
}
}
// getTTLForPreset returns the appropriate cache TTL based on time range
func getTTLForPreset(preset TimeRangePreset) time.Duration {
switch preset {
case TimeRangePreset1h:
return 30 * time.Second
case TimeRangePreset24h:
return 60 * time.Second
case TimeRangePreset7d:
return 2 * time.Minute
case TimeRangePreset30d:
return 5 * time.Minute
default:
return 60 * time.Second
}
}
// generateCacheKey creates a cache key from time range parameters
func generateCacheKey(startTime, endTime time.Time, compare bool) string {
// Round to hour for better cache reuse
startHour := startTime.Truncate(time.Hour).Unix()
endHour := endTime.Truncate(time.Hour).Unix()
compareStr := "0"
if compare {
compareStr = "1"
}
return fmt.Sprintf("%d-%d-%s", startHour, endHour, compareStr)
}
// Get retrieves cached enhanced dashboard data if still valid
func (c *EnhancedDashboardCache) Get(key string, preset TimeRangePreset) (*EnhancedDashboardResponse, bool) {
c.mutex.RLock()
defer c.mutex.RUnlock()
entry, exists := c.entries[key]
if !exists {
return nil, false
}
ttl := getTTLForPreset(preset)
if time.Since(entry.timestamp) >= ttl {
return nil, false
}
return entry.data, true
}
// Set stores enhanced dashboard data in the cache with LRU eviction
func (c *EnhancedDashboardCache) Set(key string, data *EnhancedDashboardResponse) {
c.mutex.Lock()
defer c.mutex.Unlock()
// Simple LRU: if at capacity, remove oldest entry
if len(c.entries) >= c.maxSize {
var oldestKey string
var oldestTime time.Time
for k, entry := range c.entries {
if oldestKey == "" || entry.timestamp.Before(oldestTime) {
oldestKey = k
oldestTime = entry.timestamp
}
}
if oldestKey != "" {
delete(c.entries, oldestKey)
}
}
c.entries[key] = &enhancedCacheEntry{
data: data,
timestamp: time.Now(),
}
}
// GetDashboardSummaryHandler handles dashboard summary requests
// GET /v1/ui/dashboard/summary
func (h *DashboardHandler) GetDashboardSummaryHandler(c *gin.Context) {
ctx := c.Request.Context()
now := time.Now().UTC()
// Check cache first
if cachedData, found := h.cache.Get(); found {
logger.Logger.Debug().Msg("Returning cached dashboard summary")
c.JSON(http.StatusOK, cachedData)
return
}
logger.Logger.Debug().Msg("Generating fresh dashboard summary")
// Collect all data concurrently for better performance
var wg sync.WaitGroup
var agentsSummary AgentsSummary
var executionsSummary ExecutionsSummary
var packagesSummary PackagesSummary
var successRate float64
var errors []error
var errorsMutex sync.Mutex
// Helper function to handle errors
addError := func(err error) {
if err != nil {
errorsMutex.Lock()
errors = append(errors, err)
errorsMutex.Unlock()
}
}
// Collect agents data
wg.Add(1)
go func() {
defer wg.Done()
summary, err := h.getAgentsSummary(ctx)
if err != nil {
addError(err)
return
}
agentsSummary = summary
}()
// Collect executions data and success rate
wg.Add(1)
go func() {
defer wg.Done()
summary, rate, err := h.getExecutionsSummaryAndSuccessRate(ctx, now)
if err != nil {
addError(err)
return
}
executionsSummary = summary
successRate = rate
}()
// Collect packages data
wg.Add(1)
go func() {
defer wg.Done()
summary, err := h.getPackagesSummary(ctx)
if err != nil {
addError(err)
return
}
packagesSummary = summary
}()
// Wait for all goroutines to complete
wg.Wait()
// Check for errors
if len(errors) > 0 {
logger.Logger.Error().Errs("errors", errors).Msg("Errors occurred while collecting dashboard data")
c.JSON(http.StatusInternalServerError, ErrorResponse{Error: "failed to collect dashboard data"})
return
}
// Build response
response := &DashboardSummaryResponse{
Agents: agentsSummary,
Executions: executionsSummary,
SuccessRate: successRate,
Packages: packagesSummary,
}
// Cache the response
h.cache.Set(response)
c.JSON(http.StatusOK, response)
}
// parseTimeRangeParams extracts time range from query parameters
func parseTimeRangeParams(c *gin.Context, now time.Time) (startTime, endTime time.Time, preset TimeRangePreset, err error) {
presetStr := c.DefaultQuery("preset", "24h")
preset = TimeRangePreset(presetStr)
// Round to hour for consistent cache behavior
roundedNow := now.Truncate(time.Hour).Add(time.Hour) // Include current hour
switch preset {
case TimeRangePreset1h:
startTime = roundedNow.Add(-1 * time.Hour)
endTime = roundedNow
case TimeRangePreset24h:
startTime = roundedNow.Add(-24 * time.Hour)
endTime = roundedNow
case TimeRangePreset7d:
startTime = roundedNow.AddDate(0, 0, -7)
endTime = roundedNow
case TimeRangePreset30d:
startTime = roundedNow.AddDate(0, 0, -30)
endTime = roundedNow
case TimeRangePresetCustom:
startStr := c.Query("start_time")
endStr := c.Query("end_time")
if startStr == "" || endStr == "" {
logger.Logger.Warn().Msg("start_time and end_time required for custom range, falling back to 24h")
return now.Add(-24*time.Hour), now, TimeRangePreset24h, nil
}
startTime, err = time.Parse(time.RFC3339, startStr)
if err != nil {
logger.Logger.Warn().Err(err).Msg("invalid start_time format, falling back to 24h")
return now.Add(-24*time.Hour), now, TimeRangePreset24h, nil
}
endTime, err = time.Parse(time.RFC3339, endStr)
if err != nil {
logger.Logger.Warn().Err(err).Msg("invalid end_time format, falling back to 24h")
return now.Add(-24*time.Hour), now, TimeRangePreset24h, nil
}
default:
// Default to 24h
preset = TimeRangePreset24h
startTime = roundedNow.Add(-24 * time.Hour)
endTime = roundedNow
}
return startTime, endTime, preset, nil
}
// calculateComparisonPeriod returns the previous period for comparison
func calculateComparisonPeriod(startTime, endTime time.Time) (prevStart, prevEnd time.Time) {
duration := endTime.Sub(startTime)
return startTime.Add(-duration), startTime
}
// GetEnhancedDashboardSummaryHandler handles requests for the enhanced dashboard view
// GET /v1/ui/dashboard/enhanced
// Query params:
// - preset: "1h", "24h", "7d", "30d", "custom" (default: "24h")
// - start_time: RFC3339 timestamp (required if preset=custom)
// - end_time: RFC3339 timestamp (required if preset=custom)
// - compare: "true" to include comparison data (default: "false")
func (h *DashboardHandler) GetEnhancedDashboardSummaryHandler(c *gin.Context) {
ctx := c.Request.Context()
now := time.Now().UTC()
// Parse time range from query params
startTime, endTime, preset, err := parseTimeRangeParams(c, now)
if err != nil {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "invalid time range parameters"})
return
}
// Check if comparison is requested
enableComparison := c.Query("compare") == "true"
// Generate cache key and check cache
cacheKey := generateCacheKey(startTime, endTime, enableComparison)
if cached, found := h.enhancedCache.Get(cacheKey, preset); found {
logger.Logger.Debug().Str("key", cacheKey).Msg("Returning cached enhanced dashboard summary")
c.JSON(http.StatusOK, cached)
return
}
// Query executions for the specified time range
filters := types.ExecutionFilter{
StartTime: &startTime,
EndTime: &endTime,
Limit: 50000,
SortBy: "started_at",
SortDescending: false,
}
executions, err := h.store.QueryExecutionRecords(ctx, filters)
if err != nil {
logger.Logger.Error().Err(err).Msg("failed to query workflow executions for enhanced dashboard")
c.JSON(http.StatusInternalServerError, ErrorResponse{Error: "failed to load workflow execution data"})
return
}
agents, err := h.storage.ListAgents(ctx, types.AgentFilters{})
if err != nil {
logger.Logger.Error().Err(err).Msg("failed to list agents for enhanced dashboard")
c.JSON(http.StatusInternalServerError, ErrorResponse{Error: "failed to load agent data"})
return
}
statusRunning := string(types.ExecutionStatusRunning)
runningExecutions, err := h.store.QueryExecutionRecords(ctx, types.ExecutionFilter{
Status: &statusRunning,
Limit: 12,
SortBy: "started_at",
SortDescending: true,
})
if err != nil {
logger.Logger.Error().Err(err).Msg("failed to query running executions for enhanced dashboard")
c.JSON(http.StatusInternalServerError, ErrorResponse{Error: "failed to load active workflow data"})
return
}
// Build time range info
timeRange := TimeRangeInfo{
StartTime: startTime,
EndTime: endTime,
Preset: preset,
}
overview := h.buildEnhancedOverviewForRange(agents, executions, startTime, endTime)
trends := buildExecutionTrendsForRange(executions, startTime, endTime, preset)
agentHealth := h.buildAgentHealthSummary(ctx, agents)
workflows := buildWorkflowInsights(executions, runningExecutions)
incidents := buildIncidentItems(executions, 10)
hotspots := buildHotspotSummary(executions)
activityPatterns := buildActivityPatterns(executions)
response := &EnhancedDashboardResponse{
GeneratedAt: now,
TimeRange: timeRange,
Overview: overview,
ExecutionTrends: trends,
AgentHealth: agentHealth,
Workflows: workflows,
Incidents: incidents,
Hotspots: hotspots,
ActivityPatterns: activityPatterns,
}
// Calculate comparison data if requested
if enableComparison {
prevStart, prevEnd := calculateComparisonPeriod(startTime, endTime)
prevFilters := types.ExecutionFilter{
StartTime: &prevStart,
EndTime: &prevEnd,
Limit: 50000,
SortBy: "started_at",
SortDescending: false,
}
prevExecutions, err := h.store.QueryExecutionRecords(ctx, prevFilters)
if err == nil {
prevOverview := h.buildEnhancedOverviewForRange(agents, prevExecutions, prevStart, prevEnd)
response.Comparison = buildComparisonData(overview, prevOverview, prevStart, prevEnd)
}
}
h.enhancedCache.Set(cacheKey, response)
c.JSON(http.StatusOK, response)
}
// buildEnhancedOverviewForRange builds overview metrics for a specific time range
func (h *DashboardHandler) buildEnhancedOverviewForRange(agents []*types.AgentNode, executions []*types.Execution, startTime, endTime time.Time) EnhancedOverview {
overview := EnhancedOverview{TotalAgents: len(agents)}
for _, agent := range agents {
overview.TotalReasoners += len(agent.Reasoners)
overview.TotalSkills += len(agent.Skills)
isDegraded := agent.LifecycleStatus == types.AgentStatusDegraded || agent.HealthStatus == types.HealthStatusInactive
if isDegraded {
overview.DegradedAgents++
continue
}
status, err := h.agentService.GetAgentStatus(agent.ID)
if err != nil {
overview.OfflineAgents++
continue
}
if status != nil && status.IsRunning {
overview.ActiveAgents++
} else {
overview.OfflineAgents++
}
}
if overview.OfflineAgents < 0 {
overview.OfflineAgents = 0
}
var durationSamples []int64
var durationSum float64
var durationCount float64
var successCount, failedCount int
for _, exec := range executions {
overview.ExecutionsLast24h++ // Repurposed as "executions in range"
normalized := types.NormalizeExecutionStatus(exec.Status)
switch normalized {
case string(types.ExecutionStatusSucceeded):
successCount++
case string(types.ExecutionStatusFailed), string(types.ExecutionStatusCancelled), string(types.ExecutionStatusTimeout):
failedCount++
}
if exec.DurationMS != nil {
d := *exec.DurationMS
durationSamples = append(durationSamples, d)
durationSum += float64(d)
durationCount++
}
}
overview.ExecutionsLast7d = len(executions)
if len(executions) > 0 {
overview.SuccessRate24h = (float64(successCount) / float64(len(executions))) * 100
}
if durationCount > 0 {
overview.AverageDurationMs24h = durationSum / durationCount
}
overview.MedianDurationMs24h = computeMedian(durationSamples)
return overview
}
// buildExecutionTrendsForRange builds trends for the specified time range
func buildExecutionTrendsForRange(executions []*types.Execution, startTime, endTime time.Time, preset TimeRangePreset) ExecutionTrends {
trend := ExecutionTrends{}
duration := endTime.Sub(startTime)
// Determine bucket size based on preset
var bucketDuration time.Duration
var numBuckets int
switch preset {
case TimeRangePreset1h:
bucketDuration = 5 * time.Minute
numBuckets = 12
case TimeRangePreset24h:
bucketDuration = time.Hour
numBuckets = 24
case TimeRangePreset7d:
bucketDuration = 24 * time.Hour
numBuckets = 7
case TimeRangePreset30d:
bucketDuration = 24 * time.Hour
numBuckets = 30
default:
// For custom, use day buckets capped at 30
bucketDuration = 24 * time.Hour
numBuckets = int(duration.Hours() / 24)
if numBuckets > 30 {
numBuckets = 30
}
if numBuckets < 1 {
numBuckets = 1
}
}
// Create buckets
dayBuckets := make(map[string]*ExecutionTrendPoint)
orderedKeys := make([]string, 0, numBuckets)
for i := numBuckets - 1; i >= 0; i-- {
bucketTime := endTime.Add(-time.Duration(i) * bucketDuration)
var key string
if bucketDuration >= 24*time.Hour {
key = bucketTime.Format("2006-01-02")
} else {
key = bucketTime.Format("2006-01-02T15:04")
}
orderedKeys = append(orderedKeys, key)
dayBuckets[key] = &ExecutionTrendPoint{Date: key}
}
var totalInRange, successInRange, failedInRange int
var durationSum float64
var durationCount float64
for _, exec := range executions {
var bucketKey string
if bucketDuration >= 24*time.Hour {
bucketKey = exec.StartedAt.Format("2006-01-02")
} else {
// Round to bucket
bucketKey = exec.StartedAt.Truncate(bucketDuration).Format("2006-01-02T15:04")
}
if point, ok := dayBuckets[bucketKey]; ok {
point.Total++
normalized := types.NormalizeExecutionStatus(exec.Status)
switch normalized {
case string(types.ExecutionStatusSucceeded):
point.Succeeded++
case string(types.ExecutionStatusFailed), string(types.ExecutionStatusCancelled), string(types.ExecutionStatusTimeout):
point.Failed++
}
}
totalInRange++
normalized := types.NormalizeExecutionStatus(exec.Status)
switch normalized {
case string(types.ExecutionStatusSucceeded):
successInRange++
case string(types.ExecutionStatusFailed), string(types.ExecutionStatusCancelled), string(types.ExecutionStatusTimeout):
failedInRange++
}
if exec.DurationMS != nil {
durationSum += float64(*exec.DurationMS)
durationCount++
}
}
trend.Last7Days = make([]ExecutionTrendPoint, 0, len(orderedKeys))
for _, key := range orderedKeys {
trend.Last7Days = append(trend.Last7Days, *dayBuckets[key])
}
trend.Last24h.Total = totalInRange
trend.Last24h.Succeeded = successInRange
trend.Last24h.Failed = failedInRange
if totalInRange > 0 {
trend.Last24h.SuccessRate = (float64(successInRange) / float64(totalInRange)) * 100
hours := duration.Hours()
if hours > 0 {
trend.Last24h.ThroughputPerHour = float64(totalInRange) / hours
}
}
if durationCount > 0 {
trend.Last24h.AverageDurationMs = durationSum / durationCount
}
return trend
}
// buildComparisonData creates comparison metrics between current and previous periods
func buildComparisonData(current, previous EnhancedOverview, prevStart, prevEnd time.Time) *ComparisonData {
delta := EnhancedOverviewDelta{}
// Executions delta
delta.ExecutionsDelta = current.ExecutionsLast24h - previous.ExecutionsLast24h
if previous.ExecutionsLast24h > 0 {
delta.ExecutionsDeltaPct = (float64(delta.ExecutionsDelta) / float64(previous.ExecutionsLast24h)) * 100
}
// Success rate delta
delta.SuccessRateDelta = current.SuccessRate24h - previous.SuccessRate24h
// Duration delta
delta.AvgDurationDeltaMs = current.AverageDurationMs24h - previous.AverageDurationMs24h
if previous.AverageDurationMs24h > 0 {
delta.AvgDurationDeltaPct = (delta.AvgDurationDeltaMs / previous.AverageDurationMs24h) * 100
}
return &ComparisonData{
PreviousPeriod: TimeRangeInfo{
StartTime: prevStart,
EndTime: prevEnd,
},
OverviewDelta: delta,
}
}
// buildHotspotSummary aggregates failures by reasoner
func buildHotspotSummary(executions []*types.Execution) HotspotSummary {
type reasonerStats struct {
total int
failed int
errorMsgs map[string]int
}
statsMap := make(map[string]*reasonerStats)
totalFailures := 0
for _, exec := range executions {
if exec.ReasonerID == "" {
continue
}
stats, ok := statsMap[exec.ReasonerID]
if !ok {
stats = &reasonerStats{errorMsgs: make(map[string]int)}
statsMap[exec.ReasonerID] = stats
}
stats.total++
normalized := types.NormalizeExecutionStatus(exec.Status)
if normalized == string(types.ExecutionStatusFailed) ||
normalized == string(types.ExecutionStatusCancelled) ||
normalized == string(types.ExecutionStatusTimeout) {
stats.failed++
totalFailures++
if exec.ErrorMessage != nil && *exec.ErrorMessage != "" {
// Truncate long error messages
errMsg := *exec.ErrorMessage
if len(errMsg) > 100 {
errMsg = errMsg[:100] + "..."
}
stats.errorMsgs[errMsg]++
}
}
}
// Convert to slice and sort by failure count
items := make([]HotspotItem, 0, len(statsMap))
for reasonerID, stats := range statsMap {
if stats.failed == 0 {
continue
}
item := HotspotItem{
ReasonerID: reasonerID,
TotalExecutions: stats.total,
FailedExecutions: stats.failed,
}
if stats.total > 0 {
item.ErrorRate = (float64(stats.failed) / float64(stats.total)) * 100
}
if totalFailures > 0 {
item.ContributionPct = (float64(stats.failed) / float64(totalFailures)) * 100
}
// Get top errors (up to 3)
topErrors := make([]ErrorCount, 0, 3)
for msg, count := range stats.errorMsgs {
topErrors = append(topErrors, ErrorCount{Message: msg, Count: count})
}
sort.Slice(topErrors, func(i, j int) bool {
return topErrors[i].Count > topErrors[j].Count
})
if len(topErrors) > 3 {
topErrors = topErrors[:3]
}
item.TopErrors = topErrors
items = append(items, item)
}
// Sort by failure count descending
sort.Slice(items, func(i, j int) bool {
return items[i].FailedExecutions > items[j].FailedExecutions
})
// Limit to top 10
if len(items) > 10 {
items = items[:10]
}
return HotspotSummary{TopFailingReasoners: items}
}
// buildActivityPatterns creates a 7x24 heatmap of execution activity
func buildActivityPatterns(executions []*types.Execution) ActivityPatterns {
// Initialize 7x24 grid (Sunday=0 through Saturday=6)
heatmap := make([][]HeatmapCell, 7)
for day := 0; day < 7; day++ {
heatmap[day] = make([]HeatmapCell, 24)
}
for _, exec := range executions {
dayOfWeek := int(exec.StartedAt.Weekday())
hourOfDay := exec.StartedAt.Hour()
heatmap[dayOfWeek][hourOfDay].Total++
normalized := types.NormalizeExecutionStatus(exec.Status)
if normalized == string(types.ExecutionStatusFailed) ||
normalized == string(types.ExecutionStatusCancelled) ||
normalized == string(types.ExecutionStatusTimeout) {
heatmap[dayOfWeek][hourOfDay].Failed++
}
}
// Calculate error rates
for day := 0; day < 7; day++ {
for hour := 0; hour < 24; hour++ {
if heatmap[day][hour].Total > 0 {
heatmap[day][hour].ErrorRate = (float64(heatmap[day][hour].Failed) / float64(heatmap[day][hour].Total)) * 100
}
}
}
return ActivityPatterns{HourlyHeatmap: heatmap}
}
func (h *DashboardHandler) buildEnhancedOverview(now time.Time, agents []*types.AgentNode, executions []*types.Execution) EnhancedOverview {
overview := EnhancedOverview{TotalAgents: len(agents)}
for _, agent := range agents {
// Count reasoners and skills
overview.TotalReasoners += len(agent.Reasoners)
overview.TotalSkills += len(agent.Skills)
isDegraded := agent.LifecycleStatus == types.AgentStatusDegraded || agent.HealthStatus == types.HealthStatusInactive
if isDegraded {
overview.DegradedAgents++
continue
}
status, err := h.agentService.GetAgentStatus(agent.ID)
if err != nil {
overview.OfflineAgents++
continue
}
if status != nil && status.IsRunning {
overview.ActiveAgents++
} else {
overview.OfflineAgents++
}
}
// Ensure offline count is consistent
if overview.OfflineAgents < 0 {
overview.OfflineAgents = 0
}
last24h := now.Add(-24 * time.Hour)
var durationSamples []int64
var durationSum float64
var durationCount float64
var success24h, failed24h int
for _, exec := range executions {
if exec.StartedAt.After(last24h) || exec.StartedAt.Equal(last24h) {
overview.ExecutionsLast24h++
normalized := types.NormalizeExecutionStatus(exec.Status)
switch normalized {
case string(types.ExecutionStatusSucceeded):
success24h++
case string(types.ExecutionStatusFailed), string(types.ExecutionStatusCancelled), string(types.ExecutionStatusTimeout):
failed24h++
}
if exec.DurationMS != nil {
d := *exec.DurationMS
durationSamples = append(durationSamples, d)
durationSum += float64(d)
durationCount++
}
}
}
overview.ExecutionsLast7d = len(executions)
if overview.ExecutionsLast24h > 0 {
overview.SuccessRate24h = (float64(success24h) / float64(overview.ExecutionsLast24h)) * 100
}
if durationCount > 0 {
overview.AverageDurationMs24h = durationSum / durationCount
}
overview.MedianDurationMs24h = computeMedian(durationSamples)
return overview
}
func buildExecutionTrends(now time.Time, executions []*types.Execution) ExecutionTrends {
trend := ExecutionTrends{}
last24h := now.Add(-24 * time.Hour)
var total24h, success24h, failed24h int
var durationSum float64
var durationCount float64
// Prepare day buckets for the last 7 days (including today)
dayBuckets := make(map[string]*ExecutionTrendPoint)
orderedDays := make([]string, 0, 7)
for i := 6; i >= 0; i-- {
day := now.AddDate(0, 0, -i)
key := day.Format("2006-01-02")
orderedDays = append(orderedDays, key)
dayBuckets[key] = &ExecutionTrendPoint{Date: key}
}
for _, exec := range executions {
dayKey := exec.StartedAt.Format("2006-01-02")
point, ok := dayBuckets[dayKey]
if ok {
point.Total++
normalized := types.NormalizeExecutionStatus(exec.Status)
switch normalized {
case string(types.ExecutionStatusSucceeded):
point.Succeeded++
case string(types.ExecutionStatusFailed), string(types.ExecutionStatusCancelled), string(types.ExecutionStatusTimeout):
point.Failed++
}
}
if exec.StartedAt.After(last24h) || exec.StartedAt.Equal(last24h) {
total24h++
normalized := types.NormalizeExecutionStatus(exec.Status)
switch normalized {
case string(types.ExecutionStatusSucceeded):
success24h++
case string(types.ExecutionStatusFailed), string(types.ExecutionStatusCancelled), string(types.ExecutionStatusTimeout):
failed24h++
}
if exec.DurationMS != nil {
durationSum += float64(*exec.DurationMS)
durationCount++
}
}
}
trend.Last7Days = make([]ExecutionTrendPoint, 0, len(orderedDays))
for _, key := range orderedDays {
trend.Last7Days = append(trend.Last7Days, *dayBuckets[key])
}
trend.Last24h.Total = total24h
trend.Last24h.Succeeded = success24h
trend.Last24h.Failed = failed24h
if total24h > 0 {
trend.Last24h.SuccessRate = (float64(success24h) / float64(total24h)) * 100
trend.Last24h.ThroughputPerHour = float64(total24h) / 24.0
}
if durationCount > 0 {
trend.Last24h.AverageDurationMs = durationSum / durationCount
}
return trend
}
func (h *DashboardHandler) buildAgentHealthSummary(ctx context.Context, agents []*types.AgentNode) AgentHealthSummary {
summary := AgentHealthSummary{Total: len(agents)}
items := make([]AgentHealthItem, 0, len(agents))
for _, agent := range agents {
item := AgentHealthItem{
ID: agent.ID,
TeamID: agent.TeamID,
Version: agent.Version,
Health: string(agent.HealthStatus),
Lifecycle: string(agent.LifecycleStatus),
LastHeartbeat: agent.LastHeartbeat,
Reasoners: len(agent.Reasoners),
Skills: len(agent.Skills),
}
isDegraded := agent.LifecycleStatus == types.AgentStatusDegraded || agent.HealthStatus == types.HealthStatusInactive
if isDegraded {
summary.Degraded++
item.Status = "degraded"
items = append(items, item)
continue
}
status, err := h.agentService.GetAgentStatus(agent.ID)
if err != nil {
summary.Offline++
item.Status = "offline"
items = append(items, item)
continue
}
if status != nil {
item.Uptime = status.Uptime
if status.IsRunning {
summary.Active++
item.Status = "running"
} else {
summary.Offline++
item.Status = "offline"
}
} else {
summary.Offline++
item.Status = "offline"
}
items = append(items, item)
}
// Derive offline count if we encountered transient errors
if summary.Offline < 0 {
summary.Offline = 0
}
priority := map[string]int{
"degraded": 0,
"offline": 1,
"running": 2,
"unknown": 3,
}
sort.Slice(items, func(i, j int) bool {
pi := priority[items[i].Status]
pj := priority[items[j].Status]
if pi == pj {
return items[i].LastHeartbeat.After(items[j].LastHeartbeat)
}
return pi < pj
})
if len(items) > 12 {
items = items[:12]
}
summary.Agents = items
return summary
}
func buildWorkflowInsights(executions []*types.Execution, running []*types.Execution) WorkflowInsights {
insights := WorkflowInsights{}
workflowAggregates := make(map[string]*WorkflowStat)
for _, exec := range executions {
id := exec.RunID
aggregate, ok := workflowAggregates[id]
if !ok {
aggregate = &WorkflowStat{
WorkflowID: id,
Name: exec.ReasonerID,
}
workflowAggregates[id] = aggregate
}
aggregate.TotalExecutions++
aggregate.LastActivity = maxTime(aggregate.LastActivity, exec.StartedAt)
if exec.DurationMS != nil {
aggregate.AverageDuration += float64(*exec.DurationMS)
}
normalized := types.NormalizeExecutionStatus(exec.Status)
switch normalized {
case string(types.ExecutionStatusSucceeded):
aggregate.SuccessRate++
case string(types.ExecutionStatusFailed), string(types.ExecutionStatusCancelled), string(types.ExecutionStatusTimeout):
aggregate.FailedExecutions++
}
}
topWorkflows := make([]WorkflowStat, 0, len(workflowAggregates))
for _, aggregate := range workflowAggregates {
if aggregate.TotalExecutions > 0 {
aggregate.AverageDuration = aggregate.AverageDuration / float64(aggregate.TotalExecutions)
aggregate.SuccessRate = (aggregate.SuccessRate / float64(aggregate.TotalExecutions)) * 100
}
topWorkflows = append(topWorkflows, *aggregate)
}
sort.Slice(topWorkflows, func(i, j int) bool {
if topWorkflows[i].TotalExecutions == topWorkflows[j].TotalExecutions {
return topWorkflows[i].LastActivity.After(topWorkflows[j].LastActivity)
}
return topWorkflows[i].TotalExecutions > topWorkflows[j].TotalExecutions
})
if len(topWorkflows) > 5 {
topWorkflows = topWorkflows[:5]
}
insights.TopWorkflows = topWorkflows
activeRuns := make([]ActiveWorkflowRun, 0, len(running))
for _, exec := range running {
elapsed := time.Since(exec.StartedAt).Milliseconds()
activeRuns = append(activeRuns, ActiveWorkflowRun{
ExecutionID: exec.ExecutionID,
WorkflowID: exec.RunID,
Name: exec.ReasonerID,
StartedAt: exec.StartedAt,
ElapsedMs: elapsed,
AgentNodeID: exec.AgentNodeID,
ReasonerID: exec.ReasonerID,
Status: exec.Status,
})
}
sort.Slice(activeRuns, func(i, j int) bool {
return activeRuns[i].ElapsedMs > activeRuns[j].ElapsedMs
})
if len(activeRuns) > 6 {
activeRuns = activeRuns[:6]
}
insights.ActiveRuns = activeRuns
completed := make([]CompletedExecutionStat, 0, len(executions))
for _, exec := range executions {
if exec.DurationMS == nil || exec.CompletedAt == nil {
continue
}
completed = append(completed, CompletedExecutionStat{
ExecutionID: exec.ExecutionID,
WorkflowID: exec.RunID,
Name: exec.ReasonerID,
DurationMs: *exec.DurationMS,
CompletedAt: exec.CompletedAt,
Status: exec.Status,
})
}
sort.Slice(completed, func(i, j int) bool {
if completed[i].DurationMs == completed[j].DurationMs {
return completed[i].CompletedAt.After(*completed[j].CompletedAt)
}
return completed[i].DurationMs > completed[j].DurationMs
})
if len(completed) > 5 {
completed = completed[:5]
}
insights.LongestExecutions = completed
return insights
}
func buildIncidentItems(executions []*types.Execution, limit int) []IncidentItem {
incidents := make([]IncidentItem, 0, limit)
for _, exec := range executions {
normalized := types.NormalizeExecutionStatus(exec.Status)
if normalized != string(types.ExecutionStatusFailed) &&
normalized != string(types.ExecutionStatusTimeout) &&
normalized != string(types.ExecutionStatusCancelled) {
continue
}
errorMessage := ""
if exec.ErrorMessage != nil {
errorMessage = *exec.ErrorMessage
}
incidents = append(incidents, IncidentItem{
ExecutionID: exec.ExecutionID,
WorkflowID: exec.RunID,
Name: exec.ReasonerID,
Status: exec.Status,
StartedAt: exec.StartedAt,
CompletedAt: exec.CompletedAt,
AgentNodeID: exec.AgentNodeID,
ReasonerID: exec.ReasonerID,
Error: errorMessage,
})
}
sort.Slice(incidents, func(i, j int) bool {
return incidents[i].StartedAt.After(incidents[j].StartedAt)
})
if len(incidents) > limit {
incidents = incidents[:limit]
}
return incidents
}
func computeMedian(values []int64) float64 {
if len(values) == 0 {
return 0
}
sort.Slice(values, func(i, j int) bool { return values[i] < values[j] })
mid := len(values) / 2
if len(values)%2 == 1 {
return float64(values[mid])
}
return float64(values[mid-1]+values[mid]) / 2.0
}
func maxTime(current time.Time, candidate time.Time) time.Time {
if current.IsZero() {
return candidate
}
if candidate.After(current) {
return candidate
}
return current
}
// getAgentsSummary collects agent statistics
func (h *DashboardHandler) getAgentsSummary(ctx context.Context) (AgentsSummary, error) {
// Get all registered agents
agents, err := h.storage.ListAgents(ctx, types.AgentFilters{})
if err != nil {
return AgentsSummary{}, err
}
total := len(agents)
running := 0
// Count running agents using the agent service
for _, agent := range agents {
if status, err := h.agentService.GetAgentStatus(agent.ID); err == nil && status.IsRunning {
running++
}
}
return AgentsSummary{
Running: running,
Total: total,
}, nil
}
// getExecutionsSummaryAndSuccessRate collects execution statistics and calculates success rate
func (h *DashboardHandler) getExecutionsSummaryAndSuccessRate(ctx context.Context, now time.Time) (ExecutionsSummary, float64, error) {
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
yesterday := today.AddDate(0, 0, -1)
tomorrow := today.AddDate(0, 0, 1)
// Get today's executions
todayFilters := types.ExecutionFilter{
StartTime: &today,
EndTime: &tomorrow,
Limit: 10000,
SortBy: "started_at",
SortDescending: false,
}
todayExecutions, err := h.store.QueryExecutionRecords(ctx, todayFilters)
if err != nil {
return ExecutionsSummary{}, 0, err
}
// Get yesterday's executions
yesterdayFilters := types.ExecutionFilter{
StartTime: &yesterday,
EndTime: &today,
Limit: 10000,
SortBy: "started_at",
SortDescending: false,
}
yesterdayExecutions, err := h.store.QueryExecutionRecords(ctx, yesterdayFilters)
if err != nil {
return ExecutionsSummary{}, 0, err
}
// Calculate success rate from today's executions
successRate := h.calculateSuccessRate(todayExecutions)
return ExecutionsSummary{
Today: len(todayExecutions),
Yesterday: len(yesterdayExecutions),
}, successRate, nil
}
// calculateSuccessRate calculates the success rate from executions
func (h *DashboardHandler) calculateSuccessRate(executions []*types.Execution) float64 {
if len(executions) == 0 {
return 0.0
}
successCount := 0
for _, exec := range executions {
if types.NormalizeExecutionStatus(exec.Status) == types.ExecutionStatusSucceeded {
successCount++
}
}
return float64(successCount) / float64(len(executions)) * 100.0
}
// getPackagesSummary collects package statistics
func (h *DashboardHandler) getPackagesSummary(ctx context.Context) (PackagesSummary, error) {
// Get all agent packages
packages, err := h.storage.QueryAgentPackages(ctx, types.PackageFilters{})
if err != nil {
return PackagesSummary{}, err
}
available := len(packages)
installed := 0
// Count installed packages (packages with configuration or no configuration required)
for _, pkg := range packages {
configRequired := len(pkg.ConfigurationSchema) > 0
if !configRequired {
// No configuration required means it's installed
installed++
} else {
// Check if configuration exists and is active
if config, err := h.storage.GetAgentConfiguration(ctx, pkg.ID, pkg.ID); err == nil {
if config.Status == types.ConfigurationStatusActive || config.Status == types.ConfigurationStatusDraft {
installed++
}
}
}
}
return PackagesSummary{
Available: available,
Installed: installed,
}, nil
}