Files
Agentic-OS/frontend/app/inventory/page.tsx
nearxos 6185b9b85a 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>
2026-06-14 22:11:07 +03:00

468 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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