From 20e1d5a726d24882a465dc56f085cf3ad9b4c09b Mon Sep 17 00:00:00 2001 From: Alexey Bagno Date: Mon, 31 Aug 2026 11:08:45 +0500 Subject: [PATCH] Srv watch --- SPEC.md | 139 ++++++ gen/homelab/v1/homelab.pb.go | 441 ++++++++++++++++-- .../v1/homelabv1connect/homelab.connect.go | 39 +- proto/homelab/v1/homelab.proto | 34 ++ server/main.go | 290 +++++++++++- web/src/gen/homelab/v1/homelab_pb.ts | 182 +++++++- 6 files changed, 1062 insertions(+), 63 deletions(-) create mode 100644 SPEC.md diff --git a/SPEC.md b/SPEC.md new file mode 100644 index 0000000..fc251fd --- /dev/null +++ b/SPEC.md @@ -0,0 +1,139 @@ +# SPEC: hub — экран «Сервисы» + экран «Прокси (Caddy)» + +> Для агента-разработчика. Проект уже активен: Go API + Vite SPA, живёт под `hub`. +> Репозиторий — в Gitea на боксе, CI через act-runner (host-режим, от пользователя `homelab`). +> Факты окружения ниже проверены 2026-08-30 — менять не нужно, если не сказано иначе. + +## 1. Что за проект + +Личный хаб-дашборд для телефона (PWA). Три части: +1. **Клиент** — Vite SPA (сборка → `web/dist/`, раздаёт Caddy с SPA-fallback на `/index.html`). +2. **API** — Go, один бинарник `hub-api`, слушает **`127.0.0.1:8484`** (unit задаёт `PORT=8484`). +3. **Раздача/прокси** — Caddy, домен `hub.alexey-homelab.duckdns.org`: + - `/api/*` и `/homelab.v1.HomelabService/*` → `127.0.0.1:8484` + - всё остальное → `~/apps/hub/dist/` (SPA). + +Деплой: push в `main` → Gitea Actions (`deploy.workflow.yml`) → `make web server` → +`~/apps/hub/dist/` + `~/apps/hub/hub-api` (атомарный mv!) → `systemctl --user restart hub-api`. +Юнит сервиса: `~/srv/hub-api.service` (user, symlink в `~/.config/systemd/user/`). + +Уже есть в конфиге (`~/apps/hub/config.yaml`): `links` (карточки-ссылки), `monitor` +(cpu/disk/mem через скрипты), `exec` (greet). Это работает — не ломай. + +## 2. Задача: экран «Сервисы» + +Единый список всех сервисов бокса. Источник — **два разных мира**, их нужно объединить +в один список с полем `kind` и иконкой для отличия: + +- `kind: native` — бинарники под user-systemd (rathole, OliveTin, hermes-*, hub-api...) +- `kind: docker` — контейнеры Docker, сгруппированные по compose-проектам + +### 2.1. Native-сервисы + +- Список: парсить **`~/srv/*.service`** (каждый файл = сервис). Часть файлов — symlink'и + на `~/.config/systemd/user/*.service` — это нормально, читай через symlink. +- Статус (машиночитаемо, всегда exit 0, не путай с `status`): + ``` + systemctl --user show -p ActiveState,SubState,LoadState,UnitFileState --value + ``` +- Кнопки: `systemctl --user start|stop|restart ` — **без sudo**, от homelab. +- **Disabled-состояние**: если юнит не найден в systemd (`LoadState=not-found`) — это НЕ ошибка, + пользователь намеренно сделал `systemctl disable` (сейчас так: ntfy, olivetin, pairdrop, + uptime-kuma). Показывай бейдж «выключен» (disabled), серым, без алертов. + +### 2.2. Docker-сервисы + +- Список: **`docker ps -a`** (не юниты!). Контейнеров 37, юнитов на них нет/мало — источник + истины только Docker. +- Для каждого: `docker inspect -f '{{.State.Running}}|{{.State.Status}}' ` + + label проекта `com.docker.compose.project` (через `docker inspect -f '{{index .Config.Labels "com.docker.compose.project"}}'`). +- **Группировка**: контейнеры одного проекта — одна карточка проекта (itsaplan, pullmd, + affine, kurir, minepanel, searxng...), одиночные (gitea, homepage, samba...) — свои карточки. +- **Агрегированный статус проекта**: все рабочие running → «работает»; часть → «частично + (N/M)»; ни одного → «остановлен». Служебные контейнеры (`*-redis`, `*-postgres`, + `*-minio-init-1`, `affine_migration` — они `policy=no`, `running=false`) сворачивать, + не показывать как отдельные карточки. Иконка-отличие docker: 🐳 (или своя, по вкусу UI). +- Кнопки на проект: `docker start|stop <все контейнеры проекта>` (без sudo — homelab в группе docker). +- **Семантика stop**: `docker stop` + policy `unless-stopped` = контейнер не поднимется при + ребуте (docker считает его намеренно остановленным). Это ожидаемое поведение кнопки + «остановить до ребута» — задокументируй в UI тултипом, не «чини». + +### 2.3. Редактирование unit-файла в браузере + +- GET/PUT `~/srv/.service` (write — через symlink в реальный юнит, ок). +- **После сохранения — обязательно `systemctl --user daemon-reload`**, иначе правки не применятся. +- ⚠️ Валидация имени сервиса: только `^[a-z0-9_.-]+$` — защита от path traversal. +- Предупреждение в UI: правка unit = смена ExecStart = выполнение произвольных команд; + restart для docker-обёрток (`oneshot` + `RemainAfterExit`) = stop+start, а не restart. + +## 3. Задача: экран «Прокси» (Caddy) + +Минимум: отдельная страница со списком доменов и возможностью **руками редактировать конфиг**. +Идеал: на странице сервиса поле «домен» — но это позже, начни с отдельной страницы. + +**Как устроен Caddy на боксе (не менять схему!):** +- Рабочая копия конфига: **`~/caddy-sync/Caddyfile`** — её и правим (она в homelab-зоне, writable). +- Применение (NOPASSWD sudo уже настроен): + ``` + sudo -n /usr/local/sbin/caddy-sync # validate + копия в /etc/caddy/Caddyfile + backup + sudo -n /usr/local/sbin/caddy-reload # validate + reload + автоген ~/www/util/domains + sudo -n /usr/local/sbin/hosts-sync # /etc/hosts; ОБЯЗАТЕЛЕН при новом duckdns-домене + ``` +- Read: `sudo -n /usr/local/sbin/caddy-cat` (весь Caddyfile) или admin API + `http://127.0.0.1:2019/config/` (машиночитаемо, список хостов). +- **Запрещено трогать**: секцию ab-hl (внешний выход на VPS), rathole-туннели, knot-resolver. + Внешний VPS-выход — отдельная будущая задача. + +**UI страницы «Прокси»:** +- Таблица сайтов: домен → куда проксируется (или file_server) → статус (из списка выше). +- Кнопка «Редактировать» → редактор (textarea/monaco-лайт) с **рабочей копией** `~/caddy-sync/Caddyfile`. +- Кнопка «Применить» → `caddy-sync` → `caddy-reload` → `hosts-sync`, показать вывод каждой + команды (это твой «результат валидации» — caddy-sync сам вернёт REJECT при ошибке). +- После успеха — обновить таблицу. + +## 4. Контракт API (добавить в Go-бэкенд) + +Предлагаемые эндпоинты (паттерн REST, JSON; auth — как уже сделано в проекте): + +| Метод | Путь | Описание | +|---|---|---| +| GET | `/api/services` | список: `[{name, kind, group, status, substate, containers?}]` | +| POST | `/api/services/{name}/start` | native: systemctl start; docker: docker start (проект) | +| POST | `/api/services/{name}/stop` | аналогично | +| POST | `/api/services/{name}/restart` | native: restart; docker: stop+start | +| GET | `/api/services/{name}/file` | содержимое `~/srv/.service` | +| PUT | `/api/services/{name}/file` | сохранить + `daemon-reload` | +| GET | `/api/caddy` | список сайтов + рабочий конфиг | +| PUT | `/api/caddy` | сохранить рабочий конфиг | +| POST | `/api/caddy/apply` | sync → reload → hosts-sync, вернуть вывод | + +Имя `{name}` всегда валидировать: `^[a-z0-9_.-]+$`. Команды — фиксированные, без +интерполяции пользовательского ввода в shell (используй `exec.Command` с аргументами, +не shell-строку). + +## 5. Критерии приёмки + +1. Экран «Сервисы» показывает единый список: native из `~/srv` + docker-проекты из `docker ps -a`, + с иконками 🐳/⚙️ и агрегированными статусами. +2. Кнопки start/stop/restart работают для обоих видов без sudo; disabled-юниты показываются + серым как «выключен», не как ошибка. +3. Редактирование unit-файла сохраняет файл и делает daemon-reload; кривое имя сервиса не + проходит валидацию. +4. Экран «Прокси»: таблица доменов, редактор рабочей копии `~/caddy-sync/Caddyfile`, + «Применить» выполняет sync/reload/hosts-sync и показывает вывод; секция ab-hl не меняется. +5. Всё работает через существующий auth, без новых открытых портов; PWA-обновление + подхватывается на iOS (skipWaiting + clientsClaim, как в дизайн-доке). +6. Сборка/деплой проходят через существующий CI (`deploy.workflow.yml`), юнит `hub-api` переживает рестарт. + +## 6. Справочные факты бокса (не перепроверять) + +- Все сервисы живут от пользователя `homelab`; user-systemd: `systemctl --user` (XDG_RUNTIME_DIR + нужен только из root-сессий). +- `~/srv/README.md` — документация списка сервисов; обновить её, если меняется формат. +- Порт API: **8484** (не менять — Caddy смотрит туда). +- Сборка Go: `CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build` (тулчейн на боксе есть, + `~/apps/go/bin` в PATH CI). +- Фронт: Vite (node 22 / pnpm 11 в `~/.local/bin`). +- Сейчас в `~/srv` 26 юнитов: 18 docker-обёрток, 9 native (act-runner, hermes-dashboard, + hermes-gateway, hub-api, itsaplan-runner, olivetin, rathole, rathole-status, ttyd-rathole), + 4 disabled (ntfy, olivetin, pairdrop, uptime-kuma — LoadState=not-found, это норма). diff --git a/gen/homelab/v1/homelab.pb.go b/gen/homelab/v1/homelab.pb.go index 6caef11..3f17494 100644 --- a/gen/homelab/v1/homelab.pb.go +++ b/gen/homelab/v1/homelab.pb.go @@ -21,6 +21,102 @@ const ( _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) ) +// One card in the Services view: a systemd user unit or a docker compose project. +type ServiceKind int32 + +const ( + ServiceKind_SYSTEMD ServiceKind = 0 + ServiceKind_DOCKER ServiceKind = 1 +) + +// Enum value maps for ServiceKind. +var ( + ServiceKind_name = map[int32]string{ + 0: "SYSTEMD", + 1: "DOCKER", + } + ServiceKind_value = map[string]int32{ + "SYSTEMD": 0, + "DOCKER": 1, + } +) + +func (x ServiceKind) Enum() *ServiceKind { + p := new(ServiceKind) + *p = x + return p +} + +func (x ServiceKind) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (ServiceKind) Descriptor() protoreflect.EnumDescriptor { + return file_homelab_v1_homelab_proto_enumTypes[0].Descriptor() +} + +func (ServiceKind) Type() protoreflect.EnumType { + return &file_homelab_v1_homelab_proto_enumTypes[0] +} + +func (x ServiceKind) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use ServiceKind.Descriptor instead. +func (ServiceKind) EnumDescriptor() ([]byte, []int) { + return file_homelab_v1_homelab_proto_rawDescGZIP(), []int{0} +} + +type ServiceOp int32 + +const ( + ServiceOp_START ServiceOp = 0 + ServiceOp_STOP ServiceOp = 1 + ServiceOp_RESTART ServiceOp = 2 +) + +// Enum value maps for ServiceOp. +var ( + ServiceOp_name = map[int32]string{ + 0: "START", + 1: "STOP", + 2: "RESTART", + } + ServiceOp_value = map[string]int32{ + "START": 0, + "STOP": 1, + "RESTART": 2, + } +) + +func (x ServiceOp) Enum() *ServiceOp { + p := new(ServiceOp) + *p = x + return p +} + +func (x ServiceOp) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (ServiceOp) Descriptor() protoreflect.EnumDescriptor { + return file_homelab_v1_homelab_proto_enumTypes[1].Descriptor() +} + +func (ServiceOp) Type() protoreflect.EnumType { + return &file_homelab_v1_homelab_proto_enumTypes[1] +} + +func (x ServiceOp) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use ServiceOp.Descriptor instead. +func (ServiceOp) EnumDescriptor() ([]byte, []int) { + return file_homelab_v1_homelab_proto_rawDescGZIP(), []int{1} +} + type Item struct { state protoimpl.MessageState `protogen:"open.v1"` Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` @@ -190,6 +286,7 @@ type ListContentResponse struct { Links []*Link `protobuf:"bytes,1,rep,name=links,proto3" json:"links,omitempty"` Monitors []*Item `protobuf:"bytes,2,rep,name=monitors,proto3" json:"monitors,omitempty"` Exec []*Item `protobuf:"bytes,3,rep,name=exec,proto3" json:"exec,omitempty"` + Services []*Service `protobuf:"bytes,4,rep,name=services,proto3" json:"services,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -245,6 +342,13 @@ func (x *ListContentResponse) GetExec() []*Item { return nil } +func (x *ListContentResponse) GetServices() []*Service { + if x != nil { + return x.Services + } + return nil +} + type DocMeta struct { state protoimpl.MessageState `protogen:"open.v1"` Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` @@ -561,6 +665,210 @@ func (x *SaveDocResponse) GetHash() string { return "" } +type Service struct { + state protoimpl.MessageState `protogen:"open.v1"` + Kind ServiceKind `protobuf:"varint,1,opt,name=kind,proto3,enum=homelab.v1.ServiceKind" json:"kind,omitempty"` + Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` // unit name without .service / compose project name (or container name if unlabeled) + State string `protobuf:"bytes,3,opt,name=state,proto3" json:"state,omitempty"` // systemd: ActiveState. docker: active | partial | inactive + Substate string `protobuf:"bytes,4,opt,name=substate,proto3" json:"substate,omitempty"` // systemd: SubState. docker: "up/total" + LoadState string `protobuf:"bytes,5,opt,name=load_state,json=loadState,proto3" json:"load_state,omitempty"` // systemd only: loaded | not-found (= disabled by the user, not an error) + UnitFileState string `protobuf:"bytes,6,opt,name=unit_file_state,json=unitFileState,proto3" json:"unit_file_state,omitempty"` // systemd only: enabled | disabled | static + Containers []string `protobuf:"bytes,7,rep,name=containers,proto3" json:"containers,omitempty"` // docker only: containers the card controls + Error string `protobuf:"bytes,8,opt,name=error,proto3" json:"error,omitempty"` // probe failure, empty = ok + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Service) Reset() { + *x = Service{} + mi := &file_homelab_v1_homelab_proto_msgTypes[11] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Service) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Service) ProtoMessage() {} + +func (x *Service) ProtoReflect() protoreflect.Message { + mi := &file_homelab_v1_homelab_proto_msgTypes[11] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Service.ProtoReflect.Descriptor instead. +func (*Service) Descriptor() ([]byte, []int) { + return file_homelab_v1_homelab_proto_rawDescGZIP(), []int{11} +} + +func (x *Service) GetKind() ServiceKind { + if x != nil { + return x.Kind + } + return ServiceKind_SYSTEMD +} + +func (x *Service) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *Service) GetState() string { + if x != nil { + return x.State + } + return "" +} + +func (x *Service) GetSubstate() string { + if x != nil { + return x.Substate + } + return "" +} + +func (x *Service) GetLoadState() string { + if x != nil { + return x.LoadState + } + return "" +} + +func (x *Service) GetUnitFileState() string { + if x != nil { + return x.UnitFileState + } + return "" +} + +func (x *Service) GetContainers() []string { + if x != nil { + return x.Containers + } + return nil +} + +func (x *Service) GetError() string { + if x != nil { + return x.Error + } + return "" +} + +type ServiceActionRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + Kind ServiceKind `protobuf:"varint,2,opt,name=kind,proto3,enum=homelab.v1.ServiceKind" json:"kind,omitempty"` + Op ServiceOp `protobuf:"varint,3,opt,name=op,proto3,enum=homelab.v1.ServiceOp" json:"op,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ServiceActionRequest) Reset() { + *x = ServiceActionRequest{} + mi := &file_homelab_v1_homelab_proto_msgTypes[12] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ServiceActionRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ServiceActionRequest) ProtoMessage() {} + +func (x *ServiceActionRequest) ProtoReflect() protoreflect.Message { + mi := &file_homelab_v1_homelab_proto_msgTypes[12] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ServiceActionRequest.ProtoReflect.Descriptor instead. +func (*ServiceActionRequest) Descriptor() ([]byte, []int) { + return file_homelab_v1_homelab_proto_rawDescGZIP(), []int{12} +} + +func (x *ServiceActionRequest) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *ServiceActionRequest) GetKind() ServiceKind { + if x != nil { + return x.Kind + } + return ServiceKind_SYSTEMD +} + +func (x *ServiceActionRequest) GetOp() ServiceOp { + if x != nil { + return x.Op + } + return ServiceOp_START +} + +type ServiceActionResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Output string `protobuf:"bytes,1,opt,name=output,proto3" json:"output,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ServiceActionResponse) Reset() { + *x = ServiceActionResponse{} + mi := &file_homelab_v1_homelab_proto_msgTypes[13] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ServiceActionResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ServiceActionResponse) ProtoMessage() {} + +func (x *ServiceActionResponse) ProtoReflect() protoreflect.Message { + mi := &file_homelab_v1_homelab_proto_msgTypes[13] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ServiceActionResponse.ProtoReflect.Descriptor instead. +func (*ServiceActionResponse) Descriptor() ([]byte, []int) { + return file_homelab_v1_homelab_proto_rawDescGZIP(), []int{13} +} + +func (x *ServiceActionResponse) GetOutput() string { + if x != nil { + return x.Output + } + return "" +} + type RunScriptRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` @@ -571,7 +879,7 @@ type RunScriptRequest struct { func (x *RunScriptRequest) Reset() { *x = RunScriptRequest{} - mi := &file_homelab_v1_homelab_proto_msgTypes[11] + mi := &file_homelab_v1_homelab_proto_msgTypes[14] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -583,7 +891,7 @@ func (x *RunScriptRequest) String() string { func (*RunScriptRequest) ProtoMessage() {} func (x *RunScriptRequest) ProtoReflect() protoreflect.Message { - mi := &file_homelab_v1_homelab_proto_msgTypes[11] + mi := &file_homelab_v1_homelab_proto_msgTypes[14] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -596,7 +904,7 @@ func (x *RunScriptRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use RunScriptRequest.ProtoReflect.Descriptor instead. func (*RunScriptRequest) Descriptor() ([]byte, []int) { - return file_homelab_v1_homelab_proto_rawDescGZIP(), []int{11} + return file_homelab_v1_homelab_proto_rawDescGZIP(), []int{14} } func (x *RunScriptRequest) GetName() string { @@ -622,7 +930,7 @@ type RunScriptResponse struct { func (x *RunScriptResponse) Reset() { *x = RunScriptResponse{} - mi := &file_homelab_v1_homelab_proto_msgTypes[12] + mi := &file_homelab_v1_homelab_proto_msgTypes[15] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -634,7 +942,7 @@ func (x *RunScriptResponse) String() string { func (*RunScriptResponse) ProtoMessage() {} func (x *RunScriptResponse) ProtoReflect() protoreflect.Message { - mi := &file_homelab_v1_homelab_proto_msgTypes[12] + mi := &file_homelab_v1_homelab_proto_msgTypes[15] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -647,7 +955,7 @@ func (x *RunScriptResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use RunScriptResponse.ProtoReflect.Descriptor instead. func (*RunScriptResponse) Descriptor() ([]byte, []int) { - return file_homelab_v1_homelab_proto_rawDescGZIP(), []int{12} + return file_homelab_v1_homelab_proto_rawDescGZIP(), []int{15} } func (x *RunScriptResponse) GetOutput() string { @@ -672,11 +980,12 @@ const file_homelab_v1_homelab_proto_rawDesc = "" + "\x04Link\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12\x10\n" + "\x03url\x18\x02 \x01(\tR\x03url\"\x14\n" + - "\x12ListContentRequest\"\x91\x01\n" + + "\x12ListContentRequest\"\xc2\x01\n" + "\x13ListContentResponse\x12&\n" + "\x05links\x18\x01 \x03(\v2\x10.homelab.v1.LinkR\x05links\x12,\n" + "\bmonitors\x18\x02 \x03(\v2\x10.homelab.v1.ItemR\bmonitors\x12$\n" + - "\x04exec\x18\x03 \x03(\v2\x10.homelab.v1.ItemR\x04exec\"1\n" + + "\x04exec\x18\x03 \x03(\v2\x10.homelab.v1.ItemR\x04exec\x12/\n" + + "\bservices\x18\x04 \x03(\v2\x13.homelab.v1.ServiceR\bservices\"1\n" + "\aDocMeta\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12\x12\n" + "\x04hash\x18\x02 \x01(\tR\x04hash\"\x11\n" + @@ -691,17 +1000,44 @@ const file_homelab_v1_homelab_proto_rawDesc = "" + "\x04name\x18\x01 \x01(\tR\x04name\x12\x18\n" + "\acontent\x18\x02 \x01(\tR\acontent\"%\n" + "\x0fSaveDocResponse\x12\x12\n" + - "\x04hash\x18\x01 \x01(\tR\x04hash\":\n" + + "\x04hash\x18\x01 \x01(\tR\x04hash\"\xf9\x01\n" + + "\aService\x12+\n" + + "\x04kind\x18\x01 \x01(\x0e2\x17.homelab.v1.ServiceKindR\x04kind\x12\x12\n" + + "\x04name\x18\x02 \x01(\tR\x04name\x12\x14\n" + + "\x05state\x18\x03 \x01(\tR\x05state\x12\x1a\n" + + "\bsubstate\x18\x04 \x01(\tR\bsubstate\x12\x1d\n" + + "\n" + + "load_state\x18\x05 \x01(\tR\tloadState\x12&\n" + + "\x0funit_file_state\x18\x06 \x01(\tR\runitFileState\x12\x1e\n" + + "\n" + + "containers\x18\a \x03(\tR\n" + + "containers\x12\x14\n" + + "\x05error\x18\b \x01(\tR\x05error\"~\n" + + "\x14ServiceActionRequest\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12+\n" + + "\x04kind\x18\x02 \x01(\x0e2\x17.homelab.v1.ServiceKindR\x04kind\x12%\n" + + "\x02op\x18\x03 \x01(\x0e2\x15.homelab.v1.ServiceOpR\x02op\"/\n" + + "\x15ServiceActionResponse\x12\x16\n" + + "\x06output\x18\x01 \x01(\tR\x06output\":\n" + "\x10RunScriptRequest\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12\x12\n" + "\x04args\x18\x02 \x03(\tR\x04args\"+\n" + "\x11RunScriptResponse\x12\x16\n" + - "\x06output\x18\x01 \x01(\tR\x06output2\xf6\x02\n" + + "\x06output\x18\x01 \x01(\tR\x06output*&\n" + + "\vServiceKind\x12\v\n" + + "\aSYSTEMD\x10\x00\x12\n" + + "\n" + + "\x06DOCKER\x10\x01*-\n" + + "\tServiceOp\x12\t\n" + + "\x05START\x10\x00\x12\b\n" + + "\x04STOP\x10\x01\x12\v\n" + + "\aRESTART\x10\x022\xcc\x03\n" + "\x0eHomelabService\x12N\n" + "\vListContent\x12\x1e.homelab.v1.ListContentRequest\x1a\x1f.homelab.v1.ListContentResponse\x12E\n" + "\bListDocs\x12\x1b.homelab.v1.ListDocsRequest\x1a\x1c.homelab.v1.ListDocsResponse\x12?\n" + "\x06GetDoc\x12\x19.homelab.v1.GetDocRequest\x1a\x1a.homelab.v1.GetDocResponse\x12B\n" + - "\aSaveDoc\x12\x1a.homelab.v1.SaveDocRequest\x1a\x1b.homelab.v1.SaveDocResponse\x12H\n" + + "\aSaveDoc\x12\x1a.homelab.v1.SaveDocRequest\x1a\x1b.homelab.v1.SaveDocResponse\x12T\n" + + "\rServiceAction\x12 .homelab.v1.ServiceActionRequest\x1a!.homelab.v1.ServiceActionResponse\x12H\n" + "\tRunScript\x12\x1c.homelab.v1.RunScriptRequest\x1a\x1d.homelab.v1.RunScriptResponseB*Z(pwa-homelab-mon/gen/homelab/v1;homelabv1b\x06proto3" var ( @@ -716,42 +1052,54 @@ func file_homelab_v1_homelab_proto_rawDescGZIP() []byte { return file_homelab_v1_homelab_proto_rawDescData } -var file_homelab_v1_homelab_proto_msgTypes = make([]protoimpl.MessageInfo, 13) +var file_homelab_v1_homelab_proto_enumTypes = make([]protoimpl.EnumInfo, 2) +var file_homelab_v1_homelab_proto_msgTypes = make([]protoimpl.MessageInfo, 16) var file_homelab_v1_homelab_proto_goTypes = []any{ - (*Item)(nil), // 0: homelab.v1.Item - (*Link)(nil), // 1: homelab.v1.Link - (*ListContentRequest)(nil), // 2: homelab.v1.ListContentRequest - (*ListContentResponse)(nil), // 3: homelab.v1.ListContentResponse - (*DocMeta)(nil), // 4: homelab.v1.DocMeta - (*ListDocsRequest)(nil), // 5: homelab.v1.ListDocsRequest - (*ListDocsResponse)(nil), // 6: homelab.v1.ListDocsResponse - (*GetDocRequest)(nil), // 7: homelab.v1.GetDocRequest - (*GetDocResponse)(nil), // 8: homelab.v1.GetDocResponse - (*SaveDocRequest)(nil), // 9: homelab.v1.SaveDocRequest - (*SaveDocResponse)(nil), // 10: homelab.v1.SaveDocResponse - (*RunScriptRequest)(nil), // 11: homelab.v1.RunScriptRequest - (*RunScriptResponse)(nil), // 12: homelab.v1.RunScriptResponse + (ServiceKind)(0), // 0: homelab.v1.ServiceKind + (ServiceOp)(0), // 1: homelab.v1.ServiceOp + (*Item)(nil), // 2: homelab.v1.Item + (*Link)(nil), // 3: homelab.v1.Link + (*ListContentRequest)(nil), // 4: homelab.v1.ListContentRequest + (*ListContentResponse)(nil), // 5: homelab.v1.ListContentResponse + (*DocMeta)(nil), // 6: homelab.v1.DocMeta + (*ListDocsRequest)(nil), // 7: homelab.v1.ListDocsRequest + (*ListDocsResponse)(nil), // 8: homelab.v1.ListDocsResponse + (*GetDocRequest)(nil), // 9: homelab.v1.GetDocRequest + (*GetDocResponse)(nil), // 10: homelab.v1.GetDocResponse + (*SaveDocRequest)(nil), // 11: homelab.v1.SaveDocRequest + (*SaveDocResponse)(nil), // 12: homelab.v1.SaveDocResponse + (*Service)(nil), // 13: homelab.v1.Service + (*ServiceActionRequest)(nil), // 14: homelab.v1.ServiceActionRequest + (*ServiceActionResponse)(nil), // 15: homelab.v1.ServiceActionResponse + (*RunScriptRequest)(nil), // 16: homelab.v1.RunScriptRequest + (*RunScriptResponse)(nil), // 17: homelab.v1.RunScriptResponse } var file_homelab_v1_homelab_proto_depIdxs = []int32{ - 1, // 0: homelab.v1.ListContentResponse.links:type_name -> homelab.v1.Link - 0, // 1: homelab.v1.ListContentResponse.monitors:type_name -> homelab.v1.Item - 0, // 2: homelab.v1.ListContentResponse.exec:type_name -> homelab.v1.Item - 4, // 3: homelab.v1.ListDocsResponse.docs:type_name -> homelab.v1.DocMeta - 2, // 4: homelab.v1.HomelabService.ListContent:input_type -> homelab.v1.ListContentRequest - 5, // 5: homelab.v1.HomelabService.ListDocs:input_type -> homelab.v1.ListDocsRequest - 7, // 6: homelab.v1.HomelabService.GetDoc:input_type -> homelab.v1.GetDocRequest - 9, // 7: homelab.v1.HomelabService.SaveDoc:input_type -> homelab.v1.SaveDocRequest - 11, // 8: homelab.v1.HomelabService.RunScript:input_type -> homelab.v1.RunScriptRequest - 3, // 9: homelab.v1.HomelabService.ListContent:output_type -> homelab.v1.ListContentResponse - 6, // 10: homelab.v1.HomelabService.ListDocs:output_type -> homelab.v1.ListDocsResponse - 8, // 11: homelab.v1.HomelabService.GetDoc:output_type -> homelab.v1.GetDocResponse - 10, // 12: homelab.v1.HomelabService.SaveDoc:output_type -> homelab.v1.SaveDocResponse - 12, // 13: homelab.v1.HomelabService.RunScript:output_type -> homelab.v1.RunScriptResponse - 9, // [9:14] is the sub-list for method output_type - 4, // [4:9] is the sub-list for method input_type - 4, // [4:4] is the sub-list for extension type_name - 4, // [4:4] is the sub-list for extension extendee - 0, // [0:4] is the sub-list for field type_name + 3, // 0: homelab.v1.ListContentResponse.links:type_name -> homelab.v1.Link + 2, // 1: homelab.v1.ListContentResponse.monitors:type_name -> homelab.v1.Item + 2, // 2: homelab.v1.ListContentResponse.exec:type_name -> homelab.v1.Item + 13, // 3: homelab.v1.ListContentResponse.services:type_name -> homelab.v1.Service + 6, // 4: homelab.v1.ListDocsResponse.docs:type_name -> homelab.v1.DocMeta + 0, // 5: homelab.v1.Service.kind:type_name -> homelab.v1.ServiceKind + 0, // 6: homelab.v1.ServiceActionRequest.kind:type_name -> homelab.v1.ServiceKind + 1, // 7: homelab.v1.ServiceActionRequest.op:type_name -> homelab.v1.ServiceOp + 4, // 8: homelab.v1.HomelabService.ListContent:input_type -> homelab.v1.ListContentRequest + 7, // 9: homelab.v1.HomelabService.ListDocs:input_type -> homelab.v1.ListDocsRequest + 9, // 10: homelab.v1.HomelabService.GetDoc:input_type -> homelab.v1.GetDocRequest + 11, // 11: homelab.v1.HomelabService.SaveDoc:input_type -> homelab.v1.SaveDocRequest + 14, // 12: homelab.v1.HomelabService.ServiceAction:input_type -> homelab.v1.ServiceActionRequest + 16, // 13: homelab.v1.HomelabService.RunScript:input_type -> homelab.v1.RunScriptRequest + 5, // 14: homelab.v1.HomelabService.ListContent:output_type -> homelab.v1.ListContentResponse + 8, // 15: homelab.v1.HomelabService.ListDocs:output_type -> homelab.v1.ListDocsResponse + 10, // 16: homelab.v1.HomelabService.GetDoc:output_type -> homelab.v1.GetDocResponse + 12, // 17: homelab.v1.HomelabService.SaveDoc:output_type -> homelab.v1.SaveDocResponse + 15, // 18: homelab.v1.HomelabService.ServiceAction:output_type -> homelab.v1.ServiceActionResponse + 17, // 19: homelab.v1.HomelabService.RunScript:output_type -> homelab.v1.RunScriptResponse + 14, // [14:20] is the sub-list for method output_type + 8, // [8:14] is the sub-list for method input_type + 8, // [8:8] is the sub-list for extension type_name + 8, // [8:8] is the sub-list for extension extendee + 0, // [0:8] is the sub-list for field type_name } func init() { file_homelab_v1_homelab_proto_init() } @@ -764,13 +1112,14 @@ func file_homelab_v1_homelab_proto_init() { File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_homelab_v1_homelab_proto_rawDesc), len(file_homelab_v1_homelab_proto_rawDesc)), - NumEnums: 0, - NumMessages: 13, + NumEnums: 2, + NumMessages: 16, NumExtensions: 0, NumServices: 1, }, GoTypes: file_homelab_v1_homelab_proto_goTypes, DependencyIndexes: file_homelab_v1_homelab_proto_depIdxs, + EnumInfos: file_homelab_v1_homelab_proto_enumTypes, MessageInfos: file_homelab_v1_homelab_proto_msgTypes, }.Build() File_homelab_v1_homelab_proto = out.File diff --git a/gen/homelab/v1/homelabv1connect/homelab.connect.go b/gen/homelab/v1/homelabv1connect/homelab.connect.go index a725fa5..de3ffb8 100644 --- a/gen/homelab/v1/homelabv1connect/homelab.connect.go +++ b/gen/homelab/v1/homelabv1connect/homelab.connect.go @@ -42,6 +42,9 @@ const ( HomelabServiceGetDocProcedure = "/homelab.v1.HomelabService/GetDoc" // HomelabServiceSaveDocProcedure is the fully-qualified name of the HomelabService's SaveDoc RPC. HomelabServiceSaveDocProcedure = "/homelab.v1.HomelabService/SaveDoc" + // HomelabServiceServiceActionProcedure is the fully-qualified name of the HomelabService's + // ServiceAction RPC. + HomelabServiceServiceActionProcedure = "/homelab.v1.HomelabService/ServiceAction" // HomelabServiceRunScriptProcedure is the fully-qualified name of the HomelabService's RunScript // RPC. HomelabServiceRunScriptProcedure = "/homelab.v1.HomelabService/RunScript" @@ -53,6 +56,7 @@ type HomelabServiceClient interface { ListDocs(context.Context, *connect.Request[v1.ListDocsRequest]) (*connect.Response[v1.ListDocsResponse], error) GetDoc(context.Context, *connect.Request[v1.GetDocRequest]) (*connect.Response[v1.GetDocResponse], error) SaveDoc(context.Context, *connect.Request[v1.SaveDocRequest]) (*connect.Response[v1.SaveDocResponse], error) + ServiceAction(context.Context, *connect.Request[v1.ServiceActionRequest]) (*connect.Response[v1.ServiceActionResponse], error) RunScript(context.Context, *connect.Request[v1.RunScriptRequest]) (*connect.Response[v1.RunScriptResponse], error) } @@ -91,6 +95,12 @@ func NewHomelabServiceClient(httpClient connect.HTTPClient, baseURL string, opts connect.WithSchema(homelabServiceMethods.ByName("SaveDoc")), connect.WithClientOptions(opts...), ), + serviceAction: connect.NewClient[v1.ServiceActionRequest, v1.ServiceActionResponse]( + httpClient, + baseURL+HomelabServiceServiceActionProcedure, + connect.WithSchema(homelabServiceMethods.ByName("ServiceAction")), + connect.WithClientOptions(opts...), + ), runScript: connect.NewClient[v1.RunScriptRequest, v1.RunScriptResponse]( httpClient, baseURL+HomelabServiceRunScriptProcedure, @@ -102,11 +112,12 @@ func NewHomelabServiceClient(httpClient connect.HTTPClient, baseURL string, opts // homelabServiceClient implements HomelabServiceClient. type homelabServiceClient struct { - listContent *connect.Client[v1.ListContentRequest, v1.ListContentResponse] - listDocs *connect.Client[v1.ListDocsRequest, v1.ListDocsResponse] - getDoc *connect.Client[v1.GetDocRequest, v1.GetDocResponse] - saveDoc *connect.Client[v1.SaveDocRequest, v1.SaveDocResponse] - runScript *connect.Client[v1.RunScriptRequest, v1.RunScriptResponse] + listContent *connect.Client[v1.ListContentRequest, v1.ListContentResponse] + listDocs *connect.Client[v1.ListDocsRequest, v1.ListDocsResponse] + getDoc *connect.Client[v1.GetDocRequest, v1.GetDocResponse] + saveDoc *connect.Client[v1.SaveDocRequest, v1.SaveDocResponse] + serviceAction *connect.Client[v1.ServiceActionRequest, v1.ServiceActionResponse] + runScript *connect.Client[v1.RunScriptRequest, v1.RunScriptResponse] } // ListContent calls homelab.v1.HomelabService.ListContent. @@ -129,6 +140,11 @@ func (c *homelabServiceClient) SaveDoc(ctx context.Context, req *connect.Request return c.saveDoc.CallUnary(ctx, req) } +// ServiceAction calls homelab.v1.HomelabService.ServiceAction. +func (c *homelabServiceClient) ServiceAction(ctx context.Context, req *connect.Request[v1.ServiceActionRequest]) (*connect.Response[v1.ServiceActionResponse], error) { + return c.serviceAction.CallUnary(ctx, req) +} + // RunScript calls homelab.v1.HomelabService.RunScript. func (c *homelabServiceClient) RunScript(ctx context.Context, req *connect.Request[v1.RunScriptRequest]) (*connect.Response[v1.RunScriptResponse], error) { return c.runScript.CallUnary(ctx, req) @@ -140,6 +156,7 @@ type HomelabServiceHandler interface { ListDocs(context.Context, *connect.Request[v1.ListDocsRequest]) (*connect.Response[v1.ListDocsResponse], error) GetDoc(context.Context, *connect.Request[v1.GetDocRequest]) (*connect.Response[v1.GetDocResponse], error) SaveDoc(context.Context, *connect.Request[v1.SaveDocRequest]) (*connect.Response[v1.SaveDocResponse], error) + ServiceAction(context.Context, *connect.Request[v1.ServiceActionRequest]) (*connect.Response[v1.ServiceActionResponse], error) RunScript(context.Context, *connect.Request[v1.RunScriptRequest]) (*connect.Response[v1.RunScriptResponse], error) } @@ -174,6 +191,12 @@ func NewHomelabServiceHandler(svc HomelabServiceHandler, opts ...connect.Handler connect.WithSchema(homelabServiceMethods.ByName("SaveDoc")), connect.WithHandlerOptions(opts...), ) + homelabServiceServiceActionHandler := connect.NewUnaryHandler( + HomelabServiceServiceActionProcedure, + svc.ServiceAction, + connect.WithSchema(homelabServiceMethods.ByName("ServiceAction")), + connect.WithHandlerOptions(opts...), + ) homelabServiceRunScriptHandler := connect.NewUnaryHandler( HomelabServiceRunScriptProcedure, svc.RunScript, @@ -190,6 +213,8 @@ func NewHomelabServiceHandler(svc HomelabServiceHandler, opts ...connect.Handler homelabServiceGetDocHandler.ServeHTTP(w, r) case HomelabServiceSaveDocProcedure: homelabServiceSaveDocHandler.ServeHTTP(w, r) + case HomelabServiceServiceActionProcedure: + homelabServiceServiceActionHandler.ServeHTTP(w, r) case HomelabServiceRunScriptProcedure: homelabServiceRunScriptHandler.ServeHTTP(w, r) default: @@ -217,6 +242,10 @@ func (UnimplementedHomelabServiceHandler) SaveDoc(context.Context, *connect.Requ return nil, connect.NewError(connect.CodeUnimplemented, errors.New("homelab.v1.HomelabService.SaveDoc is not implemented")) } +func (UnimplementedHomelabServiceHandler) ServiceAction(context.Context, *connect.Request[v1.ServiceActionRequest]) (*connect.Response[v1.ServiceActionResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("homelab.v1.HomelabService.ServiceAction is not implemented")) +} + func (UnimplementedHomelabServiceHandler) RunScript(context.Context, *connect.Request[v1.RunScriptRequest]) (*connect.Response[v1.RunScriptResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("homelab.v1.HomelabService.RunScript is not implemented")) } diff --git a/proto/homelab/v1/homelab.proto b/proto/homelab/v1/homelab.proto index 8ad2bfc..74e7bc4 100644 --- a/proto/homelab/v1/homelab.proto +++ b/proto/homelab/v1/homelab.proto @@ -22,6 +22,7 @@ message ListContentResponse { repeated Link links = 1; repeated Item monitors = 2; repeated Item exec = 3; + repeated Service services = 4; } message DocMeta { @@ -49,11 +50,44 @@ message SaveDocResponse { string hash = 1; } +// One card in the Services view: a systemd user unit or a docker compose project. +enum ServiceKind { + SYSTEMD = 0; + DOCKER = 1; +} + +message Service { + ServiceKind kind = 1; + string name = 2; // unit name without .service / compose project name (or container name if unlabeled) + string state = 3; // systemd: ActiveState. docker: active | partial | inactive + string substate = 4; // systemd: SubState. docker: "up/total" + string load_state = 5; // systemd only: loaded | not-found (= disabled by the user, not an error) + string unit_file_state = 6; // systemd only: enabled | disabled | static + repeated string containers = 7; // docker only: containers the card controls + string error = 8; // probe failure, empty = ok +} + +enum ServiceOp { + START = 0; + STOP = 1; + RESTART = 2; +} + +message ServiceActionRequest { + string name = 1; + ServiceKind kind = 2; + ServiceOp op = 3; +} +message ServiceActionResponse { + string output = 1; +} + service HomelabService { rpc ListContent(ListContentRequest) returns (ListContentResponse); rpc ListDocs(ListDocsRequest) returns (ListDocsResponse); rpc GetDoc(GetDocRequest) returns (GetDocResponse); rpc SaveDoc(SaveDocRequest) returns (SaveDocResponse); + rpc ServiceAction(ServiceActionRequest) returns (ServiceActionResponse); rpc RunScript(RunScriptRequest) returns (RunScriptResponse); } diff --git a/server/main.go b/server/main.go index 729abb6..9966cba 100644 --- a/server/main.go +++ b/server/main.go @@ -10,6 +10,8 @@ import ( "os" "os/exec" "path/filepath" + "regexp" + "sort" "strings" "sync" "time" @@ -95,10 +97,12 @@ func loadConfig(path string) (*Config, error) { // --- service --- type server struct { - cfg *Config - exec map[string]*Item // by item name, read-only after start - monVals sync.Map // monitor item name -> string (last output) - docsDir string + cfg *Config + 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 } const execTimeout = 10 * time.Second @@ -198,9 +202,205 @@ func (s *server) SaveDoc(_ context.Context, req *connect.Request[homelabv1.SaveD return connect.NewResponse(&homelabv1.SaveDocResponse{Hash: docHash(content)}), nil } +// --- services: systemd user units + docker containers, one flat list --- + +// Service names double as file/unit/container selectors — no separators, no traversal. +var svcNameRe = regexp.MustCompile(`^[a-z0-9_.-]+$`) + +const svcTimeout = 8 * time.Second + +func (s *server) services(ctx context.Context) []*homelabv1.Service { + return append(s.systemdServices(ctx), s.dockerServices(ctx)...) +} + +// 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) { + prev, loaded := s.probeErr.LoadOrStore(key, msg) + if !loaded { + if msg != "" { + log.Printf("probe %s: %s", key, msg) + } + return + } + if prev.(string) == msg { + return + } + s.probeErr.Store(key, msg) + if msg == "" { + log.Printf("probe %s: recovered", key) + } else { + log.Printf("probe %s: %s", key, msg) + } +} + +func (s *server) units() ([]string, error) { + entries, err := os.ReadDir(s.srvDir) + if err != nil { + return nil, err + } + units := make([]string, 0, len(entries)) + for _, e := range entries { + if n := strings.TrimSuffix(e.Name(), ".service"); n != e.Name() && svcNameRe.MatchString(n) { + units = append(units, n) + } + } + sort.Strings(units) + return units, nil +} + +func (s *server) systemdServices(ctx context.Context) []*homelabv1.Service { + units, err := s.units() + if err != nil { + s.logProbeChange("units", err.Error()) + return []*homelabv1.Service{{Kind: homelabv1.ServiceKind_SYSTEMD, Name: s.srvDir, Error: err.Error()}} + } + s.logProbeChange("units", "") + svcs := make([]*homelabv1.Service, 0, len(units)) + for _, u := range units { + svcs = append(svcs, s.systemdStatus(ctx, u)) + } + return svcs +} + +// ponytail: one systemctl per unit (~26 per 3s poll, ~10ms each). Batch into a single +// `systemctl show u1 u2 ... -p ...` if the poll ever shows up in a slow trace. +func (s *server) systemdStatus(ctx context.Context, unit string) *homelabv1.Service { + svc := &homelabv1.Service{Kind: homelabv1.ServiceKind_SYSTEMD, Name: unit} + ctx, cancel := context.WithTimeout(ctx, svcTimeout) + defer cancel() + // show is the machine-readable probe (always exit 0, unlike status). LoadState=not-found means the user + // disabled the unit on purpose — a state, not an error. + out, err := exec.CommandContext(ctx, "systemctl", "--user", "show", unit+".service", + "-p", "ActiveState,SubState,LoadState,UnitFileState", "--value").Output() + // one line per property, in the order given to -p + v := strings.Split(strings.TrimRight(string(out), "\n"), "\n") + at := func(i int) string { + if i < len(v) { + return strings.TrimSpace(v[i]) + } + return "" + } + svc.State, svc.Substate, svc.LoadState, svc.UnitFileState = at(0), at(1), at(2), at(3) + if svc.LoadState == "" { + svc.LoadState = "not-found" + } + if err != nil { + svc.Error = err.Error() + s.logProbeChange("systemctl/"+unit, svc.Error) + } else { + s.logProbeChange("systemctl/"+unit, "") + } + return svc +} + +// dockerC is one container as seen by `docker inspect`. +type dockerC struct { + name, project, status, policy string +} + +func dockerOut(ctx context.Context, args ...string) (string, error) { + ctx, cancel := context.WithTimeout(ctx, svcTimeout) + defer cancel() + out, err := exec.CommandContext(ctx, "docker", args...).Output() + return string(out), err +} + +func (s *server) dockerContainers(ctx context.Context) ([]dockerC, error) { + ids, err := dockerOut(ctx, "ps", "-aq") + if err != nil { + return nil, err + } + if strings.TrimSpace(ids) == "" { + return nil, nil + } + out, err := dockerOut(ctx, append([]string{"inspect", "-f", + `{{index .Config.Labels "com.docker.compose.project"}}|{{.Name}}|{{.State.Status}}|{{.HostConfig.RestartPolicy.Name}}`}, + strings.Fields(ids)...)...) + if err != nil { + return nil, err + } + var cs []dockerC + for _, line := range strings.Split(strings.TrimSpace(out), "\n") { + f := strings.Split(line, "|") + if len(f) != 4 { + continue + } + cs = append(cs, dockerC{project: f[0], name: strings.TrimPrefix(f[1], "/"), status: f[2], policy: f[3]}) + } + sort.Slice(cs, func(i, j int) bool { return cs[i].name < cs[j].name }) + return cs, nil +} + +// working drops one-shot helpers (restart policy no + already exited: *-init, migrations). They belong to +// their project's card but must not drag its aggregate down, and must not be re-run by the start button. +func working(cs []dockerC) []dockerC { + out := make([]dockerC, 0, len(cs)) + for _, c := range cs { + if c.policy == "no" && c.status != "running" { + continue + } + out = append(out, c) + } + return out +} + +func (s *server) dockerServices(ctx context.Context) []*homelabv1.Service { + cs, err := s.dockerContainers(ctx) + if err != nil { + s.logProbeChange("docker", err.Error()) + return []*homelabv1.Service{{Kind: homelabv1.ServiceKind_DOCKER, Name: "docker", Error: err.Error()}} + } + s.logProbeChange("docker", "") + groups := map[string][]dockerC{} + var keys []string + for _, c := range cs { + key := c.project + if key == "" { + key = c.name // docker run without a compose label -> its own card + } + if _, ok := groups[key]; !ok { + keys = append(keys, key) + } + groups[key] = append(groups[key], c) + } + sort.Strings(keys) + svcs := make([]*homelabv1.Service, 0, len(keys)) + for _, k := range keys { + if svc := dockerCard(k, working(groups[k])); svc != nil { + svcs = append(svcs, svc) + } + } + return svcs +} + +// dockerCard aggregates a project: all running -> active, none -> inactive, else partial (up/total). +func dockerCard(name string, cs []dockerC) *homelabv1.Service { + if len(cs) == 0 { + return nil + } + up := 0 + names := make([]string, 0, len(cs)) + for _, c := range cs { + if c.status == "running" { + up++ + } + names = append(names, c.name) + } + state := "partial" + switch { + case up == len(cs): + state = "active" + case up == 0: + state = "inactive" + } + return &homelabv1.Service{Kind: homelabv1.ServiceKind_DOCKER, Name: name, State: state, + Substate: fmt.Sprintf("%d/%d", up, len(cs)), Containers: names} +} + // --- content RPC --- -func (s *server) ListContent(context.Context, *connect.Request[homelabv1.ListContentRequest]) (*connect.Response[homelabv1.ListContentResponse], error) { +func (s *server) ListContent(ctx context.Context, _ *connect.Request[homelabv1.ListContentRequest]) (*connect.Response[homelabv1.ListContentResponse], error) { resp := &homelabv1.ListContentResponse{} for _, l := range s.cfg.Links { resp.Links = append(resp.Links, &homelabv1.Link{Name: l.Name, Url: l.URL}) @@ -215,9 +415,72 @@ func (s *server) ListContent(context.Context, *connect.Request[homelabv1.ListCon it := &s.cfg.Exec[i] resp.Exec = append(resp.Exec, &homelabv1.Item{Name: it.Name, Args: int32(it.Args), ArgTypes: it.ArgTypes}) } + resp.Services = s.services(ctx) return connect.NewResponse(resp), nil } +// ServiceAction runs a fixed systemctl/docker command; only `name` is user input and it is regex-checked, +// never interpolated into a shell string. +func (s *server) ServiceAction(ctx context.Context, req *connect.Request[homelabv1.ServiceActionRequest]) (*connect.Response[homelabv1.ServiceActionResponse], error) { + name, kind := req.Msg.GetName(), req.Msg.GetKind() + if !svcNameRe.MatchString(name) { + return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("bad service name %q", name)) + } + var args []string + switch req.Msg.GetOp() { + case homelabv1.ServiceOp_START: + args = []string{"start"} + case homelabv1.ServiceOp_STOP: + args = []string{"stop"} + case homelabv1.ServiceOp_RESTART: + args = []string{"restart"} + default: + return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("bad op for %q", name)) + } + + ctx, cancel := context.WithTimeout(ctx, svcTimeout) + defer cancel() + + var cmd *exec.Cmd + if kind == homelabv1.ServiceKind_DOCKER { + targets, err := s.dockerTargets(ctx, name, args[0]) + if err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + if len(targets) == 0 { + return connect.NewResponse(&homelabv1.ServiceActionResponse{Output: "no containers to " + args[0]}), nil + } + cmd = exec.CommandContext(ctx, "docker", append([]string{args[0]}, targets...)...) + } else { + cmd = exec.CommandContext(ctx, "systemctl", "--user", args[0], name+".service") + } + out, err := cmd.CombinedOutput() + if err != nil { + log.Printf("service %s/%s %s: ERROR %v: %s", kind, name, args[0], err, out) + return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("%v: %s", err, out)) + } + log.Printf("service %s/%s %s -> %s", kind, name, args[0], strings.TrimSpace(string(out))) + return connect.NewResponse(&homelabv1.ServiceActionResponse{Output: strings.TrimSpace(string(out))}), nil +} + +// dockerTargets resolves a card name (compose project, or bare container name) to the containers an op applies to. +func (s *server) dockerTargets(ctx context.Context, name, op string) ([]string, error) { + cs, err := s.dockerContainers(ctx) + if err != nil { + return nil, err + } + var targets []string + for _, c := range working(cs) { + if c.project != name && c.name != name { + continue + } + if running := c.status == "running"; (op == "start" && !running) || (op != "start" && running) { + targets = append(targets, c.name) + } + } + return targets, nil +} + func (s *server) RunScript(_ context.Context, req *connect.Request[homelabv1.RunScriptRequest]) (*connect.Response[homelabv1.RunScriptResponse], error) { it, ok := s.exec[req.Msg.GetName()] if !ok { @@ -254,13 +517,13 @@ func main() { os.Exit(1) } - svc := &server{cfg: cfg, exec: map[string]*Item{}, docsDir: docsDir} + svc := &server{cfg: cfg, exec: map[string]*Item{}, docsDir: docsDir, srvDir: srvDir()} 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", cfgPath, len(cfg.Links), len(cfg.Monitor), len(cfg.Exec)) + 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 @@ -302,3 +565,16 @@ func port() string { } 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") +} diff --git a/web/src/gen/homelab/v1/homelab_pb.ts b/web/src/gen/homelab/v1/homelab_pb.ts index e5bbbcf..087723c 100644 --- a/web/src/gen/homelab/v1/homelab_pb.ts +++ b/web/src/gen/homelab/v1/homelab_pb.ts @@ -2,15 +2,15 @@ // @generated from file homelab/v1/homelab.proto (package homelab.v1, syntax proto3) /* eslint-disable */ -import type { GenFile, GenMessage, GenService } from "@bufbuild/protobuf/codegenv2"; -import { fileDesc, messageDesc, serviceDesc } from "@bufbuild/protobuf/codegenv2"; +import type { GenEnum, GenFile, GenMessage, GenService } from "@bufbuild/protobuf/codegenv2"; +import { enumDesc, fileDesc, messageDesc, serviceDesc } from "@bufbuild/protobuf/codegenv2"; import type { Message } from "@bufbuild/protobuf"; /** * Describes the file homelab/v1/homelab.proto. */ export const file_homelab_v1_homelab: GenFile = /*@__PURE__*/ - fileDesc("Chhob21lbGFiL3YxL2hvbWVsYWIucHJvdG8SCmhvbWVsYWIudjEiUgoESXRlbRIMCgRuYW1lGAEgASgJEgwKBGRhdGEYAiABKAkSDQoFdmFsdWUYAyABKAkSDAoEYXJncxgEIAEoBRIRCglhcmdfdHlwZXMYBSADKAkiIQoETGluaxIMCgRuYW1lGAEgASgJEgsKA3VybBgCIAEoCSIUChJMaXN0Q29udGVudFJlcXVlc3QiegoTTGlzdENvbnRlbnRSZXNwb25zZRIfCgVsaW5rcxgBIAMoCzIQLmhvbWVsYWIudjEuTGluaxIiCghtb25pdG9ycxgCIAMoCzIQLmhvbWVsYWIudjEuSXRlbRIeCgRleGVjGAMgAygLMhAuaG9tZWxhYi52MS5JdGVtIiUKB0RvY01ldGESDAoEbmFtZRgBIAEoCRIMCgRoYXNoGAIgASgJIhEKD0xpc3REb2NzUmVxdWVzdCI1ChBMaXN0RG9jc1Jlc3BvbnNlEiEKBGRvY3MYASADKAsyEy5ob21lbGFiLnYxLkRvY01ldGEiHQoNR2V0RG9jUmVxdWVzdBIMCgRuYW1lGAEgASgJIiEKDkdldERvY1Jlc3BvbnNlEg8KB2NvbnRlbnQYASABKAkiLwoOU2F2ZURvY1JlcXVlc3QSDAoEbmFtZRgBIAEoCRIPCgdjb250ZW50GAIgASgJIh8KD1NhdmVEb2NSZXNwb25zZRIMCgRoYXNoGAEgASgJIi4KEFJ1blNjcmlwdFJlcXVlc3QSDAoEbmFtZRgBIAEoCRIMCgRhcmdzGAIgAygJIiMKEVJ1blNjcmlwdFJlc3BvbnNlEg4KBm91dHB1dBgBIAEoCTL2AgoOSG9tZWxhYlNlcnZpY2USTgoLTGlzdENvbnRlbnQSHi5ob21lbGFiLnYxLkxpc3RDb250ZW50UmVxdWVzdBofLmhvbWVsYWIudjEuTGlzdENvbnRlbnRSZXNwb25zZRJFCghMaXN0RG9jcxIbLmhvbWVsYWIudjEuTGlzdERvY3NSZXF1ZXN0GhwuaG9tZWxhYi52MS5MaXN0RG9jc1Jlc3BvbnNlEj8KBkdldERvYxIZLmhvbWVsYWIudjEuR2V0RG9jUmVxdWVzdBoaLmhvbWVsYWIudjEuR2V0RG9jUmVzcG9uc2USQgoHU2F2ZURvYxIaLmhvbWVsYWIudjEuU2F2ZURvY1JlcXVlc3QaGy5ob21lbGFiLnYxLlNhdmVEb2NSZXNwb25zZRJICglSdW5TY3JpcHQSHC5ob21lbGFiLnYxLlJ1blNjcmlwdFJlcXVlc3QaHS5ob21lbGFiLnYxLlJ1blNjcmlwdFJlc3BvbnNlQipaKHB3YS1ob21lbGFiLW1vbi9nZW4vaG9tZWxhYi92MTtob21lbGFidjFiBnByb3RvMw"); + fileDesc("Chhob21lbGFiL3YxL2hvbWVsYWIucHJvdG8SCmhvbWVsYWIudjEiUgoESXRlbRIMCgRuYW1lGAEgASgJEgwKBGRhdGEYAiABKAkSDQoFdmFsdWUYAyABKAkSDAoEYXJncxgEIAEoBRIRCglhcmdfdHlwZXMYBSADKAkiIQoETGluaxIMCgRuYW1lGAEgASgJEgsKA3VybBgCIAEoCSIUChJMaXN0Q29udGVudFJlcXVlc3QioQEKE0xpc3RDb250ZW50UmVzcG9uc2USHwoFbGlua3MYASADKAsyEC5ob21lbGFiLnYxLkxpbmsSIgoIbW9uaXRvcnMYAiADKAsyEC5ob21lbGFiLnYxLkl0ZW0SHgoEZXhlYxgDIAMoCzIQLmhvbWVsYWIudjEuSXRlbRIlCghzZXJ2aWNlcxgEIAMoCzITLmhvbWVsYWIudjEuU2VydmljZSIlCgdEb2NNZXRhEgwKBG5hbWUYASABKAkSDAoEaGFzaBgCIAEoCSIRCg9MaXN0RG9jc1JlcXVlc3QiNQoQTGlzdERvY3NSZXNwb25zZRIhCgRkb2NzGAEgAygLMhMuaG9tZWxhYi52MS5Eb2NNZXRhIh0KDUdldERvY1JlcXVlc3QSDAoEbmFtZRgBIAEoCSIhCg5HZXREb2NSZXNwb25zZRIPCgdjb250ZW50GAEgASgJIi8KDlNhdmVEb2NSZXF1ZXN0EgwKBG5hbWUYASABKAkSDwoHY29udGVudBgCIAEoCSIfCg9TYXZlRG9jUmVzcG9uc2USDAoEaGFzaBgBIAEoCSKvAQoHU2VydmljZRIlCgRraW5kGAEgASgOMhcuaG9tZWxhYi52MS5TZXJ2aWNlS2luZBIMCgRuYW1lGAIgASgJEg0KBXN0YXRlGAMgASgJEhAKCHN1YnN0YXRlGAQgASgJEhIKCmxvYWRfc3RhdGUYBSABKAkSFwoPdW5pdF9maWxlX3N0YXRlGAYgASgJEhIKCmNvbnRhaW5lcnMYByADKAkSDQoFZXJyb3IYCCABKAkibgoUU2VydmljZUFjdGlvblJlcXVlc3QSDAoEbmFtZRgBIAEoCRIlCgRraW5kGAIgASgOMhcuaG9tZWxhYi52MS5TZXJ2aWNlS2luZBIhCgJvcBgDIAEoDjIVLmhvbWVsYWIudjEuU2VydmljZU9wIicKFVNlcnZpY2VBY3Rpb25SZXNwb25zZRIOCgZvdXRwdXQYASABKAkiLgoQUnVuU2NyaXB0UmVxdWVzdBIMCgRuYW1lGAEgASgJEgwKBGFyZ3MYAiADKAkiIwoRUnVuU2NyaXB0UmVzcG9uc2USDgoGb3V0cHV0GAEgASgJKiYKC1NlcnZpY2VLaW5kEgsKB1NZU1RFTUQQABIKCgZET0NLRVIQASotCglTZXJ2aWNlT3ASCQoFU1RBUlQQABIICgRTVE9QEAESCwoHUkVTVEFSVBACMswDCg5Ib21lbGFiU2VydmljZRJOCgtMaXN0Q29udGVudBIeLmhvbWVsYWIudjEuTGlzdENvbnRlbnRSZXF1ZXN0Gh8uaG9tZWxhYi52MS5MaXN0Q29udGVudFJlc3BvbnNlEkUKCExpc3REb2NzEhsuaG9tZWxhYi52MS5MaXN0RG9jc1JlcXVlc3QaHC5ob21lbGFiLnYxLkxpc3REb2NzUmVzcG9uc2USPwoGR2V0RG9jEhkuaG9tZWxhYi52MS5HZXREb2NSZXF1ZXN0GhouaG9tZWxhYi52MS5HZXREb2NSZXNwb25zZRJCCgdTYXZlRG9jEhouaG9tZWxhYi52MS5TYXZlRG9jUmVxdWVzdBobLmhvbWVsYWIudjEuU2F2ZURvY1Jlc3BvbnNlElQKDVNlcnZpY2VBY3Rpb24SIC5ob21lbGFiLnYxLlNlcnZpY2VBY3Rpb25SZXF1ZXN0GiEuaG9tZWxhYi52MS5TZXJ2aWNlQWN0aW9uUmVzcG9uc2USSAoJUnVuU2NyaXB0EhwuaG9tZWxhYi52MS5SdW5TY3JpcHRSZXF1ZXN0Gh0uaG9tZWxhYi52MS5SdW5TY3JpcHRSZXNwb25zZUIqWihwd2EtaG9tZWxhYi1tb24vZ2VuL2hvbWVsYWIvdjE7aG9tZWxhYnYxYgZwcm90bzM"); /** * @generated from message homelab.v1.Item @@ -110,6 +110,11 @@ export type ListContentResponse = Message<"homelab.v1.ListContentResponse"> & { * @generated from field: repeated homelab.v1.Item exec = 3; */ exec: Item[]; + + /** + * @generated from field: repeated homelab.v1.Service services = 4; + */ + services: Service[]; }; /** @@ -246,6 +251,116 @@ export type SaveDocResponse = Message<"homelab.v1.SaveDocResponse"> & { export const SaveDocResponseSchema: GenMessage = /*@__PURE__*/ messageDesc(file_homelab_v1_homelab, 10); +/** + * @generated from message homelab.v1.Service + */ +export type Service = Message<"homelab.v1.Service"> & { + /** + * @generated from field: homelab.v1.ServiceKind kind = 1; + */ + kind: ServiceKind; + + /** + * unit name without .service / compose project name (or container name if unlabeled) + * + * @generated from field: string name = 2; + */ + name: string; + + /** + * systemd: ActiveState. docker: active | partial | inactive + * + * @generated from field: string state = 3; + */ + state: string; + + /** + * systemd: SubState. docker: "up/total" + * + * @generated from field: string substate = 4; + */ + substate: string; + + /** + * systemd only: loaded | not-found (= disabled by the user, not an error) + * + * @generated from field: string load_state = 5; + */ + loadState: string; + + /** + * systemd only: enabled | disabled | static + * + * @generated from field: string unit_file_state = 6; + */ + unitFileState: string; + + /** + * docker only: containers the card controls + * + * @generated from field: repeated string containers = 7; + */ + containers: string[]; + + /** + * probe failure, empty = ok + * + * @generated from field: string error = 8; + */ + error: string; +}; + +/** + * Describes the message homelab.v1.Service. + * Use `create(ServiceSchema)` to create a new message. + */ +export const ServiceSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_homelab_v1_homelab, 11); + +/** + * @generated from message homelab.v1.ServiceActionRequest + */ +export type ServiceActionRequest = Message<"homelab.v1.ServiceActionRequest"> & { + /** + * @generated from field: string name = 1; + */ + name: string; + + /** + * @generated from field: homelab.v1.ServiceKind kind = 2; + */ + kind: ServiceKind; + + /** + * @generated from field: homelab.v1.ServiceOp op = 3; + */ + op: ServiceOp; +}; + +/** + * Describes the message homelab.v1.ServiceActionRequest. + * Use `create(ServiceActionRequestSchema)` to create a new message. + */ +export const ServiceActionRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_homelab_v1_homelab, 12); + +/** + * @generated from message homelab.v1.ServiceActionResponse + */ +export type ServiceActionResponse = Message<"homelab.v1.ServiceActionResponse"> & { + /** + * @generated from field: string output = 1; + */ + output: string; +}; + +/** + * Describes the message homelab.v1.ServiceActionResponse. + * Use `create(ServiceActionResponseSchema)` to create a new message. + */ +export const ServiceActionResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_homelab_v1_homelab, 13); + /** * @generated from message homelab.v1.RunScriptRequest */ @@ -266,7 +381,7 @@ export type RunScriptRequest = Message<"homelab.v1.RunScriptRequest"> & { * Use `create(RunScriptRequestSchema)` to create a new message. */ export const RunScriptRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_homelab_v1_homelab, 11); + messageDesc(file_homelab_v1_homelab, 14); /** * @generated from message homelab.v1.RunScriptResponse @@ -283,7 +398,56 @@ export type RunScriptResponse = Message<"homelab.v1.RunScriptResponse"> & { * Use `create(RunScriptResponseSchema)` to create a new message. */ export const RunScriptResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_homelab_v1_homelab, 12); + messageDesc(file_homelab_v1_homelab, 15); + +/** + * One card in the Services view: a systemd user unit or a docker compose project. + * + * @generated from enum homelab.v1.ServiceKind + */ +export enum ServiceKind { + /** + * @generated from enum value: SYSTEMD = 0; + */ + SYSTEMD = 0, + + /** + * @generated from enum value: DOCKER = 1; + */ + DOCKER = 1, +} + +/** + * Describes the enum homelab.v1.ServiceKind. + */ +export const ServiceKindSchema: GenEnum = /*@__PURE__*/ + enumDesc(file_homelab_v1_homelab, 0); + +/** + * @generated from enum homelab.v1.ServiceOp + */ +export enum ServiceOp { + /** + * @generated from enum value: START = 0; + */ + START = 0, + + /** + * @generated from enum value: STOP = 1; + */ + STOP = 1, + + /** + * @generated from enum value: RESTART = 2; + */ + RESTART = 2, +} + +/** + * Describes the enum homelab.v1.ServiceOp. + */ +export const ServiceOpSchema: GenEnum = /*@__PURE__*/ + enumDesc(file_homelab_v1_homelab, 1); /** * @generated from service homelab.v1.HomelabService @@ -321,6 +485,14 @@ export const HomelabService: GenService<{ input: typeof SaveDocRequestSchema; output: typeof SaveDocResponseSchema; }, + /** + * @generated from rpc homelab.v1.HomelabService.ServiceAction + */ + serviceAction: { + methodKind: "unary"; + input: typeof ServiceActionRequestSchema; + output: typeof ServiceActionResponseSchema; + }, /** * @generated from rpc homelab.v1.HomelabService.RunScript */