lomavuokraus/app/admin/monitor/page.tsx
2025-12-07 01:53:10 +02:00

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