Initial commit: Agentic OS troubleshooting platform

Self-hosted, Docker-based agentic troubleshooting platform: FastAPI backend +
LangGraph agent, Next.js UI, tiered LLM routing (local Ollama -> Gemini ->
DeepSeek -> OpenRouter), MCP server manager, encrypted device credentials,
RBAC, audit log, project-memory + Obsidian integrations, and editable
troubleshooting decision rules tuned for the GeneseasX vessel stack.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-14 22:11:07 +03:00
commit 6185b9b85a
126 changed files with 14565 additions and 0 deletions

View File

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