617 lines
19 KiB
TypeScript
617 lines
19 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useMemo, useState } from "react";
|
|
import { useI18n } from "../../components/I18nProvider";
|
|
|
|
type HetznerServer = {
|
|
id: number;
|
|
name: string;
|
|
status: string;
|
|
type?: string;
|
|
datacenter?: string;
|
|
publicIp?: string;
|
|
privateIp?: string;
|
|
created?: string;
|
|
};
|
|
|
|
type MonitorResponse = {
|
|
hetzner?: {
|
|
ok: boolean;
|
|
error?: string;
|
|
missingToken?: boolean;
|
|
servers?: HetznerServer[];
|
|
};
|
|
k8s?: {
|
|
ok: boolean;
|
|
error?: string;
|
|
nodes?: {
|
|
name: string;
|
|
ready: boolean;
|
|
status: string;
|
|
roles: string[];
|
|
internalIp?: string;
|
|
kubeletVersion?: string;
|
|
osImage?: string;
|
|
lastReadyTransition?: string | null;
|
|
}[];
|
|
pods?: {
|
|
name: string;
|
|
namespace: string;
|
|
phase: string;
|
|
reason?: string | null;
|
|
message?: string | null;
|
|
readyCount: number;
|
|
totalContainers: number;
|
|
restarts: number;
|
|
nodeName?: string;
|
|
hostIP?: string | null;
|
|
podIP?: string | null;
|
|
startedAt?: string | null;
|
|
containers: {
|
|
name: string;
|
|
ready: boolean;
|
|
restartCount: number;
|
|
state: string;
|
|
lastState?: string | null;
|
|
}[];
|
|
}[];
|
|
};
|
|
db?: {
|
|
ok: boolean;
|
|
error?: string;
|
|
serverTime?: string;
|
|
recovery?: boolean;
|
|
databaseSizeBytes?: number;
|
|
connections?: { state: string; count: number }[];
|
|
};
|
|
};
|
|
|
|
const REFRESH_MS = 30000;
|
|
|
|
function formatBytes(bytes?: number) {
|
|
if (!bytes || Number.isNaN(bytes)) return "—";
|
|
if (bytes < 1024) return `${bytes} B`;
|
|
const kb = bytes / 1024;
|
|
if (kb < 1024) return `${kb.toFixed(1)} KB`;
|
|
const mb = kb / 1024;
|
|
if (mb < 1024) return `${mb.toFixed(1)} MB`;
|
|
const gb = mb / 1024;
|
|
return `${gb.toFixed(2)} GB`;
|
|
}
|
|
|
|
function formatDurationFrom(dateStr?: string | null) {
|
|
if (!dateStr) return "—";
|
|
const started = new Date(dateStr).getTime();
|
|
const diff = Date.now() - started;
|
|
if (Number.isNaN(diff) || diff < 0) return "—";
|
|
const mins = Math.floor(diff / 60000);
|
|
if (mins < 1) return "<1m";
|
|
if (mins < 60) return `${mins}m`;
|
|
const hours = Math.floor(mins / 60);
|
|
if (hours < 24) return `${hours}h`;
|
|
const days = Math.floor(hours / 24);
|
|
return `${days}d`;
|
|
}
|
|
|
|
function statusPill(label: string, ok: boolean) {
|
|
return (
|
|
<span
|
|
style={{
|
|
padding: "2px 8px",
|
|
borderRadius: 999,
|
|
fontSize: 12,
|
|
background: ok ? "#e6f4ea" : "#fdecea",
|
|
color: ok ? "#1b5e20" : "#c62828",
|
|
border: `1px solid ${ok ? "#a5d6a7" : "#f5c6cb"}`,
|
|
}}
|
|
>
|
|
{label}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
export default function MonitorPage() {
|
|
const { t } = useI18n();
|
|
const [data, setData] = useState<MonitorResponse | null>(null);
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [lastUpdated, setLastUpdated] = useState<number | null>(null);
|
|
|
|
const hasData = useMemo(
|
|
() => Boolean(data?.hetzner || data?.k8s || data?.db),
|
|
[data],
|
|
);
|
|
|
|
async function load() {
|
|
setLoading(true);
|
|
setError(null);
|
|
try {
|
|
const res = await fetch("/api/admin/monitor", { cache: "no-store" });
|
|
const json = (await res.json()) as MonitorResponse & { error?: string };
|
|
if (!res.ok) {
|
|
setError(json.error || t("monitorLoadFailed"));
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
setData(json);
|
|
setLastUpdated(Date.now());
|
|
} catch (err) {
|
|
setError(t("monitorLoadFailed"));
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
load();
|
|
const id = setInterval(load, REFRESH_MS);
|
|
return () => clearInterval(id);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
return (
|
|
<main
|
|
className="panel"
|
|
style={{ maxWidth: 1100, margin: "40px auto", display: "grid", gap: 16 }}
|
|
>
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
justifyContent: "space-between",
|
|
alignItems: "flex-end",
|
|
gap: 12,
|
|
}}
|
|
>
|
|
<div>
|
|
<h1>{t("monitorTitle")}</h1>
|
|
<p style={{ margin: 0, color: "#555" }}>{t("monitorLead")}</p>
|
|
</div>
|
|
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
|
<button
|
|
className="button secondary"
|
|
onClick={load}
|
|
disabled={loading}
|
|
>
|
|
{loading ? t("loading") : t("monitorRefresh")}
|
|
</button>
|
|
<span style={{ fontSize: 12, color: "#666" }}>
|
|
{t("monitorLastUpdated")}:{" "}
|
|
{lastUpdated ? new Date(lastUpdated).toLocaleTimeString() : "—"}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{error ? <p style={{ color: "red", margin: 0 }}>{error}</p> : null}
|
|
{!hasData && !loading ? (
|
|
<p style={{ margin: 0 }}>{t("monitorNoData")}</p>
|
|
) : null}
|
|
|
|
<section
|
|
style={{ border: "1px solid #eee", borderRadius: 12, padding: 16 }}
|
|
>
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
justifyContent: "space-between",
|
|
alignItems: "center",
|
|
marginBottom: 10,
|
|
}}
|
|
>
|
|
<h2 style={{ margin: 0 }}>{t("monitorHetznerTitle")}</h2>
|
|
{data?.hetzner?.ok
|
|
? statusPill(t("monitorHealthy"), true)
|
|
: statusPill(t("monitorAttention"), false)}
|
|
</div>
|
|
{!data?.hetzner ? (
|
|
<p>{t("monitorNoData")}</p>
|
|
) : data.hetzner.missingToken ? (
|
|
<p style={{ color: "#c77c02" }}>{t("monitorHetznerMissingToken")}</p>
|
|
) : data.hetzner.ok && (data.hetzner.servers?.length ?? 0) > 0 ? (
|
|
<ul
|
|
style={{ listStyle: "none", padding: 0, display: "grid", gap: 8 }}
|
|
>
|
|
{data.hetzner.servers!.map((s) => (
|
|
<li
|
|
key={s.id}
|
|
style={{
|
|
border: "1px solid #eee",
|
|
borderRadius: 10,
|
|
padding: 12,
|
|
display: "grid",
|
|
gap: 6,
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
justifyContent: "space-between",
|
|
alignItems: "center",
|
|
}}
|
|
>
|
|
<div>
|
|
<strong>{s.name}</strong> — {s.type ?? "server"} (
|
|
{s.datacenter ?? "dc"})
|
|
</div>
|
|
{statusPill(s.status, s.status?.toLowerCase() === "running")}
|
|
</div>
|
|
<div
|
|
style={{
|
|
fontSize: 13,
|
|
color: "#444",
|
|
display: "flex",
|
|
gap: 12,
|
|
flexWrap: "wrap",
|
|
}}
|
|
>
|
|
<span>Public IP: {s.publicIp ?? "—"}</span>
|
|
<span>Private IP: {s.privateIp ?? "—"}</span>
|
|
<span>
|
|
{t("monitorCreated")}{" "}
|
|
{s.created ? new Date(s.created).toLocaleString() : "—"}
|
|
</span>
|
|
</div>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
) : (
|
|
<p style={{ color: "red" }}>
|
|
{data.hetzner.error || t("monitorHetznerEmpty")}
|
|
</p>
|
|
)}
|
|
</section>
|
|
|
|
<section
|
|
style={{
|
|
border: "1px solid #eee",
|
|
borderRadius: 12,
|
|
padding: 16,
|
|
display: "grid",
|
|
gap: 12,
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
justifyContent: "space-between",
|
|
alignItems: "center",
|
|
}}
|
|
>
|
|
<h2 style={{ margin: 0 }}>{t("monitorK8sTitle")}</h2>
|
|
{data?.k8s?.ok
|
|
? statusPill(t("monitorHealthy"), true)
|
|
: statusPill(t("monitorAttention"), false)}
|
|
</div>
|
|
{!data?.k8s ? (
|
|
<p>{t("monitorNoData")}</p>
|
|
) : !data.k8s.ok ? (
|
|
<p style={{ color: "red", margin: 0 }}>
|
|
{data.k8s.error ?? t("monitorLoadFailed")}
|
|
</p>
|
|
) : (
|
|
<>
|
|
<div>
|
|
<h3 style={{ marginBottom: 6 }}>{t("monitorNodesTitle")}</h3>
|
|
<div
|
|
style={{
|
|
display: "grid",
|
|
gridTemplateColumns: "repeat(auto-fit, minmax(260px, 1fr))",
|
|
gap: 10,
|
|
}}
|
|
>
|
|
{(data.k8s.nodes ?? []).map((n) => (
|
|
<div
|
|
key={n.name}
|
|
style={{
|
|
border: "1px solid #eee",
|
|
borderRadius: 10,
|
|
padding: 10,
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
justifyContent: "space-between",
|
|
alignItems: "center",
|
|
}}
|
|
>
|
|
<div>
|
|
<strong>{n.name}</strong>
|
|
<div style={{ fontSize: 12, color: "#666" }}>
|
|
{n.roles.length ? n.roles.join(", ") : "node"}
|
|
</div>
|
|
</div>
|
|
{statusPill(n.status, n.ready)}
|
|
</div>
|
|
<div style={{ fontSize: 13, color: "#444", marginTop: 6 }}>
|
|
<div>IP: {n.internalIp ?? "—"}</div>
|
|
<div>
|
|
{n.kubeletVersion ?? "kubelet"} · {n.osImage ?? ""}
|
|
</div>
|
|
<div style={{ color: "#777" }}>
|
|
{t("monitorLastReady")}:{" "}
|
|
{n.lastReadyTransition
|
|
? new Date(n.lastReadyTransition).toLocaleString()
|
|
: "—"}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<h3 style={{ marginBottom: 6 }}>{t("monitorPodsTitle")}</h3>
|
|
{(data.k8s.pods ?? []).length === 0 ? (
|
|
<p style={{ margin: 0 }}>{t("monitorNoPods")}</p>
|
|
) : (
|
|
<div style={{ overflowX: "auto" }}>
|
|
<table
|
|
style={{
|
|
width: "100%",
|
|
borderCollapse: "collapse",
|
|
fontSize: 14,
|
|
}}
|
|
>
|
|
<thead>
|
|
<tr>
|
|
<th
|
|
style={{
|
|
textAlign: "left",
|
|
padding: "6px 8px",
|
|
borderBottom: "1px solid #eee",
|
|
}}
|
|
>
|
|
Namespace
|
|
</th>
|
|
<th
|
|
style={{
|
|
textAlign: "left",
|
|
padding: "6px 8px",
|
|
borderBottom: "1px solid #eee",
|
|
}}
|
|
>
|
|
Pod
|
|
</th>
|
|
<th
|
|
style={{
|
|
textAlign: "left",
|
|
padding: "6px 8px",
|
|
borderBottom: "1px solid #eee",
|
|
}}
|
|
>
|
|
Ready
|
|
</th>
|
|
<th
|
|
style={{
|
|
textAlign: "left",
|
|
padding: "6px 8px",
|
|
borderBottom: "1px solid #eee",
|
|
}}
|
|
>
|
|
{t("monitorRestarts")}
|
|
</th>
|
|
<th
|
|
style={{
|
|
textAlign: "left",
|
|
padding: "6px 8px",
|
|
borderBottom: "1px solid #eee",
|
|
}}
|
|
>
|
|
Phase
|
|
</th>
|
|
<th
|
|
style={{
|
|
textAlign: "left",
|
|
padding: "6px 8px",
|
|
borderBottom: "1px solid #eee",
|
|
}}
|
|
>
|
|
{t("monitorAge")}
|
|
</th>
|
|
<th
|
|
style={{
|
|
textAlign: "left",
|
|
padding: "6px 8px",
|
|
borderBottom: "1px solid #eee",
|
|
}}
|
|
>
|
|
Node
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{(data.k8s.pods ?? []).map((p) => (
|
|
<tr key={`${p.namespace}-${p.name}`}>
|
|
<td
|
|
style={{
|
|
padding: "6px 8px",
|
|
borderBottom: "1px solid #f2f2f2",
|
|
}}
|
|
>
|
|
{p.namespace}
|
|
</td>
|
|
<td
|
|
style={{
|
|
padding: "6px 8px",
|
|
borderBottom: "1px solid #f2f2f2",
|
|
}}
|
|
>
|
|
<div style={{ fontWeight: 600 }}>{p.name}</div>
|
|
<div style={{ fontSize: 12, color: "#666" }}>
|
|
{p.containers
|
|
.map(
|
|
(c) =>
|
|
`${c.name} (${c.state}${c.lastState ? `, ${c.lastState}` : ""})`,
|
|
)
|
|
.join("; ")}
|
|
</div>
|
|
</td>
|
|
<td
|
|
style={{
|
|
padding: "6px 8px",
|
|
borderBottom: "1px solid #f2f2f2",
|
|
}}
|
|
>
|
|
{p.readyCount}/{p.totalContainers}
|
|
</td>
|
|
<td
|
|
style={{
|
|
padding: "6px 8px",
|
|
borderBottom: "1px solid #f2f2f2",
|
|
}}
|
|
>
|
|
{p.restarts}
|
|
</td>
|
|
<td
|
|
style={{
|
|
padding: "6px 8px",
|
|
borderBottom: "1px solid #f2f2f2",
|
|
}}
|
|
>
|
|
{p.phase}
|
|
{p.reason ? ` (${p.reason})` : ""}
|
|
</td>
|
|
<td
|
|
style={{
|
|
padding: "6px 8px",
|
|
borderBottom: "1px solid #f2f2f2",
|
|
}}
|
|
>
|
|
{formatDurationFrom(p.startedAt)}
|
|
</td>
|
|
<td
|
|
style={{
|
|
padding: "6px 8px",
|
|
borderBottom: "1px solid #f2f2f2",
|
|
}}
|
|
>
|
|
{p.nodeName ?? "—"}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</>
|
|
)}
|
|
</section>
|
|
|
|
<section
|
|
style={{
|
|
border: "1px solid #eee",
|
|
borderRadius: 12,
|
|
padding: 16,
|
|
display: "grid",
|
|
gap: 10,
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
justifyContent: "space-between",
|
|
alignItems: "center",
|
|
}}
|
|
>
|
|
<h2 style={{ margin: 0 }}>{t("monitorDbTitle")}</h2>
|
|
{data?.db?.ok
|
|
? statusPill(t("monitorHealthy"), true)
|
|
: statusPill(t("monitorAttention"), false)}
|
|
</div>
|
|
{!data?.db ? (
|
|
<p>{t("monitorNoData")}</p>
|
|
) : !data.db.ok ? (
|
|
<p style={{ color: "red", margin: 0 }}>
|
|
{data.db.error ?? t("monitorLoadFailed")}
|
|
</p>
|
|
) : (
|
|
<>
|
|
<div
|
|
style={{
|
|
display: "grid",
|
|
gridTemplateColumns: "repeat(auto-fit, minmax(240px, 1fr))",
|
|
gap: 8,
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
border: "1px solid #eee",
|
|
borderRadius: 10,
|
|
padding: 10,
|
|
}}
|
|
>
|
|
<div style={{ fontSize: 12, color: "#666" }}>
|
|
{t("monitorServerTime")}
|
|
</div>
|
|
<div style={{ fontWeight: 600 }}>
|
|
{data.db.serverTime
|
|
? new Date(data.db.serverTime).toLocaleString()
|
|
: "—"}
|
|
</div>
|
|
</div>
|
|
<div
|
|
style={{
|
|
border: "1px solid #eee",
|
|
borderRadius: 10,
|
|
padding: 10,
|
|
}}
|
|
>
|
|
<div style={{ fontSize: 12, color: "#666" }}>
|
|
{t("monitorDbSize")}
|
|
</div>
|
|
<div style={{ fontWeight: 600 }}>
|
|
{formatBytes(data.db.databaseSizeBytes)}
|
|
</div>
|
|
</div>
|
|
<div
|
|
style={{
|
|
border: "1px solid #eee",
|
|
borderRadius: 10,
|
|
padding: 10,
|
|
}}
|
|
>
|
|
<div style={{ fontSize: 12, color: "#666" }}>
|
|
{t("monitorDbRecovery")}
|
|
</div>
|
|
<div style={{ fontWeight: 600 }}>
|
|
{data.db.recovery ? t("yes") : t("no")}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<h3 style={{ marginBottom: 6 }}>{t("monitorConnections")}</h3>
|
|
{(data.db.connections ?? []).length === 0 ? (
|
|
<p style={{ margin: 0 }}>{t("monitorNoData")}</p>
|
|
) : (
|
|
<ul
|
|
style={{
|
|
listStyle: "none",
|
|
padding: 0,
|
|
margin: 0,
|
|
display: "flex",
|
|
gap: 10,
|
|
flexWrap: "wrap",
|
|
}}
|
|
>
|
|
{data.db.connections!.map((c) => (
|
|
<li
|
|
key={c.state}
|
|
style={{
|
|
border: "1px solid #eee",
|
|
borderRadius: 10,
|
|
padding: "6px 10px",
|
|
}}
|
|
>
|
|
<strong>{c.count}</strong> {c.state}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
</>
|
|
)}
|
|
</section>
|
|
</main>
|
|
);
|
|
}
|