Add admin monitoring dashboard
This commit is contained in:
parent
1fa0b4d300
commit
9ba806c8c5
7 changed files with 730 additions and 1 deletions
|
|
@ -79,3 +79,4 @@
|
||||||
- Listings now capture separate weekday/weekend prices and new amenities (microwave, free parking) across schema, API, UI, and seeds.
|
- 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.
|
- 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`).
|
- 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
311
app/admin/monitor/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
app/api/admin/monitor/route.ts
Normal file
24
app/api/admin/monitor/route.ts
Normal 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';
|
||||||
|
|
@ -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" />
|
<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>
|
</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:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
@ -178,6 +186,11 @@ export default function NavBar() {
|
||||||
<Icon name="users" /> {t('navUsers')}
|
<Icon name="users" /> {t('navUsers')}
|
||||||
</Link>
|
</Link>
|
||||||
) : null}
|
) : null}
|
||||||
|
{isAdmin ? (
|
||||||
|
<Link href="/admin/monitor" className="button secondary">
|
||||||
|
<Icon name="monitor" /> {t('navMonitoring')}
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
<button className="button secondary" onClick={logout}>
|
<button className="button secondary" onClick={logout}>
|
||||||
|
|
|
||||||
75
lib/i18n.ts
75
lib/i18n.ts
|
|
@ -8,6 +8,7 @@ const baseMessages = {
|
||||||
navNewListing: 'New listing',
|
navNewListing: 'New listing',
|
||||||
navApprovals: 'Approvals',
|
navApprovals: 'Approvals',
|
||||||
navUsers: 'Users',
|
navUsers: 'Users',
|
||||||
|
navMonitoring: 'Monitoring',
|
||||||
navLogout: 'Logout',
|
navLogout: 'Logout',
|
||||||
navLogin: 'Login',
|
navLogin: 'Login',
|
||||||
navSignup: 'Sign up',
|
navSignup: 'Sign up',
|
||||||
|
|
@ -90,6 +91,30 @@ const baseMessages = {
|
||||||
adminRequired: 'Admin access required',
|
adminRequired: 'Admin access required',
|
||||||
adminUsersTitle: 'Admin: users',
|
adminUsersTitle: 'Admin: users',
|
||||||
adminUsersLead: 'Manage user roles and approvals.',
|
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',
|
tableEmail: 'Email',
|
||||||
tableRole: 'Role',
|
tableRole: 'Role',
|
||||||
tableStatus: 'Status',
|
tableStatus: 'Status',
|
||||||
|
|
@ -297,6 +322,7 @@ const baseMessages = {
|
||||||
navNewListing: 'Luo kohde',
|
navNewListing: 'Luo kohde',
|
||||||
navApprovals: 'Tarkastettavat',
|
navApprovals: 'Tarkastettavat',
|
||||||
navUsers: 'Käyttäjät',
|
navUsers: 'Käyttäjät',
|
||||||
|
navMonitoring: 'Valvonta',
|
||||||
navLogout: 'Kirjaudu ulos',
|
navLogout: 'Kirjaudu ulos',
|
||||||
navLogin: 'Kirjaudu',
|
navLogin: 'Kirjaudu',
|
||||||
navSignup: 'Rekisteröidy',
|
navSignup: 'Rekisteröidy',
|
||||||
|
|
@ -406,6 +432,30 @@ const baseMessages = {
|
||||||
adminRequired: 'Ylläpitäjän oikeudet vaaditaan',
|
adminRequired: 'Ylläpitäjän oikeudet vaaditaan',
|
||||||
adminUsersTitle: 'Ylläpito: käyttäjät',
|
adminUsersTitle: 'Ylläpito: käyttäjät',
|
||||||
adminUsersLead: 'Hallinnoi rooleja ja hyväksyntöjä.',
|
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',
|
tableEmail: 'Sähköposti',
|
||||||
tableRole: 'Rooli',
|
tableRole: 'Rooli',
|
||||||
tableStatus: 'Tila',
|
tableStatus: 'Tila',
|
||||||
|
|
@ -588,11 +638,36 @@ const svMessages: Record<keyof typeof baseMessages.en, string> = {
|
||||||
navNewListing: 'Ny annons',
|
navNewListing: 'Ny annons',
|
||||||
navApprovals: 'Godkännanden',
|
navApprovals: 'Godkännanden',
|
||||||
navUsers: 'Användare',
|
navUsers: 'Användare',
|
||||||
|
navMonitoring: 'Övervakning',
|
||||||
navLogout: 'Logga ut',
|
navLogout: 'Logga ut',
|
||||||
navLogin: 'Logga in',
|
navLogin: 'Logga in',
|
||||||
navSignup: 'Registrera dig',
|
navSignup: 'Registrera dig',
|
||||||
navBrowse: 'Bläddra bland annonser',
|
navBrowse: 'Bläddra bland annonser',
|
||||||
navLanguage: 'Språk',
|
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).',
|
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}',
|
slugPreview: 'Länk till annonsen: {url}',
|
||||||
heroTitle: 'Hitta ditt nästa finska getaway',
|
heroTitle: 'Hitta ditt nästa finska getaway',
|
||||||
|
|
|
||||||
305
lib/monitoring.ts
Normal file
305
lib/monitoring.ts
Normal 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' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { getAuthFromRequest } from './lib/jwt';
|
import { getAuthFromRequest } from './lib/jwt';
|
||||||
|
|
||||||
const ADMIN_ONLY_PATHS = ['/admin/users'];
|
const ADMIN_ONLY_PATHS = ['/admin/users', '/admin/monitor'];
|
||||||
const MODERATOR_PATHS = ['/admin/pending'];
|
const MODERATOR_PATHS = ['/admin/pending'];
|
||||||
|
|
||||||
function buildLoginRedirect(req: NextRequest) {
|
function buildLoginRedirect(req: NextRequest) {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue