import { useEffect, useState } from "react"; import { Activity, Pencil, Plus, Server, Trash2, Undo2 } from "lucide-react"; import { api, type EffectiveOllamaNode, type OllamaNode, type OllamaNodeStats, type OllamaNodeStatus, } from "../lib/api.ts"; import { Button, ConfirmDialog, Field, Input, Modal, Picker, Spinner, Switch } from "./ui.tsx"; import { navigate } from "../lib/route.ts"; import { useT } from "../lib/i18n.ts"; import { cn } from "../lib/cn.ts"; export function fmtSize(bytes: number): string { if (!bytes || bytes < 0) return "—"; const units = ["B", "KB", "MB", "GB", "TB"]; let v = bytes; let i = 0; while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; } return `${v >= 100 ? Math.round(v) : v.toFixed(1)} ${units[i]}`; } type StatsWindowId = "1h" | "6h" | "24h" | "7d" | "30d"; const WINDOWS: StatsWindowId[] = ["1h", "6h", "24h", "7d", "30d"]; /** Admin Ollama pool: nodes with firewall-like weights + generation timeout. */ export function NodesSection() { const { t } = useT(); const [nodes, setNodes] = useState([]); const [effective, setEffective] = useState([]); const [statuses, setStatuses] = useState>({}); const [loading, setLoading] = useState(true); const [error, setError] = useState(""); const [formNode, setFormNode] = useState(null); const [detailId, setDetailId] = useState(null); const [deleteId, setDeleteId] = useState(null); const [timeoutS, setTimeoutS] = useState("300"); const [defWeight, setDefWeight] = useState("100"); const [saving, setSaving] = useState(false); async function refresh() { setError(""); try { const [list, settings] = await Promise.all([api.ollamaNodes(), api.adminGetSettings()]); setNodes(list.nodes); setEffective(list.effective); setTimeoutS(String(settings.settings.ollamaTimeoutS)); setDefWeight(String(settings.settings.ollamaDefaultWeight)); // Live probes in parallel; a dead node never blocks the list. const ids = ["default", ...list.nodes.map((n) => n.id)]; const results = await Promise.all( ids.map(async (id) => { try { return [id, await api.ollamaNodeStatus(id)] as const; } catch { return [id, null] as const; } }), ); const map: Record = {}; for (const [id, st] of results) map[id] = st; setStatuses(map); } catch (err) { setError(err instanceof Error ? err.message : t("admin.loadFailed")); } finally { setLoading(false); } } useEffect(() => { refresh(); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const totalWeight = effective.reduce((s, n) => s + n.weight, 0); async function savePool(patch: { ollamaTimeoutS?: number; ollamaDefaultWeight?: number }) { setSaving(true); try { const res = await api.adminPatchSettings(patch); setTimeoutS(String(res.settings.ollamaTimeoutS)); setDefWeight(String(res.settings.ollamaDefaultWeight)); await refresh(); } catch (err) { setError(err instanceof Error ? err.message : t("common.saveFailed")); } finally { setSaving(false); } } function commitTimeout(raw: string) { const v = Number(raw); if (!Number.isInteger(v) || v < 5 || v > 3600) { void refresh(); return; } void savePool({ ollamaTimeoutS: v }); } function commitDefWeight(raw: string) { const v = Number(raw); if (!Number.isInteger(v) || v < 0 || v > 1000) { void refresh(); return; } void savePool({ ollamaDefaultWeight: v }); } async function toggleEnabled(node: OllamaNode, enabled: boolean) { try { const res = await api.ollamaNodePatch(node.id, { enabled }); setNodes((prev) => prev.map((n) => (n.id === res.node.id ? res.node : n))); const list = await api.ollamaNodes(); setEffective(list.effective); } catch (err) { setError(err instanceof Error ? err.message : t("common.saveFailed")); } } async function remove(id: string) { try { await api.ollamaNodeDelete(id); setDeleteId(null); await refresh(); } catch (err) { setError(err instanceof Error ? err.message : t("admin.deleteFailed")); } } const rows: { id: string; name: string; host: string; weight: number; enabled: boolean; builtin: boolean }[] = [ { id: "default", name: "default", host: statuses["default"]?.host ?? "…", weight: effective.find((n) => n.id === "default")?.weight ?? 0, enabled: effective.some((n) => n.id === "default"), builtin: true, }, ...nodes.map((n) => ({ id: n.id, name: n.name, host: n.host, weight: n.weight, enabled: n.enabled, builtin: false })), ]; return (

{t("nodes.title")}

{t("nodes.desc")}

setTimeoutS(e.target.value)} onBlur={(e) => commitTimeout(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") (e.target as HTMLInputElement).blur(); }} />

{t("nodes.timeoutDesc")}

setDefWeight(e.target.value)} onBlur={(e) => commitDefWeight(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") (e.target as HTMLInputElement).blur(); }} />

{t("nodes.defWeightDesc")}

{error &&

{error}

} {loading &&

{t("common.loading")}

}
    {rows.map((r) => { const st = statuses[r.id] ?? null; const online = st?.reachable === true; const share = totalWeight > 0 && r.enabled && r.weight > 0 ? Math.round((r.weight / totalWeight) * 100) : 0; return (
  • {r.name} {r.builtin && env} {r.host} w:{r.weight} · {r.enabled ? t("nodes.share", { pct: share }) : t("nodes.off")} {!r.builtin && ( { const full = nodes.find((n) => n.id === r.id); if (full) void toggleEnabled(full, v); }} /> )} {!r.builtin && ( <> )}
  • ); })}
{ setFormNode(null); refresh(); }} /> {detailId && setDetailId(null)} />} setDeleteId(null)} title={t("nodes.deleteTitle")} message={t("nodes.deleteMsg")} onConfirm={() => deleteId && remove(deleteId)} />
); } /* ---------- add / edit modal ---------- */ function NodeFormModal({ node, open, onClose }: { node: OllamaNode | null; open: boolean; onClose: () => void }) { const { t } = useT(); const [name, setName] = useState(""); const [host, setHost] = useState(""); const [weight, setWeight] = useState("100"); const [enabled, setEnabled] = useState(true); const [busy, setBusy] = useState(false); const [probing, setProbing] = useState(false); const [probe, setProbe] = useState(null); const [error, setError] = useState(""); useEffect(() => { if (open) { setName(node?.name ?? ""); setHost(node?.host ?? ""); setWeight(String(node?.weight ?? 100)); setEnabled(node?.enabled ?? true); setProbe(null); setError(""); setBusy(false); } }, [open, node]); async function test() { setProbing(true); setProbe(null); setError(""); try { setProbe(await api.ollamaNodeProbe(host.trim())); } catch (err) { setError(err instanceof Error ? err.message : t("nodes.testFail")); } finally { setProbing(false); } } async function save(e: React.FormEvent) { e.preventDefault(); if (busy) return; setBusy(true); setError(""); try { const w = Number(weight); const payload = { name: name.trim(), host: host.trim(), weight: w, enabled }; if (node) await api.ollamaNodePatch(node.id, payload); else await api.ollamaNodeCreate(payload); onClose(); } catch (err) { const msg = err instanceof Error ? err.message : t("nodes.invalid"); setError(/already registered/i.test(msg) ? t("nodes.dupHost") : msg); } finally { setBusy(false); } } return (
setName(e.target.value)} placeholder={t("nodes.namePh")} required maxLength={60} /> setHost(e.target.value)} placeholder={t("nodes.hostPh")} required inputMode="url" />
setWeight(e.target.value)} required />
{probe && (

{probe.reachable ? `${t("nodes.testOk")}${probe.version ? ` · v${probe.version}` : ""} · ${probe.models.length} models` : `${t("nodes.testFail")}${probe.error ? ` — ${probe.error}` : ""}`}

)}

