From c7d7c80100ad9a2c6b536c26d005b390d073d036 Mon Sep 17 00:00:00 2001 From: viettranx Date: Wed, 4 Mar 2026 11:41:46 +0700 Subject: [PATCH] feat(dashboard): redesign overview page with quota usage, recent traces, system info Add quota.usage RPC method backed by QuotaChecker.Usage() which queries per-user request counts (hour/day/week) with resolved limits and today's aggregate stats. Dashboard now shows summary cards, quota progress bars, recent requests table, system health, and cron jobs. Co-Authored-By: Claude Opus 4.6 --- cmd/gateway.go | 3 + internal/channels/quota.go | 107 ++++++ internal/gateway/methods/quota_methods.go | 36 ++ pkg/protocol/methods.go | 2 + ui/web/src/api/protocol.ts | 2 + ui/web/src/pages/overview/overview-page.tsx | 397 ++++++++++++++++++-- 6 files changed, 523 insertions(+), 24 deletions(-) create mode 100644 internal/gateway/methods/quota_methods.go diff --git a/cmd/gateway.go b/cmd/gateway.go index d845adb7..f073531a 100644 --- a/cmd/gateway.go +++ b/cmd/gateway.go @@ -927,6 +927,9 @@ func runGateway() { ) } + // Register quota usage RPC (nil-safe — returns {enabled: false} in standalone mode). + methods.NewQuotaMethods(quotaChecker).Register(server.Router()) + // Reload quota config on config changes via pub/sub. if quotaChecker != nil { msgBus.Subscribe("quota-config-reload", func(evt bus.Event) { diff --git a/internal/channels/quota.go b/internal/channels/quota.go index a5b8842e..1141c9b2 100644 --- a/internal/channels/quota.go +++ b/internal/channels/quota.go @@ -175,6 +175,113 @@ func (qc *QuotaChecker) queryDB(ctx context.Context, userID string) quotaCounts return counts } +// QuotaUsage represents usage vs limit for a single time window. +type QuotaUsage struct { + Used int `json:"used"` + Limit int `json:"limit"` // 0 = unlimited +} + +// QuotaUsageEntry represents a single user/group's quota consumption. +type QuotaUsageEntry struct { + UserID string `json:"userId"` + Hour QuotaUsage `json:"hour"` + Day QuotaUsage `json:"day"` + Week QuotaUsage `json:"week"` +} + +// QuotaUsageResult is the response for the quota.usage RPC method. +type QuotaUsageResult struct { + Enabled bool `json:"enabled"` + RequestsToday int `json:"requestsToday"` + InputTokensToday int64 `json:"inputTokensToday"` + OutputTokensToday int64 `json:"outputTokensToday"` + UniqueUsersToday int `json:"uniqueUsersToday"` + Entries []QuotaUsageEntry `json:"entries"` +} + +// Usage returns quota consumption for all users with recent activity. +// Used by the dashboard to display quota usage with progress bars. +func (qc *QuotaChecker) Usage(ctx context.Context) QuotaUsageResult { + qc.mu.RLock() + cfg := qc.config + qc.mu.RUnlock() + + result := QuotaUsageResult{ + Enabled: cfg.Enabled, + Entries: []QuotaUsageEntry{}, + } + if !cfg.Enabled { + // Still return today's summary even when quota is disabled + qc.queryTodaySummary(ctx, &result) + return result + } + + now := time.Now().UTC() + hourAgo := now.Add(-1 * time.Hour) + dayAgo := now.Add(-24 * time.Hour) + weekAgo := now.Add(-7 * 24 * time.Hour) + + // Query per-user counts for the past week + rows, err := qc.db.QueryContext(ctx, ` + SELECT + user_id, + COUNT(*) FILTER (WHERE created_at >= $1) AS hour_count, + COUNT(*) FILTER (WHERE created_at >= $2) AS day_count, + COUNT(*) AS week_count + FROM traces + WHERE parent_trace_id IS NULL AND created_at >= $3 + GROUP BY user_id + ORDER BY week_count DESC + LIMIT 50`, + hourAgo, dayAgo, weekAgo, + ) + if err != nil { + slog.Warn("quota.usage: failed to query user counts", "error", err) + qc.queryTodaySummary(ctx, &result) + return result + } + defer rows.Close() + + for rows.Next() { + var userID string + var hour, day, week int + if err := rows.Scan(&userID, &hour, &day, &week); err != nil { + continue + } + + window := qc.resolveWindow(userID, "", "") + result.Entries = append(result.Entries, QuotaUsageEntry{ + UserID: userID, + Hour: QuotaUsage{Used: hour, Limit: window.Hour}, + Day: QuotaUsage{Used: day, Limit: window.Day}, + Week: QuotaUsage{Used: week, Limit: window.Week}, + }) + } + + qc.queryTodaySummary(ctx, &result) + return result +} + +// queryTodaySummary fills today's aggregate stats into the result. +func (qc *QuotaChecker) queryTodaySummary(ctx context.Context, result *QuotaUsageResult) { + now := time.Now().UTC() + startOfDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC) + + err := qc.db.QueryRowContext(ctx, ` + SELECT + COUNT(*), + COALESCE(SUM(total_input_tokens), 0), + COALESCE(SUM(total_output_tokens), 0), + COUNT(DISTINCT user_id) + FROM traces + WHERE parent_trace_id IS NULL AND created_at >= $1`, + startOfDay, + ).Scan(&result.RequestsToday, &result.InputTokensToday, &result.OutputTokensToday, &result.UniqueUsersToday) + if err != nil { + slog.Warn("quota.usage: failed to query today summary", "error", err) + } +} + // cleanupLoop periodically evicts stale cache entries. func (qc *QuotaChecker) cleanupLoop() { ticker := time.NewTicker(2 * time.Minute) diff --git a/internal/gateway/methods/quota_methods.go b/internal/gateway/methods/quota_methods.go new file mode 100644 index 00000000..e9abff05 --- /dev/null +++ b/internal/gateway/methods/quota_methods.go @@ -0,0 +1,36 @@ +package methods + +import ( + "context" + + "github.com/nextlevelbuilder/goclaw/internal/channels" + "github.com/nextlevelbuilder/goclaw/internal/gateway" + "github.com/nextlevelbuilder/goclaw/pkg/protocol" +) + +// QuotaMethods handles quota.usage — returns per-user quota consumption for the dashboard. +// Nil-safe: returns {enabled: false} when quotaChecker is nil (standalone mode). +type QuotaMethods struct { + checker *channels.QuotaChecker +} + +func NewQuotaMethods(checker *channels.QuotaChecker) *QuotaMethods { + return &QuotaMethods{checker: checker} +} + +func (m *QuotaMethods) Register(router *gateway.MethodRouter) { + router.Register(protocol.MethodQuotaUsage, m.handleUsage) +} + +func (m *QuotaMethods) handleUsage(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) { + if m.checker == nil { + client.SendResponse(protocol.NewOKResponse(req.ID, channels.QuotaUsageResult{ + Enabled: false, + Entries: []channels.QuotaUsageEntry{}, + })) + return + } + + result := m.checker.Usage(ctx) + client.SendResponse(protocol.NewOKResponse(req.ID, result)) +} diff --git a/pkg/protocol/methods.go b/pkg/protocol/methods.go index 63ad86f5..6f17b85a 100644 --- a/pkg/protocol/methods.go +++ b/pkg/protocol/methods.go @@ -78,6 +78,8 @@ const ( MethodUsageGet = "usage.get" MethodUsageSummary = "usage.summary" + MethodQuotaUsage = "quota.usage" + MethodSend = "send" ) diff --git a/ui/web/src/api/protocol.ts b/ui/web/src/api/protocol.ts index 62874210..3a438159 100644 --- a/ui/web/src/api/protocol.ts +++ b/ui/web/src/api/protocol.ts @@ -116,6 +116,8 @@ export const Methods = { USAGE_GET: "usage.get", USAGE_SUMMARY: "usage.summary", + QUOTA_USAGE: "quota.usage", + SEND: "send", // Agent links (delegation) diff --git a/ui/web/src/pages/overview/overview-page.tsx b/ui/web/src/pages/overview/overview-page.tsx index f264d561..02db774f 100644 --- a/ui/web/src/pages/overview/overview-page.tsx +++ b/ui/web/src/pages/overview/overview-page.tsx @@ -1,15 +1,33 @@ -import { useEffect, useState, useCallback } from "react"; -import { Activity, Bot, History, Zap, AlertTriangle } from "lucide-react"; +import { useEffect, useCallback } from "react"; +import { + Activity, + Bot, + Hash, + Radio, + AlertTriangle, + ArrowRight, + Clock, + Timer, +} from "lucide-react"; import { Link } from "react-router"; import { PageHeader } from "@/components/shared/page-header"; import { StatusBadge } from "@/components/shared/status-badge"; import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { useAuthStore } from "@/stores/use-auth-store"; import { useWsCall } from "@/hooks/use-ws-call"; import { useWsEvent } from "@/hooks/use-ws-event"; import { useProviders } from "@/pages/providers/hooks/use-providers"; +import { useTraces } from "@/pages/traces/hooks/use-traces"; import { Methods, Events } from "@/api/protocol"; import { ROUTES } from "@/lib/constants"; +import { + formatRelativeTime, + formatTokens, + formatDuration, +} from "@/lib/format"; + +// --- Types --- interface HealthPayload { status?: string; @@ -28,17 +46,65 @@ interface StatusPayload { clients?: number; } +interface ChannelStatusEntry { + enabled: boolean; + running: boolean; +} + +interface ChannelStatusPayload { + channels: Record; +} + +interface QuotaUsage { + used: number; + limit: number; +} + +interface QuotaUsageEntry { + userId: string; + hour: QuotaUsage; + day: QuotaUsage; + week: QuotaUsage; +} + +interface QuotaUsageResult { + enabled: boolean; + requestsToday: number; + inputTokensToday: number; + outputTokensToday: number; + uniqueUsersToday: number; + entries: QuotaUsageEntry[]; +} + +interface CronJob { + id: string; + name: string; + enabled: boolean; + state: { + nextRunAtMs?: number; + lastStatus?: string; + }; +} + +interface CronListPayload { + jobs: CronJob[]; +} + +// --- Helpers --- + function StatCard({ icon: Icon, label, value, + sub, }: { icon: React.ElementType; label: string; value: string | number; + sub?: string; }) { return ( -
+
@@ -46,42 +112,106 @@ function StatCard({

{label}

{value}

+ {sub &&

{sub}

}
); } +function QuotaBar({ used, limit }: { used: number; limit: number }) { + if (limit === 0) { + return no limit; + } + const pct = Math.min((used / limit) * 100, 100); + const color = + pct > 85 + ? "bg-red-500" + : pct > 60 + ? "bg-amber-500" + : "bg-emerald-500"; + return ( +
+
+
+ ); +} + +function QuotaCell({ usage }: { usage: QuotaUsage }) { + const label = + usage.limit === 0 + ? String(usage.used) + : `${usage.used}/${usage.limit}`; + return ( +
+ {label} + +
+ ); +} + +function formatUptime(ms: number | undefined): string { + if (!ms) return "—"; + const sec = Math.floor(ms / 1000); + const min = Math.floor(sec / 60) % 60; + const hr = Math.floor(sec / 3600) % 24; + const day = Math.floor(sec / 86400); + if (day > 0) return `${day}d ${hr}h ${min}m`; + if (hr > 0) return `${hr}h ${min}m`; + return `${min}m`; +} + +// --- Page --- + export function OverviewPage() { const connected = useAuthStore((s) => s.connected); const { call: fetchHealth, data: health } = useWsCall(Methods.HEALTH); const { call: fetchStatus, data: status } = useWsCall(Methods.STATUS); + const { call: fetchQuota, data: quota } = useWsCall(Methods.QUOTA_USAGE); + const { call: fetchCron, data: cronData } = useWsCall(Methods.CRON_LIST); + const { call: fetchChannels, data: channelStatusData } = useWsCall(Methods.CHANNELS_STATUS); const { providers, loading: providersLoading } = useProviders(); - const [, setLastUpdate] = useState(0); + const { traces } = useTraces({ limit: 8 }); const hasNoProviders = !providersLoading && providers.length === 0; - const hasNoEnabledProviders = !providersLoading && providers.length > 0 && !providers.some((p) => p.enabled); + const hasNoEnabledProviders = + !providersLoading && providers.length > 0 && !providers.some((p) => p.enabled); useEffect(() => { if (connected) { fetchHealth(); fetchStatus(); + fetchQuota(); + fetchCron({ includeDisabled: true }); + fetchChannels(); } - }, [connected, fetchHealth, fetchStatus]); + }, [connected, fetchHealth, fetchStatus, fetchQuota, fetchCron, fetchChannels]); - // Re-fetch on health events const handleHealth = useCallback(() => { - setLastUpdate(Date.now()); fetchHealth(); - }, [fetchHealth]); + fetchStatus(); + }, [fetchHealth, fetchStatus]); useWsEvent(Events.HEALTH, handleHealth); + // Compute stats + const agents = status?.agents ?? []; + const runningAgents = agents.filter((a) => a.isRunning).length; + const channelEntries = channelStatusData?.channels + ? Object.values(channelStatusData.channels) + : []; + const channelsOnline = channelEntries.filter((c) => c.running).length; + const cronJobs = cronData?.jobs ?? []; + const enabledProviders = providers.filter((p) => p.enabled).length; + return (
+ {/* Provider warning */} {(hasNoProviders || hasNoEnabledProviders) && ( - {hasNoProviders ? "No LLM providers configured" : "No LLM providers enabled"} + {hasNoProviders + ? "No LLM providers configured" + : "No LLM providers enabled"} {hasNoProviders ? "You need to add at least one LLM provider before agents can work. " : "All providers are currently disabled. Enable at least one to start using agents. "} - + Go to Provider Settings )} + {/* Summary cards */}
+ 0 ? `${runningAgents} / ${agents.length}` : "0"} + sub={agents.length > 0 ? "running" : undefined} /> - 0 + ? `${channelsOnline} / ${channelEntries.length}` + : "0" + } + sub={channelEntries.length > 0 ? "online" : undefined} />
+ + {/* Quota Usage */} + {quota?.enabled && quota.entries.length > 0 && ( + + + Quota Usage + + + +
+ + + + + + + + + + + {quota.entries.map((entry) => ( + + + + + + + ))} + +
User / GroupHourDayWeek
+ {entry.userId} + + + + + + +
+
+
+
+ )} + + {/* Recent Requests */} + {traces.length > 0 && ( + + + Recent Requests + + View All + + + +
+ + + + + + + + + + + + + + {traces.map((t) => ( + + + + + + + + + + ))} + +
TimeNameUserChannelTokensDurationStatus
+ {formatRelativeTime(t.created_at)} + + {t.name || "—"} + + {t.user_id || "—"} + {t.channel || "—"} + {formatTokens(t.total_input_tokens + t.total_output_tokens)} + + {formatDuration(t.duration_ms)} + + +
+
+
+
+ )} + + {/* Bottom row: System + Cron */} +
+ {/* System */} + + + System + + +
+
+
+ Uptime +
+
{formatUptime(health?.uptime)}
+
+
+
Sessions
+
{status?.sessions ?? 0}
+
+
+
Connected Clients
+
{status?.clients ?? 0}
+
+
+
Providers
+
+ {enabledProviders > 0 + ? `${enabledProviders} active` + : "none"} +
+
+
+
+
+ + {/* Cron Jobs */} + + + Cron Jobs + {cronJobs.length > 0 && ( + + Manage + + )} + + + {cronJobs.length === 0 ? ( +

No cron jobs configured

+ ) : ( +
+ {cronJobs.slice(0, 5).map((job) => ( +
+
+ + + {job.name} + +
+ + {job.enabled && job.state.nextRunAtMs ? ( + <> + + {formatRelativeTime(new Date(job.state.nextRunAtMs)).replace(" ago", "")} + + ) : !job.enabled ? ( + "disabled" + ) : ( + "—" + )} + +
+ ))} +
+ )} +
+
+
); }