durchsicht: Rueckfragen passen aufs kleine Handy, ehrlichere Texte, Abbrechen nur wo es stoppt
Befunde der Schlussdurchsicht (Attrappe des Backends, 320 x 568 bis 1440 px):
- Rueckfragen: Bei 320 x 568 lagen "Installieren"/"Abbrechen" unter dem Bildschirmrand
und liessen sich nicht erreichen (Dialog 791 px hoch, kein Scrollen). Jetzt hoechstens
Bildschirmhoehe, der Text scrollt, die Knoepfe bleiben unten stehen. Gilt fuer alle.
- Installieren: Die Rueckfrage sagt jetzt, dass llama-swap beim Eintragen sofort neu laedt
(--watch-config): laufende Antworten von Lucy/OpenChamber brechen ab, mit Rolle nach dem
Download noch einmal. Der Ersatz-Hinweis nennt, dass das bisherige Modell "heavy" behaelt.
- Abbrechen nur noch bei Downloads (eigener Prozess). Updates und die Suche laufen als
Shell-Kette, ein Abbruch stoppt dort nicht sicher alles.
- Auftragstitel in den Worten der Oberflaeche ("Update Motor (llama.cpp)" statt
"Engine-Update (llama.cpp Vulkan)"); "Laeuft seit ... min" zaehlt weiter, auch wenn der
Auftrag nichts schreibt; Ueberschriftenebene passend zum Panel.
- Updates: "Alles jetzt aktualisieren" richtet sich wie die Zeilen nach den frischen
Details (vorher nur nach dem bis zu 10 min alten Start-Stand); "Nach Neuem suchen"
sperrt, solange eine Suche laeuft (zwei apt-get update stolpern ueber die Sperre);
Rueckfragetext sagt genau, was gesichert wird.
- Nach Auftragsende nur nachladen, was betroffen ist (Download: Modelle/Platte; sonst
Versionen/Verlauf/Sicherungen) - die Update-Pruefung fragt GitHub, apt und git.
- Radar "Uebernehmen"/"Verwerfen" und "Endgueltig loeschen": Ja-Knopf gesperrt, solange
die Aktion laeuft, und ein zweiter Klick waehrend des Schliessens loest nichts mehr aus.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
83941847ab
commit
bc1106ddc8
@@ -20,6 +20,7 @@ export function Auftraege({ alsPanel = false }: { alsPanel?: boolean }) {
|
|||||||
<JobKarte
|
<JobKarte
|
||||||
key={j.id}
|
key={j.id}
|
||||||
job={j}
|
job={j}
|
||||||
|
ebene={alsPanel ? 3 : 4}
|
||||||
abbrechenGesperrt={abbrechen.isPending}
|
abbrechenGesperrt={abbrechen.isPending}
|
||||||
onAbbrechen={(job) => abbrechen.mutate({ id: job.id, titel: jobTitel(job) })}
|
onAbbrechen={(job) => abbrechen.mutate({ id: job.id, titel: jobTitel(job) })}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -23,13 +23,16 @@ export function Bestaetigen({ offen, titel, text, knopf, neinKnopf = "Abbrechen"
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Dialog open={offen} onOpenChange={(o) => !o && onNein()}>
|
<Dialog open={offen} onOpenChange={(o) => !o && onNein()}>
|
||||||
<DialogContent className="border-linie bg-panel sm:max-w-lg">
|
{/* Nie höher als der Bildschirm: Auf kleinen Handys (320 × 568) lagen die Knöpfe sonst außerhalb
|
||||||
|
und ließen sich nicht erreichen. Der Text scrollt, die Knöpfe bleiben unten stehen — bündig,
|
||||||
|
darum ohne Bodenpolster (sticky rechnet das Polster ein, darunter schien sonst Text durch). */}
|
||||||
|
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto border-linie bg-panel pb-0 sm:max-w-lg">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="schild pr-10 text-xl break-words">{titel}</DialogTitle>
|
<DialogTitle className="schild pr-10 text-xl break-words">{titel}</DialogTitle>
|
||||||
<DialogDescription className="text-[15px] leading-relaxed text-text-2">{text}</DialogDescription>
|
<DialogDescription className="text-[15px] leading-relaxed text-text-2">{text}</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
{children}
|
{children}
|
||||||
<DialogFooter className="gap-2 sm:gap-2">
|
<DialogFooter className="sticky bottom-0 mb-0 gap-2 bg-erhaben sm:gap-2">
|
||||||
<Button variant="outline" onClick={onNein}>
|
<Button variant="outline" onClick={onNein}>
|
||||||
{neinKnopf}
|
{neinKnopf}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { fireEvent, render, screen } from "@testing-library/react"
|
import { fireEvent, render, screen } from "@testing-library/react"
|
||||||
import { sichtbareJobs } from "@/lib/wartung"
|
import { jobTitel, sichtbareJobs } from "@/lib/wartung"
|
||||||
import type { Job } from "@/lib/typen"
|
import type { Job } from "@/lib/typen"
|
||||||
import { JobKarte } from "./JobKarte"
|
import { JobKarte } from "./JobKarte"
|
||||||
|
|
||||||
@@ -44,10 +44,15 @@ describe("JobKarte", () => {
|
|||||||
expect(screen.queryByRole("button", { name: "Abbrechen" })).not.toBeInTheDocument()
|
expect(screen.queryByRole("button", { name: "Abbrechen" })).not.toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it("bietet bei Updates kein Abbrechen an und zeigt bei Fehlern die letzte Meldung", () => {
|
it("bietet bei Updates und der Suche kein Abbrechen an und zeigt bei Fehlern die letzte Meldung", () => {
|
||||||
const { rerender } = render(
|
const { rerender } = render(
|
||||||
<JobKarte job={job({ label: "Hermes-Agent-Update", group: "maintenance" })} onAbbrechen={() => {}} />,
|
<JobKarte job={job({ label: "Hermes-Agent-Update", group: "maintenance" })} onAbbrechen={() => {}} />,
|
||||||
)
|
)
|
||||||
|
expect(screen.getByText("Update Hermes")).toBeInTheDocument()
|
||||||
|
expect(screen.queryByRole("button", { name: "Abbrechen" })).not.toBeInTheDocument()
|
||||||
|
|
||||||
|
// Die Suche läuft als Shell-Kette wie die Updates: kein Abbrechen, das womöglich nichts stoppt.
|
||||||
|
rerender(<JobKarte job={job({ label: "Nach Updates suchen" })} onAbbrechen={() => {}} />)
|
||||||
expect(screen.queryByRole("button", { name: "Abbrechen" })).not.toBeInTheDocument()
|
expect(screen.queryByRole("button", { name: "Abbrechen" })).not.toBeInTheDocument()
|
||||||
|
|
||||||
rerender(
|
rerender(
|
||||||
@@ -63,6 +68,18 @@ describe("JobKarte", () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("jobTitel", () => {
|
||||||
|
it("nennt die Bausteine wie die Oberfläche und lässt Unbekanntes stehen", () => {
|
||||||
|
expect(jobTitel({ label: "Engine-Update (llama.cpp Vulkan)" })).toBe("Update Motor (llama.cpp)")
|
||||||
|
expect(jobTitel({ label: "Router-Update (llama-swap)" })).toBe("Update llama-swap")
|
||||||
|
expect(jobTitel({ label: "OS-Update (apt)" })).toBe("Update Betriebssystem")
|
||||||
|
expect(jobTitel({ label: "Alle aktualisieren (Engine (llama.cpp) → Router (llama-swap) → Hermes-Agent → OS (apt))" }))
|
||||||
|
.toBe("Alle aktualisieren (Motor → llama-swap → Hermes → Betriebssystem)")
|
||||||
|
expect(jobTitel({ label: "download unsloth/Qwen3.8-27B-GGUF" })).toBe("Download unsloth/Qwen3.8-27B-GGUF")
|
||||||
|
expect(jobTitel({ label: "Nach Updates suchen" })).toBe("Nach Updates suchen")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe("sichtbareJobs", () => {
|
describe("sichtbareJobs", () => {
|
||||||
it("zeigt alle laufenden Aufträge jeder Gruppe und die drei zuletzt beendeten", () => {
|
it("zeigt alle laufenden Aufträge jeder Gruppe und die drei zuletzt beendeten", () => {
|
||||||
const liste = sichtbareJobs(
|
const liste = sichtbareJobs(
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import { cn } from "cn"
|
import { cn } from "cn"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { laeuftNoch } from "@/lib/anzeige"
|
import { laeuftNoch } from "@/lib/anzeige"
|
||||||
@@ -22,14 +22,30 @@ const BALKEN: Record<JobZustand, string> = {
|
|||||||
|
|
||||||
const zeitVon = (sekunden: number) => flugplanZeit(new Date(sekunden * 1000).toISOString())
|
const zeitVon = (sekunden: number) => flugplanZeit(new Date(sekunden * 1000).toISOString())
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die Uhrzeit in Sekunden, alle 30 s neu, solange `aktiv`. Ohne sie bliebe „Läuft seit 5 min“ stehen,
|
||||||
|
* wenn ein Auftrag eine Weile nichts schreibt: Die Karte zeichnet sonst nur neu, wenn sich der Auftrag ändert.
|
||||||
|
*/
|
||||||
|
function useJetzt(aktiv: boolean) {
|
||||||
|
const [jetzt, setJetzt] = useState(() => Date.now() / 1000)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!aktiv) return
|
||||||
|
const t = window.setInterval(() => setJetzt(Date.now() / 1000), 30_000)
|
||||||
|
return () => window.clearInterval(t)
|
||||||
|
}, [aktiv])
|
||||||
|
return jetzt
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ein Hintergrund-Auftrag als Karte: Titel und Zustand, bei Downloads der Fortschritt, bei Fehlern
|
* Ein Hintergrund-Auftrag als Karte: Titel und Zustand, bei Downloads der Fortschritt, bei Fehlern
|
||||||
* die letzte Meldung, auf Wunsch das Protokoll. Abbrechen nur, wo es wirklich stoppt (`abbrechbar`).
|
* die letzte Meldung, auf Wunsch das Protokoll. Abbrechen nur, wo es wirklich stoppt (`abbrechbar`).
|
||||||
|
* `ebene` ist die Überschriftenebene (unter einem Panel-Titel h3, unter einer Zwischenüberschrift h4).
|
||||||
*/
|
*/
|
||||||
export function JobKarte({ job, onAbbrechen, abbrechenGesperrt }: {
|
export function JobKarte({ job, onAbbrechen, abbrechenGesperrt, ebene = 4 }: {
|
||||||
job: Job
|
job: Job
|
||||||
onAbbrechen?: (job: Job) => void
|
onAbbrechen?: (job: Job) => void
|
||||||
abbrechenGesperrt?: boolean
|
abbrechenGesperrt?: boolean
|
||||||
|
ebene?: 3 | 4
|
||||||
}) {
|
}) {
|
||||||
const [frage, setFrage] = useState(false)
|
const [frage, setFrage] = useState(false)
|
||||||
const [protokoll, setProtokoll] = useState(false)
|
const [protokoll, setProtokoll] = useState(false)
|
||||||
@@ -38,13 +54,15 @@ export function JobKarte({ job, onAbbrechen, abbrechenGesperrt }: {
|
|||||||
const fortschritt = fortschrittText(job)
|
const fortschritt = fortschrittText(job)
|
||||||
const meldung = letzteMeldung(job)
|
const meldung = letzteMeldung(job)
|
||||||
const laeuft = laeuftNoch(job)
|
const laeuft = laeuftNoch(job)
|
||||||
|
const jetzt = useJetzt(laeuft)
|
||||||
const darfAbbrechen = !!onAbbrechen && abbrechbar(job)
|
const darfAbbrechen = !!onAbbrechen && abbrechbar(job)
|
||||||
const protokollZeilen = (job.log ?? []).slice(-12)
|
const protokollZeilen = (job.log ?? []).slice(-12)
|
||||||
|
const Titel = ebene === 3 ? "h3" : "h4"
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article aria-label={`Auftrag: ${titel}`} className="flex flex-col gap-2.5 rounded-xl border border-linie bg-erhaben/50 p-3.5">
|
<article aria-label={`Auftrag: ${titel}`} className="flex flex-col gap-2.5 rounded-xl border border-linie bg-erhaben/50 p-3.5">
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<h4 className="m-0 min-w-0 text-[15px] leading-snug font-semibold break-words">{titel}</h4>
|
<Titel className="m-0 min-w-0 text-[15px] leading-snug font-semibold break-words">{titel}</Titel>
|
||||||
<Chip art={zustand.art}>{zustand.text}</Chip>
|
<Chip art={zustand.art}>{zustand.text}</Chip>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -66,7 +84,7 @@ export function JobKarte({ job, onAbbrechen, abbrechenGesperrt }: {
|
|||||||
|
|
||||||
<p className="ziffern m-0 text-sm text-text-2">
|
<p className="ziffern m-0 text-sm text-text-2">
|
||||||
{laeuft
|
{laeuft
|
||||||
? fortschritt ?? laufText(job)
|
? fortschritt ?? laufText(job, jetzt)
|
||||||
: `${zustand.text} · ${zeitVon(job.finished_at ?? job.started_at)}`}
|
: `${zustand.text} · ${zeitVon(job.finished_at ?? job.started_at)}`}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { fireEvent, render, screen, waitFor, within } from "@testing-library/rea
|
|||||||
import { hinweisRueckfrage } from "@/lib/anzeige"
|
import { hinweisRueckfrage } from "@/lib/anzeige"
|
||||||
import { post } from "@/lib/api"
|
import { post } from "@/lib/api"
|
||||||
import type { Hinweis } from "@/lib/typen"
|
import type { Hinweis } from "@/lib/typen"
|
||||||
import { rollenErsatzText } from "@/lib/wartung"
|
import { installierenUnterbrechung, rollenErsatzText } from "@/lib/wartung"
|
||||||
import { Bestaetigen } from "./Bestaetigen"
|
import { Bestaetigen } from "./Bestaetigen"
|
||||||
import { Checkliste } from "./Checkliste"
|
import { Checkliste } from "./Checkliste"
|
||||||
|
|
||||||
@@ -63,11 +63,18 @@ describe("Bestaetigen", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe("rollenErsatzText", () => {
|
describe("rollenErsatzText", () => {
|
||||||
it("sagt deutlich, welches Modell die Rolle verliert", () => {
|
it("sagt deutlich, welches Modell die Rolle verliert und was es behält", () => {
|
||||||
expect(rollenErsatzText("org/Neu-GGUF", "Coder", { name: "Qwen3.8-27B-Heretic", zwilling: true })).toBe(
|
expect(rollenErsatzText("org/Neu-GGUF", "Coder", { name: "Qwen3.8-27B-Heretic", zwilling: true, weitere: ["heavy"] })).toBe(
|
||||||
"Achtung: Nach dem Download übernimmt org/Neu-GGUF die Rolle „Coder“ und ersetzt damit Qwen3.8-27B-Heretic. " +
|
"Achtung: Nach dem Download übernimmt org/Neu-GGUF die Rolle „Coder“ und ersetzt damit Qwen3.8-27B-Heretic. " +
|
||||||
"Das bisherige Modell bleibt auf der Platte. Bilder liest vorerst weiter sein Bild-Zwilling, also noch das bisherige Modell.",
|
"Das bisherige Modell bleibt auf der Platte. Die Rolle „heavy“ behält es. " +
|
||||||
|
"Bilder liest vorerst weiter sein Bild-Zwilling, also noch das bisherige Modell.",
|
||||||
)
|
)
|
||||||
expect(rollenErsatzText("org/Neu-GGUF", "Coder", undefined)).toBe("Nach dem Download übernimmt org/Neu-GGUF die Rolle „Coder“.")
|
expect(rollenErsatzText("org/Neu-GGUF", "Coder", undefined)).toBe("Nach dem Download übernimmt org/Neu-GGUF die Rolle „Coder“.")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it("warnt vor der Unterbrechung durch das Neuladen von llama-swap", () => {
|
||||||
|
expect(installierenUnterbrechung(false)).toContain("bricht ab")
|
||||||
|
expect(installierenUnterbrechung(false)).not.toContain("noch einmal")
|
||||||
|
expect(installierenUnterbrechung(true)).toContain("Mit Rolle passiert das nach dem Download noch einmal.")
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
// Takte: Steht der Live-Strom, meldet er Änderungen selbst — die Abfragen sind dann nur
|
// Takte: Steht der Live-Strom, meldet er Änderungen selbst — die Abfragen sind dann nur
|
||||||
// Sicherheitsnetz und laufen fünfmal seltener.
|
// Sicherheitsnetz und laufen fünfmal seltener.
|
||||||
|
|
||||||
import { type QueryClient, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
import { type QueryClient, useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||||
import { api, post } from "./api"
|
import { api, post } from "./api"
|
||||||
import { fehlerText, laeuftNoch } from "./anzeige"
|
import { fehlerText, laeuftNoch } from "./anzeige"
|
||||||
import { melden } from "./meldungen"
|
import { melden } from "./meldungen"
|
||||||
@@ -15,14 +15,24 @@ import { gbText } from "./zeit"
|
|||||||
|
|
||||||
const takt = (ms: number) => () => (stromVerbunden() ? ms * 5 : ms)
|
const takt = (ms: number) => () => (stromVerbunden() ? ms * 5 : ms)
|
||||||
|
|
||||||
/** Was ein beendeter Auftrag verändert haben kann: Versionen, Modelle, Sicherungen, Platte. */
|
/**
|
||||||
const NACH_AUFTRAG = ["start", "update-details", "updates-verlauf", "models", "sicherungen", "aufraeumen"]
|
* Ist seit der letzten Abfrage ein Auftrag fertig geworden, lesen die betroffenen Ansichten neu:
|
||||||
|
* nach einem Download die Modelle und die Platte, nach Updates und der Suche die Versionen, der
|
||||||
/** Ist seit der letzten Abfrage ein Auftrag fertig geworden, lesen die betroffenen Ansichten neu. */
|
* Verlauf und die Sicherungen. Gezielt, weil die Update-Prüfung teuer ist (GitHub, apt, git fetch).
|
||||||
|
*/
|
||||||
function auftragsEndeMelden(qc: QueryClient, neu: Job[]) {
|
function auftragsEndeMelden(qc: QueryClient, neu: Job[]) {
|
||||||
const alt = qc.getQueryData<{ jobs: Job[] }>(["jobs"])?.jobs ?? []
|
const alt = qc.getQueryData<{ jobs: Job[] }>(["jobs"])?.jobs ?? []
|
||||||
const ebenFertig = neu.some((j) => !laeuftNoch(j) && alt.some((a) => a.id === j.id && laeuftNoch(a)))
|
const ebenFertig = neu.filter((j) => !laeuftNoch(j) && alt.some((a) => a.id === j.id && laeuftNoch(a)))
|
||||||
if (ebenFertig) for (const k of NACH_AUFTRAG) qc.invalidateQueries({ queryKey: [k] })
|
if (ebenFertig.length === 0) return
|
||||||
|
const schluessel = new Set(["start"])
|
||||||
|
for (const j of ebenFertig) {
|
||||||
|
// Wie wartung.jobDownload — hier ausgeschrieben, damit wartung.ts nicht ins Start-Bündel wandert.
|
||||||
|
const download = j.group === "download" || /^download\s/i.test(j.label)
|
||||||
|
for (const k of download ? ["models", "aufraeumen"] : ["update-details", "updates-verlauf", "sicherungen"]) {
|
||||||
|
schluessel.add(k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const k of schluessel) qc.invalidateQueries({ queryKey: [k] })
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useStart = () =>
|
export const useStart = () =>
|
||||||
@@ -66,12 +76,18 @@ export const useSicherungen = () =>
|
|||||||
queryFn: () => api<{ available: boolean; backups: Sicherung[] }>("/api/zeitmaschine"),
|
queryFn: () => api<{ available: boolean; backups: Sicherung[] }>("/api/zeitmaschine"),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const useUpdateDetails = (art: string | null) =>
|
/**
|
||||||
useQuery({
|
* Was die Updates der Bausteine bringen würden — je Baustein eine eigene Abfrage, damit ein langsamer
|
||||||
|
* (Hermes: git fetch bis 25 s) die anderen nicht aufhält. Die Prüfung fragt GitHub, apt und git,
|
||||||
|
* darum gilt ein Ergebnis zehn Minuten.
|
||||||
|
*/
|
||||||
|
export const useUpdateDetails = (arten: readonly string[]) =>
|
||||||
|
useQueries({
|
||||||
|
queries: arten.map((art) => ({
|
||||||
queryKey: ["update-details", art],
|
queryKey: ["update-details", art],
|
||||||
queryFn: () => api<UpdateDetails>(`/api/maintenance/update-details?kind=${art}`),
|
queryFn: () => api<UpdateDetails>(`/api/maintenance/update-details?kind=${encodeURIComponent(art)}`),
|
||||||
enabled: !!art,
|
|
||||||
staleTime: 600_000,
|
staleTime: 600_000,
|
||||||
|
})),
|
||||||
})
|
})
|
||||||
|
|
||||||
export interface UpdateLauf {
|
export interface UpdateLauf {
|
||||||
|
|||||||
+65
-21
@@ -46,22 +46,45 @@ export function pruefFehlerText(fehler: string): string {
|
|||||||
|
|
||||||
// --- Aufträge (services/jobengine.py) ---------------------------------------------------
|
// --- 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.) */
|
/** 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)
|
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. */
|
/** „noch etwa 6 min“ aus der geschätzten Restzeit in Sekunden. */
|
||||||
export function restzeit(s: number): string {
|
export function restzeit(s: number): string {
|
||||||
if (s < 60) return "noch unter einer Minute"
|
if (s < 60) return "noch unter einer Minute"
|
||||||
@@ -113,20 +136,41 @@ export function sichtbareJobs(jobs: Job[], jetzt = Date.now() / 1000): Job[] {
|
|||||||
|
|
||||||
// --- Installieren -------------------------------------------------------------------
|
// --- 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
|
* 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
|
* nur dieser eine Rollen-Alias um (services/llamaswap.set_role_alias): Das bisherige Modell bleibt
|
||||||
* und sein Bild-Zwilling zeigt weiter auf dessen Gewichte.
|
* auf der Platte, behält seine übrigen Rollen, und sein Bild-Zwilling zeigt weiter auf dessen Gewichte.
|
||||||
*/
|
*/
|
||||||
export function rollenErsatzText(
|
export function rollenErsatzText(neu: string, rolle: string, bisher: RollenInhaber | undefined): string {
|
||||||
neu: string,
|
|
||||||
rolle: string,
|
|
||||||
bisher: { name: string; zwilling: boolean } | undefined,
|
|
||||||
): string {
|
|
||||||
if (!bisher) return `Nach dem Download übernimmt ${neu} die Rolle „${rolle}“.`
|
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 (
|
return (
|
||||||
`Achtung: Nach dem Download übernimmt ${neu} die Rolle „${rolle}“ und ersetzt damit ${bisher.name}. ` +
|
`Achtung: Nach dem Download übernimmt ${neu} die Rolle „${rolle}“ und ersetzt damit ${bisher.name}. ` +
|
||||||
"Das bisherige Modell bleibt auf der Platte." +
|
`Das bisherige Modell bleibt auf der Platte.${behaelt}` +
|
||||||
(bisher.zwilling ? " Bilder liest vorerst weiter sein Bild-Zwilling, also noch das bisherige Modell." : "")
|
(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." : "")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,18 +5,21 @@ import { Button } from "@/components/ui/button"
|
|||||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet"
|
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet"
|
||||||
import { useInstallieren } from "@/lib/abfragen"
|
import { useInstallieren } from "@/lib/abfragen"
|
||||||
import { api } from "@/lib/api"
|
import { api } from "@/lib/api"
|
||||||
import { rollenErsatzText } from "@/lib/wartung"
|
import { installierenUnterbrechung, type RollenInhaber, rollenErsatzText } from "@/lib/wartung"
|
||||||
import { gbText, zahl } from "@/lib/zeit"
|
import { gbText, zahl } from "@/lib/zeit"
|
||||||
|
|
||||||
interface Treffer { repo: string; downloads: number; likes: number }
|
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. */
|
/** /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 }
|
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). */
|
/**
|
||||||
|
* Rollen, die sich beim Installieren gleich vergeben lassen (Rollen-Alias → Name in der Oberfläche).
|
||||||
|
* Das Hirn steht hier (wie bisher) nicht zur Wahl; Hirn-Kandidaten testet das Radar nachts, bevor man sie übernimmt.
|
||||||
|
*/
|
||||||
const ROLLEN: Record<string, string> = { coder: "Coder" }
|
const ROLLEN: Record<string, string> = { coder: "Coder" }
|
||||||
|
|
||||||
/** Wer eine Rolle heute innehat: Modellname und ob ein Bild-Zwilling daran hängt. */
|
/** Wer die Rollen heute innehat (Schlüssel = Rollen-Alias). */
|
||||||
export type RollenStand = Record<string, { name: string; zwilling: boolean } | undefined>
|
export type RollenStand = Record<string, RollenInhaber | undefined>
|
||||||
|
|
||||||
/** Modelle selbst auf Hugging Face suchen und installieren — zusätzlich zum Radar. */
|
/** Modelle selbst auf Hugging Face suchen und installieren — zusätzlich zum Radar. */
|
||||||
export function ModellSuche({ offen, onSchliessen, rollenJetzt }: {
|
export function ModellSuche({ offen, onSchliessen, rollenJetzt }: {
|
||||||
@@ -188,6 +191,7 @@ export function ModellSuche({ offen, onSchliessen, rollenJetzt }: {
|
|||||||
{ersatz}
|
{ersatz}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
<p className="m-0 text-[15px] leading-relaxed text-text-2">{installierenUnterbrechung(!!rolle)}</p>
|
||||||
</Bestaetigen>
|
</Bestaetigen>
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
|
|||||||
@@ -214,8 +214,10 @@ function Kandidat({ k, test, heute }: { k: RadarKandidat; test: RadarTest | unde
|
|||||||
? `${k.name} wird Lucys Hirn${heute ? ` und löst ${heute} ab` : ""}. NerdQuiz und OpenChamber nutzen ab dann ebenfalls das neue Modell. Hermes startet dafür kurz neu, Lucy ist etwa eine Minute nicht erreichbar. Das alte Modell bleibt auf der Platte.`
|
? `${k.name} wird Lucys Hirn${heute ? ` und löst ${heute} ab` : ""}. NerdQuiz und OpenChamber nutzen ab dann ebenfalls das neue Modell. Hermes startet dafür kurz neu, Lucy ist etwa eine Minute nicht erreichbar. Das alte Modell bleibt auf der Platte.`
|
||||||
: `${k.name} wird der Coder${heute ? ` und löst ${heute} ab` : ""}. OpenChamber plant und baut ab dann damit. Das alte Modell bleibt auf der Platte.`}
|
: `${k.name} wird der Coder${heute ? ` und löst ${heute} ab` : ""}. OpenChamber plant und baut ab dann damit. Das alte Modell bleibt auf der Platte.`}
|
||||||
knopf="Übernehmen"
|
knopf="Übernehmen"
|
||||||
|
gesperrt={aktion.isPending}
|
||||||
onNein={() => setFrage(null)}
|
onNein={() => setFrage(null)}
|
||||||
onJa={() => {
|
onJa={() => {
|
||||||
|
if (frage !== "uebernehmen") return // zweiter Klick eines Doppelklicks, der Dialog schließt schon
|
||||||
aktion.mutate({ id: k.id, aktion: "uebernehmen", name: k.name })
|
aktion.mutate({ id: k.id, aktion: "uebernehmen", name: k.name })
|
||||||
setFrage(null)
|
setFrage(null)
|
||||||
}}
|
}}
|
||||||
@@ -226,8 +228,10 @@ function Kandidat({ k, test, heute }: { k: RadarKandidat; test: RadarTest | unde
|
|||||||
titel={`${k.name} verwerfen?`}
|
titel={`${k.name} verwerfen?`}
|
||||||
text="Die heruntergeladenen Dateien werden gelöscht, und das Radar testet dieses Modell nicht noch einmal."
|
text="Die heruntergeladenen Dateien werden gelöscht, und das Radar testet dieses Modell nicht noch einmal."
|
||||||
knopf="Verwerfen"
|
knopf="Verwerfen"
|
||||||
|
gesperrt={aktion.isPending}
|
||||||
onNein={() => setFrage(null)}
|
onNein={() => setFrage(null)}
|
||||||
onJa={() => {
|
onJa={() => {
|
||||||
|
if (frage !== "verwerfen") return
|
||||||
aktion.mutate({ id: k.id, aktion: "verwerfen", name: k.name })
|
aktion.mutate({ id: k.id, aktion: "verwerfen", name: k.name })
|
||||||
setFrage(null)
|
setFrage(null)
|
||||||
}}
|
}}
|
||||||
@@ -353,6 +357,7 @@ function AufraeumenPanel() {
|
|||||||
titel={`${frage?.name ?? ""} löschen?`}
|
titel={`${frage?.name ?? ""} löschen?`}
|
||||||
text={`${frage?.hinweis ?? ""} Es werden ${frage?.groesse_gb.toLocaleString("de-DE") ?? ""} GB frei. Das lässt sich nicht rückgängig machen — zurück ginge es nur mit einem neuen Download.`}
|
text={`${frage?.hinweis ?? ""} Es werden ${frage?.groesse_gb.toLocaleString("de-DE") ?? ""} GB frei. Das lässt sich nicht rückgängig machen — zurück ginge es nur mit einem neuen Download.`}
|
||||||
knopf="Endgültig löschen"
|
knopf="Endgültig löschen"
|
||||||
|
gesperrt={loeschen.isPending}
|
||||||
onNein={() => setFrage(null)}
|
onNein={() => setFrage(null)}
|
||||||
onJa={() => {
|
onJa={() => {
|
||||||
if (frage) loeschen.mutate(frage)
|
if (frage) loeschen.mutate(frage)
|
||||||
@@ -432,7 +437,11 @@ export function ModelleSeite() {
|
|||||||
<ModellSuche
|
<ModellSuche
|
||||||
offen={suche}
|
offen={suche}
|
||||||
onSchliessen={() => setSuche(false)}
|
onSchliessen={() => setSuche(false)}
|
||||||
rollenJetzt={{ coder: coder ? { name: coder.name, zwilling: !!coderZwilling } : undefined }}
|
rollenJetzt={{
|
||||||
|
coder: coder
|
||||||
|
? { name: coder.name, zwilling: !!coderZwilling, weitere: coder.aliases.filter((a) => a.toLowerCase() !== "coder") }
|
||||||
|
: undefined,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
|
import type { UseQueryResult } from "@tanstack/react-query"
|
||||||
import { ChevronRight } from "lucide-react"
|
import { ChevronRight } from "lucide-react"
|
||||||
import { cn } from "cn"
|
import { cn } from "cn"
|
||||||
import { Auftraege } from "@/components/cockpit/Auftraege"
|
import { Auftraege } from "@/components/cockpit/Auftraege"
|
||||||
@@ -11,7 +12,7 @@ import {
|
|||||||
useUpdateStarten, useUpdateVerlauf, useZurueckspielen,
|
useUpdateStarten, useUpdateVerlauf, useZurueckspielen,
|
||||||
} from "@/lib/abfragen"
|
} from "@/lib/abfragen"
|
||||||
import { laeuftNoch } from "@/lib/anzeige"
|
import { laeuftNoch } from "@/lib/anzeige"
|
||||||
import { detailsHabenNeues, pruefFehlerText, versionen } from "@/lib/wartung"
|
import { detailsHabenNeues, jobSuche, pruefFehlerText, versionen } from "@/lib/wartung"
|
||||||
import type { Festgehalten, Sicherung, UpdateDetails } from "@/lib/typen"
|
import type { Festgehalten, Sicherung, UpdateDetails } from "@/lib/typen"
|
||||||
import { flugplanZeit } from "@/lib/zeit"
|
import { flugplanZeit } from "@/lib/zeit"
|
||||||
|
|
||||||
@@ -110,9 +111,12 @@ function zusammenfassung(summary: string | undefined): string | null {
|
|||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
function BausteinZeile({ id, name, neuLautStart, fest, gesperrt, onStart, onFreigeben }: {
|
function BausteinZeile({ id, name, details, hatUpdate, neuLautStart, fest, gesperrt, onStart, onFreigeben }: {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
|
details: UseQueryResult<UpdateDetails, Error>
|
||||||
|
/** Gibt es Neues? Frische Details schlagen den gecachten Stand der Startseite (siehe UpdatesSeite). */
|
||||||
|
hatUpdate: boolean
|
||||||
/** Was /api/start über diesen Baustein sagt („3 Pakete“, „neuer Build“), undefined = nichts Neues. */
|
/** Was /api/start über diesen Baustein sagt („3 Pakete“, „neuer Build“), undefined = nichts Neues. */
|
||||||
neuLautStart: string | undefined
|
neuLautStart: string | undefined
|
||||||
fest: Festgehalten | undefined
|
fest: Festgehalten | undefined
|
||||||
@@ -120,13 +124,10 @@ function BausteinZeile({ id, name, neuLautStart, fest, gesperrt, onStart, onFrei
|
|||||||
onStart: () => void
|
onStart: () => void
|
||||||
onFreigeben: () => void
|
onFreigeben: () => void
|
||||||
}) {
|
}) {
|
||||||
const details = useUpdateDetails(id)
|
|
||||||
const d = details.data
|
const d = details.data
|
||||||
const laedt = details.isPending
|
const laedt = details.isPending
|
||||||
const fehler = details.isError ? details.error.message : d?.error
|
const fehler = details.isError ? details.error.message : d?.error
|
||||||
const v = d ? versionen(id, d) : null
|
const v = d ? versionen(id, d) : null
|
||||||
// Frische Details schlagen den gecachten Stand der Startseite; wissen sie nichts, gilt der Start.
|
|
||||||
const hatUpdate = detailsHabenNeues(id, d) ?? neuLautStart != null
|
|
||||||
const neu = hatUpdate ? v?.neu ?? neuLautStart ?? null : null
|
const neu = hatUpdate ? v?.neu ?? neuLautStart ?? null : null
|
||||||
const summary = zusammenfassung(d?.summary)
|
const summary = zusammenfassung(d?.summary)
|
||||||
|
|
||||||
@@ -269,12 +270,16 @@ export function UpdatesSeite() {
|
|||||||
|
|
||||||
const neuLautStart = new Map((start.data?.updates.bausteine ?? []).map((b) => [b.id, b.neu]))
|
const neuLautStart = new Map((start.data?.updates.bausteine ?? []).map((b) => [b.id, b.neu]))
|
||||||
const festgehalten = new Map((start.data?.updates.festgehalten ?? []).map((f) => [f.baustein, f]))
|
const festgehalten = new Map((start.data?.updates.festgehalten ?? []).map((f) => [f.baustein, f]))
|
||||||
|
// /api/start kennt nur einen bis zu zehn Minuten alten Stand. Die Details prüfen frisch — sagen
|
||||||
|
// sie etwas, gilt das (für die Zeilen UND für „Alles jetzt aktualisieren“), sonst der Start.
|
||||||
|
const details = useUpdateDetails(BAUSTEINE.map((b) => b.id))
|
||||||
|
const hatNeues = BAUSTEINE.map((b, i) => detailsHabenNeues(b.id, details[i].data) ?? neuLautStart.has(b.id))
|
||||||
const alleJobs = jobs.data?.jobs ?? []
|
const alleJobs = jobs.data?.jobs ?? []
|
||||||
// Nur ein Update gleichzeitig (Riegel im Backend) — auch der Sonntags-Lauf zählt.
|
// Nur ein Update gleichzeitig (Riegel im Backend) — auch der Sonntags-Lauf zählt.
|
||||||
const updateLaeuft =
|
const updateLaeuft =
|
||||||
alleJobs.some((j) => j.group === "maintenance" && laeuftNoch(j)) || (start.data?.zustand.update_laeuft ?? false)
|
alleJobs.some((j) => j.group === "maintenance" && laeuftNoch(j)) || (start.data?.zustand.update_laeuft ?? false)
|
||||||
// Die Suche hat im Backend (noch) keine Gruppe; sobald sie "pruefen" heißt, sperrt der Knopf auch hier.
|
// Zwei Suchen gleichzeitig stolpern über die apt-Sperre — solange eine läuft, bleibt der Knopf zu.
|
||||||
const suchtGerade = alleJobs.some((j) => j.group === "pruefen" && laeuftNoch(j))
|
const suchtGerade = alleJobs.some((j) => jobSuche(j) && laeuftNoch(j))
|
||||||
const gesperrt = updateLaeuft || updateStarten.isPending
|
const gesperrt = updateLaeuft || updateStarten.isPending
|
||||||
const zurueckMoeglich = sicherungen.data?.available ?? false
|
const zurueckMoeglich = sicherungen.data?.available ?? false
|
||||||
|
|
||||||
@@ -296,11 +301,13 @@ export function UpdatesSeite() {
|
|||||||
<span role="columnheader">Was sich ändert</span>
|
<span role="columnheader">Was sich ändert</span>
|
||||||
<span role="columnheader" className="sr-only">Aktion</span>
|
<span role="columnheader" className="sr-only">Aktion</span>
|
||||||
</div>
|
</div>
|
||||||
{BAUSTEINE.map((b) => (
|
{BAUSTEINE.map((b, i) => (
|
||||||
<BausteinZeile
|
<BausteinZeile
|
||||||
key={b.id}
|
key={b.id}
|
||||||
id={b.id}
|
id={b.id}
|
||||||
name={b.name}
|
name={b.name}
|
||||||
|
details={details[i]}
|
||||||
|
hatUpdate={hatNeues[i]}
|
||||||
neuLautStart={neuLautStart.get(b.id)}
|
neuLautStart={neuLautStart.get(b.id)}
|
||||||
fest={festgehalten.get(b.id)}
|
fest={festgehalten.get(b.id)}
|
||||||
gesperrt={gesperrt}
|
gesperrt={gesperrt}
|
||||||
@@ -371,9 +378,9 @@ export function UpdatesSeite() {
|
|||||||
onClick={() => updateStarten.mutate("pruefen")}
|
onClick={() => updateStarten.mutate("pruefen")}
|
||||||
disabled={updateStarten.isPending || suchtGerade}
|
disabled={updateStarten.isPending || suchtGerade}
|
||||||
>
|
>
|
||||||
Nach Neuem suchen
|
{suchtGerade ? "Sucht …" : "Nach Neuem suchen"}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={() => setFrage({ art: "alle" })} disabled={gesperrt || neuLautStart.size === 0}>
|
<Button onClick={() => setFrage({ art: "alle" })} disabled={gesperrt || !hatNeues.some(Boolean)}>
|
||||||
Alles jetzt aktualisieren
|
Alles jetzt aktualisieren
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -413,7 +420,7 @@ export function UpdatesSeite() {
|
|||||||
<Bestaetigen
|
<Bestaetigen
|
||||||
offen={frage?.art === "alle"}
|
offen={frage?.art === "alle"}
|
||||||
titel="Alles jetzt aktualisieren?"
|
titel="Alles jetzt aktualisieren?"
|
||||||
text="Motor, llama-swap, Hermes und Betriebssystem werden nacheinander aktualisiert, soweit es Neues gibt. Vorher wird gesichert, danach geprüft. Lucy ist dabei ein paar Minuten nicht erreichbar."
|
text="Motor, llama-swap, Hermes und Betriebssystem werden nacheinander aktualisiert, soweit es Neues gibt, und danach geprüft. Motor und llama-swap holen bei Problemen ihren alten Stand von selbst zurück, vor Hermes wird gesichert. Lucy ist dabei ein paar Minuten nicht erreichbar."
|
||||||
knopf="Aktualisieren"
|
knopf="Aktualisieren"
|
||||||
gesperrt={gesperrt}
|
gesperrt={gesperrt}
|
||||||
onNein={() => setFrage(null)}
|
onNein={() => setFrage(null)}
|
||||||
|
|||||||
Reference in New Issue
Block a user