mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-31 15:45:23 +00:00
feat: ship host metrics as a first-party in-process plugin
This commit is contained in:
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 };
|
||||
Reference in new issue
Block a user