311 lines
14 KiB
TypeScript
311 lines
14 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>
|
|
);
|
|
}
|