From 5405acaed5fd8eaea04c1223f60ce7ccd0b07b5e Mon Sep 17 00:00:00 2001 From: Developer-Simon Date: Tue, 22 Sep 2026 17:52:20 +0200 Subject: [PATCH 01/15] feat(bootstrap): add the rule that decides which service units restart Co-Authored-By: Claude Sonnet 5 --- scripts/bootstrap/lib/restart_rule.py | 82 +++++++++++++++++++ scripts/bootstrap/testdata/restart_cases.json | 60 ++++++++++++++ scripts/tests/test_restart_rule.py | 56 +++++++++++++ 3 files changed, 198 insertions(+) create mode 100644 scripts/bootstrap/lib/restart_rule.py create mode 100644 scripts/bootstrap/testdata/restart_cases.json create mode 100644 scripts/tests/test_restart_rule.py diff --git a/scripts/bootstrap/lib/restart_rule.py b/scripts/bootstrap/lib/restart_rule.py new file mode 100644 index 0000000..8950966 --- /dev/null +++ b/scripts/bootstrap/lib/restart_rule.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +"""Entscheidet, ob eine Dienst-Unit nach einem Update neu starten muss. + +Eine Stelle fuer die Regel: die Dienstschritte (service_step.sh) fragen sie +zur Laufzeit, plan.sh fuer die Vorschau des Installers. Das Go-Gegenstueck im +Dashboard (dashboard/internal/updaterhost/restart.go) wird gegen dieselbe +Fallliste geprueft (scripts/bootstrap/testdata/restart_cases.json). + +Antworten: + "" kein Neustart noetig + "all" EN_RESTART=all: der Betreiber will alle Dienste neu starten + "first" kein installiertes Manifest oder der Dienst steht nicht darin + "unknown" eine Version fehlt (aelteres Manifest): im Zweifel neu starten + "version" die Version des Dienstes hat sich geaendert + "library" eine gemeinsame Bibliothek hat sich geaendert, die er importiert +""" +import json +import os +import pathlib +import sys + +# Bibliothek -> Dienstverzeichnisse, die sie importieren. None = alle Dienste. +# test_restart_rule.py haelt diese Tabelle gegen die echten Imports. +LIBRARY_USERS = { + "energy_node_common": None, + "battery_soc_core": {"battery_soc"}, +} + + +def _step(manifest, step_id): + for step in (manifest or {}).get("steps") or []: + if str(step.get("id")) == str(step_id): + return step + return None + + +def restart_reason(candidate, installed, step_id, restart_all=False): + if restart_all: + return "all" + if not isinstance(candidate, dict): + return "unknown" + if not isinstance(installed, dict): + return "first" + new = _step(candidate, step_id) + if new is None: + return "" + old = _step(installed, step_id) + if old is None: + return "first" + if not old.get("version") or not new.get("version"): + return "unknown" + if old["version"] != new["version"]: + return "version" + old_components = installed.get("components") or {} + new_components = candidate.get("components") or {} + for name, users in LIBRARY_USERS.items(): + version = new_components.get(name) + if version and old_components.get(name) != version and (users is None or new.get("dir") in users): + return "library" + return "" + + +def _load(path): + try: + return json.loads(pathlib.Path(path).read_text(encoding="utf-8")) + except (OSError, ValueError): + return None + + +def main(argv): + bundle_dir, state_dir, step_id = argv[1:4] + reason = restart_reason( + _load(pathlib.Path(bundle_dir) / "manifest.json"), + _load(pathlib.Path(state_dir) / "installed-manifest.json"), + step_id, + os.environ.get("EN_RESTART") == "all", + ) + print(reason) + + +if __name__ == "__main__": + main(sys.argv) diff --git a/scripts/bootstrap/testdata/restart_cases.json b/scripts/bootstrap/testdata/restart_cases.json new file mode 100644 index 0000000..4271bf1 --- /dev/null +++ b/scripts/bootstrap/testdata/restart_cases.json @@ -0,0 +1,60 @@ +[ + {"name": "restart all wins over everything", + "candidate": {"steps": [{"id": "82", "dir": "battery_soc", "version": "v0.4.0"}], "components": {"energy_node_common": "v0.4.5"}}, + "installed": {"steps": [{"id": "82", "dir": "battery_soc", "version": "v0.4.0"}], "components": {"energy_node_common": "v0.4.5"}}, + "step_id": "82", "restart_all": true, "want": "all"}, + + {"name": "no installed manifest means first install", + "candidate": {"steps": [{"id": "82", "dir": "battery_soc", "version": "v0.4.0"}], "components": {}}, + "installed": null, "step_id": "82", "restart_all": false, "want": "first"}, + + {"name": "service missing from the installed manifest is new", + "candidate": {"steps": [{"id": "84", "dir": "trucki", "version": "v0.4.0"}], "components": {}}, + "installed": {"steps": [{"id": "82", "dir": "battery_soc", "version": "v0.4.0"}], "components": {}}, + "step_id": "84", "restart_all": false, "want": "first"}, + + {"name": "same version, same libraries: no restart", + "candidate": {"steps": [{"id": "83", "dir": "shelly", "version": "v0.4.2"}], "components": {"energy_node_common": "v0.4.5", "battery_soc_core": "v0.1.9"}}, + "installed": {"steps": [{"id": "83", "dir": "shelly", "version": "v0.4.2"}], "components": {"energy_node_common": "v0.4.5", "battery_soc_core": "v0.1.9"}}, + "step_id": "83", "restart_all": false, "want": ""}, + + {"name": "changed service version restarts that service", + "candidate": {"steps": [{"id": "83", "dir": "shelly", "version": "v0.4.3"}], "components": {}}, + "installed": {"steps": [{"id": "83", "dir": "shelly", "version": "v0.4.2"}], "components": {}}, + "step_id": "83", "restart_all": false, "want": "version"}, + + {"name": "installed manifest from before per-service versions is unknown", + "candidate": {"steps": [{"id": "83", "dir": "shelly", "version": "v0.4.2"}], "components": {}}, + "installed": {"steps": [{"id": "83", "dir": "shelly"}], "components": {}}, + "step_id": "83", "restart_all": false, "want": "unknown"}, + + {"name": "candidate without a version is unknown", + "candidate": {"steps": [{"id": "83", "dir": "shelly"}], "components": {}}, + "installed": {"steps": [{"id": "83", "dir": "shelly", "version": "v0.4.2"}], "components": {}}, + "step_id": "83", "restart_all": false, "want": "unknown"}, + + {"name": "energy_node_common changed restarts every service", + "candidate": {"steps": [{"id": "83", "dir": "shelly", "version": "v0.4.2"}], "components": {"energy_node_common": "v0.4.6"}}, + "installed": {"steps": [{"id": "83", "dir": "shelly", "version": "v0.4.2"}], "components": {"energy_node_common": "v0.4.5"}}, + "step_id": "83", "restart_all": false, "want": "library"}, + + {"name": "battery_soc_core changed restarts battery_soc", + "candidate": {"steps": [{"id": "82", "dir": "battery_soc", "version": "v0.4.0"}], "components": {"battery_soc_core": "v0.2.0"}}, + "installed": {"steps": [{"id": "82", "dir": "battery_soc", "version": "v0.4.0"}], "components": {"battery_soc_core": "v0.1.9"}}, + "step_id": "82", "restart_all": false, "want": "library"}, + + {"name": "battery_soc_core changed leaves other services alone", + "candidate": {"steps": [{"id": "83", "dir": "shelly", "version": "v0.4.2"}], "components": {"battery_soc_core": "v0.2.0"}}, + "installed": {"steps": [{"id": "83", "dir": "shelly", "version": "v0.4.2"}], "components": {"battery_soc_core": "v0.1.9"}}, + "step_id": "83", "restart_all": false, "want": ""}, + + {"name": "library missing from the installed manifest counts as changed", + "candidate": {"steps": [{"id": "82", "dir": "battery_soc", "version": "v0.4.0"}], "components": {"battery_soc_core": "v0.1.9"}}, + "installed": {"steps": [{"id": "82", "dir": "battery_soc", "version": "v0.4.0"}], "components": {}}, + "step_id": "82", "restart_all": false, "want": "library"}, + + {"name": "step not in the candidate needs no restart", + "candidate": {"steps": [{"id": "82", "dir": "battery_soc", "version": "v0.4.0"}], "components": {}}, + "installed": {"steps": [{"id": "82", "dir": "battery_soc", "version": "v0.4.0"}], "components": {}}, + "step_id": "99", "restart_all": false, "want": ""} +] diff --git a/scripts/tests/test_restart_rule.py b/scripts/tests/test_restart_rule.py new file mode 100644 index 0000000..e4e78f1 --- /dev/null +++ b/scripts/tests/test_restart_rule.py @@ -0,0 +1,56 @@ +"""Table test for scripts/bootstrap/lib/restart_rule.py and a drift guard for +its library map against what the services really import.""" +import json +import pathlib +import re +import subprocess +import sys + +REPO = pathlib.Path(__file__).resolve().parents[2] +LIB = REPO / "scripts" / "bootstrap" / "lib" +CASES = json.loads((REPO / "scripts" / "bootstrap" / "testdata" / "restart_cases.json").read_text(encoding="utf-8")) + +sys.path.insert(0, str(LIB)) +import restart_rule # noqa: E402 + + +def test_every_case_in_the_table(): + for case in CASES: + got = restart_rule.restart_reason(case["candidate"], case["installed"], case["step_id"], case["restart_all"]) + assert got == case["want"], case["name"] + + +def test_cli_reads_manifests_and_env(tmp_path): + bundle, state = tmp_path / "bundle", tmp_path / "state" + bundle.mkdir() + state.mkdir() + (bundle / "manifest.json").write_text(json.dumps( + {"steps": [{"id": "83", "dir": "shelly", "version": "v0.4.3"}], "components": {}})) + (state / "installed-manifest.json").write_text(json.dumps( + {"steps": [{"id": "83", "dir": "shelly", "version": "v0.4.2"}], "components": {}})) + + def run(env=None): + out = subprocess.run([sys.executable, str(LIB / "restart_rule.py"), str(bundle), str(state), "83"], + capture_output=True, text=True, check=True, env=env) + return out.stdout.strip() + + assert run({"PATH": "/usr/bin"}) == "version" + assert run({"PATH": "/usr/bin", "EN_RESTART": "all"}) == "all" + (state / "installed-manifest.json").unlink() + assert run({"PATH": "/usr/bin"}) == "first" + (bundle / "manifest.json").write_text("not json") + assert run({"PATH": "/usr/bin"}) == "unknown" + + +def test_library_map_matches_the_services_imports(): + """battery_soc_core is imported by battery_soc only; every service imports + energy_node_common. A new importer must be added to LIBRARY_USERS.""" + importers = {"battery_soc_core": set(), "energy_node_common": set()} + for path in (REPO / "services").glob("*/*.py"): + text = path.read_text(encoding="utf-8") + for lib in importers: + if re.search(r"^\s*(from|import)\s+%s\b" % lib, text, re.M): + importers[lib].add(path.parent.name) + assert importers["battery_soc_core"] == restart_rule.LIBRARY_USERS["battery_soc_core"] + assert restart_rule.LIBRARY_USERS["energy_node_common"] is None, "every service restarts for it" + assert importers["energy_node_common"], "no service imports energy_node_common any more?" From 440f113ac01b61bdd8f6a636b6d0ec5f3a5ab6eb Mon Sep 17 00:00:00 2001 From: Developer-Simon Date: Tue, 22 Sep 2026 17:53:13 +0200 Subject: [PATCH 02/15] feat(bootstrap): restart a service unit only when its version or library changed Co-Authored-By: Claude Sonnet 5 --- scripts/bootstrap/lib/service_step.sh | 29 +++++++---- scripts/tests/test_bootstrap_service_step.sh | 51 ++++++++++++++++++++ 2 files changed, 71 insertions(+), 9 deletions(-) diff --git a/scripts/bootstrap/lib/service_step.sh b/scripts/bootstrap/lib/service_step.sh index fac3e18..e4f5024 100644 --- a/scripts/bootstrap/lib/service_step.sh +++ b/scripts/bootstrap/lib/service_step.sh @@ -25,6 +25,13 @@ service_step_is_artefact() { esac } +# Muss diese Unit neu starten? Die Regel steht in restart_rule.py (eine +# Stelle fuer Schritt, Vorschau und Dashboard). Leere Antwort = nein. +service_restart_reason() { + python3 "${SERVICE_STEP_LIB_DIR}/restart_rule.py" \ + "${EN_BUNDLE_DIR}" "${EN_STATE_DIR}" "$1" +} + # Achtung: step_fail beendet den Prozess. Das ist gewollt - jedes NN-*.sh # ruft service_step genau einmal und hat danach nichts mehr zu tun. service_step() { @@ -82,15 +89,19 @@ service_step() { "${EN_ROOT}/etc/systemd/system/${unit}" || { rm -f "${rendered}"; step_fail SERVICE_UNIT_FAILED; } rm -f "${rendered}" "${SUDO[@]}" systemctl daemon-reload - # enable + restart statt enable --now: --now startet nur eine gestoppte - # Unit. Bei einem Update laeuft sie schon mit dem alten Python-Code im - # Speicher und muss neu starten. restart startet eine gestoppte Unit - # ebenfalls, der Ablauf ist also fuer Erstinstallation und Update derselbe. - # Der Koerper laeuft nur einmal je Bundle-Version (step_done), das ist - # also ein Neustart je Update, nicht je Lauf. + # enable statt enable --now, und restart nur, wenn die Regel es verlangt: + # ein Update laesst laufende Dienste in Ruhe, die sich nicht geaendert haben + # (restart_rule.py). Eine gestoppte Unit wird immer gestartet - restart tut + # das ebenfalls, ein Lauf ist also fuer Erstinstallation und Update derselbe. + # Der Koerper laeuft nur einmal je Bundle-Version (step_done). + local reason + reason="$(service_restart_reason "${id}")" "${SUDO[@]}" systemctl enable "${unit}" || step_fail SERVICE_START_FAILED - "${SUDO[@]}" systemctl restart "${unit}" || step_fail SERVICE_START_FAILED - - step_log "Dienst ${dir} eingerichtet (${unit})." + if [[ -n "${reason}" ]] || ! "${SUDO[@]}" systemctl is-active --quiet "${unit}"; then + "${SUDO[@]}" systemctl restart "${unit}" || step_fail SERVICE_START_FAILED + step_log "Dienst ${dir} eingerichtet und neu gestartet (${unit}, ${reason:-war nicht aktiv})." + else + step_log "Dienst ${dir} eingerichtet (${unit}), unveraendert - kein Neustart." + fi step_ok } diff --git a/scripts/tests/test_bootstrap_service_step.sh b/scripts/tests/test_bootstrap_service_step.sh index d74ccd0..516c9f4 100755 --- a/scripts/tests/test_bootstrap_service_step.sh +++ b/scripts/tests/test_bootstrap_service_step.sh @@ -12,6 +12,11 @@ fail() { echo "FAIL: $1"; [ -n "${2:-}" ] && printf '%s\n' "$2"; exit 1; } mkdir -p "$tmp/bin" cat > "$tmp/bin/systemctl" <<'SH' #!/usr/bin/env bash +# is-active fragt nur; es steht nicht im Protokoll. UNIT_ACTIVE=1 = laeuft. +if [ "$1" = is-active ]; then + [ "${UNIT_ACTIVE:-0}" = 1 ] + exit $? +fi printf 'systemctl %s\n' "$*" >> "$SYSTEMCTL_LOG" SH chmod +x "$tmp/bin/systemctl" @@ -102,4 +107,50 @@ set -e [ "$rc" -eq 1 ] || fail "fehlende Quelle nicht gemeldet" "$rc" grep -q '^##STEP 81 fail SERVICE_SOURCE_MISSING$' <<<"$out" || fail "falscher Code" "$out" +# --- Neustart nur, wenn sich der Dienst geaendert hat --------------------- +manifest() { # + printf '{"version":"v1.0.0","components":{"energy_node_common":"%s"},"steps":[{"id":"81","dir":"demo","version":"%s"}]}\n' "$2" "$1" +} +manifest v0.4.0 v0.4.5 > "$bundle/manifest.json" +mkdir -p "$tmp/state" +manifest v0.4.0 v0.4.5 > "$tmp/state/installed-manifest.json" +restarts() { grep -c 'systemctl restart demo.service' "$SYSTEMCTL_LOG" || true; } + +# unveraendert und laeuft: kein Neustart, die Unit wird trotzdem eingerichtet +rm -rf "$tmp/state/steps"; : > "$SYSTEMCTL_LOG" +out="$(UNIT_ACTIVE=1 run)" +grep -q '^##STEP 81 ok$' <<<"$out" || fail "unveraenderter Dienst: kein ok" "$out" +[ "$(restarts)" = 0 ] || fail "unveraenderter, laufender Dienst wurde neu gestartet" "$(cat "$SYSTEMCTL_LOG")" +grep -qx 'systemctl enable demo.service' "$SYSTEMCTL_LOG" || fail "Unit nicht aktiviert" +grep -qi 'kein Neustart' <<<"$out" || fail "kein Hinweis auf den ausgebliebenen Neustart" "$out" + +# unveraendert, aber gestoppt: wird gestartet +rm -rf "$tmp/state/steps"; : > "$SYSTEMCTL_LOG" +UNIT_ACTIVE=0 run >/dev/null +[ "$(restarts)" = 1 ] || fail "gestoppter Dienst wurde nicht gestartet" "$(cat "$SYSTEMCTL_LOG")" + +# neue Dienstversion: Neustart +manifest v0.4.1 v0.4.5 > "$bundle/manifest.json" +rm -rf "$tmp/state/steps"; : > "$SYSTEMCTL_LOG" +UNIT_ACTIVE=1 run >/dev/null +[ "$(restarts)" = 1 ] || fail "geaenderter Dienst wurde nicht neu gestartet" "$(cat "$SYSTEMCTL_LOG")" + +# gemeinsame Bibliothek geaendert: Neustart trotz gleicher Dienstversion +manifest v0.4.0 v0.4.6 > "$bundle/manifest.json" +rm -rf "$tmp/state/steps"; : > "$SYSTEMCTL_LOG" +UNIT_ACTIVE=1 run >/dev/null +[ "$(restarts)" = 1 ] || fail "Bibliotheksaenderung fuehrte nicht zum Neustart" "$(cat "$SYSTEMCTL_LOG")" + +# EN_RESTART=all erzwingt den Neustart +manifest v0.4.0 v0.4.5 > "$bundle/manifest.json" +rm -rf "$tmp/state/steps"; : > "$SYSTEMCTL_LOG" +EN_RESTART=all UNIT_ACTIVE=1 run >/dev/null +[ "$(restarts)" = 1 ] || fail "EN_RESTART=all startete nicht neu" "$(cat "$SYSTEMCTL_LOG")" + +# ohne installiertes Manifest (Erstinstallation): Start +rm -f "$tmp/state/installed-manifest.json" +rm -rf "$tmp/state/steps"; : > "$SYSTEMCTL_LOG" +UNIT_ACTIVE=1 run >/dev/null +[ "$(restarts)" = 1 ] || fail "Erstinstallation startete nicht" "$(cat "$SYSTEMCTL_LOG")" + echo "OK: $(basename "$0")" From bad757ae22dbd63269b6cb569f8b089480863fec Mon Sep 17 00:00:00 2001 From: Developer-Simon Date: Tue, 22 Sep 2026 17:55:39 +0200 Subject: [PATCH 03/15] feat(installer): carry a restart-all request from the UI to the service steps Co-Authored-By: Claude Sonnet 5 --- dashboard/energy-node-updater.sh | 15 ++++++++++- dashboard/internal/updaterhost/host.go | 2 +- dashboard/internal/updaterjob/job.go | 1 + dashboard/internal/updaterjob/job_test.go | 32 +++++++++++++++++++++++ installer/internal/host/host.go | 1 + installer/internal/steps/runner.go | 4 +++ installer/internal/steps/runner_test.go | 31 ++++++++++++++++++++++ installer/webui/hostapi/backend.go | 3 +++ scripts/tests/test_updater.sh | 12 +++++++++ 9 files changed, 99 insertions(+), 2 deletions(-) diff --git a/dashboard/energy-node-updater.sh b/dashboard/energy-node-updater.sh index 98e9483..8df7cc1 100755 --- a/dashboard/energy-node-updater.sh +++ b/dashboard/energy-node-updater.sh @@ -234,6 +234,18 @@ if [[ -z "${job_steps}" ]]; then fi mapfile -t step_ids <<< "${job_steps}" +# restart_all: job.json ist dashboard-beschreibbar und ungesigniert, darf den +# Neustart also nur ausweiten, nie sonst etwas steuern - deshalb nur "true" +# akzeptiert, alles andere (fehlend, false, kaputt) bleibt "". +restart_all="" +if [[ "$(python3 - "${CURRENT}" <<'PY' +import json, sys +print("all" if json.load(open(sys.argv[1], encoding="utf-8")).get("restart_all") is True else "") +PY +)" == all ]]; then + restart_all=all +fi + # step_known haelt eine Kennung aus job.json gegen das gepruefte Manifest. # Ohne diese Pruefung baute eine Kennung wie "../../etc/cron.d/x" den # Glob unten aus dem Bootstrap-Verzeichnis heraus, und root fuehrte aus, @@ -322,7 +334,8 @@ for id in "${step_ids[@]}"; do # bereits als root, und ein zusaetzliches sudo haenge den Lauf an eine # gesunde sudoers-Konfiguration und strippte Umgebungsvariablen, auf die # einzelne Schritte bauen (DEBIAN_FRONTEND in 10-apt.sh). - done < <(EN_STATE_DIR="${STATE_DIR}" EN_SELECTION="${STATE_DIR}/selection.json" \ + done < <(env ${restart_all:+EN_RESTART=all} \ + EN_STATE_DIR="${STATE_DIR}" EN_SELECTION="${STATE_DIR}/selection.json" \ EN_BUNDLE_DIR="${BUNDLE}" EN_BUNDLE_VERSION="${bundle_version}" \ EN_TARGET_USER="${target_user}" EN_TARGET_BASE="${target_base}" \ EN_SUDO="" \ diff --git a/dashboard/internal/updaterhost/host.go b/dashboard/internal/updaterhost/host.go index e268dd5..e4a0885 100644 --- a/dashboard/internal/updaterhost/host.go +++ b/dashboard/internal/updaterhost/host.go @@ -222,7 +222,7 @@ func (h *Host) Run(ctx context.Context, req hostapi.RunRequest, sink hostapi.Sin // manifest instead. job := updaterjob.Job{ BundleVersion: candidate.Version, Mode: string(req.Mode), Only: req.Only, - Steps: stepIDs, + Steps: stepIDs, RestartAll: req.RestartAll, } if err := updaterjob.Stage(h.cfg.JobDir, job, h.cfg.CandidateBundleDir); err != nil { return &hostapi.Error{Code: "JOB_STAGING_FAILED", Detail: err.Error()} diff --git a/dashboard/internal/updaterjob/job.go b/dashboard/internal/updaterjob/job.go index c856c8e..c17a91c 100644 --- a/dashboard/internal/updaterjob/job.go +++ b/dashboard/internal/updaterjob/job.go @@ -34,6 +34,7 @@ type Job struct { Mode string `json:"mode"` Only string `json:"only,omitempty"` Steps []string `json:"steps"` + RestartAll bool `json:"restart_all,omitempty"` } // Status is the updater's final report, written once to status.json. A diff --git a/dashboard/internal/updaterjob/job_test.go b/dashboard/internal/updaterjob/job_test.go index 2442fe0..11028e4 100644 --- a/dashboard/internal/updaterjob/job_test.go +++ b/dashboard/internal/updaterjob/job_test.go @@ -41,6 +41,38 @@ func TestStageWritesJobJSONAndPendingTrigger(t *testing.T) { } } +func TestStageWritesRestartAllOnlyWhenSet(t *testing.T) { + dir := t.TempDir() + bundle := t.TempDir() + if err := os.WriteFile(filepath.Join(bundle, "manifest.json"), []byte(`{"version":"1.5.0"}`), 0o644); err != nil { + t.Fatal(err) + } + job := updaterjob.Job{BundleVersion: "1.5.0", Mode: "redeploy", Steps: []string{"50"}, RestartAll: true} + if err := updaterjob.Stage(dir, job, bundle); err != nil { + t.Fatalf("Stage: %v", err) + } + raw, err := os.ReadFile(filepath.Join(dir, "pending.json")) + if err != nil { + t.Fatal(err) + } + if !contains(string(raw), `"restart_all":true`) { + t.Fatalf("restart_all missing from pending.json: %s", raw) + } + + dir2 := t.TempDir() + job2 := updaterjob.Job{BundleVersion: "1.5.0", Mode: "redeploy", Steps: []string{"50"}} + if err := updaterjob.Stage(dir2, job2, bundle); err != nil { + t.Fatalf("Stage: %v", err) + } + raw2, err := os.ReadFile(filepath.Join(dir2, "pending.json")) + if err != nil { + t.Fatal(err) + } + if contains(string(raw2), "restart_all") { + t.Fatalf("restart_all present without being set: %s", raw2) + } +} + func TestStageRefusesAConcurrentJob(t *testing.T) { dir := t.TempDir() bundle := t.TempDir() diff --git a/installer/internal/host/host.go b/installer/internal/host/host.go index 94761f0..f0efa4b 100644 --- a/installer/internal/host/host.go +++ b/installer/internal/host/host.go @@ -404,6 +404,7 @@ func (h *Host) Run(ctx context.Context, req hostapi.RunRequest, sink hostapi.Sin TargetUser: firstNonEmpty(req.TargetUser, manifest.TargetUser), TargetBase: firstNonEmpty(req.TargetBase, manifest.TargetBase), MQTTUser: req.MQTTUser, + RestartAll: req.RestartAll, Steps: list, Selection: h.selectionForRun(), Secrets: &steps.Secrets{MQTTPassword: req.MQTTPassword, AdminPassword: req.AdminPassword}, diff --git a/installer/internal/steps/runner.go b/installer/internal/steps/runner.go index 49b3b59..0d438be 100644 --- a/installer/internal/steps/runner.go +++ b/installer/internal/steps/runner.go @@ -30,6 +30,7 @@ type RunOptions struct { Selection *selection.Selection // optional; uploaded before the first step Secrets *Secrets // optional; only step 60 ever receives it NodeConfigPath string // optional; where step 20 finds the MQTT user without credentials, default DefaultNodeConfigPath + RestartAll bool // optional; steps see EN_RESTART=all and restart every service unit OnMarker func(Marker) OnLog func(stepID, line string) } @@ -102,6 +103,9 @@ func runOneStep(ctx context.Context, opts RunOptions, step bundle.StepEntry) (Ma if opts.TargetBase != "" { env["EN_TARGET_BASE"] = opts.TargetBase } + if opts.RestartAll { + env["EN_RESTART"] = "all" + } scriptCommand := "bash " + transport.ShellQuote(scriptPath) if step.ID == mosquittoStepID { extraArgs, cleanupStep20, err := stageSecretsForStep20(opts.Client, opts.MQTTUser, opts.Secrets) diff --git a/installer/internal/steps/runner_test.go b/installer/internal/steps/runner_test.go index 0d39fb2..63ac680 100644 --- a/installer/internal/steps/runner_test.go +++ b/installer/internal/steps/runner_test.go @@ -251,3 +251,34 @@ printf '##STEP 60 ok\n' t.Fatalf("Run: %v", err) } } + +func TestRunPassesRestartAllToTheStepsOnlyWhenAsked(t *testing.T) { + requireSFTPServerForSteps(t) + sshd := transporttest.Start(t) + client := dialForStepsTest(t, sshd) + const echoScript = `#!/bin/sh +printf '##STEP 10 begin\n' +printf 'EN_RESTART=%s\n' "${EN_RESTART:-unset}" +printf '##STEP 10 ok\n' +` + bundleDir, stateDir := deployBootstrapScripts(t, client, map[string]string{"10-apt.sh": echoScript}) + + for _, tc := range []struct { + restartAll bool + want string + }{{false, "EN_RESTART=unset"}, {true, "EN_RESTART=all"}} { + var logs []string + err := steps.Run(context.Background(), steps.RunOptions{ + Client: client, RemoteBundleDir: bundleDir, RemoteStateDir: stateDir, BundleVersion: "v0.1.0", + Steps: []bundle.StepEntry{{ID: "10"}}, + RestartAll: tc.restartAll, + OnLog: func(_, line string) { logs = append(logs, line) }, + }) + if err != nil { + t.Fatalf("Run(restartAll=%v): %v", tc.restartAll, err) + } + if len(logs) != 1 || logs[0] != tc.want { + t.Errorf("restartAll=%v: logs = %q, want [%q]", tc.restartAll, logs, tc.want) + } + } +} diff --git a/installer/webui/hostapi/backend.go b/installer/webui/hostapi/backend.go index d72bb62..f8e2095 100644 --- a/installer/webui/hostapi/backend.go +++ b/installer/webui/hostapi/backend.go @@ -190,6 +190,9 @@ type RunRequest struct { TargetBase string `json:"target_base,omitempty"` // MQTTUser ist der Broker-Benutzer, den Schritt 20 anlegt. MQTTUser string `json:"mqtt_user,omitempty"` + // RestartAll starts every service unit again, not only the ones whose + // version changed (the redeploy page's "Restart all services" switch). + RestartAll bool `json:"restart_all,omitempty"` } // Secrets liefert die Werte, die aus jeder Ausgabe gefiltert werden muessen. diff --git a/scripts/tests/test_updater.sh b/scripts/tests/test_updater.sh index d5edd7a..f8b513f 100755 --- a/scripts/tests/test_updater.sh +++ b/scripts/tests/test_updater.sh @@ -52,6 +52,7 @@ if [ -z "$user" ] || [ -z "$pwfile" ] || [ ! -f "$pwfile" ]; then echo "##STEP 20 fail MOSQUITTO_ARGS_MISSING"; exit 1 fi echo "Broker eingerichtet, Benutzer $user." +echo "EN_RESTART=${EN_RESTART:-unset}" echo "##STEP 20 ok" SH cat > "$fixture/bootstrap/50-python-deps.sh" <<'SH' @@ -275,4 +276,15 @@ printf '%s\n' '{"bundle_version":"1.5.0","mode":"redeploy","steps":["60"]}' > "$ if run_updater "$job_f"; then fail "invalid pinned target accepted"; fi grep -q '"code":"TARGET_INVALID"' "$job_f/status.json" || fail "expected TARGET_INVALID: $(cat "$job_f/status.json")" +# --- restart_all im Auftrag setzt EN_RESTART=all fuer die Schritte --------- +job8="$tmp/job8" +stage_job "$job8" '{"bundle_version":"1.5.0","mode":"redeploy","restart_all":true,"steps":["20"]}' +run_updater "$job8" +grep -q 'EN_RESTART=all' "$job8/log" || fail "restart_all kam nicht bei den Schritten an" "$(cat "$job8/log")" + +job9="$tmp/job9" +stage_job "$job9" '{"bundle_version":"1.5.0","mode":"redeploy","steps":["20"]}' +run_updater "$job9" +grep -q 'EN_RESTART=unset' "$job9/log" || fail "EN_RESTART ohne restart_all gesetzt" "$(cat "$job9/log")" + echo "OK" From e2464cb581409dcbb4cd5578e4836c0e5bf19640 Mon Sep 17 00:00:00 2001 From: Developer-Simon Date: Tue, 22 Sep 2026 17:59:02 +0200 Subject: [PATCH 04/15] feat(installer): show per service which units restart in the preview Co-Authored-By: Claude Sonnet 5 --- dashboard/internal/updaterhost/host.go | 31 +++++++-- dashboard/internal/updaterhost/restart.go | 66 +++++++++++++++++++ .../internal/updaterhost/restart_test.go | 32 +++++++++ installer/internal/host/host.go | 9 ++- installer/internal/steps/plan.go | 17 +++-- installer/internal/steps/plan_test.go | 5 +- installer/webui/hostapi/backend.go | 5 ++ scripts/bootstrap/plan.sh | 21 +++++- scripts/tests/test_bootstrap_plan.sh | 13 +++- 9 files changed, 182 insertions(+), 17 deletions(-) create mode 100644 dashboard/internal/updaterhost/restart.go create mode 100644 dashboard/internal/updaterhost/restart_test.go diff --git a/dashboard/internal/updaterhost/host.go b/dashboard/internal/updaterhost/host.go index e4a0885..3b8a272 100644 --- a/dashboard/internal/updaterhost/host.go +++ b/dashboard/internal/updaterhost/host.go @@ -62,11 +62,18 @@ type candidateManifest struct { Steps []struct { ID string `json:"id"` Optional bool `json:"optional"` + Dir string `json:"dir"` + Version string `json:"version"` } `json:"steps"` } type installedManifest struct { - Version string `json:"version"` + Version string `json:"version"` + Components map[string]string `json:"components"` + Steps []struct { + ID string `json:"id"` + Version string `json:"version"` + } `json:"steps"` } type selectionFile struct { @@ -163,9 +170,12 @@ func (h *Host) Plan(context.Context) (*hostapi.PlanView, error) { if err != nil { return nil, err } - var installed installedManifest + var installed *installedManifest if raw, err := os.ReadFile(h.cfg.InstalledManifestPath); err == nil { - _ = json.Unmarshal(raw, &installed) + var doc installedManifest + if json.Unmarshal(raw, &doc) == nil { + installed = &doc + } } view := &hostapi.PlanView{BundleVersion: candidate.Version, Components: map[string]hostapi.ComponentDelta{}} @@ -178,7 +188,20 @@ func (h *Host) Plan(context.Context) (*hostapi.PlanView, error) { if s.Optional && !sel.Steps[s.ID] { state = "deselected" } - view.Steps = append(view.Steps, hostapi.PlanStep{ID: s.ID, Optional: s.Optional, Selected: selected, State: state}) + ps := hostapi.PlanStep{ID: s.ID, Optional: s.Optional, Selected: selected, State: state} + if s.Dir != "" { + if installed != nil { + for _, is := range installed.Steps { + if is.ID == s.ID { + ps.From = is.Version + } + } + } + if state == "pending" { + ps.Restart = restartReason(installed, candidate, s.ID, false) + } + } + view.Steps = append(view.Steps, ps) } return view, nil } diff --git a/dashboard/internal/updaterhost/restart.go b/dashboard/internal/updaterhost/restart.go new file mode 100644 index 0000000..65fcf74 --- /dev/null +++ b/dashboard/internal/updaterhost/restart.go @@ -0,0 +1,66 @@ +package updaterhost + +// libraryUsers maps a shared library to the service directories that import +// it; nil means every service. Keep in step with LIBRARY_USERS in +// scripts/bootstrap/lib/restart_rule.py (restart_test.go checks the rule, and +// test_restart_rule.py checks that table against the services' imports). +var libraryUsers = map[string][]string{ + "energy_node_common": nil, + "battery_soc_core": {"battery_soc"}, +} + +// restartReason is the Go copy of restart_rule.restart_reason: "" (no +// restart), "all", "first", "unknown", "version" or "library". +func restartReason(installed *installedManifest, candidate *candidateManifest, stepID string, restartAll bool) string { + if restartAll { + return "all" + } + if candidate == nil { + return "unknown" + } + if installed == nil { + return "first" + } + var dir, newVersion string + found := false + for _, s := range candidate.Steps { + if s.ID == stepID { + dir, newVersion, found = s.Dir, s.Version, true + break + } + } + if !found { + return "" + } + oldVersion, seen := "", false + for _, s := range installed.Steps { + if s.ID == stepID { + oldVersion, seen = s.Version, true + break + } + } + if !seen { + return "first" + } + if oldVersion == "" || newVersion == "" { + return "unknown" + } + if oldVersion != newVersion { + return "version" + } + for name, users := range libraryUsers { + version := candidate.Components[name] + if version == "" || installed.Components[name] == version { + continue + } + if users == nil { + return "library" + } + for _, u := range users { + if u == dir { + return "library" + } + } + } + return "" +} diff --git a/dashboard/internal/updaterhost/restart_test.go b/dashboard/internal/updaterhost/restart_test.go new file mode 100644 index 0000000..2de3889 --- /dev/null +++ b/dashboard/internal/updaterhost/restart_test.go @@ -0,0 +1,32 @@ +package updaterhost + +import ( + "encoding/json" + "os" + "testing" +) + +// The bash/Python rule (scripts/bootstrap/lib/restart_rule.py) and this Go +// copy must agree; both read the same table. +func TestRestartReasonMatchesTheSharedTable(t *testing.T) { + raw, err := os.ReadFile("../../../scripts/bootstrap/testdata/restart_cases.json") + if err != nil { + t.Fatal(err) + } + var cases []struct { + Name string `json:"name"` + Candidate *candidateManifest `json:"candidate"` + Installed *installedManifest `json:"installed"` + StepID string `json:"step_id"` + RestartAll bool `json:"restart_all"` + Want string `json:"want"` + } + if err := json.Unmarshal(raw, &cases); err != nil { + t.Fatal(err) + } + for _, c := range cases { + if got := restartReason(c.Installed, c.Candidate, c.StepID, c.RestartAll); got != c.Want { + t.Errorf("%s: got %q, want %q", c.Name, got, c.Want) + } + } +} diff --git a/installer/internal/host/host.go b/installer/internal/host/host.go index f0efa4b..08bd87f 100644 --- a/installer/internal/host/host.go +++ b/installer/internal/host/host.go @@ -364,10 +364,15 @@ func (h *Host) Plan(ctx context.Context) (*hostapi.PlanView, error) { } view := &hostapi.PlanView{BundleVersion: preview.BundleVersion, Components: map[string]hostapi.ComponentDelta{}} for _, step := range preview.Steps { - view.Steps = append(view.Steps, hostapi.PlanStep{ + ps := hostapi.PlanStep{ ID: step.ID, State: step.State, Optional: step.Optional, Selected: step.Selected, Unit: step.Unit, - }) + To: step.To, Restart: step.Restart, + } + if step.From != nil { + ps.From = *step.From + } + view.Steps = append(view.Steps, ps) } for name, versions := range preview.Components { view.Components[name] = hostapi.ComponentDelta{From: versions.From, To: versions.To} diff --git a/installer/internal/steps/plan.go b/installer/internal/steps/plan.go index d46a3f2..45900ef 100644 --- a/installer/internal/steps/plan.go +++ b/installer/internal/steps/plan.go @@ -15,13 +15,16 @@ import ( // StepPreview mirrors one entry of plan.sh's "steps" array (Plan A-II, // Task 14). type StepPreview struct { - ID string `json:"id"` - Optional bool `json:"optional"` - Selected bool `json:"selected"` - State string `json:"state"` // "done", "deselected" or "pending" - ServiceID string `json:"service_id,omitempty"` - Dir string `json:"dir,omitempty"` - Unit string `json:"unit,omitempty"` + ID string `json:"id"` + Optional bool `json:"optional"` + Selected bool `json:"selected"` + State string `json:"state"` // "done", "deselected" or "pending" + ServiceID string `json:"service_id,omitempty"` + Dir string `json:"dir,omitempty"` + Unit string `json:"unit,omitempty"` + From *string `json:"von,omitempty"` // installed version of a service step; nil when unknown + To string `json:"nach,omitempty"` // version in the bundle + Restart string `json:"restart,omitempty"` } // ComponentVersions mirrors one entry of plan.sh's "components" map. From diff --git a/installer/internal/steps/plan_test.go b/installer/internal/steps/plan_test.go index 38efa66..4328ff0 100644 --- a/installer/internal/steps/plan_test.go +++ b/installer/internal/steps/plan_test.go @@ -17,7 +17,7 @@ cat <<'JSON' "steps": [ {"id": "10", "optional": false, "selected": true, "state": "done"}, {"id": "40", "optional": true, "selected": false, "state": "deselected"}, - {"id": "81", "optional": true, "selected": true, "state": "pending", "service_id": "apsystems", "dir": "apsystems_ez1", "unit": "apsystems-ez1.service"} + {"id": "81", "optional": true, "selected": true, "state": "pending", "service_id": "apsystems", "dir": "apsystems_ez1", "unit": "apsystems-ez1.service", "von": "v0.4.0", "nach": "v0.4.1", "restart": "version"} ], "components": { "dashboard": {"von": "v0.6.0", "nach": "v0.6.1"}, @@ -47,6 +47,9 @@ func TestPreviewParsesThePlanReport(t *testing.T) { if plan.Steps[2].ServiceID != "apsystems" || plan.Steps[2].Unit != "apsystems-ez1.service" { t.Fatalf("unexpected service step: %+v", plan.Steps[2]) } + if plan.Steps[2].From == nil || *plan.Steps[2].From != "v0.4.0" || plan.Steps[2].To != "v0.4.1" || plan.Steps[2].Restart != "version" { + t.Fatalf("unexpected restart info: %+v", plan.Steps[2]) + } dashboard, ok := plan.Components["dashboard"] if !ok || dashboard.From == nil || *dashboard.From != "v0.6.0" || dashboard.To != "v0.6.1" { diff --git a/installer/webui/hostapi/backend.go b/installer/webui/hostapi/backend.go index f8e2095..0ae58aa 100644 --- a/installer/webui/hostapi/backend.go +++ b/installer/webui/hostapi/backend.go @@ -159,6 +159,11 @@ type PlanStep struct { Optional bool `json:"optional"` Selected bool `json:"selected"` Unit string `json:"unit,omitempty"` + From string `json:"from,omitempty"` + To string `json:"to,omitempty"` + // Restart says why the unit restarts: "version", "library", "first" or + // "unknown"; empty when it stays as it is. + Restart string `json:"restart,omitempty"` } // PlanView ist die Antwort von GET /api/plan. diff --git a/scripts/bootstrap/plan.sh b/scripts/bootstrap/plan.sh index 6fe18b4..cbe334d 100755 --- a/scripts/bootstrap/plan.sh +++ b/scripts/bootstrap/plan.sh @@ -7,8 +7,9 @@ # Komponente von/nach. Gibt JSON aus und KEINE ##STEP-Marker - dies ist kein # Schritt, sondern ein Bericht. set -euo pipefail +SCRIPT_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib" # shellcheck source=scripts/bootstrap/lib/step.sh -source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib/step.sh" +source "${SCRIPT_LIB_DIR}/step.sh" if [[ ! -f "${EN_BUNDLE_DIR}/manifest.json" ]]; then printf 'FEHLER BUNDLE_MANIFEST_MISSING\n' @@ -19,6 +20,7 @@ EN_STATE_DIR="${EN_STATE_DIR}" \ EN_BUNDLE_DIR="${EN_BUNDLE_DIR}" \ EN_BUNDLE_VERSION="${EN_BUNDLE_VERSION}" \ EN_SELECTION="${EN_SELECTION}" \ +EN_PLAN_LIB_DIR="${SCRIPT_LIB_DIR}" \ python3 <<'PY' import json, os, pathlib, sys @@ -26,8 +28,19 @@ state = pathlib.Path(os.environ["EN_STATE_DIR"]) bundle = pathlib.Path(os.environ["EN_BUNDLE_DIR"]) version = os.environ["EN_BUNDLE_VERSION"] +sys.path.insert(0, os.environ["EN_PLAN_LIB_DIR"]) +import restart_rule + manifest = json.loads((bundle / "manifest.json").read_text(encoding="utf-8")) +installed_doc = None +_installed_path = state / "installed-manifest.json" +if _installed_path.is_file(): + try: + installed_doc = json.loads(_installed_path.read_text(encoding="utf-8")) + except ValueError: + installed_doc = None + # Auswahl: fehlende Datei oder nicht genannter Schritt = gewaehlt. selection = {} sel_path = pathlib.Path(os.environ["EN_SELECTION"]) @@ -59,6 +72,12 @@ for entry in manifest.get("steps", []): for extra in ("service_id", "dir", "unit"): if entry.get(extra): item[extra] = entry[extra] + if entry.get("dir"): + old = next((s for s in (installed_doc or {}).get("steps") or [] if str(s.get("id")) == step_id), None) + item["von"] = (old or {}).get("version") + item["nach"] = entry.get("version") + if state_name == "pending": + item["restart"] = restart_rule.restart_reason(manifest, installed_doc, step_id) steps.append(item) # von: das Manifest des zuletzt vollstaendig angewandten Bundles. Diese diff --git a/scripts/tests/test_bootstrap_plan.sh b/scripts/tests/test_bootstrap_plan.sh index e940885..8838d74 100755 --- a/scripts/tests/test_bootstrap_plan.sh +++ b/scripts/tests/test_bootstrap_plan.sh @@ -20,7 +20,7 @@ cat > "$bundle/manifest.json" <<'JSON' { "id": "40", "optional": true, "default": true }, { "id": "50", "optional": false }, { "id": "81", "optional": true, "default": true, "service_id": "apsystems", - "dir": "apsystems_ez1", "unit": "apsystems-ez1.service" } + "dir": "apsystems_ez1", "unit": "apsystems-ez1.service", "version": "v0.4.1" } ] } JSON @@ -32,7 +32,7 @@ mkdir -p "$EN_STATE_DIR/steps" printf 'bundle=v0.2.0\n' > "$EN_STATE_DIR/steps/10" # erledigt printf 'bundle=v0.1.0\n' > "$EN_STATE_DIR/steps/50" # altes Bundle -> offen printf '{"steps":{"40":false}}\n' > "$EN_SELECTION" -printf '{"version":"v0.1.0","components":{"dashboard":"v0.6.0"}}\n' \ +printf '{"version":"v0.1.0","components":{"dashboard":"v0.6.0"},"steps":[{"id":"81","dir":"apsystems_ez1","version":"v0.4.0"}]}\n' \ > "$EN_STATE_DIR/installed-manifest.json" out="$(bash "$script")" @@ -51,6 +51,15 @@ get() { python3 -c 'import json,sys; d=json.load(sys.stdin); print(eval(sys.argv [ "$(get 'd["components"]["dashboard"]["nach"]')" = "v0.6.1" ] || fail "nach falsch" "$out" [ "$(get 'd["components"]["services"]["von"]')" = "None" ] || fail "unbekanntes von nicht null" "$out" +python3 - "$out" <<'PY' || fail "Vorschau meldet den Neustart des Dienstes falsch" +import json, sys +steps = {s["id"]: s for s in json.loads(sys.argv[1])["steps"]} +apsystems = steps["81"] +assert apsystems["von"] == "v0.4.0" and apsystems["nach"] == "v0.4.1", apsystems +assert apsystems["restart"] == "version", apsystems +assert "restart" not in steps["50"], steps["50"] # kein Dienstschritt +PY + # --- ohne installed-manifest.json ist jedes von null ---------------------- rm -f "$EN_STATE_DIR/installed-manifest.json" out="$(bash "$script")" From 63aa4069c39c00572a9ce1cb5b3fe0b5530a8670 Mon Sep 17 00:00:00 2001 From: Developer-Simon Date: Tue, 22 Sep 2026 18:01:04 +0200 Subject: [PATCH 05/15] feat(webui): restart only the services that changed, with a restart-all switch Co-Authored-By: Claude Sonnet 5 --- installer/webui/catalogs/de.json | 1 + installer/webui/catalogs/en.json | 1 + installer/webui/static/js/screen-preview.js | 24 +++++++++++++------ installer/webui/templates/screen-preview.html | 5 ++++ installer/webui/test/helpers/fixtures.mjs | 4 ++-- installer/webui/test/screen-preview.test.mjs | 20 +++++++++++++++- 6 files changed, 45 insertions(+), 10 deletions(-) diff --git a/installer/webui/catalogs/de.json b/installer/webui/catalogs/de.json index 776e432..627845d 100644 --- a/installer/webui/catalogs/de.json +++ b/installer/webui/catalogs/de.json @@ -345,6 +345,7 @@ "preview.keep.separator": " · ", "preview.package": "Paket", "preview.progress.loading": "Änderungen werden ermittelt …", + "preview.restart.all": "Alle Dienste neu starten", "preview.restart.heading": "Startet neu", "preview.restart.none": "nichts", "preview.reuse": "Dieselbe Seite steckt im Dashboard. Dort startest du ein Update ohne dieses Programm, über die gleichen Schritte.", diff --git a/installer/webui/catalogs/en.json b/installer/webui/catalogs/en.json index 99ef75b..ac11f58 100644 --- a/installer/webui/catalogs/en.json +++ b/installer/webui/catalogs/en.json @@ -345,6 +345,7 @@ "preview.keep.separator": " · ", "preview.package": "Package", "preview.progress.loading": "Checking for changes …", + "preview.restart.all": "Restart all services", "preview.restart.heading": "Restarts", "preview.restart.none": "nothing", "preview.reuse": "The same page is part of the dashboard. There you start an update without this program, through the same steps.", diff --git a/installer/webui/static/js/screen-preview.js b/installer/webui/static/js/screen-preview.js index 4bc13fe..ecc4557 100644 --- a/installer/webui/static/js/screen-preview.js +++ b/installer/webui/static/js/screen-preview.js @@ -56,10 +56,11 @@ return rows; }, - // restart: die Units der ausstehenden Schritte. Der Schritt, der die - // Dienst-Station anfuehrt (60), hat keine Unit im Manifest - er ist das - // Dashboard. - restart: function (plan, manifest, shell) { + // restart: die Units, die neu starten. Ein Dienst startet nur neu, wenn der + // Plan ihm einen Grund gibt (step.restart) oder "Alle neu starten" an ist. + // Der Schritt, der die Dienst-Station anfuehrt (60), hat keine Unit im + // Manifest - er ist das Dashboard und laeuft wie bisher immer mit. + restart: function (plan, manifest, shell, restartAll) { var group = window.Services.runGroups(manifest, { steps: {} }, shell).filter(function (g) { return g.subs; })[0]; var core = group ? group.ids[0] : ''; var units = []; @@ -68,7 +69,11 @@ return; } var unit = step.unit || (step.id === core ? window.Services.DASHBOARD_UNIT : ''); - if (unit && units.indexOf(unit) < 0) { + var isService = !!step.unit; + if (!unit || (isService && !restartAll && !step.restart)) { + return; + } + if (units.indexOf(unit) < 0) { units.push(unit); } }); @@ -91,6 +96,7 @@ plan: null, manifest: null, busy: false, + restartAll: false, get shell() { return window.Installer.shell; @@ -161,7 +167,7 @@ }, get restart() { - return this.plan && this.manifest ? PreviewModel.restart(this.plan, this.manifest, this.shell) : []; + return this.plan && this.manifest ? PreviewModel.restart(this.plan, this.manifest, this.shell, this.restartAll) : []; }, get keepNames() { @@ -205,7 +211,11 @@ this.busy = true; this.shell.error = null; try { - var response = await window.Api.post('/api/run', { mode: 'redeploy' }); + var body = { mode: 'redeploy' }; + if (this.restartAll) { + body.restart_all = true; + } + var response = await window.Api.post('/api/run', body); this.shell.shared.selectionAtEntry = null; this.shell.startRun(response, { mode: 'redeploy' }); } catch (err) { diff --git a/installer/webui/templates/screen-preview.html b/installer/webui/templates/screen-preview.html index 44629b2..7d34a9f 100644 --- a/installer/webui/templates/screen-preview.html +++ b/installer/webui/templates/screen-preview.html @@ -31,6 +31,11 @@

