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 <noreply@anthropic.com>
This commit is contained in:
viettranxandClaude Opus 4.6 committed 2026-03-04 11:41:46 +07:00
1 parent bae367b188
commit c7d7c80100
6 files changed
+523 -24

No files matched your search

+3
View File
@@ -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) {
+107
View File
@@ -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)
+36
View File
@@ -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))
}
+2
View File
@@ -78,6 +78,8 @@ const (
MethodUsageGet = "usage.get"
MethodUsageSummary = "usage.summary"
MethodQuotaUsage = "quota.usage"
MethodSend = "send"
)
+2
View File
@@ -116,6 +116,8 @@ export const Methods = {
USAGE_GET: "usage.get",
USAGE_SUMMARY: "usage.summary",
QUOTA_USAGE: "quota.usage",
SEND: "send",
// Agent links (delegation)
+373 -24
View File
@@ -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<string, ChannelStatusEntry>;
}
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 (
<div className="rounded-lg border bg-card p-6">
<div className="rounded-lg border bg-card p-5">
<div className="flex items-center gap-3">
<div className="rounded-md bg-muted p-2">
<Icon className="h-4 w-4 text-muted-foreground" />
@@ -46,42 +112,106 @@ function StatCard({
<div>
<p className="text-sm text-muted-foreground">{label}</p>
<p className="text-2xl font-semibold">{value}</p>
{sub && <p className="text-xs text-muted-foreground">{sub}</p>}
</div>
</div>
</div>
);
}
function QuotaBar({ used, limit }: { used: number; limit: number }) {
if (limit === 0) {
return <span className="text-xs text-muted-foreground">no limit</span>;
}
const pct = Math.min((used / limit) * 100, 100);
const color =
pct > 85
? "bg-red-500"
: pct > 60
? "bg-amber-500"
: "bg-emerald-500";
return (
<div className="h-1.5 w-full rounded-full bg-muted">
<div
className={`h-full rounded-full transition-all ${color}`}
style={{ width: `${pct}%` }}
/>
</div>
);
}
function QuotaCell({ usage }: { usage: QuotaUsage }) {
const label =
usage.limit === 0
? String(usage.used)
: `${usage.used}/${usage.limit}`;
return (
<div className="space-y-1">
<span className="text-sm tabular-nums">{label}</span>
<QuotaBar used={usage.used} limit={usage.limit} />
</div>
);
}
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<HealthPayload>(Methods.HEALTH);
const { call: fetchStatus, data: status } = useWsCall<StatusPayload>(Methods.STATUS);
const { call: fetchQuota, data: quota } = useWsCall<QuotaUsageResult>(Methods.QUOTA_USAGE);
const { call: fetchCron, data: cronData } = useWsCall<CronListPayload>(Methods.CRON_LIST);
const { call: fetchChannels, data: channelStatusData } = useWsCall<ChannelStatusPayload>(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 (
<div className="space-y-6 p-6">
<PageHeader
title="Overview"
description="Gateway status and health"
title="Dashboard"
description="Gateway overview and quota usage"
actions={
<StatusBadge
status={connected ? "success" : "error"}
@@ -90,45 +220,264 @@ export function OverviewPage() {
}
/>
{/* Provider warning */}
{(hasNoProviders || hasNoEnabledProviders) && (
<Alert>
<AlertTriangle className="h-4 w-4" />
<AlertTitle>
{hasNoProviders ? "No LLM providers configured" : "No LLM providers enabled"}
{hasNoProviders
? "No LLM providers configured"
: "No LLM providers enabled"}
</AlertTitle>
<AlertDescription>
{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. "}
<Link to={ROUTES.PROVIDERS} className="font-medium underline underline-offset-4 hover:text-foreground">
<Link
to={ROUTES.PROVIDERS}
className="font-medium underline underline-offset-4 hover:text-foreground"
>
Go to Provider Settings
</Link>
</AlertDescription>
</Alert>
)}
{/* Summary cards */}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<StatCard
icon={Activity}
label="Status"
value={health?.status ?? "unknown"}
label="Requests Today"
value={quota?.requestsToday ?? 0}
sub={quota?.uniqueUsersToday ? `${quota.uniqueUsersToday} users` : undefined}
/>
<StatCard
icon={Hash}
label="Tokens Today"
value={formatTokens(
(quota?.inputTokensToday ?? 0) + (quota?.outputTokensToday ?? 0),
)}
sub={
quota
? `${formatTokens(quota.inputTokensToday)} in / ${formatTokens(quota.outputTokensToday)} out`
: undefined
}
/>
<StatCard
icon={Bot}
label="Agents"
value={status?.agents?.length ?? 0}
value={agents.length > 0 ? `${runningAgents} / ${agents.length}` : "0"}
sub={agents.length > 0 ? "running" : undefined}
/>
<StatCard
icon={History}
label="Sessions"
value={status?.sessions ?? 0}
/>
<StatCard
icon={Zap}
label="Connected Clients"
value={status?.clients ?? 0}
icon={Radio}
label="Channels"
value={
channelEntries.length > 0
? `${channelsOnline} / ${channelEntries.length}`
: "0"
}
sub={channelEntries.length > 0 ? "online" : undefined}
/>
</div>
{/* Quota Usage */}
{quota?.enabled && quota.entries.length > 0 && (
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-3">
<CardTitle className="text-base">Quota Usage</CardTitle>
<StatusBadge status="success" label="Enabled" />
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="pb-2 pr-4 font-medium">User / Group</th>
<th className="pb-2 px-4 font-medium w-36">Hour</th>
<th className="pb-2 px-4 font-medium w-36">Day</th>
<th className="pb-2 pl-4 font-medium w-36">Week</th>
</tr>
</thead>
<tbody>
{quota.entries.map((entry) => (
<tr key={entry.userId} className="border-b last:border-0">
<td className="py-3 pr-4">
<span className="font-mono text-xs">{entry.userId}</span>
</td>
<td className="py-3 px-4">
<QuotaCell usage={entry.hour} />
</td>
<td className="py-3 px-4">
<QuotaCell usage={entry.day} />
</td>
<td className="py-3 pl-4">
<QuotaCell usage={entry.week} />
</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
)}
{/* Recent Requests */}
{traces.length > 0 && (
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-3">
<CardTitle className="text-base">Recent Requests</CardTitle>
<Link
to={ROUTES.TRACES}
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
View All <ArrowRight className="h-3 w-3" />
</Link>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="pb-2 pr-4 font-medium">Time</th>
<th className="pb-2 px-4 font-medium">Name</th>
<th className="pb-2 px-4 font-medium">User</th>
<th className="pb-2 px-4 font-medium">Channel</th>
<th className="pb-2 px-4 font-medium text-right">Tokens</th>
<th className="pb-2 px-4 font-medium text-right">Duration</th>
<th className="pb-2 pl-4 font-medium">Status</th>
</tr>
</thead>
<tbody>
{traces.map((t) => (
<tr key={t.id} className="border-b last:border-0">
<td className="py-2.5 pr-4 text-muted-foreground whitespace-nowrap">
{formatRelativeTime(t.created_at)}
</td>
<td className="py-2.5 px-4 max-w-[160px] truncate">
{t.name || "—"}
</td>
<td className="py-2.5 px-4 font-mono text-xs">
{t.user_id || "—"}
</td>
<td className="py-2.5 px-4">{t.channel || "—"}</td>
<td className="py-2.5 px-4 text-right tabular-nums">
{formatTokens(t.total_input_tokens + t.total_output_tokens)}
</td>
<td className="py-2.5 px-4 text-right tabular-nums">
{formatDuration(t.duration_ms)}
</td>
<td className="py-2.5 pl-4">
<StatusBadge
status={
t.status === "completed"
? "success"
: t.status === "error"
? "error"
: t.status === "running"
? "info"
: "default"
}
label={t.status}
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
)}
{/* Bottom row: System + Cron */}
<div className="grid gap-4 lg:grid-cols-2">
{/* System */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">System</CardTitle>
</CardHeader>
<CardContent>
<dl className="space-y-3 text-sm">
<div className="flex justify-between">
<dt className="flex items-center gap-2 text-muted-foreground">
<Clock className="h-3.5 w-3.5" /> Uptime
</dt>
<dd className="font-medium">{formatUptime(health?.uptime)}</dd>
</div>
<div className="flex justify-between">
<dt className="text-muted-foreground">Sessions</dt>
<dd className="font-medium">{status?.sessions ?? 0}</dd>
</div>
<div className="flex justify-between">
<dt className="text-muted-foreground">Connected Clients</dt>
<dd className="font-medium">{status?.clients ?? 0}</dd>
</div>
<div className="flex justify-between">
<dt className="text-muted-foreground">Providers</dt>
<dd className="font-medium">
{enabledProviders > 0
? `${enabledProviders} active`
: "none"}
</dd>
</div>
</dl>
</CardContent>
</Card>
{/* Cron Jobs */}
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-3">
<CardTitle className="text-base">Cron Jobs</CardTitle>
{cronJobs.length > 0 && (
<Link
to={ROUTES.CRON}
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
Manage <ArrowRight className="h-3 w-3" />
</Link>
)}
</CardHeader>
<CardContent>
{cronJobs.length === 0 ? (
<p className="text-sm text-muted-foreground">No cron jobs configured</p>
) : (
<div className="space-y-2.5">
{cronJobs.slice(0, 5).map((job) => (
<div
key={job.id}
className="flex items-center justify-between text-sm"
>
<div className="flex items-center gap-2">
<span
className={`h-1.5 w-1.5 rounded-full ${
job.enabled ? "bg-emerald-500" : "bg-muted-foreground/40"
}`}
/>
<span className={job.enabled ? "" : "text-muted-foreground"}>
{job.name}
</span>
</div>
<span className="flex items-center gap-1 text-xs text-muted-foreground">
{job.enabled && job.state.nextRunAtMs ? (
<>
<Timer className="h-3 w-3" />
{formatRelativeTime(new Date(job.state.nextRunAtMs)).replace(" ago", "")}
</>
) : !job.enabled ? (
"disabled"
) : (
"—"
)}
</span>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
</div>
);
}