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

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