Initial commit: Agentic OS troubleshooting platform
Self-hosted, Docker-based agentic troubleshooting platform: FastAPI backend + LangGraph agent, Next.js UI, tiered LLM routing (local Ollama -> Gemini -> DeepSeek -> OpenRouter), MCP server manager, encrypted device credentials, RBAC, audit log, project-memory + Obsidian integrations, and editable troubleshooting decision rules tuned for the GeneseasX vessel stack. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
119
frontend/components/AppShell.tsx
Normal file
119
frontend/components/AppShell.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useAuth, roleAtLeast } from "@/lib/auth";
|
||||
|
||||
const NAV = [
|
||||
{ href: "/", label: "Dashboard", icon: "▦", min: "readonly" as const },
|
||||
{ href: "/tasks", label: "Tasks", icon: "✦", min: "readonly" as const },
|
||||
{ href: "/inventory", label: "Vessels", icon: "▤", min: "readonly" as const },
|
||||
{ href: "/mcp", label: "MCP Servers", icon: "⚇", min: "readonly" as const },
|
||||
{ href: "/rules", label: "Rules", icon: "☰", min: "support" as const },
|
||||
{ href: "/models", label: "Models", icon: "◈", min: "admin" as const },
|
||||
{ href: "/users", label: "Users", icon: "♟", min: "admin" as const },
|
||||
];
|
||||
|
||||
export default function AppShell({ children }: { children: React.ReactNode }) {
|
||||
const { user, loading, logout } = useAuth();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const [navOpen, setNavOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && !user) router.replace("/login");
|
||||
}, [loading, user, router]);
|
||||
|
||||
// Close the mobile drawer on navigation.
|
||||
useEffect(() => {
|
||||
setNavOpen(false);
|
||||
}, [pathname]);
|
||||
|
||||
if (loading || !user) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center gap-2 text-muted">
|
||||
<span className="inline-block h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
Loading…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const sidebar = (
|
||||
<>
|
||||
<div className="mb-8 flex items-center gap-2 px-2">
|
||||
<div className="grid h-9 w-9 place-items-center rounded-xl bg-gradient-to-br from-accent to-accent2 font-bold text-white">
|
||||
A
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-semibold">Agentic OS</div>
|
||||
<div className="text-[11px] text-muted">troubleshooting</div>
|
||||
</div>
|
||||
</div>
|
||||
<nav className="flex flex-1 flex-col gap-1">
|
||||
{NAV.filter((n) => roleAtLeast(user.role, n.min)).map((n) => {
|
||||
const active = pathname === n.href || (n.href !== "/" && pathname.startsWith(n.href));
|
||||
return (
|
||||
<Link
|
||||
key={n.href}
|
||||
href={n.href}
|
||||
aria-current={active ? "page" : undefined}
|
||||
className={`flex items-center gap-3 rounded-xl px-3 py-2 text-sm transition ${
|
||||
active ? "bg-accent/15 text-white" : "text-slate-300 hover:bg-panel2"
|
||||
}`}
|
||||
>
|
||||
<span className="text-base opacity-80" aria-hidden="true">
|
||||
{n.icon}
|
||||
</span>
|
||||
{n.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
<div className="mt-4 border-t border-edge pt-4">
|
||||
<div className="px-2 text-sm">{user.full_name || user.email}</div>
|
||||
<div className="mb-3 px-2 text-[11px] uppercase tracking-wide text-muted">{user.role}</div>
|
||||
<button onClick={logout} className="btn-ghost w-full">
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen">
|
||||
{/* Mobile top bar */}
|
||||
<header className="fixed inset-x-0 top-0 z-30 flex items-center gap-3 border-b border-edge bg-panel/90 px-4 py-3 backdrop-blur md:hidden">
|
||||
<button
|
||||
aria-label="Open menu"
|
||||
aria-expanded={navOpen}
|
||||
onClick={() => setNavOpen(true)}
|
||||
className="btn-ghost btn-sm"
|
||||
>
|
||||
☰
|
||||
</button>
|
||||
<span className="text-sm font-semibold">Agentic OS</span>
|
||||
</header>
|
||||
|
||||
{/* Mobile drawer */}
|
||||
{navOpen && (
|
||||
<div className="fixed inset-0 z-40 md:hidden" onClick={() => setNavOpen(false)}>
|
||||
<div className="absolute inset-0 bg-black/60" />
|
||||
<aside
|
||||
className="absolute left-0 top-0 flex h-full w-64 flex-col border-r border-edge bg-panel p-4"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{sidebar}
|
||||
</aside>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Desktop sidebar */}
|
||||
<aside className="hidden w-60 flex-col border-r border-edge bg-panel/60 p-4 md:flex">
|
||||
{sidebar}
|
||||
</aside>
|
||||
|
||||
<main className="flex-1 overflow-y-auto p-4 pt-20 md:p-8 md:pt-8">{children}</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
205
frontend/components/TaskLiveStatus.tsx
Normal file
205
frontend/components/TaskLiveStatus.tsx
Normal file
@@ -0,0 +1,205 @@
|
||||
"use client";
|
||||
|
||||
import { humanToolName, taskPhaseLabel, TaskEvent } from "@/lib/task-types";
|
||||
|
||||
const PHASES = [
|
||||
{ id: "context", label: "Context" },
|
||||
{ id: "triage", label: "Plan" },
|
||||
{ id: "connect", label: "Connect" },
|
||||
{ id: "diagnose", label: "Diagnose" },
|
||||
{ id: "reason", label: "Analyze" },
|
||||
{ id: "report", label: "Report" },
|
||||
] as const;
|
||||
|
||||
function phaseIndex(phase: string | undefined): number {
|
||||
if (!phase) return -1;
|
||||
return PHASES.findIndex((p) => p.id === phase);
|
||||
}
|
||||
|
||||
function derivePhase(events: TaskEvent[], taskStatus?: string): string {
|
||||
if (taskStatus === "succeeded" || taskStatus === "waiting_approval") return "report";
|
||||
for (let i = events.length - 1; i >= 0; i--) {
|
||||
const p = events[i].payload?.phase as string | undefined;
|
||||
if (p) return p;
|
||||
const k = events[i].kind;
|
||||
if (k === "context" || k === "rule") return "context";
|
||||
if (k === "triage") return "triage";
|
||||
if (k === "connect") return "connect";
|
||||
if (k === "diagnose" || k === "tool_start" || k === "tool_call") return "diagnose";
|
||||
if (k === "reason") return "reason";
|
||||
if (k === "report") return "report";
|
||||
}
|
||||
return "context";
|
||||
}
|
||||
|
||||
function activeTool(events: TaskEvent[]): TaskEvent | null {
|
||||
for (let i = events.length - 1; i >= 0; i--) {
|
||||
const ev = events[i];
|
||||
if (ev.kind === "tool_start") return ev;
|
||||
if (ev.kind === "tool_call" || ev.kind === "error") return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function TaskLiveStatus({
|
||||
events,
|
||||
taskStatus,
|
||||
}: {
|
||||
events: TaskEvent[];
|
||||
taskStatus: string;
|
||||
}) {
|
||||
const running = taskStatus === "running" || taskStatus === "queued";
|
||||
const currentPhase = derivePhase(events, taskStatus);
|
||||
const currentIdx = phaseIndex(currentPhase);
|
||||
const active = activeTool(events);
|
||||
const lastProgress = [...events].reverse().find((e) => e.kind === "progress");
|
||||
const devices = Array.from(
|
||||
new Set(
|
||||
events
|
||||
.map((e) => e.payload?.device)
|
||||
.filter((device): device is string => typeof device === "string" && !!device)
|
||||
)
|
||||
);
|
||||
const nowDoing = active
|
||||
? `${active.payload?.device || "Device"}: ${humanToolName(String(active.payload?.tool || "diagnostic"))}`
|
||||
: taskPhaseLabel(taskStatus, events);
|
||||
|
||||
return (
|
||||
<div className="card mb-6 border-accent/30 bg-gradient-to-br from-panel to-panel2">
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="font-semibold">Task status</h2>
|
||||
<p className="mt-1 text-sm text-muted">{nowDoing}</p>
|
||||
</div>
|
||||
{running && (
|
||||
<span className="flex items-center gap-2 text-xs text-accent">
|
||||
<span className="inline-block h-2 w-2 animate-pulse rounded-full bg-accent" />
|
||||
In progress
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mb-4 grid grid-cols-2 gap-2 md:grid-cols-6">
|
||||
{PHASES.map((phase, idx) => {
|
||||
const done = !running && currentIdx >= 0 ? idx <= currentIdx : idx < currentIdx;
|
||||
const activePhase = idx === currentIdx && running;
|
||||
return (
|
||||
<div
|
||||
key={phase.id}
|
||||
className={`rounded-lg px-3 py-2 text-center text-xs transition-colors ${
|
||||
activePhase
|
||||
? "bg-accent/20 text-accent ring-1 ring-accent/40"
|
||||
: done
|
||||
? "bg-good/10 text-good"
|
||||
: "bg-ink/40 text-muted"
|
||||
}`}
|
||||
>
|
||||
<span className="font-medium">{phase.label}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{devices.length > 0 && (
|
||||
<div className="mb-4 flex flex-wrap gap-2">
|
||||
{devices.map((device) => (
|
||||
<span key={device} className="badge bg-panel text-xs text-accent">
|
||||
{device}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(lastProgress?.message || active) && (
|
||||
<div className="rounded-lg bg-ink/50 p-3">
|
||||
{lastProgress?.message && (
|
||||
<p className="text-sm text-slate-200">{lastProgress.message}</p>
|
||||
)}
|
||||
{active && (
|
||||
<div className="mt-2 border-t border-edge/50 pt-2">
|
||||
<div className="text-xs uppercase tracking-wide text-accent2">Running now</div>
|
||||
<div className="mt-1 font-mono text-sm">
|
||||
{(active.payload?.device as string) || "?"} - {humanToolName((active.payload?.tool as string) || "tool")}
|
||||
</div>
|
||||
{typeof active.payload?.command === "string" && (
|
||||
<pre className="mt-2 overflow-x-auto rounded bg-black/40 p-2 font-mono text-[11px] text-emerald-300/90">
|
||||
$ {active.payload.command}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function eventTime(ev: TaskEvent) {
|
||||
const raw = ev.ts || ev.created_at;
|
||||
if (!raw) return "";
|
||||
try {
|
||||
return new Date(raw).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
export function TimelineEvent({ ev }: { ev: TaskEvent }) {
|
||||
const p = ev.payload || {};
|
||||
const isError = ev.kind === "error" || p.status === "error" || p.ok === false;
|
||||
const isRunning = p.status === "running" || ev.kind === "tool_start";
|
||||
const isDone = p.ok === true || p.status === "done";
|
||||
|
||||
const border = isError
|
||||
? "border-l-2 border-l-bad"
|
||||
: isRunning
|
||||
? "border-l-2 border-l-accent animate-pulse"
|
||||
: isDone
|
||||
? "border-l-2 border-l-good"
|
||||
: "border-l-2 border-l-transparent";
|
||||
|
||||
return (
|
||||
<div className={`rounded-lg bg-panel2 px-3 py-2 ${border}`}>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={isError ? "text-bad" : isRunning ? "text-accent" : "text-accent2"}>
|
||||
{isError ? "!" : isRunning ? ">" : "-"}
|
||||
</span>
|
||||
<span className="text-xs uppercase tracking-wide text-muted">{ev.kind.replace("_", " ")}</span>
|
||||
{eventTime(ev) && <span className="text-[10px] text-muted">{eventTime(ev)}</span>}
|
||||
</div>
|
||||
{typeof p.phase === "string" && (
|
||||
<span className="badge bg-panel text-[10px] text-muted">{p.phase}</span>
|
||||
)}
|
||||
{p.ok === false && <span className="text-[10px] text-bad">fail</span>}
|
||||
{isRunning && <span className="text-[10px] text-accent">running…</span>}
|
||||
</div>
|
||||
{ev.message && <div className="mt-1 text-sm">{ev.message}</div>}
|
||||
{typeof p.tool === "string" && (
|
||||
<div className="mt-1 text-xs text-muted">
|
||||
{typeof p.device === "string" ? `${p.device} - ` : ""}
|
||||
{humanToolName(p.tool)}
|
||||
</div>
|
||||
)}
|
||||
{typeof p.command === "string" && (
|
||||
<details className="mt-2">
|
||||
<summary className="cursor-pointer text-xs text-muted">Command</summary>
|
||||
<pre className="mt-2 overflow-x-auto rounded bg-black/50 p-2 font-mono text-[11px] text-emerald-300/90">
|
||||
$ {p.command}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
{typeof p.output === "string" && p.output && (
|
||||
<details className="mt-2">
|
||||
<summary className="cursor-pointer text-xs text-muted">Evidence output</summary>
|
||||
<pre className="mt-2 max-h-40 overflow-auto rounded bg-ink/70 p-2 text-[11px] text-slate-300">
|
||||
{p.output}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
{typeof p.error === "string" && (
|
||||
<pre className="mt-2 text-[11px] text-bad">{p.error}</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
23
frontend/components/providers.tsx
Normal file
23
frontend/components/providers.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import { SWRConfig } from "swr";
|
||||
import { AuthProvider } from "@/lib/auth";
|
||||
import { ToastProvider } from "@/components/toast";
|
||||
|
||||
export default function Providers({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<SWRConfig
|
||||
value={{
|
||||
revalidateOnFocus: true,
|
||||
refreshWhenHidden: false,
|
||||
refreshWhenOffline: false,
|
||||
dedupingInterval: 2000,
|
||||
errorRetryCount: 3,
|
||||
}}
|
||||
>
|
||||
<AuthProvider>
|
||||
<ToastProvider>{children}</ToastProvider>
|
||||
</AuthProvider>
|
||||
</SWRConfig>
|
||||
);
|
||||
}
|
||||
354
frontend/components/task-detail-panels.tsx
Normal file
354
frontend/components/task-detail-panels.tsx
Normal file
@@ -0,0 +1,354 @@
|
||||
"use client";
|
||||
|
||||
import type React from "react";
|
||||
import { StatusBadge } from "@/components/ui";
|
||||
import { TimelineEvent } from "@/components/TaskLiveStatus";
|
||||
import { humanToolName, TaskEvent } from "@/lib/task-types";
|
||||
|
||||
export type LlmUsage = {
|
||||
step: string;
|
||||
provider: string;
|
||||
backend: string;
|
||||
model: string;
|
||||
display: string;
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
cost_usd: number;
|
||||
};
|
||||
|
||||
export type RunHistory = {
|
||||
run_number: number;
|
||||
follow_up?: string | null;
|
||||
summary?: string;
|
||||
resolved?: boolean;
|
||||
run_cost_usd?: number;
|
||||
llm_usage?: LlmUsage[];
|
||||
};
|
||||
|
||||
export function formatCost(usd: number | undefined | null) {
|
||||
if (usd === undefined || usd === null) return "$0.0000";
|
||||
if (usd === 0) return "$0.00 (local)";
|
||||
return `$${usd.toFixed(4)}`;
|
||||
}
|
||||
|
||||
export function TaskScopeCard({ task, report }: { task: any; report: any }) {
|
||||
const rule = report?.matched_rule;
|
||||
const checked = report?.devices_checked || [];
|
||||
return (
|
||||
<div className="card">
|
||||
<h2 className="mb-3 font-semibold">Scope</h2>
|
||||
<div className="space-y-3 text-sm">
|
||||
<div>
|
||||
<div className="label">Requested</div>
|
||||
<p className="text-muted">{task.issue}</p>
|
||||
</div>
|
||||
<div>
|
||||
<div className="label">Devices selected</div>
|
||||
<div className="mt-1 flex flex-wrap gap-2">
|
||||
{checked.length ? (
|
||||
checked.map((device: string) => (
|
||||
<span key={device} className="badge bg-accent/10 text-accent">
|
||||
{device}
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<span className="text-muted">Selection pending</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{rule && (
|
||||
<div>
|
||||
<div className="label">Rule</div>
|
||||
<p className="text-muted">{rule.name || rule.id}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TaskTimelinePanel({
|
||||
events,
|
||||
wsConnected,
|
||||
pinnedToBottom,
|
||||
scrollBoxRef,
|
||||
endRef,
|
||||
onScroll,
|
||||
onJump,
|
||||
loading,
|
||||
}: {
|
||||
events: TaskEvent[];
|
||||
wsConnected: boolean;
|
||||
pinnedToBottom: boolean;
|
||||
scrollBoxRef: React.RefObject<HTMLDivElement>;
|
||||
endRef: React.RefObject<HTMLDivElement>;
|
||||
onScroll: () => void;
|
||||
onJump: () => void;
|
||||
loading: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="font-semibold">Run Timeline</h2>
|
||||
<p className="mt-1 text-xs text-muted">Plain-language progress; expand events only when evidence is needed.</p>
|
||||
</div>
|
||||
<span className={`badge text-xs ${wsConnected ? "bg-good/15 text-good" : "bg-warn/15 text-warn"}`}>
|
||||
{wsConnected ? "live" : "reconnecting"}
|
||||
</span>
|
||||
</div>
|
||||
<div ref={scrollBoxRef} onScroll={onScroll} className="max-h-[520px] space-y-2 overflow-y-auto pr-1">
|
||||
{events.map((ev, i) => (
|
||||
<TimelineEvent key={`${ev.id || ""}-${ev.ts || ev.created_at || ""}-${ev.kind}-${i}`} ev={ev} />
|
||||
))}
|
||||
{events.length === 0 && <div className="text-sm text-muted">{loading ? "Loading..." : "Waiting for events..."}</div>}
|
||||
<div ref={endRef} />
|
||||
</div>
|
||||
{!pinnedToBottom && events.length > 0 && (
|
||||
<button className="btn-ghost btn-sm mt-2" onClick={onJump}>
|
||||
Jump to latest
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MarkdownTable({ text }: { text: string }) {
|
||||
const lines = text.trim().split("\n");
|
||||
const tableLines = lines.filter((line) => line.trim().startsWith("|") && line.trim().endsWith("|"));
|
||||
if (tableLines.length < 2) return null;
|
||||
const rows = tableLines
|
||||
.filter((line) => !/^\|\s*-+/.test(line))
|
||||
.map((line) => line.split("|").slice(1, -1).map((cell) => cell.trim()));
|
||||
const [head, ...body] = rows;
|
||||
if (!head || body.length === 0) return null;
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-xs">
|
||||
<thead>
|
||||
<tr className="border-b border-edge text-muted">
|
||||
{head.map((cell, i) => (
|
||||
<th key={i} className="py-2 pr-3 font-medium">
|
||||
{cell}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{body.map((row, i) => (
|
||||
<tr key={i} className="border-b border-edge/50">
|
||||
{row.map((cell, j) => (
|
||||
<td key={j} className="py-2 pr-3 align-top">
|
||||
{cell}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DiagnosticEvidence({ diagnostic }: { diagnostic: any }) {
|
||||
const text = diagnostic.formatted || diagnostic.summary || diagnostic.output || "_No output_";
|
||||
const table = typeof text === "string" ? <MarkdownTable text={text} /> : null;
|
||||
return (
|
||||
<details className="rounded-lg bg-panel2 p-3">
|
||||
<summary className="cursor-pointer">
|
||||
<span className="mr-2 font-mono text-xs text-accent2">
|
||||
[{diagnostic.device}] {humanToolName(diagnostic.tool || "tool")}
|
||||
</span>
|
||||
<StatusBadge status={diagnostic.ok ? "succeeded" : "failed"} />
|
||||
{diagnostic.output_chars ? <span className="ml-2 text-xs text-muted">{diagnostic.output_chars} chars</span> : null}
|
||||
</summary>
|
||||
<div className="mt-3">
|
||||
{table || (
|
||||
<pre className="max-h-80 overflow-auto whitespace-pre-wrap rounded bg-ink/70 p-2 font-mono text-xs text-muted">
|
||||
{text}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
export function TaskReportPanel({ report, pendingApprovals }: { report: any; pendingApprovals: number }) {
|
||||
const summary = report.executive_summary || report.summary;
|
||||
const findings = report.findings || [];
|
||||
const scope = report.scope_checked || report.devices_checked || [];
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<h2 className="font-semibold">Report</h2>
|
||||
<StatusBadge status={pendingApprovals > 0 ? "waiting_approval" : report.resolved ? "succeeded" : "running"} />
|
||||
</div>
|
||||
<div className="space-y-4 text-sm">
|
||||
{summary && (
|
||||
<section>
|
||||
<div className="label">Summary</div>
|
||||
<p>{summary}</p>
|
||||
</section>
|
||||
)}
|
||||
{scope.length > 0 && (
|
||||
<section>
|
||||
<div className="label">Scope checked</div>
|
||||
<div className="mt-1 flex flex-wrap gap-2">
|
||||
{scope.map((item: any) => (
|
||||
<span key={typeof item === "string" ? item : item.device} className="badge bg-panel2 text-xs text-accent">
|
||||
{typeof item === "string" ? item : item.device}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
{findings.length > 0 ? (
|
||||
<section>
|
||||
<div className="label">Findings</div>
|
||||
<div className="space-y-2">
|
||||
{findings.map((finding: any, i: number) => (
|
||||
<div key={i} className="rounded-lg bg-panel2 p-3">
|
||||
<div className="font-medium">{finding.title || finding.summary || String(finding)}</div>
|
||||
{finding.description && <p className="mt-1 text-muted">{finding.description}</p>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : (
|
||||
<section>
|
||||
<div className="label">Finding</div>
|
||||
<p>{report.root_cause || report.resolution || "No notable finding recorded."}</p>
|
||||
</section>
|
||||
)}
|
||||
{report.recommendations?.length > 0 && (
|
||||
<section>
|
||||
<div className="label">Recommendations</div>
|
||||
<ul className="list-disc space-y-1 pl-5">
|
||||
{report.recommendations.map((r: { description?: string }, i: number) => (
|
||||
<li key={i}>{r.description || JSON.stringify(r)}</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
{report.actions_taken?.length > 0 && (
|
||||
<details>
|
||||
<summary className="cursor-pointer text-sm font-medium text-muted">Actions taken</summary>
|
||||
<ul className="mt-2 list-disc space-y-1 pl-5">
|
||||
{report.actions_taken.map((s: string, i: number) => (
|
||||
<li key={i}>{s}</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
)}
|
||||
{report.diagnostics?.length > 0 && (
|
||||
<section>
|
||||
<div className="label">Evidence</div>
|
||||
<div className="space-y-2">
|
||||
{report.diagnostics.map((d: any, i: number) => (
|
||||
<DiagnosticEvidence key={i} diagnostic={d} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TaskArtifactsPanel({ task, report }: { task: any; report: any }) {
|
||||
return (
|
||||
<div className="card">
|
||||
<h2 className="mb-3 font-semibold">Saved Artifacts</h2>
|
||||
<div className="space-y-2 text-sm">
|
||||
<ArtifactRow label="Memory" value={task?.memory_id || report?.memory_id} detail={report?.memory_project_id || "agentic-os-task"} />
|
||||
<ArtifactRow
|
||||
label="Obsidian"
|
||||
value={task?.obsidian_path || report?.obsidian_path}
|
||||
detail={report?.obsidian_push_error ? "push failed" : "pushed"}
|
||||
bad={!!report?.obsidian_push_error}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ArtifactRow({ label, value, detail, bad = false }: { label: string; value?: string | null; detail?: string; bad?: boolean }) {
|
||||
return (
|
||||
<div className="rounded-lg bg-panel2 p-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="font-medium">{label}</span>
|
||||
<span className={`text-xs ${bad ? "text-bad" : "text-muted"}`}>{detail}</span>
|
||||
</div>
|
||||
<div className="mt-1 break-all font-mono text-xs text-muted">{value || "Not saved"}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function providerLabel(provider: string) {
|
||||
return provider === "local" ? "Local" : "API";
|
||||
}
|
||||
|
||||
export function TaskCostPanel({ report, llmUsage }: { report: any; llmUsage: LlmUsage[] }) {
|
||||
return (
|
||||
<div className="card">
|
||||
<h2 className="mb-3 font-semibold">Models & Cost</h2>
|
||||
<div className="mb-3 flex flex-wrap gap-4 text-sm">
|
||||
<div>
|
||||
<div className="label">This run</div>
|
||||
<div className="font-mono">{formatCost(report.run_cost_usd)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="label">Total</div>
|
||||
<div className="font-mono">{formatCost(report.total_cost_usd)}</div>
|
||||
</div>
|
||||
</div>
|
||||
{llmUsage.length > 0 ? (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-xs">
|
||||
<thead>
|
||||
<tr className="border-b border-edge text-muted">
|
||||
<th className="py-2 pr-3">Step</th>
|
||||
<th className="py-2 pr-3">Type</th>
|
||||
<th className="py-2 pr-3">Model</th>
|
||||
<th className="py-2">Cost</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{llmUsage.map((u, i) => (
|
||||
<tr key={i} className="border-b border-edge/50">
|
||||
<td className="py-2 pr-3 capitalize">{u.step}</td>
|
||||
<td className="py-2 pr-3">{providerLabel(u.provider)}</td>
|
||||
<td className="py-2 pr-3 font-mono">{u.display || u.model}</td>
|
||||
<td className="py-2 font-mono">{formatCost(u.cost_usd)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted">No LLM usage recorded.</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TaskRunHistoryPanel({ runs }: { runs: RunHistory[] }) {
|
||||
if (runs.length <= 1) return null;
|
||||
return (
|
||||
<div className="card">
|
||||
<h2 className="mb-3 font-semibold">Run History</h2>
|
||||
<div className="space-y-3">
|
||||
{runs.map((r) => (
|
||||
<div key={r.run_number} className="rounded-lg bg-panel2 p-3 text-sm">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="font-medium">Run {r.run_number}</span>
|
||||
<span className="text-xs text-muted">{formatCost(r.run_cost_usd)}</span>
|
||||
</div>
|
||||
{r.follow_up && <p className="mt-1 text-muted">Follow-up: {r.follow_up}</p>}
|
||||
{r.summary && <p className="mt-1">{r.summary}</p>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
75
frontend/components/toast.tsx
Normal file
75
frontend/components/toast.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useCallback, useContext, useEffect, useState } from "react";
|
||||
|
||||
type ToastKind = "success" | "error" | "info";
|
||||
type Toast = { id: number; kind: ToastKind; message: string };
|
||||
|
||||
type ToastContextValue = {
|
||||
toast: (message: string, kind?: ToastKind) => void;
|
||||
success: (message: string) => void;
|
||||
error: (message: string) => void;
|
||||
};
|
||||
|
||||
const ToastContext = createContext<ToastContextValue | null>(null);
|
||||
|
||||
let _id = 0;
|
||||
|
||||
export function ToastProvider({ children }: { children: React.ReactNode }) {
|
||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||
|
||||
const remove = useCallback((id: number) => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||
}, []);
|
||||
|
||||
const toast = useCallback(
|
||||
(message: string, kind: ToastKind = "info") => {
|
||||
const id = ++_id;
|
||||
setToasts((prev) => [...prev, { id, kind, message }]);
|
||||
setTimeout(() => remove(id), 4500);
|
||||
},
|
||||
[remove]
|
||||
);
|
||||
|
||||
const value: ToastContextValue = {
|
||||
toast,
|
||||
success: (m) => toast(m, "success"),
|
||||
error: (m) => toast(m, "error"),
|
||||
};
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={value}>
|
||||
{children}
|
||||
<div
|
||||
className="pointer-events-none fixed inset-x-0 bottom-4 z-[100] flex flex-col items-center gap-2 px-4"
|
||||
aria-live="polite"
|
||||
role="status"
|
||||
>
|
||||
{toasts.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
onClick={() => remove(t.id)}
|
||||
className={`pointer-events-auto w-full max-w-md rounded-xl border px-4 py-3 text-left text-sm shadow-lg backdrop-blur transition ${
|
||||
t.kind === "success"
|
||||
? "border-good/40 bg-good/15 text-good"
|
||||
: t.kind === "error"
|
||||
? "border-bad/40 bg-bad/15 text-bad"
|
||||
: "border-edge bg-panel/90 text-slate-200"
|
||||
}`}
|
||||
>
|
||||
{t.message}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useToast(): ToastContextValue {
|
||||
const ctx = useContext(ToastContext);
|
||||
if (!ctx) {
|
||||
// No-op fallback so components don't crash outside the provider.
|
||||
return { toast: () => {}, success: () => {}, error: () => {} };
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
226
frontend/components/ui.tsx
Normal file
226
frontend/components/ui.tsx
Normal file
@@ -0,0 +1,226 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import Link from "next/link";
|
||||
|
||||
export function PageHeader({
|
||||
title,
|
||||
subtitle,
|
||||
action,
|
||||
backHref,
|
||||
backLabel,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
action?: React.ReactNode;
|
||||
backHref?: string;
|
||||
backLabel?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
{backHref && (
|
||||
<Link
|
||||
href={backHref}
|
||||
className="mb-1 inline-block text-xs text-muted hover:text-accent"
|
||||
>
|
||||
← {backLabel || "Back"}
|
||||
</Link>
|
||||
)}
|
||||
<h1 className="text-2xl font-semibold">{title}</h1>
|
||||
{subtitle && <p className="mt-1 break-words text-sm text-muted">{subtitle}</p>}
|
||||
</div>
|
||||
{action && <div className="shrink-0">{action}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Spinner({ className = "" }: { className?: string }) {
|
||||
return (
|
||||
<span
|
||||
className={`inline-block h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent ${className}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function SkeletonRows({ rows = 3 }: { rows?: number }) {
|
||||
return (
|
||||
<div className="space-y-3" aria-hidden="true">
|
||||
{Array.from({ length: rows }).map((_, i) => (
|
||||
<div key={i} className="h-16 animate-pulse rounded-2xl border border-edge bg-panel2/50" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function EmptyState({
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
}: {
|
||||
title: string;
|
||||
description?: string;
|
||||
action?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="card flex flex-col items-center gap-2 py-10 text-center">
|
||||
<div className="text-sm font-medium">{title}</div>
|
||||
{description && <div className="max-w-sm text-sm text-muted">{description}</div>}
|
||||
{action && <div className="mt-2">{action}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function LoadError({
|
||||
message = "Couldn’t load data.",
|
||||
onRetry,
|
||||
}: {
|
||||
message?: string;
|
||||
onRetry?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="card border-bad/40 text-center">
|
||||
<div className="text-sm text-bad">{message}</div>
|
||||
{onRetry && (
|
||||
<button className="btn-ghost btn-sm mt-3" onClick={onRetry}>
|
||||
Retry
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FormError({ message }: { message?: string | null }) {
|
||||
if (!message) return null;
|
||||
return <div className="text-sm text-bad">{message}</div>;
|
||||
}
|
||||
|
||||
const STATUS_STYLES: Record<string, string> = {
|
||||
queued: "bg-slate-500/20 text-slate-300",
|
||||
running: "bg-accent/20 text-accent",
|
||||
waiting_approval: "bg-warn/20 text-warn",
|
||||
succeeded: "bg-good/20 text-good",
|
||||
failed: "bg-bad/20 text-bad",
|
||||
cancelled: "bg-slate-500/20 text-slate-400",
|
||||
loaded: "bg-good/20 text-good",
|
||||
error: "bg-bad/20 text-bad",
|
||||
starting: "bg-warn/20 text-warn",
|
||||
cloning: "bg-accent/20 text-accent",
|
||||
stopped: "bg-slate-500/20 text-slate-400",
|
||||
};
|
||||
|
||||
export function StatusBadge({ status }: { status: string }) {
|
||||
return (
|
||||
<span className={`badge ${STATUS_STYLES[status] || "bg-slate-500/20 text-slate-300"}`}>
|
||||
{status.replace(/_/g, " ")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function Modal({
|
||||
open,
|
||||
onClose,
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const onCloseRef = useRef(onClose);
|
||||
onCloseRef.current = onClose;
|
||||
|
||||
// Focus the dialog only when it opens (not on every parent re-render).
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
ref.current?.focus();
|
||||
}, [open]);
|
||||
|
||||
// Escape-to-close + body scroll lock while open.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onCloseRef.current();
|
||||
};
|
||||
document.addEventListener("keydown", onKey);
|
||||
const prev = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => {
|
||||
document.removeEventListener("keydown", onKey);
|
||||
document.body.style.overflow = prev;
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
if (!open) return null;
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 grid place-items-center bg-black/60 p-4"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
ref={ref}
|
||||
tabIndex={-1}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={title}
|
||||
className="max-h-[90vh] w-full max-w-lg overflow-y-auto rounded-2xl border border-edge bg-panel p-6 shadow-2xl focus:outline-none"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">{title}</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
aria-label="Close dialog"
|
||||
className="rounded-lg px-2 text-muted hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ConfirmDialog({
|
||||
open,
|
||||
title,
|
||||
message,
|
||||
confirmLabel = "Confirm",
|
||||
cancelLabel = "Cancel",
|
||||
destructive = false,
|
||||
busy = false,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: {
|
||||
open: boolean;
|
||||
title: string;
|
||||
message?: string;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
destructive?: boolean;
|
||||
busy?: boolean;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Modal open={open} onClose={onCancel} title={title}>
|
||||
{message && <p className="mb-5 text-sm text-muted">{message}</p>}
|
||||
<div className="flex justify-end gap-2">
|
||||
<button className="btn-ghost" onClick={onCancel} disabled={busy}>
|
||||
{cancelLabel}
|
||||
</button>
|
||||
<button
|
||||
className={destructive ? "btn-danger" : "btn-primary"}
|
||||
onClick={onConfirm}
|
||||
disabled={busy}
|
||||
>
|
||||
{busy ? "Working…" : confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user