195 lines
5.4 KiB
Go
195 lines
5.4 KiB
Go
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/<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")
|
|
}
|