Analytics: time-series message graph with 5 time spans (1h/4h/24h/7d/30d), top-5 agents and channels leaderboards, summary cards. 4 new REST endpoints. PWA: web app manifest, service worker with cache-first static/network-only API strategy, push notifications via Web Push API with VAPID keys, push subscription management endpoints, SQLite migration for subscriptions. UX fixes: auto-resize compose textarea (3-12 lines), inline editable agent display name, editable human display name in settings, smart mention/channel highlighting (existing→link, deleted→inactive badge, unknown→plain text), font size -/+ preference (12-24px persisted in localStorage), version footer with GitHub link. MCP: 4 prompts — daily-digest, agent-health-check, channel-overview, debug-agent. Registered with prompt capabilities enabled. Code review fixes: scoped push unsubscribe to user, capped analytics limit at 100, hardened HTML strip regex, bounded SW cache, backend push unsub on disable. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
248 lines
8.4 KiB
Go
248 lines
8.4 KiB
Go
package api
|
|
|
|
import (
|
|
"database/sql"
|
|
"net/http"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
"github.com/synapbus/synapbus/internal/agents"
|
|
"github.com/synapbus/synapbus/internal/apikeys"
|
|
"github.com/synapbus/synapbus/internal/attachments"
|
|
"github.com/synapbus/synapbus/internal/channels"
|
|
"github.com/synapbus/synapbus/internal/k8s"
|
|
"github.com/synapbus/synapbus/internal/messaging"
|
|
"github.com/synapbus/synapbus/internal/push"
|
|
"github.com/synapbus/synapbus/internal/trace"
|
|
"github.com/synapbus/synapbus/internal/webhooks"
|
|
)
|
|
|
|
// RouterConfig holds optional services for the API router.
|
|
// Fields may be nil if the corresponding feature is not configured.
|
|
type RouterConfig struct {
|
|
TraceStore trace.TraceStore
|
|
Metrics *trace.Metrics
|
|
AttachmentService *attachments.Service
|
|
MsgService *messaging.MessagingService
|
|
AgentService *agents.AgentService
|
|
ChannelService *channels.Service
|
|
APIKeyService *apikeys.Service
|
|
DeadLetterStore *messaging.DeadLetterStore
|
|
WebhookService *webhooks.WebhookService
|
|
WebhookStore webhooks.WebhookStore
|
|
K8sService *k8s.K8sService
|
|
K8sStore k8s.K8sStore
|
|
PushService *push.Service
|
|
SSEHub *SSEHub
|
|
Broadcaster *SSEBroadcaster
|
|
SessionMiddleware func(http.Handler) http.Handler
|
|
DB *sql.DB
|
|
Version string
|
|
}
|
|
|
|
// NewRouter creates a chi router with all API routes configured.
|
|
// metricsInstance may be nil if metrics are disabled.
|
|
// attachmentService may be nil if attachments are not configured.
|
|
func NewRouter(traceStore trace.TraceStore, metricsInstance *trace.Metrics, attachmentService *attachments.Service) chi.Router {
|
|
return NewRouterWithConfig(RouterConfig{
|
|
TraceStore: traceStore,
|
|
Metrics: metricsInstance,
|
|
AttachmentService: attachmentService,
|
|
})
|
|
}
|
|
|
|
// NewRouterWithConfig creates a chi router using the full configuration.
|
|
func NewRouterWithConfig(cfg RouterConfig) chi.Router {
|
|
r := chi.NewRouter()
|
|
|
|
// Global middleware
|
|
r.Use(RequestIDMiddleware)
|
|
r.Use(LoggingMiddleware)
|
|
|
|
tracesHandler := NewTracesHandler(cfg.TraceStore)
|
|
|
|
// Determine which auth middleware to use for API routes
|
|
authMiddleware := OwnerAuthMiddleware
|
|
if cfg.SessionMiddleware != nil {
|
|
authMiddleware = cfg.SessionMiddleware
|
|
}
|
|
|
|
// Authenticated API routes (traces)
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(authMiddleware)
|
|
|
|
r.Get("/api/traces", tracesHandler.ListTraces)
|
|
r.Get("/api/traces/export", tracesHandler.ExportTraces)
|
|
r.Get("/api/traces/stats", tracesHandler.TraceStats)
|
|
})
|
|
|
|
// Attachment API routes (for Web UI)
|
|
if cfg.AttachmentService != nil {
|
|
attachmentsHandler := NewAttachmentsHandler(cfg.AttachmentService)
|
|
r.Get("/api/attachments/{hash}", attachmentsHandler.Download)
|
|
r.Get("/api/attachments/{hash}/meta", attachmentsHandler.Metadata)
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(authMiddleware)
|
|
r.Post("/api/attachments", attachmentsHandler.Upload)
|
|
})
|
|
}
|
|
|
|
// Web UI API routes (messages, agents, channels, SSE)
|
|
if cfg.MsgService != nil && cfg.AgentService != nil {
|
|
messagesHandler := NewMessagesHandler(cfg.MsgService, cfg.AgentService)
|
|
agentsHandler := NewAgentsHandler(cfg.AgentService, cfg.TraceStore, cfg.ChannelService)
|
|
notificationsHandler := NewNotificationsHandler(cfg.MsgService, cfg.AgentService, cfg.ChannelService)
|
|
|
|
// Wire up SSE broadcaster for real-time events
|
|
if cfg.Broadcaster != nil {
|
|
messagesHandler.SetBroadcaster(cfg.Broadcaster)
|
|
} else if cfg.SSEHub != nil {
|
|
messagesHandler.SetBroadcaster(NewSSEBroadcaster(cfg.SSEHub, cfg.AgentService, cfg.ChannelService))
|
|
}
|
|
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(authMiddleware)
|
|
|
|
// Messages
|
|
r.Get("/api/messages", messagesHandler.ListMessages)
|
|
r.Get("/api/messages/search", messagesHandler.SearchMessages)
|
|
r.Get("/api/messages/{id}", messagesHandler.GetMessage)
|
|
r.Get("/api/messages/{id}/replies", messagesHandler.GetReplies)
|
|
r.Post("/api/messages", messagesHandler.SendMessage)
|
|
r.Post("/api/messages/{id}/done", messagesHandler.MarkDone)
|
|
|
|
// Conversations
|
|
r.Get("/api/conversations", messagesHandler.ListConversations)
|
|
r.Get("/api/conversations/{id}", messagesHandler.GetConversation)
|
|
|
|
// Agents
|
|
r.Get("/api/agents", agentsHandler.ListAgents)
|
|
r.Get("/api/agents/{name}", agentsHandler.GetAgent)
|
|
r.Post("/api/agents", agentsHandler.RegisterAgent)
|
|
r.Put("/api/agents/{name}", agentsHandler.UpdateAgent)
|
|
r.Delete("/api/agents/{name}", agentsHandler.DeleteAgent)
|
|
r.Post("/api/agents/{name}/revoke-key", agentsHandler.RevokeKey)
|
|
r.Get("/api/agents/{name}/messages", messagesHandler.DMMessages)
|
|
|
|
// Notifications
|
|
r.Get("/api/notifications/unread", notificationsHandler.UnreadCounts)
|
|
r.Post("/api/notifications/mark-read", notificationsHandler.MarkRead)
|
|
})
|
|
|
|
// API Keys
|
|
if cfg.APIKeyService != nil {
|
|
apiKeysHandler := NewAPIKeysHandler(cfg.APIKeyService)
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(authMiddleware)
|
|
|
|
r.Get("/api/keys", apiKeysHandler.ListKeys)
|
|
r.Post("/api/keys", apiKeysHandler.CreateKey)
|
|
r.Get("/api/keys/{id}", apiKeysHandler.GetKey)
|
|
r.Delete("/api/keys/{id}", apiKeysHandler.RevokeKey)
|
|
})
|
|
}
|
|
|
|
// Channels
|
|
if cfg.ChannelService != nil {
|
|
channelsHandler := NewChannelsHandler(cfg.ChannelService, cfg.AgentService, cfg.MsgService)
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(authMiddleware)
|
|
|
|
r.Get("/api/channels", channelsHandler.ListChannels)
|
|
r.Get("/api/channels/{name}", channelsHandler.GetChannel)
|
|
r.Post("/api/channels", channelsHandler.CreateChannel)
|
|
r.Get("/api/channels/{name}/messages", channelsHandler.ChannelMessages)
|
|
r.Post("/api/channels/{name}/join", channelsHandler.JoinChannel)
|
|
r.Post("/api/channels/{name}/leave", channelsHandler.LeaveChannel)
|
|
})
|
|
}
|
|
|
|
// SSE events
|
|
if cfg.SSEHub != nil {
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(authMiddleware)
|
|
r.Get("/api/events", cfg.SSEHub.HandleEvents)
|
|
})
|
|
}
|
|
|
|
// Dead Letters
|
|
if cfg.DeadLetterStore != nil {
|
|
deadLettersHandler := NewDeadLettersHandler(cfg.DeadLetterStore)
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(authMiddleware)
|
|
|
|
r.Get("/api/dead-letters", deadLettersHandler.List)
|
|
r.Get("/api/dead-letters/count", deadLettersHandler.Count)
|
|
r.Post("/api/dead-letters/{id}/acknowledge", deadLettersHandler.Acknowledge)
|
|
})
|
|
}
|
|
|
|
// Webhooks
|
|
if cfg.WebhookService != nil && cfg.WebhookStore != nil {
|
|
webhooksHandler := NewWebhooksHandler(cfg.WebhookService, cfg.WebhookStore, cfg.AgentService)
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(authMiddleware)
|
|
|
|
r.Get("/api/webhooks", webhooksHandler.ListWebhooks)
|
|
r.Get("/api/webhooks/{id}/deliveries", webhooksHandler.WebhookDeliveries)
|
|
r.Get("/api/deliveries/dead-letters", webhooksHandler.DeadLetters)
|
|
r.Post("/api/deliveries/{id}/retry", webhooksHandler.RetryDelivery)
|
|
})
|
|
}
|
|
|
|
// K8s Handlers
|
|
if cfg.K8sService != nil && cfg.K8sStore != nil {
|
|
k8sHandler := NewK8sHandler(cfg.K8sService, cfg.K8sStore, cfg.AgentService)
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(authMiddleware)
|
|
|
|
r.Get("/api/k8s/handlers", k8sHandler.ListHandlers)
|
|
r.Get("/api/k8s/job-runs", k8sHandler.ListJobRuns)
|
|
r.Get("/api/k8s/job-runs/{id}/logs", k8sHandler.JobRunLogs)
|
|
})
|
|
}
|
|
|
|
// Push Notifications
|
|
if cfg.PushService != nil {
|
|
pushHandler := NewPushHandler(cfg.PushService)
|
|
// VAPID key endpoint is unauthenticated (needed before subscription)
|
|
r.Get("/api/push/vapid-key", pushHandler.VAPIDKey)
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(authMiddleware)
|
|
|
|
r.Post("/api/push/subscribe", pushHandler.Subscribe)
|
|
r.Delete("/api/push/subscribe", pushHandler.Unsubscribe)
|
|
})
|
|
}
|
|
}
|
|
|
|
// Analytics (authenticated, requires DB)
|
|
if cfg.DB != nil {
|
|
analyticsHandler := NewAnalyticsHandler(cfg.DB, cfg.AgentService, cfg.ChannelService)
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(authMiddleware)
|
|
|
|
r.Get("/api/analytics/timeline", analyticsHandler.Timeline)
|
|
r.Get("/api/analytics/top-agents", analyticsHandler.TopAgents)
|
|
r.Get("/api/analytics/top-channels", analyticsHandler.TopChannels)
|
|
r.Get("/api/analytics/summary", analyticsHandler.Summary)
|
|
})
|
|
}
|
|
|
|
// Version (unauthenticated)
|
|
if cfg.Version != "" {
|
|
versionHandler := NewVersionHandler(cfg.Version)
|
|
r.Get("/api/version", versionHandler.GetVersion)
|
|
}
|
|
|
|
// Metrics endpoint (unauthenticated, only registered when enabled)
|
|
if cfg.Metrics != nil {
|
|
r.Get("/metrics", func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
|
|
cfg.Metrics.WritePrometheus(w)
|
|
})
|
|
}
|
|
|
|
return r
|
|
}
|