Add admin monitoring dashboard

This commit is contained in:
Tero Halla-aho 2025-12-07 01:53:10 +02:00
parent 1fa0b4d300
commit 9ba806c8c5
7 changed files with 730 additions and 1 deletions

View file

@ -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.

311
app/admin/monitor/page.tsx Normal file
View file

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

View file

@ -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';

View file

@ -83,6 +83,14 @@ function Icon({ name }: { name: string }) {
<path d="M12 3a15.3 15.3 0 0 1 4 9 15.3 15.3 0 0 1-4 9 15.3 15.3 0 0 1-4-9 15.3 15.3 0 0 1 4-9z" />
</svg>
);
case 'monitor':
return (
<svg {...common} viewBox="0 0 24 24" aria-hidden>
<rect x="3" y="4" width="18" height="14" rx="2" ry="2" />
<path d="M9 20h6" />
<path d="M9 14l2-3 2 2 2-4" />
</svg>
);
default:
return null;
}
@ -178,6 +186,11 @@ export default function NavBar() {
<Icon name="users" /> {t('navUsers')}
</Link>
) : null}
{isAdmin ? (
<Link href="/admin/monitor" className="button secondary">
<Icon name="monitor" /> {t('navMonitoring')}
</Link>
) : null}
</>
) : null}
<button className="button secondary" onClick={logout}>

View file

