lomavuokraus/app/admin/monitor/page.tsx
Tero Halla-aho 0bb709d9c5
Some checks failed
CI / checks (push) Has been cancelled
chore: fix audit alerts and formatting
2026-02-04 12:43:03 +02:00

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>
);
}