umbau(boxwart): neue Oberflaeche im Cockpit-Stil (Richtung A), Werkzeuge angehoben
Drei Seiten statt zehn: Start, Updates, Modelle — am PC mit Kopfnavigation, am Handy mit Leiste unten. Umsetzung von Mockup A („Cockpit“), vom User am 23.09. gewaehlt. - Start: Hauptwarnleuchte + 8 Warnlampen, Rundinstrumente mit Live-Werten aus dem Strom (Speicher, Temperatur, Platte) und Laufzeit-Zaehlwerk, Checkliste der Waechter-Hinweise mit ihren Knoepfen, Flugplan (heute gelaufen / geplant), Radar-Kasten. - Updates: Bausteine mit „Laeuft → Neu“ und Zusammenfassung, laufende Auftraege, Verlauf der Sonntagslaeufe (neu: GET /api/updates/verlauf), Sicherungen samt Zurueckspielen mit Rueckfrage. - Modelle: Speicherbalken, Rollen Hirn/Coder/Dritte Rolle, wer die Modelle nutzt (7 Tage + 24 h je Stunde), Modell-Radar, weitere Eintraege, Modelle selbst suchen. - Schubladen: Dienste mit Protokoll und Neustart, Einstellungen (HF-Zugang), Hermes-Link. - Werkzeuge: Vite 8, React 19.3 mit React Compiler 1.0 (Babel), vitest 5, Tailwind 4.3, shadcn 4 (Radix) fuer Dialog/Schublade/Knopf, Schriften Barlow/Barlow Condensed/ JetBrains Mono. Entfernt: recharts, cmdk, Kraftgraph, zustand, Inter, Space Grotesk. - Startbuendel 115 KB gzip (Budget 140); Updates/Modelle/Schubladen laden bei Bedarf. - 16 Oberflaechen-Tests (Instrument-Bogen, Hauptleuchte, Zeitformate, Versionen). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
3d2c9549fb
commit
907289d7dc
@@ -0,0 +1,173 @@
|
||||
// Alle Abfragen und Aktionen der Oberfläche an einem Ort (TanStack Query).
|
||||
//
|
||||
// Takte: Steht der Live-Strom, meldet er Änderungen selbst — die Abfragen sind dann nur
|
||||
// Sicherheitsnetz und laufen fünfmal seltener.
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { api, post } from "./api"
|
||||
import { melden } from "./meldungen"
|
||||
import { stromVerbunden } from "./strom"
|
||||
import type {
|
||||
AktionsErgebnis, Job, Modell, Nutzung, Radar, Sicherung, Start,
|
||||
} from "./typen"
|
||||
|
||||
const takt = (ms: number) => () => (stromVerbunden() ? ms * 5 : ms)
|
||||
|
||||
export const useStart = () =>
|
||||
useQuery({ queryKey: ["start"], queryFn: () => api<Start>("/api/start"), refetchInterval: takt(20_000) })
|
||||
|
||||
export const useModelle = () =>
|
||||
useQuery({
|
||||
queryKey: ["models"],
|
||||
queryFn: () => api<{ models: Modell[]; running: string[] }>("/api/models"),
|
||||
refetchInterval: takt(30_000),
|
||||
})
|
||||
|
||||
export const useNutzung = () =>
|
||||
useQuery({ queryKey: ["nutzung"], queryFn: () => api<Nutzung>("/api/modelle/nutzung"), refetchInterval: 600_000 })
|
||||
|
||||
export const useRadar = () =>
|
||||
useQuery({ queryKey: ["radar"], queryFn: () => api<Radar>("/api/radar"), retry: false, refetchInterval: 300_000 })
|
||||
|
||||
export const useJobs = () =>
|
||||
useQuery({
|
||||
queryKey: ["jobs"],
|
||||
queryFn: () => api<{ jobs: Job[] }>("/api/jobs"),
|
||||
// Läuft etwas, wollen wir den Fortschritt sehen; sonst reicht der Strom.
|
||||
refetchInterval: (q) =>
|
||||
q.state.data?.jobs.some((j) => j.state === "running" || j.state === "queued") ? 2000 : takt(30_000)(),
|
||||
})
|
||||
|
||||
export const useSicherungen = () =>
|
||||
useQuery({
|
||||
queryKey: ["sicherungen"],
|
||||
queryFn: () => api<{ available: boolean; backups: Sicherung[] }>("/api/zeitmaschine"),
|
||||
})
|
||||
|
||||
export interface UpdateDetails {
|
||||
kind: string
|
||||
installed_build?: number | null
|
||||
latest_build?: number | null
|
||||
latest_tag?: string | null
|
||||
count?: number
|
||||
packages?: { name: string; current: string; candidate: string }[]
|
||||
summary?: string
|
||||
action_needed?: boolean | null
|
||||
action_text?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
export const useUpdateDetails = (art: string | null) =>
|
||||
useQuery({
|
||||
queryKey: ["update-details", art],
|
||||
queryFn: () => api<UpdateDetails>(`/api/maintenance/update-details?kind=${art}`),
|
||||
enabled: !!art,
|
||||
staleTime: 600_000,
|
||||
})
|
||||
|
||||
export interface UpdateLauf {
|
||||
start: string | null
|
||||
ende: string | null
|
||||
status: string
|
||||
fehler: string | null
|
||||
job: string
|
||||
}
|
||||
|
||||
export const useUpdateVerlauf = () =>
|
||||
useQuery({ queryKey: ["updates-verlauf"], queryFn: () => api<{ laeufe: UpdateLauf[] }>("/api/updates/verlauf") })
|
||||
|
||||
export const useProtokoll = (dienst: string | null) =>
|
||||
useQuery({
|
||||
queryKey: ["protokoll", dienst],
|
||||
queryFn: () => api<{ ok: boolean; text?: string; err?: string }>(
|
||||
`/api/maintenance/logs?service=${encodeURIComponent(dienst ?? "")}&lines=200`),
|
||||
enabled: !!dienst,
|
||||
})
|
||||
|
||||
export interface Dienst {
|
||||
name: string
|
||||
unit: string
|
||||
ok: boolean
|
||||
scope: string
|
||||
}
|
||||
|
||||
export const useDienste = () =>
|
||||
useQuery({ queryKey: ["dienste"], queryFn: () => api<{ services: Dienst[] }>("/api/system/services") })
|
||||
|
||||
// --- Aktionen ----------------------------------------------------------------
|
||||
|
||||
function useAktion<V, E extends AktionsErgebnis | Record<string, unknown>>(
|
||||
fn: (v: V) => Promise<E>,
|
||||
opt: { erfolg: (v: V, e: E) => string; neuLaden: string[] },
|
||||
) {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: fn,
|
||||
onSuccess: (e, v) => {
|
||||
const ergebnis = e as AktionsErgebnis
|
||||
if (ergebnis && ergebnis.ok === false) {
|
||||
melden("fehler", ergebnis.detail || ergebnis.err || "Das hat nicht geklappt.")
|
||||
} else {
|
||||
melden("erfolg", opt.erfolg(v, e))
|
||||
}
|
||||
for (const k of opt.neuLaden) qc.invalidateQueries({ queryKey: [k] })
|
||||
},
|
||||
onError: (f: Error) => melden("fehler", f.message),
|
||||
})
|
||||
}
|
||||
|
||||
export const useHinweisAktion = () =>
|
||||
useAktion(
|
||||
({ hinweis, aktion }: { hinweis: string; aktion: string; label: string }) =>
|
||||
post<AktionsErgebnis>(`/api/hinweise/${encodeURIComponent(hinweis)}/aktion/${aktion}`),
|
||||
{ erfolg: (v) => `${v.label}: erledigt. Der Wächter prüft in einer Minute nach.`, neuLaden: ["start"] },
|
||||
)
|
||||
|
||||
const UPDATE_PFADE: Record<string, string> = {
|
||||
alle: "/api/maintenance/update-all",
|
||||
pruefen: "/api/maintenance/check-updates",
|
||||
os: "/api/maintenance/os-update",
|
||||
engine: "/api/maintenance/engine-update",
|
||||
swap: "/api/maintenance/swap-update",
|
||||
hermes: "/api/maintenance/hermes-update",
|
||||
}
|
||||
|
||||
export const useUpdateStarten = () =>
|
||||
useAktion((art: string) => post<AktionsErgebnis>(UPDATE_PFADE[art]), {
|
||||
erfolg: (art) => (art === "pruefen" ? "Suche nach Neuem läuft." : "Update läuft. Den Fortschritt siehst du unten."),
|
||||
neuLaden: ["jobs", "start"],
|
||||
})
|
||||
|
||||
export const useJetztSichern = () =>
|
||||
useAktion(() => post<AktionsErgebnis>("/api/system/backup"), {
|
||||
erfolg: () => "Sicherung erstellt.",
|
||||
neuLaden: ["sicherungen", "start"],
|
||||
})
|
||||
|
||||
export const useZurueckspielen = () =>
|
||||
useAktion((datei: string) => post<AktionsErgebnis>("/api/zeitmaschine/restore", { file: datei }), {
|
||||
erfolg: () => "Zurückspielen läuft. Die Dienste starten gleich neu.",
|
||||
neuLaden: ["sicherungen"],
|
||||
})
|
||||
|
||||
export const useModellLaden = () =>
|
||||
useAktion((name: string) => post<AktionsErgebnis>(`/api/models/${encodeURIComponent(name)}/load`), {
|
||||
erfolg: (name) => `${name} wird geladen.`,
|
||||
neuLaden: ["models", "start"],
|
||||
})
|
||||
|
||||
export const useRadarAktion = () =>
|
||||
useAktion(
|
||||
({ id, aktion }: { id: string; aktion: "uebernehmen" | "verwerfen"; name: string }) =>
|
||||
post<AktionsErgebnis>(`/api/radar/${encodeURIComponent(id)}/${aktion}`),
|
||||
{
|
||||
erfolg: (v) => (v.aktion === "uebernehmen" ? `${v.name} übernommen.` : `${v.name} verworfen.`),
|
||||
neuLaden: ["radar", "models", "start"],
|
||||
},
|
||||
)
|
||||
|
||||
export const useRadarSuche = () =>
|
||||
useAktion(() => post<AktionsErgebnis>("/api/radar/suche"), {
|
||||
erfolg: () => "Radar sucht nach neuen Modellen.",
|
||||
neuLaden: ["radar"],
|
||||
})
|
||||
@@ -0,0 +1,78 @@
|
||||
// Reine Anzeige-Logik der Cockpit-Seiten — getrennt von den Komponenten, damit sie ohne
|
||||
// DOM testbar bleibt und Vites Hot-Reload die Komponenten-Dateien sauber tauschen kann.
|
||||
|
||||
import type { UpdateDetails } from "./abfragen"
|
||||
import type { Modell, Start } from "./typen"
|
||||
|
||||
// --- Rundinstrument -----------------------------------------------------------------
|
||||
// 240°-Bogen wie ein Zeigerinstrument, Mittelpunkt (100|95) in einer 200×150-Zeichnung.
|
||||
const MX = 100
|
||||
const MY = 95
|
||||
const START = 210 // Grad, links unten
|
||||
const SPANNE = 240
|
||||
|
||||
function punkt(grad: number, r: number): [number, number] {
|
||||
const rad = (grad * Math.PI) / 180
|
||||
return [MX + r * Math.cos(rad), MY - r * Math.sin(rad)]
|
||||
}
|
||||
|
||||
const r2 = (n: number) => Math.round(n * 100) / 100
|
||||
|
||||
/** SVG-Pfad eines Bogens von Anteil `von` bis `bis` (0…1) auf Radius `r`. */
|
||||
export function bogenPfad(von: number, bis: number, r: number): string {
|
||||
const a = Math.max(0, Math.min(1, von))
|
||||
const b = Math.max(a, Math.min(1, bis))
|
||||
const [x0, y0] = punkt(START - SPANNE * a, r)
|
||||
const [x1, y1] = punkt(START - SPANNE * b, r)
|
||||
const gross = (b - a) * SPANNE > 180 ? 1 : 0
|
||||
return `M ${r2(x0)} ${r2(y0)} A ${r} ${r} 0 ${gross} 1 ${r2(x1)} ${r2(y1)}`
|
||||
}
|
||||
|
||||
/** Farbe der Temperatur: grün bis 70 °C, dann Bernstein, ab 85 °C rot. */
|
||||
export function temperaturFarbe(grad: number | null | undefined) {
|
||||
if (grad == null) return "var(--text-3)"
|
||||
if (grad >= 85) return "var(--rot)"
|
||||
if (grad >= 70) return "var(--bernstein)"
|
||||
return "var(--gruen)"
|
||||
}
|
||||
|
||||
// --- Hauptwarnleuchte ---------------------------------------------------------------
|
||||
|
||||
/** Was die Hauptwarnleuchte sagt. Der schweigende Wächter geht allem anderen vor. */
|
||||
export function hauptleuchte(z: Start["zustand"]) {
|
||||
if (!z.waechter_wach) {
|
||||
return { art: "stumm" as const, oben: "Wächter", mitte: "SCHWEIGT", unten: "Seit über 5 Minuten kein Prüflauf" }
|
||||
}
|
||||
if (z.update_laeuft) {
|
||||
return { art: "info" as const, oben: "Wartung", mitte: "UPDATE LÄUFT", unten: "Hinweise ruhen bis zum Ende" }
|
||||
}
|
||||
if (z.anzahl === 0) {
|
||||
return { art: "ok" as const, oben: "Alles", mitte: "IN ORDNUNG", unten: "Keine offenen Hinweise" }
|
||||
}
|
||||
const mitte = `${z.anzahl} HINWEIS${z.anzahl === 1 ? "" : "E"}`
|
||||
return z.stufe === "rot"
|
||||
? { art: "rot" as const, oben: "Störung", mitte, unten: "Drücken zum Ansehen" }
|
||||
: { art: "gelb" as const, oben: "Achtung", mitte, unten: "Drücken zum Ansehen" }
|
||||
}
|
||||
|
||||
// --- Updates ------------------------------------------------------------------------
|
||||
|
||||
/** „Läuft“ und „Neu“ aus den Update-Details lesbar machen. */
|
||||
export function versionen(id: string, d: UpdateDetails | undefined): { laeuft: string; neu: string | null } {
|
||||
if (!d) return { laeuft: "…", neu: null }
|
||||
if (id === "os") return { laeuft: "Ubuntu", neu: d.count ? `${d.count} Pakete` : null }
|
||||
const pre = id === "engine" ? "b" : "v"
|
||||
const laeuft = d.installed_build != null ? `${pre}${d.installed_build}` : "–"
|
||||
const neuer = d.latest_build != null && d.installed_build != null && d.latest_build > d.installed_build
|
||||
return { laeuft, neu: neuer ? `${pre}${d.latest_build}` : null }
|
||||
}
|
||||
|
||||
// --- Modelle ------------------------------------------------------------------------
|
||||
|
||||
/** Rolle eines Modells aus seinen llama-swap-Aliasen. */
|
||||
export function rolleVon(m: Modell): "hirn" | "coder" | null {
|
||||
const a = m.aliases.map((x) => x.toLowerCase())
|
||||
if (a.includes("hermes") || a.includes("fast")) return "hirn"
|
||||
if (a.includes("coder")) return "coder"
|
||||
return null
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest"
|
||||
import { ApiError, api } from "./api"
|
||||
|
||||
/** Ruft api() auf und gibt den erwarteten ApiError typisiert zurueck.
|
||||
* Kommt kein Fehler, scheitert der Test hier statt spaeter an einer Zusicherung. */
|
||||
async function fehlerVon(pfad: string, init?: RequestInit): Promise<ApiError> {
|
||||
try {
|
||||
await api(pfad, init)
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError) return e
|
||||
throw e
|
||||
}
|
||||
throw new Error(`api(${pfad}) hat keinen Fehler geworfen`)
|
||||
}
|
||||
|
||||
// Antwort-Attrappe: `text()` liefert den Rumpf, den fehlerText() lesen soll.
|
||||
function antwort(status: number, rumpf: string, ok = status < 400): Response {
|
||||
return {
|
||||
ok,
|
||||
status,
|
||||
statusText: status === 500 ? "Internal Server Error" : "Bad Request",
|
||||
text: async () => rumpf,
|
||||
json: async () => JSON.parse(rumpf),
|
||||
} as unknown as Response
|
||||
}
|
||||
|
||||
function fetchAttrappe(res: Response) {
|
||||
const spion = vi.fn(async () => res)
|
||||
vi.stubGlobal("fetch", spion)
|
||||
return spion
|
||||
}
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
|
||||
describe("api()", () => {
|
||||
it("gibt den JSON-Rumpf zurueck, wenn alles gut geht", async () => {
|
||||
fetchAttrappe(antwort(200, '{"ok":true,"wert":42}'))
|
||||
await expect(api<{ wert: number }>("/api/test")).resolves.toEqual({ ok: true, wert: 42 })
|
||||
})
|
||||
|
||||
// Der Kern von B-14: frueher warf der Helfer "500 Internal Server Error" und verwarf
|
||||
// den Rumpf — der eigentliche Grund erreichte die Oberflaeche nie.
|
||||
it("hebt FastAPIs `detail` in die Fehlermeldung", async () => {
|
||||
fetchAttrappe(antwort(400, '{"detail":"Dienst \'llama-swap\' nicht erlaubt."}'))
|
||||
await expect(api("/api/maintenance/restart", { method: "POST" }))
|
||||
.rejects.toThrowError("Dienst 'llama-swap' nicht erlaubt.")
|
||||
})
|
||||
|
||||
it("fasst FastAPI-Validierungslisten zu einem Satz zusammen", async () => {
|
||||
fetchAttrappe(antwort(422, '{"detail":[{"msg":"repo fehlt"},{"msg":"quant ungueltig"}]}'))
|
||||
await expect(api("/api/models/install", { method: "POST" }))
|
||||
.rejects.toThrowError("repo fehlt; quant ungueltig")
|
||||
})
|
||||
|
||||
it("faellt auf Status + Statustext zurueck, wenn der Rumpf leer ist", async () => {
|
||||
fetchAttrappe(antwort(500, ""))
|
||||
await expect(api("/api/test")).rejects.toThrowError("500 Internal Server Error")
|
||||
})
|
||||
|
||||
it("laesst sich von einer HTML-Fehlerseite nicht aus dem Tritt bringen", async () => {
|
||||
fetchAttrappe(antwort(502, "<html><body>Bad Gateway</body></html>"))
|
||||
const fehler = await fehlerVon("/api/test")
|
||||
expect(fehler.status).toBe(502)
|
||||
// Kein zweiter Fehler beim Parsen — irgendeine brauchbare Zeile kommt heraus.
|
||||
expect(typeof fehler.message).toBe("string")
|
||||
expect(fehler.message.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it("traegt status und detail am Fehlerobjekt", async () => {
|
||||
fetchAttrappe(antwort(400, '{"detail":"kaputt"}'))
|
||||
const fehler = await fehlerVon("/api/test")
|
||||
expect(fehler.status).toBe(400)
|
||||
expect(fehler.detail).toBe("kaputt")
|
||||
})
|
||||
})
|
||||
|
||||
// ── Sicherheits-Wache (B-01) ────────────────────────────────────────────────
|
||||
// Bis zum 28.08.2026 las api() das Box-Sudo-Passwort und den HF-Token aus dem
|
||||
// localStorage und haengte sie an jeden mutierenden Request — als Header UND in den
|
||||
// JSON-Rumpf. Da der Dienst-Nutzer auf der Box mit NOPASSWD: ALL laeuft, war ein XSS
|
||||
// in dieser SPA gleichbedeutend mit Root. Diese Tests sind der Zaun davor: Wer die
|
||||
// Bequemlichkeit je wieder einbaut, macht sie rot.
|
||||
describe("api() traegt keine Geheimnisse", () => {
|
||||
it("schickt keine Kopfzeilen und keinen Rumpf aus dem localStorage", async () => {
|
||||
localStorage.setItem("mc_sudo_password", "geheim123")
|
||||
localStorage.setItem("mc_hf_token", "hf_geheim")
|
||||
|
||||
const spion = fetchAttrappe(antwort(200, '{"ok":true}'))
|
||||
await api("/api/maintenance/restart", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ service: "llama-swap" }),
|
||||
})
|
||||
|
||||
const [, init] = spion.mock.calls[0] as unknown as [string, RequestInit]
|
||||
const kopfzeilen = JSON.stringify(init.headers ?? {})
|
||||
const rumpf = String(init.body ?? "")
|
||||
|
||||
expect(kopfzeilen.toLowerCase()).not.toContain("sudo")
|
||||
expect(kopfzeilen).not.toContain("geheim123")
|
||||
expect(rumpf).not.toContain("geheim123")
|
||||
expect(rumpf).not.toContain("hf_geheim")
|
||||
expect(rumpf).not.toContain("sudo_password")
|
||||
expect(rumpf).not.toContain("hf_token")
|
||||
// Der Rumpf des Aufrufers bleibt unveraendert — nichts wird heimlich angereichert.
|
||||
expect(JSON.parse(rumpf)).toEqual({ service: "llama-swap" })
|
||||
})
|
||||
|
||||
it("erfindet auch ohne Rumpf keinen", async () => {
|
||||
localStorage.setItem("mc_sudo_password", "geheim123")
|
||||
const spion = fetchAttrappe(antwort(200, '{"ok":true}'))
|
||||
await api("/api/maintenance/reboot", { method: "POST" })
|
||||
|
||||
const [, init] = spion.mock.calls[0] as unknown as [string, RequestInit]
|
||||
expect(init.body ?? null).toBeNull()
|
||||
})
|
||||
})
|
||||
+40
-635
@@ -1,644 +1,49 @@
|
||||
// Schmaler Fetch-Helfer gegen das MC-2-Backend (/api/*).
|
||||
//
|
||||
// v3-Umbau P1 (28.08.2026): Hier wurden frueher zwei Geheimnisse aus dem localStorage
|
||||
// gelesen und an jeden mutierenden Request gehaengt — das Box-Sudo-Passwort (als Header
|
||||
// UND im JSON-Rumpf) und der HuggingFace-Token. Da der Dienst-Nutzer auf der Box mit
|
||||
// `NOPASSWD: ALL` laeuft, war ein XSS in dieser SPA gleichbedeutend mit Root.
|
||||
// Das Sudo-Passwort ist ersatzlos weg (auf der Box gemessen: `sudo -n true` laeuft durch,
|
||||
// es wurde nie gebraucht), der HF-Token liegt jetzt auf der Box (services/geheimnisse.py).
|
||||
// Diese Datei kennt kein Geheimnis mehr — und soll auch nie wieder eines kennen.
|
||||
// Dünner Zugriff auf das MC2-Backend. Bewusst ohne Anmelde- oder Geheimnis-Felder:
|
||||
// Nichts, was der Browser speichert, reist hier mit (Lehre aus v3-Umbau P1).
|
||||
|
||||
/** Fehler mit dem, was das Backend WIRKLICH gesagt hat.
|
||||
*
|
||||
* Vorher warf der Helfer `new Error("500 Internal Server Error")` und verwarf den Rumpf.
|
||||
* FastAPI legt den eigentlichen Grund aber in `detail` — der erreichte die Oberflaeche nie,
|
||||
* und der Nutzer las "500" statt "Dienst 'llama-swap' nicht erlaubt". */
|
||||
export class ApiError extends Error {
|
||||
readonly status: number
|
||||
readonly detail: string | null
|
||||
constructor(status: number, statusText: string, detail: string | null) {
|
||||
super(detail || `${status} ${statusText}`)
|
||||
this.name = "ApiError"
|
||||
this.status = status
|
||||
this.detail = detail
|
||||
export class ApiFehler extends Error {
|
||||
constructor(
|
||||
public status: number,
|
||||
public detail: string,
|
||||
) {
|
||||
super(detail)
|
||||
this.name = "ApiFehler"
|
||||
}
|
||||
}
|
||||
|
||||
/** Liest den Fehler-Rumpf, ohne selbst zu scheitern: Ein kaputter oder leerer Rumpf darf
|
||||
* die Fehlermeldung nicht in einen zweiten Fehler verwandeln. */
|
||||
async function fehlerText(res: Response): Promise<string | null> {
|
||||
/** FastAPI legt Fehlertexte in `detail` ab — manchmal als Text, manchmal als Liste. */
|
||||
function detailAus(body: unknown, status: number): string {
|
||||
if (body && typeof body === "object" && "detail" in body) {
|
||||
const d = (body as { detail: unknown }).detail
|
||||
if (typeof d === "string") return d
|
||||
if (Array.isArray(d)) return d.map((x) => (x as { msg?: string }).msg ?? String(x)).join("; ")
|
||||
}
|
||||
return `Die Box antwortet mit Fehler ${status}.`
|
||||
}
|
||||
|
||||
export async function api<T>(pfad: string, init: RequestInit = {}): Promise<T> {
|
||||
let antwort: Response
|
||||
try {
|
||||
const roh = await res.text()
|
||||
if (!roh) return null
|
||||
try {
|
||||
const d = JSON.parse(roh)
|
||||
const detail = d?.detail ?? d?.err ?? d?.message
|
||||
if (typeof detail === "string" && detail.trim()) return detail.trim()
|
||||
if (Array.isArray(detail) && detail.length) {
|
||||
// FastAPI-Validierungsfehler: Liste aus {loc, msg, type}
|
||||
return detail.map((e: any) => e?.msg).filter(Boolean).join("; ") || null
|
||||
}
|
||||
return null
|
||||
} catch {
|
||||
// Kein JSON (HTML-Fehlerseite eines Proxys o. AE.) — erste Zeile reicht als Hinweis.
|
||||
return roh.slice(0, 200).split("\n")[0].trim() || null
|
||||
}
|
||||
antwort = await fetch(pfad, {
|
||||
...init,
|
||||
headers: { "Content-Type": "application/json", Accept: "application/json", ...init.headers },
|
||||
})
|
||||
} catch {
|
||||
return null
|
||||
throw new ApiFehler(0, "MC2 ist nicht erreichbar. Läuft die Box?")
|
||||
}
|
||||
const text = await antwort.text()
|
||||
const body = text ? safeJson(text) : null
|
||||
if (!antwort.ok) throw new ApiFehler(antwort.status, detailAus(body, antwort.status))
|
||||
return body as T
|
||||
}
|
||||
|
||||
function safeJson(text: string): unknown {
|
||||
try {
|
||||
return JSON.parse(text)
|
||||
} catch {
|
||||
return text
|
||||
}
|
||||
}
|
||||
|
||||
export async function api<T = unknown>(path: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(path, {
|
||||
...init,
|
||||
headers: { "Content-Type": "application/json", ...init?.headers },
|
||||
})
|
||||
if (!res.ok) throw new ApiError(res.status, res.statusText, await fehlerText(res))
|
||||
return res.json() as Promise<T>
|
||||
}
|
||||
|
||||
// llama-swap-`groups`: Mitglieder mit swap=false dürfen GLEICHZEITIG resident sein (Ko-Residenz).
|
||||
// Die `brains`-Gruppe hält Hirn (fast) + Augen (vision) gemeinsam warm, statt sich zu verdrängen.
|
||||
export interface GroupSpec {
|
||||
swap: boolean
|
||||
persist: boolean
|
||||
members: string[]
|
||||
}
|
||||
export interface GroupsResp {
|
||||
groups: Record<string, GroupSpec>
|
||||
}
|
||||
export const getGroups = () => api<GroupsResp>("/api/groups")
|
||||
export const setGroup = (group: string, members: string[], swap = false, persist = true) =>
|
||||
api("/api/groups", { method: "PUT", body: JSON.stringify({ group, members, swap, persist }) })
|
||||
|
||||
export interface Capabilities {
|
||||
moe: boolean
|
||||
active_b: number | null
|
||||
tools: "yes" | "likely" | "no"
|
||||
vision: boolean
|
||||
coder: boolean
|
||||
reasoning: boolean
|
||||
embedding: boolean
|
||||
ctx: number | null
|
||||
params_b: number | null
|
||||
arch: string | null
|
||||
}
|
||||
|
||||
export interface Fit {
|
||||
level: "perfect" | "marginal" | "too_tight"
|
||||
text: string
|
||||
req_gb: number
|
||||
tps: number
|
||||
}
|
||||
|
||||
export interface RoleRecModel {
|
||||
name: string
|
||||
current_role: string | null
|
||||
params_b: number
|
||||
quant: string
|
||||
fit: Fit
|
||||
suitable: boolean
|
||||
incomplete: boolean
|
||||
score: number
|
||||
reason: string
|
||||
recommended: boolean
|
||||
}
|
||||
|
||||
export interface RoleRecResp {
|
||||
role: string
|
||||
recommended: string | null
|
||||
models: RoleRecModel[]
|
||||
}
|
||||
|
||||
export interface FitResp {
|
||||
params_b: number
|
||||
fit: Fit
|
||||
optimal_ctx: number
|
||||
assigned_ctx: number
|
||||
budget: { gtt_gb: number; reserved_gb: number; budget_gb: number; mode: string }
|
||||
sys_ram_gb: number
|
||||
}
|
||||
|
||||
export interface ModelInfo {
|
||||
name: string
|
||||
role: string | null
|
||||
aliases: string[]
|
||||
api_ids: string[]
|
||||
ctx: number | null
|
||||
ttl: number | null
|
||||
cmd: string
|
||||
gguf_path: string
|
||||
filename: string
|
||||
quant: string
|
||||
size_bytes: number | null
|
||||
incomplete: boolean
|
||||
prompt_cache: boolean
|
||||
spec_draft_model: string | null
|
||||
spec_type: string | null
|
||||
spec_active: boolean
|
||||
parallel_slots: number
|
||||
capabilities: Capabilities
|
||||
}
|
||||
|
||||
export interface VocabFingerprint {
|
||||
model: string | null
|
||||
pre: string | null
|
||||
n_vocab: number | null
|
||||
arch: string | null
|
||||
}
|
||||
|
||||
export interface DraftInfo {
|
||||
path: string
|
||||
filename: string
|
||||
size_bytes: number | null
|
||||
vocab: VocabFingerprint | null
|
||||
compatible: boolean | null // null = nicht prüfbar (Ziel-GGUF fehlt)
|
||||
mtp?: boolean // MTP-Kopf (Multi-Token-Prediction) statt klassischem Draft
|
||||
}
|
||||
|
||||
export interface DraftsResp {
|
||||
target_path: string
|
||||
target_exists: boolean
|
||||
target_vocab: VocabFingerprint | null
|
||||
drafts: DraftInfo[]
|
||||
}
|
||||
|
||||
export interface DiscoverModel {
|
||||
name: string
|
||||
author: string
|
||||
repo: string
|
||||
role: string
|
||||
params_b: number
|
||||
quant: string
|
||||
tags: string[]
|
||||
downloads: number
|
||||
fit: Fit
|
||||
optimal_ctx: number
|
||||
caps: Capabilities
|
||||
}
|
||||
|
||||
export interface DiscoverCategory {
|
||||
role: string
|
||||
title: string
|
||||
icon: string
|
||||
models: DiscoverModel[]
|
||||
recommended: string | null
|
||||
}
|
||||
|
||||
export interface DiscoverResp {
|
||||
updated: number
|
||||
categories: DiscoverCategory[]
|
||||
sys_ram_gb: number
|
||||
}
|
||||
|
||||
export interface RoutingResp {
|
||||
mode?: string
|
||||
endpoint?: string
|
||||
heavy_threshold_chars?: number
|
||||
routes: { name: string; target: string }[]
|
||||
lanes?: { name: string; target: string; threshold_chars?: number; escalate_chars?: number; aka?: string }[]
|
||||
fallbacks: Record<string, string[]>[]
|
||||
context_window_fallbacks: Record<string, string[]>[]
|
||||
gateway_reachable: boolean
|
||||
}
|
||||
|
||||
// UI-editierbare Routing-Policy (GET/PUT /api/routing/policy). Aliase + Zeichen-Schwellen
|
||||
// hinter den Lanes; hot-reload im Backend (kein Restart).
|
||||
export interface RoutingPolicy {
|
||||
fast: string
|
||||
heavy: string
|
||||
coder: string
|
||||
coder_lite: string
|
||||
heavy_chars: number
|
||||
coding_escalate_chars: number
|
||||
fast_no_think: boolean
|
||||
}
|
||||
export interface RoutingPolicyField {
|
||||
key: keyof RoutingPolicy
|
||||
label: string
|
||||
type: "str" | "int" | "bool"
|
||||
min?: number
|
||||
max?: number
|
||||
}
|
||||
export interface RoutingPolicyMeta {
|
||||
policy: RoutingPolicy
|
||||
defaults: RoutingPolicy
|
||||
fields: RoutingPolicyField[]
|
||||
}
|
||||
export const getRoutingPolicy = () => api<RoutingPolicyMeta>("/api/routing/policy")
|
||||
export const updateRoutingPolicy = (patch: Partial<RoutingPolicy>) =>
|
||||
api<{ policy: RoutingPolicy }>("/api/routing/policy", { method: "PUT", body: JSON.stringify(patch) })
|
||||
|
||||
export interface GitInfo {
|
||||
hash: string
|
||||
date: string
|
||||
subject: string
|
||||
branch: string
|
||||
dirty: boolean
|
||||
path: string
|
||||
}
|
||||
|
||||
// Engine kann git-, binary- oder unbekannte Version sein — Felder je nach `type`.
|
||||
export interface ComponentVersion {
|
||||
type: "git" | "binary" | "unknown"
|
||||
hash?: string
|
||||
date?: string
|
||||
subject?: string
|
||||
branch?: string
|
||||
dirty?: boolean
|
||||
path?: string
|
||||
version_text?: string
|
||||
}
|
||||
|
||||
export interface Versions {
|
||||
mc2: GitInfo | null
|
||||
engine: ComponentVersion
|
||||
hermes_agent: GitInfo | null
|
||||
}
|
||||
|
||||
export interface SystemStatus {
|
||||
cpu: { percent: number; cores: number | null }
|
||||
ram: { total: number; used: number; percent: number }
|
||||
gpu: {
|
||||
busy_percent: number | null
|
||||
vram_used: number | null
|
||||
vram_total: number | null
|
||||
gtt_used?: number | null
|
||||
gtt_total?: number | null
|
||||
} | null
|
||||
temp: { cpu?: number; gpu?: number } | null
|
||||
disk: { total: number; used: number; percent: number } | null
|
||||
/** Sekunden seit dem Systemstart (v3-Umbau P3). null, wenn die Box es nicht liefert —
|
||||
* die Statusleiste blendet das Feld dann aus, statt eine Zahl zu erfinden. */
|
||||
uptime_s?: number | null
|
||||
versions?: Versions
|
||||
}
|
||||
|
||||
export interface ServiceInfo {
|
||||
name: string
|
||||
unit: string // echter systemd-Name — Basis für Restart/Logs/Matching (kein Namens-Raten)
|
||||
url: string
|
||||
ok: boolean
|
||||
scope: "user" | "system"
|
||||
}
|
||||
export interface ServicesResp {
|
||||
services: ServiceInfo[]
|
||||
links: { engine_ui: string; gateway: string; hermes_ui: string }
|
||||
}
|
||||
|
||||
export interface ComponentUpdate {
|
||||
key: string // z.B. "hermes_agent"
|
||||
name: string
|
||||
current: string | null
|
||||
latest: string | null
|
||||
update: boolean | null // null = unbestimmbar (installierte Version remote nicht abfragbar)
|
||||
reachable: boolean | null
|
||||
}
|
||||
|
||||
export interface UpdatesResp {
|
||||
os: number
|
||||
engine: number
|
||||
swap: number
|
||||
models: number
|
||||
model_list: { role: string; title: string; repo: string }[]
|
||||
last_check?: number | null
|
||||
components?: ComponentUpdate[]
|
||||
}
|
||||
|
||||
export interface UpdateDetails {
|
||||
kind: "os" | "engine" | "swap" | "hermes"
|
||||
error?: string
|
||||
// LLM-Zusammenfassung in Lucys Stimme (hermes/engine/swap; Breaking Changes zuerst)
|
||||
summary?: string
|
||||
// Aktions-Verdikt: muss der Besitzer selbst etwas tun? (null = kein Verdikt/keine Summary)
|
||||
action_needed?: boolean | null
|
||||
action_text?: string
|
||||
// os
|
||||
count?: number
|
||||
packages?: { name: string; current: string; candidate: string }[]
|
||||
// os: vom System zurückgestellte Pakete (Phasen-Rollout / kept back) — ehrlich statt "hängt"
|
||||
held_back?: { name: string; reason: "phasing" | "kept_back" }[]
|
||||
// engine
|
||||
installed_build?: number | null
|
||||
latest_build?: number | null
|
||||
latest_tag?: string | null
|
||||
name?: string | null
|
||||
url?: string | null
|
||||
body?: string | null
|
||||
// hermes
|
||||
branch?: string | null
|
||||
behind?: number
|
||||
commits?: { hash: string; subject: string; when: string }[]
|
||||
}
|
||||
|
||||
export interface ConnectTool {
|
||||
label: string
|
||||
lang: string
|
||||
snippet: string
|
||||
note: string
|
||||
}
|
||||
export interface ConnectResp {
|
||||
host: string
|
||||
gateway_url: string
|
||||
mc_url: string
|
||||
tools: Record<string, ConnectTool> // Leitung 1 — Modell (IDEs/Agenten → Gateway)
|
||||
}
|
||||
|
||||
export interface ConnectLine {
|
||||
ok: boolean
|
||||
detail: string
|
||||
}
|
||||
export interface ConnectHealth {
|
||||
gateway: ConnectLine
|
||||
memory: ConnectLine // Leitung 2 — Hermes-natives Gedächtnis (nur Status, nichts zu konfigurieren)
|
||||
desktop_gateway: ConnectLine
|
||||
}
|
||||
|
||||
// Graph-Ansicht des Wissens-Vaults (GET /api/wissen/graph) — trägt aus historischen Gründen
|
||||
// noch „Memory" im Namen; die Daten kommen aus dem Vault, nicht aus einem Gedächtnis-Dienst.
|
||||
export interface MemoryGraphNode {
|
||||
id: string
|
||||
content: string
|
||||
category: string
|
||||
source: string
|
||||
}
|
||||
export interface MemoryGraph {
|
||||
nodes: MemoryGraphNode[]
|
||||
edges: { source: string; target: string; weight: number }[]
|
||||
}
|
||||
|
||||
export interface AgentStatus {
|
||||
gateway_url: string
|
||||
box_console_url?: string
|
||||
hermes_ui_url?: string
|
||||
gateway_reachable: boolean
|
||||
box_console_reachable?: boolean
|
||||
hermes_ui_reachable?: boolean
|
||||
home_exists: boolean
|
||||
brain_model?: string
|
||||
has_config: boolean
|
||||
has_skills: boolean
|
||||
has_memories: boolean
|
||||
telegram_enabled?: boolean
|
||||
mcp_server_count?: number
|
||||
pc_executor_reachable?: boolean
|
||||
pc_executor_url?: string
|
||||
}
|
||||
|
||||
export interface Job {
|
||||
id: string
|
||||
label: string
|
||||
group?: string | null // "maintenance" = system-veränderndes Update (Wartungs-Riegel)
|
||||
state: "queued" | "running" | "done" | "failed" | "canceled"
|
||||
progress?: number
|
||||
total_bytes?: number
|
||||
done_bytes?: number
|
||||
rate_bps?: number
|
||||
eta_s?: number
|
||||
}
|
||||
|
||||
// Werkzeug-Verlauf des Agenten (GET /api/agent/aktivitaet, v3-Umbau P6).
|
||||
// Gelesen aus Hermes' Log — Denkstrom und Werkzeug-Argumente stehen dort NICHT und
|
||||
// tauchen darum auch hier nicht auf.
|
||||
export interface WerkzeugRuf {
|
||||
zeit: string
|
||||
lauf: string | null
|
||||
werkzeug: string
|
||||
dauer_s: number
|
||||
zeichen: number | null
|
||||
ok: boolean
|
||||
fehler: string | null
|
||||
}
|
||||
export interface WerkzeugBilanz {
|
||||
werkzeug: string
|
||||
rufe: number
|
||||
fehler: number
|
||||
dauer_schnitt_s: number
|
||||
letzter_fehler: string | null
|
||||
}
|
||||
export interface AgentLauf {
|
||||
lauf: string
|
||||
von: string
|
||||
bis: string
|
||||
rufe: number
|
||||
fehler: number
|
||||
}
|
||||
export interface AktivitaetResp {
|
||||
verfuegbar: boolean
|
||||
pfad: string
|
||||
rufe: WerkzeugRuf[]
|
||||
werkzeuge: WerkzeugBilanz[]
|
||||
laeufe: AgentLauf[]
|
||||
}
|
||||
|
||||
export interface Health {
|
||||
status: string
|
||||
version: string
|
||||
engine_reachable: boolean
|
||||
gateway_reachable: boolean
|
||||
brain?: { role: string; model: string | null; ready: boolean }
|
||||
}
|
||||
|
||||
// 24-h-Metrik-Verlauf (GET /api/system/history?minutes=N) — Basis der Cockpit-Zeitachse.
|
||||
// tp/tc sind Token-GESAMTZÄHLER; Raten rechnet das Frontend aus den Deltas.
|
||||
export interface HistoryPoint {
|
||||
t: number // Epoch-Sekunden
|
||||
cpu: number | null
|
||||
ram: number | null
|
||||
gpu: number | null
|
||||
disk: number | null
|
||||
tp: number | null
|
||||
tc: number | null
|
||||
}
|
||||
export interface HistoryResp {
|
||||
sample_s: number
|
||||
points: HistoryPoint[]
|
||||
}
|
||||
|
||||
export interface TokenStats {
|
||||
prompt_tokens: number
|
||||
completion_tokens: number
|
||||
total_tokens: number
|
||||
saved_usd: number
|
||||
saved_eur: number
|
||||
pricing?: Record<string, { in: number; out: number }>
|
||||
}
|
||||
|
||||
// Per-Turn-Latenz-Trace (GET /api/voice/trace). Balken-Stufen (zeitlich disjunkt):
|
||||
// stt · vision · hirn · gen. Der Gedächtnis-Abruf läuft seit der Mem0-Ablösung Hermes-intern
|
||||
// (kein Rückruf an MC2) und steckt daher unsichtbar in hirn_ms.
|
||||
export interface VoiceTurn {
|
||||
id: string
|
||||
ts: number // Epoch-Sekunden
|
||||
session: string
|
||||
kind: string
|
||||
had_images: boolean
|
||||
stt_ms: number | null
|
||||
vision_ms: number | null
|
||||
hirn_ms: number | null // Zeit bis erstes Inhalts-Token (Agent + Gedächtnis + LLM-TTFT)
|
||||
gen_ms: number | null // Generierung nach dem ersten Token bis Stream-Ende
|
||||
total_ms: number
|
||||
error: string | null
|
||||
}
|
||||
|
||||
export interface VoiceTraceResp {
|
||||
turns: VoiceTurn[]
|
||||
}
|
||||
|
||||
export interface ModelsResp {
|
||||
models: ModelInfo[]
|
||||
running?: string[]
|
||||
}
|
||||
|
||||
// ── Ideen-Queue (natives Hermes-Kanban) ──────────────────────────────────────
|
||||
export interface IdeenItem {
|
||||
id: string
|
||||
titel: string
|
||||
body: string
|
||||
status: string // triage | todo | ready | running | blocked | review | done
|
||||
assignee: string | null
|
||||
welt?: string // "box" = Werkstatt baut · "ide" = für Zed vorbereitet
|
||||
art?: string // "box" | "idee" (nur durchdacht) | "projekt" (Repo vorbereitet)
|
||||
erstellt: number | null
|
||||
fertig: number | null
|
||||
von: string | null
|
||||
frage?: string | null // bei status=blocked: die Frage/der Grund des Workers
|
||||
frage_kind?: string | null // needs_input | capability | transient | ""
|
||||
eltern?: string[] // offene Vorgänger-Karten (task_links) — leer = startbereit
|
||||
wartet_auf?: string[] // Titel der offenen Vorgänger (max. 3, für die Anzeige)
|
||||
notiz?: string | null // bei running: jüngste Heartbeat-Notiz des Workers
|
||||
projekt?: string | null // Familien-Schlüssel (verkettete Karten) — null = Einzelkarte
|
||||
prio?: number // Dispatcher-Reihenfolge: priority DESC, created ASC
|
||||
puls_alter?: number | null // bei running: Sekunden seit letztem Lebenszeichen (Hänger-Warnung)
|
||||
gestartet?: number | null // bei running: started_at (Epoch)
|
||||
}
|
||||
// Karten-Familie (verkettete Karten = ein Projekt) für die gruppierte Queue-Sicht
|
||||
export interface IdeenProjekt {
|
||||
key: string
|
||||
titel: string
|
||||
gesamt: number
|
||||
fertig: number
|
||||
laeuft: number
|
||||
haengt: number
|
||||
}
|
||||
export interface IdeenLogResp {
|
||||
available: boolean
|
||||
lines: string[]
|
||||
}
|
||||
export interface IdeenResp {
|
||||
available: boolean
|
||||
items: IdeenItem[]
|
||||
offen: number
|
||||
fehler?: string
|
||||
projekte?: IdeenProjekt[]
|
||||
}
|
||||
|
||||
// Ergebnis einer fertigen Queue-Aufgabe (Abschluss-Zusammenfassung des Workers)
|
||||
export interface IdeenErgebnis {
|
||||
available: boolean
|
||||
summary: string
|
||||
result: string
|
||||
}
|
||||
|
||||
// Konzept einer IDE-Karte — aus dem Gitea-Repo oder aus ~/konzepte auf der Box
|
||||
export interface IdeenKonzept {
|
||||
available: boolean
|
||||
repo?: string | null
|
||||
clone_url?: string
|
||||
quelle?: "repo" | "datei"
|
||||
pfad?: string // nur quelle=datei: Pfad auf der Box
|
||||
datei?: string
|
||||
konzept?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
// ── Chronik (Timeline aus dem Melde-Briefkasten) ─────────────────────────────
|
||||
export interface ChronikItem {
|
||||
id: number
|
||||
ts: number
|
||||
subject: string
|
||||
text: string
|
||||
source: string
|
||||
priority: "normal" | "silent"
|
||||
}
|
||||
export interface ChronikResp {
|
||||
items: ChronikItem[]
|
||||
}
|
||||
|
||||
// ── Wissens-Vault (Traum-Notizen, read-only) ─────────────────────────────────
|
||||
export interface WissenFile {
|
||||
path: string
|
||||
name: string
|
||||
dir: string
|
||||
title: string
|
||||
mtime: number
|
||||
neu: boolean
|
||||
}
|
||||
export interface WissenResp {
|
||||
available: boolean
|
||||
files: WissenFile[]
|
||||
}
|
||||
export interface WissenDatei {
|
||||
path: string
|
||||
content: string
|
||||
mtime: number
|
||||
}
|
||||
|
||||
// ── Zeitmaschine (Snapshots + Restore) ───────────────────────────────────────
|
||||
export interface Snapshot {
|
||||
snapshot: string
|
||||
file: string
|
||||
size_mb: number
|
||||
}
|
||||
export interface ZeitmaschineResp {
|
||||
available: boolean
|
||||
backups: Snapshot[]
|
||||
}
|
||||
|
||||
// ── Erinnerungen (Wecker/Routinen) ───────────────────────────────────────────
|
||||
export interface Reminder {
|
||||
id: number
|
||||
text: string
|
||||
next_ts: number
|
||||
repeat: string // '' einmalig | daily | weekdays | weekly
|
||||
created_ts: number
|
||||
}
|
||||
|
||||
export interface HermesBrainModel {
|
||||
name: string
|
||||
filename?: string
|
||||
params_b: number | null
|
||||
quant?: string
|
||||
size_bytes?: number | null
|
||||
version?: number | null
|
||||
gguf_path?: string
|
||||
incomplete?: boolean
|
||||
}
|
||||
|
||||
export interface HermesBrainCandidate {
|
||||
repo: string
|
||||
name: string
|
||||
version: number
|
||||
params_b: number
|
||||
downloads: number
|
||||
fit: Fit
|
||||
}
|
||||
|
||||
export interface HermesBrainBudget {
|
||||
gtt_gb: number
|
||||
brain_gb: number // Footprint des (empfohlenen) Brains, das immer resident bleibt
|
||||
warm_projected_gb: number // alle persist-Modelle warm (Info)
|
||||
free_after_gb: number // frei nach Brain + größtem on-demand
|
||||
largest_ondemand_gb: number // größtes on-demand-Modell (z.B. heavy/coder)
|
||||
fits: boolean // passt Brain + größtes on-demand zusammen ins Budget?
|
||||
}
|
||||
|
||||
export interface HermesBrainResp {
|
||||
current: HermesBrainModel | null
|
||||
recommended: HermesBrainCandidate | null
|
||||
update_available: boolean
|
||||
budget?: HermesBrainBudget | null
|
||||
}
|
||||
|
||||
// ── Ideen-Log (Live Worker Log) ──────────────────────────────────────────────
|
||||
export const getIdeenLog = (id: string) => api<IdeenLogResp>(`/api/ideen/${id}/log`)
|
||||
export const post = <T>(pfad: string, daten?: unknown) =>
|
||||
api<T>(pfad, { method: "POST", body: daten === undefined ? undefined : JSON.stringify(daten) })
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
// Ereignisstrom — Gegenstück zu backend/routers/events.py.
|
||||
//
|
||||
// EIN EventSource auf /api/stream. Zwei Ereignisarten:
|
||||
// · `invalidate` stößt gezielt die betroffenen React-Query-Caches an
|
||||
// · `metrik` schreibt jede Sekunde direkt in den Metrik-Speicher (v3-Umbau P4)
|
||||
//
|
||||
// Solange der Strom steht, sind die Poller nur noch Sicherheitsnetz (queries.ts entspannt
|
||||
// sie ×5 über sseVerbunden()). Reißt er, reconnectet EventSource von selbst — bis dahin
|
||||
// pollt die UI wie früher, und die Statusleiste sagt es ehrlich.
|
||||
//
|
||||
// v3-Umbau P4: Die Messpunkte kommen jetzt GEPUSHT statt gepollt. Vorher fragten
|
||||
// `system-status` und `token-stats` im 3-Sekunden-Takt nach — zwei Dauer-Anfragen,
|
||||
// unabhängig davon, ob sich etwas geändert hatte. Beide Poller sind ersatzlos weg,
|
||||
// solange der Strom steht.
|
||||
//
|
||||
// WICHTIG (der Grund, warum der Metrik-Speicher kein Store ist): `metrik` feuert jede
|
||||
// Sekunde. Der Speicher liegt bewusst AUSSERHALB von React (lib/metricsStore.ts,
|
||||
// useSyncExternalStore) — so rendern nur die Karten neu, die den Verlauf wirklich
|
||||
// abonnieren, nicht die ganze Shell.
|
||||
|
||||
import { useEffect } from "react"
|
||||
import { useQueryClient } from "@tanstack/react-query"
|
||||
import { melden, stromEreignis, stromLageSetzen } from "@/app/store"
|
||||
import { pushMetrik } from "@/lib/metricsStore"
|
||||
|
||||
let verbunden = false
|
||||
export const sseVerbunden = () => verbunden
|
||||
|
||||
/** Rohform eines `metrik`-Ereignisses (siehe services/system.py → metrik_punkt). */
|
||||
export interface MetrikPunkt {
|
||||
cpu: number
|
||||
ram: number
|
||||
ram_used: number
|
||||
ram_total: number
|
||||
gpu: number | null
|
||||
disk: number | null
|
||||
temp_cpu: number | null
|
||||
temp_gpu: number | null
|
||||
uptime_s: number | null
|
||||
/** GESAMTZÄHLER, keine Rate — die rechnet der Speicher aus zwei Punkten selbst. */
|
||||
tok_p: number
|
||||
tok_c: number
|
||||
}
|
||||
|
||||
export function useEventStream() {
|
||||
const qc = useQueryClient()
|
||||
useEffect(() => {
|
||||
const es = new EventSource("/api/stream")
|
||||
// Der erste Verbindungsaufbau ist der Normalfall und wird NICHT gemeldet — sonst
|
||||
// begrüßt jede Seite den Nutzer mit „Verbindung wieder da". Gemeldet wird nur der
|
||||
// Übergang: einmal beim Reißen, einmal beim Heilen.
|
||||
let warSchonMalVerbunden = false
|
||||
|
||||
es.onopen = () => {
|
||||
verbunden = true
|
||||
stromLageSetzen("live")
|
||||
if (warSchonMalVerbunden) melden("erfolg", "Verbindung zur Zentrale ist wieder da.")
|
||||
warSchonMalVerbunden = true
|
||||
// Beim (Wieder-)Verbinden einmal alles nachziehen: Was während der Trennung
|
||||
// passiert ist, hat der Strom nicht gemeldet.
|
||||
qc.invalidateQueries()
|
||||
}
|
||||
|
||||
es.onerror = () => {
|
||||
if (!verbunden) return
|
||||
verbunden = false
|
||||
// „nachlauf" statt „getrennt": EventSource reconnectet selbst, und bis dahin
|
||||
// pollen die Queries weiter — die Zahlen sind also älter, aber nicht tot.
|
||||
stromLageSetzen("nachlauf")
|
||||
melden("warnung", "Verbindung zur Zentrale verloren — die Anzeigen laufen im Nachlauf.")
|
||||
// Sofort auf den normalen Takt zurück, statt bis zum nächsten Refetch zu warten.
|
||||
qc.invalidateQueries()
|
||||
}
|
||||
|
||||
es.addEventListener("invalidate", (e) => {
|
||||
try {
|
||||
const keys: string[] = JSON.parse((e as MessageEvent).data)?.keys ?? []
|
||||
for (const k of keys) qc.invalidateQueries({ queryKey: [k] })
|
||||
stromEreignis()
|
||||
} catch { /* kaputtes Event bricht den Strom nicht */ }
|
||||
})
|
||||
|
||||
es.addEventListener("metrik", (e) => {
|
||||
try {
|
||||
pushMetrik(JSON.parse((e as MessageEvent).data) as MetrikPunkt)
|
||||
} catch { /* ein kaputter Messpunkt darf den Strom nicht reißen */ }
|
||||
})
|
||||
|
||||
return () => {
|
||||
verbunden = false
|
||||
stromLageSetzen("getrennt")
|
||||
es.close()
|
||||
}
|
||||
}, [qc])
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
// Zentrale Formatierungs-Helfer (vorher in einzelnen Views dupliziert).
|
||||
|
||||
/** Bytes → GB als String mit einer Nachkommastelle (z.B. "14.1"). */
|
||||
export function gb(b: number): string {
|
||||
return (b / 1024 ** 3).toFixed(1)
|
||||
}
|
||||
|
||||
/** Bytes → "1.2 GB" / "512 MB"; leer bei 0/undefined. */
|
||||
export function fmtBytes(b?: number): string {
|
||||
if (!b) return ""
|
||||
if (b > 1024 ** 3) return `${(b / 1024 ** 3).toFixed(1)} GB`
|
||||
return `${(b / 1024 ** 2).toFixed(0)} MB`
|
||||
}
|
||||
|
||||
/** Bytes → "1.2 GB" / "512 MB"; "—" bei 0/undefined/null. */
|
||||
export function fmtSize(b?: number | null): string {
|
||||
if (!b) return "—"
|
||||
const g = b / 1024 ** 3
|
||||
return g >= 1 ? `${g.toFixed(1)} GB` : `${(b / 1024 ** 2).toFixed(0)} MB`
|
||||
}
|
||||
|
||||
/** Sekunden → "3 min" / "45 s"; leer bei 0/undefined. */
|
||||
export function fmtEta(s?: number): string {
|
||||
if (!s) return ""
|
||||
const m = Math.floor(s / 60)
|
||||
return m > 0 ? `${m} min` : `${s} s`
|
||||
}
|
||||
|
||||
/** Kontextlänge → "32k"; "—" bei null. */
|
||||
export function fmtCtx(c: number | null): string {
|
||||
return c ? `${Math.round(c / 1024)}k` : "—"
|
||||
}
|
||||
|
||||
/** Unix-Sekunden → "25.07., 21:04"; "—" bei 0/null/undefined. */
|
||||
export function fmtWhen(ts: number | null | undefined): string {
|
||||
if (!ts) return "—"
|
||||
return new Date(ts * 1000).toLocaleString("de-DE", { day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit" })
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest"
|
||||
import { hashUmleiten } from "./hashUmleitung"
|
||||
import { NAV, navFuerPfad } from "@/nav"
|
||||
|
||||
function adresseSetzen(url: string) {
|
||||
window.history.replaceState(null, "", url)
|
||||
}
|
||||
|
||||
// Diese Brücke ist die einzige Verbindung zu Lesezeichen aus der Hash-Zeit. Sie sieht
|
||||
// nach totem Code aus und wäre damit ein naheliegender Kandidat zum Wegräumen — die
|
||||
// Tests machen sichtbar, was dabei kaputtginge.
|
||||
describe("hashUmleiten()", () => {
|
||||
beforeEach(() => adresseSetzen("/"))
|
||||
|
||||
it("biegt jedes alte Hash-Lesezeichen auf seinen neuen Pfad", () => {
|
||||
for (const eintrag of NAV) {
|
||||
adresseSetzen("/#" + eintrag.id)
|
||||
hashUmleiten()
|
||||
expect(window.location.pathname, `#${eintrag.id}`).toBe(eintrag.pfad)
|
||||
}
|
||||
})
|
||||
|
||||
it("verträgt die Schreibweise mit Schrägstrich (#/models)", () => {
|
||||
adresseSetzen("/#/models")
|
||||
hashUmleiten()
|
||||
expect(window.location.pathname).toBe("/modelle")
|
||||
})
|
||||
|
||||
it("behält vorhandene Suchparameter", () => {
|
||||
adresseSetzen("/?system=logs#models")
|
||||
hashUmleiten()
|
||||
expect(window.location.pathname).toBe("/modelle")
|
||||
expect(window.location.search).toBe("?system=logs")
|
||||
})
|
||||
|
||||
it("fasst einen unbekannten Hash nicht an", () => {
|
||||
adresseSetzen("/cockpit#irgendwas")
|
||||
hashUmleiten()
|
||||
expect(window.location.pathname).toBe("/cockpit")
|
||||
})
|
||||
|
||||
it("tut ohne Hash gar nichts", () => {
|
||||
adresseSetzen("/modelle?reiter=routing")
|
||||
hashUmleiten()
|
||||
expect(window.location.pathname + window.location.search).toBe("/modelle?reiter=routing")
|
||||
})
|
||||
})
|
||||
|
||||
describe("navFuerPfad()", () => {
|
||||
it("findet den Eintrag zum exakten Pfad", () => {
|
||||
expect(navFuerPfad("/modelle")?.id).toBe("models")
|
||||
expect(navFuerPfad("/cockpit")?.id).toBe("dashboard")
|
||||
})
|
||||
|
||||
// Damit /ideen/<id> die Sidebar weiterhin bei „Ideen" markiert und nicht ins Leere fällt.
|
||||
it("ordnet Unterpfade dem übergeordneten Eintrag zu", () => {
|
||||
expect(navFuerPfad("/ideen/id_9f3c")?.id).toBe("ideen")
|
||||
})
|
||||
|
||||
it("liefert nichts für einen unbekannten Pfad", () => {
|
||||
expect(navFuerPfad("/gibtsnicht")).toBeUndefined()
|
||||
})
|
||||
|
||||
// Der Grund für die Längen-Sortierung: ein kurzer Pfad darf einen längeren nicht
|
||||
// überstimmen, sonst gewinnt der erstbeste Präfix-Treffer.
|
||||
it("nimmt bei mehreren Treffern den längsten Pfad", () => {
|
||||
const treffer = navFuerPfad("/modelle")
|
||||
expect(treffer?.pfad).toBe("/modelle")
|
||||
})
|
||||
})
|
||||
@@ -1,26 +0,0 @@
|
||||
// Alte Hash-Lesezeichen auf die neuen Pfade umbiegen (v3-Umbau P2).
|
||||
//
|
||||
// Bis zum 28.08.2026 lebte die Ansicht im URL-Hash (`#models`). Das war teilbar und
|
||||
// reload-fest — aber nur auf View-Ebene; für den Zustand DARUNTER (offene Idee, Reiter,
|
||||
// Drawer) war kein Platz. Mit dem Router zieht das in echte Pfade um.
|
||||
//
|
||||
// Wer ein altes Lesezeichen hat, soll nichts merken: Diese Funktion läuft EINMAL vor dem
|
||||
// Router-Mount und ersetzt `#models` durch `/modelle` — per `replaceState`, damit die
|
||||
// Zurück-Taste nicht in einer Umleitungs-Schleife landet.
|
||||
//
|
||||
// Die Tabelle darf mit der Zeit schrumpfen, aber nicht kommentarlos: Solange jemand ein
|
||||
// Lesezeichen von früher haben könnte, ist sie die einzige Brücke dorthin.
|
||||
|
||||
import { NAV, type ViewId } from "@/nav"
|
||||
|
||||
const ALT_ZU_PFAD: Record<ViewId, string> = Object.fromEntries(
|
||||
NAV.map((n) => [n.id, n.pfad]),
|
||||
) as Record<ViewId, string>
|
||||
|
||||
export function hashUmleiten(): void {
|
||||
const hash = window.location.hash.replace(/^#\/?/, "")
|
||||
if (!hash) return
|
||||
const ziel = ALT_ZU_PFAD[hash as ViewId]
|
||||
if (!ziel) return
|
||||
window.history.replaceState(null, "", ziel + window.location.search)
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
// Leichtgewichtiger Markdown-Renderer (Zeilen-basiert, XSS-frei da reines JSX,
|
||||
// bewusst ohne externe Lib). Überschriften, Listen, fett/kursiv, `code`, Codeblöcke.
|
||||
// Verwandt mit dem wiki-link-Renderer in WissenView, hier aber ohne [[links]] —
|
||||
// gedacht für Doku-Anzeige (z. B. das KONZEPT.md eines vorbereiteten IDE-Projekts).
|
||||
export function Markdown({ text, className }: { text: string; className?: string }) {
|
||||
const lines = (text || "").split("\n")
|
||||
const out: React.ReactNode[] = []
|
||||
let list: React.ReactNode[] = []
|
||||
let code: string[] | null = null
|
||||
|
||||
const flushList = (key: string) => {
|
||||
if (list.length) {
|
||||
out.push(<ul key={key} className="mb-3 ml-4 list-disc space-y-1">{list}</ul>)
|
||||
list = []
|
||||
}
|
||||
}
|
||||
|
||||
lines.forEach((raw, i) => {
|
||||
const key = `l${i}`
|
||||
if (code !== null) {
|
||||
if (raw.trimEnd() === "```") {
|
||||
out.push(<pre key={key} className="mb-3 overflow-x-auto rounded-xl border border-border/50 bg-background/50 p-3 font-mono text-[11px] leading-relaxed text-foreground/85">{code.join("\n")}</pre>)
|
||||
code = null
|
||||
} else code.push(raw)
|
||||
return
|
||||
}
|
||||
if (raw.trimStart().startsWith("```")) { flushList(key); code = []; return }
|
||||
|
||||
const line = raw.trimEnd()
|
||||
if (!line.trim()) { flushList(key); return }
|
||||
|
||||
const h = line.match(/^(#{1,4})\s+(.*)$/)
|
||||
if (h) {
|
||||
flushList(key)
|
||||
const level = h[1].length
|
||||
const cls = level === 1 ? "text-base font-bold mt-1 mb-2" : level === 2 ? "text-sm font-bold mt-4 mb-1.5" : "text-xs font-bold mt-3 mb-1 uppercase tracking-wide text-muted-foreground"
|
||||
out.push(<p key={key} className={cn(cls, "font-space text-foreground")}>{inline(h[2], key)}</p>)
|
||||
return
|
||||
}
|
||||
const li = line.match(/^\s*[-*•]\s+(.*)$/)
|
||||
if (li) {
|
||||
list.push(<li key={key} className="text-xs leading-relaxed text-foreground/85">{inline(li[1], key)}</li>)
|
||||
return
|
||||
}
|
||||
const ol = line.match(/^\s*\d+\.\s+(.*)$/)
|
||||
if (ol) {
|
||||
list.push(<li key={key} className="text-xs leading-relaxed text-foreground/85">{inline(ol[1], key)}</li>)
|
||||
return
|
||||
}
|
||||
flushList(key)
|
||||
out.push(<p key={key} className="mb-2 text-xs leading-relaxed text-foreground/85">{inline(line, key)}</p>)
|
||||
})
|
||||
flushList("end")
|
||||
if (code !== null) out.push(<pre key="code-end" className="mb-3 overflow-x-auto rounded-xl border border-border/50 bg-background/50 p-3 font-mono text-[11px]">{(code as string[]).join("\n")}</pre>)
|
||||
|
||||
return <div className={cn("max-w-3xl", className)}>{out}</div>
|
||||
}
|
||||
|
||||
// Inline: **fett**, *kursiv*, `code` — per Regex-Split, reines JSX (kein HTML-Inject).
|
||||
function inline(text: string, keyBase: string): React.ReactNode[] {
|
||||
const parts = text.split(/(\*\*[^*]+\*\*|\*[^*]+\*|`[^`]+`)/g)
|
||||
return parts.map((p, i) => {
|
||||
const key = `${keyBase}-${i}`
|
||||
if (/^\*\*[^*]+\*\*$/.test(p)) return <b key={key} className="font-semibold text-foreground">{p.slice(2, -2)}</b>
|
||||
if (/^\*[^*]+\*$/.test(p)) return <i key={key}>{p.slice(1, -1)}</i>
|
||||
if (/^`[^`]+`$/.test(p)) return <code key={key} className="rounded bg-background/50 px-1 font-mono text-[11px] text-foreground/90">{p.slice(1, -1)}</code>
|
||||
return <span key={key}>{p}</span>
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
// Kurze Rückmeldungen nach einer Aktion („Dienst neu gestartet“). Außerhalb von React
|
||||
// gehalten, damit jede Stelle melden kann, ohne einen Kontext durchzureichen.
|
||||
|
||||
import { useSyncExternalStore } from "react"
|
||||
|
||||
export type MeldungsArt = "erfolg" | "fehler" | "info"
|
||||
export interface Meldung {
|
||||
id: number
|
||||
art: MeldungsArt
|
||||
text: string
|
||||
}
|
||||
|
||||
let meldungen: Meldung[] = []
|
||||
let naechste = 1
|
||||
const hoerer = new Set<() => void>()
|
||||
const melde = () => hoerer.forEach((h) => h())
|
||||
|
||||
export function melden(art: MeldungsArt, text: string) {
|
||||
const id = naechste++
|
||||
meldungen = [...meldungen, { id, art, text }].slice(-4)
|
||||
melde()
|
||||
// Fehler bleiben, bis man sie wegklickt — die will man lesen können.
|
||||
if (art !== "fehler") window.setTimeout(() => entfernen(id), 6000)
|
||||
}
|
||||
|
||||
export function entfernen(id: number) {
|
||||
meldungen = meldungen.filter((m) => m.id !== id)
|
||||
melde()
|
||||
}
|
||||
|
||||
export const useMeldungen = () =>
|
||||
useSyncExternalStore(
|
||||
(h) => {
|
||||
hoerer.add(h)
|
||||
return () => {
|
||||
hoerer.delete(h)
|
||||
}
|
||||
},
|
||||
() => meldungen,
|
||||
)
|
||||
@@ -1,120 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||
import { act, renderHook } from "@testing-library/react"
|
||||
import type { MetrikPunkt } from "./events"
|
||||
|
||||
// Der Verlaufsspeicher ist modul-global (er soll Tab-Wechsel überleben). Für jeden Test
|
||||
// eine frische Modul-Instanz, sonst tragen sich die Fälle gegenseitig Daten ein.
|
||||
async function frischerStore() {
|
||||
vi.resetModules()
|
||||
return await import("./metricsStore")
|
||||
}
|
||||
|
||||
function punkt(p: Partial<MetrikPunkt> = {}): MetrikPunkt {
|
||||
return {
|
||||
cpu: 10, ram: 20, ram_used: 1, ram_total: 2, gpu: null, disk: null,
|
||||
temp_cpu: null, temp_gpu: null, uptime_s: 100, tok_p: 0, tok_c: 0, ...p,
|
||||
}
|
||||
}
|
||||
|
||||
describe("metricsStore", () => {
|
||||
let store: Awaited<ReturnType<typeof frischerStore>>
|
||||
|
||||
beforeEach(async () => {
|
||||
store = await frischerStore()
|
||||
})
|
||||
|
||||
it("beginnt leer", () => {
|
||||
const { result } = renderHook(() => store.useSysHistory())
|
||||
expect(result.current).toEqual([])
|
||||
})
|
||||
|
||||
it("meldet neue Messpunkte an die Abonnenten", () => {
|
||||
const { result } = renderHook(() => store.useSysHistory())
|
||||
act(() => store.pushSys({ t: 1, cpu: 10, ram: 20, gpu: 30, disk: 40 }))
|
||||
expect(result.current).toHaveLength(1)
|
||||
expect(result.current[0].cpu).toBe(10)
|
||||
})
|
||||
|
||||
// Ohne Deckel wächst der Verlauf unbegrenzt — bei 1-s-Takt sind das ~86 400 Punkte
|
||||
// pro Tag in einem Dauerläufer-Tab.
|
||||
it("deckelt den Verlauf und behält die JÜNGSTEN Punkte", () => {
|
||||
const { result } = renderHook(() => store.useSysHistory())
|
||||
const n = store.MAX_POINTS + 20
|
||||
act(() => {
|
||||
for (let i = 0; i < n; i++) store.pushSys({ t: i, cpu: i, ram: 0, gpu: null, disk: null })
|
||||
})
|
||||
expect(result.current).toHaveLength(store.MAX_POINTS)
|
||||
expect(result.current[0].t).toBe(n - store.MAX_POINTS)
|
||||
expect(result.current[store.MAX_POINTS - 1].t).toBe(n - 1)
|
||||
})
|
||||
|
||||
it("hält System- und Token-Verlauf auseinander", () => {
|
||||
const { result: sys } = renderHook(() => store.useSysHistory())
|
||||
const { result: tok } = renderHook(() => store.useTokHistory())
|
||||
act(() => store.pushTok({ t: 1, prompt: 5, completion: 7 }))
|
||||
expect(sys.current).toHaveLength(0)
|
||||
expect(tok.current).toHaveLength(1)
|
||||
expect(tok.current[0].completion).toBe(7)
|
||||
})
|
||||
|
||||
// useSyncExternalStore vergleicht per Referenz: würde pushSys das Array an Ort und
|
||||
// Stelle verändern, bliebe die Anzeige stehen, obwohl Daten da sind.
|
||||
it("liefert bei jeder Änderung ein NEUES Array (Referenzwechsel)", () => {
|
||||
const { result } = renderHook(() => store.useSysHistory())
|
||||
const vorher = result.current
|
||||
act(() => store.pushSys({ t: 1, cpu: 1, ram: 1, gpu: null, disk: null }))
|
||||
expect(result.current).not.toBe(vorher)
|
||||
})
|
||||
})
|
||||
|
||||
// ── Der Einstieg des Ereignisstroms (v3-Umbau P4) ───────────────────────────
|
||||
// Der Server schickt Token-GESAMTZÄHLER, keine Raten — die Umrechnung passiert hier.
|
||||
// Genau dort steckt die Fehleranfälligkeit: ein Backend-Neustart setzt die Zähler
|
||||
// zurück, und aus einem Rückwärtssprung darf keine negative Rate werden.
|
||||
describe("pushMetrik (Strom-Einstieg)", () => {
|
||||
let store: Awaited<ReturnType<typeof frischerStore>>
|
||||
|
||||
beforeEach(async () => {
|
||||
store = await frischerStore()
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
it("schreibt beim ersten Punkt NOCH KEINE Rate — dafür fehlt der Vorgänger", () => {
|
||||
const { result: sys } = renderHook(() => store.useSysHistory())
|
||||
const { result: tok } = renderHook(() => store.useTokHistory())
|
||||
act(() => store.pushMetrik(punkt({ tok_p: 100, tok_c: 50 })))
|
||||
expect(sys.current).toHaveLength(1)
|
||||
expect(tok.current).toHaveLength(0)
|
||||
})
|
||||
|
||||
it("rechnet aus zwei Zählerständen eine Rate pro Sekunde", () => {
|
||||
const { result } = renderHook(() => store.useTokHistory())
|
||||
act(() => store.pushMetrik(punkt({ tok_p: 100, tok_c: 50 })))
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(2_000)
|
||||
store.pushMetrik(punkt({ tok_p: 300, tok_c: 90 }))
|
||||
})
|
||||
expect(result.current).toHaveLength(1)
|
||||
expect(result.current[0].prompt).toBeCloseTo(100, 1) // 200 Token in 2 s
|
||||
expect(result.current[0].completion).toBeCloseTo(20, 1) // 40 Token in 2 s
|
||||
})
|
||||
|
||||
it("macht aus einem Zähler-Rücksprung keine negative Rate", () => {
|
||||
const { result } = renderHook(() => store.useTokHistory())
|
||||
act(() => store.pushMetrik(punkt({ tok_p: 5_000, tok_c: 5_000 })))
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1_000)
|
||||
store.pushMetrik(punkt({ tok_p: 0, tok_c: 0 })) // Backend neu gestartet
|
||||
})
|
||||
expect(result.current[0].prompt).toBe(0)
|
||||
expect(result.current[0].completion).toBe(0)
|
||||
})
|
||||
|
||||
it("hält den letzten Messpunkt für die Statusleiste bereit", () => {
|
||||
const { result } = renderHook(() => store.useLetzteMetrik())
|
||||
expect(result.current).toBeNull()
|
||||
act(() => store.pushMetrik(punkt({ uptime_s: 4_242, temp_cpu: 71 })))
|
||||
expect(result.current?.uptime_s).toBe(4_242)
|
||||
expect(result.current?.temp_cpu).toBe(71)
|
||||
})
|
||||
})
|
||||
@@ -1,114 +0,0 @@
|
||||
import { useEffect, useRef, useSyncExternalStore } from "react"
|
||||
import { useSystemStatus, useTokenStats } from "@/lib/queries"
|
||||
import type { MetrikPunkt } from "@/lib/events"
|
||||
|
||||
export type SysSample = { t: number; cpu: number; ram: number; gpu: number | null; disk: number | null }
|
||||
export type RateSample = { t: number; prompt: number; completion: number }
|
||||
|
||||
// v3-Umbau P4: Der Strom liefert jede Sekunde einen Punkt statt alle drei. Bei gleicher
|
||||
// Fenstergröße (~2 Min Live-Verlauf) heißt das dreimal so viele Punkte.
|
||||
// Exportiert, damit der Test gegen DIESEN Wert prüft und nicht gegen eine Kopie, die
|
||||
// beim nächsten Takt-Wechsel still danebenliegt.
|
||||
export const MAX_POINTS = 120
|
||||
|
||||
// Modul-globaler Verlauf: überlebt Mount/Unmount (Tab-Wechsel) → Graphen starten nie leer.
|
||||
//
|
||||
// Bewusst KEIN Zustand-Store (v3-Umbau P3/P4): Hier landet jede Sekunde ein Wert. Ein
|
||||
// Store-Update pro Sekunde würde jede abonnierende Komponente neu rendern; mit einem
|
||||
// eigenen `useSyncExternalStore` außerhalb von React rendern nur die Karten neu, die den
|
||||
// Verlauf wirklich lesen. Das ist der Grund, warum diese Datei nicht in app/store.ts
|
||||
// aufgegangen ist.
|
||||
let sysHist: SysSample[] = []
|
||||
let tokHist: RateSample[] = []
|
||||
const listeners = new Set<() => void>()
|
||||
const emit = () => listeners.forEach((l) => l())
|
||||
|
||||
function subscribe(l: () => void) {
|
||||
listeners.add(l)
|
||||
return () => { listeners.delete(l) }
|
||||
}
|
||||
|
||||
export function pushSys(s: SysSample) { sysHist = [...sysHist, s].slice(-MAX_POINTS); emit() }
|
||||
export function pushTok(s: RateSample) { tokHist = [...tokHist, s].slice(-MAX_POINTS); emit() }
|
||||
|
||||
// Der ZULETZT empfangene Messpunkt. Warum eigens gehalten: Seit P4 ist der Poller von
|
||||
// /api/system/status abgeschaltet, solange der Strom steht — die Query liefert dann nur
|
||||
// noch den Stand vom Seitenaufbau. Ohne diesen Wert froeren Betriebszeit, Temperatur und
|
||||
// die aktuellen Prozentzahlen bei der ersten gemessenen Sekunde ein.
|
||||
let letzteMetrik: MetrikPunkt | null = null
|
||||
|
||||
export const useSysHistory = () => useSyncExternalStore(subscribe, () => sysHist)
|
||||
export const useTokHistory = () => useSyncExternalStore(subscribe, () => tokHist)
|
||||
export const useLetzteMetrik = () => useSyncExternalStore(subscribe, () => letzteMetrik)
|
||||
|
||||
// Letzter Token-Gesamtstand, um daraus die Rate zu bilden. Der Server schickt bewusst
|
||||
// Zähler statt Raten — so ist er zustandslos und ein verpasster Punkt verfälscht nichts.
|
||||
let letzteToken: { p: number; c: number; t: number } | null = null
|
||||
|
||||
/** Einstieg für den Ereignisstrom (lib/events.ts). Ein Messpunkt → beide Verläufe. */
|
||||
export function pushMetrik(m: MetrikPunkt) {
|
||||
const jetzt = Date.now()
|
||||
letzteMetrik = m
|
||||
pushSys({ t: jetzt, cpu: m.cpu ?? 0, ram: m.ram ?? 0, gpu: m.gpu, disk: m.disk })
|
||||
|
||||
if (letzteToken) {
|
||||
const dt = Math.max((jetzt - letzteToken.t) / 1000, 0.001)
|
||||
// Nie negativ: Startet das Backend neu, springen die Zähler zurück — daraus eine
|
||||
// negative Rate zu zeichnen wäre schlicht falsch.
|
||||
pushTok({
|
||||
t: jetzt,
|
||||
prompt: Math.max(0, (m.tok_p - letzteToken.p) / dt),
|
||||
completion: Math.max(0, (m.tok_c - letzteToken.c) / dt),
|
||||
})
|
||||
}
|
||||
letzteToken = { p: m.tok_p, c: m.tok_c, t: jetzt }
|
||||
}
|
||||
|
||||
/** Sammler für den Rückfall-Betrieb (in der Shell eingehängt, also stets gemountet).
|
||||
*
|
||||
* Seit P4 kommen die Messpunkte über den Strom. Dieser Sammler füttert den Verlauf nur
|
||||
* noch aus den POLLERN — also genau dann, wenn der Strom gerissen ist und die Queries
|
||||
* wieder im Normaltakt nachfragen. Ohne ihn stünden die Graphen bei einem Ausfall still,
|
||||
* obwohl die Daten da wären.
|
||||
*
|
||||
* Doppelte Punkte kann es nicht geben: Steht der Strom, sind die Poller entspannt und
|
||||
* liefern kaum neue Zeitstempel; der Filter unten wirft alles weg, was jünger als eine
|
||||
* Sekunde nach dem letzten Punkt ist.
|
||||
*/
|
||||
export function useMetricsFeeder() {
|
||||
const { data: sys, dataUpdatedAt: sysAt } = useSystemStatus()
|
||||
const { data: ts, dataUpdatedAt: tsAt } = useTokenStats()
|
||||
const prevTok = useRef<{ p: number; c: number; t: number } | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!sys) return
|
||||
const jetzt = Date.now()
|
||||
if (sysHist.length && jetzt - sysHist[sysHist.length - 1].t < 1_000) return
|
||||
pushSys({
|
||||
t: jetzt,
|
||||
cpu: sys.cpu?.percent ?? 0,
|
||||
ram: sys.ram?.percent ?? 0,
|
||||
gpu: sys.gpu?.busy_percent ?? null,
|
||||
disk: sys.disk?.percent ?? null,
|
||||
})
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [sysAt])
|
||||
|
||||
useEffect(() => {
|
||||
if (!ts) return
|
||||
const now = Date.now()
|
||||
const p = ts.prompt_tokens, c = ts.completion_tokens
|
||||
if (prevTok.current) {
|
||||
const dt = Math.max((now - prevTok.current.t) / 1000, 0.001)
|
||||
if (!tokHist.length || now - tokHist[tokHist.length - 1].t >= 1_000) {
|
||||
pushTok({
|
||||
t: now,
|
||||
prompt: Math.max(0, (p - prevTok.current.p) / dt),
|
||||
completion: Math.max(0, (c - prevTok.current.c) / dt),
|
||||
})
|
||||
}
|
||||
}
|
||||
prevTok.current = { p, c, t: now }
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [tsAt])
|
||||
}
|
||||
@@ -1,262 +0,0 @@
|
||||
// Zentrale Daten-Hooks (TanStack Query). Kapseln lib/api.ts und liefern Caching,
|
||||
// Dedup (gleicher queryKey = eine Anfrage über alle Views), Retry und Polling.
|
||||
// Mutations invalidieren gezielt die betroffenen Keys, statt manuell neu zu laden.
|
||||
|
||||
import { useQuery, useQueryClient, type QueryClient } from "@tanstack/react-query"
|
||||
import { sseVerbunden } from "./events"
|
||||
import {
|
||||
api,
|
||||
type AgentStatus,
|
||||
type AktivitaetResp,
|
||||
type ChronikResp,
|
||||
type ConnectResp,
|
||||
type ConnectHealth,
|
||||
type DiscoverResp,
|
||||
type DraftsResp,
|
||||
type GroupsResp,
|
||||
type HermesBrainResp,
|
||||
type Health,
|
||||
type HistoryResp,
|
||||
type IdeenLogResp,
|
||||
type IdeenResp,
|
||||
type Job,
|
||||
type MemoryGraph,
|
||||
type ModelsResp,
|
||||
type Reminder,
|
||||
type RoutingResp,
|
||||
type RoutingPolicyMeta,
|
||||
type ServicesResp,
|
||||
type SystemStatus,
|
||||
type TokenStats,
|
||||
type UpdatesResp,
|
||||
type VoiceTraceResp,
|
||||
type WissenResp,
|
||||
type ZeitmaschineResp,
|
||||
} from "./api"
|
||||
|
||||
// Zentrale Query-Keys (eine Quelle der Wahrheit für invalidate).
|
||||
export const qk = {
|
||||
health: ["health"] as const,
|
||||
systemStatus: ["system-status"] as const,
|
||||
services: ["services"] as const,
|
||||
models: ["models"] as const,
|
||||
groups: ["groups"] as const,
|
||||
routing: ["routing"] as const,
|
||||
routingPolicy: ["routing-policy"] as const,
|
||||
jobs: ["jobs"] as const,
|
||||
tokenStats: ["token-stats"] as const,
|
||||
agentStatus: ["agent-status"] as const,
|
||||
agentAktivitaet: ["agent-aktivitaet"] as const,
|
||||
hermesBrain: ["hermes-brain"] as const,
|
||||
updates: ["updates"] as const,
|
||||
discover: ["discover"] as const,
|
||||
drafts: (target?: string) => ["drafts", target ?? ""] as const,
|
||||
connect: (params?: string) => ["connect", params ?? ""] as const,
|
||||
connectHealth: ["connect-health"] as const,
|
||||
memoryGraph: ["memory-graph"] as const,
|
||||
voiceTrace: ["voice-trace"] as const,
|
||||
ideen: ["ideen"] as const,
|
||||
chronik: ["chronik"] as const,
|
||||
wissen: ["wissen"] as const,
|
||||
zeitmaschine: ["zeitmaschine"] as const,
|
||||
reminders: ["reminders"] as const,
|
||||
}
|
||||
|
||||
// ── Polling-Takte ────────────────────────────────────────────────────────────
|
||||
// EINE Stelle für alle Intervalle. Regel: Die Live-Graphen (System/Token) takten
|
||||
// schnell, alles andere gemächlich — Mutationen invalidieren ohnehin gezielt,
|
||||
// Polling ist nur das Sicherheitsnetz. Komponenten übergeben KEINE eigenen
|
||||
// Intervalle mehr (mehrere Beobachter = kleinstes Intervall gewinnt).
|
||||
const TAKT = {
|
||||
graph: 3_000, // System + Token: seit P4 nur noch Rueckfall/Randdaten (siehe unten)
|
||||
schnell: 8_000, // „fühlt sich live an": Modelle, Voice-Trace
|
||||
normal: 10_000, // Dienste, Routing, Agent-Status, Health
|
||||
gemuetlich: 30_000, // Chronik, Ideen
|
||||
traege: 60_000, // Updates-Check, Reminders, Zeitmaschine, HF-Abfragen
|
||||
} as const
|
||||
|
||||
// UMBAU v3 P3a: Läuft der SSE-Eventstrom (/api/events, siehe lib/events.ts), sind die
|
||||
// Poller der ereignishaften Quellen nur noch SICHERHEITSNETZ — der Strom invalidiert
|
||||
// bei Änderung sofort. Verbunden = Takt ×5; Strom weg = alter Takt (EventSource
|
||||
// reconnectet selbst). Live-Graphen/Status bleiben bewusst beim Polling.
|
||||
const relax = (ms: number) => () => (sseVerbunden() ? ms * 5 : ms)
|
||||
|
||||
// UMBAU v3 P4: Die MESSWERTE (CPU, RAM, GPU, Temperatur, Token-Zähler, Betriebszeit)
|
||||
// kommen jetzt gepusht — Ereignis `metrik`, jede Sekunde. Der Poller dieser beiden
|
||||
// Endpunkte war die halbe Grundlast der Startseite (Befund B-12).
|
||||
//
|
||||
// Er wird trotzdem NICHT abgeschaltet, sondern gedrosselt. Grund: Beide Antworten tragen
|
||||
// mehr als die Messwerte — `/api/system/status` liefert die Versions-Hashes (Sidebar-Fuß),
|
||||
// `/api/system/token-stats` die Gesamtsumme und die Cloud-Ersparnis, für die das Backend
|
||||
// die Tarife auflöst (Preis-Logik ist dort die einzige Wahrheit, sie hier nachzubauen wäre
|
||||
// eine zweite). Beides ändert sich langsam — einmal pro Minute reicht dafür völlig.
|
||||
//
|
||||
// 3 s → 60 s ist eine Zwanzigstel-Last; abschalten hätte diese Felder einfrieren lassen.
|
||||
const langsamWennStrom = (ms: number) => () => (sseVerbunden() ? 60_000 : ms)
|
||||
|
||||
// Ideen-Queue (natives Hermes-Kanban): das Backend cached ~15 s, öfter pollen lohnt nicht.
|
||||
export const useIdeen = (refetchInterval: number = TAKT.gemuetlich) =>
|
||||
useQuery({
|
||||
queryKey: qk.ideen,
|
||||
queryFn: () => api<IdeenResp>("/api/ideen"),
|
||||
refetchInterval: relax(refetchInterval),
|
||||
})
|
||||
|
||||
// Ideen-Log (Live Worker Log): pollt nur solange Idee triage/running UND offen
|
||||
export const useIdeenLog = (id: string, enabled: boolean = false) =>
|
||||
useQuery({
|
||||
queryKey: ["ideen-log", id],
|
||||
queryFn: () => api<IdeenLogResp>(`/api/ideen/${id}/log`),
|
||||
enabled,
|
||||
refetchInterval: 4000, // alle ~4s
|
||||
staleTime: 0,
|
||||
})
|
||||
|
||||
// Limit gehört in den Key: zwei Aufrufer mit verschiedenen Limits würden sich sonst
|
||||
// denselben Cache-Eintrag gegenseitig überschreiben. SSE invalidiert das Präfix ["chronik"].
|
||||
export const useChronik = (limit = 150, refetchInterval: number = TAKT.gemuetlich) =>
|
||||
useQuery({
|
||||
queryKey: [...qk.chronik, limit],
|
||||
queryFn: () => api<ChronikResp>(`/api/chronik?limit=${limit}`),
|
||||
refetchInterval: relax(refetchInterval),
|
||||
select: (d) => d.items ?? [],
|
||||
})
|
||||
|
||||
export const useWissen = () =>
|
||||
useQuery({ queryKey: qk.wissen, queryFn: () => api<WissenResp>("/api/wissen") })
|
||||
|
||||
export const useZeitmaschine = (refetchInterval: number = TAKT.traege, enabled = true) =>
|
||||
useQuery({
|
||||
queryKey: qk.zeitmaschine,
|
||||
queryFn: () => api<ZeitmaschineResp>("/api/zeitmaschine"),
|
||||
refetchInterval,
|
||||
enabled,
|
||||
})
|
||||
|
||||
export const useReminders = (refetchInterval: number = TAKT.traege) =>
|
||||
useQuery({
|
||||
queryKey: qk.reminders,
|
||||
queryFn: () => api<{ items: Reminder[] }>("/api/reminders"),
|
||||
refetchInterval: relax(refetchInterval),
|
||||
select: (d) => d.items ?? [],
|
||||
})
|
||||
|
||||
// Per-Turn-Latenz-Trace (letzte N Voice/Lucy-Turns mit Stufen-Breakdown).
|
||||
export const useVoiceTrace = (limit = 12, refetchInterval: number = TAKT.schnell) =>
|
||||
useQuery({
|
||||
queryKey: qk.voiceTrace,
|
||||
queryFn: () => api<VoiceTraceResp>(`/api/voice/trace?limit=${limit}`),
|
||||
refetchInterval,
|
||||
select: (d) => d.turns ?? [],
|
||||
})
|
||||
|
||||
export const useMemoryGraph = (enabled = true) =>
|
||||
useQuery({
|
||||
queryKey: qk.memoryGraph,
|
||||
queryFn: () => api<MemoryGraph>("/api/wissen/graph"),
|
||||
enabled,
|
||||
staleTime: 60 * 1000,
|
||||
})
|
||||
|
||||
export const useHealth = () =>
|
||||
useQuery({ queryKey: qk.health, queryFn: () => api<Health>("/api/health"), refetchInterval: TAKT.normal })
|
||||
|
||||
export const useSystemStatus = (refetchInterval: number = TAKT.graph) =>
|
||||
useQuery({ queryKey: qk.systemStatus, queryFn: () => api<SystemStatus>("/api/system/status"),
|
||||
refetchInterval: langsamWennStrom(refetchInterval) })
|
||||
|
||||
// Metrik-Verlauf (Cockpit-Zeitachse): 1h/24h aus dem Backend-Ringpuffer. Nur aktiv,
|
||||
// wenn eine Karte gerade diesen Bereich zeigt (enabled) — Live bleibt beim 3s-Store.
|
||||
export const useMetricHistory = (minutes: number, enabled = true) =>
|
||||
useQuery({
|
||||
queryKey: ["metrics-history", minutes],
|
||||
queryFn: () => api<HistoryResp>(`/api/system/history?minutes=${minutes}`),
|
||||
enabled,
|
||||
refetchInterval: 60_000,
|
||||
})
|
||||
|
||||
export const useServices = (refetchInterval: number = TAKT.normal) =>
|
||||
useQuery({ queryKey: qk.services, queryFn: () => api<ServicesResp>("/api/system/services"), refetchInterval })
|
||||
|
||||
// Der SSE-Strom invalidiert "models" bei Laden/Entladen → Poller ist nur Sicherheitsnetz.
|
||||
export const useModels = (refetchInterval: number = TAKT.schnell) =>
|
||||
useQuery({ queryKey: qk.models, queryFn: () => api<ModelsResp>("/api/models"), refetchInterval: relax(refetchInterval) })
|
||||
|
||||
export const useGroups = (refetchInterval: number = TAKT.normal) =>
|
||||
useQuery({ queryKey: qk.groups, queryFn: () => api<GroupsResp>("/api/groups"), refetchInterval })
|
||||
|
||||
export const useRouting = (refetchInterval: number = TAKT.normal) =>
|
||||
useQuery({ queryKey: qk.routing, queryFn: () => api<RoutingResp>("/api/routing"), refetchInterval })
|
||||
|
||||
export const useRoutingPolicy = () =>
|
||||
useQuery({ queryKey: qk.routingPolicy, queryFn: () => api<RoutingPolicyMeta>("/api/routing/policy") })
|
||||
|
||||
// Jobs (Downloads) takten etwas schneller, damit Fortschritts-Balken flüssig wirken.
|
||||
// `enabled=false` pausiert den Beobachter (z. B. geschlossene System-Schublade) —
|
||||
// der Cache bleibt geteilt, es pollt nur niemand umsonst.
|
||||
export const useJobs = (refetchInterval = 3_000, enabled = true) =>
|
||||
useQuery({
|
||||
queryKey: qk.jobs,
|
||||
queryFn: () => api<{ jobs: Job[] }>("/api/jobs"),
|
||||
// Seit P4 meldet der Fingerabdruck-Sammler Zustand UND Fortschritt der Jobs —
|
||||
// der Poller ist damit auch hier nur noch Sicherheitsnetz.
|
||||
refetchInterval: relax(refetchInterval),
|
||||
enabled,
|
||||
select: (d) => d.jobs ?? [],
|
||||
})
|
||||
|
||||
export const useTokenStats = (refetchInterval: number = TAKT.graph) =>
|
||||
useQuery({ queryKey: qk.tokenStats, queryFn: () => api<TokenStats>("/api/system/token-stats"),
|
||||
refetchInterval: langsamWennStrom(refetchInterval) })
|
||||
|
||||
export const useAgentStatus = (refetchInterval: number = TAKT.normal) =>
|
||||
useQuery({ queryKey: qk.agentStatus, queryFn: () => api<AgentStatus>("/api/agent/status"), refetchInterval })
|
||||
|
||||
// Werkzeug-Verlauf des Agenten. Liest das Ende von Hermes' Log — billig, aber es
|
||||
// aendert sich nur, wenn Lucy wirklich arbeitet; der gemuetliche Takt reicht.
|
||||
export const useWerkzeugverlauf = (refetchInterval: number = TAKT.gemuetlich) =>
|
||||
useQuery({
|
||||
queryKey: qk.agentAktivitaet,
|
||||
queryFn: () => api<AktivitaetResp>("/api/agent/aktivitaet?limit=80"),
|
||||
refetchInterval: relax(refetchInterval),
|
||||
})
|
||||
|
||||
// Agent-Hirn (Hermes) + bestes NousResearch-Update. Selten pollen (HF-Abfrage).
|
||||
export const useHermesBrain = (refetchInterval: number = TAKT.traege) =>
|
||||
useQuery({ queryKey: qk.hermesBrain, queryFn: () => api<HermesBrainResp>("/api/agent/brain"), refetchInterval })
|
||||
|
||||
// Updates-Check ist teuer (apt/git auf der Box) — träge pollen reicht völlig.
|
||||
export const useUpdates = (refetchInterval: number = TAKT.traege) =>
|
||||
useQuery({ queryKey: qk.updates, queryFn: () => api<UpdatesResp>("/api/maintenance/updates"), refetchInterval })
|
||||
|
||||
export const useDiscover = () =>
|
||||
useQuery({ queryKey: qk.discover, queryFn: () => api<DiscoverResp>("/api/discover") })
|
||||
|
||||
// Verfügbare Spec-Draft-Modelle + Vocab-Kompatibilität zum Ziel-Modell (GGUF-Pfad).
|
||||
export const useDrafts = (target?: string) =>
|
||||
useQuery({
|
||||
queryKey: qk.drafts(target),
|
||||
queryFn: () => api<DraftsResp>(`/api/models/drafts?target=${encodeURIComponent(target ?? "")}`),
|
||||
enabled: !!target,
|
||||
})
|
||||
|
||||
export const useConnect = (params?: string) =>
|
||||
useQuery({
|
||||
queryKey: qk.connect(params),
|
||||
queryFn: () => api<ConnectResp>(params ? `/api/connect?${params}` : "/api/connect"),
|
||||
})
|
||||
|
||||
// Live-Status der zwei Leitungen (Gateway + Gedächtnis) — alle 15s aktualisiert.
|
||||
export const useConnectHealth = () =>
|
||||
useQuery({
|
||||
queryKey: qk.connectHealth,
|
||||
queryFn: () => api<ConnectHealth>("/api/connect/health"),
|
||||
refetchInterval: 15000,
|
||||
})
|
||||
|
||||
/** Nach Mutationen die betroffenen Listen-Queries neu ziehen. */
|
||||
export function invalidate(qc: QueryClient, ...keys: (readonly unknown[])[]) {
|
||||
for (const key of keys) qc.invalidateQueries({ queryKey: key })
|
||||
}
|
||||
|
||||
export { useQueryClient }
|
||||
@@ -1,145 +0,0 @@
|
||||
import {
|
||||
Brain,
|
||||
BrainCog,
|
||||
Code,
|
||||
Eye,
|
||||
BookMarked,
|
||||
Telescope,
|
||||
Scale,
|
||||
ListOrdered,
|
||||
type LucideIcon,
|
||||
} from "lucide-react"
|
||||
|
||||
// ── EINE Quelle der Wahrheit für ALLE Serving-Rollen ────────────────────────
|
||||
// Klartext statt Kürzel: jede Rolle bekommt einen Zweck-Namen, ein Icon und
|
||||
// eine Ein-Satz-Erklärung, damit die UI ohne Fachjargon verständlich ist.
|
||||
// Das technische Kürzel (`role`) bleibt für den späteren Experten-Modus erhalten.
|
||||
//
|
||||
// Die Rollen hier spiegeln den ECHTEN Stand der Box (/api/models → role):
|
||||
// hermes · heavy · coder · coder-lite · vision · embed · scout
|
||||
// (Kein Phantom-`fast`: das ist nur ein Lane-Alias, keine Modell-Rolle. Lucys
|
||||
// Alltags-Hirn ist die Rolle `hermes` = Qwen3.6.)
|
||||
|
||||
export interface RoleMeta {
|
||||
role: string // technisches Kürzel (Experten-Modus)
|
||||
label: string // Klartext-Zweckname
|
||||
short: string // Kurzform für enge Badges
|
||||
icon: LucideIcon
|
||||
desc: string // Tooltip / Erklärung in einfacher Sprache
|
||||
tone: string // Farbklassen fürs Badge
|
||||
protected?: boolean // Hirn & Gedächtnis: lebenswichtig → nicht löschbar/entladbar (Etappe 3)
|
||||
}
|
||||
|
||||
// Reihenfolge = Anzeige-Reihenfolge in den Slot-Übersichten (Wichtigstes zuerst).
|
||||
export const ROLE_META: RoleMeta[] = [
|
||||
{
|
||||
role: "hermes",
|
||||
label: "Lucys Hirn",
|
||||
short: "Hirn",
|
||||
icon: Brain,
|
||||
desc: "Lucys Alltags-Verstand — denkt und redet mit dir. Immer bereit.",
|
||||
tone: "bg-indigo-500/15 text-indigo-400 border-indigo-500/25",
|
||||
protected: true,
|
||||
},
|
||||
{
|
||||
role: "embed",
|
||||
label: "Lucys Gedächtnis",
|
||||
short: "Gedächtnis",
|
||||
icon: BookMarked,
|
||||
desc: "Merkt sich Wichtiges und findet es später wieder.",
|
||||
tone: "bg-emerald-500/15 text-emerald-400 border-emerald-500/25",
|
||||
protected: true,
|
||||
},
|
||||
{
|
||||
role: "reranker",
|
||||
label: "Gedächtnis-Sortierer",
|
||||
short: "Sortierer",
|
||||
icon: ListOrdered,
|
||||
desc: "Ordnet gefundene Erinnerungen nach echter Relevanz — die Feinsortierung hinter dem Gedächtnis.",
|
||||
tone: "bg-lime-500/15 text-lime-400 border-lime-500/25",
|
||||
},
|
||||
{
|
||||
role: "vision",
|
||||
label: "Lucys Augen",
|
||||
short: "Augen",
|
||||
icon: Eye,
|
||||
desc: "Sieht Bilder und Screenshots und beschreibt, was drauf ist.",
|
||||
tone: "bg-pink-500/15 text-pink-400 border-pink-500/25",
|
||||
},
|
||||
{
|
||||
role: "heavy",
|
||||
label: "Großes Hirn",
|
||||
short: "Groß",
|
||||
icon: BrainCog,
|
||||
desc: "Das schwere Geschütz — wird für besonders knifflige Fragen dazugeholt.",
|
||||
tone: "bg-amber-500/15 text-amber-400 border-amber-500/25",
|
||||
},
|
||||
{
|
||||
role: "coder-lite",
|
||||
label: "Programmierer (schnell)",
|
||||
short: "Code schnell",
|
||||
icon: Code,
|
||||
desc: "Schnelles Programmieren im Alltag.",
|
||||
tone: "bg-violet-500/15 text-violet-400 border-violet-500/25",
|
||||
},
|
||||
{
|
||||
role: "coder",
|
||||
label: "Programmierer",
|
||||
short: "Code",
|
||||
icon: Code,
|
||||
desc: "Baut und plant. Der Kopf der Coding-Mannschaft — er hält den Faden und verteilt Zuarbeit.",
|
||||
tone: "bg-fuchsia-500/15 text-fuchsia-400 border-fuchsia-500/25",
|
||||
protected: true,
|
||||
},
|
||||
{
|
||||
role: "scout",
|
||||
label: "Späher",
|
||||
short: "Späher",
|
||||
icon: Telescope,
|
||||
desc: "Schneller erster Blick und grobe Einschätzung.",
|
||||
tone: "bg-teal-500/15 text-teal-400 border-teal-500/25",
|
||||
},
|
||||
{
|
||||
role: "kritiker",
|
||||
label: "Kritiker",
|
||||
short: "Kritiker",
|
||||
icon: Scale,
|
||||
desc: "Liest dem Programmierer nach jeder Etappe gegen — bewusst aus einer FREMDEN Modellfamilie (Mistral statt Qwen), damit er andere blinde Flecken hat.",
|
||||
tone: "bg-cyan-500/15 text-cyan-400 border-cyan-500/25",
|
||||
},
|
||||
]
|
||||
|
||||
// Kanonische Rollen-Reihenfolge (ersetzt die alte, veraltete ROLES-Liste).
|
||||
export const ROLES: string[] = ROLE_META.map((m) => m.role)
|
||||
|
||||
const BY_ROLE: Record<string, RoleMeta> = Object.fromEntries(
|
||||
ROLE_META.map((m) => [m.role, m]),
|
||||
)
|
||||
|
||||
// Fallback für unbekannte/neue Rollen — nie „undefined", damit die UID nicht bricht.
|
||||
const UNKNOWN = (role?: string | null): RoleMeta => ({
|
||||
role: role || "?",
|
||||
label: role || "unbekannt",
|
||||
short: role || "?",
|
||||
icon: Brain,
|
||||
desc: "Rolle ohne hinterlegten Klartext-Namen.",
|
||||
tone: "bg-slate-500/15 text-slate-300 border-slate-500/25",
|
||||
})
|
||||
|
||||
// Aliase, die auf eine echte Rolle zeigen (z.B. Lane-Alias `fast` = Hirn).
|
||||
const ALIAS: Record<string, string> = {
|
||||
fast: "hermes",
|
||||
coder_lite: "coder-lite",
|
||||
}
|
||||
|
||||
export function roleMeta(role?: string | null): RoleMeta {
|
||||
if (!role) return UNKNOWN(role)
|
||||
return BY_ROLE[role] || BY_ROLE[ALIAS[role]] || UNKNOWN(role)
|
||||
}
|
||||
|
||||
// Klartext-Name einer Rolle (mit optionaler Kurzform).
|
||||
export const roleLabel = (role?: string | null, short = false) =>
|
||||
short ? roleMeta(role).short : roleMeta(role).label
|
||||
|
||||
// Farbton — bleibt kompatibel zur bisherigen roleTone()-Signatur.
|
||||
export const roleTone = (role?: string | null) => roleMeta(role).tone
|
||||
@@ -0,0 +1,89 @@
|
||||
// Live-Strom — Gegenstück zu backend/routers/events.py (/api/stream).
|
||||
//
|
||||
// Ein EventSource, zwei Ereignisarten:
|
||||
// · `invalidate` stößt die betroffenen Abfragen an (z. B. „start“, wenn der Wächter
|
||||
// etwas Neues findet — sein Stand ändert sich höchstens einmal pro Minute)
|
||||
// · `metrik` jede Sekunde ein Messpunkt für die Rundinstrumente
|
||||
//
|
||||
// Der Messpunkt liegt bewusst AUSSERHALB von React (useSyncExternalStore): So zeichnen
|
||||
// nur die Instrumente jede Sekunde neu, nicht die ganze Seite.
|
||||
|
||||
import { useEffect, useSyncExternalStore } from "react"
|
||||
import { useQueryClient } from "@tanstack/react-query"
|
||||
|
||||
export interface MetrikPunkt {
|
||||
cpu: number
|
||||
ram: number
|
||||
ram_used: number
|
||||
ram_total: number
|
||||
gpu: number | null
|
||||
disk: number | null
|
||||
temp_cpu: number | null
|
||||
temp_gpu: number | null
|
||||
uptime_s: number | null
|
||||
tok_p: number
|
||||
tok_c: number
|
||||
}
|
||||
|
||||
export type StromLage = "verbinde" | "live" | "getrennt"
|
||||
|
||||
let lage: StromLage = "verbinde"
|
||||
let letzte: MetrikPunkt | null = null
|
||||
const hoerer = new Set<() => void>()
|
||||
const melde = () => hoerer.forEach((h) => h())
|
||||
|
||||
function abonniere(h: () => void) {
|
||||
hoerer.add(h)
|
||||
return () => {
|
||||
hoerer.delete(h)
|
||||
}
|
||||
}
|
||||
|
||||
export const stromVerbunden = () => lage === "live"
|
||||
export const useStromLage = () => useSyncExternalStore(abonniere, () => lage)
|
||||
export const useLetzteMetrik = () => useSyncExternalStore(abonniere, () => letzte)
|
||||
|
||||
/** Nur für Tests: Messpunkt direkt einspeisen. */
|
||||
export function metrikEinspeisen(m: MetrikPunkt | null) {
|
||||
letzte = m
|
||||
melde()
|
||||
}
|
||||
|
||||
function setzeLage(neu: StromLage) {
|
||||
if (neu !== lage) {
|
||||
lage = neu
|
||||
melde()
|
||||
}
|
||||
}
|
||||
|
||||
export function useStrom() {
|
||||
const qc = useQueryClient()
|
||||
useEffect(() => {
|
||||
const es = new EventSource("/api/stream")
|
||||
es.onopen = () => {
|
||||
setzeLage("live")
|
||||
// Was während einer Trennung passiert ist, hat der Strom nicht gemeldet.
|
||||
qc.invalidateQueries()
|
||||
}
|
||||
es.onerror = () => setzeLage("getrennt")
|
||||
es.addEventListener("invalidate", (e) => {
|
||||
try {
|
||||
const keys: string[] = JSON.parse((e as MessageEvent).data)?.keys ?? []
|
||||
for (const k of keys) qc.invalidateQueries({ queryKey: [k] })
|
||||
} catch {
|
||||
/* ein kaputtes Ereignis reißt den Strom nicht */
|
||||
}
|
||||
})
|
||||
es.addEventListener("metrik", (e) => {
|
||||
try {
|
||||
metrikEinspeisen(JSON.parse((e as MessageEvent).data) as MetrikPunkt)
|
||||
} catch {
|
||||
/* ein kaputter Messpunkt reißt den Strom nicht */
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
es.close()
|
||||
setzeLage("getrennt")
|
||||
}
|
||||
}, [qc])
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
// Datenformen der Box-Wart-Schnittstellen — Gegenstück zu backend/routers/boxwart.py,
|
||||
// services/waechter.py, modell_nutzung.py, zeitplan.py und routers/radar.py.
|
||||
|
||||
export type Stufe = "rot" | "gelb"
|
||||
export type LampenZustand = "ok" | "aus" | "warn" | "info" | "fehler"
|
||||
|
||||
export interface HinweisAktion {
|
||||
id: "neustart" | "protokoll" | "job-wiederholen" | string
|
||||
label: string
|
||||
dienst?: string
|
||||
job?: string
|
||||
}
|
||||
|
||||
export interface Hinweis {
|
||||
id: string
|
||||
stufe: Stufe
|
||||
titel: string
|
||||
text: string
|
||||
quelle: string
|
||||
seit: number
|
||||
zuletzt: number
|
||||
takte?: number
|
||||
aktionen: HinweisAktion[]
|
||||
}
|
||||
|
||||
export interface VerlaufEintrag {
|
||||
ts: number
|
||||
art: "neu" | "erledigt" | "auto"
|
||||
id: string
|
||||
text: string
|
||||
}
|
||||
|
||||
export interface Lampe {
|
||||
id: string
|
||||
label: string
|
||||
zustand: LampenZustand
|
||||
wert: string
|
||||
}
|
||||
|
||||
export interface FlugplanEintrag {
|
||||
zeit: string
|
||||
titel: string
|
||||
text: string
|
||||
status: "erledigt" | "fehler" | "geplant"
|
||||
}
|
||||
|
||||
export interface Start {
|
||||
zustand: {
|
||||
stufe: "ok" | Stufe
|
||||
anzahl: number
|
||||
waechter_wach: boolean
|
||||
stand: number | null
|
||||
update_laeuft: boolean
|
||||
}
|
||||
hinweise: Hinweis[]
|
||||
verlauf: VerlaufEintrag[]
|
||||
lampen: Lampe[]
|
||||
box: {
|
||||
ram_used: number | null
|
||||
ram_total: number | null
|
||||
temp_cpu: number | null
|
||||
temp_gpu: number | null
|
||||
platte: { used: number; total: number; percent: number } | null
|
||||
uptime_s: number | null
|
||||
}
|
||||
updates: {
|
||||
bausteine: { id: string; name: string; neu: string }[]
|
||||
modelle: { role: string; title: string; repo: string }[]
|
||||
}
|
||||
flugplan: { gelaufen: FlugplanEintrag[]; geplant: FlugplanEintrag[] }
|
||||
}
|
||||
|
||||
export interface AktionsErgebnis {
|
||||
ok: boolean
|
||||
text?: string
|
||||
out?: string
|
||||
err?: string
|
||||
detail?: string
|
||||
}
|
||||
|
||||
export interface Nutzung {
|
||||
tage: number
|
||||
absender: { name: string; anfragen: number }[]
|
||||
je_tag: Record<string, Record<string, number>>
|
||||
letzte_24h: { stunde: string; je_absender: Record<string, number> }[]
|
||||
zuletzt_geladen: Record<string, string>
|
||||
}
|
||||
|
||||
export interface Modell {
|
||||
name: string
|
||||
role: string | null
|
||||
aliases: string[]
|
||||
ctx: number | null
|
||||
ttl: number | null
|
||||
filename: string
|
||||
quant: string
|
||||
size_bytes: number | null
|
||||
spec_active: boolean
|
||||
parallel_slots: number
|
||||
capabilities?: { vision?: boolean; tools?: boolean; params_b?: number } & Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface Job {
|
||||
id: string
|
||||
label: string
|
||||
state: "running" | "done" | "failed" | "cancelled" | string
|
||||
progress?: number | null
|
||||
log?: string[]
|
||||
started?: number
|
||||
finished?: number | null
|
||||
}
|
||||
|
||||
export interface Sicherung {
|
||||
snapshot: string
|
||||
file: string
|
||||
size_mb: number
|
||||
mtime?: number
|
||||
}
|
||||
|
||||
export type RadarStatus =
|
||||
| "neu" | "wartet" | "getestet" | "bestanden" | "durchgefallen" | "uebernommen" | "verworfen"
|
||||
|
||||
export interface RadarKandidat {
|
||||
id: string
|
||||
name: string
|
||||
rolle: "hirn" | "coder" | string
|
||||
repo: string
|
||||
datei: string
|
||||
groesse_gb: number | null
|
||||
passt: boolean
|
||||
eng: boolean
|
||||
quelle: string
|
||||
status: RadarStatus
|
||||
begruendung: string
|
||||
}
|
||||
|
||||
export interface RadarTest {
|
||||
id: string
|
||||
kandidat: string
|
||||
rolle: string
|
||||
datum: string
|
||||
ergebnis: "bestanden" | "durchgefallen" | "abgebrochen"
|
||||
werte: Record<string, unknown>
|
||||
vergleich: Record<string, unknown>
|
||||
zusammenfassung: string
|
||||
}
|
||||
|
||||
export interface Radar {
|
||||
naechster_test: string | null
|
||||
letzter_test: string | null
|
||||
kandidaten: RadarKandidat[]
|
||||
tests: RadarTest[]
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
// Ein Hook für Alert/Confirm/Prompt-Dialoge — ersetzt die zuvor in jeder View
|
||||
// duplizierte showAlert/showConfirm-Logik + den lokalen Dialog-State.
|
||||
//
|
||||
// Nutzung:
|
||||
// const { showAlert, showConfirm, showPrompt, dialogElement } = useDialog()
|
||||
// ...
|
||||
// showConfirm("Titel", "Wirklich?", () => doIt())
|
||||
// return (<>{dialogElement}...</>)
|
||||
|
||||
import { useCallback, useState } from "react"
|
||||
import { CustomDialog } from "@/components/CustomDialog"
|
||||
|
||||
interface DialogState {
|
||||
type: "alert" | "confirm" | "prompt"
|
||||
title: string
|
||||
message: string
|
||||
defaultValue?: string
|
||||
autoValue?: string
|
||||
autoLabel?: string
|
||||
onConfirm: (val?: string) => void
|
||||
onCancel?: () => void
|
||||
}
|
||||
|
||||
export function useDialog() {
|
||||
const [dialog, setDialog] = useState<DialogState | null>(null)
|
||||
const close = useCallback(() => setDialog(null), [])
|
||||
|
||||
const showAlert = useCallback((title: string, message: string, onConfirm?: () => void) => {
|
||||
setDialog({
|
||||
type: "alert", title, message,
|
||||
onConfirm: () => { setDialog(null); onConfirm?.() },
|
||||
})
|
||||
}, [])
|
||||
|
||||
const showConfirm = useCallback(
|
||||
(title: string, message: string, onConfirm: () => void, onCancel?: () => void) => {
|
||||
setDialog({
|
||||
type: "confirm", title, message,
|
||||
onConfirm: () => { setDialog(null); onConfirm() },
|
||||
onCancel: () => { setDialog(null); onCancel?.() },
|
||||
})
|
||||
}, [])
|
||||
|
||||
const showPrompt = useCallback(
|
||||
(title: string, message: string, defaultValue: string,
|
||||
onConfirm: (val?: string) => void, onCancel?: () => void,
|
||||
opts?: { autoValue?: string; autoLabel?: string }) => {
|
||||
setDialog({
|
||||
type: "prompt", title, message, defaultValue,
|
||||
autoValue: opts?.autoValue, autoLabel: opts?.autoLabel,
|
||||
onConfirm: (val) => { setDialog(null); onConfirm(val) },
|
||||
onCancel: () => { setDialog(null); onCancel?.() },
|
||||
})
|
||||
}, [])
|
||||
|
||||
const dialogElement = dialog ? <CustomDialog {...dialog} /> : null
|
||||
|
||||
return { showAlert, showConfirm, showPrompt, close, dialogElement }
|
||||
}
|
||||
@@ -1,235 +0,0 @@
|
||||
// „Geht's Lucy gut?" — bündelt die vielen technischen Status-Signale zu wenigen
|
||||
// Lucy-Fähigkeiten in Klartext, inkl. Gesamt-Verdikt, Speicher-Ampel und einer
|
||||
// konkreten 1-Klick-Reparatur pro Problem. EINE Quelle für das Status-Panel.
|
||||
|
||||
import { Brain, BookMarked, Eye, Plug, Bot, Mic, ShieldAlert, Server, type LucideIcon } from "lucide-react"
|
||||
import { useHealth, useServices, useModels, useSystemStatus } from "./queries"
|
||||
|
||||
export type CheckStatus = "ok" | "warn" | "down" | "loading"
|
||||
|
||||
// Reparatur-Rezept: entweder einen systemd-Dienst neu starten oder ein Modell laden.
|
||||
// WICHTIG: `service` sind die ECHTEN Unit-Namen (nicht die Anzeige-Namen der
|
||||
// Dienste-Karte), damit /api/maintenance/restart sie akzeptiert.
|
||||
export type Repair =
|
||||
| { kind: "restart"; service: string; label: string; needsSudo?: boolean }
|
||||
| { kind: "loadModel"; model: string; label: string }
|
||||
|
||||
export interface LucyCheck {
|
||||
id: string
|
||||
label: string // Klartext-Name der Fähigkeit
|
||||
icon: LucideIcon
|
||||
status: CheckStatus
|
||||
detail: string // Ein Satz: was ist gerade los
|
||||
critical: boolean // zählt fürs Gesamt-Verdikt „Problem"
|
||||
repair?: Repair
|
||||
}
|
||||
|
||||
export type Verdict = "loading" | "gut" | "warn" | "problem"
|
||||
|
||||
export interface MemoryAmpel {
|
||||
status: "ok" | "warn" | "full" | "unknown"
|
||||
usedGb: number
|
||||
totalGb: number
|
||||
pct: number
|
||||
text: string
|
||||
}
|
||||
|
||||
export interface LucyHealth {
|
||||
verdict: Verdict
|
||||
checks: LucyCheck[]
|
||||
memory: MemoryAmpel
|
||||
problems: LucyCheck[] // kritische Down-Checks (für den Schnellzugriff im Banner)
|
||||
warns: LucyCheck[] // nicht-kritische Hinweise — damit das Banner SAGT, was los ist
|
||||
unreachable: boolean // Zentrale (MC2) antwortet nicht → alle anderen Anzeigen sind veraltet
|
||||
}
|
||||
|
||||
export function useLucyHealth(): LucyHealth {
|
||||
const { data: health, isError: healthErr } = useHealth()
|
||||
const { data: svc, isError: svcErr } = useServices()
|
||||
const { data: modelsResp } = useModels()
|
||||
const { data: sys } = useSystemStatus()
|
||||
|
||||
// React Query behält bei Fehlern die letzten Daten — ohne diesen Riegel zeigte die UI
|
||||
// bei totem Backend dauerhaft den letzten grünen Stand („eingefrorene Ampel", Review 15.07.).
|
||||
const unreachable = healthErr || svcErr
|
||||
|
||||
const loaded = !!health && !!svc
|
||||
// Matching über den ECHTEN systemd-Unit-Namen (Backend liefert `unit`) — vorher wurde
|
||||
// fragil im deutschen Anzeige-Namen gesucht ("gateway (integr" …).
|
||||
const svcOk = (unit: string): boolean | undefined =>
|
||||
svc?.services.find((s) => s.unit === unit)?.ok
|
||||
const svcHas = (unit: string): boolean => !!svc?.services.some((s) => s.unit === unit)
|
||||
|
||||
const engineOk = health?.engine_reachable
|
||||
const brainReady = health?.brain?.ready
|
||||
const brainName =
|
||||
health?.brain?.model ||
|
||||
modelsResp?.models.find((m) => m.role === "hermes")?.name ||
|
||||
""
|
||||
|
||||
const checks: LucyCheck[] = []
|
||||
|
||||
// 0) Die Zentrale selbst. Antwortet MC2 nicht, ist ALLES andere Spekulation —
|
||||
// dieser Check übertrumpft die übrigen (die zeigen sonst eingefrorene alte Werte).
|
||||
if (unreachable) {
|
||||
checks.push({
|
||||
id: "mc2", label: "Steuerpult (MC2)", icon: Server, critical: true, status: "down",
|
||||
detail: "Die Zentrale antwortet nicht — alle Anzeigen hier können veraltet sein. Läuft die Box? Deploy aktiv?",
|
||||
})
|
||||
}
|
||||
|
||||
// 1) Lucys Hirn — die wichtigste Fähigkeit. Engine läuft UND das Hirn-Modell ist geladen?
|
||||
checks.push(((): LucyCheck => {
|
||||
const base = { id: "brain", label: "Lucys Hirn", icon: Brain, critical: true }
|
||||
if (!loaded) return { ...base, status: "loading", detail: "Wird geprüft …" }
|
||||
if (engineOk === false)
|
||||
return {
|
||||
...base,
|
||||
status: "down",
|
||||
detail: "Die Motor-Maschine (Engine) läuft nicht — Lucy kann gerade gar nicht denken.",
|
||||
repair: { kind: "restart", service: "llama-swap", label: "Motor neu starten", needsSudo: true },
|
||||
}
|
||||
if (brainReady === false)
|
||||
return {
|
||||
...base,
|
||||
status: "down",
|
||||
detail: "Lucys Hirn ist gerade eingeschlafen — einmal aufwecken.",
|
||||
repair: brainName
|
||||
? { kind: "loadModel", model: brainName, label: "Hirn aufwecken" }
|
||||
: undefined,
|
||||
}
|
||||
return { ...base, status: "ok", detail: "Wach und ansprechbar." }
|
||||
})())
|
||||
|
||||
// 2) Lucys Augen — das Vision-Modell (Rolle `vision`). Seit der Warm-Set-Diät 07.07. ist
|
||||
// ON-DEMAND der NORMALZUSTAND (User-Entscheid: Augen nur, wenn Lucy an ist / IDE sie ruft;
|
||||
// die Lucy-App wärmt sie beim Start selbst an). Ruhende Augen sind darum OK, keine Warnung.
|
||||
checks.push(((): LucyCheck => {
|
||||
const base = { id: "vision", label: "Lucys Augen", icon: Eye, critical: false }
|
||||
if (!loaded || !modelsResp) return { ...base, status: "loading", detail: "Wird geprüft …" }
|
||||
const vm = modelsResp.models.find((m) => m.role === "vision")
|
||||
if (!vm)
|
||||
return { ...base, status: "warn", detail: "Kein Augen-Modell eingerichtet — Lucy kann keine Bilder ansehen." }
|
||||
const warm = (modelsResp.running ?? []).includes(vm.name)
|
||||
return warm
|
||||
? { ...base, status: "ok", detail: "Sieht gerade zu (geladen, solange Lucy sie nutzt)." }
|
||||
: {
|
||||
...base,
|
||||
status: "ok",
|
||||
detail: "Augen ruhen — sie laden von selbst, sobald Lucy startet oder ein Bild kommt.",
|
||||
repair: { kind: "loadModel", model: vm.name, label: "Augen wecken" },
|
||||
}
|
||||
})())
|
||||
|
||||
// 3) Lucys Gedächtnis — Hermes Native Gedächtnis (integriert in hermes-gateway).
|
||||
checks.push(((): LucyCheck => {
|
||||
const base = { id: "memory", label: "Lucys Gedächtnis", icon: BookMarked, critical: true }
|
||||
const ok = svcOk("hermes-gateway")
|
||||
if (!loaded || ok === undefined) return { ...base, status: "loading", detail: "Wird geprüft …" }
|
||||
return ok
|
||||
? { ...base, status: "ok", detail: "Hermes-Natives Gedächtnis aktiv (SQLite & Embeddings)." }
|
||||
: {
|
||||
...base,
|
||||
status: "down",
|
||||
detail: "Lucy kann sich gerade nichts merken — das Gateway antwortet nicht.",
|
||||
repair: { kind: "restart", service: "hermes-gateway", label: "Gateway neu starten" },
|
||||
}
|
||||
})())
|
||||
|
||||
// 4) Verbindung (LLM-Gateway) — der Denkpfad, über den Apps/IDEs/Lucy die Modelle erreichen.
|
||||
// Seit UMBAU v3 ist das der EIGENE mc2-gateway-Prozess; health.gateway_reachable prüft
|
||||
// ihn jetzt wirklich (statt nur die Engine). Repariert wird die richtige Unit.
|
||||
checks.push(((): LucyCheck => {
|
||||
const base = { id: "gateway", label: "Verbindung (Gateway)", icon: Plug, critical: true }
|
||||
const ok = health ? health.gateway_reachable : svcOk("mc2-gateway")
|
||||
if (!loaded || ok === undefined) return { ...base, status: "loading", detail: "Wird geprüft …" }
|
||||
const unit = svcHas("mc2-gateway") ? "mc2-gateway" : "mission-control-2"
|
||||
return ok
|
||||
? { ...base, status: "ok", detail: "Apps und IDEs können Lucy erreichen." }
|
||||
: {
|
||||
...base,
|
||||
status: "down",
|
||||
detail: "Der Modell-Gateway antwortet nicht — Lucy und die IDEs erreichen kein Modell.",
|
||||
repair: { kind: "restart", service: unit, label: "Gateway neu starten" },
|
||||
}
|
||||
})())
|
||||
|
||||
// 4b) Der Wächter (Steward) — überwacht Warm-Set, Health-Flanken und Gedächtnis-Dubletten.
|
||||
// Nicht kritisch fürs Antworten, aber ohne ihn merkt niemand, wenn etwas kippt.
|
||||
checks.push(((): LucyCheck => {
|
||||
const base = { id: "steward", label: "Wächter (Steward)", icon: ShieldAlert, critical: false }
|
||||
if (!loaded) return { ...base, status: "loading", detail: "Wird geprüft …" }
|
||||
if (!svcHas("mc2-steward"))
|
||||
return { ...base, status: "ok", detail: "Auf dieser Installation nicht eingerichtet." }
|
||||
return svcOk("mc2-steward")
|
||||
? { ...base, status: "ok", detail: "Wacht über Warm-Set, Dienste und Gedächtnis." }
|
||||
: {
|
||||
...base,
|
||||
status: "warn",
|
||||
detail: "Der Wächter schläft — niemand meldet gerade Ausfälle oder wärmt Modelle nach.",
|
||||
repair: { kind: "restart", service: "mc2-steward", label: "Wächter neu starten" },
|
||||
}
|
||||
})())
|
||||
|
||||
// 5) Lucys Agent (Hermes) — der Dienst, der als Lucy antwortet (Terminal/Telegram/Sprechen).
|
||||
checks.push(((): LucyCheck => {
|
||||
const base = { id: "agent", label: "Lucys Agent (Hermes)", icon: Bot, critical: true }
|
||||
const ok = svcOk("hermes-gateway") // unit-Name (identisch zum alten Namens-Match, jetzt exakt)
|
||||
if (!loaded || ok === undefined) return { ...base, status: "loading", detail: "Wird geprüft …" }
|
||||
return ok
|
||||
? { ...base, status: "ok", detail: "Lucy ist bereit zu reden und zu handeln." }
|
||||
: {
|
||||
...base,
|
||||
status: "down",
|
||||
detail: "Lucys Agent ist offline — sie reagiert gerade nicht.",
|
||||
repair: { kind: "restart", service: "hermes-gateway", label: "Agent neu starten" },
|
||||
}
|
||||
})())
|
||||
|
||||
// 6) Lucys Stimme — Voice-Dienst (STT/TTS). Nicht kritisch: Chat geht auch ohne.
|
||||
checks.push(((): LucyCheck => {
|
||||
const base = { id: "voice", label: "Lucys Stimme", icon: Mic, critical: false }
|
||||
const ok = svcOk("voice-service")
|
||||
if (!loaded || ok === undefined) return { ...base, status: "loading", detail: "Wird geprüft …" }
|
||||
return ok
|
||||
? { ...base, status: "ok", detail: "Lucy kann hören und sprechen." }
|
||||
: {
|
||||
...base,
|
||||
status: "warn",
|
||||
detail: "Sprechen ist gerade aus — Tippen geht weiter normal.",
|
||||
repair: { kind: "restart", service: "voice-service", label: "Stimme neu starten" },
|
||||
}
|
||||
})())
|
||||
|
||||
// Speicher-Ampel = System-RAM, DIESELBE Quelle wie die RAM-Zeile in der
|
||||
// System-Status-Kachel. Vorher zeigte die Pille den GTT-Pool (GPU-Sicht auf den
|
||||
// Unified-Memory) — der weicht auf der APU vom System-RAM ab, wodurch „Box gesund"
|
||||
// und „System-Status" zwei verschiedene Speicher-Zahlen zeigten. Eine Box, eine Zahl.
|
||||
// Schwellen am RAM-Charakter geeicht: 4 Modelle warm ≈ 78 % gilt als gesund → grün.
|
||||
const total = sys?.ram?.total ?? 0
|
||||
const used = sys?.ram?.used ?? 0
|
||||
const pct = sys?.ram?.percent ?? (total > 0 ? Math.min(100, (used / total) * 100) : 0)
|
||||
const B = 1024 ** 3
|
||||
const memory: MemoryAmpel = !total
|
||||
? { status: "unknown", usedGb: 0, totalGb: 0, pct: 0, text: "Speicher-Auslastung unbekannt." }
|
||||
: pct >= 93
|
||||
? { status: "full", usedGb: used / B, totalGb: total / B, pct, text: "Speicher fast voll — Lucy könnte langsamer werden." }
|
||||
: pct >= 85
|
||||
? { status: "warn", usedGb: used / B, totalGb: total / B, pct, text: "Speicher gut gefüllt — noch okay." }
|
||||
: { status: "ok", usedGb: used / B, totalGb: total / B, pct, text: "Genug Speicher frei." }
|
||||
|
||||
const anyLoading = checks.some((c) => c.status === "loading")
|
||||
const problems = checks.filter((c) => c.critical && c.status === "down")
|
||||
const warns = checks.filter((c) => c.status === "warn" || (!c.critical && c.status === "down"))
|
||||
const memBad = memory.status === "full"
|
||||
|
||||
const verdict: Verdict = anyLoading && !problems.length
|
||||
? "loading"
|
||||
: problems.length
|
||||
? "problem"
|
||||
: warns.length || memBad
|
||||
? "warn"
|
||||
: "gut"
|
||||
|
||||
return { verdict, checks, memory, problems, warns, unreachable }
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import { useMemo } from "react"
|
||||
import { useSystemStatus } from "@/lib/queries"
|
||||
import { useLetzteMetrik, useSysHistory, type SysSample } from "@/lib/metricsStore"
|
||||
import type { SystemStatus } from "@/lib/api"
|
||||
|
||||
export type { SysSample }
|
||||
|
||||
/** Aktuelle System-Werte (für die Legende) + rollende Live-Historie.
|
||||
*
|
||||
* v3-Umbau P4: Die Query läuft nur noch minütlich, solange der Ereignisstrom steht —
|
||||
* ihre MESSWERTE wären damit bis zu 60 s alt. Deshalb werden sie hier mit dem letzten
|
||||
* Messpunkt aus dem Strom überschrieben. Alles, was der Strom NICHT trägt (VRAM-Detail,
|
||||
* Kernzahl, Versionen), bleibt aus der Query: Der Strom soll schlank bleiben, und diese
|
||||
* Felder ändern sich ohnehin selten.
|
||||
*/
|
||||
export function useSystemHistory() {
|
||||
const { data: query, error } = useSystemStatus()
|
||||
const live = useLetzteMetrik()
|
||||
const hist = useSysHistory()
|
||||
|
||||
const sys = useMemo<SystemStatus | undefined>(() => {
|
||||
if (!query) return query
|
||||
if (!live) return query
|
||||
return {
|
||||
...query,
|
||||
cpu: { ...query.cpu, percent: live.cpu ?? query.cpu.percent },
|
||||
ram: {
|
||||
...query.ram,
|
||||
percent: live.ram ?? query.ram.percent,
|
||||
used: live.ram_used ?? query.ram.used,
|
||||
total: live.ram_total ?? query.ram.total,
|
||||
},
|
||||
// gtt_used/gtt_total bleiben aus der Query — der Strom trägt nur die Auslastung.
|
||||
gpu: query.gpu ? { ...query.gpu, busy_percent: live.gpu ?? query.gpu.busy_percent } : query.gpu,
|
||||
disk: query.disk ? { ...query.disk, percent: live.disk ?? query.disk.percent } : query.disk,
|
||||
temp: { cpu: live.temp_cpu ?? query.temp?.cpu, gpu: live.temp_gpu ?? query.temp?.gpu },
|
||||
uptime_s: live.uptime_s ?? query.uptime_s,
|
||||
}
|
||||
}, [query, live])
|
||||
|
||||
return { sys, hist, error }
|
||||
}
|
||||
@@ -1,11 +1 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
export function resolveExternalUrl(url?: string): string {
|
||||
if (!url) return ""
|
||||
return url.replace(/127\.0\.0\.1|localhost/g, "192.168.178.151")
|
||||
}
|
||||
export { cn } from "cn"
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { flugplanZeit, gbText, laufzeit, seit, vor } from "./zeit"
|
||||
import { rolleVon, temperaturFarbe, versionen } from "./anzeige"
|
||||
import type { Modell } from "./typen"
|
||||
|
||||
// Fester Bezugspunkt: Mittwoch, 23.09.2026, 20:45 Uhr Berliner Zeit.
|
||||
const JETZT = new Date("2026-09-23T20:45:00+02:00")
|
||||
|
||||
describe("flugplanZeit", () => {
|
||||
it("benennt heute, gestern und morgen", () => {
|
||||
expect(flugplanZeit("2026-09-23T03:00:00+02:00", JETZT)).toBe("HEUTE 03:00")
|
||||
expect(flugplanZeit("2026-09-22T23:10:00+02:00", JETZT)).toBe("GESTERN 23:10")
|
||||
expect(flugplanZeit("2026-09-24T03:00:00+02:00", JETZT)).toBe("MORGEN 03:00")
|
||||
})
|
||||
|
||||
it("nimmt für die nächsten Tage den Wochentag", () => {
|
||||
expect(flugplanZeit("2026-09-27T04:30:00+02:00", JETZT)).toBe("SO 04:30")
|
||||
})
|
||||
|
||||
it("rechnet UTC-Zeiten in Berliner Zeit um", () => {
|
||||
expect(flugplanZeit("2026-09-23T05:00:00Z", JETZT)).toBe("HEUTE 07:00")
|
||||
})
|
||||
})
|
||||
|
||||
describe("kleine Helfer", () => {
|
||||
it("seit und vor", () => {
|
||||
expect(seit(new Date("2026-09-07T11:03:00+02:00").getTime() / 1000, JETZT)).toBe("seit 07.09., 11:03")
|
||||
expect(vor(JETZT.getTime() / 1000 - 40, JETZT.getTime())).toBe("vor 40 s")
|
||||
expect(vor(JETZT.getTime() / 1000 - 2 * 86_400, JETZT.getTime())).toBe("vor 2 Tagen")
|
||||
})
|
||||
|
||||
it("laufzeit und gbText", () => {
|
||||
expect(laufzeit(10 * 86_400 + 14 * 3600 + 59)).toEqual([10, 14])
|
||||
expect(gbText(55 * 1024 ** 3)).toBe("55 GB")
|
||||
expect(gbText(1.9 * 1024 ** 4, 1)).toBe("1,9 TB")
|
||||
})
|
||||
|
||||
it("Temperaturfarbe wechselt bei 70 und 85 Grad", () => {
|
||||
expect(temperaturFarbe(37)).toBe("var(--gruen)")
|
||||
expect(temperaturFarbe(70)).toBe("var(--bernstein)")
|
||||
expect(temperaturFarbe(85)).toBe("var(--rot)")
|
||||
})
|
||||
})
|
||||
|
||||
describe("versionen", () => {
|
||||
it("zeigt Motor-Builds mit b und nur echte Neuerungen", () => {
|
||||
expect(versionen("engine", { kind: "engine", installed_build: 11057, latest_build: 11139 })).toEqual({ laeuft: "b11057", neu: "b11139" })
|
||||
expect(versionen("engine", { kind: "engine", installed_build: 11139, latest_build: 11139 })).toEqual({ laeuft: "b11139", neu: null })
|
||||
})
|
||||
|
||||
it("zählt beim Betriebssystem die Pakete", () => {
|
||||
expect(versionen("os", { kind: "os", count: 24 })).toEqual({ laeuft: "Ubuntu", neu: "24 Pakete" })
|
||||
})
|
||||
})
|
||||
|
||||
describe("rolleVon", () => {
|
||||
const m = (aliases: string[]): Modell => ({
|
||||
name: "x", role: null, aliases, ctx: null, ttl: null, filename: "", quant: "", size_bytes: null, spec_active: false, parallel_slots: 1,
|
||||
})
|
||||
it("erkennt Hirn und Coder an den Aliasen", () => {
|
||||
expect(rolleVon(m(["hermes", "fast"]))).toBe("hirn")
|
||||
expect(rolleVon(m(["coder", "heavy"]))).toBe("coder")
|
||||
expect(rolleVon(m(["debugger"]))).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,65 @@
|
||||
// Anzeige-Helfer: Uhrzeiten, Dauer, Größen — alles auf Deutsch und in Box-Zeit.
|
||||
|
||||
const ZONE = "Europe/Berlin"
|
||||
|
||||
const uhr = new Intl.DateTimeFormat("de-DE", { hour: "2-digit", minute: "2-digit", timeZone: ZONE })
|
||||
const tagKurz = new Intl.DateTimeFormat("de-DE", { weekday: "short", timeZone: ZONE })
|
||||
const datumKurz = new Intl.DateTimeFormat("de-DE", { day: "2-digit", month: "2-digit", timeZone: ZONE })
|
||||
const tagIso = new Intl.DateTimeFormat("en-CA", { timeZone: ZONE })
|
||||
|
||||
function tagSchluessel(d: Date) {
|
||||
return tagIso.format(d)
|
||||
}
|
||||
|
||||
/** "HEUTE 03:00", "GESTERN 23:10", "SA 08:00" (≤ 6 Tage entfernt), sonst "27.09. 04:30". */
|
||||
export function flugplanZeit(iso: string, jetzt = new Date()): string {
|
||||
const d = new Date(iso)
|
||||
if (Number.isNaN(d.getTime())) return "–"
|
||||
const heute = tagSchluessel(jetzt)
|
||||
const gestern = tagSchluessel(new Date(jetzt.getTime() - 86_400_000))
|
||||
const morgen = tagSchluessel(new Date(jetzt.getTime() + 86_400_000))
|
||||
const t = tagSchluessel(d)
|
||||
const zeit = uhr.format(d)
|
||||
if (t === heute) return `HEUTE ${zeit}`
|
||||
if (t === gestern) return `GESTERN ${zeit}`
|
||||
if (t === morgen) return `MORGEN ${zeit}`
|
||||
if (Math.abs(d.getTime() - jetzt.getTime()) < 6.5 * 86_400_000) {
|
||||
return `${tagKurz.format(d).replace(".", "").toUpperCase()} ${zeit}`
|
||||
}
|
||||
// de-DE liefert „27.09.“ schon mit Schlusspunkt.
|
||||
return `${datumKurz.format(d)} ${zeit}`
|
||||
}
|
||||
|
||||
/** Unix-Sekunden → "seit 07.09., 11:03" bzw. "seit heute 07:02". */
|
||||
export function seit(ts: number, jetzt = new Date()): string {
|
||||
const d = new Date(ts * 1000)
|
||||
if (tagSchluessel(d) === tagSchluessel(jetzt)) return `seit heute ${uhr.format(d)}`
|
||||
return `seit ${datumKurz.format(d)}, ${uhr.format(d)}`
|
||||
}
|
||||
|
||||
/** Unix-Sekunden → "vor 40 s", "vor 3 min", "vor 2 h", "vor 5 Tagen". */
|
||||
export function vor(ts: number, jetzt = Date.now()): string {
|
||||
const s = Math.max(0, Math.round(jetzt / 1000 - ts))
|
||||
if (s < 60) return `vor ${s} s`
|
||||
if (s < 3600) return `vor ${Math.round(s / 60)} min`
|
||||
if (s < 86_400) return `vor ${Math.round(s / 3600)} h`
|
||||
const tage = Math.round(s / 86_400)
|
||||
return `vor ${tage} ${tage === 1 ? "Tag" : "Tagen"}`
|
||||
}
|
||||
|
||||
/** Laufzeit in Sekunden → [Tage, Stunden]. */
|
||||
export function laufzeit(s: number | null | undefined): [number, number] | null {
|
||||
if (s == null || s < 0) return null
|
||||
return [Math.floor(s / 86_400), Math.floor((s % 86_400) / 3600)]
|
||||
}
|
||||
|
||||
export const gb = (bytes: number | null | undefined) =>
|
||||
bytes == null ? null : bytes / 1024 ** 3
|
||||
|
||||
export function gbText(bytes: number | null | undefined, stellen = 0): string {
|
||||
const g = gb(bytes)
|
||||
if (g == null) return "–"
|
||||
return g >= 1000 ? `${(g / 1024).toFixed(1).replace(".", ",")} TB` : `${g.toFixed(stellen).replace(".", ",")} GB`
|
||||
}
|
||||
|
||||
export const zahl = (n: number) => n.toLocaleString("de-DE")
|
||||
Reference in New Issue
Block a user