+
diff --git a/installer/webui/test/helpers/fixtures.mjs b/installer/webui/test/helpers/fixtures.mjs index 4623c78..8e86269 100644 --- a/installer/webui/test/helpers/fixtures.mjs +++ b/installer/webui/test/helpers/fixtures.mjs @@ -42,8 +42,8 @@ export const PLAN_UPDATE = { { id: '82', state: 'deselected', optional: true, unit: 'battery-soc.service' }, { id: '83', state: 'done', optional: true, selected: true, unit: 'shelly-rpc.service' }, { id: '84', state: 'done', optional: true, selected: true, unit: 'trucki-http.service' }, - { id: '85', state: 'pending', optional: true, selected: true, unit: 'tuya.service' }, - { id: '88', state: 'pending', optional: true, selected: true, unit: 'automation.service' }, + { id: '85', state: 'pending', optional: true, selected: true, unit: 'tuya.service', from: '1.0.0', to: '1.0.1', restart: 'version' }, + { id: '88', state: 'pending', optional: true, selected: true, unit: 'automation.service', from: '1.0.0', to: '1.0.0', restart: '' }, { id: '89', state: 'deselected', optional: true, unit: 'modbus.service' }, ], components: { diff --git a/installer/webui/test/screen-preview.test.mjs b/installer/webui/test/screen-preview.test.mjs index e8162a4..7ed6960 100644 --- a/installer/webui/test/screen-preview.test.mjs +++ b/installer/webui/test/screen-preview.test.mjs @@ -66,10 +66,28 @@ test('eine Komponente ohne Vorzustand ist neu', async () => { test('Startet neu und Bleibt stehen folgen dem Plan (A17)', async () => { const { screen } = await mount(); - assert.deepEqual(plain(screen.restart), ['energy-node-dashboard.service', 'tuya.service', 'automation.service']); + assert.deepEqual(plain(screen.restart), ['energy-node-dashboard.service', 'tuya.service']); assert.equal(screen.keepNames, 'Systempakete · MQTT-Broker · Firewall · Tailscale · HTTPS über Caddy'); }); +test('nur Dienste mit Aenderung stehen unter Neustarts, "Alle neu starten" nimmt alle dazu', async () => { + const { screen } = await mount(); + const units = plain(screen.restart); + assert.ok(units.includes('tuya.service'), 'geaenderter Dienst fehlt'); + assert.ok(!units.includes('automation.service'), 'unveraenderter Dienst darf nicht neu starten'); + screen.restartAll = true; + assert.ok(plain(screen.restart).includes('automation.service'), 'mit "alle" muss auch automation dabei sein'); +}); + +test('start sendet restart_all nur, wenn der Schalter an ist', async () => { + const { screen, calls } = await mount(); + await screen.start(); + assert.equal(calls.find((call) => call.key === 'POST /api/run').body.restart_all, undefined); + screen.restartAll = true; + await screen.start(); + assert.equal(calls.filter((call) => call.key === 'POST /api/run').pop().body.restart_all, true); +}); + test('der Funktionsumfang zeigt die Auswahl und einen neuen Dienst als aus', async () => { const { screen } = await mount(); assert.deepEqual(plain(screen.serviceParts.map((part) => [part.type, part.name || '', part.on, part.isNew || false])), [ From 08ae4b98c34b9b4e62541e04e5d4879764f412ef Mon Sep 17 00:00:00 2001 From: Developer-Simon Date: Tue, 22 Sep 2026 18:04:55 +0200 Subject: [PATCH 06/15] docs: describe which service units an update restarts Co-Authored-By: Claude Sonnet 5 --- dashboard/test/smoke/run-local-dashboard.sh | 23 ++++++++++++++++++--- docs/installer.md | 15 +++++++++----- 2 files changed, 30 insertions(+), 8 deletions(-) diff --git a/dashboard/test/smoke/run-local-dashboard.sh b/dashboard/test/smoke/run-local-dashboard.sh index 6ecf624..3f00ad3 100755 --- a/dashboard/test/smoke/run-local-dashboard.sh +++ b/dashboard/test/smoke/run-local-dashboard.sh @@ -320,6 +320,10 @@ manifest = { {"id": "30", "optional": False}, {"id": "40", "optional": True}, {"id": "50", "optional": False}, {"id": "60", "optional": False}, {"id": "65", "optional": False}, {"id": "70", "optional": True}, + # Zwei Dienstschritte, damit die Vorschau etwas zum Neustart-Vergleich + # hat: battery_soc geaendert (Neustart), apsystems unveraendert. + {"id": "81", "optional": True, "dir": "apsystems_ez1", "unit": "apsystems-ez1.service", "version": "v0.4.0"}, + {"id": "82", "optional": True, "dir": "battery_soc", "unit": "battery-soc.service", "version": "v0.5.0"}, ], } files = { @@ -336,8 +340,15 @@ with tarfile.open(archive, "w:gz") as tar: info.size = len(body) info.mode = 0o755 if name.endswith(".sh") else 0o644 tar.addfile(info, io.BytesIO(body)) -json.dump({"steps": {"40": True, "70": False}}, open(os.path.join(state, "selection.json"), "w")) -json.dump({"version": "v0.7.0"}, open(os.path.join(state, "installed-manifest.json"), "w")) +json.dump({"steps": {"40": True, "70": False, "81": True, "82": True}}, open(os.path.join(state, "selection.json"), "w")) +json.dump({ + "version": "v0.7.0", + "components": {}, + "steps": [ + {"id": "82", "version": "v0.4.0"}, + {"id": "81", "version": "v0.4.0"}, + ], +}, open(os.path.join(state, "installed-manifest.json"), "w")) PY FAKE_GITHUB_ARGS+=("$WORK/package/$PACKAGE_ASSET") fi @@ -699,11 +710,17 @@ if [[ $SIMULATE_PACKAGE -eq 1 ]]; then echo " FEHL Bundle wurde nicht heruntergeladen (siehe $WORK/dashboard.log)"; FAILED=1 fi check "Vorschau nennt Version und alle Schritte des heruntergeladenen Bundles" \ - "data['bundle_version'] == 'v9.9.9' and [s['id'] for s in data['steps']] == ['10','20','30','40','50','60','65','70']" \ + "data['bundle_version'] == 'v9.9.9' and [s['id'] for s in data['steps']] == ['10','20','30','40','50','60','65','70','81','82']" \ "$BASE/redeploy/api/plan" check "Auswahl des Nodes gilt (optionaler Schritt 70 abgewaehlt)" \ "any(s['id'] == '70' and s['state'] == 'deselected' for s in data['steps'])" \ "$BASE/redeploy/api/plan" + check "geaenderter Dienst (battery_soc) steht mit restart=version in der Vorschau" \ + "next(s for s in data['steps'] if s['id'] == '82')['restart'] == 'version'" \ + "$BASE/redeploy/api/plan" + check "unveraenderter Dienst (apsystems) hat keinen Neustartgrund" \ + "not next(s for s in data['steps'] if s['id'] == '81').get('restart')" \ + "$BASE/redeploy/api/plan" check "Die Oberflaeche kennt jetzt das bereitliegende Paket" \ "data['auto_prepare'] is True and data['bundle_version'] == 'v9.9.9'" \ "$BASE/redeploy/api/bootstrap" diff --git a/docs/installer.md b/docs/installer.md index 4e2fff3..0aae249 100644 --- a/docs/installer.md +++ b/docs/installer.md @@ -163,11 +163,16 @@ file. Choose **Update** and connect. The preview compares each component's version against the stamp on the Pi and lists what changes, which services restart, and -which steps are skipped because their work is done. The scope card shows the -services as they were last selected; a service that is new in the package stays -off until you tick it (**Change services**). **Update** runs exactly the steps -that are pending — the same code path as a fresh install, so there is no -separate "partial" update to go wrong. +which steps are skipped because their work is done. By default, only a service +whose own version changed restarts; a change to `energy_node_common` restarts +every service, and a change to `battery_soc_core` restarts `battery_soc` alone. +Tick **Restart all services** to restart every service unit regardless. A unit +that is not currently running always starts, whatever the rule says, and a node +whose last run predates per-service versions restarts every service once. The +scope card shows the services as they were last selected; a service that is new +in the package stays off until you tick it (**Change services**). **Update** +runs exactly the steps that are pending — the same code path as a fresh +install, so there is no separate "partial" update to go wrong. The dashboard serves the same page under `/redeploy/`, behind its login, and runs the same steps on the node itself, without this program and without SSH. From a1391d358aec5c216fcf083f3f53d92a2f777f67 Mon Sep 17 00:00:00 2001 From: Developer-Simon Date: Tue, 22 Sep 2026 18:13:31 +0200 Subject: [PATCH 07/15] chore(release): bump component versions Co-Authored-By: Claude Sonnet 5 --- dashboard/VERSION | 2 +- installer/VERSION | 2 +- installer/webui/VERSION | 2 +- scripts/bootstrap/VERSION | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/dashboard/VERSION b/dashboard/VERSION index 378c127..b977a66 100644 --- a/dashboard/VERSION +++ b/dashboard/VERSION @@ -1 +1 @@ -v0.7.6 +v0.7.7 diff --git a/installer/VERSION b/installer/VERSION index da14730..549f177 100644 --- a/installer/VERSION +++ b/installer/VERSION @@ -1 +1 @@ -v0.1.8 +v0.1.9 diff --git a/installer/webui/VERSION b/installer/webui/VERSION index 82942c3..fad30c5 100644 --- a/installer/webui/VERSION +++ b/installer/webui/VERSION @@ -1 +1 @@ -v0.1.6 +v0.1.7 diff --git a/scripts/bootstrap/VERSION b/scripts/bootstrap/VERSION index da14730..549f177 100644 --- a/scripts/bootstrap/VERSION +++ b/scripts/bootstrap/VERSION @@ -1 +1 @@ -v0.1.8 +v0.1.9 From 16c5068b070d76f9ccec28f35063b7df8c6fcbae Mon Sep 17 00:00:00 2001 From: Developer-Simon Date: Tue, 22 Sep 2026 18:22:33 +0200 Subject: [PATCH 08/15] fix(build): create libs//dist before probing it for a cached wheel find on a directory that does not exist yet (a checkout that has never built a wheel locally before) fails; under set -e and pipefail that silently kills make_bundle.sh at the cached=$(find ...) assignment with no error message, because the one message that would explain it is thrown away by 2>/dev/null. Pre-existing bug, unrelated to the restart- services work on this branch; found while building a bundle from this worktree to test that work on a Pi. Co-Authored-By: Claude Sonnet 5 --- scripts/build/lib/wheels.sh | 6 ++++++ scripts/tests/test_build_wheels.sh | 34 ++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/scripts/build/lib/wheels.sh b/scripts/build/lib/wheels.sh index c788beb..b27e330 100644 --- a/scripts/build/lib/wheels.sh +++ b/scripts/build/lib/wheels.sh @@ -127,6 +127,12 @@ build_local_wheels() { version="$(tr -d '[:space:]' < "${repo_root}/libs/${lib}/VERSION")" version="${version#v}" + # dist/ may not exist yet on a checkout that has never built a wheel + # locally before -- find on a missing directory fails, and pipefail + # would otherwise carry that failure into the assignment below and kill + # the script under set -e without printing anything (the real error is + # thrown away by 2>/dev/null). + mkdir -p "${dist}" cached="$(find "${dist}" -maxdepth 1 -name "${lib}-${version}-*.whl" 2>/dev/null | head -n 1)" if [[ -n "${cached}" ]]; then echo "Wiederverwendet: $(basename "${cached}")" diff --git a/scripts/tests/test_build_wheels.sh b/scripts/tests/test_build_wheels.sh index 354641d..eb89fc0 100755 --- a/scripts/tests/test_build_wheels.sh +++ b/scripts/tests/test_build_wheels.sh @@ -99,4 +99,38 @@ set -e [ "$rc" -ne 0 ] || fail "pip-Fehler nicht weitergereicht" grep -qi 'kein Wheel' <<<"$out" || fail "Meldung nennt das Problem nicht" "$out" +# --- build_local_wheels auf einem frischen Checkout ohne dist/ ----------- +# find auf einem fehlenden Verzeichnis schlaegt fehl; unter set -e/pipefail +# darf das cached=$(find ...) nicht das ganze Skript stumm abbrechen (es tat +# das vor dem Fix, weil 2>/dev/null die einzige Fehlermeldung verschluckte). +repo="$tmp/repo" +mkdir -p "$repo/libs/energy_node_common" "$repo/libs/battery_soc_core" +(cd "$repo" && git init -q) +printf 'v1.2.3\n' > "$repo/libs/energy_node_common/VERSION" +printf 'v0.1.0\n' > "$repo/libs/battery_soc_core/VERSION" +cat > "$tmp/bin/fakepip" <<'SH' +#!/usr/bin/env bash +printf 'pip %s\n' "$*" >> "$PIP_LOG" +if [ "$1" = wheel ]; then + src="$2" dir="" + shift + while [ $# -gt 0 ]; do + case "$1" in --wheel-dir) dir="$2"; shift 2 ;; *) shift ;; esac + done + name="$(basename "$src")" + version="$(tr -d '[:space:]' < "$src/VERSION")"; version="${version#v}" + : > "$dir/${name}-${version}-py3-none-any.whl" +fi +exit "${PIP_RC:-0}" +SH +chmod +x "$tmp/bin/fakepip" +: > "$PIP_LOG" +# make_bundle.sh selbst laeuft mit set -euo pipefail - das muss hier auch +# gelten, sonst prueft der Test nicht denselben Fehlerpfad (ein einfacher +# "source; call" ohne -e haette den Bug nicht gezeigt). +out="$(cd "$repo" && bash -c 'set -euo pipefail; source "$1"; shift; "$@"' _ "$lib" build_local_wheels "$tmp/localwheels" 2>&1)" \ + || fail "build_local_wheels brach auf einem Checkout ohne dist/ stumm ab" "$out" +[ -d "$repo/libs/energy_node_common/dist" ] || fail "dist/ wurde nicht angelegt" +grep -q 'Baue energy_node_common 1.2.3' <<<"$out" || fail "Bauhinweis fehlt" "$out" + echo "OK: $(basename "$0")" From c67b49db82d737dbcb932b80f619011c896a53c1 Mon Sep 17 00:00:00 2001 From: Developer-Simon Date: Tue, 22 Sep 2026 18:33:06 +0200 Subject: [PATCH 09/15] fix(installer): localize the package-preparation log lines Two lines in resolve.go's repo-build path and two in package.go's upload/verify path were passed as raw German literals to the plain log channel instead of a translation key on the note channel, so they showed up untranslated during an English-language run (mixed in among otherwise correctly localized lines like "Detecting the device's processor architecture"). Give them catalog keys, following the existing package.log.* pattern (package.log.extract, .repo_build, .upload, .verify), and route them through note()/notef() instead of log()/logf(). The raw log() call that streams make_bundle.sh's own stdout is left untouched -- that is genuine subprocess output, not installer UI text, and cannot be a catalog key. Co-Authored-By: Claude Sonnet 5 --- installer/internal/bundlesource/resolve.go | 12 ++++++++++-- installer/internal/host/package.go | 4 ++-- installer/webui/catalogs/de.json | 4 ++++ installer/webui/catalogs/en.json | 4 ++++ 4 files changed, 20 insertions(+), 4 deletions(-) diff --git a/installer/internal/bundlesource/resolve.go b/installer/internal/bundlesource/resolve.go index b577622..64e8c7e 100644 --- a/installer/internal/bundlesource/resolve.go +++ b/installer/internal/bundlesource/resolve.go @@ -57,6 +57,10 @@ func (r *Resolver) Resolve(ctx context.Context, req Request) (*Resolved, error) if log == nil { log = func(string) {} } + note := req.Note + if note == nil { + note = func(string, map[string]string) {} + } if req.Kind == KindBundled { return r.finish(req, r.BundledDir, "", false, noop, "") } @@ -94,7 +98,7 @@ func (r *Resolver) Resolve(ctx context.Context, req Request) (*Resolved, error) return nil, err } - log("Paket entpacken") + note("package.log.extract", map[string]string{}) dir := filepath.Join(work, "bundle") if err := bundle.ExtractArchive(archive, dir); err != nil { cleanup() @@ -139,6 +143,10 @@ func (r *Resolver) finish(req Request, dir, archive string, strict bool, cleanup } func (r *Resolver) buildFromRepo(ctx context.Context, req Request, log func(string)) (string, error) { + note := req.Note + if note == nil { + note = func(string, map[string]string) {} + } req.Path = ExpandHome(req.Path) if err := CheckRepo(req.Path); err != nil { return "", err @@ -163,7 +171,7 @@ func (r *Resolver) buildFromRepo(ctx context.Context, req Request, log func(stri if build == nil { build = bundle.BuildViaRepo } - log("Bundle aus dem Repository bauen (das kann einige Minuten dauern)") + note("package.log.repo_build", map[string]string{}) archive, err := build(ctx, bundle.BuildArgs{ RepoRoot: req.Path, Arch: req.Arch, User: req.User, Base: req.Base, OutDir: outDir, Log: log, diff --git a/installer/internal/host/package.go b/installer/internal/host/package.go index f046620..caed6fb 100644 --- a/installer/internal/host/package.go +++ b/installer/internal/host/package.go @@ -246,11 +246,11 @@ func (h *Host) doPrepare(ctx context.Context, client *transport.Client, logf fun if err := provisionRemoteStateDir(ctx, client, h.cfg.RemoteStateDir); err != nil { return &hostapi.Error{Code: "PACKAGE_STAGE_FAILED", Detail: err.Error()} } - logf("Paket auf das Geraet uebertragen") + notef("package.log.upload", map[string]string{}) if err := stageBundle(ctx, client, archive, h.cfg.RemoteBundleDir, uploadProgress(notef)); err != nil { return &hostapi.Error{Code: "PACKAGE_STAGE_FAILED", Detail: err.Error()} } - logf("Paket auf dem Geraet pruefen") + notef("package.log.verify", map[string]string{}) if err := verifyStaged(ctx, client, h.cfg.RemoteBundleDir, resolved.Signed); err != nil { return err } diff --git a/installer/webui/catalogs/de.json b/installer/webui/catalogs/de.json index 627845d..308070d 100644 --- a/installer/webui/catalogs/de.json +++ b/installer/webui/catalogs/de.json @@ -117,8 +117,12 @@ "package.log.cached": "{name} liegt bereits im Zwischenspeicher", "package.log.detect_arch": "Prozessorarchitektur des Geräts ermitteln", "package.log.download": "Lade {name}", + "package.log.extract": "Paket entpacken", "package.log.github_search": "Suche das neueste Release für {arch}", + "package.log.repo_build": "Bundle aus dem Repository bauen (das kann einige Minuten dauern)", + "package.log.upload": "Paket auf das Gerät übertragen", "package.log.upload_progress": "Übertrage auf das Gerät: {percent} % ({done} von {total} MB)", + "package.log.verify": "Paket auf dem Gerät prüfen", "prepare.done": "Das Paket liegt auf dem Gerät und ist geprüft.", "prepare.failed": "Das Paket konnte nicht vorbereitet werden.", "prepare.heading": "Paket vorbereiten", diff --git a/installer/webui/catalogs/en.json b/installer/webui/catalogs/en.json index ac11f58..3147fed 100644 --- a/installer/webui/catalogs/en.json +++ b/installer/webui/catalogs/en.json @@ -117,8 +117,12 @@ "package.log.cached": "{name} is already in the cache", "package.log.detect_arch": "Detecting the device's processor architecture", "package.log.download": "Downloading {name}", + "package.log.extract": "Unpacking the package", "package.log.github_search": "Looking for the newest release for {arch}", + "package.log.repo_build": "Building the bundle from the repository (this can take a few minutes)", + "package.log.upload": "Transferring the package to the device", "package.log.upload_progress": "Uploading to the device: {percent} % ({done} of {total} MB)", + "package.log.verify": "Checking the package on the device", "prepare.done": "The package is on the device and has been verified.", "prepare.failed": "The package could not be prepared.", "prepare.heading": "Prepare package", From 787d1bb5412f65c6554a0a0799cb70809ba054d2 Mon Sep 17 00:00:00 2001 From: Developer-Simon Date: Tue, 22 Sep 2026 18:33:22 +0200 Subject: [PATCH 10/15] feat(webui): give the prepare step its own stepper entry Preparing the package (fetching/building it, transferring it to the device) ran under the "connect" station in the step chain, so the operator saw "Connection" stay highlighted while a repo build or upload was actually in progress. Add "prepare" to both FLOW tables, right after "connect", and drop the special-case mapping that pointed the active station back at "connect" while on the prepare screen. It only appears when the screen will actually be visited: bootstrap.package (the installer's own package selection, shown after connect) or bootstrap.auto_prepare (the dashboard fetching its package by itself, shown as the very first screen, connect skipped). Neither existing test fixture sets either flag, so the five/three-station tests are unaffected; new tests cover both the installer and dashboard cases where prepare does show. Co-Authored-By: Claude Sonnet 5 --- installer/webui/catalogs/de.json | 1 + installer/webui/catalogs/en.json | 1 + installer/webui/static/js/app.js | 19 ++++++++++++++----- installer/webui/test/shell.test.mjs | 22 ++++++++++++++++++++++ 4 files changed, 38 insertions(+), 5 deletions(-) diff --git a/installer/webui/catalogs/de.json b/installer/webui/catalogs/de.json index 308070d..88c27e1 100644 --- a/installer/webui/catalogs/de.json +++ b/installer/webui/catalogs/de.json @@ -422,6 +422,7 @@ "stepper.configure": "Konfiguration", "stepper.connect": "Verbindung", "stepper.precheck": "Vorprüfung", + "stepper.prepare": "Vorbereitung", "stepper.preview": "Vorschau", "stepper.result": "Ergebnis", "stepper.run": "Ausführung", diff --git a/installer/webui/catalogs/en.json b/installer/webui/catalogs/en.json index 3147fed..d2f906b 100644 --- a/installer/webui/catalogs/en.json +++ b/installer/webui/catalogs/en.json @@ -422,6 +422,7 @@ "stepper.configure": "Configuration", "stepper.connect": "Connection", "stepper.precheck": "Pre-check", + "stepper.prepare": "Preparation", "stepper.preview": "Preview", "stepper.result": "Result", "stepper.run": "Run", diff --git a/installer/webui/static/js/app.js b/installer/webui/static/js/app.js index 4644e00..de58ef9 100644 --- a/installer/webui/static/js/app.js +++ b/installer/webui/static/js/app.js @@ -11,8 +11,8 @@ // Plan C-II, Vertrag 5, als Tabellen. var FIRST_SCREEN = { install: 'precheck', redeploy: 'preview', diagnose: 'diagnose' }; var FLOW = { - install: ['connect', 'precheck', 'configure', 'run', 'result'], - redeploy: ['connect', 'preview', 'run', 'result'], + install: ['connect', 'prepare', 'precheck', 'configure', 'run', 'result'], + redeploy: ['connect', 'prepare', 'preview', 'run', 'result'], }; // Auf diesen Bildschirmen ist noch nichts veraendert - nur dort steht der // Einstiegs-Umschalter. @@ -153,11 +153,20 @@ return []; } var needsConnection = this.bootstrap.needs_connection; + // prepare steht nur in der Kette, wenn dieser Bildschirm ueberhaupt + // angelaufen wird: ueber die Paketauswahl des Installers (package) + // oder das selbstaendige Besorgen des Dashboards (auto_prepare). + var showsPrepare = !!(this.bootstrap.package || this.bootstrap.auto_prepare); var flow = FLOW[this.entry].filter(function (id) { - return id !== 'connect' || needsConnection; + if (id === 'connect') { + return needsConnection; + } + if (id === 'prepare') { + return showsPrepare; + } + return true; }); - var at = this.screen === 'prepare' ? 'connect' - : this.screen === 'configure' && this.entry === 'redeploy' ? 'preview' : this.screen; + var at = this.screen === 'configure' && this.entry === 'redeploy' ? 'preview' : this.screen; var position = flow.indexOf(at); if (position < 0) { return []; diff --git a/installer/webui/test/shell.test.mjs b/installer/webui/test/shell.test.mjs index dd7c8bb..8bc21c0 100644 --- a/installer/webui/test/shell.test.mjs +++ b/installer/webui/test/shell.test.mjs @@ -272,3 +272,25 @@ test('ohne auto_prepare bleibt der erste Bildschirm des Dashboards die Vorschau' const { shell } = await createShell({ bootstrap: DASHBOARD }); assert.equal(shell.screen, 'preview'); }); + +test('das Vorbereiten hat im Stepper eine eigene Station statt unter Verbindung mitzulaufen', async () => { + const { shell } = await createShell({ bootstrap: Object.assign({}, BOOT, { package: {} }) }); + shell.screen = 'prepare'; + const items = shell.stepperParts.filter((part) => part.item); + assert.deepEqual(plain(items.map((item) => item.key)), ['connect', 'prepare', 'precheck', 'configure', 'run', 'result']); + assert.equal(items[0].done, true, 'Verbindung liegt hinter dem Vorbereiten'); + assert.equal(items[1].cls, 'st-item active'); +}); + +test('ein Wirt ohne Paketauswahl und ohne auto_prepare zeigt keine Vorbereiten-Station', async () => { + const { shell } = await createShell(); + const items = shell.stepperParts.filter((part) => part.item); + assert.equal(items.some((item) => item.key === 'prepare'), false); +}); + +test('beim Dashboard mit auto_prepare steht das Vorbereiten als eigene, aktive Station vor der Vorschau', async () => { + const { shell } = await createShell({ bootstrap: Object.assign({}, DASHBOARD, { auto_prepare: true }) }); + const items = shell.stepperParts.filter((part) => part.item); + assert.deepEqual(plain(items.map((item) => item.key)), ['prepare', 'preview', 'run', 'result']); + assert.equal(items[0].cls, 'st-item active'); +}); From b8350d177e3f1b416226e544041864c6fc968153 Mon Sep 17 00:00:00 2001 From: energy-node-bot Date: Tue, 22 Sep 2026 16:45:45 +0000 Subject: [PATCH 11/15] docs(changelog): update changelogs --- dashboard/CHANGELOG.md | 8 +++++++- installer/CHANGELOG.md | 5 ++++- installer/webui/CHANGELOG.md | 7 ++++++- scripts/bootstrap/CHANGELOG.md | 5 ++++- services/tuya_mqtt/CHANGELOG.md | 2 +- 5 files changed, 22 insertions(+), 5 deletions(-) diff --git a/dashboard/CHANGELOG.md b/dashboard/CHANGELOG.md index 6caad30..bbe880e 100644 --- a/dashboard/CHANGELOG.md +++ b/dashboard/CHANGELOG.md @@ -1,11 +1,13 @@ # Changelog -## v0.7.6 (2026-09-21) +## v0.7.7 (2026-09-22) ### Features - **installer:** add package sources (file, repo build, GitHub) (#42) (f831eab) - **dashboard:** download the newest release bundle from the redeploy page (#44) (245b277) +- **installer:** carry a restart-all request from the UI to the service steps (bad757a) +- **installer:** show per service which units restart in the preview (e2464cb) - **updater:** take the target from the root-owned target.json for user-independent bundles (e3813fc) - **dashboard:** add a GitHub release client that finds and downloads the node bundle (0493264) - **dashboard:** extract, validate and atomically install a downloaded bundle (e0896ae) @@ -24,6 +26,10 @@ - **dashboard:** hand the redeploy page the session's CSRF token (7bd8f3a) - **dashboard:** implement the new hostapi.Sink.Message method (8ae6af3) +### Documentation + +- describe which service units an update restarts (08ae4b9) + ### Tests - **dashboard:** show the package download in the local smoke test (f2c2f2c) diff --git a/installer/CHANGELOG.md b/installer/CHANGELOG.md index 6c46d34..4d7c74f 100644 --- a/installer/CHANGELOG.md +++ b/installer/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## v0.1.8 (2026-09-21) +## v0.1.9 (2026-09-22) ### Features @@ -11,12 +11,15 @@ - **installer:** add package sources (file, repo build, GitHub) (#42) (f831eab) - **dashboard:** download the newest release bundle from the redeploy page (#44) (245b277) - **services:** give every service its own version and changelog (#45) (5bc91b8) +- **installer:** carry a restart-all request from the UI to the service steps (bad757a) +- **installer:** show per service which units restart in the preview (e2464cb) - **installer:** report the bundle upload progress and write concurrently (19df0c0) ### Fixes - **installer:** restart service units on update and record the installed manifest (#43) (0f2aec2) - **installer:** make redeploy, repair and repo-built bundles install cleanly (#46) (bf1fe10) +- **installer:** localize the package-preparation log lines (c67b49d) - **installer:** expand ~ in the repo package path (873bdda) - **installer:** replace remote files the SSH user cannot open for writing (eca0198) - **installer:** give step 20 its MQTT arguments from the node on redeploy and repair (bb4cd40) diff --git a/installer/webui/CHANGELOG.md b/installer/webui/CHANGELOG.md index 6baaaf1..0c76f5c 100644 --- a/installer/webui/CHANGELOG.md +++ b/installer/webui/CHANGELOG.md @@ -1,16 +1,21 @@ # Changelog -## v0.1.6 (2026-09-21) +## v0.1.7 (2026-09-22) ### Features - **installer:** add package sources (file, repo build, GitHub) (#42) (f831eab) - **dashboard:** download the newest release bundle from the redeploy page (#44) (245b277) +- **installer:** carry a restart-all request from the UI to the service steps (bad757a) +- **installer:** show per service which units restart in the preview (e2464cb) +- **webui:** restart only the services that changed, with a restart-all switch (63aa406) +- **webui:** give the prepare step its own stepper entry (787d1bb) - **installer:** report the bundle upload progress and write concurrently (19df0c0) ### Fixes - **installer:** make redeploy, repair and repo-built bundles install cleanly (#46) (bf1fe10) +- **installer:** localize the package-preparation log lines (c67b49d) - **webui:** show the error detail of a failed run (5017432) - **installer:** give step 20 its MQTT arguments from the node on redeploy and repair (bb4cd40) - **installer:** add texts for every fault code the bootstrap steps emit (23a64c3) diff --git a/scripts/bootstrap/CHANGELOG.md b/scripts/bootstrap/CHANGELOG.md index f490c90..d8b2f95 100644 --- a/scripts/bootstrap/CHANGELOG.md +++ b/scripts/bootstrap/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## v0.1.8 (2026-09-21) +## v0.1.9 (2026-09-22) ### Features @@ -10,6 +10,9 @@ - **installer:** hide dashboard tabs for deselected optional services (#31) (59f2d40) - **installer:** add the dashboard's local self-update path (Plan D) (#33) (e50d236) - **dashboard:** download the newest release bundle from the redeploy page (#44) (245b277) +- **bootstrap:** add the rule that decides which service units restart (5405aca) +- **bootstrap:** restart a service unit only when its version or library changed (440f113) +- **installer:** show per service which units restart in the preview (e2464cb) ### Fixes diff --git a/services/tuya_mqtt/CHANGELOG.md b/services/tuya_mqtt/CHANGELOG.md index 91004d3..1204c53 100644 --- a/services/tuya_mqtt/CHANGELOG.md +++ b/services/tuya_mqtt/CHANGELOG.md @@ -8,7 +8,7 @@ ### Fixes -- **tuya:** retry once on a stale persistent socket before reporting offline (41e9f55) +- **tuya:** retry once on a stale persistent socket before reporting offline (#47) (1dfc8bd) ## v0.3.2 (2026-09-15) From bbf1e270d104777282ec5802ffbce277acf3d8ea Mon Sep 17 00:00:00 2001 From: Developer-Simon Date: Tue, 22 Sep 2026 18:47:21 +0200 Subject: [PATCH 12/15] fix(webui): stop the "restart all" label from overlapping neighbouring text The label text sat inside the .settings-toggle element, which is sized to fit only the switch itself (2.35rem x 1.32rem, per installer.css) -- the text wrapped inside that fixed box and overlapped the restart list above it and the "stays as is" text below. Every other .settings-toggle in this codebase (screen-configure.html's service rows) keeps the label text as a sibling span outside the switch's own