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:
2026-06-14 22:11:07 +03:00
commit 6185b9b85a
126 changed files with 14565 additions and 0 deletions

5
frontend/.dockerignore Normal file
View File

@@ -0,0 +1,5 @@
node_modules/
.next/
out/
.env
npm-debug.log*

22
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,22 @@
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json ./
RUN npm install
FROM node:20-alpine AS builder
WORKDIR /app
ARG NEXT_PUBLIC_API_BASE_URL
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./package.json
COPY --from=builder /app/public ./public
EXPOSE 3000
CMD ["npm", "run", "start"]

53
frontend/app/globals.css Normal file
View File

@@ -0,0 +1,53 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
color-scheme: dark;
}
body {
@apply bg-ink text-slate-100;
background-image: radial-gradient(1200px 600px at 80% -10%, rgba(91, 140, 255, 0.12), transparent),
radial-gradient(900px 500px at -10% 10%, rgba(34, 211, 238, 0.08), transparent);
min-height: 100vh;
}
@layer components {
.card {
@apply rounded-2xl border border-edge bg-panel/80 backdrop-blur p-5 shadow-lg shadow-black/20;
}
.btn {
@apply inline-flex items-center justify-center gap-2 rounded-xl px-4 py-2 text-sm font-medium transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-ink disabled:cursor-not-allowed disabled:opacity-50;
}
.btn-primary {
@apply btn bg-accent text-white hover:bg-accent/90;
}
.btn-ghost {
@apply btn border border-edge bg-panel2 text-slate-200 hover:border-accent/60;
}
.btn-secondary {
@apply btn border border-accent/40 bg-accent/10 text-accent hover:bg-accent/20;
}
.btn-danger {
@apply btn bg-bad/90 text-white hover:bg-bad;
}
.btn-sm {
@apply px-2.5 py-1 text-xs;
}
.input {
@apply w-full rounded-xl border border-edge bg-ink/60 px-3 py-2 text-sm outline-none focus:border-accent focus-visible:ring-2 focus-visible:ring-accent/40;
}
.label {
@apply mb-1 block text-xs font-medium uppercase tracking-wide text-muted;
}
.badge {
@apply inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium;
}
.th {
@apply px-3 py-2 text-left text-xs font-semibold uppercase tracking-wide text-muted;
}
.td {
@apply px-3 py-2 text-sm border-t border-edge/60;
}
}

View File

