This commit is contained in:
k
2026-09-24 11:02:24 +02:00
parent 8e64c0c963
commit 6e65d84a1c
17 changed files with 2141 additions and 191 deletions
+3 -3
View File
@@ -9,7 +9,7 @@ import type {
FilePreview,
User,
} from "./lib/types.ts";
import { chatIdFromPath, isChatPath, navigate, normalizePath, useRoute } from "./lib/route.ts";
import { adminTabFromPath, chatIdFromPath, isChatPath, navigate, normalizePath, useRoute } from "./lib/route.ts";
import { Login } from "./components/Login.tsx";
import { RegisterPage } from "./components/RegisterPage.tsx";
import { Sidebar } from "./components/Sidebar.tsx";
@@ -44,7 +44,7 @@ const RESET_TOKEN = typeof window !== "undefined" ? resetTokenFromPath() : null;
const REVIEW_ID = typeof window !== "undefined" ? reviewIdFromPath() : null;
const IS_ADMIN_PAGE =
typeof window !== "undefined" &&
/^\/admin\/?$/.test(window.location.pathname);
(/^\/admin\/?$/.test(window.location.pathname) || adminTabFromPath(window.location.pathname) !== null);
function resetTokenFromPath(): string | null {
const m = window.location.pathname.match(
@@ -139,7 +139,7 @@ export function App() {
// Public routes: no auth needed. IDs are constant for the page lifetime.
if (IS_ADMIN_PAGE) {
return <AdminCenter />;
return <AdminCenter initialTab={adminTabFromPath(window.location.pathname)} />;
}
if (RESET_TOKEN) {
return <ResetPage token={RESET_TOKEN} />;
+81 -7
View File
@@ -1,20 +1,27 @@
import { useEffect, useRef, useState } from "react";
import { ArrowLeft, Boxes, Eye, EyeOff, Flag, Inbox, LayoutDashboard, Mail, Send, ShieldAlert, SlidersHorizontal, Users } from "lucide-react";
import { api, getToken, type AccessStatus, type ReportStatus } from "../lib/api.ts";
import { api, getToken, type AccessStatus, type AdminSettings, type AdminSettingsPatch, type ReportStatus } from "../lib/api.ts";
import { subscribeAccessLive, wsUrl } from "../lib/accessWs.ts";
import { navigate } from "../lib/route.ts";
import { AdminPanel } from "./AdminPanel.tsx";
import { AccessRequestsPanel } from "./AccessRequests.tsx";
import { ReportsPanel } from "./ReportsPanel.tsx";
import { ModelsPanel } from "./ModelsPanel.tsx";
import { Toasts } from "./Toasts.tsx";
import { pushToast } from "../lib/toasts.ts";
import { Button, CopyButton, FlowerMark, Input, LoadingScreen, Spinner, Switch } from "./ui.tsx";
import { Button, CopyButton, Field, FlowerMark, Input, LoadingScreen, Spinner, Switch } from "./ui.tsx";
import { cn } from "../lib/cn.ts";
import { msUntilNextSolarSwitch, resolveThemeDark } from "../lib/solarTheme.ts";
import { useT } from "../lib/i18n.ts";
type Tab = "accounts" | "access" | "reports" | "models" | "features" | "mail";
const VALID_TABS: readonly string[] = ["accounts", "access", "reports", "models", "features", "mail"];
function validTab(v: string | null | undefined): Tab {
return v !== null && v !== undefined && (VALID_TABS as readonly string[]).includes(v) ? (v as Tab) : "accounts";
}
function applyTheme(theme: string) {
const root = document.documentElement;
const dark = resolveThemeDark(theme);
@@ -22,15 +29,28 @@ function applyTheme(theme: string) {
root.style.colorScheme = dark ? "dark" : "light";
}
export function AdminCenter() {
export function AdminCenter({ initialTab }: { initialTab?: string | null }) {
const { t } = useT();
const [allowed, setAllowed] = useState<boolean | null>(null);
const [tab, setTab] = useState<Tab>("accounts");
const [settings, setSettings] = useState<{ registrationEnabled: boolean; accessRequestEnabled: boolean; ocrEnabled: boolean; reportsEnabled: boolean; usernameChangeEnabled: boolean } | null>(null);
const [tab, setTab] = useState<Tab>(() => validTab(initialTab));
const [settings, setSettings] = useState<AdminSettings | null>(null);
const [error, setError] = useState("");
const [saving, setSaving] = useState(false);
const [openAccessCount, setOpenAccessCount] = useState(0);
const [openReportCount, setOpenReportCount] = useState(0);
// Deep links (/admin, /admin/models, …): unknown sections bounce to /admin.
useEffect(() => {
if (initialTab !== null && initialTab !== undefined && !VALID_TABS.includes(initialTab)) {
navigate("/admin", true);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
function selectTab(id: Tab): void {
setTab(id);
navigate(id === "accounts" ? "/admin" : `/admin/${id}`);
}
// Full badge resync (HTTP) — only for mount, reconnect gaps and rare
// actions with no WS event (reporter shadow-ban). Hot path is incremental.
const resyncBadgesRef = useRef<() => void>(() => {});
@@ -139,7 +159,7 @@ export function AdminCenter() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [allowed]);
async function toggle(patch: { registrationEnabled?: boolean; accessRequestEnabled?: boolean; ocrEnabled?: boolean; reportsEnabled?: boolean; usernameChangeEnabled?: boolean }) {
async function toggle(patch: AdminSettingsPatch) {
setSaving(true);
setError("");
try {
@@ -187,7 +207,7 @@ export function AdminCenter() {
{TABS.map((t) => (
<button
key={t.id}
onClick={() => setTab(t.id)}
onClick={() => selectTab(t.id)}
className={cn(
"relative flex flex-1 items-center justify-center gap-2 whitespace-nowrap rounded-full px-4 py-2 text-sm font-medium transition",
tab === t.id
@@ -330,9 +350,17 @@ function MailPanel() {
const [verifying, setVerifying] = useState(false);
const [sending, setSending] = useState(false);
const [result, setResult] = useState<{ ok: boolean; text: string } | null>(null);
const [recMax, setRecMax] = useState("5");
const [recWindow, setRecWindow] = useState("60");
const [recSaving, setRecSaving] = useState(false);
const [recMsg, setRecMsg] = useState<{ ok: boolean; text: string } | null>(null);
useEffect(() => {
api.adminMailStatus().then((r) => setSmtp(r.smtp)).catch((e) => setError(e instanceof Error ? e.message : t("admin.loadFailed")));
api.adminGetSettings().then((r) => {
setRecMax(String(r.settings.recoveryLimitMax));
setRecWindow(String(r.settings.recoveryLimitWindowMin));
}).catch(() => {});
}, []);
async function verify() {
@@ -370,6 +398,27 @@ function MailPanel() {
);
}
async function saveRecoveryLimits() {
const max = Number(recMax);
const win = Number(recWindow);
if (!Number.isInteger(max) || max < 1 || max > 100 || !Number.isInteger(win) || win < 1 || win > 1440) {
setRecMsg({ ok: false, text: t("rec.invalid") });
return;
}
setRecSaving(true);
setRecMsg(null);
try {
const res = await api.adminPatchSettings({ recoveryLimitMax: max, recoveryLimitWindowMin: win });
setRecMax(String(res.settings.recoveryLimitMax));
setRecWindow(String(res.settings.recoveryLimitWindowMin));
setRecMsg({ ok: true, text: t("rec.saved") });
} catch (e) {
setRecMsg({ ok: false, text: e instanceof Error ? e.message : t("common.saveFailed") });
} finally {
setRecSaving(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">
@@ -467,6 +516,31 @@ function MailPanel() {
</p>
)}
</section>
<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="text-sm font-medium">{t("rec.title")}</p>
<p className="mt-0.5 text-sm opacity-60">{t("rec.desc")}</p>
<div className="mt-3 flex flex-wrap items-end gap-2">
<div className="w-32">
<Field label={t("rec.max")} hint={t("rec.maxHint")}>
<Input type="number" min={1} max={100} value={recMax} onChange={(e) => setRecMax(e.target.value)} disabled={recSaving} />
</Field>
</div>
<div className="w-32">
<Field label={t("rec.window")} hint={t("rec.windowHint")}>
<Input type="number" min={1} max={1440} value={recWindow} onChange={(e) => setRecWindow(e.target.value)} disabled={recSaving} />
</Field>
</div>
<Button size="sm" onClick={saveRecoveryLimits} disabled={recSaving}>
{recSaving ? <Spinner size={14} /> : null} {t("rec.save")}
</Button>
</div>
{recMsg && (
<p role={recMsg.ok ? "status" : "alert"} className={cn("mt-3 rounded-2xl px-4 py-2.5 text-sm", recMsg.ok ? "bg-emerald-600/10 text-emerald-700 dark:text-emerald-400" : "bg-red-500/10 text-red-600 dark:text-red-400")}>
{recMsg.text}
</p>
)}
</section>
</div>
);
}
+10 -138
View File
@@ -1,12 +1,14 @@
import { useCallback, useEffect, useState } from "react";
import { Activity, Bot, Undo2 } from "lucide-react";
import { api, type OllamaStatus, type PolicyModel } from "../lib/api.ts";
import { useEffect, useState } from "react";
import { Bot, Undo2 } from "lucide-react";
import { api, type PolicyModel } from "../lib/api.ts";
import { OllamaModelsModal } from "./AdminPanel.tsx";
import { NodesSection } from "./OllamaNodes.tsx";
import { CatalogSection } from "./OllamaCatalog.tsx";
import { Button, Input, Spinner, Switch } from "./ui.tsx";
import { useT } from "../lib/i18n.ts";
import { cn } from "../lib/cn.ts";
/** Admin models section: per-model kill-switch + rate limits, Ollama library. */
/** Admin models section: node pool, per-model policy, Ollama library + HF catalog. */
export function ModelsPanel() {
const [models, setModels] = useState<PolicyModel[]>([]);
const [loading, setLoading] = useState(true);
@@ -48,7 +50,7 @@ export function ModelsPanel() {
return (
<div className="space-y-5">
<OllamaConnectivity />
<NodesSection />
<section>
<div className="mb-2 flex items-center gap-2">
@@ -103,143 +105,13 @@ export function ModelsPanel() {
refresh();
}}
/>
<CatalogSection />
</div>
);
}
/* ---------- Ollama connectivity probe (admin) ---------- */
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]}`;
}
/** Live Ollama host status: reachability, version, latency, on-disk models. */
function OllamaConnectivity() {
const [status, setStatus] = useState<OllamaStatus | null>(null);
const [checking, setChecking] = useState(true);
const { t } = useT();
const check = useCallback(async () => {
setChecking(true);
try {
setStatus(await api.ollamaStatus());
} catch {
setStatus(null);
} finally {
setChecking(false);
}
}, []);
useEffect(() => {
void check();
}, [check]);
const state = !status ? "unknown" : !status.enabled ? "disabled" : status.reachable ? "online" : "offline";
const dot =
state === "online"
? "bg-emerald-500"
: state === "offline"
? "bg-red-500"
: "bg-stone-400 dark:bg-zinc-500";
const stateLabel =
state === "online"
? t("models.connOnline")
: state === "offline"
? t("models.connOffline")
: state === "disabled"
? t("models.connDisabledState")
: t("common.loading");
return (
<section className="rounded-3xl border border-stone-200/70 bg-white p-5 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<div className="mb-1 flex items-center gap-2">
<p className="flex items-center gap-1.5 text-sm font-medium">
<Activity size={14} className="opacity-60" /> {t("models.connTitle")}
</p>
<span
className={cn(
"inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium",
state === "online" && "bg-emerald-600/10 text-emerald-700 dark:text-emerald-400",
state === "offline" && "bg-red-500/10 text-red-600 dark:text-red-400",
(state === "disabled" || state === "unknown") && "bg-stone-200/70 dark:bg-zinc-800",
)}
role="status"
>
<span className={cn("h-2 w-2 rounded-full", dot, checking && "animate-pulse")} />
{checking && !status ? t("common.loading") : stateLabel}
</span>
<button
onClick={() => void check()}
disabled={checking}
className="ml-auto rounded-full p-2 opacity-60 transition hover:bg-stone-200/60 hover:opacity-100 disabled:opacity-30 dark:hover:bg-zinc-800"
title={t("models.connCheck")}
aria-label={t("models.connCheckAria")}
>
<Undo2 size={15} className={checking ? "animate-spin" : undefined} />
</button>
</div>
<p className="mb-3 text-sm opacity-60">{t("models.connDesc")}</p>
{status && (
<dl className="mb-3 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("models.connVersion")}</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("models.connLatency")}</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("models.connModels")}</dt>
<dd className="truncate font-mono text-sm">{status.models.length}</dd>
</div>
</dl>
)}
{!checking && !status && (
<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">
{t("admin.loadFailed")}
</p>
)}
{status && status.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">
<span className="block font-mono text-[13px]">{status.error}</span>
<span className="mt-0.5 block opacity-80">{t("models.connHint")}</span>
</p>
)}
{status && status.reachable && (
status.models.length === 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("models.connEmpty")}
</p>
) : (
<ul className="max-h-64 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 dark:border-zinc-800"
>
<span className="min-w-0 flex-1 truncate font-mono text-sm">{m.name}</span>
<span className="shrink-0 text-xs opacity-60">{fmtSize(m.size)}</span>
</li>
))}
</ul>
)
)}
</section>
);
}
/* ---------- per-model policy rows ---------- */
function ModelRow({ model, onChanged, onError }: {
model: PolicyModel;
+237
View File
@@ -0,0 +1,237 @@
import { useEffect, useRef, useState } from "react";
import { Download, Globe, Server } from "lucide-react";
import { api, type EffectiveOllamaNode } from "../lib/api.ts";
import { Field, Input, Picker, Spinner } from "./ui.tsx";
import { useT } from "../lib/i18n.ts";
import { cn } from "../lib/cn.ts";
interface HfHit {
id: string;
downloads: number;
likes: number;
}
interface HfFile {
name: string;
quant: string;
size: number | null;
}
/** Extract the quantization tag from a GGUF filename (…-Q4_K_M.gguf). */
function quantOf(filename: string): string | null {
const base = filename.split("/").pop() ?? filename;
const m = base.match(/[-_.](Q\d(?:_\w+)?|IQ\d_\w+|BF16|F16|F32)(?:\.gguf)$/i);
return m ? m[1]!.toUpperCase() : null;
}
function fmtNum(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
if (n >= 1000) return `${(n / 1000).toFixed(1)}k`;
return String(n);
}
/** Hugging Face GGUF catalog: search, pick a quantization, pull hf.co/… */
export function CatalogSection() {
const { t } = useT();
const [q, setQ] = useState("");
const [hits, setHits] = useState<HfHit[]>([]);
const [searching, setSearching] = useState(false);
const [searched, setSearched] = useState(false);
const [files, setFiles] = useState<Record<string, HfFile[] | null>>({});
const [nodes, setNodes] = useState<EffectiveOllamaNode[]>([{ id: "default", name: "default", host: "", weight: 0 }]);
const [nodeId, setNodeId] = useState("default");
const [pulling, setPulling] = useState<string | null>(null);
const [pullStatus, setPullStatus] = useState("");
const [pullPct, setPullPct] = useState<number | null>(null);
const [error, setError] = useState("");
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
api.ollamaNodes().then((r) => {
setNodes([{ id: "default", name: "default", host: "", weight: 0 }, ...r.effective.filter((n) => n.id !== "default")]);
}).catch(() => {});
}, []);
useEffect(() => {
if (timer.current) clearTimeout(timer.current);
const query = q.trim();
if (query.length < 2) {
setHits([]);
setSearched(false);
return;
}
setSearching(true);
timer.current = setTimeout(async () => {
try {
const res = await fetch(
`https://huggingface.co/api/models?search=${encodeURIComponent(query)}&filter=gguf&sort=downloads&direction=-1&limit=15`,
);
if (!res.ok) throw new Error(`Hugging Face: ${res.status}`);
const data = (await res.json()) as { id?: string; downloads?: number; likes?: number }[];
setHits(
(Array.isArray(data) ? data : [])
.filter((m) => typeof m.id === "string")
.map((m) => ({ id: m.id as string, downloads: m.downloads ?? 0, likes: m.likes ?? 0 })),
);
setSearched(true);
} catch (err) {
setError(err instanceof Error ? err.message : "search failed");
} finally {
setSearching(false);
}
}, 450);
return () => {
if (timer.current) clearTimeout(timer.current);
};
}, [q]);
async function loadFiles(id: string) {
if (files[id] !== undefined) return;
setFiles((prev) => ({ ...prev, [id]: null }));
try {
const res = await fetch(`https://huggingface.co/api/models/${encodeURIComponent(id)}`);
if (!res.ok) throw new Error(`Hugging Face: ${res.status}`);
const data = (await res.json()) as { siblings?: { rfilename?: string }[] };
const list: HfFile[] = [];
for (const s of data.siblings ?? []) {
const name = s.rfilename ?? "";
if (!name.toLowerCase().endsWith(".gguf")) continue;
const quant = quantOf(name);
if (quant) list.push({ name, quant, size: null });
}
// Biggest quants last (heuristic on the tag, not the file size).
setFiles((prev) => ({ ...prev, [id]: list }));
} catch {
setFiles((prev) => ({ ...prev, [id]: [] }));
}
}
async function pull(id: string, quant: string) {
const ref = `hf.co/${id}:${quant}`;
if (pulling) return;
setPulling(ref);
setError("");
setPullStatus(t("catalog.pulling", { name: ref }));
setPullPct(null);
try {
await api.ollamaPull(ref, (p) => {
if (p.error) {
setError(p.error);
return;
}
setPullStatus(p.status || ref);
if (p.total && p.completed) setPullPct(Math.min(100, Math.round((p.completed / p.total) * 100)));
else setPullPct(null);
}, { nodeId });
setPullStatus(ref);
setPullPct(100);
} catch (err) {
setError(err instanceof Error ? err.message : "pull failed");
} finally {
setPulling(null);
}
}
return (
<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">
<Globe size={14} className="opacity-60" /> {t("catalog.title")}
</p>
<p className="mb-3 mt-0.5 text-sm opacity-60">{t("catalog.desc")}</p>
<div className="mb-3 flex flex-wrap items-end gap-2">
<div className="min-w-48 flex-1">
<Input
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder={t("catalog.searchPh")}
aria-label={t("catalog.searchPh")}
/>
</div>
<div className="w-44">
<Field label={t("catalog.node")}>
<Picker
ariaLabel={t("catalog.node")}
icon={Server}
value={nodeId}
onChange={setNodeId}
align="right"
options={nodes.map((n) => ({ value: n.id, label: n.id === "default" ? "default" : `${n.name} (w:${n.weight})` }))}
/>
</Field>
</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>}
{pulling && (
<div className="mb-3 rounded-2xl border border-accent-500/40 bg-accent-50 p-4 dark:bg-accent-950/30">
<p className="mb-2 flex items-center gap-2 text-sm font-medium">
<Download size={15} className="animate-bounce" /> {t("catalog.pulling", { name: pulling })}
</p>
<p className="mb-2 truncate text-xs opacity-70">{pullStatus}</p>
{pullPct !== null && (
<div className="h-2 overflow-hidden rounded-full bg-stone-200 dark:bg-zinc-800">
<div className="h-full rounded-full bg-accent-600 transition-all dark:bg-accent-500" style={{ width: `${pullPct}%` }} />
</div>
)}
</div>
)}
{searching && <p className="flex items-center gap-2 text-sm opacity-60"><Spinner size={14} /> {t("common.loading")}</p>}
{!searching && searched && hits.length === 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("catalog.noRes")}
</p>
)}
<ul className="space-y-2">
{hits.map((h) => (
<li key={h.id} className="rounded-2xl border border-stone-200/70 px-3.5 py-2.5 dark:border-zinc-800">
<button
onClick={() => loadFiles(h.id)}
className="flex w-full items-center gap-3 text-left"
aria-expanded={files[h.id] !== undefined}
>
<span className="min-w-0 flex-1">
<span className="block truncate font-mono text-sm font-medium">{h.id}</span>
<span className="block text-xs opacity-50">{fmtNum(h.downloads)} {t("catalog.downloads")} · ♥ {fmtNum(h.likes)}</span>
</span>
<span className={cn("text-xs opacity-60", files[h.id] === undefined && "underline underline-offset-2")}>
{t("catalog.files")}
</span>
</button>
{files[h.id] !== undefined && (
<div className="mt-2 border-t border-stone-200/70 pt-2 dark:border-zinc-800">
{files[h.id] === null ? (
<p className="flex items-center gap-2 py-1 text-xs opacity-60"><Spinner size={13} /> {t("common.loading")}</p>
) : files[h.id]!.length === 0 ? (
<p className="py-1 text-xs opacity-50">{t("catalog.noRes")}</p>
) : (
<ul className="flex flex-wrap gap-1.5">
{files[h.id]!.map((f) => {
const ref = `hf.co/${h.id}:${f.quant}`;
return (
<li key={f.name}>
<button
onClick={() => pull(h.id, f.quant)}
disabled={pulling !== null}
title={ref}
className="rounded-full border border-stone-200/70 px-3 py-1.5 font-mono text-xs transition hover:border-accent-500/60 hover:bg-accent-500/5 disabled:opacity-40 dark:border-zinc-700"
>
{pulling === ref ? <Spinner size={12} /> : <Download size={12} className="mr-1 inline" />}
{f.quant}
</button>
</li>
);
})}
</ul>
)}
<p className="mt-1.5 text-xs opacity-50">{t("catalog.hint")}</p>
</div>
)}
</li>
))}
</ul>
</section>
);
}
+520
View File
@@ -0,0 +1,520 @@
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 { 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>
{!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>
);
}
+90 -5
View File
@@ -68,6 +68,74 @@ export interface OllamaHostModel {
modifiedAt: string | null;
}
export interface AdminSettings {
registrationEnabled: boolean;
accessRequestEnabled: boolean;
ocrEnabled: boolean;
reportsEnabled: boolean;
usernameChangeEnabled: boolean;
ollamaTimeoutS: number;
ollamaDefaultWeight: number;
recoveryLimitMax: number;
recoveryLimitWindowMin: number;
}
export interface AdminSettingsPatch {
registrationEnabled?: boolean;
accessRequestEnabled?: boolean;
ocrEnabled?: boolean;
reportsEnabled?: boolean;
usernameChangeEnabled?: boolean;
ollamaTimeoutS?: number;
ollamaDefaultWeight?: number;
recoveryLimitMax?: number;
recoveryLimitWindowMin?: number;
}
export interface OllamaNode {
id: string;
name: string;
host: string;
weight: number;
enabled: boolean;
created_at: number;
updated_at: number;
}
export interface EffectiveOllamaNode {
id: string;
name: string;
host: string;
weight: number;
}
export interface OllamaNodeStatus {
reachable: boolean;
host: string;
version: string | null;
latencyMs: number | null;
models: OllamaHostModel[];
running: { name: string; sizeVram: number; size: number; expiresAt: string | null }[];
error: string | null;
}
export interface OllamaStatsBucket {
t: number;
req: number;
err: number;
avgMs: number | null;
promptTok: number;
evalTok: number;
}
export interface OllamaNodeStats {
window: string;
from: number;
to: number;
totals: { req: number; err: number; avgMs: number | null; promptTok: number; evalTok: number; tokPerSec: number | null };
buckets: OllamaStatsBucket[];
}
export interface OllamaStatus {
enabled: boolean;
host: string;
@@ -188,9 +256,9 @@ export const api = {
adminPatch: (id: string, patch: { username?: string; displayName?: string; avatarUrl?: string; theme?: string; email?: string }) =>
req<{ user: User }>(`/api/admin/users/${id}`, { method: "PATCH", body: JSON.stringify(patch) }),
adminGetSettings: () => req<{ settings: { registrationEnabled: boolean; accessRequestEnabled: boolean; ocrEnabled: boolean; reportsEnabled: boolean; usernameChangeEnabled: boolean } }>("/api/admin/settings"),
adminPatchSettings: (patch: { registrationEnabled?: boolean; accessRequestEnabled?: boolean; ocrEnabled?: boolean; reportsEnabled?: boolean; usernameChangeEnabled?: boolean }) =>
req<{ settings: { registrationEnabled: boolean; accessRequestEnabled: boolean; ocrEnabled: boolean; reportsEnabled: boolean; usernameChangeEnabled: boolean } }>("/api/admin/settings", { method: "PATCH", body: JSON.stringify(patch) }),
adminGetSettings: () => req<{ settings: AdminSettings }>("/api/admin/settings"),
adminPatchSettings: (patch: AdminSettingsPatch) =>
req<{ settings: AdminSettings }>("/api/admin/settings", { method: "PATCH", body: JSON.stringify(patch) }),
/** Admin: SMTP credentials viewer (password included, blur it by default) + connectivity. */
adminMailStatus: () =>
@@ -281,11 +349,12 @@ export const api = {
ollamaPull: async (
name: string,
onProgress: (p: { status?: string; digest?: string; total?: number; completed?: number; error?: string }) => void,
opts: { nodeId?: string } = {},
): Promise<void> => {
const res = await fetch("/api/admin/ollama/pull", {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${getToken()}` },
body: JSON.stringify({ name }),
body: JSON.stringify({ name, ...(opts.nodeId ? { nodeId: opts.nodeId } : {}) }),
});
if (res.status === 401) {
clearToken();
@@ -313,7 +382,23 @@ export const api = {
},
/** Admin: delete a local Ollama model (frees disk on the Ollama host). */
ollamaDelete: (name: string) => req<{ ok: boolean }>(`/api/admin/ollama/models/${encodeURIComponent(name)}`, { method: "DELETE" }),
ollamaDelete: (name: string, nodeId?: string) =>
req<{ ok: boolean }>(`/api/admin/ollama/models/${encodeURIComponent(name)}${nodeId ? `?node=${encodeURIComponent(nodeId)}` : ""}`, { method: "DELETE" }),
/** Admin: Ollama node pool (extra hosts with firewall-like weights). */
ollamaNodes: () => req<{ nodes: OllamaNode[]; effective: EffectiveOllamaNode[] }>("/api/admin/ollama/nodes"),
ollamaNodeCreate: (n: { name: string; host: string; weight: number; enabled: boolean }) =>
req<{ node: OllamaNode }>("/api/admin/ollama/nodes", { method: "POST", body: JSON.stringify(n) }),
ollamaNodePatch: (id: string, patch: { name?: string; host?: string; weight?: number; enabled?: boolean }) =>
req<{ node: OllamaNode }>(`/api/admin/ollama/nodes/${encodeURIComponent(id)}`, { method: "PATCH", body: JSON.stringify(patch) }),
ollamaNodeDelete: (id: string) =>
req<{ ok: boolean }>(`/api/admin/ollama/nodes/${encodeURIComponent(id)}`, { method: "DELETE" }),
ollamaNodeStatus: (id: string) =>
req<OllamaNodeStatus>(`/api/admin/ollama/nodes/${encodeURIComponent(id)}/status`),
ollamaNodeProbe: (host: string) =>
req<OllamaNodeStatus>(`/api/admin/ollama/probe?host=${encodeURIComponent(host)}`),
ollamaNodeStats: (id: string, window: string) =>
req<{ stats: OllamaNodeStats }>(`/api/admin/ollama/nodes/${encodeURIComponent(id)}/stats?window=${encodeURIComponent(window)}`),
/** Admin: Ollama connectivity probe (reachability, version, on-disk models). */
ollamaStatus: () => req<OllamaStatus>("/api/admin/ollama/status"),
+360
View File
@@ -491,6 +491,66 @@ const en = {
"live.newReport": "New content report ({reason})",
"live.adminReply": "Admin replied to your ticket",
"live.ticketStatus": "Ticket {status}",
"nodes.title": "Ollama nodes",
"nodes.desc": "Extra Ollama hosts. Each generation picks a node by weight (firewall-like priority, heaviest first) and fails over to the next one on error. The default node comes from OLLAMA_HOST.",
"nodes.add": "Add node",
"nodes.edit": "Edit node",
"nodes.name": "Name",
"nodes.namePh": "gpu-box-2",
"nodes.host": "Host",
"nodes.hostPh": "http://10.0.0.5:11434",
"nodes.hostHint": "http(s) base URL, no path. Must be reachable from this server.",
"nodes.weight": "Weight",
"nodes.weightHint": "0 disables traffic, higher gets more generations.",
"nodes.enabled": "Enabled",
"nodes.share": "{pct}%",
"nodes.off": "off",
"nodes.test": "Test",
"nodes.probing": "Probing…",
"nodes.testOk": "Reachable",
"nodes.testFail": "Unreachable",
"nodes.deleteTitle": "Delete this node?",
"nodes.deleteMsg": "Generations will no longer be routed to this host.",
"nodes.invalid": "Check the name, host and weight.",
"nodes.dupHost": "This host is already registered.",
"nodes.detail": "Node details",
"nodes.running": "Running now",
"nodes.vram": "VRAM",
"nodes.diskModels": "On disk ({n})",
"nodes.version": "Version",
"nodes.latency": "Latency",
"nodes.timeout": "Generation timeout",
"nodes.timeoutDesc": "Max time for one Ollama generation before failing over. Applies to every node.",
"nodes.timeoutUnit": "seconds (5–3600)",
"nodes.defWeight": "Default node weight",
"nodes.defWeightDesc": "Traffic share of the OLLAMA_HOST node. 0 excludes it when custom nodes exist.",
"nodes.stats": "Load",
"nodes.req": "requests",
"nodes.err": "errors",
"nodes.avg": "avg latency",
"nodes.tps": "tokens/s",
"nodes.tok": "tokens",
"nodes.emptyStats": "No generations in this window yet.",
"nodes.cpuNote": "Ollama exposes no CPU/RAM API — charts come from app-side generation telemetry; per-model VRAM via /api/ps.",
"catalog.title": "Model catalog (Hugging Face)",
"catalog.desc": "GGUF models installable with one click via ollama pull hf.co/…. Needs Ollama with Hugging Face support and internet on the Ollama host.",
"catalog.searchPh": "Search GGUF models…",
"catalog.pull": "Pull",
"catalog.pulling": "Pulling {name}…",
"catalog.node": "Target node",
"catalog.downloads": "downloads",
"catalog.files": "Quantizations",
"catalog.noRes": "No results.",
"catalog.hint": "Pick a quantization, it pulls straight from Hugging Face.",
"rec.title": "Password recovery limits",
"rec.desc": "Dedicated envelope for key-recovery requests (separate from login).",
"rec.max": "Max attempts",
"rec.maxHint": "per window, per IP",
"rec.window": "Window",
"rec.windowHint": "minutes",
"rec.save": "Save limits",
"rec.saved": "Limits saved",
"rec.invalid": "Check the values (attempts 1–100, window 1–1440 min).",
"msg.copy": "Copy message",
"msg.search": "Search the web",
@@ -1068,6 +1128,66 @@ const es: Dict = {
"live.newReport": "New content report ({reason})",
"live.adminReply": "Admin replied to your ticket",
"live.ticketStatus": "Ticket {status}",
"nodes.title": "Ollama nodes",
"nodes.desc": "Extra Ollama hosts. Each generation picks a node by weight (firewall-like priority, heaviest first) and fails over to the next one on error. The default node comes from OLLAMA_HOST.",
"nodes.add": "Add node",
"nodes.edit": "Edit node",
"nodes.name": "Name",
"nodes.namePh": "gpu-box-2",
"nodes.host": "Host",
"nodes.hostPh": "http://10.0.0.5:11434",
"nodes.hostHint": "http(s) base URL, no path. Must be reachable from this server.",
"nodes.weight": "Weight",
"nodes.weightHint": "0 disables traffic, higher gets more generations.",
"nodes.enabled": "Enabled",
"nodes.share": "{pct}%",
"nodes.off": "off",
"nodes.test": "Test",
"nodes.probing": "Probing…",
"nodes.testOk": "Reachable",
"nodes.testFail": "Unreachable",
"nodes.deleteTitle": "Delete this node?",
"nodes.deleteMsg": "Generations will no longer be routed to this host.",
"nodes.invalid": "Check the name, host and weight.",
"nodes.dupHost": "This host is already registered.",
"nodes.detail": "Node details",
"nodes.running": "Running now",
"nodes.vram": "VRAM",
"nodes.diskModels": "On disk ({n})",
"nodes.version": "Version",
"nodes.latency": "Latency",
"nodes.timeout": "Generation timeout",
"nodes.timeoutDesc": "Max time for one Ollama generation before failing over. Applies to every node.",
"nodes.timeoutUnit": "seconds (5–3600)",
"nodes.defWeight": "Default node weight",
"nodes.defWeightDesc": "Traffic share of the OLLAMA_HOST node. 0 excludes it when custom nodes exist.",
"nodes.stats": "Load",
"nodes.req": "requests",
"nodes.err": "errors",
"nodes.avg": "avg latency",
"nodes.tps": "tokens/s",
"nodes.tok": "tokens",
"nodes.emptyStats": "No generations in this window yet.",
"nodes.cpuNote": "Ollama exposes no CPU/RAM API — charts come from app-side generation telemetry; per-model VRAM via /api/ps.",
"catalog.title": "Model catalog (Hugging Face)",
"catalog.desc": "GGUF models installable with one click via ollama pull hf.co/…. Needs Ollama with Hugging Face support and internet on the Ollama host.",
"catalog.searchPh": "Search GGUF models…",
"catalog.pull": "Pull",
"catalog.pulling": "Pulling {name}…",
"catalog.node": "Target node",
"catalog.downloads": "downloads",
"catalog.files": "Quantizations",
"catalog.noRes": "No results.",
"catalog.hint": "Pick a quantization, it pulls straight from Hugging Face.",
"rec.title": "Password recovery limits",
"rec.desc": "Dedicated envelope for key-recovery requests (separate from login).",
"rec.max": "Max attempts",
"rec.maxHint": "per window, per IP",
"rec.window": "Window",
"rec.windowHint": "minutes",
"rec.save": "Save limits",
"rec.saved": "Limits saved",
"rec.invalid": "Check the values (attempts 1–100, window 1–1440 min).",
"msg.copy": "Copiar mensaje",
"msg.search": "Buscar en la web",
@@ -1640,6 +1760,66 @@ const fr: Dict = {
"live.newReport": "New content report ({reason})",
"live.adminReply": "Admin replied to your ticket",
"live.ticketStatus": "Ticket {status}",
"nodes.title": "Ollama nodes",
"nodes.desc": "Extra Ollama hosts. Each generation picks a node by weight (firewall-like priority, heaviest first) and fails over to the next one on error. The default node comes from OLLAMA_HOST.",
"nodes.add": "Add node",
"nodes.edit": "Edit node",
"nodes.name": "Name",
"nodes.namePh": "gpu-box-2",
"nodes.host": "Host",
"nodes.hostPh": "http://10.0.0.5:11434",
"nodes.hostHint": "http(s) base URL, no path. Must be reachable from this server.",
"nodes.weight": "Weight",
"nodes.weightHint": "0 disables traffic, higher gets more generations.",
"nodes.enabled": "Enabled",
"nodes.share": "{pct}%",
"nodes.off": "off",
"nodes.test": "Test",
"nodes.probing": "Probing…",
"nodes.testOk": "Reachable",
"nodes.testFail": "Unreachable",
"nodes.deleteTitle": "Delete this node?",
"nodes.deleteMsg": "Generations will no longer be routed to this host.",
"nodes.invalid": "Check the name, host and weight.",
"nodes.dupHost": "This host is already registered.",
"nodes.detail": "Node details",
"nodes.running": "Running now",
"nodes.vram": "VRAM",
"nodes.diskModels": "On disk ({n})",
"nodes.version": "Version",
"nodes.latency": "Latency",
"nodes.timeout": "Generation timeout",
"nodes.timeoutDesc": "Max time for one Ollama generation before failing over. Applies to every node.",
"nodes.timeoutUnit": "seconds (5–3600)",
"nodes.defWeight": "Default node weight",
"nodes.defWeightDesc": "Traffic share of the OLLAMA_HOST node. 0 excludes it when custom nodes exist.",
"nodes.stats": "Load",
"nodes.req": "requests",
"nodes.err": "errors",
"nodes.avg": "avg latency",
"nodes.tps": "tokens/s",
"nodes.tok": "tokens",
"nodes.emptyStats": "No generations in this window yet.",
"nodes.cpuNote": "Ollama exposes no CPU/RAM API — charts come from app-side generation telemetry; per-model VRAM via /api/ps.",
"catalog.title": "Model catalog (Hugging Face)",
"catalog.desc": "GGUF models installable with one click via ollama pull hf.co/…. Needs Ollama with Hugging Face support and internet on the Ollama host.",
"catalog.searchPh": "Search GGUF models…",
"catalog.pull": "Pull",
"catalog.pulling": "Pulling {name}…",
"catalog.node": "Target node",
"catalog.downloads": "downloads",
"catalog.files": "Quantizations",
"catalog.noRes": "No results.",
"catalog.hint": "Pick a quantization, it pulls straight from Hugging Face.",
"rec.title": "Password recovery limits",
"rec.desc": "Dedicated envelope for key-recovery requests (separate from login).",
"rec.max": "Max attempts",
"rec.maxHint": "per window, per IP",
"rec.window": "Window",
"rec.windowHint": "minutes",
"rec.save": "Save limits",
"rec.saved": "Limits saved",
"rec.invalid": "Check the values (attempts 1–100, window 1–1440 min).",
"msg.copy": "Copier le message",
"msg.search": "Rechercher sur le web",
@@ -2200,6 +2380,66 @@ const ru: Dict = {
"live.newReport": "New content report ({reason})",
"live.adminReply": "Admin replied to your ticket",
"live.ticketStatus": "Ticket {status}",
"nodes.title": "Ollama nodes",
"nodes.desc": "Extra Ollama hosts. Each generation picks a node by weight (firewall-like priority, heaviest first) and fails over to the next one on error. The default node comes from OLLAMA_HOST.",
"nodes.add": "Add node",
"nodes.edit": "Edit node",
"nodes.name": "Name",
"nodes.namePh": "gpu-box-2",
"nodes.host": "Host",
"nodes.hostPh": "http://10.0.0.5:11434",
"nodes.hostHint": "http(s) base URL, no path. Must be reachable from this server.",
"nodes.weight": "Weight",
"nodes.weightHint": "0 disables traffic, higher gets more generations.",
"nodes.enabled": "Enabled",
"nodes.share": "{pct}%",
"nodes.off": "off",
"nodes.test": "Test",
"nodes.probing": "Probing…",
"nodes.testOk": "Reachable",
"nodes.testFail": "Unreachable",
"nodes.deleteTitle": "Delete this node?",
"nodes.deleteMsg": "Generations will no longer be routed to this host.",
"nodes.invalid": "Check the name, host and weight.",
"nodes.dupHost": "This host is already registered.",
"nodes.detail": "Node details",
"nodes.running": "Running now",
"nodes.vram": "VRAM",
"nodes.diskModels": "On disk ({n})",
"nodes.version": "Version",
"nodes.latency": "Latency",
"nodes.timeout": "Generation timeout",
"nodes.timeoutDesc": "Max time for one Ollama generation before failing over. Applies to every node.",
"nodes.timeoutUnit": "seconds (5–3600)",
"nodes.defWeight": "Default node weight",
"nodes.defWeightDesc": "Traffic share of the OLLAMA_HOST node. 0 excludes it when custom nodes exist.",
"nodes.stats": "Load",
"nodes.req": "requests",
"nodes.err": "errors",
"nodes.avg": "avg latency",
"nodes.tps": "tokens/s",
"nodes.tok": "tokens",
"nodes.emptyStats": "No generations in this window yet.",
"nodes.cpuNote": "Ollama exposes no CPU/RAM API — charts come from app-side generation telemetry; per-model VRAM via /api/ps.",
"catalog.title": "Model catalog (Hugging Face)",
"catalog.desc": "GGUF models installable with one click via ollama pull hf.co/…. Needs Ollama with Hugging Face support and internet on the Ollama host.",
"catalog.searchPh": "Search GGUF models…",
"catalog.pull": "Pull",
"catalog.pulling": "Pulling {name}…",
"catalog.node": "Target node",
"catalog.downloads": "downloads",
"catalog.files": "Quantizations",
"catalog.noRes": "No results.",
"catalog.hint": "Pick a quantization, it pulls straight from Hugging Face.",
"rec.title": "Password recovery limits",
"rec.desc": "Dedicated envelope for key-recovery requests (separate from login).",
"rec.max": "Max attempts",
"rec.maxHint": "per window, per IP",
"rec.window": "Window",
"rec.windowHint": "minutes",
"rec.save": "Save limits",
"rec.saved": "Limits saved",
"rec.invalid": "Check the values (attempts 1–100, window 1–1440 min).",
"msg.copy": "Скопировать сообщение",
"msg.search": "Искать в интернете",
@@ -2765,6 +3005,66 @@ const it: Dict = {
"live.newReport": "New content report ({reason})",
"live.adminReply": "Admin replied to your ticket",
"live.ticketStatus": "Ticket {status}",
"nodes.title": "Ollama nodes",
"nodes.desc": "Extra Ollama hosts. Each generation picks a node by weight (firewall-like priority, heaviest first) and fails over to the next one on error. The default node comes from OLLAMA_HOST.",
"nodes.add": "Add node",
"nodes.edit": "Edit node",
"nodes.name": "Name",
"nodes.namePh": "gpu-box-2",
"nodes.host": "Host",
"nodes.hostPh": "http://10.0.0.5:11434",
"nodes.hostHint": "http(s) base URL, no path. Must be reachable from this server.",
"nodes.weight": "Weight",
"nodes.weightHint": "0 disables traffic, higher gets more generations.",
"nodes.enabled": "Enabled",
"nodes.share": "{pct}%",
"nodes.off": "off",
"nodes.test": "Test",
"nodes.probing": "Probing…",
"nodes.testOk": "Reachable",
"nodes.testFail": "Unreachable",
"nodes.deleteTitle": "Delete this node?",
"nodes.deleteMsg": "Generations will no longer be routed to this host.",
"nodes.invalid": "Check the name, host and weight.",
"nodes.dupHost": "This host is already registered.",
"nodes.detail": "Node details",
"nodes.running": "Running now",
"nodes.vram": "VRAM",
"nodes.diskModels": "On disk ({n})",
"nodes.version": "Version",
"nodes.latency": "Latency",
"nodes.timeout": "Generation timeout",
"nodes.timeoutDesc": "Max time for one Ollama generation before failing over. Applies to every node.",
"nodes.timeoutUnit": "seconds (5–3600)",
"nodes.defWeight": "Default node weight",
"nodes.defWeightDesc": "Traffic share of the OLLAMA_HOST node. 0 excludes it when custom nodes exist.",
"nodes.stats": "Load",
"nodes.req": "requests",
"nodes.err": "errors",
"nodes.avg": "avg latency",
"nodes.tps": "tokens/s",
"nodes.tok": "tokens",
"nodes.emptyStats": "No generations in this window yet.",
"nodes.cpuNote": "Ollama exposes no CPU/RAM API — charts come from app-side generation telemetry; per-model VRAM via /api/ps.",
"catalog.title": "Model catalog (Hugging Face)",
"catalog.desc": "GGUF models installable with one click via ollama pull hf.co/…. Needs Ollama with Hugging Face support and internet on the Ollama host.",
"catalog.searchPh": "Search GGUF models…",
"catalog.pull": "Pull",
"catalog.pulling": "Pulling {name}…",
"catalog.node": "Target node",
"catalog.downloads": "downloads",
"catalog.files": "Quantizations",
"catalog.noRes": "No results.",
"catalog.hint": "Pick a quantization, it pulls straight from Hugging Face.",
"rec.title": "Password recovery limits",
"rec.desc": "Dedicated envelope for key-recovery requests (separate from login).",
"rec.max": "Max attempts",
"rec.maxHint": "per window, per IP",
"rec.window": "Window",
"rec.windowHint": "minutes",
"rec.save": "Save limits",
"rec.saved": "Limits saved",
"rec.invalid": "Check the values (attempts 1–100, window 1–1440 min).",
"msg.copy": "Copia messaggio",
"msg.search": "Cerca nel web",
@@ -3321,6 +3621,66 @@ const pt: Dict = {
"live.newReport": "New content report ({reason})",
"live.adminReply": "Admin replied to your ticket",
"live.ticketStatus": "Ticket {status}",
"nodes.title": "Ollama nodes",
"nodes.desc": "Extra Ollama hosts. Each generation picks a node by weight (firewall-like priority, heaviest first) and fails over to the next one on error. The default node comes from OLLAMA_HOST.",
"nodes.add": "Add node",
"nodes.edit": "Edit node",
"nodes.name": "Name",
"nodes.namePh": "gpu-box-2",
"nodes.host": "Host",
"nodes.hostPh": "http://10.0.0.5:11434",
"nodes.hostHint": "http(s) base URL, no path. Must be reachable from this server.",
"nodes.weight": "Weight",
"nodes.weightHint": "0 disables traffic, higher gets more generations.",
"nodes.enabled": "Enabled",
"nodes.share": "{pct}%",
"nodes.off": "off",
"nodes.test": "Test",
"nodes.probing": "Probing…",
"nodes.testOk": "Reachable",
"nodes.testFail": "Unreachable",
"nodes.deleteTitle": "Delete this node?",
"nodes.deleteMsg": "Generations will no longer be routed to this host.",
"nodes.invalid": "Check the name, host and weight.",
"nodes.dupHost": "This host is already registered.",
"nodes.detail": "Node details",
"nodes.running": "Running now",
"nodes.vram": "VRAM",
"nodes.diskModels": "On disk ({n})",
"nodes.version": "Version",
"nodes.latency": "Latency",
"nodes.timeout": "Generation timeout",
"nodes.timeoutDesc": "Max time for one Ollama generation before failing over. Applies to every node.",
"nodes.timeoutUnit": "seconds (5–3600)",
"nodes.defWeight": "Default node weight",
"nodes.defWeightDesc": "Traffic share of the OLLAMA_HOST node. 0 excludes it when custom nodes exist.",
"nodes.stats": "Load",
"nodes.req": "requests",
"nodes.err": "errors",
"nodes.avg": "avg latency",
"nodes.tps": "tokens/s",
"nodes.tok": "tokens",
"nodes.emptyStats": "No generations in this window yet.",
"nodes.cpuNote": "Ollama exposes no CPU/RAM API — charts come from app-side generation telemetry; per-model VRAM via /api/ps.",
"catalog.title": "Model catalog (Hugging Face)",
"catalog.desc": "GGUF models installable with one click via ollama pull hf.co/…. Needs Ollama with Hugging Face support and internet on the Ollama host.",
"catalog.searchPh": "Search GGUF models…",
"catalog.pull": "Pull",
"catalog.pulling": "Pulling {name}…",
"catalog.node": "Target node",
"catalog.downloads": "downloads",
"catalog.files": "Quantizations",
"catalog.noRes": "No results.",
"catalog.hint": "Pick a quantization, it pulls straight from Hugging Face.",
"rec.title": "Password recovery limits",
"rec.desc": "Dedicated envelope for key-recovery requests (separate from login).",
"rec.max": "Max attempts",
"rec.maxHint": "per window, per IP",
"rec.window": "Window",
"rec.windowHint": "minutes",
"rec.save": "Save limits",
"rec.saved": "Limits saved",
"rec.invalid": "Check the values (attempts 1–100, window 1–1440 min).",
"msg.copy": "Copiar mensagem",
"msg.search": "Pesquisar na web",
+8
View File
@@ -23,6 +23,14 @@ export function chatIdFromPath(pathname: string): string | null {
return m ? decodeURIComponent(m[1]!) : null;
}
/** Admin section from /admin itself (null) or /admin/<tab>. */
export function adminTabFromPath(pathname: string): string | null {
const p = normalizePath(pathname);
if (p === "/admin") return null;
const m = p.match(/^\/admin\/([^/]+)$/);
return m ? decodeURIComponent(m[1]!) : null;
}
/** Client-side navigation without reload (same-origin path only). */
export function navigate(to: string, replace = false): void {
const dest = normalizePath(to);