From 202ab225306ff62c6f2fae5e2f2d8b0cb3612c06 Mon Sep 17 00:00:00 2001 From: Alexey Bagno Date: Mon, 31 Aug 2026 14:29:04 +0500 Subject: [PATCH] UI for systemd srv --- web/public/sw.js | 2 +- web/src/App.tsx | 33 ++++++++----- web/src/components/ServiceCard.tsx | 78 ++++++++++++++++++++++++++++++ 3 files changed, 101 insertions(+), 12 deletions(-) create mode 100644 web/src/components/ServiceCard.tsx diff --git a/web/public/sw.js b/web/public/sw.js index 5b1fe75..bdc951f 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-v3"; +const CACHE = "homelab-shell-v4"; 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 ac448e6..7b424ea 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,11 +1,12 @@ -import { useEffect, useState } from "preact/hooks"; +import { useCallback, useEffect, useState } from "preact/hooks"; import { createClient } from "@connectrpc/connect"; import { createConnectTransport } from "@connectrpc/connect-web"; -import { BookOpen, Gauge, Link as LinkIcon, Play } from "lucide-preact"; -import type { Item, Link } from "./gen/homelab/v1/homelab_pb"; +import { BookOpen, Gauge, Link as LinkIcon, Play, Server } from "lucide-preact"; +import type { Item, Link, Service } from "./gen/homelab/v1/homelab_pb"; import { HomelabService } from "./gen/homelab/v1/homelab_pb"; import { MonitorCard } from "./components/MonitorCard"; import { ScriptCard } from "./components/ScriptCard"; +import { ServiceCard } from "./components/ServiceCard"; import { DocsView, DocView, localDocs } from "./components/Docs"; import { syncDocs } from "./docsStore"; @@ -14,6 +15,7 @@ const client = createClient(HomelabService, createConnectTransport({ baseUrl: "" const CATEGORIES = [ { id: "links", label: "Links", icon: LinkIcon }, { id: "monitor", label: "Monitor", icon: Gauge }, + { id: "services", label: "Services", icon: Server }, { id: "exec", label: "Exec", icon: Play }, { id: "docs", label: "Docs", icon: BookOpen }, ] as const; @@ -38,25 +40,29 @@ function LinkList({ links }: { links: Link[] }) { } export function App() { - const [content, setContent] = useState<{ links: Link[]; monitors: Item[]; exec: Item[] }>({ + const [content, setContent] = useState<{ links: Link[]; monitors: Item[]; exec: Item[]; services: Service[] }>({ links: [], monitors: [], exec: [], + services: [], }); const [category, setCategory] = useState("monitor"); const [docs, setDocs] = useState(localDocs()); const [openDoc, setOpenDoc] = useState(null); - useEffect(() => { - const poll = () => + const refresh = useCallback( + () => 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); + .then((r) => setContent({ links: r.links, monitors: r.monitors, exec: r.exec, services: r.services })) + .catch(() => {}), // offline: keep last state + [], + ); + useEffect(() => { + refresh(); + const id = setInterval(refresh, 3000); return () => clearInterval(id); - }, []); + }, [refresh]); // Docs sync + offline dirty flush, every 5s. useEffect(() => { @@ -96,6 +102,11 @@ export function App() { )} {category === "exec" && content.exec.map((it) => )} + {category === "services" && ( +
+ {content.services.map((s) => )} +
+ )} {category === "docs" && (open ? ( setOpenDoc(null)} /> diff --git a/web/src/components/ServiceCard.tsx b/web/src/components/ServiceCard.tsx new file mode 100644 index 0000000..f469c93 --- /dev/null +++ b/web/src/components/ServiceCard.tsx @@ -0,0 +1,78 @@ +import { useState } from "preact/hooks"; +import { createClient } from "@connectrpc/connect"; +import { createConnectTransport } from "@connectrpc/connect-web"; +import { Container, Server } 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 — агрегат проекта. */ +function badge(s: Service) { + if (s.error) return ошибка; + if (s.kind === ServiceKind.DOCKER) { + if (s.state === "active") return работает {s.substate}; + if (s.state === "partial") return частично {s.substate}; + return остановлен; + } + if (s.loadState === "not-found") return выключен; + if (s.state === "active") return {s.substate || "активен"}; + if (s.state === "failed") return сбой; + return остановлен; +} + +/** Карточка сервиса: systemd-юнит или docker-проект, start/stop/restart через ServiceAction. */ +export function ServiceCard({ s, refresh }: { s: Service; refresh: () => void }) { + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + + const act = async (op: ServiceOp) => { + setBusy(true); + setError(""); + try { + await client.serviceAction({ name: s.name, kind: s.kind, op }); + refresh(); // не ждём 3s поллинга + } catch (e) { + setError(String(e).replace(/^.*real error: /, "")); + } finally { + setBusy(false); + } + }; + + const disabled = s.kind === ServiceKind.SYSTEMD && s.loadState === "not-found"; + const btn = (op: ServiceOp, label: string, title?: string) => ( + + ); + + return ( +
+
+
+ {s.kind === ServiceKind.DOCKER ? ( + + ) : ( + + )} + {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(", ")}

+ )} +
+
+ ); +}