Added MOCK=1 mode for local dev
Deploy / deploy (push) Successful in 9s

This commit is contained in:
2026-08-31 18:45:53 +05:00
parent 53d98bfd53
commit fed9ad594c
3 changed files with 137 additions and 2 deletions
+90 -2
View File
@@ -101,8 +101,9 @@ type server struct {
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
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
@@ -210,9 +211,84 @@ var svcNameRe = regexp.MustCompile(`^[a-z0-9_.-]+$`)
const svcTimeout = 8 * time.Second
func (s *server) services(ctx context.Context) []*homelabv1.Service {
if s.mockSvcs != nil {
return s.mockSvcs
}
return append(s.systemdServices(ctx), s.dockerServices(ctx)...)
}
// --- mock mode (MOCK=1): fixture cards for frontend dev on machines without systemd/docker ---
func mockServices() []*homelabv1.Service {
return []*homelabv1.Service{
{Kind: homelabv1.ServiceKind_SYSTEMD, Name: "hub-api", State: "active", Substate: "running", LoadState: "loaded", UnitFileState: "enabled"},
{Kind: homelabv1.ServiceKind_SYSTEMD, Name: "rathole", State: "active", Substate: "running", LoadState: "loaded", UnitFileState: "enabled"},
{Kind: homelabv1.ServiceKind_SYSTEMD, Name: "hermes-dashboard", State: "inactive", Substate: "dead", LoadState: "loaded", UnitFileState: "disabled"},
{Kind: homelabv1.ServiceKind_SYSTEMD, Name: "olivetin", State: "failed", Substate: "exit-code", LoadState: "loaded", UnitFileState: "enabled"},
{Kind: homelabv1.ServiceKind_SYSTEMD, Name: "ntfy", LoadState: "not-found"},
{Kind: homelabv1.ServiceKind_DOCKER, Name: "affine", State: "active", Substate: "3/3",
Containers: []string{"affine-self-hosted-1", "affine-migration-1", "affine-redis-1"}},
{Kind: homelabv1.ServiceKind_DOCKER, Name: "itsaplan", State: "partial", Substate: "1/2",
Containers: []string{"itsaplan-web-1", "itsaplan-worker-1"}},
{Kind: homelabv1.ServiceKind_DOCKER, Name: "gitea", State: "inactive", Substate: "0/1", Containers: []string{"gitea"}},
}
}
// mockAct flips fixture state so the UI reacts to the buttons in mock mode.
func (s *server) mockAct(name string, kind homelabv1.ServiceKind, op string) string {
for _, svc := range s.mockSvcs {
if svc.Name != name || svc.Kind != kind {
continue
}
switch op {
case "start", "restart":
svc.State, svc.Substate = "active", "running"
if kind == homelabv1.ServiceKind_DOCKER {
n := len(svc.Containers)
svc.Substate = fmt.Sprintf("%d/%d", n, n)
}
case "stop":
svc.State, svc.Substate = "inactive", "dead"
if kind == homelabv1.ServiceKind_DOCKER {
svc.Substate = fmt.Sprintf("0/%d", len(svc.Containers))
}
}
return "mock: " + op + " " + name
}
return "mock: no card " + name
}
func (s *server) mockInfo(name string, kind homelabv1.ServiceKind) string {
if kind == homelabv1.ServiceKind_DOCKER {
return fmt.Sprintf("NAMES STATUS\n%s-1 Up 2 days\n%s-2 Exited (0) 5 hours ago", name, name)
}
return fmt.Sprintf("● %s.service - hub API\n Loaded: loaded (~/srv/%s.service; enabled)\n Active: %s (%s) since Mon 2026-08-31 10:00:00 MSK; 2 days ago\n Main PID: 4242 (hub-api)\n Memory: 12.3M",
name, name, mockField(s.mockSvcs, name, "state"), mockField(s.mockSvcs, name, "substate"))
}
// mockField reads a fixture field by name for the info template ("" falls back inside mockField callers).
func mockField(svcs []*homelabv1.Service, name, field string) string {
for _, svc := range svcs {
if svc.Name != name {
continue
}
switch field {
case "state":
if svc.State != "" {
return svc.State
}
case "substate":
if svc.Substate != "" {
return svc.Substate
}
}
}
if field == "state" {
return "active"
}
return "running"
}
// logProbeChange logs poll-path probe failures once per change: the poll repeats every 3s per client,
// and the same error every tick is noise. msg "" = probe healthy, logs a single recovery line.
func (s *server) logProbeChange(key, msg string) {
@@ -438,6 +514,10 @@ func (s *server) ServiceAction(ctx context.Context, req *connect.Request[homelab
return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("bad op for %q", name))
}
if s.mockSvcs != nil {
return connect.NewResponse(&homelabv1.ServiceActionResponse{Output: s.mockAct(name, kind, args[0])}), nil
}
ctx, cancel := context.WithTimeout(ctx, svcTimeout)
defer cancel()
@@ -490,6 +570,10 @@ func (s *server) ServiceInfo(ctx context.Context, req *connect.Request[homelabv1
ctx, cancel := context.WithTimeout(ctx, svcTimeout)
defer cancel()
if s.mockSvcs != nil {
return connect.NewResponse(&homelabv1.ServiceInfoResponse{Output: s.mockInfo(name, kind)}), nil
}
var out string
var err error
if kind == homelabv1.ServiceKind_DOCKER {
@@ -549,6 +633,10 @@ func main() {
}
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]
}