Files
pwa-homelab-mon/server/main.go
T
alexey.bagno 47e4066de5
Deploy / deploy (push) Successful in 8s
pretty search
2026-08-31 21:15:26 +05:00

166 lines
4.5 KiB
Go

package main
import (
"context"
"fmt"
"log"
"net/http"
"os"
"os/exec"
"path/filepath"
"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
Data string `yaml:"data"` // monitor: text | percentage
Args int `yaml:"args"` // exec: arg count
ArgTypes []string `yaml:"arg_types"` // exec: text | number per arg
}
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 -> string (last 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
func runScript(s *Item, args ...string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), execTimeout)
defer cancel()
out, err := exec.CommandContext(ctx, s.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 *Item) {
for {
v, err := runScript(sc)
if err != nil {
log.Printf("monitor %s: %v", sc.Name, err)
v = "error: " + err.Error()
} else {
v = strings.TrimSpace(v)
log.Printf("monitor %s -> %s", sc.Name, v)
}
s.monVals.Store(sc.Name, v)
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/<Method>).
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")
}