diff --git a/gen/homelab/v1/homelab.pb.go b/gen/homelab/v1/homelab.pb.go index 3f17494..9567b53 100644 --- a/gen/homelab/v1/homelab.pb.go +++ b/gen/homelab/v1/homelab.pb.go @@ -869,6 +869,102 @@ func (x *ServiceActionResponse) GetOutput() string { return "" } +type ServiceInfoRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + Kind ServiceKind `protobuf:"varint,2,opt,name=kind,proto3,enum=homelab.v1.ServiceKind" json:"kind,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ServiceInfoRequest) Reset() { + *x = ServiceInfoRequest{} + mi := &file_homelab_v1_homelab_proto_msgTypes[14] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ServiceInfoRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ServiceInfoRequest) ProtoMessage() {} + +func (x *ServiceInfoRequest) ProtoReflect() protoreflect.Message { + mi := &file_homelab_v1_homelab_proto_msgTypes[14] + 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 ServiceInfoRequest.ProtoReflect.Descriptor instead. +func (*ServiceInfoRequest) Descriptor() ([]byte, []int) { + return file_homelab_v1_homelab_proto_rawDescGZIP(), []int{14} +} + +func (x *ServiceInfoRequest) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *ServiceInfoRequest) GetKind() ServiceKind { + if x != nil { + return x.Kind + } + return ServiceKind_SYSTEMD +} + +type ServiceInfoResponse 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 *ServiceInfoResponse) Reset() { + *x = ServiceInfoResponse{} + mi := &file_homelab_v1_homelab_proto_msgTypes[15] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ServiceInfoResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ServiceInfoResponse) ProtoMessage() {} + +func (x *ServiceInfoResponse) ProtoReflect() protoreflect.Message { + mi := &file_homelab_v1_homelab_proto_msgTypes[15] + 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 ServiceInfoResponse.ProtoReflect.Descriptor instead. +func (*ServiceInfoResponse) Descriptor() ([]byte, []int) { + return file_homelab_v1_homelab_proto_rawDescGZIP(), []int{15} +} + +func (x *ServiceInfoResponse) GetOutput() string { + if x != nil { + return x.Output + } + return "" +} + type RunScriptRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` @@ -879,7 +975,7 @@ type RunScriptRequest struct { func (x *RunScriptRequest) Reset() { *x = RunScriptRequest{} - mi := &file_homelab_v1_homelab_proto_msgTypes[14] + mi := &file_homelab_v1_homelab_proto_msgTypes[16] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -891,7 +987,7 @@ func (x *RunScriptRequest) String() string { func (*RunScriptRequest) ProtoMessage() {} func (x *RunScriptRequest) ProtoReflect() protoreflect.Message { - mi := &file_homelab_v1_homelab_proto_msgTypes[14] + mi := &file_homelab_v1_homelab_proto_msgTypes[16] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -904,7 +1000,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{14} + return file_homelab_v1_homelab_proto_rawDescGZIP(), []int{16} } func (x *RunScriptRequest) GetName() string { @@ -930,7 +1026,7 @@ type RunScriptResponse struct { func (x *RunScriptResponse) Reset() { *x = RunScriptResponse{} - mi := &file_homelab_v1_homelab_proto_msgTypes[15] + mi := &file_homelab_v1_homelab_proto_msgTypes[17] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -942,7 +1038,7 @@ func (x *RunScriptResponse) String() string { func (*RunScriptResponse) ProtoMessage() {} func (x *RunScriptResponse) ProtoReflect() protoreflect.Message { - mi := &file_homelab_v1_homelab_proto_msgTypes[15] + mi := &file_homelab_v1_homelab_proto_msgTypes[17] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -955,7 +1051,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{15} + return file_homelab_v1_homelab_proto_rawDescGZIP(), []int{17} } func (x *RunScriptResponse) GetOutput() string { @@ -1018,6 +1114,11 @@ const file_homelab_v1_homelab_proto_rawDesc = "" + "\x04kind\x18\x02 \x01(\x0e2\x17.homelab.v1.ServiceKindR\x04kind\x12%\n" + "\x02op\x18\x03 \x01(\x0e2\x15.homelab.v1.ServiceOpR\x02op\"/\n" + "\x15ServiceActionResponse\x12\x16\n" + + "\x06output\x18\x01 \x01(\tR\x06output\"U\n" + + "\x12ServiceInfoRequest\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12+\n" + + "\x04kind\x18\x02 \x01(\x0e2\x17.homelab.v1.ServiceKindR\x04kind\"-\n" + + "\x13ServiceInfoResponse\x12\x16\n" + "\x06output\x18\x01 \x01(\tR\x06output\":\n" + "\x10RunScriptRequest\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12\x12\n" + @@ -1031,13 +1132,14 @@ const file_homelab_v1_homelab_proto_rawDesc = "" + "\tServiceOp\x12\t\n" + "\x05START\x10\x00\x12\b\n" + "\x04STOP\x10\x01\x12\v\n" + - "\aRESTART\x10\x022\xcc\x03\n" + + "\aRESTART\x10\x022\x9c\x04\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\x12T\n" + - "\rServiceAction\x12 .homelab.v1.ServiceActionRequest\x1a!.homelab.v1.ServiceActionResponse\x12H\n" + + "\rServiceAction\x12 .homelab.v1.ServiceActionRequest\x1a!.homelab.v1.ServiceActionResponse\x12N\n" + + "\vServiceInfo\x12\x1e.homelab.v1.ServiceInfoRequest\x1a\x1f.homelab.v1.ServiceInfoResponse\x12H\n" + "\tRunScript\x12\x1c.homelab.v1.RunScriptRequest\x1a\x1d.homelab.v1.RunScriptResponseB*Z(pwa-homelab-mon/gen/homelab/v1;homelabv1b\x06proto3" var ( @@ -1053,7 +1155,7 @@ func file_homelab_v1_homelab_proto_rawDescGZIP() []byte { } var file_homelab_v1_homelab_proto_enumTypes = make([]protoimpl.EnumInfo, 2) -var file_homelab_v1_homelab_proto_msgTypes = make([]protoimpl.MessageInfo, 16) +var file_homelab_v1_homelab_proto_msgTypes = make([]protoimpl.MessageInfo, 18) var file_homelab_v1_homelab_proto_goTypes = []any{ (ServiceKind)(0), // 0: homelab.v1.ServiceKind (ServiceOp)(0), // 1: homelab.v1.ServiceOp @@ -1071,8 +1173,10 @@ var file_homelab_v1_homelab_proto_goTypes = []any{ (*Service)(nil), // 13: homelab.v1.Service (*ServiceActionRequest)(nil), // 14: homelab.v1.ServiceActionRequest (*ServiceActionResponse)(nil), // 15: homelab.v1.ServiceActionResponse - (*RunScriptRequest)(nil), // 16: homelab.v1.RunScriptRequest - (*RunScriptResponse)(nil), // 17: homelab.v1.RunScriptResponse + (*ServiceInfoRequest)(nil), // 16: homelab.v1.ServiceInfoRequest + (*ServiceInfoResponse)(nil), // 17: homelab.v1.ServiceInfoResponse + (*RunScriptRequest)(nil), // 18: homelab.v1.RunScriptRequest + (*RunScriptResponse)(nil), // 19: homelab.v1.RunScriptResponse } var file_homelab_v1_homelab_proto_depIdxs = []int32{ 3, // 0: homelab.v1.ListContentResponse.links:type_name -> homelab.v1.Link @@ -1083,23 +1187,26 @@ var file_homelab_v1_homelab_proto_depIdxs = []int32{ 0, // 5: homelab.v1.Service.kind:type_name -> homelab.v1.ServiceKind 0, // 6: homelab.v1.ServiceActionRequest.kind:type_name -> homelab.v1.ServiceKind 1, // 7: homelab.v1.ServiceActionRequest.op:type_name -> homelab.v1.ServiceOp - 4, // 8: homelab.v1.HomelabService.ListContent:input_type -> homelab.v1.ListContentRequest - 7, // 9: homelab.v1.HomelabService.ListDocs:input_type -> homelab.v1.ListDocsRequest - 9, // 10: homelab.v1.HomelabService.GetDoc:input_type -> homelab.v1.GetDocRequest - 11, // 11: homelab.v1.HomelabService.SaveDoc:input_type -> homelab.v1.SaveDocRequest - 14, // 12: homelab.v1.HomelabService.ServiceAction:input_type -> homelab.v1.ServiceActionRequest - 16, // 13: homelab.v1.HomelabService.RunScript:input_type -> homelab.v1.RunScriptRequest - 5, // 14: homelab.v1.HomelabService.ListContent:output_type -> homelab.v1.ListContentResponse - 8, // 15: homelab.v1.HomelabService.ListDocs:output_type -> homelab.v1.ListDocsResponse - 10, // 16: homelab.v1.HomelabService.GetDoc:output_type -> homelab.v1.GetDocResponse - 12, // 17: homelab.v1.HomelabService.SaveDoc:output_type -> homelab.v1.SaveDocResponse - 15, // 18: homelab.v1.HomelabService.ServiceAction:output_type -> homelab.v1.ServiceActionResponse - 17, // 19: homelab.v1.HomelabService.RunScript:output_type -> homelab.v1.RunScriptResponse - 14, // [14:20] is the sub-list for method output_type - 8, // [8:14] is the sub-list for method input_type - 8, // [8:8] is the sub-list for extension type_name - 8, // [8:8] is the sub-list for extension extendee - 0, // [0:8] is the sub-list for field type_name + 0, // 8: homelab.v1.ServiceInfoRequest.kind:type_name -> homelab.v1.ServiceKind + 4, // 9: homelab.v1.HomelabService.ListContent:input_type -> homelab.v1.ListContentRequest + 7, // 10: homelab.v1.HomelabService.ListDocs:input_type -> homelab.v1.ListDocsRequest + 9, // 11: homelab.v1.HomelabService.GetDoc:input_type -> homelab.v1.GetDocRequest + 11, // 12: homelab.v1.HomelabService.SaveDoc:input_type -> homelab.v1.SaveDocRequest + 14, // 13: homelab.v1.HomelabService.ServiceAction:input_type -> homelab.v1.ServiceActionRequest + 16, // 14: homelab.v1.HomelabService.ServiceInfo:input_type -> homelab.v1.ServiceInfoRequest + 18, // 15: homelab.v1.HomelabService.RunScript:input_type -> homelab.v1.RunScriptRequest + 5, // 16: homelab.v1.HomelabService.ListContent:output_type -> homelab.v1.ListContentResponse + 8, // 17: homelab.v1.HomelabService.ListDocs:output_type -> homelab.v1.ListDocsResponse + 10, // 18: homelab.v1.HomelabService.GetDoc:output_type -> homelab.v1.GetDocResponse + 12, // 19: homelab.v1.HomelabService.SaveDoc:output_type -> homelab.v1.SaveDocResponse + 15, // 20: homelab.v1.HomelabService.ServiceAction:output_type -> homelab.v1.ServiceActionResponse + 17, // 21: homelab.v1.HomelabService.ServiceInfo:output_type -> homelab.v1.ServiceInfoResponse + 19, // 22: homelab.v1.HomelabService.RunScript:output_type -> homelab.v1.RunScriptResponse + 16, // [16:23] is the sub-list for method output_type + 9, // [9:16] is the sub-list for method input_type + 9, // [9:9] is the sub-list for extension type_name + 9, // [9:9] is the sub-list for extension extendee + 0, // [0:9] is the sub-list for field type_name } func init() { file_homelab_v1_homelab_proto_init() } @@ -1113,7 +1220,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: 2, - NumMessages: 16, + NumMessages: 18, NumExtensions: 0, NumServices: 1, }, diff --git a/gen/homelab/v1/homelabv1connect/homelab.connect.go b/gen/homelab/v1/homelabv1connect/homelab.connect.go index de3ffb8..ffe9bd0 100644 --- a/gen/homelab/v1/homelabv1connect/homelab.connect.go +++ b/gen/homelab/v1/homelabv1connect/homelab.connect.go @@ -45,6 +45,9 @@ const ( // HomelabServiceServiceActionProcedure is the fully-qualified name of the HomelabService's // ServiceAction RPC. HomelabServiceServiceActionProcedure = "/homelab.v1.HomelabService/ServiceAction" + // HomelabServiceServiceInfoProcedure is the fully-qualified name of the HomelabService's + // ServiceInfo RPC. + HomelabServiceServiceInfoProcedure = "/homelab.v1.HomelabService/ServiceInfo" // HomelabServiceRunScriptProcedure is the fully-qualified name of the HomelabService's RunScript // RPC. HomelabServiceRunScriptProcedure = "/homelab.v1.HomelabService/RunScript" @@ -57,6 +60,7 @@ type HomelabServiceClient interface { GetDoc(context.Context, *connect.Request[v1.GetDocRequest]) (*connect.Response[v1.GetDocResponse], error) SaveDoc(context.Context, *connect.Request[v1.SaveDocRequest]) (*connect.Response[v1.SaveDocResponse], error) ServiceAction(context.Context, *connect.Request[v1.ServiceActionRequest]) (*connect.Response[v1.ServiceActionResponse], error) + ServiceInfo(context.Context, *connect.Request[v1.ServiceInfoRequest]) (*connect.Response[v1.ServiceInfoResponse], error) RunScript(context.Context, *connect.Request[v1.RunScriptRequest]) (*connect.Response[v1.RunScriptResponse], error) } @@ -101,6 +105,12 @@ func NewHomelabServiceClient(httpClient connect.HTTPClient, baseURL string, opts connect.WithSchema(homelabServiceMethods.ByName("ServiceAction")), connect.WithClientOptions(opts...), ), + serviceInfo: connect.NewClient[v1.ServiceInfoRequest, v1.ServiceInfoResponse]( + httpClient, + baseURL+HomelabServiceServiceInfoProcedure, + connect.WithSchema(homelabServiceMethods.ByName("ServiceInfo")), + connect.WithClientOptions(opts...), + ), runScript: connect.NewClient[v1.RunScriptRequest, v1.RunScriptResponse]( httpClient, baseURL+HomelabServiceRunScriptProcedure, @@ -117,6 +127,7 @@ type homelabServiceClient struct { getDoc *connect.Client[v1.GetDocRequest, v1.GetDocResponse] saveDoc *connect.Client[v1.SaveDocRequest, v1.SaveDocResponse] serviceAction *connect.Client[v1.ServiceActionRequest, v1.ServiceActionResponse] + serviceInfo *connect.Client[v1.ServiceInfoRequest, v1.ServiceInfoResponse] runScript *connect.Client[v1.RunScriptRequest, v1.RunScriptResponse] } @@ -145,6 +156,11 @@ func (c *homelabServiceClient) ServiceAction(ctx context.Context, req *connect.R return c.serviceAction.CallUnary(ctx, req) } +// ServiceInfo calls homelab.v1.HomelabService.ServiceInfo. +func (c *homelabServiceClient) ServiceInfo(ctx context.Context, req *connect.Request[v1.ServiceInfoRequest]) (*connect.Response[v1.ServiceInfoResponse], error) { + return c.serviceInfo.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) @@ -157,6 +173,7 @@ type HomelabServiceHandler interface { GetDoc(context.Context, *connect.Request[v1.GetDocRequest]) (*connect.Response[v1.GetDocResponse], error) SaveDoc(context.Context, *connect.Request[v1.SaveDocRequest]) (*connect.Response[v1.SaveDocResponse], error) ServiceAction(context.Context, *connect.Request[v1.ServiceActionRequest]) (*connect.Response[v1.ServiceActionResponse], error) + ServiceInfo(context.Context, *connect.Request[v1.ServiceInfoRequest]) (*connect.Response[v1.ServiceInfoResponse], error) RunScript(context.Context, *connect.Request[v1.RunScriptRequest]) (*connect.Response[v1.RunScriptResponse], error) } @@ -197,6 +214,12 @@ func NewHomelabServiceHandler(svc HomelabServiceHandler, opts ...connect.Handler connect.WithSchema(homelabServiceMethods.ByName("ServiceAction")), connect.WithHandlerOptions(opts...), ) + homelabServiceServiceInfoHandler := connect.NewUnaryHandler( + HomelabServiceServiceInfoProcedure, + svc.ServiceInfo, + connect.WithSchema(homelabServiceMethods.ByName("ServiceInfo")), + connect.WithHandlerOptions(opts...), + ) homelabServiceRunScriptHandler := connect.NewUnaryHandler( HomelabServiceRunScriptProcedure, svc.RunScript, @@ -215,6 +238,8 @@ func NewHomelabServiceHandler(svc HomelabServiceHandler, opts ...connect.Handler homelabServiceSaveDocHandler.ServeHTTP(w, r) case HomelabServiceServiceActionProcedure: homelabServiceServiceActionHandler.ServeHTTP(w, r) + case HomelabServiceServiceInfoProcedure: + homelabServiceServiceInfoHandler.ServeHTTP(w, r) case HomelabServiceRunScriptProcedure: homelabServiceRunScriptHandler.ServeHTTP(w, r) default: @@ -246,6 +271,10 @@ func (UnimplementedHomelabServiceHandler) ServiceAction(context.Context, *connec return nil, connect.NewError(connect.CodeUnimplemented, errors.New("homelab.v1.HomelabService.ServiceAction is not implemented")) } +func (UnimplementedHomelabServiceHandler) ServiceInfo(context.Context, *connect.Request[v1.ServiceInfoRequest]) (*connect.Response[v1.ServiceInfoResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("homelab.v1.HomelabService.ServiceInfo is not implemented")) +} + func (UnimplementedHomelabServiceHandler) RunScript(context.Context, *connect.Request[v1.RunScriptRequest]) (*connect.Response[v1.RunScriptResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("homelab.v1.HomelabService.RunScript is not implemented")) } diff --git a/proto/homelab/v1/homelab.proto b/proto/homelab/v1/homelab.proto index 74e7bc4..11d3029 100644 --- a/proto/homelab/v1/homelab.proto +++ b/proto/homelab/v1/homelab.proto @@ -82,12 +82,21 @@ message ServiceActionResponse { string output = 1; } +message ServiceInfoRequest { + string name = 1; + ServiceKind kind = 2; +} +message ServiceInfoResponse { + string output = 1; +} + service HomelabService { rpc ListContent(ListContentRequest) returns (ListContentResponse); rpc ListDocs(ListDocsRequest) returns (ListDocsResponse); rpc GetDoc(GetDocRequest) returns (GetDocResponse); rpc SaveDoc(SaveDocRequest) returns (SaveDocResponse); rpc ServiceAction(ServiceActionRequest) returns (ServiceActionResponse); + rpc ServiceInfo(ServiceInfoRequest) returns (ServiceInfoResponse); rpc RunScript(RunScriptRequest) returns (RunScriptResponse); } diff --git a/server/main.go b/server/main.go index 9966cba..bdbc56a 100644 --- a/server/main.go +++ b/server/main.go @@ -481,6 +481,37 @@ func (s *server) dockerTargets(ctx context.Context, name, op string) ([]string, return targets, nil } +// ServiceInfo returns raw status output for the modal: `systemctl status` / `docker ps -a` for the card. +func (s *server) ServiceInfo(ctx context.Context, req *connect.Request[homelabv1.ServiceInfoRequest]) (*connect.Response[homelabv1.ServiceInfoResponse], error) { + name, kind := req.Msg.GetName(), req.Msg.GetKind() + if !svcNameRe.MatchString(name) { + return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("bad service name %q", name)) + } + ctx, cancel := context.WithTimeout(ctx, svcTimeout) + defer cancel() + + var out string + var err error + if kind == homelabv1.ServiceKind_DOCKER { + // compose project first, bare container (no label) as fallback + out, err = dockerOut(ctx, "ps", "-a", "--filter", "label=com.docker.compose.project="+name) + if strings.TrimSpace(out) == "" { + out, err = dockerOut(ctx, "ps", "-a", "--filter", "name="+name) + } + } else { + // status exits non-zero for inactive units (code 3) — that is valid output, not an error + c := exec.CommandContext(ctx, "systemctl", "--user", "status", name+".service", "--no-pager", "-n", "0") + b, cerr := c.CombinedOutput() + out, err = string(b), cerr + } + if err != nil && strings.TrimSpace(out) == "" { + log.Printf("service info %s/%s: ERROR %v", kind, name, err) + return nil, connect.NewError(connect.CodeInternal, err) + } + log.Printf("service info %s/%s ok", kind, name) + return connect.NewResponse(&homelabv1.ServiceInfoResponse{Output: strings.TrimRight(out, "\n")}), nil +} + func (s *server) RunScript(_ context.Context, req *connect.Request[homelabv1.RunScriptRequest]) (*connect.Response[homelabv1.RunScriptResponse], error) { it, ok := s.exec[req.Msg.GetName()] if !ok { diff --git a/web/src/App.tsx b/web/src/App.tsx index 7b424ea..afa49d9 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -94,7 +94,11 @@ export function App() { -
+
{category === "links" && } {category === "monitor" && (
@@ -103,7 +107,7 @@ export function App() { )} {category === "exec" && content.exec.map((it) => )} {category === "services" && ( -
+
{content.services.map((s) => )}
)} diff --git a/web/src/components/ServiceCard.tsx b/web/src/components/ServiceCard.tsx index f469c93..40351b4 100644 --- a/web/src/components/ServiceCard.tsx +++ b/web/src/components/ServiceCard.tsx @@ -1,13 +1,13 @@ import { useState } from "preact/hooks"; import { createClient } from "@connectrpc/connect"; import { createConnectTransport } from "@connectrpc/connect-web"; -import { Container, Server } from "lucide-preact"; +import { Container, Info, Server, X } from "lucide-preact"; import type { Service } from "../gen/homelab/v1/homelab_pb"; import { HomelabService, ServiceKind, ServiceOp } from "../gen/homelab/v1/homelab_pb"; const client = createClient(HomelabService, createConnectTransport({ baseUrl: "" })); -/** Бейдж статуса: system d — active/failed/выключен (not-found = намеренно disabled, не ошибка), docker — агрегат проекта. */ +/** Бейдж статуса: systemd — active/failed/выключен (not-found = намеренно disabled, не ошибка), docker — агрегат проекта. */ function badge(s: Service) { if (s.error) return ошибка; if (s.kind === ServiceKind.DOCKER) { @@ -21,10 +21,29 @@ function badge(s: Service) { return остановлен; } -/** Карточка сервиса: systemd-юнит или docker-проект, start/stop/restart через ServiceAction. */ +/** Кнопки контроля; вынесены, чтобы переиспользовать на карточке и в модалке. */ +function Controls({ s, onAct, busy }: { s: Service; onAct: (op: ServiceOp) => void; busy: boolean }) { + const disabled = s.kind === ServiceKind.SYSTEMD && s.loadState === "not-found"; + const btn = (op: ServiceOp, label: string, title?: string) => ( + + ); + return ( +
+ {btn(ServiceOp.START, "start")} + {btn(ServiceOp.STOP, "stop", "docker stop: контейнер не поднимется при ребуте (unless-stopped)")} + {btn(ServiceOp.RESTART, "restart")} +
+ ); +} + +/** Карточка сервиса: systemd-юнит или docker-проект, start/stop/restart через ServiceAction, info — статус-вывод в модалке. */ export function ServiceCard({ s, refresh }: { s: Service; refresh: () => void }) { const [busy, setBusy] = useState(false); const [error, setError] = useState(""); + const [info, setInfo] = useState(null); + const [infoBusy, setInfoBusy] = useState(false); const act = async (op: ServiceOp) => { setBusy(true); @@ -32,6 +51,10 @@ export function ServiceCard({ s, refresh }: { s: Service; refresh: () => void }) try { await client.serviceAction({ name: s.name, kind: s.kind, op }); refresh(); // не ждём 3s поллинга + if (info !== null) { + const r = await client.serviceInfo({ name: s.name, kind: s.kind }); + setInfo(r.output); + } } catch (e) { setError(String(e).replace(/^.*real error: /, "")); } finally { @@ -39,40 +62,69 @@ export function ServiceCard({ s, refresh }: { s: Service; refresh: () => void }) } }; - const disabled = s.kind === ServiceKind.SYSTEMD && s.loadState === "not-found"; - const btn = (op: ServiceOp, label: string, title?: string) => ( - - ); + const openInfo = async () => { + setInfoBusy(true); + setError(""); + try { + setInfo((await client.serviceInfo({ name: s.name, kind: s.kind })).output); + } catch (e) { + setError(String(e).replace(/^.*real error: /, "")); + } finally { + setInfoBusy(false); + } + }; + + const kindIcon = + s.kind === ServiceKind.DOCKER ? ( + + ) : ( + + ); return (
- {s.kind === ServiceKind.DOCKER ? ( - - ) : ( - - )} + {kindIcon} {s.name} {badge(s)}
{error && }
- {btn(ServiceOp.START, "start")} - {btn(ServiceOp.STOP, "stop", "docker stop: контейнер не поднимется при ребуте (unless-stopped)")} - {btn(ServiceOp.RESTART, "restart")} + +
{s.containers.length > 0 && (

{s.containers.join(", ")}

)}
+ + {info !== null && ( + ); } diff --git a/web/src/gen/homelab/v1/homelab_pb.ts b/web/src/gen/homelab/v1/homelab_pb.ts index 087723c..b3da449 100644 --- a/web/src/gen/homelab/v1/homelab_pb.ts +++ b/web/src/gen/homelab/v1/homelab_pb.ts @@ -10,7 +10,7 @@ import type { Message } from "@bufbuild/protobuf"; * Describes the file homelab/v1/homelab.proto. */ export const file_homelab_v1_homelab: GenFile = /*@__PURE__*/ - fileDesc("Chhob21lbGFiL3YxL2hvbWVsYWIucHJvdG8SCmhvbWVsYWIudjEiUgoESXRlbRIMCgRuYW1lGAEgASgJEgwKBGRhdGEYAiABKAkSDQoFdmFsdWUYAyABKAkSDAoEYXJncxgEIAEoBRIRCglhcmdfdHlwZXMYBSADKAkiIQoETGluaxIMCgRuYW1lGAEgASgJEgsKA3VybBgCIAEoCSIUChJMaXN0Q29udGVudFJlcXVlc3QioQEKE0xpc3RDb250ZW50UmVzcG9uc2USHwoFbGlua3MYASADKAsyEC5ob21lbGFiLnYxLkxpbmsSIgoIbW9uaXRvcnMYAiADKAsyEC5ob21lbGFiLnYxLkl0ZW0SHgoEZXhlYxgDIAMoCzIQLmhvbWVsYWIudjEuSXRlbRIlCghzZXJ2aWNlcxgEIAMoCzITLmhvbWVsYWIudjEuU2VydmljZSIlCgdEb2NNZXRhEgwKBG5hbWUYASABKAkSDAoEaGFzaBgCIAEoCSIRCg9MaXN0RG9jc1JlcXVlc3QiNQoQTGlzdERvY3NSZXNwb25zZRIhCgRkb2NzGAEgAygLMhMuaG9tZWxhYi52MS5Eb2NNZXRhIh0KDUdldERvY1JlcXVlc3QSDAoEbmFtZRgBIAEoCSIhCg5HZXREb2NSZXNwb25zZRIPCgdjb250ZW50GAEgASgJIi8KDlNhdmVEb2NSZXF1ZXN0EgwKBG5hbWUYASABKAkSDwoHY29udGVudBgCIAEoCSIfCg9TYXZlRG9jUmVzcG9uc2USDAoEaGFzaBgBIAEoCSKvAQoHU2VydmljZRIlCgRraW5kGAEgASgOMhcuaG9tZWxhYi52MS5TZXJ2aWNlS2luZBIMCgRuYW1lGAIgASgJEg0KBXN0YXRlGAMgASgJEhAKCHN1YnN0YXRlGAQgASgJEhIKCmxvYWRfc3RhdGUYBSABKAkSFwoPdW5pdF9maWxlX3N0YXRlGAYgASgJEhIKCmNvbnRhaW5lcnMYByADKAkSDQoFZXJyb3IYCCABKAkibgoUU2VydmljZUFjdGlvblJlcXVlc3QSDAoEbmFtZRgBIAEoCRIlCgRraW5kGAIgASgOMhcuaG9tZWxhYi52MS5TZXJ2aWNlS2luZBIhCgJvcBgDIAEoDjIVLmhvbWVsYWIudjEuU2VydmljZU9wIicKFVNlcnZpY2VBY3Rpb25SZXNwb25zZRIOCgZvdXRwdXQYASABKAkiLgoQUnVuU2NyaXB0UmVxdWVzdBIMCgRuYW1lGAEgASgJEgwKBGFyZ3MYAiADKAkiIwoRUnVuU2NyaXB0UmVzcG9uc2USDgoGb3V0cHV0GAEgASgJKiYKC1NlcnZpY2VLaW5kEgsKB1NZU1RFTUQQABIKCgZET0NLRVIQASotCglTZXJ2aWNlT3ASCQoFU1RBUlQQABIICgRTVE9QEAESCwoHUkVTVEFSVBACMswDCg5Ib21lbGFiU2VydmljZRJOCgtMaXN0Q29udGVudBIeLmhvbWVsYWIudjEuTGlzdENvbnRlbnRSZXF1ZXN0Gh8uaG9tZWxhYi52MS5MaXN0Q29udGVudFJlc3BvbnNlEkUKCExpc3REb2NzEhsuaG9tZWxhYi52MS5MaXN0RG9jc1JlcXVlc3QaHC5ob21lbGFiLnYxLkxpc3REb2NzUmVzcG9uc2USPwoGR2V0RG9jEhkuaG9tZWxhYi52MS5HZXREb2NSZXF1ZXN0GhouaG9tZWxhYi52MS5HZXREb2NSZXNwb25zZRJCCgdTYXZlRG9jEhouaG9tZWxhYi52MS5TYXZlRG9jUmVxdWVzdBobLmhvbWVsYWIudjEuU2F2ZURvY1Jlc3BvbnNlElQKDVNlcnZpY2VBY3Rpb24SIC5ob21lbGFiLnYxLlNlcnZpY2VBY3Rpb25SZXF1ZXN0GiEuaG9tZWxhYi52MS5TZXJ2aWNlQWN0aW9uUmVzcG9uc2USSAoJUnVuU2NyaXB0EhwuaG9tZWxhYi52MS5SdW5TY3JpcHRSZXF1ZXN0Gh0uaG9tZWxhYi52MS5SdW5TY3JpcHRSZXNwb25zZUIqWihwd2EtaG9tZWxhYi1tb24vZ2VuL2hvbWVsYWIvdjE7aG9tZWxhYnYxYgZwcm90bzM"); + fileDesc("Chhob21lbGFiL3YxL2hvbWVsYWIucHJvdG8SCmhvbWVsYWIudjEiUgoESXRlbRIMCgRuYW1lGAEgASgJEgwKBGRhdGEYAiABKAkSDQoFdmFsdWUYAyABKAkSDAoEYXJncxgEIAEoBRIRCglhcmdfdHlwZXMYBSADKAkiIQoETGluaxIMCgRuYW1lGAEgASgJEgsKA3VybBgCIAEoCSIUChJMaXN0Q29udGVudFJlcXVlc3QioQEKE0xpc3RDb250ZW50UmVzcG9uc2USHwoFbGlua3MYASADKAsyEC5ob21lbGFiLnYxLkxpbmsSIgoIbW9uaXRvcnMYAiADKAsyEC5ob21lbGFiLnYxLkl0ZW0SHgoEZXhlYxgDIAMoCzIQLmhvbWVsYWIudjEuSXRlbRIlCghzZXJ2aWNlcxgEIAMoCzITLmhvbWVsYWIudjEuU2VydmljZSIlCgdEb2NNZXRhEgwKBG5hbWUYASABKAkSDAoEaGFzaBgCIAEoCSIRCg9MaXN0RG9jc1JlcXVlc3QiNQoQTGlzdERvY3NSZXNwb25zZRIhCgRkb2NzGAEgAygLMhMuaG9tZWxhYi52MS5Eb2NNZXRhIh0KDUdldERvY1JlcXVlc3QSDAoEbmFtZRgBIAEoCSIhCg5HZXREb2NSZXNwb25zZRIPCgdjb250ZW50GAEgASgJIi8KDlNhdmVEb2NSZXF1ZXN0EgwKBG5hbWUYASABKAkSDwoHY29udGVudBgCIAEoCSIfCg9TYXZlRG9jUmVzcG9uc2USDAoEaGFzaBgBIAEoCSKvAQoHU2VydmljZRIlCgRraW5kGAEgASgOMhcuaG9tZWxhYi52MS5TZXJ2aWNlS2luZBIMCgRuYW1lGAIgASgJEg0KBXN0YXRlGAMgASgJEhAKCHN1YnN0YXRlGAQgASgJEhIKCmxvYWRfc3RhdGUYBSABKAkSFwoPdW5pdF9maWxlX3N0YXRlGAYgASgJEhIKCmNvbnRhaW5lcnMYByADKAkSDQoFZXJyb3IYCCABKAkibgoUU2VydmljZUFjdGlvblJlcXVlc3QSDAoEbmFtZRgBIAEoCRIlCgRraW5kGAIgASgOMhcuaG9tZWxhYi52MS5TZXJ2aWNlS2luZBIhCgJvcBgDIAEoDjIVLmhvbWVsYWIudjEuU2VydmljZU9wIicKFVNlcnZpY2VBY3Rpb25SZXNwb25zZRIOCgZvdXRwdXQYASABKAkiSQoSU2VydmljZUluZm9SZXF1ZXN0EgwKBG5hbWUYASABKAkSJQoEa2luZBgCIAEoDjIXLmhvbWVsYWIudjEuU2VydmljZUtpbmQiJQoTU2VydmljZUluZm9SZXNwb25zZRIOCgZvdXRwdXQYASABKAkiLgoQUnVuU2NyaXB0UmVxdWVzdBIMCgRuYW1lGAEgASgJEgwKBGFyZ3MYAiADKAkiIwoRUnVuU2NyaXB0UmVzcG9uc2USDgoGb3V0cHV0GAEgASgJKiYKC1NlcnZpY2VLaW5kEgsKB1NZU1RFTUQQABIKCgZET0NLRVIQASotCglTZXJ2aWNlT3ASCQoFU1RBUlQQABIICgRTVE9QEAESCwoHUkVTVEFSVBACMpwECg5Ib21lbGFiU2VydmljZRJOCgtMaXN0Q29udGVudBIeLmhvbWVsYWIudjEuTGlzdENvbnRlbnRSZXF1ZXN0Gh8uaG9tZWxhYi52MS5MaXN0Q29udGVudFJlc3BvbnNlEkUKCExpc3REb2NzEhsuaG9tZWxhYi52MS5MaXN0RG9jc1JlcXVlc3QaHC5ob21lbGFiLnYxLkxpc3REb2NzUmVzcG9uc2USPwoGR2V0RG9jEhkuaG9tZWxhYi52MS5HZXREb2NSZXF1ZXN0GhouaG9tZWxhYi52MS5HZXREb2NSZXNwb25zZRJCCgdTYXZlRG9jEhouaG9tZWxhYi52MS5TYXZlRG9jUmVxdWVzdBobLmhvbWVsYWIudjEuU2F2ZURvY1Jlc3BvbnNlElQKDVNlcnZpY2VBY3Rpb24SIC5ob21lbGFiLnYxLlNlcnZpY2VBY3Rpb25SZXF1ZXN0GiEuaG9tZWxhYi52MS5TZXJ2aWNlQWN0aW9uUmVzcG9uc2USTgoLU2VydmljZUluZm8SHi5ob21lbGFiLnYxLlNlcnZpY2VJbmZvUmVxdWVzdBofLmhvbWVsYWIudjEuU2VydmljZUluZm9SZXNwb25zZRJICglSdW5TY3JpcHQSHC5ob21lbGFiLnYxLlJ1blNjcmlwdFJlcXVlc3QaHS5ob21lbGFiLnYxLlJ1blNjcmlwdFJlc3BvbnNlQipaKHB3YS1ob21lbGFiLW1vbi9nZW4vaG9tZWxhYi92MTtob21lbGFidjFiBnByb3RvMw"); /** * @generated from message homelab.v1.Item @@ -361,6 +361,45 @@ export type ServiceActionResponse = Message<"homelab.v1.ServiceActionResponse"> export const ServiceActionResponseSchema: GenMessage = /*@__PURE__*/ messageDesc(file_homelab_v1_homelab, 13); +/** + * @generated from message homelab.v1.ServiceInfoRequest + */ +export type ServiceInfoRequest = Message<"homelab.v1.ServiceInfoRequest"> & { + /** + * @generated from field: string name = 1; + */ + name: string; + + /** + * @generated from field: homelab.v1.ServiceKind kind = 2; + */ + kind: ServiceKind; +}; + +/** + * Describes the message homelab.v1.ServiceInfoRequest. + * Use `create(ServiceInfoRequestSchema)` to create a new message. + */ +export const ServiceInfoRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_homelab_v1_homelab, 14); + +/** + * @generated from message homelab.v1.ServiceInfoResponse + */ +export type ServiceInfoResponse = Message<"homelab.v1.ServiceInfoResponse"> & { + /** + * @generated from field: string output = 1; + */ + output: string; +}; + +/** + * Describes the message homelab.v1.ServiceInfoResponse. + * Use `create(ServiceInfoResponseSchema)` to create a new message. + */ +export const ServiceInfoResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_homelab_v1_homelab, 15); + /** * @generated from message homelab.v1.RunScriptRequest */ @@ -381,7 +420,7 @@ export type RunScriptRequest = Message<"homelab.v1.RunScriptRequest"> & { * Use `create(RunScriptRequestSchema)` to create a new message. */ export const RunScriptRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_homelab_v1_homelab, 14); + messageDesc(file_homelab_v1_homelab, 16); /** * @generated from message homelab.v1.RunScriptResponse @@ -398,7 +437,7 @@ export type RunScriptResponse = Message<"homelab.v1.RunScriptResponse"> & { * Use `create(RunScriptResponseSchema)` to create a new message. */ export const RunScriptResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_homelab_v1_homelab, 15); + messageDesc(file_homelab_v1_homelab, 17); /** * One card in the Services view: a systemd user unit or a docker compose project. @@ -493,6 +532,14 @@ export const HomelabService: GenService<{ input: typeof ServiceActionRequestSchema; output: typeof ServiceActionResponseSchema; }, + /** + * @generated from rpc homelab.v1.HomelabService.ServiceInfo + */ + serviceInfo: { + methodKind: "unary"; + input: typeof ServiceInfoRequestSchema; + output: typeof ServiceInfoResponseSchema; + }, /** * @generated from rpc homelab.v1.HomelabService.RunScript */