fix: use grid/cell components

This commit is contained in:
Austin Pickett 2026-04-19 15:21:57 -04:00
parent 923539a46b
commit 60fd4b7d16
12 changed files with 434 additions and 181 deletions

View file

@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Hermes Agent</title>
<title>Hermes Agent - Dashboard</title>
</head>
<body>
<div id="root"></div>

9
web/package-lock.json generated
View file

@ -29,6 +29,7 @@
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.4.0",
"three": "^0.180.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.56.1",
"vite": "^7.3.1"
@ -3840,6 +3841,14 @@
"url": "https://opencollective.com/webpack"
}
},
"node_modules/three": {
"version": "0.180.0",
"resolved": "https://registry.npmjs.org/three/-/three-0.180.0.tgz",
"integrity": "sha512-o+qycAMZrh+TsE01GqWUxUIKR1AL0S8pq7zDkYOQw8GqfX8b8VoCKYUoHbhiX5j+7hr8XsuHDVU6+gkQJQKg9w==",
"devOptional": true,
"license": "MIT",
"peer": true
},
"node_modules/tinyglobby": {
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",

View file

@ -34,6 +34,7 @@
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.4.0",
"three": "^0.180.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.56.1",
"vite": "^7.3.1"

View file

@ -1,12 +1,30 @@
import { useMemo } from "react";
import { Routes, Route, NavLink, Navigate } from "react-router-dom";
import {
Activity, BarChart3, Clock, FileText, KeyRound,
MessageSquare, Package, Settings, Puzzle,
Sparkles, Terminal, Globe, Database, Shield,
Wrench, Zap, Heart, Star, Code, Eye,
Activity,
BarChart3,
Clock,
FileText,
KeyRound,
MessageSquare,
Package,
Settings,
Puzzle,
Sparkles,
Terminal,
Globe,
Database,
Shield,
Wrench,
Zap,
Heart,
Star,
Code,
Eye,
} from "lucide-react";
import { Cell, Grid } from "@nous-research/ui/ui/components/grid/index";
import { SelectionSwitcher } from "@nous-research/ui/ui/components/selection-switcher";
import { Typography } from "@nous-research/ui/ui/components/typography/index";
import { cn } from "@/lib/utils";
import { Backdrop } from "@/components/Backdrop";
import StatusPage from "@/pages/StatusPage";
@ -25,8 +43,18 @@ import type { RegisteredPlugin } from "@/plugins";
const BUILTIN_NAV: NavItem[] = [
{ path: "/", labelKey: "status", label: "Status", icon: Activity },
{ path: "/sessions", labelKey: "sessions", label: "Sessions", icon: MessageSquare },
{ path: "/analytics", labelKey: "analytics", label: "Analytics", icon: BarChart3 },
{
path: "/sessions",
labelKey: "sessions",
label: "Sessions",
icon: MessageSquare,
},
{
path: "/analytics",
labelKey: "analytics",
label: "Analytics",
icon: BarChart3,
},
{ path: "/logs", labelKey: "logs", label: "Logs", icon: FileText },
{ path: "/cron", labelKey: "cron", label: "Cron", icon: Clock },
{ path: "/skills", labelKey: "skills", label: "Skills", icon: Package },
@ -37,17 +65,38 @@ const BUILTIN_NAV: NavItem[] = [
// Plugins can reference any of these by name in their manifest — keeps bundle
// size sane vs. importing the full lucide-react set.
const ICON_MAP: Record<string, React.ComponentType<{ className?: string }>> = {
Activity, BarChart3, Clock, FileText, KeyRound,
MessageSquare, Package, Settings, Puzzle,
Sparkles, Terminal, Globe, Database, Shield,
Wrench, Zap, Heart, Star, Code, Eye,
Activity,
BarChart3,
Clock,
FileText,
KeyRound,
MessageSquare,
Package,
Settings,
Puzzle,
Sparkles,
Terminal,
Globe,
Database,
Shield,
Wrench,
Zap,
Heart,
Star,
Code,
Eye,
};
function resolveIcon(name: string): React.ComponentType<{ className?: string }> {
function resolveIcon(
name: string,
): React.ComponentType<{ className?: string }> {
return ICON_MAP[name] ?? Puzzle;
}
function buildNavItems(builtIn: NavItem[], plugins: RegisteredPlugin[]): NavItem[] {
function buildNavItems(
builtIn: NavItem[],
plugins: RegisteredPlugin[],
): NavItem[] {
const items = [...builtIn];
for (const { manifest } of plugins) {
@ -97,69 +146,86 @@ export default function App() {
"bg-background-base/90 backdrop-blur-sm",
)}
>
<div className="mx-auto flex h-12 max-w-[1600px] items-stretch">
<div className="flex items-center border-r border-current/20 px-3 sm:px-5 shrink-0">
<span
className="font-sans font-bold text-[1.0625rem] sm:text-[1.125rem] leading-[0.95] tracking-[0.0525rem] text-midground"
style={{ mixBlendMode: "plus-lighter" }}
<div className="mx-auto flex h-12 max-w-[1600px]">
<div className="min-w-0 flex-1 overflow-x-auto scrollbar-none">
<Grid
className="h-full !border-t-0 !border-b-0"
style={{
gridTemplateColumns: `auto repeat(${navItems.length}, auto)`,
}}
>
Hermes
<br />
Agent
</span>
</div>
<Cell className="flex items-center !p-0 !px-3 sm:!px-5">
<Typography
className="font-bold text-[1.0625rem] sm:text-[1.125rem] leading-[0.95] tracking-[0.0525rem] text-midground"
style={{ mixBlendMode: "plus-lighter" }}
>
Hermes
<br />
Agent
</Typography>
</Cell>
<nav className="flex items-stretch overflow-x-auto scrollbar-none">
{navItems.map(({ path, label, labelKey, icon: Icon }) => (
<NavLink
key={path}
to={path}
end={path === "/"}
className={({ isActive }) =>
cn(
"group relative inline-flex items-center gap-1.5 shrink-0",
"border-r border-current/20 px-2.5 sm:px-4 py-2",
"font-mondwest text-[0.65rem] sm:text-[0.8rem] tracking-[0.12em]",
"whitespace-nowrap transition-colors cursor-pointer",
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-midground",
isActive
? "text-midground"
: "opacity-60 hover:opacity-100",
)
}
>
{({ isActive }) => (
<>
<Icon className="h-3.5 w-3.5 shrink-0" />
<span className="hidden sm:inline">
{labelKey ? (t.app.nav as Record<string, string>)[labelKey] ?? label : label}
</span>
{navItems.map(({ path, label, labelKey, icon: Icon }) => (
<Cell key={path} className="relative !p-0">
<NavLink
to={path}
end={path === "/"}
className={({ isActive }) =>
cn(
"group relative flex h-full w-full items-center gap-1.5",
"px-2.5 sm:px-4 py-2",
"font-mondwest text-[0.65rem] sm:text-[0.8rem] tracking-[0.12em]",
"whitespace-nowrap transition-colors cursor-pointer",
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-midground",
isActive
? "text-midground"
: "opacity-60 hover:opacity-100",
)
}
>
{({ isActive }) => (
<>
<Icon className="h-3.5 w-3.5 shrink-0" />
<span className="hidden sm:inline">
{labelKey
? ((t.app.nav as Record<string, string>)[
labelKey
] ?? label)
: label}
</span>
<span
aria-hidden
className="absolute inset-1 bg-midground opacity-0 pointer-events-none transition-opacity duration-200 group-hover:opacity-5"
/>
<span
aria-hidden
className="absolute inset-1 bg-midground opacity-0 pointer-events-none transition-opacity duration-200 group-hover:opacity-5"
/>
{isActive && (
<span
aria-hidden
className="absolute bottom-0 left-0 right-0 h-px bg-midground"
style={{ mixBlendMode: "plus-lighter" }}
/>
{isActive && (
<span
aria-hidden
className="absolute bottom-0 left-0 right-0 h-px bg-midground"
style={{ mixBlendMode: "plus-lighter" }}
/>
)}
</>
)}
</>
)}
</NavLink>
))}
</nav>
<div className="ml-auto flex items-center gap-2 border-l border-current/20 px-2 sm:px-4">
<ThemeSwitcher />
<LanguageSwitcher />
<span className="hidden sm:inline font-mondwest text-[0.7rem] tracking-[0.15em] opacity-50">
{t.app.webUi}
</span>
</NavLink>
</Cell>
))}
</Grid>
</div>
<Grid className="h-full shrink-0 !border-t-0 !border-b-0">
<Cell className="flex items-center gap-2 !p-0 !px-2 sm:!px-4">
<ThemeSwitcher />
<LanguageSwitcher />
<Typography
mondwest
className="hidden sm:inline text-[0.7rem] tracking-[0.15em] opacity-50"
>
{t.app.webUi}
</Typography>
</Cell>
</Grid>
</div>
</header>
@ -187,17 +253,25 @@ export default function App() {
</main>
<footer className="relative z-2 border-t border-current/20">
<div className="mx-auto flex max-w-[1600px] items-center justify-between px-3 sm:px-6 py-3">
<span className="font-mondwest text-[0.7rem] sm:text-[0.8rem] tracking-[0.12em] opacity-60">
{t.app.footer.name}
</span>
<span
className="font-mondwest text-[0.6rem] sm:text-[0.7rem] tracking-[0.15em] text-midground"
style={{ mixBlendMode: "plus-lighter" }}
>
{t.app.footer.org}
</span>
</div>
<Grid className="mx-auto max-w-[1600px] !border-t-0 !border-b-0">
<Cell className="flex items-center !px-3 sm:!px-6 !py-3">
<Typography
mondwest
className="text-[0.7rem] sm:text-[0.8rem] tracking-[0.12em] opacity-60"
>
{t.app.footer.name}
</Typography>
</Cell>
<Cell className="flex items-center justify-end !px-3 sm:!px-6 !py-3">
<Typography
mondwest
className="text-[0.6rem] sm:text-[0.7rem] tracking-[0.15em] text-midground"
style={{ mixBlendMode: "plus-lighter" }}
>
{t.app.footer.org}
</Typography>
</Cell>
</Grid>
</footer>
</div>
);

View file

@ -1,3 +1,4 @@
import { Typography } from "@nous-research/ui/ui/components/typography/index";
import { useI18n } from "@/i18n/context";
/**
@ -19,9 +20,12 @@ export function LanguageSwitcher() {
>
{/* Show the *current* language's flag — tooltip advertises the click action */}
<span className="text-base leading-none">{locale === "en" ? "🇬🇧" : "🇨🇳"}</span>
<span className="hidden sm:inline font-mondwest tracking-wide uppercase text-[0.65rem]">
<Typography
mondwest
className="hidden sm:inline tracking-wide uppercase text-[0.65rem]"
>
{locale === "en" ? "EN" : "中文"}
</span>
</Typography>
</button>
);
}

View file

@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from "react";
import { ExternalLink, Copy, X, Check, Loader2 } from "lucide-react";
import { Typography } from "@nous-research/ui/ui/components/typography/index";
import { api, type OAuthProvider, type OAuthStartResponse } from "@/lib/api";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@ -12,9 +13,21 @@ interface Props {
onError: (msg: string) => void;
}
type Phase = "idle" | "starting" | "awaiting_user" | "submitting" | "polling" | "approved" | "error";
type Phase =
| "idle"
| "starting"
| "awaiting_user"
| "submitting"
| "polling"
| "approved"
| "error";
export function OAuthLoginModal({ provider, onClose, onSuccess, onError }: Props) {
export function OAuthLoginModal({
provider,
onClose,
onSuccess,
onError,
}: Props) {
const [phase, setPhase] = useState<Phase>("starting");
const [start, setStart] = useState<OAuthStartResponse | null>(null);
const [pkceCode, setPkceCode] = useState("");
@ -81,13 +94,15 @@ export function OAuthLoginModal({ provider, onClose, onSuccess, onError }: Props
if (!isMounted.current) return;
if (resp.status === "approved") {
setPhase("approved");
if (pollTimer.current !== null) window.clearInterval(pollTimer.current);
if (pollTimer.current !== null)
window.clearInterval(pollTimer.current);
onSuccess(`${provider.name} connected`);
window.setTimeout(() => isMounted.current && onClose(), 1500);
} else if (resp.status !== "pending") {
setPhase("error");
setErrorMsg(resp.error_message || `Login ${resp.status}`);
if (pollTimer.current !== null) window.clearInterval(pollTimer.current);
if (pollTimer.current !== null)
window.clearInterval(pollTimer.current);
}
} catch (e) {
if (!isMounted.current) return;
@ -107,7 +122,11 @@ export function OAuthLoginModal({ provider, onClose, onSuccess, onError }: Props
setPhase("submitting");
setErrorMsg(null);
try {
const resp = await api.submitOAuthCode(provider.id, start.session_id, pkceCode.trim());
const resp = await api.submitOAuthCode(
provider.id,
start.session_id,
pkceCode.trim(),
);
if (!isMounted.current) return;
if (resp.ok && resp.status === "approved") {
setPhase("approved");
@ -175,14 +194,24 @@ export function OAuthLoginModal({ provider, onClose, onSuccess, onError }: Props
</button>
<div className="p-6 flex flex-col gap-4">
<div>
<h2 id="oauth-modal-title" className="font-mondwest text-base tracking-wider uppercase">
<Typography
as="h2"
mondwest
id="oauth-modal-title"
className="text-base tracking-wider uppercase"
>
{t.oauth.connect} {provider.name}
</h2>
{secondsLeft !== null && phase !== "approved" && phase !== "error" && (
<p className="text-xs text-muted-foreground mt-1">
{t.oauth.sessionExpires.replace("{time}", fmtTime(secondsLeft))}
</p>
)}
</Typography>
{secondsLeft !== null &&
phase !== "approved" &&
phase !== "error" && (
<p className="text-xs text-muted-foreground mt-1">
{t.oauth.sessionExpires.replace(
"{time}",
fmtTime(secondsLeft),
)}
</p>
)}
</div>
{/* ── starting ───────────────────────────────────── */}
@ -211,7 +240,10 @@ export function OAuthLoginModal({ provider, onClose, onSuccess, onError }: Props
/>
<div className="flex items-center gap-2 justify-between">
<a
href={(start as Extract<OAuthStartResponse, { flow: "pkce" }>).auth_url}
href={
(start as Extract<OAuthStartResponse, { flow: "pkce" }>)
.auth_url
}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-muted-foreground hover:text-foreground inline-flex items-center gap-1"
@ -219,7 +251,11 @@ export function OAuthLoginModal({ provider, onClose, onSuccess, onError }: Props
<ExternalLink className="h-3 w-3" />
{t.oauth.reOpenAuth}
</a>
<Button onClick={handleSubmitPkceCode} disabled={!pkceCode.trim()} size="sm">
<Button
onClick={handleSubmitPkceCode}
disabled={!pkceCode.trim()}
size="sm"
>
{t.oauth.submitCode}
</Button>
</div>
@ -243,23 +279,46 @@ export function OAuthLoginModal({ provider, onClose, onSuccess, onError }: Props
</p>
<div className="flex items-center justify-between gap-2 border border-border bg-secondary/30 p-4">
<code className="font-mono-ui text-2xl tracking-widest text-foreground">
{(start as Extract<OAuthStartResponse, { flow: "device_code" }>).user_code}
{
(
start as Extract<
OAuthStartResponse,
{ flow: "device_code" }
>
).user_code
}
</code>
<Button
variant="outline"
size="sm"
onClick={() =>
handleCopyUserCode(
(start as Extract<OAuthStartResponse, { flow: "device_code" }>).user_code,
(
start as Extract<
OAuthStartResponse,
{ flow: "device_code" }
>
).user_code,
)
}
className="text-xs"
>
{codeCopied ? <Check className="h-3 w-3" /> : <Copy className="h-3 w-3" />}
{codeCopied ? (
<Check className="h-3 w-3" />
) : (
<Copy className="h-3 w-3" />
)}
</Button>
</div>
<a
href={(start as Extract<OAuthStartResponse, { flow: "device_code" }>).verification_url}
href={
(
start as Extract<
OAuthStartResponse,
{ flow: "device_code" }
>
).verification_url
}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-muted-foreground hover:text-foreground inline-flex items-center gap-1"
@ -302,21 +361,36 @@ export function OAuthLoginModal({ provider, onClose, onSuccess, onError }: Props
setStart(null);
setPkceCode("");
setPhase("starting");
api.startOAuthLogin(provider.id).then((resp) => {
if (!isMounted.current) return;
setStart(resp);
setSecondsLeft(resp.expires_in);
setPhase(resp.flow === "device_code" ? "polling" : "awaiting_user");
if (resp.flow === "pkce") {
window.open(resp.auth_url, "_blank", "noopener,noreferrer");
} else {
window.open(resp.verification_url, "_blank", "noopener,noreferrer");
}
}).catch((e) => {
if (!isMounted.current) return;
setPhase("error");
setErrorMsg(`${t.common.retry} failed: ${e}`);
});
api
.startOAuthLogin(provider.id)
.then((resp) => {
if (!isMounted.current) return;
setStart(resp);
setSecondsLeft(resp.expires_in);
setPhase(
resp.flow === "device_code"
? "polling"
: "awaiting_user",
);
if (resp.flow === "pkce") {
window.open(
resp.auth_url,
"_blank",
"noopener,noreferrer",
);
} else {
window.open(
resp.verification_url,
"_blank",
"noopener,noreferrer",
);
}
})
.catch((e) => {
if (!isMounted.current) return;
setPhase("error");
setErrorMsg(`${t.common.retry} failed: ${e}`);
});
}}
>
{t.common.retry}

View file

@ -1,5 +1,6 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Palette, Check } from "lucide-react";
import { Typography } from "@nous-research/ui/ui/components/typography/index";
import { BUILTIN_THEMES, useTheme } from "@/themes";
import { useI18n } from "@/i18n";
import { cn } from "@/lib/utils";
@ -56,9 +57,12 @@ export function ThemeSwitcher() {
aria-haspopup="listbox"
>
<Palette className="h-3.5 w-3.5" />
<span className="hidden sm:inline font-mondwest tracking-wide uppercase text-[0.65rem]">
<Typography
mondwest
className="hidden sm:inline tracking-wide uppercase text-[0.65rem]"
>
{label}
</span>
</Typography>
</button>
{open && (
@ -72,9 +76,12 @@ export function ThemeSwitcher() {
)}
>
<div className="border-b border-current/20 px-3 py-2">
<span className="font-mondwest text-[0.65rem] tracking-[0.15em] uppercase text-midground/70">
<Typography
mondwest
className="text-[0.65rem] tracking-[0.15em] uppercase text-midground/70"
>
{t.theme?.title ?? "Theme"}
</span>
</Typography>
</div>
{availableThemes.map((th) => {
@ -100,13 +107,16 @@ export function ThemeSwitcher() {
{preset ? <ThemeSwatch theme={preset.name} /> : <PlaceholderSwatch />}
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="truncate font-mondwest text-[0.75rem] tracking-wide uppercase">
<Typography
mondwest
className="truncate text-[0.75rem] tracking-wide uppercase"
>
{th.label}
</span>
</Typography>
{th.description && (
<span className="truncate font-sans text-[0.65rem] normal-case tracking-normal text-midground/50">
<Typography className="truncate text-[0.65rem] normal-case tracking-normal text-midground/50">
{th.description}
</span>
</Typography>
)}
</div>

View file

@ -1,5 +1,6 @@
import { useEffect, useState } from "react";
import { Clock, Pause, Play, Plus, Trash2, Zap } from "lucide-react";
import { H2 } from "@nous-research/ui/ui/components/typography/h2";
import { api } from "@/lib/api";
import type { CronJob } from "@/lib/api";
import { useToast } from "@/hooks/useToast";
@ -195,10 +196,10 @@ export default function CronPage() {
{/* Jobs list */}
<div className="flex flex-col gap-3">
<h2 className="text-sm font-medium text-muted-foreground flex items-center gap-2">
<H2 variant="sm" className="flex items-center gap-2 text-muted-foreground">
<Clock className="h-4 w-4" />
{t.cron.scheduledJobs} ({jobs.length})
</h2>
</H2>
{jobs.length === 0 && (
<Card>

View file

@ -1,5 +1,6 @@
import { useEffect, useState, useCallback, useRef } from "react";
import { FileText, RefreshCw, ChevronRight } from "lucide-react";
import { H2 } from "@nous-research/ui/ui/components/typography/h2";
import { api } from "@/lib/api";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
@ -104,7 +105,7 @@ export default function LogsPage() {
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-2">
<FileText className="h-5 w-5 text-muted-foreground" />
<h1 className="text-base font-semibold">{t.logs.title}</h1>
<H2 variant="sm">{t.logs.title}</H2>
{loading && (
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
)}

View file

@ -13,6 +13,7 @@ import {
Hash,
X,
} from "lucide-react";
import { H2 } from "@nous-research/ui/ui/components/typography/h2";
import { api } from "@/lib/api";
import type { SessionInfo, SessionMessage, SessionSearchResult } from "@/lib/api";
import { timeAgo } from "@/lib/utils";
@ -383,7 +384,7 @@ export default function SessionsPage() {
<div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:justify-between">
<div className="flex items-center gap-2">
<MessageSquare className="h-5 w-5 text-muted-foreground" />
<h1 className="text-base font-semibold">{t.sessions.title}</h1>
<H2 variant="sm">{t.sessions.title}</H2>
<Badge variant="secondary" className="text-xs">
{total}
</Badge>

View file

@ -15,6 +15,7 @@ import {
Code,
Zap,
} from "lucide-react";
import { H2 } from "@nous-research/ui/ui/components/typography/h2";
import { api } from "@/lib/api";
import type { SkillInfo, ToolsetInfo } from "@/lib/api";
import { useToast } from "@/hooks/useToast";
@ -193,7 +194,7 @@ export default function SkillsPage() {
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3">
<Package className="h-5 w-5 text-muted-foreground" />
<h1 className="text-base font-semibold">{t.skills.title}</h1>
<H2 variant="sm">{t.skills.title}</H2>
<span className="text-xs text-muted-foreground">
{t.skills.enabledOf.replace("{enabled}", String(enabledCount)).replace("{total}", String(skills.length))}
</span>

View file

@ -9,6 +9,7 @@ import {
Wifi,
WifiOff,
} from "lucide-react";
import { Cell, Grid } from "@nous-research/ui/ui/components/grid/index";
import { api } from "@/lib/api";
import type { PlatformStatus, SessionInfo, StatusResponse } from "@/lib/api";
import { timeAgo, isoTimeAgo } from "@/lib/utils";
@ -23,8 +24,14 @@ export default function StatusPage() {
useEffect(() => {
const load = () => {
api.getStatus().then(setStatus).catch(() => {});
api.getSessions(50).then((resp) => setSessions(resp.sessions)).catch(() => {});
api
.getStatus()
.then(setStatus)
.catch(() => {});
api
.getSessions(50)
.then((resp) => setSessions(resp.sessions))
.catch(() => {});
};
load();
const interval = setInterval(load, 5000);
@ -39,13 +46,19 @@ export default function StatusPage() {
);
}
const PLATFORM_STATE_BADGE: Record<string, { variant: "success" | "warning" | "destructive"; label: string }> = {
const PLATFORM_STATE_BADGE: Record<
string,
{ variant: "success" | "warning" | "destructive"; label: string }
> = {
connected: { variant: "success", label: t.status.connected },
disconnected: { variant: "warning", label: t.status.disconnected },
fatal: { variant: "destructive", label: t.status.error },
};
const GATEWAY_STATE_DISPLAY: Record<string, { badge: "success" | "warning" | "destructive" | "outline"; label: string }> = {
const GATEWAY_STATE_DISPLAY: Record<
string,
{ badge: "success" | "warning" | "destructive" | "outline"; label: string }
> = {
running: { badge: "success", label: t.status.running },
starting: { badge: "warning", label: t.status.starting },
startup_failed: { badge: "destructive", label: t.status.failed },
@ -53,15 +66,19 @@ export default function StatusPage() {
};
function gatewayValue(): string {
if (status!.gateway_running && status!.gateway_health_url) return status!.gateway_health_url;
if (status!.gateway_running && status!.gateway_pid) return `${t.status.pid} ${status!.gateway_pid}`;
if (status!.gateway_running && status!.gateway_health_url)
return status!.gateway_health_url;
if (status!.gateway_running && status!.gateway_pid)
return `${t.status.pid} ${status!.gateway_pid}`;
if (status!.gateway_running) return t.status.runningRemote;
if (status!.gateway_state === "startup_failed") return t.status.startFailed;
return t.status.notRunning;
}
function gatewayBadge() {
const info = status!.gateway_state ? GATEWAY_STATE_DISPLAY[status!.gateway_state] : null;
const info = status!.gateway_state
? GATEWAY_STATE_DISPLAY[status!.gateway_state]
: null;
if (info) return info;
return status!.gateway_running
? { badge: "success" as const, label: t.status.running }
@ -88,9 +105,14 @@ export default function StatusPage() {
{
icon: Activity,
label: t.status.activeSessions,
value: status.active_sessions > 0 ? `${status.active_sessions} ${t.status.running.toLowerCase()}` : t.status.noneRunning,
value:
status.active_sessions > 0
? `${status.active_sessions} ${t.status.running.toLowerCase()}`
: t.status.noneRunning,
badgeText: status.active_sessions > 0 ? t.common.live : t.common.off,
badgeVariant: (status.active_sessions > 0 ? "success" : "outline") as "success" | "outline",
badgeVariant: (status.active_sessions > 0 ? "success" : "outline") as
| "success"
| "outline",
},
];
@ -106,9 +128,14 @@ export default function StatusPage() {
detail: status.gateway_exit_reason ?? undefined,
});
}
const failedPlatforms = platforms.filter(([, info]) => info.state === "fatal" || info.state === "disconnected");
const failedPlatforms = platforms.filter(
([, info]) => info.state === "fatal" || info.state === "disconnected",
);
for (const [name, info] of failedPlatforms) {
const stateLabel = info.state === "fatal" ? t.status.platformError : t.status.platformDisconnected;
const stateLabel =
info.state === "fatal"
? t.status.platformError
: t.status.platformDisconnected;
alerts.push({
message: `${name.charAt(0).toUpperCase() + name.slice(1)} ${stateLabel}`,
detail: info.error_message ?? undefined,
@ -117,7 +144,6 @@ export default function StatusPage() {
return (
<div className="flex flex-col gap-6">
{/* Alert banner — breaks grid monotony for critical states */}
{alerts.length > 0 && (
<div className="border border-destructive/30 bg-destructive/[0.06] p-4">
<div className="flex items-start gap-3">
@ -125,9 +151,13 @@ export default function StatusPage() {
<div className="flex flex-col gap-2 min-w-0">
{alerts.map((alert, i) => (
<div key={i}>
<p className="text-sm font-medium text-destructive">{alert.message}</p>
<p className="text-sm font-medium text-destructive">
{alert.message}
</p>
{alert.detail && (
<p className="text-xs text-destructive/70 mt-0.5">{alert.detail}</p>
<p className="text-xs text-destructive/70 mt-0.5">
{alert.detail}
</p>
)}
</div>
))}
@ -136,32 +166,41 @@ export default function StatusPage() {
</div>
)}
<div className="grid gap-4 sm:grid-cols-3">
<Grid className="border-b lg:!grid-cols-3">
{items.map(({ icon: Icon, label, value, badgeText, badgeVariant }) => (
<Card key={label} className="min-w-0 overflow-hidden">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<Cell
key={label}
className="flex min-w-0 flex-col gap-2 overflow-hidden"
>
<div className="flex items-center justify-between">
<CardTitle className="text-sm font-medium">{label}</CardTitle>
<Icon className="h-4 w-4 text-muted-foreground" />
</CardHeader>
</div>
<CardContent>
<div className="text-2xl font-bold font-mondwest truncate" title={value}>{value}</div>
<div
className="truncate text-2xl font-bold font-mondwest"
title={value}
>
{value}
</div>
{badgeText && (
<Badge variant={badgeVariant} className="mt-2">
{badgeVariant === "success" && (
<span className="mr-1 inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-current" />
)}
{badgeText}
</Badge>
)}
</CardContent>
</Card>
{badgeText && (
<Badge variant={badgeVariant} className="self-start">
{badgeVariant === "success" && (
<span className="mr-1 inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-current" />
)}
{badgeText}
</Badge>
)}
</Cell>
))}
</div>
</Grid>
{platforms.length > 0 && (
<PlatformsCard platforms={platforms} platformStateBadge={PLATFORM_STATE_BADGE} />
<PlatformsCard
platforms={platforms}
platformStateBadge={PLATFORM_STATE_BADGE}
/>
)}
{activeSessions.length > 0 && (
@ -169,7 +208,9 @@ export default function StatusPage() {
<CardHeader>
<div className="flex items-center gap-2">
<Activity className="h-5 w-5 text-success" />
<CardTitle className="text-base">{t.status.activeSessions}</CardTitle>
<CardTitle className="text-base">
{t.status.activeSessions}
</CardTitle>
</div>
</CardHeader>
@ -181,7 +222,9 @@ export default function StatusPage() {
>
<div className="flex flex-col gap-1 min-w-0 w-full">
<div className="flex items-center gap-2">
<span className="font-medium text-sm truncate">{s.title ?? t.common.untitled}</span>
<span className="font-medium text-sm truncate">
{s.title ?? t.common.untitled}
</span>
<Badge variant="success" className="text-[10px] shrink-0">
<span className="mr-1 inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-current" />
@ -190,7 +233,11 @@ export default function StatusPage() {
</div>
<span className="text-xs text-muted-foreground truncate">
<span className="font-mono-ui">{(s.model ?? t.common.unknown).split("/").pop()}</span> · {s.message_count} {t.common.msgs} · {timeAgo(s.last_active)}
<span className="font-mono-ui">
{(s.model ?? t.common.unknown).split("/").pop()}
</span>{" "}
· {s.message_count} {t.common.msgs} ·{" "}
{timeAgo(s.last_active)}
</span>
</div>
</div>
@ -204,7 +251,9 @@ export default function StatusPage() {
<CardHeader>
<div className="flex items-center gap-2">
<Clock className="h-5 w-5 text-muted-foreground" />
<CardTitle className="text-base">{t.status.recentSessions}</CardTitle>
<CardTitle className="text-base">
{t.status.recentSessions}
</CardTitle>
</div>
</CardHeader>
@ -215,10 +264,16 @@ export default function StatusPage() {
className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 border border-border p-3 w-full"
>
<div className="flex flex-col gap-1 min-w-0 w-full">
<span className="font-medium text-sm truncate">{s.title ?? t.common.untitled}</span>
<span className="font-medium text-sm truncate">
{s.title ?? t.common.untitled}
</span>
<span className="text-xs text-muted-foreground truncate">
<span className="font-mono-ui">{(s.model ?? t.common.unknown).split("/").pop()}</span> · {s.message_count} {t.common.msgs} · {timeAgo(s.last_active)}
<span className="font-mono-ui">
{(s.model ?? t.common.unknown).split("/").pop()}
</span>{" "}
· {s.message_count} {t.common.msgs} ·{" "}
{timeAgo(s.last_active)}
</span>
{s.preview && (
@ -228,7 +283,10 @@ export default function StatusPage() {
)}
</div>
<Badge variant="outline" className="text-[10px] shrink-0 self-start sm:self-center">
<Badge
variant="outline"
className="text-[10px] shrink-0 self-start sm:self-center"
>
<Database className="mr-1 h-3 w-3" />
{s.source ?? "local"}
</Badge>
@ -249,7 +307,9 @@ function PlatformsCard({ platforms, platformStateBadge }: PlatformsCardProps) {
<CardHeader>
<div className="flex items-center gap-2">
<Radio className="h-5 w-5 text-muted-foreground" />
<CardTitle className="text-base">{t.status.connectedPlatforms}</CardTitle>
<CardTitle className="text-base">
{t.status.connectedPlatforms}
</CardTitle>
</div>
</CardHeader>
@ -259,7 +319,12 @@ function PlatformsCard({ platforms, platformStateBadge }: PlatformsCardProps) {
variant: "outline" as const,
label: info.state,
};
const IconComponent = info.state === "connected" ? Wifi : info.state === "fatal" ? AlertTriangle : WifiOff;
const IconComponent =
info.state === "connected"
? Wifi
: info.state === "fatal"
? AlertTriangle
: WifiOff;
return (
<div
@ -267,19 +332,25 @@ function PlatformsCard({ platforms, platformStateBadge }: PlatformsCardProps) {
className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 border border-border p-3 w-full"
>
<div className="flex items-center gap-3 min-w-0 w-full">
<IconComponent className={`h-4 w-4 shrink-0 ${
info.state === "connected"
? "text-success"
: info.state === "fatal"
? "text-destructive"
: "text-warning"
}`} />
<IconComponent
className={`h-4 w-4 shrink-0 ${
info.state === "connected"
? "text-success"
: info.state === "fatal"
? "text-destructive"
: "text-warning"
}`}
/>
<div className="flex flex-col gap-0.5 min-w-0">
<span className="text-sm font-medium capitalize truncate">{name}</span>
<span className="text-sm font-medium capitalize truncate">
{name}
</span>
{info.error_message && (
<span className="text-xs text-destructive">{info.error_message}</span>
<span className="text-xs text-destructive">
{info.error_message}
</span>
)}
{info.updated_at && (
@ -290,7 +361,10 @@ function PlatformsCard({ platforms, platformStateBadge }: PlatformsCardProps) {
</div>
</div>
<Badge variant={display.variant} className="shrink-0 self-start sm:self-center">
<Badge
variant={display.variant}
className="shrink-0 self-start sm:self-center"
>
{display.variant === "success" && (
<span className="mr-1 inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-current" />
)}
@ -306,5 +380,8 @@ function PlatformsCard({ platforms, platformStateBadge }: PlatformsCardProps) {
interface PlatformsCardProps {
platforms: [string, PlatformStatus][];
platformStateBadge: Record<string, { variant: "success" | "warning" | "destructive"; label: string }>;
platformStateBadge: Record<
string,
{ variant: "success" | "warning" | "destructive"; label: string }
>;
}