import { useState, useRef } from "preact/hooks"; import { createClient } from "@connectrpc/connect"; import { createConnectTransport } from "@connectrpc/connect-web"; import { Container, 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: "" }), ); /** Бейдж статуса: systemd — 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 остановлен; } /** Кнопки контроля; вынесены, чтобы переиспользовать на карточке и в модалке. */ 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 infoOpen = useRef(false); const closeInfo = () => { infoOpen.current = false; setInfo(null); }; const act = async (op: ServiceOp) => { setBusy(true); setError(""); try { await client.serviceAction({ name: s.name, kind: s.kind, op }); refresh(); // не ждём 3s поллинга if (infoOpen.current) { const r = await client.serviceInfo({ name: s.name, kind: s.kind }); setInfo(r.output); } } catch (e) { setError(String(e).replace(/^.*real error: /, "")); } finally { setBusy(false); } }; const openInfo = async () => { setInfoBusy(true); setError(""); infoOpen.current = true; 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 (
{kindIcon} {s.name} {badge(s)}
{error && ( )}
{s.containers.length > 0 && (

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

)}
{info !== null && ( ); }