This commit is contained in:
2026-08-29 10:29:48 +05:00
parent eaa9a7bef2
commit 069b51a84c
16 changed files with 949 additions and 471 deletions
+45 -63
View File
@@ -1,85 +1,67 @@
import { useEffect, useState } from "preact/hooks";
import { createClient } from "@connectrpc/connect";
import { createConnectTransport } from "@connectrpc/connect-web";
import { Dices, Hash, Send } from "lucide-preact";
import { Gauge, FlaskConical } from "lucide-preact";
import type { Section } from "./gen/homelab/v1/homelab_pb";
import { HomelabService } from "./gen/homelab/v1/homelab_pb";
import { ScriptCard } from "./components/ScriptCard";
import { MonitorCard } from "./components/MonitorCard";
const client = createClient(HomelabService, createConnectTransport({ baseUrl: "" }));
// Иконки секций: строка из конфига → компонент. Новый icon в yaml = добавить сюда.
const ICONS: Record<string, preact.JSX.ElementType> = { gauge: Gauge, "flask-conical": FlaskConical };
function DynIcon({ name, size = 20 }: { name: string; size?: number }) {
const Cmp = ICONS[name] ?? Gauge;
return <Cmp size={size} />;
}
export function App() {
const [lucky, setLucky] = useState<number | null>(null);
const [count, setCount] = useState(0);
const [name, setName] = useState("");
const [greeting, setGreeting] = useState("");
const [error, setError] = useState("");
const [sections, setSections] = useState<Section[]>([]);
const [active, setActive] = useState(0);
useEffect(() => {
client.getLuckyNumber({}).then((r) => setLucky(r.number));
const poll = () => client.listSections({}).then((r) => setSections(r.sections));
poll();
const id = setInterval(poll, 3000);
return () => clearInterval(id);
}, []);
const run = async (fn: () => Promise<unknown>) => {
try {
await fn();
setError("");
} catch (e) {
setError(String(e));
}
};
const sec = sections[active];
return (
<div class="mx-auto max-w-md px-4 pt-[env(safe-area-inset-top)] pb-[env(safe-area-inset-bottom)]">
<div class="navbar px-0">
<div class="navbar-start" />
<div class="navbar-center">
<span class="text-lg font-bold">Homelab</span>
</div>
<div class="navbar-end" />
</div>
<div class="flex min-h-screen">
{/* Sidebar */}
<nav class="bg-base-200 w-20 flex flex-col items-center gap-1 py-4 pt-[calc(env(safe-area-inset-top)+1rem)]">
{sections.map((s, i) => (
<button
class={`btn btn-square btn-ghost ${i === active ? "btn-primary" : ""}`}
title={s.name}
onClick={() => setActive(i)}
>
<DynIcon name={s.icon} />
</button>
))}
</nav>
{error && <div role="alert" class="alert alert-error text-sm">{error}</div>}
<div class="stats w-full shadow bg-base-200 mt-4">
<div class="stat">
<div class="stat-figure text-primary"><Dices size={28} /></div>
<div class="stat-title">Lucky number</div>
<div class="stat-value text-primary">{lucky ?? "…"}</div>
<div class="stat-desc">set at server start</div>
</div>
<div class="stat">
<div class="stat-figure text-secondary"><Hash size={28} /></div>
<div class="stat-title">Counter</div>
<div class="stat-value text-secondary">{count}</div>
<div class="stat-actions">
<button class="btn btn-sm btn-secondary" onClick={() => run(async () => setCount((await client.incrementCounter({})).value))}>
+1
</button>
{/* Active section view */}
<div class="flex-1 max-w-md mx-auto px-4 pt-[env(safe-area-inset-top)] pb-[env(safe-area-inset-bottom)]">
<div class="navbar px-0">
<div class="navbar-start" />
<div class="navbar-center">
<span class="text-lg font-bold">{sec?.name ?? "Homelab"}</span>
</div>
<div class="navbar-end" />
</div>
</div>
<form
class="card bg-base-200 shadow mt-4"
onSubmit={(e) => {
e.preventDefault();
run(async () => setGreeting((await client.sayHello({ name })).greeting));
}}
>
<div class="card-body">
<h2 class="card-title text-base">Hello</h2>
<div class="flex gap-2">
<input
class="input input-bordered flex-1"
value={name}
onInput={(e) => setName((e.target as HTMLInputElement).value)}
placeholder="NAME"
/>
<button type="submit" class="btn btn-primary">
<Send size={16} /> Send
</button>
{sec && (
<div class="flex flex-col gap-4 pb-6">
{sec.items.filter((it) => it.type === "monitor").map((it) => <MonitorCard s={it} />)}
{sec.items.filter((it) => it.type === "response").map((it) => <ScriptCard s={it} />)}
</div>
{greeting && <p class="text-xl font-semibold">{greeting}</p>}
</div>
</form>
)}
</div>
</div>
);
}
+25
View File
@@ -0,0 +1,25 @@
import type { Item } from "../gen/homelab/v1/homelab_pb";
/** Карточка монитора: percentage → radial-progress, иначе текст. */
export function MonitorCard({ s }: { s: Item }) {
const pct = Number.parseInt(s.value, 10);
const isPct = s.data === "percentage" && !Number.isNaN(pct);
return (
<div class="card bg-base-200 shadow">
<div class="card-body p-4">
<h2 class="card-title text-sm font-medium text-base-content/60">{s.name}</h2>
{isPct ? (
<div
class="radial-progress text-primary"
style={{ "--value": pct } as preact.JSX.CSSProperties}
role="progressbar"
>
{pct}%
</div>
) : (
<p class="font-mono text-lg break-all">{s.value || "…"}</p>
)}
</div>
</div>
);
}
+72
View File
@@ -0,0 +1,72 @@
import { useState } from "preact/hooks";
import { createClient } from "@connectrpc/connect";
import { createConnectTransport } from "@connectrpc/connect-web";
import { X, Play } from "lucide-preact";
import type { Item } from "../gen/homelab/v1/homelab_pb";
import { HomelabService } from "../gen/homelab/v1/homelab_pb";
const client = createClient(HomelabService, createConnectTransport({ baseUrl: "" }));
/** Карточка response-скрипта: форма по количеству аргументов; результат — в модалке. */
export function ScriptCard({ s }: { s: Item }) {
const [args, setArgs] = useState<string[]>(Array(s.args).fill(""));
const [output, setOutput] = useState<string | null>(null);
const [error, setError] = useState("");
const [running, setRunning] = useState(false);
const run = async () => {
setRunning(true);
try {
setOutput((await client.runScript({ name: s.name, args })).output);
setError("");
} catch (e) {
setError(String(e));
} finally {
setRunning(false);
}
};
return (
<div class="card bg-base-200 shadow">
<div class="card-body p-4">
<h2 class="card-title text-sm font-medium text-base-content/60">
<Play size={14} /> {s.name}
</h2>
{error && <div role="alert" class="alert alert-error text-sm">{error}</div>}
<div class="flex flex-wrap gap-2 items-center">
{s.argTypes.map((t, i) => (
<input
class="input input-bordered w-full sm:w-32"
type={t === "number" ? "number" : "text"}
placeholder={t}
value={args[i]}
onInput={(e) =>
setArgs((a) => {
const next = [...a];
next[i] = (e.target as HTMLInputElement).value;
return next;
})
}
/>
))}
<button class="btn btn-primary btn-sm" disabled={running} onClick={run}>
<Play size={14} /> Run
</button>
</div>
</div>
{output != null && (
<div class="modal modal-open">
<div class="modal-box">
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2" onClick={() => setOutput(null)}>
<X size={16} />
</button>
<h3 class="font-bold text-lg">{s.name}</h3>
<pre class="bg-base-300 rounded-box p-3 mt-3 text-sm whitespace-pre-wrap">{output}</pre>
</div>
<div class="modal-backdrop" onClick={() => setOutput(null)} />
</div>
)}
</div>
);
}
+135 -89
View File
@@ -10,100 +10,154 @@ import type { Message } from "@bufbuild/protobuf";
* Describes the file homelab/v1/homelab.proto.
*/
export const file_homelab_v1_homelab: GenFile = /*@__PURE__*/
fileDesc("Chhob21lbGFiL3YxL2hvbWVsYWIucHJvdG8SCmhvbWVsYWIudjEiFwoVR2V0THVja3lOdW1iZXJSZXF1ZXN0IigKFkdldEx1Y2t5TnVtYmVyUmVzcG9uc2USDgoGbnVtYmVyGAEgASgDIhkKF0luY3JlbWVudENvdW50ZXJSZXF1ZXN0IikKGEluY3JlbWVudENvdW50ZXJSZXNwb25zZRINCgV2YWx1ZRgBIAEoAyIfCg9TYXlIZWxsb1JlcXVlc3QSDAoEbmFtZRgBIAEoCSIkChBTYXlIZWxsb1Jlc3BvbnNlEhAKCGdyZWV0aW5nGAEgASgJMo8CCg5Ib21lbGFiU2VydmljZRJXCg5HZXRMdWNreU51bWJlchIhLmhvbWVsYWIudjEuR2V0THVja3lOdW1iZXJSZXF1ZXN0GiIuaG9tZWxhYi52MS5HZXRMdWNreU51bWJlclJlc3BvbnNlEl0KEEluY3JlbWVudENvdW50ZXISIy5ob21lbGFiLnYxLkluY3JlbWVudENvdW50ZXJSZXF1ZXN0GiQuaG9tZWxhYi52MS5JbmNyZW1lbnRDb3VudGVyUmVzcG9uc2USRQoIU2F5SGVsbG8SGy5ob21lbGFiLnYxLlNheUhlbGxvUmVxdWVzdBocLmhvbWVsYWIudjEuU2F5SGVsbG9SZXNwb25zZUIqWihwd2EtaG9tZWxhYi1tb24vZ2VuL2hvbWVsYWIvdjE7aG9tZWxhYnYxYgZwcm90bzM");
fileDesc("Chhob21lbGFiL3YxL2hvbWVsYWIucHJvdG8SCmhvbWVsYWIudjEiYAoESXRlbRIMCgRuYW1lGAEgASgJEgwKBHR5cGUYAiABKAkSDAoEZGF0YRgDIAEoCRINCgV2YWx1ZRgEIAEoCRIMCgRhcmdzGAUgASgFEhEKCWFyZ190eXBlcxgGIAMoCSJGCgdTZWN0aW9uEgwKBG5hbWUYASABKAkSDAoEaWNvbhgCIAEoCRIfCgVpdGVtcxgDIAMoCzIQLmhvbWVsYWIudjEuSXRlbSIVChNMaXN0U2VjdGlvbnNSZXF1ZXN0Ij0KFExpc3RTZWN0aW9uc1Jlc3BvbnNlEiUKCHNlY3Rpb25zGAEgAygLMhMuaG9tZWxhYi52MS5TZWN0aW9uIi4KEFJ1blNjcmlwdFJlcXVlc3QSDAoEbmFtZRgBIAEoCRIMCgRhcmdzGAIgAygJIiMKEVJ1blNjcmlwdFJlc3BvbnNlEg4KBm91dHB1dBgBIAEoCTKtAQoOSG9tZWxhYlNlcnZpY2USUQoMTGlzdFNlY3Rpb25zEh8uaG9tZWxhYi52MS5MaXN0U2VjdGlvbnNSZXF1ZXN0GiAuaG9tZWxhYi52MS5MaXN0U2VjdGlvbnNSZXNwb25zZRJICglSdW5TY3JpcHQSHC5ob21lbGFiLnYxLlJ1blNjcmlwdFJlcXVlc3QaHS5ob21lbGFiLnYxLlJ1blNjcmlwdFJlc3BvbnNlQipaKHB3YS1ob21lbGFiLW1vbi9nZW4vaG9tZWxhYi92MTtob21lbGFidjFiBnByb3RvMw");
/**
* @generated from message homelab.v1.GetLuckyNumberRequest
* @generated from message homelab.v1.Item
*/
export type GetLuckyNumberRequest = Message<"homelab.v1.GetLuckyNumberRequest"> & {
};
/**
* Describes the message homelab.v1.GetLuckyNumberRequest.
* Use `create(GetLuckyNumberRequestSchema)` to create a new message.
*/
export const GetLuckyNumberRequestSchema: GenMessage<GetLuckyNumberRequest> = /*@__PURE__*/
messageDesc(file_homelab_v1_homelab, 0);
/**
* @generated from message homelab.v1.GetLuckyNumberResponse
*/
export type GetLuckyNumberResponse = Message<"homelab.v1.GetLuckyNumberResponse"> & {
/**
* @generated from field: int64 number = 1;
*/
number: bigint;
};
/**
* Describes the message homelab.v1.GetLuckyNumberResponse.
* Use `create(GetLuckyNumberResponseSchema)` to create a new message.
*/
export const GetLuckyNumberResponseSchema: GenMessage<GetLuckyNumberResponse> = /*@__PURE__*/
messageDesc(file_homelab_v1_homelab, 1);
/**
* @generated from message homelab.v1.IncrementCounterRequest
*/
export type IncrementCounterRequest = Message<"homelab.v1.IncrementCounterRequest"> & {
};
/**
* Describes the message homelab.v1.IncrementCounterRequest.
* Use `create(IncrementCounterRequestSchema)` to create a new message.
*/
export const IncrementCounterRequestSchema: GenMessage<IncrementCounterRequest> = /*@__PURE__*/
messageDesc(file_homelab_v1_homelab, 2);
/**
* @generated from message homelab.v1.IncrementCounterResponse
*/
export type IncrementCounterResponse = Message<"homelab.v1.IncrementCounterResponse"> & {
/**
* @generated from field: int64 value = 1;
*/
value: bigint;
};
/**
* Describes the message homelab.v1.IncrementCounterResponse.
* Use `create(IncrementCounterResponseSchema)` to create a new message.
*/
export const IncrementCounterResponseSchema: GenMessage<IncrementCounterResponse> = /*@__PURE__*/
messageDesc(file_homelab_v1_homelab, 3);
/**
* @generated from message homelab.v1.SayHelloRequest
*/
export type SayHelloRequest = Message<"homelab.v1.SayHelloRequest"> & {
export type Item = Message<"homelab.v1.Item"> & {
/**
* @generated from field: string name = 1;
*/
name: string;
/**
* monitor | response
*
* @generated from field: string type = 2;
*/
type: string;
/**
* monitor: text | percentage
*
* @generated from field: string data = 3;
*/
data: string;
/**
* monitor: last output
*
* @generated from field: string value = 4;
*/
value: string;
/**
* response: arg count
*
* @generated from field: int32 args = 5;
*/
args: number;
/**
* response: text | number per arg
*
* @generated from field: repeated string arg_types = 6;
*/
argTypes: string[];
};
/**
* Describes the message homelab.v1.SayHelloRequest.
* Use `create(SayHelloRequestSchema)` to create a new message.
* Describes the message homelab.v1.Item.
* Use `create(ItemSchema)` to create a new message.
*/
export const SayHelloRequestSchema: GenMessage<SayHelloRequest> = /*@__PURE__*/
export const ItemSchema: GenMessage<Item> = /*@__PURE__*/
messageDesc(file_homelab_v1_homelab, 0);
/**
* @generated from message homelab.v1.Section
*/
export type Section = Message<"homelab.v1.Section"> & {
/**
* @generated from field: string name = 1;
*/
name: string;
/**
* @generated from field: string icon = 2;
*/
icon: string;
/**
* @generated from field: repeated homelab.v1.Item items = 3;
*/
items: Item[];
};
/**
* Describes the message homelab.v1.Section.
* Use `create(SectionSchema)` to create a new message.
*/
export const SectionSchema: GenMessage<Section> = /*@__PURE__*/
messageDesc(file_homelab_v1_homelab, 1);
/**
* @generated from message homelab.v1.ListSectionsRequest
*/
export type ListSectionsRequest = Message<"homelab.v1.ListSectionsRequest"> & {
};
/**
* Describes the message homelab.v1.ListSectionsRequest.
* Use `create(ListSectionsRequestSchema)` to create a new message.
*/
export const ListSectionsRequestSchema: GenMessage<ListSectionsRequest> = /*@__PURE__*/
messageDesc(file_homelab_v1_homelab, 2);
/**
* @generated from message homelab.v1.ListSectionsResponse
*/
export type ListSectionsResponse = Message<"homelab.v1.ListSectionsResponse"> & {
/**
* @generated from field: repeated homelab.v1.Section sections = 1;
*/
sections: Section[];
};
/**
* Describes the message homelab.v1.ListSectionsResponse.
* Use `create(ListSectionsResponseSchema)` to create a new message.
*/
export const ListSectionsResponseSchema: GenMessage<ListSectionsResponse> = /*@__PURE__*/
messageDesc(file_homelab_v1_homelab, 3);
/**
* @generated from message homelab.v1.RunScriptRequest
*/
export type RunScriptRequest = Message<"homelab.v1.RunScriptRequest"> & {
/**
* @generated from field: string name = 1;
*/
name: string;
/**
* @generated from field: repeated string args = 2;
*/
args: string[];
};
/**
* Describes the message homelab.v1.RunScriptRequest.
* Use `create(RunScriptRequestSchema)` to create a new message.
*/
export const RunScriptRequestSchema: GenMessage<RunScriptRequest> = /*@__PURE__*/
messageDesc(file_homelab_v1_homelab, 4);
/**
* @generated from message homelab.v1.SayHelloResponse
* @generated from message homelab.v1.RunScriptResponse
*/
export type SayHelloResponse = Message<"homelab.v1.SayHelloResponse"> & {
export type RunScriptResponse = Message<"homelab.v1.RunScriptResponse"> & {
/**
* @generated from field: string greeting = 1;
* @generated from field: string output = 1;
*/
greeting: string;
output: string;
};
/**
* Describes the message homelab.v1.SayHelloResponse.
* Use `create(SayHelloResponseSchema)` to create a new message.
* Describes the message homelab.v1.RunScriptResponse.
* Use `create(RunScriptResponseSchema)` to create a new message.
*/
export const SayHelloResponseSchema: GenMessage<SayHelloResponse> = /*@__PURE__*/
export const RunScriptResponseSchema: GenMessage<RunScriptResponse> = /*@__PURE__*/
messageDesc(file_homelab_v1_homelab, 5);
/**
@@ -111,28 +165,20 @@ export const SayHelloResponseSchema: GenMessage<SayHelloResponse> = /*@__PURE__*
*/
export const HomelabService: GenService<{
/**
* @generated from rpc homelab.v1.HomelabService.GetLuckyNumber
* @generated from rpc homelab.v1.HomelabService.ListSections
*/
getLuckyNumber: {
listSections: {
methodKind: "unary";
input: typeof GetLuckyNumberRequestSchema;
output: typeof GetLuckyNumberResponseSchema;
input: typeof ListSectionsRequestSchema;
output: typeof ListSectionsResponseSchema;
},
/**
* @generated from rpc homelab.v1.HomelabService.IncrementCounter
* @generated from rpc homelab.v1.HomelabService.RunScript
*/
incrementCounter: {
runScript: {
methodKind: "unary";
input: typeof IncrementCounterRequestSchema;
output: typeof IncrementCounterResponseSchema;
},
/**
* @generated from rpc homelab.v1.HomelabService.SayHello
*/
sayHello: {
methodKind: "unary";
input: typeof SayHelloRequestSchema;
output: typeof SayHelloResponseSchema;
input: typeof RunScriptRequestSchema;
output: typeof RunScriptResponseSchema;
},
}> = /*@__PURE__*/
serviceDesc(file_homelab_v1_homelab, 0);