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>
177 lines
8.9 KiB
TypeScript
177 lines
8.9 KiB
TypeScript
// 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." : "")
|
||
)
|
||
}
|