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:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user