:)
This commit is contained in:
@@ -61,8 +61,12 @@ from the **Accounts** panel (each gets a one-time access key to hand over).
|
|||||||
| GET/PATCH | `/api/admin/settings` | admin | feature flags: registrationEnabled, ocrEnabled |
|
| GET/PATCH | `/api/admin/settings` | admin | feature flags: registrationEnabled, ocrEnabled |
|
||||||
| GET/POST | `/api/conversations` | Bearer | server-persisted chats (title, topic, model) |
|
| GET/POST | `/api/conversations` | Bearer | server-persisted chats (title, topic, model) |
|
||||||
| GET | `/api/conversations/search?q=` | Bearer | search titles, topics + old prompts (with snippet) |
|
| GET | `/api/conversations/search?q=` | Bearer | search titles, topics + old prompts (with snippet) |
|
||||||
| POST | `/api/admin/ollama/pull` | admin | pull a model from the Ollama library (NDJSON progress) |
|
| POST | `/api/admin/ollama/pull` | admin | pull a model (library tag or `hf.co/…`, `{name, nodeId?}`, NDJSON progress) |
|
||||||
| DELETE | `/api/admin/ollama/models/:name` | admin | remove a local Ollama model |
|
| DELETE | `/api/admin/ollama/models/:name` | admin | remove a local Ollama model (`?node=` for a pool node) |
|
||||||
|
| GET/POST | `/api/admin/ollama/nodes` | admin | Ollama pool: list (+effective order) / register a host |
|
||||||
|
| PATCH/DELETE | `/api/admin/ollama/nodes/:id` | admin | rename / reweight / enable / delete a node |
|
||||||
|
| GET | `/api/admin/ollama/nodes/:id/status`, `…/stats?window=` | admin | live probe (version, running, VRAM) / load charts 1h–30d |
|
||||||
|
| GET | `/api/admin/ollama/probe?host=` | admin | test a host before registering it |
|
||||||
| GET | `/api/admin/ollama/status` | admin | Ollama connectivity probe: reachability, version, on-disk models |
|
| GET | `/api/admin/ollama/status` | admin | Ollama connectivity probe: reachability, version, on-disk models |
|
||||||
| POST | `/api/access/request` | public | reserve username+email with a motivation → ticket (`/review/:id`) |
|
| POST | `/api/access/request` | public | reserve username+email with a motivation → ticket (`/review/:id`) |
|
||||||
| GET/POST | `/api/access/ticket/:id` (+`/message`) | ticket bearer | follow + reply on an access request |
|
| GET/POST | `/api/access/ticket/:id` (+`/message`) | ticket bearer | follow + reply on an access request |
|
||||||
|
|||||||
@@ -121,7 +121,11 @@ only ever sees reviewed text blocks, never raw files.
|
|||||||
Models are addressed as `"driver:model"` (split on the first `:`).
|
Models are addressed as `"driver:model"` (split on the first `:`).
|
||||||
|
|
||||||
- `OllamaDriver`: model discovery via `GET {OLLAMA_HOST}/api/tags`, chat via
|
- `OllamaDriver`: model discovery via `GET {OLLAMA_HOST}/api/tags`, chat via
|
||||||
`POST /api/chat` with NDJSON streaming.
|
`POST /api/chat` with NDJSON streaming. Multi-node pool (`src/ollamaNodes.ts`:
|
||||||
|
`ollama_nodes` table + `effectiveOllamaNodes()`): each generation picks a
|
||||||
|
node by weighted random and fails over pre-stream on error/timeout
|
||||||
|
(`ollama_timeout_s` setting); per-attempt telemetry (ok, latency, token
|
||||||
|
counts) lands in `ollama_stats` for the admin charts.
|
||||||
- `GET /api/models` is served from an in-memory cache in `DriverRegistry`
|
- `GET /api/models` is served from an in-memory cache in `DriverRegistry`
|
||||||
(`MODEL_CACHE_TTL_MS` = 60 s) so logins don't hit Ollama every time.
|
(`MODEL_CACHE_TTL_MS` = 60 s) so logins don't hit Ollama every time.
|
||||||
Concurrent misses share one in-flight upstream fetch. Admins pass
|
Concurrent misses share one in-flight upstream fetch. Admins pass
|
||||||
|
|||||||
@@ -81,6 +81,11 @@ No router library — a tiny history wrapper (`lib/route.ts`: `navigate()`,
|
|||||||
- `Markdown.tsx` — `react-markdown` + GFM; code blocks get a language label, a
|
- `Markdown.tsx` — `react-markdown` + GFM; code blocks get a language label, a
|
||||||
copy button and dependency-free syntax highlighting (`lib/highlight.tsx` —
|
copy button and dependency-free syntax highlighting (`lib/highlight.tsx` —
|
||||||
regex tokenizer, no highlight.js, so the Nix offline lockfile is untouched).
|
regex tokenizer, no highlight.js, so the Nix offline lockfile is untouched).
|
||||||
|
- Admin Center tabs are deep-linkable (`/admin`, `/admin/models`,
|
||||||
|
`/admin/access`, … — `lib/route.ts` `adminTabFromPath`, SPA fallback serves
|
||||||
|
them all). Models tab: `OllamaNodes.tsx` (pool with weights, per-node live
|
||||||
|
status + 1h–30d load charts, generation timeout) and `OllamaCatalog.tsx`
|
||||||
|
(live Hugging Face GGUF search + one-click `hf.co/…` pull to a node).
|
||||||
|
|
||||||
## Theming
|
## Theming
|
||||||
|
|
||||||
|
|||||||
+54
-10
@@ -84,11 +84,14 @@ Notes:
|
|||||||
## Admin Center (`/admin`) & self-registration
|
## Admin Center (`/admin`) & self-registration
|
||||||
|
|
||||||
The sidebar **Admin Center** button (admin only) opens the `/admin` page with
|
The sidebar **Admin Center** button (admin only) opens the `/admin` page with
|
||||||
four tabs: **Accounts** (same manager as before, incl. the Ollama model
|
six deep-linkable tabs (`/admin` = accounts, `/admin/models`, `/admin/access`,
|
||||||
catalog), **Access** (access-request wishlist triage, see below),
|
`/admin/reports`, `/admin/features`, `/admin/mail`): **Accounts** (same
|
||||||
**Features** (server-side switches), **Mail** (SMTP credentials viewer +
|
manager as before), **Access** (access-request wishlist triage, see below),
|
||||||
connectivity tester: `GET /api/admin/mail`, `POST /api/admin/mail/verify`,
|
**Reports**, **Models** (node pool, per-model policy, Ollama library + HF
|
||||||
`POST /api/admin/mail/test`, admin Bearer only).
|
catalog), **Features** (server-side switches), **Mail** (SMTP credentials
|
||||||
|
viewer + connectivity tester: `GET /api/admin/mail`,
|
||||||
|
`POST /api/admin/mail/verify`, `POST /api/admin/mail/test`, admin Bearer
|
||||||
|
only — plus the password-recovery rate limits below).
|
||||||
|
|
||||||
- `registration_enabled` (default off): when on, the login page shows a
|
- `registration_enabled` (default off): when on, the login page shows a
|
||||||
working **Register** button — anyone can create an account (key shown once)
|
working **Register** button — anyone can create an account (key shown once)
|
||||||
@@ -108,7 +111,20 @@ connectivity tester: `GET /api/admin/mail`, `POST /api/admin/mail/verify`,
|
|||||||
Admins bypass it (`PATCH /api/admin/users/:id` still works). Surfaced to
|
Admins bypass it (`PATCH /api/admin/users/:id` still works). Surfaced to
|
||||||
clients via `GET /api/tools`.
|
clients via `GET /api/tools`.
|
||||||
- Flags live in the `settings` SQLite table, edited via
|
- Flags live in the `settings` SQLite table, edited via
|
||||||
`GET/PATCH /api/admin/settings` (admin Bearer only).
|
`GET/PATCH /api/admin/settings` (admin Bearer only). The same endpoint
|
||||||
|
carries the Ollama pool tuning (`ollama_timeout_s` 5–3600, default 300;
|
||||||
|
`ollama_default_weight` 0–1000, default 100) and the recovery envelope
|
||||||
|
(`recovery_limit_max` 1–100, default 5; `recovery_limit_window_min`
|
||||||
|
1–1440, default 60).
|
||||||
|
|
||||||
|
## Password recovery rate limits
|
||||||
|
|
||||||
|
`POST /api/auth/recover` and the `/api/auth/reset/:token` endpoints share a
|
||||||
|
dedicated per-IP envelope (separate from login brute-force protection):
|
||||||
|
every hit counts, successes included (recovery answers are always-ok by
|
||||||
|
design, so enumeration is impossible). Over-limit answers 429 with
|
||||||
|
`retryAfterSec` + `Retry-After`. The limits are admin-configurable live in
|
||||||
|
Admin Center → Mail → Password recovery limits, no restart.
|
||||||
|
|
||||||
## Model access policy (kill-switch + rate limits)
|
## Model access policy (kill-switch + rate limits)
|
||||||
|
|
||||||
@@ -124,10 +140,38 @@ an **Enabled** switch and **hourly / daily** per-user request caps
|
|||||||
toward limits even when generation later fails; over-limit answers 429.
|
toward limits even when generation later fails; over-limit answers 429.
|
||||||
- Routes: `GET /api/admin/model-policy[?refresh=1]`,
|
- Routes: `GET /api/admin/model-policy[?refresh=1]`,
|
||||||
`PATCH /api/admin/model-policy` (`{model, enabled?, hourly?, daily?}`).
|
`PATCH /api/admin/model-policy` (`{model, enabled?, hourly?, daily?}`).
|
||||||
Ollama library: `POST /api/admin/ollama/pull` (NDJSON progress),
|
Ollama library: `POST /api/admin/ollama/pull` (`{name, nodeId?}`, NDJSON
|
||||||
`DELETE /api/admin/ollama/models/:name`, and the connectivity probe
|
progress — library tags AND `hf.co/user/repo:quant` references),
|
||||||
`GET /api/admin/ollama/status` (reachability, version, on-disk models —
|
`DELETE /api/admin/ollama/models/:name[?node=]`, and the connectivity probe
|
||||||
surfaced in Admin Center → Models).
|
`GET /api/admin/ollama/status` (default node — surfaced in the node pool).
|
||||||
|
|
||||||
|
## Ollama node pool (multi-host, weighted)
|
||||||
|
|
||||||
|
Admin Center → Models → **Ollama nodes**: register extra Ollama hosts
|
||||||
|
(modal with name, host, weight + a Test probe before saving). The
|
||||||
|
env-configured `OLLAMA_HOST` stays as the implicit `default` node (read-only
|
||||||
|
row, `env` badge). Each generation picks a node by **weighted random**
|
||||||
|
among enabled nodes with weight > 0 (share shown as % — firewall-like
|
||||||
|
priority, heaviest first) and fails over to the next node on error or
|
||||||
|
timeout, but only before any token was produced (a started reply never
|
||||||
|
restarts elsewhere). Weight 0 or disabled = no traffic. Pool changes
|
||||||
|
invalidate the model list cache; `listModels` merges tags across nodes.
|
||||||
|
|
||||||
|
- `ollama_timeout_s` (default 300 s, Admin Center → Models): max time for
|
||||||
|
one generation per node attempt (caller aborts propagate instantly and
|
||||||
|
never fail over).
|
||||||
|
- Node detail view: live probe (version, latency, on-disk models, running
|
||||||
|
models with VRAM via `/api/ps`) + load charts over 1h / 6h / 24h / 7d /
|
||||||
|
30d (requests, errors, avg latency, tokens/s from app-side generation
|
||||||
|
telemetry in `ollama_stats`, 35-day retention — Ollama exposes no CPU/RAM
|
||||||
|
API, so host CPU/RAM meters are intentionally absent).
|
||||||
|
- HF catalog (same tab): live Hugging Face search (`filter=gguf`, sorted by
|
||||||
|
downloads), per-repo quantization picker, one-click pull to the selected
|
||||||
|
node. Needs Ollama with `hf.co/…` support + internet on that host.
|
||||||
|
- Routes: `GET/POST /api/admin/ollama/nodes`, `PATCH/DELETE
|
||||||
|
/api/admin/ollama/nodes/:id`, `GET …/nodes/:id/status`,
|
||||||
|
`GET …/nodes/:id/stats?window=1h|6h|24h|7d|30d`,
|
||||||
|
`GET /api/admin/ollama/probe?host=…`.
|
||||||
|
|
||||||
## Personal providers (BYOK)
|
## Personal providers (BYOK)
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import type {
|
|||||||
FilePreview,
|
FilePreview,
|
||||||
User,
|
User,
|
||||||
} from "./lib/types.ts";
|
} 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 { Login } from "./components/Login.tsx";
|
||||||
import { RegisterPage } from "./components/RegisterPage.tsx";
|
import { RegisterPage } from "./components/RegisterPage.tsx";
|
||||||
import { Sidebar } from "./components/Sidebar.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 REVIEW_ID = typeof window !== "undefined" ? reviewIdFromPath() : null;
|
||||||
const IS_ADMIN_PAGE =
|
const IS_ADMIN_PAGE =
|
||||||
typeof window !== "undefined" &&
|
typeof window !== "undefined" &&
|
||||||
/^\/admin\/?$/.test(window.location.pathname);
|
(/^\/admin\/?$/.test(window.location.pathname) || adminTabFromPath(window.location.pathname) !== null);
|
||||||
|
|
||||||
function resetTokenFromPath(): string | null {
|
function resetTokenFromPath(): string | null {
|
||||||
const m = window.location.pathname.match(
|
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.
|
// Public routes: no auth needed. IDs are constant for the page lifetime.
|
||||||
if (IS_ADMIN_PAGE) {
|
if (IS_ADMIN_PAGE) {
|
||||||
return <AdminCenter />;
|
return <AdminCenter initialTab={adminTabFromPath(window.location.pathname)} />;
|
||||||
}
|
}
|
||||||
if (RESET_TOKEN) {
|
if (RESET_TOKEN) {
|
||||||
return <ResetPage token={RESET_TOKEN} />;
|
return <ResetPage token={RESET_TOKEN} />;
|
||||||
|
|||||||
@@ -1,20 +1,27 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { ArrowLeft, Boxes, Eye, EyeOff, Flag, Inbox, LayoutDashboard, Mail, Send, ShieldAlert, SlidersHorizontal, Users } from "lucide-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 { subscribeAccessLive, wsUrl } from "../lib/accessWs.ts";
|
||||||
|
import { navigate } from "../lib/route.ts";
|
||||||
import { AdminPanel } from "./AdminPanel.tsx";
|
import { AdminPanel } from "./AdminPanel.tsx";
|
||||||
import { AccessRequestsPanel } from "./AccessRequests.tsx";
|
import { AccessRequestsPanel } from "./AccessRequests.tsx";
|
||||||
import { ReportsPanel } from "./ReportsPanel.tsx";
|
import { ReportsPanel } from "./ReportsPanel.tsx";
|
||||||
import { ModelsPanel } from "./ModelsPanel.tsx";
|
import { ModelsPanel } from "./ModelsPanel.tsx";
|
||||||
import { Toasts } from "./Toasts.tsx";
|
import { Toasts } from "./Toasts.tsx";
|
||||||
import { pushToast } from "../lib/toasts.ts";
|
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 { cn } from "../lib/cn.ts";
|
||||||
import { msUntilNextSolarSwitch, resolveThemeDark } from "../lib/solarTheme.ts";
|
import { msUntilNextSolarSwitch, resolveThemeDark } from "../lib/solarTheme.ts";
|
||||||
import { useT } from "../lib/i18n.ts";
|
import { useT } from "../lib/i18n.ts";
|
||||||
|
|
||||||
type Tab = "accounts" | "access" | "reports" | "models" | "features" | "mail";
|
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) {
|
function applyTheme(theme: string) {
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
const dark = resolveThemeDark(theme);
|
const dark = resolveThemeDark(theme);
|
||||||
@@ -22,15 +29,28 @@ function applyTheme(theme: string) {
|
|||||||
root.style.colorScheme = dark ? "dark" : "light";
|
root.style.colorScheme = dark ? "dark" : "light";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AdminCenter() {
|
export function AdminCenter({ initialTab }: { initialTab?: string | null }) {
|
||||||
const { t } = useT();
|
const { t } = useT();
|
||||||
const [allowed, setAllowed] = useState<boolean | null>(null);
|
const [allowed, setAllowed] = useState<boolean | null>(null);
|
||||||
const [tab, setTab] = useState<Tab>("accounts");
|
const [tab, setTab] = useState<Tab>(() => validTab(initialTab));
|
||||||
const [settings, setSettings] = useState<{ registrationEnabled: boolean; accessRequestEnabled: boolean; ocrEnabled: boolean; reportsEnabled: boolean; usernameChangeEnabled: boolean } | null>(null);
|
const [settings, setSettings] = useState<AdminSettings | null>(null);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [openAccessCount, setOpenAccessCount] = useState(0);
|
const [openAccessCount, setOpenAccessCount] = useState(0);
|
||||||
const [openReportCount, setOpenReportCount] = 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
|
// Full badge resync (HTTP) — only for mount, reconnect gaps and rare
|
||||||
// actions with no WS event (reporter shadow-ban). Hot path is incremental.
|
// actions with no WS event (reporter shadow-ban). Hot path is incremental.
|
||||||
const resyncBadgesRef = useRef<() => void>(() => {});
|
const resyncBadgesRef = useRef<() => void>(() => {});
|
||||||
@@ -139,7 +159,7 @@ export function AdminCenter() {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [allowed]);
|
}, [allowed]);
|
||||||
|
|
||||||
async function toggle(patch: { registrationEnabled?: boolean; accessRequestEnabled?: boolean; ocrEnabled?: boolean; reportsEnabled?: boolean; usernameChangeEnabled?: boolean }) {
|
async function toggle(patch: AdminSettingsPatch) {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
@@ -187,7 +207,7 @@ export function AdminCenter() {
|
|||||||
{TABS.map((t) => (
|
{TABS.map((t) => (
|
||||||
<button
|
<button
|
||||||
key={t.id}
|
key={t.id}
|
||||||
onClick={() => setTab(t.id)}
|
onClick={() => selectTab(t.id)}
|
||||||
className={cn(
|
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",
|
"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
|
tab === t.id
|
||||||
@@ -330,9 +350,17 @@ function MailPanel() {
|
|||||||
const [verifying, setVerifying] = useState(false);
|
const [verifying, setVerifying] = useState(false);
|
||||||
const [sending, setSending] = useState(false);
|
const [sending, setSending] = useState(false);
|
||||||
const [result, setResult] = useState<{ ok: boolean; text: string } | null>(null);
|
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(() => {
|
useEffect(() => {
|
||||||
api.adminMailStatus().then((r) => setSmtp(r.smtp)).catch((e) => setError(e instanceof Error ? e.message : t("admin.loadFailed")));
|
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() {
|
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 (
|
return (
|
||||||
<div className="space-y-4">
|
<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">
|
<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>
|
</p>
|
||||||
)}
|
)}
|
||||||
</section>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Activity, Bot, Undo2 } from "lucide-react";
|
import { Bot, Undo2 } from "lucide-react";
|
||||||
import { api, type OllamaStatus, type PolicyModel } from "../lib/api.ts";
|
import { api, type PolicyModel } from "../lib/api.ts";
|
||||||
import { OllamaModelsModal } from "./AdminPanel.tsx";
|
import { OllamaModelsModal } from "./AdminPanel.tsx";
|
||||||
|
import { NodesSection } from "./OllamaNodes.tsx";
|
||||||
|
import { CatalogSection } from "./OllamaCatalog.tsx";
|
||||||
import { Button, Input, Spinner, Switch } from "./ui.tsx";
|
import { Button, Input, Spinner, Switch } from "./ui.tsx";
|
||||||
import { useT } from "../lib/i18n.ts";
|
import { useT } from "../lib/i18n.ts";
|
||||||
import { cn } from "../lib/cn.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() {
|
export function ModelsPanel() {
|
||||||
const [models, setModels] = useState<PolicyModel[]>([]);
|
const [models, setModels] = useState<PolicyModel[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -48,7 +50,7 @@ export function ModelsPanel() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<OllamaConnectivity />
|
<NodesSection />
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<div className="mb-2 flex items-center gap-2">
|
<div className="mb-2 flex items-center gap-2">
|
||||||
@@ -103,143 +105,13 @@ export function ModelsPanel() {
|
|||||||
refresh();
|
refresh();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<CatalogSection />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- Ollama connectivity probe (admin) ---------- */
|
/* ---------- per-model policy rows ---------- */
|
||||||
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ModelRow({ model, onChanged, onError }: {
|
function ModelRow({ model, onChanged, onError }: {
|
||||||
model: PolicyModel;
|
model: PolicyModel;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
@@ -68,6 +68,74 @@ export interface OllamaHostModel {
|
|||||||
modifiedAt: string | null;
|
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 {
|
export interface OllamaStatus {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
host: string;
|
host: string;
|
||||||
@@ -188,9 +256,9 @@ export const api = {
|
|||||||
adminPatch: (id: string, patch: { username?: string; displayName?: string; avatarUrl?: string; theme?: string; email?: string }) =>
|
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) }),
|
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"),
|
adminGetSettings: () => req<{ settings: AdminSettings }>("/api/admin/settings"),
|
||||||
adminPatchSettings: (patch: { registrationEnabled?: boolean; accessRequestEnabled?: boolean; ocrEnabled?: boolean; reportsEnabled?: boolean; usernameChangeEnabled?: boolean }) =>
|
adminPatchSettings: (patch: AdminSettingsPatch) =>
|
||||||
req<{ settings: { registrationEnabled: boolean; accessRequestEnabled: boolean; ocrEnabled: boolean; reportsEnabled: boolean; usernameChangeEnabled: boolean } }>("/api/admin/settings", { method: "PATCH", body: JSON.stringify(patch) }),
|
req<{ settings: AdminSettings }>("/api/admin/settings", { method: "PATCH", body: JSON.stringify(patch) }),
|
||||||
|
|
||||||
/** Admin: SMTP credentials viewer (password included, blur it by default) + connectivity. */
|
/** Admin: SMTP credentials viewer (password included, blur it by default) + connectivity. */
|
||||||
adminMailStatus: () =>
|
adminMailStatus: () =>
|
||||||
@@ -281,11 +349,12 @@ export const api = {
|
|||||||
ollamaPull: async (
|
ollamaPull: async (
|
||||||
name: string,
|
name: string,
|
||||||
onProgress: (p: { status?: string; digest?: string; total?: number; completed?: number; error?: string }) => void,
|
onProgress: (p: { status?: string; digest?: string; total?: number; completed?: number; error?: string }) => void,
|
||||||
|
opts: { nodeId?: string } = {},
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
const res = await fetch("/api/admin/ollama/pull", {
|
const res = await fetch("/api/admin/ollama/pull", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${getToken()}` },
|
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) {
|
if (res.status === 401) {
|
||||||
clearToken();
|
clearToken();
|
||||||
@@ -313,7 +382,23 @@ export const api = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
/** Admin: delete a local Ollama model (frees disk on the Ollama host). */
|
/** 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). */
|
/** Admin: Ollama connectivity probe (reachability, version, on-disk models). */
|
||||||
ollamaStatus: () => req<OllamaStatus>("/api/admin/ollama/status"),
|
ollamaStatus: () => req<OllamaStatus>("/api/admin/ollama/status"),
|
||||||
|
|||||||
@@ -491,6 +491,66 @@ const en = {
|
|||||||
"live.newReport": "New content report ({reason})",
|
"live.newReport": "New content report ({reason})",
|
||||||
"live.adminReply": "Admin replied to your ticket",
|
"live.adminReply": "Admin replied to your ticket",
|
||||||
"live.ticketStatus": "Ticket {status}",
|
"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.copy": "Copy message",
|
||||||
"msg.search": "Search the web",
|
"msg.search": "Search the web",
|
||||||
@@ -1068,6 +1128,66 @@ const es: Dict = {
|
|||||||
"live.newReport": "New content report ({reason})",
|
"live.newReport": "New content report ({reason})",
|
||||||
"live.adminReply": "Admin replied to your ticket",
|
"live.adminReply": "Admin replied to your ticket",
|
||||||
"live.ticketStatus": "Ticket {status}",
|
"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.copy": "Copiar mensaje",
|
||||||
"msg.search": "Buscar en la web",
|
"msg.search": "Buscar en la web",
|
||||||
@@ -1640,6 +1760,66 @@ const fr: Dict = {
|
|||||||
"live.newReport": "New content report ({reason})",
|
"live.newReport": "New content report ({reason})",
|
||||||
"live.adminReply": "Admin replied to your ticket",
|
"live.adminReply": "Admin replied to your ticket",
|
||||||
"live.ticketStatus": "Ticket {status}",
|
"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.copy": "Copier le message",
|
||||||
"msg.search": "Rechercher sur le web",
|
"msg.search": "Rechercher sur le web",
|
||||||
@@ -2200,6 +2380,66 @@ const ru: Dict = {
|
|||||||
"live.newReport": "New content report ({reason})",
|
"live.newReport": "New content report ({reason})",
|
||||||
"live.adminReply": "Admin replied to your ticket",
|
"live.adminReply": "Admin replied to your ticket",
|
||||||
"live.ticketStatus": "Ticket {status}",
|
"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.copy": "Скопировать сообщение",
|
||||||
"msg.search": "Искать в интернете",
|
"msg.search": "Искать в интернете",
|
||||||
@@ -2765,6 +3005,66 @@ const it: Dict = {
|
|||||||
"live.newReport": "New content report ({reason})",
|
"live.newReport": "New content report ({reason})",
|
||||||
"live.adminReply": "Admin replied to your ticket",
|
"live.adminReply": "Admin replied to your ticket",
|
||||||
"live.ticketStatus": "Ticket {status}",
|
"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.copy": "Copia messaggio",
|
||||||
"msg.search": "Cerca nel web",
|
"msg.search": "Cerca nel web",
|
||||||
@@ -3321,6 +3621,66 @@ const pt: Dict = {
|
|||||||
"live.newReport": "New content report ({reason})",
|
"live.newReport": "New content report ({reason})",
|
||||||
"live.adminReply": "Admin replied to your ticket",
|
"live.adminReply": "Admin replied to your ticket",
|
||||||
"live.ticketStatus": "Ticket {status}",
|
"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.copy": "Copiar mensagem",
|
||||||
"msg.search": "Pesquisar na web",
|
"msg.search": "Pesquisar na web",
|
||||||
|
|||||||
@@ -23,6 +23,14 @@ export function chatIdFromPath(pathname: string): string | null {
|
|||||||
return m ? decodeURIComponent(m[1]!) : 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). */
|
/** Client-side navigation without reload (same-origin path only). */
|
||||||
export function navigate(to: string, replace = false): void {
|
export function navigate(to: string, replace = false): void {
|
||||||
const dest = normalizePath(to);
|
const dest = normalizePath(to);
|
||||||
|
|||||||
@@ -15,3 +15,28 @@ fait un timeout sur les requete envoyez au ollama , configurable dans l'admin ce
|
|||||||
Faire un catalog des modèles ollama via internet /hugging face compatible ollama.
|
Faire un catalog des modèles ollama via internet /hugging face compatible ollama.
|
||||||
voir les détails des nodes ollama (charge cpu, mémoire, vram..., sur le temps (1h,6h,12h,24h,7w,14w,1m))
|
voir les détails des nodes ollama (charge cpu, mémoire, vram..., sur le temps (1h,6h,12h,24h,7w,14w,1m))
|
||||||
leur composants,...
|
leur composants,...
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Changer le favicon en fonction de la couleur accentique.
|
||||||
|
Revoir la plate-forme
|
||||||
|
|
||||||
|
----
|
||||||
|
|
||||||
|
Proposer un Text-to-speech dans le composer de message via un Modèle comme whisper ou autre (voir tts)
|
||||||
|
le composer message qui fait les message vocaux, doit respecter la DA du site, un beau truc, avec les waves form jolie du message vocal en real time, proposer de stoper la vocal, transcribe en texte, proposer l'édition avant envoie
|
||||||
|
|
||||||
|
---- pour les telemtry des nodes ollama
|
||||||
|
Vraiment rendre le truc super clean: telemtry avec très beau graphe, dock bar, pour changer le type de graphique, faire une vrai sous-page dans le admin center, avec de magnifique graphe, mangifique tooltip vraiment sympathique, voir le host, kernel, uptime, network, ip, etc
|
||||||
|
|
||||||
|
--- pour les waves formes du message vocal, rendre le tout vraiment vraiment beau, un beau composer unique.
|
||||||
|
--- Nouvel outil, l'ia en temps réel:
|
||||||
|
Faire un Speech-To-Speech, soit avec whisper + LLM ,etc proposer à l'utilisateur sont combo modèle + très belle animation quand le modèle parle, un peu style siri ai. (Apple Inteligence) , outil potentiel à inclur: FlowerlyTTS local ou bien modèle whisper , speech2speech. Proposer dans l'admin center sa configuration , avec les outils, etc...
|
||||||
|
Le modèle doit détecter la langue par le texte/etc. l'user peut customiser l'accent sur sa langue configuré(accent féminin, accent masculin, avec preview audio sur un text déjà pré-encoder: Exemple:
|
||||||
|
"Salut, euhm, comment je peux t'aider aujourd'hui ?"> voix grave héistante
|
||||||
|
"5 sur 5 je recois! Que-veux-tu ?" voix grave ton serieux
|
||||||
|
"Owwww, ce chat est trop mignon, oops, pardon. Je me concentre, que puis-je faire pour toi " voix fémine, mignonne
|
||||||
|
"Hey , que puis-je faire pour toi ? " voix féminine médium par exemple
|
||||||
|
|
||||||
|
Ces exemples sont les default voix preview . dans l'admin center , l'utilisateur peut les modifier /revenir à défaut
|
||||||
|
L'utilisateur doit pouvoir configurée ces ai,vocaux,voix,langue dans une sous-section dédier dans ces paramètres.
|
||||||
|
|||||||
+37
@@ -4,6 +4,7 @@ import {
|
|||||||
createSession,
|
createSession,
|
||||||
deleteSession,
|
deleteSession,
|
||||||
getSession,
|
getSession,
|
||||||
|
getSetting,
|
||||||
getUserById,
|
getUserById,
|
||||||
getUserByUsername,
|
getUserByUsername,
|
||||||
toPublicUser,
|
toPublicUser,
|
||||||
@@ -109,3 +110,39 @@ export function recordAttempt(ip: string): void {
|
|||||||
export function clearAttempts(ip: string): void {
|
export function clearAttempts(ip: string): void {
|
||||||
attempts.delete(ip);
|
attempts.delete(ip);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- dedicated rate limiter for password recovery (admin-configurable) ---
|
||||||
|
// Separate bucket from login: recovery is anonymous-by-design (always-ok
|
||||||
|
// answers) so it needs its own, usually stricter, envelope.
|
||||||
|
|
||||||
|
const recoveryAttempts = new Map<string, number[]>();
|
||||||
|
|
||||||
|
function clampSettingInt(raw: string, min: number, max: number, fallback: number): number {
|
||||||
|
const v = Number.parseInt(raw, 10);
|
||||||
|
if (!Number.isInteger(v)) return fallback;
|
||||||
|
return Math.min(max, Math.max(min, v));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function recoveryLimitMax(): Promise<number> {
|
||||||
|
return clampSettingInt(await getSetting("recovery_limit_max", "5"), 1, 100, 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function recoveryLimitWindowMs(): Promise<number> {
|
||||||
|
return clampSettingInt(await getSetting("recovery_limit_window_min", "60"), 1, 1440, 60) * 60_000;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function isRecoveryLimited(ip: string): Promise<{ limited: boolean; retryAfterSec: number }> {
|
||||||
|
const windowMs = await recoveryLimitWindowMs();
|
||||||
|
const max = await recoveryLimitMax();
|
||||||
|
const now = Date.now();
|
||||||
|
const list = (recoveryAttempts.get(ip) ?? []).filter((t) => t > now - windowMs);
|
||||||
|
recoveryAttempts.set(ip, list);
|
||||||
|
if (list.length < max) return { limited: false, retryAfterSec: 0 };
|
||||||
|
return { limited: true, retryAfterSec: Math.max(1, Math.ceil((list[0]! + windowMs - now) / 1000)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recordRecoveryAttempt(ip: string): void {
|
||||||
|
const list = recoveryAttempts.get(ip) ?? [];
|
||||||
|
list.push(Date.now());
|
||||||
|
recoveryAttempts.set(ip, list);
|
||||||
|
}
|
||||||
|
|||||||
+98
-22
@@ -1,12 +1,28 @@
|
|||||||
import { config } from "../config.ts";
|
import { config } from "../config.ts";
|
||||||
import { driverLog, fmtBytes, fmtMs } from "./log.ts";
|
import { driverLog, fmtBytes, fmtMs } from "./log.ts";
|
||||||
|
import {
|
||||||
|
effectiveOllamaNodes,
|
||||||
|
getOllamaTimeoutMs,
|
||||||
|
pickOllamaRoute,
|
||||||
|
recordOllamaStat,
|
||||||
|
type EffectiveNode,
|
||||||
|
} from "../ollamaNodes.ts";
|
||||||
import type { ChatMessage, ChatOptions, DriverModel, LLMDriver } from "./types.ts";
|
import type { ChatMessage, ChatOptions, DriverModel, LLMDriver } from "./types.ts";
|
||||||
|
|
||||||
interface OllamaTag {
|
interface OllamaTag {
|
||||||
name: string;
|
name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Local LLM via Ollama. Priority #1. */
|
interface OllamaDone {
|
||||||
|
done?: boolean;
|
||||||
|
error?: string;
|
||||||
|
message?: { content?: string };
|
||||||
|
prompt_eval_count?: number;
|
||||||
|
eval_count?: number;
|
||||||
|
eval_duration?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Local LLM via Ollama (multi-node pool). Priority #1. */
|
||||||
export class OllamaDriver implements LLMDriver {
|
export class OllamaDriver implements LLMDriver {
|
||||||
readonly name = "ollama";
|
readonly name = "ollama";
|
||||||
readonly enabled: boolean;
|
readonly enabled: boolean;
|
||||||
@@ -16,22 +32,31 @@ export class OllamaDriver implements LLMDriver {
|
|||||||
this.enabled = enabled;
|
this.enabled = enabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Model list merged across every enabled node (deduped by name). */
|
||||||
async listModels(): Promise<DriverModel[]> {
|
async listModels(): Promise<DriverModel[]> {
|
||||||
const t0 = Date.now();
|
const t0 = Date.now();
|
||||||
const res = await fetch(`${this.host}/api/tags`, { signal: AbortSignal.timeout(8000) });
|
const nodes = await effectiveOllamaNodes();
|
||||||
if (!res.ok) {
|
if (nodes.length === 0) throw new Error("no ollama node available (all disabled or weight 0)");
|
||||||
driverLog(this.name, `listModels ERROR: /api/tags -> ${res.status} (${fmtMs(Date.now() - t0)})`);
|
const seen = new Map<string, DriverModel>();
|
||||||
throw new Error(`ollama /api/tags failed: ${res.status}`);
|
const errors: string[] = [];
|
||||||
}
|
await Promise.all(nodes.map(async (n) => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${n.host}/api/tags`, { signal: AbortSignal.timeout(8000) });
|
||||||
|
if (!res.ok) throw new Error(`status ${res.status}`);
|
||||||
const data = (await res.json()) as { models?: OllamaTag[] };
|
const data = (await res.json()) as { models?: OllamaTag[] };
|
||||||
const models = data.models ?? [];
|
for (const m of data.models ?? []) {
|
||||||
driverLog(this.name, `listModels -> ${models.length} model(s) [${models.map((m) => m.name).join(", ")}] (${fmtMs(Date.now() - t0)})`);
|
if (typeof m.name === "string" && !seen.has(m.name)) {
|
||||||
return models.map((m) => ({
|
seen.set(m.name, { id: `ollama:${m.name}`, name: m.name, driver: "ollama", label: m.name });
|
||||||
id: `ollama:${m.name}`,
|
}
|
||||||
name: m.name,
|
}
|
||||||
driver: "ollama",
|
} catch (e) {
|
||||||
label: m.name,
|
errors.push(`${n.name}: ${(e as Error).message}`);
|
||||||
|
}
|
||||||
}));
|
}));
|
||||||
|
const models = [...seen.values()];
|
||||||
|
driverLog(this.name, `listModels -> ${models.length} model(s) across ${nodes.length} node(s) (${fmtMs(Date.now() - t0)})${errors.length > 0 ? ` [skipped: ${errors.join("; ")}]` : ""}`);
|
||||||
|
if (models.length === 0 && errors.length > 0) throw new Error(`ollama listModels failed: ${errors.join("; ")}`);
|
||||||
|
return models;
|
||||||
}
|
}
|
||||||
|
|
||||||
async chat(messages: ChatMessage[], opts: ChatOptions): Promise<string> {
|
async chat(messages: ChatMessage[], opts: ChatOptions): Promise<string> {
|
||||||
@@ -41,17 +66,56 @@ export class OllamaDriver implements LLMDriver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async *chatStream(messages: ChatMessage[], opts: ChatOptions): AsyncGenerator<string, void, void> {
|
async *chatStream(messages: ChatMessage[], opts: ChatOptions): AsyncGenerator<string, void, void> {
|
||||||
|
const timeoutMs = await getOllamaTimeoutMs();
|
||||||
|
const { order } = await pickOllamaRoute();
|
||||||
|
let lastError: Error | null = null;
|
||||||
|
for (const node of order) {
|
||||||
|
// Failover only while nothing was produced yet: once tokens reached
|
||||||
|
// the client, restarting on another node would duplicate the reply.
|
||||||
|
let produced = false;
|
||||||
|
const counting: ChatOptions = {
|
||||||
|
...opts,
|
||||||
|
onToken: (t) => {
|
||||||
|
produced = true;
|
||||||
|
opts.onToken?.(t);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
yield* this.streamFromNode(node, messages, counting, timeoutMs);
|
||||||
|
return;
|
||||||
|
} catch (e) {
|
||||||
|
// Caller hangup: never fail over (nobody is listening anymore).
|
||||||
|
if (opts.signal?.aborted) throw e;
|
||||||
|
if (produced) throw e;
|
||||||
|
lastError = e as Error;
|
||||||
|
driverLog(this.name, `chat model=${opts.model} node ${node.name} failed, failing over: ${lastError.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw lastError ?? new Error("ollama chat failed on every node");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One streaming attempt against a single node (throws on any failure). */
|
||||||
|
private async *streamFromNode(
|
||||||
|
node: EffectiveNode,
|
||||||
|
messages: ChatMessage[],
|
||||||
|
opts: ChatOptions,
|
||||||
|
timeoutMs: number,
|
||||||
|
): AsyncGenerator<string, void, void> {
|
||||||
const t0 = Date.now();
|
const t0 = Date.now();
|
||||||
driverLog(this.name, `chat model=${opts.model} messages=${messages.length} stream=open`);
|
driverLog(this.name, `chat model=${opts.model} node=${node.name} stream=open`);
|
||||||
const res = await fetch(`${this.host}/api/chat`, {
|
const signals = [AbortSignal.timeout(timeoutMs)];
|
||||||
|
if (opts.signal) signals.push(opts.signal);
|
||||||
|
const signal = typeof AbortSignal.any === "function" ? AbortSignal.any(signals) : signals[0]!;
|
||||||
|
const res = await fetch(`${node.host}/api/chat`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ model: opts.model, messages, stream: true }),
|
body: JSON.stringify({ model: opts.model, messages, stream: true }),
|
||||||
signal: opts.signal,
|
signal,
|
||||||
});
|
});
|
||||||
if (!res.ok || !res.body) {
|
if (!res.ok || !res.body) {
|
||||||
const text = await res.text().catch(() => "");
|
const text = await res.text().catch(() => "");
|
||||||
driverLog(this.name, `chat model=${opts.model} ERROR: upstream ${res.status} ${text.slice(0, 120)}`);
|
driverLog(this.name, `chat model=${opts.model} node=${node.name} ERROR: upstream ${res.status} ${text.slice(0, 120)}`);
|
||||||
|
await recordOllamaStat({ node_id: node.id, ok: false, latency_ms: Date.now() - t0, prompt_tokens: 0, eval_tokens: 0, model: opts.model });
|
||||||
throw new Error(`ollama chat failed (${res.status}): ${text.slice(0, 300)}`);
|
throw new Error(`ollama chat failed (${res.status}): ${text.slice(0, 300)}`);
|
||||||
}
|
}
|
||||||
const reader = res.body.getReader();
|
const reader = res.body.getReader();
|
||||||
@@ -59,6 +123,8 @@ export class OllamaDriver implements LLMDriver {
|
|||||||
let buf = "";
|
let buf = "";
|
||||||
let chunks = 0;
|
let chunks = 0;
|
||||||
let chars = 0;
|
let chars = 0;
|
||||||
|
let promptTokens = 0;
|
||||||
|
let evalTokens = 0;
|
||||||
let status = "done";
|
let status = "done";
|
||||||
try {
|
try {
|
||||||
while (true) {
|
while (true) {
|
||||||
@@ -71,7 +137,7 @@ export class OllamaDriver implements LLMDriver {
|
|||||||
const trimmed = line.trim();
|
const trimmed = line.trim();
|
||||||
if (!trimmed) continue;
|
if (!trimmed) continue;
|
||||||
try {
|
try {
|
||||||
const obj = JSON.parse(trimmed) as { message?: { content?: string }; error?: string; done?: boolean };
|
const obj = JSON.parse(trimmed) as OllamaDone;
|
||||||
if (obj.error) throw new Error(`ollama error: ${obj.error}`);
|
if (obj.error) throw new Error(`ollama error: ${obj.error}`);
|
||||||
const token = obj.message?.content ?? "";
|
const token = obj.message?.content ?? "";
|
||||||
if (token) {
|
if (token) {
|
||||||
@@ -80,8 +146,11 @@ export class OllamaDriver implements LLMDriver {
|
|||||||
opts.onToken?.(token);
|
opts.onToken?.(token);
|
||||||
yield token;
|
yield token;
|
||||||
}
|
}
|
||||||
|
if (typeof obj.prompt_eval_count === "number") promptTokens = obj.prompt_eval_count;
|
||||||
|
if (typeof obj.eval_count === "number") evalTokens = obj.eval_count;
|
||||||
if (obj.done) {
|
if (obj.done) {
|
||||||
driverLog(this.name, `chat model=${opts.model} done: ${chunks} chunks, ${fmtBytes(chars)} in ${fmtMs(Date.now() - t0)}`);
|
driverLog(this.name, `chat model=${opts.model} node=${node.name} done: ${chunks} chunks, ${fmtBytes(chars)} in ${fmtMs(Date.now() - t0)}`);
|
||||||
|
await recordOllamaStat({ node_id: node.id, ok: true, latency_ms: Date.now() - t0, prompt_tokens: promptTokens, eval_tokens: evalTokens, model: opts.model });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -90,9 +159,16 @@ export class OllamaDriver implements LLMDriver {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
driverLog(this.name, `chat model=${opts.model} upstream closed: ${chunks} chunks, ${fmtBytes(chars)} in ${fmtMs(Date.now() - t0)}`);
|
driverLog(this.name, `chat model=${opts.model} node=${node.name} upstream closed: ${chunks} chunks, ${fmtBytes(chars)} in ${fmtMs(Date.now() - t0)}`);
|
||||||
|
// Truncated stream (no done:true): still a usable partial reply.
|
||||||
|
await recordOllamaStat({ node_id: node.id, ok: true, latency_ms: Date.now() - t0, prompt_tokens: promptTokens, eval_tokens: evalTokens, model: opts.model });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
status = (e as Error).name === "AbortError" ? "aborted-by-caller" : `ERROR: ${(e as Error).message}`;
|
const name = (e as Error).name;
|
||||||
|
status = name === "AbortError" ? "aborted-by-caller" : name === "TimeoutError" ? "timeout" : `ERROR: ${(e as Error).message}`;
|
||||||
|
// Caller aborts are not node failures — don't pollute telemetry.
|
||||||
|
if (name !== "AbortError" || !opts.signal?.aborted) {
|
||||||
|
await recordOllamaStat({ node_id: node.id, ok: false, latency_ms: Date.now() - t0, prompt_tokens: promptTokens, eval_tokens: evalTokens, model: opts.model });
|
||||||
|
}
|
||||||
throw e;
|
throw e;
|
||||||
} finally {
|
} finally {
|
||||||
// Stop the upstream generation when the consumer goes away
|
// Stop the upstream generation when the consumer goes away
|
||||||
@@ -104,7 +180,7 @@ export class OllamaDriver implements LLMDriver {
|
|||||||
}
|
}
|
||||||
reader.releaseLock();
|
reader.releaseLock();
|
||||||
if (status !== "done") {
|
if (status !== "done") {
|
||||||
driverLog(this.name, `chat model=${opts.model} ${status} after ${chunks} chunks, ${fmtBytes(chars)} in ${fmtMs(Date.now() - t0)}`);
|
driverLog(this.name, `chat model=${opts.model} node=${node.name} ${status} after ${chunks} chunks, ${fmtBytes(chars)} in ${fmtMs(Date.now() - t0)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+264
-2
@@ -5,14 +5,35 @@ import {
|
|||||||
extractBearer,
|
extractBearer,
|
||||||
hashSecret,
|
hashSecret,
|
||||||
isRateLimited,
|
isRateLimited,
|
||||||
|
isRecoveryLimited,
|
||||||
issueToken,
|
issueToken,
|
||||||
login,
|
login,
|
||||||
newAccessKey,
|
newAccessKey,
|
||||||
recordAttempt,
|
recordAttempt,
|
||||||
|
recordRecoveryAttempt,
|
||||||
|
recoveryLimitMax,
|
||||||
|
recoveryLimitWindowMs,
|
||||||
revokeToken,
|
revokeToken,
|
||||||
verifyToken,
|
verifyToken,
|
||||||
type PublicUser,
|
type PublicUser,
|
||||||
} from "./auth.ts";
|
} from "./auth.ts";
|
||||||
|
import {
|
||||||
|
createOllamaNode,
|
||||||
|
DEFAULT_NODE_ID,
|
||||||
|
deleteOllamaNode,
|
||||||
|
effectiveOllamaNodes,
|
||||||
|
getOllamaDefaultWeight,
|
||||||
|
getOllamaNode,
|
||||||
|
getOllamaTimeoutMs,
|
||||||
|
listOllamaNodes,
|
||||||
|
nodeStats,
|
||||||
|
resolveOllamaHost,
|
||||||
|
sanitizeNodeHost,
|
||||||
|
sanitizeNodeName,
|
||||||
|
sanitizeWeight,
|
||||||
|
updateOllamaNode,
|
||||||
|
type StatsWindow,
|
||||||
|
} from "./ollamaNodes.ts";
|
||||||
import {
|
import {
|
||||||
addMessage,
|
addMessage,
|
||||||
consumeReset,
|
consumeReset,
|
||||||
@@ -123,6 +144,7 @@ import {
|
|||||||
} from "./cdn.ts";
|
} from "./cdn.ts";
|
||||||
import { OCR_MAX_BYTES, ocrAllowed, recordOcrJob } from "./tools/ocr.ts";
|
import { OCR_MAX_BYTES, ocrAllowed, recordOcrJob } from "./tools/ocr.ts";
|
||||||
import { tools } from "./tools/registry.ts";
|
import { tools } from "./tools/registry.ts";
|
||||||
|
import { initOllamaNodes } from "./ollamaNodes.ts";
|
||||||
|
|
||||||
assertConfig();
|
assertConfig();
|
||||||
await initDb();
|
await initDb();
|
||||||
@@ -134,6 +156,7 @@ await initAccessTables();
|
|||||||
await initReportsTables();
|
await initReportsTables();
|
||||||
await initModelUsageTables();
|
await initModelUsageTables();
|
||||||
await initTotpTables();
|
await initTotpTables();
|
||||||
|
await initOllamaNodes();
|
||||||
|
|
||||||
function json(data: unknown, status = 200): Response {
|
function json(data: unknown, status = 200): Response {
|
||||||
return new Response(JSON.stringify(data), {
|
return new Response(JSON.stringify(data), {
|
||||||
@@ -142,6 +165,64 @@ function json(data: unknown, status = 200): Response {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Live probe of one Ollama host: version, latency, on-disk models and
|
||||||
|
* running models (/api/ps — the only load signal Ollama exposes, with
|
||||||
|
* per-model VRAM usage). Always resolves (reachability is data).
|
||||||
|
*/
|
||||||
|
async function probeOllamaHost(host: string): Promise<{
|
||||||
|
reachable: boolean;
|
||||||
|
host: string;
|
||||||
|
version: string | null;
|
||||||
|
latencyMs: number | null;
|
||||||
|
models: { name: string; size: number; modifiedAt: string | null }[];
|
||||||
|
running: { name: string; sizeVram: number; size: number; expiresAt: string | null }[];
|
||||||
|
error: string | null;
|
||||||
|
}> {
|
||||||
|
const t0 = Date.now();
|
||||||
|
try {
|
||||||
|
const verRes = await fetch(`${host}/api/version`, { signal: AbortSignal.timeout(8000) });
|
||||||
|
if (!verRes.ok) {
|
||||||
|
return { reachable: false, host, version: null, latencyMs: Date.now() - t0, models: [], running: [], error: `ollama /api/version failed: ${verRes.status}` };
|
||||||
|
}
|
||||||
|
const ver = (await verRes.json().catch(() => ({}))) as { version?: unknown };
|
||||||
|
const tagsRes = await fetch(`${host}/api/tags`, { signal: AbortSignal.timeout(8000) });
|
||||||
|
const tags = (await tagsRes.json().catch(() => ({}))) as { models?: { name?: unknown; size?: unknown; modified_at?: unknown }[] };
|
||||||
|
const models = tagsRes.ok && Array.isArray(tags.models)
|
||||||
|
? tags.models
|
||||||
|
.filter((m) => typeof m.name === "string")
|
||||||
|
.map((m) => ({
|
||||||
|
name: m.name as string,
|
||||||
|
size: typeof m.size === "number" ? m.size : 0,
|
||||||
|
modifiedAt: typeof m.modified_at === "string" ? (m.modified_at as string) : null,
|
||||||
|
}))
|
||||||
|
: [];
|
||||||
|
const psRes = await fetch(`${host}/api/ps`, { signal: AbortSignal.timeout(8000) });
|
||||||
|
const ps = (await psRes.json().catch(() => ({}))) as { models?: { name?: unknown; size_vram?: unknown; size?: unknown; expires_at?: unknown }[] };
|
||||||
|
const running = psRes.ok && Array.isArray(ps.models)
|
||||||
|
? ps.models
|
||||||
|
.filter((m) => typeof m.name === "string")
|
||||||
|
.map((m) => ({
|
||||||
|
name: m.name as string,
|
||||||
|
sizeVram: typeof m.size_vram === "number" ? m.size_vram : 0,
|
||||||
|
size: typeof m.size === "number" ? m.size : 0,
|
||||||
|
expiresAt: typeof m.expires_at === "string" ? (m.expires_at as string) : null,
|
||||||
|
}))
|
||||||
|
: [];
|
||||||
|
return {
|
||||||
|
reachable: true,
|
||||||
|
host,
|
||||||
|
version: typeof ver.version === "string" ? ver.version : null,
|
||||||
|
latencyMs: Date.now() - t0,
|
||||||
|
models,
|
||||||
|
running,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
return { reachable: false, host, version: null, latencyMs: Date.now() - t0, models: [], running: [], error: (e as Error).message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function clientIp(req: Request, server: { requestIP?: (r: Request) => { address: string } | null }): string {
|
function clientIp(req: Request, server: { requestIP?: (r: Request) => { address: string } | null }): string {
|
||||||
try {
|
try {
|
||||||
const info = server.requestIP?.(req);
|
const info = server.requestIP?.(req);
|
||||||
@@ -487,6 +568,16 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
|
|||||||
if (path === "/api/auth/recover" && req.method === "POST") {
|
if (path === "/api/auth/recover" && req.method === "POST") {
|
||||||
const ip = clientIp(req, server);
|
const ip = clientIp(req, server);
|
||||||
if (isRateLimited(ip)) return json({ error: "too many attempts, try again later" }, 429);
|
if (isRateLimited(ip)) return json({ error: "too many attempts, try again later" }, 429);
|
||||||
|
// Dedicated recovery envelope (admin-configurable, stricter than
|
||||||
|
// login): every hit counts, successes included (anonymous by design).
|
||||||
|
const rl = await isRecoveryLimited(ip);
|
||||||
|
if (rl.limited) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: "too many recovery attempts, try again later", retryAfterSec: rl.retryAfterSec }),
|
||||||
|
{ status: 429, headers: { "Content-Type": "application/json", "Cache-Control": "no-store", "Retry-After": String(rl.retryAfterSec) } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
recordRecoveryAttempt(ip);
|
||||||
const { ok, body } = await readJson(req);
|
const { ok, body } = await readJson(req);
|
||||||
// Never enumerate accounts: always answer ok.
|
// Never enumerate accounts: always answer ok.
|
||||||
if (ok && typeof body.username === "string") {
|
if (ok && typeof body.username === "string") {
|
||||||
@@ -507,6 +598,15 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
|
|||||||
|
|
||||||
const resetMatch = path.match(/^\/api\/auth\/reset\/([A-Za-z0-9_-]{6,80})$/);
|
const resetMatch = path.match(/^\/api\/auth\/reset\/([A-Za-z0-9_-]{6,80})$/);
|
||||||
if (resetMatch) {
|
if (resetMatch) {
|
||||||
|
const ip = clientIp(req, server);
|
||||||
|
const rl = await isRecoveryLimited(ip);
|
||||||
|
if (rl.limited) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: "too many recovery attempts, try again later", retryAfterSec: rl.retryAfterSec }),
|
||||||
|
{ status: 429, headers: { "Content-Type": "application/json", "Cache-Control": "no-store", "Retry-After": String(rl.retryAfterSec) } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
recordRecoveryAttempt(ip);
|
||||||
const tokenHash = createHash("sha256").update(resetMatch[1]!).digest("hex");
|
const tokenHash = createHash("sha256").update(resetMatch[1]!).digest("hex");
|
||||||
if (req.method === "GET") {
|
if (req.method === "GET") {
|
||||||
const r = await peekReset(tokenHash);
|
const r = await peekReset(tokenHash);
|
||||||
@@ -866,6 +966,10 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
|
|||||||
ocrEnabled: (await getSetting("tools_ocr_enabled", "1")) === "1",
|
ocrEnabled: (await getSetting("tools_ocr_enabled", "1")) === "1",
|
||||||
reportsEnabled: await isReportsEnabled(),
|
reportsEnabled: await isReportsEnabled(),
|
||||||
usernameChangeEnabled: await isUsernameChangeEnabled(),
|
usernameChangeEnabled: await isUsernameChangeEnabled(),
|
||||||
|
ollamaTimeoutS: Math.round(await getOllamaTimeoutMs() / 1000),
|
||||||
|
ollamaDefaultWeight: await getOllamaDefaultWeight(),
|
||||||
|
recoveryLimitMax: await recoveryLimitMax(),
|
||||||
|
recoveryLimitWindowMin: Math.round(await recoveryLimitWindowMs() / 60_000),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -901,6 +1005,30 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
|
|||||||
if (typeof body.usernameChangeEnabled !== "boolean") return json({ error: "usernameChangeEnabled must be boolean" }, 400);
|
if (typeof body.usernameChangeEnabled !== "boolean") return json({ error: "usernameChangeEnabled must be boolean" }, 400);
|
||||||
await setSetting("username_change_enabled", body.usernameChangeEnabled ? "1" : "0");
|
await setSetting("username_change_enabled", body.usernameChangeEnabled ? "1" : "0");
|
||||||
}
|
}
|
||||||
|
if (body.ollamaTimeoutS !== undefined) {
|
||||||
|
if (!Number.isInteger(body.ollamaTimeoutS) || (body.ollamaTimeoutS as number) < 5 || (body.ollamaTimeoutS as number) > 3600) {
|
||||||
|
return json({ error: "ollamaTimeoutS must be an integer 5-3600 (seconds)" }, 400);
|
||||||
|
}
|
||||||
|
await setSetting("ollama_timeout_s", String(body.ollamaTimeoutS));
|
||||||
|
}
|
||||||
|
if (body.ollamaDefaultWeight !== undefined) {
|
||||||
|
if (!Number.isInteger(body.ollamaDefaultWeight) || (body.ollamaDefaultWeight as number) < 0 || (body.ollamaDefaultWeight as number) > 1000) {
|
||||||
|
return json({ error: "ollamaDefaultWeight must be an integer 0-1000 (0 = never use the default node)" }, 400);
|
||||||
|
}
|
||||||
|
await setSetting("ollama_default_weight", String(body.ollamaDefaultWeight));
|
||||||
|
}
|
||||||
|
if (body.recoveryLimitMax !== undefined) {
|
||||||
|
if (!Number.isInteger(body.recoveryLimitMax) || (body.recoveryLimitMax as number) < 1 || (body.recoveryLimitMax as number) > 100) {
|
||||||
|
return json({ error: "recoveryLimitMax must be an integer 1-100" }, 400);
|
||||||
|
}
|
||||||
|
await setSetting("recovery_limit_max", String(body.recoveryLimitMax));
|
||||||
|
}
|
||||||
|
if (body.recoveryLimitWindowMin !== undefined) {
|
||||||
|
if (!Number.isInteger(body.recoveryLimitWindowMin) || (body.recoveryLimitWindowMin as number) < 1 || (body.recoveryLimitWindowMin as number) > 1440) {
|
||||||
|
return json({ error: "recoveryLimitWindowMin must be an integer 1-1440 (minutes)" }, 400);
|
||||||
|
}
|
||||||
|
await setSetting("recovery_limit_window_min", String(body.recoveryLimitWindowMin));
|
||||||
|
}
|
||||||
return json({
|
return json({
|
||||||
settings: {
|
settings: {
|
||||||
registrationEnabled: await isRegistrationEnabled(),
|
registrationEnabled: await isRegistrationEnabled(),
|
||||||
@@ -908,6 +1036,10 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
|
|||||||
ocrEnabled: (await getSetting("tools_ocr_enabled", "1")) === "1",
|
ocrEnabled: (await getSetting("tools_ocr_enabled", "1")) === "1",
|
||||||
reportsEnabled: await isReportsEnabled(),
|
reportsEnabled: await isReportsEnabled(),
|
||||||
usernameChangeEnabled: await isUsernameChangeEnabled(),
|
usernameChangeEnabled: await isUsernameChangeEnabled(),
|
||||||
|
ollamaTimeoutS: Math.round(await getOllamaTimeoutMs() / 1000),
|
||||||
|
ollamaDefaultWeight: await getOllamaDefaultWeight(),
|
||||||
|
recoveryLimitMax: await recoveryLimitMax(),
|
||||||
|
recoveryLimitWindowMin: Math.round(await recoveryLimitWindowMs() / 60_000),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -965,6 +1097,16 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
|
|||||||
const name = typeof body.name === "string" ? body.name.trim() : "";
|
const name = typeof body.name === "string" ? body.name.trim() : "";
|
||||||
if (!/^[a-z0-9._:\/-]{1,100}$/i.test(name)) return json({ error: "invalid model name" }, 400);
|
if (!/^[a-z0-9._:\/-]{1,100}$/i.test(name)) return json({ error: "invalid model name" }, 400);
|
||||||
if (!ollamaDriver.enabled) return json({ error: "ollama driver is disabled" }, 502);
|
if (!ollamaDriver.enabled) return json({ error: "ollama driver is disabled" }, 502);
|
||||||
|
// Target node for the pull (library tags AND hf.co/... references).
|
||||||
|
let pullHost = ollamaDriver.host;
|
||||||
|
if (body.nodeId !== undefined) {
|
||||||
|
if (typeof body.nodeId !== "string") return json({ error: "nodeId must be a string" }, 400);
|
||||||
|
try {
|
||||||
|
pullHost = (await resolveOllamaHost(body.nodeId === DEFAULT_NODE_ID ? undefined : body.nodeId)).host;
|
||||||
|
} catch {
|
||||||
|
return json({ error: "unknown ollama node" }, 404);
|
||||||
|
}
|
||||||
|
}
|
||||||
const abortUpstream = new AbortController();
|
const abortUpstream = new AbortController();
|
||||||
const onClientAbort = () => abortUpstream.abort();
|
const onClientAbort = () => abortUpstream.abort();
|
||||||
req.signal.addEventListener("abort", onClientAbort);
|
req.signal.addEventListener("abort", onClientAbort);
|
||||||
@@ -983,7 +1125,7 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${ollamaDriver.host}/api/pull`, {
|
const res = await fetch(`${pullHost}/api/pull`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ model: name, stream: true }),
|
body: JSON.stringify({ model: name, stream: true }),
|
||||||
@@ -1038,8 +1180,17 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
|
|||||||
if (ollamaDeleteMatch && req.method === "DELETE") {
|
if (ollamaDeleteMatch && req.method === "DELETE") {
|
||||||
if (!admin) return json({ error: "forbidden" }, 403);
|
if (!admin) return json({ error: "forbidden" }, 403);
|
||||||
if (!ollamaDriver.enabled) return json({ error: "ollama driver is disabled" }, 502);
|
if (!ollamaDriver.enabled) return json({ error: "ollama driver is disabled" }, 502);
|
||||||
|
const nodeParam = url.searchParams.get("node");
|
||||||
|
let deleteHost = ollamaDriver.host;
|
||||||
|
if (nodeParam) {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${ollamaDriver.host}/api/delete`, {
|
deleteHost = (await resolveOllamaHost(nodeParam === DEFAULT_NODE_ID ? undefined : nodeParam)).host;
|
||||||
|
} catch {
|
||||||
|
return json({ error: "unknown ollama node" }, 404);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${deleteHost}/api/delete`, {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ model: ollamaDeleteMatch[1] }),
|
body: JSON.stringify({ model: ollamaDeleteMatch[1] }),
|
||||||
@@ -1168,6 +1319,117 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- admin: ollama nodes (multi-host pool, firewall-like weights) ---
|
||||||
|
if (path === "/api/admin/ollama/nodes" && req.method === "GET") {
|
||||||
|
if (!admin) return json({ error: "forbidden" }, 403);
|
||||||
|
return json({ nodes: await listOllamaNodes(), effective: await effectiveOllamaNodes() });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === "/api/admin/ollama/nodes" && req.method === "POST") {
|
||||||
|
if (!admin) return json({ error: "forbidden" }, 403);
|
||||||
|
const { ok, body } = await readJson(req);
|
||||||
|
if (!ok) return json({ error: "invalid JSON" }, 400);
|
||||||
|
const name = sanitizeNodeName(body.name);
|
||||||
|
const host = sanitizeNodeHost(body.host);
|
||||||
|
const weight = sanitizeWeight(body.weight ?? 100);
|
||||||
|
if (!name) return json({ error: "name: 1-60 chars" }, 400);
|
||||||
|
if (!host) return json({ error: "host: http(s) base URL without path (e.g. http://10.0.0.5:11434)" }, 400);
|
||||||
|
if (weight === null) return json({ error: "weight: integer 0-1000" }, 400);
|
||||||
|
if (body.enabled !== undefined && typeof body.enabled !== "boolean") {
|
||||||
|
return json({ error: "enabled must be boolean" }, 400);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const node = await createOllamaNode({ name, host, weight, enabled: body.enabled ?? true });
|
||||||
|
registry.invalidateModelsCache();
|
||||||
|
return json({ node }, 201);
|
||||||
|
} catch (e) {
|
||||||
|
if ((e as Error).message === "HOST_TAKEN") return json({ error: "this host is already registered" }, 409);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodeMatch = path.match(/^\/api\/admin\/ollama\/nodes\/([A-Za-z0-9_-]{1,64})$/);
|
||||||
|
if (nodeMatch && !path.endsWith("/status") && !path.endsWith("/stats")) {
|
||||||
|
if (!admin) return json({ error: "forbidden" }, 403);
|
||||||
|
const id = nodeMatch[1]!;
|
||||||
|
if (id === DEFAULT_NODE_ID) return json({ error: "the default node comes from OLLAMA_HOST and cannot be edited here" }, 400);
|
||||||
|
if (req.method === "DELETE") {
|
||||||
|
if (!await deleteOllamaNode(id)) return json({ error: "node not found" }, 404);
|
||||||
|
registry.invalidateModelsCache();
|
||||||
|
return json({ ok: true });
|
||||||
|
}
|
||||||
|
if (req.method === "PATCH") {
|
||||||
|
const { ok, body } = await readJson(req);
|
||||||
|
if (!ok) return json({ error: "invalid JSON" }, 400);
|
||||||
|
const patch: { name?: string; host?: string; weight?: number; enabled?: boolean } = {};
|
||||||
|
if (body.name !== undefined) {
|
||||||
|
const name = sanitizeNodeName(body.name);
|
||||||
|
if (!name) return json({ error: "name: 1-60 chars" }, 400);
|
||||||
|
patch.name = name;
|
||||||
|
}
|
||||||
|
if (body.host !== undefined) {
|
||||||
|
const host = sanitizeNodeHost(body.host);
|
||||||
|
if (!host) return json({ error: "host: http(s) base URL without path" }, 400);
|
||||||
|
patch.host = host;
|
||||||
|
}
|
||||||
|
if (body.weight !== undefined) {
|
||||||
|
const weight = sanitizeWeight(body.weight);
|
||||||
|
if (weight === null) return json({ error: "weight: integer 0-1000" }, 400);
|
||||||
|
patch.weight = weight;
|
||||||
|
}
|
||||||
|
if (body.enabled !== undefined) {
|
||||||
|
if (typeof body.enabled !== "boolean") return json({ error: "enabled must be boolean" }, 400);
|
||||||
|
patch.enabled = body.enabled;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const updated = await updateOllamaNode(id, patch);
|
||||||
|
if (!updated) return json({ error: "node not found" }, 404);
|
||||||
|
registry.invalidateModelsCache();
|
||||||
|
return json({ node: updated });
|
||||||
|
} catch (e) {
|
||||||
|
if ((e as Error).message === "HOST_TAKEN") return json({ error: "this host is already registered" }, 409);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return json({ error: "not found" }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-node live probe: version, latency, on-disk models, running models
|
||||||
|
// (/api/ps — the only load signal Ollama exposes, with VRAM usage).
|
||||||
|
const nodeStatusMatch = path.match(/^\/api\/admin\/ollama\/nodes\/([A-Za-z0-9_-]{1,64})\/status$/);
|
||||||
|
if (nodeStatusMatch && req.method === "GET") {
|
||||||
|
if (!admin) return json({ error: "forbidden" }, 403);
|
||||||
|
let host: string;
|
||||||
|
try {
|
||||||
|
host = (await resolveOllamaHost(nodeStatusMatch[1] === DEFAULT_NODE_ID ? undefined : nodeStatusMatch[1])).host;
|
||||||
|
} catch {
|
||||||
|
return json({ error: "unknown ollama node" }, 404);
|
||||||
|
}
|
||||||
|
return json(await probeOllamaHost(host));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Probe an arbitrary (not yet registered) host from the node form.
|
||||||
|
if (path === "/api/admin/ollama/probe" && req.method === "GET") {
|
||||||
|
if (!admin) return json({ error: "forbidden" }, 403);
|
||||||
|
const host = sanitizeNodeHost(url.searchParams.get("host"));
|
||||||
|
if (!host) return json({ error: "host: http(s) base URL without path" }, 400);
|
||||||
|
return json(await probeOllamaHost(host));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-node generation telemetry aggregated over a window.
|
||||||
|
const nodeStatsMatch = path.match(/^\/api\/admin\/ollama\/nodes\/([A-Za-z0-9_-]{1,64})\/stats$/);
|
||||||
|
if (nodeStatsMatch && req.method === "GET") {
|
||||||
|
if (!admin) return json({ error: "forbidden" }, 403);
|
||||||
|
const raw = (url.searchParams.get("window") ?? "24h").toLowerCase();
|
||||||
|
const win: StatsWindow = raw === "1h" || raw === "6h" || raw === "24h" || raw === "7d" || raw === "30d" ? raw : "24h";
|
||||||
|
try {
|
||||||
|
await resolveOllamaHost(nodeStatsMatch[1] === DEFAULT_NODE_ID ? undefined : nodeStatsMatch[1]);
|
||||||
|
} catch {
|
||||||
|
return json({ error: "unknown ollama node" }, 404);
|
||||||
|
}
|
||||||
|
return json({ stats: await nodeStats(nodeStatsMatch[1]!, win) });
|
||||||
|
}
|
||||||
|
|
||||||
// --- admin: access-request wishlist triage ---
|
// --- admin: access-request wishlist triage ---
|
||||||
if (path === "/api/admin/access" && req.method === "GET") {
|
if (path === "/api/admin/access" && req.method === "GET") {
|
||||||
if (!admin) return json({ error: "forbidden" }, 403);
|
if (!admin) return json({ error: "forbidden" }, 403);
|
||||||
|
|||||||
@@ -0,0 +1,337 @@
|
|||||||
|
import { randomBytes } from "node:crypto";
|
||||||
|
import { db, dbKind, getSetting } from "./db.ts";
|
||||||
|
import { config } from "./config.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Multi-node Ollama pool + generation telemetry.
|
||||||
|
*
|
||||||
|
* - `ollama_nodes`: admin-managed extra Ollama hosts. The env-configured
|
||||||
|
* host (`OLLAMA_HOST`) always exists as the implicit "default" node; its
|
||||||
|
* traffic share is `ollama_default_weight` (0 = never use it).
|
||||||
|
* - Routing is firewall-like: nodes are ordered by weight (highest first)
|
||||||
|
* and each generation picks a node by weighted random among the enabled
|
||||||
|
* ones, failing over to the next heaviest node on error/timeout.
|
||||||
|
* - `ollama_stats`: one row per generation attempt (node, ok, latency,
|
||||||
|
* token counts from Ollama's final chunk) powering the per-node detail
|
||||||
|
* charts. Rows older than 35 days are pruned on write.
|
||||||
|
*
|
||||||
|
* NOTE on host metrics: Ollama exposes no CPU/RAM API — only per-model
|
||||||
|
* VRAM via /api/ps (surfaced in the node detail view). Load-over-time
|
||||||
|
* charts come from app-side telemetry (requests, errors, latency,
|
||||||
|
* tokens/s), which is also what drives routing decisions.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface OllamaNode {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
host: string;
|
||||||
|
weight: number;
|
||||||
|
enabled: boolean;
|
||||||
|
created_at: number;
|
||||||
|
updated_at: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EffectiveNode {
|
||||||
|
/** "default" for the env host, otherwise the row id. */
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
host: string;
|
||||||
|
weight: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_NODE_ID = "default";
|
||||||
|
const STATS_RETENTION_MS = 35 * 86400_000;
|
||||||
|
|
||||||
|
export async function initOllamaNodes(): Promise<void> {
|
||||||
|
const autoId = dbKind() === "postgres" ? "BIGSERIAL PRIMARY KEY" : "INTEGER PRIMARY KEY AUTOINCREMENT";
|
||||||
|
await db().exec(`CREATE TABLE IF NOT EXISTS ollama_nodes (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
host TEXT NOT NULL UNIQUE,
|
||||||
|
weight INTEGER NOT NULL DEFAULT 100,
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
created_at BIGINT NOT NULL,
|
||||||
|
updated_at BIGINT NOT NULL
|
||||||
|
)`);
|
||||||
|
await db().exec(`CREATE TABLE IF NOT EXISTS ollama_stats (
|
||||||
|
id ${autoId},
|
||||||
|
node_id TEXT NOT NULL,
|
||||||
|
ts BIGINT NOT NULL,
|
||||||
|
ok INTEGER NOT NULL,
|
||||||
|
latency_ms INTEGER NOT NULL DEFAULT 0,
|
||||||
|
prompt_tokens INTEGER NOT NULL DEFAULT 0,
|
||||||
|
eval_tokens INTEGER NOT NULL DEFAULT 0,
|
||||||
|
model TEXT NOT NULL DEFAULT ''
|
||||||
|
)`);
|
||||||
|
await db().exec(`CREATE INDEX IF NOT EXISTS idx_ollama_stats_node_ts ON ollama_stats(node_id, ts)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowToNode(r: Record<string, number | string>): OllamaNode {
|
||||||
|
return {
|
||||||
|
id: String(r.id),
|
||||||
|
name: String(r.name),
|
||||||
|
host: String(r.host),
|
||||||
|
weight: Number(r.weight),
|
||||||
|
enabled: Number(r.enabled) === 1,
|
||||||
|
created_at: Number(r.created_at),
|
||||||
|
updated_at: Number(r.updated_at),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listOllamaNodes(): Promise<OllamaNode[]> {
|
||||||
|
const rows = await db().all<Record<string, number | string>>(
|
||||||
|
"SELECT * FROM ollama_nodes ORDER BY weight DESC, created_at ASC",
|
||||||
|
);
|
||||||
|
return rows.map(rowToNode);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getOllamaNode(id: string): Promise<OllamaNode | null> {
|
||||||
|
const row = await db().get<Record<string, number | string>>("SELECT * FROM ollama_nodes WHERE id = ?", id);
|
||||||
|
return row ? rowToNode(row) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function newNodeId(): string {
|
||||||
|
return "node_" + randomBytes(9).toString("base64url");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Normalize + validate an Ollama base URL (scheme + host, no path). */
|
||||||
|
export function sanitizeNodeHost(raw: unknown): string | null {
|
||||||
|
if (typeof raw !== "string") return null;
|
||||||
|
const t = raw.trim().replace(/\/+$/, "");
|
||||||
|
if (t.length < 10 || t.length > 200) return null;
|
||||||
|
let u: URL;
|
||||||
|
try {
|
||||||
|
u = new URL(t);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (u.protocol !== "http:" && u.protocol !== "https:") return null;
|
||||||
|
if (!u.hostname || u.username || u.password) return null;
|
||||||
|
// Drop default ports and any path/query/fragment for canonical form.
|
||||||
|
const port = u.port && !((u.protocol === "http:" && u.port === "80") || (u.protocol === "https:" && u.port === "443"))
|
||||||
|
? `:${u.port}`
|
||||||
|
: "";
|
||||||
|
if (u.pathname !== "/" && u.pathname !== "") return null;
|
||||||
|
return `${u.protocol}//${u.hostname}${port}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sanitizeNodeName(raw: unknown): string | null {
|
||||||
|
if (typeof raw !== "string") return null;
|
||||||
|
const t = raw.trim();
|
||||||
|
if (t.length === 0 || t.length > 60) return null;
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sanitizeWeight(raw: unknown): number | null {
|
||||||
|
if (typeof raw !== "number" || !Number.isInteger(raw) || raw < 0 || raw > 1000) return null;
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createOllamaNode(opts: { name: string; host: string; weight: number; enabled: boolean }): Promise<OllamaNode> {
|
||||||
|
const now = Date.now();
|
||||||
|
const row: OllamaNode = { id: newNodeId(), ...opts, created_at: now, updated_at: now };
|
||||||
|
try {
|
||||||
|
await db().run(
|
||||||
|
"INSERT INTO ollama_nodes (id, name, host, weight, enabled, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
row.id, row.name, row.host, row.weight, row.enabled ? 1 : 0, now, now,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
if (/unique|duplicate/i.test((e as Error).message)) throw new Error("HOST_TAKEN");
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateOllamaNode(
|
||||||
|
id: string,
|
||||||
|
patch: { name?: string; host?: string; weight?: number; enabled?: boolean },
|
||||||
|
): Promise<OllamaNode | null> {
|
||||||
|
const cur = await getOllamaNode(id);
|
||||||
|
if (!cur) return null;
|
||||||
|
const next: OllamaNode = {
|
||||||
|
...cur,
|
||||||
|
name: patch.name ?? cur.name,
|
||||||
|
host: patch.host ?? cur.host,
|
||||||
|
weight: patch.weight ?? cur.weight,
|
||||||
|
enabled: patch.enabled ?? cur.enabled,
|
||||||
|
updated_at: Date.now(),
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
await db().run(
|
||||||
|
"UPDATE ollama_nodes SET name = ?, host = ?, weight = ?, enabled = ?, updated_at = ? WHERE id = ?",
|
||||||
|
next.name, next.host, next.weight, next.enabled ? 1 : 0, next.updated_at, id,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
if (/unique|duplicate/i.test((e as Error).message)) throw new Error("HOST_TAKEN");
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteOllamaNode(id: string): Promise<boolean> {
|
||||||
|
const r = await db().run("DELETE FROM ollama_nodes WHERE id = ?", id);
|
||||||
|
return r.changes > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- effective pool (env default + custom rows) ---
|
||||||
|
|
||||||
|
function clampInt(raw: string, min: number, max: number, fallback: number): number {
|
||||||
|
const v = Number.parseInt(raw, 10);
|
||||||
|
if (!Number.isInteger(v)) return fallback;
|
||||||
|
return Math.min(max, Math.max(min, v));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Generation timeout for Ollama chat requests (admin setting, seconds). */
|
||||||
|
export async function getOllamaTimeoutMs(): Promise<number> {
|
||||||
|
return clampInt(await getSetting("ollama_timeout_s", "300"), 5, 3600, 300) * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Traffic weight of the implicit env node (0 = never use it). */
|
||||||
|
export async function getOllamaDefaultWeight(): Promise<number> {
|
||||||
|
return clampInt(await getSetting("ollama_default_weight", "100"), 0, 1000, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** All routable nodes, heaviest first (default env node included). */
|
||||||
|
export async function effectiveOllamaNodes(): Promise<EffectiveNode[]> {
|
||||||
|
const out: EffectiveNode[] = [];
|
||||||
|
if (config.ollamaEnabled) {
|
||||||
|
const w = await getOllamaDefaultWeight();
|
||||||
|
if (w > 0) {
|
||||||
|
out.push({ id: DEFAULT_NODE_ID, name: "default", host: config.ollamaHost.replace(/\/$/, ""), weight: w });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const n of await listOllamaNodes()) {
|
||||||
|
if (n.enabled && n.weight > 0) out.push({ id: n.id, name: n.name, host: n.host, weight: n.weight });
|
||||||
|
}
|
||||||
|
out.sort((a, b) => b.weight - a.weight);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pick the node for one generation (weighted random) + the failover order
|
||||||
|
* (picked node first, then heaviest-first). Throws when nothing is routable.
|
||||||
|
*/
|
||||||
|
export async function pickOllamaRoute(): Promise<{ first: EffectiveNode; order: EffectiveNode[] }> {
|
||||||
|
const nodes = await effectiveOllamaNodes();
|
||||||
|
if (nodes.length === 0) throw new Error('no ollama node available (all disabled or weight 0)');
|
||||||
|
const total = nodes.reduce((s, n) => s + n.weight, 0);
|
||||||
|
let r = Math.random() * total;
|
||||||
|
let first = nodes[0]!;
|
||||||
|
for (const n of nodes) {
|
||||||
|
r -= n.weight;
|
||||||
|
if (r <= 0) {
|
||||||
|
first = n;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const order = [first, ...nodes.filter((n) => n.id !== first.id)];
|
||||||
|
return { first, order };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve an admin-selected node id (pull/delete/status) to its host. */
|
||||||
|
export async function resolveOllamaHost(nodeId: string | undefined): Promise<EffectiveNode> {
|
||||||
|
if (!nodeId || nodeId === DEFAULT_NODE_ID) {
|
||||||
|
return { id: DEFAULT_NODE_ID, name: "default", host: config.ollamaHost.replace(/\/$/, ""), weight: 0 };
|
||||||
|
}
|
||||||
|
const n = await getOllamaNode(nodeId);
|
||||||
|
if (!n) throw new Error("unknown ollama node");
|
||||||
|
return { id: n.id, name: n.name, host: n.host, weight: n.weight };
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- telemetry ---
|
||||||
|
|
||||||
|
export async function recordOllamaStat(s: {
|
||||||
|
node_id: string;
|
||||||
|
ok: boolean;
|
||||||
|
latency_ms: number;
|
||||||
|
prompt_tokens: number;
|
||||||
|
eval_tokens: number;
|
||||||
|
model: string;
|
||||||
|
}): Promise<void> {
|
||||||
|
const d = db();
|
||||||
|
await d.run(
|
||||||
|
"INSERT INTO ollama_stats (node_id, ts, ok, latency_ms, prompt_tokens, eval_tokens, model) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
s.node_id, Date.now(), s.ok ? 1 : 0, Math.max(0, Math.round(s.latency_ms)),
|
||||||
|
Math.max(0, Math.round(s.prompt_tokens)), Math.max(0, Math.round(s.eval_tokens)), s.model.slice(0, 160),
|
||||||
|
);
|
||||||
|
// Best-effort retention (indexed range delete, cheap).
|
||||||
|
try {
|
||||||
|
await d.run("DELETE FROM ollama_stats WHERE ts < ?", Date.now() - STATS_RETENTION_MS);
|
||||||
|
} catch {
|
||||||
|
// stats must never break generations
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type StatsWindow = "1h" | "6h" | "24h" | "7d" | "30d";
|
||||||
|
|
||||||
|
export function statsWindowMs(w: StatsWindow): number {
|
||||||
|
return { "1h": 3600_000, "6h": 6 * 3600_000, "24h": 86400_000, "7d": 7 * 86400_000, "30d": 30 * 86400_000 }[w];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StatsBucket {
|
||||||
|
t: number;
|
||||||
|
req: number;
|
||||||
|
err: number;
|
||||||
|
avgMs: number | null;
|
||||||
|
promptTok: number;
|
||||||
|
evalTok: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NodeStats {
|
||||||
|
window: StatsWindow;
|
||||||
|
from: number;
|
||||||
|
to: number;
|
||||||
|
totals: { req: number; err: number; avgMs: number | null; promptTok: number; evalTok: number; tokPerSec: number | null };
|
||||||
|
buckets: StatsBucket[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const BUCKETS = 24;
|
||||||
|
|
||||||
|
export async function nodeStats(nodeId: string, window: StatsWindow): Promise<NodeStats> {
|
||||||
|
const to = Date.now();
|
||||||
|
const span = statsWindowMs(window);
|
||||||
|
const from = to - span;
|
||||||
|
const bucketMs = Math.floor(span / BUCKETS);
|
||||||
|
// GROUP BY output alias works on both SQLite and Postgres; AVG may come
|
||||||
|
// back as text (pg numeric) so every aggregate is coerced in JS.
|
||||||
|
const rows = await db().all<Record<string, number | string | null>>(
|
||||||
|
`SELECT CAST((ts - ?) / ? AS INTEGER) AS b, COUNT(*) AS req,
|
||||||
|
COUNT(*) - COALESCE(SUM(ok), 0) AS err,
|
||||||
|
AVG(CASE WHEN ok = 1 THEN latency_ms END) AS avgms,
|
||||||
|
COALESCE(SUM(prompt_tokens), 0) AS pt, COALESCE(SUM(eval_tokens), 0) AS et
|
||||||
|
FROM ollama_stats WHERE node_id = ? AND ts >= ? GROUP BY b ORDER BY b ASC`,
|
||||||
|
from, bucketMs, nodeId, from,
|
||||||
|
);
|
||||||
|
const buckets: StatsBucket[] = Array.from({ length: BUCKETS }, (_, i) => ({
|
||||||
|
t: from + i * bucketMs,
|
||||||
|
req: 0,
|
||||||
|
err: 0,
|
||||||
|
avgMs: null,
|
||||||
|
promptTok: 0,
|
||||||
|
evalTok: 0,
|
||||||
|
}));
|
||||||
|
for (const r of rows) {
|
||||||
|
const b = Number(r.b);
|
||||||
|
if (!Number.isInteger(b) || b < 0 || b >= BUCKETS) continue;
|
||||||
|
const slot = buckets[b]!;
|
||||||
|
slot.req = Number(r.req);
|
||||||
|
slot.err = Number(r.err);
|
||||||
|
slot.avgMs = r.avgms === null ? null : Number(r.avgms);
|
||||||
|
slot.promptTok = Number(r.pt);
|
||||||
|
slot.evalTok = Number(r.et);
|
||||||
|
}
|
||||||
|
const totals = buckets.reduce(
|
||||||
|
(a, b) => ({ req: a.req + b.req, err: a.err + b.err, promptTok: a.promptTok + b.promptTok, evalTok: a.evalTok + b.evalTok }),
|
||||||
|
{ req: 0, err: 0, promptTok: 0, evalTok: 0 },
|
||||||
|
);
|
||||||
|
const latRows = buckets.filter((b) => b.avgMs !== null && b.req - b.err > 0);
|
||||||
|
const latWeight = latRows.reduce((s, b) => s + (b.req - b.err), 0);
|
||||||
|
const avgMs = latWeight > 0
|
||||||
|
? latRows.reduce((s, b) => s + b.avgMs! * (b.req - b.err), 0) / latWeight
|
||||||
|
: null;
|
||||||
|
// Tokens/s over the window's active span (first to last non-empty bucket).
|
||||||
|
const active = buckets.filter((b) => b.req > 0);
|
||||||
|
const activeMs = active.length > 1 ? active[active.length - 1]!.t - active[0]!.t + bucketMs : active.length === 1 ? bucketMs : 0;
|
||||||
|
const tokPerSec = activeMs > 0 ? (totals.promptTok + totals.evalTok) / (activeMs / 1000) : null;
|
||||||
|
return { window, from, to, totals: { ...totals, avgMs, tokPerSec }, buckets };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user