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:
5
frontend/.dockerignore
Normal file
5
frontend/.dockerignore
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
.next/
|
||||
out/
|
||||
.env
|
||||
npm-debug.log*
|
||||
22
frontend/Dockerfile
Normal file
22
frontend/Dockerfile
Normal file
@@ -0,0 +1,22 @@
|
||||
FROM node:20-alpine AS deps
|
||||
WORKDIR /app
|
||||
COPY package.json ./
|
||||
RUN npm install
|
||||
|
||||
FROM node:20-alpine AS builder
|
||||
WORKDIR /app
|
||||
ARG NEXT_PUBLIC_API_BASE_URL
|
||||
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM node:20-alpine AS runner
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
COPY --from=builder /app/.next ./.next
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
COPY --from=builder /app/package.json ./package.json
|
||||
COPY --from=builder /app/public ./public
|
||||
EXPOSE 3000
|
||||
CMD ["npm", "run", "start"]
|
||||
53
frontend/app/globals.css
Normal file
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>
|
||||
);
|
||||
}
|
||||
119
frontend/components/AppShell.tsx
Normal file
119
frontend/components/AppShell.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useAuth, roleAtLeast } from "@/lib/auth";
|
||||
|
||||
const NAV = [
|
||||
{ href: "/", label: "Dashboard", icon: "▦", min: "readonly" as const },
|
||||
{ href: "/tasks", label: "Tasks", icon: "✦", min: "readonly" as const },
|
||||
{ href: "/inventory", label: "Vessels", icon: "▤", min: "readonly" as const },
|
||||
{ href: "/mcp", label: "MCP Servers", icon: "⚇", min: "readonly" as const },
|
||||
{ href: "/rules", label: "Rules", icon: "☰", min: "support" as const },
|
||||
{ href: "/models", label: "Models", icon: "◈", min: "admin" as const },
|
||||
{ href: "/users", label: "Users", icon: "♟", min: "admin" as const },
|
||||
];
|
||||
|
||||
export default function AppShell({ children }: { children: React.ReactNode }) {
|
||||
const { user, loading, logout } = useAuth();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const [navOpen, setNavOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && !user) router.replace("/login");
|
||||
}, [loading, user, router]);
|
||||
|
||||
// Close the mobile drawer on navigation.
|
||||
useEffect(() => {
|
||||
setNavOpen(false);
|
||||
}, [pathname]);
|
||||
|
||||
if (loading || !user) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center gap-2 text-muted">
|
||||
<span className="inline-block h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
Loading…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const sidebar = (
|
||||
<>
|
||||
<div className="mb-8 flex items-center gap-2 px-2">
|
||||
<div className="grid h-9 w-9 place-items-center rounded-xl bg-gradient-to-br from-accent to-accent2 font-bold text-white">
|
||||
A
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-semibold">Agentic OS</div>
|
||||
<div className="text-[11px] text-muted">troubleshooting</div>
|
||||
</div>
|
||||
</div>
|
||||
<nav className="flex flex-1 flex-col gap-1">
|
||||
{NAV.filter((n) => roleAtLeast(user.role, n.min)).map((n) => {
|
||||
const active = pathname === n.href || (n.href !== "/" && pathname.startsWith(n.href));
|
||||
return (
|
||||
<Link
|
||||
key={n.href}
|
||||
href={n.href}
|
||||
aria-current={active ? "page" : undefined}
|
||||
className={`flex items-center gap-3 rounded-xl px-3 py-2 text-sm transition ${
|
||||
active ? "bg-accent/15 text-white" : "text-slate-300 hover:bg-panel2"
|
||||
}`}
|
||||
>
|
||||
<span className="text-base opacity-80" aria-hidden="true">
|
||||
{n.icon}
|
||||
</span>
|
||||
{n.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
<div className="mt-4 border-t border-edge pt-4">
|
||||
<div className="px-2 text-sm">{user.full_name || user.email}</div>
|
||||
<div className="mb-3 px-2 text-[11px] uppercase tracking-wide text-muted">{user.role}</div>
|
||||
<button onClick={logout} className="btn-ghost w-full">
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen">
|
||||
{/* Mobile top bar */}
|
||||
<header className="fixed inset-x-0 top-0 z-30 flex items-center gap-3 border-b border-edge bg-panel/90 px-4 py-3 backdrop-blur md:hidden">
|
||||
<button
|
||||
aria-label="Open menu"
|
||||
aria-expanded={navOpen}
|
||||
onClick={() => setNavOpen(true)}
|
||||
className="btn-ghost btn-sm"
|
||||
>
|
||||
☰
|
||||
</button>
|
||||
<span className="text-sm font-semibold">Agentic OS</span>
|
||||
</header>
|
||||
|
||||
{/* Mobile drawer */}
|
||||
{navOpen && (
|
||||
<div className="fixed inset-0 z-40 md:hidden" onClick={() => setNavOpen(false)}>
|
||||
<div className="absolute inset-0 bg-black/60" />
|
||||
<aside
|
||||
className="absolute left-0 top-0 flex h-full w-64 flex-col border-r border-edge bg-panel p-4"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{sidebar}
|
||||
</aside>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Desktop sidebar */}
|
||||
<aside className="hidden w-60 flex-col border-r border-edge bg-panel/60 p-4 md:flex">
|
||||
{sidebar}
|
||||
</aside>
|
||||
|
||||
<main className="flex-1 overflow-y-auto p-4 pt-20 md:p-8 md:pt-8">{children}</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
205
frontend/components/TaskLiveStatus.tsx
Normal file
205
frontend/components/TaskLiveStatus.tsx
Normal file
@@ -0,0 +1,205 @@
|
||||
"use client";
|
||||
|
||||
import { humanToolName, taskPhaseLabel, TaskEvent } from "@/lib/task-types";
|
||||
|
||||
const PHASES = [
|
||||
{ id: "context", label: "Context" },
|
||||
{ id: "triage", label: "Plan" },
|
||||
{ id: "connect", label: "Connect" },
|
||||
{ id: "diagnose", label: "Diagnose" },
|
||||
{ id: "reason", label: "Analyze" },
|
||||
{ id: "report", label: "Report" },
|
||||
] as const;
|
||||
|
||||
function phaseIndex(phase: string | undefined): number {
|
||||
if (!phase) return -1;
|
||||
return PHASES.findIndex((p) => p.id === phase);
|
||||
}
|
||||
|
||||
function derivePhase(events: TaskEvent[], taskStatus?: string): string {
|
||||
if (taskStatus === "succeeded" || taskStatus === "waiting_approval") return "report";
|
||||
for (let i = events.length - 1; i >= 0; i--) {
|
||||
const p = events[i].payload?.phase as string | undefined;
|
||||
if (p) return p;
|
||||
const k = events[i].kind;
|
||||
if (k === "context" || k === "rule") return "context";
|
||||
if (k === "triage") return "triage";
|
||||
if (k === "connect") return "connect";
|
||||
if (k === "diagnose" || k === "tool_start" || k === "tool_call") return "diagnose";
|
||||
if (k === "reason") return "reason";
|
||||
if (k === "report") return "report";
|
||||
}
|
||||
return "context";
|
||||
}
|
||||
|
||||
function activeTool(events: TaskEvent[]): TaskEvent | null {
|
||||
for (let i = events.length - 1; i >= 0; i--) {
|
||||
const ev = events[i];
|
||||
if (ev.kind === "tool_start") return ev;
|
||||
if (ev.kind === "tool_call" || ev.kind === "error") return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function TaskLiveStatus({
|
||||
events,
|
||||
taskStatus,
|
||||
}: {
|
||||
events: TaskEvent[];
|
||||
taskStatus: string;
|
||||
}) {
|
||||
const running = taskStatus === "running" || taskStatus === "queued";
|
||||
const currentPhase = derivePhase(events, taskStatus);
|
||||
const currentIdx = phaseIndex(currentPhase);
|
||||
const active = activeTool(events);
|
||||
const lastProgress = [...events].reverse().find((e) => e.kind === "progress");
|
||||
const devices = Array.from(
|
||||
new Set(
|
||||
events
|
||||
.map((e) => e.payload?.device)
|
||||
.filter((device): device is string => typeof device === "string" && !!device)
|
||||
)
|
||||
);
|
||||
const nowDoing = active
|
||||
? `${active.payload?.device || "Device"}: ${humanToolName(String(active.payload?.tool || "diagnostic"))}`
|
||||
: taskPhaseLabel(taskStatus, events);
|
||||
|
||||
return (
|
||||
<div className="card mb-6 border-accent/30 bg-gradient-to-br from-panel to-panel2">
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="font-semibold">Task status</h2>
|
||||
<p className="mt-1 text-sm text-muted">{nowDoing}</p>
|
||||
</div>
|
||||
{running && (
|
||||
<span className="flex items-center gap-2 text-xs text-accent">
|
||||
<span className="inline-block h-2 w-2 animate-pulse rounded-full bg-accent" />
|
||||
In progress
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mb-4 grid grid-cols-2 gap-2 md:grid-cols-6">
|
||||
{PHASES.map((phase, idx) => {
|
||||
const done = !running && currentIdx >= 0 ? idx <= currentIdx : idx < currentIdx;
|
||||
const activePhase = idx === currentIdx && running;
|
||||
return (
|
||||
<div
|
||||
key={phase.id}
|
||||
className={`rounded-lg px-3 py-2 text-center text-xs transition-colors ${
|
||||
activePhase
|
||||
? "bg-accent/20 text-accent ring-1 ring-accent/40"
|
||||
: done
|
||||
? "bg-good/10 text-good"
|
||||
: "bg-ink/40 text-muted"
|
||||
}`}
|
||||
>
|
||||
<span className="font-medium">{phase.label}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{devices.length > 0 && (
|
||||
<div className="mb-4 flex flex-wrap gap-2">
|
||||
{devices.map((device) => (
|
||||
<span key={device} className="badge bg-panel text-xs text-accent">
|
||||
{device}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(lastProgress?.message || active) && (
|
||||
<div className="rounded-lg bg-ink/50 p-3">
|
||||
{lastProgress?.message && (
|
||||
<p className="text-sm text-slate-200">{lastProgress.message}</p>
|
||||
)}
|
||||
{active && (
|
||||
<div className="mt-2 border-t border-edge/50 pt-2">
|
||||
<div className="text-xs uppercase tracking-wide text-accent2">Running now</div>
|
||||
<div className="mt-1 font-mono text-sm">
|
||||
{(active.payload?.device as string) || "?"} - {humanToolName((active.payload?.tool as string) || "tool")}
|
||||
</div>
|
||||
{typeof active.payload?.command === "string" && (
|
||||
<pre className="mt-2 overflow-x-auto rounded bg-black/40 p-2 font-mono text-[11px] text-emerald-300/90">
|
||||
$ {active.payload.command}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function eventTime(ev: TaskEvent) {
|
||||
const raw = ev.ts || ev.created_at;
|
||||
if (!raw) return "";
|
||||
try {
|
||||
return new Date(raw).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
export function TimelineEvent({ ev }: { ev: TaskEvent }) {
|
||||
const p = ev.payload || {};
|
||||
const isError = ev.kind === "error" || p.status === "error" || p.ok === false;
|
||||
const isRunning = p.status === "running" || ev.kind === "tool_start";
|
||||
const isDone = p.ok === true || p.status === "done";
|
||||
|
||||
const border = isError
|
||||
? "border-l-2 border-l-bad"
|
||||
: isRunning
|
||||
? "border-l-2 border-l-accent animate-pulse"
|
||||
: isDone
|
||||
? "border-l-2 border-l-good"
|
||||
: "border-l-2 border-l-transparent";
|
||||
|
||||
return (
|
||||
<div className={`rounded-lg bg-panel2 px-3 py-2 ${border}`}>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={isError ? "text-bad" : isRunning ? "text-accent" : "text-accent2"}>
|
||||
{isError ? "!" : isRunning ? ">" : "-"}
|
||||
</span>
|
||||
<span className="text-xs uppercase tracking-wide text-muted">{ev.kind.replace("_", " ")}</span>
|
||||
{eventTime(ev) && <span className="text-[10px] text-muted">{eventTime(ev)}</span>}
|
||||
</div>
|
||||
{typeof p.phase === "string" && (
|
||||
<span className="badge bg-panel text-[10px] text-muted">{p.phase}</span>
|
||||
)}
|
||||
{p.ok === false && <span className="text-[10px] text-bad">fail</span>}
|
||||
{isRunning && <span className="text-[10px] text-accent">running…</span>}
|
||||
</div>
|
||||
{ev.message && <div className="mt-1 text-sm">{ev.message}</div>}
|
||||
{typeof p.tool === "string" && (
|
||||
<div className="mt-1 text-xs text-muted">
|
||||
{typeof p.device === "string" ? `${p.device} - ` : ""}
|
||||
{humanToolName(p.tool)}
|
||||
</div>
|
||||
)}
|
||||
{typeof p.command === "string" && (
|
||||
<details className="mt-2">
|
||||
<summary className="cursor-pointer text-xs text-muted">Command</summary>
|
||||
<pre className="mt-2 overflow-x-auto rounded bg-black/50 p-2 font-mono text-[11px] text-emerald-300/90">
|
||||
$ {p.command}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
{typeof p.output === "string" && p.output && (
|
||||
<details className="mt-2">
|
||||
<summary className="cursor-pointer text-xs text-muted">Evidence output</summary>
|
||||
<pre className="mt-2 max-h-40 overflow-auto rounded bg-ink/70 p-2 text-[11px] text-slate-300">
|
||||
{p.output}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
{typeof p.error === "string" && (
|
||||
<pre className="mt-2 text-[11px] text-bad">{p.error}</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
23
frontend/components/providers.tsx
Normal file
23
frontend/components/providers.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import { SWRConfig } from "swr";
|
||||
import { AuthProvider } from "@/lib/auth";
|
||||
import { ToastProvider } from "@/components/toast";
|
||||
|
||||
export default function Providers({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<SWRConfig
|
||||
value={{
|
||||
revalidateOnFocus: true,
|
||||
refreshWhenHidden: false,
|
||||
refreshWhenOffline: false,
|
||||
dedupingInterval: 2000,
|
||||
errorRetryCount: 3,
|
||||
}}
|
||||
>
|
||||
<AuthProvider>
|
||||
<ToastProvider>{children}</ToastProvider>
|
||||
</AuthProvider>
|
||||
</SWRConfig>
|
||||
);
|
||||
}
|
||||
354
frontend/components/task-detail-panels.tsx
Normal file
354
frontend/components/task-detail-panels.tsx
Normal file
@@ -0,0 +1,354 @@
|
||||
"use client";
|
||||
|
||||
import type React from "react";
|
||||
import { StatusBadge } from "@/components/ui";
|
||||
import { TimelineEvent } from "@/components/TaskLiveStatus";
|
||||
import { humanToolName, TaskEvent } from "@/lib/task-types";
|
||||
|
||||
export type LlmUsage = {
|
||||
step: string;
|
||||
provider: string;
|
||||
backend: string;
|
||||
model: string;
|
||||
display: string;
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
cost_usd: number;
|
||||
};
|
||||
|
||||
export type RunHistory = {
|
||||
run_number: number;
|
||||
follow_up?: string | null;
|
||||
summary?: string;
|
||||
resolved?: boolean;
|
||||
run_cost_usd?: number;
|
||||
llm_usage?: LlmUsage[];
|
||||
};
|
||||
|
||||
export function formatCost(usd: number | undefined | null) {
|
||||
if (usd === undefined || usd === null) return "$0.0000";
|
||||
if (usd === 0) return "$0.00 (local)";
|
||||
return `$${usd.toFixed(4)}`;
|
||||
}
|
||||
|
||||
export function TaskScopeCard({ task, report }: { task: any; report: any }) {
|
||||
const rule = report?.matched_rule;
|
||||
const checked = report?.devices_checked || [];
|
||||
return (
|
||||
<div className="card">
|
||||
<h2 className="mb-3 font-semibold">Scope</h2>
|
||||
<div className="space-y-3 text-sm">
|
||||
<div>
|
||||
<div className="label">Requested</div>
|
||||
<p className="text-muted">{task.issue}</p>
|
||||
</div>
|
||||
<div>
|
||||
<div className="label">Devices selected</div>
|
||||
<div className="mt-1 flex flex-wrap gap-2">
|
||||
{checked.length ? (
|
||||
checked.map((device: string) => (
|
||||
<span key={device} className="badge bg-accent/10 text-accent">
|
||||
{device}
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<span className="text-muted">Selection pending</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{rule && (
|
||||
<div>
|
||||
<div className="label">Rule</div>
|
||||
<p className="text-muted">{rule.name || rule.id}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TaskTimelinePanel({
|
||||
events,
|
||||
wsConnected,
|
||||
pinnedToBottom,
|
||||
scrollBoxRef,
|
||||
endRef,
|
||||
onScroll,
|
||||
onJump,
|
||||
loading,
|
||||
}: {
|
||||
events: TaskEvent[];
|
||||
wsConnected: boolean;
|
||||
pinnedToBottom: boolean;
|
||||
scrollBoxRef: React.RefObject<HTMLDivElement>;
|
||||
endRef: React.RefObject<HTMLDivElement>;
|
||||
onScroll: () => void;
|
||||
onJump: () => void;
|
||||
loading: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="font-semibold">Run Timeline</h2>
|
||||
<p className="mt-1 text-xs text-muted">Plain-language progress; expand events only when evidence is needed.</p>
|
||||
</div>
|
||||
<span className={`badge text-xs ${wsConnected ? "bg-good/15 text-good" : "bg-warn/15 text-warn"}`}>
|
||||
{wsConnected ? "live" : "reconnecting"}
|
||||
</span>
|
||||
</div>
|
||||
<div ref={scrollBoxRef} onScroll={onScroll} className="max-h-[520px] space-y-2 overflow-y-auto pr-1">
|
||||
{events.map((ev, i) => (
|
||||
<TimelineEvent key={`${ev.id || ""}-${ev.ts || ev.created_at || ""}-${ev.kind}-${i}`} ev={ev} />
|
||||
))}
|
||||
{events.length === 0 && <div className="text-sm text-muted">{loading ? "Loading..." : "Waiting for events..."}</div>}
|
||||
<div ref={endRef} />
|
||||
</div>
|
||||
{!pinnedToBottom && events.length > 0 && (
|
||||
<button className="btn-ghost btn-sm mt-2" onClick={onJump}>
|
||||
Jump to latest
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MarkdownTable({ text }: { text: string }) {
|
||||
const lines = text.trim().split("\n");
|
||||
const tableLines = lines.filter((line) => line.trim().startsWith("|") && line.trim().endsWith("|"));
|
||||
if (tableLines.length < 2) return null;
|
||||
const rows = tableLines
|
||||
.filter((line) => !/^\|\s*-+/.test(line))
|
||||
.map((line) => line.split("|").slice(1, -1).map((cell) => cell.trim()));
|
||||
const [head, ...body] = rows;
|
||||
if (!head || body.length === 0) return null;
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-xs">
|
||||
<thead>
|
||||
<tr className="border-b border-edge text-muted">
|
||||
{head.map((cell, i) => (
|
||||
<th key={i} className="py-2 pr-3 font-medium">
|
||||
{cell}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{body.map((row, i) => (
|
||||
<tr key={i} className="border-b border-edge/50">
|
||||
{row.map((cell, j) => (
|
||||
<td key={j} className="py-2 pr-3 align-top">
|
||||
{cell}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DiagnosticEvidence({ diagnostic }: { diagnostic: any }) {
|
||||
const text = diagnostic.formatted || diagnostic.summary || diagnostic.output || "_No output_";
|
||||
const table = typeof text === "string" ? <MarkdownTable text={text} /> : null;
|
||||
return (
|
||||
<details className="rounded-lg bg-panel2 p-3">
|
||||
<summary className="cursor-pointer">
|
||||
<span className="mr-2 font-mono text-xs text-accent2">
|
||||
[{diagnostic.device}] {humanToolName(diagnostic.tool || "tool")}
|
||||
</span>
|
||||
<StatusBadge status={diagnostic.ok ? "succeeded" : "failed"} />
|
||||
{diagnostic.output_chars ? <span className="ml-2 text-xs text-muted">{diagnostic.output_chars} chars</span> : null}
|
||||
</summary>
|
||||
<div className="mt-3">
|
||||
{table || (
|
||||
<pre className="max-h-80 overflow-auto whitespace-pre-wrap rounded bg-ink/70 p-2 font-mono text-xs text-muted">
|
||||
{text}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
export function TaskReportPanel({ report, pendingApprovals }: { report: any; pendingApprovals: number }) {
|
||||
const summary = report.executive_summary || report.summary;
|
||||
const findings = report.findings || [];
|
||||
const scope = report.scope_checked || report.devices_checked || [];
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<h2 className="font-semibold">Report</h2>
|
||||
<StatusBadge status={pendingApprovals > 0 ? "waiting_approval" : report.resolved ? "succeeded" : "running"} />
|
||||
</div>
|
||||
<div className="space-y-4 text-sm">
|
||||
{summary && (
|
||||
<section>
|
||||
<div className="label">Summary</div>
|
||||
<p>{summary}</p>
|
||||
</section>
|
||||
)}
|
||||
{scope.length > 0 && (
|
||||
<section>
|
||||
<div className="label">Scope checked</div>
|
||||
<div className="mt-1 flex flex-wrap gap-2">
|
||||
{scope.map((item: any) => (
|
||||
<span key={typeof item === "string" ? item : item.device} className="badge bg-panel2 text-xs text-accent">
|
||||
{typeof item === "string" ? item : item.device}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
{findings.length > 0 ? (
|
||||
<section>
|
||||
<div className="label">Findings</div>
|
||||
<div className="space-y-2">
|
||||
{findings.map((finding: any, i: number) => (
|
||||
<div key={i} className="rounded-lg bg-panel2 p-3">
|
||||
<div className="font-medium">{finding.title || finding.summary || String(finding)}</div>
|
||||
{finding.description && <p className="mt-1 text-muted">{finding.description}</p>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : (
|
||||
<section>
|
||||
<div className="label">Finding</div>
|
||||
<p>{report.root_cause || report.resolution || "No notable finding recorded."}</p>
|
||||
</section>
|
||||
)}
|
||||
{report.recommendations?.length > 0 && (
|
||||
<section>
|
||||
<div className="label">Recommendations</div>
|
||||
<ul className="list-disc space-y-1 pl-5">
|
||||
{report.recommendations.map((r: { description?: string }, i: number) => (
|
||||
<li key={i}>{r.description || JSON.stringify(r)}</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
{report.actions_taken?.length > 0 && (
|
||||
<details>
|
||||
<summary className="cursor-pointer text-sm font-medium text-muted">Actions taken</summary>
|
||||
<ul className="mt-2 list-disc space-y-1 pl-5">
|
||||
{report.actions_taken.map((s: string, i: number) => (
|
||||
<li key={i}>{s}</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
)}
|
||||
{report.diagnostics?.length > 0 && (
|
||||
<section>
|
||||
<div className="label">Evidence</div>
|
||||
<div className="space-y-2">
|
||||
{report.diagnostics.map((d: any, i: number) => (
|
||||
<DiagnosticEvidence key={i} diagnostic={d} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TaskArtifactsPanel({ task, report }: { task: any; report: any }) {
|
||||
return (
|
||||
<div className="card">
|
||||
<h2 className="mb-3 font-semibold">Saved Artifacts</h2>
|
||||
<div className="space-y-2 text-sm">
|
||||
<ArtifactRow label="Memory" value={task?.memory_id || report?.memory_id} detail={report?.memory_project_id || "agentic-os-task"} />
|
||||
<ArtifactRow
|
||||
label="Obsidian"
|
||||
value={task?.obsidian_path || report?.obsidian_path}
|
||||
detail={report?.obsidian_push_error ? "push failed" : "pushed"}
|
||||
bad={!!report?.obsidian_push_error}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ArtifactRow({ label, value, detail, bad = false }: { label: string; value?: string | null; detail?: string; bad?: boolean }) {
|
||||
return (
|
||||
<div className="rounded-lg bg-panel2 p-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="font-medium">{label}</span>
|
||||
<span className={`text-xs ${bad ? "text-bad" : "text-muted"}`}>{detail}</span>
|
||||
</div>
|
||||
<div className="mt-1 break-all font-mono text-xs text-muted">{value || "Not saved"}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function providerLabel(provider: string) {
|
||||
return provider === "local" ? "Local" : "API";
|
||||
}
|
||||
|
||||
export function TaskCostPanel({ report, llmUsage }: { report: any; llmUsage: LlmUsage[] }) {
|
||||
return (
|
||||
<div className="card">
|
||||
<h2 className="mb-3 font-semibold">Models & Cost</h2>
|
||||
<div className="mb-3 flex flex-wrap gap-4 text-sm">
|
||||
<div>
|
||||
<div className="label">This run</div>
|
||||
<div className="font-mono">{formatCost(report.run_cost_usd)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="label">Total</div>
|
||||
<div className="font-mono">{formatCost(report.total_cost_usd)}</div>
|
||||
</div>
|
||||
</div>
|
||||
{llmUsage.length > 0 ? (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-xs">
|
||||
<thead>
|
||||
<tr className="border-b border-edge text-muted">
|
||||
<th className="py-2 pr-3">Step</th>
|
||||
<th className="py-2 pr-3">Type</th>
|
||||
<th className="py-2 pr-3">Model</th>
|
||||
<th className="py-2">Cost</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{llmUsage.map((u, i) => (
|
||||
<tr key={i} className="border-b border-edge/50">
|
||||
<td className="py-2 pr-3 capitalize">{u.step}</td>
|
||||
<td className="py-2 pr-3">{providerLabel(u.provider)}</td>
|
||||
<td className="py-2 pr-3 font-mono">{u.display || u.model}</td>
|
||||
<td className="py-2 font-mono">{formatCost(u.cost_usd)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted">No LLM usage recorded.</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TaskRunHistoryPanel({ runs }: { runs: RunHistory[] }) {
|
||||
if (runs.length <= 1) return null;
|
||||
return (
|
||||
<div className="card">
|
||||
<h2 className="mb-3 font-semibold">Run History</h2>
|
||||
<div className="space-y-3">
|
||||
{runs.map((r) => (
|
||||
<div key={r.run_number} className="rounded-lg bg-panel2 p-3 text-sm">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="font-medium">Run {r.run_number}</span>
|
||||
<span className="text-xs text-muted">{formatCost(r.run_cost_usd)}</span>
|
||||
</div>
|
||||
{r.follow_up && <p className="mt-1 text-muted">Follow-up: {r.follow_up}</p>}
|
||||
{r.summary && <p className="mt-1">{r.summary}</p>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
75
frontend/components/toast.tsx
Normal file
75
frontend/components/toast.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useCallback, useContext, useEffect, useState } from "react";
|
||||
|
||||
type ToastKind = "success" | "error" | "info";
|
||||
type Toast = { id: number; kind: ToastKind; message: string };
|
||||
|
||||
type ToastContextValue = {
|
||||
toast: (message: string, kind?: ToastKind) => void;
|
||||
success: (message: string) => void;
|
||||
error: (message: string) => void;
|
||||
};
|
||||
|
||||
const ToastContext = createContext<ToastContextValue | null>(null);
|
||||
|
||||
let _id = 0;
|
||||
|
||||
export function ToastProvider({ children }: { children: React.ReactNode }) {
|
||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||
|
||||
const remove = useCallback((id: number) => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||
}, []);
|
||||
|
||||
const toast = useCallback(
|
||||
(message: string, kind: ToastKind = "info") => {
|
||||
const id = ++_id;
|
||||
setToasts((prev) => [...prev, { id, kind, message }]);
|
||||
setTimeout(() => remove(id), 4500);
|
||||
},
|
||||
[remove]
|
||||
);
|
||||
|
||||
const value: ToastContextValue = {
|
||||
toast,
|
||||
success: (m) => toast(m, "success"),
|
||||
error: (m) => toast(m, "error"),
|
||||
};
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={value}>
|
||||
{children}
|
||||
<div
|
||||
className="pointer-events-none fixed inset-x-0 bottom-4 z-[100] flex flex-col items-center gap-2 px-4"
|
||||
aria-live="polite"
|
||||
role="status"
|
||||
>
|
||||
{toasts.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
onClick={() => remove(t.id)}
|
||||
className={`pointer-events-auto w-full max-w-md rounded-xl border px-4 py-3 text-left text-sm shadow-lg backdrop-blur transition ${
|
||||
t.kind === "success"
|
||||
? "border-good/40 bg-good/15 text-good"
|
||||
: t.kind === "error"
|
||||
? "border-bad/40 bg-bad/15 text-bad"
|
||||
: "border-edge bg-panel/90 text-slate-200"
|
||||
}`}
|
||||
>
|
||||
{t.message}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useToast(): ToastContextValue {
|
||||
const ctx = useContext(ToastContext);
|
||||
if (!ctx) {
|
||||
// No-op fallback so components don't crash outside the provider.
|
||||
return { toast: () => {}, success: () => {}, error: () => {} };
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
226
frontend/components/ui.tsx
Normal file
226
frontend/components/ui.tsx
Normal file
@@ -0,0 +1,226 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import Link from "next/link";
|
||||
|
||||
export function PageHeader({
|
||||
title,
|
||||
subtitle,
|
||||
action,
|
||||
backHref,
|
||||
backLabel,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
action?: React.ReactNode;
|
||||
backHref?: string;
|
||||
backLabel?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
{backHref && (
|
||||
<Link
|
||||
href={backHref}
|
||||
className="mb-1 inline-block text-xs text-muted hover:text-accent"
|
||||
>
|
||||
← {backLabel || "Back"}
|
||||
</Link>
|
||||
)}
|
||||
<h1 className="text-2xl font-semibold">{title}</h1>
|
||||
{subtitle && <p className="mt-1 break-words text-sm text-muted">{subtitle}</p>}
|
||||
</div>
|
||||
{action && <div className="shrink-0">{action}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Spinner({ className = "" }: { className?: string }) {
|
||||
return (
|
||||
<span
|
||||
className={`inline-block h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent ${className}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function SkeletonRows({ rows = 3 }: { rows?: number }) {
|
||||
return (
|
||||
<div className="space-y-3" aria-hidden="true">
|
||||
{Array.from({ length: rows }).map((_, i) => (
|
||||
<div key={i} className="h-16 animate-pulse rounded-2xl border border-edge bg-panel2/50" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function EmptyState({
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
}: {
|
||||
title: string;
|
||||
description?: string;
|
||||
action?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="card flex flex-col items-center gap-2 py-10 text-center">
|
||||
<div className="text-sm font-medium">{title}</div>
|
||||
{description && <div className="max-w-sm text-sm text-muted">{description}</div>}
|
||||
{action && <div className="mt-2">{action}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function LoadError({
|
||||
message = "Couldn’t load data.",
|
||||
onRetry,
|
||||
}: {
|
||||
message?: string;
|
||||
onRetry?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="card border-bad/40 text-center">
|
||||
<div className="text-sm text-bad">{message}</div>
|
||||
{onRetry && (
|
||||
<button className="btn-ghost btn-sm mt-3" onClick={onRetry}>
|
||||
Retry
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FormError({ message }: { message?: string | null }) {
|
||||
if (!message) return null;
|
||||
return <div className="text-sm text-bad">{message}</div>;
|
||||
}
|
||||
|
||||
const STATUS_STYLES: Record<string, string> = {
|
||||
queued: "bg-slate-500/20 text-slate-300",
|
||||
running: "bg-accent/20 text-accent",
|
||||
waiting_approval: "bg-warn/20 text-warn",
|
||||
succeeded: "bg-good/20 text-good",
|
||||
failed: "bg-bad/20 text-bad",
|
||||
cancelled: "bg-slate-500/20 text-slate-400",
|
||||
loaded: "bg-good/20 text-good",
|
||||
error: "bg-bad/20 text-bad",
|
||||
starting: "bg-warn/20 text-warn",
|
||||
cloning: "bg-accent/20 text-accent",
|
||||
stopped: "bg-slate-500/20 text-slate-400",
|
||||
};
|
||||
|
||||
export function StatusBadge({ status }: { status: string }) {
|
||||
return (
|
||||
<span className={`badge ${STATUS_STYLES[status] || "bg-slate-500/20 text-slate-300"}`}>
|
||||
{status.replace(/_/g, " ")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function Modal({
|
||||
open,
|
||||
onClose,
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const onCloseRef = useRef(onClose);
|
||||
onCloseRef.current = onClose;
|
||||
|
||||
// Focus the dialog only when it opens (not on every parent re-render).
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
ref.current?.focus();
|
||||
}, [open]);
|
||||
|
||||
// Escape-to-close + body scroll lock while open.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onCloseRef.current();
|
||||
};
|
||||
document.addEventListener("keydown", onKey);
|
||||
const prev = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => {
|
||||
document.removeEventListener("keydown", onKey);
|
||||
document.body.style.overflow = prev;
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
if (!open) return null;
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 grid place-items-center bg-black/60 p-4"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
ref={ref}
|
||||
tabIndex={-1}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={title}
|
||||
className="max-h-[90vh] w-full max-w-lg overflow-y-auto rounded-2xl border border-edge bg-panel p-6 shadow-2xl focus:outline-none"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">{title}</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
aria-label="Close dialog"
|
||||
className="rounded-lg px-2 text-muted hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ConfirmDialog({
|
||||
open,
|
||||
title,
|
||||
message,
|
||||
confirmLabel = "Confirm",
|
||||
cancelLabel = "Cancel",
|
||||
destructive = false,
|
||||
busy = false,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: {
|
||||
open: boolean;
|
||||
title: string;
|
||||
message?: string;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
destructive?: boolean;
|
||||
busy?: boolean;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Modal open={open} onClose={onCancel} title={title}>
|
||||
{message && <p className="mb-5 text-sm text-muted">{message}</p>}
|
||||
<div className="flex justify-end gap-2">
|
||||
<button className="btn-ghost" onClick={onCancel} disabled={busy}>
|
||||
{cancelLabel}
|
||||
</button>
|
||||
<button
|
||||
className={destructive ? "btn-danger" : "btn-primary"}
|
||||
onClick={onConfirm}
|
||||
disabled={busy}
|
||||
>
|
||||
{busy ? "Working…" : confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
80
frontend/lib/api.ts
Normal file
80
frontend/lib/api.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
export const API_BASE =
|
||||
process.env.NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000";
|
||||
|
||||
const TOKEN_KEY = "agentic_token";
|
||||
|
||||
export function getToken(): string | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
return window.localStorage.getItem(TOKEN_KEY);
|
||||
}
|
||||
|
||||
export function setToken(token: string | null) {
|
||||
if (typeof window === "undefined") return;
|
||||
if (token) window.localStorage.setItem(TOKEN_KEY, token);
|
||||
else window.localStorage.removeItem(TOKEN_KEY);
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number;
|
||||
constructor(status: number, message: string) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
export async function api<T = any>(
|
||||
path: string,
|
||||
options: RequestInit = {}
|
||||
): Promise<T> {
|
||||
const headers = new Headers(options.headers);
|
||||
const token = getToken();
|
||||
if (token) headers.set("Authorization", `Bearer ${token}`);
|
||||
if (!headers.has("Content-Type") && options.body && !(options.body instanceof FormData)) {
|
||||
headers.set("Content-Type", "application/json");
|
||||
}
|
||||
const res = await fetch(`${API_BASE}${path}`, { ...options, headers });
|
||||
if (res.status === 401 && typeof window !== "undefined") {
|
||||
setToken(null);
|
||||
if (!window.location.pathname.startsWith("/login")) {
|
||||
window.location.href = "/login";
|
||||
}
|
||||
}
|
||||
if (!res.ok) {
|
||||
let detail = res.statusText;
|
||||
try {
|
||||
const body = await res.json();
|
||||
detail = body.detail || detail;
|
||||
} catch {}
|
||||
throw new ApiError(res.status, typeof detail === "string" ? detail : JSON.stringify(detail));
|
||||
}
|
||||
if (res.status === 204) return undefined as T;
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function login(email: string, password: string) {
|
||||
const form = new URLSearchParams();
|
||||
form.set("username", email);
|
||||
form.set("password", password);
|
||||
const res = await fetch(`${API_BASE}/api/auth/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: form.toString(),
|
||||
});
|
||||
if (!res.ok) {
|
||||
let detail = "Login failed";
|
||||
try {
|
||||
detail = (await res.json()).detail || detail;
|
||||
} catch {}
|
||||
throw new ApiError(res.status, detail);
|
||||
}
|
||||
return res.json() as Promise<{ access_token: string; role: string; email: string }>;
|
||||
}
|
||||
|
||||
export function wsUrl(path: string): string {
|
||||
const base = API_BASE.replace(/^http/, "ws");
|
||||
const token = getToken();
|
||||
const sep = path.includes("?") ? "&" : "?";
|
||||
return `${base}${path}${sep}token=${encodeURIComponent(token || "")}`;
|
||||
}
|
||||
|
||||
export const fetcher = (path: string) => api(path);
|
||||
75
frontend/lib/auth.tsx
Normal file
75
frontend/lib/auth.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useCallback, useContext, useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { api, login as apiLogin, setToken, getToken } from "./api";
|
||||
|
||||
type Me = {
|
||||
id: number;
|
||||
email: string;
|
||||
full_name: string | null;
|
||||
role: "admin" | "support" | "readonly";
|
||||
is_active: boolean;
|
||||
};
|
||||
|
||||
type AuthCtx = {
|
||||
user: Me | null;
|
||||
loading: boolean;
|
||||
login: (email: string, password: string) => Promise<void>;
|
||||
logout: () => void;
|
||||
};
|
||||
|
||||
const Ctx = createContext<AuthCtx>({
|
||||
user: null,
|
||||
loading: true,
|
||||
login: async () => {},
|
||||
logout: () => {},
|
||||
});
|
||||
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const [user, setUser] = useState<Me | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const router = useRouter();
|
||||
|
||||
const loadMe = useCallback(async () => {
|
||||
if (!getToken()) {
|
||||
setUser(null);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const me = await api<Me>("/api/auth/me");
|
||||
setUser(me);
|
||||
} catch {
|
||||
setUser(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadMe();
|
||||
}, [loadMe]);
|
||||
|
||||
const login = async (email: string, password: string) => {
|
||||
const res = await apiLogin(email, password);
|
||||
setToken(res.access_token);
|
||||
await loadMe();
|
||||
router.push("/");
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
setToken(null);
|
||||
setUser(null);
|
||||
router.push("/login");
|
||||
};
|
||||
|
||||
return <Ctx.Provider value={{ user, loading, login, logout }}>{children}</Ctx.Provider>;
|
||||
}
|
||||
|
||||
export const useAuth = () => useContext(Ctx);
|
||||
|
||||
export function roleAtLeast(role: string | undefined, min: "readonly" | "support" | "admin") {
|
||||
const rank: Record<string, number> = { readonly: 0, support: 1, admin: 2 };
|
||||
return (rank[role || "readonly"] ?? 0) >= rank[min];
|
||||
}
|
||||
93
frontend/lib/task-types.ts
Normal file
93
frontend/lib/task-types.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
export const ACTIVE_STATUSES = ["running", "queued", "waiting_approval"] as const;
|
||||
|
||||
export type TaskStatus =
|
||||
| "queued"
|
||||
| "running"
|
||||
| "waiting_approval"
|
||||
| "succeeded"
|
||||
| "failed"
|
||||
| "cancelled";
|
||||
|
||||
export type TaskEvent = {
|
||||
id?: number;
|
||||
kind: string;
|
||||
message: string | null;
|
||||
payload?: Record<string, any> | null;
|
||||
ts?: string;
|
||||
created_at?: string;
|
||||
replay?: boolean;
|
||||
};
|
||||
|
||||
export type TaskOverview = {
|
||||
id: number;
|
||||
title: string;
|
||||
issue: string;
|
||||
status: TaskStatus;
|
||||
vessel_id: number | null;
|
||||
vessel_name?: string | null;
|
||||
summary?: string | null;
|
||||
key_finding?: string | null;
|
||||
devices_checked: string[];
|
||||
resolved?: boolean | null;
|
||||
memory_id?: string | null;
|
||||
obsidian_path?: string | null;
|
||||
obsidian_push_error?: string | null;
|
||||
run_cost_usd?: number | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type TaskPreview = {
|
||||
vessel_id: number;
|
||||
vessel_name?: string | null;
|
||||
devices: { catalog_key: string; name: string; label: string; checks: string[] }[];
|
||||
rule_id?: string | null;
|
||||
rule_name?: string | null;
|
||||
severity?: string | null;
|
||||
hints: string[];
|
||||
warning?: string | null;
|
||||
};
|
||||
|
||||
export function isActiveStatus(status: string) {
|
||||
return ACTIVE_STATUSES.includes(status as (typeof ACTIVE_STATUSES)[number]);
|
||||
}
|
||||
|
||||
export function taskPhaseLabel(status: string, events: TaskEvent[] = []) {
|
||||
if (status === "queued") return "Waiting in queue";
|
||||
if (status === "waiting_approval") return "Waiting for approval";
|
||||
if (status === "succeeded") return "Report complete";
|
||||
if (status === "failed") return "Failed";
|
||||
if (status === "cancelled") return "Stopped";
|
||||
const last = [...events].reverse().find((e) => e.kind !== "log");
|
||||
if (!last) return "Starting";
|
||||
if (last.kind === "context") return "Gathering context";
|
||||
if (last.kind === "triage" || last.kind === "rule") return "Planning checks";
|
||||
if (last.kind === "connect") return "Connecting to devices";
|
||||
if (last.kind === "tool_start" || last.kind === "tool_call" || last.kind === "diagnose") {
|
||||
const device = last.payload?.device;
|
||||
const tool = humanToolName(String(last.payload?.tool || ""));
|
||||
return [device, tool].filter(Boolean).join(": ") || "Running diagnostics";
|
||||
}
|
||||
if (last.kind === "reason") return "Analyzing findings";
|
||||
if (last.kind === "report") return "Writing report";
|
||||
return last.message || "Running";
|
||||
}
|
||||
|
||||
export function humanToolName(tool: string) {
|
||||
const known: Record<string, string> = {
|
||||
pfsense_get_system_status: "pfSense system status",
|
||||
pfsense_list_interfaces: "pfSense interfaces",
|
||||
pfsense_list_gateways: "pfSense gateways",
|
||||
pfsense_get_gateway_status: "pfSense gateway status",
|
||||
pfsense_list_firewall_rules: "pfSense firewall rules",
|
||||
pfsense_list_port_forwards: "pfSense port forwards",
|
||||
pfsense_list_outbound_nat_mappings: "pfSense outbound NAT",
|
||||
proxmox_list_nodes: "Proxmox nodes",
|
||||
proxmox_list_qemu: "Proxmox VMs",
|
||||
proxmox_list_lxc: "Proxmox LXCs",
|
||||
proxmox_get_qemu_status: "VM status",
|
||||
proxmox_get_qemu_config: "VM config",
|
||||
ssh_run: "SSH command",
|
||||
};
|
||||
return known[tool] || tool.replace(/_/g, " ");
|
||||
}
|
||||
2
frontend/next-env.d.ts
vendored
Normal file
2
frontend/next-env.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
6
frontend/next.config.js
Normal file
6
frontend/next.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
};
|
||||
|
||||
module.exports = nextConfig;
|
||||
26
frontend/package.json
Normal file
26
frontend/package.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "agentic-os-frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -p 3000",
|
||||
"build": "next build",
|
||||
"start": "next start -p 3000",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"next": "14.2.18",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"swr": "2.2.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "20.14.10",
|
||||
"@types/react": "18.3.3",
|
||||
"@types/react-dom": "18.3.0",
|
||||
"autoprefixer": "10.4.20",
|
||||
"postcss": "8.4.49",
|
||||
"tailwindcss": "3.4.17",
|
||||
"typescript": "5.5.3"
|
||||
}
|
||||
}
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
1
frontend/public/.gitkeep
Normal file
1
frontend/public/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
24
frontend/tailwind.config.ts
Normal file
24
frontend/tailwind.config.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { Config } from "tailwindcss";
|
||||
|
||||
const config: Config = {
|
||||
content: ["./app/**/*.{ts,tsx}", "./components/**/*.{ts,tsx}"],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
ink: "#0b0f17",
|
||||
panel: "#121826",
|
||||
panel2: "#1a2234",
|
||||
edge: "#243049",
|
||||
accent: "#5b8cff",
|
||||
accent2: "#22d3ee",
|
||||
good: "#34d399",
|
||||
warn: "#fbbf24",
|
||||
bad: "#f87171",
|
||||
muted: "#8a96ad",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
|
||||
export default config;
|
||||
22
frontend/tsconfig.json
Normal file
22
frontend/tsconfig.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [{ "name": "next" }],
|
||||
"baseUrl": ".",
|
||||
"paths": { "@/*": ["./*"] }
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Reference in New Issue
Block a user