mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-28 01:21:43 +00:00
feat: web UI dashboard for managing Hermes Agent (#8756)
* feat: web UI dashboard for managing Hermes Agent (salvage of #8204/#7621) Adds an embedded web UI dashboard accessible via `hermes web`: - Status page: agent version, active sessions, gateway status, connected platforms - Config editor: schema-driven form with tabbed categories, import/export, reset - API Keys page: set, clear, and view redacted values with category grouping - Sessions, Skills, Cron, Logs, and Analytics pages Backend: - hermes_cli/web_server.py: FastAPI server with REST endpoints - hermes_cli/config.py: reload_env() utility for hot-reloading .env - hermes_cli/main.py: `hermes web` subcommand (--port, --host, --no-open) - cli.py / commands.py: /reload slash command for .env hot-reload - pyproject.toml: [web] optional dependency extra (fastapi + uvicorn) - Both update paths (git + zip) auto-build web frontend when npm available Frontend: - Vite + React + TypeScript + Tailwind v4 SPA in web/ - shadcn/ui-style components, Nous design language - Auto-refresh status page, toast notifications, masked password inputs Security: - Path traversal guard (resolve().is_relative_to()) on SPA file serving - CORS localhost-only via allow_origin_regex - Generic error messages (no internal leak), SessionDB handles closed properly Tests: 47 tests covering reload_env, redact_key, API endpoints, schema generation, path traversal, category merging, internal key stripping, and full config round-trip. Original work by @austinpickett (PR #1813), salvaged by @kshitijk4poor (PR #7621 → #8204), re-salvaged onto current main with stale-branch regressions removed. * fix(web): clean up status page cards, always rebuild on `hermes web` - Remove config version migration alert banner from status page - Remove config version card (internal noise, not surfaced in TUI) - Reorder status cards: Agent → Gateway → Active Sessions (3-col grid) - `hermes web` now always rebuilds from source before serving, preventing stale web_dist when editing frontend files * feat(web): full-text search across session messages - Add GET /api/sessions/search endpoint backed by FTS5 - Auto-append prefix wildcards so partial words match (e.g. 'nimb' → 'nimby') - Debounced search (300ms) with spinner in the search icon slot - Search results show FTS5 snippets with highlighted match delimiters - Expanding a search hit auto-scrolls to the first matching message - Matching messages get a warning ring + 'match' badge - Inline term highlighting within Markdown (text, bold, italic, headings, lists) - Clear button (x) on search input for quick reset --------- Co-authored-by: emozilla <emozilla@nousresearch.com>
This commit is contained in:
parent
c052cf0eea
commit
e2a9b5369f
55 changed files with 10187 additions and 3 deletions
370
web/src/pages/AnalyticsPage.tsx
Normal file
370
web/src/pages/AnalyticsPage.tsx
Normal file
|
|
@ -0,0 +1,370 @@
|
|||
import { useEffect, useState, useCallback } from "react";
|
||||
import {
|
||||
BarChart3,
|
||||
Coins,
|
||||
Cpu,
|
||||
Database,
|
||||
Hash,
|
||||
TrendingUp,
|
||||
} from "lucide-react";
|
||||
import { api } from "@/lib/api";
|
||||
import type { AnalyticsResponse, AnalyticsDailyEntry, AnalyticsModelEntry } from "@/lib/api";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
const PERIODS = [
|
||||
{ label: "7d", days: 7 },
|
||||
{ label: "30d", days: 30 },
|
||||
{ label: "90d", days: 90 },
|
||||
] as const;
|
||||
|
||||
const CHART_HEIGHT_PX = 160;
|
||||
|
||||
function formatTokens(n: number): string {
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
||||
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
|
||||
return String(n);
|
||||
}
|
||||
|
||||
function formatCost(n: number): string {
|
||||
if (n < 0.01) return `$${n.toFixed(4)}`;
|
||||
return `$${n.toFixed(2)}`;
|
||||
}
|
||||
|
||||
/** Pick the best cost value: actual > estimated > 0 */
|
||||
function bestCost(entry: { estimated_cost: number; actual_cost?: number }): number {
|
||||
if (entry.actual_cost && entry.actual_cost > 0) return entry.actual_cost;
|
||||
return entry.estimated_cost;
|
||||
}
|
||||
|
||||
function formatDate(day: string): string {
|
||||
try {
|
||||
const d = new Date(day + "T00:00:00");
|
||||
return d.toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
||||
} catch {
|
||||
return day;
|
||||
}
|
||||
}
|
||||
|
||||
function SummaryCard({
|
||||
icon: Icon,
|
||||
label,
|
||||
value,
|
||||
sub,
|
||||
}: {
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
label: string;
|
||||
value: string;
|
||||
sub?: string;
|
||||
}) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-medium">{label}</CardTitle>
|
||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{value}</div>
|
||||
{sub && <p className="text-xs text-muted-foreground mt-1">{sub}</p>}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function TokenBarChart({ daily }: { daily: AnalyticsDailyEntry[] }) {
|
||||
if (daily.length === 0) return null;
|
||||
|
||||
const maxTokens = Math.max(...daily.map((d) => d.input_tokens + d.output_tokens), 1);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<BarChart3 className="h-5 w-5 text-muted-foreground" />
|
||||
<CardTitle className="text-base">Daily Token Usage</CardTitle>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="h-2.5 w-2.5 rounded-sm bg-[#ffe6cb]" />
|
||||
Input
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="h-2.5 w-2.5 rounded-sm bg-emerald-500" />
|
||||
Output
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-end gap-[2px]" style={{ height: CHART_HEIGHT_PX }}>
|
||||
{daily.map((d) => {
|
||||
const total = d.input_tokens + d.output_tokens;
|
||||
const inputH = Math.round((d.input_tokens / maxTokens) * CHART_HEIGHT_PX);
|
||||
const outputH = Math.round((d.output_tokens / maxTokens) * CHART_HEIGHT_PX);
|
||||
const cacheReadPct = d.cache_read_tokens > 0
|
||||
? Math.round((d.cache_read_tokens / (d.input_tokens + d.cache_read_tokens)) * 100)
|
||||
: 0;
|
||||
return (
|
||||
<div
|
||||
key={d.day}
|
||||
className="flex-1 min-w-0 group relative flex flex-col justify-end"
|
||||
style={{ height: CHART_HEIGHT_PX }}
|
||||
>
|
||||
{/* Tooltip */}
|
||||
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 hidden group-hover:block z-10 pointer-events-none">
|
||||
<div className="rounded-md bg-card border border-border px-2.5 py-1.5 text-[10px] text-foreground shadow-lg whitespace-nowrap">
|
||||
<div className="font-medium">{formatDate(d.day)}</div>
|
||||
<div>Input: {formatTokens(d.input_tokens)}</div>
|
||||
<div>Output: {formatTokens(d.output_tokens)}</div>
|
||||
{cacheReadPct > 0 && <div>Cache hit: {cacheReadPct}%</div>}
|
||||
<div>Total: {formatTokens(total)}</div>
|
||||
{bestCost(d) > 0 && <div>Cost: {formatCost(bestCost(d))}</div>}
|
||||
</div>
|
||||
</div>
|
||||
{/* Input bar */}
|
||||
<div
|
||||
className="w-full bg-[#ffe6cb]/70"
|
||||
style={{ height: Math.max(inputH, total > 0 ? 1 : 0) }}
|
||||
/>
|
||||
{/* Output bar */}
|
||||
<div
|
||||
className="w-full bg-emerald-500/70"
|
||||
style={{ height: Math.max(outputH, d.output_tokens > 0 ? 1 : 0) }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{/* X-axis labels */}
|
||||
<div className="flex justify-between mt-2 text-[10px] text-muted-foreground">
|
||||
<span>{daily.length > 0 ? formatDate(daily[0].day) : ""}</span>
|
||||
{daily.length > 2 && (
|
||||
<span>{formatDate(daily[Math.floor(daily.length / 2)].day)}</span>
|
||||
)}
|
||||
<span>{daily.length > 1 ? formatDate(daily[daily.length - 1].day) : ""}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function DailyTable({ daily }: { daily: AnalyticsDailyEntry[] }) {
|
||||
if (daily.length === 0) return null;
|
||||
|
||||
const sorted = [...daily].reverse();
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<TrendingUp className="h-5 w-5 text-muted-foreground" />
|
||||
<CardTitle className="text-base">Daily Breakdown</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-muted-foreground text-xs">
|
||||
<th className="text-left py-2 pr-4 font-medium">Date</th>
|
||||
<th className="text-right py-2 px-4 font-medium">Sessions</th>
|
||||
<th className="text-right py-2 px-4 font-medium">Input</th>
|
||||
<th className="text-right py-2 px-4 font-medium">Output</th>
|
||||
<th className="text-right py-2 px-4 font-medium">Cache Hit</th>
|
||||
<th className="text-right py-2 pl-4 font-medium">Cost</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sorted.map((d) => {
|
||||
const cost = bestCost(d);
|
||||
const cacheHitPct = d.cache_read_tokens > 0 && d.input_tokens > 0
|
||||
? Math.round((d.cache_read_tokens / d.input_tokens) * 100)
|
||||
: 0;
|
||||
return (
|
||||
<tr key={d.day} className="border-b border-border/50 hover:bg-secondary/20 transition-colors">
|
||||
<td className="py-2 pr-4 font-medium">{formatDate(d.day)}</td>
|
||||
<td className="text-right py-2 px-4 text-muted-foreground">{d.sessions}</td>
|
||||
<td className="text-right py-2 px-4">
|
||||
<span className="text-[#ffe6cb]">{formatTokens(d.input_tokens)}</span>
|
||||
</td>
|
||||
<td className="text-right py-2 px-4">
|
||||
<span className="text-emerald-400">{formatTokens(d.output_tokens)}</span>
|
||||
</td>
|
||||
<td className="text-right py-2 px-4 text-muted-foreground">
|
||||
{cacheHitPct > 0 ? `${cacheHitPct}%` : "—"}
|
||||
</td>
|
||||
<td className="text-right py-2 pl-4 text-muted-foreground">
|
||||
{cost > 0 ? formatCost(cost) : "—"}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function ModelTable({ models }: { models: AnalyticsModelEntry[] }) {
|
||||
if (models.length === 0) return null;
|
||||
|
||||
const sorted = [...models].sort(
|
||||
(a, b) => b.input_tokens + b.output_tokens - (a.input_tokens + a.output_tokens),
|
||||
);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Cpu className="h-5 w-5 text-muted-foreground" />
|
||||
<CardTitle className="text-base">Per-Model Breakdown</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-muted-foreground text-xs">
|
||||
<th className="text-left py-2 pr-4 font-medium">Model</th>
|
||||
<th className="text-right py-2 px-4 font-medium">Sessions</th>
|
||||
<th className="text-right py-2 px-4 font-medium">Tokens</th>
|
||||
<th className="text-right py-2 pl-4 font-medium">Cost</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sorted.map((m) => (
|
||||
<tr key={m.model} className="border-b border-border/50 hover:bg-secondary/20 transition-colors">
|
||||
<td className="py-2 pr-4">
|
||||
<span className="font-mono-ui text-xs">{m.model}</span>
|
||||
</td>
|
||||
<td className="text-right py-2 px-4 text-muted-foreground">{m.sessions}</td>
|
||||
<td className="text-right py-2 px-4">
|
||||
<span className="text-[#ffe6cb]">{formatTokens(m.input_tokens)}</span>
|
||||
{" / "}
|
||||
<span className="text-emerald-400">{formatTokens(m.output_tokens)}</span>
|
||||
</td>
|
||||
<td className="text-right py-2 pl-4 text-muted-foreground">
|
||||
{m.estimated_cost > 0 ? formatCost(m.estimated_cost) : "—"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AnalyticsPage() {
|
||||
const [days, setDays] = useState(30);
|
||||
const [data, setData] = useState<AnalyticsResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(() => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
api
|
||||
.getAnalytics(days)
|
||||
.then(setData)
|
||||
.catch((err) => setError(String(err)))
|
||||
.finally(() => setLoading(false));
|
||||
}, [days]);
|
||||
|
||||
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">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" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Card>
|
||||
<CardContent className="py-6">
|
||||
<p className="text-sm text-destructive text-center">{error}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{data && (
|
||||
<>
|
||||
{/* Summary cards — matches hermes's token model */}
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<SummaryCard
|
||||
icon={Hash}
|
||||
label="Total Tokens"
|
||||
value={formatTokens(data.totals.total_input + data.totals.total_output)}
|
||||
sub={`${formatTokens(data.totals.total_input)} in / ${formatTokens(data.totals.total_output)} out`}
|
||||
/>
|
||||
<SummaryCard
|
||||
icon={Database}
|
||||
label="Cache Hit"
|
||||
value={data.totals.total_cache_read > 0
|
||||
? `${Math.round((data.totals.total_cache_read / (data.totals.total_input + data.totals.total_cache_read)) * 100)}%`
|
||||
: "—"}
|
||||
sub={`${formatTokens(data.totals.total_cache_read)} tokens from cache`}
|
||||
/>
|
||||
<SummaryCard
|
||||
icon={Coins}
|
||||
label="Total Cost"
|
||||
value={formatCost(
|
||||
data.totals.total_actual_cost > 0
|
||||
? data.totals.total_actual_cost
|
||||
: data.totals.total_estimated_cost
|
||||
)}
|
||||
sub={data.totals.total_actual_cost > 0 ? "actual" : `estimated · last ${days}d`}
|
||||
/>
|
||||
<SummaryCard
|
||||
icon={BarChart3}
|
||||
label="Total Sessions"
|
||||
value={String(data.totals.total_sessions)}
|
||||
sub={`~${(data.totals.total_sessions / days).toFixed(1)}/day avg`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Bar chart */}
|
||||
<TokenBarChart daily={data.daily} />
|
||||
|
||||
{/* Tables */}
|
||||
<DailyTable daily={data.daily} />
|
||||
<ModelTable models={data.by_model} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{data && data.daily.length === 0 && data.by_model.length === 0 && (
|
||||
<Card>
|
||||
<CardContent className="py-12">
|
||||
<div className="flex flex-col items-center text-muted-foreground">
|
||||
<BarChart3 className="h-8 w-8 mb-3 opacity-40" />
|
||||
<p className="text-sm font-medium">No usage data for this period</p>
|
||||
<p className="text-xs mt-1 text-muted-foreground/60">Start a session to see analytics here</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
451
web/src/pages/ConfigPage.tsx
Normal file
451
web/src/pages/ConfigPage.tsx
Normal file
|
|
@ -0,0 +1,451 @@
|
|||
import { useEffect, useRef, useState, useMemo } from "react";
|
||||
import {
|
||||
Code,
|
||||
Download,
|
||||
FormInput,
|
||||
RotateCcw,
|
||||
Save,
|
||||
Search,
|
||||
Upload,
|
||||
X,
|
||||
ChevronRight,
|
||||
Settings2,
|
||||
FileText,
|
||||
} from "lucide-react";
|
||||
import { api } from "@/lib/api";
|
||||
import { getNestedValue, setNestedValue } from "@/lib/nested";
|
||||
import { useToast } from "@/hooks/useToast";
|
||||
import { Toast } from "@/components/Toast";
|
||||
import { AutoField } from "@/components/AutoField";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Helpers */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const CATEGORY_ICONS: Record<string, string> = {
|
||||
general: "⚙️",
|
||||
agent: "🤖",
|
||||
terminal: "💻",
|
||||
display: "🎨",
|
||||
delegation: "👥",
|
||||
memory: "🧠",
|
||||
compression: "📦",
|
||||
security: "🔒",
|
||||
browser: "🌐",
|
||||
voice: "🎙️",
|
||||
tts: "🔊",
|
||||
stt: "👂",
|
||||
logging: "📋",
|
||||
discord: "💬",
|
||||
auxiliary: "🔧",
|
||||
};
|
||||
|
||||
function prettyCategoryName(cat: string): string {
|
||||
if (cat === "tts") return "Text-to-Speech";
|
||||
if (cat === "stt") return "Speech-to-Text";
|
||||
return cat.charAt(0).toUpperCase() + cat.slice(1);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Component */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export default function ConfigPage() {
|
||||
const [config, setConfig] = useState<Record<string, unknown> | null>(null);
|
||||
const [schema, setSchema] = useState<Record<string, Record<string, unknown>> | null>(null);
|
||||
const [categoryOrder, setCategoryOrder] = useState<string[]>([]);
|
||||
const [defaults, setDefaults] = useState<Record<string, unknown> | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [yamlMode, setYamlMode] = useState(false);
|
||||
const [yamlText, setYamlText] = useState("");
|
||||
const [yamlLoading, setYamlLoading] = useState(false);
|
||||
const [yamlSaving, setYamlSaving] = useState(false);
|
||||
const [activeCategory, setActiveCategory] = useState<string>("");
|
||||
const { toast, showToast } = useToast();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
api.getConfig().then(setConfig).catch(() => {});
|
||||
api
|
||||
.getSchema()
|
||||
.then((resp) => {
|
||||
setSchema(resp.fields as Record<string, Record<string, unknown>>);
|
||||
setCategoryOrder(resp.category_order ?? []);
|
||||
})
|
||||
.catch(() => {});
|
||||
api.getDefaults().then(setDefaults).catch(() => {});
|
||||
}, []);
|
||||
|
||||
// Set active category when categories load
|
||||
useEffect(() => {
|
||||
if (categoryOrder.length > 0 && !activeCategory) {
|
||||
setActiveCategory(categoryOrder[0]);
|
||||
}
|
||||
}, [categoryOrder, activeCategory]);
|
||||
|
||||
// Load YAML when switching to YAML mode
|
||||
useEffect(() => {
|
||||
if (yamlMode) {
|
||||
setYamlLoading(true);
|
||||
api
|
||||
.getConfigRaw()
|
||||
.then((resp) => setYamlText(resp.yaml))
|
||||
.catch(() => showToast("Failed to load raw config", "error"))
|
||||
.finally(() => setYamlLoading(false));
|
||||
}
|
||||
}, [yamlMode]);
|
||||
|
||||
/* ---- Categories ---- */
|
||||
const categories = useMemo(() => {
|
||||
if (!schema) return [];
|
||||
const allCats = [...new Set(Object.values(schema).map((s) => String(s.category ?? "general")))];
|
||||
const ordered = categoryOrder.filter((c) => allCats.includes(c));
|
||||
const extra = allCats.filter((c) => !categoryOrder.includes(c)).sort();
|
||||
return [...ordered, ...extra];
|
||||
}, [schema, categoryOrder]);
|
||||
|
||||
/* ---- Category field counts ---- */
|
||||
const categoryCounts = useMemo(() => {
|
||||
if (!schema) return {};
|
||||
const counts: Record<string, number> = {};
|
||||
for (const s of Object.values(schema)) {
|
||||
const cat = String(s.category ?? "general");
|
||||
counts[cat] = (counts[cat] || 0) + 1;
|
||||
}
|
||||
return counts;
|
||||
}, [schema]);
|
||||
|
||||
/* ---- Search ---- */
|
||||
const isSearching = searchQuery.trim().length > 0;
|
||||
const lowerSearch = searchQuery.toLowerCase();
|
||||
|
||||
const searchMatchedFields = useMemo(() => {
|
||||
if (!isSearching || !schema) return [];
|
||||
return Object.entries(schema).filter(([key, s]) => {
|
||||
const label = key.split(".").pop() ?? key;
|
||||
const humanLabel = label.replace(/_/g, " ");
|
||||
return (
|
||||
key.toLowerCase().includes(lowerSearch) ||
|
||||
humanLabel.toLowerCase().includes(lowerSearch) ||
|
||||
String(s.category ?? "").toLowerCase().includes(lowerSearch) ||
|
||||
String(s.description ?? "").toLowerCase().includes(lowerSearch)
|
||||
);
|
||||
});
|
||||
}, [isSearching, lowerSearch, schema]);
|
||||
|
||||
/* ---- Active tab fields ---- */
|
||||
const activeFields = useMemo(() => {
|
||||
if (!schema || isSearching) return [];
|
||||
return Object.entries(schema).filter(
|
||||
([, s]) => String(s.category ?? "general") === activeCategory
|
||||
);
|
||||
}, [schema, activeCategory, isSearching]);
|
||||
|
||||
/* ---- Handlers ---- */
|
||||
const handleSave = async () => {
|
||||
if (!config) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await api.saveConfig(config);
|
||||
showToast("Configuration saved", "success");
|
||||
} catch (e) {
|
||||
showToast(`Failed to save: ${e}`, "error");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleYamlSave = async () => {
|
||||
setYamlSaving(true);
|
||||
try {
|
||||
await api.saveConfigRaw(yamlText);
|
||||
showToast("YAML config saved", "success");
|
||||
api.getConfig().then(setConfig).catch(() => {});
|
||||
} catch (e) {
|
||||
showToast(`Failed to save YAML: ${e}`, "error");
|
||||
} finally {
|
||||
setYamlSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
if (defaults) setConfig(structuredClone(defaults));
|
||||
};
|
||||
|
||||
const handleExport = () => {
|
||||
if (!config) return;
|
||||
const blob = new Blob([JSON.stringify(config, null, 2)], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "hermes-config.json";
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const handleImport = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
try {
|
||||
const imported = JSON.parse(reader.result as string);
|
||||
setConfig(imported);
|
||||
showToast("Config imported — review and save", "success");
|
||||
} catch {
|
||||
showToast("Invalid JSON file", "error");
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
};
|
||||
|
||||
/* ---- Loading ---- */
|
||||
if (!config || !schema) {
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---- Render field list (shared between search & normal) ---- */
|
||||
const renderFields = (fields: [string, Record<string, unknown>][], showCategory = false) => {
|
||||
let lastSection = "";
|
||||
let lastCat = "";
|
||||
return fields.map(([key, s]) => {
|
||||
const parts = key.split(".");
|
||||
const section = parts.length > 1 ? parts[0] : "";
|
||||
const cat = String(s.category ?? "general");
|
||||
const showCatBadge = showCategory && cat !== lastCat;
|
||||
const showSection = !showCategory && section && section !== lastSection && section !== activeCategory;
|
||||
lastSection = section;
|
||||
lastCat = cat;
|
||||
|
||||
return (
|
||||
<div key={key}>
|
||||
{showCatBadge && (
|
||||
<div className="flex items-center gap-2 pt-4 pb-2 first:pt-0">
|
||||
<span className="text-base">{CATEGORY_ICONS[cat] || "📄"}</span>
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{prettyCategoryName(cat)}
|
||||
</span>
|
||||
<div className="flex-1 border-t border-border" />
|
||||
</div>
|
||||
)}
|
||||
{showSection && (
|
||||
<div className="flex items-center gap-2 pt-4 pb-2 first:pt-0">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{section.replace(/_/g, " ")}
|
||||
</span>
|
||||
<div className="flex-1 border-t border-border" />
|
||||
</div>
|
||||
)}
|
||||
<div className="py-1">
|
||||
<AutoField
|
||||
schemaKey={key}
|
||||
schema={s}
|
||||
value={getNestedValue(config, key)}
|
||||
onChange={(v) => setConfig(setNestedValue(config, key, v))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<Toast toast={toast} />
|
||||
|
||||
{/* ═══════════════ Header Bar ═══════════════ */}
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Settings2 className="h-4 w-4 text-muted-foreground" />
|
||||
<code className="text-xs text-muted-foreground bg-muted/50 px-2 py-0.5 rounded">
|
||||
~/.hermes/config.yaml
|
||||
</code>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button variant="ghost" size="sm" onClick={handleExport} title="Export config as JSON" aria-label="Export config">
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => fileInputRef.current?.click()} title="Import config from JSON" aria-label="Import config">
|
||||
<Upload className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<input ref={fileInputRef} type="file" accept=".json" className="hidden" onChange={handleImport} />
|
||||
<Button variant="ghost" size="sm" onClick={handleReset} title="Reset to defaults" aria-label="Reset to defaults">
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
|
||||
<div className="w-px h-5 bg-border mx-1" />
|
||||
|
||||
<Button
|
||||
variant={yamlMode ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setYamlMode(!yamlMode)}
|
||||
className="gap-1.5"
|
||||
>
|
||||
{yamlMode ? (
|
||||
<>
|
||||
<FormInput className="h-3.5 w-3.5" />
|
||||
Form
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Code className="h-3.5 w-3.5" />
|
||||
YAML
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{yamlMode ? (
|
||||
<Button size="sm" onClick={handleYamlSave} disabled={yamlSaving} className="gap-1.5">
|
||||
<Save className="h-3.5 w-3.5" />
|
||||
{yamlSaving ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="sm" onClick={handleSave} disabled={saving} className="gap-1.5">
|
||||
<Save className="h-3.5 w-3.5" />
|
||||
{saving ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ═══════════════ YAML Mode ═══════════════ */}
|
||||
{yamlMode ? (
|
||||
<Card>
|
||||
<CardHeader className="py-3 px-4">
|
||||
<CardTitle className="text-sm flex items-center gap-2">
|
||||
<FileText className="h-4 w-4" />
|
||||
Raw YAML Configuration
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
{yamlLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="h-5 w-5 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||
</div>
|
||||
) : (
|
||||
<textarea
|
||||
className="flex min-h-[600px] w-full bg-transparent px-4 py-3 text-sm font-mono leading-relaxed placeholder:text-muted-foreground focus-visible:outline-none border-t border-border"
|
||||
value={yamlText}
|
||||
onChange={(e) => setYamlText(e.target.value)}
|
||||
spellCheck={false}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
/* ═══════════════ Form Mode ═══════════════ */
|
||||
<div className="flex gap-4" style={{ minHeight: "calc(100vh - 180px)" }}>
|
||||
{/* ---- Sidebar ---- */}
|
||||
<div className="w-52 shrink-0">
|
||||
<div className="sticky top-[72px] flex flex-col gap-1">
|
||||
{/* Search */}
|
||||
<div className="relative mb-2">
|
||||
<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="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>
|
||||
|
||||
{/* Category nav */}
|
||||
{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 rounded-md 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"
|
||||
}`}
|
||||
>
|
||||
<span className="text-sm leading-none">{CATEGORY_ICONS[cat] || "📄"}</span>
|
||||
<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>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ---- Content ---- */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{isSearching ? (
|
||||
/* Search results */
|
||||
<Card>
|
||||
<CardHeader className="py-3 px-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-sm flex items-center gap-2">
|
||||
<Search className="h-4 w-4" />
|
||||
Search Results
|
||||
</CardTitle>
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
{searchMatchedFields.length} field{searchMatchedFields.length !== 1 ? "s" : ""}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-2 px-4 pb-4">
|
||||
{searchMatchedFields.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-8">
|
||||
No fields match "<span className="text-foreground">{searchQuery}</span>"
|
||||
</p>
|
||||
) : (
|
||||
renderFields(searchMatchedFields, true)
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
/* Active category */
|
||||
<Card>
|
||||
<CardHeader className="py-3 px-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-sm flex items-center gap-2">
|
||||
<span className="text-base">{CATEGORY_ICONS[activeCategory] || "📄"}</span>
|
||||
{prettyCategoryName(activeCategory)}
|
||||
</CardTitle>
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
{activeFields.length} field{activeFields.length !== 1 ? "s" : ""}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-2 px-4 pb-4">
|
||||
{renderFields(activeFields)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
279
web/src/pages/CronPage.tsx
Normal file
279
web/src/pages/CronPage.tsx
Normal file
|
|
@ -0,0 +1,279 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { Clock, Pause, Play, Plus, Trash2, Zap } from "lucide-react";
|
||||
import { api } from "@/lib/api";
|
||||
import type { CronJob } from "@/lib/api";
|
||||
import { useToast } from "@/hooks/useToast";
|
||||
import { Toast } from "@/components/Toast";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select } from "@/components/ui/select";
|
||||
|
||||
function formatTime(iso?: string | null): string {
|
||||
if (!iso) return "—";
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleString();
|
||||
}
|
||||
|
||||
const STATUS_VARIANT: Record<string, "success" | "warning" | "destructive"> = {
|
||||
enabled: "success",
|
||||
paused: "warning",
|
||||
error: "destructive",
|
||||
};
|
||||
|
||||
export default function CronPage() {
|
||||
const [jobs, setJobs] = useState<CronJob[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const { toast, showToast } = useToast();
|
||||
|
||||
// New job form state
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [schedule, setSchedule] = useState("");
|
||||
const [name, setName] = useState("");
|
||||
const [deliver, setDeliver] = useState("local");
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
const loadJobs = () => {
|
||||
api
|
||||
.getCronJobs()
|
||||
.then(setJobs)
|
||||
.catch(() => showToast("Failed to load cron jobs", "error"))
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadJobs();
|
||||
}, []);
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!prompt.trim() || !schedule.trim()) {
|
||||
showToast("Prompt and schedule are required", "error");
|
||||
return;
|
||||
}
|
||||
setCreating(true);
|
||||
try {
|
||||
await api.createCronJob({
|
||||
prompt: prompt.trim(),
|
||||
schedule: schedule.trim(),
|
||||
name: name.trim() || undefined,
|
||||
deliver,
|
||||
});
|
||||
showToast("Cron job created", "success");
|
||||
setPrompt("");
|
||||
setSchedule("");
|
||||
setName("");
|
||||
setDeliver("local");
|
||||
loadJobs();
|
||||
} catch (e) {
|
||||
showToast(`Failed to create job: ${e}`, "error");
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePauseResume = async (job: CronJob) => {
|
||||
try {
|
||||
if (job.status === "paused") {
|
||||
await api.resumeCronJob(job.id);
|
||||
showToast(`Resumed "${job.name || job.prompt.slice(0, 30)}"`, "success");
|
||||
} else {
|
||||
await api.pauseCronJob(job.id);
|
||||
showToast(`Paused "${job.name || job.prompt.slice(0, 30)}"`, "success");
|
||||
}
|
||||
loadJobs();
|
||||
} catch (e) {
|
||||
showToast(`Action failed: ${e}`, "error");
|
||||
}
|
||||
};
|
||||
|
||||
const handleTrigger = async (job: CronJob) => {
|
||||
try {
|
||||
await api.triggerCronJob(job.id);
|
||||
showToast(`Triggered "${job.name || job.prompt.slice(0, 30)}"`, "success");
|
||||
loadJobs();
|
||||
} catch (e) {
|
||||
showToast(`Trigger failed: ${e}`, "error");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (job: CronJob) => {
|
||||
try {
|
||||
await api.deleteCronJob(job.id);
|
||||
showToast(`Deleted "${job.name || job.prompt.slice(0, 30)}"`, "success");
|
||||
loadJobs();
|
||||
} catch (e) {
|
||||
showToast(`Delete failed: ${e}`, "error");
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<Toast toast={toast} />
|
||||
|
||||
{/* Create new job form */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Plus className="h-4 w-4" />
|
||||
New Cron Job
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="cron-name">Name (optional)</Label>
|
||||
<Input
|
||||
id="cron-name"
|
||||
placeholder="e.g. Daily summary"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="cron-prompt">Prompt</Label>
|
||||
<textarea
|
||||
id="cron-prompt"
|
||||
className="flex min-h-[80px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
placeholder="What should the agent do on each run?"
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="cron-schedule">Schedule (cron expression)</Label>
|
||||
<Input
|
||||
id="cron-schedule"
|
||||
placeholder="0 9 * * *"
|
||||
value={schedule}
|
||||
onChange={(e) => setSchedule(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="cron-deliver">Deliver to</Label>
|
||||
<Select
|
||||
id="cron-deliver"
|
||||
value={deliver}
|
||||
onChange={(e) => setDeliver(e.target.value)}
|
||||
>
|
||||
<option value="local">Local</option>
|
||||
<option value="telegram">Telegram</option>
|
||||
<option value="discord">Discord</option>
|
||||
<option value="slack">Slack</option>
|
||||
<option value="email">Email</option>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-end">
|
||||
<Button onClick={handleCreate} disabled={creating} className="w-full">
|
||||
<Plus className="h-3 w-3" />
|
||||
{creating ? "Creating..." : "Create"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Jobs list */}
|
||||
<div className="flex flex-col gap-3">
|
||||
<h2 className="text-sm font-medium text-muted-foreground flex items-center gap-2">
|
||||
<Clock className="h-4 w-4" />
|
||||
Scheduled Jobs ({jobs.length})
|
||||
</h2>
|
||||
|
||||
{jobs.length === 0 && (
|
||||
<Card>
|
||||
<CardContent className="py-8 text-center text-sm text-muted-foreground">
|
||||
No cron jobs configured. Create one above.
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{jobs.map((job) => (
|
||||
<Card key={job.id}>
|
||||
<CardContent className="flex items-center gap-4 py-4">
|
||||
{/* Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-medium text-sm truncate">
|
||||
{job.name || job.prompt.slice(0, 60) + (job.prompt.length > 60 ? "..." : "")}
|
||||
</span>
|
||||
<Badge variant={STATUS_VARIANT[job.status] ?? "secondary"}>
|
||||
{job.status}
|
||||
</Badge>
|
||||
{job.deliver && job.deliver !== "local" && (
|
||||
<Badge variant="outline">{job.deliver}</Badge>
|
||||
)}
|
||||
</div>
|
||||
{job.name && (
|
||||
<p className="text-xs text-muted-foreground truncate mb-1">
|
||||
{job.prompt.slice(0, 100)}{job.prompt.length > 100 ? "..." : ""}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||
<span className="font-mono">{job.schedule}</span>
|
||||
<span>Last: {formatTime(job.last_run_at)}</span>
|
||||
<span>Next: {formatTime(job.next_run_at)}</span>
|
||||
</div>
|
||||
{job.error && (
|
||||
<p className="text-xs text-destructive mt-1">{job.error}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
title={job.status === "paused" ? "Resume" : "Pause"}
|
||||
aria-label={job.status === "paused" ? "Resume job" : "Pause job"}
|
||||
onClick={() => handlePauseResume(job)}
|
||||
>
|
||||
{job.status === "paused" ? (
|
||||
<Play className="h-4 w-4 text-success" />
|
||||
) : (
|
||||
<Pause className="h-4 w-4 text-warning" />
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
title="Trigger now"
|
||||
aria-label="Trigger job now"
|
||||
onClick={() => handleTrigger(job)}
|
||||
>
|
||||
<Zap className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
title="Delete"
|
||||
aria-label="Delete job"
|
||||
onClick={() => handleDelete(job)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
614
web/src/pages/EnvPage.tsx
Normal file
614
web/src/pages/EnvPage.tsx
Normal file
|
|
@ -0,0 +1,614 @@
|
|||
import { useEffect, useState, useMemo } from "react";
|
||||
import {
|
||||
Eye,
|
||||
EyeOff,
|
||||
ExternalLink,
|
||||
KeyRound,
|
||||
MessageSquare,
|
||||
Pencil,
|
||||
Save,
|
||||
Settings,
|
||||
Trash2,
|
||||
X,
|
||||
Zap,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
} from "lucide-react";
|
||||
import { api } from "@/lib/api";
|
||||
import type { EnvVarInfo } from "@/lib/api";
|
||||
import { useToast } from "@/hooks/useToast";
|
||||
import { Toast } from "@/components/Toast";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Provider grouping */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/** Map env-var key prefixes to a human-friendly provider name + ordering. */
|
||||
const PROVIDER_GROUPS: { prefix: string; name: string; priority: number }[] = [
|
||||
// Nous Portal first
|
||||
{ prefix: "NOUS_", name: "Nous Portal", priority: 0 },
|
||||
// Then alphabetical by display name
|
||||
{ prefix: "ANTHROPIC_", name: "Anthropic", priority: 1 },
|
||||
{ prefix: "DASHSCOPE_", name: "DashScope (Qwen)", priority: 2 },
|
||||
{ prefix: "HERMES_QWEN_", name: "DashScope (Qwen)", priority: 2 },
|
||||
{ prefix: "DEEPSEEK_", name: "DeepSeek", priority: 3 },
|
||||
{ prefix: "GOOGLE_", name: "Gemini", priority: 4 },
|
||||
{ prefix: "GEMINI_", name: "Gemini", priority: 4 },
|
||||
{ prefix: "GLM_", name: "GLM / Z.AI", priority: 5 },
|
||||
{ prefix: "ZAI_", name: "GLM / Z.AI", priority: 5 },
|
||||
{ prefix: "Z_AI_", name: "GLM / Z.AI", priority: 5 },
|
||||
{ prefix: "HF_", name: "Hugging Face", priority: 6 },
|
||||
{ prefix: "KIMI_", name: "Kimi / Moonshot", priority: 7 },
|
||||
{ prefix: "MINIMAX_CN_", name: "MiniMax (China)", priority: 9 },
|
||||
{ prefix: "MINIMAX_", name: "MiniMax", priority: 8 },
|
||||
{ prefix: "OPENCODE_GO_", name: "OpenCode Go", priority: 10 },
|
||||
{ prefix: "OPENCODE_ZEN_", name: "OpenCode Zen", priority: 11 },
|
||||
{ prefix: "OPENROUTER_", name: "OpenRouter", priority: 12 },
|
||||
{ prefix: "XIAOMI_", name: "Xiaomi MiMo", priority: 13 },
|
||||
];
|
||||
|
||||
function getProviderGroup(key: string): string {
|
||||
for (const g of PROVIDER_GROUPS) {
|
||||
if (key.startsWith(g.prefix)) return g.name;
|
||||
}
|
||||
return "Other";
|
||||
}
|
||||
|
||||
function getProviderPriority(groupName: string): number {
|
||||
const entry = PROVIDER_GROUPS.find((g) => g.name === groupName);
|
||||
return entry?.priority ?? 99;
|
||||
}
|
||||
|
||||
interface ProviderGroup {
|
||||
name: string;
|
||||
priority: number;
|
||||
entries: [string, EnvVarInfo][];
|
||||
hasAnySet: boolean;
|
||||
}
|
||||
|
||||
const CATEGORY_META: Record<string, { label: string; icon: typeof KeyRound }> = {
|
||||
provider: { label: "LLM Providers", icon: Zap },
|
||||
tool: { label: "Tool API Keys", icon: KeyRound },
|
||||
messaging: { label: "Messaging Platforms", icon: MessageSquare },
|
||||
setting: { label: "Agent Settings", icon: Settings },
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* EnvVarRow — single key edit row */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function EnvVarRow({
|
||||
varKey,
|
||||
info,
|
||||
edits,
|
||||
setEdits,
|
||||
revealed,
|
||||
saving,
|
||||
onSave,
|
||||
onClear,
|
||||
onReveal,
|
||||
onCancelEdit,
|
||||
compact = false,
|
||||
}: {
|
||||
varKey: string;
|
||||
info: EnvVarInfo;
|
||||
edits: Record<string, string>;
|
||||
setEdits: React.Dispatch<React.SetStateAction<Record<string, string>>>;
|
||||
revealed: Record<string, string>;
|
||||
saving: string | null;
|
||||
onSave: (key: string) => void;
|
||||
onClear: (key: string) => void;
|
||||
onReveal: (key: string) => void;
|
||||
onCancelEdit: (key: string) => void;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const isEditing = edits[varKey] !== undefined;
|
||||
const isRevealed = !!revealed[varKey];
|
||||
const displayValue = isRevealed ? revealed[varKey] : (info.redacted_value ?? "---");
|
||||
|
||||
// Compact inline row for unset, non-editing keys (used inside provider groups)
|
||||
if (compact && !info.is_set && !isEditing) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3 py-1.5 opacity-50 hover:opacity-100 transition-opacity">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="font-mono-ui text-[0.7rem] text-muted-foreground">{varKey}</span>
|
||||
<span className="text-[0.65rem] text-muted-foreground/60 truncate hidden sm:block">{info.description}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{info.url && (
|
||||
<a href={info.url} target="_blank" rel="noreferrer"
|
||||
className="inline-flex items-center gap-1 text-[0.65rem] text-primary hover:underline">
|
||||
Get key <ExternalLink className="h-2.5 w-2.5" />
|
||||
</a>
|
||||
)}
|
||||
<Button size="sm" variant="outline" className="h-6 text-[0.6rem] px-2"
|
||||
onClick={() => setEdits((prev) => ({ ...prev, [varKey]: "" }))}>
|
||||
<Pencil className="h-2.5 w-2.5" />
|
||||
Set
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Non-compact unset row
|
||||
if (!info.is_set && !isEditing) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3 border border-border/50 px-4 py-2.5 opacity-60 hover:opacity-100 transition-opacity">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<Label className="font-mono-ui text-[0.7rem] text-muted-foreground">{varKey}</Label>
|
||||
<span className="text-[0.65rem] text-muted-foreground/60 truncate hidden sm:block">{info.description}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{info.url && (
|
||||
<a href={info.url} target="_blank" rel="noreferrer"
|
||||
className="inline-flex items-center gap-1 text-[0.65rem] text-primary hover:underline">
|
||||
Get key <ExternalLink className="h-2.5 w-2.5" />
|
||||
</a>
|
||||
)}
|
||||
<Button size="sm" variant="outline" className="h-7 text-[0.6rem]"
|
||||
onClick={() => setEdits((prev) => ({ ...prev, [varKey]: "" }))}>
|
||||
<Pencil className="h-3 w-3" />
|
||||
Set
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Full expanded row for set keys or keys being edited
|
||||
return (
|
||||
<div className="grid gap-2 border border-border p-4">
|
||||
<div className="flex items-center justify-between gap-2 flex-wrap">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="font-mono-ui text-[0.7rem]">{varKey}</Label>
|
||||
<Badge variant={info.is_set ? "success" : "outline"}>
|
||||
{info.is_set ? "Set" : "Not set"}
|
||||
</Badge>
|
||||
</div>
|
||||
{info.url && (
|
||||
<a href={info.url} target="_blank" rel="noreferrer"
|
||||
className="inline-flex items-center gap-1 text-[0.65rem] text-primary hover:underline">
|
||||
Get key <ExternalLink className="h-2.5 w-2.5" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">{info.description}</p>
|
||||
|
||||
{info.tools.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{info.tools.map((tool) => (
|
||||
<Badge key={tool} variant="secondary" className="text-[0.6rem] py-0 px-1.5">{tool}</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isEditing && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`flex-1 border border-border px-3 py-2 font-mono-ui text-xs ${
|
||||
isRevealed ? "bg-background text-foreground select-all" : "bg-muted/30 text-muted-foreground"
|
||||
}`}>
|
||||
{info.is_set ? displayValue : "---"}
|
||||
</div>
|
||||
|
||||
{info.is_set && (
|
||||
<Button size="sm" variant="ghost" onClick={() => onReveal(varKey)}
|
||||
title={isRevealed ? "Hide value" : "Show real value"}
|
||||
aria-label={isRevealed ? `Hide ${varKey}` : `Reveal ${varKey}`}>
|
||||
{isRevealed
|
||||
? <EyeOff className="h-4 w-4" />
|
||||
: <Eye className="h-4 w-4" />}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button size="sm" variant="outline"
|
||||
onClick={() => setEdits((prev) => ({ ...prev, [varKey]: "" }))}>
|
||||
<Pencil className="h-3 w-3" />
|
||||
{info.is_set ? "Replace" : "Set"}
|
||||
</Button>
|
||||
|
||||
{info.is_set && (
|
||||
<Button size="sm" variant="ghost"
|
||||
className="text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
onClick={() => onClear(varKey)} disabled={saving === varKey}>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
{saving === varKey ? "..." : "Clear"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isEditing && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Input autoFocus type="text" value={edits[varKey]}
|
||||
onChange={(e) => setEdits((prev) => ({ ...prev, [varKey]: e.target.value }))}
|
||||
placeholder={info.is_set ? `Replace current value (${info.redacted_value ?? "---"})` : "Enter value..."}
|
||||
className="flex-1 font-mono-ui text-xs" />
|
||||
<Button size="sm" onClick={() => onSave(varKey)}
|
||||
disabled={saving === varKey || !edits[varKey]}>
|
||||
<Save className="h-3 w-3" />
|
||||
{saving === varKey ? "..." : "Save"}
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => onCancelEdit(varKey)}>
|
||||
<X className="h-3 w-3" /> Cancel
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* ProviderGroupCard — groups API key + base URL per provider */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function ProviderGroupCard({
|
||||
group,
|
||||
edits,
|
||||
setEdits,
|
||||
revealed,
|
||||
saving,
|
||||
onSave,
|
||||
onClear,
|
||||
onReveal,
|
||||
onCancelEdit,
|
||||
}: {
|
||||
group: ProviderGroup;
|
||||
edits: Record<string, string>;
|
||||
setEdits: React.Dispatch<React.SetStateAction<Record<string, string>>>;
|
||||
revealed: Record<string, string>;
|
||||
saving: string | null;
|
||||
onSave: (key: string) => void;
|
||||
onClear: (key: string) => void;
|
||||
onReveal: (key: string) => void;
|
||||
onCancelEdit: (key: string) => void;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
// Separate API keys from base URLs and other settings
|
||||
const apiKeys = group.entries.filter(([k]) => k.endsWith("_API_KEY") || k.endsWith("_TOKEN"));
|
||||
const baseUrls = group.entries.filter(([k]) => k.endsWith("_BASE_URL"));
|
||||
const other = group.entries.filter(([k]) => !k.endsWith("_API_KEY") && !k.endsWith("_TOKEN") && !k.endsWith("_BASE_URL"));
|
||||
const hasAnyConfigured = group.entries.some(([, info]) => info.is_set);
|
||||
const configuredCount = group.entries.filter(([, info]) => info.is_set).length;
|
||||
|
||||
// Get a representative URL for "Get key" link
|
||||
const keyUrl = apiKeys.find(([, info]) => info.url)?.[1]?.url ?? null;
|
||||
|
||||
return (
|
||||
<div className="border border-border">
|
||||
{/* Header — always visible */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="flex w-full items-center justify-between gap-3 px-4 py-3 cursor-pointer hover:bg-primary/5 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
{expanded ? <ChevronDown className="h-3.5 w-3.5 text-muted-foreground shrink-0" /> : <ChevronRight className="h-3.5 w-3.5 text-muted-foreground shrink-0" />}
|
||||
<span className="font-semibold text-sm tracking-wide">{group.name}</span>
|
||||
{hasAnyConfigured && (
|
||||
<Badge variant="success" className="text-[0.6rem]">
|
||||
{configuredCount} set
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{keyUrl && (
|
||||
<a href={keyUrl} target="_blank" rel="noreferrer"
|
||||
className="inline-flex items-center gap-1 text-[0.65rem] text-primary hover:underline"
|
||||
onClick={(e) => e.stopPropagation()}>
|
||||
Get key <ExternalLink className="h-2.5 w-2.5" />
|
||||
</a>
|
||||
)}
|
||||
<span className="text-[0.65rem] text-muted-foreground/60">
|
||||
{group.entries.length} key{group.entries.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Expanded content */}
|
||||
{expanded && (
|
||||
<div className="border-t border-border px-4 py-3 grid gap-2">
|
||||
{/* API keys first (most important) */}
|
||||
{apiKeys.map(([key, info]) => (
|
||||
<EnvVarRow
|
||||
key={key} varKey={key} info={info} compact
|
||||
edits={edits} setEdits={setEdits} revealed={revealed} saving={saving}
|
||||
onSave={onSave} onClear={onClear} onReveal={onReveal} onCancelEdit={onCancelEdit}
|
||||
/>
|
||||
))}
|
||||
{/* Base URLs (secondary) */}
|
||||
{baseUrls.map(([key, info]) => (
|
||||
<EnvVarRow
|
||||
key={key} varKey={key} info={info} compact
|
||||
edits={edits} setEdits={setEdits} revealed={revealed} saving={saving}
|
||||
onSave={onSave} onClear={onClear} onReveal={onReveal} onCancelEdit={onCancelEdit}
|
||||
/>
|
||||
))}
|
||||
{/* Anything else */}
|
||||
{other.map(([key, info]) => (
|
||||
<EnvVarRow
|
||||
key={key} varKey={key} info={info} compact
|
||||
edits={edits} setEdits={setEdits} revealed={revealed} saving={saving}
|
||||
onSave={onSave} onClear={onClear} onReveal={onReveal} onCancelEdit={onCancelEdit}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Main page */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export default function EnvPage() {
|
||||
const [vars, setVars] = useState<Record<string, EnvVarInfo> | null>(null);
|
||||
const [edits, setEdits] = useState<Record<string, string>>({});
|
||||
const [revealed, setRevealed] = useState<Record<string, string>>({});
|
||||
const [saving, setSaving] = useState<string | null>(null);
|
||||
const [showAdvanced, setShowAdvanced] = useState(true); // Show all providers by default
|
||||
const { toast, showToast } = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
api.getEnvVars().then(setVars).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const handleSave = async (key: string) => {
|
||||
const value = edits[key];
|
||||
if (!value) return;
|
||||
setSaving(key);
|
||||
try {
|
||||
await api.setEnvVar(key, value);
|
||||
setVars((prev) =>
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
[key]: { ...prev[key], is_set: true, redacted_value: value.slice(0, 4) + "..." + value.slice(-4) },
|
||||
}
|
||||
: prev,
|
||||
);
|
||||
setEdits((prev) => { const n = { ...prev }; delete n[key]; return n; });
|
||||
setRevealed((prev) => { const n = { ...prev }; delete n[key]; return n; });
|
||||
showToast(`${key} saved`, "success");
|
||||
} catch (e) {
|
||||
showToast(`Failed to save ${key}: ${e}`, "error");
|
||||
} finally {
|
||||
setSaving(null);
|
||||
}
|
||||
};
|
||||
|
||||
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} removed`, "success");
|
||||
} catch (e) {
|
||||
showToast(`Failed to remove ${key}: ${e}`, "error");
|
||||
} finally {
|
||||
setSaving(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReveal = async (key: string) => {
|
||||
if (revealed[key]) {
|
||||
setRevealed((prev) => { const n = { ...prev }; delete n[key]; return n; });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const resp = await api.revealEnvVar(key);
|
||||
setRevealed((prev) => ({ ...prev, [key]: resp.value }));
|
||||
} catch {
|
||||
showToast(`Failed to reveal ${key}`, "error");
|
||||
}
|
||||
};
|
||||
|
||||
const cancelEdit = (key: string) => {
|
||||
setEdits((prev) => { const n = { ...prev }; delete n[key]; return n; });
|
||||
};
|
||||
|
||||
/* ---- Build provider groups ---- */
|
||||
const { providerGroups, nonProviderGrouped } = useMemo(() => {
|
||||
if (!vars) return { providerGroups: [], nonProviderGrouped: [] };
|
||||
|
||||
const providerEntries = Object.entries(vars).filter(
|
||||
([, info]) => info.category === "provider" && (showAdvanced || !info.advanced),
|
||||
);
|
||||
|
||||
// Group by provider
|
||||
const groupMap = new Map<string, [string, EnvVarInfo][]>();
|
||||
for (const entry of providerEntries) {
|
||||
const groupName = getProviderGroup(entry[0]);
|
||||
if (!groupMap.has(groupName)) groupMap.set(groupName, []);
|
||||
groupMap.get(groupName)!.push(entry);
|
||||
}
|
||||
|
||||
const groups: ProviderGroup[] = Array.from(groupMap.entries())
|
||||
.map(([name, entries]) => ({
|
||||
name,
|
||||
priority: getProviderPriority(name),
|
||||
entries,
|
||||
hasAnySet: entries.some(([, info]) => info.is_set),
|
||||
}))
|
||||
.sort((a, b) => a.priority - b.priority);
|
||||
|
||||
// Non-provider categories
|
||||
const otherCategories = ["tool", "messaging", "setting"];
|
||||
const nonProvider = otherCategories.map((cat) => {
|
||||
const entries = Object.entries(vars).filter(
|
||||
([, info]) => info.category === cat && (showAdvanced || !info.advanced),
|
||||
);
|
||||
const setEntries = entries.filter(([, info]) => info.is_set);
|
||||
const unsetEntries = entries.filter(([, info]) => !info.is_set);
|
||||
return {
|
||||
...CATEGORY_META[cat],
|
||||
category: cat,
|
||||
setEntries,
|
||||
unsetEntries,
|
||||
totalEntries: entries.length,
|
||||
};
|
||||
});
|
||||
|
||||
return { providerGroups: groups, nonProviderGrouped: nonProvider };
|
||||
}, [vars, showAdvanced]);
|
||||
|
||||
if (!vars) {
|
||||
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 totalProviders = providerGroups.length;
|
||||
const configuredProviders = providerGroups.filter((g) => g.hasAnySet).length;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<Toast toast={toast} />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Manage API keys and secrets stored in <code>~/.hermes/.env</code>
|
||||
</p>
|
||||
<p className="text-[0.7rem] text-muted-foreground/70">
|
||||
Changes are saved to disk immediately. Active sessions pick up new keys automatically.
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={() => setShowAdvanced(!showAdvanced)}>
|
||||
{showAdvanced ? "Hide Advanced" : "Show Advanced"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* ═══════════════ LLM Providers (grouped) ═══════════════ */}
|
||||
<Card>
|
||||
<CardHeader className="sticky top-14 z-10 bg-card border-b border-border">
|
||||
<div className="flex items-center gap-2">
|
||||
<Zap className="h-5 w-5 text-muted-foreground" />
|
||||
<CardTitle className="text-base">LLM Providers</CardTitle>
|
||||
</div>
|
||||
<CardDescription>
|
||||
{configuredProviders} of {totalProviders} providers configured
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="grid gap-0 p-0">
|
||||
{providerGroups.map((group) => (
|
||||
<ProviderGroupCard
|
||||
key={group.name}
|
||||
group={group}
|
||||
edits={edits} setEdits={setEdits} revealed={revealed} saving={saving}
|
||||
onSave={handleSave} onClear={handleClear} onReveal={handleReveal} onCancelEdit={cancelEdit}
|
||||
/>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* ═══════════════ Other categories (flat) ═══════════════ */}
|
||||
{nonProviderGrouped.map(({ label, icon: Icon, setEntries, unsetEntries, totalEntries, category }) => {
|
||||
if (totalEntries === 0) return null;
|
||||
|
||||
return (
|
||||
<Card key={category}>
|
||||
<CardHeader className="sticky top-14 z-10 bg-card border-b border-border">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className="h-5 w-5 text-muted-foreground" />
|
||||
<CardTitle className="text-base">{label}</CardTitle>
|
||||
</div>
|
||||
<CardDescription>
|
||||
{setEntries.length} of {totalEntries} configured
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="grid gap-3 pt-4">
|
||||
{setEntries.map(([key, info]) => (
|
||||
<EnvVarRow
|
||||
key={key} varKey={key} info={info}
|
||||
edits={edits} setEdits={setEdits} revealed={revealed} saving={saving}
|
||||
onSave={handleSave} onClear={handleClear} onReveal={handleReveal} onCancelEdit={cancelEdit}
|
||||
/>
|
||||
))}
|
||||
|
||||
{unsetEntries.length > 0 && (
|
||||
<CollapsibleUnset
|
||||
category={category}
|
||||
unsetEntries={unsetEntries}
|
||||
edits={edits} setEdits={setEdits} revealed={revealed} saving={saving}
|
||||
onSave={handleSave} onClear={handleClear} onReveal={handleReveal} onCancelEdit={cancelEdit}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* CollapsibleUnset — for non-provider categories */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function CollapsibleUnset({
|
||||
category: _category,
|
||||
unsetEntries,
|
||||
edits,
|
||||
setEdits,
|
||||
revealed,
|
||||
saving,
|
||||
onSave,
|
||||
onClear,
|
||||
onReveal,
|
||||
onCancelEdit,
|
||||
}: {
|
||||
category: string;
|
||||
unsetEntries: [string, EnvVarInfo][];
|
||||
edits: Record<string, string>;
|
||||
setEdits: React.Dispatch<React.SetStateAction<Record<string, string>>>;
|
||||
revealed: Record<string, string>;
|
||||
saving: string | null;
|
||||
onSave: (key: string) => void;
|
||||
onClear: (key: string) => void;
|
||||
onReveal: (key: string) => void;
|
||||
onCancelEdit: (key: string) => void;
|
||||
}) {
|
||||
const [collapsed, setCollapsed] = useState(true);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 text-xs text-muted-foreground hover:text-foreground transition-colors cursor-pointer pt-1"
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
>
|
||||
{collapsed
|
||||
? <ChevronRight className="h-3 w-3" />
|
||||
: <ChevronDown className="h-3 w-3" />}
|
||||
<span>{unsetEntries.length} not configured</span>
|
||||
</button>
|
||||
|
||||
{!collapsed && unsetEntries.map(([key, info]) => (
|
||||
<EnvVarRow
|
||||
key={key} varKey={key} info={info}
|
||||
edits={edits} setEdits={setEdits} revealed={revealed} saving={saving}
|
||||
onSave={onSave} onClear={onClear} onReveal={onReveal} onCancelEdit={onCancelEdit}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
175
web/src/pages/LogsPage.tsx
Normal file
175
web/src/pages/LogsPage.tsx
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
import { useEffect, 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";
|
||||
|
||||
const FILES = ["agent", "errors", "gateway"] as const;
|
||||
const LEVELS = ["ALL", "DEBUG", "INFO", "WARNING", "ERROR"] as const;
|
||||
const COMPONENTS = ["all", "gateway", "agent", "tools", "cli", "cron"] as const;
|
||||
const LINE_COUNTS = [50, 100, 200, 500] as const;
|
||||
|
||||
function classifyLine(line: string): "error" | "warning" | "info" | "debug" {
|
||||
const upper = line.toUpperCase();
|
||||
if (upper.includes("ERROR") || upper.includes("CRITICAL") || upper.includes("FATAL")) return "error";
|
||||
if (upper.includes("WARNING") || upper.includes("WARN")) return "warning";
|
||||
if (upper.includes("DEBUG")) return "debug";
|
||||
return "info";
|
||||
}
|
||||
|
||||
const LINE_COLORS: Record<string, string> = {
|
||||
error: "text-destructive",
|
||||
warning: "text-warning",
|
||||
info: "text-foreground",
|
||||
debug: "text-muted-foreground/60",
|
||||
};
|
||||
|
||||
function FilterBar<T extends string>({
|
||||
label,
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
options: readonly T[];
|
||||
value: T;
|
||||
onChange: (v: T) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs text-muted-foreground font-medium w-20 shrink-0">{label}</span>
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{options.map((opt) => (
|
||||
<Button
|
||||
key={opt}
|
||||
variant={value === opt ? "default" : "outline"}
|
||||
size="sm"
|
||||
className="text-xs h-7 px-2.5"
|
||||
onClick={() => onChange(opt)}
|
||||
>
|
||||
{opt}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function LogsPage() {
|
||||
const [file, setFile] = useState<(typeof FILES)[number]>("agent");
|
||||
const [level, setLevel] = useState<(typeof LEVELS)[number]>("ALL");
|
||||
const [component, setComponent] = useState<(typeof COMPONENTS)[number]>("all");
|
||||
const [lineCount, setLineCount] = useState<(typeof LINE_COUNTS)[number]>(100);
|
||||
const [autoRefresh, setAutoRefresh] = useState(false);
|
||||
const [lines, setLines] = useState<string[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const fetchLogs = useCallback(() => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
api
|
||||
.getLogs({ file, lines: lineCount, level, component })
|
||||
.then((resp) => {
|
||||
setLines(resp.lines);
|
||||
// Auto-scroll to bottom
|
||||
setTimeout(() => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
}
|
||||
}, 50);
|
||||
})
|
||||
.catch((err) => setError(String(err)))
|
||||
.finally(() => setLoading(false));
|
||||
}, [file, lineCount, level, component]);
|
||||
|
||||
// Initial load + refetch on filter change
|
||||
useEffect(() => {
|
||||
fetchLogs();
|
||||
}, [fetchLogs]);
|
||||
|
||||
// Auto-refresh polling
|
||||
useEffect(() => {
|
||||
if (!autoRefresh) return;
|
||||
const interval = setInterval(fetchLogs, 5000);
|
||||
return () => clearInterval(interval);
|
||||
}, [autoRefresh, fetchLogs]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="h-5 w-5 text-muted-foreground" />
|
||||
<CardTitle className="text-base">Logs</CardTitle>
|
||||
{loading && (
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={autoRefresh}
|
||||
onCheckedChange={setAutoRefresh}
|
||||
/>
|
||||
<Label className="text-xs">Auto-refresh</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" />
|
||||
Live
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={fetchLogs} className="text-xs h-7">
|
||||
<RefreshCw className="h-3 w-3 mr-1" />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<div className="flex flex-col gap-3 mb-4">
|
||||
<FilterBar label="File" options={FILES} value={file} onChange={setFile} />
|
||||
<FilterBar label="Level" options={LEVELS} value={level} onChange={setLevel} />
|
||||
<FilterBar label="Component" options={COMPONENTS} value={component} onChange={setComponent} />
|
||||
<FilterBar
|
||||
label="Lines"
|
||||
options={LINE_COUNTS.map(String) as unknown as readonly string[]}
|
||||
value={String(lineCount)}
|
||||
onChange={(v) => setLineCount(Number(v) as (typeof LINE_COUNTS)[number])}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-md bg-destructive/10 border border-destructive/20 p-3 mb-4">
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="border border-border bg-background 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">No log lines found</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 rounded`}>
|
||||
{line}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
429
web/src/pages/SessionsPage.tsx
Normal file
429
web/src/pages/SessionsPage.tsx
Normal file
|
|
@ -0,0 +1,429 @@
|
|||
import { useEffect, useState, useCallback, useRef } from "react";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
MessageSquare,
|
||||
Search,
|
||||
Trash2,
|
||||
Clock,
|
||||
Terminal,
|
||||
Globe,
|
||||
MessageCircle,
|
||||
Hash,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { api } from "@/lib/api";
|
||||
import type { SessionInfo, SessionMessage, SessionSearchResult } from "@/lib/api";
|
||||
import { timeAgo } from "@/lib/utils";
|
||||
import { Markdown } from "@/components/Markdown";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
const ROLE_STYLES: Record<string, { bg: string; text: string; label: string }> = {
|
||||
user: { bg: "bg-primary/10", text: "text-primary", label: "User" },
|
||||
assistant: { bg: "bg-success/10", text: "text-success", label: "Assistant" },
|
||||
system: { bg: "bg-muted", text: "text-muted-foreground", label: "System" },
|
||||
tool: { bg: "bg-warning/10", text: "text-warning", label: "Tool" },
|
||||
};
|
||||
|
||||
const SOURCE_CONFIG: Record<string, { icon: typeof Terminal; color: string }> = {
|
||||
cli: { icon: Terminal, color: "text-primary" },
|
||||
telegram: { icon: MessageCircle, color: "text-[oklch(0.65_0.15_250)]" },
|
||||
discord: { icon: Hash, color: "text-[oklch(0.65_0.15_280)]" },
|
||||
slack: { icon: MessageSquare, color: "text-[oklch(0.7_0.15_155)]" },
|
||||
whatsapp: { icon: Globe, color: "text-success" },
|
||||
cron: { icon: Clock, color: "text-warning" },
|
||||
};
|
||||
|
||||
/** Render an FTS5 snippet with highlighted matches.
|
||||
* The backend wraps matches in >>> and <<< delimiters. */
|
||||
function SnippetHighlight({ snippet }: { snippet: string }) {
|
||||
const parts: React.ReactNode[] = [];
|
||||
const regex = />>>(.*?)<<</g;
|
||||
let last = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
let i = 0;
|
||||
while ((match = regex.exec(snippet)) !== null) {
|
||||
if (match.index > last) {
|
||||
parts.push(snippet.slice(last, match.index));
|
||||
}
|
||||
parts.push(
|
||||
<mark key={i++} className="bg-warning/30 text-warning rounded-sm px-0.5">
|
||||
{match[1]}
|
||||
</mark>
|
||||
);
|
||||
last = regex.lastIndex;
|
||||
}
|
||||
if (last < snippet.length) {
|
||||
parts.push(snippet.slice(last));
|
||||
}
|
||||
return (
|
||||
<p className="text-xs text-muted-foreground/80 truncate max-w-lg mt-0.5">
|
||||
{parts}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolCallBlock({ toolCall }: { toolCall: { id: string; function: { name: string; arguments: string } } }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
let args = toolCall.function.arguments;
|
||||
try {
|
||||
args = JSON.stringify(JSON.parse(args), null, 2);
|
||||
} catch {
|
||||
// keep as-is
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-2 rounded-md border border-warning/20 bg-warning/5">
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-xs text-warning cursor-pointer hover:bg-warning/10 transition-colors"
|
||||
onClick={() => setOpen(!open)}
|
||||
aria-label={`${open ? "Collapse" : "Expand"} tool call ${toolCall.function.name}`}
|
||||
>
|
||||
{open ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
|
||||
<span className="font-mono-ui font-medium">{toolCall.function.name}</span>
|
||||
<span className="text-warning/50 ml-auto">{toolCall.id}</span>
|
||||
</button>
|
||||
{open && (
|
||||
<pre className="border-t border-warning/20 px-3 py-2 text-xs text-warning/80 overflow-x-auto whitespace-pre-wrap font-mono">
|
||||
{args}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MessageBubble({ msg, highlight }: { msg: SessionMessage; highlight?: string }) {
|
||||
const style = ROLE_STYLES[msg.role] ?? ROLE_STYLES.system;
|
||||
const label = msg.tool_name ? `Tool: ${msg.tool_name}` : style.label;
|
||||
|
||||
// Check if any search term appears as a prefix of any word in content
|
||||
const isHit = (() => {
|
||||
if (!highlight || !msg.content) return false;
|
||||
const content = msg.content.toLowerCase();
|
||||
const terms = highlight.toLowerCase().split(/\s+/).filter(Boolean);
|
||||
return terms.some((term) => content.includes(term));
|
||||
})();
|
||||
|
||||
// Split search query into terms for inline highlighting
|
||||
const highlightTerms = isHit && highlight
|
||||
? highlight.split(/\s+/).filter(Boolean)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div className={`${style.bg} p-3 ${isHit ? "ring-1 ring-warning/40" : ""}`} data-search-hit={isHit || undefined}>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className={`text-xs font-semibold ${style.text}`}>{label}</span>
|
||||
{isHit && (
|
||||
<Badge variant="warning" className="text-[9px] py-0 px-1.5">match</Badge>
|
||||
)}
|
||||
{msg.timestamp && (
|
||||
<span className="text-[10px] text-muted-foreground">{timeAgo(msg.timestamp)}</span>
|
||||
)}
|
||||
</div>
|
||||
{msg.content && (
|
||||
msg.role === "system"
|
||||
? <div className="text-sm text-foreground whitespace-pre-wrap leading-relaxed">{msg.content}</div>
|
||||
: <Markdown content={msg.content} highlightTerms={highlightTerms} />
|
||||
)}
|
||||
{msg.tool_calls && msg.tool_calls.length > 0 && (
|
||||
<div className="mt-1">
|
||||
{msg.tool_calls.map((tc) => (
|
||||
<ToolCallBlock key={tc.id} toolCall={tc} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Message list with auto-scroll to first search hit. */
|
||||
function MessageList({ messages, highlight }: { messages: SessionMessage[]; highlight?: string }) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!highlight || !containerRef.current) return;
|
||||
// Scroll to first hit after render
|
||||
const timer = setTimeout(() => {
|
||||
const hit = containerRef.current?.querySelector("[data-search-hit]");
|
||||
if (hit) {
|
||||
hit.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
}
|
||||
}, 50);
|
||||
return () => clearTimeout(timer);
|
||||
}, [messages, highlight]);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="flex flex-col gap-3 max-h-[600px] overflow-y-auto pr-2">
|
||||
{messages.map((msg, i) => (
|
||||
<MessageBubble key={i} msg={msg} highlight={highlight} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SessionRow({
|
||||
session,
|
||||
snippet,
|
||||
searchQuery,
|
||||
isExpanded,
|
||||
onToggle,
|
||||
onDelete,
|
||||
}: {
|
||||
session: SessionInfo;
|
||||
snippet?: string;
|
||||
searchQuery?: string;
|
||||
isExpanded: boolean;
|
||||
onToggle: () => void;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
const [messages, setMessages] = useState<SessionMessage[] | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isExpanded && messages === null && !loading) {
|
||||
setLoading(true);
|
||||
api
|
||||
.getSessionMessages(session.id)
|
||||
.then((resp) => setMessages(resp.messages))
|
||||
.catch((err) => setError(String(err)))
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
}, [isExpanded, session.id, messages, loading]);
|
||||
|
||||
const sourceInfo = (session.source ? SOURCE_CONFIG[session.source] : null) ?? { icon: Globe, color: "text-muted-foreground" };
|
||||
const SourceIcon = sourceInfo.icon;
|
||||
const hasTitle = session.title && session.title !== "Untitled";
|
||||
|
||||
return (
|
||||
<div className={`border overflow-hidden transition-colors ${
|
||||
session.is_active
|
||||
? "border-success/30 bg-success/[0.03]"
|
||||
: "border-border"
|
||||
}`}>
|
||||
<div
|
||||
className="flex items-center justify-between p-3 cursor-pointer hover:bg-secondary/30 transition-colors"
|
||||
onClick={onToggle}
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||
<div className={`shrink-0 ${sourceInfo.color}`}>
|
||||
<SourceIcon className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-sm truncate pr-2 ${hasTitle ? "font-medium" : "text-muted-foreground italic"}`}>
|
||||
{hasTitle ? session.title : (session.preview ? session.preview.slice(0, 60) : "Untitled session")}
|
||||
</span>
|
||||
{session.is_active && (
|
||||
<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" />
|
||||
Live
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<span className="truncate max-w-[180px]">{(session.model ?? "unknown").split("/").pop()}</span>
|
||||
<span className="text-border">·</span>
|
||||
<span>{session.message_count} msgs</span>
|
||||
{session.tool_call_count > 0 && (
|
||||
<>
|
||||
<span className="text-border">·</span>
|
||||
<span>{session.tool_call_count} tools</span>
|
||||
</>
|
||||
)}
|
||||
<span className="text-border">·</span>
|
||||
<span>{timeAgo(session.last_active)}</span>
|
||||
</div>
|
||||
{snippet && (
|
||||
<SnippetHighlight snippet={snippet} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
{session.source ?? "local"}
|
||||
</Badge>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-muted-foreground hover:text-destructive"
|
||||
aria-label="Delete session"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="border-t border-border bg-background/50 p-4">
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="h-5 w-5 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<p className="text-sm text-destructive py-4 text-center">{error}</p>
|
||||
)}
|
||||
{messages && messages.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground py-4 text-center">No messages</p>
|
||||
)}
|
||||
{messages && messages.length > 0 && (
|
||||
<MessageList messages={messages} highlight={searchQuery} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SessionsPage() {
|
||||
const [sessions, setSessions] = useState<SessionInfo[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [search, setSearch] = useState("");
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
const [searchResults, setSearchResults] = useState<SessionSearchResult[] | null>(null);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>(null);
|
||||
|
||||
const loadSessions = useCallback(() => {
|
||||
api
|
||||
.getSessions()
|
||||
.then(setSessions)
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadSessions();
|
||||
}, [loadSessions]);
|
||||
|
||||
// Debounced FTS search
|
||||
useEffect(() => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
|
||||
if (!search.trim()) {
|
||||
setSearchResults(null);
|
||||
setSearching(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setSearching(true);
|
||||
debounceRef.current = setTimeout(() => {
|
||||
api
|
||||
.searchSessions(search.trim())
|
||||
.then((resp) => setSearchResults(resp.results))
|
||||
.catch(() => setSearchResults(null))
|
||||
.finally(() => setSearching(false));
|
||||
}, 300);
|
||||
|
||||
return () => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
};
|
||||
}, [search]);
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await api.deleteSession(id);
|
||||
setSessions((prev) => prev.filter((s) => s.id !== id));
|
||||
if (expandedId === id) setExpandedId(null);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
// Build snippet map from search results (session_id → snippet)
|
||||
const snippetMap = new Map<string, string>();
|
||||
if (searchResults) {
|
||||
for (const r of searchResults) {
|
||||
snippetMap.set(r.session_id, r.snippet);
|
||||
}
|
||||
}
|
||||
|
||||
// When searching, filter sessions to those with FTS matches;
|
||||
// when not searching, show all sessions
|
||||
const filtered = searchResults
|
||||
? sessions.filter((s) => snippetMap.has(s.id))
|
||||
: sessions;
|
||||
|
||||
if (loading) {
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Header outside card for lighter feel */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<MessageSquare className="h-5 w-5 text-muted-foreground" />
|
||||
<h1 className="text-base font-semibold">Sessions</h1>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{sessions.length}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="relative 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="Search message content..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-8 pr-7 h-8 text-xs"
|
||||
/>
|
||||
{search && (
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground cursor-pointer"
|
||||
onClick={() => setSearch("")}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
|
||||
<Clock className="h-8 w-8 mb-3 opacity-40" />
|
||||
<p className="text-sm font-medium">
|
||||
{search ? "No sessions match your search" : "No sessions yet"}
|
||||
</p>
|
||||
{!search && (
|
||||
<p className="text-xs mt-1 text-muted-foreground/60">Start a conversation to see it here</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{filtered.map((s) => (
|
||||
<SessionRow
|
||||
key={s.id}
|
||||
session={s}
|
||||
snippet={snippetMap.get(s.id)}
|
||||
searchQuery={search || undefined}
|
||||
isExpanded={expandedId === s.id}
|
||||
onToggle={() =>
|
||||
setExpandedId((prev) => (prev === s.id ? null : s.id))
|
||||
}
|
||||
onDelete={() => handleDelete(s.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
439
web/src/pages/SkillsPage.tsx
Normal file
439
web/src/pages/SkillsPage.tsx
Normal file
|
|
@ -0,0 +1,439 @@
|
|||
import { useEffect, useState, useMemo } from "react";
|
||||
import {
|
||||
Package,
|
||||
Search,
|
||||
Wrench,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Filter,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { api } from "@/lib/api";
|
||||
import type { SkillInfo, ToolsetInfo } from "@/lib/api";
|
||||
import { useToast } from "@/hooks/useToast";
|
||||
import { Toast } from "@/components/Toast";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Types & helpers */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
interface CategoryGroup {
|
||||
name: string; // display name
|
||||
key: string; // raw key (or "__none__")
|
||||
skills: SkillInfo[];
|
||||
enabledCount: number;
|
||||
}
|
||||
|
||||
const CATEGORY_LABELS: Record<string, string> = {
|
||||
mlops: "MLOps",
|
||||
"mlops/cloud": "MLOps / Cloud",
|
||||
"mlops/evaluation": "MLOps / Evaluation",
|
||||
"mlops/inference": "MLOps / Inference",
|
||||
"mlops/models": "MLOps / Models",
|
||||
"mlops/training": "MLOps / Training",
|
||||
"mlops/vector-databases": "MLOps / Vector DBs",
|
||||
mcp: "MCP",
|
||||
"red-teaming": "Red Teaming",
|
||||
ocr: "OCR",
|
||||
p5js: "p5.js",
|
||||
ai: "AI",
|
||||
ux: "UX",
|
||||
ui: "UI",
|
||||
};
|
||||
|
||||
function prettyCategory(raw: string | null | undefined): string {
|
||||
if (!raw) return "General";
|
||||
if (CATEGORY_LABELS[raw]) return CATEGORY_LABELS[raw];
|
||||
return raw
|
||||
.split(/[-_/]/)
|
||||
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Component */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export default function SkillsPage() {
|
||||
const [skills, setSkills] = useState<SkillInfo[]>([]);
|
||||
const [toolsets, setToolsets] = useState<ToolsetInfo[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [search, setSearch] = useState("");
|
||||
const [activeCategory, setActiveCategory] = useState<string | null>(null);
|
||||
const [togglingSkills, setTogglingSkills] = useState<Set<string>>(new Set());
|
||||
// Start collapsed by default
|
||||
const [collapsedCategories, setCollapsedCategories] = useState<Set<string> | "all">("all");
|
||||
const { toast, showToast } = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([api.getSkills(), api.getToolsets()])
|
||||
.then(([s, t]) => {
|
||||
setSkills(s);
|
||||
setToolsets(t);
|
||||
})
|
||||
.catch(() => showToast("Failed to load skills/toolsets", "error"))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
/* ---- Toggle skill ---- */
|
||||
const handleToggleSkill = async (skill: SkillInfo) => {
|
||||
setTogglingSkills((prev) => new Set(prev).add(skill.name));
|
||||
try {
|
||||
await api.toggleSkill(skill.name, !skill.enabled);
|
||||
setSkills((prev) =>
|
||||
prev.map((s) =>
|
||||
s.name === skill.name ? { ...s, enabled: !s.enabled } : s
|
||||
)
|
||||
);
|
||||
showToast(
|
||||
`${skill.name} ${skill.enabled ? "disabled" : "enabled"}`,
|
||||
"success"
|
||||
);
|
||||
} catch {
|
||||
showToast(`Failed to toggle ${skill.name}`, "error");
|
||||
} finally {
|
||||
setTogglingSkills((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(skill.name);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/* ---- Derived data ---- */
|
||||
const lowerSearch = search.toLowerCase();
|
||||
|
||||
const filteredSkills = useMemo(() => {
|
||||
return skills.filter((s) => {
|
||||
const matchesSearch =
|
||||
!search ||
|
||||
s.name.toLowerCase().includes(lowerSearch) ||
|
||||
s.description.toLowerCase().includes(lowerSearch) ||
|
||||
(s.category ?? "").toLowerCase().includes(lowerSearch);
|
||||
const matchesCategory =
|
||||
!activeCategory ||
|
||||
(activeCategory === "__none__" ? !s.category : s.category === activeCategory);
|
||||
return matchesSearch && matchesCategory;
|
||||
});
|
||||
}, [skills, search, lowerSearch, activeCategory]);
|
||||
|
||||
const categoryGroups: CategoryGroup[] = useMemo(() => {
|
||||
const map = new Map<string, SkillInfo[]>();
|
||||
for (const s of filteredSkills) {
|
||||
const key = s.category || "__none__";
|
||||
if (!map.has(key)) map.set(key, []);
|
||||
map.get(key)!.push(s);
|
||||
}
|
||||
// Sort: General first, then alphabetical
|
||||
const entries = [...map.entries()].sort((a, b) => {
|
||||
if (a[0] === "__none__") return -1;
|
||||
if (b[0] === "__none__") return 1;
|
||||
return a[0].localeCompare(b[0]);
|
||||
});
|
||||
return entries.map(([key, list]) => ({
|
||||
key,
|
||||
name: prettyCategory(key === "__none__" ? null : key),
|
||||
skills: list.sort((a, b) => a.name.localeCompare(b.name)),
|
||||
enabledCount: list.filter((s) => s.enabled).length,
|
||||
}));
|
||||
}, [filteredSkills]);
|
||||
|
||||
const allCategories = useMemo(() => {
|
||||
const cats = new Map<string, number>();
|
||||
for (const s of skills) {
|
||||
const key = s.category || "__none__";
|
||||
cats.set(key, (cats.get(key) || 0) + 1);
|
||||
}
|
||||
return [...cats.entries()]
|
||||
.sort((a, b) => {
|
||||
if (a[0] === "__none__") return -1;
|
||||
if (b[0] === "__none__") return 1;
|
||||
return a[0].localeCompare(b[0]);
|
||||
})
|
||||
.map(([key, count]) => ({ key, name: prettyCategory(key === "__none__" ? null : key), count }));
|
||||
}, [skills]);
|
||||
|
||||
const enabledCount = skills.filter((s) => s.enabled).length;
|
||||
|
||||
const filteredToolsets = useMemo(() => {
|
||||
return toolsets.filter(
|
||||
(t) =>
|
||||
!search ||
|
||||
t.name.toLowerCase().includes(lowerSearch) ||
|
||||
t.label.toLowerCase().includes(lowerSearch) ||
|
||||
t.description.toLowerCase().includes(lowerSearch)
|
||||
);
|
||||
}, [toolsets, search, lowerSearch]);
|
||||
|
||||
const isCollapsed = (key: string): boolean => {
|
||||
if (collapsedCategories === "all") return true;
|
||||
return collapsedCategories.has(key);
|
||||
};
|
||||
|
||||
const toggleCollapse = (key: string) => {
|
||||
setCollapsedCategories((prev) => {
|
||||
if (prev === "all") {
|
||||
// Switching from "all collapsed" → expand just this one
|
||||
const allKeys = new Set(categoryGroups.map((g) => g.key));
|
||||
allKeys.delete(key);
|
||||
return allKeys;
|
||||
}
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
/* ---- Loading ---- */
|
||||
if (loading) {
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<Toast toast={toast} />
|
||||
|
||||
{/* ═══════════════ Header + Search ═══════════════ */}
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Package className="h-5 w-5 text-muted-foreground" />
|
||||
<h1 className="text-base font-semibold">Skills</h1>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{enabledCount}/{skills.length} enabled
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ═══════════════ Search + Category Filter ═══════════════ */}
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
className="pl-9"
|
||||
placeholder="Search skills and toolsets..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
{search && (
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setSearch("")}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category pills */}
|
||||
{allCategories.length > 1 && (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Filter className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
||||
<button
|
||||
type="button"
|
||||
className={`inline-flex items-center px-3 py-1 text-xs font-medium transition-colors cursor-pointer ${
|
||||
!activeCategory
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-secondary text-secondary-foreground hover:bg-secondary/80"
|
||||
}`}
|
||||
onClick={() => setActiveCategory(null)}
|
||||
>
|
||||
All ({skills.length})
|
||||
</button>
|
||||
{allCategories.map(({ key, name, count }) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
className={`inline-flex items-center px-3 py-1 text-xs font-medium transition-colors cursor-pointer ${
|
||||
activeCategory === key
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-secondary text-secondary-foreground hover:bg-secondary/80"
|
||||
}`}
|
||||
onClick={() =>
|
||||
setActiveCategory(activeCategory === key ? null : key)
|
||||
}
|
||||
>
|
||||
{name}
|
||||
<span className="ml-1 opacity-60">{count}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ═══════════════ Skills by Category ═══════════════ */}
|
||||
<section className="flex flex-col gap-3">
|
||||
|
||||
{filteredSkills.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center text-sm text-muted-foreground">
|
||||
{skills.length === 0
|
||||
? "No skills found. Skills are loaded from ~/.hermes/skills/"
|
||||
: "No skills match your search or filter."}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
categoryGroups.map(({ key, name, skills: catSkills, enabledCount: catEnabled }) => {
|
||||
const collapsed = isCollapsed(key);
|
||||
return (
|
||||
<Card key={key}>
|
||||
<CardHeader
|
||||
className="cursor-pointer select-none py-3 px-4"
|
||||
onClick={() => toggleCollapse(key)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{collapsed ? (
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
<CardTitle className="text-sm font-medium">{name}</CardTitle>
|
||||
<Badge variant="secondary" className="text-[10px] font-normal">
|
||||
{catSkills.length} skill{catSkills.length !== 1 ? "s" : ""}
|
||||
</Badge>
|
||||
</div>
|
||||
<Badge
|
||||
variant={catEnabled === catSkills.length ? "success" : "outline"}
|
||||
className="text-[10px]"
|
||||
>
|
||||
{catEnabled}/{catSkills.length} enabled
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
{collapsed ? (
|
||||
/* Peek: show first few skill names so collapsed isn't blank */
|
||||
<div className="px-4 pb-3 flex items-center min-h-[28px]">
|
||||
<p className="text-xs text-muted-foreground/60 truncate leading-normal">
|
||||
{catSkills.slice(0, 4).map((s) => s.name).join(", ")}
|
||||
{catSkills.length > 4 && `, +${catSkills.length - 4} more`}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<CardContent className="pt-0 px-4 pb-3">
|
||||
<div className="grid gap-1">
|
||||
{catSkills.map((skill) => (
|
||||
<div
|
||||
key={skill.name}
|
||||
className="group flex items-start gap-3 rounded-md px-3 py-2.5 transition-colors hover:bg-muted/40"
|
||||
>
|
||||
<div className="pt-0.5 shrink-0">
|
||||
<Switch
|
||||
checked={skill.enabled}
|
||||
onCheckedChange={() => handleToggleSkill(skill)}
|
||||
disabled={togglingSkills.has(skill.name)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-0.5">
|
||||
<span
|
||||
className={`font-mono-ui text-sm ${
|
||||
skill.enabled
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{skill.name}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed line-clamp-2">
|
||||
{skill.description || "No description available."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* ═══════════════ Toolsets ═══════════════ */}
|
||||
<section className="flex flex-col gap-4">
|
||||
<h2 className="text-sm font-medium text-muted-foreground flex items-center gap-2">
|
||||
<Wrench className="h-4 w-4" />
|
||||
Toolsets ({filteredToolsets.length})
|
||||
</h2>
|
||||
|
||||
{filteredToolsets.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="py-8 text-center text-sm text-muted-foreground">
|
||||
No toolsets match the search.
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{filteredToolsets.map((ts) => {
|
||||
// Strip emoji prefix from label for cleaner display
|
||||
const labelText = ts.label.replace(/^[\p{Emoji}\s]+/u, "").trim() || ts.name;
|
||||
const emoji = ts.label.match(/^[\p{Emoji}]+/u)?.[0] || "🔧";
|
||||
|
||||
return (
|
||||
<Card key={ts.name} className="relative overflow-hidden">
|
||||
<CardContent className="py-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="text-2xl shrink-0 leading-none mt-0.5">{emoji}</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-medium text-sm">{labelText}</span>
|
||||
<Badge
|
||||
variant={ts.enabled ? "success" : "outline"}
|
||||
className="text-[10px]"
|
||||
>
|
||||
{ts.enabled ? "active" : "inactive"}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mb-2">
|
||||
{ts.description}
|
||||
</p>
|
||||
{ts.enabled && !ts.configured && (
|
||||
<p className="text-[10px] text-amber-300/80 mb-2">
|
||||
Setup needed
|
||||
</p>
|
||||
)}
|
||||
{ts.tools.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{ts.tools.map((tool) => (
|
||||
<Badge
|
||||
key={tool}
|
||||
variant="secondary"
|
||||
className="text-[10px] font-mono"
|
||||
>
|
||||
{tool}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{ts.tools.length === 0 && (
|
||||
<span className="text-[10px] text-muted-foreground/60">
|
||||
{ts.enabled ? `${ts.name} toolset` : "Disabled for CLI"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
303
web/src/pages/StatusPage.tsx
Normal file
303
web/src/pages/StatusPage.tsx
Normal file
|
|
@ -0,0 +1,303 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Activity,
|
||||
AlertTriangle,
|
||||
Clock,
|
||||
Cpu,
|
||||
Database,
|
||||
Radio,
|
||||
Wifi,
|
||||
WifiOff,
|
||||
} from "lucide-react";
|
||||
import { api } from "@/lib/api";
|
||||
import type { PlatformStatus, SessionInfo, StatusResponse } from "@/lib/api";
|
||||
import { timeAgo, isoTimeAgo } from "@/lib/utils";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
const PLATFORM_STATE_BADGE: Record<string, { variant: "success" | "warning" | "destructive"; label: string }> = {
|
||||
connected: { variant: "success", label: "Connected" },
|
||||
disconnected: { variant: "warning", label: "Disconnected" },
|
||||
fatal: { variant: "destructive", label: "Error" },
|
||||
};
|
||||
|
||||
const GATEWAY_STATE_DISPLAY: Record<string, { badge: "success" | "warning" | "destructive" | "outline"; label: string }> = {
|
||||
running: { badge: "success", label: "Running" },
|
||||
starting: { badge: "warning", label: "Starting" },
|
||||
startup_failed: { badge: "destructive", label: "Failed" },
|
||||
stopped: { badge: "outline", label: "Stopped" },
|
||||
};
|
||||
|
||||
function gatewayValue(status: StatusResponse): string {
|
||||
if (status.gateway_running) return `PID ${status.gateway_pid}`;
|
||||
if (status.gateway_state === "startup_failed") return "Start failed";
|
||||
return "Not running";
|
||||
}
|
||||
|
||||
function gatewayBadge(status: StatusResponse) {
|
||||
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: "Running" }
|
||||
: { badge: "outline" as const, label: "Off" };
|
||||
}
|
||||
|
||||
export default function StatusPage() {
|
||||
const [status, setStatus] = useState<StatusResponse | null>(null);
|
||||
const [sessions, setSessions] = useState<SessionInfo[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const load = () => {
|
||||
api.getStatus().then(setStatus).catch(() => {});
|
||||
api.getSessions().then(setSessions).catch(() => {});
|
||||
};
|
||||
load();
|
||||
const interval = setInterval(load, 5000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
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 gwBadge = gatewayBadge(status);
|
||||
|
||||
const items = [
|
||||
{
|
||||
icon: Cpu,
|
||||
label: "Agent",
|
||||
value: `v${status.version}`,
|
||||
badgeText: "Live",
|
||||
badgeVariant: "success" as const,
|
||||
},
|
||||
{
|
||||
icon: Radio,
|
||||
label: "Gateway",
|
||||
value: gatewayValue(status),
|
||||
badgeText: gwBadge.label,
|
||||
badgeVariant: gwBadge.badge,
|
||||
},
|
||||
{
|
||||
icon: Activity,
|
||||
label: "Active Sessions",
|
||||
value: status.active_sessions > 0 ? `${status.active_sessions} running` : "None",
|
||||
badgeText: status.active_sessions > 0 ? "Live" : "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: "Gateway failed to start",
|
||||
detail: status.gateway_exit_reason ?? undefined,
|
||||
});
|
||||
}
|
||||
const failedPlatforms = platforms.filter(([, info]) => info.state === "fatal" || info.state === "disconnected");
|
||||
for (const [name, info] of failedPlatforms) {
|
||||
alerts.push({
|
||||
message: `${name.charAt(0).toUpperCase() + name.slice(1)} ${info.state === "fatal" ? "error" : "disconnected"}`,
|
||||
detail: info.error_message ?? undefined,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* Alert banner — breaks grid monotony for critical states */}
|
||||
{alerts.length > 0 && (
|
||||
<div className="border border-destructive/30 bg-destructive/[0.06] p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<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="grid gap-4 sm:grid-cols-3">
|
||||
{items.map(({ icon: Icon, label, value, badgeText, badgeVariant }) => (
|
||||
<Card key={label}>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-medium">{label}</CardTitle>
|
||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold font-display">{value}</div>
|
||||
|
||||
{badgeText && (
|
||||
<Badge variant={badgeVariant} className="mt-2">
|
||||
{badgeVariant === "success" && (
|
||||
<span className="mr-1 inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-current" />
|
||||
)}
|
||||
{badgeText}
|
||||
</Badge>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{platforms.length > 0 && (
|
||||
<PlatformsCard platforms={platforms} />
|
||||
)}
|
||||
|
||||
{activeSessions.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Activity className="h-5 w-5 text-success" />
|
||||
<CardTitle className="text-base">Active Sessions</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="grid gap-3">
|
||||
{activeSessions.map((s) => (
|
||||
<div
|
||||
key={s.id}
|
||||
className="flex items-center justify-between border border-border p-3"
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm">{s.title ?? "Untitled"}</span>
|
||||
|
||||
<Badge variant="success" className="text-[10px]">
|
||||
<span className="mr-1 inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-current" />
|
||||
Live
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<span className="text-xs text-muted-foreground">
|
||||
<span className="font-mono-ui">{s.model ?? "unknown"}</span> · {s.message_count} 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">Recent Sessions</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="grid gap-3">
|
||||
{recentSessions.map((s) => (
|
||||
<div
|
||||
key={s.id}
|
||||
className="flex items-center justify-between border border-border p-3"
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="font-medium text-sm">{s.title ?? "Untitled"}</span>
|
||||
|
||||
<span className="text-xs text-muted-foreground">
|
||||
<span className="font-mono-ui">{s.model ?? "unknown"}</span> · {s.message_count} msgs · {timeAgo(s.last_active)}
|
||||
</span>
|
||||
|
||||
{s.preview && (
|
||||
<span className="text-xs text-muted-foreground/70 truncate max-w-md">
|
||||
{s.preview}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
<Database className="mr-1 h-3 w-3" />
|
||||
{s.source ?? "local"}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PlatformsCard({ platforms }: PlatformsCardProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Radio className="h-5 w-5 text-muted-foreground" />
|
||||
<CardTitle className="text-base">Connected Platforms</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="grid gap-3">
|
||||
{platforms.map(([name, info]) => {
|
||||
const display = PLATFORM_STATE_BADGE[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 items-center justify-between border border-border p-3"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<IconComponent className={`h-4 w-4 ${
|
||||
info.state === "connected"
|
||||
? "text-success"
|
||||
: info.state === "fatal"
|
||||
? "text-destructive"
|
||||
: "text-warning"
|
||||
}`} />
|
||||
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-sm font-medium capitalize">{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">
|
||||
Last update: {isoTimeAgo(info.updated_at)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Badge variant={display.variant}>
|
||||
{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 PlatformsCardProps {
|
||||
platforms: [string, PlatformStatus][];
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue