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 ( + + {t("help:dialog.title")} + + + + + + + + { + if (checked) onDisableRoute(); + }} + /> + } + label={t("help:dialog.disableForRoute")} + /> + { + if (checked) onDisableGlobal(); + }} + /> + } + label={t("help:dialog.disableGlobal")} + /> + + + + + ); +} 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 }} /> - - {contentError && ( - {contentError} - )} + + - {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")} + + + + {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} + /> +
+ ); +}