From eff63e00242101699e128777c4a7da63905051ac Mon Sep 17 00:00:00 2001 From: Anais Saraiva Date: Tue, 22 Sep 2026 08:13:32 +0200 Subject: [PATCH] :) --- README.md | 1 + docs/OPERATIONS.md | 4 + frontend/src/components/AdminCenter.tsx | 4 +- frontend/src/components/ModelsPanel.tsx | 142 +++++++++++++++++++++++- frontend/src/lib/api.ts | 19 ++++ frontend/src/lib/i18n.ts | 78 +++++++++++++ src/index.ts | 46 +++++++- 7 files changed, 288 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 9821844..4abddc0 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,7 @@ from the **Accounts** panel (each gets a one-time access key to hand over). | 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) | | DELETE | `/api/admin/ollama/models/:name` | admin | remove a local Ollama model | +| 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`) | | GET/POST | `/api/access/ticket/:id` (+`/message`) | ticket bearer | follow + reply on an access request | | WS | `/api/access/ws/:id` | ticket bearer | live ticket messages + status (ping/pong heartbeat) | diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index c788c64..1341950 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -88,6 +88,10 @@ an **Enabled** switch and **hourly / daily** per-user request caps toward limits even when generation later fails; over-limit answers 429. - Routes: `GET /api/admin/model-policy[?refresh=1]`, `PATCH /api/admin/model-policy` (`{model, enabled?, hourly?, daily?}`). + Ollama library: `POST /api/admin/ollama/pull` (NDJSON progress), + `DELETE /api/admin/ollama/models/:name`, and the connectivity probe + `GET /api/admin/ollama/status` (reachability, version, on-disk models — + surfaced in Admin Center → Models). ## Personal providers (BYOK) diff --git a/frontend/src/components/AdminCenter.tsx b/frontend/src/components/AdminCenter.tsx index 53e8963..5748fb3 100644 --- a/frontend/src/components/AdminCenter.tsx +++ b/frontend/src/components/AdminCenter.tsx @@ -162,7 +162,7 @@ export function AdminCenter() { {openAccessCount > 99 ? "99+" : openAccessCount} @@ -171,7 +171,7 @@ export function AdminCenter() { {openReportCount > 99 ? "99+" : openReportCount} diff --git a/frontend/src/components/ModelsPanel.tsx b/frontend/src/components/ModelsPanel.tsx index 5faa84b..ef45dbb 100644 --- a/frontend/src/components/ModelsPanel.tsx +++ b/frontend/src/components/ModelsPanel.tsx @@ -1,6 +1,6 @@ -import { useEffect, useState } from "react"; -import { Bot, Undo2 } from "lucide-react"; -import { api, type PolicyModel } from "../lib/api.ts"; +import { useCallback, useEffect, useState } from "react"; +import { Activity, Bot, Undo2 } from "lucide-react"; +import { api, type OllamaStatus, type PolicyModel } from "../lib/api.ts"; import { OllamaModelsModal } from "./AdminPanel.tsx"; import { Button, Input, Spinner, Switch } from "./ui.tsx"; import { useT } from "../lib/i18n.ts"; @@ -48,6 +48,8 @@ export function ModelsPanel() { return (
+ +

{t("models.title")}

@@ -105,6 +107,140 @@ export function ModelsPanel() { ); } +/* ---------- Ollama connectivity probe (admin) ---------- */ + +function fmtSize(bytes: number): string { + if (!bytes || bytes < 0) return "—"; + const units = ["B", "KB", "MB", "GB", "TB"]; + let v = bytes; + let i = 0; + while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; } + return `${v >= 100 ? Math.round(v) : v.toFixed(1)} ${units[i]}`; +} + +/** Live Ollama host status: reachability, version, latency, on-disk models. */ +function OllamaConnectivity() { + const [status, setStatus] = useState(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 ( +
+
+

+ {t("models.connTitle")} +

+ + + {checking && !status ? t("common.loading") : stateLabel} + + +
+

{t("models.connDesc")}

+ + {status && ( +
+
+
{t("models.connHost")}
+
{status.host}
+
+
+
{t("models.connVersion")}
+
{status.version ?? "—"}
+
+
+
{t("models.connLatency")}
+
{status.latencyMs === null ? "—" : `${status.latencyMs} ms`}
+
+
+
{t("models.connModels")}
+
{status.models.length}
+
+
+ )} + + {!checking && !status && ( +

+ {t("admin.loadFailed")} +

+ )} + {status && status.error && ( +

+ {status.error} + {t("models.connHint")} +

+ )} + + {status && status.reachable && ( + status.models.length === 0 ? ( +

+ {t("models.connEmpty")} +

+ ) : ( +
    + {status.models.map((m) => ( +
  • + {m.name} + {fmtSize(m.size)} +
  • + ))} +
+ ) + )} +
+ ); +} + function ModelRow({ model, onChanged, onError }: { model: PolicyModel; onChanged: (m: PolicyModel) => void; diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index e8cbae8..a0eda7f 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -62,6 +62,22 @@ export interface PolicyModel extends DriverModel { daily: number; } +export interface OllamaHostModel { + name: string; + size: number; + modifiedAt: string | null; +} + +export interface OllamaStatus { + enabled: boolean; + host: string; + reachable: boolean; + version: string | null; + latencyMs: number | null; + models: OllamaHostModel[]; + error: string | null; +} + const TOKEN_KEY = "kisassistant_token"; export function getToken(): string | null { @@ -299,6 +315,9 @@ export const api = { /** Admin: delete a local Ollama model (frees disk on the Ollama host). */ ollamaDelete: (name: string) => req<{ ok: boolean }>(`/api/admin/ollama/models/${encodeURIComponent(name)}`, { method: "DELETE" }), + /** Admin: Ollama connectivity probe (reachability, version, on-disk models). */ + ollamaStatus: () => req("/api/admin/ollama/status"), + /** Personal LLM providers (BYOK): presence + last4 only, keys never leave the server. */ providers: () => req<{ providers: UserProvider[] }>("/api/me/providers"), setProvider: (provider: string, apiKey: string) => diff --git a/frontend/src/lib/i18n.ts b/frontend/src/lib/i18n.ts index 3f76241..7a754c1 100644 --- a/frontend/src/lib/i18n.ts +++ b/frontend/src/lib/i18n.ts @@ -313,6 +313,19 @@ const en = { "models.ollamaDesc": "Pull or remove local models.", "models.empty": "No models available.", "models.saveFailed": "Could not save", + "models.connTitle": "Ollama connectivity", + "models.connDesc": "Live status of the Ollama host: reachability, version, latency and installed models.", + "models.connOnline": "Online", + "models.connOffline": "Offline", + "models.connDisabledState": "Disabled", + "models.connCheck": "Check now", + "models.connCheckAria": "Check Ollama connectivity now", + "models.connHost": "Host", + "models.connVersion": "Version", + "models.connLatency": "Latency", + "models.connModels": "Models", + "models.connEmpty": "No models on the Ollama host yet — pull one from the library below.", + "models.connHint": "Make sure Ollama is running and OLLAMA_HOST points at it.", "center.locked": "Admins only", "center.lockedSub": "Log in with the admin account to open the Admin Center.", "center.regTitle": "Public registration", @@ -862,6 +875,19 @@ const es: Dict = { "models.ollamaDesc": "Descargar o eliminar modelos locales.", "models.empty": "No hay modelos disponibles.", "models.saveFailed": "No se pudo guardar", + "models.connTitle": "Conectividad Ollama", + "models.connDesc": "Estado en vivo del servidor Ollama: alcance, versión, latencia y modelos instalados.", + "models.connOnline": "En línea", + "models.connOffline": "Sin conexión", + "models.connDisabledState": "Desactivado", + "models.connCheck": "Comprobar ahora", + "models.connCheckAria": "Comprobar la conectividad Ollama ahora", + "models.connHost": "Host", + "models.connVersion": "Versión", + "models.connLatency": "Latencia", + "models.connModels": "Modelos", + "models.connEmpty": "Aún no hay modelos en el servidor Ollama — descarga uno desde la biblioteca.", + "models.connHint": "Asegúrate de que Ollama esté en marcha y de que OLLAMA_HOST apunte a él.", "center.locked": "Solo admins", "center.lockedSub": "Inicia sesión como admin para abrir el Centro de admin.", "center.regTitle": "Registro público", @@ -1405,6 +1431,19 @@ const fr: Dict = { "models.ollamaDesc": "Télécharger ou supprimer des modèles locaux.", "models.empty": "Aucun modèle disponible.", "models.saveFailed": "Enregistrement impossible", + "models.connTitle": "Connectivité Ollama", + "models.connDesc": "État en direct de l'hôte Ollama : joignabilité, version, latence et modèles installés.", + "models.connOnline": "En ligne", + "models.connOffline": "Hors ligne", + "models.connDisabledState": "Désactivé", + "models.connCheck": "Vérifier", + "models.connCheckAria": "Vérifier la connectivité Ollama", + "models.connHost": "Hôte", + "models.connVersion": "Version", + "models.connLatency": "Latence", + "models.connModels": "Modèles", + "models.connEmpty": "Aucun modèle sur l'hôte Ollama — téléchargez-en un depuis la bibliothèque ci-dessous.", + "models.connHint": "Vérifiez qu'Ollama tourne et que OLLAMA_HOST pointe vers lui.", "center.locked": "Admins uniquement", "center.lockedSub": "Connectez-vous en admin pour ouvrir le Centre admin.", "center.regTitle": "Inscription publique", @@ -1939,6 +1978,19 @@ const ru: Dict = { "models.ollamaDesc": "Скачивание и удаление локальных моделей.", "models.empty": "Нет доступных моделей.", "models.saveFailed": "Не удалось сохранить", + "models.connTitle": "Подключение Ollama", + "models.connDesc": "Состояние хоста Ollama: доступность, версия, задержка и модели.", + "models.connOnline": "В сети", + "models.connOffline": "Нет связи", + "models.connDisabledState": "Отключено", + "models.connCheck": "Проверить", + "models.connCheckAria": "Проверить подключение Ollama", + "models.connHost": "Хост", + "models.connVersion": "Версия", + "models.connLatency": "Задержка", + "models.connModels": "Модели", + "models.connEmpty": "На хосте Ollama пока нет моделей — скачайте из библиотеки ниже.", + "models.connHint": "Убедитесь, что Ollama запущена и OLLAMA_HOST указывает на неё.", "center.locked": "Только админы", "center.lockedSub": "Войдите как админ, чтобы открыть центр.", "center.regTitle": "Открытая регистрация", @@ -2471,6 +2523,19 @@ const it: Dict = { "models.ollamaDesc": "Scarica o rimuovi modelli locali.", "models.empty": "Nessun modello disponibile.", "models.saveFailed": "Salvataggio fallito", + "models.connTitle": "Connettività Ollama", + "models.connDesc": "Stato live dell'host Ollama: raggiungibilità, versione, latenza e modelli installati.", + "models.connOnline": "Online", + "models.connOffline": "Offline", + "models.connDisabledState": "Disabilitato", + "models.connCheck": "Verifica ora", + "models.connCheckAria": "Verifica la connettività Ollama", + "models.connHost": "Host", + "models.connVersion": "Versione", + "models.connLatency": "Latenza", + "models.connModels": "Modelli", + "models.connEmpty": "Nessun modello sull'host Ollama — scaricane uno dalla libreria qui sotto.", + "models.connHint": "Assicurati che Ollama sia in esecuzione e che OLLAMA_HOST punti a esso.", "center.locked": "Solo admin", "center.lockedSub": "Accedi come admin per aprire il Centro.", "center.regTitle": "Registrazione pubblica", @@ -3001,6 +3066,19 @@ const pt: Dict = { "models.ollamaDesc": "Baixar ou remover modelos locais.", "models.empty": "Nenhum modelo disponível.", "models.saveFailed": "Falha ao guardar", + "models.connTitle": "Conectividade Ollama", + "models.connDesc": "Estado ao vivo do servidor Ollama: alcance, versão, latência e modelos instalados.", + "models.connOnline": "Online", + "models.connOffline": "Offline", + "models.connDisabledState": "Desativado", + "models.connCheck": "Verificar agora", + "models.connCheckAria": "Verificar a conectividade Ollama agora", + "models.connHost": "Servidor", + "models.connVersion": "Versão", + "models.connLatency": "Latência", + "models.connModels": "Modelos", + "models.connEmpty": "Nenhum modelo no servidor Ollama — baixe um da biblioteca abaixo.", + "models.connHint": "Certifique-se de que o Ollama está a correr e de que OLLAMA_HOST aponta para ele.", "center.locked": "Só admins", "center.lockedSub": "Entra como admin para abrir o Centro.", "center.regTitle": "Registo público", diff --git a/src/index.ts b/src/index.ts index b11652a..7d1b0cb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -867,7 +867,51 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({ }); } - // --- admin: Ollama model management (pull from the library, delete) --- + // --- admin: Ollama model management (connectivity probe, pull from the library, delete) --- + // Connectivity probe: is the Ollama host reachable, which version, + // and what sits on its disk (name + bytes + modification time). + // Always 200 for admins (reachability is data, not an error code). + if (path === "/api/admin/ollama/status" && req.method === "GET") { + if (!admin) return json({ error: "forbidden" }, 403); + const host = ollamaDriver.host; + if (!ollamaDriver.enabled) { + return json({ enabled: false, host, reachable: false, version: null, latencyMs: null, models: [], error: "ollama driver is disabled" }); + } + const t0 = Date.now(); + try { + const verRes = await fetch(`${host}/api/version`, { signal: AbortSignal.timeout(8000) }); + if (!verRes.ok) { + return json({ enabled: true, host, reachable: false, version: null, latencyMs: Date.now() - t0, models: [], 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) }); + if (!tagsRes.ok) { + return json({ enabled: true, host, reachable: false, version: typeof ver.version === "string" ? ver.version : null, latencyMs: Date.now() - t0, models: [], error: `ollama /api/tags failed: ${tagsRes.status}` }); + } + const tags = (await tagsRes.json().catch(() => ({}))) as { models?: { name?: unknown; size?: unknown; modified_at?: unknown }[] }; + const models = 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, + })) + : []; + return json({ + enabled: true, + host, + reachable: true, + version: typeof ver.version === "string" ? ver.version : null, + latencyMs: Date.now() - t0, + models, + error: null, + }); + } catch (e) { + return json({ enabled: true, host, reachable: false, version: null, latencyMs: Date.now() - t0, models: [], error: (e as Error).message }); + } + } + // Pull streams Ollama's NDJSON progress straight to the admin client. if (path === "/api/admin/ollama/pull" && req.method === "POST") { if (!admin) return json({ error: "forbidden" }, 403);