@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
-534
@@ -2,24 +2,18 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/hex"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"connectrpc.com/connect"
|
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
"github.com/labstack/echo/v4/middleware"
|
"github.com/labstack/echo/v4/middleware"
|
||||||
"gopkg.in/yaml.v3"
|
|
||||||
homelabv1 "pwa-homelab-mon/gen/homelab/v1"
|
homelabv1 "pwa-homelab-mon/gen/homelab/v1"
|
||||||
"pwa-homelab-mon/gen/homelab/v1/homelabv1connect"
|
"pwa-homelab-mon/gen/homelab/v1/homelabv1connect"
|
||||||
)
|
)
|
||||||
@@ -40,60 +34,6 @@ type Link struct {
|
|||||||
URL string `yaml:"url"`
|
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 ---
|
// --- service ---
|
||||||
|
|
||||||
type server struct {
|
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 ---
|
// --- main ---
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
+163
-70
@@ -8,7 +8,7 @@ import { MonitorCard } from "./components/MonitorCard";
|
|||||||
import { ScriptCard } from "./components/ScriptCard";
|
import { ScriptCard } from "./components/ScriptCard";
|
||||||
import { ServiceCard } from "./components/ServiceCard";
|
import { ServiceCard } from "./components/ServiceCard";
|
||||||
import { DocsView, DocView, localDocs } from "./components/Docs";
|
import { DocsView, DocView, localDocs } from "./components/Docs";
|
||||||
import { syncDocs } from "./docsStore";
|
import { StoredDoc, syncDocs } from "./docsStore";
|
||||||
|
|
||||||
const client = createClient(
|
const client = createClient(
|
||||||
HomelabService,
|
HomelabService,
|
||||||
@@ -63,15 +63,45 @@ function ServiceList({
|
|||||||
s.name.toLowerCase().includes(searchQuery.toLowerCase()),
|
s.name.toLowerCase().includes(searchQuery.toLowerCase()),
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<div>
|
<div class="flex flex-col gap-2 mt-4">
|
||||||
|
{/*
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="search"
|
||||||
|
class="gap-3"
|
||||||
placeholder="Filter"
|
placeholder="Filter"
|
||||||
className="input"
|
className="input"
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onInput={(e) => setSearchQuery(e.currentTarget.value)}
|
onInput={(e) => setSearchQuery(e.currentTarget.value)}
|
||||||
/>
|
/>
|
||||||
<div class="grid gap-3 mt-4 md:grid-cols-2 lg:grid-cols-3">
|
*/}
|
||||||
|
<label className="input">
|
||||||
|
<svg
|
||||||
|
className="h-[1em] opacity-50"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<g
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeWidth="2.5"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<circle cx="11" cy="11" r="8"></circle>
|
||||||
|
<path d="m21 21-4.3-4.3"></path>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
className="grow"
|
||||||
|
placeholder="Filter"
|
||||||
|
value={searchQuery}
|
||||||
|
onInput={(e) => setSearchQuery(e.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
<kbd className="kbd kbd-sm">⌘</kbd>
|
||||||
|
<kbd className="kbd kbd-sm">K</kbd>
|
||||||
|
</label>
|
||||||
|
<div class="grid gap-3 mt-2 md:grid-cols-2 lg:grid-cols-3">
|
||||||
{filteredServices.map((s) => (
|
{filteredServices.map((s) => (
|
||||||
<ServiceCard key={s.name} s={s} refresh={refresh} />
|
<ServiceCard key={s.name} s={s} refresh={refresh} />
|
||||||
))}
|
))}
|
||||||
@@ -80,13 +110,118 @@ function ServiceList({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Nav({
|
||||||
|
category,
|
||||||
|
setCategory,
|
||||||
|
setOpenDoc,
|
||||||
|
}: {
|
||||||
|
category: Category;
|
||||||
|
setCategory: (c: Category) => void;
|
||||||
|
setOpenDoc: (d: string | null) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div class="bg-base-200 px-4 py-2 hidden md:block">
|
||||||
|
<div class="max-w-4xl mx-auto flex items-center gap-2">
|
||||||
|
<span class="text-lg font-bold mr-4">Homelab</span>
|
||||||
|
{CATEGORIES.map((c) => (
|
||||||
|
<button
|
||||||
|
class={`btn btn-sm ${c.id === category ? "btn-primary" : "btn-ghost"}`}
|
||||||
|
onClick={() => {
|
||||||
|
setCategory(c.id);
|
||||||
|
setOpenDoc(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<c.icon size={18} /> {c.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Footer({
|
||||||
|
category,
|
||||||
|
setCategory,
|
||||||
|
setOpenDoc,
|
||||||
|
}: {
|
||||||
|
category: Category;
|
||||||
|
setCategory: (c: Category) => void;
|
||||||
|
setOpenDoc: (d: string | null) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div class="dock md:hidden">
|
||||||
|
{CATEGORIES.map((c) => (
|
||||||
|
<button
|
||||||
|
class={
|
||||||
|
c.id === category
|
||||||
|
? "dock-active text-primary"
|
||||||
|
: "text-base-content/60"
|
||||||
|
}
|
||||||
|
onClick={() => {
|
||||||
|
setCategory(c.id);
|
||||||
|
setOpenDoc(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<c.icon size={26} />
|
||||||
|
<span class="dock-label text-xs">{c.label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
class={`flex-1 w-full mx-auto px-4 pb-24 md:pb-6 pt-[env(safe-area-inset-top)] ${
|
||||||
|
category === "services" ? "max-w-5xl" : "max-w-md"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{category === "links" && <LinkList links={content.links} />}
|
||||||
|
{category === "monitor" && (
|
||||||
|
<div class="flex flex-col gap-3 mt-4">
|
||||||
|
{content.monitors.map((it) => (
|
||||||
|
<MonitorCard s={it} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{category === "exec" && content.exec.map((it) => <ScriptCard s={it} />)}
|
||||||
|
{category === "services" && (
|
||||||
|
<ServiceList services={content.services} refresh={refresh} />
|
||||||
|
)}
|
||||||
|
{category === "docs" &&
|
||||||
|
(open ? (
|
||||||
|
<DocView doc={open} onBack={() => setOpenDoc(null)} />
|
||||||
|
) : (
|
||||||
|
<DocsView docs={docs} onOpen={(d) => setOpenDoc(d.name)} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type Content = {
|
||||||
|
links: Link[];
|
||||||
|
monitors: Item[];
|
||||||
|
exec: Item[];
|
||||||
|
services: Service[];
|
||||||
|
};
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
const [content, setContent] = useState<{
|
const [content, setContent] = useState<Content>({
|
||||||
links: Link[];
|
|
||||||
monitors: Item[];
|
|
||||||
exec: Item[];
|
|
||||||
services: Service[];
|
|
||||||
}>({
|
|
||||||
links: [],
|
links: [],
|
||||||
monitors: [],
|
monitors: [],
|
||||||
exec: [],
|
exec: [],
|
||||||
@@ -132,68 +267,26 @@ export function App() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="min-h-screen flex flex-col">
|
<div class="min-h-screen flex flex-col">
|
||||||
{/* Desktop only: tab row top-left */}
|
<Nav
|
||||||
<div class="bg-base-200 px-4 py-2 hidden md:block">
|
category={category}
|
||||||
<div class="max-w-4xl mx-auto flex items-center gap-2">
|
setCategory={setCategory}
|
||||||
<span class="text-lg font-bold mr-4">Homelab</span>
|
setOpenDoc={setOpenDoc}
|
||||||
{CATEGORIES.map((c) => (
|
/>
|
||||||
<button
|
|
||||||
class={`btn btn-sm ${c.id === category ? "btn-primary" : "btn-ghost"}`}
|
|
||||||
onClick={() => {
|
|
||||||
setCategory(c.id);
|
|
||||||
setOpenDoc(null);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<c.icon size={18} /> {c.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
<MainScreen
|
||||||
class={`flex-1 w-full mx-auto px-4 pb-24 md:pb-6 pt-[env(safe-area-inset-top)] ${
|
category={category}
|
||||||
category === "services" ? "max-w-5xl" : "max-w-md"
|
setOpenDoc={setOpenDoc}
|
||||||
}`}
|
content={content}
|
||||||
>
|
refresh={refresh}
|
||||||
{category === "links" && <LinkList links={content.links} />}
|
open={open}
|
||||||
{category === "monitor" && (
|
docs={docs}
|
||||||
<div class="flex flex-col gap-3 mt-4">
|
/>
|
||||||
{content.monitors.map((it) => (
|
|
||||||
<MonitorCard s={it} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{category === "exec" && content.exec.map((it) => <ScriptCard s={it} />)}
|
|
||||||
{category === "services" && (
|
|
||||||
<ServiceList services={content.services} refresh={refresh} />
|
|
||||||
)}
|
|
||||||
{category === "docs" &&
|
|
||||||
(open ? (
|
|
||||||
<DocView doc={open} onBack={() => setOpenDoc(null)} />
|
|
||||||
) : (
|
|
||||||
<DocsView docs={docs} onOpen={(d) => setOpenDoc(d.name)} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Mobile footer nav: daisyUI dock (v5 btm-nav removed), equal quarters */}
|
<Footer
|
||||||
<div class="dock md:hidden">
|
category={category}
|
||||||
{CATEGORIES.map((c) => (
|
setCategory={setCategory}
|
||||||
<button
|
setOpenDoc={setOpenDoc}
|
||||||
class={
|
/>
|
||||||
c.id === category
|
|
||||||
? "dock-active text-primary"
|
|
||||||
: "text-base-content/60"
|
|
||||||
}
|
|
||||||
onClick={() => {
|
|
||||||
setCategory(c.id);
|
|
||||||
setOpenDoc(null);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<c.icon size={26} />
|
|
||||||
<span class="dock-label text-xs">{c.label}</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user