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>
283 lines
7.7 KiB
Go
283 lines
7.7 KiB
Go
package api
|
|
|
|
import (
|
|
"database/sql"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/synapbus/synapbus/internal/agents"
|
|
"github.com/synapbus/synapbus/internal/channels"
|
|
)
|
|
|
|
// AnalyticsHandler serves analytics endpoints for the Web UI dashboard.
|
|
type AnalyticsHandler struct {
|
|
db *sql.DB
|
|
agentService *agents.AgentService
|
|
channelService *channels.Service
|
|
logger *slog.Logger
|
|
}
|
|
|
|
// NewAnalyticsHandler creates a new analytics handler.
|
|
func NewAnalyticsHandler(db *sql.DB, agentService *agents.AgentService, channelService *channels.Service) *AnalyticsHandler {
|
|
return &AnalyticsHandler{
|
|
db: db,
|
|
agentService: agentService,
|
|
channelService: channelService,
|
|
logger: slog.Default().With("component", "api.analytics"),
|
|
}
|
|
}
|
|
|
|
// parseSpan parses a span parameter and returns the cutoff time and strftime format string.
|
|
func parseSpan(span string) (time.Time, string, error) {
|
|
now := time.Now().UTC()
|
|
switch span {
|
|
case "1h":
|
|
return now.Add(-1 * time.Hour), "%Y-%m-%d %H:%M", nil
|
|
case "4h":
|
|
return now.Add(-4 * time.Hour), "%Y-%m-%d %H:%M", nil
|
|
case "24h":
|
|
return now.Add(-24 * time.Hour), "%Y-%m-%d %H:00", nil
|
|
case "7d":
|
|
return now.Add(-7 * 24 * time.Hour), "%Y-%m-%d", nil
|
|
case "30d":
|
|
return now.Add(-30 * 24 * time.Hour), "%Y-%m-%d", nil
|
|
default:
|
|
return time.Time{}, "", fmt.Errorf("invalid span: %s (valid: 1h, 4h, 24h, 7d, 30d)", span)
|
|
}
|
|
}
|
|
|
|
// Timeline handles GET /api/analytics/timeline?span=24h.
|
|
func (h *AnalyticsHandler) Timeline(w http.ResponseWriter, r *http.Request) {
|
|
span := r.URL.Query().Get("span")
|
|
if span == "" {
|
|
span = "24h"
|
|
}
|
|
|
|
cutoff, strftimeFmt, err := parseSpan(span)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, errorBody("invalid_span", err.Error()))
|
|
return
|
|
}
|
|
|
|
// For 1h and 4h spans, bucket by 5-minute intervals using SQL expression
|
|
var query string
|
|
if span == "1h" || span == "4h" {
|
|
// Round minutes down to nearest 5 using printf
|
|
query = `SELECT strftime('%Y-%m-%d %H:', created_at) || printf('%02d', (CAST(strftime('%M', created_at) AS INTEGER) / 5) * 5) AS bucket, COUNT(*) AS count FROM messages WHERE created_at >= ? GROUP BY bucket ORDER BY bucket`
|
|
} else {
|
|
query = `SELECT strftime(?, created_at) AS bucket, COUNT(*) AS count FROM messages WHERE created_at >= ? GROUP BY bucket ORDER BY bucket`
|
|
}
|
|
|
|
type bucket struct {
|
|
Time string `json:"time"`
|
|
Count int `json:"count"`
|
|
}
|
|
|
|
var buckets []bucket
|
|
var total int
|
|
|
|
cutoffStr := cutoff.Format("2006-01-02 15:04:05")
|
|
|
|
var rows *sql.Rows
|
|
if span == "1h" || span == "4h" {
|
|
rows, err = h.db.QueryContext(r.Context(), query, cutoffStr)
|
|
} else {
|
|
rows, err = h.db.QueryContext(r.Context(), query, strftimeFmt, cutoffStr)
|
|
}
|
|
if err != nil {
|
|
h.logger.Error("timeline query failed", "error", err)
|
|
writeJSON(w, http.StatusInternalServerError, errorBody("server_error", "Failed to query timeline"))
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
for rows.Next() {
|
|
var b bucket
|
|
if err := rows.Scan(&b.Time, &b.Count); err != nil {
|
|
h.logger.Error("timeline scan failed", "error", err)
|
|
continue
|
|
}
|
|
buckets = append(buckets, b)
|
|
total += b.Count
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
h.logger.Error("timeline rows error", "error", err)
|
|
}
|
|
|
|
if buckets == nil {
|
|
buckets = []bucket{}
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"span": span,
|
|
"buckets": buckets,
|
|
"total": total,
|
|
})
|
|
}
|
|
|
|
// TopAgents handles GET /api/analytics/top-agents?span=24h&limit=5.
|
|
func (h *AnalyticsHandler) TopAgents(w http.ResponseWriter, r *http.Request) {
|
|
span := r.URL.Query().Get("span")
|
|
if span == "" {
|
|
span = "24h"
|
|
}
|
|
|
|
cutoff, _, err := parseSpan(span)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, errorBody("invalid_span", err.Error()))
|
|
return
|
|
}
|
|
|
|
limit := 5
|
|
if l := r.URL.Query().Get("limit"); l != "" {
|
|
if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 {
|
|
limit = parsed
|
|
if limit > 100 {
|
|
limit = 100
|
|
}
|
|
}
|
|
}
|
|
|
|
cutoffStr := cutoff.Format("2006-01-02 15:04:05")
|
|
|
|
rows, err := h.db.QueryContext(r.Context(),
|
|
`SELECT from_agent, COUNT(*) as count FROM messages WHERE created_at >= ? AND from_agent != '' GROUP BY from_agent ORDER BY count DESC LIMIT ?`,
|
|
cutoffStr, limit,
|
|
)
|
|
if err != nil {
|
|
h.logger.Error("top-agents query failed", "error", err)
|
|
writeJSON(w, http.StatusInternalServerError, errorBody("server_error", "Failed to query top agents"))
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
type agentStat struct {
|
|
Name string `json:"name"`
|
|
DisplayName string `json:"display_name"`
|
|
Count int `json:"count"`
|
|
}
|
|
|
|
var agentStats []agentStat
|
|
for rows.Next() {
|
|
var s agentStat
|
|
if err := rows.Scan(&s.Name, &s.Count); err != nil {
|
|
h.logger.Error("top-agents scan failed", "error", err)
|
|
continue
|
|
}
|
|
|
|
// Look up display name from agent service
|
|
if h.agentService != nil {
|
|
if agent, err := h.agentService.GetAgent(r.Context(), s.Name); err == nil {
|
|
s.DisplayName = agent.DisplayName
|
|
}
|
|
}
|
|
if s.DisplayName == "" {
|
|
s.DisplayName = s.Name
|
|
}
|
|
|
|
agentStats = append(agentStats, s)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
h.logger.Error("top-agents rows error", "error", err)
|
|
}
|
|
|
|
if agentStats == nil {
|
|
agentStats = []agentStat{}
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"span": span,
|
|
"agents": agentStats,
|
|
})
|
|
}
|
|
|
|
// TopChannels handles GET /api/analytics/top-channels?span=24h&limit=5.
|
|
func (h *AnalyticsHandler) TopChannels(w http.ResponseWriter, r *http.Request) {
|
|
span := r.URL.Query().Get("span")
|
|
if span == "" {
|
|
span = "24h"
|
|
}
|
|
|
|
cutoff, _, err := parseSpan(span)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, errorBody("invalid_span", err.Error()))
|
|
return
|
|
}
|
|
|
|
limit := 5
|
|
if l := r.URL.Query().Get("limit"); l != "" {
|
|
if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 {
|
|
limit = parsed
|
|
if limit > 100 {
|
|
limit = 100
|
|
}
|
|
}
|
|
}
|
|
|
|
cutoffStr := cutoff.Format("2006-01-02 15:04:05")
|
|
|
|
rows, err := h.db.QueryContext(r.Context(),
|
|
`SELECT c.name, COUNT(*) as count FROM messages m JOIN channels c ON m.channel_id = c.id WHERE m.created_at >= ? AND m.channel_id IS NOT NULL GROUP BY c.name ORDER BY count DESC LIMIT ?`,
|
|
cutoffStr, limit,
|
|
)
|
|
if err != nil {
|
|
h.logger.Error("top-channels query failed", "error", err)
|
|
writeJSON(w, http.StatusInternalServerError, errorBody("server_error", "Failed to query top channels"))
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
type channelStat struct {
|
|
Name string `json:"name"`
|
|
Count int `json:"count"`
|
|
}
|
|
|
|
var channelStats []channelStat
|
|
for rows.Next() {
|
|
var s channelStat
|
|
if err := rows.Scan(&s.Name, &s.Count); err != nil {
|
|
h.logger.Error("top-channels scan failed", "error", err)
|
|
continue
|
|
}
|
|
channelStats = append(channelStats, s)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
h.logger.Error("top-channels rows error", "error", err)
|
|
}
|
|
|
|
if channelStats == nil {
|
|
channelStats = []channelStat{}
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"span": span,
|
|
"channels": channelStats,
|
|
})
|
|
}
|
|
|
|
// Summary handles GET /api/analytics/summary.
|
|
func (h *AnalyticsHandler) Summary(w http.ResponseWriter, r *http.Request) {
|
|
var totalAgents, totalChannels, totalMessages int
|
|
|
|
if err := h.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM agents WHERE status = 'active'`).Scan(&totalAgents); err != nil {
|
|
h.logger.Error("summary agents count failed", "error", err)
|
|
}
|
|
|
|
if err := h.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM channels`).Scan(&totalChannels); err != nil {
|
|
h.logger.Error("summary channels count failed", "error", err)
|
|
}
|
|
|
|
if err := h.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM messages`).Scan(&totalMessages); err != nil {
|
|
h.logger.Error("summary messages count failed", "error", err)
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"total_agents": totalAgents,
|
|
"total_channels": totalChannels,
|
|
"total_messages": totalMessages,
|
|
})
|
|
}
|