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:
@@ -0,0 +1,48 @@
|
||||
# Hermes Agent — Web UI
|
||||
|
||||
Browser-based dashboard for managing Hermes Agent configuration, API keys, and monitoring active sessions.
|
||||
|
||||
## Stack
|
||||
|
||||
- **Vite** + **React 19** + **TypeScript**
|
||||
- **Tailwind CSS v4** with custom dark theme
|
||||
- **shadcn/ui**-style components (hand-rolled, no CLI dependency)
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# Start the backend API server
|
||||
cd ../
|
||||
python -m hermes_cli.main web --no-open
|
||||
|
||||
# In another terminal, start the Vite dev server (with HMR + API proxy)
|
||||
cd web/
|
||||
npm run dev
|
||||
```
|
||||
|
||||
The Vite dev server proxies `/api` requests to `http://127.0.0.1:9119` (the FastAPI backend).
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
This outputs to `../hermes_cli/web_dist/`, which the FastAPI server serves as a static SPA. The built assets are included in the Python package via `pyproject.toml` package-data.
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── components/ui/ # Reusable UI primitives (Card, Badge, Button, Input, etc.)
|
||||
├── lib/
|
||||
│ ├── api.ts # API client — typed fetch wrappers for all backend endpoints
|
||||
│ └── utils.ts # cn() helper for Tailwind class merging
|
||||
├── pages/
|
||||
│ ├── StatusPage # Agent status, active/recent sessions
|
||||
│ ├── ConfigPage # Dynamic config editor (reads schema from backend)
|
||||
│ └── EnvPage # API key management with save/clear
|
||||
├── App.tsx # Main layout and navigation
|
||||
├── main.tsx # React entry point
|
||||
└── index.css # Tailwind imports and theme variables
|
||||
```
|
||||
@@ -0,0 +1,23 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Hermes Agent</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+3835
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "web",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.577.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss": "^4.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@types/node": "^24.12.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.2.0",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.4.0",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.56.1",
|
||||
"vite": "^7.3.1"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 8.3 KiB |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+117
@@ -0,0 +1,117 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Activity, BarChart3, Clock, FileText, KeyRound, MessageSquare, Package, Settings } from "lucide-react";
|
||||
import StatusPage from "@/pages/StatusPage";
|
||||
import ConfigPage from "@/pages/ConfigPage";
|
||||
import EnvPage from "@/pages/EnvPage";
|
||||
import SessionsPage from "@/pages/SessionsPage";
|
||||
import LogsPage from "@/pages/LogsPage";
|
||||
import AnalyticsPage from "@/pages/AnalyticsPage";
|
||||
import CronPage from "@/pages/CronPage";
|
||||
import SkillsPage from "@/pages/SkillsPage";
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ id: "status", label: "Status", icon: Activity },
|
||||
{ id: "sessions", label: "Sessions", icon: MessageSquare },
|
||||
{ id: "analytics", label: "Analytics", icon: BarChart3 },
|
||||
{ id: "logs", label: "Logs", icon: FileText },
|
||||
{ id: "cron", label: "Cron", icon: Clock },
|
||||
{ id: "skills", label: "Skills", icon: Package },
|
||||
{ id: "config", label: "Config", icon: Settings },
|
||||
{ id: "env", label: "Keys", icon: KeyRound },
|
||||
] as const;
|
||||
|
||||
type PageId = (typeof NAV_ITEMS)[number]["id"];
|
||||
|
||||
const PAGE_COMPONENTS: Record<PageId, React.FC> = {
|
||||
status: StatusPage,
|
||||
sessions: SessionsPage,
|
||||
analytics: AnalyticsPage,
|
||||
logs: LogsPage,
|
||||
cron: CronPage,
|
||||
skills: SkillsPage,
|
||||
config: ConfigPage,
|
||||
env: EnvPage,
|
||||
};
|
||||
|
||||
export default function App() {
|
||||
const [page, setPage] = useState<PageId>("status");
|
||||
const [animKey, setAnimKey] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
setAnimKey((k) => k + 1);
|
||||
}, [page]);
|
||||
|
||||
const PageComponent = PAGE_COMPONENTS[page];
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col bg-background text-foreground">
|
||||
{/* Global grain + warm glow (matches landing page) */}
|
||||
<div className="noise-overlay" />
|
||||
<div className="warm-glow" />
|
||||
|
||||
{/* ---- Header with grid-border nav ---- */}
|
||||
<header className="sticky top-0 z-40 border-b border-border bg-background/90 backdrop-blur-sm">
|
||||
<div className="mx-auto flex h-12 max-w-[1400px] items-stretch">
|
||||
{/* Brand */}
|
||||
<div className="flex items-center border-r border-border px-5 shrink-0">
|
||||
<span className="font-collapse text-xl font-bold tracking-wider uppercase blend-lighter">
|
||||
Hermes<br className="hidden sm:inline" /><span className="sm:hidden"> </span>Agent
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Nav grid — Mondwest labels like the landing page nav */}
|
||||
<nav className="flex items-stretch overflow-x-auto scrollbar-none">
|
||||
{NAV_ITEMS.map(({ id, label, icon: Icon }) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
onClick={() => setPage(id)}
|
||||
className={`group relative inline-flex items-center gap-1.5 border-r border-border px-4 py-2 font-display text-[0.8rem] tracking-[0.12em] uppercase whitespace-nowrap transition-colors cursor-pointer shrink-0 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring ${
|
||||
page === id
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
{label}
|
||||
{/* Hover highlight */}
|
||||
<span className="absolute inset-0 bg-foreground pointer-events-none transition-opacity duration-150 group-hover:opacity-5 opacity-0" />
|
||||
{/* Active indicator — dither bar */}
|
||||
{page === id && (
|
||||
<span className="absolute bottom-0 left-0 right-0 h-px bg-foreground" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Version badge */}
|
||||
<div className="ml-auto flex items-center px-4 text-muted-foreground">
|
||||
<span className="font-display text-[0.7rem] tracking-[0.15em] uppercase opacity-50">
|
||||
Web UI
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main
|
||||
key={animKey}
|
||||
className="relative z-2 mx-auto w-full max-w-[1400px] flex-1 px-6 py-8"
|
||||
style={{ animation: "fade-in 150ms ease-out" }}
|
||||
>
|
||||
<PageComponent />
|
||||
</main>
|
||||
|
||||
{/* ---- Footer ---- */}
|
||||
<footer className="relative z-2 border-t border-border">
|
||||
<div className="mx-auto flex max-w-[1400px] items-center justify-between px-6 py-3">
|
||||
<span className="font-display text-[0.8rem] tracking-[0.12em] uppercase opacity-50">
|
||||
Hermes Agent
|
||||
</span>
|
||||
<span className="font-display text-[0.7rem] tracking-[0.15em] uppercase text-foreground/40">
|
||||
Nous Research
|
||||
</span>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select } from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
|
||||
function FieldHint({ schema, schemaKey }: { schema: Record<string, unknown>; schemaKey: string }) {
|
||||
const keyPath = schemaKey.includes(".") ? schemaKey : "";
|
||||
const description = schema.description ? String(schema.description) : "";
|
||||
|
||||
if (!keyPath && !description) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{keyPath && <span className="text-[10px] font-mono text-muted-foreground/50">{keyPath}</span>}
|
||||
{description && <span className="text-xs text-muted-foreground/70">{description}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AutoField({
|
||||
schemaKey,
|
||||
schema,
|
||||
value,
|
||||
onChange,
|
||||
}: AutoFieldProps) {
|
||||
const rawLabel = schemaKey.split(".").pop() ?? schemaKey;
|
||||
const label = rawLabel.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
|
||||
if (schema.type === "boolean") {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<Label className="text-sm">{label}</Label>
|
||||
<FieldHint schema={schema} schemaKey={schemaKey} />
|
||||
</div>
|
||||
<Switch checked={!!value} onCheckedChange={onChange} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (schema.type === "select") {
|
||||
const options = (schema.options as string[]) ?? [];
|
||||
return (
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-sm">{label}</Label>
|
||||
<FieldHint schema={schema} schemaKey={schemaKey} />
|
||||
<Select value={String(value ?? "")} onChange={(e) => onChange(e.target.value)}>
|
||||
{options.map((opt) => (
|
||||
<option key={opt} value={opt}>
|
||||
{opt || "(none)"}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (schema.type === "number") {
|
||||
return (
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-sm">{label}</Label>
|
||||
<FieldHint schema={schema} schemaKey={schemaKey} />
|
||||
<Input
|
||||
type="number"
|
||||
value={value === undefined || value === null ? "" : String(value)}
|
||||
onChange={(e) => {
|
||||
const raw = e.target.value;
|
||||
if (raw === "") {
|
||||
onChange(0);
|
||||
return;
|
||||
}
|
||||
const n = Number(raw);
|
||||
if (!Number.isNaN(n)) {
|
||||
onChange(n);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (schema.type === "text") {
|
||||
return (
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-sm">{label}</Label>
|
||||
<FieldHint schema={schema} schemaKey={schemaKey} />
|
||||
<textarea
|
||||
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"
|
||||
value={String(value ?? "")}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (schema.type === "list") {
|
||||
return (
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-sm">{label}</Label>
|
||||
<FieldHint schema={schema} schemaKey={schemaKey} />
|
||||
<Input
|
||||
value={Array.isArray(value) ? value.join(", ") : String(value ?? "")}
|
||||
onChange={(e) =>
|
||||
onChange(
|
||||
e.target.value
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean),
|
||||
)
|
||||
}
|
||||
placeholder="comma-separated values"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
|
||||
const obj = value as Record<string, unknown>;
|
||||
return (
|
||||
<div className="grid gap-3 rounded-lg border border-border p-3">
|
||||
<Label className="text-xs font-medium">{label}</Label>
|
||||
<FieldHint schema={schema} schemaKey={schemaKey} />
|
||||
{Object.entries(obj).map(([subKey, subVal]) => (
|
||||
<div key={subKey} className="grid gap-1">
|
||||
<Label className="text-xs text-muted-foreground">{subKey}</Label>
|
||||
<Input
|
||||
value={String(subVal ?? "")}
|
||||
onChange={(e) => onChange({ ...obj, [subKey]: e.target.value })}
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-sm">{label}</Label>
|
||||
<FieldHint schema={schema} schemaKey={schemaKey} />
|
||||
<Input value={String(value ?? "")} onChange={(e) => onChange(e.target.value)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface AutoFieldProps {
|
||||
schemaKey: string;
|
||||
schema: Record<string, unknown>;
|
||||
value: unknown;
|
||||
onChange: (v: unknown) => void;
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
import { useMemo } from "react";
|
||||
|
||||
/**
|
||||
* Lightweight markdown renderer for LLM output.
|
||||
* Handles: code blocks, inline code, bold, italic, headers, links, lists, horizontal rules.
|
||||
* NOT a full CommonMark parser — optimized for typical assistant message patterns.
|
||||
*/
|
||||
export function Markdown({ content, highlightTerms }: { content: string; highlightTerms?: string[] }) {
|
||||
const blocks = useMemo(() => parseBlocks(content), [content]);
|
||||
|
||||
return (
|
||||
<div className="text-sm text-foreground leading-relaxed space-y-2">
|
||||
{blocks.map((block, i) => (
|
||||
<Block key={i} block={block} highlightTerms={highlightTerms} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Types */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
type BlockNode =
|
||||
| { type: "code"; lang: string; content: string }
|
||||
| { type: "heading"; level: number; content: string }
|
||||
| { type: "hr" }
|
||||
| { type: "list"; ordered: boolean; items: string[] }
|
||||
| { type: "paragraph"; content: string };
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Block parser */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function parseBlocks(text: string): BlockNode[] {
|
||||
const lines = text.split("\n");
|
||||
const blocks: BlockNode[] = [];
|
||||
let i = 0;
|
||||
|
||||
while (i < lines.length) {
|
||||
const line = lines[i];
|
||||
|
||||
// Fenced code block
|
||||
const fenceMatch = line.match(/^```(\w*)/);
|
||||
if (fenceMatch) {
|
||||
const lang = fenceMatch[1] || "";
|
||||
const codeLines: string[] = [];
|
||||
i++;
|
||||
while (i < lines.length && !lines[i].startsWith("```")) {
|
||||
codeLines.push(lines[i]);
|
||||
i++;
|
||||
}
|
||||
i++; // skip closing ```
|
||||
blocks.push({ type: "code", lang, content: codeLines.join("\n") });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Heading
|
||||
const headingMatch = line.match(/^(#{1,4})\s+(.+)/);
|
||||
if (headingMatch) {
|
||||
blocks.push({ type: "heading", level: headingMatch[1].length, content: headingMatch[2] });
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Horizontal rule
|
||||
if (/^[-*_]{3,}\s*$/.test(line)) {
|
||||
blocks.push({ type: "hr" });
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Unordered list
|
||||
if (/^[-*+]\s/.test(line)) {
|
||||
const items: string[] = [];
|
||||
while (i < lines.length && /^[-*+]\s/.test(lines[i])) {
|
||||
items.push(lines[i].replace(/^[-*+]\s/, ""));
|
||||
i++;
|
||||
}
|
||||
blocks.push({ type: "list", ordered: false, items });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Ordered list
|
||||
if (/^\d+[.)]\s/.test(line)) {
|
||||
const items: string[] = [];
|
||||
while (i < lines.length && /^\d+[.)]\s/.test(lines[i])) {
|
||||
items.push(lines[i].replace(/^\d+[.)]\s/, ""));
|
||||
i++;
|
||||
}
|
||||
blocks.push({ type: "list", ordered: true, items });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Empty line
|
||||
if (line.trim() === "") {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Paragraph — collect consecutive non-empty, non-special lines
|
||||
const paraLines: string[] = [];
|
||||
while (
|
||||
i < lines.length &&
|
||||
lines[i].trim() !== "" &&
|
||||
!lines[i].match(/^```/) &&
|
||||
!lines[i].match(/^#{1,4}\s/) &&
|
||||
!lines[i].match(/^[-*+]\s/) &&
|
||||
!lines[i].match(/^\d+[.)]\s/) &&
|
||||
!lines[i].match(/^[-*_]{3,}\s*$/)
|
||||
) {
|
||||
paraLines.push(lines[i]);
|
||||
i++;
|
||||
}
|
||||
if (paraLines.length > 0) {
|
||||
blocks.push({ type: "paragraph", content: paraLines.join("\n") });
|
||||
}
|
||||
}
|
||||
|
||||
return blocks;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Block renderer */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function Block({ block, highlightTerms }: { block: BlockNode; highlightTerms?: string[] }) {
|
||||
switch (block.type) {
|
||||
case "code":
|
||||
return (
|
||||
<pre className="rounded-md bg-secondary/60 border border-border px-3 py-2.5 text-xs font-mono leading-relaxed overflow-x-auto">
|
||||
<code>{block.content}</code>
|
||||
</pre>
|
||||
);
|
||||
|
||||
case "heading": {
|
||||
const Tag = `h${Math.min(block.level, 4)}` as "h1" | "h2" | "h3" | "h4";
|
||||
const sizes: Record<string, string> = {
|
||||
h1: "text-base font-bold",
|
||||
h2: "text-sm font-bold",
|
||||
h3: "text-sm font-semibold",
|
||||
h4: "text-sm font-medium",
|
||||
};
|
||||
return <Tag className={sizes[Tag]}><InlineContent text={block.content} highlightTerms={highlightTerms} /></Tag>;
|
||||
}
|
||||
|
||||
case "hr":
|
||||
return <hr className="border-border" />;
|
||||
|
||||
case "list": {
|
||||
const Tag = block.ordered ? "ol" : "ul";
|
||||
return (
|
||||
<Tag className={`space-y-0.5 ${block.ordered ? "list-decimal" : "list-disc"} pl-5 text-sm`}>
|
||||
{block.items.map((item, i) => (
|
||||
<li key={i}><InlineContent text={item} highlightTerms={highlightTerms} /></li>
|
||||
))}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
|
||||
case "paragraph":
|
||||
return <p><InlineContent text={block.content} highlightTerms={highlightTerms} /></p>;
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Inline parser + renderer */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
type InlineNode =
|
||||
| { type: "text"; content: string }
|
||||
| { type: "code"; content: string }
|
||||
| { type: "bold"; content: string }
|
||||
| { type: "italic"; content: string }
|
||||
| { type: "link"; text: string; href: string }
|
||||
| { type: "br" };
|
||||
|
||||
function parseInline(text: string): InlineNode[] {
|
||||
const nodes: InlineNode[] = [];
|
||||
// Pattern priority: code > link > bold > italic > bare URL > line break
|
||||
const pattern = /(`[^`]+`)|(\[([^\]]+)\]\(([^)]+)\))|(\*\*([^*]+)\*\*)|(\*([^*]+)\*)|(\bhttps?:\/\/[^\s<>)\]]+)|(\n)/g;
|
||||
let lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = pattern.exec(text)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
nodes.push({ type: "text", content: text.slice(lastIndex, match.index) });
|
||||
}
|
||||
|
||||
if (match[1]) {
|
||||
// Inline code
|
||||
nodes.push({ type: "code", content: match[1].slice(1, -1) });
|
||||
} else if (match[2]) {
|
||||
// [text](url) link
|
||||
nodes.push({ type: "link", text: match[3], href: match[4] });
|
||||
} else if (match[5]) {
|
||||
// **bold**
|
||||
nodes.push({ type: "bold", content: match[6] });
|
||||
} else if (match[7]) {
|
||||
// *italic*
|
||||
nodes.push({ type: "italic", content: match[8] });
|
||||
} else if (match[9]) {
|
||||
// Bare URL
|
||||
nodes.push({ type: "link", text: match[9], href: match[9] });
|
||||
} else if (match[10]) {
|
||||
// Line break within paragraph
|
||||
nodes.push({ type: "br" });
|
||||
}
|
||||
|
||||
lastIndex = match.index + match[0].length;
|
||||
}
|
||||
|
||||
if (lastIndex < text.length) {
|
||||
nodes.push({ type: "text", content: text.slice(lastIndex) });
|
||||
}
|
||||
|
||||
return nodes;
|
||||
}
|
||||
|
||||
function InlineContent({ text, highlightTerms }: { text: string; highlightTerms?: string[] }) {
|
||||
const nodes = useMemo(() => parseInline(text), [text]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{nodes.map((node, i) => {
|
||||
switch (node.type) {
|
||||
case "text":
|
||||
return <HighlightedText key={i} text={node.content} terms={highlightTerms} />;
|
||||
case "code":
|
||||
return (
|
||||
<code key={i} className="rounded bg-secondary/60 px-1.5 py-0.5 text-xs font-mono text-primary/90">
|
||||
{node.content}
|
||||
</code>
|
||||
);
|
||||
case "bold":
|
||||
return <strong key={i} className="font-semibold"><HighlightedText text={node.content} terms={highlightTerms} /></strong>;
|
||||
case "italic":
|
||||
return <em key={i}><HighlightedText text={node.content} terms={highlightTerms} /></em>;
|
||||
case "link":
|
||||
return (
|
||||
<a
|
||||
key={i}
|
||||
href={node.href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-primary underline underline-offset-2 decoration-primary/30 hover:decoration-primary/60 transition-colors"
|
||||
>
|
||||
{node.text}
|
||||
</a>
|
||||
);
|
||||
case "br":
|
||||
return <br key={i} />;
|
||||
}
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Highlight search terms within a plain text string. */
|
||||
function HighlightedText({ text, terms }: { text: string; terms?: string[] }) {
|
||||
if (!terms || terms.length === 0) return <>{text}</>;
|
||||
|
||||
// Build a regex that matches any of the search terms (case-insensitive)
|
||||
const escaped = terms.map((t) => t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
|
||||
const regex = new RegExp(`(${escaped.join("|")})`, "gi");
|
||||
const parts = text.split(regex);
|
||||
|
||||
return (
|
||||
<>
|
||||
{parts.map((part, i) =>
|
||||
regex.test(part) ? (
|
||||
<mark key={i} className="bg-warning/30 text-warning rounded-sm px-0.5">{part}</mark>
|
||||
) : (
|
||||
<span key={i}>{part}</span>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export function Toast({ toast }: { toast: { message: string; type: "success" | "error" } | null }) {
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [current, setCurrent] = useState(toast);
|
||||
|
||||
useEffect(() => {
|
||||
if (toast) {
|
||||
setCurrent(toast);
|
||||
setVisible(true);
|
||||
} else {
|
||||
setVisible(false);
|
||||
const timer = setTimeout(() => setCurrent(null), 200);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [toast]);
|
||||
|
||||
if (!current) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className={`fixed top-4 right-4 z-50 border px-4 py-2.5 font-courier text-xs tracking-wider uppercase backdrop-blur-sm ${
|
||||
current.type === "success"
|
||||
? "bg-success/15 text-success border-success/30"
|
||||
: "bg-destructive/15 text-destructive border-destructive/30"
|
||||
}`}
|
||||
style={{
|
||||
animation: visible ? "toast-in 200ms ease-out forwards" : "toast-out 200ms ease-in forwards",
|
||||
}}
|
||||
>
|
||||
{current.message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center border px-2 py-0.5 font-compressed text-[0.65rem] tracking-[0.15em] uppercase transition-colors",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border-foreground/20 bg-foreground/10 text-foreground",
|
||||
secondary: "border-border bg-secondary text-secondary-foreground",
|
||||
destructive: "border-destructive/30 bg-destructive/15 text-destructive",
|
||||
outline: "border-border text-muted-foreground",
|
||||
success: "grain border-emerald-600/30 bg-emerald-950/70 text-emerald-400",
|
||||
warning: "border-warning/30 bg-warning/15 text-warning",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export function Badge({
|
||||
className,
|
||||
variant,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof badgeVariants>) {
|
||||
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap font-display text-xs tracking-[0.1em] uppercase transition-colors cursor-pointer"
|
||||
+ " disabled:pointer-events-none disabled:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-foreground/90 text-background hover:bg-foreground",
|
||||
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
outline: "border border-border bg-transparent hover:bg-foreground/10 hover:text-foreground",
|
||||
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-foreground/10 hover:text-foreground",
|
||||
link: "text-foreground underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2",
|
||||
sm: "h-8 px-3 text-[0.65rem]",
|
||||
lg: "h-10 px-8",
|
||||
icon: "h-9 w-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export function Button({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
...props
|
||||
}: React.ButtonHTMLAttributes<HTMLButtonElement> & VariantProps<typeof buttonVariants>) {
|
||||
return <button className={cn(buttonVariants({ variant, size }), className)} {...props} />;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function Card({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"border border-border bg-card/80 text-card-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function CardHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn("flex flex-col gap-1.5 p-4 border-b border-border", className)} {...props} />;
|
||||
}
|
||||
|
||||
export function CardTitle({ className, ...props }: React.HTMLAttributes<HTMLHeadingElement>) {
|
||||
return <h3 className={cn("font-expanded text-sm font-bold tracking-[0.08em] uppercase blend-lighter", className)} {...props} />;
|
||||
}
|
||||
|
||||
export function CardDescription({ className, ...props }: React.HTMLAttributes<HTMLParagraphElement>) {
|
||||
return <p className={cn("font-display text-xs text-muted-foreground", className)} {...props} />;
|
||||
}
|
||||
|
||||
export function CardContent({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn("p-4", className)} {...props} />;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function Input({ className, ...props }: React.InputHTMLAttributes<HTMLInputElement>) {
|
||||
return (
|
||||
<input
|
||||
className={cn(
|
||||
"flex h-9 w-full border border-border bg-background/40 px-3 py-1 font-courier text-sm transition-colors",
|
||||
"placeholder:text-muted-foreground",
|
||||
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-foreground/30 focus-visible:border-foreground/25",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function Label({ className, ...props }: React.LabelHTMLAttributes<HTMLLabelElement>) {
|
||||
return (
|
||||
<label
|
||||
className={cn(
|
||||
"font-display text-xs tracking-[0.1em] uppercase leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function Select({ className, ...props }: React.SelectHTMLAttributes<HTMLSelectElement>) {
|
||||
return (
|
||||
<select
|
||||
className={cn(
|
||||
"flex h-9 w-full border border-border bg-background/40 px-3 py-1 font-courier text-sm",
|
||||
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-foreground/30 focus-visible:border-foreground/25",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement> & { orientation?: "horizontal" | "vertical" }) {
|
||||
return (
|
||||
<div
|
||||
role="separator"
|
||||
className={cn(
|
||||
"shrink-0 bg-border",
|
||||
orientation === "horizontal" ? "h-px w-full" : "h-full w-px",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function Switch({
|
||||
checked,
|
||||
onCheckedChange,
|
||||
className,
|
||||
disabled,
|
||||
}: {
|
||||
checked: boolean;
|
||||
onCheckedChange: (v: boolean) => void;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center border border-border transition-colors",
|
||||
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-foreground/30",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
checked ? "bg-foreground/15 border-foreground/30" : "bg-background",
|
||||
className,
|
||||
)}
|
||||
onClick={() => onCheckedChange(!checked)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"pointer-events-none block h-3.5 w-3.5 transition-transform",
|
||||
checked ? "translate-x-4 bg-foreground" : "translate-x-0.5 bg-muted-foreground",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function Tabs({
|
||||
defaultValue,
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
defaultValue: string;
|
||||
children: (active: string, setActive: (v: string) => void) => React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
const [active, setActive] = useState(defaultValue);
|
||||
return <div className={cn("flex flex-col gap-4", className)}>{children(active, setActive)}</div>;
|
||||
}
|
||||
|
||||
export function TabsList({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"inline-flex h-9 items-center justify-start border-b border-border text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function TabsTrigger({
|
||||
active,
|
||||
value,
|
||||
onClick,
|
||||
className,
|
||||
...props
|
||||
}: React.ButtonHTMLAttributes<HTMLButtonElement> & { active: boolean; value: string }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"relative inline-flex items-center justify-center whitespace-nowrap px-3 py-1.5 font-display text-xs tracking-[0.1em] uppercase transition-all cursor-pointer",
|
||||
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
|
||||
active
|
||||
? "text-foreground after:absolute after:bottom-0 after:left-0 after:right-0 after:h-px after:bg-foreground"
|
||||
: "hover:text-foreground",
|
||||
className,
|
||||
)}
|
||||
onClick={onClick}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
export function useToast(duration = 3000) {
|
||||
const [toast, setToast] = useState<{ message: string; type: "success" | "error" } | null>(null);
|
||||
|
||||
const showToast = useCallback(
|
||||
(message: string, type: "success" | "error") => {
|
||||
setToast({ message, type });
|
||||
setTimeout(() => setToast(null), duration);
|
||||
},
|
||||
[duration],
|
||||
);
|
||||
|
||||
return { toast, showToast };
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Hermes Agent — Design tokens */
|
||||
/* Matched to hermes-agent.nousresearch.com (dark teal theme) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/* --- Font faces --- */
|
||||
@font-face { font-family: "Collapse"; src: url("/fonts/Collapse-Regular.woff2") format("woff2"); font-weight: 400; font-display: swap; }
|
||||
@font-face { font-family: "Collapse"; src: url("/fonts/Collapse-Bold.woff2") format("woff2"); font-weight: 700; font-display: swap; }
|
||||
@font-face { font-family: "Courier Prime"; src: url("/fonts/CourierPrime-Regular.woff2") format("woff2"); font-weight: 400; font-display: swap; }
|
||||
@font-face { font-family: "Courier Prime"; src: url("/fonts/CourierPrime-Bold.woff2") format("woff2"); font-weight: 700; font-display: swap; }
|
||||
@font-face { font-family: "RulesCompressed"; src: url("/fonts/RulesCompressed-Regular.woff2") format("woff2"); font-weight: 400; font-display: swap; }
|
||||
@font-face { font-family: "RulesCompressed"; src: url("/fonts/RulesCompressed-Medium.woff2") format("woff2"); font-weight: 600; font-display: swap; }
|
||||
@font-face { font-family: "RulesExpanded"; src: url("/fonts/RulesExpanded-Regular.woff2") format("woff2"); font-weight: 400; font-display: swap; }
|
||||
@font-face { font-family: "RulesExpanded"; src: url("/fonts/RulesExpanded-Bold.woff2") format("woff2"); font-weight: 700; font-display: swap; }
|
||||
@font-face { font-family: "Mondwest"; src: url("/fonts/Mondwest-Regular.woff2") format("woff2"); font-weight: 400; font-display: swap; }
|
||||
|
||||
@theme {
|
||||
/* ---- Hermes palette (dark teal, from live site) ---- */
|
||||
--color-background: #041C1C;
|
||||
--color-foreground: #ffe6cb;
|
||||
--color-card: #062424;
|
||||
--color-card-foreground: #ffe6cb;
|
||||
--color-primary: #ffe6cb;
|
||||
--color-primary-foreground: #041C1C;
|
||||
--color-secondary: #0a2e2e;
|
||||
--color-secondary-foreground: #ffe6cb;
|
||||
--color-muted: #083030;
|
||||
--color-muted-foreground: #8aaa9a;
|
||||
--color-accent: #0c3838;
|
||||
--color-accent-foreground: #ffe6cb;
|
||||
--color-destructive: #fb2c36;
|
||||
--color-destructive-foreground: #fff;
|
||||
--color-success: #4ade80;
|
||||
--color-warning: #ffbd38;
|
||||
--color-border: color-mix(in srgb, #ffe6cb 15%, transparent);
|
||||
--color-input: color-mix(in srgb, #ffe6cb 15%, transparent);
|
||||
--color-ring: #ffe6cb;
|
||||
--color-popover: #062424;
|
||||
--color-popover-foreground: #ffe6cb;
|
||||
|
||||
/* ---- Font stacks ---- */
|
||||
--font-sans: "Mondwest", Arial, sans-serif;
|
||||
--font-mono: "Courier Prime", "Courier New", monospace;
|
||||
--font-display: "Mondwest", Arial, sans-serif;
|
||||
--font-expanded: "RulesExpanded", Arial, sans-serif;
|
||||
--font-compressed: "RulesCompressed", Arial, sans-serif;
|
||||
}
|
||||
|
||||
/* ---- Global body ---- */
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: "Mondwest", Arial, sans-serif;
|
||||
background: var(--color-background);
|
||||
color: var(--color-foreground);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
/* ---- Selection ---- */
|
||||
::selection {
|
||||
background: var(--color-foreground);
|
||||
color: var(--color-background);
|
||||
}
|
||||
|
||||
/* ---- Scrollbars (thin, subtle) ---- */
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: transparent transparent;
|
||||
}
|
||||
*:hover {
|
||||
scrollbar-color: color-mix(in srgb, var(--color-foreground) 15%, transparent) transparent;
|
||||
}
|
||||
html, body {
|
||||
scrollbar-color: color-mix(in srgb, var(--color-foreground) 25%, transparent) transparent;
|
||||
}
|
||||
::-webkit-scrollbar { width: 4px; height: 4px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: color-mix(in srgb, var(--color-foreground) 20%, transparent);
|
||||
border-radius: 2px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: color-mix(in srgb, var(--color-foreground) 35%, transparent);
|
||||
}
|
||||
|
||||
/* ---- Hide scrollbar utility ---- */
|
||||
.scrollbar-none {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.scrollbar-none::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ---- Code blocks ---- */
|
||||
code {
|
||||
font-family: "Courier Prime", "Courier New", monospace;
|
||||
font-size: 0.85em;
|
||||
padding: 0.15em 0.4em;
|
||||
border-radius: 0;
|
||||
background: color-mix(in srgb, var(--color-foreground) 8%, transparent);
|
||||
}
|
||||
|
||||
/* ---- Dither texture ---- */
|
||||
.dither {
|
||||
background: repeating-conic-gradient(currentColor 0% 25%, #0000 0% 50%) 0 0 / 2px 2px;
|
||||
}
|
||||
|
||||
/* ---- Blink cursor (only on group hover, like canonical) ---- */
|
||||
@keyframes blink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
.blink {
|
||||
display: none;
|
||||
}
|
||||
.group:hover .blink {
|
||||
display: inline-block;
|
||||
animation: blink 1s step-end infinite;
|
||||
}
|
||||
|
||||
/* ---- Page transitions ---- */
|
||||
@keyframes fade-in {
|
||||
from { opacity: 0; transform: translateY(4px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
@keyframes toast-in {
|
||||
from { opacity: 0; transform: translateX(16px); }
|
||||
to { opacity: 1; transform: translateX(0); }
|
||||
}
|
||||
@keyframes toast-out {
|
||||
from { opacity: 1; transform: translateX(0); }
|
||||
to { opacity: 0; transform: translateX(16px); }
|
||||
}
|
||||
|
||||
/* ---- Plus-lighter blend for headings ---- */
|
||||
.blend-lighter {
|
||||
mix-blend-mode: plus-lighter;
|
||||
}
|
||||
|
||||
/* ---- Font utilities ---- */
|
||||
.font-display { font-family: "Mondwest", Arial, sans-serif; }
|
||||
.font-expanded { font-family: "RulesExpanded", Arial, sans-serif; }
|
||||
.font-compressed { font-family: "RulesCompressed", Arial, sans-serif; }
|
||||
.font-courier { font-family: "Courier Prime", "Courier New", monospace; }
|
||||
.font-collapse { font-family: "Collapse", Arial, sans-serif; }
|
||||
.font-mono-ui { font-family: ui-monospace, "SF Mono", "Cascadia Mono", Menlo, monospace; }
|
||||
|
||||
/* ---- Subtle grain overlay for badges ---- */
|
||||
.grain {
|
||||
position: relative;
|
||||
}
|
||||
.grain::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
opacity: 0.12;
|
||||
pointer-events: none;
|
||||
background: repeating-conic-gradient(currentColor 0% 25%, #0000 0% 50%) 0 0 / 2px 2px;
|
||||
}
|
||||
|
||||
/* ---- Global noise grain (canonical: color-dodge, #eaeaea, high density) ---- */
|
||||
.noise-overlay {
|
||||
pointer-events: none;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 101;
|
||||
mix-blend-mode: color-dodge;
|
||||
opacity: 0.10;
|
||||
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 512 512' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' fill='%23eaeaea' filter='url(%23n)' opacity='0.6'/%3E%3C/svg%3E");
|
||||
background-size: 512px 512px;
|
||||
}
|
||||
|
||||
/* ---- Vignette (canonical: top-left amber radial, lighten blend) ---- */
|
||||
.warm-glow {
|
||||
pointer-events: none;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 99;
|
||||
mix-blend-mode: lighten;
|
||||
opacity: 0.22;
|
||||
background: radial-gradient(ellipse at 0% 0%, rgba(255,189,56,0.35) 0%, rgba(255,189,56,0) 60%);
|
||||
}
|
||||
|
||||
/* ---- Reduced motion ---- */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
const BASE = "";
|
||||
|
||||
// Ephemeral session token for protected endpoints (reveal).
|
||||
// Fetched once on first reveal request and cached in memory.
|
||||
let _sessionToken: string | null = null;
|
||||
|
||||
async function fetchJSON<T>(url: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${BASE}${url}`, init);
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => res.statusText);
|
||||
throw new Error(`${res.status}: ${text}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function getSessionToken(): Promise<string> {
|
||||
if (_sessionToken) return _sessionToken;
|
||||
const resp = await fetchJSON<{ token: string }>("/api/auth/session-token");
|
||||
_sessionToken = resp.token;
|
||||
return _sessionToken;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
getStatus: () => fetchJSON<StatusResponse>("/api/status"),
|
||||
getSessions: () => fetchJSON<SessionInfo[]>("/api/sessions"),
|
||||
getSessionMessages: (id: string) =>
|
||||
fetchJSON<SessionMessagesResponse>(`/api/sessions/${encodeURIComponent(id)}/messages`),
|
||||
deleteSession: (id: string) =>
|
||||
fetchJSON<{ ok: boolean }>(`/api/sessions/${encodeURIComponent(id)}`, {
|
||||
method: "DELETE",
|
||||
}),
|
||||
getLogs: (params: { file?: string; lines?: number; level?: string; component?: string }) => {
|
||||
const qs = new URLSearchParams();
|
||||
if (params.file) qs.set("file", params.file);
|
||||
if (params.lines) qs.set("lines", String(params.lines));
|
||||
if (params.level && params.level !== "ALL") qs.set("level", params.level);
|
||||
if (params.component && params.component !== "all") qs.set("component", params.component);
|
||||
return fetchJSON<LogsResponse>(`/api/logs?${qs.toString()}`);
|
||||
},
|
||||
getAnalytics: (days: number) =>
|
||||
fetchJSON<AnalyticsResponse>(`/api/analytics/usage?days=${days}`),
|
||||
getConfig: () => fetchJSON<Record<string, unknown>>("/api/config"),
|
||||
getDefaults: () => fetchJSON<Record<string, unknown>>("/api/config/defaults"),
|
||||
getSchema: () => fetchJSON<{ fields: Record<string, unknown>; category_order: string[] }>("/api/config/schema"),
|
||||
saveConfig: (config: Record<string, unknown>) =>
|
||||
fetchJSON<{ ok: boolean }>("/api/config", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ config }),
|
||||
}),
|
||||
getConfigRaw: () => fetchJSON<{ yaml: string }>("/api/config/raw"),
|
||||
saveConfigRaw: (yaml_text: string) =>
|
||||
fetchJSON<{ ok: boolean }>("/api/config/raw", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ yaml_text }),
|
||||
}),
|
||||
getEnvVars: () => fetchJSON<Record<string, EnvVarInfo>>("/api/env"),
|
||||
setEnvVar: (key: string, value: string) =>
|
||||
fetchJSON<{ ok: boolean }>("/api/env", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ key, value }),
|
||||
}),
|
||||
deleteEnvVar: (key: string) =>
|
||||
fetchJSON<{ ok: boolean }>("/api/env", {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ key }),
|
||||
}),
|
||||
revealEnvVar: async (key: string) => {
|
||||
const token = await getSessionToken();
|
||||
return fetchJSON<{ key: string; value: string }>("/api/env/reveal", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ key }),
|
||||
});
|
||||
},
|
||||
|
||||
// Cron jobs
|
||||
getCronJobs: () => fetchJSON<CronJob[]>("/api/cron/jobs"),
|
||||
createCronJob: (job: { prompt: string; schedule: string; name?: string; deliver?: string }) =>
|
||||
fetchJSON<CronJob>("/api/cron/jobs", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(job),
|
||||
}),
|
||||
pauseCronJob: (id: string) =>
|
||||
fetchJSON<{ ok: boolean }>(`/api/cron/jobs/${id}/pause`, { method: "POST" }),
|
||||
resumeCronJob: (id: string) =>
|
||||
fetchJSON<{ ok: boolean }>(`/api/cron/jobs/${id}/resume`, { method: "POST" }),
|
||||
triggerCronJob: (id: string) =>
|
||||
fetchJSON<{ ok: boolean }>(`/api/cron/jobs/${id}/trigger`, { method: "POST" }),
|
||||
deleteCronJob: (id: string) =>
|
||||
fetchJSON<{ ok: boolean }>(`/api/cron/jobs/${id}`, { method: "DELETE" }),
|
||||
|
||||
// Skills & Toolsets
|
||||
getSkills: () => fetchJSON<SkillInfo[]>("/api/skills"),
|
||||
toggleSkill: (name: string, enabled: boolean) =>
|
||||
fetchJSON<{ ok: boolean }>("/api/skills/toggle", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name, enabled }),
|
||||
}),
|
||||
getToolsets: () => fetchJSON<ToolsetInfo[]>("/api/tools/toolsets"),
|
||||
|
||||
// Session search (FTS5)
|
||||
searchSessions: (q: string) =>
|
||||
fetchJSON<SessionSearchResponse>(`/api/sessions/search?q=${encodeURIComponent(q)}`),
|
||||
};
|
||||
|
||||
export interface PlatformStatus {
|
||||
error_code?: string;
|
||||
error_message?: string;
|
||||
state: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface StatusResponse {
|
||||
active_sessions: number;
|
||||
config_path: string;
|
||||
config_version: number;
|
||||
env_path: string;
|
||||
gateway_exit_reason: string | null;
|
||||
gateway_pid: number | null;
|
||||
gateway_platforms: Record<string, PlatformStatus>;
|
||||
gateway_running: boolean;
|
||||
gateway_state: string | null;
|
||||
gateway_updated_at: string | null;
|
||||
hermes_home: string;
|
||||
latest_config_version: number;
|
||||
release_date: string;
|
||||
version: string;
|
||||
}
|
||||
|
||||
export interface SessionInfo {
|
||||
id: string;
|
||||
source: string | null;
|
||||
model: string | null;
|
||||
title: string | null;
|
||||
started_at: number;
|
||||
ended_at: number | null;
|
||||
last_active: number;
|
||||
is_active: boolean;
|
||||
message_count: number;
|
||||
tool_call_count: number;
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
preview: string | null;
|
||||
}
|
||||
|
||||
export interface EnvVarInfo {
|
||||
is_set: boolean;
|
||||
redacted_value: string | null;
|
||||
description: string;
|
||||
url: string | null;
|
||||
category: string;
|
||||
is_password: boolean;
|
||||
tools: string[];
|
||||
advanced: boolean;
|
||||
}
|
||||
|
||||
export interface SessionMessage {
|
||||
role: "user" | "assistant" | "system" | "tool";
|
||||
content: string | null;
|
||||
tool_calls?: Array<{
|
||||
id: string;
|
||||
function: { name: string; arguments: string };
|
||||
}>;
|
||||
tool_name?: string;
|
||||
tool_call_id?: string;
|
||||
timestamp?: number;
|
||||
}
|
||||
|
||||
export interface SessionMessagesResponse {
|
||||
session_id: string;
|
||||
messages: SessionMessage[];
|
||||
}
|
||||
|
||||
export interface LogsResponse {
|
||||
file: string;
|
||||
lines: string[];
|
||||
}
|
||||
|
||||
export interface AnalyticsDailyEntry {
|
||||
day: string;
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
cache_read_tokens: number;
|
||||
reasoning_tokens: number;
|
||||
estimated_cost: number;
|
||||
actual_cost: number;
|
||||
sessions: number;
|
||||
}
|
||||
|
||||
export interface AnalyticsModelEntry {
|
||||
model: string;
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
estimated_cost: number;
|
||||
sessions: number;
|
||||
}
|
||||
|
||||
export interface AnalyticsResponse {
|
||||
daily: AnalyticsDailyEntry[];
|
||||
by_model: AnalyticsModelEntry[];
|
||||
totals: {
|
||||
total_input: number;
|
||||
total_output: number;
|
||||
total_cache_read: number;
|
||||
total_reasoning: number;
|
||||
total_estimated_cost: number;
|
||||
total_actual_cost: number;
|
||||
total_sessions: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface CronJob {
|
||||
id: string;
|
||||
name?: string;
|
||||
prompt: string;
|
||||
schedule: string;
|
||||
status: "enabled" | "paused" | "error";
|
||||
deliver?: string;
|
||||
last_run_at?: string | null;
|
||||
next_run_at?: string | null;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
export interface SkillInfo {
|
||||
name: string;
|
||||
description: string;
|
||||
category: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface ToolsetInfo {
|
||||
name: string;
|
||||
label: string;
|
||||
description: string;
|
||||
enabled: boolean;
|
||||
configured: boolean;
|
||||
tools: string[];
|
||||
}
|
||||
|
||||
export interface SessionSearchResult {
|
||||
session_id: string;
|
||||
snippet: string;
|
||||
role: string | null;
|
||||
source: string | null;
|
||||
model: string | null;
|
||||
session_started: number | null;
|
||||
}
|
||||
|
||||
export interface SessionSearchResponse {
|
||||
results: SessionSearchResult[];
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
export function getNestedValue(obj: Record<string, unknown>, path: string): unknown {
|
||||
const parts = path.split(".");
|
||||
let cur: unknown = obj;
|
||||
for (const p of parts) {
|
||||
if (cur == null || typeof cur !== "object") return undefined;
|
||||
cur = (cur as Record<string, unknown>)[p];
|
||||
}
|
||||
return cur;
|
||||
}
|
||||
|
||||
export function setNestedValue(obj: Record<string, unknown>, path: string, value: unknown): Record<string, unknown> {
|
||||
const clone = structuredClone(obj);
|
||||
const parts = path.split(".");
|
||||
let cur: Record<string, unknown> = clone;
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
if (cur[parts[i]] == null || typeof cur[parts[i]] !== "object") {
|
||||
cur[parts[i]] = {};
|
||||
}
|
||||
cur = cur[parts[i]] as Record<string, unknown>;
|
||||
}
|
||||
cur[parts[parts.length - 1]] = value;
|
||||
return clone;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
/** Relative time from a Unix epoch timestamp (seconds). */
|
||||
export function timeAgo(ts: number): string {
|
||||
const delta = Date.now() / 1000 - ts;
|
||||
if (delta < 60) return "just now";
|
||||
if (delta < 3600) return `${Math.floor(delta / 60)}m ago`;
|
||||
if (delta < 86400) return `${Math.floor(delta / 3600)}h ago`;
|
||||
if (delta < 172800) return "yesterday";
|
||||
return `${Math.floor(delta / 86400)}d ago`;
|
||||
}
|
||||
|
||||
/** Relative time from an ISO-8601 timestamp string. */
|
||||
export function isoTimeAgo(iso: string): string {
|
||||
const delta = (Date.now() - new Date(iso).getTime()) / 1000;
|
||||
if (delta < 0 || Number.isNaN(delta)) return "unknown";
|
||||
if (delta < 60) return "just now";
|
||||
if (delta < 3600) return `${Math.floor(delta / 60)}m ago`;
|
||||
if (delta < 86400) return `${Math.floor(delta / 3600)}h ago`;
|
||||
return `${Math.floor(delta / 86400)}d ago`;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import "./index.css";
|
||||
import App from "./App";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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][];
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"types": ["vite/client"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Path aliases */
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import path from "path";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: "../hermes_cli/web_dist",
|
||||
emptyOutDir: true,
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
"/api": "http://127.0.0.1:9119",
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user