mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-28 01:21:43 +00:00
feat: web UI dashboard for managing Hermes Agent (#8756)
* feat: web UI dashboard for managing Hermes Agent (salvage of #8204/#7621) Adds an embedded web UI dashboard accessible via `hermes web`: - Status page: agent version, active sessions, gateway status, connected platforms - Config editor: schema-driven form with tabbed categories, import/export, reset - API Keys page: set, clear, and view redacted values with category grouping - Sessions, Skills, Cron, Logs, and Analytics pages Backend: - hermes_cli/web_server.py: FastAPI server with REST endpoints - hermes_cli/config.py: reload_env() utility for hot-reloading .env - hermes_cli/main.py: `hermes web` subcommand (--port, --host, --no-open) - cli.py / commands.py: /reload slash command for .env hot-reload - pyproject.toml: [web] optional dependency extra (fastapi + uvicorn) - Both update paths (git + zip) auto-build web frontend when npm available Frontend: - Vite + React + TypeScript + Tailwind v4 SPA in web/ - shadcn/ui-style components, Nous design language - Auto-refresh status page, toast notifications, masked password inputs Security: - Path traversal guard (resolve().is_relative_to()) on SPA file serving - CORS localhost-only via allow_origin_regex - Generic error messages (no internal leak), SessionDB handles closed properly Tests: 47 tests covering reload_env, redact_key, API endpoints, schema generation, path traversal, category merging, internal key stripping, and full config round-trip. Original work by @austinpickett (PR #1813), salvaged by @kshitijk4poor (PR #7621 → #8204), re-salvaged onto current main with stale-branch regressions removed. * fix(web): clean up status page cards, always rebuild on `hermes web` - Remove config version migration alert banner from status page - Remove config version card (internal noise, not surfaced in TUI) - Reorder status cards: Agent → Gateway → Active Sessions (3-col grid) - `hermes web` now always rebuilds from source before serving, preventing stale web_dist when editing frontend files * feat(web): full-text search across session messages - Add GET /api/sessions/search endpoint backed by FTS5 - Auto-append prefix wildcards so partial words match (e.g. 'nimb' → 'nimby') - Debounced search (300ms) with spinner in the search icon slot - Search results show FTS5 snippets with highlighted match delimiters - Expanding a search hit auto-scrolls to the first matching message - Matching messages get a warning ring + 'match' badge - Inline term highlighting within Markdown (text, bold, italic, headings, lists) - Clear button (x) on search input for quick reset --------- Co-authored-by: emozilla <emozilla@nousresearch.com>
This commit is contained in:
parent
c052cf0eea
commit
e2a9b5369f
55 changed files with 10187 additions and 3 deletions
175
web/src/pages/LogsPage.tsx
Normal file
175
web/src/pages/LogsPage.tsx
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
import { useEffect, useState, useCallback, useRef } from "react";
|
||||
import { FileText, RefreshCw } from "lucide-react";
|
||||
import { api } from "@/lib/api";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
const FILES = ["agent", "errors", "gateway"] as const;
|
||||
const LEVELS = ["ALL", "DEBUG", "INFO", "WARNING", "ERROR"] as const;
|
||||
const COMPONENTS = ["all", "gateway", "agent", "tools", "cli", "cron"] as const;
|
||||
const LINE_COUNTS = [50, 100, 200, 500] as const;
|
||||
|
||||
function classifyLine(line: string): "error" | "warning" | "info" | "debug" {
|
||||
const upper = line.toUpperCase();
|
||||
if (upper.includes("ERROR") || upper.includes("CRITICAL") || upper.includes("FATAL")) return "error";
|
||||
if (upper.includes("WARNING") || upper.includes("WARN")) return "warning";
|
||||
if (upper.includes("DEBUG")) return "debug";
|
||||
return "info";
|
||||
}
|
||||
|
||||
const LINE_COLORS: Record<string, string> = {
|
||||
error: "text-destructive",
|
||||
warning: "text-warning",
|
||||
info: "text-foreground",
|
||||
debug: "text-muted-foreground/60",
|
||||
};
|
||||
|
||||
function FilterBar<T extends string>({
|
||||
label,
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
options: readonly T[];
|
||||
value: T;
|
||||
onChange: (v: T) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs text-muted-foreground font-medium w-20 shrink-0">{label}</span>
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{options.map((opt) => (
|
||||
<Button
|
||||
key={opt}
|
||||
variant={value === opt ? "default" : "outline"}
|
||||
size="sm"
|
||||
className="text-xs h-7 px-2.5"
|
||||
onClick={() => onChange(opt)}
|
||||
>
|
||||
{opt}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function LogsPage() {
|
||||
const [file, setFile] = useState<(typeof FILES)[number]>("agent");
|
||||
const [level, setLevel] = useState<(typeof LEVELS)[number]>("ALL");
|
||||
const [component, setComponent] = useState<(typeof COMPONENTS)[number]>("all");
|
||||
const [lineCount, setLineCount] = useState<(typeof LINE_COUNTS)[number]>(100);
|
||||
const [autoRefresh, setAutoRefresh] = useState(false);
|
||||
const [lines, setLines] = useState<string[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const fetchLogs = useCallback(() => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
api
|
||||
.getLogs({ file, lines: lineCount, level, component })
|
||||
.then((resp) => {
|
||||
setLines(resp.lines);
|
||||
// Auto-scroll to bottom
|
||||
setTimeout(() => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
}
|
||||
}, 50);
|
||||
})
|
||||
.catch((err) => setError(String(err)))
|
||||
.finally(() => setLoading(false));
|
||||
}, [file, lineCount, level, component]);
|
||||
|
||||
// Initial load + refetch on filter change
|
||||
useEffect(() => {
|
||||
fetchLogs();
|
||||
}, [fetchLogs]);
|
||||
|
||||
// Auto-refresh polling
|
||||
useEffect(() => {
|
||||
if (!autoRefresh) return;
|
||||
const interval = setInterval(fetchLogs, 5000);
|
||||
return () => clearInterval(interval);
|
||||
}, [autoRefresh, fetchLogs]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="h-5 w-5 text-muted-foreground" />
|
||||
<CardTitle className="text-base">Logs</CardTitle>
|
||||
{loading && (
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={autoRefresh}
|
||||
onCheckedChange={setAutoRefresh}
|
||||
/>
|
||||
<Label className="text-xs">Auto-refresh</Label>
|
||||
{autoRefresh && (
|
||||
<Badge variant="success" className="text-[10px]">
|
||||
<span className="mr-1 inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-current" />
|
||||
Live
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={fetchLogs} className="text-xs h-7">
|
||||
<RefreshCw className="h-3 w-3 mr-1" />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<div className="flex flex-col gap-3 mb-4">
|
||||
<FilterBar label="File" options={FILES} value={file} onChange={setFile} />
|
||||
<FilterBar label="Level" options={LEVELS} value={level} onChange={setLevel} />
|
||||
<FilterBar label="Component" options={COMPONENTS} value={component} onChange={setComponent} />
|
||||
<FilterBar
|
||||
label="Lines"
|
||||
options={LINE_COUNTS.map(String) as unknown as readonly string[]}
|
||||
value={String(lineCount)}
|
||||
onChange={(v) => setLineCount(Number(v) as (typeof LINE_COUNTS)[number])}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-md bg-destructive/10 border border-destructive/20 p-3 mb-4">
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="border border-border bg-background p-4 font-mono-ui text-xs leading-5 overflow-auto max-h-[600px] min-h-[200px]"
|
||||
>
|
||||
{lines.length === 0 && !loading && (
|
||||
<p className="text-muted-foreground text-center py-8">No log lines found</p>
|
||||
)}
|
||||
{lines.map((line, i) => {
|
||||
const cls = classifyLine(line);
|
||||
return (
|
||||
<div key={i} className={`${LINE_COLORS[cls]} hover:bg-secondary/20 px-1 -mx-1 rounded`}>
|
||||
{line}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue