package main //test import ( "context" "fmt" "log" "net/http" "os" "os/exec" "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" ) // --- config --- type Item struct { Name string `yaml:"name"` Type string `yaml:"type"` // monitor | response Script string `yaml:"script"` Refresh int `yaml:"refresh"` // monitor: seconds Data string `yaml:"data"` // monitor: text | percentage Args int `yaml:"args"` // response: arg count ArgTypes []string `yaml:"arg_types"` // response: text | number per arg } // Section is a full view on the web page: a list of items plus sidebar metadata. type Section struct { Name string `yaml:"name"` Icon string `yaml:"icon"` Items []Item `yaml:"items"` } type Config struct { Sections []Section `yaml:"sections"` } 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) } for _, sec := range cfg.Sections { for _, it := range sec.Items { switch it.Type { case "monitor": if it.Refresh <= 0 { return nil, fmt.Errorf("item %q: refresh must be > 0", it.Name) } if it.Data != "text" && it.Data != "percentage" { return nil, fmt.Errorf("item %q: data must be text|percentage", it.Name) } case "response": if len(it.ArgTypes) != it.Args { return nil, fmt.Errorf("item %q: arg_types must have args entries", it.Name) } for _, t := range it.ArgTypes { if t != "text" && t != "number" { return nil, fmt.Errorf("item %q: arg type must be text|number", it.Name) } } default: return nil, fmt.Errorf("item %q: unknown type %q", it.Name, it.Type) } } } return &cfg, nil } // --- service --- type server struct { cfg *Config scripts map[string]*Item // by item name, read-only after start monVals sync.Map // item name -> string (last monitor output) } 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 _, sec := range s.cfg.Sections { for _, it := range sec.Items { if it.Type != "monitor" { continue } sc := it 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 { log.Printf("monitor %s -> %s", sc.Name, strings.TrimSpace(v)) } s.monVals.Store(sc.Name, v) time.Sleep(time.Duration(sc.Refresh) * time.Second) } }(&sc) } } } func toProto(it *Item, val string) *homelabv1.Item { p := &homelabv1.Item{Name: it.Name, Type: it.Type, Data: it.Data, Args: int32(it.Args), ArgTypes: it.ArgTypes} if it.Type == "monitor" { p.Value = val } return p } func (s *server) ListSections(context.Context, *connect.Request[homelabv1.ListSectionsRequest]) (*connect.Response[homelabv1.ListSectionsResponse], error) { resp := &homelabv1.ListSectionsResponse{} for _, sec := range s.cfg.Sections { ps := &homelabv1.Section{Name: sec.Name, Icon: sec.Icon} for _, it := range sec.Items { val, _ := s.monVals.Load(it.Name) v, _ := val.(string) ps.Items = append(ps.Items, toProto(&it, v)) } resp.Sections = append(resp.Sections, ps) } return connect.NewResponse(resp), nil } func (s *server) RunScript(_ context.Context, req *connect.Request[homelabv1.RunScriptRequest]) (*connect.Response[homelabv1.RunScriptResponse], error) { it, ok := s.scripts[req.Msg.GetName()] if !ok || it.Type != "response" { return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("no response script %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() { 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) } svc := &server{cfg: cfg, scripts: map[string]*Item{}} for _, sec := range cfg.Sections { for i := range sec.Items { svc.scripts[sec.Items[i].Name] = &sec.Items[i] } } svc.startMonitors() log.Printf("config %s: %d sections, %d items", cfgPath, len(cfg.Sections), len(svc.scripts)) e := echo.New() e.HideBanner = true // HTTP request log, except the ListSections 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/ListSections" }, })) // 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 must never be cached, otherwise iOS serves a stale service worker. e.Use(func(next echo.HandlerFunc) echo.HandlerFunc { return func(c echo.Context) error { if c.Request().URL.Path == "/sw.js" || c.Request().URL.Path == "/manifest.webmanifest" { c.Response().Header().Set("Cache-Control", "no-cache") } return next(c) } }) // Static PWA from dist/ next to the binary (CI copies web/dist → dist); 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" }