@ -8,6 +8,7 @@ const baseMessages = {
navNewListing: 'New listing',
navApprovals: 'Approvals',
navUsers: 'Users',
navMonitoring: 'Monitoring',
navLogout: 'Logout',
navLogin: 'Login',
navSignup: 'Sign up',
@ -90,6 +91,30 @@ const baseMessages = {
adminRequired: 'Admin access required',
adminUsersTitle: 'Admin: users',
adminUsersLead: 'Manage user roles and approvals.',
monitorTitle: 'Admin: monitoring',
monitorLead: 'Live status for Hetzner nodes, Kubernetes pods, and PostgreSQL.',
monitorRefresh: 'Refresh now',
monitorLastUpdated: 'Last updated',
monitorNoData: 'No data yet.',
monitorHetznerTitle: 'Hetzner nodes',
monitorHetznerMissingToken: 'Set HCLOUD_TOKEN to show Hetzner nodes.',
monitorHetznerEmpty: 'No Hetzner servers returned.',
monitorK8sTitle: 'Kubernetes',
monitorNodesTitle: 'Nodes',
monitorPodsTitle: 'Pods',
monitorNoPods: 'No pods in lomavuokraus namespaces.',
monitorRestarts: 'Restarts',
monitorAge: 'Age',
monitorDbTitle: 'PostgreSQL',
monitorServerTime: 'Server time',
monitorDbSize: 'Database size',
monitorDbRecovery: 'Recovery mode',
monitorConnections: 'Connections',
monitorHealthy: 'Healthy',
monitorAttention: 'Needs attention',
monitorLoadFailed: 'Failed to load monitoring data.',
monitorCreated: 'Created',
monitorLastReady: 'Last Ready transition',
tableEmail: 'Email',
tableRole: 'Role',
tableStatus: 'Status',
@ -297,6 +322,7 @@ const baseMessages = {
navNewListing: 'Luo kohde',
navApprovals: 'Tarkastettavat',
navUsers: 'Käyttäjät',
navMonitoring: 'Valvonta',
navLogout: 'Kirjaudu ulos',
navLogin: 'Kirjaudu',
navSignup: 'Rekisteröidy',
@ -406,6 +432,30 @@ const baseMessages = {
adminRequired: 'Ylläpitäjän oikeudet vaaditaan',
adminUsersTitle: 'Ylläpito: käyttäjät',
adminUsersLead: 'Hallinnoi rooleja ja hyväksyntöjä.',
monitorTitle: 'Ylläpito: valvonta',
monitorLead: 'Hetzner-solmujen, Kubernetes-podien ja PostgreSQL:n tilanne yhdessä näkymässä.',
monitorRefresh: 'Päivitä nyt',
monitorLastUpdated: 'Päivitetty',
monitorNoData: 'Ei dataa vielä.',
monitorHetznerTitle: 'Hetzner-solmut',
monitorHetznerMissingToken: 'Aseta HCLOUD_TOKEN, jotta Hetzner-solmut saadaan näkyviin.',
monitorHetznerEmpty: 'Hetzner ei palauttanut palvelimia.',
monitorK8sTitle: 'Kubernetes',
monitorNodesTitle: 'Solmut',
monitorPodsTitle: 'Podit',
monitorNoPods: 'Ei podeja lomavuokraus-namespacessa.',
monitorRestarts: 'Restartit',
monitorAge: 'Ikä',
monitorDbTitle: 'PostgreSQL',
monitorServerTime: 'Palvelimen aika',
monitorDbSize: 'Tietokannan koko',
monitorDbRecovery: 'Recovery-tila',
monitorConnections: 'Yhteydet',
monitorHealthy: 'Kunnossa',
monitorAttention: 'Huomio',
monitorLoadFailed: 'Valvontadatan haku epäonnistui.',
monitorCreated: 'Luotu',
monitorLastReady: 'Viimeisin Ready-muutos',
tableEmail: 'Sähköposti',
tableRole: 'Rooli',
tableStatus: 'Tila',
@ -588,11 +638,36 @@ const svMessages: Record<keyof typeof baseMessages.en, string> = {
navNewListing: 'Ny annons',
navApprovals: 'Godkännanden',
navUsers: 'Användare',
navMonitoring: 'Övervakning',
navLogout: 'Logga ut',
navLogin: 'Logga in',
navSignup: 'Registrera dig',
navBrowse: 'Bläddra bland annonser',
navLanguage: 'Språk',
monitorTitle: 'Admin: övervakning',
monitorLead: 'Livesstatus för Hetzner-noder, Kubernetes-pods och PostgreSQL.',
monitorRefresh: 'Uppdatera nu',
monitorLastUpdated: 'Senast uppdaterad',
monitorNoData: 'Ingen data ännu.',
monitorHetznerTitle: 'Hetzner-noder',
monitorHetznerMissingToken: 'Sätt HCLOUD_TOKEN för att visa Hetzner-noder.',
monitorHetznerEmpty: 'Inga Hetzner-servrar hittades.',
monitorK8sTitle: 'Kubernetes',
monitorNodesTitle: 'Noder',
monitorPodsTitle: 'Pods',
monitorNoPods: 'Inga pods i lomavuokraus-namespaces.',
monitorRestarts: 'Omstarter',
monitorAge: 'Ålder',
monitorDbTitle: 'PostgreSQL',
monitorServerTime: 'Servertid',
monitorDbSize: 'Databasstorlek',
monitorDbRecovery: 'Recovery-läge',
monitorConnections: 'Anslutningar',
monitorHealthy: 'OK',
monitorAttention: 'Behöver åtgärd',
monitorLoadFailed: 'Kunde inte hämta övervakningsdata.',
monitorCreated: 'Skapad',
monitorLastReady: 'Senaste Ready-ändring',
slugHelp: 'Hitta på en kort och enkel länk; använd små bokstäver och bindestreck (t.ex. sjo-stuga).',
slugPreview: 'Länk till annonsen: {url}',
heroTitle: 'Hitta ditt nästa finska getaway',

305
lib/monitoring.ts Normal file
View file

@ -0,0 +1,305 @@
import fs from 'fs';
import https from 'https';
import { prisma } from './prisma';
type HetznerServerSummary = {
id: number;
name: string;
status: string;
type?: string;
datacenter?: string;
publicIp?: string;
privateIp?: string;
created?: string;
};
export type HetznerStatus = {
ok: boolean;
error?: string;
missingToken?: boolean;
servers?: HetznerServerSummary[];
};
type KubernetesClientConfig = {
server: string;
token?: string;
ca?: string;
insecureSkipTlsVerify?: boolean;
};
type KubernetesNodeSummary = {
name: string;
ready: boolean;
status: string;
roles: string[];
internalIp?: string;
kubeletVersion?: string;
osImage?: string;
lastReadyTransition?: string | null;
};
type KubernetesPodContainer = {
name: string;
ready: boolean;
restartCount: number;
state: string;
lastState?: string | null;
};
type KubernetesPodSummary = {
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: KubernetesPodContainer[];
};
export type KubernetesStatus = {
ok: boolean;
error?: string;
nodes?: KubernetesNodeSummary[];
pods?: KubernetesPodSummary[];
};
export type DbStatus = {
ok: boolean;
error?: string;
serverTime?: string;
recovery?: boolean;
databaseSizeBytes?: number;
connections?: { state: string; count: number }[];
};
function readFileSafe(path: string): string | null {
try {
return fs.readFileSync(path, 'utf8');
} catch {
return null;
}
}
export async function fetchHetznerServers(): Promise<HetznerStatus> {
const token = process.env.HCLOUD_TOKEN ?? process.env.HETZNER_TOKEN;
if (!token) {
return { ok: false, missingToken: true, error: 'HCLOUD_TOKEN not configured' };
}
try {
const res = await fetch('https://api.hetzner.cloud/v1/servers', {
headers: { Authorization: `Bearer ${token}` },
signal: AbortSignal.timeout(8000),
});
if (!res.ok) {
const body = await res.text();
return { ok: false, error: `Hetzner API ${res.status}: ${body.slice(0, 200)}` };
}
const json = (await res.json()) as { servers?: any[] };
const servers =
json.servers?.map((s) => ({
id: s.id,
name: s.name,
status: s.status,
type: s.server_type?.name,
datacenter: s.datacenter?.name ?? s.datacenter?.description ?? s.datacenter?.location?.name,
publicIp: s.public_net?.ipv4?.ip,
privateIp: s.private_net?.[0]?.ip,
created: s.created,
})) ?? [];
return { ok: true, servers };
} catch (error: any) {
return { ok: false, error: error?.message ?? 'Hetzner API request failed' };
}
}
function loadKubernetesConfig(): KubernetesClientConfig | null {
const token = readFileSafe('/var/run/secrets/kubernetes.io/serviceaccount/token');
const ca = readFileSafe('/var/run/secrets/kubernetes.io/serviceaccount/ca.crt');
const serviceHost = process.env.KUBERNETES_SERVICE_HOST ?? 'kubernetes.default.svc';
const servicePort = process.env.KUBERNETES_SERVICE_PORT ?? '443';
if (token) {
return {
server: `https://${serviceHost}:${servicePort}`,
token: token.trim(),
ca: ca ?? undefined,
};
}
if (process.env.K8S_API_SERVER && process.env.K8S_BEARER_TOKEN) {
const caCert = process.env.K8S_CA_CERT;
return {
server: process.env.K8S_API_SERVER.replace(/\/$/, ''),
token: process.env.K8S_BEARER_TOKEN,
ca: caCert,
insecureSkipTlsVerify: process.env.K8S_INSECURE_SKIP_TLS === 'true',
};
}
return null;
}
function k8sRequest(path: string, cfg: KubernetesClientConfig): Promise<any> {
const url = `${cfg.server}${path}`;
const headers: Record<string, string> = { Accept: 'application/json' };
if (cfg.token) headers.Authorization = `Bearer ${cfg.token}`;
return new Promise((resolve, reject) => {
const req = https.request(
url,
{
method: 'GET',
headers,
ca: cfg.ca,
rejectUnauthorized: cfg.insecureSkipTlsVerify ? false : true,
timeout: 7000,
},
(res) => {
const chunks: Buffer[] = [];
res.on('data', (d) => chunks.push(typeof d === 'string' ? Buffer.from(d) : d));
res.on('end', () => {
const body = Buffer.concat(chunks).toString('utf8');
if (res.statusCode && res.statusCode >= 400) {
reject(new Error(`Kubernetes ${res.statusCode}: ${body.slice(0, 200)}`));
return;
}
try {
resolve(body ? JSON.parse(body) : {});
} catch (err) {
reject(err);
}
});
},
);
req.on('error', reject);
req.on('timeout', () => req.destroy(new Error('Kubernetes request timed out')));
req.end();
});
}
function parseK8sNodes(data: any): KubernetesNodeSummary[] {
const items: any[] = data?.items ?? [];
return items.map((node) => {
const labels = node?.metadata?.labels ?? {};
const roles = Object.keys(labels)
.filter((key) => key.startsWith('node-role.kubernetes.io/'))
.map((key) => key.replace('node-role.kubernetes.io/', '') || 'control-plane');
const readyCondition = (node?.status?.conditions ?? []).find((c: any) => c.type === 'Ready');
const addresses = node?.status?.addresses ?? [];
const internal = addresses.find((a: any) => a.type === 'InternalIP')?.address;
return {
name: node?.metadata?.name ?? 'unknown',
ready: readyCondition?.status === 'True',
status: readyCondition?.status === 'True' ? 'Ready' : readyCondition?.reason ?? 'NotReady',
roles,
internalIp: internal,
kubeletVersion: node?.status?.nodeInfo?.kubeletVersion,
osImage: node?.status?.nodeInfo?.osImage,
lastReadyTransition: readyCondition?.lastTransitionTime ?? null,
};
});
}
function parseContainerState(status: any): string {
if (!status) return 'unknown';
if (status.state?.running) return 'running';
if (status.state?.waiting?.reason) return status.state.waiting.reason;
if (status.state?.terminated?.reason) return status.state.terminated.reason;
if (status.state?.waiting) return 'waiting';
if (status.state?.terminated) return 'terminated';
return 'unknown';
}
function parseLastState(status: any): string | null {
if (!status?.lastState) return null;
const { lastState } = status;
if (lastState.terminated?.reason) return `terminated: ${lastState.terminated.reason}`;
if (lastState.terminated?.exitCode !== undefined) return `terminated: code ${lastState.terminated.exitCode}`;
if (lastState.waiting?.reason) return `waiting: ${lastState.waiting.reason}`;
return 'previous state recorded';
}
function parseK8sPods(data: any): KubernetesPodSummary[] {
const items: any[] = data?.items ?? [];
return items
.filter((pod) => {
const ns = pod?.metadata?.namespace ?? '';
return ns.startsWith('lomavuokraus-') || ns === 'default';
})
.map((pod) => {
const containers: KubernetesPodContainer[] = (pod?.status?.containerStatuses ?? []).map((c: any) => ({
name: c.name,
ready: Boolean(c.ready),
restartCount: Number(c.restartCount ?? 0),
state: parseContainerState(c),
lastState: parseLastState(c),
}));
const readyCount = containers.filter((c) => c.ready).length;
const restarts = containers.reduce((sum, c) => sum + c.restartCount, 0);
return {
name: pod?.metadata?.name ?? 'pod',
namespace: pod?.metadata?.namespace ?? 'unknown',
phase: pod?.status?.phase ?? 'Unknown',
reason: pod?.status?.reason ?? null,
message: pod?.status?.message ?? null,
readyCount,
totalContainers: containers.length,
restarts,
nodeName: pod?.spec?.nodeName,
hostIP: pod?.status?.hostIP ?? null,
podIP: pod?.status?.podIP ?? null,
startedAt: pod?.status?.startTime ?? null,
containers,
};
});
}
export async function fetchKubernetesStatus(): Promise<KubernetesStatus> {
const config = loadKubernetesConfig();
if (!config) {
return { ok: false, error: 'Kubernetes config not found (service account or env K8S_API_SERVER/K8S_BEARER_TOKEN required)' };
}
try {
const [nodes, pods] = await Promise.all([k8sRequest('/api/v1/nodes', config), k8sRequest('/api/v1/pods', config)]);
return { ok: true, nodes: parseK8sNodes(nodes), pods: parseK8sPods(pods) };
} catch (error: any) {
return { ok: false, error: error?.message ?? 'Kubernetes query failed' };
}
}
export async function fetchDbStatus(): Promise<DbStatus> {
try {
const [summary, activity] = await Promise.all([
prisma.$queryRaw<{ server_time: Date; recovery: boolean; size_bytes: bigint | number }[]>`
SELECT now() as server_time, pg_is_in_recovery() as recovery, pg_database_size(current_database()) as size_bytes
`,
prisma.$queryRaw<{ state: string | null; count: number }[]>`
SELECT COALESCE(state, 'unknown') as state, count(*)::int as count FROM pg_stat_activity GROUP BY state
`,
]);
const row = summary?.[0];
const serverTime = row?.server_time instanceof Date ? row.server_time.toISOString() : row?.server_time ? String(row.server_time) : undefined;
const sizeVal = row?.size_bytes ?? 0;
const sizeNumber = typeof sizeVal === 'bigint' ? Number(sizeVal) : Number(sizeVal);
const connections = activity?.map((a) => ({ state: a.state ?? 'unknown', count: Number(a.count ?? 0) })) ?? [];
return {
ok: true,
serverTime,
recovery: Boolean(row?.recovery),
databaseSizeBytes: Number.isFinite(sizeNumber) ? sizeNumber : undefined,
connections,
};
} catch (error: any) {
return { ok: false, error: error?.message ?? 'Database query failed' };
}
}

View file

@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { getAuthFromRequest } from './lib/jwt';
const ADMIN_ONLY_PATHS = ['/admin/users'];
const ADMIN_ONLY_PATHS = ['/admin/users', '/admin/monitor'];
const MODERATOR_PATHS = ['/admin/pending'];
function buildLoginRedirect(req: NextRequest) {