{t("nodes.weightHint")}

{error &&

{error}

}
); } /* ---------- node detail: live status + load charts ---------- */ function NodeDetailModal({ nodeId, onClose }: { nodeId: string; onClose: () => void }) { const { t } = useT(); const [status, setStatus] = useState(null); const [stats, setStats] = useState(null); const [window, setWindow] = useState("24h"); useEffect(() => { let alive = true; api.ollamaNodeStatus(nodeId).then((s) => { if (alive) setStatus(s); }).catch(() => { if (alive) setStatus(null); }); return () => { alive = false; }; }, [nodeId]); useEffect(() => { let alive = true; api.ollamaNodeStats(nodeId, window).then((r) => { if (alive) setStats(r.stats); }).catch(() => {}); return () => { alive = false; }; }, [nodeId, window]); const maxReq = Math.max(1, ...((stats?.buckets ?? []).map((b) => b.req))); return (
{!status ? (

{t("common.loading")}

) : (
{t("models.connHost")}
{status.host}
{t("nodes.version")}
{status.version ?? "—"}
{t("nodes.latency")}
{status.latencyMs === null ? "—" : `${status.latencyMs} ms`}
{t("nodes.diskModels", { n: status.models.length })}
{status.running.length} running
{status.running.length > 0 && (

{t("nodes.running")}

    {status.running.map((m) => (
  • {m.name} {t("nodes.vram")}: {fmtSize(m.sizeVram)}
  • ))}
)}

{t("nodes.stats")}

setWindow(v as StatsWindowId)} align="right" options={WINDOWS.map((w) => ({ value: w, label: w }))} />
{stats && ( <>
{stats.totals.req} {t("nodes.req")} 0 ? "text-red-500" : undefined}>{stats.totals.err} {t("nodes.err")} {t("nodes.avg")}: {stats.totals.avgMs === null ? "—" : `${Math.round(stats.totals.avgMs)} ms`} {t("nodes.tps")}: {stats.totals.tokPerSec === null ? "—" : stats.totals.tokPerSec.toFixed(1)} {stats.totals.promptTok + stats.totals.evalTok} {t("nodes.tok")}
{stats.totals.req === 0 ? (

{t("nodes.emptyStats")}

) : (
{stats.buckets.map((b, i) => { const h = Math.max(2, Math.round((b.req / maxReq) * 100)); const errH = b.req > 0 ? Math.round(h * (b.err / b.req)) : 0; return (
{errH > 0 &&
}
); })}
)} )}
{status.models.length > 0 && (

{t("nodes.diskModels", { n: status.models.length })}

    {status.models.map((m) => (
  • {m.name} {fmtSize(m.size)}
  • ))}
)}

{t("nodes.cpuNote")}

)} ); }