Selbst-Update: Ladezustand und echter Neustart-Knopf

Der erste echte Live-Test des Selbst-Updates gegen das private Gitea-Repo
lief erfolgreich (mehrfach im Test-Container bestätigt) - deckte aber zwei
UX-Lücken auf, die der Owner direkt gemeldet hat:

- Prüfen/Installieren gaben während der 15-25 Sekunden dauernden
  Netzwerkaktion keine sichtbare Rückmeldung - nicht von einem Hänger zu
  unterscheiden. Jetzt ein Ladeindikator (Panel: wiederverwendetes
  .lade-spinner; companion-app: neues .dm-spinner-Äquivalent, gab es dort
  noch gar nicht).
- Nach erfolgreicher Installation stand nur ein Hinweistext da, kein Weg
  zum eigentlich nötigen nächsten Schritt. Der Knopf wechselt jetzt zu
  "Installation abschließen - Jetzt neu starten" und stößt
  homeassistant.restart direkt an.

Das separat gemeldete "Lädt"-Hängenbleiben war kein Bug: reproduziert durch
Live-Test des neuen Neustart-Knopfs - eine echte HA-Verbindungsunterbrechung
während eines Neustarts zeigt exakt denselben, bereits bestehenden
Bootstrap-Ladebildschirm. Vermutlich derselbe Effekt durch den
Docker-Neustart früher in dieser Sitzung.

Verifiziert im Test-Container per Browser-Automatisierung: Neustart-Knopf
ausgelöst, Verbindungsabbruch beobachtet, per docker logs bestätigt, dass
Home Assistant tatsächlich neu gestartet ist und die neue Version aktiv
wurde.

