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);