Co-authored-by: Jeffrey Smith <jasafpro@gmail.com> Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
240 lines
6.8 KiB
Go
240 lines
6.8 KiB
Go
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
|
|
}
|
|
|