diff --git a/server/config.go b/server/config.go new file mode 100644 index 0000000..d6718bf --- /dev/null +++ b/server/config.go @@ -0,0 +1,61 @@ +package main + +import ( + "fmt" + "gopkg.in/yaml.v3" + "os" +) + +type Config struct { + Links []Link `yaml:"links"` + Monitor []Item `yaml:"monitor"` + Exec []Item `yaml:"exec"` +} + +func loadConfig(path string) (*Config, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + var cfg Config + dec := yaml.NewDecoder(f) + dec.KnownFields(true) + if err := dec.Decode(&cfg); err != nil { + return nil, fmt.Errorf("parse %s: %w", path, err) + } + seen := map[string]bool{} // item names are global keys (monVals, RunScript) + for _, l := range cfg.Links { + if seen[l.Name] { + return nil, fmt.Errorf("duplicate name %q", l.Name) + } + seen[l.Name] = true + } + for _, list := range [][]Item{cfg.Monitor, cfg.Exec} { + for _, it := range list { + if seen[it.Name] { + return nil, fmt.Errorf("duplicate name %q", it.Name) + } + seen[it.Name] = true + } + } + for _, it := range cfg.Monitor { + if it.Refresh <= 0 { + return nil, fmt.Errorf("monitor %q: refresh must be > 0", it.Name) + } + if it.Data != "text" && it.Data != "percentage" { + return nil, fmt.Errorf("monitor %q: data must be text|percentage", it.Name) + } + } + for _, it := range cfg.Exec { + if len(it.ArgTypes) != it.Args { + return nil, fmt.Errorf("exec %q: arg_types must have args entries", it.Name) + } + for _, t := range it.ArgTypes { + if t != "text" && t != "number" { + return nil, fmt.Errorf("exec %q: arg type must be text|number", it.Name) + } + } + } + return &cfg, nil +} diff --git a/server/docs.go b/server/docs.go new file mode 100644 index 0000000..2e71c5e --- /dev/null +++ b/server/docs.go @@ -0,0 +1,78 @@ +package main + +import ( + "connectrpc.com/connect" + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "log" + "os" + "path/filepath" + homelabv1 "pwa-homelab-mon/gen/homelab/v1" + "strings" +) + +// --- docs --- + +func docHash(b []byte) string { + h := sha256.Sum256(b) + return hex.EncodeToString(h[:]) +} + +// docPath resolves a doc name safely: plain file name only, no separators, always inside docsDir. +func (s *server) docPath(name string) (string, error) { + if filepath.Base(name) != name || filepath.Ext(name) != ".md" { + return "", fmt.Errorf("bad doc name %q", name) + } + p := filepath.Join(s.docsDir, name) + if filepath.Dir(p) != s.docsDir { + return "", fmt.Errorf("bad doc name %q", name) + } + return p, nil +} + +func (s *server) ListDocs(context.Context, *connect.Request[homelabv1.ListDocsRequest]) (*connect.Response[homelabv1.ListDocsResponse], error) { + resp := &homelabv1.ListDocsResponse{} + entries, err := os.ReadDir(s.docsDir) + if err != nil { + return connect.NewResponse(resp), nil // no docs dir = no docs + } + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".md") { + continue + } + b, err := os.ReadFile(filepath.Join(s.docsDir, e.Name())) + if err != nil { + continue + } + resp.Docs = append(resp.Docs, &homelabv1.DocMeta{Name: e.Name(), Hash: docHash(b)}) + } + return connect.NewResponse(resp), nil +} + +func (s *server) GetDoc(_ context.Context, req *connect.Request[homelabv1.GetDocRequest]) (*connect.Response[homelabv1.GetDocResponse], error) { + p, err := s.docPath(req.Msg.GetName()) + if err != nil { + return nil, connect.NewError(connect.CodeInvalidArgument, err) + } + b, err := os.ReadFile(p) + if err != nil { + return nil, connect.NewError(connect.CodeNotFound, err) + } + return connect.NewResponse(&homelabv1.GetDocResponse{Content: string(b)}), nil +} + +func (s *server) SaveDoc(_ context.Context, req *connect.Request[homelabv1.SaveDocRequest]) (*connect.Response[homelabv1.SaveDocResponse], error) { + p, err := s.docPath(req.Msg.GetName()) + if err != nil { + return nil, connect.NewError(connect.CodeInvalidArgument, err) + } + content := []byte(req.Msg.GetContent()) + // ponytail: client authority — last write wins, no base-hash check; add optimistic locking if concurrent edits appear + if err := os.WriteFile(p, content, 0o644); err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + log.Printf("doc saved: %s (%d bytes)", filepath.Base(p), len(content)) + return connect.NewResponse(&homelabv1.SaveDocResponse{Hash: docHash(content)}), nil +} diff --git a/server/main.go b/server/main.go index 742762b..f03adf9 100644 --- a/server/main.go +++ b/server/main.go @@ -2,24 +2,18 @@ package main import ( "context" - "crypto/sha256" - "encoding/hex" "fmt" "log" "net/http" "os" "os/exec" "path/filepath" - "regexp" - "sort" "strings" "sync" "time" - "connectrpc.com/connect" "github.com/labstack/echo/v4" "github.com/labstack/echo/v4/middleware" - "gopkg.in/yaml.v3" homelabv1 "pwa-homelab-mon/gen/homelab/v1" "pwa-homelab-mon/gen/homelab/v1/homelabv1connect" ) @@ -40,60 +34,6 @@ type Link struct { URL string `yaml:"url"` } -type Config struct { - Links []Link `yaml:"links"` - Monitor []Item `yaml:"monitor"` - Exec []Item `yaml:"exec"` -} - -func loadConfig(path string) (*Config, error) { - f, err := os.Open(path) - if err != nil { - return nil, err - } - defer f.Close() - var cfg Config - dec := yaml.NewDecoder(f) - dec.KnownFields(true) - if err := dec.Decode(&cfg); err != nil { - return nil, fmt.Errorf("parse %s: %w", path, err) - } - seen := map[string]bool{} // item names are global keys (monVals, RunScript) - for _, l := range cfg.Links { - if seen[l.Name] { - return nil, fmt.Errorf("duplicate name %q", l.Name) - } - seen[l.Name] = true - } - for _, list := range [][]Item{cfg.Monitor, cfg.Exec} { - for _, it := range list { - if seen[it.Name] { - return nil, fmt.Errorf("duplicate name %q", it.Name) - } - seen[it.Name] = true - } - } - for _, it := range cfg.Monitor { - if it.Refresh <= 0 { - return nil, fmt.Errorf("monitor %q: refresh must be > 0", it.Name) - } - if it.Data != "text" && it.Data != "percentage" { - return nil, fmt.Errorf("monitor %q: data must be text|percentage", it.Name) - } - } - for _, it := range cfg.Exec { - if len(it.ArgTypes) != it.Args { - return nil, fmt.Errorf("exec %q: arg_types must have args entries", it.Name) - } - for _, t := range it.ArgTypes { - if t != "text" && t != "number" { - return nil, fmt.Errorf("exec %q: arg type must be text|number", it.Name) - } - } - } - return &cfg, nil -} - // --- service --- type server struct { @@ -139,480 +79,6 @@ func (s *server) startMonitors() { } } -// --- docs --- - -func docHash(b []byte) string { - h := sha256.Sum256(b) - return hex.EncodeToString(h[:]) -} - -// docPath resolves a doc name safely: plain file name only, no separators, always inside docsDir. -func (s *server) docPath(name string) (string, error) { - if filepath.Base(name) != name || filepath.Ext(name) != ".md" { - return "", fmt.Errorf("bad doc name %q", name) - } - p := filepath.Join(s.docsDir, name) - if filepath.Dir(p) != s.docsDir { - return "", fmt.Errorf("bad doc name %q", name) - } - return p, nil -} - -func (s *server) ListDocs(context.Context, *connect.Request[homelabv1.ListDocsRequest]) (*connect.Response[homelabv1.ListDocsResponse], error) { - resp := &homelabv1.ListDocsResponse{} - entries, err := os.ReadDir(s.docsDir) - if err != nil { - return connect.NewResponse(resp), nil // no docs dir = no docs - } - for _, e := range entries { - if e.IsDir() || !strings.HasSuffix(e.Name(), ".md") { - continue - } - b, err := os.ReadFile(filepath.Join(s.docsDir, e.Name())) - if err != nil { - continue - } - resp.Docs = append(resp.Docs, &homelabv1.DocMeta{Name: e.Name(), Hash: docHash(b)}) - } - return connect.NewResponse(resp), nil -} - -func (s *server) GetDoc(_ context.Context, req *connect.Request[homelabv1.GetDocRequest]) (*connect.Response[homelabv1.GetDocResponse], error) { - p, err := s.docPath(req.Msg.GetName()) - if err != nil { - return nil, connect.NewError(connect.CodeInvalidArgument, err) - } - b, err := os.ReadFile(p) - if err != nil { - return nil, connect.NewError(connect.CodeNotFound, err) - } - return connect.NewResponse(&homelabv1.GetDocResponse{Content: string(b)}), nil -} - -func (s *server) SaveDoc(_ context.Context, req *connect.Request[homelabv1.SaveDocRequest]) (*connect.Response[homelabv1.SaveDocResponse], error) { - p, err := s.docPath(req.Msg.GetName()) - if err != nil { - return nil, connect.NewError(connect.CodeInvalidArgument, err) - } - content := []byte(req.Msg.GetContent()) - // ponytail: client authority — last write wins, no base-hash check; add optimistic locking if concurrent edits appear - if err := os.WriteFile(p, content, 0o644); err != nil { - return nil, connect.NewError(connect.CodeInternal, err) - } - log.Printf("doc saved: %s (%d bytes)", filepath.Base(p), len(content)) - return connect.NewResponse(&homelabv1.SaveDocResponse{Hash: docHash(content)}), nil -} - -// --- services: systemd user units + docker containers, one flat list --- - -// Service names double as file/unit/container selectors — no separators, no traversal. -var svcNameRe = regexp.MustCompile(`^[a-z0-9_.-]+$`) - -const svcTimeout = 8 * time.Second - -func (s *server) services(ctx context.Context) []*homelabv1.Service { - if s.mockSvcs != nil { - return s.mockSvcs - } - return append(s.systemdServices(ctx), s.dockerServices(ctx)...) -} - -// --- mock mode (MOCK=1): fixture cards for frontend dev on machines without systemd/docker --- - -func mockServices() []*homelabv1.Service { - return []*homelabv1.Service{ - {Kind: homelabv1.ServiceKind_SYSTEMD, Name: "hub-api", State: "active", Substate: "running", LoadState: "loaded", UnitFileState: "enabled"}, - {Kind: homelabv1.ServiceKind_SYSTEMD, Name: "rathole", State: "active", Substate: "running", LoadState: "loaded", UnitFileState: "enabled"}, - {Kind: homelabv1.ServiceKind_SYSTEMD, Name: "hermes-dashboard", State: "inactive", Substate: "dead", LoadState: "loaded", UnitFileState: "disabled"}, - {Kind: homelabv1.ServiceKind_SYSTEMD, Name: "olivetin", State: "failed", Substate: "exit-code", LoadState: "loaded", UnitFileState: "enabled"}, - {Kind: homelabv1.ServiceKind_SYSTEMD, Name: "ntfy", LoadState: "not-found"}, - {Kind: homelabv1.ServiceKind_DOCKER, Name: "affine", State: "active", Substate: "3/3", - Containers: []string{"affine-self-hosted-1", "affine-migration-1", "affine-redis-1"}}, - {Kind: homelabv1.ServiceKind_DOCKER, Name: "itsaplan", State: "partial", Substate: "1/2", - Containers: []string{"itsaplan-web-1", "itsaplan-worker-1"}}, - {Kind: homelabv1.ServiceKind_DOCKER, Name: "gitea", State: "inactive", Substate: "0/1", Containers: []string{"gitea"}}, - } -} - -// mockAct flips fixture state so the UI reacts to the buttons in mock mode. -func (s *server) mockAct(name string, kind homelabv1.ServiceKind, op string) string { - for _, svc := range s.mockSvcs { - if svc.Name != name || svc.Kind != kind { - continue - } - switch op { - case "start", "restart": - svc.State, svc.Substate = "active", "running" - if kind == homelabv1.ServiceKind_DOCKER { - n := len(svc.Containers) - svc.Substate = fmt.Sprintf("%d/%d", n, n) - } - case "stop": - svc.State, svc.Substate = "inactive", "dead" - if kind == homelabv1.ServiceKind_DOCKER { - svc.Substate = fmt.Sprintf("0/%d", len(svc.Containers)) - } - } - return "mock: " + op + " " + name - } - return "mock: no card " + name -} - -func (s *server) mockInfo(name string, kind homelabv1.ServiceKind) string { - if kind == homelabv1.ServiceKind_DOCKER { - return fmt.Sprintf("NAMES STATUS\n%s-1 Up 2 days\n%s-2 Exited (0) 5 hours ago", name, name) - } - return fmt.Sprintf("● %s.service - hub API\n Loaded: loaded (~/srv/%s.service; enabled)\n Active: %s (%s) since Mon 2026-08-31 10:00:00 MSK; 2 days ago\n Main PID: 4242 (hub-api)\n Memory: 12.3M", - name, name, mockField(s.mockSvcs, name, "state"), mockField(s.mockSvcs, name, "substate")) -} - -// mockField reads a fixture field by name for the info template ("" falls back inside mockField callers). -func mockField(svcs []*homelabv1.Service, name, field string) string { - for _, svc := range svcs { - if svc.Name != name { - continue - } - switch field { - case "state": - if svc.State != "" { - return svc.State - } - case "substate": - if svc.Substate != "" { - return svc.Substate - } - } - } - if field == "state" { - return "active" - } - return "running" -} - -// logProbeChange logs poll-path probe failures once per change: the poll repeats every 3s per client, -// and the same error every tick is noise. msg "" = probe healthy, logs a single recovery line. -func (s *server) logProbeChange(key, msg string) { - prev, loaded := s.probeErr.LoadOrStore(key, msg) - if !loaded { - if msg != "" { - log.Printf("probe %s: %s", key, msg) - } - return - } - if prev.(string) == msg { - return - } - s.probeErr.Store(key, msg) - if msg == "" { - log.Printf("probe %s: recovered", key) - } else { - log.Printf("probe %s: %s", key, msg) - } -} - -func (s *server) units() ([]string, error) { - entries, err := os.ReadDir(s.srvDir) - if err != nil { - return nil, err - } - units := make([]string, 0, len(entries)) - for _, e := range entries { - if n := strings.TrimSuffix(e.Name(), ".service"); n != e.Name() && svcNameRe.MatchString(n) { - units = append(units, n) - } - } - sort.Strings(units) - return units, nil -} - -func (s *server) systemdServices(ctx context.Context) []*homelabv1.Service { - units, err := s.units() - if err != nil { - s.logProbeChange("units", err.Error()) - return []*homelabv1.Service{{Kind: homelabv1.ServiceKind_SYSTEMD, Name: s.srvDir, Error: err.Error()}} - } - s.logProbeChange("units", "") - svcs := make([]*homelabv1.Service, 0, len(units)) - for _, u := range units { - svcs = append(svcs, s.systemdStatus(ctx, u)) - } - return svcs -} - -// ponytail: one systemctl per unit (~26 per 3s poll, ~10ms each). Batch into a single -// `systemctl show u1 u2 ... -p ...` if the poll ever shows up in a slow trace. -func (s *server) systemdStatus(ctx context.Context, unit string) *homelabv1.Service { - svc := &homelabv1.Service{Kind: homelabv1.ServiceKind_SYSTEMD, Name: unit} - ctx, cancel := context.WithTimeout(ctx, svcTimeout) - defer cancel() - // show is the machine-readable probe (always exit 0, unlike status). LoadState=not-found means the user - // disabled the unit on purpose — a state, not an error. - out, err := exec.CommandContext(ctx, "systemctl", "--user", "show", unit+".service", - "-p", "ActiveState,SubState,LoadState,UnitFileState").Output() - // systemctl prints properties in its own fixed table order (not the -p order), so parse by key - props := map[string]string{} - for _, line := range strings.Split(string(out), "\n") { - if k, v, ok := strings.Cut(strings.TrimSpace(line), "="); ok { - props[k] = v - } - } - svc.State, svc.Substate = props["ActiveState"], props["SubState"] - svc.LoadState, svc.UnitFileState = props["LoadState"], props["UnitFileState"] - if svc.LoadState == "" { - svc.LoadState = "not-found" - } - if err != nil { - svc.Error = err.Error() - s.logProbeChange("systemctl/"+unit, svc.Error) - } else { - s.logProbeChange("systemctl/"+unit, "") - } - return svc -} - -// dockerC is one container as seen by `docker inspect`. -type dockerC struct { - name, project, status, policy string -} - -func dockerOut(ctx context.Context, args ...string) (string, error) { - ctx, cancel := context.WithTimeout(ctx, svcTimeout) - defer cancel() - out, err := exec.CommandContext(ctx, "docker", args...).Output() - return string(out), err -} - -func (s *server) dockerContainers(ctx context.Context) ([]dockerC, error) { - ids, err := dockerOut(ctx, "ps", "-aq") - if err != nil { - return nil, err - } - if strings.TrimSpace(ids) == "" { - return nil, nil - } - out, err := dockerOut(ctx, append([]string{"inspect", "-f", - `{{index .Config.Labels "com.docker.compose.project"}}|{{.Name}}|{{.State.Status}}|{{.HostConfig.RestartPolicy.Name}}`}, - strings.Fields(ids)...)...) - if err != nil { - return nil, err - } - var cs []dockerC - for _, line := range strings.Split(strings.TrimSpace(out), "\n") { - f := strings.Split(line, "|") - if len(f) != 4 { - continue - } - cs = append(cs, dockerC{project: f[0], name: strings.TrimPrefix(f[1], "/"), status: f[2], policy: f[3]}) - } - sort.Slice(cs, func(i, j int) bool { return cs[i].name < cs[j].name }) - return cs, nil -} - -// working drops one-shot helpers (restart policy no + already exited: *-init, migrations). They belong to -// their project's card but must not drag its aggregate down, and must not be re-run by the start button. -func working(cs []dockerC) []dockerC { - out := make([]dockerC, 0, len(cs)) - for _, c := range cs { - if c.policy == "no" && c.status != "running" { - continue - } - out = append(out, c) - } - return out -} - -func (s *server) dockerServices(ctx context.Context) []*homelabv1.Service { - cs, err := s.dockerContainers(ctx) - if err != nil { - s.logProbeChange("docker", err.Error()) - return []*homelabv1.Service{{Kind: homelabv1.ServiceKind_DOCKER, Name: "docker", Error: err.Error()}} - } - s.logProbeChange("docker", "") - groups := map[string][]dockerC{} - var keys []string - for _, c := range cs { - key := c.project - if key == "" { - key = c.name // docker run without a compose label -> its own card - } - if _, ok := groups[key]; !ok { - keys = append(keys, key) - } - groups[key] = append(groups[key], c) - } - sort.Strings(keys) - svcs := make([]*homelabv1.Service, 0, len(keys)) - for _, k := range keys { - if svc := dockerCard(k, working(groups[k])); svc != nil { - svcs = append(svcs, svc) - } - } - return svcs -} - -// dockerCard aggregates a project: all running -> active, none -> inactive, else partial (up/total). -func dockerCard(name string, cs []dockerC) *homelabv1.Service { - if len(cs) == 0 { - return nil - } - up := 0 - names := make([]string, 0, len(cs)) - for _, c := range cs { - if c.status == "running" { - up++ - } - names = append(names, c.name) - } - state := "partial" - switch { - case up == len(cs): - state = "active" - case up == 0: - state = "inactive" - } - return &homelabv1.Service{Kind: homelabv1.ServiceKind_DOCKER, Name: name, State: state, - Substate: fmt.Sprintf("%d/%d", up, len(cs)), Containers: names} -} - -// --- content RPC --- - -func (s *server) ListContent(ctx context.Context, _ *connect.Request[homelabv1.ListContentRequest]) (*connect.Response[homelabv1.ListContentResponse], error) { - resp := &homelabv1.ListContentResponse{} - for _, l := range s.cfg.Links { - resp.Links = append(resp.Links, &homelabv1.Link{Name: l.Name, Url: l.URL}) - } - for i := range s.cfg.Monitor { - it := &s.cfg.Monitor[i] - val, _ := s.monVals.Load(it.Name) - v, _ := val.(string) - resp.Monitors = append(resp.Monitors, &homelabv1.Item{Name: it.Name, Data: it.Data, Value: v}) - } - for i := range s.cfg.Exec { - it := &s.cfg.Exec[i] - resp.Exec = append(resp.Exec, &homelabv1.Item{Name: it.Name, Args: int32(it.Args), ArgTypes: it.ArgTypes}) - } - resp.Services = s.services(ctx) - return connect.NewResponse(resp), nil -} - -// ServiceAction runs a fixed systemctl/docker command; only `name` is user input and it is regex-checked, -// never interpolated into a shell string. -func (s *server) ServiceAction(ctx context.Context, req *connect.Request[homelabv1.ServiceActionRequest]) (*connect.Response[homelabv1.ServiceActionResponse], error) { - name, kind := req.Msg.GetName(), req.Msg.GetKind() - if !svcNameRe.MatchString(name) { - return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("bad service name %q", name)) - } - var args []string - switch req.Msg.GetOp() { - case homelabv1.ServiceOp_START: - args = []string{"start"} - case homelabv1.ServiceOp_STOP: - args = []string{"stop"} - case homelabv1.ServiceOp_RESTART: - args = []string{"restart"} - default: - return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("bad op for %q", name)) - } - - if s.mockSvcs != nil { - return connect.NewResponse(&homelabv1.ServiceActionResponse{Output: s.mockAct(name, kind, args[0])}), nil - } - - ctx, cancel := context.WithTimeout(ctx, svcTimeout) - defer cancel() - - var cmd *exec.Cmd - if kind == homelabv1.ServiceKind_DOCKER { - targets, err := s.dockerTargets(ctx, name, args[0]) - if err != nil { - return nil, connect.NewError(connect.CodeInternal, err) - } - if len(targets) == 0 { - return connect.NewResponse(&homelabv1.ServiceActionResponse{Output: "no containers to " + args[0]}), nil - } - cmd = exec.CommandContext(ctx, "docker", append([]string{args[0]}, targets...)...) - } else { - cmd = exec.CommandContext(ctx, "systemctl", "--user", args[0], name+".service") - } - out, err := cmd.CombinedOutput() - if err != nil { - log.Printf("service %s/%s %s: ERROR %v: %s", kind, name, args[0], err, out) - return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("%v: %s", err, out)) - } - log.Printf("service %s/%s %s -> %s", kind, name, args[0], strings.TrimSpace(string(out))) - return connect.NewResponse(&homelabv1.ServiceActionResponse{Output: strings.TrimSpace(string(out))}), nil -} - -// dockerTargets resolves a card name (compose project, or bare container name) to the containers an op applies to. -func (s *server) dockerTargets(ctx context.Context, name, op string) ([]string, error) { - cs, err := s.dockerContainers(ctx) - if err != nil { - return nil, err - } - var targets []string - for _, c := range working(cs) { - if c.project != name && c.name != name { - continue - } - if running := c.status == "running"; (op == "start" && !running) || (op != "start" && running) { - targets = append(targets, c.name) - } - } - return targets, nil -} - -// ServiceInfo returns raw status output for the modal: `systemctl status` / `docker ps -a` for the card. -func (s *server) ServiceInfo(ctx context.Context, req *connect.Request[homelabv1.ServiceInfoRequest]) (*connect.Response[homelabv1.ServiceInfoResponse], error) { - name, kind := req.Msg.GetName(), req.Msg.GetKind() - if !svcNameRe.MatchString(name) { - return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("bad service name %q", name)) - } - ctx, cancel := context.WithTimeout(ctx, svcTimeout) - defer cancel() - - if s.mockSvcs != nil { - return connect.NewResponse(&homelabv1.ServiceInfoResponse{Output: s.mockInfo(name, kind)}), nil - } - - var out string - var err error - if kind == homelabv1.ServiceKind_DOCKER { - // compose project first, bare container (no label) as fallback - out, err = dockerOut(ctx, "ps", "-a", "--filter", "label=com.docker.compose.project="+name) - if strings.TrimSpace(out) == "" { - out, err = dockerOut(ctx, "ps", "-a", "--filter", "name="+name) - } - } else { - // status exits non-zero for inactive units (code 3) — that is valid output, not an error - c := exec.CommandContext(ctx, "systemctl", "--user", "status", name+".service", "--no-pager", "-n", "0") - b, cerr := c.CombinedOutput() - out, err = string(b), cerr - } - if err != nil && strings.TrimSpace(out) == "" { - log.Printf("service info %s/%s: ERROR %v", kind, name, err) - return nil, connect.NewError(connect.CodeInternal, err) - } - log.Printf("service info %s/%s ok", kind, name) - return connect.NewResponse(&homelabv1.ServiceInfoResponse{Output: strings.TrimRight(out, "\n")}), nil -} - -func (s *server) RunScript(_ context.Context, req *connect.Request[homelabv1.RunScriptRequest]) (*connect.Response[homelabv1.RunScriptResponse], error) { - it, ok := s.exec[req.Msg.GetName()] - if !ok { - return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("no exec item %q", req.Msg.GetName())) - } - if len(req.Msg.GetArgs()) != it.Args { - return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("%q takes %d args, got %d", it.Name, it.Args, len(req.Msg.GetArgs()))) - } - out, err := runScript(it, req.Msg.GetArgs()...) - if err != nil { - log.Printf("script %s args=%v: ERROR %v", it.Name, req.Msg.GetArgs(), err) - return nil, connect.NewError(connect.CodeInternal, err) - } - log.Printf("script %s args=%v -> %s", it.Name, req.Msg.GetArgs(), strings.TrimSpace(out)) - return connect.NewResponse(&homelabv1.RunScriptResponse{Output: out}), nil -} - // --- main --- func main() { diff --git a/server/mock.go b/server/mock.go new file mode 100644 index 0000000..c7ede9e --- /dev/null +++ b/server/mock.go @@ -0,0 +1,78 @@ +package main + +import ( + "fmt" + homelabv1 "pwa-homelab-mon/gen/homelab/v1" +) + +// --- mock mode (MOCK=1): fixture cards for frontend dev on machines without systemd/docker --- + +func mockServices() []*homelabv1.Service { + return []*homelabv1.Service{ + {Kind: homelabv1.ServiceKind_SYSTEMD, Name: "hub-api", State: "active", Substate: "running", LoadState: "loaded", UnitFileState: "enabled"}, + {Kind: homelabv1.ServiceKind_SYSTEMD, Name: "rathole", State: "active", Substate: "running", LoadState: "loaded", UnitFileState: "enabled"}, + {Kind: homelabv1.ServiceKind_SYSTEMD, Name: "hermes-dashboard", State: "inactive", Substate: "dead", LoadState: "loaded", UnitFileState: "disabled"}, + {Kind: homelabv1.ServiceKind_SYSTEMD, Name: "olivetin", State: "failed", Substate: "exit-code", LoadState: "loaded", UnitFileState: "enabled"}, + {Kind: homelabv1.ServiceKind_SYSTEMD, Name: "ntfy", LoadState: "not-found"}, + {Kind: homelabv1.ServiceKind_DOCKER, Name: "affine", State: "active", Substate: "3/3", + Containers: []string{"affine-self-hosted-1", "affine-migration-1", "affine-redis-1"}}, + {Kind: homelabv1.ServiceKind_DOCKER, Name: "itsaplan", State: "partial", Substate: "1/2", + Containers: []string{"itsaplan-web-1", "itsaplan-worker-1"}}, + {Kind: homelabv1.ServiceKind_DOCKER, Name: "gitea", State: "inactive", Substate: "0/1", Containers: []string{"gitea"}}, + } +} + +// mockAct flips fixture state so the UI reacts to the buttons in mock mode. +func (s *server) mockAct(name string, kind homelabv1.ServiceKind, op string) string { + for _, svc := range s.mockSvcs { + if svc.Name != name || svc.Kind != kind { + continue + } + switch op { + case "start", "restart": + svc.State, svc.Substate = "active", "running" + if kind == homelabv1.ServiceKind_DOCKER { + n := len(svc.Containers) + svc.Substate = fmt.Sprintf("%d/%d", n, n) + } + case "stop": + svc.State, svc.Substate = "inactive", "dead" + if kind == homelabv1.ServiceKind_DOCKER { + svc.Substate = fmt.Sprintf("0/%d", len(svc.Containers)) + } + } + return "mock: " + op + " " + name + } + return "mock: no card " + name +} + +func (s *server) mockInfo(name string, kind homelabv1.ServiceKind) string { + if kind == homelabv1.ServiceKind_DOCKER { + return fmt.Sprintf("NAMES STATUS\n%s-1 Up 2 days\n%s-2 Exited (0) 5 hours ago", name, name) + } + return fmt.Sprintf("● %s.service - hub API\n Loaded: loaded (~/srv/%s.service; enabled)\n Active: %s (%s) since Mon 2026-08-31 10:00:00 MSK; 2 days ago\n Main PID: 4242 (hub-api)\n Memory: 12.3M", + name, name, mockField(s.mockSvcs, name, "state"), mockField(s.mockSvcs, name, "substate")) +} + +// mockField reads a fixture field by name for the info template ("" falls back inside mockField callers). +func mockField(svcs []*homelabv1.Service, name, field string) string { + for _, svc := range svcs { + if svc.Name != name { + continue + } + switch field { + case "state": + if svc.State != "" { + return svc.State + } + case "substate": + if svc.Substate != "" { + return svc.Substate + } + } + } + if field == "state" { + return "active" + } + return "running" +} diff --git a/server/rpc.go b/server/rpc.go new file mode 100644 index 0000000..a2e4ff8 --- /dev/null +++ b/server/rpc.go @@ -0,0 +1,48 @@ +package main + +import ( + "connectrpc.com/connect" + "context" + "fmt" + "log" + homelabv1 "pwa-homelab-mon/gen/homelab/v1" + "strings" +) + +// --- content RPC --- + +func (s *server) ListContent(ctx context.Context, _ *connect.Request[homelabv1.ListContentRequest]) (*connect.Response[homelabv1.ListContentResponse], error) { + resp := &homelabv1.ListContentResponse{} + for _, l := range s.cfg.Links { + resp.Links = append(resp.Links, &homelabv1.Link{Name: l.Name, Url: l.URL}) + } + for i := range s.cfg.Monitor { + it := &s.cfg.Monitor[i] + val, _ := s.monVals.Load(it.Name) + v, _ := val.(string) + resp.Monitors = append(resp.Monitors, &homelabv1.Item{Name: it.Name, Data: it.Data, Value: v}) + } + for i := range s.cfg.Exec { + it := &s.cfg.Exec[i] + resp.Exec = append(resp.Exec, &homelabv1.Item{Name: it.Name, Args: int32(it.Args), ArgTypes: it.ArgTypes}) + } + resp.Services = s.services(ctx) + return connect.NewResponse(resp), nil +} + +func (s *server) RunScript(_ context.Context, req *connect.Request[homelabv1.RunScriptRequest]) (*connect.Response[homelabv1.RunScriptResponse], error) { + it, ok := s.exec[req.Msg.GetName()] + if !ok { + return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("no exec item %q", req.Msg.GetName())) + } + if len(req.Msg.GetArgs()) != it.Args { + return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("%q takes %d args, got %d", it.Name, it.Args, len(req.Msg.GetArgs()))) + } + out, err := runScript(it, req.Msg.GetArgs()...) + if err != nil { + log.Printf("script %s args=%v: ERROR %v", it.Name, req.Msg.GetArgs(), err) + return nil, connect.NewError(connect.CodeInternal, err) + } + log.Printf("script %s args=%v -> %s", it.Name, req.Msg.GetArgs(), strings.TrimSpace(out)) + return connect.NewResponse(&homelabv1.RunScriptResponse{Output: out}), nil +} diff --git a/server/services.go b/server/services.go new file mode 100644 index 0000000..69a9cc5 --- /dev/null +++ b/server/services.go @@ -0,0 +1,315 @@ +package main + +import ( + "connectrpc.com/connect" + "context" + "fmt" + "log" + "os" + "os/exec" + homelabv1 "pwa-homelab-mon/gen/homelab/v1" + "regexp" + "sort" + "strings" + "time" +) + +// dockerC is one container as seen by `docker inspect`. +type dockerC struct { + name, project, status, policy string +} + +// --- services: systemd user units + docker containers, one flat list --- + +// Service names double as file/unit/container selectors — no separators, no traversal. +var svcNameRe = regexp.MustCompile(`^[a-z0-9_.-]+$`) + +const svcTimeout = 8 * time.Second + +func (s *server) services(ctx context.Context) []*homelabv1.Service { + if s.mockSvcs != nil { + return s.mockSvcs + } + return append(s.systemdServices(ctx), s.dockerServices(ctx)...) +} + +// logProbeChange logs poll-path probe failures once per change: the poll repeats every 3s per client, +// and the same error every tick is noise. msg "" = probe healthy, logs a single recovery line. +func (s *server) logProbeChange(key, msg string) { + prev, loaded := s.probeErr.LoadOrStore(key, msg) + if !loaded { + if msg != "" { + log.Printf("probe %s: %s", key, msg) + } + return + } + if prev.(string) == msg { + return + } + s.probeErr.Store(key, msg) + if msg == "" { + log.Printf("probe %s: recovered", key) + } else { + log.Printf("probe %s: %s", key, msg) + } +} + +func dockerOut(ctx context.Context, args ...string) (string, error) { + ctx, cancel := context.WithTimeout(ctx, svcTimeout) + defer cancel() + out, err := exec.CommandContext(ctx, "docker", args...).Output() + return string(out), err +} + +func (s *server) dockerContainers(ctx context.Context) ([]dockerC, error) { + ids, err := dockerOut(ctx, "ps", "-aq") + if err != nil { + return nil, err + } + if strings.TrimSpace(ids) == "" { + return nil, nil + } + out, err := dockerOut(ctx, append([]string{"inspect", "-f", + `{{index .Config.Labels "com.docker.compose.project"}}|{{.Name}}|{{.State.Status}}|{{.HostConfig.RestartPolicy.Name}}`}, + strings.Fields(ids)...)...) + if err != nil { + return nil, err + } + var cs []dockerC + for _, line := range strings.Split(strings.TrimSpace(out), "\n") { + f := strings.Split(line, "|") + if len(f) != 4 { + continue + } + cs = append(cs, dockerC{project: f[0], name: strings.TrimPrefix(f[1], "/"), status: f[2], policy: f[3]}) + } + sort.Slice(cs, func(i, j int) bool { return cs[i].name < cs[j].name }) + return cs, nil +} + +// working drops one-shot helpers (restart policy no + already exited: *-init, migrations). They belong to +// their project's card but must not drag its aggregate down, and must not be re-run by the start button. +func working(cs []dockerC) []dockerC { + out := make([]dockerC, 0, len(cs)) + for _, c := range cs { + if c.policy == "no" && c.status != "running" { + continue + } + out = append(out, c) + } + return out +} + +func (s *server) dockerServices(ctx context.Context) []*homelabv1.Service { + cs, err := s.dockerContainers(ctx) + if err != nil { + s.logProbeChange("docker", err.Error()) + return []*homelabv1.Service{{Kind: homelabv1.ServiceKind_DOCKER, Name: "docker", Error: err.Error()}} + } + s.logProbeChange("docker", "") + groups := map[string][]dockerC{} + var keys []string + for _, c := range cs { + key := c.project + if key == "" { + key = c.name // docker run without a compose label -> its own card + } + if _, ok := groups[key]; !ok { + keys = append(keys, key) + } + groups[key] = append(groups[key], c) + } + sort.Strings(keys) + svcs := make([]*homelabv1.Service, 0, len(keys)) + for _, k := range keys { + if svc := dockerCard(k, working(groups[k])); svc != nil { + svcs = append(svcs, svc) + } + } + return svcs +} + +// dockerCard aggregates a project: all running -> active, none -> inactive, else partial (up/total). +func dockerCard(name string, cs []dockerC) *homelabv1.Service { + if len(cs) == 0 { + return nil + } + up := 0 + names := make([]string, 0, len(cs)) + for _, c := range cs { + if c.status == "running" { + up++ + } + names = append(names, c.name) + } + state := "partial" + switch { + case up == len(cs): + state = "active" + case up == 0: + state = "inactive" + } + return &homelabv1.Service{Kind: homelabv1.ServiceKind_DOCKER, Name: name, State: state, + Substate: fmt.Sprintf("%d/%d", up, len(cs)), Containers: names} +} + +func (s *server) units() ([]string, error) { + entries, err := os.ReadDir(s.srvDir) + if err != nil { + return nil, err + } + units := make([]string, 0, len(entries)) + for _, e := range entries { + if n := strings.TrimSuffix(e.Name(), ".service"); n != e.Name() && svcNameRe.MatchString(n) { + units = append(units, n) + } + } + sort.Strings(units) + return units, nil +} + +func (s *server) systemdServices(ctx context.Context) []*homelabv1.Service { + units, err := s.units() + if err != nil { + s.logProbeChange("units", err.Error()) + return []*homelabv1.Service{{Kind: homelabv1.ServiceKind_SYSTEMD, Name: s.srvDir, Error: err.Error()}} + } + s.logProbeChange("units", "") + svcs := make([]*homelabv1.Service, 0, len(units)) + for _, u := range units { + svcs = append(svcs, s.systemdStatus(ctx, u)) + } + return svcs +} + +// ponytail: one systemctl per unit (~26 per 3s poll, ~10ms each). Batch into a single +// `systemctl show u1 u2 ... -p ...` if the poll ever shows up in a slow trace. +func (s *server) systemdStatus(ctx context.Context, unit string) *homelabv1.Service { + svc := &homelabv1.Service{Kind: homelabv1.ServiceKind_SYSTEMD, Name: unit} + ctx, cancel := context.WithTimeout(ctx, svcTimeout) + defer cancel() + // show is the machine-readable probe (always exit 0, unlike status). LoadState=not-found means the user + // disabled the unit on purpose — a state, not an error. + out, err := exec.CommandContext(ctx, "systemctl", "--user", "show", unit+".service", + "-p", "ActiveState,SubState,LoadState,UnitFileState").Output() + // systemctl prints properties in its own fixed table order (not the -p order), so parse by key + props := map[string]string{} + for _, line := range strings.Split(string(out), "\n") { + if k, v, ok := strings.Cut(strings.TrimSpace(line), "="); ok { + props[k] = v + } + } + svc.State, svc.Substate = props["ActiveState"], props["SubState"] + svc.LoadState, svc.UnitFileState = props["LoadState"], props["UnitFileState"] + if svc.LoadState == "" { + svc.LoadState = "not-found" + } + if err != nil { + svc.Error = err.Error() + s.logProbeChange("systemctl/"+unit, svc.Error) + } else { + s.logProbeChange("systemctl/"+unit, "") + } + return svc +} + +// ServiceAction runs a fixed systemctl/docker command; only `name` is user input and it is regex-checked, +// never interpolated into a shell string. +func (s *server) ServiceAction(ctx context.Context, req *connect.Request[homelabv1.ServiceActionRequest]) (*connect.Response[homelabv1.ServiceActionResponse], error) { + name, kind := req.Msg.GetName(), req.Msg.GetKind() + if !svcNameRe.MatchString(name) { + return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("bad service name %q", name)) + } + var args []string + switch req.Msg.GetOp() { + case homelabv1.ServiceOp_START: + args = []string{"start"} + case homelabv1.ServiceOp_STOP: + args = []string{"stop"} + case homelabv1.ServiceOp_RESTART: + args = []string{"restart"} + default: + return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("bad op for %q", name)) + } + + if s.mockSvcs != nil { + return connect.NewResponse(&homelabv1.ServiceActionResponse{Output: s.mockAct(name, kind, args[0])}), nil + } + + ctx, cancel := context.WithTimeout(ctx, svcTimeout) + defer cancel() + + var cmd *exec.Cmd + if kind == homelabv1.ServiceKind_DOCKER { + targets, err := s.dockerTargets(ctx, name, args[0]) + if err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + if len(targets) == 0 { + return connect.NewResponse(&homelabv1.ServiceActionResponse{Output: "no containers to " + args[0]}), nil + } + cmd = exec.CommandContext(ctx, "docker", append([]string{args[0]}, targets...)...) + } else { + cmd = exec.CommandContext(ctx, "systemctl", "--user", args[0], name+".service") + } + out, err := cmd.CombinedOutput() + if err != nil { + log.Printf("service %s/%s %s: ERROR %v: %s", kind, name, args[0], err, out) + return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("%v: %s", err, out)) + } + log.Printf("service %s/%s %s -> %s", kind, name, args[0], strings.TrimSpace(string(out))) + return connect.NewResponse(&homelabv1.ServiceActionResponse{Output: strings.TrimSpace(string(out))}), nil +} + +// dockerTargets resolves a card name (compose project, or bare container name) to the containers an op applies to. +func (s *server) dockerTargets(ctx context.Context, name, op string) ([]string, error) { + cs, err := s.dockerContainers(ctx) + if err != nil { + return nil, err + } + var targets []string + for _, c := range working(cs) { + if c.project != name && c.name != name { + continue + } + if running := c.status == "running"; (op == "start" && !running) || (op != "start" && running) { + targets = append(targets, c.name) + } + } + return targets, nil +} + +// ServiceInfo returns raw status output for the modal: `systemctl status` / `docker ps -a` for the card. +func (s *server) ServiceInfo(ctx context.Context, req *connect.Request[homelabv1.ServiceInfoRequest]) (*connect.Response[homelabv1.ServiceInfoResponse], error) { + name, kind := req.Msg.GetName(), req.Msg.GetKind() + if !svcNameRe.MatchString(name) { + return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("bad service name %q", name)) + } + ctx, cancel := context.WithTimeout(ctx, svcTimeout) + defer cancel() + + if s.mockSvcs != nil { + return connect.NewResponse(&homelabv1.ServiceInfoResponse{Output: s.mockInfo(name, kind)}), nil + } + + var out string + var err error + if kind == homelabv1.ServiceKind_DOCKER { + // compose project first, bare container (no label) as fallback + out, err = dockerOut(ctx, "ps", "-a", "--filter", "label=com.docker.compose.project="+name) + if strings.TrimSpace(out) == "" { + out, err = dockerOut(ctx, "ps", "-a", "--filter", "name="+name) + } + } else { + // status exits non-zero for inactive units (code 3) — that is valid output, not an error + c := exec.CommandContext(ctx, "systemctl", "--user", "status", name+".service", "--no-pager", "-n", "0") + b, cerr := c.CombinedOutput() + out, err = string(b), cerr + } + if err != nil && strings.TrimSpace(out) == "" { + log.Printf("service info %s/%s: ERROR %v", kind, name, err) + return nil, connect.NewError(connect.CodeInternal, err) + } + log.Printf("service info %s/%s ok", kind, name) + return connect.NewResponse(&homelabv1.ServiceInfoResponse{Output: strings.TrimRight(out, "\n")}), nil +} diff --git a/web/src/App.tsx b/web/src/App.tsx index 428aeec..b6dec50 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -8,7 +8,7 @@ import { MonitorCard } from "./components/MonitorCard"; import { ScriptCard } from "./components/ScriptCard"; import { ServiceCard } from "./components/ServiceCard"; import { DocsView, DocView, localDocs } from "./components/Docs"; -import { syncDocs } from "./docsStore"; +import { StoredDoc, syncDocs } from "./docsStore"; const client = createClient( HomelabService, @@ -63,15 +63,45 @@ function ServiceList({ s.name.toLowerCase().includes(searchQuery.toLowerCase()), ); return ( -
+
+ {/* setSearchQuery(e.currentTarget.value)} /> -
+ */} + +
{filteredServices.map((s) => ( ))} @@ -80,13 +110,118 @@ function ServiceList({ ); } +function Nav({ + category, + setCategory, + setOpenDoc, +}: { + category: Category; + setCategory: (c: Category) => void; + setOpenDoc: (d: string | null) => void; +}) { + return ( + + ); +} + +function Footer({ + category, + setCategory, + setOpenDoc, +}: { + category: Category; + setCategory: (c: Category) => void; + setOpenDoc: (d: string | null) => void; +}) { + return ( +
+ {CATEGORIES.map((c) => ( + + ))} +
+ ); +} + +function MainScreen({ + category, + setOpenDoc, + content, + refresh, + open, + docs, +}: { + category: Category; + setOpenDoc: (d: string | null) => void; + content: Content; + refresh: () => void; + open: StoredDoc | undefined; + docs: StoredDoc[]; +}) { + return ( +
+ {category === "links" && } + {category === "monitor" && ( +
+ {content.monitors.map((it) => ( + + ))} +
+ )} + {category === "exec" && content.exec.map((it) => )} + {category === "services" && ( + + )} + {category === "docs" && + (open ? ( + setOpenDoc(null)} /> + ) : ( + setOpenDoc(d.name)} /> + ))} +
+ ); +} + +type Content = { + links: Link[]; + monitors: Item[]; + exec: Item[]; + services: Service[]; +}; + export function App() { - const [content, setContent] = useState<{ - links: Link[]; - monitors: Item[]; - exec: Item[]; - services: Service[]; - }>({ + const [content, setContent] = useState({ links: [], monitors: [], exec: [], @@ -132,68 +267,26 @@ export function App() { return (
- {/* Desktop only: tab row top-left */} - +
); }