Changeset 0.35.0 (#209)
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com> Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit is contained in:
239
server/handlers/workflow_monitor.go
Normal file
239
server/handlers/workflow_monitor.go
Normal file
@@ -0,0 +1,239 @@
|
||||
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"
|
||||
|
||||
"chat-switchboard/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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user