package handlers // workflow_monitor.go — v0.35.0 Monitoring Dashboard // // Provides admin and team-scoped views of active workflow instances, // stage funnels, and stale instance detection for SLA tracking. import ( "context" "net/http" "strconv" "time" "github.com/gin-gonic/gin" "switchboard-core/store" ) // ── Workflow Monitor Handler ───────────────── type WorkflowMonitorHandler struct { stores store.Stores } func NewWorkflowMonitorHandler(stores store.Stores) *WorkflowMonitorHandler { return &WorkflowMonitorHandler{stores: stores} } // ── Instance types ────────────────────────── // MonitorInstance is a single active workflow instance for the monitoring dashboard. type MonitorInstance struct { ChannelID string `json:"channel_id"` ChannelTitle string `json:"channel_title"` WorkflowID string `json:"workflow_id"` WorkflowName string `json:"workflow_name"` CurrentStage int `json:"current_stage"` StageName string `json:"stage_name"` AssignedTo *string `json:"assigned_to,omitempty"` AgeSeconds int64 `json:"age_seconds"` StageAgeSeconds int64 `json:"stage_age_seconds"` SLASeconds *int `json:"sla_seconds,omitempty"` SLARemaining *int64 `json:"sla_remaining_seconds,omitempty"` SLABreached bool `json:"sla_breached"` LastActivityAt string `json:"last_activity_at"` } // FunnelEntry is a count of instances at a given stage. type FunnelEntry struct { StageOrdinal int `json:"stage_ordinal"` StageName string `json:"stage_name"` Count int `json:"count"` } // ── Endpoints ─────────────────────────────── // ListActiveInstances returns all active workflow instances. // GET /api/v1/admin/workflows/monitor/instances func (h *WorkflowMonitorHandler) ListActiveInstances(c *gin.Context) { ctx := c.Request.Context() instances, err := h.queryActiveInstances(ctx, "") if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to query instances: " + err.Error()}) return } c.JSON(http.StatusOK, gin.H{"data": instances}) } // ListTeamActiveInstances returns active workflow instances for a team. // GET /api/v1/teams/:teamId/workflows/monitor/instances func (h *WorkflowMonitorHandler) ListTeamActiveInstances(c *gin.Context) { teamID := c.Param("teamId") ctx := c.Request.Context() instances, err := h.queryActiveInstances(ctx, teamID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to query instances: " + err.Error()}) return } c.JSON(http.StatusOK, gin.H{"data": instances}) } // GetFunnel returns stage-by-stage instance counts for a workflow. // GET /api/v1/admin/workflows/monitor/funnel/:id func (h *WorkflowMonitorHandler) GetFunnel(c *gin.Context) { workflowID := c.Param("id") ctx := c.Request.Context() stages, err := h.stores.Workflows.ListStages(ctx, workflowID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load stages"}) return } instances, err := h.queryActiveInstances(ctx, "") if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to query instances"}) return } // Count instances at each stage counts := make(map[int]int) for _, inst := range instances { if inst.WorkflowID == workflowID { counts[inst.CurrentStage]++ } } funnel := make([]FunnelEntry, len(stages)) for i, s := range stages { funnel[i] = FunnelEntry{ StageOrdinal: s.Ordinal, StageName: s.Name, Count: counts[s.Ordinal], } } c.JSON(http.StatusOK, gin.H{"data": funnel}) } // ListStaleInstances returns workflow instances that haven't been touched recently. // GET /api/v1/admin/workflows/monitor/stale func (h *WorkflowMonitorHandler) ListStaleInstances(c *gin.Context) { thresholdHours, _ := strconv.Atoi(c.DefaultQuery("threshold_hours", "48")) if thresholdHours <= 0 { thresholdHours = 48 } ctx := c.Request.Context() instances, err := h.queryActiveInstances(ctx, "") if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to query instances"}) return } now := time.Now().UTC() threshold := time.Duration(thresholdHours) * time.Hour var stale []MonitorInstance for _, inst := range instances { if inst.AgeSeconds > 0 && time.Duration(inst.AgeSeconds)*time.Second > threshold { stale = append(stale, inst) } else { // Also check last_activity_at if t, err := time.Parse(time.RFC3339, inst.LastActivityAt); err == nil { if now.Sub(t) > threshold { stale = append(stale, inst) } } } } if stale == nil { stale = []MonitorInstance{} } c.JSON(http.StatusOK, gin.H{"data": stale, "threshold_hours": thresholdHours}) } // ── Helpers ───────────────────────────────── func (h *WorkflowMonitorHandler) queryActiveInstances(ctx context.Context, teamIDFilter string) ([]MonitorInstance, error) { // Query all active workflow channels via store // This is a new query we build from existing store methods channels, err := h.stores.Channels.ListByType(ctx, "workflow") if err != nil { return nil, err } now := time.Now().UTC() var result []MonitorInstance for _, ch := range channels { ws, err := h.stores.Channels.GetWorkflowStatus(ctx, ch.ID) if err != nil || ws == nil || ws.WorkflowID == nil || ws.Status != "active" { continue } // Team filter if teamIDFilter != "" { if ch.TeamID == nil || *ch.TeamID != teamIDFilter { continue } } wf, err := h.stores.Workflows.GetByID(ctx, *ws.WorkflowID) if err != nil || wf == nil { continue } stages, _ := h.stores.Workflows.ListStages(ctx, *ws.WorkflowID) var stageName string var slaSeconds *int if ws.CurrentStage < len(stages) { stageName = stages[ws.CurrentStage].Name slaSeconds = stages[ws.CurrentStage].SLASeconds } inst := MonitorInstance{ ChannelID: ch.ID, ChannelTitle: ch.Title, WorkflowID: *ws.WorkflowID, WorkflowName: wf.Name, CurrentStage: ws.CurrentStage, StageName: stageName, SLASeconds: slaSeconds, LastActivityAt: "", } // Compute ages inst.AgeSeconds = int64(now.Sub(ch.CreatedAt).Seconds()) if ws.LastActivityAt != nil { inst.LastActivityAt = *ws.LastActivityAt } // Stage age from stage_entered_at if ws.StageEnteredAt != nil { if t, err := time.Parse(time.RFC3339, *ws.StageEnteredAt); err == nil { inst.StageAgeSeconds = int64(now.Sub(t).Seconds()) // SLA computation if slaSeconds != nil { remaining := int64(*slaSeconds) - inst.StageAgeSeconds inst.SLARemaining = &remaining inst.SLABreached = remaining < 0 } } } result = append(result, inst) } if result == nil { result = []MonitorInstance{} } return result, nil }