From d4119f8133144cfe05f2f4f347ebb2b7b5588fc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Damian=20K=C4=99ska?= <372403+keskad@users.noreply.github.com> Date: Wed, 5 Aug 2026 22:11:47 +0200 Subject: [PATCH 1/2] feat: admin system power control via microinit Expose GET/POST /api/v1/admin/system for init-mode shutdown and restart, and add an administrator menu dialog with Cancel, Shutdown, and Restart. Co-authored-by: Cursor --- go.mod | 2 +- go.sum | 2 + pkgs/bigfred/server/cli/root.go | 1 + pkgs/bigfred/server/http/router.go | 5 + pkgs/bigfred/server/http/system.go | 70 ++++++++++++ pkgs/bigfred/server/http/system_test.go | 47 +++++++++ pkgs/bigfred/server/service/system.go | 75 +++++++++++++ web/src/api/system.ts | 34 ++++++ web/src/components/AppShell.tsx | 14 +++ web/src/components/SystemPowerDialog.tsx | 129 +++++++++++++++++++++++ web/src/i18n/locales/de/common.json | 17 ++- web/src/i18n/locales/en/common.json | 17 ++- web/src/i18n/locales/pl/common.json | 17 ++- 13 files changed, 423 insertions(+), 7 deletions(-) create mode 100644 pkgs/bigfred/server/http/system.go create mode 100644 pkgs/bigfred/server/http/system_test.go create mode 100644 pkgs/bigfred/server/service/system.go create mode 100644 web/src/api/system.ts create mode 100644 web/src/components/SystemPowerDialog.tsx diff --git a/go.mod b/go.mod index fa5cb42..94c1888 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ require ( github.com/alicebob/miniredis/v2 v2.38.0 github.com/brutella/dnssd v1.2.14 github.com/coder/websocket v1.8.13 - github.com/dcc-bigfred/microinit/go v0.5.0 + github.com/dcc-bigfred/microinit/go v0.5.1-0.20260805200933-fc2b4ab607ba github.com/fsnotify/fsnotify v1.9.0 github.com/go-chi/chi/v5 v5.3.0 github.com/go-chi/cors v1.2.2 diff --git a/go.sum b/go.sum index ebc927c..ceba03e 100644 --- a/go.sum +++ b/go.sum @@ -20,6 +20,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dcc-bigfred/microinit/go v0.5.0 h1:Ts0qJ5VFwXZndFyLwmnZEAt+1ZOjnucrpbvbykCYzjI= github.com/dcc-bigfred/microinit/go v0.5.0/go.mod h1:pWmLbG5Xl+PEx0nr0t70gc+Ztizk/+LuJcBvz00dtys= +github.com/dcc-bigfred/microinit/go v0.5.1-0.20260805200933-fc2b4ab607ba h1:hkQRJOh9w19fXx4nLiMI1Fr3baTeMl4dZQPk0+/2C6o= +github.com/dcc-bigfred/microinit/go v0.5.1-0.20260805200933-fc2b4ab607ba/go.mod h1:pWmLbG5Xl+PEx0nr0t70gc+Ztizk/+LuJcBvz00dtys= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= diff --git a/pkgs/bigfred/server/cli/root.go b/pkgs/bigfred/server/cli/root.go index 750217c..882b210 100644 --- a/pkgs/bigfred/server/cli/root.go +++ b/pkgs/bigfred/server/cli/root.go @@ -661,6 +661,7 @@ func run(ctx context.Context, log *logrus.Logger, f Flags) error { Sudo: sudoSvc, CommandStations: commandStationSvc, Diagnostics: diagSvc, + System: service.NewSystemControl(supSvc), Hub: hub, DccBus: dccBusSvc, Radio: radioSvc, diff --git a/pkgs/bigfred/server/http/router.go b/pkgs/bigfred/server/http/router.go index ba65d38..c4d786f 100644 --- a/pkgs/bigfred/server/http/router.go +++ b/pkgs/bigfred/server/http/router.go @@ -35,6 +35,7 @@ type RouterConfig struct { Sudo *cmd.Sudo CommandStations *cmd.CommandStation Diagnostics *service.DiagnosticsService + System *service.SystemControl Hub *ws.Hub DccBus *service.DccBusService Radio *service.RadioService @@ -101,6 +102,7 @@ func NewRouter(cfg RouterConfig) http.Handler { commandStationH.SetExecutable(cfg.DccBus.Executable()) } diagnosticsH := NewDiagnosticsHandler(cfg.Diagnostics) + systemH := NewSystemHandler(cfg.System) radioH := NewRadioHandler(cfg.Radio) auditH := NewAuditHandler(cfg.Audit) leaseH := NewLeaseHandler(cfg.Leases, cfg.Auth) @@ -266,6 +268,9 @@ func NewRouter(cfg RouterConfig) http.Handler { r.Get("/diagnostics/sources", diagnosticsH.ListSources) r.Get("/diagnostics/content", diagnosticsH.ReadContent) + r.Get("/admin/system", systemH.Get) + r.Post("/admin/system/shutdown", systemH.Shutdown) + if cfg.DccBus != nil { slotsProxy := NewDccBusSlotsProxy(cfg.Auth, cfg.DccBus) r.Get("/admin/dcc-bus/{commandStationId}/slots/ws", slotsProxy.ServeHTTP) diff --git a/pkgs/bigfred/server/http/system.go b/pkgs/bigfred/server/http/system.go new file mode 100644 index 0000000..97a3219 --- /dev/null +++ b/pkgs/bigfred/server/http/system.go @@ -0,0 +1,70 @@ +package httpapi + +import ( + "encoding/json" + "errors" + "net/http" + + "github.com/keskad/loco/pkgs/bigfred/server/service" +) + +// SystemHandler serves admin host power control (BigFredOS / microinit init). +type SystemHandler struct { + svc *service.SystemControl +} + +// NewSystemHandler returns a SystemHandler. svc may be nil (503). +func NewSystemHandler(svc *service.SystemControl) *SystemHandler { + return &SystemHandler{svc: svc} +} + +// Get handles GET /api/v1/admin/system. +func (h *SystemHandler) Get(w http.ResponseWriter, _ *http.Request) { + if h.svc == nil { + writeJSONError(w, http.StatusServiceUnavailable, "system_unavailable") + return + } + info, err := h.svc.Info() + if err != nil { + if errors.Is(err, service.ErrSystemUnavailable) { + writeJSONError(w, http.StatusServiceUnavailable, "system_unavailable") + return + } + writeJSONError(w, http.StatusInternalServerError, "internal_error") + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(info) +} + +type systemShutdownBody struct { + Mode string `json:"mode"` +} + +// Shutdown handles POST /api/v1/admin/system/shutdown. +func (h *SystemHandler) Shutdown(w http.ResponseWriter, r *http.Request) { + if h.svc == nil { + writeJSONError(w, http.StatusServiceUnavailable, "system_unavailable") + return + } + var body systemShutdownBody + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeJSONError(w, http.StatusBadRequest, "invalid_body") + return + } + err := h.svc.RequestShutdown(body.Mode) + if err != nil { + switch { + case errors.Is(err, service.ErrInvalidShutdownMode): + writeJSONError(w, http.StatusBadRequest, "invalid_mode") + case errors.Is(err, service.ErrSystemNotInit): + writeJSONError(w, http.StatusConflict, "system_not_init") + case errors.Is(err, service.ErrSystemUnavailable): + writeJSONError(w, http.StatusServiceUnavailable, "system_unavailable") + default: + writeJSONError(w, http.StatusInternalServerError, "internal_error") + } + return + } + w.WriteHeader(http.StatusNoContent) +} diff --git a/pkgs/bigfred/server/http/system_test.go b/pkgs/bigfred/server/http/system_test.go new file mode 100644 index 0000000..07fd1e9 --- /dev/null +++ b/pkgs/bigfred/server/http/system_test.go @@ -0,0 +1,47 @@ +package httpapi + +import ( + "bytes" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-chi/chi/v5" + "github.com/stretchr/testify/require" + + "github.com/keskad/loco/pkgs/bigfred/server/service" +) + +func mountSystem(h *SystemHandler) http.Handler { + r := chi.NewRouter() + r.Get("/api/v1/admin/system", h.Get) + r.Post("/api/v1/admin/system/shutdown", h.Shutdown) + return r +} + +func TestSystemGetUnavailable(t *testing.T) { + h := NewSystemHandler(nil) + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/system", nil) + rec := httptest.NewRecorder() + mountSystem(h).ServeHTTP(rec, req) + require.Equal(t, http.StatusServiceUnavailable, rec.Code) +} + +func TestSystemShutdownInvalidMode(t *testing.T) { + ctl := service.NewSystemControl(nil) + h := NewSystemHandler(ctl) + body, _ := json.Marshal(map[string]string{"mode": "halt"}) + req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/system/shutdown", bytes.NewReader(body)) + rec := httptest.NewRecorder() + mountSystem(h).ServeHTTP(rec, req) + require.Equal(t, http.StatusBadRequest, rec.Code) +} + +func TestSystemShutdownModeValidationOrder(t *testing.T) { + err := service.NewSystemControl(nil).RequestShutdown("halt") + require.True(t, errors.Is(err, service.ErrInvalidShutdownMode)) + err = service.NewSystemControl(nil).RequestShutdown("poweroff") + require.True(t, errors.Is(err, service.ErrSystemUnavailable)) +} diff --git a/pkgs/bigfred/server/service/system.go b/pkgs/bigfred/server/service/system.go new file mode 100644 index 0000000..15c4500 --- /dev/null +++ b/pkgs/bigfred/server/service/system.go @@ -0,0 +1,75 @@ +package service + +import ( + "errors" + "fmt" +) + +var ( + // ErrSystemUnavailable is returned when microinit is not wired + // (--no-supervisor) or the control socket cannot be reached. + ErrSystemUnavailable = errors.New("system control unavailable") + // ErrSystemNotInit is returned when machine poweroff/reboot is + // requested but microinit is running in supervise mode. + ErrSystemNotInit = errors.New("system shutdown requires microinit init mode") + // ErrInvalidShutdownMode is returned for unknown shutdown modes. + ErrInvalidShutdownMode = errors.New("invalid shutdown mode") +) + +// SystemInfo is the admin-facing snapshot of host power capability. +type SystemInfo struct { + Mode string `json:"mode"` + CanShutdown bool `json:"canShutdown"` +} + +// SystemControl talks to microinit for host power control. +// A nil control (or nil underlying manager) returns ErrSystemUnavailable. +type SystemControl struct { + mgr *manager +} + +// NewSystemControl wraps a ServiceManager. Non-*manager implementations +// (tests / --no-supervisor) yield a control that always returns unavailable. +func NewSystemControl(mgr ServiceManager) *SystemControl { + m, _ := mgr.(*manager) + return &SystemControl{mgr: m} +} + +// Info returns microinit mode and whether machine poweroff/reboot is allowed. +func (s *SystemControl) Info() (SystemInfo, error) { + if s == nil || s.mgr == nil || s.mgr.supervisor == nil { + return SystemInfo{}, ErrSystemUnavailable + } + info, err := s.mgr.supervisor.Client().Info() + if err != nil { + return SystemInfo{}, fmt.Errorf("%w: %v", ErrSystemUnavailable, err) + } + mode := info.Mode + if mode == "" { + mode = "supervise" + } + return SystemInfo{ + Mode: mode, + CanShutdown: mode == "init", + }, nil +} + +// RequestShutdown sends poweroff or reboot to microinit when mode is init. +func (s *SystemControl) RequestShutdown(mode string) error { + switch mode { + case "poweroff", "reboot": + default: + return fmt.Errorf("%w: %q", ErrInvalidShutdownMode, mode) + } + if s == nil || s.mgr == nil || s.mgr.supervisor == nil { + return ErrSystemUnavailable + } + info, err := s.mgr.supervisor.Client().Info() + if err != nil { + return fmt.Errorf("%w: %v", ErrSystemUnavailable, err) + } + if info.Mode != "init" { + return ErrSystemNotInit + } + return s.mgr.supervisor.Client().ShutdownMode(mode) +} diff --git a/web/src/api/system.ts b/web/src/api/system.ts new file mode 100644 index 0000000..230f870 --- /dev/null +++ b/web/src/api/system.ts @@ -0,0 +1,34 @@ +import { apiFetch } from "./client"; + +export interface SystemInfo { + mode: string; + canShutdown: boolean; +} + +export type SystemShutdownMode = "poweroff" | "reboot"; + +export function fetchSystemInfo(): Promise { + return apiFetch("/api/v1/admin/system"); +} + +/** Fire-and-forget host shutdown. Network errors after send are treated as success. */ +export async function requestSystemShutdown( + mode: SystemShutdownMode, +): Promise { + try { + await apiFetch("/api/v1/admin/system/shutdown", { + method: "POST", + body: JSON.stringify({ mode }), + }); + } catch (err) { + // bigfred is stopped early during microinit stop_all — connection drop is expected. + if (err instanceof TypeError) { + return; + } + const status = (err as { status?: number })?.status; + if (status === undefined || status === 0) { + return; + } + throw err; + } +} diff --git a/web/src/components/AppShell.tsx b/web/src/components/AppShell.tsx index b19802a..b3ae64e 100644 --- a/web/src/components/AppShell.tsx +++ b/web/src/components/AppShell.tsx @@ -28,6 +28,7 @@ import TuneIcon from "@mui/icons-material/Tune"; import VpnKeyIcon from "@mui/icons-material/VpnKey"; import LockResetIcon from "@mui/icons-material/LockReset"; import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined"; +import PowerSettingsNewIcon from "@mui/icons-material/PowerSettingsNew"; import LogoutIcon from "@mui/icons-material/Logout"; import { Link, Outlet, useMatch, useNavigate } from "react-router-dom"; import { useTranslation } from "react-i18next"; @@ -38,6 +39,7 @@ import { getUserName } from "../utils/getUserName"; import { SocketProvider } from "../context/SocketContext"; import { useSessionExpiryRedirect } from "../hooks/useSessionExpiryRedirect"; import LanguageMenu from "./LanguageMenu"; +import SystemPowerDialog from "./SystemPowerDialog"; import { useSudoMobileMenuItems } from "./SudoIndicator"; import MobileNavDrawer, { type MobileNavSection } from "./MobileNavDrawer"; import TopBarMenu, { type TopBarMenuItem } from "./TopBarMenu"; @@ -79,6 +81,7 @@ function AppShellContent() { const isCompactNav = useMediaQuery(theme.breakpoints.down("md")); const hideAppTitle = useMediaQuery(theme.breakpoints.down("lg")); const [mobileNavOpen, setMobileNavOpen] = useState(false); + const [systemPowerOpen, setSystemPowerOpen] = useState(false); const onThrottlePage = Boolean(useMatch("/throttle")); // Throttle is a fixed-viewport route (AppShell clips to 100dvh). Ensure @@ -149,6 +152,13 @@ function AppShellContent() { icon: , onClick: () => navigate("/admin/rentals"), }, + { id: "divider-system", divider: true }, + { + id: "systemPower", + label: t("nav.administration.systemPower"), + icon: , + onClick: () => setSystemPowerOpen(true), + }, ], // eslint-disable-next-line react-hooks/exhaustive-deps [t], @@ -468,6 +478,10 @@ function AppShellContent() { identityLine={accountCaption ?? undefined} /> {sudoMobileDialogs} + setSystemPowerOpen(false)} + /> )} diff --git a/web/src/components/SystemPowerDialog.tsx b/web/src/components/SystemPowerDialog.tsx new file mode 100644 index 0000000..89a7b1c --- /dev/null +++ b/web/src/components/SystemPowerDialog.tsx @@ -0,0 +1,129 @@ +import { useCallback, useEffect, useState } from "react"; +import { + Button, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, +} from "@mui/material"; +import { useTranslation } from "react-i18next"; + +import { + fetchSystemInfo, + requestSystemShutdown, + type SystemShutdownMode, +} from "../api/system"; +import { ApiError } from "../api/client"; + +type Phase = "loading" | "unavailable" | "confirm" | "working" | "done"; + +/** + * Admin dialog for host poweroff/reboot when microinit runs in init mode + * (BigFredOS). POST is fire-and-forget — see plan D2.1. + */ +export default function SystemPowerDialog({ + open, + onClose, +}: { + open: boolean; + onClose: () => void; +}) { + const { t } = useTranslation("common"); + const [phase, setPhase] = useState("loading"); + const [workingMode, setWorkingMode] = useState( + null, + ); + + useEffect(() => { + if (!open) { + setPhase("loading"); + setWorkingMode(null); + return; + } + let cancelled = false; + setPhase("loading"); + void fetchSystemInfo() + .then((info) => { + if (!cancelled) { + setPhase(info.canShutdown ? "confirm" : "unavailable"); + } + }) + .catch(() => { + if (!cancelled) setPhase("unavailable"); + }); + return () => { + cancelled = true; + }; + }, [open]); + + const handleClose = useCallback(() => { + if (phase === "working" || phase === "done") return; + onClose(); + }, [onClose, phase]); + + const run = async (mode: SystemShutdownMode) => { + setWorkingMode(mode); + setPhase("working"); + try { + await requestSystemShutdown(mode); + setPhase("done"); + } catch (err) { + if (err instanceof ApiError && err.code === "system_not_init") { + setPhase("unavailable"); + return; + } + setPhase("done"); + } + }; + + const title = + phase === "unavailable" + ? t("systemPower.unavailableTitle") + : phase === "done" || phase === "working" + ? workingMode === "reboot" + ? t("systemPower.restartingTitle") + : t("systemPower.shuttingDownTitle") + : t("systemPower.title"); + + return ( + + {title} + + {phase === "loading" || phase === "working" ? ( + + ) : null} + {phase === "unavailable" ? ( + {t("systemPower.unavailableBody")} + ) : null} + {phase === "confirm" ? ( + {t("systemPower.confirmBody")} + ) : null} + {phase === "done" || phase === "working" ? ( + + {workingMode === "reboot" + ? t("systemPower.restartingBody") + : t("systemPower.shuttingDownBody")} + + ) : null} + + + {phase === "unavailable" ? ( + + ) : null} + {phase === "confirm" ? ( + <> + + + + + ) : null} + + + ); +} diff --git a/web/src/i18n/locales/de/common.json b/web/src/i18n/locales/de/common.json index 2d512a2..79983bb 100644 --- a/web/src/i18n/locales/de/common.json +++ b/web/src/i18n/locales/de/common.json @@ -45,7 +45,8 @@ "slotsDiagnostics": "Slot-Diagnose", "logs": "Protokolle", "auditLog": "Audit-Protokoll", - "rentals": "Ausleihen" + "rentals": "Ausleihen", + "systemPower": "System ausschalten" }, "my": { "menuLabel": "Meine", @@ -71,5 +72,17 @@ } }, "comingSoon": "Demnächst ({{milestone}})", - "networkError": "Server nicht erreichbar." + "networkError": "Server nicht erreichbar.", + "systemPower": { + "title": "System ausschalten", + "confirmBody": "Ausschalten oder Neustart beendet alle Dienste auf dem Hub. Fortfahren?", + "unavailableTitle": "Ausschalten nicht verfügbar", + "unavailableBody": "Systemabschaltung funktioniert nur, wenn BigFred auf Raspberry Pi / BigFredOS läuft (microinit im Init-Modus).", + "shutdown": "Ausschalten", + "restart": "Neustart", + "shuttingDownTitle": "Wird ausgeschaltet…", + "shuttingDownBody": "Das System wird ausgeschaltet. Dieses Fenster schließt sich möglicherweise von selbst.", + "restartingTitle": "Neustart…", + "restartingBody": "Das System wird neu gestartet. Dieses Fenster schließt sich möglicherweise von selbst." + } } diff --git a/web/src/i18n/locales/en/common.json b/web/src/i18n/locales/en/common.json index 3bcf667..b8ddd5c 100644 --- a/web/src/i18n/locales/en/common.json +++ b/web/src/i18n/locales/en/common.json @@ -45,7 +45,8 @@ "slotsDiagnostics": "Slot diagnostics", "logs": "Logs", "auditLog": "Audit log", - "rentals": "Rentals" + "rentals": "Rentals", + "systemPower": "Shut down system" }, "my": { "menuLabel": "My", @@ -71,5 +72,17 @@ } }, "comingSoon": "Coming soon ({{milestone}})", - "networkError": "Could not reach the server." + "networkError": "Could not reach the server.", + "systemPower": { + "title": "System power", + "confirmBody": "Shutting down or restarting will stop all services on the hub. Continue?", + "unavailableTitle": "Shutdown unavailable", + "unavailableBody": "System shutdown only works when BigFred is running on Raspberry Pi / BigFredOS (microinit in init mode).", + "shutdown": "Shut down", + "restart": "Restart", + "shuttingDownTitle": "Shutting down…", + "shuttingDownBody": "The system is shutting down. This window may close by itself.", + "restartingTitle": "Restarting…", + "restartingBody": "The system is restarting. This window may close by itself." + } } diff --git a/web/src/i18n/locales/pl/common.json b/web/src/i18n/locales/pl/common.json index 157c365..4d2f4be 100644 --- a/web/src/i18n/locales/pl/common.json +++ b/web/src/i18n/locales/pl/common.json @@ -45,7 +45,8 @@ "slotsDiagnostics": "Diagnostyka slotów", "logs": "Logi", "auditLog": "Dziennik audytu", - "rentals": "Wypożyczenia" + "rentals": "Wypożyczenia", + "systemPower": "Wyłącz system" }, "my": { "menuLabel": "Moje", @@ -71,5 +72,17 @@ } }, "comingSoon": "Wkrótce ({{milestone}})", - "networkError": "Nie udało się połączyć z serwerem." + "networkError": "Nie udało się połączyć z serwerem.", + "systemPower": { + "title": "Wyłączenie systemu", + "confirmBody": "Wyłączenie lub restart zatrzyma wszystkie usługi na hubie. Kontynuować?", + "unavailableTitle": "Wyłączenie niedostępne", + "unavailableBody": "Wyłączenie systemu działa tylko gdy BigFred jest uruchomiony na Raspberry Pi / BigFredOS (microinit w trybie init).", + "shutdown": "Wyłącz", + "restart": "Restart", + "shuttingDownTitle": "Wyłączanie…", + "shuttingDownBody": "System jest wyłączany. To okno może się zamknąć samo.", + "restartingTitle": "Restartowanie…", + "restartingBody": "System jest restartowany. To okno może się zamknąć samo." + } } From e1eceaf0188fc0d43ad857f0fb79350f31300326 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Damian=20K=C4=99ska?= <372403+keskad@users.noreply.github.com> Date: Thu, 6 Aug 2026 09:30:10 +0200 Subject: [PATCH 2/2] fix: harden admin system power control and error handling Inject MicroinitPower instead of holding *manager; treat empty daemon mode as supervise; surface API errors in the UI; add 409/empty-mode tests. Co-authored-by: Cursor --- go.mod | 2 +- go.sum | 2 + pkgs/bigfred/server/http/system_test.go | 45 ++++++++++++ pkgs/bigfred/server/service/system.go | 58 +++++++++++----- pkgs/bigfred/server/service/system_test.go | 79 ++++++++++++++++++++++ web/src/api/system.ts | 13 +++- web/src/components/SystemPowerDialog.tsx | 67 ++++++++++++++---- web/src/i18n/locales/de/common.json | 2 + web/src/i18n/locales/en/common.json | 2 + web/src/i18n/locales/pl/common.json | 2 + 10 files changed, 239 insertions(+), 33 deletions(-) create mode 100644 pkgs/bigfred/server/service/system_test.go diff --git a/go.mod b/go.mod index 94c1888..f30cd7d 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ require ( github.com/alicebob/miniredis/v2 v2.38.0 github.com/brutella/dnssd v1.2.14 github.com/coder/websocket v1.8.13 - github.com/dcc-bigfred/microinit/go v0.5.1-0.20260805200933-fc2b4ab607ba + github.com/dcc-bigfred/microinit/go v0.5.1-0.20260806072850-022153bd9391 github.com/fsnotify/fsnotify v1.9.0 github.com/go-chi/chi/v5 v5.3.0 github.com/go-chi/cors v1.2.2 diff --git a/go.sum b/go.sum index ceba03e..1ac1d65 100644 --- a/go.sum +++ b/go.sum @@ -22,6 +22,8 @@ github.com/dcc-bigfred/microinit/go v0.5.0 h1:Ts0qJ5VFwXZndFyLwmnZEAt+1ZOjnucrpb github.com/dcc-bigfred/microinit/go v0.5.0/go.mod h1:pWmLbG5Xl+PEx0nr0t70gc+Ztizk/+LuJcBvz00dtys= github.com/dcc-bigfred/microinit/go v0.5.1-0.20260805200933-fc2b4ab607ba h1:hkQRJOh9w19fXx4nLiMI1Fr3baTeMl4dZQPk0+/2C6o= github.com/dcc-bigfred/microinit/go v0.5.1-0.20260805200933-fc2b4ab607ba/go.mod h1:pWmLbG5Xl+PEx0nr0t70gc+Ztizk/+LuJcBvz00dtys= +github.com/dcc-bigfred/microinit/go v0.5.1-0.20260806072850-022153bd9391 h1:Gq3X+Nfpa3tiHnM7OXR5SnXZsmb6DWzorEjpgsMl3uI= +github.com/dcc-bigfred/microinit/go v0.5.1-0.20260806072850-022153bd9391/go.mod h1:pWmLbG5Xl+PEx0nr0t70gc+Ztizk/+LuJcBvz00dtys= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= diff --git a/pkgs/bigfred/server/http/system_test.go b/pkgs/bigfred/server/http/system_test.go index 07fd1e9..81f5306 100644 --- a/pkgs/bigfred/server/http/system_test.go +++ b/pkgs/bigfred/server/http/system_test.go @@ -11,6 +11,7 @@ import ( "github.com/go-chi/chi/v5" "github.com/stretchr/testify/require" + miclient "github.com/dcc-bigfred/microinit/go/client" "github.com/keskad/loco/pkgs/bigfred/server/service" ) @@ -21,6 +22,20 @@ func mountSystem(h *SystemHandler) http.Handler { return r } +type fakePower struct { + info *miclient.DaemonInfo + infoErr error +} + +func (f *fakePower) Info() (*miclient.DaemonInfo, error) { + if f.infoErr != nil { + return nil, f.infoErr + } + return f.info, nil +} + +func (f *fakePower) ShutdownMode(string) error { return nil } + func TestSystemGetUnavailable(t *testing.T) { h := NewSystemHandler(nil) req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/system", nil) @@ -45,3 +60,33 @@ func TestSystemShutdownModeValidationOrder(t *testing.T) { err = service.NewSystemControl(nil).RequestShutdown("poweroff") require.True(t, errors.Is(err, service.ErrSystemUnavailable)) } + +func TestSystemShutdownNotInit(t *testing.T) { + ctl := service.NewSystemControlWithPower(&fakePower{ + info: &miclient.DaemonInfo{Mode: "supervise"}, + }) + h := NewSystemHandler(ctl) + body, _ := json.Marshal(map[string]string{"mode": "poweroff"}) + req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/system/shutdown", bytes.NewReader(body)) + rec := httptest.NewRecorder() + mountSystem(h).ServeHTTP(rec, req) + require.Equal(t, http.StatusConflict, rec.Code) + var env map[string]string + require.NoError(t, json.NewDecoder(rec.Body).Decode(&env)) + require.Equal(t, "system_not_init", env["error"]) +} + +func TestSystemGetInit(t *testing.T) { + ctl := service.NewSystemControlWithPower(&fakePower{ + info: &miclient.DaemonInfo{Mode: "init"}, + }) + h := NewSystemHandler(ctl) + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/system", nil) + rec := httptest.NewRecorder() + mountSystem(h).ServeHTTP(rec, req) + require.Equal(t, http.StatusOK, rec.Code) + var info service.SystemInfo + require.NoError(t, json.NewDecoder(rec.Body).Decode(&info)) + require.Equal(t, "init", info.Mode) + require.True(t, info.CanShutdown) +} diff --git a/pkgs/bigfred/server/service/system.go b/pkgs/bigfred/server/service/system.go index 15c4500..59d4c8c 100644 --- a/pkgs/bigfred/server/service/system.go +++ b/pkgs/bigfred/server/service/system.go @@ -3,6 +3,8 @@ package service import ( "errors" "fmt" + + miclient "github.com/dcc-bigfred/microinit/go/client" ) var ( @@ -22,32 +24,43 @@ type SystemInfo struct { CanShutdown bool `json:"canShutdown"` } +// MicroinitPower is the subset of the microinit Go client used for host power. +type MicroinitPower interface { + Info() (*miclient.DaemonInfo, error) + ShutdownMode(mode string) error +} + // SystemControl talks to microinit for host power control. -// A nil control (or nil underlying manager) returns ErrSystemUnavailable. type SystemControl struct { - mgr *manager + power MicroinitPower } -// NewSystemControl wraps a ServiceManager. Non-*manager implementations -// (tests / --no-supervisor) yield a control that always returns unavailable. +// NewSystemControl wraps a ServiceManager when it is the microinit-backed +// *manager with a live supervisor; otherwise returns a control that always +// reports unavailable (tests / --no-supervisor). func NewSystemControl(mgr ServiceManager) *SystemControl { - m, _ := mgr.(*manager) - return &SystemControl{mgr: m} + m, ok := mgr.(*manager) + if !ok || m == nil || m.supervisor == nil { + return &SystemControl{} + } + return &SystemControl{power: m.supervisor.Client()} +} + +// NewSystemControlWithPower is for tests that inject a fake microinit client. +func NewSystemControlWithPower(power MicroinitPower) *SystemControl { + return &SystemControl{power: power} } // Info returns microinit mode and whether machine poweroff/reboot is allowed. func (s *SystemControl) Info() (SystemInfo, error) { - if s == nil || s.mgr == nil || s.mgr.supervisor == nil { + if s == nil || s.power == nil { return SystemInfo{}, ErrSystemUnavailable } - info, err := s.mgr.supervisor.Client().Info() + info, err := s.power.Info() if err != nil { return SystemInfo{}, fmt.Errorf("%w: %v", ErrSystemUnavailable, err) } - mode := info.Mode - if mode == "" { - mode = "supervise" - } + mode := normalizeDaemonMode(info.Mode) return SystemInfo{ Mode: mode, CanShutdown: mode == "init", @@ -55,21 +68,34 @@ func (s *SystemControl) Info() (SystemInfo, error) { } // RequestShutdown sends poweroff or reboot to microinit when mode is init. +// Halt is intentionally rejected here — BigFred admin UI only offers +// poweroff/reboot; direct SDK callers may use Client.ShutdownMode("halt"). func (s *SystemControl) RequestShutdown(mode string) error { switch mode { case "poweroff", "reboot": default: return fmt.Errorf("%w: %q", ErrInvalidShutdownMode, mode) } - if s == nil || s.mgr == nil || s.mgr.supervisor == nil { + if s == nil || s.power == nil { return ErrSystemUnavailable } - info, err := s.mgr.supervisor.Client().Info() + info, err := s.power.Info() if err != nil { return fmt.Errorf("%w: %v", ErrSystemUnavailable, err) } - if info.Mode != "init" { + // Empty Mode (older microinit without the field) is treated as supervise: + // refuse host power rather than assume init. Same rule as Info(). + if normalizeDaemonMode(info.Mode) != "init" { return ErrSystemNotInit } - return s.mgr.supervisor.Client().ShutdownMode(mode) + return s.power.ShutdownMode(mode) +} + +// normalizeDaemonMode maps wire values to "init" or "supervise". +// Unknown / empty → supervise (safe side: deny host power). +func normalizeDaemonMode(mode string) string { + if mode == "init" { + return "init" + } + return "supervise" } diff --git a/pkgs/bigfred/server/service/system_test.go b/pkgs/bigfred/server/service/system_test.go new file mode 100644 index 0000000..e8907bb --- /dev/null +++ b/pkgs/bigfred/server/service/system_test.go @@ -0,0 +1,79 @@ +package service + +import ( + "errors" + "testing" + + miclient "github.com/dcc-bigfred/microinit/go/client" + "github.com/stretchr/testify/require" +) + +type fakePower struct { + info *miclient.DaemonInfo + infoErr error + shutErr error + lastMode string +} + +func (f *fakePower) Info() (*miclient.DaemonInfo, error) { + if f.infoErr != nil { + return nil, f.infoErr + } + return f.info, nil +} + +func (f *fakePower) ShutdownMode(mode string) error { + f.lastMode = mode + return f.shutErr +} + +func TestSystemControlInfoEmptyModeIsSupervise(t *testing.T) { + ctl := NewSystemControlWithPower(&fakePower{ + info: &miclient.DaemonInfo{Mode: ""}, + }) + info, err := ctl.Info() + require.NoError(t, err) + require.Equal(t, "supervise", info.Mode) + require.False(t, info.CanShutdown) +} + +func TestSystemControlInfoInit(t *testing.T) { + ctl := NewSystemControlWithPower(&fakePower{ + info: &miclient.DaemonInfo{Mode: "init"}, + }) + info, err := ctl.Info() + require.NoError(t, err) + require.Equal(t, "init", info.Mode) + require.True(t, info.CanShutdown) +} + +func TestSystemControlRequestShutdownEmptyModeNotInit(t *testing.T) { + ctl := NewSystemControlWithPower(&fakePower{ + info: &miclient.DaemonInfo{Mode: ""}, + }) + err := ctl.RequestShutdown("poweroff") + require.True(t, errors.Is(err, ErrSystemNotInit)) +} + +func TestSystemControlRequestShutdownSupervise(t *testing.T) { + ctl := NewSystemControlWithPower(&fakePower{ + info: &miclient.DaemonInfo{Mode: "supervise"}, + }) + err := ctl.RequestShutdown("reboot") + require.True(t, errors.Is(err, ErrSystemNotInit)) +} + +func TestSystemControlRequestShutdownInit(t *testing.T) { + fp := &fakePower{info: &miclient.DaemonInfo{Mode: "init"}} + ctl := NewSystemControlWithPower(fp) + require.NoError(t, ctl.RequestShutdown("poweroff")) + require.Equal(t, "poweroff", fp.lastMode) +} + +func TestSystemControlNilPowerUnavailable(t *testing.T) { + ctl := NewSystemControl(nil) + _, err := ctl.Info() + require.True(t, errors.Is(err, ErrSystemUnavailable)) + err = ctl.RequestShutdown("poweroff") + require.True(t, errors.Is(err, ErrSystemUnavailable)) +} diff --git a/web/src/api/system.ts b/web/src/api/system.ts index 230f870..8ae698e 100644 --- a/web/src/api/system.ts +++ b/web/src/api/system.ts @@ -1,4 +1,4 @@ -import { apiFetch } from "./client"; +import { apiFetch, ApiError } from "./client"; export interface SystemInfo { mode: string; @@ -11,7 +11,11 @@ export function fetchSystemInfo(): Promise { return apiFetch("/api/v1/admin/system"); } -/** Fire-and-forget host shutdown. Network errors after send are treated as success. */ +/** + * Fire-and-forget host shutdown. + * Treats connection drop after send as success (server stops during shutdown). + * Real HTTP error responses (ApiError with status) are rethrown. + */ export async function requestSystemShutdown( mode: SystemShutdownMode, ): Promise { @@ -21,11 +25,16 @@ export async function requestSystemShutdown( body: JSON.stringify({ mode }), }); } catch (err) { + // Structured API errors must surface (503/409/400/…). + if (err instanceof ApiError) { + throw err; + } // bigfred is stopped early during microinit stop_all — connection drop is expected. if (err instanceof TypeError) { return; } const status = (err as { status?: number })?.status; + // status 0 / missing: aborted fetch / opaque network failure after send. if (status === undefined || status === 0) { return; } diff --git a/web/src/components/SystemPowerDialog.tsx b/web/src/components/SystemPowerDialog.tsx index 89a7b1c..90643c1 100644 --- a/web/src/components/SystemPowerDialog.tsx +++ b/web/src/components/SystemPowerDialog.tsx @@ -17,11 +17,17 @@ import { } from "../api/system"; import { ApiError } from "../api/client"; -type Phase = "loading" | "unavailable" | "confirm" | "working" | "done"; +type Phase = + | "loading" + | "unavailable" + | "confirm" + | "working" + | "done" + | "error"; /** * Admin dialog for host poweroff/reboot when microinit runs in init mode - * (BigFredOS). POST is fire-and-forget — see plan D2.1. + * (BigFredOS). POST is fire-and-forget — connection drop after send is success. */ export default function SystemPowerDialog({ open, @@ -64,16 +70,25 @@ export default function SystemPowerDialog({ }, [onClose, phase]); const run = async (mode: SystemShutdownMode) => { + if (phase === "working" || phase === "done") return; setWorkingMode(mode); setPhase("working"); try { await requestSystemShutdown(mode); setPhase("done"); } catch (err) { - if (err instanceof ApiError && err.code === "system_not_init") { - setPhase("unavailable"); + if (err instanceof ApiError) { + if ( + err.code === "system_not_init" || + err.code === "system_unavailable" + ) { + setPhase("unavailable"); + return; + } + setPhase("error"); return; } + // Unexpected non-ApiError after send — treat as success (connection drop). setPhase("done"); } }; @@ -81,11 +96,15 @@ export default function SystemPowerDialog({ const title = phase === "unavailable" ? t("systemPower.unavailableTitle") - : phase === "done" || phase === "working" - ? workingMode === "reboot" - ? t("systemPower.restartingTitle") - : t("systemPower.shuttingDownTitle") - : t("systemPower.title"); + : phase === "error" + ? t("systemPower.errorTitle") + : phase === "done" || phase === "working" + ? workingMode === "reboot" + ? t("systemPower.restartingTitle") + : t("systemPower.shuttingDownTitle") + : t("systemPower.title"); + + const actionsLocked = phase === "working" || phase === "done"; return ( @@ -100,8 +119,16 @@ export default function SystemPowerDialog({ {phase === "confirm" ? ( {t("systemPower.confirmBody")} ) : null} + {phase === "error" ? ( + {t("systemPower.errorBody")} + ) : null} {phase === "done" || phase === "working" ? ( - + {workingMode === "reboot" ? t("systemPower.restartingBody") : t("systemPower.shuttingDownBody")} @@ -109,16 +136,28 @@ export default function SystemPowerDialog({ ) : null} - {phase === "unavailable" ? ( + {phase === "unavailable" || phase === "error" ? ( ) : null} {phase === "confirm" ? ( <> - - + - diff --git a/web/src/i18n/locales/de/common.json b/web/src/i18n/locales/de/common.json index 79983bb..377048d 100644 --- a/web/src/i18n/locales/de/common.json +++ b/web/src/i18n/locales/de/common.json @@ -78,6 +78,8 @@ "confirmBody": "Ausschalten oder Neustart beendet alle Dienste auf dem Hub. Fortfahren?", "unavailableTitle": "Ausschalten nicht verfügbar", "unavailableBody": "Systemabschaltung funktioniert nur, wenn BigFred auf Raspberry Pi / BigFredOS läuft (microinit im Init-Modus).", + "errorTitle": "Ausschalten fehlgeschlagen", + "errorBody": "Die Abschaltanforderung wurde abgelehnt. Der Hub läuft weiter — erneut versuchen oder Logs prüfen.", "shutdown": "Ausschalten", "restart": "Neustart", "shuttingDownTitle": "Wird ausgeschaltet…", diff --git a/web/src/i18n/locales/en/common.json b/web/src/i18n/locales/en/common.json index b8ddd5c..e399d5a 100644 --- a/web/src/i18n/locales/en/common.json +++ b/web/src/i18n/locales/en/common.json @@ -78,6 +78,8 @@ "confirmBody": "Shutting down or restarting will stop all services on the hub. Continue?", "unavailableTitle": "Shutdown unavailable", "unavailableBody": "System shutdown only works when BigFred is running on Raspberry Pi / BigFredOS (microinit in init mode).", + "errorTitle": "Shutdown failed", + "errorBody": "The shutdown request was rejected. The hub is still running — try again or check logs.", "shutdown": "Shut down", "restart": "Restart", "shuttingDownTitle": "Shutting down…", diff --git a/web/src/i18n/locales/pl/common.json b/web/src/i18n/locales/pl/common.json index 4d2f4be..8a99520 100644 --- a/web/src/i18n/locales/pl/common.json +++ b/web/src/i18n/locales/pl/common.json @@ -78,6 +78,8 @@ "confirmBody": "Wyłączenie lub restart zatrzyma wszystkie usługi na hubie. Kontynuować?", "unavailableTitle": "Wyłączenie niedostępne", "unavailableBody": "Wyłączenie systemu działa tylko gdy BigFred jest uruchomiony na Raspberry Pi / BigFredOS (microinit w trybie init).", + "errorTitle": "Wyłączenie nieudane", + "errorBody": "Żądanie wyłączenia zostało odrzucone. Hub nadal działa — spróbuj ponownie lub sprawdź logi.", "shutdown": "Wyłącz", "restart": "Restart", "shuttingDownTitle": "Wyłączanie…",