diff --git a/go.mod b/go.mod index fa5cb42..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.0 + 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 ebc927c..1ac1d65 100644 --- a/go.sum +++ b/go.sum @@ -20,6 +20,10 @@ 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/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/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..81f5306 --- /dev/null +++ b/pkgs/bigfred/server/http/system_test.go @@ -0,0 +1,92 @@ +package httpapi + +import ( + "bytes" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "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" +) + +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 +} + +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) + 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)) +} + +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 new file mode 100644 index 0000000..59d4c8c --- /dev/null +++ b/pkgs/bigfred/server/service/system.go @@ -0,0 +1,101 @@ +package service + +import ( + "errors" + "fmt" + + miclient "github.com/dcc-bigfred/microinit/go/client" +) + +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"` +} + +// 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. +type SystemControl struct { + power MicroinitPower +} + +// 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, 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.power == nil { + return SystemInfo{}, ErrSystemUnavailable + } + info, err := s.power.Info() + if err != nil { + return SystemInfo{}, fmt.Errorf("%w: %v", ErrSystemUnavailable, err) + } + mode := normalizeDaemonMode(info.Mode) + return SystemInfo{ + Mode: mode, + CanShutdown: mode == "init", + }, nil +} + +// 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.power == nil { + return ErrSystemUnavailable + } + info, err := s.power.Info() + if err != nil { + return fmt.Errorf("%w: %v", ErrSystemUnavailable, err) + } + // 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.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 new file mode 100644 index 0000000..8ae698e --- /dev/null +++ b/web/src/api/system.ts @@ -0,0 +1,43 @@ +import { apiFetch, ApiError } 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. + * 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 { + try { + await apiFetch("/api/v1/admin/system/shutdown", { + method: "POST", + 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; + } + 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..90643c1 --- /dev/null +++ b/web/src/components/SystemPowerDialog.tsx @@ -0,0 +1,168 @@ +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" + | "error"; + +/** + * Admin dialog for host poweroff/reboot when microinit runs in init mode + * (BigFredOS). POST is fire-and-forget — connection drop after send is success. + */ +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) => { + if (phase === "working" || phase === "done") return; + setWorkingMode(mode); + setPhase("working"); + try { + await requestSystemShutdown(mode); + setPhase("done"); + } catch (err) { + 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"); + } + }; + + const title = + phase === "unavailable" + ? t("systemPower.unavailableTitle") + : 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 ( + + {title} + + {phase === "loading" || phase === "working" ? ( + + ) : null} + {phase === "unavailable" ? ( + {t("systemPower.unavailableBody")} + ) : null} + {phase === "confirm" ? ( + {t("systemPower.confirmBody")} + ) : null} + {phase === "error" ? ( + {t("systemPower.errorBody")} + ) : null} + {phase === "done" || phase === "working" ? ( + + {workingMode === "reboot" + ? t("systemPower.restartingBody") + : t("systemPower.shuttingDownBody")} + + ) : null} + + + {phase === "unavailable" || phase === "error" ? ( + + ) : null} + {phase === "confirm" ? ( + <> + + + + + ) : null} + + + ); +} diff --git a/web/src/i18n/locales/de/common.json b/web/src/i18n/locales/de/common.json index 2d512a2..377048d 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,19 @@ } }, "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).", + "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…", + "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..e399d5a 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,19 @@ } }, "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).", + "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…", + "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..8a99520 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,19 @@ } }, "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).", + "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…", + "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." + } }