mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-28 01:21:43 +00:00
feat: add sidebar
This commit is contained in:
parent
7db2703b33
commit
e5d2815b41
41 changed files with 2469 additions and 1391 deletions
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
>;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue