mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-14 06:17:41 +00:00
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:
1 parent
bae367b188
commit
c7d7c80100
6 files changed
+523
-24
No files matched your search
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -78,6 +78,8 @@ const (
|
||||
MethodUsageGet = "usage.get"
|
||||
MethodUsageSummary = "usage.summary"
|
||||
|
||||
MethodQuotaUsage = "quota.usage"
|
||||
|
||||
MethodSend = "send"
|
||||
)
|
||||
|
||||
|
||||
@@ -116,6 +116,8 @@ export const Methods = {
|
||||
USAGE_GET: "usage.get",
|
||||
USAGE_SUMMARY: "usage.summary",
|
||||
|
||||
QUOTA_USAGE: "quota.usage",
|
||||
|
||||
SEND: "send",
|
||||
|
||||
// Agent links (delegation)
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user