modelle: Installieren mit Rueckfrage, Doppelklick-Sperre und Rollen-Hinweis, Auftraege mit Fortschritt
- "Installieren" fragt vorher nach (Modell, Quantisierung, Groesse falls bekannt) und sperrt, solange der Start unterwegs ist - ein Doppelklick startet keinen zweiten Download. - Mit Rolle "Coder" steht deutlich da, welches Modell ersetzt wird (unter der Auswahl und in der Rueckfrage), dass es auf der Platte bleibt und der Bild-Zwilling vorerst beim bisherigen Modell liest. - Die Modelle-Seite zeigt laufende Downloads als Auftraege mit Fortschritt und Abbrechen. - Suche und Quantisierungen haben Lade-, Fehler- und Leerzustaende; Nutzung, Radar und Aufraeumen melden Fehler statt still zu verschwinden. Ein 404 heisst "Radar nicht eingerichtet", alles andere ist ein Fehler. Scheitert nur ein spaeteres Nachladen, bleibt der letzte Stand der Modell-Liste stehen. - Anzeige-Logik der Wartungsseiten liegt jetzt in lib/wartung.ts statt in anzeige.ts, die im Start-Buendel steckt. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
5ddc952ecd
commit
3c1d8bf88f
@@ -1,5 +1,5 @@
|
||||
import { useJobAbbrechen, useJobs } from "@/lib/abfragen"
|
||||
import { jobTitel, sichtbareJobs } from "@/lib/anzeige"
|
||||
import { jobTitel, sichtbareJobs } from "@/lib/wartung"
|
||||
import { JobKarte } from "./JobKarte"
|
||||
import { Panel } from "./Panel"
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react"
|
||||
import { sichtbareJobs } from "@/lib/anzeige"
|
||||
import { sichtbareJobs } from "@/lib/wartung"
|
||||
import type { Job } from "@/lib/typen"
|
||||
import { JobKarte } from "./JobKarte"
|
||||
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { useState } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
abbrechbar, fortschrittText, jobDownload, jobTitel, laeuftNoch, laufText, letzteMeldung,
|
||||
} from "@/lib/anzeige"
|
||||
import { laeuftNoch } from "@/lib/anzeige"
|
||||
import { abbrechbar, fortschrittText, jobDownload, jobTitel, laufText, letzteMeldung } from "@/lib/wartung"
|
||||
import type { Job, JobZustand } from "@/lib/typen"
|
||||
import { flugplanZeit } from "@/lib/zeit"
|
||||
import { Bestaetigen } from "./Bestaetigen"
|
||||
|
||||
+2
-107
@@ -1,8 +1,7 @@
|
||||
// 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 { AktionsErgebnis, Job, Modell, Start, UpdateDetails } from "./typen"
|
||||
import { gbText } from "./zeit"
|
||||
import type { AktionsErgebnis, Job, Modell, Start } from "./typen"
|
||||
|
||||
// --- Rundinstrument -----------------------------------------------------------------
|
||||
// 240°-Bogen wie ein Zeigerinstrument, Mittelpunkt (100|95) in einer 200×150-Zeichnung.
|
||||
@@ -55,44 +54,7 @@ export function hauptleuchte(z: Start["zustand"]) {
|
||||
: { art: "gelb" as const, oben: "Achtung", mitte, unten: "Drücken zum Ansehen" }
|
||||
}
|
||||
|
||||
// --- 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}`
|
||||
}
|
||||
// --- Aktionen und Aufträge (auch im Start-Bündel gebraucht) ----------------------------
|
||||
|
||||
/** Warum eine Aktion nicht geklappt hat — die Endpunkte legen den Grund in verschiedene Felder. */
|
||||
export function fehlerText(e: AktionsErgebnis | null | undefined): string {
|
||||
@@ -104,76 +66,9 @@ export function fehlerText(e: AktionsErgebnis | null | undefined): string {
|
||||
return e?.detail || e?.err || e?.error || e?.text || "Das hat nicht geklappt."
|
||||
}
|
||||
|
||||
// --- Aufträge (services/jobengine.py) ---------------------------------------------------
|
||||
|
||||
/** Wartet oder läuft der Auftrag noch? */
|
||||
export const laeuftNoch = (j: Pick<Job, "state">) => j.state === "running" || j.state === "queued"
|
||||
|
||||
/**
|
||||
* Darf man den Auftrag abbrechen? Updates nicht: Der Abbruch beendet nur die äußere Shell, apt
|
||||
* und die Update-Skripte liefen weiter, und die Karte zeigte fälschlich „Abgebrochen“. Scheitert
|
||||
* ein Update, rollen Motor und llama-swap selbst zurück.
|
||||
*/
|
||||
export const abbrechbar = (j: Pick<Job, "state" | "group">) => laeuftNoch(j) && j.group !== "maintenance"
|
||||
|
||||
/** Die Beschriftung eines Auftrags auf Deutsch („download org/repo“ heißt im Backend noch englisch). */
|
||||
export function jobTitel(j: Pick<Job, "label">): string {
|
||||
const m = /^download\s+(.+)$/i.exec(j.label)
|
||||
return m ? `Download ${m[1]}` : j.label
|
||||
}
|
||||
|
||||
/** 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)
|
||||
|
||||
/** „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) && 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]
|
||||
}
|
||||
|
||||
// --- Modelle ------------------------------------------------------------------------
|
||||
|
||||
/** Rolle eines Modells aus seinen llama-swap-Aliasen. */
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
// 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) ---------------------------------------------------
|
||||
|
||||
/**
|
||||
* Darf man den Auftrag abbrechen? Updates nicht: Der Abbruch beendet nur die äußere Shell, apt
|
||||
* und die Update-Skripte liefen weiter, und die Karte zeigte fälschlich „Abgebrochen“. Scheitert
|
||||
* ein Update, rollen Motor und llama-swap selbst zurück.
|
||||
*/
|
||||
export const abbrechbar = (j: Pick<Job, "state" | "group">) => laeuftNoch(j) && j.group !== "maintenance"
|
||||
|
||||
/** Die Beschriftung eines Auftrags auf Deutsch („download org/repo“ heißt im Backend noch englisch). */
|
||||
export function jobTitel(j: Pick<Job, "label">): string {
|
||||
const m = /^download\s+(.+)$/i.exec(j.label)
|
||||
return m ? `Download ${m[1]}` : j.label
|
||||
}
|
||||
|
||||
/** 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)
|
||||
|
||||
/** „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) && 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 -------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Hinweis vor dem Installieren mit Rolle: Wen ersetzt das neue Modell? Nach dem Download wandert
|
||||
* nur der Rollen-Alias um (services/llamaswap.set_role) — das bisherige Modell bleibt auf der Platte
|
||||
* und sein Bild-Zwilling zeigt weiter auf dessen Gewichte.
|
||||
*/
|
||||
export function rollenErsatzText(
|
||||
neu: string,
|
||||
rolle: string,
|
||||
bisher: { name: string; zwilling: boolean } | undefined,
|
||||
): string {
|
||||
if (!bisher) return `Nach dem Download übernimmt ${neu} die Rolle „${rolle}“.`
|
||||
return (
|
||||
`Achtung: Nach dem Download übernimmt ${neu} die Rolle „${rolle}“ und ersetzt damit ${bisher.name}. ` +
|
||||
"Das bisherige Modell bleibt auf der Platte." +
|
||||
(bisher.zwilling ? " Bilder liest vorerst weiter sein Bild-Zwilling, also noch das bisherige Modell." : "")
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { flugplanZeit, gbText, laufzeit, seit, vor } from "./zeit"
|
||||
import { rolleVon, temperaturFarbe, versionen } from "./anzeige"
|
||||
import { rolleVon, temperaturFarbe } from "./anzeige"
|
||||
import { versionen } from "./wartung"
|
||||
import type { Modell } from "./typen"
|
||||
|
||||
// Fester Bezugspunkt: Mittwoch, 23.09.2026, 20:45 Uhr Berliner Zeit.
|
||||
|
||||
@@ -1,21 +1,35 @@
|
||||
import { useState } from "react"
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { Bestaetigen } from "@/components/cockpit/Bestaetigen"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet"
|
||||
import { api, post } from "@/lib/api"
|
||||
import { melden } from "@/lib/meldungen"
|
||||
import { zahl } from "@/lib/zeit"
|
||||
import { useInstallieren } from "@/lib/abfragen"
|
||||
import { api } from "@/lib/api"
|
||||
import { rollenErsatzText } from "@/lib/wartung"
|
||||
import { gbText, zahl } from "@/lib/zeit"
|
||||
|
||||
interface Treffer { repo: string; downloads: number; likes: number }
|
||||
/** /api/hf/quants liefert heute nur die Namen. Kommen Größen dazu, zeigen Liste und Rückfrage sie an. */
|
||||
interface Quant { quant?: string; name?: string; size_bytes?: number; total_bytes?: number }
|
||||
|
||||
/** Rollen, die sich beim Installieren gleich vergeben lassen (Rollen-Alias → Name in der Oberfläche). */
|
||||
const ROLLEN: Record<string, string> = { coder: "Coder" }
|
||||
|
||||
/** Wer eine Rolle heute innehat: Modellname und ob ein Bild-Zwilling daran hängt. */
|
||||
export type RollenStand = Record<string, { name: string; zwilling: boolean } | undefined>
|
||||
|
||||
/** Modelle selbst auf Hugging Face suchen und installieren — zusätzlich zum Radar. */
|
||||
export function ModellSuche({ offen, onSchliessen }: { offen: boolean; onSchliessen: () => void }) {
|
||||
const qc = useQueryClient()
|
||||
export function ModellSuche({ offen, onSchliessen, rollenJetzt }: {
|
||||
offen: boolean
|
||||
onSchliessen: () => void
|
||||
rollenJetzt: RollenStand
|
||||
}) {
|
||||
const installieren = useInstallieren()
|
||||
const [eingabe, setEingabe] = useState("")
|
||||
const [begriff, setBegriff] = useState("")
|
||||
const [repo, setRepo] = useState<string | null>(null)
|
||||
const [rolle, setRolle] = useState("")
|
||||
const [auswahl, setAuswahl] = useState<{ quant: string; bytes?: number } | null>(null)
|
||||
|
||||
const treffer = useQuery({
|
||||
queryKey: ["hf-suche", begriff],
|
||||
@@ -28,20 +42,24 @@ export function ModellSuche({ offen, onSchliessen }: { offen: boolean; onSchlies
|
||||
enabled: !!repo,
|
||||
})
|
||||
|
||||
async function installieren(quant: string) {
|
||||
if (!repo) return
|
||||
try {
|
||||
await post("/api/models/install", { repo, quant, role: rolle || null })
|
||||
melden("erfolg", `${repo} (${quant}) wird heruntergeladen. Den Fortschritt siehst du unter Updates.`)
|
||||
qc.invalidateQueries({ queryKey: ["jobs"] })
|
||||
qc.invalidateQueries({ queryKey: ["models"] })
|
||||
onSchliessen()
|
||||
} catch (f) {
|
||||
melden("fehler", (f as Error).message)
|
||||
}
|
||||
}
|
||||
|
||||
const quantListe = (quants.data?.quants ?? []).map((q) => (typeof q === "string" ? { quant: q } : q))
|
||||
const rollenName = rolle ? ROLLEN[rolle] ?? rolle : null
|
||||
const ersatz = rollenName && repo ? rollenErsatzText(repo, rollenName, rollenJetzt[rolle]) : null
|
||||
|
||||
function starten() {
|
||||
// Die Sperre gegen den Doppelklick: Solange ein Start unterwegs ist, geht kein zweiter los.
|
||||
if (!repo || !auswahl || installieren.isPending) return
|
||||
installieren.mutate(
|
||||
{ repo, quant: auswahl.quant, rolle: rolle || null },
|
||||
{
|
||||
onSuccess: (e) => {
|
||||
setAuswahl(null)
|
||||
if (e.ok !== false) onSchliessen()
|
||||
},
|
||||
onError: () => setAuswahl(null),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open={offen} onOpenChange={(o) => !o && onSchliessen()}>
|
||||
@@ -69,32 +87,61 @@ export function ModellSuche({ offen, onSchliessen }: { offen: boolean; onSchlies
|
||||
<Button type="submit">Suchen</Button>
|
||||
</form>
|
||||
<div className="flex flex-col px-4 pb-4">
|
||||
{treffer.isFetching && <p className="py-3 text-sm text-text-2">Suche läuft …</p>}
|
||||
{!repo && treffer.data?.results.map((t) => (
|
||||
<button
|
||||
key={t.repo}
|
||||
type="button"
|
||||
onClick={() => setRepo(t.repo)}
|
||||
className="flex min-h-11 items-center justify-between gap-3 border-t border-linie py-2 text-left hover:text-cyan"
|
||||
>
|
||||
<span className="ziffern min-w-0 text-sm break-all">{t.repo}</span>
|
||||
<span className="ziffern shrink-0 text-xs text-text-3">{zahl(t.downloads)} ↓</span>
|
||||
</button>
|
||||
))}
|
||||
{!repo && (
|
||||
<>
|
||||
{treffer.isFetching && <p role="status" className="py-3 text-sm text-text-2">Suche läuft …</p>}
|
||||
{!treffer.isFetching && treffer.isError && (
|
||||
<p role="alert" className="py-3 text-sm text-rot-text">Die Suche ging nicht: {treffer.error.message}</p>
|
||||
)}
|
||||
{!treffer.isFetching && treffer.data?.results.length === 0 && (
|
||||
<p className="py-3 text-sm text-text-2">
|
||||
Keine Treffer. Versuch einen anderen Suchbegriff — oder Hugging Face ist gerade nicht erreichbar.
|
||||
</p>
|
||||
)}
|
||||
{treffer.data?.results.map((t) => (
|
||||
<button
|
||||
key={t.repo}
|
||||
type="button"
|
||||
onClick={() => setRepo(t.repo)}
|
||||
className="flex min-h-11 items-center justify-between gap-3 border-t border-linie py-2 text-left hover:text-cyan"
|
||||
>
|
||||
<span className="ziffern min-w-0 text-sm break-all">{t.repo}</span>
|
||||
<span className="ziffern shrink-0 text-xs text-text-3">{zahl(t.downloads)} ↓</span>
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{repo && (
|
||||
<div className="flex flex-col gap-3 pt-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="ziffern text-sm break-all">{repo}</span>
|
||||
<span className="ziffern min-w-0 text-sm break-all">{repo}</span>
|
||||
<Button variant="ghost" size="sm" onClick={() => setRepo(null)}>Zurück</Button>
|
||||
</div>
|
||||
<label className="flex items-center gap-3 text-sm text-text-2">
|
||||
<label className="flex flex-wrap items-center gap-3 text-sm text-text-2">
|
||||
Rolle
|
||||
<select value={rolle} onChange={(e) => setRolle(e.target.value)} className="h-11 rounded-lg border border-linie-stark bg-erhaben px-3 text-foreground">
|
||||
<select
|
||||
value={rolle}
|
||||
onChange={(e) => setRolle(e.target.value)}
|
||||
className="h-11 min-w-0 rounded-lg border border-linie-stark bg-erhaben px-3 text-foreground"
|
||||
>
|
||||
<option value="">ohne Rolle (nur installieren)</option>
|
||||
<option value="coder">Coder</option>
|
||||
{Object.entries(ROLLEN).map(([wert, name]) => (
|
||||
<option key={wert} value={wert}>{name}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{quants.isFetching && <p className="text-sm text-text-2">Quantisierungen werden gelesen …</p>}
|
||||
{ersatz && (
|
||||
<p className="m-0 rounded-lg border border-bernstein/60 bg-bernstein-grund px-3 py-2.5 text-sm leading-relaxed text-bernstein">
|
||||
{ersatz}
|
||||
</p>
|
||||
)}
|
||||
{quants.isFetching && <p role="status" className="text-sm text-text-2">Quantisierungen werden gelesen …</p>}
|
||||
{!quants.isFetching && quants.isError && (
|
||||
<p role="alert" className="text-sm text-rot-text">Die Dateien dieses Modells ließen sich nicht lesen: {quants.error.message}</p>
|
||||
)}
|
||||
{!quants.isFetching && quants.data && quantListe.length === 0 && (
|
||||
<p className="text-sm text-text-2">Hier gibt es keine GGUF-Dateien, die sich installieren lassen.</p>
|
||||
)}
|
||||
{quantListe.map((q) => {
|
||||
const name = q.quant ?? q.name ?? "?"
|
||||
const bytes = q.total_bytes ?? q.size_bytes
|
||||
@@ -102,8 +149,15 @@ export function ModellSuche({ offen, onSchliessen }: { offen: boolean; onSchlies
|
||||
<div key={name} className="flex items-center justify-between gap-3 border-t border-linie py-2">
|
||||
<span className="ziffern text-sm">{name}</span>
|
||||
<span className="flex items-center gap-3">
|
||||
{bytes ? <span className="ziffern text-xs text-text-3">{(bytes / 1024 ** 3).toFixed(1).replace(".", ",")} GB</span> : null}
|
||||
<Button variant="outline" size="sm" onClick={() => installieren(name)}>Installieren</Button>
|
||||
{bytes ? <span className="ziffern text-xs text-text-3">{gbText(bytes, 1)}</span> : null}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={installieren.isPending}
|
||||
onClick={() => setAuswahl({ quant: name, bytes })}
|
||||
>
|
||||
Installieren
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
@@ -111,6 +165,24 @@ export function ModellSuche({ offen, onSchliessen }: { offen: boolean; onSchlies
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Bestaetigen
|
||||
offen={auswahl !== null}
|
||||
titel="Modell installieren?"
|
||||
text={auswahl && repo
|
||||
? `${repo} (${auswahl.quant}${auswahl.bytes ? `, ${gbText(auswahl.bytes, 1)}` : ""}) wird heruntergeladen und in llama-swap eingetragen.` +
|
||||
(auswahl.bytes ? "" : " Die Größe siehst du beim Auftrag, sobald der Download läuft.")
|
||||
: ""}
|
||||
knopf={installieren.isPending ? "Wird gestartet …" : "Installieren"}
|
||||
gesperrt={installieren.isPending}
|
||||
onNein={() => setAuswahl(null)}
|
||||
onJa={starten}
|
||||
>
|
||||
{ersatz && (
|
||||
<p className="m-0 rounded-lg border border-bernstein/60 bg-bernstein-grund px-3 py-2.5 text-[15px] leading-relaxed text-bernstein">
|
||||
{ersatz}
|
||||
</p>
|
||||
)}
|
||||
</Bestaetigen>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from "react"
|
||||
import { Search } from "lucide-react"
|
||||
import { Auftraege } from "@/components/cockpit/Auftraege"
|
||||
import { Bestaetigen } from "@/components/cockpit/Bestaetigen"
|
||||
import { Chip, type ChipArt, Zustandsfeld } from "@/components/cockpit/Chip"
|
||||
import { Panel } from "@/components/cockpit/Panel"
|
||||
@@ -9,6 +10,7 @@ import {
|
||||
useRadarSuche, useStart,
|
||||
} from "@/lib/abfragen"
|
||||
import { messwert, RADAR_STATUS_TEXT, rolleVon } from "@/lib/anzeige"
|
||||
import { ApiFehler } from "@/lib/api"
|
||||
import { useLetzteMetrik } from "@/lib/strom"
|
||||
import type { AufraeumKandidat, Modell, Nutzung, RadarKandidat, RadarTest } from "@/lib/typen"
|
||||
import { flugplanZeit, gb, gbText, zahl } from "@/lib/zeit"
|
||||
@@ -98,8 +100,18 @@ function Rolle({ titel, modell, zwilling, laeuft, nutzer }: {
|
||||
)
|
||||
}
|
||||
|
||||
function NutzungsPanel({ nutzung }: { nutzung: Nutzung | undefined }) {
|
||||
if (!nutzung) return null
|
||||
function NutzungsPanel({ nutzung, laedt, fehler }: { nutzung: Nutzung | undefined; laedt: boolean; fehler: string | null }) {
|
||||
if (!nutzung) {
|
||||
return (
|
||||
<Panel titel="Wer nutzt die Modelle">
|
||||
{laedt ? (
|
||||
<p className="text-[15px] text-text-2">Wird gelesen …</p>
|
||||
) : (
|
||||
<p role="alert" className="text-[15px] text-rot-text">Die Nutzung lässt sich gerade nicht lesen{fehler ? `: ${fehler}` : "."}</p>
|
||||
)}
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
const max = Math.max(1, ...nutzung.absender.map((a) => a.anfragen))
|
||||
const stunden = nutzung.letzte_24h.map((s) => Object.values(s.je_absender).reduce((a, b) => a + b, 0))
|
||||
const maxStunde = Math.max(1, ...stunden)
|
||||
@@ -226,10 +238,16 @@ function Kandidat({ k, test, heute }: { k: RadarKandidat; test: RadarTest | unde
|
||||
function RadarPanel({ hirn, coder }: { hirn: string | undefined; coder: string | undefined }) {
|
||||
const radar = useRadar()
|
||||
const suche = useRadarSuche()
|
||||
if (radar.isError) {
|
||||
if (radar.isError && !radar.data) {
|
||||
// 404 heißt: Diese Box hat kein Radar. Alles andere ist ein Fehler, kein fehlendes Radar.
|
||||
const fehlt = radar.error instanceof ApiFehler && radar.error.status === 404
|
||||
return (
|
||||
<Panel titel="Modell-Radar" id="radar">
|
||||
<p className="text-[15px] text-text-2">Das Radar ist auf dieser Box noch nicht eingerichtet.</p>
|
||||
<Panel titel="Modell-Radar" id="radar" className="scroll-mt-4">
|
||||
{fehlt ? (
|
||||
<p className="text-[15px] text-text-2">Das Radar ist auf dieser Box noch nicht eingerichtet.</p>
|
||||
) : (
|
||||
<p role="alert" className="text-[15px] text-rot-text">Das Radar antwortet gerade nicht: {radar.error.message}</p>
|
||||
)}
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
@@ -238,7 +256,12 @@ function RadarPanel({ hirn, coder }: { hirn: string | undefined; coder: string |
|
||||
<Panel
|
||||
titel="Modell-Radar"
|
||||
id="radar"
|
||||
rechts={<Button variant="info" size="sm" onClick={() => suche.mutate(undefined)} disabled={suche.isPending}>Jetzt suchen</Button>}
|
||||
className="scroll-mt-4"
|
||||
rechts={
|
||||
<Button variant="info" size="sm" onClick={() => suche.mutate(undefined)} disabled={suche.isPending}>
|
||||
{suche.isPending ? "Sucht …" : "Jetzt suchen"}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<p className="-mt-2 text-sm text-text-2">
|
||||
Testet nachts zwischen 00:30 und 02:30 selbst, höchstens einen Kandidaten pro Woche.
|
||||
@@ -281,7 +304,13 @@ function AufraeumenPanel() {
|
||||
const [frage, setFrage] = useState<AufraeumKandidat | null>(null)
|
||||
const kandidaten = liste.data?.kandidaten ?? []
|
||||
const summe = kandidaten.reduce((s, k) => s + k.groesse_gb, 0)
|
||||
if (liste.isError) return null
|
||||
if (liste.isError && !liste.data) {
|
||||
return (
|
||||
<Panel titel="Aufräumen">
|
||||
<p role="alert" className="text-[15px] text-rot-text">Was sich aufräumen ließe, lässt sich gerade nicht lesen: {liste.error.message}</p>
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Panel
|
||||
titel="Aufräumen"
|
||||
@@ -339,7 +368,8 @@ export function ModelleSeite() {
|
||||
const [suche, setSuche] = useState(false)
|
||||
|
||||
if (modelle.isPending) return <Zustandsfeld text="Modelle werden gelesen …" />
|
||||
if (modelle.error || !modelle.data) return <Zustandsfeld fehler text={`Die Modell-Liste fehlt: ${modelle.error?.message ?? "keine Daten"}`} />
|
||||
// Scheitert nur ein späteres Nachladen, bleibt der letzte Stand stehen.
|
||||
if (!modelle.data) return <Zustandsfeld fehler text={`Die Modell-Liste fehlt: ${modelle.error?.message ?? "keine Daten"}`} />
|
||||
|
||||
const liste = modelle.data.models
|
||||
const laufend = modelle.data.running
|
||||
@@ -356,19 +386,21 @@ export function ModelleSeite() {
|
||||
return (
|
||||
<>
|
||||
<Speicher modelle={liste} laufend={laufend} />
|
||||
<Auftraege alsPanel />
|
||||
<section aria-label="Rollen" className="grid gap-5 md:grid-cols-3 md:gap-6">
|
||||
<Rolle titel="Hirn" modell={hirn} zwilling={hirnZwilling} laeuft={!!hirn && laufend.includes(hirn.name)} nutzer="Lucy, NerdQuiz und OpenChamber für Kleinkram" />
|
||||
<Rolle titel="Coder" modell={coder} zwilling={coderZwilling} laeuft={!!coder && laufend.includes(coder.name)} nutzer="OpenChamber zum Planen und Bauen und Lucys Delegation" />
|
||||
<Rolle titel="Dritte Rolle" laeuft={false} nutzer="" />
|
||||
</section>
|
||||
<div className="grid gap-5 md:gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.3fr)]">
|
||||
<NutzungsPanel nutzung={nutzung.data} />
|
||||
<NutzungsPanel nutzung={nutzung.data} laedt={nutzung.isPending} fehler={nutzung.error?.message ?? null} />
|
||||
<RadarPanel hirn={hirn?.name} coder={coder?.name} />
|
||||
</div>
|
||||
<Panel
|
||||
titel="Weitere Einträge"
|
||||
rechts={<Button variant="outline" size="sm" onClick={() => setSuche(true)}><Search aria-hidden /> Modelle selbst suchen</Button>}
|
||||
>
|
||||
{weitere.length === 0 && <p className="text-[15px] text-text-2">Keine weiteren Einträge.</p>}
|
||||
<ul className="m-0 flex list-none flex-col p-0">
|
||||
{weitere.map((m) => (
|
||||
<li key={m.name} className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3 border-t border-linie py-2.5 sm:grid-cols-[minmax(0,1fr)_120px_160px]">
|
||||
@@ -385,7 +417,11 @@ export function ModelleSeite() {
|
||||
</ul>
|
||||
</Panel>
|
||||
<AufraeumenPanel />
|
||||
<ModellSuche offen={suche} onSchliessen={() => setSuche(false)} />
|
||||
<ModellSuche
|
||||
offen={suche}
|
||||
onSchliessen={() => setSuche(false)}
|
||||
rollenJetzt={{ coder: coder ? { name: coder.name, zwilling: !!coderZwilling } : undefined }}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -10,7 +10,8 @@ import {
|
||||
type UpdateLauf, useFreigeben, useJetztSichern, useJobs, useSicherungen, useStart, useUpdateDetails,
|
||||
useUpdateStarten, useUpdateVerlauf, useZurueckspielen,
|
||||
} from "@/lib/abfragen"
|
||||
import { detailsHabenNeues, laeuftNoch, pruefFehlerText, versionen } from "@/lib/anzeige"
|
||||
import { laeuftNoch } from "@/lib/anzeige"
|
||||
import { detailsHabenNeues, pruefFehlerText, versionen } from "@/lib/wartung"
|
||||
import type { Festgehalten, Sicherung, UpdateDetails } from "@/lib/typen"
|
||||
import { flugplanZeit } from "@/lib/zeit"
|
||||
|
||||
@@ -149,7 +150,7 @@ function BausteinZeile({ id, name, neuLautStart, fest, gesperrt, onStart, onFrei
|
||||
was = "Aktuell."
|
||||
} else {
|
||||
was = (
|
||||
<span className="flex flex-col items-start gap-2">
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
{fehler && <span className="text-bernstein">{pruefFehlerText(fehler)}</span>}
|
||||
{d && <Urteil d={d} />}
|
||||
{id === "os" ? (
|
||||
@@ -160,7 +161,7 @@ function BausteinZeile({ id, name, neuLautStart, fest, gesperrt, onStart, onFrei
|
||||
) : summary ? (
|
||||
<Zusammenfassung text={summary} />
|
||||
) : null}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -179,14 +180,14 @@ function BausteinZeile({ id, name, neuLautStart, fest, gesperrt, onStart, onFrei
|
||||
</span>
|
||||
<span role="cell" className="ziffern hidden text-sm text-text-2 md:block">{laeuftZelle}</span>
|
||||
<span role="cell" className="ziffern hidden text-sm md:block">{neuZelle}</span>
|
||||
<span role="cell" className="col-span-2 text-sm leading-relaxed text-text-2 md:col-span-1">
|
||||
<div role="cell" className="col-span-2 text-sm leading-relaxed text-text-2 md:col-span-1">
|
||||
{fest && (
|
||||
<span className="mb-1.5 block text-bernstein">
|
||||
Festgehalten seit {fest.seit} auf {fest.version}: {fest.grund}. Der Sonntags-Lauf überspringt {name}.
|
||||
</span>
|
||||
)}
|
||||
{was}
|
||||
</span>
|
||||
</div>
|
||||
{(fest || hatUpdate) && (
|
||||
<span role="cell" className="col-span-2 flex flex-wrap gap-2 md:col-span-1 md:flex-col md:items-end">
|
||||
{fest && (
|
||||
|
||||
Reference in New Issue
Block a user