@@ -0,0 +1,467 @@
"use client";
import { useMemo, useState } from "react";
import useSWR from "swr";
import AppShell from "@/components/AppShell";
import {
ConfirmDialog,
EmptyState,
FormError,
LoadError,
Modal,
PageHeader,
SkeletonRows,
Spinner,
} from "@/components/ui";
import { useToast } from "@/components/toast";
import { api, fetcher } from "@/lib/api";
type CatalogEntry = {
key: string;
label: string;
description: string;
device_type: string;
mcp_server: string | null;
default_port: number;
default_username: string | null;
has_default_secret: boolean;
secret_kind: string;
env_defaults_configured: boolean;
asterisk_container?: string | null;
category: string;
};
type Device = {
id: number;
name: string;
catalog_key: string | null;
device_type: string;
address: string;
port: number | null;
mcp_server: string | null;
username: string | null;
has_secret: boolean;
vessel_id: number | null;
};
type Vessel = {
id: number;
name: string;
site: string | null;
public_ip: string | null;
notes: string | null;
devices?: Device[];
};
type SlotState = {
enabled: boolean;
port: string;
address: string;
username: string;
secret: string;
};
function defaultSlots(catalog: CatalogEntry[]): Record<string, SlotState> {
return Object.fromEntries(
catalog.map((c) => [
c.key,
{
enabled: ["proxmox", "pfsense", "docker_vm", "asterisk_geneseasx"].includes(c.key),
port: String(c.default_port),
address: "",
username: c.default_username || "",
secret: "",
},
])
);
}
function secretPlaceholder(c: CatalogEntry, editing: boolean) {
if (c.has_default_secret) return "configured in .env (leave blank to use)";
if (c.secret_kind === "api_key") return editing ? "leave blank to keep" : "API key";
return editing ? "leave blank to keep" : "password";
}
export default function InventoryPage() {
const { data: catalog, error: catalogError } = useSWR<CatalogEntry[]>(
"/api/vessels/catalog",
fetcher
);
const { data: vessels, error: vesselsError, isLoading, mutate } = useSWR<Vessel[]>(
"/api/vessels",
fetcher
);
const toast = useToast();
const [open, setOpen] = useState(false);
const [editId, setEditId] = useState<number | null>(null);
const [expanded, setExpanded] = useState<number | null>(null);
const [loadingDetail, setLoadingDetail] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [query, setQuery] = useState("");
const [deleteId, setDeleteId] = useState<number | null>(null);
const [deleting, setDeleting] = useState(false);
const [form, setForm] = useState({ name: "", site: "", public_ip: "", notes: "" });
const [slots, setSlots] = useState<Record<string, SlotState>>({});
const filteredVessels = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return vessels || [];
return (vessels || []).filter(
(v) =>
v.name.toLowerCase().includes(q) ||
(v.site || "").toLowerCase().includes(q) ||
(v.public_ip || "").toLowerCase().includes(q)
);
}, [vessels, query]);
const catalogSorted = useMemo(() => {
if (!catalog) return [];
const order = { core: 0, network: 1, optional: 2 };
return [...catalog].sort(
(a, b) => (order[a.category as keyof typeof order] ?? 9) - (order[b.category as keyof typeof order] ?? 9)
);
}, [catalog]);
const openCreate = () => {
setEditId(null);
setForm({ name: "", site: "", public_ip: "", notes: "" });
setSlots(catalog ? defaultSlots(catalog) : {});
setError(null);
setOpen(true);
};
const openEdit = async (id: number) => {
setError(null);
const detail = await api<Vessel>(`/api/vessels/${id}`);
setEditId(id);
setForm({
name: detail.name,
site: detail.site || "",
public_ip: detail.public_ip || "",
notes: detail.notes || "",
});
const base = catalog ? defaultSlots(catalog) : {};
for (const d of detail.devices || []) {
if (d.catalog_key && base[d.catalog_key]) {
base[d.catalog_key] = {
enabled: true,
port: d.port != null ? String(d.port) : base[d.catalog_key].port,
address: d.address !== detail.public_ip ? d.address : "",
username: d.username || "",
secret: "",
};
}
}
setSlots(base);
setOpen(true);
};
const toggleSlot = (key: string, enabled: boolean) => {
setSlots((s) => ({ ...s, [key]: { ...s[key], enabled } }));
};
const updateSlot = (key: string, patch: Partial<SlotState>) => {
setSlots((s) => ({ ...s, [key]: { ...s[key], ...patch } }));
};
const buildPayload = () => {
const devices = Object.entries(slots)
.filter(([, v]) => v.enabled)
.map(([catalog_key, v]) => ({
catalog_key,
enabled: true,
port: v.port ? Number(v.port) : undefined,
address: v.address || undefined,
username: v.username || undefined,
secret: v.secret || undefined,
}));
return { ...form, site: form.site || null, public_ip: form.public_ip || null, notes: form.notes || null, devices };
};
const submit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
setSaving(true);
try {
const payload = buildPayload();
if (editId) {
await api(`/api/vessels/${editId}`, { method: "PATCH", body: JSON.stringify(payload) });
} else {
await api("/api/vessels", { method: "POST", body: JSON.stringify(payload) });
}
setOpen(false);
toast.success(editId ? "Vessel updated" : "Vessel created");
mutate();
} catch (err: any) {
setError(err.message);
toast.error(err.message || "Save failed");
} finally {
setSaving(false);
}
};
const remove = async () => {
if (deleteId == null) return;
setDeleting(true);
try {
await api(`/api/vessels/${deleteId}`, { method: "DELETE" });
toast.success("Vessel deleted");
mutate();
} catch (err: any) {
toast.error(err.message || "Delete failed");
} finally {
setDeleting(false);
setDeleteId(null);
}
};
const loadDetail = async (id: number) => {
if (expanded === id) {
setExpanded(null);
return;
}
setLoadingDetail(id);
try {
const detail = await api<Vessel>(`/api/vessels/${id}`);
mutate(
(current) => current?.map((v) => (v.id === id ? { ...v, devices: detail.devices } : v)),
false
);
setExpanded(id);
} catch (err: any) {
toast.error(err.message || "Could not load vessel devices");
} finally {
setLoadingDetail(null);
}
};
return (
<AppShell>
<PageHeader
title="Vessels"
subtitle="Each vessel is a site with a public IP and the devices you select from the catalog"
action={
<button className="btn-primary" onClick={openCreate} disabled={!catalog}>
{catalog ? "+ New vessel" : "Loading catalog…"}
</button>
}
/>
{vessels && vessels.length > 0 && (
<input
className="input mb-4 sm:max-w-xs"
placeholder="Search name, site, or IP…"
value={query}
onChange={(e) => setQuery(e.target.value)}
aria-label="Search vessels"
/>
)}
{isLoading && !vessels ? (
<SkeletonRows rows={3} />
) : vesselsError ? (
<LoadError message="Couldnt load vessels." onRetry={() => mutate()} />
) : (vessels || []).length === 0 ? (
<EmptyState
title="No vessels yet"
description="Create one and pick its devices from the catalog."
action={
<button className="btn-primary" onClick={openCreate} disabled={!catalog}>
+ New vessel
</button>
}
/>
) : filteredVessels.length === 0 ? (
<EmptyState title="No matching vessels" description="Try a different search." />
) : (
<div className="space-y-3">
{filteredVessels.map((v) => (
<div key={v.id} className="card">
<div className="flex flex-col items-start justify-between gap-4 sm:flex-row sm:items-center">
<button className="min-w-0 flex-1 text-left" onClick={() => loadDetail(v.id)}>
<div className="flex items-center gap-2">
<span className="font-semibold">{v.name}</span>
{v.site && <span className="text-xs text-muted">{v.site}</span>}
{loadingDetail === v.id && <Spinner className="text-muted" />}
</div>
<div className="mt-1 font-mono text-xs text-accent2">{v.public_ip || "no public IP"}</div>
{v.notes && <div className="mt-1 text-sm text-muted">{v.notes}</div>}
</button>
<div className="flex shrink-0 gap-2">
<button className="btn-ghost btn-sm" onClick={() => openEdit(v.id)}>
Edit
</button>
<button className="btn-danger btn-sm" onClick={() => setDeleteId(v.id)}>
Delete
</button>
</div>
</div>
{expanded === v.id && v.devices && (
<div className="mt-4 overflow-x-auto rounded-xl border border-edge">
<table className="w-full min-w-[560px]">
<thead>
<tr>
<th className="th">Device</th>
<th className="th">Type</th>
<th className="th">Endpoint</th>
<th className="th">Port</th>
<th className="th">MCP</th>
</tr>
</thead>
<tbody>
{v.devices.map((d) => (
<tr key={d.id}>
<td className="td">{d.name}</td>
<td className="td text-xs">{d.catalog_key || d.device_type}</td>
<td className="td font-mono text-xs">{d.address}</td>
<td className="td font-mono text-xs">{d.port ?? "—"}</td>
<td className="td text-xs">{d.mcp_server || "—"}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
))}
</div>
)}
<Modal open={open} onClose={() => setOpen(false)} title={editId ? "Edit vessel" : "New vessel"}>
<form onSubmit={submit} className="space-y-4">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<label className="label">Vessel name</label>
<input className="input" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} required />
</div>
<div>
<label className="label">Site / region</label>
<input className="input" value={form.site} onChange={(e) => setForm({ ...form, site: e.target.value })} />
</div>
</div>
<div>
<label className="label">Public IP (shared endpoint for port forwards)</label>
<input
className="input font-mono"
value={form.public_ip}
onChange={(e) => setForm({ ...form, public_ip: e.target.value })}
placeholder="e.g. 203.0.113.10"
/>
</div>
<div>
<label className="label">Notes</label>
<textarea className="input" value={form.notes} onChange={(e) => setForm({ ...form, notes: e.target.value })} />
</div>
<div>
<label className="label mb-2">Onboard devices</label>
<p className="mb-3 text-xs text-muted">
Check what this vessel has. Ports and credentials pre-fill from catalog defaults (.env when set).
Address defaults to the vessel public IP.
</p>
<div className="space-y-2">
{catalogSorted.map((c) => {
const slot = slots[c.key];
if (!slot) return null;
return (
<div
key={c.key}
className={`rounded-xl border p-3 ${slot.enabled ? "border-accent/40 bg-panel2" : "border-edge/60"}`}
>
<label className="flex cursor-pointer items-start gap-3">
<input
type="checkbox"
className="mt-1"
checked={slot.enabled}
onChange={(e) => toggleSlot(c.key, e.target.checked)}
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="font-medium">{c.label}</span>
<span className="badge bg-slate-500/20 text-slate-400">{c.category}</span>
</div>
<div className="text-xs text-muted">{c.description}</div>
{c.asterisk_container && (
<div className="mt-1 text-[11px] text-muted">
Docker container: <span className="font-mono">{c.asterisk_container}</span>
</div>
)}
{c.env_defaults_configured && (
<div className="mt-1 text-[11px] text-accent2">
.env defaults loaded
{c.has_default_secret ? ` · ${c.secret_kind === "api_key" ? "API key" : "password"} on file` : ""}
</div>
)}
</div>
</label>
{slot.enabled && (
<div className="mt-3 grid grid-cols-1 gap-2 pl-7 sm:grid-cols-2">
<div>
<label className="label">Port (default {c.default_port})</label>
<input
className="input font-mono"
value={slot.port}
onChange={(e) => updateSlot(c.key, { port: e.target.value })}
/>
</div>
<div>
<label className="label">Address override</label>
<input
className="input font-mono text-xs"
placeholder="uses public IP if empty"
value={slot.address}
onChange={(e) => updateSlot(c.key, { address: e.target.value })}
/>
</div>
<div>
<label className="label">Username</label>
<input
className="input"
placeholder={c.default_username || "optional"}
value={slot.username}
onChange={(e) => updateSlot(c.key, { username: e.target.value })}
/>
</div>
<div>
<label className="label">
{c.secret_kind === "api_key" ? "API key" : "Password"}
</label>
<input
className="input"
type="password"
placeholder={secretPlaceholder(c, !!editId)}
value={slot.secret}
onChange={(e) => updateSlot(c.key, { secret: e.target.value })}
/>
</div>
</div>
)}
</div>
);
})}
</div>
</div>
<FormError message={error} />
<button className="btn-primary w-full" disabled={saving}>
{saving ? "Saving…" : editId ? "Save vessel" : "Create vessel"}
</button>
</form>
</Modal>
<ConfirmDialog
open={deleteId != null}
title="Delete vessel?"
message="This deletes the vessel and all of its onboard devices."
confirmLabel="Delete"
destructive
busy={deleting}
onConfirm={remove}
onCancel={() => setDeleteId(null)}
/>
</AppShell>
);
}

18
frontend/app/layout.tsx Normal file
View File

@@ -0,0 +1,18 @@
import "./globals.css";
import type { Metadata } from "next";
import Providers from "@/components/providers";
export const metadata: Metadata = {
title: "Agentic OS",
description: "Self-hosted agentic troubleshooting platform",
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<Providers>{children}</Providers>
</body>
</html>
);
}

View File

@@ -0,0 +1,82 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/lib/auth";
export default function LoginPage() {
const { login, user, loading } = useAuth();
const router = useRouter();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
useEffect(() => {
if (!loading && user) router.replace("/");
}, [loading, user, router]);
const submit = async (e: React.FormEvent) => {
e.preventDefault();
setBusy(true);
setError(null);
try {
await login(email, password);
} catch (err: any) {
setError(err.message || "Login failed");
} finally {
setBusy(false);
}
};
return (
<div className="grid min-h-screen place-items-center p-4">
<div className="w-full max-w-sm">
<div className="mb-6 flex items-center gap-3">
<div className="grid h-12 w-12 place-items-center rounded-2xl bg-gradient-to-br from-accent to-accent2 text-xl font-bold text-white">
A
</div>
<div>
<h1 className="text-xl font-semibold">Agentic OS</h1>
<p className="text-sm text-muted">Sign in to continue</p>
</div>
</div>
<form onSubmit={submit} className="card space-y-4">
<div>
<label htmlFor="login-email" className="label">
Email
</label>
<input
id="login-email"
className="input"
type="email"
autoComplete="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
autoFocus
required
/>
</div>
<div>
<label htmlFor="login-password" className="label">
Password
</label>
<input
id="login-password"
className="input"
type="password"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</div>
{error && <div className="text-sm text-bad">{error}</div>}
<button className="btn-primary w-full" disabled={busy}>
{busy ? "Signing in…" : "Sign in"}
</button>
</form>
</div>
</div>
);
}

199
frontend/app/mcp/page.tsx Normal file
View File

@@ -0,0 +1,199 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import useSWR from "swr";
import AppShell from "@/components/AppShell";
import { EmptyState, LoadError, Modal, PageHeader, SkeletonRows, StatusBadge } from "@/components/ui";
import { useToast } from "@/components/toast";
import { api, fetcher } from "@/lib/api";
import { useAuth, roleAtLeast } from "@/lib/auth";
type Mcp = {
name: string;
status: string;
error: string | null;
source?: string;
tools: { name: string; description: string | null }[];
};
export default function McpPage() {
const router = useRouter();
const { user } = useAuth();
const isAdmin = roleAtLeast(user?.role, "admin");
const toast = useToast();
const {
data: mcps,
error: mcpError,
isLoading,
mutate,
} = useSWR<Mcp[]>("/api/mcp/servers", fetcher, { refreshInterval: 15000 });
const [expanded, setExpanded] = useState<string | null>(null);
const [busy, setBusy] = useState<string | null>(null);
const [developOpen, setDevelopOpen] = useState<string | null>(null);
const [developIssue, setDevelopIssue] = useState("");
const run = async (key: string, label: string, fn: () => Promise<void>) => {
setBusy(key);
try {
await fn();
toast.success(`${label} — status will refresh shortly.`);
mutate();
} catch (err: unknown) {
toast.error(err instanceof Error ? err.message : "Action failed");
} finally {
setBusy(null);
}
};
return (
<AppShell>
<PageHeader
title="MCP Servers"
subtitle="Device integrations — reload or upgrade when tools fail or repos change"
action={
isAdmin && (
<button
className="btn-ghost text-sm"
disabled={!!busy}
onClick={() =>
run("all", "Reload all queued", async () => {
await api("/api/mcp/reload-all", { method: "POST" });
})
}
>
{busy === "all" ? "Reloading…" : "Reload all"}
</button>
)
}
/>
{isLoading && !mcps ? (
<SkeletonRows rows={4} />
) : mcpError ? (
<LoadError message="Couldnt load MCP servers." onRetry={() => mutate()} />
) : (mcps || []).length === 0 ? (
<EmptyState
title="No MCP servers reported yet"
description="The worker clones and launches them on startup; check worker logs if this persists."
/>
) : (
<div className="space-y-4">
{(mcps || []).map((m) => (
<div key={m.name} className="card">
<div className="flex w-full flex-wrap items-center justify-between gap-3">
<button
className="flex flex-1 items-center gap-3 text-left"
onClick={() => setExpanded(expanded === m.name ? null : m.name)}
>
<span className="font-medium">{m.name}</span>
<span className="text-xs text-muted">
{m.tools?.length || 0} tools · {m.source || "git"}
</span>
</button>
<div className="flex items-center gap-2">
{isAdmin && (
<>
<button
className="btn-ghost btn-sm"
disabled={!!busy}
onClick={() =>
run(`reload-${m.name}`, "Reload queued", async () => {
await api(`/api/mcp/servers/${m.name}/reload`, { method: "POST" });
})
}
>
{busy === `reload-${m.name}` ? "…" : "Reload"}
</button>
<button
className="btn-ghost btn-sm text-accent2"
disabled={!!busy}
onClick={() =>
run(`upgrade-${m.name}`, "Upgrade queued", async () => {
await api(`/api/mcp/servers/${m.name}/upgrade`, { method: "POST" });
})
}
>
{busy === `upgrade-${m.name}` ? "…" : "Upgrade"}
</button>
<button
className="btn-ghost btn-sm text-accent"
disabled={!!busy}
onClick={() => {
setDevelopIssue("");
setDevelopOpen(m.name);
}}
>
Develop
</button>
</>
)}
<StatusBadge status={m.status} />
</div>
</div>
{m.error && <div className="mt-2 text-sm text-bad">{m.error}</div>}
{expanded === m.name && m.tools?.length > 0 && (
<div className="mt-4 grid grid-cols-1 gap-2 md:grid-cols-2">
{m.tools.map((t) => (
<div key={t.name} className="rounded-lg bg-panel2 px-3 py-2">
<div className="font-mono text-xs text-accent2">{t.name}</div>
{t.description && (
<div className="mt-1 line-clamp-2 text-xs text-muted">{t.description}</div>
)}
</div>
))}
</div>
)}
</div>
))}
</div>
)}
<Modal
open={!!developOpen}
onClose={() => setDevelopOpen(null)}
title={`Develop ${developOpen || ""}`}
>
<p className="text-sm text-muted">
Describe the missing tool or bug. A monitor task opens automatically so you can watch
analyze patch reload git push in the task timeline. Push to Gitea needs approval
unless auto-push is enabled.
</p>
<textarea
className="input mt-4 min-h-[120px]"
value={developIssue}
onChange={(e) => setDevelopIssue(e.target.value)}
placeholder="e.g. add pfsense_list_firewall_rules_by_interface or fix NameError in get_firewall_log"
/>
<div className="mt-4 flex justify-end gap-2">
<button className="btn-ghost" onClick={() => setDevelopOpen(null)}>
Cancel
</button>
<button
className="btn-primary"
disabled={!developIssue.trim() || !!busy}
onClick={() => {
const server = developOpen;
if (!server) return;
run(`develop-${server}`, "Develop started", async () => {
const res = await api<{ task_id?: number; summary?: string }>(
`/api/mcp/servers/${server}/develop`,
{
method: "POST",
body: JSON.stringify({ issue: developIssue.trim() }),
}
);
setDevelopOpen(null);
if (res.task_id) {
router.push(`/tasks/${res.task_id}`);
}
});
}}
>
{busy === `develop-${developOpen}` ? "Starting…" : "Run & monitor"}
</button>
</div>
</Modal>
</AppShell>
);
}

View File

@@ -0,0 +1,368 @@
"use client";
import { useMemo, useState } from "react";
import useSWR from "swr";
import AppShell from "@/components/AppShell";
import { PageHeader } from "@/components/ui";
import { api, fetcher } from "@/lib/api";
import { useAuth } from "@/lib/auth";
type CatalogEntry = {
id: string;
backend: string;
model: string;
tier: string;
label: string;
display: string;
input_per_m: number;
output_per_m: number;
};
type RoutingResponse = {
config: {
local_model_id: string;
economy_gemini_id: string;
economy_deepseek_id: string;
economy_openrouter_id: string;
premium_gemini_id: string;
premium_deepseek_id: string;
premium_openrouter_id: string;
cloud_provider_order: string[];
auto_escalate: boolean;
max_tier: string;
};
catalog: Record<string, Record<string, CatalogEntry[]>>;
available_backends: Record<string, boolean>;
strategy: string;
};
type LiveModel = { id: string; label: string; [key: string]: unknown };
type AvailableModelsResponse = {
providers: Record<
string,
{ available: boolean; error?: string; models: LiveModel[]; note?: string }
>;
routing_order: string;
};
const TIER_LABEL: Record<string, string> = {
local: "Local (always tried first)",
economy: "Economy cloud (capable, low cost)",
premium: "Premium cloud (strongest, higher cost)",
};
const PROVIDER_ORDER_OPTIONS = [
{ value: "gemini,deepseek,openrouter", label: "Gemini → DeepSeek → OpenRouter" },
{ value: "gemini,openrouter,deepseek", label: "Gemini → OpenRouter → DeepSeek" },
{ value: "deepseek,gemini,openrouter", label: "DeepSeek → Gemini → OpenRouter" },
{ value: "deepseek,openrouter,gemini", label: "DeepSeek → OpenRouter → Gemini" },
{ value: "openrouter,gemini,deepseek", label: "OpenRouter → Gemini → DeepSeek" },
{ value: "openrouter,deepseek,gemini", label: "OpenRouter → DeepSeek → Gemini" },
];
function priceLabel(m: CatalogEntry) {
if (m.input_per_m === 0 && m.output_per_m === 0) return "free tier eligible";
return `$${m.input_per_m}/${m.output_per_m} per 1M`;
}
export default function ModelsPage() {
const { user } = useAuth();
const { data, mutate } = useSWR<RoutingResponse>("/api/llm/routing", fetcher);
const { data: liveModels } = useSWR<AvailableModelsResponse>(
user?.role === "admin" ? "/api/llm/models/available" : null,
fetcher,
);
const [form, setForm] = useState<RoutingResponse["config"] | null>(null);
const [saving, setSaving] = useState(false);
const [msg, setMsg] = useState<string | null>(null);
const [showLive, setShowLive] = useState(false);
const cfg = form ?? data?.config;
const economyOptions = useMemo(() => {
if (!data) return { gemini: [], deepseek: [], openrouter: [] };
return {
gemini: data.catalog.economy?.gemini || [],
deepseek: data.catalog.economy?.deepseek || [],
openrouter: data.catalog.economy?.openrouter || [],
};
}, [data]);
const premiumOptions = useMemo(() => {
if (!data) return { gemini: [], deepseek: [], openrouter: [] };
return {
gemini: data.catalog.premium?.gemini || [],
deepseek: data.catalog.premium?.deepseek || [],
openrouter: data.catalog.premium?.openrouter || [],
};
}, [data]);
const localOptions = data?.catalog.local?.ollama || [];
const save = async (e: React.FormEvent) => {
e.preventDefault();
if (!cfg) return;
setSaving(true);
setMsg(null);
try {
await api("/api/llm/routing", { method: "PUT", body: JSON.stringify(cfg) });
setMsg("Saved — new tasks will use this routing.");
mutate();
} catch (err: any) {
setMsg(err.message);
} finally {
setSaving(false);
}
};
if (user?.role !== "admin") {
return (
<AppShell>
<PageHeader title="Model routing" subtitle="Admin access required" />
</AppShell>
);
}
return (
<AppShell>
<PageHeader
title="Model routing"
subtitle="Local first, then Gemini → DeepSeek → OpenRouter on escalation"
/>
{data?.strategy && (
<div className="card mb-6 text-sm text-muted">{data.strategy}</div>
)}
<div className="mb-6 flex flex-wrap gap-3">
{Object.entries(data?.available_backends || {}).map(([k, ok]) => (
<span
key={k}
className={`badge ${ok ? "bg-good/20 text-good" : "bg-panel2 text-muted"}`}
>
{k}: {ok ? "available" : "no API key"}
</span>
))}
</div>
{!cfg ? (
<div className="card text-muted">Loading</div>
) : (
<form onSubmit={save} className="space-y-6">
<div className="card">
<h2 className="mb-1 font-semibold">{TIER_LABEL.local}</h2>
<p className="mb-4 text-xs text-muted">Used for triage and first reasoning attempt on every task.</p>
<select
className="input"
value={cfg.local_model_id}
onChange={(e) => setForm({ ...cfg, local_model_id: e.target.value })}
>
{localOptions.map((m) => (
<option key={m.id} value={m.id}>
{m.label} ({m.model}) free
</option>
))}
</select>
</div>
<div className="card">
<h2 className="mb-1 font-semibold">{TIER_LABEL.economy}</h2>
<p className="mb-4 text-xs text-muted">
Escalation target for medium complexity, partial diagnostic failures, or follow-up runs.
</p>
<div className="grid gap-4 md:grid-cols-3">
<div>
<label className="label">Gemini economy</label>
<select
className="input"
value={cfg.economy_gemini_id}
disabled={!data?.available_backends.gemini}
onChange={(e) => setForm({ ...cfg, economy_gemini_id: e.target.value })}
>
{economyOptions.gemini.map((m) => (
<option key={m.id} value={m.id}>
{m.label} {priceLabel(m)}
</option>
))}
</select>
</div>
<div>
<label className="label">DeepSeek economy</label>
<select
className="input"
value={cfg.economy_deepseek_id}
disabled={!data?.available_backends.deepseek}
onChange={(e) => setForm({ ...cfg, economy_deepseek_id: e.target.value })}
>
{economyOptions.deepseek.map((m) => (
<option key={m.id} value={m.id}>
{m.label} {priceLabel(m)}
</option>
))}
</select>
</div>
<div>
<label className="label">OpenRouter economy</label>
<select
className="input"
value={cfg.economy_openrouter_id}
disabled={!data?.available_backends.openrouter}
onChange={(e) => setForm({ ...cfg, economy_openrouter_id: e.target.value })}
>
{economyOptions.openrouter.map((m) => (
<option key={m.id} value={m.id}>
{m.label} {priceLabel(m)}
</option>
))}
</select>
</div>
</div>
</div>
<div className="card">
<h2 className="mb-1 font-semibold">{TIER_LABEL.premium}</h2>
<p className="mb-4 text-xs text-muted">
Used for critical severity, many diagnostic failures, or when economy/local models request escalation.
</p>
<div className="grid gap-4 md:grid-cols-3">
<div>
<label className="label">Gemini premium</label>
<select
className="input"
value={cfg.premium_gemini_id}
disabled={!data?.available_backends.gemini}
onChange={(e) => setForm({ ...cfg, premium_gemini_id: e.target.value })}
>
{premiumOptions.gemini.map((m) => (
<option key={m.id} value={m.id}>
{m.label} {priceLabel(m)}
</option>
))}
</select>
</div>
<div>
<label className="label">DeepSeek premium</label>
<select
className="input"
value={cfg.premium_deepseek_id}
disabled={!data?.available_backends.deepseek}
onChange={(e) => setForm({ ...cfg, premium_deepseek_id: e.target.value })}
>
{premiumOptions.deepseek.map((m) => (
<option key={m.id} value={m.id}>
{m.label} {priceLabel(m)}
</option>
))}
</select>
</div>
<div>
<label className="label">OpenRouter premium</label>
<select
className="input"
value={cfg.premium_openrouter_id}
disabled={!data?.available_backends.openrouter}
onChange={(e) => setForm({ ...cfg, premium_openrouter_id: e.target.value })}
>
{premiumOptions.openrouter.map((m) => (
<option key={m.id} value={m.id}>
{m.label} {priceLabel(m)}
</option>
))}
</select>
</div>
</div>
</div>
<div className="card grid gap-4 md:grid-cols-3">
<div>
<label className="label">Cloud provider priority</label>
<select
className="input"
value={cfg.cloud_provider_order.join(",")}
onChange={(e) =>
setForm({ ...cfg, cloud_provider_order: e.target.value.split(",") })
}
>
{PROVIDER_ORDER_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<div>
<label className="label">Max tier (cost cap)</label>
<select
className="input"
value={cfg.max_tier}
onChange={(e) => setForm({ ...cfg, max_tier: e.target.value })}
>
<option value="local">Local only</option>
<option value="economy">Up to economy</option>
<option value="premium">Up to premium</option>
</select>
</div>
<div className="flex items-end">
<label className="flex cursor-pointer items-center gap-2 text-sm">
<input
type="checkbox"
checked={cfg.auto_escalate}
onChange={(e) => setForm({ ...cfg, auto_escalate: e.target.checked })}
className="rounded"
/>
Auto-escalate when local/economy is uncertain
</label>
</div>
</div>
{msg && <div className="text-sm text-muted">{msg}</div>}
<button className="btn-primary" disabled={saving}>
{saving ? "Saving…" : "Save routing config"}
</button>
</form>
)}
{liveModels && (
<div className="card mt-8">
<div className="mb-4 flex items-center justify-between">
<h2 className="font-semibold">Live models from each API</h2>
<button type="button" className="btn-secondary text-sm" onClick={() => setShowLive(!showLive)}>
{showLive ? "Hide" : "Show all"}
</button>
</div>
{!showLive ? (
<p className="text-sm text-muted">
Ollama: {liveModels.providers.ollama?.models?.length ?? 0} · Gemini:{" "}
{liveModels.providers.gemini?.models?.length ?? 0} · DeepSeek:{" "}
{liveModels.providers.deepseek?.models?.length ?? 0} · OpenRouter:{" "}
{liveModels.providers.openrouter?.models?.length ?? 0}
</p>
) : (
<div className="space-y-6">
{Object.entries(liveModels.providers).map(([name, info]) => (
<div key={name}>
<h3 className="mb-2 text-sm font-medium capitalize">
{name}{" "}
<span className={info.available ? "text-good" : "text-muted"}>
({info.available ? `${info.models.length} models` : info.error || "unavailable"})
</span>
</h3>
{info.note && <p className="mb-2 text-xs text-muted">{info.note}</p>}
<ul className="max-h-48 overflow-y-auto text-xs text-muted">
{info.models.map((m) => (
<li key={m.id} className="font-mono">
{m.id}
{m.label !== m.id ? `${m.label}` : ""}
</li>
))}
</ul>
</div>
))}
</div>
)}
</div>
)}
</AppShell>
);
}

223
frontend/app/page.tsx Normal file
View File

@@ -0,0 +1,223 @@
"use client";
import useSWR from "swr";
import Link from "next/link";
import AppShell from "@/components/AppShell";
import { PageHeader, StatusBadge } from "@/components/ui";
import { fetcher } from "@/lib/api";
import { isActiveStatus, TaskOverview } from "@/lib/task-types";
type Balance = {
provider: string;
remaining: number | null;
total_credits: number | null;
total_usage: number | null;
currency: string;
error: string | null;
};
type Mcp = { name: string; status: string; tools: { name: string }[] };
export default function Dashboard() {
const { data: balances } = useSWR<Balance[]>("/api/balance", fetcher, { refreshInterval: 60000 });
const { data: mcps } = useSWR<Mcp[]>("/api/mcp/servers", fetcher, { refreshInterval: 30000 });
const { data: tasks } = useSWR<TaskOverview[]>("/api/tasks/overview", fetcher, {
refreshInterval: (data) =>
(data || []).some((t) => isActiveStatus(t.status)) ? 8000 : 30000,
});
const taskList = tasks || [];
const active = taskList.filter((t) => isActiveStatus(t.status));
const approvals = taskList.filter((t) => t.status === "waiting_approval");
const failed = taskList.filter((t) => t.status === "failed");
const artifactWarnings = taskList.filter((t) => t.obsidian_push_error);
const lowBalance = (balances || []).filter((b) => b.error || (b.remaining != null && b.remaining < 1));
const attention = [...approvals, ...failed, ...artifactWarnings].filter(
(task, index, arr) => arr.findIndex((t) => t.id === task.id) === index
);
const recentReports = taskList.filter((t) => t.summary || t.key_finding).slice(0, 5);
const mcpReady = (mcps || []).filter((m) => m.status === "loaded").length;
const mcpTotal = (mcps || []).length;
return (
<AppShell>
<PageHeader
title="Operations Dashboard"
subtitle="Active troubleshooting, attention queue, recent findings, and platform readiness"
action={
<Link href="/tasks" className="btn-primary">
New / view tasks
</Link>
}
/>
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
<MetricCard label="Active" value={active.length} tone={active.length ? "accent" : "muted"} />
<MetricCard label="Needs approval" value={approvals.length} tone={approvals.length ? "warn" : "muted"} />
<MetricCard label="Failed" value={failed.length} tone={failed.length ? "bad" : "muted"} />
<MetricCard label="MCP ready" value={`${mcpReady}/${mcpTotal || "—"}`} tone={mcpReady === mcpTotal ? "good" : "warn"} />
</div>
<div className="mt-6 grid grid-cols-1 gap-6 xl:grid-cols-[1.3fr_0.9fr]">
<div className="card">
<div className="mb-4 flex items-center justify-between">
<h2 className="font-semibold">Active Tasks</h2>
<span className="text-xs text-muted">queued, running, approvals</span>
</div>
<div className="space-y-2">
{active.map((t) => (
<Link key={t.id} href={`/tasks/${t.id}`} className="block rounded-xl bg-panel2 p-3 hover:ring-1 hover:ring-accent/40">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-sm font-medium">{t.title}</div>
<div className="mt-1 text-xs text-muted">
{t.vessel_name || "No vessel"} · {t.devices_checked?.join(", ") || "scope pending"}
</div>
</div>
<StatusBadge status={t.status} />
</div>
<p className="mt-2 line-clamp-2 text-sm text-muted">{t.summary || t.issue}</p>
</Link>
))}
{active.length === 0 && (
<div className="rounded-xl bg-panel2 p-4 text-sm text-muted">No active tasks right now.</div>
)}
</div>
</div>
<div className="card">
<div className="mb-4 flex items-center justify-between">
<h2 className="font-semibold">Attention Needed</h2>
<Link href="/tasks" className="text-xs text-accent hover:underline">
View all
</Link>
</div>
<div className="space-y-2">
{attention.map((t) => (
<Link key={t.id} href={`/tasks/${t.id}`} className="block rounded-xl bg-panel2 p-3 hover:ring-1 hover:ring-warn/40">
<div className="flex items-center justify-between gap-3">
<span className="truncate text-sm font-medium">{t.title}</span>
<StatusBadge status={t.status} />
</div>
<p className="mt-1 text-xs text-muted">
{t.obsidian_push_error
? "Obsidian push failed"
: t.status === "waiting_approval"
? "Approval required before changes continue"
: t.status === "failed"
? t.key_finding || "Task failed"
: t.issue}
</p>
</Link>
))}
{lowBalance.map((b) => (
<div key={b.provider} className="rounded-xl bg-panel2 p-3">
<div className="text-sm font-medium capitalize text-warn">{b.provider} budget</div>
<div className="mt-1 text-xs text-muted">{b.error || `Low balance: $${b.remaining?.toFixed(2)}`}</div>
</div>
))}
{attention.length === 0 && lowBalance.length === 0 && (
<div className="rounded-xl bg-panel2 p-4 text-sm text-muted">Nothing needs attention.</div>
)}
</div>
</div>
</div>
<div className="mt-6 grid grid-cols-1 gap-6 xl:grid-cols-[1.2fr_0.8fr]">
<div className="card">
<div className="mb-4 flex items-center justify-between">
<h2 className="font-semibold">Recent Findings</h2>
<Link href="/tasks" className="text-xs text-accent hover:underline">
All reports
</Link>
</div>
<div className="space-y-3">
{recentReports.map((t) => (
<Link key={t.id} href={`/tasks/${t.id}`} className="block rounded-xl bg-panel2 p-3 hover:ring-1 hover:ring-accent/40">
<div className="mb-1 flex flex-wrap items-center gap-2">
<span className="text-sm font-medium">{t.title}</span>
{t.vessel_name && <span className="badge bg-panel text-xs text-accent">{t.vessel_name}</span>}
<StatusBadge status={t.status} />
</div>
<p className="line-clamp-2 text-sm text-muted">{t.key_finding || t.summary}</p>
<div className="mt-2 flex flex-wrap gap-2 text-[11px] text-muted">
{t.devices_checked?.length > 0 && <span>checked: {t.devices_checked.join(", ")}</span>}
{t.memory_id && <span>memory saved</span>}
{t.obsidian_path && <span>obsidian saved</span>}
</div>
</Link>
))}
{recentReports.length === 0 && <div className="text-sm text-muted">No completed findings yet.</div>}
</div>
</div>
<div className="space-y-6">
<div className="card">
<div className="mb-4 flex items-center justify-between">
<h2 className="font-semibold">Budgets</h2>
<span className="text-xs text-muted">API balances</span>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-1">
{(balances || []).map((b) => (
<div key={b.provider} className="rounded-xl bg-panel2 p-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium capitalize">{b.provider}</span>
<span className="text-xs text-muted">{b.currency}</span>
</div>
{b.error ? (
<div className="mt-2 text-sm text-bad">{b.error}</div>
) : (
<div className="mt-2 text-2xl font-semibold">
{b.remaining != null ? `$${b.remaining.toFixed(2)}` : "—"}
</div>
)}
{b.total_usage != null && <div className="mt-1 text-xs text-muted">used ${b.total_usage.toFixed(2)}</div>}
</div>
))}
{(!balances || balances.length === 0) && <div className="text-sm text-muted">No balance data yet.</div>}
</div>
</div>
<div className="card">
<div className="mb-4 flex items-center justify-between">
<h2 className="font-semibold">Platform Readiness</h2>
<Link href="/mcp" className="text-xs text-accent hover:underline">
MCP details
</Link>
</div>
<div className="space-y-2">
{(mcps || []).map((m) => (
<div key={m.name} className="flex items-center justify-between rounded-xl bg-panel2 px-3 py-2">
<div>
<div className="text-sm font-medium">{m.name}</div>
<div className="text-xs text-muted">{m.tools?.length || 0} tools</div>
</div>
<StatusBadge status={m.status} />
</div>
))}
{(!mcps || mcps.length === 0) && <div className="text-sm text-muted">Worker is starting MCP servers.</div>}
</div>
</div>
</div>
</div>
</AppShell>
);
}
function MetricCard({ label, value, tone }: { label: string; value: number | string; tone: "accent" | "warn" | "bad" | "good" | "muted" }) {
const toneClass =
tone === "accent"
? "text-accent"
: tone === "warn"
? "text-warn"
: tone === "bad"
? "text-bad"
: tone === "good"
? "text-good"
: "text-muted";
return (
<div className="card">
<div className="text-xs uppercase tracking-wide text-muted">{label}</div>
<div className={`mt-2 text-3xl font-semibold ${toneClass}`}>{value}</div>
</div>
);
}

134
frontend/app/rules/page.tsx Normal file
View File

@@ -0,0 +1,134 @@
"use client";
import { useState } from "react";
import useSWR from "swr";
import AppShell from "@/components/AppShell";
import { PageHeader } from "@/components/ui";
import { api, fetcher } from "@/lib/api";
import { useAuth } from "@/lib/auth";
type RulesResponse = {
yaml: string;
parsed: { rules?: { id: string; name: string; priority?: number }[] };
path_hint: string;
};
export default function RulesPage() {
const { user } = useAuth();
const isAdmin = user?.role === "admin";
const { data, mutate } = useSWR<RulesResponse>("/api/rules/troubleshooting", fetcher);
const [yaml, setYaml] = useState<string | null>(null);
const [previewTitle, setPreviewTitle] = useState("Asterisk health status");
const [previewIssue, setPreviewIssue] = useState("Check voip pjsip registration on GeneseasX");
const [preview, setPreview] = useState<{ matched: Record<string, unknown> | null } | null>(null);
const [saving, setSaving] = useState(false);
const [msg, setMsg] = useState<string | null>(null);
const content = yaml ?? data?.yaml ?? "";
const [msgKind, setMsgKind] = useState<"ok" | "err">("ok");
const save = async () => {
setSaving(true);
setMsg(null);
try {
await api("/api/rules/troubleshooting", { method: "PUT", body: JSON.stringify({ yaml: content }) });
setMsgKind("ok");
setMsg("Rules saved — next task run will use them.");
setYaml(null);
mutate();
} catch (err: unknown) {
setMsgKind("err");
setMsg(err instanceof Error ? err.message : "Save failed");
} finally {
setSaving(false);
}
};
const runPreview = async () => {
setPreview(null);
try {
const res = await api<{ matched: Record<string, unknown> | null }>("/api/rules/troubleshooting/preview", {
method: "POST",
body: JSON.stringify({ title: previewTitle, issue: previewIssue }),
});
setPreview(res);
} catch (err: unknown) {
setMsg(err instanceof Error ? err.message : "Preview failed");
}
};
return (
<AppShell>
<PageHeader
title="Troubleshooting rules"
subtitle="Standardize which devices to check and investigation order — YAML editable by admins"
/>
<div className="mb-6 grid gap-4 lg:grid-cols-2">
<div className="card">
<h3 className="mb-2 text-sm font-semibold">Rule preview</h3>
<p className="mb-3 text-xs text-muted">Test which rule matches a task title + issue.</p>
<input
className="input mb-2 w-full"
value={previewTitle}
onChange={(e) => setPreviewTitle(e.target.value)}
placeholder="Task title"
/>
<textarea
className="input mb-2 min-h-[80px] w-full"
value={previewIssue}
onChange={(e) => setPreviewIssue(e.target.value)}
placeholder="Issue description"
/>
<button type="button" className="btn-secondary" onClick={runPreview}>
Preview match
</button>
{preview?.matched && (
<pre className="mt-3 overflow-x-auto rounded-lg bg-panel2 p-3 text-xs">
{JSON.stringify(preview.matched, null, 2)}
</pre>
)}
{preview && !preview.matched && (
<p className="mt-3 text-sm text-muted">No specific rule keyword fallback / all devices.</p>
)}
</div>
<div className="card">
<h3 className="mb-2 text-sm font-semibold">Defined rules</h3>
<ul className="space-y-2 text-sm">
{(data?.parsed?.rules || []).map((r) => (
<li key={r.id} className="flex justify-between gap-2 border-b border-edge/50 py-2">
<span>{r.name}</span>
<span className="text-xs text-muted">priority {r.priority ?? 0}</span>
</li>
))}
</ul>
<p className="mt-3 text-xs text-muted">File: {data?.path_hint}</p>
</div>
</div>
<div className="card">
<div className="mb-2 flex items-center justify-between">
<h3 className="text-sm font-semibold">YAML editor</h3>
{!isAdmin && <span className="text-xs text-muted">Read-only (admin can edit)</span>}
</div>
<textarea
className="input min-h-[420px] w-full font-mono text-xs leading-relaxed"
value={content}
onChange={(e) => isAdmin && setYaml(e.target.value)}
readOnly={!isAdmin}
spellCheck={false}
/>
{msg && (
<p className={`mt-2 text-sm ${msgKind === "ok" ? "text-good" : "text-bad"}`}>{msg}</p>
)}
{isAdmin && (
<button type="button" className="btn-primary mt-4" disabled={saving} onClick={save}>
{saving ? "Saving…" : "Save rules"}
</button>
)}
</div>
</AppShell>
);
}

View File

@@ -0,0 +1,453 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useParams } from "next/navigation";
import useSWR from "swr";
import AppShell from "@/components/AppShell";
import TaskLiveStatus from "@/components/TaskLiveStatus";
import { ConfirmDialog, Modal, PageHeader, StatusBadge } from "@/components/ui";
import { useToast } from "@/components/toast";
import { api, fetcher, wsUrl } from "@/lib/api";
import { useAuth, roleAtLeast } from "@/lib/auth";
import { TaskEvent } from "@/lib/task-types";
import {
LlmUsage,
RunHistory,
TaskArtifactsPanel,
TaskCostPanel,
TaskReportPanel,
TaskRunHistoryPanel,
TaskScopeCard,
TaskTimelinePanel,
} from "@/components/task-detail-panels";
const MAX_EVENTS = 600;
const ACTIVE_STATUSES = ["running", "queued", "waiting_approval"];
type Event = TaskEvent;
type Approval = {
id: number;
tool_name: string;
tool_args: any;
risk: string | null;
status: string;
};
type Task = {
id: number;
title: string;
issue: string;
status: string;
report: any;
error: string | null;
memory_id: string | null;
obsidian_path: string | null;
vessel_id: number | null;
vessel_name?: string | null;
events?: Event[];
};
export default function TaskDetailPage() {
const params = useParams();
const id = params.id as string;
const { user } = useAuth();
const canAct = roleAtLeast(user?.role, "support");
const toast = useToast();
// Poll only while the task is active; stop once it settles (WS drives live updates).
const taskActive = (data?: Task) => !!data && ACTIVE_STATUSES.includes(data.status);
const { data: task, error: taskError, isLoading: taskLoading, mutate } = useSWR<Task>(
`/api/tasks/${id}`,
fetcher,
{ refreshInterval: (d) => (taskActive(d) ? 8000 : 0) }
);
const { data: approvals, mutate: mutateApprovals } = useSWR<Approval[]>(
`/api/tasks/${id}/approvals`,
fetcher,
{ refreshInterval: (d) => ((d || []).some((a) => a.status === "pending") ? 8000 : 0) }
);
const [events, setEvents] = useState<Event[]>([]);
const [wsConnected, setWsConnected] = useState(false);
const [continueOpen, setContinueOpen] = useState(false);
const [retryOpen, setRetryOpen] = useState(false);
const [cancelOpen, setCancelOpen] = useState(false);
const [followUp, setFollowUp] = useState("");
const [actionError, setActionError] = useState<string | null>(null);
const [acting, setActing] = useState(false);
const [decidingId, setDecidingId] = useState<number | null>(null);
const [pinnedToBottom, setPinnedToBottom] = useState(true);
const endRef = useRef<HTMLDivElement>(null);
const scrollBoxRef = useRef<HTMLDivElement>(null);
const appendEvent = useCallback((ev: Event) => {
setEvents((prev) => {
const key = `${ev.id || ""}-${ev.ts || ev.created_at || ""}-${ev.kind}-${ev.message || ""}`;
if (prev.some((item) => `${item.id || ""}-${item.ts || item.created_at || ""}-${item.kind}-${item.message || ""}` === key)) {
return prev;
}
const next = [...prev, ev];
return next.length > MAX_EVENTS ? next.slice(next.length - MAX_EVENTS) : next;
});
}, []);
useEffect(() => {
if (!task?.events?.length) return;
setEvents((prev) => {
if (prev.length > 0) return prev;
return task.events!.slice(-MAX_EVENTS);
});
}, [task?.events]);
// Hydrate historical events once (so joining mid/post-run shows context),
// then open a WebSocket with exponential-backoff reconnect.
useEffect(() => {
let ws: WebSocket | null = null;
let closed = false;
let retry = 0;
let timer: ReturnType<typeof setTimeout>;
setEvents([]);
const connect = () => {
ws = new WebSocket(wsUrl(`/api/tasks/${id}/stream`));
ws.onopen = () => {
retry = 0;
setWsConnected(true);
};
ws.onmessage = (msg) => {
try {
const ev = JSON.parse(msg.data) as Event;
appendEvent(ev);
if (ev.kind === "approval") {
mutateApprovals();
mutate();
}
if (ev.kind === "report" || ev.kind === "error" || ev.kind === "cancelled") {
mutate();
}
} catch {
// ignore malformed frames
}
};
ws.onclose = () => {
setWsConnected(false);
if (closed) return;
retry += 1;
const delay = Math.min(1000 * 2 ** retry, 15000);
timer = setTimeout(connect, delay);
};
ws.onerror = () => ws?.close();
};
connect();
return () => {
closed = true;
clearTimeout(timer);
ws?.close();
};
}, [id, appendEvent, mutate, mutateApprovals]);
useEffect(() => {
if (pinnedToBottom) endRef.current?.scrollIntoView({ behavior: "smooth" });
}, [events, pinnedToBottom]);
const onScroll = () => {
const el = scrollBoxRef.current;
if (!el) return;
const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 80;
setPinnedToBottom(nearBottom);
};
const decide = async (approvalId: number, approve: boolean) => {
setDecidingId(approvalId);
try {
await api(`/api/tasks/${id}/approvals/${approvalId}`, {
method: "POST",
body: JSON.stringify({ approve }),
});
toast.success(approve ? "Approved" : "Rejected");
mutateApprovals();
mutate();
} catch (err: any) {
toast.error(err.message || "Action failed");
} finally {
setDecidingId(null);
}
};
const continueTask = async (e: React.FormEvent) => {
e.preventDefault();
setActionError(null);
setActing(true);
try {
await api(`/api/tasks/${id}/continue`, {
method: "POST",
body: JSON.stringify({ follow_up: followUp }),
});
setContinueOpen(false);
setFollowUp("");
setEvents([]);
toast.success("Follow-up run queued");
mutate();
} catch (err: any) {
setActionError(err.message);
toast.error(err.message || "Could not continue task");
} finally {
setActing(false);
}
};
const retryTask = async () => {
setActing(true);
try {
await api(`/api/tasks/${id}/retry`, { method: "POST" });
setEvents([]);
setRetryOpen(false);
toast.success("Task restarted");
mutate();
} catch (err: any) {
setActionError(err.message);
toast.error(err.message || "Could not restart task");
} finally {
setActing(false);
}
};
const cancelTask = async () => {
setActing(true);
try {
await api(`/api/tasks/${id}/cancel`, { method: "POST" });
setCancelOpen(false);
toast.success("Task stopped");
mutate();
} catch (err: any) {
setActionError(err.message);
toast.error(err.message || "Could not stop task");
} finally {
setActing(false);
}
};
const report = task?.report;
const llmUsage: LlmUsage[] = report?.llm_usage || [];
const runs: RunHistory[] = report?.runs || [];
const pendingApprovals = (approvals || []).filter((a) => a.status === "pending");
const canContinue =
canAct &&
task &&
!["running", "queued"].includes(task.status);
const isRunning = task?.status === "running";
const canStop = canAct && task && ["running", "queued"].includes(task.status);
const escalateLlm = async (tier: "economy" | "premium") => {
setActionError(null);
setActing(true);
try {
await api(`/api/tasks/${id}/escalate-llm`, {
method: "POST",
body: JSON.stringify({ tier }),
});
mutate();
} catch (err: any) {
setActionError(err.message);
} finally {
setActing(false);
}
};
return (
<AppShell>
<PageHeader
backHref="/tasks"
backLabel="Tasks"
title={task?.title || `Task #${id}`}
subtitle={
task
? [
task.vessel_name && `Vessel: ${task.vessel_name}`,
task.report?.vessel_public_ip && `(${task.report.vessel_public_ip})`,
task.issue,
]
.filter(Boolean)
.join(" · ")
: undefined
}
action={
task && (
<div className="flex flex-wrap items-center gap-2">
<StatusBadge status={task.status} />
{report?.run_number > 1 && (
<span className="badge bg-panel2 text-xs text-muted">Run {report.run_number}</span>
)}
{isRunning && canAct && (
<>
<button
className="btn-ghost btn-sm border border-warn/40 text-warn"
onClick={() => escalateLlm("economy")}
disabled={acting}
title="Skip local LLM for remaining reasoning; use DeepSeek economy API"
>
Use Economy API
</button>
<button
className="btn-ghost btn-sm border border-accent2/40 text-accent2"
onClick={() => escalateLlm("premium")}
disabled={acting}
title="Skip local LLM for remaining reasoning; use premium cloud API"
>
Use Premium API
</button>
</>
)}
{canStop && (
<button
className="btn-danger btn-sm"
onClick={() => setCancelOpen(true)}
disabled={acting}
title="Stop this task if it is stuck or no longer needed"
>
Stop task
</button>
)}
{canContinue && (
<>
<button className="btn-primary btn-sm" onClick={() => setContinueOpen(true)} disabled={acting}>
Continue investigation
</button>
<button className="btn-ghost btn-sm" onClick={() => setRetryOpen(true)} disabled={acting}>
Restart from scratch
</button>
</>
)}
</div>
)
}
/>
{actionError && <div className="mb-4 text-sm text-bad">{actionError}</div>}
{taskError && !task && (
<div className="mb-4 text-sm text-bad">Couldnt load this task. Retrying</div>
)}
{task && <TaskLiveStatus events={events} taskStatus={task.status} />}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
<TaskTimelinePanel
events={events}
wsConnected={wsConnected}
pinnedToBottom={pinnedToBottom}
scrollBoxRef={scrollBoxRef}
endRef={endRef}
onScroll={onScroll}
loading={taskLoading}
onJump={() => {
setPinnedToBottom(true);
endRef.current?.scrollIntoView({ behavior: "smooth" });
}}
/>
<div className="space-y-6">
{pendingApprovals.length > 0 && (
<div className="card border-warn/40">
<h2 className="mb-1 font-semibold text-warn">Approvals required</h2>
<p className="mb-3 text-xs text-muted">
Config-changing actions proposed by the agent. Approve before they can be applied.
</p>
<div className="space-y-3">
{pendingApprovals.map((a) => (
<div key={a.id} className="rounded-lg bg-panel2 p-3">
<div className="font-mono text-sm text-accent2">{a.tool_name}</div>
{a.risk && <div className="mt-1 text-sm text-muted">{a.risk}</div>}
{canAct && (
<div className="mt-3 flex gap-2">
<button
className="btn-primary btn-sm"
onClick={() => decide(a.id, true)}
disabled={decidingId === a.id}
>
{decidingId === a.id ? "Working…" : "Approve"}
</button>
<button
className="btn-danger btn-sm"
onClick={() => decide(a.id, false)}
disabled={decidingId === a.id}
>
Reject
</button>
</div>
)}
</div>
))}
</div>
</div>
)}
{report && (
<>
<TaskScopeCard task={task} report={report} />
<TaskReportPanel report={report} pendingApprovals={pendingApprovals.length} />
<TaskArtifactsPanel task={task} report={report} />
<TaskCostPanel report={report} llmUsage={llmUsage} />
<TaskRunHistoryPanel runs={runs} />
</>
)}
{task?.error && (
<div className="card border-bad/40">
<h2 className="mb-2 font-semibold text-bad">Error</h2>
<p className="text-sm">{task.error}</p>
</div>
)}
</div>
</div>
<Modal open={continueOpen} onClose={() => setContinueOpen(false)} title="Continue investigation">
<form onSubmit={continueTask} className="space-y-4">
<p className="text-sm text-muted">
The agent will use prior findings and run additional checks. Describe what to investigate next
or why the issue is still unresolved.
</p>
<div>
<label htmlFor="follow-up" className="label">
Follow-up instructions
</label>
<textarea
id="follow-up"
className="input min-h-[120px]"
value={followUp}
onChange={(e) => setFollowUp(e.target.value)}
placeholder="e.g. check asterisk SIP registrations and pfsense NAT rules for port 5060"
required
/>
</div>
{actionError && <div className="text-sm text-bad">{actionError}</div>}
<button className="btn-primary w-full" disabled={acting}>
{acting ? "Queuing…" : "Run follow-up"}
</button>
</form>
</Modal>
<ConfirmDialog
open={cancelOpen}
title="Stop this task?"
message="The agent will stop at the next checkpoint. You can restart or continue the investigation later."
confirmLabel="Stop task"
destructive
busy={acting}
onConfirm={cancelTask}
onCancel={() => setCancelOpen(false)}
/>
<ConfirmDialog
open={retryOpen}
title="Restart from scratch?"
message="Run history and cost totals will be cleared, and the task will run again from the beginning."
confirmLabel="Restart"
destructive
busy={acting}
onConfirm={retryTask}
onCancel={() => setRetryOpen(false)}
/>
</AppShell>
);
}

394
frontend/app/tasks/page.tsx Normal file
View File

@@ -0,0 +1,394 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import useSWR from "swr";
import { useRouter } from "next/navigation";
import AppShell from "@/components/AppShell";
import {
EmptyState,
FormError,
LoadError,
Modal,
PageHeader,
SkeletonRows,
StatusBadge,
} from "@/components/ui";
import { useToast } from "@/components/toast";
import { api, fetcher } from "@/lib/api";
import { useAuth, roleAtLeast } from "@/lib/auth";
import { TaskPreview } from "@/lib/task-types";
const ACTIVE_STATUSES = ["running", "queued", "waiting_approval"];
const STATUS_FILTERS = ["all", "queued", "running", "waiting_approval", "succeeded", "failed", "cancelled"];
const TEMPLATES = [
{
label: "Proxmox + pfSense status",
title: "Proxmox and pfSense status",
issue: "check status of proxmox and pfsense",
},
{
label: "Full vessel health",
title: "Full vessel health check",
issue: "run a full stack health check on all systems",
},
{
label: "Firewall rules",
title: "pfSense firewall rules",
issue: "list firewall rules, NAT, gateways, and interfaces for pfsense",
},
{
label: "VoIP registration",
title: "VoIP registration check",
issue: "check pfsense SIP/NAT and asterisk registrations",
},
];
type Task = {
id: number;
title: string;
issue: string;
status: string;
vessel_id: number | null;
vessel_name?: string | null;
created_at: string;
};
type VesselDetail = {
id: number;
name: string;
devices?: { catalog_key: string | null; device_type: string; name: string }[];
};
export default function TasksPage() {
const { user } = useAuth();
const router = useRouter();
const toast = useToast();
const {
data: tasks,
error: tasksError,
isLoading,
mutate,
} = useSWR<Task[]>("/api/tasks", fetcher, {
refreshInterval: (data) =>
(data || []).some((t) => ACTIVE_STATUSES.includes(t.status)) ? 5000 : 0,
});
const { data: vessels } = useSWR<VesselDetail[]>("/api/vessels", fetcher);
const [open, setOpen] = useState(false);
const [form, setForm] = useState({ title: "", issue: "", vessel_id: "" });
const [error, setError] = useState<string | null>(null);
const [query, setQuery] = useState("");
const [statusFilter, setStatusFilter] = useState("all");
const [creating, setCreating] = useState(false);
const [preview, setPreview] = useState<TaskPreview | null>(null);
const [previewLoading, setPreviewLoading] = useState(false);
const canCreate = roleAtLeast(user?.role, "support");
const selectedVessel = useMemo(
() => vessels?.find((v) => String(v.id) === form.vessel_id),
[vessels, form.vessel_id]
);
const filteredTasks = useMemo(() => {
const q = query.trim().toLowerCase();
return (tasks || []).filter((t) => {
if (statusFilter !== "all" && t.status !== statusFilter) return false;
if (!q) return true;
return (
t.title.toLowerCase().includes(q) ||
t.issue.toLowerCase().includes(q) ||
(t.vessel_name || "").toLowerCase().includes(q)
);
});
}, [tasks, query, statusFilter]);
useEffect(() => {
const issue = form.issue.trim();
if (!open || !form.vessel_id || issue.length < 4) {
setPreview(null);
setPreviewLoading(false);
return;
}
let cancelled = false;
const timer = setTimeout(async () => {
setPreviewLoading(true);
try {
const data = await api<TaskPreview>("/api/tasks/preview", {
method: "POST",
body: JSON.stringify({
title: form.title,
issue,
vessel_id: Number(form.vessel_id),
}),
});
if (!cancelled) setPreview(data);
} catch {
if (!cancelled) setPreview(null);
} finally {
if (!cancelled) setPreviewLoading(false);
}
}, 350);
return () => {
cancelled = true;
clearTimeout(timer);
};
}, [form.issue, form.title, form.vessel_id, open]);
const create = async (e: React.FormEvent | null, watch = true) => {
e?.preventDefault();
setError(null);
if (!form.vessel_id) {
setError("Select a vessel");
return;
}
if (form.issue.trim().length < 5) {
setError("Describe the issue in a bit more detail");
return;
}
setCreating(true);
try {
const payload = {
title: form.title,
issue: form.issue,
vessel_id: Number(form.vessel_id),
};
const created = await api<Task>("/api/tasks", {
method: "POST",
body: JSON.stringify(payload),
});
setOpen(false);
setForm({ title: "", issue: "", vessel_id: "" });
setPreview(null);
toast.success("Task created");
mutate();
router.push(watch ? `/tasks/${created.id}` : "/");
} catch (err: any) {
setError(err.message);
toast.error(err.message || "Could not create task");
} finally {
setCreating(false);
}
};
return (
<AppShell>
<PageHeader
title="Tasks"
subtitle="Pick a vessel and describe the issue — the agent chooses which onboard devices to check"
action={
canCreate && (
<button className="btn-primary" onClick={() => setOpen(true)}>
+ New task
</button>
)
}
/>
{tasks && tasks.length > 0 && (
<div className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-center">
<input
className="input sm:max-w-xs"
placeholder="Search title, issue, or vessel…"
value={query}
onChange={(e) => setQuery(e.target.value)}
aria-label="Search tasks"
/>
<div className="flex flex-wrap gap-1">
{STATUS_FILTERS.map((s) => (
<button
key={s}
onClick={() => setStatusFilter(s)}
className={`badge btn-sm ${
statusFilter === s ? "bg-accent/20 text-accent" : "bg-panel2 text-muted"
}`}
>
{s.replace(/_/g, " ")}
</button>
))}
</div>
</div>
)}
{isLoading && !tasks ? (
<SkeletonRows rows={4} />
) : tasksError ? (
<LoadError message="Couldnt load tasks." onRetry={() => mutate()} />
) : (tasks || []).length === 0 ? (
<EmptyState
title="No tasks yet"
description="Create a troubleshooting task and the agent will diagnose the selected vessel."
action={
canCreate && (
<button className="btn-primary" onClick={() => setOpen(true)}>
+ New task
</button>
)
}
/>
) : filteredTasks.length === 0 ? (
<EmptyState title="No matching tasks" description="Try a different search or status filter." />
) : (
<div className="grid grid-cols-1 gap-3">
{filteredTasks.map((t) => (
<button
key={t.id}
onClick={() => router.push(`/tasks/${t.id}`)}
aria-label={`Task ${t.title}, status ${t.status.replace(/_/g, " ")}`}
className="card flex items-center justify-between gap-3 text-left hover:border-accent/50"
>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium">{t.title}</span>
<span className="text-xs text-muted">#{t.id}</span>
{t.vessel_name && (
<span className="badge bg-panel2 text-xs text-accent">{t.vessel_name}</span>
)}
</div>
<div className="mt-1 truncate text-sm text-muted">{t.issue}</div>
</div>
<StatusBadge status={t.status} />
</button>
))}
</div>
)}
<Modal open={open} onClose={() => setOpen(false)} title="New troubleshooting task">
<form onSubmit={(e) => create(e, true)} className="space-y-4">
<div>
<div className="label">Quick templates</div>
<div className="flex flex-wrap gap-2">
{TEMPLATES.map((template) => (
<button
key={template.label}
type="button"
className="badge bg-panel2 text-xs text-muted hover:text-accent"
onClick={() =>
setForm({
...form,
title: template.title,
issue: template.issue,
})
}
>
{template.label}
</button>
))}
</div>
</div>
<div>
<label htmlFor="task-title" className="label">
Title
</label>
<input
id="task-title"
className="input"
value={form.title}
onChange={(e) => setForm({ ...form, title: e.target.value })}
placeholder="e.g. GeneseasX health check"
required
/>
</div>
<div>
<label htmlFor="task-issue" className="label">
Describe the issue
</label>
<textarea
id="task-issue"
className="input min-h-[120px]"
value={form.issue}
onChange={(e) => setForm({ ...form, issue: e.target.value })}
placeholder="e.g. check proxmox health, pfsense gateway status, and asterisk registrations"
required
/>
<p className="mt-1 text-xs text-muted">
Mention device types (proxmox, pfsense, asterisk, docker) or say &quot;health status&quot; to
check all onboard devices.
</p>
</div>
<div>
<label htmlFor="task-vessel" className="label">
Vessel
</label>
<select
id="task-vessel"
className="input"
value={form.vessel_id}
onChange={(e) => setForm({ ...form, vessel_id: e.target.value })}
required
>
<option value=""> select vessel </option>
{(vessels || []).map((v) => (
<option key={v.id} value={v.id}>
{v.name}
</option>
))}
</select>
{selectedVessel?.devices && selectedVessel.devices.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1">
{selectedVessel.devices.map((d) => (
<span
key={d.catalog_key || d.name}
className="badge bg-panel2 text-xs text-muted"
>
{d.catalog_key || d.device_type}
</span>
))}
</div>
)}
</div>
<div className="rounded-xl border border-edge bg-panel2 p-3">
<div className="mb-2 flex items-center justify-between gap-3">
<div className="font-medium">What will be checked</div>
{previewLoading && <span className="text-xs text-muted">detecting</span>}
</div>
{!form.vessel_id ? (
<p className="text-sm text-muted">Select a vessel to preview onboard devices.</p>
) : !form.issue.trim() ? (
<p className="text-sm text-muted">Describe the issue to preview device scope.</p>
) : preview?.warning ? (
<p className="text-sm text-warn">{preview.warning}</p>
) : preview?.devices.length ? (
<div className="space-y-3">
{preview.rule_name && (
<div className="text-xs text-muted">
Rule: <span className="text-accent">{preview.rule_name}</span>
{preview.severity ? ` · ${preview.severity}` : ""}
</div>
)}
{preview.devices.map((device) => (
<div key={device.catalog_key} className="rounded-lg bg-panel px-3 py-2">
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-medium">{device.label}</span>
<span className="badge bg-panel2 text-xs text-muted">{device.catalog_key}</span>
</div>
<div className="mt-2 flex flex-wrap gap-1">
{device.checks.map((check) => (
<span key={check} className="badge bg-accent/10 text-[11px] text-accent">
{check}
</span>
))}
</div>
</div>
))}
</div>
) : (
<p className="text-sm text-muted">No preview yet.</p>
)}
</div>
<FormError message={error} />
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
<button className="btn-primary w-full" disabled={creating}>
{creating ? "Creating…" : "Create & watch live"}
</button>
<button
type="button"
className="btn-ghost w-full"
disabled={creating}
onClick={() => create(null, false)}
>
Run in background
</button>
</div>
</form>
</Modal>
</AppShell>
);
}

229
frontend/app/users/page.tsx Normal file
View File

@@ -0,0 +1,229 @@
"use client";
import { useState } from "react";
import useSWR from "swr";
import AppShell from "@/components/AppShell";
import {
ConfirmDialog,
EmptyState,
FormError,
LoadError,
Modal,
PageHeader,
SkeletonRows,
StatusBadge,
} from "@/components/ui";
import { useToast } from "@/components/toast";
import { api, fetcher } from "@/lib/api";
type User = {
id: number;
email: string;
full_name: string | null;
role: string;
is_active: boolean;
};
const ROLES = ["readonly", "support", "admin"];
export default function UsersPage() {
const { data: users, error: usersError, isLoading, mutate } = useSWR<User[]>(
"/api/users",
fetcher
);
const toast = useToast();
const [open, setOpen] = useState(false);
const [form, setForm] = useState({ email: "", password: "", full_name: "", role: "readonly" });
const [error, setError] = useState<string | null>(null);
const [creating, setCreating] = useState(false);
const [deleteId, setDeleteId] = useState<number | null>(null);
const [deleting, setDeleting] = useState(false);
const create = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
setCreating(true);
try {
await api("/api/users", { method: "POST", body: JSON.stringify(form) });
setOpen(false);
setForm({ email: "", password: "", full_name: "", role: "readonly" });
toast.success("User created");
mutate();
} catch (err: any) {
setError(err.message);
toast.error(err.message || "Could not create user");
} finally {
setCreating(false);
}
};
const update = async (id: number, patch: Partial<User>) => {
try {
await api(`/api/users/${id}`, { method: "PATCH", body: JSON.stringify(patch) });
toast.success("User updated");
mutate();
} catch (err: any) {
toast.error(err.message || "Update failed");
}
};
const remove = async () => {
if (deleteId == null) return;
setDeleting(true);
try {
await api(`/api/users/${deleteId}`, { method: "DELETE" });
toast.success("User deleted");
mutate();
} catch (err: any) {
toast.error(err.message || "Delete failed");
} finally {
setDeleting(false);
setDeleteId(null);
}
};
return (
<AppShell>
<PageHeader
title="Users"
subtitle="Manage operators and their roles"
action={
<button className="btn-primary" onClick={() => setOpen(true)}>
+ New user
</button>
}
/>
{isLoading && !users ? (
<SkeletonRows rows={4} />
) : usersError ? (
<LoadError message="Couldnt load users." onRetry={() => mutate()} />
) : (users || []).length === 0 ? (
<EmptyState title="No users yet" description="Create the first operator account." />
) : (
<div className="card overflow-x-auto p-0">
<table className="w-full min-w-[640px]">
<thead>
<tr>
<th className="th">Email</th>
<th className="th">Name</th>
<th className="th">Role</th>
<th className="th">Active</th>
<th className="th"></th>
</tr>
</thead>
<tbody>
{(users || []).map((u) => (
<tr key={u.id}>
<td className="td">{u.email}</td>
<td className="td">{u.full_name || "—"}</td>
<td className="td">
<select
aria-label={`Role for ${u.email}`}
className="input max-w-[140px]"
value={u.role}
onChange={(e) => update(u.id, { role: e.target.value as any })}
>
{ROLES.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</select>
</td>
<td className="td">
<button
aria-label={`Toggle active for ${u.email}`}
aria-pressed={u.is_active}
onClick={() => update(u.id, { is_active: !u.is_active })}
>
<StatusBadge status={u.is_active ? "loaded" : "stopped"} />
</button>
</td>
<td className="td text-right">
<button className="btn-danger btn-sm" onClick={() => setDeleteId(u.id)}>
Delete
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<Modal open={open} onClose={() => setOpen(false)} title="Create user">
<form onSubmit={create} className="space-y-4">
<div>
<label htmlFor="user-email" className="label">
Email
</label>
<input
id="user-email"
className="input"
type="email"
value={form.email}
onChange={(e) => setForm({ ...form, email: e.target.value })}
required
/>
</div>
<div>
<label htmlFor="user-name" className="label">
Full name
</label>
<input
id="user-name"
className="input"
value={form.full_name}
onChange={(e) => setForm({ ...form, full_name: e.target.value })}
/>
</div>
<div>
<label htmlFor="user-password" className="label">
Password
</label>
<input
id="user-password"
className="input"
type="password"
value={form.password}
onChange={(e) => setForm({ ...form, password: e.target.value })}
required
/>
</div>
<div>
<label htmlFor="user-role" className="label">
Role
</label>
<select
id="user-role"
className="input"
value={form.role}
onChange={(e) => setForm({ ...form, role: e.target.value })}
>
{ROLES.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</select>
</div>
<FormError message={error} />
<button className="btn-primary w-full" disabled={creating}>
{creating ? "Creating…" : "Create"}
</button>
</form>
</Modal>
<ConfirmDialog
open={deleteId != null}
title="Delete user?"
message="This permanently removes the user account."
confirmLabel="Delete"
destructive
busy={deleting}
onConfirm={remove}
onCancel={() => setDeleteId(null)}
/>
</AppShell>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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
View 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 = "Couldnt 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>
);
}

80
frontend/lib/api.ts Normal file
View File

@@ -0,0 +1,80 @@
export const API_BASE =
process.env.NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000";
const TOKEN_KEY = "agentic_token";
export function getToken(): string | null {
if (typeof window === "undefined") return null;
return window.localStorage.getItem(TOKEN_KEY);
}
export function setToken(token: string | null) {
if (typeof window === "undefined") return;
if (token) window.localStorage.setItem(TOKEN_KEY, token);
else window.localStorage.removeItem(TOKEN_KEY);
}
export class ApiError extends Error {
status: number;
constructor(status: number, message: string) {
super(message);
this.status = status;
}
}
export async function api<T = any>(
path: string,
options: RequestInit = {}
): Promise<T> {
const headers = new Headers(options.headers);
const token = getToken();
if (token) headers.set("Authorization", `Bearer ${token}`);
if (!headers.has("Content-Type") && options.body && !(options.body instanceof FormData)) {
headers.set("Content-Type", "application/json");
}
const res = await fetch(`${API_BASE}${path}`, { ...options, headers });
if (res.status === 401 && typeof window !== "undefined") {
setToken(null);
if (!window.location.pathname.startsWith("/login")) {
window.location.href = "/login";
}
}
if (!res.ok) {
let detail = res.statusText;
try {
const body = await res.json();
detail = body.detail || detail;
} catch {}
throw new ApiError(res.status, typeof detail === "string" ? detail : JSON.stringify(detail));
}
if (res.status === 204) return undefined as T;
return res.json();
}
export async function login(email: string, password: string) {
const form = new URLSearchParams();
form.set("username", email);
form.set("password", password);
const res = await fetch(`${API_BASE}/api/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: form.toString(),
});
if (!res.ok) {
let detail = "Login failed";
try {
detail = (await res.json()).detail || detail;
} catch {}
throw new ApiError(res.status, detail);
}
return res.json() as Promise<{ access_token: string; role: string; email: string }>;
}
export function wsUrl(path: string): string {
const base = API_BASE.replace(/^http/, "ws");
const token = getToken();
const sep = path.includes("?") ? "&" : "?";
return `${base}${path}${sep}token=${encodeURIComponent(token || "")}`;
}
export const fetcher = (path: string) => api(path);

75
frontend/lib/auth.tsx Normal file
View File

@@ -0,0 +1,75 @@
"use client";
import { createContext, useCallback, useContext, useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { api, login as apiLogin, setToken, getToken } from "./api";
type Me = {
id: number;
email: string;
full_name: string | null;
role: "admin" | "support" | "readonly";
is_active: boolean;
};
type AuthCtx = {
user: Me | null;
loading: boolean;
login: (email: string, password: string) => Promise<void>;
logout: () => void;
};
const Ctx = createContext<AuthCtx>({
user: null,
loading: true,
login: async () => {},
logout: () => {},
});
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<Me | null>(null);
const [loading, setLoading] = useState(true);
const router = useRouter();
const loadMe = useCallback(async () => {
if (!getToken()) {
setUser(null);
setLoading(false);
return;
}
try {
const me = await api<Me>("/api/auth/me");
setUser(me);
} catch {
setUser(null);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
loadMe();
}, [loadMe]);
const login = async (email: string, password: string) => {
const res = await apiLogin(email, password);
setToken(res.access_token);
await loadMe();
router.push("/");
};
const logout = () => {
setToken(null);
setUser(null);
router.push("/login");
};
return <Ctx.Provider value={{ user, loading, login, logout }}>{children}</Ctx.Provider>;
}
export const useAuth = () => useContext(Ctx);
export function roleAtLeast(role: string | undefined, min: "readonly" | "support" | "admin") {
const rank: Record<string, number> = { readonly: 0, support: 1, admin: 2 };
return (rank[role || "readonly"] ?? 0) >= rank[min];
}

View File

@@ -0,0 +1,93 @@
export const ACTIVE_STATUSES = ["running", "queued", "waiting_approval"] as const;
export type TaskStatus =
| "queued"
| "running"
| "waiting_approval"
| "succeeded"
| "failed"
| "cancelled";
export type TaskEvent = {
id?: number;
kind: string;
message: string | null;
payload?: Record<string, any> | null;
ts?: string;
created_at?: string;
replay?: boolean;
};
export type TaskOverview = {
id: number;
title: string;
issue: string;
status: TaskStatus;
vessel_id: number | null;
vessel_name?: string | null;
summary?: string | null;
key_finding?: string | null;
devices_checked: string[];
resolved?: boolean | null;
memory_id?: string | null;
obsidian_path?: string | null;
obsidian_push_error?: string | null;
run_cost_usd?: number | null;
created_at: string;
updated_at: string;
};
export type TaskPreview = {
vessel_id: number;
vessel_name?: string | null;
devices: { catalog_key: string; name: string; label: string; checks: string[] }[];
rule_id?: string | null;
rule_name?: string | null;
severity?: string | null;
hints: string[];
warning?: string | null;
};
export function isActiveStatus(status: string) {
return ACTIVE_STATUSES.includes(status as (typeof ACTIVE_STATUSES)[number]);
}
export function taskPhaseLabel(status: string, events: TaskEvent[] = []) {
if (status === "queued") return "Waiting in queue";
if (status === "waiting_approval") return "Waiting for approval";
if (status === "succeeded") return "Report complete";
if (status === "failed") return "Failed";
if (status === "cancelled") return "Stopped";
const last = [...events].reverse().find((e) => e.kind !== "log");
if (!last) return "Starting";
if (last.kind === "context") return "Gathering context";
if (last.kind === "triage" || last.kind === "rule") return "Planning checks";
if (last.kind === "connect") return "Connecting to devices";
if (last.kind === "tool_start" || last.kind === "tool_call" || last.kind === "diagnose") {
const device = last.payload?.device;
const tool = humanToolName(String(last.payload?.tool || ""));
return [device, tool].filter(Boolean).join(": ") || "Running diagnostics";
}
if (last.kind === "reason") return "Analyzing findings";
if (last.kind === "report") return "Writing report";
return last.message || "Running";
}
export function humanToolName(tool: string) {
const known: Record<string, string> = {
pfsense_get_system_status: "pfSense system status",
pfsense_list_interfaces: "pfSense interfaces",
pfsense_list_gateways: "pfSense gateways",
pfsense_get_gateway_status: "pfSense gateway status",
pfsense_list_firewall_rules: "pfSense firewall rules",
pfsense_list_port_forwards: "pfSense port forwards",
pfsense_list_outbound_nat_mappings: "pfSense outbound NAT",
proxmox_list_nodes: "Proxmox nodes",
proxmox_list_qemu: "Proxmox VMs",
proxmox_list_lxc: "Proxmox LXCs",
proxmox_get_qemu_status: "VM status",
proxmox_get_qemu_config: "VM config",
ssh_run: "SSH command",
};
return known[tool] || tool.replace(/_/g, " ");
}

2
frontend/next-env.d.ts vendored Normal file
View File

@@ -0,0 +1,2 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />

6
frontend/next.config.js Normal file
View File

@@ -0,0 +1,6 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
};
module.exports = nextConfig;

26
frontend/package.json Normal file
View File

@@ -0,0 +1,26 @@
{
"name": "agentic-os-frontend",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev -p 3000",
"build": "next build",
"start": "next start -p 3000",
"lint": "next lint"
},
"dependencies": {
"next": "14.2.18",
"react": "18.3.1",
"react-dom": "18.3.1",
"swr": "2.2.5"
},
"devDependencies": {
"@types/node": "20.14.10",
"@types/react": "18.3.3",
"@types/react-dom": "18.3.0",
"autoprefixer": "10.4.20",
"postcss": "8.4.49",
"tailwindcss": "3.4.17",
"typescript": "5.5.3"
}
}

View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

1
frontend/public/.gitkeep Normal file
View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,24 @@
import type { Config } from "tailwindcss";
const config: Config = {
content: ["./app/**/*.{ts,tsx}", "./components/**/*.{ts,tsx}"],
theme: {
extend: {
colors: {
ink: "#0b0f17",
panel: "#121826",
panel2: "#1a2234",
edge: "#243049",
accent: "#5b8cff",
accent2: "#22d3ee",
good: "#34d399",
warn: "#fbbf24",
bad: "#f87171",
muted: "#8a96ad",
},
},
},
plugins: [],
};
export default config;

22
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"baseUrl": ".",
"paths": { "@/*": ["./*"] }
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}