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/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/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..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 } @@ -222,7 +245,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/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/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/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. 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/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/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/host.go b/installer/internal/host/host.go index 94761f0..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} @@ -404,6 +409,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/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/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/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/CHANGELOG.md b/installer/webui/CHANGELOG.md index 6baaaf1..90c7ce7 100644 --- a/installer/webui/CHANGELOG.md +++ b/installer/webui/CHANGELOG.md @@ -1,16 +1,23 @@ # 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:** stop the "restart all" label from overlapping neighbouring text (bbf1e27) +- **webui:** update the design drafts for the new prepare stepper entry (c958d03) - **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/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/installer/webui/catalogs/de.json b/installer/webui/catalogs/de.json index 776e432..88c27e1 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", @@ -345,6 +349,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.", @@ -417,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 99ef75b..d2f906b 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", @@ -345,6 +349,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.", @@ -417,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/hostapi/backend.go b/installer/webui/hostapi/backend.go index d72bb62..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. @@ -190,6 +195,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/installer/webui/static/css/screens.css b/installer/webui/static/css/screens.css index 2ec0ada..07c3058 100644 --- a/installer/webui/static/css/screens.css +++ b/installer/webui/static/css/screens.css @@ -229,6 +229,10 @@ .app[data-screen="preview"] .card.soft{background:var(--sunken)} .app[data-screen="preview"] .reuse{margin:0;font-size:.785rem;line-height:1.5;color:var(--text-subtle)} /* == Ende Vorlage == */ +/* Ergaenzung Aktualisieren: "Alle neu starten" (nicht in der Vorlage) - + Schalter und Beschriftung als Zeile, statt den Text ins feste + .settings-toggle-Mass (2.35rem) zu zwaengen. */ +.app[data-screen="preview"] .restart-all{display:flex;align-items:center;gap:.5rem;margin-top:.5rem;font-size:.765rem;color:var(--text-subtle)} /* == Vorlage: Diagnose == */ .app[data-screen="diagnose"] .tally{flex:none;display:flex;gap:.55rem;margin-bottom:1rem} .app[data-screen="diagnose"] .tl{display:flex;align-items:center;gap:.5rem;padding:.5rem .8rem;border-radius:var(--radius-sm); border:1px solid var(--border-soft);background:var(--panel)} 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/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..bc8cb9d 100644 --- a/installer/webui/templates/screen-preview.html +++ b/installer/webui/templates/screen-preview.html @@ -31,6 +31,13 @@
, +