feat: ship host metrics as a first-party in-process plugin

This commit is contained in:
LukeGus committed 2026-09-21 11:30:46 -05:00
1 parent a2479c19b0
commit 19b70430a1
145 files changed
+675 -349

No files matched your search

@@ -0,0 +1,162 @@
import { useEffect, useMemo, useState } from "react";
import { Clock4, Plus, Trash2, Save } from "lucide-react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { Button } from "@/components/button";
import { managerPost } from "@/main-axios";
import { useManagerData, extractError } from "./useManagerData";
import { ManagerCardShell } from "./ManagerCardShell";
import { ManagerSearch } from "./ManagerToolbar";
interface CronEntry {
raw: string;
enabled: boolean;
schedule: string;
command: string;
}
export function CronManagerCard({ hostId }: { hostId: number | null }) {
const { t } = useTranslation();
const { data, loading, error, refresh } = useManagerData<{
entries: CronEntry[];
}>(hostId, "cron");
const [entries, setEntries] = useState<CronEntry[]>([]);
const [dirty, setDirty] = useState(false);
const [saving, setSaving] = useState(false);
const [query, setQuery] = useState("");
// Keep the real array index so edits/removes target the right entry even when
// the list is filtered by the search box.
const visible = useMemo(() => {
const q = query.toLowerCase();
return entries
.map((entry, index) => ({ entry, index }))
.filter(
({ entry }) =>
!q ||
entry.schedule.toLowerCase().includes(q) ||
entry.command.toLowerCase().includes(q),
);
}, [entries, query]);
useEffect(() => {
if (data?.entries) {
setEntries(data.entries);
setDirty(false);
}
}, [data]);
const update = (i: number, patch: Partial<CronEntry>) => {
setEntries((prev) =>
prev.map((e, idx) => (idx === i ? { ...e, ...patch } : e)),
);
setDirty(true);
};
const remove = (i: number) => {
setEntries((prev) => prev.filter((_, idx) => idx !== i));
setDirty(true);
};
const add = () => {
setEntries((prev) => [
...prev,
{ raw: "", enabled: true, schedule: "0 * * * *", command: "" },
]);
setDirty(true);
};
const save = async () => {
if (hostId == null) return;
setSaving(true);
try {
const res = await managerPost<{ success: boolean; output: string }>(
hostId,
"cron",
{ entries },
);
if (res.success) {
toast.success(t("hostMetrics.managers.cronSaved"));
setDirty(false);
refresh();
} else {
toast.error(res.output || t("hostMetrics.managers.actionFailed"));
}
} catch (e) {
toast.error(extractError(e).message);
} finally {
setSaving(false);
}
};
return (
<ManagerCardShell
title={t("hostMetrics.managers.cron")}
icon={<Clock4 className="size-3.5" />}
loading={loading}
error={error}
onRefresh={refresh}
headerExtra={
<>
<Button variant="ghost" size="xs" onClick={add}>
<Plus className="size-3" />
</Button>
{dirty && (
<Button
variant="outline"
size="xs"
disabled={saving}
onClick={save}
>
<Save className="size-3" />
{t("hostMetrics.managers.save")}
</Button>
)}
</>
}
empty={!loading && entries.length === 0}
>
{entries.length > 5 && (
<ManagerSearch
value={query}
onChange={setQuery}
count={visible.length}
/>
)}
<div className="flex flex-col gap-2">
{visible.map(({ entry: e, index: i }) => (
<div
key={i}
className="flex flex-col gap-1 border border-border bg-muted/20 p-2"
>
<div className="flex items-center gap-2">
<input
type="checkbox"
checked={e.enabled}
onChange={(ev) => update(i, { enabled: ev.target.checked })}
className="accent-accent-brand"
title={t("hostMetrics.managers.enabled")}
/>
<input
value={e.schedule}
onChange={(ev) => update(i, { schedule: ev.target.value })}
placeholder="0 * * * *"
className="h-6 w-28 border border-border bg-background px-1.5 font-mono text-[11px] outline-none focus:ring-1 focus:ring-ring"
/>
<button
onClick={() => remove(i)}
className="ml-auto text-muted-foreground hover:text-destructive"
>
<Trash2 className="size-3.5" />
</button>
</div>
<input
value={e.command}
onChange={(ev) => update(i, { command: ev.target.value })}
placeholder={t("hostMetrics.managers.command")}
className="h-6 w-full border border-border bg-background px-1.5 font-mono text-[11px] outline-none focus:ring-1 focus:ring-ring"
/>
</div>
))}
</div>
</ManagerCardShell>
);
}
@@ -0,0 +1,230 @@
import { useMemo, useState } from "react";
import { ShieldCheck, Plus, Trash2, Save } from "lucide-react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { Button } from "@/components/button";
import { managerPost, type FirewallChain } from "@/main-axios";
import { useManagerData, extractError } from "./useManagerData";
import { ManagerCardShell } from "./ManagerCardShell";
import { ManagerSearch } from "./ManagerToolbar";
import { Select2 } from "@/components/select2";
interface FirewallData {
type: "iptables" | "nftables" | "none";
status: string;
chains: FirewallChain[];
}
export function FirewallManagerCard({ hostId }: { hostId: number | null }) {
const { t } = useTranslation();
const { data, loading, error, refresh } = useManagerData<FirewallData>(
hostId,
"firewall",
);
const [proto, setProto] = useState<"tcp" | "udp">("tcp");
const [port, setPort] = useState("");
const [target, setTarget] = useState<"ACCEPT" | "DROP" | "REJECT">("ACCEPT");
const [busy, setBusy] = useState(false);
const [query, setQuery] = useState("");
const rule = async (op: "add" | "delete") => {
if (hostId == null) return;
const portNum = Number(port);
if (!Number.isInteger(portNum) || portNum < 1 || portNum > 65535) {
toast.error(t("hostMetrics.managers.invalidPort"));
return;
}
setBusy(true);
try {
const res = await managerPost<{ success: boolean; output: string }>(
hostId,
"firewall",
{ op, protocol: proto, port: portNum, target },
"rule",
);
if (res.success) {
toast.success(t("hostMetrics.managers.ruleApplied"));
refresh();
} else {
toast.error(res.output || t("hostMetrics.managers.actionFailed"));
}
} catch (e) {
toast.error(extractError(e).message);
} finally {
setBusy(false);
}
};
const persist = async () => {
if (hostId == null) return;
setBusy(true);
toast.loading(t("hostMetrics.managers.working"), { id: "fw-persist" });
try {
const res = await managerPost<{ success: boolean; output: string }>(
hostId,
"firewall",
{},
"persist",
);
toast[res.success ? "success" : "error"](
res.success
? t("hostMetrics.managers.firewallPersisted")
: t("hostMetrics.managers.actionFailed"),
{ id: "fw-persist", description: res.output?.slice(-200) },
);
} catch (e) {
toast.error(extractError(e).message, { id: "fw-persist" });
} finally {
setBusy(false);
}
};
const chains = useMemo(() => {
const q = query.toLowerCase();
return (data?.chains ?? [])
.map((c) => ({
...c,
rules: c.rules.filter(
(r) =>
!q ||
r.target.toLowerCase().includes(q) ||
(r.dport ?? "").includes(q) ||
r.protocol.toLowerCase().includes(q) ||
r.source.toLowerCase().includes(q),
),
}))
.filter((c) => c.rules.length > 0 || !q);
}, [data?.chains, query]);
return (
<ManagerCardShell
title={t("hostMetrics.managers.firewall")}
icon={<ShieldCheck className="size-3.5" />}
loading={loading}
error={error}
onRefresh={refresh}
headerExtra={
<>
<span className="text-[10px] uppercase tracking-widest text-muted-foreground">
{data?.type ?? ""}
</span>
<Button
variant="ghost"
size="xs"
disabled={busy || data?.type === "none"}
onClick={persist}
title={t("hostMetrics.managers.firewallPersist")}
>
<Save className="size-3" />
</Button>
</>
}
>
<div className="mb-3 flex flex-col gap-2 border border-dashed border-border p-2">
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hostMetrics.managers.addInputRule")}
</span>
<div className="flex items-center gap-1.5">
<Select2
value={proto}
onChange={(e) => setProto(e.target.value as "tcp" | "udp")}
className="h-7 border border-border bg-background px-1 text-xs"
>
<option value="tcp">tcp</option>
<option value="udp">udp</option>
</Select2>
<input
value={port}
onChange={(e) => setPort(e.target.value)}
placeholder={t("hostMetrics.ports.port")}
className="h-7 w-16 border border-border bg-background px-1.5 text-xs outline-none focus:ring-1 focus:ring-ring"
/>
<Select2
value={target}
onChange={(e) =>
setTarget(e.target.value as "ACCEPT" | "DROP" | "REJECT")
}
className="h-7 border border-border bg-background px-1 text-xs"
>
<option>ACCEPT</option>
<option>DROP</option>
<option>REJECT</option>
</Select2>
<Button
variant="outline"
size="xs"
disabled={busy}
onClick={() => rule("add")}
>
<Plus className="size-3" />
</Button>
<Button
variant="ghost"
size="xs"
disabled={busy}
onClick={() => rule("delete")}
>
<Trash2 className="size-3" />
</Button>
</div>
<span className="text-[10px] text-yellow-500">
{t("hostMetrics.managers.firewallWarning")}
</span>
</div>
<ManagerSearch value={query} onChange={setQuery} />
{chains.length === 0 ? (
<span className="text-xs text-muted-foreground">
{t("hostMetrics.firewall.noData")}
</span>
) : (
<div className="flex flex-col gap-3">
{chains.map((chain) => (
<div key={chain.name} className="flex flex-col">
<div className="mb-1 flex items-center justify-between">
<span className="text-[10px] font-bold uppercase tracking-widest text-foreground">
{chain.name}
</span>
{chain.policy && (
<span className="text-[10px] text-muted-foreground">
{t("hostMetrics.firewall.policy")}: {chain.policy}
</span>
)}
</div>
{chain.rules.length === 0 ? (
<span className="text-[10px] italic text-muted-foreground/60">
{t("hostMetrics.firewall.noRules")}
</span>
) : (
<>
<div className="grid grid-cols-4 gap-2 border-b border-border pb-1 text-[10px] font-bold uppercase text-muted-foreground">
<span>{t("hostMetrics.firewall.action")}</span>
<span>{t("hostMetrics.firewall.protocol")}</span>
<span>{t("hostMetrics.firewall.port")}</span>
<span>{t("hostMetrics.firewall.source")}</span>
</div>
{chain.rules.map((r, i) => (
<div
key={i}
className="grid grid-cols-4 gap-2 border-b border-border/50 py-1 font-mono text-xs last:border-0"
>
<span className="font-bold">{r.target}</span>
<span className="text-muted-foreground">
{r.protocol.toUpperCase()}
</span>
<span>{r.dport ?? "—"}</span>
<span className="truncate text-muted-foreground">
{r.source}
</span>
</div>
))}
</>
)}
</div>
))}
</div>
)}
</ManagerCardShell>
);
}
@@ -0,0 +1,311 @@
import { useEffect, useMemo, useState } from "react";
import {
HeartPulse,
CheckCircle2,
XCircle,
Plus,
Trash2,
Play,
Pencil,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { Button } from "@/components/button";
import { Sparkline } from "@/components/charts";
import { managerPost } from "@/main-axios";
import { useManagerData, extractError } from "./useManagerData";
import { ManagerCardShell } from "./ManagerCardShell";
import { Select2 } from "@/components/select2";
interface HealthCheck {
id: string;
name: string;
type: "tcp" | "http";
target: string;
port?: number;
path?: string;
}
interface HealthResult {
checkId: string;
ok: boolean;
latencyMs: number | null;
detail: string;
}
interface HistoryRow {
checkId: string;
ts: string;
ok: boolean;
latencyMs: number | null;
}
interface HealthData {
checks: HealthCheck[];
results: HealthResult[];
history: HistoryRow[];
}
function newCheck(): HealthCheck {
return {
id: `c_${Math.random().toString(36).slice(2, 9)}`,
name: "",
type: "tcp",
target: "",
port: 80,
};
}
export function HealthCheckCard({ hostId }: { hostId: number | null }) {
const { t } = useTranslation();
const { data, loading, error, refresh } = useManagerData<HealthData>(
hostId,
"health",
);
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState<HealthCheck[]>([]);
const [saving, setSaving] = useState(false);
const [running, setRunning] = useState(false);
useEffect(() => {
if (data?.checks && !editing) setDraft(data.checks);
}, [data?.checks, editing]);
const byCheck = useMemo(() => {
const map = new Map<string, HistoryRow[]>();
for (const h of data?.history ?? []) {
const arr = map.get(h.checkId) ?? [];
arr.push(h);
map.set(h.checkId, arr);
}
return map;
}, [data?.history]);
const save = async () => {
if (hostId == null) return;
for (const c of draft) {
if (!c.name.trim() || !c.target.trim()) {
toast.error(t("hostMetrics.managers.healthMissingFields"));
return;
}
}
setSaving(true);
try {
const res = await managerPost<{ success: boolean }>(
hostId,
"health",
{ checks: draft },
"config",
);
if (res.success) {
toast.success(t("hostMetrics.managers.healthSaved"));
setEditing(false);
refresh();
} else {
toast.error(t("hostMetrics.managers.actionFailed"));
}
} catch (e) {
toast.error(extractError(e).message);
} finally {
setSaving(false);
}
};
const runNow = async () => {
if (hostId == null) return;
setRunning(true);
try {
await managerPost(hostId, "health", {}, "run");
refresh();
} catch (e) {
toast.error(extractError(e).message);
} finally {
setRunning(false);
}
};
const updateDraft = (id: string, patch: Partial<HealthCheck>) =>
setDraft((d) => d.map((c) => (c.id === id ? { ...c, ...patch } : c)));
return (
<ManagerCardShell
title={t("hostMetrics.managers.healthCheck")}
icon={<HeartPulse className="size-3.5" />}
loading={loading}
error={error}
onRefresh={refresh}
headerExtra={
editing ? (
<>
<Button
variant="ghost"
size="xs"
onClick={() => {
setEditing(false);
setDraft(data?.checks ?? []);
}}
>
{t("common.cancel")}
</Button>
<Button
variant="outline"
size="xs"
disabled={saving}
onClick={save}
>
{t("common.save")}
</Button>
</>
) : (
<>
<Button
variant="ghost"
size="xs"
disabled={running || (data?.checks?.length ?? 0) === 0}
onClick={runNow}
>
<Play className="size-3" />
{t("hostMetrics.managers.healthRun")}
</Button>
<Button variant="ghost" size="xs" onClick={() => setEditing(true)}>
<Pencil className="size-3" />
{t("common.edit")}
</Button>
</>
)
}
>
{editing ? (
<div className="flex flex-col gap-2">
{draft.map((c) => (
<div
key={c.id}
className="flex flex-col gap-1.5 border border-border bg-muted/20 p-2"
>
<div className="flex items-center gap-1.5">
<input
value={c.name}
onChange={(e) => updateDraft(c.id, { name: e.target.value })}
placeholder={t("hostMetrics.managers.healthName")}
className="h-7 flex-1 border border-border bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring"
/>
<Select2
value={c.type}
onChange={(e) =>
updateDraft(c.id, {
type: e.target.value as "tcp" | "http",
})
}
className="h-7 border border-border bg-background px-1 text-xs"
>
<option value="tcp">TCP</option>
<option value="http">HTTP</option>
</Select2>
<button
onClick={() =>
setDraft((d) => d.filter((x) => x.id !== c.id))
}
className="flex size-7 items-center justify-center text-muted-foreground hover:text-destructive"
>
<Trash2 className="size-3.5" />
</button>
</div>
<div className="flex items-center gap-1.5">
<input
value={c.target}
onChange={(e) =>
updateDraft(c.id, { target: e.target.value })
}
placeholder={t("hostMetrics.managers.healthTarget")}
className="h-7 flex-1 border border-border bg-background px-2 font-mono text-xs outline-none focus:ring-1 focus:ring-ring"
/>
{c.type === "tcp" ? (
<input
type="number"
value={c.port ?? ""}
onChange={(e) =>
updateDraft(c.id, { port: Number(e.target.value) })
}
placeholder={t("hostMetrics.ports.port")}
className="h-7 w-16 border border-border bg-background px-1.5 text-xs outline-none focus:ring-1 focus:ring-ring"
/>
) : (
<input
value={c.path ?? ""}
onChange={(e) =>
updateDraft(c.id, { path: e.target.value })
}
placeholder="/health"
className="h-7 w-24 border border-border bg-background px-1.5 font-mono text-xs outline-none focus:ring-1 focus:ring-ring"
/>
)}
</div>
</div>
))}
<Button
variant="ghost"
size="sm"
className="self-start"
onClick={() => setDraft((d) => [...d, newCheck()])}
>
<Plus className="size-3" />
{t("hostMetrics.managers.healthAddCheck")}
</Button>
</div>
) : (data?.checks?.length ?? 0) === 0 ? (
<div className="flex flex-col items-center gap-2 py-6 text-center">
<span className="text-xs text-muted-foreground/60">
{t("hostMetrics.managers.noHealthChecks")}
</span>
<Button variant="outline" size="sm" onClick={() => setEditing(true)}>
<Plus className="size-3" />
{t("hostMetrics.managers.healthAddCheck")}
</Button>
</div>
) : (
<div className="flex flex-col gap-3">
{(data?.checks ?? []).map((check) => {
const result = data?.results?.find((r) => r.checkId === check.id);
const hist = (byCheck.get(check.id) ?? []).slice().reverse();
const upPct =
hist.length > 0
? Math.round(
(hist.filter((h) => h.ok).length / hist.length) * 100,
)
: null;
return (
<div
key={check.id}
className="flex flex-col gap-1 border border-border bg-muted/20 p-2"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{result?.ok ? (
<CheckCircle2 className="size-3.5 text-accent-brand" />
) : (
<XCircle className="size-3.5 text-destructive" />
)}
<span className="text-xs font-semibold">{check.name}</span>
<span className="font-mono text-[10px] text-muted-foreground/60">
{check.type === "tcp"
? `${check.target}:${check.port}`
: check.target}
</span>
</div>
<span className="font-mono text-[10px] text-muted-foreground">
{result?.latencyMs != null ? `${result.latencyMs}ms` : "—"}
{upPct != null ? ` · ${upPct}%` : ""}
</span>
</div>
{hist.length > 1 && (
<Sparkline
data={hist.map((h) => h.latencyMs ?? 0)}
height={28}
showLastDot={false}
/>
)}
</div>
);
})}
</div>
)}
</ManagerCardShell>
);
}
@@ -0,0 +1,199 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { ScrollText } from "lucide-react";
import { useTranslation } from "react-i18next";
import { managerGet, managerGetSub } from "@/main-axios";
import { extractError } from "./useManagerData";
import { ManagerCardShell } from "./ManagerCardShell";
import { useAdaptivePolling } from "@/hooks/use-adaptive-polling";
import { Select2 } from "@/components/select2";
interface LogFiles {
common: string[];
files: string[];
}
type Mode = "file" | "unit";
export function LogViewerCard({ hostId }: { hostId: number | null }) {
const { t } = useTranslation();
const [mode, setMode] = useState<Mode>("file");
const [path, setPath] = useState<string>("/var/log/syslog");
const [unit, setUnit] = useState<string>("");
const [customPath, setCustomPath] = useState("");
const [lines, setLines] = useState(300);
const [content, setContent] = useState("");
const [grep, setGrep] = useState("");
const [follow, setFollow] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [files, setFiles] = useState<string[]>([]);
const bodyRef = useRef<HTMLPreElement | null>(null);
const contentRef = useRef(content);
contentRef.current = content;
// Load the host's actual log files once.
useEffect(() => {
if (hostId == null) return;
managerGetSub<LogFiles>(hostId, "logs", "files")
.then((res) => {
const merged = Array.from(
new Set([...(res.common ?? []), ...(res.files ?? [])]),
);
setFiles(merged);
if (merged.length && !merged.includes(path)) setPath(merged[0]);
})
.catch(() => {});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hostId]);
const fetchLog = useCallback(async () => {
if (hostId == null) return false;
const params: Record<string, string | number> = { lines };
if (mode === "unit") {
if (!unit.trim()) return false;
params.unit = unit.trim();
} else {
const p = customPath.trim() || path;
if (!p) return false;
params.path = p;
}
setLoading(true);
setError(null);
try {
const res = await managerGet<{ content: string }>(hostId, "logs", params);
const next = res.content || "";
const changed = next !== contentRef.current;
if (changed) {
contentRef.current = next;
setContent(next);
requestAnimationFrame(() => {
if (bodyRef.current)
bodyRef.current.scrollTop = bodyRef.current.scrollHeight;
});
}
return changed;
} catch (e) {
setError(extractError(e).message);
} finally {
setLoading(false);
}
}, [customPath, hostId, lines, mode, path, unit]);
useEffect(() => {
void fetchLog();
// Custom paths are applied on blur/manual refresh, not on every keystroke.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hostId, mode, path, unit, lines]);
useAdaptivePolling(
fetchLog,
{
minIntervalMs: 3_000,
maxIntervalMs: 18_000,
stablePollsPerStep: 2,
maxRequestDutyCycle: 0.2,
},
follow,
{ runImmediately: false },
);
const shown = useMemo(() => {
if (!grep) return content;
const needle = grep.toLowerCase();
return content
.split("\n")
.filter((l) => l.toLowerCase().includes(needle))
.join("\n");
}, [content, grep]);
return (
<ManagerCardShell
title={t("hostMetrics.managers.logViewer")}
icon={<ScrollText className="size-3.5" />}
loading={loading}
error={error ? { message: error } : null}
onRefresh={fetchLog}
headerExtra={
<label className="flex items-center gap-1 text-[10px] text-muted-foreground">
<input
type="checkbox"
checked={follow}
onChange={(e) => setFollow(e.target.checked)}
className="accent-accent-brand"
/>
{t("hostMetrics.managers.follow")}
</label>
}
>
<div className="mb-2 flex items-center gap-1.5">
<Select2
value={mode}
onChange={(e) => setMode(e.target.value as Mode)}
className="h-7 border border-border bg-background px-1 text-[11px]"
>
<option value="file">{t("hostMetrics.managers.logFile")}</option>
<option value="unit">{t("hostMetrics.managers.logUnit")}</option>
</Select2>
{mode === "file" ? (
<Select2
value={customPath ? "" : path}
onChange={(e) => {
setCustomPath("");
setPath(e.target.value);
}}
className="h-7 flex-1 border border-border bg-background px-2 font-mono text-[11px] outline-none focus:ring-1 focus:ring-ring"
>
{files.map((f) => (
<option key={f} value={f}>
{f}
</option>
))}
</Select2>
) : (
<input
value={unit}
onChange={(e) => setUnit(e.target.value)}
onBlur={fetchLog}
placeholder="nginx.service"
className="h-7 flex-1 border border-border bg-background px-2 font-mono text-[11px] outline-none focus:ring-1 focus:ring-ring"
/>
)}
<Select2
value={lines}
onChange={(e) => setLines(Number(e.target.value))}
className="h-7 border border-border bg-background px-1 text-[11px]"
>
{[100, 300, 500, 1000, 2000].map((n) => (
<option key={n} value={n}>
{n}
</option>
))}
</Select2>
</div>
{mode === "file" && (
<input
value={customPath}
onChange={(e) => setCustomPath(e.target.value)}
onBlur={() => customPath && fetchLog()}
placeholder={t("hostMetrics.managers.logCustomPath")}
className="mb-2 h-7 w-full border border-border bg-background px-2 font-mono text-[11px] outline-none focus:ring-1 focus:ring-ring"
/>
)}
<input
value={grep}
onChange={(e) => setGrep(e.target.value)}
placeholder={t("hostMetrics.managers.logGrep")}
className="mb-2 h-7 w-full border border-border bg-background px-2 text-[11px] outline-none focus:ring-1 focus:ring-ring"
/>
<pre
ref={bodyRef}
className="max-h-[320px] overflow-auto whitespace-pre-wrap break-all border border-border/50 bg-muted/20 p-2 font-mono text-[10px] leading-relaxed"
>
{shown || t("hostMetrics.managers.noLogData")}
</pre>
</ManagerCardShell>
);
}
@@ -0,0 +1,75 @@
import type { ReactNode } from "react";
import { RefreshCw, AlertTriangle } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/button";
import { MetricCard } from "@/components/metric-card";
import type { ManagerError } from "./useManagerData";
export function ManagerCardShell({
title,
icon,
loading,
error,
onRefresh,
empty,
emptyMessage,
children,
headerExtra,
}: {
title: string;
icon: ReactNode;
loading: boolean;
error: ManagerError | null;
onRefresh: () => void;
empty?: boolean;
emptyMessage?: string;
children: ReactNode;
headerExtra?: ReactNode;
}) {
const { t } = useTranslation();
return (
<MetricCard
title={title}
icon={icon}
scroll
action={
<div className="flex items-center gap-1">
{headerExtra}
<button
onClick={onRefresh}
className="flex size-6 items-center justify-center text-muted-foreground transition-colors hover:text-foreground"
title={t("hostMetrics.refresh")}
>
<RefreshCw className={`size-3 ${loading ? "animate-spin" : ""}`} />
</button>
</div>
}
>
{error ? (
<div className="flex flex-col items-center gap-2 py-6 text-center">
<AlertTriangle className="size-6 text-yellow-500" />
<span className="text-xs text-muted-foreground">{error.message}</span>
{error.code === "SUDO_REQUIRED" && (
<span className="text-[10px] text-muted-foreground">
{t("hostMetrics.managers.sudoHint")}
</span>
)}
<Button
variant="outline"
size="sm"
onClick={onRefresh}
className="mt-1"
>
{t("hostMetrics.retry")}
</Button>
</div>
) : empty ? (
<div className="flex items-center justify-center py-8 text-xs text-muted-foreground/50">
{emptyMessage ?? t("hostMetrics.managers.noData")}
</div>
) : (
children
)}
</MetricCard>
);
}
@@ -0,0 +1,42 @@
import type { ReactNode } from "react";
import { Search } from "lucide-react";
import { useTranslation } from "react-i18next";
/**
* Compact search row shared by manager cards. Optional `extra` renders to the
* right (filters, action buttons) and `count` shows a result tally.
*/
export function ManagerSearch({
value,
onChange,
placeholder,
count,
extra,
}: {
value: string;
onChange: (v: string) => void;
placeholder?: string;
count?: number;
extra?: ReactNode;
}) {
const { t } = useTranslation();
return (
<div className="mb-2 flex items-center gap-1.5">
<div className="relative flex-1">
<Search className="pointer-events-none absolute left-2 top-1/2 size-3 -translate-y-1/2 text-muted-foreground/60" />
<input
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder ?? t("hostMetrics.managers.filter")}
className="h-7 w-full border border-border bg-background pl-7 pr-2 text-xs outline-none focus:ring-1 focus:ring-ring"
/>
</div>
{count != null && (
<span className="shrink-0 text-[10px] tabular-nums text-muted-foreground">
{count}
</span>
)}
{extra}
</div>
);
}
@@ -0,0 +1,115 @@
import { useMemo, useState } from "react";
import { Package, ArrowUpCircle } from "lucide-react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { Button } from "@/components/button";
import { managerPost } from "@/main-axios";
import { useManagerData, extractError } from "./useManagerData";
import { ManagerCardShell } from "./ManagerCardShell";
import { ManagerSearch } from "./ManagerToolbar";
interface UpgradablePackage {
name: string;
currentVersion?: string;
newVersion?: string;
}
export function PackageManagerCard({ hostId }: { hostId: number | null }) {
const { t } = useTranslation();
const { data, loading, error, refresh } = useManagerData<{
pkg: string | null;
upgradable: UpgradablePackage[];
}>(hostId, "packages");
const [busy, setBusy] = useState<string | null>(null);
const [filter, setFilter] = useState("");
const all = useMemo(() => data?.upgradable ?? [], [data?.upgradable]);
const pkgs = useMemo(
() =>
all.filter(
(p) => !filter || p.name.toLowerCase().includes(filter.toLowerCase()),
),
[all, filter],
);
const run = async (action: string, pkg?: string) => {
if (hostId == null) return;
setBusy(pkg ?? action);
toast.loading(t("hostMetrics.managers.working"), { id: "pkg-op" });
try {
const res = await managerPost<{ success: boolean; output: string }>(
hostId,
"packages",
{ action, pkg },
"action",
);
toast[res.success ? "success" : "error"](
res.success
? t("hostMetrics.managers.actionDone", { name: pkg ?? "upgrade" })
: t("hostMetrics.managers.actionFailed"),
{ id: "pkg-op", description: res.output?.slice(-200) },
);
if (res.success) refresh();
} catch (e) {
toast.error(extractError(e).message, { id: "pkg-op" });
} finally {
setBusy(null);
}
};
return (
<ManagerCardShell
title={t("hostMetrics.managers.packages")}
icon={<Package className="size-3.5" />}
loading={loading}
error={error}
onRefresh={refresh}
empty={!loading && all.length === 0}
emptyMessage={t("hostMetrics.managers.allUpToDate")}
headerExtra={
all.length > 0 ? (
<Button
variant="outline"
size="xs"
disabled={!!busy}
onClick={() => run("upgrade-all")}
>
<ArrowUpCircle className="size-3" />
{t("hostMetrics.managers.upgradeAll")}
</Button>
) : undefined
}
>
{all.length > 5 && (
<ManagerSearch
value={filter}
onChange={setFilter}
count={pkgs.length}
/>
)}
<div className="flex flex-col">
{pkgs.map((p) => (
<div
key={p.name}
className="flex items-center justify-between gap-2 border-b border-border/50 py-1.5 last:border-0"
>
<div className="flex min-w-0 flex-col">
<span className="truncate text-xs font-semibold">{p.name}</span>
<span className="truncate font-mono text-[10px] text-muted-foreground">
{p.currentVersion ? `${p.currentVersion} → ` : ""}
{p.newVersion ?? ""}
</span>
</div>
<Button
variant="ghost"
size="xs"
disabled={busy === p.name}
onClick={() => run("install", p.name)}
>
{t("hostMetrics.managers.update")}
</Button>
</div>
))}
</div>
</ManagerCardShell>
);
}
@@ -0,0 +1,192 @@
import { useMemo, useState } from "react";
import { ListTree, X, Skull } from "lucide-react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { managerPost } from "@/main-axios";
import { useManagerData, extractError } from "./useManagerData";
import { ManagerCardShell } from "./ManagerCardShell";
import { ManagerSearch } from "./ManagerToolbar";
import { Select2 } from "@/components/select2";
interface ProcessRow {
pid: number;
ppid: number;
user: string;
cpu: number;
mem: number;
command: string;
args: string;
}
type SortKey = "cpu" | "mem" | "pid";
type Signal = "TERM" | "KILL";
export function ProcessInspectorCard({ hostId }: { hostId: number | null }) {
const { t } = useTranslation();
const { data, loading, error, refresh } = useManagerData<{
processes: ProcessRow[];
}>(hostId, "processes");
const [filter, setFilter] = useState("");
const [sort, setSort] = useState<SortKey>("cpu");
const [tree, setTree] = useState(false);
const [busy, setBusy] = useState<number | null>(null);
const all = useMemo(() => data?.processes ?? [], [data?.processes]);
const filtered = useMemo(() => {
const q = filter.toLowerCase();
const list = all.filter(
(p) =>
!q ||
p.args.toLowerCase().includes(q) ||
p.command.toLowerCase().includes(q) ||
String(p.pid).includes(q) ||
p.user.toLowerCase().includes(q),
);
return [...list].sort((a, b) => {
if (sort === "pid") return a.pid - b.pid;
return b[sort] - a[sort];
});
}, [all, filter, sort]);
// Build a depth map for the tree view from ppid links (only among visible).
const treeRows = useMemo(() => {
if (!tree) return null;
const byId = new Map(filtered.map((p) => [p.pid, p]));
const children = new Map<number, ProcessRow[]>();
const roots: ProcessRow[] = [];
for (const p of filtered) {
if (p.ppid && byId.has(p.ppid)) {
const arr = children.get(p.ppid) ?? [];
arr.push(p);
children.set(p.ppid, arr);
} else {
roots.push(p);
}
}
const out: Array<{ proc: ProcessRow; depth: number }> = [];
const walk = (p: ProcessRow, depth: number) => {
out.push({ proc: p, depth });
for (const c of children.get(p.pid) ?? []) walk(c, depth + 1);
};
roots.forEach((r) => walk(r, 0));
return out;
}, [tree, filtered]);
const kill = async (pid: number, signal: Signal) => {
if (hostId == null) return;
setBusy(pid);
try {
const res = await managerPost<{ success: boolean; output: string }>(
hostId,
"processes",
{ pid, signal },
"signal",
);
if (res.success) {
toast.success(t("hostMetrics.managers.signalSent", { pid }));
refresh();
} else {
toast.error(res.output || t("hostMetrics.managers.actionFailed"));
}
} catch (e) {
toast.error(extractError(e).message);
} finally {
setBusy(null);
}
};
const rows = treeRows
? treeRows.slice(0, 200)
: filtered.slice(0, 200).map((proc) => ({ proc, depth: 0 }));
return (
<ManagerCardShell
title={t("hostMetrics.managers.processInspector")}
icon={<ListTree className="size-3.5" />}
loading={loading}
error={error}
onRefresh={refresh}
empty={!loading && filtered.length === 0}
headerExtra={
<>
<button
onClick={() => setTree((v) => !v)}
className={`flex h-6 items-center gap-1 border px-1.5 text-[10px] transition-colors ${
tree
? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand"
: "border-border text-muted-foreground hover:text-foreground"
}`}
>
<ListTree className="size-3" />
{t("hostMetrics.managers.tree")}
</button>
<Select2
value={sort}
onChange={(e) => setSort(e.target.value as SortKey)}
disabled={tree}
className="h-6 border border-border bg-background px-1 text-[10px] disabled:opacity-40"
>
<option value="cpu">CPU</option>
<option value="mem">MEM</option>
<option value="pid">PID</option>
</Select2>
</>
}
>
<ManagerSearch
value={filter}
onChange={setFilter}
count={filtered.length}
/>
<div className="flex flex-col">
<div className="grid grid-cols-[3rem_2.5rem_2.5rem_1fr_1.5rem] gap-2 border-b border-border pb-1 text-[10px] font-bold uppercase text-muted-foreground">
<span>PID</span>
<span>CPU</span>
<span>MEM</span>
<span>CMD</span>
<span />
</div>
{rows.map(({ proc: p, depth }) => (
<div
key={p.pid}
className="relative grid grid-cols-[3rem_2.5rem_2.5rem_1fr_1.5rem] items-center gap-2 border-b border-border/50 py-1 font-mono text-xs last:border-0"
>
<span className="text-muted-foreground">{p.pid}</span>
<span className="font-bold text-accent-brand">
{p.cpu.toFixed(0)}%
</span>
<span>{p.mem.toFixed(0)}%</span>
<span
className="truncate"
title={`${p.user} · ${p.args}`}
style={depth ? { paddingLeft: depth * 12 } : undefined}
>
{depth > 0 && (
<span className="text-muted-foreground/40">└ </span>
)}
{/* `comm` is capped at 15 chars by the kernel; the full command line is in args. */}
{p.args || p.command}
</span>
<button
onClick={() => kill(p.pid, "TERM")}
onContextMenu={(e) => {
e.preventDefault();
kill(p.pid, "KILL");
}}
disabled={busy === p.pid}
title={t("hostMetrics.managers.killHint")}
className="flex size-5 items-center justify-center text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive disabled:opacity-40"
>
{busy === p.pid ? (
<Skull className="size-3" />
) : (
<X className="size-3" />
)}
</button>
</div>
))}
</div>
</ManagerCardShell>
);
}
@@ -0,0 +1,154 @@
import { useMemo, useState } from "react";
import { Cog, Play, Square, RotateCw, Power } from "lucide-react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { managerPost } from "@/main-axios";
import { useManagerData, extractError } from "./useManagerData";
import { ManagerCardShell } from "./ManagerCardShell";
import { ManagerSearch } from "./ManagerToolbar";
interface SystemdService {
unit: string;
active: string;
sub: string;
description: string;
}
export function ServiceManagerCard({ hostId }: { hostId: number | null }) {
const { t } = useTranslation();
const { data, loading, error, refresh } = useManagerData<{
services: SystemdService[];
}>(hostId, "services");
const [filter, setFilter] = useState("");
const [busy, setBusy] = useState<string | null>(null);
const services = useMemo(
() =>
(data?.services ?? []).filter(
(s) =>
!filter ||
s.unit.toLowerCase().includes(filter.toLowerCase()) ||
s.description.toLowerCase().includes(filter.toLowerCase()),
),
[data?.services, filter],
);
const act = async (unit: string, action: string) => {
if (hostId == null) return;
setBusy(`${unit}:${action}`);
try {
const res = await managerPost<{ success: boolean; output: string }>(
hostId,
"services",
{ unit, action },
"action",
);
if (res.success) {
toast.success(t("hostMetrics.managers.actionDone", { name: unit }));
refresh();
} else {
toast.error(res.output || t("hostMetrics.managers.actionFailed"));
}
} catch (e) {
toast.error(extractError(e).message);
} finally {
setBusy(null);
}
};
return (
<ManagerCardShell
title={t("hostMetrics.managers.services")}
icon={<Cog className="size-3.5" />}
loading={loading}
error={error}
onRefresh={refresh}
empty={!loading && services.length === 0}
>
<ManagerSearch
value={filter}
onChange={setFilter}
count={services.length}
/>
<div className="flex flex-col">
{services.map((s) => (
<div
key={s.unit}
className="flex items-center justify-between gap-2 border-b border-border/50 py-1.5 last:border-0"
>
<div className="flex min-w-0 items-center gap-2">
<span
className={`size-1.5 shrink-0 rounded-full ${s.active === "active" ? "bg-accent-brand" : "bg-muted-foreground/40"}`}
/>
<div className="flex min-w-0 flex-col">
<span className="truncate text-xs font-semibold" title={s.unit}>
{s.unit.replace(/\.service$/, "")}
</span>
<span className="truncate text-[10px] text-muted-foreground">
{s.sub}
</span>
</div>
</div>
<div className="flex shrink-0 items-center gap-0.5">
<ActionBtn
onClick={() => act(s.unit, "start")}
busy={busy === `${s.unit}:start`}
title={t("hostMetrics.managers.start")}
>
<Play className="size-3" />
</ActionBtn>
<ActionBtn
onClick={() => act(s.unit, "restart")}
busy={busy === `${s.unit}:restart`}
title={t("hostMetrics.managers.restart")}
>
<RotateCw className="size-3" />
</ActionBtn>
<ActionBtn
onClick={() => act(s.unit, "stop")}
busy={busy === `${s.unit}:stop`}
title={t("hostMetrics.managers.stop")}
>
<Square className="size-3" />
</ActionBtn>
<ActionBtn
onClick={() =>
act(s.unit, s.sub === "running" ? "disable" : "enable")
}
busy={
busy === `${s.unit}:enable` || busy === `${s.unit}:disable`
}
title={t("hostMetrics.managers.enableDisable")}
>
<Power className="size-3" />
</ActionBtn>
</div>
</div>
))}
</div>
</ManagerCardShell>
);
}
function ActionBtn({
onClick,
busy,
title,
children,
}: {
onClick: () => void;
busy: boolean;
title: string;
children: React.ReactNode;
}) {
return (
<button
onClick={onClick}
disabled={busy}
title={title}
className="flex size-6 items-center justify-center text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:opacity-40"
>
{children}
</button>
);
}
@@ -0,0 +1,165 @@
import { useMemo, useState } from "react";
import { MemoryStick, Timer, HardDrive } from "lucide-react";
import { useTranslation } from "react-i18next";
import { BarSeries, StatRow } from "@/components/charts";
import { useManagerData } from "./useManagerData";
import { ManagerCardShell } from "./ManagerCardShell";
import { ManagerSearch } from "./ManagerToolbar";
interface MemProc {
pid: number;
user: string;
mem: number;
rss: number;
command: string;
}
interface TimerRow {
unit: string;
activates: string;
next: string;
}
interface MountUsage {
filesystem: string;
usePct: number;
usedKb: number;
sizeKb: number;
mount: string;
}
function fmtGiB(kb: number): string {
return `${(kb / 1024 / 1024).toFixed(1)}G`;
}
export function TopMemoryCard({ hostId }: { hostId: number | null }) {
const { t } = useTranslation();
const { data, loading, error, refresh } = useManagerData<{
processes: MemProc[];
}>(hostId, "top-memory");
const [query, setQuery] = useState("");
const all = useMemo(() => data?.processes ?? [], [data?.processes]);
const procs = useMemo(() => {
const q = query.toLowerCase();
return all.filter(
(p) =>
!q ||
p.command.toLowerCase().includes(q) ||
String(p.pid).includes(q) ||
p.user.toLowerCase().includes(q),
);
}, [all, query]);
return (
<ManagerCardShell
title={t("hostMetrics.managers.topMemory")}
icon={<MemoryStick className="size-3.5" />}
loading={loading}
error={error}
onRefresh={refresh}
empty={!loading && all.length === 0}
>
{all.length > 5 && (
<ManagerSearch value={query} onChange={setQuery} count={procs.length} />
)}
<BarSeries
items={procs.map((p) => ({
label: `${p.command} (${p.pid})`,
value: p.mem,
valueLabel: `${p.mem.toFixed(1)}%`,
}))}
max={Math.max(1, ...procs.map((p) => p.mem))}
/>
</ManagerCardShell>
);
}
export function SystemdTimersCard({ hostId }: { hostId: number | null }) {
const { t } = useTranslation();
const { data, loading, error, refresh } = useManagerData<{
timers: TimerRow[];
}>(hostId, "timers");
const [query, setQuery] = useState("");
const all = useMemo(() => data?.timers ?? [], [data?.timers]);
const timers = useMemo(() => {
const q = query.toLowerCase();
return all.filter(
(tm) =>
!q ||
tm.unit.toLowerCase().includes(q) ||
tm.activates.toLowerCase().includes(q),
);
}, [all, query]);
return (
<ManagerCardShell
title={t("hostMetrics.managers.systemdTimers")}
icon={<Timer className="size-3.5" />}
loading={loading}
error={error}
onRefresh={refresh}
empty={!loading && all.length === 0}
>
{all.length > 5 && (
<ManagerSearch
value={query}
onChange={setQuery}
count={timers.length}
/>
)}
<div className="flex flex-col divide-y divide-border">
{timers.map((tm) => (
<StatRow
key={tm.unit}
label={tm.unit.replace(/\.timer$/, "")}
value={tm.activates}
mono
/>
))}
</div>
</ManagerCardShell>
);
}
export function DiskBreakdownCard({ hostId }: { hostId: number | null }) {
const { t } = useTranslation();
const { data, loading, error, refresh } = useManagerData<{
mounts: MountUsage[];
}>(hostId, "disk-breakdown");
const [query, setQuery] = useState("");
const all = useMemo(() => data?.mounts ?? [], [data?.mounts]);
const mounts = useMemo(() => {
const q = query.toLowerCase();
return all.filter(
(m) =>
!q ||
m.mount.toLowerCase().includes(q) ||
m.filesystem.toLowerCase().includes(q),
);
}, [all, query]);
return (
<ManagerCardShell
title={t("hostMetrics.managers.diskBreakdown")}
icon={<HardDrive className="size-3.5" />}
loading={loading}
error={error}
onRefresh={refresh}
empty={!loading && all.length === 0}
>
{all.length > 5 && (
<ManagerSearch
value={query}
onChange={setQuery}
count={mounts.length}
/>
)}
<BarSeries
items={mounts.map((m) => ({
label: `${m.mount} (${fmtGiB(m.usedKb)}/${fmtGiB(m.sizeKb)})`,
value: m.usePct,
valueLabel: `${m.usePct}%`,
}))}
max={100}
/>
</ManagerCardShell>
);
}
@@ -0,0 +1,302 @@
import { useMemo, useState } from "react";
import { ShieldCheck, RefreshCw, Plus, X, Trash2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/button";
import { managerPost } from "@/main-axios";
import {
useManagerData,
useManagerAction,
extractError,
} from "./useManagerData";
import { ManagerCardShell } from "./ManagerCardShell";
import { Select2 } from "@/components/select2";
interface CertInfo {
client: string;
name: string;
domains: string[];
expiry: string | null;
}
interface SslData {
clients: { certbot: boolean; acmeSh: boolean };
certs: CertInfo[];
}
type Challenge = "http-standalone" | "http-webroot" | "dns";
/** Days until the given expiry string, or null if unparseable. */
function daysUntil(expiry: string | null): number | null {
if (!expiry) return null;
const ts = Date.parse(expiry);
if (Number.isNaN(ts)) return null;
return Math.round((ts - Date.now()) / 86400000);
}
function ExpiryBadge({ expiry }: { expiry: string | null }) {
const { t } = useTranslation();
const days = daysUntil(expiry);
if (days == null)
return <span className="text-muted-foreground">{expiry ?? "—"}</span>;
const tone =
days < 0
? "text-destructive"
: days < 14
? "text-yellow-500"
: "text-accent-brand";
return (
<span className={tone} title={expiry ?? undefined}>
{days < 0
? t("hostMetrics.managers.sslExpired")
: t("hostMetrics.managers.sslInDays", { days })}
</span>
);
}
export function SslManagerCard({ hostId }: { hostId: number | null }) {
const { t } = useTranslation();
const { data, loading, error, refresh } = useManagerData<SslData>(
hostId,
"ssl",
);
const { busy, run } = useManagerAction(hostId);
const [showIssue, setShowIssue] = useState(false);
const [domains, setDomains] = useState("");
const [challenge, setChallenge] = useState<Challenge>("http-standalone");
const [webroot, setWebroot] = useState("");
const [dnsProvider, setDnsProvider] = useState("");
const [issuing, setIssuing] = useState(false);
const clients = data?.clients;
const activeClient: "certbot" | "acme.sh" | null = clients?.certbot
? "certbot"
: clients?.acmeSh
? "acme.sh"
: null;
const certs = useMemo(() => data?.certs ?? [], [data?.certs]);
const renew = (dryRun: boolean) => {
if (!activeClient) return;
run(
"ssl",
{ client: activeClient, dryRun },
{
action: "renew",
toastId: "ssl-op",
loadingMsg: t("hostMetrics.managers.working"),
successMsg: t("hostMetrics.managers.actionDone", { name: "renew" }),
failMsg: t("hostMetrics.managers.actionFailed"),
onDone: refresh,
},
);
};
const revoke = (cert: CertInfo) => {
if (cert.client !== "certbot" && cert.client !== "acme.sh") return;
if (
!window.confirm(
t("hostMetrics.managers.sslRevokeConfirm", { name: cert.name }),
)
)
return;
run(
"ssl",
{ client: cert.client, name: cert.name },
{
action: "revoke",
toastId: "ssl-op",
loadingMsg: t("hostMetrics.managers.working"),
successMsg: t("hostMetrics.managers.sslRevoked"),
failMsg: t("hostMetrics.managers.actionFailed"),
onDone: refresh,
},
);
};
const issue = async () => {
if (hostId == null || !activeClient) return;
const list = domains
.split(/[\s,]+/)
.map((d) => d.trim())
.filter(Boolean);
if (list.length === 0) {
const { toast } = await import("sonner");
toast.error(t("hostMetrics.managers.sslNeedDomain"));
return;
}
setIssuing(true);
const { toast } = await import("sonner");
toast.loading(t("hostMetrics.managers.working"), { id: "ssl-issue" });
try {
const res = await managerPost<{ success: boolean; output: string }>(
hostId,
"ssl",
{
client: activeClient,
domains: list,
challenge,
webroot: challenge === "http-webroot" ? webroot : undefined,
dnsProvider: challenge === "dns" ? dnsProvider : undefined,
},
"issue",
);
toast[res.success ? "success" : "error"](
res.success
? t("hostMetrics.managers.sslIssued")
: t("hostMetrics.managers.actionFailed"),
{ id: "ssl-issue", description: res.output?.slice(-200) },
);
if (res.success) {
setShowIssue(false);
setDomains("");
refresh();
}
} catch (e) {
toast.error(extractError(e).message, { id: "ssl-issue" });
} finally {
setIssuing(false);
}
};
return (
<ManagerCardShell
title={t("hostMetrics.managers.ssl")}
icon={<ShieldCheck className="size-3.5" />}
loading={loading}
error={error}
onRefresh={refresh}
empty={!loading && certs.length === 0 && !activeClient}
emptyMessage={t("hostMetrics.managers.noAcmeClient")}
headerExtra={
activeClient ? (
<>
<Button
variant="ghost"
size="xs"
disabled={busy}
onClick={() => renew(true)}
>
{t("hostMetrics.managers.dryRun")}
</Button>
<Button
variant="outline"
size="xs"
disabled={busy}
onClick={() => renew(false)}
>
<RefreshCw className="size-3" />
{t("hostMetrics.managers.renew")}
</Button>
</>
) : undefined
}
>
<div className="mb-2 flex items-center justify-between text-[10px] uppercase tracking-widest text-muted-foreground">
<div className="flex items-center gap-2">
<span>{t("hostMetrics.managers.clients")}:</span>
<span className={clients?.certbot ? "text-accent-brand" : ""}>
certbot
</span>
<span className={clients?.acmeSh ? "text-accent-brand" : ""}>
acme.sh
</span>
</div>
{activeClient && (
<Button
variant="ghost"
size="xs"
onClick={() => setShowIssue((v) => !v)}
>
{showIssue ? <X className="size-3" /> : <Plus className="size-3" />}
{t("hostMetrics.managers.sslIssueCert")}
</Button>
)}
</div>
{showIssue && activeClient && (
<div className="mb-3 flex flex-col gap-2 border border-dashed border-border p-2">
<input
value={domains}
onChange={(e) => setDomains(e.target.value)}
placeholder={t("hostMetrics.managers.sslDomainsPlaceholder")}
className="h-7 w-full border border-border bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring"
/>
<div className="flex items-center gap-1.5">
<Select2
value={challenge}
onChange={(e) => setChallenge(e.target.value as Challenge)}
className="h-7 flex-1 border border-border bg-background px-1 text-xs"
>
<option value="http-standalone">
{t("hostMetrics.managers.sslHttpStandalone")}
</option>
<option value="http-webroot">
{t("hostMetrics.managers.sslHttpWebroot")}
</option>
<option value="dns">{t("hostMetrics.managers.sslDns")}</option>
</Select2>
</div>
{challenge === "http-webroot" && (
<input
value={webroot}
onChange={(e) => setWebroot(e.target.value)}
placeholder="/var/www/html"
className="h-7 w-full border border-border bg-background px-2 font-mono text-xs outline-none focus:ring-1 focus:ring-ring"
/>
)}
{challenge === "dns" && (
<input
value={dnsProvider}
onChange={(e) => setDnsProvider(e.target.value)}
placeholder={t("hostMetrics.managers.sslDnsProvider")}
className="h-7 w-full border border-border bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring"
/>
)}
<Button
variant="outline"
size="sm"
disabled={issuing}
onClick={issue}
className="self-start"
>
{t("hostMetrics.managers.sslIssueCert")}
</Button>
<span className="text-[10px] text-muted-foreground/70">
{t("hostMetrics.managers.sslIssueHint")}
</span>
</div>
)}
<div className="flex flex-col divide-y divide-border">
{certs.map((c) => (
<div
key={`${c.client}-${c.name}`}
className="flex items-center justify-between gap-2 py-1.5 text-xs"
>
<span
className="truncate font-semibold"
title={c.domains.join(", ")}
>
{c.name}
</span>
<div className="flex shrink-0 items-center gap-2">
<span className="font-mono text-[11px]">
<ExpiryBadge expiry={c.expiry} />
</span>
{(c.client === "certbot" || c.client === "acme.sh") && (
<button
onClick={() => revoke(c)}
disabled={busy}
title={t("hostMetrics.managers.sslRevoke")}
className="text-muted-foreground transition-colors hover:text-destructive disabled:opacity-40"
>
<Trash2 className="size-3.5" />
</button>
)}
</div>
</div>
))}
</div>
</ManagerCardShell>
);
}
@@ -0,0 +1,171 @@
import { useState } from "react";
import { Power } from "lucide-react";
import { useTranslation } from "react-i18next";
import { useManagerData, useManagerAction } from "./useManagerData";
import { ManagerCardShell } from "./ManagerCardShell";
import { ManagerSearch } from "./ManagerToolbar";
interface TailscalePeer {
hostname: string;
tailscaleIPs: string[];
online: boolean;
isExitNode: boolean;
}
interface TailscaleData {
installed: boolean;
running: boolean;
tailscaleIPs: string[];
hostname: string | null;
peers: TailscalePeer[];
exitNodeInUse: boolean;
}
export function TailscaleManagerCard({ hostId }: { hostId: number | null }) {
const { t } = useTranslation();
const { data, loading, error, refresh } = useManagerData<TailscaleData>(
hostId,
"tailscale",
);
const { busy, run } = useManagerAction(hostId);
const [query, setQuery] = useState("");
const handleToggle = async () => {
if (!data) return;
const action = data.running ? "down" : "up";
await run(
"tailscale",
{ action },
{
action: "action",
loadingMsg: data.running
? t("hostMetrics.managers.tsDisabling")
: t("hostMetrics.managers.tsEnabling"),
successMsg: data.running
? t("hostMetrics.managers.tsDisabled")
: t("hostMetrics.managers.tsEnabled"),
onDone: refresh,
},
);
};
const filteredPeers = (data?.peers ?? []).filter(
(p) =>
!query ||
p.hostname.toLowerCase().includes(query.toLowerCase()) ||
p.tailscaleIPs.some((ip) => ip.includes(query)),
);
const isNotInstalled = data && !data.installed;
return (
<ManagerCardShell
title={t("hostMetrics.managers.tailscale")}
icon={<Power className="size-3.5" />}
loading={loading}
error={error}
onRefresh={refresh}
empty={!!isNotInstalled}
emptyMessage={t("hostMetrics.managers.tsNotInstalled")}
>
{data && (
<div className="flex flex-col">
{/* Status + toggle row */}
<div className="flex items-center justify-between border-b border-border/50 py-1.5">
<div className="flex min-w-0 items-center gap-2">
<span
className={`size-1.5 shrink-0 rounded-full ${data.running ? "bg-accent-brand" : "bg-muted-foreground/40"}`}
/>
<div className="flex min-w-0 flex-col">
<span className="text-xs font-semibold">
{data.running
? t("hostMetrics.managers.tsRunning")
: t("hostMetrics.managers.tsStopped")}
</span>
{data.hostname && (
<span className="truncate font-mono text-[10px] text-muted-foreground">
{data.hostname}
</span>
)}
{data.tailscaleIPs.map((ip) => (
<span
key={ip}
className="truncate font-mono text-[10px] text-muted-foreground/60"
>
{ip}
</span>
))}
</div>
</div>
<div className="flex shrink-0 items-center">
<button
onClick={handleToggle}
disabled={busy || !data.installed}
title={
data.running
? t("hostMetrics.managers.tsDisable")
: t("hostMetrics.managers.tsEnable")
}
className={`flex size-6 items-center justify-center transition-colors disabled:opacity-40 ${
data.running
? "text-accent-brand hover:bg-muted hover:text-destructive"
: "text-muted-foreground hover:bg-muted hover:text-accent-brand"
}`}
>
<Power className="size-3.5" />
</button>
</div>
</div>
{/* Exit node badge */}
{data.exitNodeInUse && (
<div className="border-b border-border/50 px-0 py-1.5">
<span className="text-[10px] text-blue-500">
{t("hostMetrics.managers.tsExitNodeActive")}
</span>
</div>
)}
{/* Peers */}
{(data.peers?.length ?? 0) > 5 && (
<ManagerSearch
value={query}
onChange={setQuery}
count={filteredPeers.length}
/>
)}
{filteredPeers.map((peer) => (
<div
key={peer.hostname}
className="flex items-center justify-between gap-2 border-b border-border/50 py-1.5 last:border-0"
>
<div className="flex min-w-0 items-center gap-2">
<span
className={`size-1.5 shrink-0 rounded-full ${peer.online ? "bg-accent-brand" : "bg-muted-foreground/40"}`}
/>
<div className="flex min-w-0 flex-col">
<span className="truncate text-xs font-semibold">
{peer.hostname}
</span>
{peer.tailscaleIPs.map((ip) => (
<span
key={ip}
className="truncate font-mono text-[10px] text-muted-foreground"
>
{ip}
</span>
))}
</div>
</div>
{peer.isExitNode && (
<span className="shrink-0 text-[10px] text-blue-500">
{t("hostMetrics.managers.tsExitNode")}
</span>
)}
</div>
))}
</div>
)}
</ManagerCardShell>
);
}
@@ -0,0 +1,145 @@
import { useMemo, useState } from "react";
import { Users, Plus, Trash2, ShieldCheck, Shield } from "lucide-react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { Button } from "@/components/button";
import { managerPost } from "@/main-axios";
import { useManagerData, extractError } from "./useManagerData";
import { ManagerCardShell } from "./ManagerCardShell";
import { ManagerSearch } from "./ManagerToolbar";
interface SystemUser {
name: string;
uid: number;
shell: string;
}
interface UsersData {
users: SystemUser[];
sudoers: string[];
}
export function UserManagerCard({ hostId }: { hostId: number | null }) {
const { t } = useTranslation();
const { data, loading, error, refresh } = useManagerData<UsersData>(
hostId,
"users",
);
const [newUser, setNewUser] = useState("");
const [filter, setFilter] = useState("");
const [busy, setBusy] = useState<string | null>(null);
const sudoers = new Set(data?.sudoers ?? []);
const users = useMemo(
() =>
(data?.users ?? []).filter(
(u) => !filter || u.name.toLowerCase().includes(filter.toLowerCase()),
),
[data?.users, filter],
);
const action = async (act: string, username: string, group?: string) => {
if (hostId == null) return;
setBusy(username);
try {
const res = await managerPost<{ success: boolean; output: string }>(
hostId,
"users",
{ action: act, username, group },
"action",
);
if (res.success) {
toast.success(t("hostMetrics.managers.actionDone", { name: username }));
if (act === "create") setNewUser("");
refresh();
} else {
toast.error(res.output || t("hostMetrics.managers.actionFailed"));
}
} catch (e) {
toast.error(extractError(e).message);
} finally {
setBusy(null);
}
};
return (
<ManagerCardShell
title={t("hostMetrics.managers.users")}
icon={<Users className="size-3.5" />}
loading={loading}
error={error}
onRefresh={refresh}
>
<div className="mb-2 flex items-center gap-1.5">
<input
value={newUser}
onChange={(e) => setNewUser(e.target.value)}
placeholder={t("hostMetrics.managers.newUsername")}
className="h-7 flex-1 border border-border bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring"
/>
<Button
variant="outline"
size="xs"
disabled={!newUser || busy === newUser}
onClick={() => action("create", newUser)}
>
<Plus className="size-3" />
{t("hostMetrics.managers.addUser")}
</Button>
</div>
<ManagerSearch value={filter} onChange={setFilter} count={users.length} />
<div className="flex flex-col">
{users.map((u) => {
const isSudoer = sudoers.has(u.name);
return (
<div
key={u.name}
className="flex items-center justify-between gap-2 border-b border-border/50 py-1.5 last:border-0"
>
<div className="flex min-w-0 items-center gap-2">
<span className="truncate text-xs font-semibold">{u.name}</span>
{isSudoer && (
<ShieldCheck className="size-3 shrink-0 text-accent-brand" />
)}
<span className="truncate font-mono text-[10px] text-muted-foreground">
{u.uid}
</span>
</div>
<div className="flex shrink-0 items-center gap-1">
<button
onClick={() =>
action(
isSudoer ? "removeFromGroup" : "addToGroup",
u.name,
"sudo",
)
}
disabled={busy === u.name}
title={
isSudoer
? t("hostMetrics.managers.revokeSudo")
: t("hostMetrics.managers.grantSudo")
}
className={`disabled:opacity-40 ${
isSudoer
? "text-accent-brand hover:text-muted-foreground"
: "text-muted-foreground hover:text-accent-brand"
}`}
>
<Shield className="size-3.5" />
</button>
<button
onClick={() => action("delete", u.name)}
disabled={busy === u.name}
title={t("hostMetrics.managers.deleteUser")}
className="text-muted-foreground hover:text-destructive disabled:opacity-40"
>
<Trash2 className="size-3.5" />
</button>
</div>
</div>
);
})}
</div>
</ManagerCardShell>
);
}
@@ -0,0 +1,209 @@
import { useState } from "react";
import {
ChevronDown,
ChevronRight,
Network,
ArrowUp,
ArrowDown,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import { useManagerData, useManagerAction } from "./useManagerData";
import { ManagerCardShell } from "./ManagerCardShell";
interface WireGuardPeer {
publicKey: string;
endpoint: string | null;
allowedIPs: string[];
latestHandshake: number | null;
rxBytes: number;
txBytes: number;
}
interface WireGuardInterface {
name: string;
publicKey: string | null;
listenPort: number | null;
up: boolean;
peers: WireGuardPeer[];
}
interface WireGuardData {
installed: boolean;
interfaces: WireGuardInterface[];
}
function fmtBytes(n: number): string {
if (n >= 1024 * 1024 * 1024)
return `${(n / 1024 / 1024 / 1024).toFixed(1)}GB`;
if (n >= 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)}MB`;
if (n >= 1024) return `${(n / 1024).toFixed(1)}KB`;
return `${n}B`;
}
function fmtHandshake(ts: number | null): string {
if (!ts) return "";
const diff = Math.floor(Date.now() / 1000) - ts;
if (diff < 60) return `${diff}s ago`;
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
return `${Math.floor(diff / 86400)}d ago`;
}
function truncKey(key: string): string {
return key.length > 12 ? `${key.slice(0, 12)}...` : key;
}
export function WireGuardManagerCard({ hostId }: { hostId: number | null }) {
const { t } = useTranslation();
const { data, loading, error, refresh } = useManagerData<WireGuardData>(
hostId,
"wireguard",
);
const { busy, run } = useManagerAction(hostId);
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const toggleExpand = (name: string) => {
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(name)) next.delete(name);
else next.add(name);
return next;
});
};
const handleAction = async (iface: WireGuardInterface) => {
const action = iface.up ? "down" : "up";
await run(
"wireguard",
{ interface: iface.name, action },
{
action: "action",
loadingMsg:
action === "up"
? t("hostMetrics.managers.wgBringingUp", { name: iface.name })
: t("hostMetrics.managers.wgBringingDown", { name: iface.name }),
successMsg:
action === "up"
? t("hostMetrics.managers.wgInterfaceUpDone", { name: iface.name })
: t("hostMetrics.managers.wgInterfaceDownDone", {
name: iface.name,
}),
onDone: refresh,
},
);
};
const isNotInstalled = data && !data.installed;
const isEmpty = data?.installed && data.interfaces.length === 0;
return (
<ManagerCardShell
title={t("hostMetrics.managers.wireguard")}
icon={<Network className="size-3.5" />}
loading={loading}
error={error}
onRefresh={refresh}
empty={isNotInstalled || isEmpty}
emptyMessage={
isNotInstalled
? t("hostMetrics.managers.wgNotInstalled")
: t("hostMetrics.managers.wgNoInterfaces")
}
>
<div className="flex flex-col">
{data?.interfaces.map((iface) => (
<div
key={iface.name}
className="border-b border-border/50 last:border-0"
>
<div className="flex items-center justify-between gap-2 py-1.5">
<button
className="flex min-w-0 flex-1 items-center gap-2 text-left"
onClick={() => toggleExpand(iface.name)}
>
{expanded.has(iface.name) ? (
<ChevronDown className="size-3 shrink-0 text-muted-foreground" />
) : (
<ChevronRight className="size-3 shrink-0 text-muted-foreground" />
)}
<span
className={`size-1.5 shrink-0 rounded-full ${iface.up ? "bg-accent-brand" : "bg-muted-foreground/40"}`}
/>
<div className="flex min-w-0 flex-col">
<span className="text-xs font-semibold font-mono">
{iface.name}
</span>
<span className="text-[10px] text-muted-foreground">
{iface.up ? "up" : "down"}
{iface.listenPort ? ` · :${iface.listenPort}` : ""}
{iface.peers.length > 0 ? ` · ${iface.peers.length}p` : ""}
</span>
</div>
</button>
<button
onClick={() => handleAction(iface)}
disabled={busy}
title={
iface.up
? t("hostMetrics.managers.wgInterfaceDown")
: t("hostMetrics.managers.wgInterfaceUp")
}
className="flex size-6 shrink-0 items-center justify-center text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:opacity-40"
>
{iface.up ? (
<ArrowDown className="size-3" />
) : (
<ArrowUp className="size-3" />
)}
</button>
</div>
{expanded.has(iface.name) && (
<div className="mb-1 ml-5 flex flex-col border-l border-border/50">
{iface.peers.length === 0 ? (
<span className="px-3 py-1 text-[10px] text-muted-foreground/50">
No peers
</span>
) : (
iface.peers.map((peer) => (
<div
key={peer.publicKey}
className="flex flex-col gap-0.5 border-b border-border/30 px-3 py-1.5 last:border-0"
>
<div className="flex items-center justify-between gap-2">
<span className="font-mono text-[10px] text-muted-foreground">
{truncKey(peer.publicKey)}
</span>
{peer.endpoint && (
<span className="font-mono text-[10px] text-muted-foreground/60">
{peer.endpoint}
</span>
)}
</div>
{peer.allowedIPs.length > 0 && (
<span className="text-[10px] text-muted-foreground/70">
{peer.allowedIPs.join(", ")}
</span>
)}
<div className="flex items-center gap-3 text-[10px] text-muted-foreground/50">
<span>
{t("hostMetrics.managers.wgLastHandshake")}:{" "}
{peer.latestHandshake
? fmtHandshake(peer.latestHandshake)
: t("hostMetrics.managers.wgHandshakeNever")}
</span>
<span>
{fmtBytes(peer.rxBytes)} / {fmtBytes(peer.txBytes)}
</span>
</div>
</div>
))
)}
</div>
)}
</div>
))}
</div>
</ManagerCardShell>
);
}
@@ -0,0 +1,119 @@
import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
import { managerGet, managerPost } from "@/main-axios";
interface ManagerError {
message: string;
code?: string;
}
function extractError(err: unknown): ManagerError {
const e = err as {
response?: { data?: { error?: string; code?: string } };
message?: string;
};
return {
message: e?.response?.data?.error || e?.message || "Request failed",
code: e?.response?.data?.code,
};
}
/**
* Fetch a manager read resource on mount + manual refresh, with loading/error
* state. `hostId` null disables fetching.
*/
export function useManagerData<T>(
hostId: number | null,
resource: string,
params?: Record<string, string | number>,
) {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<ManagerError | null>(null);
const paramsKey = params ? JSON.stringify(params) : "";
const refresh = useCallback(async () => {
if (hostId == null) return;
setLoading(true);
setError(null);
try {
const res = await managerGet<T>(hostId, resource, params);
setData(res);
} catch (err) {
setError(extractError(err));
} finally {
setLoading(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hostId, resource, paramsKey]);
useEffect(() => {
refresh();
}, [refresh]);
return { data, loading, error, refresh, setData };
}
interface ActionResult {
success: boolean;
output?: string;
}
/**
* Run a manager POST action with consistent toast feedback (loading -> success
* /error) and a busy flag. On success the optional `onDone` callback runs (e.g.
* to refresh the card). Returns the result so callers can branch further.
*/
export function useManagerAction(hostId: number | null) {
const [busy, setBusy] = useState(false);
const run = useCallback(
async <T extends ActionResult>(
resource: string,
body: unknown,
opts: {
action?: string;
toastId?: string;
loadingMsg?: string;
successMsg?: string;
failMsg?: string;
onDone?: () => void;
} = {},
): Promise<T | null> => {
if (hostId == null) return null;
const id = opts.toastId ?? `${resource}-action`;
setBusy(true);
if (opts.loadingMsg) toast.loading(opts.loadingMsg, { id });
try {
const res = await managerPost<T>(hostId, resource, body, opts.action);
if (res.success) {
if (opts.successMsg)
toast.success(opts.successMsg, {
id,
description: res.output?.slice(-200),
});
else toast.dismiss(id);
opts.onDone?.();
} else {
toast.error(opts.failMsg ?? res.output ?? "Action failed", {
id,
description: res.output?.slice(-200),
});
}
return res;
} catch (err) {
toast.error(extractError(err).message, { id });
return null;
} finally {
setBusy(false);
}
},
[hostId],
);
return { busy, run };
}
export { extractError };
export type { ManagerError };