This commit is contained in:
k
2026-09-22 08:13:32 +02:00
parent 76f1834712
commit eff63e0024
7 changed files with 288 additions and 6 deletions
+2 -2
View File
@@ -162,7 +162,7 @@ export function AdminCenter() {
<span
role="status"
aria-label={`${t.label}: ${openAccessCount}`}
className="absolute -right-0.5 -top-1.5 grid min-h-5 min-w-5 place-items-center rounded-full bg-red-500 px-1 text-[11px] font-bold leading-5 text-white shadow"
className="absolute -right-0.5 -top-0.5 z-10 grid min-h-4 min-w-4 place-items-center rounded-full bg-red-500 px-1 text-[10px] font-bold leading-4 text-white shadow"
>
{openAccessCount > 99 ? "99+" : openAccessCount}
</span>
@@ -171,7 +171,7 @@ export function AdminCenter() {
<span
role="status"
aria-label={`${t.label}: ${openReportCount}`}
className="absolute -right-0.5 -top-1.5 grid min-h-5 min-w-5 place-items-center rounded-full bg-red-500 px-1 text-[11px] font-bold leading-5 text-white shadow"
className="absolute -right-0.5 -top-0.5 z-10 grid min-h-4 min-w-4 place-items-center rounded-full bg-red-500 px-1 text-[10px] font-bold leading-4 text-white shadow"
>
{openReportCount > 99 ? "99+" : openReportCount}
</span>
+139 -3
View File
@@ -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 (
<div className="space-y-5">
<OllamaConnectivity />
<section>
<div className="mb-2 flex items-center gap-2">
<p className="text-sm font-medium">{t("models.title")}</p>
@@ -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<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 }: {
model: PolicyModel;
onChanged: (m: PolicyModel) => void;
+19
View File
@@ -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<OllamaStatus>("/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) =>
+78
View File
@@ -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",