diff --git a/PROGRESS.md b/PROGRESS.md index fbe5d78..e292b87 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -79,3 +79,4 @@ - Listings now capture separate weekday/weekend prices and new amenities (microwave, free parking) across schema, API, UI, and seeds. - Deployed pricing/amenity update image `registry.halla-aho.net/thalla/lomavuokraus-web:bee691e` to staging and production. - Added site favicon generated from the updated logo (`public/favicon.ico`). +- New admin monitoring dashboard at `/admin/monitor` surfaces Hetzner node status, Kubernetes nodes/pods health, and PostgreSQL connection/size checks with auto-refresh. diff --git a/app/admin/monitor/page.tsx b/app/admin/monitor/page.tsx new file mode 100644 index 0000000..d7d4747 --- /dev/null +++ b/app/admin/monitor/page.tsx @@ -0,0 +1,311 @@ +'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 ( + + {label} + + ); +} + +export default function MonitorPage() { + const { t } = useI18n(); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [lastUpdated, setLastUpdated] = useState(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 ( +
+
+
+

{t('monitorTitle')}

+

{t('monitorLead')}

+
+
+ + + {t('monitorLastUpdated')}: {lastUpdated ? new Date(lastUpdated).toLocaleTimeString() : '—'} + +
+
+ + {error ?

{error}

: null} + {!hasData && !loading ?

{t('monitorNoData')}

: null} + +
+
+

{t('monitorHetznerTitle')}

+ {data?.hetzner?.ok ? statusPill(t('monitorHealthy'), true) : statusPill(t('monitorAttention'), false)} +
+ {!data?.hetzner ? ( +

{t('monitorNoData')}

+ ) : data.hetzner.missingToken ? ( +

{t('monitorHetznerMissingToken')}

+ ) : data.hetzner.ok && (data.hetzner.servers?.length ?? 0) > 0 ? ( +
    + {data.hetzner.servers!.map((s) => ( +
  • +
    +
    + {s.name} — {s.type ?? 'server'} ({s.datacenter ?? 'dc'}) +
    + {statusPill(s.status, s.status?.toLowerCase() === 'running')} +
    +
    + Public IP: {s.publicIp ?? '—'} + Private IP: {s.privateIp ?? '—'} + {t('monitorCreated')} {s.created ? new Date(s.created).toLocaleString() : '—'} +
    +
  • + ))} +
+ ) : ( +

{data.hetzner.error || t('monitorHetznerEmpty')}

+ )} +
+ +
+
+

{t('monitorK8sTitle')}

+ {data?.k8s?.ok ? statusPill(t('monitorHealthy'), true) : statusPill(t('monitorAttention'), false)} +
+ {!data?.k8s ? ( +

{t('monitorNoData')}

+ ) : !data.k8s.ok ? ( +

{data.k8s.error ?? t('monitorLoadFailed')}

+ ) : ( + <> +
+

{t('monitorNodesTitle')}

+
+ {(data.k8s.nodes ?? []).map((n) => ( +
+
+
+ {n.name} +
{n.roles.length ? n.roles.join(', ') : 'node'}
+
+ {statusPill(n.status, n.ready)} +
+
+
IP: {n.internalIp ?? '—'}
+
{n.kubeletVersion ?? 'kubelet'} · {n.osImage ?? ''}
+
+ {t('monitorLastReady')}: {n.lastReadyTransition ? new Date(n.lastReadyTransition).toLocaleString() : '—'} +
+
+
+ ))} +
+
+ +
+

{t('monitorPodsTitle')}

+ {(data.k8s.pods ?? []).length === 0 ? ( +

{t('monitorNoPods')}

+ ) : ( +
+ + + + + + + + + + + + + + {(data.k8s.pods ?? []).map((p) => ( + + + + + + + + + + ))} + +
NamespacePodReady{t('monitorRestarts')}Phase{t('monitorAge')}Node
{p.namespace} +
{p.name}
+
+ {p.containers.map((c) => `${c.name} (${c.state}${c.lastState ? `, ${c.lastState}` : ''})`).join('; ')} +
+
+ {p.readyCount}/{p.totalContainers} + {p.restarts} + {p.phase} + {p.reason ? ` (${p.reason})` : ''} + {formatDurationFrom(p.startedAt)}{p.nodeName ?? '—'}
+
+ )} +
+ + )} +
+ +
+
+

{t('monitorDbTitle')}

+ {data?.db?.ok ? statusPill(t('monitorHealthy'), true) : statusPill(t('monitorAttention'), false)} +
+ {!data?.db ? ( +

{t('monitorNoData')}

+ ) : !data.db.ok ? ( +

{data.db.error ?? t('monitorLoadFailed')}

+ ) : ( + <> +
+
+
{t('monitorServerTime')}
+
{data.db.serverTime ? new Date(data.db.serverTime).toLocaleString() : '—'}
+
+
+
{t('monitorDbSize')}
+
{formatBytes(data.db.databaseSizeBytes)}
+
+
+
{t('monitorDbRecovery')}
+
{data.db.recovery ? t('yes') : t('no')}
+
+
+
+

{t('monitorConnections')}

+ {(data.db.connections ?? []).length === 0 ? ( +

{t('monitorNoData')}

+ ) : ( +
    + {data.db.connections!.map((c) => ( +
  • + {c.count} {c.state} +
  • + ))} +
+ )} +
+ + )} +
+
+ ); +} diff --git a/app/api/admin/monitor/route.ts b/app/api/admin/monitor/route.ts new file mode 100644 index 0000000..cfd1669 --- /dev/null +++ b/app/api/admin/monitor/route.ts @@ -0,0 +1,24 @@ +import { NextResponse } from 'next/server'; +import { Role } from '@prisma/client'; +import { requireAuth } from '../../../../lib/jwt'; +import { fetchDbStatus, fetchHetznerServers, fetchKubernetesStatus } from '../../../../lib/monitoring'; + +export async function GET(req: Request) { + try { + const auth = await requireAuth(req); + if (auth.role !== Role.ADMIN) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const [hetzner, k8s, db] = await Promise.all([fetchHetznerServers(), fetchKubernetesStatus(), fetchDbStatus()]); + return NextResponse.json({ hetzner, k8s, db }); + } catch (error) { + if (String(error).includes('Unauthorized')) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + console.error('Monitoring endpoint error', error); + return NextResponse.json({ error: 'Failed to load monitoring data' }, { status: 500 }); + } +} + +export const dynamic = 'force-dynamic'; diff --git a/app/components/NavBar.tsx b/app/components/NavBar.tsx index c5b99ba..438b679 100644 --- a/app/components/NavBar.tsx +++ b/app/components/NavBar.tsx @@ -83,6 +83,14 @@ function Icon({ name }: { name: string }) { ); + case 'monitor': + return ( + + + + + + ); default: return null; } @@ -178,6 +186,11 @@ export default function NavBar() { {t('navUsers')} ) : null} + {isAdmin ? ( + + {t('navMonitoring')} + + ) : null} ) : null}