Srv watch
Deploy / deploy (push) Successful in 11s

This commit is contained in:
2026-08-31 11:08:45 +05:00
parent e4f58e820f
commit 20e1d5a726
6 changed files with 1062 additions and 63 deletions
+139
View File
@@ -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 <unit> -p ActiveState,SubState,LoadState,UnitFileState --value
```
- Кнопки: `systemctl --user start|stop|restart <unit>` — **без 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}}' <name>` +
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/<name>.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/<name>.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, это норма).
+395 -46
View File
@@ -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
@@ -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,
@@ -106,6 +116,7 @@ type homelabServiceClient struct {
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]
}
@@ -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"))
}
+34
View File
@@ -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);
}
+279 -3
View File
@@ -10,6 +10,8 @@ import (
"os"
"os/exec"
"path/filepath"
"regexp"
"sort"
"strings"
"sync"
"time"
@@ -99,6 +101,8 @@ 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
}
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")
}
+177 -5
View File
@@ -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<SaveDocResponse> = /*@__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<Service> = /*@__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<ServiceActionRequest> = /*@__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<ServiceActionResponse> = /*@__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<RunScriptRequest> = /*@__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<RunScriptResponse> = /*@__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<ServiceKind> = /*@__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<ServiceOp> = /*@__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
*/