From 0857d77a0137c68cf0456044ba296643b56b890a Mon Sep 17 00:00:00 2001 From: Alexey Bagno Date: Sat, 29 Aug 2026 13:33:17 +0500 Subject: [PATCH] Rework: 4 tabs by func --- .gitea/workflows/deploy.yml | 2 +- .nvim.lua | 31 + AGENTS.md | 16 +- Makefile | 3 +- config.yaml | 27 +- docs/second.md | 1 + docs/test.md | 12 + gen/homelab/v1/homelab.pb.go | 531 +++++++++++++++--- .../v1/homelabv1connect/homelab.connect.go | 126 ++++- proto/homelab/v1/homelab.proto | 60 +- server/main.go | 221 +++++--- web/package.json | 1 + web/pnpm-lock.yaml | 10 + web/public/sw.js | 2 +- web/src/App.tsx | 135 +++-- web/src/components/Docs.tsx | 71 +++ web/src/docsStore.ts | 67 +++ web/src/gen/homelab/v1/homelab_pb.ts | 237 ++++++-- 18 files changed, 1239 insertions(+), 314 deletions(-) create mode 100644 .nvim.lua create mode 100644 docs/second.md create mode 100644 docs/test.md create mode 100644 web/src/components/Docs.tsx create mode 100644 web/src/docsStore.ts diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 4a39cd6..6e4e09b 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -15,7 +15,7 @@ jobs: - name: Deploy to box run: | - mkdir -p ~/apps/hub/dist ~/apps/hub/scripts + mkdir -p ~/apps/hub/dist ~/apps/hub/scripts ~/apps/hub/docs cp bin/hub-api ~/apps/hub/hub-api.new mv -f ~/apps/hub/hub-api.new ~/apps/hub/hub-api chmod +x ~/apps/hub/hub-api diff --git a/.nvim.lua b/.nvim.lua new file mode 100644 index 0000000..a82155e --- /dev/null +++ b/.nvim.lua @@ -0,0 +1,31 @@ +require("custom.runner").setup({ + commands = { + -- Bottom terminal (focused after exec) + -- run = function() + -- local command = require("custom.runner").command + -- command:add({ + -- cmd = "make", + -- flags = { "run" }, + -- }) + -- command.target = { position = "bottom" } + -- command.focus = true + -- command:run() + -- end, + -- float-job (no target): executes in a float + -- debug = function() + -- local command = require("custom.runner").command + -- command:add({ + -- cmd = "odin build .", + -- flags = { "-debug" }, + -- }) + -- command:run() + -- end, + }, + -- additional excludes for snacks picker + exclude = { + "gen", + "*.png", + "go.mod", + "go.sum", + }, +}) diff --git a/AGENTS.md b/AGENTS.md index d341bd7..4188665 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,14 +15,20 @@ PWA для менеджмента homelab: монорепозиторий Go-с ``` proto/homelab/v1/ контракт (единственный источник правды) gen/ сгенерённый Go-код (коммитим) -server/main.go весь сервер: конфиг, мониторы, RPC, статика -web/src/App.tsx layout, сайдбар секций, поллинг ListSections (3s) -web/src/components/ MonitorCard (мониторы), ScriptCard (response-форма + модалка результата) +server/main.go весь сервер: конфиг, мониторы, RPC, статика, docs API +web/src/App.tsx layout, топбар + hamburger-drawer, 4 хардкод-таба, поллинг ListContent (3s) +web/src/components/ MonitorCard, ScriptCard, Docs (ридер + textarea-редактор) +web/src/docsStore.ts localStorage доков (hub:doc:*), dirty-флаги, syncDocs: pull по хэшам + flush раз в 5s web/src/gen/ сгенерированный TS-клиент web/public/ PWA-обвязка: manifest, sw.js, иконки scripts/ bash-скрипты, пути из config.yaml +docs/ markdown-доки, НЕ в репо — живут только на сервере рядом с бинарьём ``` +## Категории + +Хардкод в бандле (App.tsx CATEGORIES): Links (из config links), Monitor (config monitor), Exec (config exec), Docs (папка docs/, API ListDocs/GetDoc/SaveDoc). Оффлайн: контент-стейт держит последний ответ, доки — localStorage; правки в оффлайне помечаются dirty и флашатся на сервер каждые 5s (client authority — последняя запись побеждает, без base-hash check). + ## Команды (всё через make) - `make dev` — сервер :8080 + vite :5173 (работать на http://localhost:5173) @@ -46,7 +52,9 @@ scripts/ bash-скрипты, пути из config.yaml - `int64` в proto → `bigint` в TS (не `number`!). - Поля protobuf в TS — camelCase (`arg_types` → `argTypes`). - Новый item-тип или поле в yaml = обновить proto + валидацию в `loadConfig` + компонент на фронте. -- Иконки секций: строка из конфига → ручной маппинг `ICONS` в `App.tsx` (не тянуть весь lucide через `icons` — раздувает бандл в 5 раз). +- Иконки: хардкод в компонентах из lucide-preact поимённо (не тянуть весь lucide через `icons` — раздувает бандл в 5 раз). +- Маркдаун: `marked` (~10KB gzip) + dangerouslySetInnerHTML без санитайзера (контент свой). Если редактор перестанет нравиться — **переход на MDXEditor** ( agreed upgrade path, изолирован в компоненте Docs). +- index.html и sw.js отдаются с `Cache-Control: no-cache` — иначе iOS не подхватит новый бандл. - Логи сервера: monitors и RunScript пишут в stdout; `ListSections` (поллинг-шум) исключён из HTTP-лога echo. - SW: `web/public/sw.js`, версия кэша `homelab-shell-vN` — **бампить при изменении** офлайн-логики, иначе iOS не обновит. - Тесты: `make test` (smoke RPC) + `tsc --noEmit` внутри `make web`. Не плодить фреймворки. diff --git a/Makefile b/Makefile index 348b73e..9969696 100644 --- a/Makefile +++ b/Makefile @@ -30,6 +30,7 @@ dev-web: test: ## Smoke-test the RPC endpoints @go build -o bin/hub-api ./server @./bin/hub-api & sleep 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/ListContent -H 'Content-Type: application/json' -d '{}' | grep -q '"cpu"' || exit 1; \ + curl -sf -X POST localhost:8080/homelab.v1.HomelabService/ListDocs -H 'Content-Type: application/json' -d '{}' | grep -q 'test.md' || 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 index edc9970..8aa882d 100644 --- a/config.yaml +++ b/config.yaml @@ -1,14 +1,13 @@ -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] +links: + - { name: Proxmox, url: "https://pve.local:8006" } + - { name: Router, url: "http://192.168.1.1" } +monitor: + - name: cpu + script: scripts/cpu.sh + refresh: 5 + data: percentage +exec: + - name: greet + script: scripts/greet.sh + args: 2 + arg_types: [text, number] diff --git a/docs/second.md b/docs/second.md new file mode 100644 index 0000000..e02664d --- /dev/null +++ b/docs/second.md @@ -0,0 +1 @@ +# Second doc diff --git a/docs/test.md b/docs/test.md new file mode 100644 index 0000000..41a71a4 --- /dev/null +++ b/docs/test.md @@ -0,0 +1,12 @@ +# Test doc + +Hello **world**, list: +- one +- two + +`code` and [link](https://example.com) + + +Edited OFFLINE + +Edited OFFLINE v3 \ No newline at end of file diff --git a/gen/homelab/v1/homelab.pb.go b/gen/homelab/v1/homelab.pb.go index 2d4f30a..6caef11 100644 --- a/gen/homelab/v1/homelab.pb.go +++ b/gen/homelab/v1/homelab.pb.go @@ -24,11 +24,10 @@ const ( 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 + Data string `protobuf:"bytes,2,opt,name=data,proto3" json:"data,omitempty"` // monitor: text | percentage + Value string `protobuf:"bytes,3,opt,name=value,proto3" json:"value,omitempty"` // monitor: last output + Args int32 `protobuf:"varint,4,opt,name=args,proto3" json:"args,omitempty"` // exec: arg count + ArgTypes []string `protobuf:"bytes,5,rep,name=arg_types,json=argTypes,proto3" json:"arg_types,omitempty"` // exec: text | number per arg unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -70,13 +69,6 @@ func (x *Item) GetName() string { return "" } -func (x *Item) GetType() string { - if x != nil { - return x.Type - } - return "" -} - func (x *Item) GetData() string { if x != nil { return x.Data @@ -105,29 +97,28 @@ func (x *Item) GetArgTypes() []string { return nil } -type Section struct { +type Link struct { state protoimpl.MessageState `protogen:"open.v1"` 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"` + Url string `protobuf:"bytes,2,opt,name=url,proto3" json:"url,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } -func (x *Section) Reset() { - *x = Section{} +func (x *Link) Reset() { + *x = Link{} mi := &file_homelab_v1_homelab_proto_msgTypes[1] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *Section) String() string { +func (x *Link) String() string { return protoimpl.X.MessageStringOf(x) } -func (*Section) ProtoMessage() {} +func (*Link) ProtoMessage() {} -func (x *Section) ProtoReflect() protoreflect.Message { +func (x *Link) ProtoReflect() protoreflect.Message { mi := &file_homelab_v1_homelab_proto_msgTypes[1] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) @@ -139,52 +130,45 @@ func (x *Section) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use Section.ProtoReflect.Descriptor instead. -func (*Section) Descriptor() ([]byte, []int) { +// Deprecated: Use Link.ProtoReflect.Descriptor instead. +func (*Link) Descriptor() ([]byte, []int) { return file_homelab_v1_homelab_proto_rawDescGZIP(), []int{1} } -func (x *Section) GetName() string { +func (x *Link) GetName() string { if x != nil { return x.Name } return "" } -func (x *Section) GetIcon() string { +func (x *Link) GetUrl() string { if x != nil { - return x.Icon + return x.Url } return "" } -func (x *Section) GetItems() []*Item { - if x != nil { - return x.Items - } - return nil -} - -type ListSectionsRequest struct { +type ListContentRequest struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } -func (x *ListSectionsRequest) Reset() { - *x = ListSectionsRequest{} +func (x *ListContentRequest) Reset() { + *x = ListContentRequest{} mi := &file_homelab_v1_homelab_proto_msgTypes[2] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *ListSectionsRequest) String() string { +func (x *ListContentRequest) String() string { return protoimpl.X.MessageStringOf(x) } -func (*ListSectionsRequest) ProtoMessage() {} +func (*ListContentRequest) ProtoMessage() {} -func (x *ListSectionsRequest) ProtoReflect() protoreflect.Message { +func (x *ListContentRequest) ProtoReflect() protoreflect.Message { mi := &file_homelab_v1_homelab_proto_msgTypes[2] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) @@ -196,32 +180,34 @@ func (x *ListSectionsRequest) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use ListSectionsRequest.ProtoReflect.Descriptor instead. -func (*ListSectionsRequest) Descriptor() ([]byte, []int) { +// Deprecated: Use ListContentRequest.ProtoReflect.Descriptor instead. +func (*ListContentRequest) Descriptor() ([]byte, []int) { return file_homelab_v1_homelab_proto_rawDescGZIP(), []int{2} } -type ListSectionsResponse struct { +type ListContentResponse struct { state protoimpl.MessageState `protogen:"open.v1"` - Sections []*Section `protobuf:"bytes,1,rep,name=sections,proto3" json:"sections,omitempty"` + 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"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } -func (x *ListSectionsResponse) Reset() { - *x = ListSectionsResponse{} +func (x *ListContentResponse) Reset() { + *x = ListContentResponse{} mi := &file_homelab_v1_homelab_proto_msgTypes[3] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *ListSectionsResponse) String() string { +func (x *ListContentResponse) String() string { return protoimpl.X.MessageStringOf(x) } -func (*ListSectionsResponse) ProtoMessage() {} +func (*ListContentResponse) ProtoMessage() {} -func (x *ListSectionsResponse) ProtoReflect() protoreflect.Message { +func (x *ListContentResponse) ProtoReflect() protoreflect.Message { mi := &file_homelab_v1_homelab_proto_msgTypes[3] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) @@ -233,18 +219,348 @@ func (x *ListSectionsResponse) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use ListSectionsResponse.ProtoReflect.Descriptor instead. -func (*ListSectionsResponse) Descriptor() ([]byte, []int) { +// Deprecated: Use ListContentResponse.ProtoReflect.Descriptor instead. +func (*ListContentResponse) Descriptor() ([]byte, []int) { return file_homelab_v1_homelab_proto_rawDescGZIP(), []int{3} } -func (x *ListSectionsResponse) GetSections() []*Section { +func (x *ListContentResponse) GetLinks() []*Link { if x != nil { - return x.Sections + return x.Links } return nil } +func (x *ListContentResponse) GetMonitors() []*Item { + if x != nil { + return x.Monitors + } + return nil +} + +func (x *ListContentResponse) GetExec() []*Item { + if x != nil { + return x.Exec + } + return nil +} + +type DocMeta struct { + state protoimpl.MessageState `protogen:"open.v1"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + Hash string `protobuf:"bytes,2,opt,name=hash,proto3" json:"hash,omitempty"` // sha256 hex of content + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DocMeta) Reset() { + *x = DocMeta{} + mi := &file_homelab_v1_homelab_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DocMeta) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DocMeta) ProtoMessage() {} + +func (x *DocMeta) 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 DocMeta.ProtoReflect.Descriptor instead. +func (*DocMeta) Descriptor() ([]byte, []int) { + return file_homelab_v1_homelab_proto_rawDescGZIP(), []int{4} +} + +func (x *DocMeta) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *DocMeta) GetHash() string { + if x != nil { + return x.Hash + } + return "" +} + +type ListDocsRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListDocsRequest) Reset() { + *x = ListDocsRequest{} + mi := &file_homelab_v1_homelab_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListDocsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListDocsRequest) ProtoMessage() {} + +func (x *ListDocsRequest) ProtoReflect() protoreflect.Message { + mi := &file_homelab_v1_homelab_proto_msgTypes[5] + 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 ListDocsRequest.ProtoReflect.Descriptor instead. +func (*ListDocsRequest) Descriptor() ([]byte, []int) { + return file_homelab_v1_homelab_proto_rawDescGZIP(), []int{5} +} + +type ListDocsResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Docs []*DocMeta `protobuf:"bytes,1,rep,name=docs,proto3" json:"docs,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListDocsResponse) Reset() { + *x = ListDocsResponse{} + mi := &file_homelab_v1_homelab_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListDocsResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListDocsResponse) ProtoMessage() {} + +func (x *ListDocsResponse) ProtoReflect() protoreflect.Message { + mi := &file_homelab_v1_homelab_proto_msgTypes[6] + 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 ListDocsResponse.ProtoReflect.Descriptor instead. +func (*ListDocsResponse) Descriptor() ([]byte, []int) { + return file_homelab_v1_homelab_proto_rawDescGZIP(), []int{6} +} + +func (x *ListDocsResponse) GetDocs() []*DocMeta { + if x != nil { + return x.Docs + } + return nil +} + +type GetDocRequest 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 *GetDocRequest) Reset() { + *x = GetDocRequest{} + mi := &file_homelab_v1_homelab_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetDocRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetDocRequest) ProtoMessage() {} + +func (x *GetDocRequest) ProtoReflect() protoreflect.Message { + mi := &file_homelab_v1_homelab_proto_msgTypes[7] + 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 GetDocRequest.ProtoReflect.Descriptor instead. +func (*GetDocRequest) Descriptor() ([]byte, []int) { + return file_homelab_v1_homelab_proto_rawDescGZIP(), []int{7} +} + +func (x *GetDocRequest) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +type GetDocResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Content string `protobuf:"bytes,1,opt,name=content,proto3" json:"content,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetDocResponse) Reset() { + *x = GetDocResponse{} + mi := &file_homelab_v1_homelab_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetDocResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetDocResponse) ProtoMessage() {} + +func (x *GetDocResponse) ProtoReflect() protoreflect.Message { + mi := &file_homelab_v1_homelab_proto_msgTypes[8] + 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 GetDocResponse.ProtoReflect.Descriptor instead. +func (*GetDocResponse) Descriptor() ([]byte, []int) { + return file_homelab_v1_homelab_proto_rawDescGZIP(), []int{8} +} + +func (x *GetDocResponse) GetContent() string { + if x != nil { + return x.Content + } + return "" +} + +type SaveDocRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + Content string `protobuf:"bytes,2,opt,name=content,proto3" json:"content,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SaveDocRequest) Reset() { + *x = SaveDocRequest{} + mi := &file_homelab_v1_homelab_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SaveDocRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SaveDocRequest) ProtoMessage() {} + +func (x *SaveDocRequest) ProtoReflect() protoreflect.Message { + mi := &file_homelab_v1_homelab_proto_msgTypes[9] + 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 SaveDocRequest.ProtoReflect.Descriptor instead. +func (*SaveDocRequest) Descriptor() ([]byte, []int) { + return file_homelab_v1_homelab_proto_rawDescGZIP(), []int{9} +} + +func (x *SaveDocRequest) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *SaveDocRequest) GetContent() string { + if x != nil { + return x.Content + } + return "" +} + +type SaveDocResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Hash string `protobuf:"bytes,1,opt,name=hash,proto3" json:"hash,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SaveDocResponse) Reset() { + *x = SaveDocResponse{} + mi := &file_homelab_v1_homelab_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SaveDocResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SaveDocResponse) ProtoMessage() {} + +func (x *SaveDocResponse) ProtoReflect() protoreflect.Message { + mi := &file_homelab_v1_homelab_proto_msgTypes[10] + 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 SaveDocResponse.ProtoReflect.Descriptor instead. +func (*SaveDocResponse) Descriptor() ([]byte, []int) { + return file_homelab_v1_homelab_proto_rawDescGZIP(), []int{10} +} + +func (x *SaveDocResponse) GetHash() string { + if x != nil { + return x.Hash + } + return "" +} + type RunScriptRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` @@ -255,7 +571,7 @@ type RunScriptRequest struct { func (x *RunScriptRequest) Reset() { *x = RunScriptRequest{} - mi := &file_homelab_v1_homelab_proto_msgTypes[4] + mi := &file_homelab_v1_homelab_proto_msgTypes[11] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -267,7 +583,7 @@ func (x *RunScriptRequest) String() string { func (*RunScriptRequest) ProtoMessage() {} func (x *RunScriptRequest) ProtoReflect() protoreflect.Message { - mi := &file_homelab_v1_homelab_proto_msgTypes[4] + mi := &file_homelab_v1_homelab_proto_msgTypes[11] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -280,7 +596,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{4} + return file_homelab_v1_homelab_proto_rawDescGZIP(), []int{11} } func (x *RunScriptRequest) GetName() string { @@ -306,7 +622,7 @@ type RunScriptResponse struct { func (x *RunScriptResponse) Reset() { *x = RunScriptResponse{} - mi := &file_homelab_v1_homelab_proto_msgTypes[5] + mi := &file_homelab_v1_homelab_proto_msgTypes[12] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -318,7 +634,7 @@ func (x *RunScriptResponse) String() string { func (*RunScriptResponse) ProtoMessage() {} func (x *RunScriptResponse) ProtoReflect() protoreflect.Message { - mi := &file_homelab_v1_homelab_proto_msgTypes[5] + mi := &file_homelab_v1_homelab_proto_msgTypes[12] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -331,7 +647,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{5} + return file_homelab_v1_homelab_proto_rawDescGZIP(), []int{12} } func (x *RunScriptResponse) GetOutput() string { @@ -346,28 +662,46 @@ var File_homelab_v1_homelab_proto protoreflect.FileDescriptor const file_homelab_v1_homelab_proto_rawDesc = "" + "\n" + "\x18homelab/v1/homelab.proto\x12\n" + - "homelab.v1\"\x89\x01\n" + + "homelab.v1\"u\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" + + "\x04data\x18\x02 \x01(\tR\x04data\x12\x14\n" + + "\x05value\x18\x03 \x01(\tR\x05value\x12\x12\n" + + "\x04args\x18\x04 \x01(\x05R\x04args\x12\x1b\n" + + "\targ_types\x18\x05 \x03(\tR\bargTypes\",\n" + + "\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" + + "\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" + + "\aDocMeta\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" + + "\x04hash\x18\x02 \x01(\tR\x04hash\"\x11\n" + + "\x0fListDocsRequest\";\n" + + "\x10ListDocsResponse\x12'\n" + + "\x04docs\x18\x01 \x03(\v2\x13.homelab.v1.DocMetaR\x04docs\"#\n" + + "\rGetDocRequest\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\"*\n" + + "\x0eGetDocResponse\x12\x18\n" + + "\acontent\x18\x01 \x01(\tR\acontent\">\n" + + "\x0eSaveDocRequest\x12\x12\n" + + "\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" + "\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" + + "\x06output\x18\x01 \x01(\tR\x06output2\xf6\x02\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" + "\tRunScript\x12\x1c.homelab.v1.RunScriptRequest\x1a\x1d.homelab.v1.RunScriptResponseB*Z(pwa-homelab-mon/gen/homelab/v1;homelabv1b\x06proto3" var ( @@ -382,27 +716,42 @@ 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, 6) +var file_homelab_v1_homelab_proto_msgTypes = make([]protoimpl.MessageInfo, 13) var file_homelab_v1_homelab_proto_goTypes = []any{ - (*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 + (*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 } var file_homelab_v1_homelab_proto_depIdxs = []int32{ - 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 + 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 } func init() { file_homelab_v1_homelab_proto_init() } @@ -416,7 +765,7 @@ func file_homelab_v1_homelab_proto_init() { 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: 6, + NumMessages: 13, NumExtensions: 0, NumServices: 1, }, diff --git a/gen/homelab/v1/homelabv1connect/homelab.connect.go b/gen/homelab/v1/homelabv1connect/homelab.connect.go index a1ef6c9..a725fa5 100644 --- a/gen/homelab/v1/homelabv1connect/homelab.connect.go +++ b/gen/homelab/v1/homelabv1connect/homelab.connect.go @@ -33,9 +33,15 @@ const ( // reflection-formatted method names, remove the leading slash and convert the remaining slash to a // period. const ( - // HomelabServiceListSectionsProcedure is the fully-qualified name of the HomelabService's - // ListSections RPC. - HomelabServiceListSectionsProcedure = "/homelab.v1.HomelabService/ListSections" + // HomelabServiceListContentProcedure is the fully-qualified name of the HomelabService's + // ListContent RPC. + HomelabServiceListContentProcedure = "/homelab.v1.HomelabService/ListContent" + // HomelabServiceListDocsProcedure is the fully-qualified name of the HomelabService's ListDocs RPC. + HomelabServiceListDocsProcedure = "/homelab.v1.HomelabService/ListDocs" + // HomelabServiceGetDocProcedure is the fully-qualified name of the HomelabService's GetDoc RPC. + HomelabServiceGetDocProcedure = "/homelab.v1.HomelabService/GetDoc" + // HomelabServiceSaveDocProcedure is the fully-qualified name of the HomelabService's SaveDoc RPC. + HomelabServiceSaveDocProcedure = "/homelab.v1.HomelabService/SaveDoc" // HomelabServiceRunScriptProcedure is the fully-qualified name of the HomelabService's RunScript // RPC. HomelabServiceRunScriptProcedure = "/homelab.v1.HomelabService/RunScript" @@ -43,7 +49,10 @@ const ( // HomelabServiceClient is a client for the homelab.v1.HomelabService service. type HomelabServiceClient interface { - ListSections(context.Context, *connect.Request[v1.ListSectionsRequest]) (*connect.Response[v1.ListSectionsResponse], error) + ListContent(context.Context, *connect.Request[v1.ListContentRequest]) (*connect.Response[v1.ListContentResponse], error) + 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) RunScript(context.Context, *connect.Request[v1.RunScriptRequest]) (*connect.Response[v1.RunScriptResponse], error) } @@ -58,10 +67,28 @@ 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{ - listSections: connect.NewClient[v1.ListSectionsRequest, v1.ListSectionsResponse]( + listContent: connect.NewClient[v1.ListContentRequest, v1.ListContentResponse]( httpClient, - baseURL+HomelabServiceListSectionsProcedure, - connect.WithSchema(homelabServiceMethods.ByName("ListSections")), + baseURL+HomelabServiceListContentProcedure, + connect.WithSchema(homelabServiceMethods.ByName("ListContent")), + connect.WithClientOptions(opts...), + ), + listDocs: connect.NewClient[v1.ListDocsRequest, v1.ListDocsResponse]( + httpClient, + baseURL+HomelabServiceListDocsProcedure, + connect.WithSchema(homelabServiceMethods.ByName("ListDocs")), + connect.WithClientOptions(opts...), + ), + getDoc: connect.NewClient[v1.GetDocRequest, v1.GetDocResponse]( + httpClient, + baseURL+HomelabServiceGetDocProcedure, + connect.WithSchema(homelabServiceMethods.ByName("GetDoc")), + connect.WithClientOptions(opts...), + ), + saveDoc: connect.NewClient[v1.SaveDocRequest, v1.SaveDocResponse]( + httpClient, + baseURL+HomelabServiceSaveDocProcedure, + connect.WithSchema(homelabServiceMethods.ByName("SaveDoc")), connect.WithClientOptions(opts...), ), runScript: connect.NewClient[v1.RunScriptRequest, v1.RunScriptResponse]( @@ -75,13 +102,31 @@ func NewHomelabServiceClient(httpClient connect.HTTPClient, baseURL string, opts // homelabServiceClient implements HomelabServiceClient. type homelabServiceClient struct { - listSections *connect.Client[v1.ListSectionsRequest, v1.ListSectionsResponse] - runScript *connect.Client[v1.RunScriptRequest, v1.RunScriptResponse] + listContent *connect.Client[v1.ListContentRequest, v1.ListContentResponse] + listDocs *connect.Client[v1.ListDocsRequest, v1.ListDocsResponse] + getDoc *connect.Client[v1.GetDocRequest, v1.GetDocResponse] + saveDoc *connect.Client[v1.SaveDocRequest, v1.SaveDocResponse] + runScript *connect.Client[v1.RunScriptRequest, v1.RunScriptResponse] } -// 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) +// ListContent calls homelab.v1.HomelabService.ListContent. +func (c *homelabServiceClient) ListContent(ctx context.Context, req *connect.Request[v1.ListContentRequest]) (*connect.Response[v1.ListContentResponse], error) { + return c.listContent.CallUnary(ctx, req) +} + +// ListDocs calls homelab.v1.HomelabService.ListDocs. +func (c *homelabServiceClient) ListDocs(ctx context.Context, req *connect.Request[v1.ListDocsRequest]) (*connect.Response[v1.ListDocsResponse], error) { + return c.listDocs.CallUnary(ctx, req) +} + +// GetDoc calls homelab.v1.HomelabService.GetDoc. +func (c *homelabServiceClient) GetDoc(ctx context.Context, req *connect.Request[v1.GetDocRequest]) (*connect.Response[v1.GetDocResponse], error) { + return c.getDoc.CallUnary(ctx, req) +} + +// SaveDoc calls homelab.v1.HomelabService.SaveDoc. +func (c *homelabServiceClient) SaveDoc(ctx context.Context, req *connect.Request[v1.SaveDocRequest]) (*connect.Response[v1.SaveDocResponse], error) { + return c.saveDoc.CallUnary(ctx, req) } // RunScript calls homelab.v1.HomelabService.RunScript. @@ -91,7 +136,10 @@ func (c *homelabServiceClient) RunScript(ctx context.Context, req *connect.Reque // HomelabServiceHandler is an implementation of the homelab.v1.HomelabService service. type HomelabServiceHandler interface { - ListSections(context.Context, *connect.Request[v1.ListSectionsRequest]) (*connect.Response[v1.ListSectionsResponse], error) + ListContent(context.Context, *connect.Request[v1.ListContentRequest]) (*connect.Response[v1.ListContentResponse], error) + 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) RunScript(context.Context, *connect.Request[v1.RunScriptRequest]) (*connect.Response[v1.RunScriptResponse], error) } @@ -102,10 +150,28 @@ 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() - homelabServiceListSectionsHandler := connect.NewUnaryHandler( - HomelabServiceListSectionsProcedure, - svc.ListSections, - connect.WithSchema(homelabServiceMethods.ByName("ListSections")), + homelabServiceListContentHandler := connect.NewUnaryHandler( + HomelabServiceListContentProcedure, + svc.ListContent, + connect.WithSchema(homelabServiceMethods.ByName("ListContent")), + connect.WithHandlerOptions(opts...), + ) + homelabServiceListDocsHandler := connect.NewUnaryHandler( + HomelabServiceListDocsProcedure, + svc.ListDocs, + connect.WithSchema(homelabServiceMethods.ByName("ListDocs")), + connect.WithHandlerOptions(opts...), + ) + homelabServiceGetDocHandler := connect.NewUnaryHandler( + HomelabServiceGetDocProcedure, + svc.GetDoc, + connect.WithSchema(homelabServiceMethods.ByName("GetDoc")), + connect.WithHandlerOptions(opts...), + ) + homelabServiceSaveDocHandler := connect.NewUnaryHandler( + HomelabServiceSaveDocProcedure, + svc.SaveDoc, + connect.WithSchema(homelabServiceMethods.ByName("SaveDoc")), connect.WithHandlerOptions(opts...), ) homelabServiceRunScriptHandler := connect.NewUnaryHandler( @@ -116,8 +182,14 @@ func NewHomelabServiceHandler(svc HomelabServiceHandler, opts ...connect.Handler ) return "/homelab.v1.HomelabService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { - case HomelabServiceListSectionsProcedure: - homelabServiceListSectionsHandler.ServeHTTP(w, r) + case HomelabServiceListContentProcedure: + homelabServiceListContentHandler.ServeHTTP(w, r) + case HomelabServiceListDocsProcedure: + homelabServiceListDocsHandler.ServeHTTP(w, r) + case HomelabServiceGetDocProcedure: + homelabServiceGetDocHandler.ServeHTTP(w, r) + case HomelabServiceSaveDocProcedure: + homelabServiceSaveDocHandler.ServeHTTP(w, r) case HomelabServiceRunScriptProcedure: homelabServiceRunScriptHandler.ServeHTTP(w, r) default: @@ -129,8 +201,20 @@ func NewHomelabServiceHandler(svc HomelabServiceHandler, opts ...connect.Handler // UnimplementedHomelabServiceHandler returns CodeUnimplemented from all methods. type UnimplementedHomelabServiceHandler struct{} -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) ListContent(context.Context, *connect.Request[v1.ListContentRequest]) (*connect.Response[v1.ListContentResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("homelab.v1.HomelabService.ListContent is not implemented")) +} + +func (UnimplementedHomelabServiceHandler) ListDocs(context.Context, *connect.Request[v1.ListDocsRequest]) (*connect.Response[v1.ListDocsResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("homelab.v1.HomelabService.ListDocs is not implemented")) +} + +func (UnimplementedHomelabServiceHandler) GetDoc(context.Context, *connect.Request[v1.GetDocRequest]) (*connect.Response[v1.GetDocResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("homelab.v1.HomelabService.GetDoc is not implemented")) +} + +func (UnimplementedHomelabServiceHandler) SaveDoc(context.Context, *connect.Request[v1.SaveDocRequest]) (*connect.Response[v1.SaveDocResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("homelab.v1.HomelabService.SaveDoc is not implemented")) } func (UnimplementedHomelabServiceHandler) RunScript(context.Context, *connect.Request[v1.RunScriptRequest]) (*connect.Response[v1.RunScriptResponse], error) { diff --git a/proto/homelab/v1/homelab.proto b/proto/homelab/v1/homelab.proto index 81bb823..8ad2bfc 100644 --- a/proto/homelab/v1/homelab.proto +++ b/proto/homelab/v1/homelab.proto @@ -6,22 +6,55 @@ option go_package = "pwa-homelab-mon/gen/homelab/v1;homelabv1"; 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 + string data = 2; // monitor: text | percentage + string value = 3; // monitor: last output + int32 args = 4; // exec: arg count + repeated string arg_types = 5; // exec: text | number per arg } -message Section { +message Link { string name = 1; - string icon = 2; - repeated Item items = 3; + string url = 2; } -message ListSectionsRequest {} -message ListSectionsResponse { - repeated Section sections = 1; +message ListContentRequest {} +message ListContentResponse { + repeated Link links = 1; + repeated Item monitors = 2; + repeated Item exec = 3; +} + +message DocMeta { + string name = 1; + string hash = 2; // sha256 hex of content +} + +message ListDocsRequest {} +message ListDocsResponse { + repeated DocMeta docs = 1; +} + +message GetDocRequest { + string name = 1; +} +message GetDocResponse { + string content = 1; +} + +message SaveDocRequest { + string name = 1; + string content = 2; +} +message SaveDocResponse { + string hash = 1; +} + +service HomelabService { + rpc ListContent(ListContentRequest) returns (ListContentResponse); + rpc ListDocs(ListDocsRequest) returns (ListDocsResponse); + rpc GetDoc(GetDocRequest) returns (GetDocResponse); + rpc SaveDoc(SaveDocRequest) returns (SaveDocResponse); + rpc RunScript(RunScriptRequest) returns (RunScriptResponse); } message RunScriptRequest { @@ -31,8 +64,3 @@ message RunScriptRequest { message RunScriptResponse { string output = 1; } - -service HomelabService { - rpc ListSections(ListSectionsRequest) returns (ListSectionsResponse); - rpc RunScript(RunScriptRequest) returns (RunScriptResponse); -} diff --git a/server/main.go b/server/main.go index 58d4e72..7e9ddbd 100644 --- a/server/main.go +++ b/server/main.go @@ -1,14 +1,15 @@ package main -//test - import ( "context" + "crypto/sha256" + "encoding/hex" "fmt" "log" "net/http" "os" "os/exec" + "path/filepath" "strings" "sync" "time" @@ -25,23 +26,22 @@ import ( 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 + Args int `yaml:"args"` // exec: arg count + ArgTypes []string `yaml:"arg_types"` // exec: text | number per arg } -// 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"` +type Link struct { + Name string `yaml:"name"` + URL string `yaml:"url"` } type Config struct { - Sections []Section `yaml:"sections"` + Links []Link `yaml:"links"` + Monitor []Item `yaml:"monitor"` + Exec []Item `yaml:"exec"` } func loadConfig(path string) (*Config, error) { @@ -56,27 +56,21 @@ func loadConfig(path string) (*Config, error) { 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) + for _, it := range cfg.Monitor { + if it.Refresh <= 0 { + return nil, fmt.Errorf("monitor %q: refresh must be > 0", it.Name) + } + if it.Data != "text" && it.Data != "percentage" { + return nil, fmt.Errorf("monitor %q: data must be text|percentage", it.Name) + } + } + for _, it := range cfg.Exec { + if len(it.ArgTypes) != it.Args { + return nil, fmt.Errorf("exec %q: arg_types must have args entries", it.Name) + } + for _, t := range it.ArgTypes { + if t != "text" && t != "number" { + return nil, fmt.Errorf("exec %q: arg type must be text|number", it.Name) } } } @@ -87,8 +81,9 @@ func loadConfig(path string) (*Config, error) { 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) + exec map[string]*Item // by item name, read-only after start + monVals sync.Map // monitor item name -> string (last output) + docsDir string } const execTimeout = 10 * time.Second @@ -104,56 +99,113 @@ func runScript(s *Item, args ...string) (string, error) { } 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) + for i := range s.cfg.Monitor { + sc := &s.cfg.Monitor[i] + 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)) } - }(&sc) - } + 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 +// --- docs --- + +func docHash(b []byte) string { + h := sha256.Sum256(b) + return hex.EncodeToString(h[:]) } -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)) +// docPath resolves a doc name safely: plain file name only, no separators, always inside docsDir. +func (s *server) docPath(name string) (string, error) { + if filepath.Base(name) != name || filepath.Ext(name) != ".md" { + return "", fmt.Errorf("bad doc name %q", name) + } + p := filepath.Join(s.docsDir, name) + if filepath.Dir(p) != s.docsDir { + return "", fmt.Errorf("bad doc name %q", name) + } + return p, nil +} + +func (s *server) ListDocs(context.Context, *connect.Request[homelabv1.ListDocsRequest]) (*connect.Response[homelabv1.ListDocsResponse], error) { + resp := &homelabv1.ListDocsResponse{} + entries, err := os.ReadDir(s.docsDir) + if err != nil { + return connect.NewResponse(resp), nil // no docs dir = no docs + } + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".md") { + continue } - resp.Sections = append(resp.Sections, ps) + b, err := os.ReadFile(filepath.Join(s.docsDir, e.Name())) + if err != nil { + continue + } + resp.Docs = append(resp.Docs, &homelabv1.DocMeta{Name: e.Name(), Hash: docHash(b)}) + } + return connect.NewResponse(resp), nil +} + +func (s *server) GetDoc(_ context.Context, req *connect.Request[homelabv1.GetDocRequest]) (*connect.Response[homelabv1.GetDocResponse], error) { + p, err := s.docPath(req.Msg.GetName()) + if err != nil { + return nil, connect.NewError(connect.CodeInvalidArgument, err) + } + b, err := os.ReadFile(p) + if err != nil { + return nil, connect.NewError(connect.CodeNotFound, err) + } + return connect.NewResponse(&homelabv1.GetDocResponse{Content: string(b)}), nil +} + +func (s *server) SaveDoc(_ context.Context, req *connect.Request[homelabv1.SaveDocRequest]) (*connect.Response[homelabv1.SaveDocResponse], error) { + p, err := s.docPath(req.Msg.GetName()) + if err != nil { + return nil, connect.NewError(connect.CodeInvalidArgument, err) + } + content := []byte(req.Msg.GetContent()) + // ponytail: client authority — last write wins, no base-hash check; add optimistic locking if concurrent edits appear + if err := os.WriteFile(p, content, 0o644); err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + log.Printf("doc saved: %s (%d bytes)", filepath.Base(p), len(content)) + return connect.NewResponse(&homelabv1.SaveDocResponse{Hash: docHash(content)}), nil +} + +// --- content RPC --- + +func (s *server) ListContent(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}) + } + for i := range s.cfg.Monitor { + it := &s.cfg.Monitor[i] + val, _ := s.monVals.Load(it.Name) + v, _ := val.(string) + resp.Monitors = append(resp.Monitors, &homelabv1.Item{Name: it.Name, Data: it.Data, Value: v}) + } + for i := range s.cfg.Exec { + it := &s.cfg.Exec[i] + resp.Exec = append(resp.Exec, &homelabv1.Item{Name: it.Name, Args: int32(it.Args), ArgTypes: it.ArgTypes}) } 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())) + it, ok := s.exec[req.Msg.GetName()] + if !ok { + return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("no exec item %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()))) @@ -180,22 +232,26 @@ func main() { 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] - } + docsDir := "docs" + if err := os.MkdirAll(docsDir, 0o755); err != nil { + fmt.Fprintln(os.Stderr, "docs:", err) + os.Exit(1) + } + + svc := &server{cfg: cfg, exec: map[string]*Item{}, docsDir: docsDir} + for i := range cfg.Exec { + svc.exec[cfg.Exec[i].Name] = &cfg.Exec[i] } svc.startMonitors() - log.Printf("config %s: %d sections, %d items", cfgPath, len(cfg.Sections), len(svc.scripts)) + log.Printf("config %s: %d links, %d monitors, %d exec", cfgPath, len(cfg.Links), len(cfg.Monitor), len(cfg.Exec)) e := echo.New() e.HideBanner = true - // HTTP request log, except the ListSections heartbeat (every 3s per client — pure noise). + // HTTP request log, except the ListContent 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" + return c.Request().URL.Path == "/homelab.v1.HomelabService/ListContent" }, })) @@ -204,10 +260,11 @@ func main() { mux.Handle(homelabv1connect.NewHomelabServiceHandler(svc)) e.Any("/homelab.v1.HomelabService/*", echo.WrapHandler(mux)) - // sw.js must never be cached, otherwise iOS serves a stale service worker. + // sw.js and index.html must never be cached: stale index = stale asset references = iOS never updates. e.Use(func(next echo.HandlerFunc) echo.HandlerFunc { return func(c echo.Context) error { - if c.Request().URL.Path == "/sw.js" || c.Request().URL.Path == "/manifest.webmanifest" { + p := c.Request().URL.Path + if p == "/sw.js" || p == "/manifest.webmanifest" || p == "/index.html" || p == "/" { c.Response().Header().Set("Cache-Control", "no-cache") } return next(c) diff --git a/web/package.json b/web/package.json index c819626..d7c50f3 100644 --- a/web/package.json +++ b/web/package.json @@ -15,6 +15,7 @@ "@tailwindcss/vite": "^4.3.3", "daisyui": "^5.7.22", "lucide-preact": "^1.35.0", + "marked": "^18.0.11", "preact": "^10.29.8", "tailwindcss": "^4.3.3" }, diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 6c99bd4..ba35a7a 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -26,6 +26,9 @@ importers: lucide-preact: specifier: ^1.35.0 version: 1.35.0(preact@10.29.8) + marked: + specifier: ^18.0.11 + version: 18.0.11 preact: specifier: ^10.29.8 version: 10.29.8 @@ -808,6 +811,11 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + marked@18.0.11: + resolution: {integrity: sha512-HnslJfsZkRPBDJRHvVtAaWlZHEpSu7u8LgQuJCELjRKuWR+hpq4A7sLq3p8HaI9ypVoXDXxV34CsQJEe1+J5Aw==} + engines: {node: '>= 20'} + hasBin: true + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -1569,6 +1577,8 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.6.0 + marked@18.0.11: {} + ms@2.1.3: {} nanoid@3.3.18: {} diff --git a/web/public/sw.js b/web/public/sw.js index e9260ff..5b1fe75 100644 --- a/web/public/sw.js +++ b/web/public/sw.js @@ -1,5 +1,5 @@ /* Offline shell: navigations are network-first, static assets cache-first. */ -const CACHE = "homelab-shell-v2"; +const CACHE = "homelab-shell-v3"; self.addEventListener("install", (e) => { e.waitUntil(caches.open(CACHE).then((c) => c.addAll(["/", "/index.html"]))); diff --git a/web/src/App.tsx b/web/src/App.tsx index 8dd19cc..bcff3e1 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,66 +1,123 @@ import { useEffect, useState } from "preact/hooks"; import { createClient } from "@connectrpc/connect"; import { createConnectTransport } from "@connectrpc/connect-web"; -import { Gauge, FlaskConical } from "lucide-preact"; -import type { Section } from "./gen/homelab/v1/homelab_pb"; +import { BookOpen, Gauge, Link as LinkIcon, Menu, Play } from "lucide-preact"; +import type { Item, Link } from "./gen/homelab/v1/homelab_pb"; import { HomelabService } from "./gen/homelab/v1/homelab_pb"; -import { ScriptCard } from "./components/ScriptCard"; import { MonitorCard } from "./components/MonitorCard"; +import { ScriptCard } from "./components/ScriptCard"; +import { DocsView, DocView, localDocs } from "./components/Docs"; +import { syncDocs } from "./docsStore"; const client = createClient(HomelabService, createConnectTransport({ baseUrl: "" })); -// Иконки секций: строка из конфига → компонент. Новый icon в yaml = добавить сюда. -const ICONS: Record = { gauge: Gauge, "flask-conical": FlaskConical }; +const CATEGORIES = [ + { id: "links", label: "Links", icon: LinkIcon }, + { id: "monitor", label: "Monitor", icon: Gauge }, + { id: "exec", label: "Exec", icon: Play }, + { id: "docs", label: "Docs", icon: BookOpen }, +] as const; -function DynIcon({ name, size = 20 }: { name: string; size?: number }) { - const Cmp = ICONS[name] ?? Gauge; - return ; +type Category = (typeof CATEGORIES)[number]["id"]; + +function LinkList({ links }: { links: Link[] }) { + if (links.length === 0) return
No links in config
; + return ( +
+ {links.map((l) => ( + +
+ + {l.name} + {l.url} +
+
+ ))} +
+ ); } export function App() { - const [sections, setSections] = useState([]); - const [active, setActive] = useState(0); + const [content, setContent] = useState<{ links: Link[]; monitors: Item[]; exec: Item[] }>({ + links: [], + monitors: [], + exec: [], + }); + const [category, setCategory] = useState("monitor"); + const [drawerOpen, setDrawerOpen] = useState(false); + const [docs, setDocs] = useState(localDocs()); + const [openDoc, setOpenDoc] = useState(null); useEffect(() => { - const poll = () => client.listSections({}).then((r) => setSections(r.sections)); + const poll = () => + client + .listContent({}) + .then((r) => setContent({ links: r.links, monitors: r.monitors, exec: r.exec })) + .catch(() => {}); // offline: keep last state poll(); const id = setInterval(poll, 3000); return () => clearInterval(id); }, []); - const sec = sections[active]; + // Docs sync + offline dirty flush, every 5s. + useEffect(() => { + const tick = () => syncDocs().then(() => setDocs(localDocs())).catch(() => {}); + tick(); + const id = setInterval(tick, 5000); + return () => clearInterval(id); + }, []); + + const cat = CATEGORIES.find((c) => c.id === category)!; + const open = docs.find((d) => d.name === openDoc); return ( -
- {/* Sidebar */} - +
+ setDrawerOpen((v) => !v)} /> - {/* Active section view */} -
-