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:
53
frontend/app/globals.css
Normal file
53
frontend/app/globals.css
Normal 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;
|
||||
}
|
||||
}
|
||||
467
frontend/app/inventory/page.tsx
Normal file
467
frontend/app/inventory/page.tsx
Normal 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="Couldn’t 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
18
frontend/app/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
82
frontend/app/login/page.tsx
Normal file
82
frontend/app/login/page.tsx
Normal 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
199
frontend/app/mcp/page.tsx
Normal 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="Couldn’t 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>
|
||||
);
|
||||
}
|
||||
368
frontend/app/models/page.tsx
Normal file
368
frontend/app/models/page.tsx
Normal 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
223
frontend/app/page.tsx
Normal 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
134
frontend/app/rules/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
453
frontend/app/tasks/[id]/page.tsx
Normal file
453
frontend/app/tasks/[id]/page.tsx
Normal 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">Couldn’t 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
394
frontend/app/tasks/page.tsx
Normal 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="Couldn’t 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 "health status" 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
229
frontend/app/users/page.tsx
Normal 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="Couldn’t 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user