diff --git a/pkgs/bigfred/server/cli/config/config.go b/pkgs/bigfred/server/cli/config/config.go
index 3b89ffd..67e855c 100644
--- a/pkgs/bigfred/server/cli/config/config.go
+++ b/pkgs/bigfred/server/cli/config/config.go
@@ -34,6 +34,7 @@ type File struct {
NoSupervisor *bool
MicroinitSocket string
MicroinitBin string
+ MicrodnsBin string
LogLevel string
RedisBin string
RedisBindAddr string
@@ -59,6 +60,7 @@ func DefaultFile() File {
CorsOrigins: []string{"http://localhost:5173", "http://127.0.0.1:5173"},
LogLevel: "info",
RedisBin: "valkey-server",
+ MicrodnsBin: "microdns",
RedisBindAddr: "127.0.0.1",
RedisPort: &port,
TelemetryConfig: service.DefaultTelemetryConfigPath(),
@@ -134,6 +136,8 @@ func Parse(text string) File {
f.MicroinitSocket = value
case "MICROINIT_BIN", "MICROINITBIN":
f.MicroinitBin = value
+ case "MICRODNS_BIN", "MICRODNSBIN":
+ f.MicrodnsBin = value
case "LOG_LEVEL", "LOGLEVEL":
f.LogLevel = value
case "REDIS_BIN", "REDISBIN":
diff --git a/pkgs/bigfred/server/cli/config_merge.go b/pkgs/bigfred/server/cli/config_merge.go
index 5f21f67..b64f0d1 100644
--- a/pkgs/bigfred/server/cli/config_merge.go
+++ b/pkgs/bigfred/server/cli/config_merge.go
@@ -47,6 +47,9 @@ func applyConfig(f *Flags, cfg *config.File, changed func(string) bool) {
if !changed("microinit-bin") && cfg.MicroinitBin != "" {
f.MicroinitBin = cfg.MicroinitBin
}
+ if !changed("microdns-bin") && cfg.MicrodnsBin != "" {
+ f.MicrodnsBin = cfg.MicrodnsBin
+ }
if !changed("log-level") && cfg.LogLevel != "" {
f.LogLevel = cfg.LogLevel
}
diff --git a/pkgs/bigfred/server/cli/root.go b/pkgs/bigfred/server/cli/root.go
index 4365226..bca6b8f 100644
--- a/pkgs/bigfred/server/cli/root.go
+++ b/pkgs/bigfred/server/cli/root.go
@@ -53,6 +53,7 @@ type Flags struct {
MicroinitSocket string
MicroinitBin string
+ MicrodnsBin string
// Redis. By default loco-server spawns its own redis-server via
// supervisord on RedisBindAddr:RedisPort; pass --redis-external
@@ -127,6 +128,8 @@ real-time throttle commands.`,
"microinit IPC socket path (default $BIGFRED_DATA_DIR/run/microinit.sock)")
cmd.Flags().StringVar(&f.MicroinitBin, "microinit-bin", "microinit",
"microinit binary path (PATH-relative or absolute)")
+ cmd.Flags().StringVar(&f.MicrodnsBin, "microdns-bin", "microdns",
+ "microdns binary path (PATH-relative or absolute) used by the managed daemon")
cmd.Flags().StringVar(&f.LogLevel, "log-level", "info",
"logrus level (debug, info, warn, error). BIGFRED_LOG_LEVEL env overrides this flag.")
@@ -319,17 +322,13 @@ func run(ctx context.Context, log *logrus.Logger, f Flags) error {
Disable: !redisMgmt.Managed,
},
Telemetry: telemetryCfg,
+ Microdns: microinit.MicrodnsConfig{
+ Bin: f.MicrodnsBin,
+ Disable: !platform.SupportsMicrodns(),
+ },
}); err != nil {
return fmt.Errorf("ensure microinit infrastructure: %w", err)
}
- // microdns.json is best-effort: seed a default only when absent so
- // operator edits are preserved; never block startup on write failure.
- // Android phone builds do not ship microdns — skip seeding entirely.
- if platform.SupportsMicrodns() {
- if err := microinit.EnsureMicrodnsConfig(); err != nil {
- log.WithError(err).Warn("microdns config seed failed; continuing without default template")
- }
- }
if f.EnableTelemetry {
log.WithFields(logrus.Fields{
"config": f.TelemetryConfig,
@@ -673,6 +672,7 @@ func run(ctx context.Context, log *logrus.Logger, f Flags) error {
CommandStations: commandStationSvc,
Diagnostics: diagSvc,
System: service.NewSystemControl(supSvc),
+ Microinit: service.NewMicroinitControl(supSvc),
Hub: hub,
DccBus: dccBusSvc,
Radio: radioSvc,
diff --git a/pkgs/bigfred/server/http/microinit.go b/pkgs/bigfred/server/http/microinit.go
new file mode 100644
index 0000000..70e247f
--- /dev/null
+++ b/pkgs/bigfred/server/http/microinit.go
@@ -0,0 +1,255 @@
+package httpapi
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "io"
+ "net"
+ "net/http"
+ "strings"
+
+ "github.com/coder/websocket"
+ miclient "github.com/dcc-bigfred/microinit/go/client"
+ "github.com/go-chi/chi/v5"
+
+ "github.com/keskad/loco/pkgs/bigfred/server/cmd"
+ "github.com/keskad/loco/pkgs/bigfred/server/domain"
+ "github.com/keskad/loco/pkgs/bigfred/server/service"
+)
+
+const microinitLogHistoryLines = 300
+
+type microinitWSMessage struct {
+ Type string `json:"type"`
+ Lines []string `json:"lines,omitempty"`
+ Text string `json:"text,omitempty"`
+ Error string `json:"error,omitempty"`
+}
+
+// MicroinitHandler serves admin microinit service listing, info, and log WS.
+type MicroinitHandler struct {
+ svc *service.MicroinitControl
+ auth *cmd.Auth
+}
+
+// NewMicroinitHandler returns a MicroinitHandler. svc/auth may be nil (503/401).
+func NewMicroinitHandler(svc *service.MicroinitControl, auth *cmd.Auth) *MicroinitHandler {
+ return &MicroinitHandler{svc: svc, auth: auth}
+}
+
+// ListServices handles GET /api/v1/admin/microinit/services.
+func (h *MicroinitHandler) ListServices(w http.ResponseWriter, _ *http.Request) {
+ if h.svc == nil || !h.svc.Available() {
+ writeJSONError(w, http.StatusServiceUnavailable, "service_unavailable")
+ return
+ }
+ services, err := h.svc.ListServices()
+ if err != nil {
+ if errors.Is(err, service.ErrSystemUnavailable) {
+ writeJSONError(w, http.StatusServiceUnavailable, "service_unavailable")
+ return
+ }
+ writeJSONError(w, http.StatusInternalServerError, "internal_error")
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{"services": services})
+}
+
+// Info handles GET /api/v1/admin/microinit/info.
+func (h *MicroinitHandler) Info(w http.ResponseWriter, _ *http.Request) {
+ if h.svc == nil || !h.svc.Available() {
+ writeJSONError(w, http.StatusServiceUnavailable, "service_unavailable")
+ return
+ }
+ info, err := h.svc.Info()
+ if err != nil {
+ if errors.Is(err, service.ErrSystemUnavailable) {
+ writeJSONError(w, http.StatusServiceUnavailable, "service_unavailable")
+ return
+ }
+ writeJSONError(w, http.StatusInternalServerError, "internal_error")
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(info)
+}
+
+// StreamLogs handles GET /api/v1/admin/microinit/services/{id}/logs/stream (WebSocket).
+// Auth is verified in-handler (cookie / ?token=) like ScanWS — chi
+// RequireRole does not reliably gate the WS upgrade itself. Uses
+// auth.Effective so sudo-elevated admins match the rest of the admin UI.
+func (h *MicroinitHandler) StreamLogs(w http.ResponseWriter, r *http.Request) {
+ if h.auth == nil {
+ writeJSONError(w, http.StatusUnauthorized, "unauthorized")
+ return
+ }
+ token := readSessionToken(r)
+ if token == "" {
+ writeJSONError(w, http.StatusUnauthorized, "unauthorized")
+ return
+ }
+ id, err := h.auth.VerifyToken(r.Context(), token)
+ if err != nil {
+ writeJSONError(w, http.StatusUnauthorized, "unauthorized")
+ return
+ }
+ eff, err := h.auth.Effective(r.Context(), id.User, id.Layout.ID)
+ if err != nil {
+ writeJSONError(w, http.StatusInternalServerError, "internal_error")
+ return
+ }
+ if !eff.Has(domain.RoleAdmin) {
+ writeJSONError(w, http.StatusForbidden, "forbidden")
+ return
+ }
+
+ serviceID := chi.URLParam(r, "id")
+ if err := miclient.ValidateName(serviceID); err != nil {
+ writeJSONError(w, http.StatusBadRequest, "bad_request")
+ return
+ }
+ if h.svc == nil || !h.svc.Available() {
+ writeJSONError(w, http.StatusServiceUnavailable, "service_unavailable")
+ return
+ }
+
+ wsConn, err := websocket.Accept(w, r, &websocket.AcceptOptions{
+ InsecureSkipVerify: true,
+ })
+ if err != nil {
+ return
+ }
+ defer wsConn.Close(websocket.StatusNormalClosure, "done")
+
+ ctx := r.Context()
+
+ history, err := h.fetchLogHistory(serviceID, microinitLogHistoryLines)
+ if err != nil {
+ _ = writeMicroinitWS(wsConn, ctx, microinitWSMessage{Type: "error", Error: err.Error()})
+ return
+ }
+ if err := writeMicroinitWS(wsConn, ctx, microinitWSMessage{Type: "history", Lines: history}); err != nil {
+ return
+ }
+
+ unix, err := h.svc.FollowLogs(serviceID, 0, true)
+ if err != nil {
+ _ = writeMicroinitWS(wsConn, ctx, microinitWSMessage{Type: "error", Error: err.Error()})
+ return
+ }
+ defer unix.Close()
+
+ // Keepalive + disconnect detection: answer client pings, and when the
+ // WebSocket read fails (client gone), close the microinit follow conn so
+ // the blocking ReadFrame loop below unblocks instead of leaking.
+ go func() {
+ defer unix.Close()
+ for {
+ _, data, err := wsConn.Read(ctx)
+ if err != nil {
+ return
+ }
+ var msg struct {
+ Type string `json:"type"`
+ }
+ if json.Unmarshal(data, &msg) != nil {
+ continue
+ }
+ if msg.Type == "ping" {
+ _ = writeMicroinitWS(wsConn, ctx, microinitWSMessage{Type: "pong"})
+ }
+ }
+ }()
+
+ for {
+ resp, err := h.svc.ReadFrame(unix)
+ if err != nil {
+ if errors.Is(err, io.EOF) || errors.Is(err, net.ErrClosed) || isClosedConn(err) {
+ return
+ }
+ // Client disconnect closes unix from the ping goroutine; treat
+ // resulting read errors as a clean end of stream.
+ if ctx.Err() != nil {
+ return
+ }
+ _ = writeMicroinitWS(wsConn, ctx, microinitWSMessage{Type: "error", Error: "stream_failed"})
+ return
+ }
+
+ switch resp.Type {
+ case "log":
+ if resp.Line == nil {
+ continue
+ }
+ text := miclient.FormatLogLine(*resp.Line)
+ if err := writeMicroinitWS(wsConn, ctx, microinitWSMessage{Type: "line", Text: text}); err != nil {
+ return
+ }
+ case "error":
+ msg := resp.Message
+ if msg == "" {
+ msg = "stream_failed"
+ }
+ _ = writeMicroinitWS(wsConn, ctx, microinitWSMessage{Type: "error", Error: msg})
+ return
+ case "ok":
+ return
+ }
+ }
+}
+
+func (h *MicroinitHandler) fetchLogHistory(name string, lines int) ([]string, error) {
+ unix, err := h.svc.FollowLogs(name, lines, false)
+ if err != nil {
+ return nil, err
+ }
+ defer unix.Close()
+
+ out := make([]string, 0, 64)
+ for {
+ resp, err := h.svc.ReadFrame(unix)
+ if err != nil {
+ if errors.Is(err, io.EOF) {
+ return out, nil
+ }
+ return nil, err
+ }
+ switch resp.Type {
+ case "log":
+ if resp.Line != nil {
+ out = append(out, miclient.FormatLogLine(*resp.Line))
+ }
+ case "ok":
+ return out, nil
+ case "error":
+ msg := resp.Message
+ if msg == "" {
+ msg = "read_failed"
+ }
+ return nil, errors.New(msg)
+ }
+ }
+}
+
+func writeMicroinitWS(conn *websocket.Conn, ctx context.Context, msg microinitWSMessage) error {
+ data, err := json.Marshal(msg)
+ if err != nil {
+ return err
+ }
+ return conn.Write(ctx, websocket.MessageText, data)
+}
+
+func isClosedConn(err error) bool {
+ if err == nil {
+ return false
+ }
+ var opErr *net.OpError
+ if errors.As(err, &opErr) {
+ return true
+ }
+ msg := err.Error()
+ return strings.Contains(msg, "use of closed network connection") ||
+ strings.Contains(msg, "closed")
+}
diff --git a/pkgs/bigfred/server/http/router.go b/pkgs/bigfred/server/http/router.go
index c4d786f..c262de8 100644
--- a/pkgs/bigfred/server/http/router.go
+++ b/pkgs/bigfred/server/http/router.go
@@ -36,6 +36,7 @@ type RouterConfig struct {
CommandStations *cmd.CommandStation
Diagnostics *service.DiagnosticsService
System *service.SystemControl
+ Microinit *service.MicroinitControl
Hub *ws.Hub
DccBus *service.DccBusService
Radio *service.RadioService
@@ -103,6 +104,7 @@ func NewRouter(cfg RouterConfig) http.Handler {
}
diagnosticsH := NewDiagnosticsHandler(cfg.Diagnostics)
systemH := NewSystemHandler(cfg.System)
+ microinitH := NewMicroinitHandler(cfg.Microinit, cfg.Auth)
radioH := NewRadioHandler(cfg.Radio)
auditH := NewAuditHandler(cfg.Audit)
leaseH := NewLeaseHandler(cfg.Leases, cfg.Auth)
@@ -270,6 +272,11 @@ func NewRouter(cfg RouterConfig) http.Handler {
r.Get("/admin/system", systemH.Get)
r.Post("/admin/system/shutdown", systemH.Shutdown)
+ r.Get("/admin/system/ports", systemH.Ports)
+
+ r.Get("/admin/microinit/services", microinitH.ListServices)
+ r.Get("/admin/microinit/info", microinitH.Info)
+ r.Get("/admin/microinit/services/{id}/logs/stream", microinitH.StreamLogs)
if cfg.DccBus != nil {
slotsProxy := NewDccBusSlotsProxy(cfg.Auth, cfg.DccBus)
diff --git a/pkgs/bigfred/server/http/system.go b/pkgs/bigfred/server/http/system.go
index 97a3219..a9e26ca 100644
--- a/pkgs/bigfred/server/http/system.go
+++ b/pkgs/bigfred/server/http/system.go
@@ -68,3 +68,14 @@ func (h *SystemHandler) Shutdown(w http.ResponseWriter, r *http.Request) {
}
w.WriteHeader(http.StatusNoContent)
}
+
+// Ports handles GET /api/v1/admin/system/ports.
+func (h *SystemHandler) Ports(w http.ResponseWriter, _ *http.Request) {
+ if h.svc == nil {
+ writeJSONError(w, http.StatusServiceUnavailable, "system_unavailable")
+ return
+ }
+ ports := h.svc.Ports()
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(ports)
+}
diff --git a/pkgs/bigfred/server/microinit/infra.go b/pkgs/bigfred/server/microinit/infra.go
index 50c7487..cfae6d0 100644
--- a/pkgs/bigfred/server/microinit/infra.go
+++ b/pkgs/bigfred/server/microinit/infra.go
@@ -111,13 +111,51 @@ func RedisServiceDef(cfg RedisConfig) (ServiceDef, error) {
}, CreatedByBigfred), nil
}
-type TelemetryConfig struct {
- Enable bool
- AlloyBin, ConfigPath, StoragePath, OTLPEndpoint string
-}
type InfraConfig struct {
Redis RedisConfig
Telemetry TelemetryConfig
+ Microdns MicrodnsConfig
+}
+
+type MicrodnsConfig struct {
+ Bin string
+ ConfigPath string
+ Disable bool
+}
+
+// MicrodnsServiceDef builds the microinit drop-in for the microdns LAN
+// advertiser. Seeds microdns.json when missing (same rules as EnsureMicrodnsConfig).
+func MicrodnsServiceDef(cfg MicrodnsConfig) (ServiceDef, error) {
+ bin := cfg.Bin
+ if bin == "" {
+ bin = "microdns"
+ }
+ configPath := cfg.ConfigPath
+ if configPath == "" {
+ configPath = datadir.Path("etc", "microdns.json")
+ }
+ if err := ensureMicrodnsConfigFile(configPath); err != nil {
+ return ServiceDef{}, err
+ }
+ return WithCreatedBy(ServiceDef{
+ Name: "microdns",
+ Enabled: BoolPtr(true),
+ Daemon: BoolPtr(true),
+ RestartPolicy: RestartAlways,
+ RestartBackoff: IntPtr(60),
+ StartWaitSecs: IntPtr(1),
+ ShutdownWaitSecs: IntPtr(5),
+ StartCmd: fmt.Sprintf(
+ "exec %s --config %s",
+ config.ShellQuote(bin),
+ config.ShellQuote(configPath),
+ ),
+ }, CreatedByBigfred), nil
+}
+
+type TelemetryConfig struct {
+ Enable bool
+ AlloyBin, ConfigPath, StoragePath, OTLPEndpoint string
}
const DefaultOTLPEndpoint = "127.0.0.1:4317"
diff --git a/pkgs/bigfred/server/microinit/infra_test.go b/pkgs/bigfred/server/microinit/infra_test.go
index 3aca5b6..ab6ba15 100644
--- a/pkgs/bigfred/server/microinit/infra_test.go
+++ b/pkgs/bigfred/server/microinit/infra_test.go
@@ -48,6 +48,30 @@ func TestRedisServiceDefDefaultDataDir(t *testing.T) {
}
}
+func TestMicrodnsServiceDefWritesConfig(t *testing.T) {
+ root := t.TempDir()
+ t.Setenv("BIGFRED_DATA_DIR", root)
+ t.Setenv("DATA_DIR", "")
+
+ svc, err := MicrodnsServiceDef(MicrodnsConfig{Bin: "microdns"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if svc.Name != "microdns" {
+ t.Fatalf("Name = %q", svc.Name)
+ }
+ if !strings.Contains(svc.StartCmd, "microdns") || !strings.Contains(svc.StartCmd, "microdns.json") {
+ t.Fatalf("StartCmd = %q", svc.StartCmd)
+ }
+ if svc.Labels[LabelCreatedBy] != CreatedByBigfred {
+ t.Fatalf("labels: %+v", svc.Labels)
+ }
+ path := filepath.Join(root, "etc", "microdns.json")
+ if _, err := os.Stat(path); err != nil {
+ t.Fatalf("expected config at %s: %v", path, err)
+ }
+}
+
func TestPrepareAlloyTelemetryRendersValidBlocks(t *testing.T) {
root := t.TempDir()
t.Setenv("BIGFRED_DATA_DIR", root)
diff --git a/pkgs/bigfred/server/microinit/microdns.go b/pkgs/bigfred/server/microinit/microdns.go
index 584216a..953a949 100644
--- a/pkgs/bigfred/server/microinit/microdns.go
+++ b/pkgs/bigfred/server/microinit/microdns.go
@@ -4,6 +4,7 @@ import (
"encoding/json"
"errors"
"os"
+ "path/filepath"
"github.com/dcc-bigfred/microinit/go/config"
@@ -30,15 +31,20 @@ func EnsureMicrodnsConfig() error {
if !platform.SupportsMicrodns() {
return nil
}
- path := datadir.Path("etc", "microdns.json")
+ return ensureMicrodnsConfigFile(datadir.Path("etc", "microdns.json"))
+}
+
+func ensureMicrodnsConfigFile(path string) error {
if _, err := os.Stat(path); err == nil {
return nil
} else if !errors.Is(err, os.ErrNotExist) {
return err
}
- // Validate the default so a typo fails tests / startup seed, not microdns.
if !json.Valid([]byte(defaultMicrodnsConfig)) {
return errors.New("microdns: default config is not valid JSON")
}
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ return err
+ }
return config.WriteFileAtomically(path, []byte(defaultMicrodnsConfig))
}
diff --git a/pkgs/bigfred/server/service/infra_test.go b/pkgs/bigfred/server/service/infra_test.go
index a41be00..efadc61 100644
--- a/pkgs/bigfred/server/service/infra_test.go
+++ b/pkgs/bigfred/server/service/infra_test.go
@@ -60,6 +60,25 @@ func TestEnsureInfraAlloySetupFailureIsNonFatal(t *testing.T) {
}
}
+func TestEnsureInfraMicrodnsSetupFailureIsNonFatal(t *testing.T) {
+ t.Setenv("BIGFRED_DATA_DIR", t.TempDir())
+ t.Setenv("DATA_DIR", "")
+
+ log := logrus.New()
+ log.SetLevel(logrus.WarnLevel)
+ var buf bytes.Buffer
+ log.SetOutput(&buf)
+
+ mgr := &alloySetupStub{hasService: false}
+ err := EnsureInfra(context.Background(), mgr, log, InfraConfig{
+ Redis: RedisConfig{Disable: true},
+ Microdns: MicrodnsConfig{Bin: "microdns"},
+ })
+ if err != nil {
+ t.Fatalf("EnsureInfra: %v", err)
+ }
+}
+
func TestEnsureInfraRedisSetupFailureIsFatal(t *testing.T) {
t.Setenv("BIGFRED_DATA_DIR", t.TempDir())
t.Setenv("DATA_DIR", "")
diff --git a/pkgs/bigfred/server/service/manager.go b/pkgs/bigfred/server/service/manager.go
index e94dba5..e3e060f 100644
--- a/pkgs/bigfred/server/service/manager.go
+++ b/pkgs/bigfred/server/service/manager.go
@@ -192,6 +192,7 @@ func (m *manager) Paths() (string, string) { return m.supervisor.Socket, m.super
type TelemetryConfig = microinit.TelemetryConfig
type RedisConfig = microinit.RedisConfig
+type MicrodnsConfig = microinit.MicrodnsConfig
type InfraConfig = microinit.InfraConfig
type RDBSavePoint = microinit.RDBSavePoint
@@ -207,12 +208,12 @@ func ResolveRDBSavePoints(noPersist bool, values []string) ([]RDBSavePoint, erro
return microinit.ResolveRDBSavePoints(noPersist, values)
}
-// EnsureInfra writes redis/alloy drop-ins only when those services are absent
-// or already labeled created-by=bigfred. Foreign services are left alone; any
-// leftover bigfred drop-in for a foreign name is removed.
+// EnsureInfra writes redis/alloy/microdns drop-ins only when those services are
+// absent or already labeled created-by=bigfred. Foreign services are left alone;
+// any leftover bigfred drop-in for a foreign name is removed.
//
-// Alloy setup is best-effort: failures are logged as warnings and do not fail
-// bootstrap. Redis remains required when managed.
+// Alloy and microdns setup are best-effort: failures are logged as warnings and
+// do not fail bootstrap. Redis remains required when managed.
func EnsureInfra(ctx context.Context, mgr ServiceManager, log *logrus.Logger, cfg InfraConfig) error {
if !cfg.Redis.Disable {
if err := ensureOwnedInfra(ctx, mgr, GroupInfra, "redis", func() (microinit.ServiceDef, error) {
@@ -233,6 +234,15 @@ func EnsureInfra(ctx context.Context, mgr ServiceManager, log *logrus.Logger, cf
}
}
}
+ if !cfg.Microdns.Disable {
+ if err := ensureOwnedInfra(ctx, mgr, GroupInfra, "microdns", func() (microinit.ServiceDef, error) {
+ return microinit.MicrodnsServiceDef(cfg.Microdns)
+ }); err != nil {
+ if log != nil {
+ log.WithError(err).WithField("service", "microdns").Warn("microinit microdns setup failed; continuing without managed mDNS")
+ }
+ }
+ }
return nil
}
diff --git a/pkgs/bigfred/server/service/microinit_control.go b/pkgs/bigfred/server/service/microinit_control.go
new file mode 100644
index 0000000..740b300
--- /dev/null
+++ b/pkgs/bigfred/server/service/microinit_control.go
@@ -0,0 +1,86 @@
+package service
+
+import (
+ "fmt"
+ "net"
+
+ miclient "github.com/dcc-bigfred/microinit/go/client"
+)
+
+// MicroinitAPI is the subset of the microinit Go client used by admin
+// service listing, daemon info, and log streaming.
+type MicroinitAPI interface {
+ List() ([]miclient.ServiceStatus, error)
+ Info() (*miclient.DaemonInfo, error)
+ FollowLogs(name string, lines int, follow bool) (net.Conn, error)
+ ReadFrame(conn net.Conn) (miclient.Response, error)
+}
+
+// MicroinitControl exposes full microinit List/Info/FollowLogs for the
+// admin System and Logi pages. It is separate from SystemControl (host
+// power) and from ServiceManager.Status() (reduced ServiceState) so the
+// ServiceManager interface stays unchanged.
+type MicroinitControl struct {
+ client MicroinitAPI
+}
+
+// NewMicroinitControl 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 NewMicroinitControl(mgr ServiceManager) *MicroinitControl {
+ m, ok := mgr.(*manager)
+ if !ok || m == nil || m.supervisor == nil {
+ return &MicroinitControl{}
+ }
+ return &MicroinitControl{client: m.supervisor.Client()}
+}
+
+// NewMicroinitControlWithClient is for tests that inject a fake client.
+func NewMicroinitControlWithClient(client MicroinitAPI) *MicroinitControl {
+ return &MicroinitControl{client: client}
+}
+
+// Available reports whether a microinit client is wired.
+func (c *MicroinitControl) Available() bool {
+ return c != nil && c.client != nil
+}
+
+// ListServices returns the full microinit service catalogue.
+func (c *MicroinitControl) ListServices() ([]miclient.ServiceStatus, error) {
+ if !c.Available() {
+ return nil, ErrSystemUnavailable
+ }
+ services, err := c.client.List()
+ if err != nil {
+ return nil, fmt.Errorf("%w: %v", ErrSystemUnavailable, err)
+ }
+ return services, nil
+}
+
+// Info returns microinit DaemonInfo.
+func (c *MicroinitControl) Info() (*miclient.DaemonInfo, error) {
+ if !c.Available() {
+ return nil, ErrSystemUnavailable
+ }
+ info, err := c.client.Info()
+ if err != nil {
+ return nil, fmt.Errorf("%w: %v", ErrSystemUnavailable, err)
+ }
+ return info, nil
+}
+
+// FollowLogs opens a microinit log stream (history and/or follow).
+func (c *MicroinitControl) FollowLogs(name string, lines int, follow bool) (net.Conn, error) {
+ if !c.Available() {
+ return nil, ErrSystemUnavailable
+ }
+ return c.client.FollowLogs(name, lines, follow)
+}
+
+// ReadFrame reads the next non-heartbeat frame from a FollowLogs connection.
+func (c *MicroinitControl) ReadFrame(conn net.Conn) (miclient.Response, error) {
+ if !c.Available() {
+ return miclient.Response{}, ErrSystemUnavailable
+ }
+ return c.client.ReadFrame(conn)
+}
diff --git a/pkgs/bigfred/server/service/system.go b/pkgs/bigfred/server/service/system.go
index 59d4c8c..1756ca1 100644
--- a/pkgs/bigfred/server/service/system.go
+++ b/pkgs/bigfred/server/service/system.go
@@ -3,6 +3,8 @@ package service
import (
"errors"
"fmt"
+ "net"
+ "time"
miclient "github.com/dcc-bigfred/microinit/go/client"
)
@@ -24,6 +26,12 @@ type SystemInfo struct {
CanShutdown bool `json:"canShutdown"`
}
+// SystemPorts reports whether optional local admin UIs are reachable.
+type SystemPorts struct {
+ OsUI bool `json:"osUI"`
+ Grafana bool `json:"grafana"`
+}
+
// MicroinitPower is the subset of the microinit Go client used for host power.
type MicroinitPower interface {
Info() (*miclient.DaemonInfo, error)
@@ -91,6 +99,23 @@ func (s *SystemControl) RequestShutdown(mode string) error {
return s.power.ShutdownMode(mode)
}
+// Ports probes loopback TCP ports for bigfred-os-ui (8090) and Grafana (3000).
+func (s *SystemControl) Ports() SystemPorts {
+ return SystemPorts{
+ OsUI: tcpPortOpen("127.0.0.1:8090", 200*time.Millisecond),
+ Grafana: tcpPortOpen("127.0.0.1:3000", 200*time.Millisecond),
+ }
+}
+
+func tcpPortOpen(addr string, timeout time.Duration) bool {
+ conn, err := net.DialTimeout("tcp", addr, timeout)
+ if err != nil {
+ return false
+ }
+ _ = conn.Close()
+ return true
+}
+
// normalizeDaemonMode maps wire values to "init" or "supervise".
// Unknown / empty → supervise (safe side: deny host power).
func normalizeDaemonMode(mode string) string {
diff --git a/web/src/App.tsx b/web/src/App.tsx
index 871e7c2..6f58b18 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -32,6 +32,7 @@ import CommandStationsAdminPage from "./pages/admin/CommandStationsPage";
import ConnectionWizardAdminPage from "./pages/admin/ConnectionWizardPage";
import SlotsDiagnosticsAdminPage from "./pages/admin/SlotsDiagnosticsPage";
import DiagnosticsAdminPage from "./pages/admin/DiagnosticsPage";
+import SystemAdminPage from "./pages/admin/SystemPage";
import AdminRentalsPage from "./pages/admin/AdminRentalsPage";
import UsersAdminPage from "./pages/admin/UsersPage";
import RemotesPage from "./pages/remotes/RemotesPage";
@@ -100,6 +101,7 @@ const router = createBrowserRouter(
element={}
/>
} />
+ } />
} />
} />
diff --git a/web/src/api/microinitLogs.ts b/web/src/api/microinitLogs.ts
new file mode 100644
index 0000000..d500e40
--- /dev/null
+++ b/web/src/api/microinitLogs.ts
@@ -0,0 +1,125 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+
+import { useWsConnection } from "../hooks/useWsConnection";
+
+export type MicroinitLogStreamState =
+ | "idle"
+ | "connecting"
+ | "live"
+ | "error"
+ | "closed";
+
+interface WsPayload {
+ type: string;
+ lines?: string[];
+ text?: string;
+ error?: string;
+}
+
+function resolveMicroinitLogWsUrl(serviceName: string | null): string | null {
+ if (!serviceName) return null;
+ const path = `/api/v1/admin/microinit/services/${encodeURIComponent(serviceName)}/logs/stream`;
+ const base = (import.meta.env.VITE_API_BASE ?? "") as string;
+ if (base) {
+ const url = new URL(base);
+ url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
+ url.pathname = path;
+ url.search = "";
+ return url.toString();
+ }
+ const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
+ return `${proto}//${window.location.host}${path}`;
+}
+
+/**
+ * Live microinit log stream for one service.
+ * On reconnect the buffer is cleared before applying the new history
+ * snapshot — avoids duplicating the last N history lines.
+ */
+export function useMicroinitLogStream(
+ serviceName: string | null,
+ enabled: boolean,
+) {
+ const [lines, setLines] = useState([]);
+ const [state, setState] = useState("idle");
+ const [error, setError] = useState(null);
+ const pausedRef = useRef(false);
+ const [paused, setPaused] = useState(false);
+
+ const url = useMemo(
+ () => (enabled ? resolveMicroinitLogWsUrl(serviceName) : null),
+ [enabled, serviceName],
+ );
+
+ useEffect(() => {
+ setLines([]);
+ setError(null);
+ setState(url ? "connecting" : "idle");
+ }, [url]);
+
+ const clear = useCallback(() => {
+ setLines([]);
+ }, []);
+
+ const setPausedState = useCallback((next: boolean) => {
+ pausedRef.current = next;
+ setPaused(next);
+ }, []);
+
+ useWsConnection({
+ url,
+ // Server answers {"type":"ping"} with {"type":"pong"} (see StreamLogs).
+ pingIntervalMs: 10_000,
+ pongTimeoutMs: 30_000,
+ onConnecting: () => {
+ setState("connecting");
+ },
+ onOpen: () => {
+ // Clear before history arrives so reconnect does not append duplicates.
+ setLines([]);
+ setError(null);
+ setState("live");
+ },
+ onClose: () => {
+ setState((prev) => (prev === "error" ? prev : "closed"));
+ },
+ onDispose: () => {
+ setState("idle");
+ },
+ onError: () => {
+ setState("error");
+ setError("stream_failed");
+ },
+ onMessage: (data) => {
+ let payload: WsPayload;
+ try {
+ payload = JSON.parse(data) as WsPayload;
+ } catch {
+ return;
+ }
+ if (payload.type === "history") {
+ if (pausedRef.current) return;
+ setLines(payload.lines ?? []);
+ return;
+ }
+ if (payload.type === "line" && payload.text != null) {
+ if (pausedRef.current) return;
+ setLines((prev) => [...prev, payload.text!]);
+ return;
+ }
+ if (payload.type === "error") {
+ setState("error");
+ setError(payload.error || "stream_failed");
+ }
+ },
+ });
+
+ return {
+ lines,
+ state,
+ error,
+ paused,
+ setPaused: setPausedState,
+ clear,
+ };
+}
diff --git a/web/src/api/system.ts b/web/src/api/system.ts
index 8ae698e..590531f 100644
--- a/web/src/api/system.ts
+++ b/web/src/api/system.ts
@@ -1,3 +1,5 @@
+import { useQuery } from "@tanstack/react-query";
+
import { apiFetch, ApiError } from "./client";
export interface SystemInfo {
@@ -5,17 +7,42 @@ export interface SystemInfo {
canShutdown: boolean;
}
+export interface SystemPorts {
+ osUI: boolean;
+ grafana: boolean;
+}
+
+export interface MicroinitServiceStatus {
+ name: string;
+ state: string;
+ pid?: number | null;
+ restarts: number;
+ enabled: boolean;
+ liveness_failures?: number;
+ labels?: Record;
+}
+
+export interface MicroinitInfo {
+ version: string;
+ tag_commit?: string;
+ build_commit?: string;
+ build_time?: string;
+ pid: number;
+ hostname: string;
+ uptime_secs: number;
+ socket: string;
+ mode: string;
+ services_total: number;
+ services_running: number;
+ otel_enabled: 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 {
@@ -25,19 +52,50 @@ 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;
- }
+ if (err instanceof ApiError) throw err;
+ 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;
- }
+ if (status === undefined || status === 0) return;
throw err;
}
}
+
+export function useSystemInfo() {
+ return useQuery({
+ queryKey: ["admin", "system"],
+ queryFn: fetchSystemInfo,
+ staleTime: 5 * 1000,
+ });
+}
+
+export function useSystemPorts() {
+ return useQuery({
+ queryKey: ["admin", "system", "ports"],
+ queryFn: () => apiFetch("/api/v1/admin/system/ports"),
+ staleTime: 5 * 1000,
+ refetchInterval: 15 * 1000,
+ });
+}
+
+export function useMicroinitServices() {
+ return useQuery({
+ queryKey: ["admin", "microinit", "services"],
+ queryFn: async () => {
+ const res = await apiFetch<{ services: MicroinitServiceStatus[] }>(
+ "/api/v1/admin/microinit/services",
+ );
+ return res.services ?? [];
+ },
+ staleTime: 2 * 1000,
+ refetchInterval: 5 * 1000,
+ });
+}
+
+export function useMicroinitInfo() {
+ return useQuery({
+ queryKey: ["admin", "microinit", "info"],
+ queryFn: () => apiFetch("/api/v1/admin/microinit/info"),
+ staleTime: 5 * 1000,
+ refetchInterval: 10 * 1000,
+ });
+}
diff --git a/web/src/components/AppShell.tsx b/web/src/components/AppShell.tsx
index b3ae64e..ef93043 100644
--- a/web/src/components/AppShell.tsx
+++ b/web/src/components/AppShell.tsx
@@ -20,7 +20,6 @@ import AccountTreeIcon from "@mui/icons-material/AccountTree";
import DirectionsRailwayIcon from "@mui/icons-material/DirectionsRailway";
import ViewListIcon from "@mui/icons-material/ViewList";
import HistoryIcon from "@mui/icons-material/History";
-import BugReportIcon from "@mui/icons-material/BugReport";
import PersonIcon from "@mui/icons-material/Person";
import HandshakeIcon from "@mui/icons-material/Handshake";
import TrainIcon from "@mui/icons-material/Train";
@@ -28,7 +27,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 SettingsApplicationsIcon from "@mui/icons-material/SettingsApplications";
import LogoutIcon from "@mui/icons-material/Logout";
import { Link, Outlet, useMatch, useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
@@ -39,7 +38,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 FloatingHelpButton from "./help/FloatingHelpButton";
import { useSudoMobileMenuItems } from "./SudoIndicator";
import MobileNavDrawer, { type MobileNavSection } from "./MobileNavDrawer";
import TopBarMenu, { type TopBarMenuItem } from "./TopBarMenu";
@@ -81,7 +80,6 @@ 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
@@ -140,12 +138,6 @@ function AppShellContent() {
icon: ,
onClick: () => navigate("/admin/dcc-bus/slots"),
},
- {
- id: "logs",
- label: t("nav.administration.logs"),
- icon: ,
- onClick: () => navigate("/admin/logs"),
- },
{
id: "rentals",
label: t("nav.administration.rentals"),
@@ -154,10 +146,10 @@ function AppShellContent() {
},
{ id: "divider-system", divider: true },
{
- id: "systemPower",
- label: t("nav.administration.systemPower"),
- icon: ,
- onClick: () => setSystemPowerOpen(true),
+ id: "system",
+ label: t("nav.administration.system"),
+ icon: ,
+ onClick: () => navigate("/admin/system"),
},
],
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -478,10 +470,7 @@ function AppShellContent() {
identityLine={accountCaption ?? undefined}
/>
{sudoMobileDialogs}
- setSystemPowerOpen(false)}
- />
+
>
)}
diff --git a/web/src/components/RosterSection.tsx b/web/src/components/RosterSection.tsx
index 2c17716..08d2790 100644
--- a/web/src/components/RosterSection.tsx
+++ b/web/src/components/RosterSection.tsx
@@ -16,6 +16,8 @@ import {
Typography,
} from "@mui/material";
import RemoveCircleOutlineIcon from "@mui/icons-material/RemoveCircleOutline";
+import DirectionsRailwayIcon from "@mui/icons-material/DirectionsRailway";
+import TrainIcon from "@mui/icons-material/Train";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
@@ -90,6 +92,7 @@ export default function RosterSection({ layoutId }: Props) {
gap: 1,
}}
>
+
{t("vehicle:roster.trains.title")}
@@ -177,6 +180,7 @@ export default function RosterSection({ layoutId }: Props) {
gap: 1,
}}
>
+
{t("vehicle:roster.vehicles.title")}
diff --git a/web/src/components/SystemPowerDialog.tsx b/web/src/components/SystemPowerDialog.tsx
index 90643c1..25c229a 100644
--- a/web/src/components/SystemPowerDialog.tsx
+++ b/web/src/components/SystemPowerDialog.tsx
@@ -32,9 +32,12 @@ type Phase =
export default function SystemPowerDialog({
open,
onClose,
+ presetMode,
}: {
open: boolean;
onClose: () => void;
+ /** When set, confirm phase only offers this single power action. */
+ presetMode?: SystemShutdownMode;
}) {
const { t } = useTranslation("common");
const [phase, setPhase] = useState("loading");
@@ -144,22 +147,26 @@ export default function SystemPowerDialog({
-
-
+ {(presetMode == null || presetMode === "poweroff") && (
+
+ )}
+ {(presetMode == null || presetMode === "reboot") && (
+
+ )}
>
) : null}
diff --git a/web/src/components/VersionCard.tsx b/web/src/components/VersionCard.tsx
new file mode 100644
index 0000000..f627268
--- /dev/null
+++ b/web/src/components/VersionCard.tsx
@@ -0,0 +1,96 @@
+import {
+ Alert,
+ Box,
+ CircularProgress,
+ Paper,
+ Stack,
+ Table,
+ TableBody,
+ TableCell,
+ TableContainer,
+ TableRow,
+ Typography,
+} from "@mui/material";
+import { useTranslation } from "react-i18next";
+
+import { useVersion } from "../api/version";
+
+function VersionField({
+ label,
+ children,
+ mono = false,
+}: {
+ label: string;
+ children: React.ReactNode;
+ mono?: boolean;
+}) {
+ return (
+
+
+ {label}
+
+
+ {children}
+
+
+ );
+}
+
+/** Shared BigFred version table used by VersionPage and SystemPage. */
+export default function VersionCard() {
+ const { t } = useTranslation(["version"]);
+ const q = useVersion();
+
+ if (q.error) {
+ return {t("version:states.error")};
+ }
+ if (q.isLoading) {
+ return (
+
+
+
+ );
+ }
+ if (!q.data) return null;
+
+ return (
+
+
+
+
+
+ {q.data.version || "—"}
+
+
+ {q.data.tagCommit || "—"}
+
+
+ {q.data.buildCommit || "—"}
+
+
+ {q.data.buildTime || "—"}
+
+
+
+
+
+ );
+}
+
+export function VersionPageHeader() {
+ const { t } = useTranslation(["version"]);
+ return (
+
+
+ {t("version:title")}
+
+
+ {t("version:subtitle")}
+
+
+ );
+}
diff --git a/web/src/components/dcc-bus/DccBusProgramList.tsx b/web/src/components/dcc-bus/DccBusProgramList.tsx
new file mode 100644
index 0000000..7a24b63
--- /dev/null
+++ b/web/src/components/dcc-bus/DccBusProgramList.tsx
@@ -0,0 +1,57 @@
+import { Alert, CircularProgress, Stack } from "@mui/material";
+import { useTranslation } from "react-i18next";
+
+import type {
+ DccBusProgramStatus,
+ DccBusSupervisordAction,
+} from "../../api/command_stations";
+import DccBusProgramRow from "./DccBusProgramRow";
+
+export default function DccBusProgramList({
+ programs,
+ pendingLayoutId,
+ errorByLayoutId,
+ loading,
+ listError,
+ onAction,
+ onViewLogs,
+}: {
+ programs: DccBusProgramStatus[];
+ pendingLayoutId: number | null;
+ errorByLayoutId?: Record;
+ loading?: boolean;
+ listError?: string | null;
+ onAction: (layoutId: number, action: DccBusSupervisordAction) => void;
+ onViewLogs: (layoutId: number) => void;
+}) {
+ const { t } = useTranslation(["commandStation"]);
+
+ if (loading) {
+ return ;
+ }
+ if (listError) {
+ return {listError};
+ }
+ if (programs.length === 0) {
+ return (
+
+ {t("commandStation:admin.supervisord.noPrograms")}
+
+ );
+ }
+
+ return (
+
+ {programs.map((program) => (
+ onAction(program.layoutId, next)}
+ onViewLogs={() => onViewLogs(program.layoutId)}
+ />
+ ))}
+
+ );
+}
diff --git a/web/src/components/dcc-bus/DccBusProgramRow.tsx b/web/src/components/dcc-bus/DccBusProgramRow.tsx
new file mode 100644
index 0000000..326b7ff
--- /dev/null
+++ b/web/src/components/dcc-bus/DccBusProgramRow.tsx
@@ -0,0 +1,98 @@
+import {
+ Alert,
+ Button,
+ Chip,
+ Stack,
+ Typography,
+} from "@mui/material";
+import { useTranslation } from "react-i18next";
+
+import type {
+ DccBusProgramStatus,
+ DccBusSupervisordAction,
+} from "../../api/command_stations";
+
+export default function DccBusProgramRow({
+ program,
+ pendingLayoutId,
+ error,
+ onAction,
+ onViewLogs,
+}: {
+ program: DccBusProgramStatus;
+ pendingLayoutId: number | null;
+ error: string | null;
+ onAction: (action: DccBusSupervisordAction) => void;
+ onViewLogs: () => void;
+}) {
+ const { t } = useTranslation(["commandStation"]);
+ const layoutLabel = program.layoutName || `#${program.layoutId}`;
+ const busy = pendingLayoutId === program.layoutId;
+ const running = program.running;
+
+ return (
+
+
+ {layoutLabel}
+
+
+
+ {program.name}
+ {program.pid != null && program.pid > 0 ? ` · pid ${program.pid}` : ""}
+
+ {error && {error}}
+
+
+
+
+
+
+
+ );
+}
diff --git a/web/src/components/help/FloatingHelpButton.tsx b/web/src/components/help/FloatingHelpButton.tsx
new file mode 100644
index 0000000..32f6c6f
--- /dev/null
+++ b/web/src/components/help/FloatingHelpButton.tsx
@@ -0,0 +1,143 @@
+import { useCallback, useEffect, useRef, useState } from "react";
+import { Fab } from "@mui/material";
+import HelpOutlineIcon from "@mui/icons-material/HelpOutline";
+import { useTranslation } from "react-i18next";
+
+import { useHelpVisibility } from "../../hooks/useHelpVisibility";
+import HelpDialog from "./HelpDialog";
+
+const FAB_SIZE = 56;
+const DRAG_THRESHOLD_PX = 6;
+
+function clampPosition(
+ x: number,
+ y: number,
+ size = FAB_SIZE,
+): { x: number; y: number } {
+ const maxX = Math.max(0, window.innerWidth - size);
+ const maxY = Math.max(0, window.innerHeight - size);
+ return {
+ x: Math.min(Math.max(0, x), maxX),
+ y: Math.min(Math.max(0, y), maxY),
+ };
+}
+
+export default function FloatingHelpButton() {
+ const { t } = useTranslation(["help", "common"]);
+ const {
+ entry,
+ pathname,
+ visible,
+ position,
+ setPosition,
+ disableRoute,
+ disableGlobal,
+ } = useHelpVisibility();
+ const [dialogOpen, setDialogOpen] = useState(false);
+
+ const dragging = useRef(false);
+ const moved = useRef(false);
+ const start = useRef({ pointerX: 0, pointerY: 0, originX: 0, originY: 0 });
+ const posRef = useRef(position);
+ posRef.current = position;
+
+ useEffect(() => {
+ const onResize = () => {
+ setPosition(clampPosition(posRef.current.x, posRef.current.y));
+ };
+ window.addEventListener("resize", onResize);
+ onResize();
+ return () => window.removeEventListener("resize", onResize);
+ }, [setPosition]);
+
+ const onPointerDown = useCallback(
+ (e: React.PointerEvent) => {
+ if (e.button !== 0) return;
+ dragging.current = true;
+ moved.current = false;
+ start.current = {
+ pointerX: e.clientX,
+ pointerY: e.clientY,
+ originX: posRef.current.x,
+ originY: posRef.current.y,
+ };
+ e.currentTarget.setPointerCapture(e.pointerId);
+ },
+ [],
+ );
+
+ const onPointerMove = useCallback(
+ (e: React.PointerEvent) => {
+ if (!dragging.current) return;
+ const dx = e.clientX - start.current.pointerX;
+ const dy = e.clientY - start.current.pointerY;
+ if (
+ Math.abs(dx) > DRAG_THRESHOLD_PX ||
+ Math.abs(dy) > DRAG_THRESHOLD_PX
+ ) {
+ moved.current = true;
+ }
+ setPosition(
+ clampPosition(start.current.originX + dx, start.current.originY + dy),
+ );
+ },
+ [setPosition],
+ );
+
+ const onPointerUp = useCallback(
+ (e: React.PointerEvent) => {
+ if (!dragging.current) return;
+ dragging.current = false;
+ try {
+ e.currentTarget.releasePointerCapture(e.pointerId);
+ } catch {
+ /* already released */
+ }
+ if (!moved.current) {
+ setDialogOpen(true);
+ }
+ },
+ [],
+ );
+
+ if (!visible || !entry) return null;
+
+ return (
+ <>
+
+
+
+ setDialogOpen(false)}
+ entry={entry}
+ pathname={pathname}
+ onDisableRoute={() => {
+ disableRoute(pathname);
+ setDialogOpen(false);
+ }}
+ onDisableGlobal={() => {
+ disableGlobal();
+ setDialogOpen(false);
+ }}
+ />
+ >
+ );
+}
diff --git a/web/src/components/help/HelpDialog.tsx b/web/src/components/help/HelpDialog.tsx
new file mode 100644
index 0000000..1f4f342
--- /dev/null
+++ b/web/src/components/help/HelpDialog.tsx
@@ -0,0 +1,81 @@
+import {
+ Button,
+ Checkbox,
+ Dialog,
+ DialogActions,
+ DialogContent,
+ DialogTitle,
+ FormControlLabel,
+ Stack,
+ Typography,
+} from "@mui/material";
+import { Trans, useTranslation } from "react-i18next";
+
+import type { HelpEntry } from "./helpRegistry";
+
+export default function HelpDialog({
+ open,
+ onClose,
+ entry,
+ onDisableRoute,
+ onDisableGlobal,
+}: {
+ open: boolean;
+ onClose: () => void;
+ entry: HelpEntry;
+ pathname: string;
+ onDisableRoute: () => void;
+ onDisableGlobal: () => void;
+}) {
+ const { t } = useTranslation(["help", "common"]);
+
+ return (
+
+ );
+}
diff --git a/web/src/components/help/helpRegistry.tsx b/web/src/components/help/helpRegistry.tsx
new file mode 100644
index 0000000..86b4255
--- /dev/null
+++ b/web/src/components/help/helpRegistry.tsx
@@ -0,0 +1,44 @@
+import type { ReactElement } from "react";
+import AddIcon from "@mui/icons-material/Add";
+import SpeedIcon from "@mui/icons-material/Speed";
+import TuneIcon from "@mui/icons-material/Tune";
+
+export type HelpI18nKey =
+ | "dashboard"
+ | "myVehicles"
+ | "rentals"
+ | "myTrains";
+
+export type HelpEntry = {
+ i18nKey: HelpI18nKey;
+ components?: Record;
+};
+
+const iconSx = { fontSize: 18, verticalAlign: "middle", mx: 0.25 } as const;
+
+export const HELP_REGISTRY: Record = {
+ "/": {
+ i18nKey: "dashboard",
+ components: {
+ addIcon: ,
+ throttleIcon: ,
+ },
+ },
+ "/my/vehicles": {
+ i18nKey: "myVehicles",
+ components: {
+ addIcon: ,
+ functionsIcon: ,
+ },
+ },
+ "/rentals": {
+ i18nKey: "rentals",
+ },
+ "/my/trains": {
+ i18nKey: "myTrains",
+ },
+};
+
+export function getHelpEntry(pathname: string): HelpEntry | null {
+ return HELP_REGISTRY[pathname] ?? null;
+}
diff --git a/web/src/hooks/useHelpVisibility.ts b/web/src/hooks/useHelpVisibility.ts
new file mode 100644
index 0000000..8bf7cf6
--- /dev/null
+++ b/web/src/hooks/useHelpVisibility.ts
@@ -0,0 +1,87 @@
+import { useCallback, useMemo, useState } from "react";
+import { useLocation } from "react-router-dom";
+
+import { getHelpEntry, type HelpEntry } from "../components/help/helpRegistry";
+
+const POSITION_KEY = "bigfred.help.position";
+const DISABLED_ROUTES_KEY = "bigfred.help.disabledRoutes";
+const DISABLED_GLOBAL_KEY = "bigfred.help.disabledGlobal";
+
+export type HelpPosition = { x: number; y: number };
+
+function readJSON(key: string, fallback: T): T {
+ try {
+ const raw = localStorage.getItem(key);
+ if (!raw) return fallback;
+ return JSON.parse(raw) as T;
+ } catch {
+ return fallback;
+ }
+}
+
+function defaultPosition(): HelpPosition {
+ if (typeof window === "undefined") return { x: 24, y: 24 };
+ return {
+ x: Math.max(16, window.innerWidth - 72),
+ y: Math.max(16, window.innerHeight - 72),
+ };
+}
+
+export function useHelpVisibility() {
+ const { pathname } = useLocation();
+ const entry = useMemo(() => getHelpEntry(pathname), [pathname]);
+
+ const [position, setPositionState] = useState(() =>
+ readJSON(POSITION_KEY, defaultPosition()),
+ );
+ const [disabledRoutes, setDisabledRoutes] = useState(() =>
+ readJSON(DISABLED_ROUTES_KEY, []),
+ );
+ const [disabledGlobal, setDisabledGlobal] = useState(() =>
+ readJSON(DISABLED_GLOBAL_KEY, false),
+ );
+
+ const setPosition = useCallback((next: HelpPosition) => {
+ setPositionState(next);
+ try {
+ localStorage.setItem(POSITION_KEY, JSON.stringify(next));
+ } catch {
+ /* ignore quota */
+ }
+ }, []);
+
+ const disableRoute = useCallback((route: string) => {
+ setDisabledRoutes((prev) => {
+ if (prev.includes(route)) return prev;
+ const next = [...prev, route];
+ try {
+ localStorage.setItem(DISABLED_ROUTES_KEY, JSON.stringify(next));
+ } catch {
+ /* ignore */
+ }
+ return next;
+ });
+ }, []);
+
+ const disableGlobal = useCallback(() => {
+ setDisabledGlobal(true);
+ try {
+ localStorage.setItem(DISABLED_GLOBAL_KEY, JSON.stringify(true));
+ } catch {
+ /* ignore */
+ }
+ }, []);
+
+ const visible =
+ !!entry && !disabledGlobal && !disabledRoutes.includes(pathname);
+
+ return {
+ entry: entry as HelpEntry | null,
+ pathname,
+ visible,
+ position,
+ setPosition,
+ disableRoute,
+ disableGlobal,
+ };
+}
diff --git a/web/src/i18n/index.ts b/web/src/i18n/index.ts
index 9cf14a3..d6429ec 100644
--- a/web/src/i18n/index.ts
+++ b/web/src/i18n/index.ts
@@ -40,6 +40,8 @@ import plAudit from "./locales/pl/audit.json";
import plRentals from "./locales/pl/rentals.json";
import plRemotes from "./locales/pl/remotes.json";
import plVersion from "./locales/pl/version.json";
+import plSystem from "./locales/pl/system.json";
+import plHelp from "./locales/pl/help.json";
import enCommon from "./locales/en/common.json";
import enAuth from "./locales/en/auth.json";
@@ -61,6 +63,8 @@ import enAudit from "./locales/en/audit.json";
import enRentals from "./locales/en/rentals.json";
import enRemotes from "./locales/en/remotes.json";
import enVersion from "./locales/en/version.json";
+import enSystem from "./locales/en/system.json";
+import enHelp from "./locales/en/help.json";
import deCommon from "./locales/de/common.json";
import deAuth from "./locales/de/auth.json";
@@ -82,6 +86,8 @@ import deAudit from "./locales/de/audit.json";
import deRentals from "./locales/de/rentals.json";
import deRemotes from "./locales/de/remotes.json";
import deVersion from "./locales/de/version.json";
+import deSystem from "./locales/de/system.json";
+import deHelp from "./locales/de/help.json";
// SUPPORTED_LOCALES is the single source of truth. Adding a third
// locale (e.g. "de") is: append it here → mirror every catalogue
@@ -116,6 +122,8 @@ export const resources = {
rentals: plRentals,
remotes: plRemotes,
version: plVersion,
+ system: plSystem,
+ help: plHelp,
},
en: {
common: enCommon,
@@ -138,6 +146,8 @@ export const resources = {
rentals: enRentals,
remotes: enRemotes,
version: enVersion,
+ system: enSystem,
+ help: enHelp,
},
de: {
common: deCommon,
@@ -160,6 +170,8 @@ export const resources = {
rentals: deRentals,
remotes: deRemotes,
version: deVersion,
+ system: deSystem,
+ help: deHelp,
},
} as const;
@@ -176,7 +188,7 @@ void i18n
fallbackLng: "pl",
supportedLngs: SUPPORTED_LOCALES as unknown as string[],
defaultNS: "common",
- ns: ["common", "auth", "errors", "role", "home", "layout", "interlocking", "radio", "vehicle", "user", "sudo", "throttle", "commandStation", "diagnostics", "function", "trainAnnouncements", "audit", "rentals", "remotes", "version"],
+ ns: ["common", "auth", "errors", "role", "home", "layout", "interlocking", "radio", "vehicle", "user", "sudo", "throttle", "commandStation", "diagnostics", "function", "trainAnnouncements", "audit", "rentals", "remotes", "version", "system", "help"],
interpolation: {
// React already escapes everything; double-escaping inside
// i18next would mangle apostrophes and quotes.
diff --git a/web/src/i18n/locales/de/common.json b/web/src/i18n/locales/de/common.json
index 377048d..7a5c935 100644
--- a/web/src/i18n/locales/de/common.json
+++ b/web/src/i18n/locales/de/common.json
@@ -46,7 +46,8 @@
"logs": "Protokolle",
"auditLog": "Audit-Protokoll",
"rentals": "Ausleihen",
- "systemPower": "System ausschalten"
+ "systemPower": "System ausschalten",
+ "system": "System"
},
"my": {
"menuLabel": "Meine",
diff --git a/web/src/i18n/locales/de/diagnostics.json b/web/src/i18n/locales/de/diagnostics.json
index dcfba09..4357249 100644
--- a/web/src/i18n/locales/de/diagnostics.json
+++ b/web/src/i18n/locales/de/diagnostics.json
@@ -1,27 +1,16 @@
{
- "title": "Protokolle",
- "groupLabel": "Quelle",
- "fileLabel": "Datei",
- "tailLines": "Letzte Zeilen",
- "refresh": "Aktualisieren",
- "truncated": "nur Ende angezeigt",
- "empty": "(leer oder fehlende Datei)",
- "noFiles": "Keine Protokolldateien",
- "sources": {
- "supervisord": {
- "label": "Supervisord"
- },
- "redis": {
- "label": "Redis"
- },
- "dcc-bus": {
- "label": "dcc-bus"
- }
- },
- "entries": {
- "supervisord.log": "Hauptprotokoll supervisord",
- "supervisord.config": "supervisord.conf",
- "redis.stdout": "stdout",
- "redis.stderr": "stderr"
+ "title": "Logs",
+ "serviceLabel": "Dienst",
+ "empty": "(keine Logs)",
+ "unavailable": "Keine Verbindung zu microinit",
+ "live": {
+ "idle": "Leerlauf",
+ "connecting": "Verbinden…",
+ "connected": "Live",
+ "error": "Stream-Fehler",
+ "reconnecting": "Neu verbinden…",
+ "pause": "Pause",
+ "resume": "Fortsetzen",
+ "clear": "Leeren"
}
}
diff --git a/web/src/i18n/locales/de/help.json b/web/src/i18n/locales/de/help.json
new file mode 100644
index 0000000..1991256
--- /dev/null
+++ b/web/src/i18n/locales/de/help.json
@@ -0,0 +1,11 @@
+{
+ "dialog": {
+ "title": "Hilfe",
+ "disableForRoute": "Für diese Seite nicht anzeigen",
+ "disableGlobal": "Auf keiner Seite anzeigen"
+ },
+ "dashboard": "Um auf der Anlage zu fahren, musst du ein Fahrzeug im Reiter „Fahrzeuge“ mit DCC-Adresse hinzufügen und dann klicken, um es zur aktuellen Anlage hinzuzufügen (zu akzeptieren). Wenn das Fahrzeug hinzugefügt ist, wähle oben auf dem Bildschirm ",
+ "myVehicles": "Um ein Fahrzeug auf der Anlage fahren zu können, musst du es mit dem Symbol zur aktuellen Anlage hinzufügen (akzeptieren). Direkt nach dem Hinzufügen hat das Fahrzeug möglicherweise keine Funktionen — zum Zuweisen nutze , wo du sie manuell setzen oder von einem anderen Fahrzeug bzw. einer Vorlage kopieren kannst.",
+ "rentals": "Du kannst einer anderen Person zeitweise ein Fahrzeug ausleihen, oder eine andere Person kann dir ihres ausleihen. Übernimmt jemand die Kontrolle, verliert die andere Person sie vorübergehend — der/die Eigentümer/in kann sie jederzeit zurückholen. Ein Wechsel der steuernden Person hält das Fahrzeug an.",
+ "myTrains": "Verbinde mehrere Fahrzeuge zu einem Zug, um mehrere Lokomotiven gleichzeitig zu steuern und Wagenlichter in einer Steueransicht einzuschalten. Hinweis — es gelten globale Limits für die Anzahl gesteuerter Fahrzeuge und belegter LocoNet-Slots, die der Anlagenadministrator setzt"
+}
diff --git a/web/src/i18n/locales/de/system.json b/web/src/i18n/locales/de/system.json
new file mode 100644
index 0000000..5d25385
--- /dev/null
+++ b/web/src/i18n/locales/de/system.json
@@ -0,0 +1,30 @@
+{
+ "title": "System",
+ "actions": {
+ "reboot": "Hub neu starten",
+ "poweroff": "Hub ausschalten",
+ "logs": "Logs anzeigen",
+ "osPanel": "OS-Verwaltungspanel öffnen",
+ "grafana": "Telemetrie in Grafana anzeigen",
+ "powerUnavailable": "Host-Abschaltung ist nur im microinit-Init-Modus verfügbar",
+ "portClosed": "Dienst auf diesem Port nicht erreichbar"
+ },
+ "refreshDccBus": "dcc-bus-Liste aktualisieren",
+ "dccBusSection": "dcc-bus-Daemons",
+ "dccBusEmpty": "Keine Zentralen",
+ "microinit": {
+ "title": "microinit",
+ "unavailable": "microinit ist nicht verfügbar",
+ "version": "Version",
+ "mode": "Modus",
+ "uptime": "Laufzeit",
+ "services": "Dienste",
+ "empty": "Keine Dienste",
+ "colName": "Name",
+ "colState": "Status",
+ "colPid": "PID",
+ "colRestarts": "Neustarts",
+ "colEnabled": "Aktiv",
+ "colLogs": "Logs"
+ }
+}
diff --git a/web/src/i18n/locales/en/common.json b/web/src/i18n/locales/en/common.json
index e399d5a..e278711 100644
--- a/web/src/i18n/locales/en/common.json
+++ b/web/src/i18n/locales/en/common.json
@@ -46,7 +46,8 @@
"logs": "Logs",
"auditLog": "Audit log",
"rentals": "Rentals",
- "systemPower": "Shut down system"
+ "systemPower": "Shut down system",
+ "system": "System"
},
"my": {
"menuLabel": "My",
diff --git a/web/src/i18n/locales/en/diagnostics.json b/web/src/i18n/locales/en/diagnostics.json
index 9004766..1e7afe0 100644
--- a/web/src/i18n/locales/en/diagnostics.json
+++ b/web/src/i18n/locales/en/diagnostics.json
@@ -1,27 +1,16 @@
{
"title": "Logs",
- "groupLabel": "Source",
- "fileLabel": "File",
- "tailLines": "Tail lines",
- "refresh": "Refresh",
- "truncated": "showing tail only",
- "empty": "(empty or missing file)",
- "noFiles": "No log files",
- "sources": {
- "supervisord": {
- "label": "Supervisord"
- },
- "redis": {
- "label": "Redis"
- },
- "dcc-bus": {
- "label": "dcc-bus"
- }
- },
- "entries": {
- "supervisord.log": "Main supervisord log",
- "supervisord.config": "supervisord.conf",
- "redis.stdout": "stdout",
- "redis.stderr": "stderr"
+ "serviceLabel": "Service",
+ "empty": "(no logs)",
+ "unavailable": "Cannot connect to microinit",
+ "live": {
+ "idle": "Idle",
+ "connecting": "Connecting…",
+ "connected": "Live",
+ "error": "Stream error",
+ "reconnecting": "Reconnecting…",
+ "pause": "Pause",
+ "resume": "Resume",
+ "clear": "Clear"
}
}
diff --git a/web/src/i18n/locales/en/help.json b/web/src/i18n/locales/en/help.json
new file mode 100644
index 0000000..05c38b6
--- /dev/null
+++ b/web/src/i18n/locales/en/help.json
@@ -0,0 +1,11 @@
+{
+ "dialog": {
+ "title": "Help",
+ "disableForRoute": "Don't show for this page",
+ "disableGlobal": "Don't show on any page"
+ },
+ "dashboard": "To drive on the layout you need to add a vehicle in the \"Vehicles\" tab with a DCC address, then click to add (accept) it to the current layout. Once the vehicle is added, choose at the top of the screen",
+ "myVehicles": "To drive a vehicle on the layout you must add (accept) it to the current layout with the icon. Right after adding, the vehicle may have no functions assigned — to assign them use , where you can set them manually or copy from another vehicle or a ready-made template.",
+ "rentals": "You can temporarily rent a vehicle to someone else, or someone else can rent their vehicle to you. When another person takes control of a vehicle, the previous person temporarily loses it — though the owner can always reclaim that control. Changing who is driving a vehicle stops it.",
+ "myTrains": "Combine several vehicles into one train so you can control multiple locomotives at once and turn on coach lights in a single control view. Note — global limits on the number of controlled vehicles and occupied LocoNet slots set by the layout administrator apply"
+}
diff --git a/web/src/i18n/locales/en/system.json b/web/src/i18n/locales/en/system.json
new file mode 100644
index 0000000..7b89aec
--- /dev/null
+++ b/web/src/i18n/locales/en/system.json
@@ -0,0 +1,30 @@
+{
+ "title": "System",
+ "actions": {
+ "reboot": "Restart hub",
+ "poweroff": "Shut down hub",
+ "logs": "View logs",
+ "osPanel": "Open OS management panel",
+ "grafana": "View telemetry in Grafana",
+ "powerUnavailable": "Host power control is only available in microinit init mode",
+ "portClosed": "Service is not reachable on this port"
+ },
+ "refreshDccBus": "Refresh dcc-bus list",
+ "dccBusSection": "dcc-bus daemons",
+ "dccBusEmpty": "No command stations",
+ "microinit": {
+ "title": "microinit",
+ "unavailable": "microinit is unavailable",
+ "version": "Version",
+ "mode": "Mode",
+ "uptime": "Uptime",
+ "services": "Services",
+ "empty": "No services",
+ "colName": "Name",
+ "colState": "State",
+ "colPid": "PID",
+ "colRestarts": "Restarts",
+ "colEnabled": "Enabled",
+ "colLogs": "Logs"
+ }
+}
diff --git a/web/src/i18n/locales/pl/common.json b/web/src/i18n/locales/pl/common.json
index 8a99520..dc3ddff 100644
--- a/web/src/i18n/locales/pl/common.json
+++ b/web/src/i18n/locales/pl/common.json
@@ -46,7 +46,8 @@
"logs": "Logi",
"auditLog": "Dziennik audytu",
"rentals": "Wypożyczenia",
- "systemPower": "Wyłącz system"
+ "systemPower": "Wyłącz system",
+ "system": "System"
},
"my": {
"menuLabel": "Moje",
diff --git a/web/src/i18n/locales/pl/diagnostics.json b/web/src/i18n/locales/pl/diagnostics.json
index d23421a..a87c976 100644
--- a/web/src/i18n/locales/pl/diagnostics.json
+++ b/web/src/i18n/locales/pl/diagnostics.json
@@ -1,27 +1,16 @@
{
"title": "Logi",
- "groupLabel": "Źródło",
- "fileLabel": "Plik",
- "tailLines": "Ostatnie linie",
- "refresh": "Odśwież",
- "truncated": "pokazano tylko koniec pliku",
- "empty": "(pusty lub brak pliku)",
- "noFiles": "Brak plików logów",
- "sources": {
- "supervisord": {
- "label": "Supervisord"
- },
- "redis": {
- "label": "Redis"
- },
- "dcc-bus": {
- "label": "dcc-bus"
- }
- },
- "entries": {
- "supervisord.log": "Główny log supervisord",
- "supervisord.config": "supervisord.conf",
- "redis.stdout": "stdout",
- "redis.stderr": "stderr"
+ "serviceLabel": "Usługa",
+ "empty": "(brak logów)",
+ "unavailable": "Nie można połączyć się z microinit",
+ "live": {
+ "idle": "Bezczynny",
+ "connecting": "Łączenie…",
+ "connected": "Na żywo",
+ "error": "Błąd strumienia",
+ "reconnecting": "Ponowne łączenie…",
+ "pause": "Wstrzymaj",
+ "resume": "Wznów",
+ "clear": "Wyczyść"
}
}
diff --git a/web/src/i18n/locales/pl/help.json b/web/src/i18n/locales/pl/help.json
new file mode 100644
index 0000000..6121e73
--- /dev/null
+++ b/web/src/i18n/locales/pl/help.json
@@ -0,0 +1,11 @@
+{
+ "dialog": {
+ "title": "Pomoc",
+ "disableForRoute": "Nie pokazuj dla tej podstrony",
+ "disableGlobal": "Nie pokazuj na żadnej podstronie"
+ },
+ "dashboard": "Aby jeździć na makiecie potrzebujesz dodać pojazd w zakładce \"Pojazdy\" wraz z adresem DCC, następnie kliknąć aby dodać (zaakceptować) do obecnej makiety. Gdy pojazd jest dodany na samej górze ekranu wybierz ",
+ "myVehicles": "By móc jeździć pojazdem na makiecie trzeba go dodać (zaakceptować) do obecnej makiety ikonką . Tuż po dodaniu pojazd może nie mieć żadnych funkcji przypisanych - aby je przypisać użyj , gdzie możesz je nadać własnoręcznie lub skopiować z innego pojazdu bądź z gotowego szablonu.",
+ "rentals": "Możesz wypożyczyć innej osobie pojazd czasowo, bądź inna osoba może wypożyczyć swój pojazd Tobie. Przejmując kontrolę nad pojazdem druga osoba ją czasowo traci - choć właściciel/ka wciąż może tą kontrolę w dowolnym momencie przywrócić. Zmiana osoby kierującej pojazdem zahamowuje pojazd.",
+ "myTrains": "Połącz kilka pojazdów w jeden skład, dzięki czemu uzyskasz możliwość sterowania kilkoma lokomotywami na raz oraz możliwość włączenia świateł w wagonach w jednym widoku stereowania. Uwaga - obowiązują globalne limity ilości sterowanych pojazdów oraz zajętych slotów LocoNet ustawione przez administratora makiety"
+}
diff --git a/web/src/i18n/locales/pl/system.json b/web/src/i18n/locales/pl/system.json
new file mode 100644
index 0000000..edc02cf
--- /dev/null
+++ b/web/src/i18n/locales/pl/system.json
@@ -0,0 +1,30 @@
+{
+ "title": "System",
+ "actions": {
+ "reboot": "Uruchom hub ponownie",
+ "poweroff": "Wyłącz hub",
+ "logs": "Zobacz logi",
+ "osPanel": "Otwórz panel zarządzania OS",
+ "grafana": "Zobacz telemetrię w Grafana",
+ "powerUnavailable": "Wyłączenie hosta jest dostępne tylko w trybie init microinit",
+ "portClosed": "Usługa niedostępna na tym porcie"
+ },
+ "refreshDccBus": "Odśwież listę dcc-bus",
+ "dccBusSection": "Daemony dcc-bus",
+ "dccBusEmpty": "Brak centralek",
+ "microinit": {
+ "title": "microinit",
+ "unavailable": "microinit jest niedostępny",
+ "version": "Wersja",
+ "mode": "Tryb",
+ "uptime": "Czas pracy",
+ "services": "Usługi",
+ "empty": "Brak usług",
+ "colName": "Nazwa",
+ "colState": "Stan",
+ "colPid": "PID",
+ "colRestarts": "Restarty",
+ "colEnabled": "Włączona",
+ "colLogs": "Logi"
+ }
+}
diff --git a/web/src/i18n/types.ts b/web/src/i18n/types.ts
index e8ea47b..9b9e390 100644
--- a/web/src/i18n/types.ts
+++ b/web/src/i18n/types.ts
@@ -39,6 +39,8 @@ import type plAudit from "./locales/pl/audit.json";
import type plRentals from "./locales/pl/rentals.json";
import type plRemotes from "./locales/pl/remotes.json";
import type plVersion from "./locales/pl/version.json";
+import type plSystem from "./locales/pl/system.json";
+import type plHelp from "./locales/pl/help.json";
declare module "i18next" {
interface CustomTypeOptions {
@@ -64,6 +66,8 @@ declare module "i18next" {
rentals: typeof plRentals;
remotes: typeof plRemotes;
version: typeof plVersion;
+ system: typeof plSystem;
+ help: typeof plHelp;
};
// returnNull is false in index.ts; mirror that here so the t()
// return type is `string` (not `string | null`).
diff --git a/web/src/pages/HomePage.tsx b/web/src/pages/HomePage.tsx
index 122e374..7329f68 100644
--- a/web/src/pages/HomePage.tsx
+++ b/web/src/pages/HomePage.tsx
@@ -1,4 +1,6 @@
import SettingsIcon from "@mui/icons-material/Settings";
+import PeopleIcon from "@mui/icons-material/People";
+import AccountTreeIcon from "@mui/icons-material/AccountTree";
import {
Alert,
Box,
@@ -74,7 +76,18 @@ export default function HomePage() {
) : (
<>
-
+
+
{t("home:onlineUsers.title")}
@@ -138,6 +151,7 @@ export default function HomePage() {
gap: 1,
}}
>
+
{t("home:interlockings.title")}
diff --git a/web/src/pages/MyTrainsPage.tsx b/web/src/pages/MyTrainsPage.tsx
index 2d6595b..b1b54e5 100644
--- a/web/src/pages/MyTrainsPage.tsx
+++ b/web/src/pages/MyTrainsPage.tsx
@@ -17,9 +17,6 @@ export default function MyTrainsPage() {
{t("vehicle:trainList.title")}
-
- {t("vehicle:trainList.intro")}
-
diff --git a/web/src/pages/VersionPage.tsx b/web/src/pages/VersionPage.tsx
index c7ab965..bb713d0 100644
--- a/web/src/pages/VersionPage.tsx
+++ b/web/src/pages/VersionPage.tsx
@@ -1,84 +1,13 @@
-import {
- Alert,
- Box,
- CircularProgress,
- Container,
- Paper,
- Stack,
- Table,
- TableBody,
- TableCell,
- TableContainer,
- TableRow,
- Typography,
-} from "@mui/material";
-import { useTranslation } from "react-i18next";
+import { Container, Stack } from "@mui/material";
-import { useVersion } from "../api/version";
-
-function VersionField({
- label,
- children,
- mono = false,
-}: {
- label: string;
- children: React.ReactNode;
- mono?: boolean;
-}) {
- return (
-
-
- {label}
-
- {children}
-
- );
-}
+import VersionCard, { VersionPageHeader } from "../components/VersionCard";
export default function VersionPage() {
- const { t } = useTranslation(["version", "common"]);
- const q = useVersion();
-
return (
-
-
- {t("version:title")}
-
-
- {t("version:subtitle")}
-
-
-
- {q.error && {t("version:states.error")}}
-
- {q.isLoading ? (
-
-
-
- ) : q.data ? (
-
-
-
-
-
- {q.data.version || "—"}
-
-
- {q.data.tagCommit || "—"}
-
-
- {q.data.buildCommit || "—"}
-
-
- {q.data.buildTime || "—"}
-
-
-
-
-
- ) : null}
+
+
);
diff --git a/web/src/pages/admin/CommandStationsPage.tsx b/web/src/pages/admin/CommandStationsPage.tsx
index fe94686..0376d85 100644
--- a/web/src/pages/admin/CommandStationsPage.tsx
+++ b/web/src/pages/admin/CommandStationsPage.tsx
@@ -40,6 +40,8 @@ import { Link as RouterLink, useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { ApiError } from "../../api/client";
+import { useMe } from "../../api/auth";
+import { useLayoutSupervisordSync } from "../../api/presence";
import {
COMMAND_STATION_KINDS,
COMMAND_STATION_SPEED_STEPS,
@@ -57,9 +59,9 @@ import {
DEFAULT_COMMAND_STATION_IDLE_TIMEOUT_SECS,
type CommandStation,
type CommandStationKind,
- type DccBusProgramStatus,
type DccBusSupervisordAction,
} from "../../api/command_stations";
+import DccBusProgramList from "../../components/dcc-bus/DccBusProgramList";
function isLoconetKind(kind: CommandStationKind): boolean {
return kind === "loconet_serial" || kind === "loconet_tcp";
@@ -68,6 +70,8 @@ function isLoconetKind(kind: CommandStationKind): boolean {
export default function CommandStationsPage() {
const { t } = useTranslation(["commandStation", "common", "errors"]);
const navigate = useNavigate();
+ const me = useMe().data;
+ useLayoutSupervisordSync(me?.layoutId ?? null);
const list = useCommandStationsCatalogue();
const create = useCreateCommandStation();
const update = useUpdateCommandStation();
@@ -725,10 +729,6 @@ export default function CommandStationsPage() {
);
}
-function dccBusStderrFileId(layoutId: number, commandStationId: number): string {
- return `dcc-bus.dcc-bus-${layoutId}-${commandStationId}.stderr.log`;
-}
-
function DccBusSupervisordDialog({
target,
onClose,
@@ -779,10 +779,9 @@ function DccBusSupervisordDialog({
};
const openLogs = (layoutId: number) => {
- const file = dccBusStderrFileId(layoutId, target.id);
onClose();
navigate(
- `/admin/logs?group=${encodeURIComponent("dcc-bus")}&file=${encodeURIComponent(file)}`,
+ `/admin/logs?service=${encodeURIComponent(`dcc-bus-${layoutId}-${target.id}`)}`,
);
};
@@ -793,30 +792,19 @@ function DccBusSupervisordDialog({
- {status.isLoading ? (
-
- ) : status.isError ? (
-
- {formatError(status.error)}
-
- ) : programs.length === 0 ? (
-
- {t("commandStation:admin.supervisord.noPrograms")}
-
- ) : (
- programs.map((program) => (
- void runAction(program.layoutId, next)}
- onViewLogs={() => openLogs(program.layoutId)}
- />
- ))
- )}
+ void runAction(layoutId, next)}
+ onViewLogs={openLogs}
+ />
@@ -827,88 +815,3 @@ function DccBusSupervisordDialog({
);
}
-
-function DccBusProgramRow({
- program,
- pendingLayoutId,
- error,
- onAction,
- onViewLogs,
-}: {
- program: DccBusProgramStatus;
- pendingLayoutId: number | null;
- error: string | null;
- onAction: (action: DccBusSupervisordAction) => void;
- onViewLogs: () => void;
-}) {
- const { t } = useTranslation(["commandStation"]);
- const layoutLabel = program.layoutName || `#${program.layoutId}`;
- const busy = pendingLayoutId === program.layoutId;
- const running = program.running;
-
- return (
-
-
- {layoutLabel}
-
-
-
- {program.name}
- {program.pid != null && program.pid > 0 ? ` · pid ${program.pid}` : ""}
-
- {error && {error}}
-
-
-
-
-
-
-
- );
-}
diff --git a/web/src/pages/admin/DiagnosticsPage.tsx b/web/src/pages/admin/DiagnosticsPage.tsx
index 421abf0..f1650ed 100644
--- a/web/src/pages/admin/DiagnosticsPage.tsx
+++ b/web/src/pages/admin/DiagnosticsPage.tsx
@@ -1,8 +1,9 @@
-import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { useEffect, useMemo, useRef, useState } from "react";
import {
Alert,
Box,
Button,
+ Chip,
CircularProgress,
Container,
FormControl,
@@ -11,136 +12,83 @@ import {
Paper,
Select,
Stack,
- TextField,
Typography,
} from "@mui/material";
-import RefreshIcon from "@mui/icons-material/Refresh";
+import PauseIcon from "@mui/icons-material/Pause";
+import PlayArrowIcon from "@mui/icons-material/PlayArrow";
+import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
import { useTranslation } from "react-i18next";
import { useSearchParams } from "react-router-dom";
-import { ApiError } from "../../api/client";
-import {
- fetchDiagnosticContent,
- useDiagnosticSources,
- type DiagnosticEntry,
-} from "../../api/diagnostics";
-
-function formatBytes(n: number): string {
- if (n < 1024) {
- return `${n} B`;
- }
- if (n < 1024 * 1024) {
- return `${(n / 1024).toFixed(1)} KiB`;
- }
- return `${(n / (1024 * 1024)).toFixed(1)} MiB`;
-}
+import { useMicroinitLogStream } from "../../api/microinitLogs";
+import { useMicroinitServices } from "../../api/system";
export default function DiagnosticsPage() {
const { t } = useTranslation(["diagnostics", "common", "errors"]);
const [searchParams] = useSearchParams();
- const sources = useDiagnosticSources();
+ const services = useMicroinitServices();
- const [groupId, setGroupId] = useState("");
- const [fileId, setFileId] = useState("");
- const [tailLines, setTailLines] = useState(500);
- const [content, setContent] = useState("");
- const [meta, setMeta] = useState<{
- fileName: string;
- size: number;
- truncated: boolean;
- } | null>(null);
- const [loadingContent, setLoadingContent] = useState(false);
- const [contentError, setContentError] = useState(null);
+ const preferredService = searchParams.get("service") ?? "";
+ const [serviceName, setServiceName] = useState("");
+ const appliedPref = useRef(false);
- const groups = sources.data?.groups ?? [];
- const preferredGroup = searchParams.get("group") ?? "";
- const preferredFile = searchParams.get("file") ?? "";
- // Apply deep-link prefs once so a later sources refetch does not
- // overwrite a manual group/file selection.
- const appliedGroupPref = useRef(false);
- const appliedFilePref = useRef(false);
-
- const entries: DiagnosticEntry[] = useMemo(() => {
- const g = groups.find((x) => x.id === groupId);
- return g?.entries ?? [];
- }, [groups, groupId]);
+ const serviceList = useMemo(
+ () => services.data ?? [],
+ [services.data],
+ );
useEffect(() => {
- if (groups.length === 0) {
- return;
- }
+ if (serviceList.length === 0) return;
if (
- !appliedGroupPref.current &&
- preferredGroup &&
- groups.some((g) => g.id === preferredGroup)
+ !appliedPref.current &&
+ preferredService &&
+ serviceList.some((s) => s.name === preferredService)
) {
- setGroupId(preferredGroup);
- appliedGroupPref.current = true;
+ setServiceName(preferredService);
+ appliedPref.current = true;
return;
}
- setGroupId((current) =>
- current && groups.some((g) => g.id === current) ? current : groups[0].id,
- );
- }, [groups, preferredGroup]);
-
- useEffect(() => {
- if (entries.length === 0) {
- setFileId("");
+ // Deep-link may name a service not yet listed — still select it.
+ if (!appliedPref.current && preferredService) {
+ setServiceName(preferredService);
+ appliedPref.current = true;
return;
}
- if (
- !appliedFilePref.current &&
- preferredFile &&
- entries.some((e) => e.id === preferredFile)
- ) {
- setFileId(preferredFile);
- appliedFilePref.current = true;
- return;
- }
- setFileId((current) =>
- current && entries.some((e) => e.id === current) ? current : entries[0].id,
+ setServiceName((current) =>
+ current &&
+ (serviceList.some((s) => s.name === current) || current === preferredService)
+ ? current
+ : serviceList[0]?.name ?? "",
);
- }, [entries, preferredFile]);
+ }, [serviceList, preferredService]);
- const loadContent = useCallback(async () => {
- if (!fileId) {
- return;
- }
- setLoadingContent(true);
- setContentError(null);
- try {
- const res = await fetchDiagnosticContent(fileId, tailLines);
- setContent(res.content);
- setMeta({
- fileName: res.fileName,
- size: res.size,
- truncated: res.truncated,
- });
- } catch (e) {
- setContent("");
- setMeta(null);
- if (e instanceof ApiError) {
- const localised = t(`errors:${e.code}` as const, { defaultValue: "" });
- setContentError(
- localised || t("errors:unknown", { code: e.code }),
- );
- } else {
- setContentError(t("errors:network"));
- }
- } finally {
- setLoadingContent(false);
- }
- }, [fileId, tailLines, t]);
+ const stream = useMicroinitLogStream(
+ serviceName || null,
+ Boolean(serviceName),
+ );
+ const preRef = useRef(null);
useEffect(() => {
- void loadContent();
- }, [loadContent]);
-
- const groupLabel = (id: string, backendLabel: string) =>
- t(`sources.${id}.label`, { defaultValue: backendLabel });
-
- const entryLabel = (id: string, backendLabel: string) =>
- t(`entries.${id}`, { defaultValue: backendLabel });
+ if (stream.paused) return;
+ const el = preRef.current;
+ if (!el) return;
+ el.scrollTop = el.scrollHeight;
+ }, [stream.lines, stream.paused]);
+
+ const stateLabel = (() => {
+ switch (stream.state) {
+ case "connecting":
+ return t("diagnostics:live.connecting");
+ case "live":
+ return t("diagnostics:live.connected");
+ case "error":
+ return t("diagnostics:live.error");
+ case "closed":
+ return t("diagnostics:live.reconnecting");
+ default:
+ return t("diagnostics:live.idle");
+ }
+ })();
return (
@@ -148,101 +96,92 @@ export default function DiagnosticsPage() {
{t("title")}
- {sources.isLoading && (
+ {services.isLoading && (
)}
- {sources.isError && (
+ {services.isError && (
- {t("common:networkError")}
+ {t("diagnostics:unavailable")}
)}
- {sources.isSuccess && (
+ {(services.isSuccess || preferredService) && (
-
+
- {t("groupLabel")}
+
+ {t("diagnostics:serviceLabel")}
+
-
- {t("fileLabel")}
-
-
-
-
- setTailLines(Math.max(1, Number(e.target.value) || 500))
+ color={
+ stream.state === "live"
+ ? "success"
+ : stream.state === "error"
+ ? "error"
+ : "default"
}
- inputProps={{ min: 1, max: 10000 }}
- sx={{ minWidth: 140 }}
+ label={stateLabel}
+ sx={{ flexShrink: 0 }}
/>
- ) : (
-
- )
- }
- onClick={() => void loadContent()}
- disabled={!fileId || loadingContent}
- sx={{ alignSelf: { sm: "center" }, flexShrink: 0 }}
+ size="small"
+ startIcon={stream.paused ? : }
+ onClick={() => stream.setPaused(!stream.paused)}
+ disabled={!serviceName}
+ sx={{ flexShrink: 0 }}
>
- {t("refresh")}
+ {stream.paused
+ ? t("diagnostics:live.resume")
+ : t("diagnostics:live.pause")}
-
- {contentError && (
- {contentError}
- )}
+ }
+ onClick={stream.clear}
+ disabled={!serviceName}
+ sx={{ flexShrink: 0 }}
+ >
+ {t("diagnostics:live.clear")}
+
+
- {meta && (
-
- {meta.fileName} · {formatBytes(meta.size)}
- {meta.truncated ? ` · ${t("truncated")}` : ""}
-
+ {stream.error && (
+ {stream.error}
)}
- {loadingContent && !content
- ? t("common:loading")
- : content || t("empty")}
+ {stream.lines.length === 0
+ ? stream.state === "connecting"
+ ? t("common:loading")
+ : t("diagnostics:empty")
+ : stream.lines.join("\n")}
diff --git a/web/src/pages/admin/LayoutsPage.tsx b/web/src/pages/admin/LayoutsPage.tsx
index 5913237..64b7593 100644
--- a/web/src/pages/admin/LayoutsPage.tsx
+++ b/web/src/pages/admin/LayoutsPage.tsx
@@ -37,6 +37,8 @@ import { useTranslation } from "react-i18next";
import { useSearchParams } from "react-router-dom";
import { ApiError } from "../../api/client";
+import { useMe } from "../../api/auth";
+import { useLayoutSupervisordSync } from "../../api/presence";
import {
useAdminLayouts,
useCreateLayout,
@@ -71,6 +73,8 @@ import {
export default function LayoutsPage() {
const { t } = useTranslation(["layout", "common", "errors", "sudo"]);
const [searchParams, setSearchParams] = useSearchParams();
+ const me = useMe().data;
+ useLayoutSupervisordSync(me?.layoutId ?? null);
const list = useAdminLayouts();
const interlockingsCatalog = useInterlockingsCatalogue();
const commandStationsCatalog = useCommandStationsCatalogue();
diff --git a/web/src/pages/admin/SystemPage.tsx b/web/src/pages/admin/SystemPage.tsx
new file mode 100644
index 0000000..9a5bdd9
--- /dev/null
+++ b/web/src/pages/admin/SystemPage.tsx
@@ -0,0 +1,478 @@
+import { useCallback, useMemo, useState } from "react";
+import {
+ Alert,
+ Box,
+ Button,
+ Card,
+ CardActionArea,
+ CardContent,
+ Chip,
+ CircularProgress,
+ Container,
+ IconButton,
+ Paper,
+ Stack,
+ Table,
+ TableBody,
+ TableCell,
+ TableContainer,
+ TableHead,
+ TableRow,
+ Tooltip,
+ Typography,
+} from "@mui/material";
+import AnalyticsIcon from "@mui/icons-material/Analytics";
+import ArticleIcon from "@mui/icons-material/Article";
+import DnsIcon from "@mui/icons-material/Dns";
+import PowerSettingsNewIcon from "@mui/icons-material/PowerSettingsNew";
+import RefreshIcon from "@mui/icons-material/Refresh";
+import RestartAltIcon from "@mui/icons-material/RestartAlt";
+import { useQueryClient } from "@tanstack/react-query";
+import { useNavigate } from "react-router-dom";
+import { useTranslation } from "react-i18next";
+
+import { useMe } from "../../api/auth";
+import { ApiError } from "../../api/client";
+import {
+ useCommandStationsCatalogue,
+ useDccBusSupervisordAction,
+ useDccBusSupervisordStatus,
+ type DccBusSupervisordAction,
+} from "../../api/command_stations";
+import { useLayoutSupervisordSync } from "../../api/presence";
+import {
+ useMicroinitInfo,
+ useMicroinitServices,
+ useSystemInfo,
+ useSystemPorts,
+ type SystemShutdownMode,
+} from "../../api/system";
+import DccBusProgramList from "../../components/dcc-bus/DccBusProgramList";
+import SystemPowerDialog from "../../components/SystemPowerDialog";
+import VersionCard from "../../components/VersionCard";
+
+function formatUptime(secs: number): string {
+ if (!Number.isFinite(secs) || secs < 0) return "—";
+ const h = Math.floor(secs / 3600);
+ const m = Math.floor((secs % 3600) / 60);
+ const s = Math.floor(secs % 60);
+ if (h > 0) return `${h}h ${m}m`;
+ if (m > 0) return `${m}m ${s}s`;
+ return `${s}s`;
+}
+
+function ActionTile({
+ icon,
+ label,
+ disabled,
+ disabledReason,
+ onClick,
+ danger,
+}: {
+ icon: React.ReactNode;
+ label: string;
+ disabled?: boolean;
+ disabledReason?: string;
+ onClick: () => void;
+ danger?: boolean;
+}) {
+ const card = (
+
+
+
+
+ {icon}
+
+
+ {label}
+
+
+
+
+ );
+ if (disabled && disabledReason) {
+ return (
+
+ {card}
+
+ );
+ }
+ return card;
+}
+
+function CommandStationDaemons({
+ csId,
+ csName,
+}: {
+ csId: number;
+ csName: string;
+}) {
+ const { t } = useTranslation(["commandStation", "errors", "system"]);
+ const navigate = useNavigate();
+ const status = useDccBusSupervisordStatus(csId);
+ const action = useDccBusSupervisordAction(csId);
+ const [pendingLayoutId, setPendingLayoutId] = useState(null);
+ const [failedLayoutId, setFailedLayoutId] = useState(null);
+ const [actionError, setActionError] = useState(null);
+
+ const formatError = (err: unknown): string => {
+ if (err instanceof ApiError) {
+ if (err.status === 503 || err.code === "service_unavailable") {
+ return t("commandStation:admin.supervisord.unavailable");
+ }
+ return (
+ err.detail ||
+ t(`errors:${err.code}` as const, {
+ defaultValue: t("commandStation:admin.supervisord.actionFailed"),
+ })
+ );
+ }
+ return t("errors:network");
+ };
+
+ const runAction = async (
+ layoutId: number,
+ next: DccBusSupervisordAction,
+ ) => {
+ setActionError(null);
+ setFailedLayoutId(null);
+ setPendingLayoutId(layoutId);
+ try {
+ await action.mutateAsync({ action: next, layoutId });
+ } catch (err) {
+ setFailedLayoutId(layoutId);
+ setActionError(formatError(err));
+ } finally {
+ setPendingLayoutId(null);
+ }
+ };
+
+ return (
+
+
+ {csName}
+
+ void runAction(layoutId, next)}
+ onViewLogs={(layoutId) =>
+ navigate(
+ `/admin/logs?service=${encodeURIComponent(`dcc-bus-${layoutId}-${csId}`)}`,
+ )
+ }
+ />
+
+ );
+}
+
+export default function SystemPage() {
+ const { t } = useTranslation(["system", "common", "version", "commandStation"]);
+ const navigate = useNavigate();
+ const qc = useQueryClient();
+ const me = useMe().data;
+ const layoutId = me?.layoutId ?? null;
+ useLayoutSupervisordSync(layoutId);
+
+ const systemInfo = useSystemInfo();
+ const ports = useSystemPorts();
+ const microInfo = useMicroinitInfo();
+ const microServices = useMicroinitServices();
+ const catalogue = useCommandStationsCatalogue();
+
+ const [powerOpen, setPowerOpen] = useState(false);
+ const [powerMode, setPowerMode] = useState();
+ const [refreshingDcc, setRefreshingDcc] = useState(false);
+
+ const canShutdown = systemInfo.data?.canShutdown === true;
+ const host = window.location.hostname;
+
+ const openPower = (mode: SystemShutdownMode) => {
+ setPowerMode(mode);
+ setPowerOpen(true);
+ };
+
+ const refreshDccBus = useCallback(async () => {
+ if (layoutId == null || layoutId <= 0) return;
+ setRefreshingDcc(true);
+ try {
+ await qc.refetchQueries({
+ queryKey: ["layouts", layoutId, "presence"],
+ });
+ await new Promise((r) => setTimeout(r, 1000));
+ await Promise.all([
+ qc.invalidateQueries({ queryKey: ["admin", "dcc-bus"] }),
+ qc.invalidateQueries({ queryKey: ["admin", "microinit", "services"] }),
+ ]);
+ } finally {
+ setRefreshingDcc(false);
+ }
+ }, [layoutId, qc]);
+
+ const stations = catalogue.data ?? [];
+
+ const serviceRows = useMemo(
+ () => microServices.data ?? [],
+ [microServices.data],
+ );
+
+ return (
+
+
+
+ {t("system:title")}
+
+
+
+ }
+ label={t("system:actions.reboot")}
+ disabled={!canShutdown}
+ disabledReason={t("system:actions.powerUnavailable")}
+ onClick={() => openPower("reboot")}
+ />
+ }
+ label={t("system:actions.poweroff")}
+ disabled={!canShutdown}
+ disabledReason={t("system:actions.powerUnavailable")}
+ danger
+ onClick={() => openPower("poweroff")}
+ />
+ }
+ label={t("system:actions.logs")}
+ onClick={() => navigate("/admin/logs")}
+ />
+ }
+ label={t("system:actions.osPanel")}
+ disabled={!ports.data?.osUI}
+ disabledReason={t("system:actions.portClosed")}
+ onClick={() => window.open(`http://${host}:8090`, "_blank")}
+ />
+ }
+ label={t("system:actions.grafana")}
+ disabled={!ports.data?.grafana}
+ disabledReason={t("system:actions.portClosed")}
+ onClick={() => window.open(`http://${host}:3000`, "_blank")}
+ />
+
+
+
+
+ {t("system:microinit.title")}
+
+ ) : (
+
+ )
+ }
+ disabled={refreshingDcc || layoutId == null}
+ onClick={() => void refreshDccBus()}
+ >
+ {t("system:refreshDccBus")}
+
+
+
+ {microInfo.isError ? (
+ {t("system:microinit.unavailable")}
+ ) : microInfo.isLoading ? (
+
+ ) : microInfo.data ? (
+
+
+
+
+
+
+
+
+
+ {microInfo.data.hostname}
+ {microInfo.data.socket ? ` · ${microInfo.data.socket}` : ""}
+
+
+ ) : null}
+
+
+
+
+
+ {t("system:microinit.colName")}
+ {t("system:microinit.colState")}
+ {t("system:microinit.colPid")}
+ {t("system:microinit.colRestarts")}
+ {t("system:microinit.colEnabled")}
+
+ {t("system:microinit.colLogs")}
+
+
+
+
+ {microServices.isLoading ? (
+
+
+
+
+
+ ) : serviceRows.length === 0 ? (
+
+
+ {t("system:microinit.empty")}
+
+
+ ) : (
+ serviceRows.map((svc) => (
+
+
+
+ {svc.name}
+
+
+
+
+
+ {svc.pid ?? "—"}
+ {svc.restarts}
+
+ {svc.enabled ? "✓" : "—"}
+
+
+
+ navigate(
+ `/admin/logs?service=${encodeURIComponent(svc.name)}`,
+ )
+ }
+ >
+
+
+
+
+ ))
+ )}
+
+
+
+
+
+
+ {t("system:dccBusSection")}
+ {catalogue.isLoading ? (
+
+ ) : stations.length === 0 ? (
+ {t("system:dccBusEmpty")}
+ ) : (
+ stations.map((cs) => (
+
+ ))
+ )}
+
+
+
+ {t("version:title")}
+
+
+
+
+ {
+ setPowerOpen(false);
+ setPowerMode(undefined);
+ }}
+ presetMode={powerMode}
+ />
+
+ );
+}