diff --git a/CI-DEPLOY.md b/CI-DEPLOY.md new file mode 100644 index 0000000..30a608e --- /dev/null +++ b/CI-DEPLOY.md @@ -0,0 +1,105 @@ +# Инструкция: настроить CI-деплой проекта на боксе (для агента-исполнителя) + +## Твоя задача +Сделать так, чтобы push в ветку `main` репозитория в Gitea автоматически: +1) собирал фронт (pnpm + vite), 2) собирал Go-бинарь, 3) раскладывал их на боксе и перезапускал сервис. + +Раннер и инструменты УЖЕ стоят и работают. Ты только добавляешь workflow-файл в репо, +регистрируешь юнит сервиса и проверяешь деплой. + +--- + +## Факты окружения (проверять не нужно — верны) + +- **Раннер:** `box-runner` (Gitea Runner v3.3.1), user-юнит `act-runner.service`, метка `ubuntu-latest` = host-режим. +- **Джобы CI выполняются на хосте бокса от пользователя `homelab`** (не в docker!). Имеют доступ к `~/apps`, `systemctl --user`, локальному docker. +- **PATH внутри джоб:** `~/.local/bin` (node 22, npm, pnpm 11) + `~/apps/go/bin` (go 1.27) + стандартный. +- **Цели деплоя:** + - статика SPA → `~/apps/hub/dist/` (Caddy `hub.alexey-homelab.duckdns.org` отдаёт её по `/`, SPA-fallback на `/index.html`) + - Go-бинарь → `~/apps/hub/hub-api`, должен слушать **`127.0.0.1:8484`** (Caddy проксирует `/api/*` туда) +- **Юнит сервиса:** файл `~/srv/hub-api.service` уже создан, но НЕ зарегистрирован (бинарника ещё нет). + +--- + +## Шаги + +### Шаг 1. Узнай структуру проекта +Выясни у пользователя (или посмотри в репо): +- где `package.json` (фронт) — ожидается `frontend/` +- где `main.go` (бэк) — ожидается `api/` +Если структура другая — поправь пути в шаблоне workflow (шаг 2). + +### Шаг 2. Добавь workflow в репо +Скопируй блок ниже в репо как `.gitea/workflows/deploy.yml`: + +```yaml +name: Deploy +on: + push: + branches: [main] + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Build frontend (pnpm + vite) + run: | + cd frontend + pnpm install --frozen-lockfile + pnpm build + + - name: Build API (Go) + run: | + cd api + CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o hub-api . + + - name: Deploy to box + run: | + mkdir -p ~/apps/hub/dist + cp -r frontend/dist/* ~/apps/hub/dist/ + cp api/hub-api ~/apps/hub/hub-api + chmod +x ~/apps/hub/hub-api + systemctl --user restart hub-api +``` + +ВАЖНО: +- `pnpm install --frozen-lockfile` требует `pnpm-lock.yaml` в репо — убедись, что он закоммичен. +- Если юнит ещё не зарегистрирован (шаг 3 не сделан), последняя строка упадёт — добавь `|| true` временно, пока не сделаешь шаг 3. + +### Шаг 3. Зарегистрируй юнит сервиса (один раз, на боксе) +```bash +ln -sf /home/homelab/srv/hub-api.service ~/.config/systemd/user/hub-api.service +systemctl --user daemon-reload +systemctl --user enable hub-api +``` +НЕ запускай (`start`) — бинарника ещё нет, юнит упадёт. Он поднимется сам после первого деплоя +(`systemctl --user restart hub-api` в workflow). + +### Шаг 4. Проверь джобу +- Сделай push в `main` → открой Gitea UI → репо → вкладка **Actions** → смотри лог джобы. +- Джоба должна пройти зелёной: build → deploy. + +### Шаг 5. Проверь деплой +```bash +ls -la ~/apps/hub/ # dist/ + hub-api на месте +curl -s http://127.0.0.1:8484/api/health # ответ API (если бинарник жив) +curl -s -o /dev/null -w '%{http_code}\n' https://hub.alexey-homelab.duckdns.org/ \ + --resolve hub.alexey-homelab.duckdns.org:443:127.0.0.1 # 200 = статика отдаётся +``` +Если бинарник падает: `journalctl --user -u hub-api -n 30` — смотри ошибку (чаще всего: не тот порт/адрес, отсутствие флага). + +--- + +## Питфоллы + +1. **Джобы без docker** (host-метка). Не используй `docker:`-шаги в CI. Если понадобится docker — + менять метку в `~/apps/act_runner/config.yaml` (`ubuntu-latest:docker://...`) + `systemctl --user restart act-runner` — но это отдельное решение. +2. **Go-бинарь должен слушать `127.0.0.1:8484`** — проверь дефолт/флаг в коде (`:8484`), Caddy уже смотрит туда. +3. **`actions/checkout@v4` скачивается с GitHub** — с бокса работает (не менять на самописный клон без нужды). +4. **pnpm store** кэшируется в `~/.local/share/pnpm` — холодная сборка только первый раз. +5. **Не удаляй и не двигай** `~/apps/act_runner/` (там `.runner` — регистрация раннера) и `~/srv/act-runner.service`. +6. **`systemctl --user restart hub-api` в CI** — юнит должен существовать (шаг 3), иначе джоба упадёт на деплое. + diff --git a/Makefile b/Makefile index a4ada5a..cf02c15 100644 --- a/Makefile +++ b/Makefile @@ -9,7 +9,7 @@ proto: ## Regenerate Go + TS code from proto --es_out=web/src/gen --es_opt=target=ts \ proto/homelab/v1/homelab.proto -web: ## Build frontend into web/dist +web: ## Build frontend into web/dist (tsc + vite) cd web && pnpm install --frozen-lockfile && pnpm build server: ## Build Go binary @@ -18,12 +18,15 @@ server: ## Build Go binary dev: ## Dev: Go server on :8080 + Vite on :5173 (use http://localhost:5173) go run ./server & cd web && pnpm dev +dev-srv: + go run ./server + +dev-web: + cd web && pnpm dev + test: ## Smoke-test the RPC endpoints @go build -o bin/server ./server @./bin/server & sleep 1; \ - for i in 1 2; do \ - curl -sf -X POST localhost:8080/homelab.v1.HomelabService/IncrementCounter -H 'Content-Type: application/json' -d '{}' | grep -q "\"value\":\"$$i\"" || exit 1; \ - done; \ - curl -sf -X POST localhost:8080/homelab.v1.HomelabService/SayHello -H 'Content-Type: application/json' -d '{"name":"Pony"}' | grep -q 'Hello, Pony!' || exit 1; \ - curl -sf -X POST localhost:8080/homelab.v1.HomelabService/GetLuckyNumber -H 'Content-Type: application/json' -d '{}' | grep -q '"number"' || exit 1; \ + curl -sf -X POST localhost:8080/homelab.v1.HomelabService/ListSections -H 'Content-Type: application/json' -d '{}' | grep -q '"cpu"' || exit 1; \ + curl -sf -X POST localhost:8080/homelab.v1.HomelabService/RunScript -H 'Content-Type: application/json' -d '{"name":"greet","args":["pony","42"]}' | grep -q 'arg1: pony' || exit 1; \ kill %1; echo OK diff --git a/config.yaml b/config.yaml new file mode 100644 index 0000000..edc9970 --- /dev/null +++ b/config.yaml @@ -0,0 +1,14 @@ +sections: + - name: Overview + icon: gauge + items: + - name: cpu + type: monitor + script: scripts/cpu.sh + refresh: 5 + data: percentage + - name: greet + type: response + script: scripts/greet.sh + args: 2 + arg_types: [text, number] diff --git a/gen/homelab/v1/homelab.pb.go b/gen/homelab/v1/homelab.pb.go index f943d77..2d4f30a 100644 --- a/gen/homelab/v1/homelab.pb.go +++ b/gen/homelab/v1/homelab.pb.go @@ -21,26 +21,32 @@ const ( _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) ) -type GetLuckyNumberRequest struct { +type Item struct { state protoimpl.MessageState `protogen:"open.v1"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + Type string `protobuf:"bytes,2,opt,name=type,proto3" json:"type,omitempty"` // monitor | response + Data string `protobuf:"bytes,3,opt,name=data,proto3" json:"data,omitempty"` // monitor: text | percentage + Value string `protobuf:"bytes,4,opt,name=value,proto3" json:"value,omitempty"` // monitor: last output + Args int32 `protobuf:"varint,5,opt,name=args,proto3" json:"args,omitempty"` // response: arg count + ArgTypes []string `protobuf:"bytes,6,rep,name=arg_types,json=argTypes,proto3" json:"arg_types,omitempty"` // response: text | number per arg unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } -func (x *GetLuckyNumberRequest) Reset() { - *x = GetLuckyNumberRequest{} +func (x *Item) Reset() { + *x = Item{} mi := &file_homelab_v1_homelab_proto_msgTypes[0] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *GetLuckyNumberRequest) String() string { +func (x *Item) String() string { return protoimpl.X.MessageStringOf(x) } -func (*GetLuckyNumberRequest) ProtoMessage() {} +func (*Item) ProtoMessage() {} -func (x *GetLuckyNumberRequest) ProtoReflect() protoreflect.Message { +func (x *Item) ProtoReflect() protoreflect.Message { mi := &file_homelab_v1_homelab_proto_msgTypes[0] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) @@ -52,200 +58,266 @@ func (x *GetLuckyNumberRequest) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use GetLuckyNumberRequest.ProtoReflect.Descriptor instead. -func (*GetLuckyNumberRequest) Descriptor() ([]byte, []int) { +// Deprecated: Use Item.ProtoReflect.Descriptor instead. +func (*Item) Descriptor() ([]byte, []int) { return file_homelab_v1_homelab_proto_rawDescGZIP(), []int{0} } -type GetLuckyNumberResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - Number int64 `protobuf:"varint,1,opt,name=number,proto3" json:"number,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *GetLuckyNumberResponse) Reset() { - *x = GetLuckyNumberResponse{} - mi := &file_homelab_v1_homelab_proto_msgTypes[1] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *GetLuckyNumberResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*GetLuckyNumberResponse) ProtoMessage() {} - -func (x *GetLuckyNumberResponse) ProtoReflect() protoreflect.Message { - mi := &file_homelab_v1_homelab_proto_msgTypes[1] - 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 GetLuckyNumberResponse.ProtoReflect.Descriptor instead. -func (*GetLuckyNumberResponse) Descriptor() ([]byte, []int) { - return file_homelab_v1_homelab_proto_rawDescGZIP(), []int{1} -} - -func (x *GetLuckyNumberResponse) GetNumber() int64 { - if x != nil { - return x.Number - } - return 0 -} - -type IncrementCounterRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *IncrementCounterRequest) Reset() { - *x = IncrementCounterRequest{} - mi := &file_homelab_v1_homelab_proto_msgTypes[2] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *IncrementCounterRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*IncrementCounterRequest) ProtoMessage() {} - -func (x *IncrementCounterRequest) ProtoReflect() protoreflect.Message { - mi := &file_homelab_v1_homelab_proto_msgTypes[2] - 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 IncrementCounterRequest.ProtoReflect.Descriptor instead. -func (*IncrementCounterRequest) Descriptor() ([]byte, []int) { - return file_homelab_v1_homelab_proto_rawDescGZIP(), []int{2} -} - -type IncrementCounterResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - Value int64 `protobuf:"varint,1,opt,name=value,proto3" json:"value,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *IncrementCounterResponse) Reset() { - *x = IncrementCounterResponse{} - mi := &file_homelab_v1_homelab_proto_msgTypes[3] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *IncrementCounterResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*IncrementCounterResponse) ProtoMessage() {} - -func (x *IncrementCounterResponse) ProtoReflect() protoreflect.Message { - mi := &file_homelab_v1_homelab_proto_msgTypes[3] - 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 IncrementCounterResponse.ProtoReflect.Descriptor instead. -func (*IncrementCounterResponse) Descriptor() ([]byte, []int) { - return file_homelab_v1_homelab_proto_rawDescGZIP(), []int{3} -} - -func (x *IncrementCounterResponse) GetValue() int64 { - if x != nil { - return x.Value - } - return 0 -} - -type SayHelloRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *SayHelloRequest) Reset() { - *x = SayHelloRequest{} - mi := &file_homelab_v1_homelab_proto_msgTypes[4] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *SayHelloRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*SayHelloRequest) ProtoMessage() {} - -func (x *SayHelloRequest) ProtoReflect() protoreflect.Message { - mi := &file_homelab_v1_homelab_proto_msgTypes[4] - 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 SayHelloRequest.ProtoReflect.Descriptor instead. -func (*SayHelloRequest) Descriptor() ([]byte, []int) { - return file_homelab_v1_homelab_proto_rawDescGZIP(), []int{4} -} - -func (x *SayHelloRequest) GetName() string { +func (x *Item) GetName() string { if x != nil { return x.Name } return "" } -type SayHelloResponse struct { +func (x *Item) GetType() string { + if x != nil { + return x.Type + } + return "" +} + +func (x *Item) GetData() string { + if x != nil { + return x.Data + } + return "" +} + +func (x *Item) GetValue() string { + if x != nil { + return x.Value + } + return "" +} + +func (x *Item) GetArgs() int32 { + if x != nil { + return x.Args + } + return 0 +} + +func (x *Item) GetArgTypes() []string { + if x != nil { + return x.ArgTypes + } + return nil +} + +type Section struct { state protoimpl.MessageState `protogen:"open.v1"` - Greeting string `protobuf:"bytes,1,opt,name=greeting,proto3" json:"greeting,omitempty"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + Icon string `protobuf:"bytes,2,opt,name=icon,proto3" json:"icon,omitempty"` + Items []*Item `protobuf:"bytes,3,rep,name=items,proto3" json:"items,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } -func (x *SayHelloResponse) Reset() { - *x = SayHelloResponse{} +func (x *Section) Reset() { + *x = Section{} + mi := &file_homelab_v1_homelab_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Section) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Section) ProtoMessage() {} + +func (x *Section) ProtoReflect() protoreflect.Message { + mi := &file_homelab_v1_homelab_proto_msgTypes[1] + 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 Section.ProtoReflect.Descriptor instead. +func (*Section) Descriptor() ([]byte, []int) { + return file_homelab_v1_homelab_proto_rawDescGZIP(), []int{1} +} + +func (x *Section) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *Section) GetIcon() string { + if x != nil { + return x.Icon + } + return "" +} + +func (x *Section) GetItems() []*Item { + if x != nil { + return x.Items + } + return nil +} + +type ListSectionsRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListSectionsRequest) Reset() { + *x = ListSectionsRequest{} + mi := &file_homelab_v1_homelab_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListSectionsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListSectionsRequest) ProtoMessage() {} + +func (x *ListSectionsRequest) ProtoReflect() protoreflect.Message { + mi := &file_homelab_v1_homelab_proto_msgTypes[2] + 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 ListSectionsRequest.ProtoReflect.Descriptor instead. +func (*ListSectionsRequest) Descriptor() ([]byte, []int) { + return file_homelab_v1_homelab_proto_rawDescGZIP(), []int{2} +} + +type ListSectionsResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Sections []*Section `protobuf:"bytes,1,rep,name=sections,proto3" json:"sections,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListSectionsResponse) Reset() { + *x = ListSectionsResponse{} + mi := &file_homelab_v1_homelab_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListSectionsResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListSectionsResponse) ProtoMessage() {} + +func (x *ListSectionsResponse) ProtoReflect() protoreflect.Message { + mi := &file_homelab_v1_homelab_proto_msgTypes[3] + 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 ListSectionsResponse.ProtoReflect.Descriptor instead. +func (*ListSectionsResponse) Descriptor() ([]byte, []int) { + return file_homelab_v1_homelab_proto_rawDescGZIP(), []int{3} +} + +func (x *ListSectionsResponse) GetSections() []*Section { + if x != nil { + return x.Sections + } + return nil +} + +type RunScriptRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + Args []string `protobuf:"bytes,2,rep,name=args,proto3" json:"args,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RunScriptRequest) Reset() { + *x = RunScriptRequest{} + mi := &file_homelab_v1_homelab_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RunScriptRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RunScriptRequest) ProtoMessage() {} + +func (x *RunScriptRequest) ProtoReflect() protoreflect.Message { + mi := &file_homelab_v1_homelab_proto_msgTypes[4] + 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 RunScriptRequest.ProtoReflect.Descriptor instead. +func (*RunScriptRequest) Descriptor() ([]byte, []int) { + return file_homelab_v1_homelab_proto_rawDescGZIP(), []int{4} +} + +func (x *RunScriptRequest) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *RunScriptRequest) GetArgs() []string { + if x != nil { + return x.Args + } + return nil +} + +type RunScriptResponse 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 *RunScriptResponse) Reset() { + *x = RunScriptResponse{} mi := &file_homelab_v1_homelab_proto_msgTypes[5] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *SayHelloResponse) String() string { +func (x *RunScriptResponse) String() string { return protoimpl.X.MessageStringOf(x) } -func (*SayHelloResponse) ProtoMessage() {} +func (*RunScriptResponse) ProtoMessage() {} -func (x *SayHelloResponse) ProtoReflect() protoreflect.Message { +func (x *RunScriptResponse) ProtoReflect() protoreflect.Message { mi := &file_homelab_v1_homelab_proto_msgTypes[5] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) @@ -257,14 +329,14 @@ func (x *SayHelloResponse) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use SayHelloResponse.ProtoReflect.Descriptor instead. -func (*SayHelloResponse) Descriptor() ([]byte, []int) { +// Deprecated: Use RunScriptResponse.ProtoReflect.Descriptor instead. +func (*RunScriptResponse) Descriptor() ([]byte, []int) { return file_homelab_v1_homelab_proto_rawDescGZIP(), []int{5} } -func (x *SayHelloResponse) GetGreeting() string { +func (x *RunScriptResponse) GetOutput() string { if x != nil { - return x.Greeting + return x.Output } return "" } @@ -274,21 +346,29 @@ var File_homelab_v1_homelab_proto protoreflect.FileDescriptor const file_homelab_v1_homelab_proto_rawDesc = "" + "\n" + "\x18homelab/v1/homelab.proto\x12\n" + - "homelab.v1\"\x17\n" + - "\x15GetLuckyNumberRequest\"0\n" + - "\x16GetLuckyNumberResponse\x12\x16\n" + - "\x06number\x18\x01 \x01(\x03R\x06number\"\x19\n" + - "\x17IncrementCounterRequest\"0\n" + - "\x18IncrementCounterResponse\x12\x14\n" + - "\x05value\x18\x01 \x01(\x03R\x05value\"%\n" + - "\x0fSayHelloRequest\x12\x12\n" + - "\x04name\x18\x01 \x01(\tR\x04name\".\n" + - "\x10SayHelloResponse\x12\x1a\n" + - "\bgreeting\x18\x01 \x01(\tR\bgreeting2\x8f\x02\n" + - "\x0eHomelabService\x12W\n" + - "\x0eGetLuckyNumber\x12!.homelab.v1.GetLuckyNumberRequest\x1a\".homelab.v1.GetLuckyNumberResponse\x12]\n" + - "\x10IncrementCounter\x12#.homelab.v1.IncrementCounterRequest\x1a$.homelab.v1.IncrementCounterResponse\x12E\n" + - "\bSayHello\x12\x1b.homelab.v1.SayHelloRequest\x1a\x1c.homelab.v1.SayHelloResponseB*Z(pwa-homelab-mon/gen/homelab/v1;homelabv1b\x06proto3" + "homelab.v1\"\x89\x01\n" + + "\x04Item\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12\x12\n" + + "\x04type\x18\x02 \x01(\tR\x04type\x12\x12\n" + + "\x04data\x18\x03 \x01(\tR\x04data\x12\x14\n" + + "\x05value\x18\x04 \x01(\tR\x05value\x12\x12\n" + + "\x04args\x18\x05 \x01(\x05R\x04args\x12\x1b\n" + + "\targ_types\x18\x06 \x03(\tR\bargTypes\"Y\n" + + "\aSection\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12\x12\n" + + "\x04icon\x18\x02 \x01(\tR\x04icon\x12&\n" + + "\x05items\x18\x03 \x03(\v2\x10.homelab.v1.ItemR\x05items\"\x15\n" + + "\x13ListSectionsRequest\"G\n" + + "\x14ListSectionsResponse\x12/\n" + + "\bsections\x18\x01 \x03(\v2\x13.homelab.v1.SectionR\bsections\":\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\xad\x01\n" + + "\x0eHomelabService\x12Q\n" + + "\fListSections\x12\x1f.homelab.v1.ListSectionsRequest\x1a .homelab.v1.ListSectionsResponse\x12H\n" + + "\tRunScript\x12\x1c.homelab.v1.RunScriptRequest\x1a\x1d.homelab.v1.RunScriptResponseB*Z(pwa-homelab-mon/gen/homelab/v1;homelabv1b\x06proto3" var ( file_homelab_v1_homelab_proto_rawDescOnce sync.Once @@ -304,25 +384,25 @@ func file_homelab_v1_homelab_proto_rawDescGZIP() []byte { var file_homelab_v1_homelab_proto_msgTypes = make([]protoimpl.MessageInfo, 6) var file_homelab_v1_homelab_proto_goTypes = []any{ - (*GetLuckyNumberRequest)(nil), // 0: homelab.v1.GetLuckyNumberRequest - (*GetLuckyNumberResponse)(nil), // 1: homelab.v1.GetLuckyNumberResponse - (*IncrementCounterRequest)(nil), // 2: homelab.v1.IncrementCounterRequest - (*IncrementCounterResponse)(nil), // 3: homelab.v1.IncrementCounterResponse - (*SayHelloRequest)(nil), // 4: homelab.v1.SayHelloRequest - (*SayHelloResponse)(nil), // 5: homelab.v1.SayHelloResponse + (*Item)(nil), // 0: homelab.v1.Item + (*Section)(nil), // 1: homelab.v1.Section + (*ListSectionsRequest)(nil), // 2: homelab.v1.ListSectionsRequest + (*ListSectionsResponse)(nil), // 3: homelab.v1.ListSectionsResponse + (*RunScriptRequest)(nil), // 4: homelab.v1.RunScriptRequest + (*RunScriptResponse)(nil), // 5: homelab.v1.RunScriptResponse } var file_homelab_v1_homelab_proto_depIdxs = []int32{ - 0, // 0: homelab.v1.HomelabService.GetLuckyNumber:input_type -> homelab.v1.GetLuckyNumberRequest - 2, // 1: homelab.v1.HomelabService.IncrementCounter:input_type -> homelab.v1.IncrementCounterRequest - 4, // 2: homelab.v1.HomelabService.SayHello:input_type -> homelab.v1.SayHelloRequest - 1, // 3: homelab.v1.HomelabService.GetLuckyNumber:output_type -> homelab.v1.GetLuckyNumberResponse - 3, // 4: homelab.v1.HomelabService.IncrementCounter:output_type -> homelab.v1.IncrementCounterResponse - 5, // 5: homelab.v1.HomelabService.SayHello:output_type -> homelab.v1.SayHelloResponse - 3, // [3:6] is the sub-list for method output_type - 0, // [0:3] is the sub-list for method input_type - 0, // [0:0] is the sub-list for extension type_name - 0, // [0:0] is the sub-list for extension extendee - 0, // [0:0] is the sub-list for field type_name + 0, // 0: homelab.v1.Section.items:type_name -> homelab.v1.Item + 1, // 1: homelab.v1.ListSectionsResponse.sections:type_name -> homelab.v1.Section + 2, // 2: homelab.v1.HomelabService.ListSections:input_type -> homelab.v1.ListSectionsRequest + 4, // 3: homelab.v1.HomelabService.RunScript:input_type -> homelab.v1.RunScriptRequest + 3, // 4: homelab.v1.HomelabService.ListSections:output_type -> homelab.v1.ListSectionsResponse + 5, // 5: homelab.v1.HomelabService.RunScript:output_type -> homelab.v1.RunScriptResponse + 4, // [4:6] is the sub-list for method output_type + 2, // [2:4] is the sub-list for method input_type + 2, // [2:2] is the sub-list for extension type_name + 2, // [2:2] is the sub-list for extension extendee + 0, // [0:2] is the sub-list for field type_name } func init() { file_homelab_v1_homelab_proto_init() } diff --git a/gen/homelab/v1/homelabv1connect/homelab.connect.go b/gen/homelab/v1/homelabv1connect/homelab.connect.go index cd13245..a1ef6c9 100644 --- a/gen/homelab/v1/homelabv1connect/homelab.connect.go +++ b/gen/homelab/v1/homelabv1connect/homelab.connect.go @@ -33,21 +33,18 @@ const ( // reflection-formatted method names, remove the leading slash and convert the remaining slash to a // period. const ( - // HomelabServiceGetLuckyNumberProcedure is the fully-qualified name of the HomelabService's - // GetLuckyNumber RPC. - HomelabServiceGetLuckyNumberProcedure = "/homelab.v1.HomelabService/GetLuckyNumber" - // HomelabServiceIncrementCounterProcedure is the fully-qualified name of the HomelabService's - // IncrementCounter RPC. - HomelabServiceIncrementCounterProcedure = "/homelab.v1.HomelabService/IncrementCounter" - // HomelabServiceSayHelloProcedure is the fully-qualified name of the HomelabService's SayHello RPC. - HomelabServiceSayHelloProcedure = "/homelab.v1.HomelabService/SayHello" + // HomelabServiceListSectionsProcedure is the fully-qualified name of the HomelabService's + // ListSections RPC. + HomelabServiceListSectionsProcedure = "/homelab.v1.HomelabService/ListSections" + // HomelabServiceRunScriptProcedure is the fully-qualified name of the HomelabService's RunScript + // RPC. + HomelabServiceRunScriptProcedure = "/homelab.v1.HomelabService/RunScript" ) // HomelabServiceClient is a client for the homelab.v1.HomelabService service. type HomelabServiceClient interface { - GetLuckyNumber(context.Context, *connect.Request[v1.GetLuckyNumberRequest]) (*connect.Response[v1.GetLuckyNumberResponse], error) - IncrementCounter(context.Context, *connect.Request[v1.IncrementCounterRequest]) (*connect.Response[v1.IncrementCounterResponse], error) - SayHello(context.Context, *connect.Request[v1.SayHelloRequest]) (*connect.Response[v1.SayHelloResponse], error) + ListSections(context.Context, *connect.Request[v1.ListSectionsRequest]) (*connect.Response[v1.ListSectionsResponse], error) + RunScript(context.Context, *connect.Request[v1.RunScriptRequest]) (*connect.Response[v1.RunScriptResponse], error) } // NewHomelabServiceClient constructs a client for the homelab.v1.HomelabService service. By @@ -61,22 +58,16 @@ func NewHomelabServiceClient(httpClient connect.HTTPClient, baseURL string, opts baseURL = strings.TrimRight(baseURL, "/") homelabServiceMethods := v1.File_homelab_v1_homelab_proto.Services().ByName("HomelabService").Methods() return &homelabServiceClient{ - getLuckyNumber: connect.NewClient[v1.GetLuckyNumberRequest, v1.GetLuckyNumberResponse]( + listSections: connect.NewClient[v1.ListSectionsRequest, v1.ListSectionsResponse]( httpClient, - baseURL+HomelabServiceGetLuckyNumberProcedure, - connect.WithSchema(homelabServiceMethods.ByName("GetLuckyNumber")), + baseURL+HomelabServiceListSectionsProcedure, + connect.WithSchema(homelabServiceMethods.ByName("ListSections")), connect.WithClientOptions(opts...), ), - incrementCounter: connect.NewClient[v1.IncrementCounterRequest, v1.IncrementCounterResponse]( + runScript: connect.NewClient[v1.RunScriptRequest, v1.RunScriptResponse]( httpClient, - baseURL+HomelabServiceIncrementCounterProcedure, - connect.WithSchema(homelabServiceMethods.ByName("IncrementCounter")), - connect.WithClientOptions(opts...), - ), - sayHello: connect.NewClient[v1.SayHelloRequest, v1.SayHelloResponse]( - httpClient, - baseURL+HomelabServiceSayHelloProcedure, - connect.WithSchema(homelabServiceMethods.ByName("SayHello")), + baseURL+HomelabServiceRunScriptProcedure, + connect.WithSchema(homelabServiceMethods.ByName("RunScript")), connect.WithClientOptions(opts...), ), } @@ -84,31 +75,24 @@ func NewHomelabServiceClient(httpClient connect.HTTPClient, baseURL string, opts // homelabServiceClient implements HomelabServiceClient. type homelabServiceClient struct { - getLuckyNumber *connect.Client[v1.GetLuckyNumberRequest, v1.GetLuckyNumberResponse] - incrementCounter *connect.Client[v1.IncrementCounterRequest, v1.IncrementCounterResponse] - sayHello *connect.Client[v1.SayHelloRequest, v1.SayHelloResponse] + listSections *connect.Client[v1.ListSectionsRequest, v1.ListSectionsResponse] + runScript *connect.Client[v1.RunScriptRequest, v1.RunScriptResponse] } -// GetLuckyNumber calls homelab.v1.HomelabService.GetLuckyNumber. -func (c *homelabServiceClient) GetLuckyNumber(ctx context.Context, req *connect.Request[v1.GetLuckyNumberRequest]) (*connect.Response[v1.GetLuckyNumberResponse], error) { - return c.getLuckyNumber.CallUnary(ctx, req) +// ListSections calls homelab.v1.HomelabService.ListSections. +func (c *homelabServiceClient) ListSections(ctx context.Context, req *connect.Request[v1.ListSectionsRequest]) (*connect.Response[v1.ListSectionsResponse], error) { + return c.listSections.CallUnary(ctx, req) } -// IncrementCounter calls homelab.v1.HomelabService.IncrementCounter. -func (c *homelabServiceClient) IncrementCounter(ctx context.Context, req *connect.Request[v1.IncrementCounterRequest]) (*connect.Response[v1.IncrementCounterResponse], error) { - return c.incrementCounter.CallUnary(ctx, req) -} - -// SayHello calls homelab.v1.HomelabService.SayHello. -func (c *homelabServiceClient) SayHello(ctx context.Context, req *connect.Request[v1.SayHelloRequest]) (*connect.Response[v1.SayHelloResponse], error) { - return c.sayHello.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) } // HomelabServiceHandler is an implementation of the homelab.v1.HomelabService service. type HomelabServiceHandler interface { - GetLuckyNumber(context.Context, *connect.Request[v1.GetLuckyNumberRequest]) (*connect.Response[v1.GetLuckyNumberResponse], error) - IncrementCounter(context.Context, *connect.Request[v1.IncrementCounterRequest]) (*connect.Response[v1.IncrementCounterResponse], error) - SayHello(context.Context, *connect.Request[v1.SayHelloRequest]) (*connect.Response[v1.SayHelloResponse], error) + ListSections(context.Context, *connect.Request[v1.ListSectionsRequest]) (*connect.Response[v1.ListSectionsResponse], error) + RunScript(context.Context, *connect.Request[v1.RunScriptRequest]) (*connect.Response[v1.RunScriptResponse], error) } // NewHomelabServiceHandler builds an HTTP handler from the service implementation. It returns the @@ -118,32 +102,24 @@ type HomelabServiceHandler interface { // and JSON codecs. They also support gzip compression. func NewHomelabServiceHandler(svc HomelabServiceHandler, opts ...connect.HandlerOption) (string, http.Handler) { homelabServiceMethods := v1.File_homelab_v1_homelab_proto.Services().ByName("HomelabService").Methods() - homelabServiceGetLuckyNumberHandler := connect.NewUnaryHandler( - HomelabServiceGetLuckyNumberProcedure, - svc.GetLuckyNumber, - connect.WithSchema(homelabServiceMethods.ByName("GetLuckyNumber")), + homelabServiceListSectionsHandler := connect.NewUnaryHandler( + HomelabServiceListSectionsProcedure, + svc.ListSections, + connect.WithSchema(homelabServiceMethods.ByName("ListSections")), connect.WithHandlerOptions(opts...), ) - homelabServiceIncrementCounterHandler := connect.NewUnaryHandler( - HomelabServiceIncrementCounterProcedure, - svc.IncrementCounter, - connect.WithSchema(homelabServiceMethods.ByName("IncrementCounter")), - connect.WithHandlerOptions(opts...), - ) - homelabServiceSayHelloHandler := connect.NewUnaryHandler( - HomelabServiceSayHelloProcedure, - svc.SayHello, - connect.WithSchema(homelabServiceMethods.ByName("SayHello")), + homelabServiceRunScriptHandler := connect.NewUnaryHandler( + HomelabServiceRunScriptProcedure, + svc.RunScript, + connect.WithSchema(homelabServiceMethods.ByName("RunScript")), connect.WithHandlerOptions(opts...), ) return "/homelab.v1.HomelabService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { - case HomelabServiceGetLuckyNumberProcedure: - homelabServiceGetLuckyNumberHandler.ServeHTTP(w, r) - case HomelabServiceIncrementCounterProcedure: - homelabServiceIncrementCounterHandler.ServeHTTP(w, r) - case HomelabServiceSayHelloProcedure: - homelabServiceSayHelloHandler.ServeHTTP(w, r) + case HomelabServiceListSectionsProcedure: + homelabServiceListSectionsHandler.ServeHTTP(w, r) + case HomelabServiceRunScriptProcedure: + homelabServiceRunScriptHandler.ServeHTTP(w, r) default: http.NotFound(w, r) } @@ -153,14 +129,10 @@ func NewHomelabServiceHandler(svc HomelabServiceHandler, opts ...connect.Handler // UnimplementedHomelabServiceHandler returns CodeUnimplemented from all methods. type UnimplementedHomelabServiceHandler struct{} -func (UnimplementedHomelabServiceHandler) GetLuckyNumber(context.Context, *connect.Request[v1.GetLuckyNumberRequest]) (*connect.Response[v1.GetLuckyNumberResponse], error) { - return nil, connect.NewError(connect.CodeUnimplemented, errors.New("homelab.v1.HomelabService.GetLuckyNumber is not implemented")) +func (UnimplementedHomelabServiceHandler) ListSections(context.Context, *connect.Request[v1.ListSectionsRequest]) (*connect.Response[v1.ListSectionsResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("homelab.v1.HomelabService.ListSections is not implemented")) } -func (UnimplementedHomelabServiceHandler) IncrementCounter(context.Context, *connect.Request[v1.IncrementCounterRequest]) (*connect.Response[v1.IncrementCounterResponse], error) { - return nil, connect.NewError(connect.CodeUnimplemented, errors.New("homelab.v1.HomelabService.IncrementCounter is not implemented")) -} - -func (UnimplementedHomelabServiceHandler) SayHello(context.Context, *connect.Request[v1.SayHelloRequest]) (*connect.Response[v1.SayHelloResponse], error) { - return nil, connect.NewError(connect.CodeUnimplemented, errors.New("homelab.v1.HomelabService.SayHello 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/go.mod b/go.mod index 4f9fc4a..3a4b306 100644 --- a/go.mod +++ b/go.mod @@ -19,4 +19,5 @@ require ( golang.org/x/sys v0.46.0 // indirect golang.org/x/text v0.38.0 // indirect golang.org/x/time v0.15.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 52568e7..ce859bd 100644 --- a/go.sum +++ b/go.sum @@ -32,5 +32,6 @@ golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/proto/homelab/v1/homelab.proto b/proto/homelab/v1/homelab.proto index 10d7cc2..81bb823 100644 --- a/proto/homelab/v1/homelab.proto +++ b/proto/homelab/v1/homelab.proto @@ -4,25 +4,35 @@ package homelab.v1; option go_package = "pwa-homelab-mon/gen/homelab/v1;homelabv1"; -message GetLuckyNumberRequest {} -message GetLuckyNumberResponse { - int64 number = 1; -} - -message IncrementCounterRequest {} -message IncrementCounterResponse { - int64 value = 1; -} - -message SayHelloRequest { +message Item { string name = 1; + string type = 2; // monitor | response + string data = 3; // monitor: text | percentage + string value = 4; // monitor: last output + int32 args = 5; // response: arg count + repeated string arg_types = 6; // response: text | number per arg } -message SayHelloResponse { - string greeting = 1; + +message Section { + string name = 1; + string icon = 2; + repeated Item items = 3; +} + +message ListSectionsRequest {} +message ListSectionsResponse { + repeated Section sections = 1; +} + +message RunScriptRequest { + string name = 1; + repeated string args = 2; +} +message RunScriptResponse { + string output = 1; } service HomelabService { - rpc GetLuckyNumber(GetLuckyNumberRequest) returns (GetLuckyNumberResponse); - rpc IncrementCounter(IncrementCounterRequest) returns (IncrementCounterResponse); - rpc SayHello(SayHelloRequest) returns (SayHelloResponse); + rpc ListSections(ListSectionsRequest) returns (ListSectionsResponse); + rpc RunScript(RunScriptRequest) returns (RunScriptResponse); } diff --git a/scripts/cpu.sh b/scripts/cpu.sh new file mode 100755 index 0000000..f9d24bb --- /dev/null +++ b/scripts/cpu.sh @@ -0,0 +1,7 @@ +#!/bin/sh +# Суммарная загрузка CPU в процентах 0-100 (mac + linux). +case "$(uname)" in + Darwin) cores=$(sysctl -n hw.ncpu) ;; + *) cores=$(nproc) ;; +esac +ps -A -o %cpu | awk -v c="$cores" '{s+=$1} END {printf "%d", s/c}' diff --git a/scripts/greet.sh b/scripts/greet.sh new file mode 100755 index 0000000..3ada991 --- /dev/null +++ b/scripts/greet.sh @@ -0,0 +1,3 @@ +#!/bin/sh +echo "arg1: $1" +echo "arg2: $2" diff --git a/server/main.go b/server/main.go index 98d92b5..a9b76f9 100644 --- a/server/main.go +++ b/server/main.go @@ -2,44 +2,201 @@ package main import ( "context" - "math/rand/v2" + "fmt" + "log" "net/http" "os" - "sync/atomic" + "os/exec" + "strings" + "sync" + "time" "connectrpc.com/connect" "github.com/labstack/echo/v4" "github.com/labstack/echo/v4/middleware" + "gopkg.in/yaml.v3" homelabv1 "pwa-homelab-mon/gen/homelab/v1" "pwa-homelab-mon/gen/homelab/v1/homelabv1connect" ) -type server struct { - lucky int64 - counter atomic.Int64 +// --- config --- + +type Item struct { + Name string `yaml:"name"` + Type string `yaml:"type"` // monitor | response + Script string `yaml:"script"` + Refresh int `yaml:"refresh"` // monitor: seconds + Data string `yaml:"data"` // monitor: text | percentage + Args int `yaml:"args"` // response: arg count + ArgTypes []string `yaml:"arg_types"` // response: text | number per arg } -func (s *server) GetLuckyNumber(context.Context, *connect.Request[homelabv1.GetLuckyNumberRequest]) (*connect.Response[homelabv1.GetLuckyNumberResponse], error) { - return connect.NewResponse(&homelabv1.GetLuckyNumberResponse{Number: s.lucky}), nil +// Section is a full view on the web page: a list of items plus sidebar metadata. +type Section struct { + Name string `yaml:"name"` + Icon string `yaml:"icon"` + Items []Item `yaml:"items"` } -func (s *server) IncrementCounter(context.Context, *connect.Request[homelabv1.IncrementCounterRequest]) (*connect.Response[homelabv1.IncrementCounterResponse], error) { - return connect.NewResponse(&homelabv1.IncrementCounterResponse{Value: s.counter.Add(1)}), nil +type Config struct { + Sections []Section `yaml:"sections"` } -func (s *server) SayHello(_ context.Context, req *connect.Request[homelabv1.SayHelloRequest]) (*connect.Response[homelabv1.SayHelloResponse], error) { - name := req.Msg.GetName() - if name == "" { - return nil, connect.NewError(connect.CodeInvalidArgument, os.ErrInvalid) +func loadConfig(path string) (*Config, error) { + f, err := os.Open(path) + if err != nil { + return nil, err } - return connect.NewResponse(&homelabv1.SayHelloResponse{Greeting: "Hello, " + name + "!"}), nil + defer f.Close() + var cfg Config + dec := yaml.NewDecoder(f) + dec.KnownFields(true) + if err := dec.Decode(&cfg); err != nil { + return nil, fmt.Errorf("parse %s: %w", path, err) + } + for _, sec := range cfg.Sections { + for _, it := range sec.Items { + switch it.Type { + case "monitor": + if it.Refresh <= 0 { + return nil, fmt.Errorf("item %q: refresh must be > 0", it.Name) + } + if it.Data != "text" && it.Data != "percentage" { + return nil, fmt.Errorf("item %q: data must be text|percentage", it.Name) + } + case "response": + if len(it.ArgTypes) != it.Args { + return nil, fmt.Errorf("item %q: arg_types must have args entries", it.Name) + } + for _, t := range it.ArgTypes { + if t != "text" && t != "number" { + return nil, fmt.Errorf("item %q: arg type must be text|number", it.Name) + } + } + default: + return nil, fmt.Errorf("item %q: unknown type %q", it.Name, it.Type) + } + } + } + return &cfg, nil } +// --- service --- + +type server struct { + cfg *Config + scripts map[string]*Item // by item name, read-only after start + monVals sync.Map // item name -> string (last monitor output) +} + +const execTimeout = 10 * time.Second + +func runScript(s *Item, args ...string) (string, error) { + ctx, cancel := context.WithTimeout(context.Background(), execTimeout) + defer cancel() + out, err := exec.CommandContext(ctx, s.Script, args...).CombinedOutput() + if err != nil { + return "", fmt.Errorf("%v: %s", err, out) + } + return string(out), nil +} + +func (s *server) startMonitors() { + for _, sec := range s.cfg.Sections { + for _, it := range sec.Items { + if it.Type != "monitor" { + continue + } + sc := it + log.Printf("monitor started: %s (%s) every %ds", sc.Name, sc.Script, sc.Refresh) + go func(sc *Item) { + for { + v, err := runScript(sc) + if err != nil { + log.Printf("monitor %s: %v", sc.Name, err) + v = "error: " + err.Error() + } else { + log.Printf("monitor %s -> %s", sc.Name, strings.TrimSpace(v)) + } + s.monVals.Store(sc.Name, v) + time.Sleep(time.Duration(sc.Refresh) * time.Second) + } + }(&sc) + } + } +} + +func toProto(it *Item, val string) *homelabv1.Item { + p := &homelabv1.Item{Name: it.Name, Type: it.Type, Data: it.Data, Args: int32(it.Args), ArgTypes: it.ArgTypes} + if it.Type == "monitor" { + p.Value = val + } + return p +} + +func (s *server) ListSections(context.Context, *connect.Request[homelabv1.ListSectionsRequest]) (*connect.Response[homelabv1.ListSectionsResponse], error) { + resp := &homelabv1.ListSectionsResponse{} + for _, sec := range s.cfg.Sections { + ps := &homelabv1.Section{Name: sec.Name, Icon: sec.Icon} + for _, it := range sec.Items { + val, _ := s.monVals.Load(it.Name) + v, _ := val.(string) + ps.Items = append(ps.Items, toProto(&it, v)) + } + resp.Sections = append(resp.Sections, ps) + } + return connect.NewResponse(resp), nil +} + +func (s *server) RunScript(_ context.Context, req *connect.Request[homelabv1.RunScriptRequest]) (*connect.Response[homelabv1.RunScriptResponse], error) { + it, ok := s.scripts[req.Msg.GetName()] + if !ok || it.Type != "response" { + return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("no response script %q", req.Msg.GetName())) + } + if len(req.Msg.GetArgs()) != it.Args { + return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("%q takes %d args, got %d", it.Name, it.Args, len(req.Msg.GetArgs()))) + } + out, err := runScript(it, req.Msg.GetArgs()...) + if err != nil { + log.Printf("script %s args=%v: ERROR %v", it.Name, req.Msg.GetArgs(), err) + return nil, connect.NewError(connect.CodeInternal, err) + } + log.Printf("script %s args=%v -> %s", it.Name, req.Msg.GetArgs(), strings.TrimSpace(out)) + return connect.NewResponse(&homelabv1.RunScriptResponse{Output: out}), nil +} + +// --- main --- + func main() { - svc := &server{lucky: rand.Int64()} + cfgPath := os.Getenv("CONFIG") + if cfgPath == "" { + cfgPath = "config.yaml" + } + cfg, err := loadConfig(cfgPath) + if err != nil { + fmt.Fprintln(os.Stderr, "config:", err) + os.Exit(1) + } + + svc := &server{cfg: cfg, scripts: map[string]*Item{}} + for _, sec := range cfg.Sections { + for i := range sec.Items { + svc.scripts[sec.Items[i].Name] = &sec.Items[i] + } + } + svc.startMonitors() + + log.Printf("config %s: %d sections, %d items", cfgPath, len(cfg.Sections), len(svc.scripts)) e := echo.New() e.HideBanner = true + // HTTP request log, except the ListSections heartbeat (every 3s per client — pure noise). + e.Use(middleware.LoggerWithConfig(middleware.LoggerConfig{ + Skipper: func(c echo.Context) bool { + return c.Request().URL.Path == "/homelab.v1.HomelabService/ListSections" + }, + })) + // ConnectRPC handler on the mux (matches /homelab.v1.HomelabService/). mux := http.NewServeMux() diff --git a/web/package.json b/web/package.json index effbd9a..c819626 100644 --- a/web/package.json +++ b/web/package.json @@ -4,7 +4,7 @@ "type": "module", "scripts": { "dev": "vite", - "build": "vite build", + "build": "tsc --noEmit && vite build", "gen": "protoc --proto_path=../proto --plugin=protoc-gen-es=./node_modules/.bin/protoc-gen-es --es_out=src/gen --es_opt=target=ts ../proto/homelab/v1/homelab.proto" }, "packageManager": "pnpm@10.6.5", diff --git a/web/src/App.tsx b/web/src/App.tsx index 2c7600c..8dd19cc 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,85 +1,67 @@ import { useEffect, useState } from "preact/hooks"; import { createClient } from "@connectrpc/connect"; import { createConnectTransport } from "@connectrpc/connect-web"; -import { Dices, Hash, Send } from "lucide-preact"; +import { Gauge, FlaskConical } from "lucide-preact"; +import type { Section } from "./gen/homelab/v1/homelab_pb"; import { HomelabService } from "./gen/homelab/v1/homelab_pb"; +import { ScriptCard } from "./components/ScriptCard"; +import { MonitorCard } from "./components/MonitorCard"; const client = createClient(HomelabService, createConnectTransport({ baseUrl: "" })); +// Иконки секций: строка из конфига → компонент. Новый icon в yaml = добавить сюда. +const ICONS: Record = { gauge: Gauge, "flask-conical": FlaskConical }; + +function DynIcon({ name, size = 20 }: { name: string; size?: number }) { + const Cmp = ICONS[name] ?? Gauge; + return ; +} + export function App() { - const [lucky, setLucky] = useState(null); - const [count, setCount] = useState(0); - const [name, setName] = useState(""); - const [greeting, setGreeting] = useState(""); - const [error, setError] = useState(""); + const [sections, setSections] = useState([]); + const [active, setActive] = useState(0); useEffect(() => { - client.getLuckyNumber({}).then((r) => setLucky(r.number)); + const poll = () => client.listSections({}).then((r) => setSections(r.sections)); + poll(); + const id = setInterval(poll, 3000); + return () => clearInterval(id); }, []); - const run = async (fn: () => Promise) => { - try { - await fn(); - setError(""); - } catch (e) { - setError(String(e)); - } - }; + const sec = sections[active]; return ( -
-