232 lines
6.5 KiB
Go
232 lines
6.5 KiB
Go
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/<Method>).
|
|
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/ (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"
|
|
}
|