Files
mission-control-v2/frontend/src/lib/wartung.ts
T
HitonabiandClaude Opus 5.5 54a3649d5e aufträge und „Alle aktualisieren“: beendete lassen sich ausblenden
User 25.09.: Die Karten waren „immer da“ und ließen sich weder bestätigen noch
wegklicken. Beendete Aufträge der KI-Box und das letzte „Alle aktualisieren“
im Homelab haben jetzt einen Knopf „Ausblenden“ (quittiert wird auf dem Server,
gilt also auf allen Geräten; das Protokoll behält alles).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 12:54:21 +02:00

177 lines
8.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Anzeige-Logik der Wartungs-Ansichten (Updates, Aufträge, Installieren). Eigene Datei, weil
// anzeige.ts im Start-Bündel liegt — was nur die nachgeladenen Seiten brauchen, gehört nicht dorthin.
import { laeuftNoch } from "./anzeige"
import type { Job, UpdateDetails } from "./typen"
import { gbText } from "./zeit"
// --- Updates ------------------------------------------------------------------------
/** „Läuft“ und „Neu“ aus den Update-Details lesbar machen (die Details sind schon geladen). */
export function versionen(id: string, d: UpdateDetails): { laeuft: string; neu: string | null } {
if (id === "os") return { laeuft: "Ubuntu", neu: d.count ? `${d.count} ${d.count === 1 ? "Paket" : "Pakete"}` : null }
// Hermes kommt aus git: „Neu“ ist die Zahl der Änderungen seit dem eingespielten Stand.
if (id === "hermes") {
return {
laeuft: d.installed_version ? `v${d.installed_version}` : "–",
neu: d.behind ? `+${d.behind.toLocaleString("de-DE")} Änderungen` : 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 }
}
/** Sagen die Details, dass es Neues gibt? null = weiß man nicht (Prüfung gescheitert oder unvollständig). */
export function detailsHabenNeues(id: string, d: UpdateDetails | undefined): boolean | null {
if (!d || d.error) return null
if (id === "os") return typeof d.count === "number" ? d.count > 0 : null
if (id === "hermes") return typeof d.behind === "number" ? d.behind > 0 : null
if (d.latest_build == null || d.installed_build == null) return null
return d.latest_build > d.installed_build
}
/** Eine gescheiterte Update-Prüfung in Worten („Konnte nicht prüfen: …“), ohne Befehlszeilen-Kauderwelsch. */
export function pruefFehlerText(fehler: string): string {
const f = fehler.trim()
if (/timed out|timeout|zeitüberschreitung/i.test(f)) {
return "Konnte nicht prüfen: Die Gegenstelle hat nicht rechtzeitig geantwortet. Später klappt es meist wieder."
}
if (/name resolution|name or service not known|network is unreachable|connection (refused|reset)|nicht erreichbar/i.test(f)) {
return "Konnte nicht prüfen: Die Box kommt gerade nicht ins Internet."
}
return `Konnte nicht prüfen: ${f.length > 160 ? `${f.slice(0, 157)}…` : f}`
}
// --- Aufträge (services/jobengine.py) ---------------------------------------------------
/** Ist der Auftrag ein Modell-Download? (Bis das Backend eine Gruppe setzt, verrät es nur die Beschriftung.) */
export const jobDownload = (j: Pick<Job, "label" | "group">) => j.group === "download" || /^download\s/i.test(j.label)
/** Ist der Auftrag die Suche nach Updates (apt-get update)? Auch sie hat im Backend noch keine Gruppe. */
export const jobSuche = (j: Pick<Job, "label" | "group">) => j.group === "pruefen" || j.label === "Nach Updates suchen"
/**
* Darf man den Auftrag abbrechen? Nur Downloads: Sie laufen als eigener Prozess (`hf download`), den
* der Abbruch wirklich beendet. Updates und die Suche laufen als Shell-Kette (`bash -c …`), und ob
* ein Abbruch dort alles stoppt, ist nicht sicher — bei Updates liefen apt und die Update-Skripte
* weiter, während die Karte „Abgebrochen“ zeigte. Scheitert ein Update, rollen Motor und llama-swap
* selbst zurück.
*/
export const abbrechbar = (j: Pick<Job, "state" | "label" | "group">) => laeuftNoch(j) && jobDownload(j)
// Die Beschriftungen setzt das Backend (services/maintenance.py, routers/models.py) mit technischen
// Namen; die Oberfläche sagt Motor, llama-swap, Hermes und Betriebssystem.
const TITEL: [RegExp, string][] = [
[/^download\s+(.+)$/i, "Download $1"],
[/^OS-Update \(apt\)$/, "Update Betriebssystem"],
[/^Engine-Update \(llama\.cpp Vulkan\)$/, "Update Motor (llama.cpp)"],
[/^Router-Update \(llama-swap\)$/, "Update llama-swap"],
[/^Hermes-Agent-Update$/, "Update Hermes"],
]
const TEILE: [string, string][] = [
["Engine (llama.cpp)", "Motor"],
["Router (llama-swap)", "llama-swap"],
["Hermes-Agent", "Hermes"],
["OS (apt)", "Betriebssystem"],
]
/** Die Beschriftung eines Auftrags in den Worten der Oberfläche. Unbekanntes bleibt, wie es ist. */
export function jobTitel(j: Pick<Job, "label">): string {
for (const [muster, ersatz] of TITEL) if (muster.test(j.label)) return j.label.replace(muster, ersatz)
// „Alle aktualisieren (Engine (llama.cpp) → Router (llama-swap) → Hermes-Agent → OS (apt))“
if (j.label.startsWith("Alle aktualisieren")) return TEILE.reduce((t, [alt, neu]) => t.split(alt).join(neu), j.label)
return j.label
}
/** „noch etwa 6 min“ aus der geschätzten Restzeit in Sekunden. */
export function restzeit(s: number): string {
if (s < 60) return "noch unter einer Minute"
const min = Math.round(s / 60)
if (min < 60) return `noch etwa ${min} min`
const h = Math.floor(min / 60)
const rest = min % 60
return rest ? `noch etwa ${h} h ${rest} min` : `noch etwa ${h} h`
}
/** „Läuft seit 12 min“ / „Wartet seit ein paar Sekunden“ für Aufträge ohne Fortschrittsanzeige. */
export function laufText(j: Pick<Job, "state" | "started_at">, jetzt = Date.now() / 1000): string {
const s = Math.max(0, jetzt - j.started_at)
const min = Math.floor(s / 60)
const dauer = s < 60 ? "ein paar Sekunden" : min < 60 ? `${min} min` : `${Math.floor(min / 60)} h ${min % 60} min`
return `${j.state === "queued" ? "Wartet" : "Läuft"} seit ${dauer}`
}
/** „42 % · 12,3 GB von 29,1 GB · noch etwa 6 min“ — oder null, wenn der Auftrag keinen Fortschritt kennt. */
export function fortschrittText(j: Job): string | null {
if (j.progress == null) return null
const teile = [`${Math.round(j.progress)} %`]
if (j.total_bytes) teile.push(`${gbText(j.done_bytes ?? 0, 1)} von ${gbText(j.total_bytes, 1)}`)
if (j.eta_s != null && laeuftNoch(j)) teile.push(restzeit(j.eta_s))
return teile.join(" · ")
}
/** Letzte aussagekräftige Zeile des Protokolls (nicht die Befehlszeile „$ …“, die der Auftrag zuerst schreibt). */
export function letzteMeldung(j: Pick<Job, "log">): string | null {
for (let i = (j.log?.length ?? 0) - 1; i >= 0; i--) {
const z = j.log[i].trim()
if (z && !z.startsWith("$ ")) return z
}
return null
}
/**
* Welche Aufträge die Oberfläche zeigt: alle laufenden und wartenden (egal aus welcher Gruppe),
* dazu die drei zuletzt beendeten der letzten 24 Stunden — damit man sieht, wie es ausging.
*/
export function sichtbareJobs(jobs: Job[], jetzt = Date.now() / 1000): Job[] {
const laufend = jobs.filter(laeuftNoch).sort((a, b) => a.started_at - b.started_at)
const beendet = jobs
.filter((j) => !laeuftNoch(j) && !j.quittiert && jetzt - (j.finished_at ?? j.started_at) < 86_400)
.sort((a, b) => (b.finished_at ?? b.started_at) - (a.finished_at ?? a.started_at))
.slice(0, 3)
return [...laufend, ...beendet]
}
// --- Installieren -------------------------------------------------------------------
/** Wer eine Rolle heute innehat — für den Hinweis vor dem Installieren. */
export interface RollenInhaber {
name: string
/** Hängt ein Bild-Zwilling an seinen Gewichten (vision, coder-bild)? */
zwilling: boolean
/** Rollen, die das Modell außerdem trägt (der Coder auch „heavy“) — die behält es. */
weitere: string[]
}
/**
* Hinweis vor dem Installieren mit Rolle: Wen ersetzt das neue Modell? Nach dem Download wandert
* nur dieser eine Rollen-Alias um (services/llamaswap.set_role_alias): Das bisherige Modell bleibt
* auf der Platte, behält seine übrigen Rollen, und sein Bild-Zwilling zeigt weiter auf dessen Gewichte.
*/
export function rollenErsatzText(neu: string, rolle: string, bisher: RollenInhaber | undefined): string {
if (!bisher) return `Nach dem Download übernimmt ${neu} die Rolle „${rolle}“.`
const behaelt = bisher.weitere.length
? ` Die Rolle${bisher.weitere.length === 1 ? "" : "n"} „${bisher.weitere.join("“, „")}“ behält es.`
: ""
return (
`Achtung: Nach dem Download übernimmt ${neu} die Rolle „${rolle}“ und ersetzt damit ${bisher.name}. ` +
`Das bisherige Modell bleibt auf der Platte.${behaelt}` +
(bisher.zwilling ? " Bilder liest vorerst weiter sein Bild-Zwilling, also noch das bisherige Modell." : "")
)
}
/**
* Was das Eintragen in llama-swap unterwegs anrichtet: llama-swap läuft mit `--watch-config` und lädt
* bei jeder Änderung sofort neu (services/warmer.py, deploy/coder-vollkontext.sh) — laufende Antworten
* brechen ab, geladene Modelle fliegen raus. Mit Rolle schreibt das Backend nach dem Download noch einmal.
*/
export function installierenUnterbrechung(mitRolle: boolean): string {
return (
"Kurze Unterbrechung: Beim Eintragen lädt llama-swap sofort neu. Was Lucy oder OpenChamber gerade " +
"schreiben, bricht ab, und die Modelle werden neu geladen." +
(mitRolle ? " Mit Rolle passiert das nach dem Download noch einmal." : "")
)
}