diff --git a/ui/web/src/pages/overview/connected-clients-card.tsx b/ui/web/src/pages/overview/connected-clients-card.tsx new file mode 100644 index 00000000..fe9297ca --- /dev/null +++ b/ui/web/src/pages/overview/connected-clients-card.tsx @@ -0,0 +1,91 @@ +import { Users } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import type { ClientInfo } from "./types"; +import { formatClientTime } from "./hooks/use-live-uptime"; + +export function ConnectedClientsCard({ + clients, + currentId, +}: { + clients: ClientInfo[]; + currentId?: string; +}) { + return ( + + + + Connected Clients + {clients.length > 0 && ( + + {clients.length} + + )} + + + + {clients.length === 0 ? ( +

+ No clients connected +

+ ) : ( +
+ + + + + + + + + + + {clients.map((c) => { + const isYou = c.id === currentId; + return ( + + + + + + + ); + })} + +
IPUserRoleConnected
+ {c.remoteAddr} + {isYou && ( + + you + + )} + + {c.userId || "--"} + + + {c.role} + + + {formatClientTime(c.connectedAt)} +
+
+ )} +
+
+ ); +} diff --git a/ui/web/src/pages/overview/cron-jobs-card.tsx b/ui/web/src/pages/overview/cron-jobs-card.tsx new file mode 100644 index 00000000..72c6ed9c --- /dev/null +++ b/ui/web/src/pages/overview/cron-jobs-card.tsx @@ -0,0 +1,71 @@ +import { ArrowRight, Timer } from "lucide-react"; +import { Link } from "react-router"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { ROUTES } from "@/lib/constants"; +import { formatRelativeTime } from "@/lib/format"; +import type { CronJob } from "./types"; + +export function CronJobsCard({ jobs }: { jobs: CronJob[] }) { + return ( + + + Cron Jobs + {jobs.length > 0 && ( + + Manage + + )} + + + {jobs.length === 0 ? ( +

+ No cron jobs configured +

+ ) : ( +
+ {jobs.slice(0, 5).map((job) => ( +
+
+ + + {job.name} + +
+ + {job.enabled && job.state.nextRunAtMs ? ( + <> + + {formatRelativeTime( + new Date(job.state.nextRunAtMs), + ).replace(" ago", "")} + + ) : !job.enabled ? ( + "disabled" + ) : ( + "--" + )} + +
+ ))} +
+ )} +
+
+ ); +} diff --git a/ui/web/src/pages/overview/hooks/use-live-uptime.ts b/ui/web/src/pages/overview/hooks/use-live-uptime.ts new file mode 100644 index 00000000..d04f7401 --- /dev/null +++ b/ui/web/src/pages/overview/hooks/use-live-uptime.ts @@ -0,0 +1,51 @@ +import { useState, useEffect, useRef } from "react"; + +export function useLiveUptime(serverUptimeMs: number | undefined) { + const [tick, setTick] = useState(0); + const baseRef = useRef<{ serverMs: number; localTs: number } | null>(null); + + useEffect(() => { + if (serverUptimeMs != null) { + baseRef.current = { serverMs: serverUptimeMs, localTs: Date.now() }; + } + }, [serverUptimeMs]); + + useEffect(() => { + const id = setInterval(() => setTick((t) => t + 1), 1000); + return () => clearInterval(id); + }, []); + + if (!baseRef.current) return undefined; + void tick; // used for re-render + return baseRef.current.serverMs + (Date.now() - baseRef.current.localTs); +} + +export function formatUptime(ms: number | undefined): string { + if (!ms) return "--"; + const sec = Math.floor(ms / 1000); + const s = sec % 60; + 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 ${s}s`; + if (hr > 0) return `${hr}h ${min}m ${s}s`; + if (min > 0) return `${min}m ${s}s`; + return `${s}s`; +} + +export function formatClientTime(iso: string): string { + try { + const d = new Date(iso); + const now = Date.now(); + const diffMs = now - d.getTime(); + if (diffMs < 0) return "just now"; + const sec = Math.floor(diffMs / 1000); + if (sec < 60) return `${sec}s ago`; + const min = Math.floor(sec / 60); + if (min < 60) return `${min}m ago`; + const hr = Math.floor(min / 60); + return `${hr}h ${min % 60}m ago`; + } catch { + return "--"; + } +} diff --git a/ui/web/src/pages/overview/overview-page.tsx b/ui/web/src/pages/overview/overview-page.tsx index 862cd153..9236ae83 100644 --- a/ui/web/src/pages/overview/overview-page.tsx +++ b/ui/web/src/pages/overview/overview-page.tsx @@ -1,27 +1,10 @@ -import { useEffect, useCallback, useState, useRef } from "react"; -import { - Activity, - Bot, - Hash, - Radio, - AlertTriangle, - ArrowRight, - Clock, - Timer, - Monitor, - Database, - Wrench, - CheckCircle2, - XCircle, - Minus, - Users, -} from "lucide-react"; +import { useEffect, useCallback } from "react"; +import { Activity, Bot, Hash, Radio, AlertTriangle } from "lucide-react"; import { Link } from "react-router"; import { PageHeader } from "@/components/shared/page-header"; import { StatusBadge } from "@/components/shared/status-badge"; import { Badge } from "@/components/ui/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"; @@ -29,214 +12,22 @@ 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"; +import { formatTokens } from "@/lib/format"; -// --- Types --- - -interface ClientInfo { - id: string; - remoteAddr: string; - userId: string; - role: string; - connectedAt: string; -} - -interface HealthPayload { - status?: string; - version?: string; - uptime?: number; - mode?: string; - database?: string; - tools?: number; - clients?: ClientInfo[]; - currentId?: string; -} - -interface AgentInfo { - id: string; - model: string; - isRunning: boolean; -} - -interface StatusPayload { - agents?: AgentInfo[]; - agentTotal?: number; - sessions?: number; - 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 ( -
-
-
- -
-
-

{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 StatusDot({ ok }: { ok: boolean | undefined }) { - if (ok === undefined) - return ; - return ok ? ( - - ) : ( - - ); -} - -function useLiveUptime(serverUptimeMs: number | undefined) { - const [tick, setTick] = useState(0); - const baseRef = useRef<{ serverMs: number; localTs: number } | null>(null); - - useEffect(() => { - if (serverUptimeMs != null) { - baseRef.current = { serverMs: serverUptimeMs, localTs: Date.now() }; - } - }, [serverUptimeMs]); - - useEffect(() => { - const id = setInterval(() => setTick((t) => t + 1), 1000); - return () => clearInterval(id); - }, []); - - if (!baseRef.current) return undefined; - void tick; // used for re-render - return baseRef.current.serverMs + (Date.now() - baseRef.current.localTs); -} - -function formatUptime(ms: number | undefined): string { - if (!ms) return "--"; - const sec = Math.floor(ms / 1000); - const s = sec % 60; - 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 ${s}s`; - if (hr > 0) return `${hr}h ${min}m ${s}s`; - if (min > 0) return `${min}m ${s}s`; - return `${s}s`; -} - -function formatClientTime(iso: string): string { - try { - const d = new Date(iso); - const now = Date.now(); - const diffMs = now - d.getTime(); - if (diffMs < 0) return "just now"; - const sec = Math.floor(diffMs / 1000); - if (sec < 60) return `${sec}s ago`; - const min = Math.floor(sec / 60); - if (min < 60) return `${min}m ago`; - const hr = Math.floor(min / 60); - return `${hr}h ${min % 60}m ago`; - } catch { - return "--"; - } -} - -// --- Page --- +import type { + HealthPayload, + StatusPayload, + QuotaUsageResult, + CronListPayload, + ChannelStatusPayload, +} from "./types"; +import { useLiveUptime } from "./hooks/use-live-uptime"; +import { StatCard } from "./stat-card"; +import { SystemHealthCard } from "./system-health-card"; +import { ConnectedClientsCard } from "./connected-clients-card"; +import { CronJobsCard } from "./cron-jobs-card"; +import { RecentRequestsCard } from "./recent-requests-card"; +import { QuotaUsageCard } from "./quota-usage-card"; const REFRESH_INTERVAL = 30_000; @@ -269,7 +60,6 @@ export function OverviewPage() { fetchChannels(); }, [fetchHealth, fetchStatus, fetchQuota, fetchCron, fetchChannels]); - // Initial fetch + auto-refresh every 30s useEffect(() => { if (!connected) return; fetchAll(); @@ -277,14 +67,12 @@ export function OverviewPage() { return () => clearInterval(id); }, [connected, fetchAll]); - // Health event listener const handleHealthEvent = useCallback(() => { fetchHealth(); fetchStatus(); }, [fetchHealth, fetchStatus]); useWsEvent(Events.HEALTH, handleHealthEvent); - // Live uptime tick const liveUptime = useLiveUptime(health?.uptime); // Computed @@ -295,10 +83,8 @@ export function OverviewPage() { ? Object.entries(channelStatusData.channels) : []; const channelsOnline = channelEntries.filter(([, c]) => c.running).length; - const cronJobs = cronData?.jobs ?? []; const enabledProviders = providers.filter((p) => p.enabled); const clientList = health?.clients ?? []; - const currentId = health?.currentId; return (
@@ -395,345 +181,31 @@ export function OverviewPage() { />
- {/* Quota Usage */} - {quota?.enabled && quota.entries.length > 0 && ( - - - Quota Usage - - - -
- - - - - - - - - - - {quota.entries.map((entry) => ( - - - - - - - ))} - -
User / GroupHourDayWeek
- - {entry.userId} - - - - - - - -
-
-
-
- )} + {/* System Health */} + - {/* Middle row: System + Cron + Connected Clients */} -
- {/* System Health */} - - - - System - - - -
-
-
- Uptime -
-
- {formatUptime(liveUptime)} -
-
- {health?.mode === "managed" && ( -
-
- Database -
-
- - {health.database === "ok" ? "Connected" : health.database} -
-
- )} -
-
Providers
-
- {enabledProviders.length > 0 ? ( - - - {enabledProviders.length} active - - ) : ( - - - none - - )} -
-
- {channelEntries.length > 0 && ( -
-
Channels
-
- {channelEntries.map(([name, ch]) => ( - - - {name} - - ))} -
-
- )} - {(health?.tools ?? 0) > 0 && ( -
-
- Tools -
-
{health!.tools} registered
-
- )} -
-
Sessions
-
{status?.sessions ?? 0}
-
-
-
-
- - {/* 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" - ) : ( - "--" - )} - -
- ))} -
- )} -
-
- - {/* Connected Clients */} - - - - Connected Clients - {clientList.length > 0 && ( - - {clientList.length} - - )} - - - - {clientList.length === 0 ? ( -

- No clients connected -

- ) : ( -
- - - - - - - - - - - {clientList.map((c) => { - const isYou = c.id === currentId; - return ( - - - - - - - ); - })} - -
IPUserRoleConnected
- {c.remoteAddr} - {isYou && ( - - you - - )} - - {c.userId || "--"} - - - {c.role} - - - {formatClientTime(c.connectedAt)} -
-
- )} -
-
+ {/* Connected Clients + Cron Jobs */} +
+ +
{/* Recent Requests */} - {traces.length > 0 && ( - - - Recent Requests - - View All - - - -
- - - - - - - - - - - - - - {traces.map((t) => ( - - - - - - - - - - ))} - -
TimeNameUserChannel - Tokens - - Duration - Status
- {formatRelativeTime(t.created_at)} - - {t.name || "--"} - - {t.user_id || "--"} - {t.channel || "--"} - {formatTokens( - t.total_input_tokens + t.total_output_tokens, - )} - - {formatDuration(t.duration_ms)} - - -
-
-
-
+ + + {/* Quota Usage */} + {quota?.enabled && quota.entries.length > 0 && ( + )}
); diff --git a/ui/web/src/pages/overview/quota-usage-card.tsx b/ui/web/src/pages/overview/quota-usage-card.tsx new file mode 100644 index 00000000..ed9e6521 --- /dev/null +++ b/ui/web/src/pages/overview/quota-usage-card.tsx @@ -0,0 +1,83 @@ +import { StatusBadge } from "@/components/shared/status-badge"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import type { QuotaUsage, QuotaUsageResult } from "./types"; + +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} + +
+ ); +} + +export function QuotaUsageCard({ quota }: { quota: QuotaUsageResult }) { + return ( + + + Quota Usage + + + +
+ + + + + + + + + + + {quota.entries.map((entry) => ( + + + + + + + ))} + +
User / GroupHourDayWeek
+ {entry.userId} + + + + + + +
+
+
+
+ ); +} diff --git a/ui/web/src/pages/overview/recent-requests-card.tsx b/ui/web/src/pages/overview/recent-requests-card.tsx new file mode 100644 index 00000000..cf489359 --- /dev/null +++ b/ui/web/src/pages/overview/recent-requests-card.tsx @@ -0,0 +1,103 @@ +import { ArrowRight } from "lucide-react"; +import { Link } from "react-router"; +import { StatusBadge } from "@/components/shared/status-badge"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { ROUTES } from "@/lib/constants"; +import { + formatRelativeTime, + formatTokens, + formatDuration, +} from "@/lib/format"; + +interface Trace { + id: string; + name: string; + user_id: string; + channel: string; + total_input_tokens: number; + total_output_tokens: number; + duration_ms: number; + status: string; + created_at: string; +} + +export function RecentRequestsCard({ traces }: { traces: Trace[] }) { + return ( + + + Recent Requests + {traces.length > 0 && ( + + View All + + )} + + + {traces.length === 0 ? ( +

+ No recent requests +

+ ) : ( +
+ + + + + + + + + + + + + + {traces.map((t) => ( + + + + + + + + + + ))} + +
TimeNameUserChannelTokens + Duration + Status
+ {formatRelativeTime(t.created_at)} + + {t.name || "--"} + + {t.user_id || "--"} + {t.channel || "--"} + {formatTokens( + t.total_input_tokens + t.total_output_tokens, + )} + + {formatDuration(t.duration_ms)} + + +
+
+ )} +
+
+ ); +} diff --git a/ui/web/src/pages/overview/stat-card.tsx b/ui/web/src/pages/overview/stat-card.tsx new file mode 100644 index 00000000..fe615a0b --- /dev/null +++ b/ui/web/src/pages/overview/stat-card.tsx @@ -0,0 +1,26 @@ +export function StatCard({ + icon: Icon, + label, + value, + sub, +}: { + icon: React.ElementType; + label: string; + value: string | number; + sub?: string; +}) { + return ( +
+
+
+ +
+
+

{label}

+

{value}

+ {sub &&

{sub}

} +
+
+
+ ); +} diff --git a/ui/web/src/pages/overview/system-health-card.tsx b/ui/web/src/pages/overview/system-health-card.tsx new file mode 100644 index 00000000..a1ad34f0 --- /dev/null +++ b/ui/web/src/pages/overview/system-health-card.tsx @@ -0,0 +1,144 @@ +import { + Timer, + Monitor, + Database, + Wrench, + Radio, + Users, + CheckCircle2, + XCircle, + Minus, +} from "lucide-react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import type { HealthPayload, ChannelStatusEntry } from "./types"; +import { formatUptime } from "./hooks/use-live-uptime"; + +function StatusDot({ ok }: { ok: boolean | undefined }) { + if (ok === undefined) + return ; + return ok ? ( + + ) : ( + + ); +} + +function HealthCell({ + label, + icon: Icon, + value, + statusOk, +}: { + label: string; + icon: React.ElementType; + value: string; + statusOk?: boolean; +}) { + return ( +
+
+ +
+
+

{label}

+
+ {statusOk !== undefined && } +

{value}

+
+
+
+ ); +} + +export function SystemHealthCard({ + health, + liveUptime, + enabledProviderCount, + sessions, + clientCount, + channelEntries, +}: { + health: HealthPayload | null; + liveUptime: number | undefined; + enabledProviderCount: number; + sessions: number; + clientCount: number; + channelEntries: [string, ChannelStatusEntry][]; +}) { + return ( + + + + System Health + + + +
+ + {health?.mode === "managed" && ( + + )} + 0 + ? `${enabledProviderCount} active` + : "none" + } + statusOk={enabledProviderCount > 0} + /> + + + +
+ + {channelEntries.length > 0 && ( +
+

+ Channels +

+
+ {channelEntries.map(([name, ch]) => ( + + + {name} + + ))} +
+
+ )} +
+
+ ); +} diff --git a/ui/web/src/pages/overview/types.ts b/ui/web/src/pages/overview/types.ts new file mode 100644 index 00000000..93da3cc1 --- /dev/null +++ b/ui/web/src/pages/overview/types.ts @@ -0,0 +1,75 @@ +export interface ClientInfo { + id: string; + remoteAddr: string; + userId: string; + role: string; + connectedAt: string; +} + +export interface HealthPayload { + status?: string; + version?: string; + uptime?: number; + mode?: string; + database?: string; + tools?: number; + clients?: ClientInfo[]; + currentId?: string; +} + +export interface AgentInfo { + id: string; + model: string; + isRunning: boolean; +} + +export interface StatusPayload { + agents?: AgentInfo[]; + agentTotal?: number; + sessions?: number; + clients?: number; +} + +export interface ChannelStatusEntry { + enabled: boolean; + running: boolean; +} + +export interface ChannelStatusPayload { + channels: Record; +} + +export interface QuotaUsage { + used: number; + limit: number; +} + +export interface QuotaUsageEntry { + userId: string; + hour: QuotaUsage; + day: QuotaUsage; + week: QuotaUsage; +} + +export interface QuotaUsageResult { + enabled: boolean; + requestsToday: number; + inputTokensToday: number; + outputTokensToday: number; + uniqueUsersToday: number; + entries: QuotaUsageEntry[]; +} + +export interface CronJob { + id: string; + name: string; + enabled: boolean; + state: { + nextRunAtMs?: number; + lastStatus?: string; + }; +} + +export interface CronListPayload { + jobs: CronJob[]; +}