Files
chat/frontend/src/components/OllamaNodes.tsx
T
2026-09-24 11:14:01 +02:00

527 lines
22 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.
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<OllamaNode[]>([]);
const [effective, setEffective] = useState<EffectiveOllamaNode[]>([]);
const [statuses, setStatuses] = useState<Record<string, OllamaNodeStatus | null>>({});
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [formNode, setFormNode] = useState<OllamaNode | null | "new">(null);
const [detailId, setDetailId] = useState<string | null>(null);
const [deleteId, setDeleteId] = useState<string | null>(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<string, OllamaNodeStatus | null> = {};
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 (
<div className="space-y-4">
<section className="rounded-3xl border border-stone-200/70 bg-white p-5 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<p className="flex items-center gap-1.5 text-sm font-medium">
<Server size={14} className="opacity-60" /> {t("nodes.title")}
</p>
<p className="mb-4 mt-0.5 text-sm opacity-60">{t("nodes.desc")}</p>
<div className="mb-4 grid gap-3 sm:grid-cols-2">
<div className="rounded-2xl border border-stone-200/70 px-3.5 py-2.5 dark:border-zinc-800">
<Field label={t("nodes.timeout")} hint={t("nodes.timeoutUnit")}>
<Input
type="number"
min={5}
max={3600}
value={timeoutS}
disabled={saving}
onChange={(e) => setTimeoutS(e.target.value)}
onBlur={(e) => commitTimeout(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") (e.target as HTMLInputElement).blur(); }}
/>
</Field>
<p className="mt-1 text-xs opacity-60">{t("nodes.timeoutDesc")}</p>
</div>
<div className="rounded-2xl border border-stone-200/70 px-3.5 py-2.5 dark:border-zinc-800">
<Field label={t("nodes.defWeight")} hint="0–1000">
<Input
type="number"
min={0}
max={1000}
value={defWeight}
disabled={saving}
onChange={(e) => setDefWeight(e.target.value)}
onBlur={(e) => commitDefWeight(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") (e.target as HTMLInputElement).blur(); }}
/>
</Field>
<p className="mt-1 text-xs opacity-60">{t("nodes.defWeightDesc")}</p>
</div>
</div>
{error && <p className="mb-3 rounded-2xl bg-red-500/10 px-4 py-2.5 text-sm text-red-600 dark:text-red-400" role="alert">{error}</p>}
{loading && <p className="flex items-center gap-2 text-sm opacity-60"><Spinner size={14} /> {t("common.loading")}</p>}
<ul className="space-y-2">
{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 (
<li
key={r.id}
className={cn(
"flex flex-wrap items-center gap-x-3 gap-y-2 rounded-2xl border border-stone-200/70 px-3.5 py-2.5 dark:border-zinc-800",
!r.enabled && "opacity-60",
)}
>
<span className={cn("h-2.5 w-2.5 shrink-0 rounded-full", st === undefined ? "bg-stone-300 dark:bg-zinc-600" : online ? "bg-emerald-500" : "bg-red-500")} role="status" />
<span className="min-w-0 flex-1 basis-40">
<span className="block truncate text-sm font-medium">
{r.name}
{r.builtin && <span className="ml-2 rounded-full bg-stone-200/70 px-2 py-0.5 font-mono text-[11px] dark:bg-zinc-800">env</span>}
</span>
<span className="block truncate font-mono text-xs opacity-50">{r.host}</span>
</span>
<span className="shrink-0 rounded-full bg-stone-200/70 px-2.5 py-1 font-mono text-xs dark:bg-zinc-800" title={t("nodes.weightHint")}>
w:{r.weight} · {r.enabled ? t("nodes.share", { pct: share }) : t("nodes.off")}
</span>
{!r.builtin && (
<Switch
label={t("nodes.enabled")}
checked={r.enabled}
onChange={(v) => {
const full = nodes.find((n) => n.id === r.id);
if (full) void toggleEnabled(full, v);
}}
/>
)}
<span className="flex shrink-0 gap-1">
<button
onClick={() => setDetailId(r.id)}
className="rounded-full p-2 transition hover:bg-stone-100 dark:hover:bg-zinc-800"
title={t("nodes.detail")}
aria-label={t("nodes.detail")}
>
<Activity size={15} />
</button>
{!r.builtin && (
<>
<button
onClick={() => {
const full = nodes.find((n) => n.id === r.id);
if (full) setFormNode(full);
}}
className="rounded-full p-2 transition hover:bg-stone-100 dark:hover:bg-zinc-800"
title={t("nodes.edit")}
aria-label={t("nodes.edit")}
>
<Pencil size={15} />
</button>
<button
onClick={() => setDeleteId(r.id)}
className="rounded-full p-2 text-red-600 transition hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-950/40"
title={t("common.delete")}
aria-label={t("common.delete")}
>
<Trash2 size={15} />
</button>
</>
)}
</span>
</li>
);
})}
</ul>
<div className="mt-3 flex items-center gap-2">
<Button size="sm" variant="secondary" onClick={() => setFormNode("new")}>
<Plus size={14} /> {t("nodes.add")}
</Button>
<button
onClick={() => refresh()}
className="rounded-full p-2 opacity-60 transition hover:bg-stone-200/60 hover:opacity-100 dark:hover:bg-zinc-800"
title={t("models.refresh")}
aria-label={t("models.refreshAria")}
>
<Undo2 size={15} />
</button>
</div>
</section>
<NodeFormModal
node={formNode === "new" ? null : formNode}
open={formNode !== null}
onClose={() => {
setFormNode(null);
refresh();
}}
/>
{detailId && <NodeDetailModal nodeId={detailId} onClose={() => setDetailId(null)} />}
<ConfirmDialog
open={deleteId !== null}
onClose={() => setDeleteId(null)}
title={t("nodes.deleteTitle")}
message={t("nodes.deleteMsg")}
onConfirm={() => deleteId && remove(deleteId)}
/>
</div>
);
}
/* ---------- 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<OllamaNodeStatus | null>(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 (
<Modal open={open} onClose={onClose} title={node ? t("nodes.edit") : t("nodes.add")} icon={Server}>
<form onSubmit={save} className="space-y-4">
<Field label={t("nodes.name")}>
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder={t("nodes.namePh")} required maxLength={60} />
</Field>
<Field label={t("nodes.host")} hint={t("nodes.hostHint")}>
<Input value={host} onChange={(e) => setHost(e.target.value)} placeholder={t("nodes.hostPh")} required inputMode="url" />
</Field>
<div className="flex flex-wrap items-end gap-3">
<div className="w-32">
<Field label={t("nodes.weight")} hint="0–1000">
<Input type="number" min={0} max={1000} value={weight} onChange={(e) => setWeight(e.target.value)} required />
</Field>
</div>
<div className="pb-2.5">
<Switch label={t("nodes.enabled")} checked={enabled} onChange={setEnabled} />
</div>
<div className="ml-auto pb-1">
<Button type="button" variant="secondary" size="sm" onClick={test} disabled={probing || !host.trim()}>
{probing ? <Spinner size={14} /> : <Activity size={14} />} {probing ? t("nodes.probing") : t("nodes.test")}
</Button>
</div>
</div>
{probe && (
<p className={cn("rounded-2xl px-4 py-2.5 text-sm", probe.reachable ? "bg-emerald-600/10 text-emerald-700 dark:text-emerald-400" : "bg-red-500/10 text-red-600 dark:text-red-400")} role="status">
{probe.reachable
? `${t("nodes.testOk")}${probe.version ? ` · v${probe.version}` : ""} · ${probe.models.length} models`
: `${t("nodes.testFail")}${probe.error ? ` — ${probe.error}` : ""}`}
</p>
)}
<p className="text-xs opacity-60">{t("nodes.weightHint")}</p>
{error && <p className="text-sm text-red-600 dark:text-red-400" role="alert">{error}</p>}
<div className="flex justify-end gap-2">
<Button type="button" variant="secondary" size="sm" onClick={onClose}>{t("common.cancel")}</Button>
<Button type="submit" size="sm" disabled={busy}>{busy ? <Spinner size={14} /> : t("common.save")}</Button>
</div>
</form>
</Modal>
);
}
/* ---------- node detail: live status + load charts ---------- */
function NodeDetailModal({ nodeId, onClose }: { nodeId: string; onClose: () => void }) {
const { t } = useT();
const [status, setStatus] = useState<OllamaNodeStatus | null>(null);
const [stats, setStats] = useState<OllamaNodeStats | null>(null);
const [window, setWindow] = useState<StatsWindowId>("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 (
<Modal open onClose={onClose} title={t("nodes.detail")} icon={Activity} wide>
<div className="mb-3 flex justify-end">
<Button variant="secondary" size="sm" onClick={() => { onClose(); navigate("/admin/stats"); }}>
<Activity size={14} /> {t("stats.openFull")}
</Button>
</div>
{!status ? (
<p className="flex items-center gap-2 text-sm opacity-60"><Spinner size={14} /> {t("common.loading")}</p>
) : (
<div className="space-y-4">
<dl className="grid grid-cols-2 gap-2 sm:grid-cols-4">
<div className="rounded-2xl bg-stone-100/70 px-3 py-2 dark:bg-zinc-800/60">
<dt className="text-[11px] uppercase tracking-wider opacity-50">{t("models.connHost")}</dt>
<dd className="truncate font-mono text-sm" title={status.host}>{status.host}</dd>
</div>
<div className="rounded-2xl bg-stone-100/70 px-3 py-2 dark:bg-zinc-800/60">
<dt className="text-[11px] uppercase tracking-wider opacity-50">{t("nodes.version")}</dt>
<dd className="truncate font-mono text-sm">{status.version ?? "—"}</dd>
</div>
<div className="rounded-2xl bg-stone-100/70 px-3 py-2 dark:bg-zinc-800/60">
<dt className="text-[11px] uppercase tracking-wider opacity-50">{t("nodes.latency")}</dt>
<dd className="truncate font-mono text-sm">{status.latencyMs === null ? "—" : `${status.latencyMs} ms`}</dd>
</div>
<div className="rounded-2xl bg-stone-100/70 px-3 py-2 dark:bg-zinc-800/60">
<dt className="text-[11px] uppercase tracking-wider opacity-50">{t("nodes.diskModels", { n: status.models.length })}</dt>
<dd className="truncate font-mono text-sm">{status.running.length} running</dd>
</div>
</dl>
{status.running.length > 0 && (
<div>
<p className="mb-1.5 text-xs font-medium uppercase tracking-wider opacity-50">{t("nodes.running")}</p>
<ul className="space-y-1.5">
{status.running.map((m) => (
<li key={m.name} className="flex items-center gap-3 rounded-2xl border border-stone-200/70 px-3.5 py-2 text-sm dark:border-zinc-800">
<span className="min-w-0 flex-1 truncate font-mono">{m.name}</span>
<span className="shrink-0 text-xs opacity-60">{t("nodes.vram")}: {fmtSize(m.sizeVram)}</span>
</li>
))}
</ul>
</div>
)}
<div>
<div className="mb-1.5 flex items-center gap-2">
<p className="text-xs font-medium uppercase tracking-wider opacity-50">{t("nodes.stats")}</p>
<span className="ml-auto w-28">
<Picker
ariaLabel={t("nodes.stats")}
value={window}
onChange={(v) => setWindow(v as StatsWindowId)}
align="right"
options={WINDOWS.map((w) => ({ value: w, label: w }))}
/>
</span>
</div>
{stats && (
<>
<div className="mb-2 flex flex-wrap gap-x-4 gap-y-1 font-mono text-xs opacity-80">
<span>{stats.totals.req} {t("nodes.req")}</span>
<span className={stats.totals.err > 0 ? "text-red-500" : undefined}>{stats.totals.err} {t("nodes.err")}</span>
<span>{t("nodes.avg")}: {stats.totals.avgMs === null ? "—" : `${Math.round(stats.totals.avgMs)} ms`}</span>
<span>{t("nodes.tps")}: {stats.totals.tokPerSec === null ? "—" : stats.totals.tokPerSec.toFixed(1)}</span>
<span>{stats.totals.promptTok + stats.totals.evalTok} {t("nodes.tok")}</span>
</div>
{stats.totals.req === 0 ? (
<p className="rounded-2xl border border-dashed border-stone-300 px-4 py-4 text-center text-sm opacity-50 dark:border-zinc-700">
{t("nodes.emptyStats")}
</p>
) : (
<div className="flex h-20 items-end gap-[3px]" role="img" aria-label={`${stats.totals.req} ${t("nodes.req")}`}>
{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 (
<div
key={i}
className="flex min-w-0 flex-1 flex-col justify-end overflow-hidden rounded-sm bg-accent-500/70"
style={{ height: `${h}%` }}
title={`${new Date(b.t).toLocaleString()} — ${b.req} ${t("nodes.req")}, ${b.err} ${t("nodes.err")}`}
>
{errH > 0 && <div className="w-full bg-red-500" style={{ height: `${(errH / h) * 100}%` }} />}
</div>
);
})}
</div>
)}
</>
)}
</div>
{status.models.length > 0 && (
<div>
<p className="mb-1.5 text-xs font-medium uppercase tracking-wider opacity-50">{t("nodes.diskModels", { n: status.models.length })}</p>
<ul className="max-h-48 space-y-1.5 overflow-y-auto">
{status.models.map((m) => (
<li key={m.name} className="flex items-center gap-3 rounded-2xl border border-stone-200/70 px-3.5 py-2 text-sm dark:border-zinc-800">
<span className="min-w-0 flex-1 truncate font-mono">{m.name}</span>
<span className="shrink-0 text-xs opacity-60">{fmtSize(m.size)}</span>
</li>
))}
</ul>
</div>
)}
<p className="text-xs opacity-50">{t("nodes.cpuNote")}</p>
</div>
)}
</Modal>
);
}