package main import ( "context" "fmt" "log" "net/http" "os" "os/exec" "path/filepath" "strconv" "strings" "sync" "time" "github.com/labstack/echo/v4" "github.com/labstack/echo/v4/middleware" homelabv1 "pwa-homelab-mon/gen/homelab/v1" "pwa-homelab-mon/gen/homelab/v1/homelabv1connect" ) // --- config --- type Item struct { Name string `yaml:"name"` Script string `yaml:"script"` Refresh int `yaml:"refresh"` // monitor: seconds Args int `yaml:"args"` // exec: arg count ArgTypes []string `yaml:"arg_types"` // exec: text | number per arg } type Monitor struct { Name string `yaml:"name"` Script string `yaml:"script"` Refresh int `yaml:"refresh"` // seconds } type Link struct { Name string `yaml:"name"` URL string `yaml:"url"` } // --- service --- type server struct { cfg *Config exec map[string]*Item // by item name, read-only after start monVals sync.Map // monitor item name -> *monData (last parsed output) docsDir string srvDir string // dir with *.service units (~/srv on the box) probeErr sync.Map // probe key -> last logged error; poll errors log once per change, not every 3s tick mockSvcs []*homelabv1.Service // MOCK=1: static fixtures, buttons mutate them; nil = real probes } const execTimeout = 10 * time.Second // monData — распарсенный вывод скрипта-монитора: первая строка — процент, // остальные — строки key=value для подписей в карточке. type monData struct { percentage int32 // -1 = скрипт не дал процент stats []*homelabv1.Stat } func parseMonitor(out string) *monData { d := &monData{percentage: -1} lines := strings.Split(strings.TrimSpace(out), "\n") if lines[0] == "" { return d } if n, err := strconv.Atoi(strings.TrimSpace(lines[0])); err == nil { d.percentage = int32(n) } for _, l := range lines[1:] { if k, v, ok := strings.Cut(strings.TrimSpace(l), "="); ok { d.stats = append(d.stats, &homelabv1.Stat{Name: strings.TrimSpace(k), Value: strings.TrimSpace(v)}) } } return d } func runScript(script string, args ...string) (string, error) { ctx, cancel := context.WithTimeout(context.Background(), execTimeout) defer cancel() out, err := exec.CommandContext(ctx, script, args...).CombinedOutput() if err != nil { return "", fmt.Errorf("%v: %s", err, out) } return string(out), nil } func (s *server) startMonitors() { for i := range s.cfg.Monitor { sc := &s.cfg.Monitor[i] log.Printf("monitor started: %s (%s) every %ds", sc.Name, sc.Script, sc.Refresh) go func(sc *Monitor) { for { out, err := runScript(sc.Script) if err != nil { log.Printf("monitor %s: %v", sc.Name, err) out = "-\nerror=" + err.Error() } d := parseMonitor(out) log.Printf("monitor %s -> %d%% (%d stats)", sc.Name, d.percentage, len(d.stats)) s.monVals.Store(sc.Name, d) time.Sleep(time.Duration(sc.Refresh) * time.Second) } }(sc) } } // --- main --- func main() { cfgPath := os.Getenv("CONFIG") if cfgPath == "" { cfgPath = "config.yaml" } cfg, err := loadConfig(cfgPath) if err != nil { fmt.Fprintln(os.Stderr, "config:", err) os.Exit(1) } docsDir := "docs" if err := os.MkdirAll(docsDir, 0o755); err != nil { fmt.Fprintln(os.Stderr, "docs:", err) os.Exit(1) } svc := &server{cfg: cfg, exec: map[string]*Item{}, docsDir: docsDir, srvDir: srvDir()} if os.Getenv("MOCK") != "" { svc.mockSvcs = mockServices() log.Print("MOCK mode: services are fixtures, buttons mutate them") } for i := range cfg.Exec { svc.exec[cfg.Exec[i].Name] = &cfg.Exec[i] } svc.startMonitors() log.Printf("config %s: %d links, %d monitors, %d exec; units from %s", cfgPath, len(cfg.Links), len(cfg.Monitor), len(cfg.Exec), svc.srvDir) e := echo.New() e.HideBanner = true // HTTP request log, except the ListContent heartbeat (every 3s per client — pure noise). e.Use(middleware.LoggerWithConfig(middleware.LoggerConfig{ Skipper: func(c echo.Context) bool { return c.Request().URL.Path == "/homelab.v1.HomelabService/ListContent" }, })) // ConnectRPC handler on the mux (matches /homelab.v1.HomelabService/). mux := http.NewServeMux() mux.Handle(homelabv1connect.NewHomelabServiceHandler(svc)) e.Any("/homelab.v1.HomelabService/*", echo.WrapHandler(mux)) // sw.js and index.html must never be cached: stale index = stale asset references = iOS never updates. e.Use(func(next echo.HandlerFunc) echo.HandlerFunc { return func(c echo.Context) error { p := c.Request().URL.Path if p == "/sw.js" || p == "/manifest.webmanifest" || p == "/index.html" || p == "/" { c.Response().Header().Set("Cache-Control", "no-cache") } return next(c) } }) // Static PWA from dist/ (built by make web); HTML5 mode falls back to index.html for SPA routes. e.Use(middleware.StaticWithConfig(middleware.StaticConfig{ Root: "dist", HTML5: true, })) e.Logger.Fatal(e.Start(port())) } func port() string { if p := os.Getenv("PORT"); p != "" { return ":" + p } return ":8080" } // srvDir: units live in ~/srv on the box, which is outside WorkingDirectory, so it is not repo-relative // (unlike dist/ and docs/). SRV_DIR overrides it for dev/tests. func srvDir() string { if d := os.Getenv("SRV_DIR"); d != "" { return d } home, err := os.UserHomeDir() if err != nil { return "srv" } return filepath.Join(home, "srv") }