feat: add sidebar

This commit is contained in:
Austin Pickett 2026-04-22 23:25:17 -04:00
parent 7db2703b33
commit e5d2815b41
41 changed files with 2469 additions and 1391 deletions

View file

@ -1,16 +1,19 @@
import { useEffect, useState, useCallback } from "react";
import { useCallback, useEffect, useLayoutEffect, useState } from "react";
import {
BarChart3,
Brain,
Cpu,
Hash,
RefreshCw,
TrendingUp,
} from "lucide-react";
import { api } from "@/lib/api";
import type { AnalyticsResponse, AnalyticsDailyEntry, AnalyticsModelEntry, AnalyticsSkillEntry } from "@/lib/api";
import { timeAgo } from "@/lib/utils";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { usePageHeader } from "@/contexts/usePageHeader";
import { useI18n } from "@/i18n";
const PERIODS = [
@ -281,6 +284,7 @@ export default function AnalyticsPage() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const { t } = useI18n();
const { setAfterTitle, setEnd } = usePageHeader();
const load = useCallback(() => {
setLoading(true);
@ -292,28 +296,60 @@ export default function AnalyticsPage() {
.finally(() => setLoading(false));
}, [days]);
useLayoutEffect(() => {
const periodLabel =
PERIODS.find((p) => p.days === days)?.label ?? `${days}d`;
setAfterTitle(
<span className="flex items-center gap-2">
{loading && (
<div className="h-4 w-4 shrink-0 animate-spin rounded-full border-2 border-primary border-t-transparent" />
)}
<Badge variant="secondary" className="text-[10px]">
{periodLabel}
</Badge>
</span>,
);
setEnd(
<div className="flex w-full min-w-0 flex-wrap items-center justify-end gap-2 sm:gap-2">
<div className="flex flex-wrap items-center gap-1.5">
{PERIODS.map((p) => (
<Button
key={p.label}
type="button"
variant={days === p.days ? "default" : "outline"}
size="sm"
className="h-7 min-w-0 text-xs"
onClick={() => setDays(p.days)}
>
{p.label}
</Button>
))}
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={load}
disabled={loading}
className="h-7 text-xs"
>
<RefreshCw className="mr-1 h-3 w-3" />
{t.common.refresh}
</Button>
</div>,
);
return () => {
setAfterTitle(null);
setEnd(null);
};
}, [days, loading, load, setAfterTitle, setEnd, t.common.refresh]);
useEffect(() => {
load();
}, [load]);
return (
<div className="flex flex-col gap-6">
{/* Period selector */}
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground font-medium">{t.analytics.period}</span>
{PERIODS.map((p) => (
<Button
key={p.label}
variant={days === p.days ? "default" : "outline"}
size="sm"
className="text-xs h-7"
onClick={() => setDays(p.days)}
>
{p.label}
</Button>
))}
</div>
{loading && !data && (
<div className="flex items-center justify-center py-24">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />

View file

@ -1,4 +1,4 @@
import { useEffect, useRef, useState, useMemo } from "react";
import { useEffect, useLayoutEffect, useRef, useState, useMemo } from "react";
import {
Code,
Download,
@ -8,7 +8,6 @@ import {
Search,
Upload,
X,
ChevronRight,
Settings2,
FileText,
Settings,
@ -27,6 +26,7 @@ import {
MessageCircle,
Wrench,
FileQuestion,
Filter,
} from "lucide-react";
import { api } from "@/lib/api";
import { getNestedValue, setNestedValue } from "@/lib/nested";
@ -38,6 +38,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { useI18n } from "@/i18n";
import { usePageHeader } from "@/contexts/usePageHeader";
/* ------------------------------------------------------------------ */
/* Helpers */
@ -85,6 +86,35 @@ export default function ConfigPage() {
const { toast, showToast } = useToast();
const fileInputRef = useRef<HTMLInputElement>(null);
const { t } = useI18n();
const { setEnd } = usePageHeader();
useLayoutEffect(() => {
if (!config || !schema) {
setEnd(null);
return;
}
setEnd(
<div className="relative w-full min-w-0 sm:max-w-xs">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
className="h-8 pl-8 pr-7 text-xs"
placeholder={t.common.search}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
{searchQuery && (
<button
type="button"
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
onClick={() => setSearchQuery("")}
>
<X className="h-3 w-3" />
</button>
)}
</div>,
);
return () => setEnd(null);
}, [config, schema, searchQuery, setEnd, t.common.search]);
function prettyCategoryName(cat: string): string {
const key = cat as keyof typeof t.config.categories;
@ -366,62 +396,66 @@ export default function ConfigPage() {
</Card>
) : (
/* ═══════════════ Form Mode ═══════════════ */
<div className="flex flex-col sm:flex-row gap-4" style={{ minHeight: "calc(100vh - 180px)" }}>
{/* ---- Sidebar — horizontal scroll on mobile, fixed column on sm+ ---- */}
<div className="sm:w-52 sm:shrink-0">
<div className="sm:sticky sm:top-[72px] flex flex-col gap-1">
{/* Search */}
<div className="relative mb-2 hidden sm:block">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
className="pl-8 h-8 text-xs"
placeholder={t.common.search}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
{searchQuery && (
<button
type="button"
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
onClick={() => setSearchQuery("")}
>
<X className="h-3 w-3" />
</button>
)}
</div>
<div className="flex flex-col sm:flex-row gap-4">
{/* ---- Filter panel ---- */}
<aside aria-label={t.config.filters} className="sm:w-56 sm:shrink-0">
<div className="sm:sticky sm:top-4">
<div className="flex flex-col border border-border bg-muted/20">
{/* Panel heading */}
<div className="hidden sm:flex items-center gap-2 px-3 py-2 border-b border-border">
<Filter className="h-3 w-3 text-muted-foreground" />
<span className="font-mondwest text-[0.65rem] tracking-[0.12em] uppercase text-muted-foreground">
{t.config.filters}
</span>
</div>
{/* Category nav — horizontal scroll on mobile */}
<div className="flex sm:flex-col gap-1 overflow-x-auto sm:overflow-x-visible scrollbar-none pb-1 sm:pb-0">
{categories.map((cat) => {
const isActive = !isSearching && activeCategory === cat;
return (
<button
key={cat}
type="button"
onClick={() => {
setSearchQuery("");
setActiveCategory(cat);
}}
className={`group flex items-center gap-2 px-2.5 py-1.5 text-left text-xs transition-colors cursor-pointer ${
isActive
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:text-foreground hover:bg-muted/50"
}`}
>
<CategoryIcon category={cat} className="h-3.5 w-3.5 shrink-0" />
<span className="flex-1 truncate">{prettyCategoryName(cat)}</span>
<span className={`text-[10px] tabular-nums ${isActive ? "text-primary/60" : "text-muted-foreground/50"}`}>
{categoryCounts[cat] || 0}
</span>
{isActive && (
<ChevronRight className="h-3 w-3 text-primary/50 shrink-0" />
)}
</button>
);
})}
{/* Sections heading (hidden on mobile since it becomes a horizontal scroll) */}
<div className="hidden sm:block px-3 pt-2 pb-1 font-mondwest text-[0.6rem] tracking-[0.12em] uppercase text-muted-foreground/70">
{t.config.sections}
</div>
{/* Category nav — horizontal scroll on mobile, pill list on sm+ */}
<div className="flex sm:flex-col gap-1 sm:gap-px p-2 sm:pt-1 overflow-x-auto sm:overflow-x-visible scrollbar-none sm:max-h-[calc(100vh-260px)] sm:overflow-y-auto">
{categories.map((cat) => {
const isActive = !isSearching && activeCategory === cat;
return (
<button
key={cat}
type="button"
onClick={() => {
setSearchQuery("");
setActiveCategory(cat);
}}
className={`
group flex items-center gap-2 px-2 py-1
rounded-sm text-left text-[11px] cursor-pointer whitespace-nowrap
transition-colors
${
isActive
? "bg-foreground/10 text-foreground"
: "text-muted-foreground hover:text-foreground hover:bg-foreground/5"
}
`}
>
<CategoryIcon category={cat} className="h-3.5 w-3.5 shrink-0" />
<span className="flex-1 truncate">{prettyCategoryName(cat)}</span>
<span
className={`text-[10px] tabular-nums ${
isActive
? "text-foreground/60"
: "text-muted-foreground/50"
}`}
>
{categoryCounts[cat] || 0}
</span>
</button>
);
})}
</div>
</div>
</div>
</div>
</aside>
{/* ---- Content ---- */}
<div className="flex-1 min-w-0">

View file

@ -1,9 +1,11 @@
import { useEffect, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { Clock, Pause, Play, Plus, Trash2, Zap } from "lucide-react";
import { H2 } from "@nous-research/ui";
import { api } from "@/lib/api";
import type { CronJob } from "@/lib/api";
import { DeleteConfirmDialog } from "@/components/DeleteConfirmDialog";
import { useToast } from "@/hooks/useToast";
import { useConfirmDelete } from "@/hooks/useConfirmDelete";
import { Toast } from "@/components/Toast";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
@ -40,17 +42,17 @@ export default function CronPage() {
const [deliver, setDeliver] = useState("local");
const [creating, setCreating] = useState(false);
const loadJobs = () => {
const loadJobs = useCallback(() => {
api
.getCronJobs()
.then(setJobs)
.catch(() => showToast(t.common.loading, "error"))
.finally(() => setLoading(false));
};
}, [showToast, t.common.loading]);
useEffect(() => {
loadJobs();
}, []);
}, [loadJobs]);
const handleCreate = async () => {
if (!prompt.trim() || !schedule.trim()) {
@ -113,18 +115,25 @@ export default function CronPage() {
}
};
const handleDelete = async (job: CronJob) => {
try {
await api.deleteCronJob(job.id);
showToast(
`${t.common.delete}: "${job.name || job.prompt.slice(0, 30)}"`,
"success",
);
loadJobs();
} catch (e) {
showToast(`${t.status.error}: ${e}`, "error");
}
};
const jobDelete = useConfirmDelete({
onDelete: useCallback(
async (id: string) => {
const job = jobs.find((j) => j.id === id);
try {
await api.deleteCronJob(id);
showToast(
`${t.common.delete}: "${job?.name || (job?.prompt ?? "").slice(0, 30) || id}"`,
"success",
);
loadJobs();
} catch (e) {
showToast(`${t.status.error}: ${e}`, "error");
throw e;
}
},
[jobs, loadJobs, showToast, t.common.delete, t.status.error],
),
});
if (loading) {
return (
@ -134,10 +143,27 @@ export default function CronPage() {
);
}
const pendingJob = jobDelete.pendingId
? jobs.find((j) => j.id === jobDelete.pendingId)
: null;
return (
<div className="flex flex-col gap-6">
<Toast toast={toast} />
<DeleteConfirmDialog
open={jobDelete.isOpen}
onCancel={jobDelete.cancel}
onConfirm={jobDelete.confirm}
title={t.cron.confirmDeleteTitle}
description={
pendingJob
? `"${pendingJob.name || pendingJob.prompt.slice(0, 40)}" — ${t.cron.confirmDeleteMessage}`
: t.cron.confirmDeleteMessage
}
loading={jobDelete.isDeleting}
/>
{/* Create new job form */}
<Card>
<CardHeader>
@ -311,7 +337,7 @@ export default function CronPage() {
size="icon"
title={t.common.delete}
aria-label={t.common.delete}
onClick={() => handleDelete(job)}
onClick={() => jobDelete.requestDelete(job.id)}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>

View file

@ -1,4 +1,4 @@
import { useEffect, useState, useMemo } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
Eye,
EyeOff,
@ -16,8 +16,10 @@ import {
} from "lucide-react";
import { api } from "@/lib/api";
import type { EnvVarInfo } from "@/lib/api";
import { useToast } from "@/hooks/useToast";
import { DeleteConfirmDialog } from "@/components/DeleteConfirmDialog";
import { Toast } from "@/components/Toast";
import { useConfirmDelete } from "@/hooks/useConfirmDelete";
import { useToast } from "@/hooks/useToast";
import { OAuthProvidersCard } from "@/components/OAuthProvidersCard";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
@ -95,6 +97,7 @@ function EnvVarRow({
onClear,
onReveal,
onCancelEdit,
clearDialogOpen = false,
compact = false,
}: {
varKey: string;
@ -107,6 +110,7 @@ function EnvVarRow({
onClear: (key: string) => void;
onReveal: (key: string) => void;
onCancelEdit: (key: string) => void;
clearDialogOpen?: boolean;
compact?: boolean;
}) {
const { t } = useI18n();
@ -219,7 +223,7 @@ function EnvVarRow({
{info.is_set && (
<Button size="sm" variant="ghost"
className="text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() => onClear(varKey)} disabled={saving === varKey}>
onClick={() => onClear(varKey)} disabled={saving === varKey || clearDialogOpen}>
<Trash2 className="h-3 w-3" />
{saving === varKey ? "..." : t.common.clear}
</Button>
@ -261,6 +265,7 @@ function ProviderGroupCard({
onClear,
onReveal,
onCancelEdit,
clearDialogOpen = false,
}: {
group: ProviderGroup;
edits: Record<string, string>;
@ -271,6 +276,7 @@ function ProviderGroupCard({
onClear: (key: string) => void;
onReveal: (key: string) => void;
onCancelEdit: (key: string) => void;
clearDialogOpen?: boolean;
}) {
const [expanded, setExpanded] = useState(false);
const { t } = useI18n();
@ -325,6 +331,7 @@ function ProviderGroupCard({
key={key} varKey={key} info={info} compact
edits={edits} setEdits={setEdits} revealed={revealed} saving={saving}
onSave={onSave} onClear={onClear} onReveal={onReveal} onCancelEdit={onCancelEdit}
clearDialogOpen={clearDialogOpen}
/>
))}
{/* Base URLs (secondary) */}
@ -333,6 +340,7 @@ function ProviderGroupCard({
key={key} varKey={key} info={info} compact
edits={edits} setEdits={setEdits} revealed={revealed} saving={saving}
onSave={onSave} onClear={onClear} onReveal={onReveal} onCancelEdit={onCancelEdit}
clearDialogOpen={clearDialogOpen}
/>
))}
{/* Anything else */}
@ -341,6 +349,7 @@ function ProviderGroupCard({
key={key} varKey={key} info={info} compact
edits={edits} setEdits={setEdits} revealed={revealed} saving={saving}
onSave={onSave} onClear={onClear} onReveal={onReveal} onCancelEdit={onCancelEdit}
clearDialogOpen={clearDialogOpen}
/>
))}
</div>
@ -390,24 +399,30 @@ export default function EnvPage() {
}
};
const handleClear = async (key: string) => {
setSaving(key);
try {
await api.deleteEnvVar(key);
setVars((prev) =>
prev
? { ...prev, [key]: { ...prev[key], is_set: false, redacted_value: null } }
: prev,
);
setEdits((prev) => { const n = { ...prev }; delete n[key]; return n; });
setRevealed((prev) => { const n = { ...prev }; delete n[key]; return n; });
showToast(`${key} ${t.common.removed}`, "success");
} catch (e) {
showToast(`${t.common.failedToRemove} ${key}: ${e}`, "error");
} finally {
setSaving(null);
}
};
const keyClear = useConfirmDelete({
onDelete: useCallback(
async (key: string) => {
setSaving(key);
try {
await api.deleteEnvVar(key);
setVars((prev) =>
prev
? { ...prev, [key]: { ...prev[key], is_set: false, redacted_value: null } }
: prev,
);
setEdits((prev) => { const n = { ...prev }; delete n[key]; return n; });
setRevealed((prev) => { const n = { ...prev }; delete n[key]; return n; });
showToast(`${key} ${t.common.removed}`, "success");
} catch (e) {
showToast(`${t.common.failedToRemove} ${key}: ${e}`, "error");
throw e;
} finally {
setSaving(null);
}
},
[showToast, t.common.removed, t.common.failedToRemove],
),
});
const handleReveal = async (key: string) => {
if (revealed[key]) {
@ -488,10 +503,29 @@ export default function EnvPage() {
const totalProviders = providerGroups.length;
const configuredProviders = providerGroups.filter((g) => g.hasAnySet).length;
const pendingClearKey = keyClear.pendingId;
const pendingKeyDescription =
pendingClearKey && vars
? vars[pendingClearKey]?.description
: undefined;
return (
<div className="flex flex-col gap-6">
<Toast toast={toast} />
<DeleteConfirmDialog
open={keyClear.isOpen}
onCancel={keyClear.cancel}
onConfirm={keyClear.confirm}
title={t.env.confirmClearTitle}
description={
pendingClearKey
? `${pendingClearKey}${pendingKeyDescription ? `${pendingKeyDescription}` : ""}. ${t.env.confirmClearMessage}`
: t.env.confirmClearMessage
}
loading={keyClear.isDeleting}
/>
<div className="flex items-center justify-between">
<div className="flex flex-col gap-1">
<p className="text-sm text-muted-foreground">
@ -530,7 +564,8 @@ export default function EnvPage() {
key={group.name}
group={group}
edits={edits} setEdits={setEdits} revealed={revealed} saving={saving}
onSave={handleSave} onClear={handleClear} onReveal={handleReveal} onCancelEdit={cancelEdit}
onSave={handleSave} onClear={keyClear.requestDelete} onReveal={handleReveal} onCancelEdit={cancelEdit}
clearDialogOpen={keyClear.isOpen}
/>
))}
</CardContent>
@ -557,7 +592,8 @@ export default function EnvPage() {
<EnvVarRow
key={key} varKey={key} info={info}
edits={edits} setEdits={setEdits} revealed={revealed} saving={saving}
onSave={handleSave} onClear={handleClear} onReveal={handleReveal} onCancelEdit={cancelEdit}
onSave={handleSave} onClear={keyClear.requestDelete} onReveal={handleReveal} onCancelEdit={cancelEdit}
clearDialogOpen={keyClear.isOpen}
/>
))}
@ -566,7 +602,8 @@ export default function EnvPage() {
category={category}
unsetEntries={unsetEntries}
edits={edits} setEdits={setEdits} revealed={revealed} saving={saving}
onSave={handleSave} onClear={handleClear} onReveal={handleReveal} onCancelEdit={cancelEdit}
onSave={handleSave} onClear={keyClear.requestDelete} onReveal={handleReveal} onCancelEdit={cancelEdit}
clearDialogOpen={keyClear.isOpen}
/>
)}
</CardContent>
@ -592,6 +629,7 @@ function CollapsibleUnset({
onClear,
onReveal,
onCancelEdit,
clearDialogOpen = false,
}: {
category: string;
unsetEntries: [string, EnvVarInfo][];
@ -603,6 +641,7 @@ function CollapsibleUnset({
onClear: (key: string) => void;
onReveal: (key: string) => void;
onCancelEdit: (key: string) => void;
clearDialogOpen?: boolean;
}) {
const [collapsed, setCollapsed] = useState(true);
const { t } = useI18n();
@ -625,6 +664,7 @@ function CollapsibleUnset({
key={key} varKey={key} info={info}
edits={edits} setEdits={setEdits} revealed={revealed} saving={saving}
onSave={onSave} onClear={onClear} onReveal={onReveal} onCancelEdit={onCancelEdit}
clearDialogOpen={clearDialogOpen}
/>
))}
</>

View file

@ -1,13 +1,14 @@
import { useEffect, useState, useCallback, useRef } from "react";
import { FileText, RefreshCw, ChevronRight } from "lucide-react";
import { H2 } from "@nous-research/ui";
import { useEffect, useLayoutEffect, useState, useCallback, useRef } from "react";
import { FileText, RefreshCw } from "lucide-react";
import { api } from "@/lib/api";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import { FilterGroup, Segmented } from "@/components/ui/segmented";
import { useI18n } from "@/i18n";
import { usePageHeader } from "@/contexts/usePageHeader";
const FILES = ["agent", "errors", "gateway"] as const;
const LEVELS = ["ALL", "DEBUG", "INFO", "WARNING", "ERROR"] as const;
@ -34,38 +35,8 @@ const LINE_COLORS: Record<string, string> = {
debug: "text-muted-foreground/60",
};
function SidebarHeading({ children }: { children: React.ReactNode }) {
return (
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/60 px-2.5 pt-3 pb-1">
{children}
</span>
);
}
function SidebarItem<T extends string>({
label,
value,
current,
onChange,
}: SidebarItemProps<T>) {
const isActive = current === value;
return (
<button
type="button"
onClick={() => onChange(value)}
className={`group flex items-center gap-2 px-2.5 py-1 text-left text-xs transition-colors cursor-pointer ${
isActive
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:text-foreground hover:bg-muted/50"
}`}
>
<span className="flex-1 truncate">{label}</span>
{isActive && (
<ChevronRight className="h-3 w-3 text-primary/50 shrink-0" />
)}
</button>
);
}
const toOptions = <T extends string>(values: readonly T[]) =>
values.map((v) => ({ value: v, label: v }));
export default function LogsPage() {
const [file, setFile] = useState<(typeof FILES)[number]>("agent");
@ -79,6 +50,7 @@ export default function LogsPage() {
const [error, setError] = useState<string | null>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const { t } = useI18n();
const { setAfterTitle, setEnd } = usePageHeader();
const fetchLogs = useCallback(() => {
setLoading(true);
@ -97,6 +69,66 @@ export default function LogsPage() {
.finally(() => setLoading(false));
}, [file, lineCount, level, component]);
useLayoutEffect(() => {
setAfterTitle(
<span className="flex items-center gap-2">
{loading && (
<div className="h-4 w-4 shrink-0 animate-spin rounded-full border-2 border-primary border-t-transparent" />
)}
<Badge variant="secondary" className="text-[10px]">
{file} · {level} · {component}
</Badge>
</span>,
);
setEnd(
<div className="flex w-full min-w-0 flex-wrap items-center justify-end gap-2 sm:gap-3">
<div className="flex items-center gap-2">
<Switch
checked={autoRefresh}
onCheckedChange={setAutoRefresh}
id="logs-auto-refresh"
/>
<Label htmlFor="logs-auto-refresh" className="text-xs cursor-pointer">
{t.logs.autoRefresh}
</Label>
{autoRefresh && (
<Badge variant="success" className="text-[10px]">
<span className="mr-1 inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-current" />
{t.common.live}
</Badge>
)}
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={fetchLogs}
disabled={loading}
className="h-7 text-xs"
>
<RefreshCw className="mr-1 h-3 w-3" />
{t.common.refresh}
</Button>
</div>,
);
return () => {
setAfterTitle(null);
setEnd(null);
};
}, [
autoRefresh,
component,
file,
level,
loading,
setAfterTitle,
setEnd,
t.common.live,
t.common.refresh,
t.logs.autoRefresh,
fetchLogs,
]);
useEffect(() => {
fetchLogs();
}, [fetchLogs]);
@ -109,145 +141,80 @@ export default function LogsPage() {
return (
<div className="flex flex-col gap-4">
{/* ═══════════════ Header ═══════════════ */}
<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" />
<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" />
)}
<Badge variant="secondary" className="text-[10px]">
{file} · {level} · {component}
</Badge>
</div>
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<Switch checked={autoRefresh} onCheckedChange={setAutoRefresh} />
<Label className="text-xs">{t.logs.autoRefresh}</Label>
{autoRefresh && (
<Badge variant="success" className="text-[10px]">
<span className="mr-1 inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-current" />
{t.common.live}
</Badge>
)}
</div>
<Button
variant="outline"
size="sm"
onClick={fetchLogs}
className="text-xs h-7"
>
<RefreshCw className="h-3 w-3 mr-1" />
{t.common.refresh}
</Button>
</div>
</div>
{/* ═══════════════ Sidebar + Content ═══════════════ */}
{/* ═══════════════ Filter toolbar ═══════════════ */}
<div
className="flex flex-col sm:flex-row gap-4"
style={{ minHeight: "calc(100vh - 180px)" }}
role="toolbar"
aria-label={t.logs.title}
className="flex flex-wrap items-center gap-x-6 gap-y-2"
>
{/* ---- Sidebar ---- */}
<div className="sm:w-44 sm:shrink-0">
<div className="sm:sticky sm:top-[72px] flex flex-col gap-0.5">
<SidebarHeading>{t.logs.file}</SidebarHeading>
{FILES.map((f) => (
<SidebarItem
key={f}
label={f}
value={f}
current={file}
onChange={setFile}
/>
))}
<FilterGroup label={t.logs.file}>
<Segmented value={file} onChange={setFile} options={toOptions(FILES)} />
</FilterGroup>
<SidebarHeading>{t.logs.level}</SidebarHeading>
{LEVELS.map((l) => (
<SidebarItem
key={l}
label={l}
value={l}
current={level}
onChange={setLevel}
/>
))}
<FilterGroup label={t.logs.level}>
<Segmented value={level} onChange={setLevel} options={toOptions(LEVELS)} />
</FilterGroup>
<SidebarHeading>{t.logs.component}</SidebarHeading>
{COMPONENTS.map((c) => (
<SidebarItem
key={c}
label={c}
value={c}
current={component}
onChange={setComponent}
/>
))}
<FilterGroup label={t.logs.component}>
<Segmented
value={component}
onChange={setComponent}
options={toOptions(COMPONENTS)}
/>
</FilterGroup>
<SidebarHeading>{t.logs.lines}</SidebarHeading>
{LINE_COUNTS.map((n) => (
<SidebarItem
key={n}
label={String(n)}
value={String(n)}
current={String(lineCount)}
onChange={(v) =>
setLineCount(Number(v) as (typeof LINE_COUNTS)[number])
}
/>
))}
</div>
</div>
{/* ---- Content ---- */}
<div className="flex-1 min-w-0">
<Card>
<CardHeader className="py-3 px-4">
<CardTitle className="text-sm flex items-center gap-2">
<FileText className="h-4 w-4" />
{file}.log
</CardTitle>
</CardHeader>
<CardContent className="p-0">
{error && (
<div className="bg-destructive/10 border-b border-destructive/20 p-3">
<p className="text-sm text-destructive">{error}</p>
</div>
)}
<div
ref={scrollRef}
className="p-4 font-mono-ui text-xs leading-5 overflow-auto max-h-[600px] min-h-[200px]"
>
{lines.length === 0 && !loading && (
<p className="text-muted-foreground text-center py-8">
{t.logs.noLogLines}
</p>
)}
{lines.map((line, i) => {
const cls = classifyLine(line);
return (
<div
key={i}
className={`${LINE_COLORS[cls]} hover:bg-secondary/20 px-1 -mx-1`}
>
{line}
</div>
);
})}
</div>
</CardContent>
</Card>
</div>
<FilterGroup label={t.logs.lines}>
<Segmented
value={String(lineCount)}
onChange={(v) =>
setLineCount(Number(v) as (typeof LINE_COUNTS)[number])
}
options={LINE_COUNTS.map((n) => ({
value: String(n),
label: String(n),
}))}
/>
</FilterGroup>
</div>
{/* ═══════════════ Log viewer ═══════════════ */}
<Card>
<CardHeader className="py-3 px-4">
<CardTitle className="text-sm flex items-center gap-2">
<FileText className="h-4 w-4" />
{file}.log
</CardTitle>
</CardHeader>
<CardContent className="p-0">
{error && (
<div className="bg-destructive/10 border-b border-destructive/20 p-3">
<p className="text-sm text-destructive">{error}</p>
</div>
)}
<div
ref={scrollRef}
className="p-4 font-mono-ui text-xs leading-5 overflow-auto min-h-[400px] max-h-[calc(100vh-220px)]"
>
{lines.length === 0 && !loading && (
<p className="text-muted-foreground text-center py-8">
{t.logs.noLogLines}
</p>
)}
{lines.map((line, i) => {
const cls = classifyLine(line);
return (
<div
key={i}
className={`${LINE_COLORS[cls]} hover:bg-secondary/20 px-1 -mx-1`}
>
{line}
</div>
);
})}
</div>
</CardContent>
</Card>
</div>
);
}
interface SidebarItemProps<T extends string> {
label: string;
value: T;
current: T;
onChange: (v: T) => void;
}

View file

@ -1,8 +1,12 @@
import { useEffect, useState, useCallback, useRef } from "react";
import { useEffect, useLayoutEffect, useState, useCallback, useRef } from "react";
import {
AlertTriangle,
CheckCircle2,
ChevronDown,
ChevronLeft,
ChevronRight,
Database,
Loader2,
MessageSquare,
Search,
Trash2,
@ -13,19 +17,27 @@ import {
Hash,
X,
} from "lucide-react";
import { H2 } from "@nous-research/ui";
import { api } from "@/lib/api";
import type {
SessionInfo,
SessionMessage,
SessionSearchResult,
StatusResponse,
} from "@/lib/api";
import { timeAgo } from "@/lib/utils";
import { Markdown } from "@/components/Markdown";
import { PlatformsCard } from "@/components/PlatformsCard";
import { Toast } from "@/components/Toast";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { DeleteConfirmDialog } from "@/components/DeleteConfirmDialog";
import { useConfirmDelete } from "@/hooks/useConfirmDelete";
import { Input } from "@/components/ui/input";
import { useSystemActions } from "@/contexts/useSystemActions";
import { useToast } from "@/hooks/useToast";
import { useI18n } from "@/i18n";
import { usePageHeader } from "@/contexts/usePageHeader";
const SOURCE_CONFIG: Record<string, { icon: typeof Terminal; color: string }> =
{
@ -381,7 +393,62 @@ export default function SessionsPage() {
>(null);
const [searching, setSearching] = useState(false);
const debounceRef = useRef<ReturnType<typeof setTimeout>>(null);
const logScrollRef = useRef<HTMLPreElement | null>(null);
const [status, setStatus] = useState<StatusResponse | null>(null);
const [overviewSessions, setOverviewSessions] = useState<SessionInfo[]>([]);
const { toast, showToast } = useToast();
const { t } = useI18n();
const { setAfterTitle, setEnd } = usePageHeader();
const { activeAction, actionStatus, dismissLog } = useSystemActions();
useLayoutEffect(() => {
if (loading) {
setAfterTitle(null);
setEnd(null);
return;
}
setAfterTitle(
<Badge variant="secondary" className="text-xs tabular-nums">
{total}
</Badge>,
);
setEnd(
<div className="relative w-full min-w-0 sm:max-w-xs">
{searching ? (
<div className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 animate-spin rounded-full border-[1.5px] border-primary border-t-transparent" />
) : (
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
)}
<Input
placeholder={t.sessions.searchPlaceholder}
value={search}
onChange={(e) => setSearch(e.target.value)}
className="h-8 pr-7 pl-8 text-xs"
/>
{search && (
<button
type="button"
className="absolute right-2 top-1/2 -translate-y-1/2 cursor-pointer text-muted-foreground hover:text-foreground"
onClick={() => setSearch("")}
>
<X className="h-3 w-3" />
</button>
)}
</div>,
);
return () => {
setAfterTitle(null);
setEnd(null);
};
}, [
loading,
search,
searching,
setAfterTitle,
setEnd,
t.sessions.searchPlaceholder,
total,
]);
const loadSessions = useCallback((p: number) => {
setLoading(true);
@ -399,6 +466,24 @@ export default function SessionsPage() {
loadSessions(page);
}, [loadSessions, page]);
useEffect(() => {
const loadOverview = () => {
api.getStatus().then(setStatus).catch(() => {});
api
.getSessions(50)
.then((r) => setOverviewSessions(r.sessions))
.catch(() => {});
};
loadOverview();
const id = setInterval(loadOverview, 5000);
return () => clearInterval(id);
}, []);
useEffect(() => {
const el = logScrollRef.current;
if (el) el.scrollTop = el.scrollHeight;
}, [actionStatus?.lines]);
// Debounced FTS search
useEffect(() => {
if (debounceRef.current) clearTimeout(debounceRef.current);
@ -423,16 +508,27 @@ export default function SessionsPage() {
};
}, [search]);
const handleDelete = async (id: string) => {
try {
await api.deleteSession(id);
setSessions((prev) => prev.filter((s) => s.id !== id));
setTotal((prev) => prev - 1);
if (expandedId === id) setExpandedId(null);
} catch {
// ignore
}
};
const sessionDelete = useConfirmDelete({
onDelete: useCallback(
async (id: string) => {
try {
await api.deleteSession(id);
setSessions((prev) => prev.filter((s) => s.id !== id));
setTotal((prev) => prev - 1);
if (expandedId === id) setExpandedId(null);
showToast(t.sessions.sessionDeleted, "success");
} catch {
showToast(t.sessions.failedToDelete, "error");
throw new Error("delete failed");
}
},
[expandedId, showToast, t.sessions.sessionDeleted, t.sessions.failedToDelete],
),
});
const pendingSession = sessionDelete.pendingId
? sessions.find((s) => s.id === sessionDelete.pendingId)
: null;
// Build snippet map from search results (session_id → snippet)
const snippetMap = new Map<string, string>();
@ -448,6 +544,36 @@ export default function SessionsPage() {
? sessions.filter((s) => snippetMap.has(s.id))
: sessions;
const platformEntries = status
? Object.entries(status.gateway_platforms ?? {})
: [];
const recentSessions = overviewSessions
.filter((s) => !s.is_active)
.slice(0, 5);
const alerts: { message: string; detail?: string }[] = [];
if (status) {
if (status.gateway_state === "startup_failed") {
alerts.push({
message: t.status.gatewayFailedToStart,
detail: status.gateway_exit_reason ?? undefined,
});
}
const failedPlatformEntries = platformEntries.filter(
([, info]) => info.state === "fatal" || info.state === "disconnected",
);
for (const [name, info] of failedPlatformEntries) {
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,
});
}
}
if (loading) {
return (
<div className="flex items-center justify-center py-24">
@ -458,38 +584,159 @@ export default function SessionsPage() {
return (
<div className="flex flex-col gap-4">
{/* Header outside card for lighter feel */}
<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" />
<H2 variant="sm">{t.sessions.title}</H2>
<Badge variant="secondary" className="text-xs">
{total}
</Badge>
<Toast toast={toast} />
<DeleteConfirmDialog
open={sessionDelete.isOpen}
onCancel={sessionDelete.cancel}
onConfirm={sessionDelete.confirm}
title={t.sessions.confirmDeleteTitle}
description={
pendingSession?.title && pendingSession.title !== "Untitled"
? `"${pendingSession.title}" — ${t.sessions.confirmDeleteMessage}`
: t.sessions.confirmDeleteMessage
}
loading={sessionDelete.isDeleting}
/>
{alerts.length > 0 && (
<div className="border border-destructive/30 bg-destructive/[0.06] p-4">
<div className="flex items-start gap-3">
<AlertTriangle className="h-5 w-5 text-destructive shrink-0 mt-0.5" />
<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>
{alert.detail && (
<p className="text-xs text-destructive/70 mt-0.5">
{alert.detail}
</p>
)}
</div>
))}
</div>
</div>
</div>
<div className="relative w-full sm:w-64">
{searching ? (
<div className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 animate-spin rounded-full border-[1.5px] border-primary border-t-transparent" />
) : (
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
)}
<Input
placeholder={t.sessions.searchPlaceholder}
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-8 pr-7 h-8 text-xs"
/>
{search && (
)}
{activeAction && (
<div className="border border-border bg-background-base/50">
<div className="flex items-center justify-between gap-2 border-b border-border px-3 py-2">
<div className="flex items-center gap-2 min-w-0">
{actionStatus?.running ? (
<Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin text-warning" />
) : actionStatus?.exit_code === 0 ? (
<CheckCircle2 className="h-3.5 w-3.5 shrink-0 text-success" />
) : actionStatus !== null ? (
<AlertTriangle className="h-3.5 w-3.5 shrink-0 text-destructive" />
) : (
<Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin text-muted-foreground" />
)}
<span className="text-xs font-mondwest tracking-[0.12em] truncate">
{activeAction === "restart"
? t.status.restartGateway
: t.status.updateHermes}
</span>
<Badge
variant={
actionStatus?.running
? "warning"
: actionStatus?.exit_code === 0
? "success"
: actionStatus
? "destructive"
: "outline"
}
className="text-[10px] shrink-0"
>
{actionStatus?.running
? t.status.running
: actionStatus?.exit_code === 0
? t.status.actionFinished
: actionStatus
? `${t.status.actionFailed} (${actionStatus.exit_code ?? "?"})`
: t.common.loading}
</Badge>
</div>
<button
type="button"
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground cursor-pointer"
onClick={() => setSearch("")}
onClick={dismissLog}
className="shrink-0 opacity-60 hover:opacity-100 cursor-pointer"
aria-label={t.common.close}
>
<X className="h-3 w-3" />
<X className="h-3.5 w-3.5" />
</button>
)}
</div>
<pre
ref={logScrollRef}
className="max-h-72 overflow-auto px-3 py-2 font-mono-ui text-[11px] leading-relaxed whitespace-pre-wrap break-all"
>
{actionStatus?.lines && actionStatus.lines.length > 0
? actionStatus.lines.join("\n")
: t.status.waitingForOutput}
</pre>
</div>
</div>
)}
{platformEntries.length > 0 && status && (
<PlatformsCard platforms={platformEntries} />
)}
{recentSessions.length > 0 && (
<Card>
<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>
</div>
</CardHeader>
<CardContent className="grid gap-3">
{recentSessions.map((s) => (
<div
key={s.id}
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="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>
{s.preview && (
<span className="text-xs text-muted-foreground/70 truncate">
{s.preview}
</span>
)}
</div>
<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>
</div>
))}
</CardContent>
</Card>
)}
{filtered.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
@ -516,7 +763,7 @@ export default function SessionsPage() {
onToggle={() =>
setExpandedId((prev) => (prev === s.id ? null : s.id))
}
onDelete={() => handleDelete(s.id)}
onDelete={() => sessionDelete.requestDelete(s.id)}
/>
))}
</div>

View file

@ -1,9 +1,8 @@
import { useEffect, useState, useMemo } from "react";
import { useEffect, useLayoutEffect, useState, useMemo } from "react";
import {
Package,
Search,
Wrench,
ChevronRight,
X,
Cpu,
Globe,
@ -14,8 +13,8 @@ import {
Blocks,
Code,
Zap,
Filter,
} from "lucide-react";
import { H2 } from "@nous-research/ui";
import { api } from "@/lib/api";
import type { SkillInfo, ToolsetInfo } from "@/lib/api";
import { useToast } from "@/hooks/useToast";
@ -25,6 +24,7 @@ import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { useI18n } from "@/i18n";
import { usePageHeader } from "@/contexts/usePageHeader";
/* ------------------------------------------------------------------ */
/* Types & helpers */
@ -98,6 +98,7 @@ export default function SkillsPage() {
const [togglingSkills, setTogglingSkills] = useState<Set<string>>(new Set());
const { toast, showToast } = useToast();
const { t } = useI18n();
const { setAfterTitle, setEnd } = usePageHeader();
useEffect(() => {
Promise.all([api.getSkills(), api.getToolsets()])
@ -182,6 +183,53 @@ export default function SkillsPage() {
const enabledCount = skills.filter((s) => s.enabled).length;
useLayoutEffect(() => {
if (loading) {
setAfterTitle(null);
setEnd(null);
return;
}
setAfterTitle(
<span className="whitespace-nowrap text-xs text-muted-foreground">
{t.skills.enabledOf
.replace("{enabled}", String(enabledCount))
.replace("{total}", String(skills.length))}
</span>,
);
setEnd(
<div className="relative w-full min-w-0 sm:max-w-xs">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
className="h-8 pl-8 pr-7 text-xs"
placeholder={t.common.search}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
{search && (
<button
type="button"
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
onClick={() => setSearch("")}
>
<X className="h-3 w-3" />
</button>
)}
</div>,
);
return () => {
setAfterTitle(null);
setEnd(null);
};
}, [
enabledCount,
loading,
search,
setAfterTitle,
setEnd,
skills.length,
t,
]);
const filteredToolsets = useMemo(() => {
return toolsets.filter(
(ts) =>
@ -205,122 +253,98 @@ export default function SkillsPage() {
<div className="flex flex-col gap-4">
<Toast toast={toast} />
{/* ═══════════════ Header ═══════════════ */}
<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" />
<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>
</div>
</div>
{/* ═══════════════ Filter panel + Content ═══════════════ */}
<div className="flex flex-col sm:flex-row gap-4">
{/* ---- Filter panel ---- */}
<aside
aria-label={t.skills.title}
className="sm:w-56 sm:shrink-0"
>
<div className="sm:sticky sm:top-4">
<div
className={`
flex flex-col
border border-border bg-muted/20
`}
>
{/* Filter heading */}
<div className="hidden sm:flex items-center gap-2 px-3 py-2 border-b border-border">
<Filter className="h-3 w-3 text-muted-foreground" />
<span className="font-mondwest text-[0.65rem] tracking-[0.12em] uppercase text-muted-foreground">
{t.skills.filters}
</span>
</div>
{/* ═══════════════ Sidebar + Content ═══════════════ */}
<div
className="flex flex-col sm:flex-row gap-4"
style={{ minHeight: "calc(100vh - 180px)" }}
>
{/* ---- Sidebar ---- */}
<div className="sm:w-52 sm:shrink-0">
<div className="sm:sticky sm:top-[72px] flex flex-col gap-1">
{/* Search */}
<div className="relative mb-2 hidden sm:block">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
className="pl-8 h-8 text-xs"
placeholder={t.common.search}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
{search && (
<button
type="button"
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
onClick={() => setSearch("")}
>
<X className="h-3 w-3" />
</button>
{/* View switch (Skills / Toolsets) */}
<div className="flex sm:flex-col gap-1 overflow-x-auto sm:overflow-x-visible scrollbar-none p-2">
<PanelItem
icon={Package}
label={`${t.skills.all} (${skills.length})`}
active={view === "skills" && !isSearching}
onClick={() => {
setView("skills");
setActiveCategory(null);
setSearch("");
}}
/>
<PanelItem
icon={Wrench}
label={`${t.skills.toolsets} (${toolsets.length})`}
active={view === "toolsets"}
onClick={() => {
setView("toolsets");
setSearch("");
}}
/>
</div>
{/* Category sub-filters (only for Skills view) */}
{view === "skills" && !isSearching && allCategories.length > 0 && (
<div className="hidden sm:flex flex-col border-t border-border">
<div className="px-3 pt-2 pb-1 font-mondwest text-[0.6rem] tracking-[0.12em] uppercase text-muted-foreground/70">
{t.skills.categories}
</div>
<div className="flex flex-col p-2 pt-1 gap-px max-h-[calc(100vh-340px)] overflow-y-auto">
{allCategories.map(({ key, name, count }) => {
const isActive = activeCategory === key;
return (
<button
key={key}
type="button"
onClick={() =>
setActiveCategory(isActive ? null : key)
}
className={`
group flex items-center gap-2 px-2 py-1
rounded-sm text-left text-[11px] cursor-pointer
transition-colors
${
isActive
? "bg-foreground/10 text-foreground"
: "text-muted-foreground hover:text-foreground hover:bg-foreground/5"
}
`}
>
<span className="flex-1 truncate">{name}</span>
<span
className={`text-[10px] tabular-nums ${
isActive
? "text-foreground/60"
: "text-muted-foreground/50"
}`}
>
{count}
</span>
</button>
);
})}
</div>
</div>
)}
</div>
{/* Top-level nav */}
<div className="flex sm:flex-col gap-1 overflow-x-auto sm:overflow-x-visible scrollbar-none pb-1 sm:pb-0">
<button
type="button"
onClick={() => {
setView("skills");
setActiveCategory(null);
setSearch("");
}}
className={`group flex items-center gap-2 px-2.5 py-1.5 text-left text-xs transition-colors cursor-pointer ${
view === "skills" && !isSearching
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:text-foreground hover:bg-muted/50"
}`}
>
<Package className="h-3.5 w-3.5 shrink-0" />
<span className="flex-1 truncate">
{t.skills.all} ({skills.length})
</span>
{view === "skills" && !isSearching && (
<ChevronRight className="h-3 w-3 text-primary/50 shrink-0" />
)}
</button>
{/* Skill categories (nested under All Skills) */}
{view === "skills" &&
!isSearching &&
allCategories.map(({ key, name, count }) => {
const isActive = activeCategory === key;
return (
<button
key={key}
type="button"
onClick={() =>
setActiveCategory(activeCategory === key ? null : key)
}
className={`group flex items-center gap-2 px-2.5 py-1 pl-7 text-left text-[11px] transition-colors cursor-pointer ${
isActive
? "text-primary font-medium"
: "text-muted-foreground hover:text-foreground hover:bg-muted/50"
}`}
>
<span className="flex-1 truncate">{name}</span>
<span
className={`text-[10px] tabular-nums ${isActive ? "text-primary/60" : "text-muted-foreground/50"}`}
>
{count}
</span>
</button>
);
})}
<button
type="button"
onClick={() => {
setView("toolsets");
setSearch("");
}}
className={`group flex items-center gap-2 px-2.5 py-1.5 text-left text-xs transition-colors cursor-pointer ${
view === "toolsets"
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:text-foreground hover:bg-muted/50"
}`}
>
<Wrench className="h-3.5 w-3.5 shrink-0" />
<span className="flex-1 truncate">
{t.skills.toolsets} ({toolsets.length})
</span>
{view === "toolsets" && (
<ChevronRight className="h-3 w-3 text-primary/50 shrink-0" />
)}
</button>
</div>
</div>
</div>
</aside>
{/* ---- Content ---- */}
<div className="flex-1 min-w-0">
@ -522,9 +546,39 @@ function SkillRow({
);
}
function PanelItem({ active, icon: Icon, label, onClick }: PanelItemProps) {
return (
<button
type="button"
onClick={onClick}
className={`
group flex items-center gap-2 px-2.5 py-1.5
font-mondwest text-[0.7rem] tracking-[0.08em] uppercase
rounded-sm text-left cursor-pointer whitespace-nowrap
transition-colors
${
active
? "bg-foreground/90 text-background"
: "text-muted-foreground hover:text-foreground hover:bg-foreground/10"
}
`}
>
<Icon className="h-3.5 w-3.5 shrink-0" />
<span className="flex-1 truncate">{label}</span>
</button>
);
}
interface PanelItemProps {
active: boolean;
icon: React.ComponentType<{ className?: string }>;
label: string;
onClick: () => void;
}
interface SkillRowProps {
noDescriptionLabel: string;
onToggle: () => void;
skill: SkillInfo;
toggling: boolean;
onToggle: () => void;
noDescriptionLabel: string;
}

View file

@ -1,614 +0,0 @@
import { useEffect, useRef, useState } from "react";
import {
Activity,
AlertTriangle,
CheckCircle2,
Clock,
Cpu,
Database,
Download,
Loader2,
Radio,
RotateCw,
Wifi,
WifiOff,
Wrench,
X,
} from "lucide-react";
import { Cell, Grid } from "@nous-research/ui";
import { api } from "@/lib/api";
import type {
ActionStatusResponse,
PlatformStatus,
SessionInfo,
StatusResponse,
} from "@/lib/api";
import { cn, timeAgo, isoTimeAgo } from "@/lib/utils";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Toast } from "@/components/Toast";
import { useI18n } from "@/i18n";
const ACTION_NAMES: Record<"restart" | "update", string> = {
restart: "gateway-restart",
update: "hermes-update",
};
export default function StatusPage() {
const [status, setStatus] = useState<StatusResponse | null>(null);
const [sessions, setSessions] = useState<SessionInfo[]>([]);
const [pendingAction, setPendingAction] = useState<
"restart" | "update" | null
>(null);
const [activeAction, setActiveAction] = useState<"restart" | "update" | null>(
null,
);
const [actionStatus, setActionStatus] = useState<ActionStatusResponse | null>(
null,
);
const [toast, setToast] = useState<ToastState | null>(null);
const logScrollRef = useRef<HTMLPreElement | null>(null);
const { t } = useI18n();
useEffect(() => {
const load = () => {
api
.getStatus()
.then(setStatus)
.catch(() => {});
api
.getSessions(50)
.then((resp) => setSessions(resp.sessions))
.catch(() => {});
};
load();
const interval = setInterval(load, 5000);
return () => clearInterval(interval);
}, []);
useEffect(() => {
if (!toast) return;
const timer = setTimeout(() => setToast(null), 4000);
return () => clearTimeout(timer);
}, [toast]);
useEffect(() => {
if (!activeAction) return;
const name = ACTION_NAMES[activeAction];
let cancelled = false;
const poll = async () => {
try {
const resp = await api.getActionStatus(name);
if (cancelled) return;
setActionStatus(resp);
if (!resp.running) {
const ok = resp.exit_code === 0;
setToast({
type: ok ? "success" : "error",
message: ok
? t.status.actionFinished
: `${t.status.actionFailed} (exit ${resp.exit_code ?? "?"})`,
});
return;
}
} catch {
// transient fetch error; keep polling
}
if (!cancelled) setTimeout(poll, 1500);
};
poll();
return () => {
cancelled = true;
};
}, [activeAction, t.status.actionFinished, t.status.actionFailed]);
useEffect(() => {
const el = logScrollRef.current;
if (el) el.scrollTop = el.scrollHeight;
}, [actionStatus?.lines]);
const runAction = async (action: "restart" | "update") => {
setPendingAction(action);
setActionStatus(null);
try {
if (action === "restart") {
await api.restartGateway();
} else {
await api.updateHermes();
}
setActiveAction(action);
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
setToast({
type: "error",
message: `${t.status.actionFailed}: ${detail}`,
});
} finally {
setPendingAction(null);
}
};
const dismissLog = () => {
setActiveAction(null);
setActionStatus(null);
};
if (!status) {
return (
<div className="flex items-center justify-center py-24">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
</div>
);
}
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 }
> = {
running: { badge: "success", label: t.status.running },
starting: { badge: "warning", label: t.status.starting },
startup_failed: { badge: "destructive", label: t.status.failed },
stopped: { badge: "outline", label: t.status.stopped },
};
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) 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;
if (info) return info;
return status!.gateway_running
? { badge: "success" as const, label: t.status.running }
: { badge: "outline" as const, label: t.common.off };
}
const gwBadge = gatewayBadge();
const items = [
{
icon: Cpu,
label: t.status.agent,
value: `v${status.version}`,
badgeText: t.common.live,
badgeVariant: "success" as const,
},
{
icon: Radio,
label: t.status.gateway,
value: gatewayValue(),
badgeText: gwBadge.label,
badgeVariant: gwBadge.badge,
},
{
icon: Activity,
label: t.status.activeSessions,
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",
},
];
const platforms = Object.entries(status.gateway_platforms ?? {});
const activeSessions = sessions.filter((s) => s.is_active);
const recentSessions = sessions.filter((s) => !s.is_active).slice(0, 5);
// Collect alerts that need attention
const alerts: { message: string; detail?: string }[] = [];
if (status.gateway_state === "startup_failed") {
alerts.push({
message: t.status.gatewayFailedToStart,
detail: status.gateway_exit_reason ?? undefined,
});
}
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;
alerts.push({
message: `${name.charAt(0).toUpperCase() + name.slice(1)} ${stateLabel}`,
detail: info.error_message ?? undefined,
});
}
return (
<div className="flex flex-col gap-6">
<Toast toast={toast} />
{alerts.length > 0 && (
<div className="border border-destructive/30 bg-destructive/[0.06] p-4">
<div className="flex items-start gap-3">
<AlertTriangle className="h-5 w-5 text-destructive shrink-0 mt-0.5" />
<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>
{alert.detail && (
<p className="text-xs text-destructive/70 mt-0.5">
{alert.detail}
</p>
)}
</div>
))}
</div>
</div>
</div>
)}
<Grid className="border-b md:!grid-cols-2 lg:!grid-cols-4">
{items.map(({ icon: Icon, label, value, badgeText, badgeVariant }) => (
<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" />
</div>
<div
className="truncate text-2xl font-bold font-mondwest"
title={value}
>
{value}
</div>
{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>
))}
<Cell className="flex min-w-0 flex-col gap-2 overflow-hidden">
<div className="flex items-center justify-between">
<CardTitle className="text-sm font-medium">
{t.status.actions}
</CardTitle>
<Wrench className="h-4 w-4 text-muted-foreground" />
</div>
<div className="flex gap-4">
<Button
variant="outline"
size="sm"
onClick={() => runAction("restart")}
disabled={
pendingAction !== null ||
(activeAction !== null && actionStatus?.running !== false)
}
className="flex-1 min-w-0"
>
<RotateCw
className={cn(
"h-3.5 w-3.5",
(pendingAction === "restart" ||
(activeAction === "restart" && actionStatus?.running)) &&
"animate-spin",
)}
/>
{activeAction === "restart" && actionStatus?.running
? t.status.restartingGateway
: t.status.restartGateway}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => runAction("update")}
disabled={
pendingAction !== null ||
(activeAction !== null && actionStatus?.running !== false)
}
className="flex-1 min-w-0"
>
<Download
className={cn(
"h-3.5 w-3.5",
(pendingAction === "update" ||
(activeAction === "update" && actionStatus?.running)) &&
"animate-pulse",
)}
/>
{activeAction === "update" && actionStatus?.running
? t.status.updatingHermes
: t.status.updateHermes}
</Button>
</div>
</Cell>
</Grid>
{activeAction && (
<div className="border border-border bg-background-base/50">
<div className="flex items-center justify-between gap-2 border-b border-border px-3 py-2">
<div className="flex items-center gap-2 min-w-0">
{actionStatus?.running ? (
<Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin text-warning" />
) : actionStatus?.exit_code === 0 ? (
<CheckCircle2 className="h-3.5 w-3.5 shrink-0 text-success" />
) : actionStatus !== null ? (
<AlertTriangle className="h-3.5 w-3.5 shrink-0 text-destructive" />
) : (
<Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin text-muted-foreground" />
)}
<span className="text-xs font-mondwest tracking-[0.12em] truncate">
{activeAction === "restart"
? t.status.restartGateway
: t.status.updateHermes}
</span>
<Badge
variant={
actionStatus?.running
? "warning"
: actionStatus?.exit_code === 0
? "success"
: actionStatus
? "destructive"
: "outline"
}
className="text-[10px] shrink-0"
>
{actionStatus?.running
? t.status.running
: actionStatus?.exit_code === 0
? t.status.actionFinished
: actionStatus
? `${t.status.actionFailed} (${actionStatus.exit_code ?? "?"})`
: t.common.loading}
</Badge>
</div>
<button
type="button"
onClick={dismissLog}
className="shrink-0 opacity-60 hover:opacity-100 cursor-pointer"
aria-label={t.common.close}
>
<X className="h-3.5 w-3.5" />
</button>
</div>
<pre
ref={logScrollRef}
className="max-h-72 overflow-auto px-3 py-2 font-mono-ui text-[11px] leading-relaxed whitespace-pre-wrap break-all"
>
{actionStatus?.lines && actionStatus.lines.length > 0
? actionStatus.lines.join("\n")
: t.status.waitingForOutput}
</pre>
</div>
)}
{platforms.length > 0 && (
<PlatformsCard
platforms={platforms}
platformStateBadge={PLATFORM_STATE_BADGE}
/>
)}
{activeSessions.length > 0 && (
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<Activity className="h-5 w-5 text-success" />
<CardTitle className="text-base">
{t.status.activeSessions}
</CardTitle>
</div>
</CardHeader>
<CardContent className="grid gap-3">
{activeSessions.map((s) => (
<div
key={s.id}
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">
<div className="flex items-center gap-2">
<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" />
{t.common.live}
</Badge>
</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>
</div>
</div>
))}
</CardContent>
</Card>
)}
{recentSessions.length > 0 && (
<Card>
<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>
</div>
</CardHeader>
<CardContent className="grid gap-3">
{recentSessions.map((s) => (
<div
key={s.id}
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="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>
{s.preview && (
<span className="text-xs text-muted-foreground/70 truncate">
{s.preview}
</span>
)}
</div>
<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>
</div>
))}
</CardContent>
</Card>
)}
</div>
);
}
function PlatformsCard({ platforms, platformStateBadge }: PlatformsCardProps) {
const { t } = useI18n();
return (
<Card>
<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>
</div>
</CardHeader>
<CardContent className="grid gap-3">
{platforms.map(([name, info]) => {
const display = platformStateBadge[info.state] ?? {
variant: "outline" as const,
label: info.state,
};
const IconComponent =
info.state === "connected"
? Wifi
: info.state === "fatal"
? AlertTriangle
: WifiOff;
return (
<div
key={name}
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"
}`}
/>
<div className="flex flex-col gap-0.5 min-w-0">
<span className="text-sm font-medium capitalize truncate">
{name}
</span>
{info.error_message && (
<span className="text-xs text-destructive">
{info.error_message}
</span>
)}
{info.updated_at && (
<span className="text-xs text-muted-foreground">
{t.status.lastUpdate}: {isoTimeAgo(info.updated_at)}
</span>
)}
</div>
</div>
<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" />
)}
{display.label}
</Badge>
</div>
);
})}
</CardContent>
</Card>
);
}
interface ToastState {
message: string;
type: "success" | "error";
}
interface PlatformsCardProps {
platforms: [string, PlatformStatus][];
platformStateBadge: Record<
string,
{ variant: "success" | "warning" | "destructive"; label: string }
>;
}