From fed9ad594c04c2feea277f921f7a42d370130bd9 Mon Sep 17 00:00:00 2001 From: Alexey Bagno Date: Mon, 31 Aug 2026 18:45:53 +0500 Subject: [PATCH] Added MOCK=1 mode for local dev --- AGENTS.md | 1 + REVIEW.md | 46 +++++++++++++++++++++++++ server/main.go | 92 ++++++++++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 137 insertions(+), 2 deletions(-) create mode 100644 REVIEW.md diff --git a/AGENTS.md b/AGENTS.md index 585eb9d..3c0e50f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -46,6 +46,7 @@ docs/ markdown-доки, НЕ в репо — живут толь - `make proto` — кодоген Go+TS из proto - `make web` — сборка фронта в `dist/` (tsc --noEmit + vite build; `tsc` — линтер, падение = ошибка типов) - `make server` — Go-бинарь `bin/hub-api` +- `MOCK=1 make dev-srv` — mock-режим: секция Services отдает фикстуры (все виды статусов), кнопки и info-модалка работают по ним; реальных systemd/docker-проб нет - `make test` — smoke-тест RPC (поднимает сервер на :8080, дергает curl'ом) Никаких других способов сборки нет: CI делает ровно `make web server`. diff --git a/REVIEW.md b/REVIEW.md new file mode 100644 index 0000000..1277c84 --- /dev/null +++ b/REVIEW.md @@ -0,0 +1,46 @@ +# Ревью SPA/PWA-части целиком (App.tsx, docsStore, св, карточки, деплой-сборка). Не безопасность — архитектура и баги. + +## Критично: крах всего приложения на одном битом доке + + docsStore.get() делает JSON.parse(raw) без try/catch. Одна повреждённая запись в localStorage (частичная запись при переполнении квоты, ручная зачистка, битая + сериализация) — и localDocs() кидает исключение прямо в рендере (useState(localDocs())) → белый экран всего дашборда, не только вкладки Docs. Лечится одним + try/catch в get(), возвращающим null. Это единственное место, где SPA может умереть целиком. + +## Свежесть данных невидима + + .catch(() => {}) в поллинге: офлайн → UI молча показывает последние значения. Для панели за VPN это главный UX-дефект: tailscale отвалился — ты минутами смотришь + на CPU 12%, который был час назад. Ошибки мониторов попадают в значение (error: ... в тексте), а вот пропажа связи — нет. Ленивый фикс: lastOkAt timestamp рядом с + content, если Date.now() - lastOkAt > 10s — один бейдж «offline» в шапке. + +## Гонка ответов при медленном VPN + + setInterval(refresh, 3000) без коалесинга и отмены: при таймаутах/лагах два запроса в полёте, ответ на старый приходит позже нового — setContent откатывает данные + назад. На 3s-такте и tailscale-латентности реально. Один guard (reqSeq или AbortController) — три строки. Сюда же: connect fetch без таймаута, на мёртвом туннеле + запросы копятся. + +## Дублирование транспорт-клиента ×4 + + createClient(HomelabService, createConnectTransport({ baseUrl: "" })) скопирован в App, ServiceCard, ScriptCard и docsStore. Один api.ts с экспортом client — и + правка транспорта (позже auth через caddy) в одном месте, а не четырёх. Это ровно то место, которое выстрелит, когда будешь прикручивать caddy-auth. + +## Docs-синк: три дыры в модели + + - Призраки: удалённый на сервере док живёт в localStorage вечно — pull удалять не умеет. Список только растёт. + - Вечный ретрай: dirty-док, который сервер отклоняет, пушится каждые 5s бесконечно, без счётчика и без сигнала юзеру. + - Черновики уезжают на сервер: каждое нажатие клавиши → localStorage → через ≤5s запушено. Второе устройство видит полуфразы; если на нём нет правок — оно + перезапишет твой док, когда ты его допишешь (last-write-wins уже задокументирован, но «push недописанного» делает конфликты чаще). Дешёвый фикс: пушить только + доки, к которым не было правок N секунд (debounce). + +## Service worker: лишняя ручная работа + + CACHE = "homelab-shell-v4" с ручным бампом версии — мёртвый груз: кэшируются только content-hashed /assets/, а activate и так удаляет старые кэши. Имя можно не + менять никогда. Обновление PWA при этом работает (network-first index + skipWaiting) — тут всё нормально, включая iOS-пункт про stale index. + +# Мелочи + + - Роутинга нет: вкладка — useState. Рефреш/OSError выкидывает на Monitor, глубокой ссылки нет. Осознанный YAGNI — ок, но одна строка location.hash вернула бы + back-button на iOS, который в standalone-PWA иначе мёртв (свайп назад закрывает приложение). + - String(e).replace(/^.*real error: /, "") — парсинг ошибок по строке. У connect-ошибок есть .message; регексп отвалится при первом изменении текста на сервере. + - error && String(e) в ScriptCard без setError("") при успехе... — есть, при успехе сбрасывается. Ок. + - Search в сервисах без debounce — при 30 карточках не нужно, не трогать. + - Модалки (info/output) без focus-trap и Escape — для личной панели терпимо, одна строка onKeyDown на document закрыла бы Escape. diff --git a/server/main.go b/server/main.go index 7a2c997..742762b 100644 --- a/server/main.go +++ b/server/main.go @@ -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] }