Details in AGENTS.md, Abschnitt L.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-24 12:44:47 +02:00
parent 7365180550
commit 146f809e9b
8 changed files with 239 additions and 22 deletions
+67
View File
@@ -2337,6 +2337,73 @@ entirely, not replaced with a space) and un-escapes `\,`/`\;`/`\n`/`\\` before u
Verified: `tsc --noEmit` clean, all 134 companion-app tests pass (127 + 7 new), panel JS re-checked
with `node --check`. Manifest bumped to `2026.8.24.5`, `npm run ota` rerun (both frontends changed).
### L) Self-update UX: loading feedback and a real restart button (2026-08-24, same day)
**The self-update feature (section J) got its first genuine live test against the real private
Gitea repo this same day**, and it worked — confirmed directly in the test container's log across
several real attempts (`Update installiert: Version 2026.8.24.5`, no manual file copying involved).
That's the first real end-to-end confirmation beyond the unit tests and structural container-restart
checks section J could offer on its own.
That same live test exposed two real UX gaps, both raised directly by the owner:
1. **No feedback during or after the ~1525 second install** (confirmed from the log's own
timestamps between a "Prüfung" and the matching "installiert" line). The button just sat there
with no state change — indistinguishable from a hang, and after success, the version still read
"2026.8.24.4 installiert" because that field only updates after a real restart, which nothing in
the UI was telling the user to do next beyond a paragraph of text.
2. **A separate, initially confusing "Lädt …" hang the owner saw and asked about.** Traced by
reproducing it directly: `.lade-spinner`/"Lädt …" is this project's own **existing** top-level
bootstrap loading screen (shown whenever `DATEN_GELADEN` is false — i.e., no live connection to
Home Assistant yet), not anything related to the update button specifically. Clicking the new
"Jetzt neu starten" button and watching it live in a browser reproduced the *exact* same screen —
because a `homeassistant.restart` call genuinely does drop the frontend's connection until HA
finishes rebooting, and this is the correct, expected screen for that. The most likely explanation
for the owner's *original* sighting (before this button existed): the Docker Desktop restart from
earlier in the same session dropped the connection the same way. Not a bug — but the fact that it
was indistinguishable from one was the actual, valid complaint: nothing signposted that a restart
was in progress versus something being stuck.
**Fixed with a three-state UI, researched against Apple HIG's guidance on indeterminate progress**
(unknown-duration network operations get a running indicator, not just a disabled control; on
completion the UI leads straight to the next required action instead of leaving the user to find it
themselves):
- **Prüfen/Installieren, in progress** — a small spinner + "Prüfe …"/"Installiere …", reusing the
panel's own pre-existing `.lade-spinner` (originally built, per its own comment, for the exact same
"don't let 'still loading' look like 'stuck'" reason at app bootstrap) and a new equivalent
`.dm-spinner`/`.dm-ladezeile` pair added to companion-app's `screens.css` (that side had no spinner
at all before, only a button-text swap).
- **On success** — the tile switches directly to "Installation abschließen - Jetzt neu starten",
which calls `homeassistant.restart` (a core HA service any admin could already trigger via
Settings → System → Restart — this button doesn't grant a new capability, it surfaces an existing
one at the point it's actually needed). Its own click shows "Home Assistant startet neu …";
no `.finally`-style cleanup after that call succeeds, since the connection normally drops before
the promise would ever resolve — only a genuine rejection (service refused, etc.) reaches the
catch block.
- **On a real request-level failure** (not the already-handled "no token"/"bad token" case from
section J, which returns normally with `fehler` set — this is Gitea unreachable, the websocket
itself failing, or any other exception that never made it back from Home Assistant at all) — this
was previously silent in companion-app (a `try/finally` with no `catch` re-throws into an
unhandled promise rejection). Added explicit `catch` blocks on all three actions in both frontends,
each surfacing its own message rather than one shared silently-stale error field.
- Panel: `INTEGRATION_UPDATE_LAEUFT` (`"pruefen" | "installieren" | "neustart" | null`), the
`data-update` click handler rewritten from `serviceRufen()` (fire-and-forget) to direct
`HASS.callService()` promise chains so the loading flag can wrap them; `vEinst()`'s tile rewritten
with the three states above.
- companion-app: `updateNeustartLaeuft`/`updateAktionFehler` state and `updateNeustartAusloesen()` in
`Einstellungen.tsx`; new `homeAssistantNeuStarten()` in `api/index.ts` (`dienstAufrufen("homeassistant",
"restart")` — note the domain isn't `DIENST_DOMAIN`, this is a core HA service, not one of this
integration's own).
Verified live end-to-end in the test container via browser automation: triggered the real restart
button, watched the connection drop into the bootstrap "Lädt …" screen exactly as reasoned above,
confirmed via `docker logs` that Home Assistant actually rebooted
(`Audi Dashboard 2026.8.24.6 eingerichtet` after restart, version now genuinely current — not just
the pre-restart file swap). companion-app: `tsc --noEmit` clean, full suite still green. Manifest
bumped to `2026.8.24.6` and `npm run ota` rerun per the standing rule in `VERSIONIERUNG.md` — both
frontends changed (new spinner/restart-button behavior is real, visible UI, not internal-only).
---
## Working conventions (observed — keep them)
+9
View File
@@ -217,6 +217,15 @@ export class DataMetricApi {
return this.rest.dienstAufrufen(DIENST_DOMAIN, "update_installieren");
}
/** Startet Home Assistant komplett neu - der letzte Schritt nach
updateInstallieren(), damit die neue Fassung tatsächlich geladen wird
(ein reiner Reload reicht dafür nicht, siehe AGENTS.md Abschnitt J).
Kein audi_dashboard-Dienst, deshalb der Bereich "homeassistant" statt
DIENST_DOMAIN. */
homeAssistantNeuStarten(): Promise<unknown> {
return this.rest.dienstAufrufen("homeassistant", "restart");
}
/* ------------------------------------------- Import aus dem HA-Verlauf
Bewusst NICHT über die Warteschlange, anders als die übrigen
schreibenden Vorgänge: der Import ist keine Eingabe, die man im Funkloch
+80 -14
View File
@@ -53,6 +53,8 @@ export function Einstellungen({
const [otaFehler, setzeOtaFehler] = useState<string | null>(null)
const [updatePruefenLaeuft, setzeUpdatePruefenLaeuft] = useState(false)
const [updateInstallierenLaeuft, setzeUpdateInstallierenLaeuft] = useState(false)
const [updateNeustartLaeuft, setzeUpdateNeustartLaeuft] = useState(false)
const [updateAktionFehler, setzeUpdateAktionFehler] = useState<string | null>(null)
const dateiwahl = useRef<HTMLInputElement | null>(null)
const otaUpdateVerfuegbar = otaMoeglich() && buendelPasst(otaBuendel, eigeneVersion())
@@ -82,12 +84,21 @@ export function Einstellungen({
// danach über neuLaden() den neuen Stand von
// sensor.audi_dashboard_app_version - der Fehlertext bei fehlendem Token
// kommt von dort mit, eine eigene "ist ein Token eingetragen?"-Prüfung
// gibt es bewusst nicht.
// gibt es bewusst nicht. updateAktionFehler fängt zusätzlich echte
// Verbindungsfehler ab (dienste.py behandelt AktualisierungsFehler selbst
// und liefert 200 zurück - hier landet nur, was gar nicht erst bei Home
// Assistant ankam), sonst bliebe ein solcher Fehlschlag ganz ohne
// Rückmeldung.
const updatePruefenAusloesen = async () => {
setzeUpdatePruefenLaeuft(true)
setzeUpdateAktionFehler(null)
try {
await api.updatePruefen()
await neuLaden()
} catch (fehler) {
setzeUpdateAktionFehler(
fehler instanceof Error ? fehler.message : "Die Prüfung konnte nicht ausgeführt werden.",
)
} finally {
setzeUpdatePruefenLaeuft(false)
}
@@ -95,14 +106,39 @@ export function Einstellungen({
const updateInstallierenAusloesen = async () => {
setzeUpdateInstallierenLaeuft(true)
setzeUpdateAktionFehler(null)
try {
await api.updateInstallieren()
await neuLaden()
} catch (fehler) {
setzeUpdateAktionFehler(
fehler instanceof Error ? fehler.message : "Das Update konnte nicht installiert werden.",
)
} finally {
setzeUpdateInstallierenLaeuft(false)
}
}
// Nach einer erfolgreichen Installation zeigt die Kachel direkt den
// nächsten nötigen Schritt statt den Nutzer selbst suchen zu lassen - ein
// reiner Reload reicht nicht, siehe AGENTS.md Abschnitt J. Kein try/finally
// um setzeUpdateNeustartLaeuft: bricht die Verbindung normalerweise durch
// den Neustart selbst ab, bevor der Aufruf überhaupt zurückkehrt.
const updateNeustartAusloesen = async () => {
setzeUpdateNeustartLaeuft(true)
setzeUpdateAktionFehler(null)
try {
await api.homeAssistantNeuStarten()
} catch (fehler) {
setzeUpdateAktionFehler(
fehler instanceof Error
? fehler.message
: "Home Assistant konnte nicht neu gestartet werden.",
)
setzeUpdateNeustartLaeuft(false)
}
}
if (!einstellungen || !fahrzeug) return null
const werte = entwurf ?? einstellungen
@@ -443,15 +479,36 @@ export function Einstellungen({
<Tile>
<span className="ads-eyebrow">Integration-Update</span>
{integrationUpdate?.installiert ? (
{/* HIG "Loading": Netzwerk zu Gitea plus Entpacken dauert unbestimmt
lang, ohne verlässliche Fortschrittsangabe - ein laufender
Indikator statt eines nur deaktivierten Knopfes verhindert, dass
das wie ein Hänger aussieht. Nach Erfolg führt der Knopf direkt
zum nächsten nötigen Schritt (Neustart) statt den Nutzer selbst
suchen zu lassen - ein reiner Reload reicht nicht (AGENTS.md
Abschnitt J). */}
{updateInstallierenLaeuft ? (
<div className="dm-ladezeile">
<span className="dm-spinner" aria-hidden="true" />
<span className="dm-fussnote" style={{ margin: 0 }}>
Installiere
</span>
</div>
) : updateNeustartLaeuft ? (
<div className="dm-ladezeile">
<span className="dm-spinner" aria-hidden="true" />
<span className="dm-fussnote" style={{ margin: 0 }}>
Home Assistant startet neu
</span>
</div>
) : integrationUpdate?.installiert ? (
<>
<p className="dm-fussnote">
Version {integrationUpdate.version} installiert. Home Assistant neu starten, damit
die neue Fassung geladen wird Einstellungen System Neu starten.
Version {integrationUpdate.version} installiert - die neue Fassung wird erst nach
einem Neustart geladen.
</p>
<div className="dm-knopfreihe">
<ActionButton onClick={() => void updatePruefenAusloesen()} disabled={updatePruefenLaeuft}>
{updatePruefenLaeuft ? "Prüfe …" : "Erneut prüfen"}
<ActionButton onClick={() => void updateNeustartAusloesen()}>
Installation abschließen - Jetzt neu starten
</ActionButton>
</div>
</>
@@ -466,14 +523,18 @@ export function Einstellungen({
nicht betroffen.
</p>
<div className="dm-knopfreihe">
<ActionButton
onClick={() => void updateInstallierenAusloesen()}
disabled={updateInstallierenLaeuft}
>
{updateInstallierenLaeuft ? "Installiere …" : "Update installieren"}
<ActionButton onClick={() => void updateInstallierenAusloesen()}>
Update installieren
</ActionButton>
</div>
</>
) : updatePruefenLaeuft ? (
<div className="dm-ladezeile">
<span className="dm-spinner" aria-hidden="true" />
<span className="dm-fussnote" style={{ margin: 0 }}>
Prüfe
</span>
</div>
) : (
<>
<p className="dm-fussnote">
@@ -482,13 +543,18 @@ export function Einstellungen({
: "Noch nicht geprüft."}
</p>
<div className="dm-knopfreihe">
<ActionButton onClick={() => void updatePruefenAusloesen()} disabled={updatePruefenLaeuft}>
{updatePruefenLaeuft ? "Prüfe …" : "Auf Update prüfen"}
<ActionButton onClick={() => void updatePruefenAusloesen()}>
Auf Update prüfen
</ActionButton>
</div>
</>
)}
{integrationUpdate?.fehler && <p className="dm-fehler">{integrationUpdate.fehler}</p>}
{updateAktionFehler && <p className="dm-fehler">{updateAktionFehler}</p>}
{!updateAktionFehler &&
integrationUpdate?.fehler &&
!updatePruefenLaeuft &&
!updateInstallierenLaeuft &&
!updateNeustartLaeuft && <p className="dm-fehler">{integrationUpdate.fehler}</p>}
</Tile>
<Tile>
+28
View File
@@ -609,6 +609,34 @@
background: var(--tile-2);
}
/* ------------------------------------------------------------ Ladezustand */
/* HIG "Loading": ein unbestimmt langer Vorgang (Netzwerk, kein verlässlicher
Fortschritt) bekommt einen laufenden Indikator statt eines stumm
daliegenden Knopfes - sonst nicht von einem Hänger zu unterscheiden.
Deckungsgleich mit .lade-spinner im Panel. */
.dm-spinner {
display: inline-block;
width: 16px;
height: 16px;
flex: none;
border: 2px solid var(--line);
border-top-color: var(--fg2);
border-radius: 50%;
animation: dm-spin 0.8s linear infinite;
}
@keyframes dm-spin {
to {
transform: rotate(360deg);
}
}
.dm-ladezeile {
display: flex;
align-items: center;
gap: var(--sp-2);
margin-top: var(--sp-3);
}
/* ------------------------------------------------------------ Meldungen */
.dm-fehler {
@@ -1 +1 @@
{"version":"2026.8.24.5","sha256":"378a1cbc7f5daa3d8c5d74734a4c09f30557b243a8ce4d720240bf911fbeff76","bytes":232096,"gebaut":"2026-08-24T10:29:33Z"}
{"version":"2026.8.24.6","sha256":"e0d8c7a6f4cc7fddc59b252e02e4ebf50002f3bd4e317ddb315670ef13299d75","bytes":232382,"gebaut":"2026-08-24T10:44:09Z"}
@@ -2426,16 +2426,32 @@ function vEinst() {
<div class="tile"><span class="label">Integration-Update</span>
${(() => {
const u = INTEGRATION_UPDATE;
const laeuft = INTEGRATION_UPDATE_LAEUFT;
// Ersetzt install.ps1 als laufenden Update-Weg (Windows Smart App
// Control blockiert dessen Ausführung zuverlässig) - install.ps1
// bleibt nur noch für die Erstinstallation nötig, bevor die
// Integration überhaupt läuft und sich selbst aktualisieren kann.
//
// HIG "Loading": Netzwerk zu Gitea plus Entpacken dauert unbestimmt
// lang, ohne verlässliche Fortschrittsangabe - ein laufender
// Indikator statt eines stumm daliegenden Knopfes verhindert, dass
// das wie ein Hänger aussieht (derselbe Grund wie beim
// Erstladen-Spinner, .lade-spinner). Nach Erfolg führt der Knopf
// direkt zum nächsten nötigen Schritt (Neustart), statt den Nutzer
// selbst suchen zu lassen.
const spinner = (text) => `<div style="display:flex;align-items:center;gap:10px;margin-top:8px">
<span class="lade-spinner" aria-hidden="true"></span>
<span class="label" style="margin:0">${esc(text)}</span>
</div>`;
if (laeuft === "installieren") return spinner("Installiere …");
if (laeuft === "neustart") return spinner("Home Assistant startet neu …");
if (u && u.installiert) {
return `<span style="display:block;margin-top:8px;color:var(--fg2);font-size:13.5px;line-height:1.6">
Version ${esc(u.version)} installiert. Home Assistant neu starten, damit die neue
Fassung geladen wird - Einstellungen -> System -> Neu starten.</span>
<button class="aktion" style="margin-top:14px" data-update="pruefen">Erneut prüfen</button>`;
Version ${esc(u.version)} installiert - die neue Fassung wird erst nach einem Neustart
geladen.</span>
<button class="aktion" style="margin-top:14px" data-update="neustart">Installation abschließen - Jetzt neu starten</button>`;
}
if (laeuft === "pruefen") return spinner("Prüfe …");
if (u && u.verfuegbar) {
return `<dl class="rows"><div class="row" style="border-bottom:0"><dt>Verfügbare Version</dt><dd>${esc(u.version)}</dd></div></dl>
<span style="display:block;margin-top:8px;color:var(--fg2);font-size:13.5px;line-height:1.6">
@@ -2448,7 +2464,7 @@ function vEinst() {
${u ? `Integration ist aktuell${u.geprueft_am ? ` (geprüft ${dedat(new Date(u.geprueft_am))})` : ""}.` : "Noch nicht geprüft."}</span>
<button class="aktion" style="margin-top:14px" data-update="pruefen">Auf Update prüfen</button>`;
})()}
${INTEGRATION_UPDATE && INTEGRATION_UPDATE.fehler
${INTEGRATION_UPDATE && INTEGRATION_UPDATE.fehler && !INTEGRATION_UPDATE_LAEUFT
? `<span class="label" style="margin-top:10px;color:var(--red)">${esc(INTEGRATION_UPDATE.fehler)}</span>`
: ""}
</div>`;
@@ -3156,6 +3172,12 @@ let APP_VERSION = null;
// geprüft wurde. Ersetzt install.ps1 als Update-Weg, das Windows Smart App
// Control zuverlässig blockiert; siehe vVersion()/Einstellungen-Ansicht.
let INTEGRATION_UPDATE = null;
// "pruefen" | "installieren" | "neustart" | null - während einer dieser drei
// Aktionen läuft, siehe vEinst()'s Integration-Update-Kachel. Eigens statt
// über serviceRufen() (das feuert-und-vergisst), weil diese drei Aktionen
// unbestimmt lange dauern (Netzwerk zu Gitea, Entpacken) und die Oberfläche
// das sichtbar machen soll - siehe .lade-spinner's eigener Kommentar dazu.
let INTEGRATION_UPDATE_LAEUFT = null;
/* Auswahl für "Modell" unter Einrichten - setzt nur CONFIG.fahrzeugtitel
(Zeile "Modell" in der Identität, Kopfzeilen, Kalender-Einträge, und den
@@ -4515,8 +4537,33 @@ function ereignisseVerdrahten() {
return;
}
const upd = e.target.closest("[data-update]");
if (upd && upd.dataset.update === "pruefen") { serviceRufen("update_pruefen", {}); return; }
if (upd && upd.dataset.update === "installieren") { serviceRufen("update_installieren", {}); return; }
if (upd) {
const aktion = upd.dataset.update;
// Eigene Promise-Kette statt serviceRufen() (das feuert-und-vergisst) -
// diese drei Aktionen brauchen einen sichtbaren Ladezustand, siehe
// INTEGRATION_UPDATE_LAEUFT's Kommentar an der Deklaration.
if (aktion === "pruefen" || aktion === "installieren") {
INTEGRATION_UPDATE_LAEUFT = aktion; render();
HASS.callService(DOMAIN, `update_${aktion}`, {})
.catch((err) => {
hinweis(
aktion === "pruefen" ? "Prüfung fehlgeschlagen" : "Installation fehlgeschlagen",
err && err.message ? err.message : "Der Dienst konnte nicht ausgeführt werden.",
);
})
.finally(() => { INTEGRATION_UPDATE_LAEUFT = null; render(); });
} else if (aktion === "neustart") {
INTEGRATION_UPDATE_LAEUFT = "neustart"; render();
HASS.callService("homeassistant", "restart", {}).catch((err) => {
// Bricht die Verbindung normalerweise durch den Neustart selbst ab,
// bevor diese Zeile überhaupt liefe - nur ein echter Fehlschlag
// (Dienst abgelehnt, o.ä.) kommt hier tatsächlich an.
hinweis("Neustart fehlgeschlagen", err && err.message ? err.message : "Home Assistant konnte nicht neu gestartet werden.");
INTEGRATION_UPDATE_LAEUFT = null; render();
});
}
return;
}
const cs = e.target.closest("[data-csv]");
if (cs) {
const w = cs.dataset.csv;
@@ -1,7 +1,7 @@
{
"domain": "audi_dashboard",
"name": "Audi Dashboard",
"version": "2026.8.24.5",
"version": "2026.8.24.6",
"documentation": "https://gitea.nothaft.cloud/paul/audi-app/src/branch/main/README.md",
"issue_tracker": "https://gitea.nothaft.cloud/paul/audi-app/issues",
"codeowners": ["@paul"],