Files
AfiS-Aktensystem/web/app/(app)/admin/system/page.tsx
T
Harlekin e0b03ea336
Prüfung / Lint, Typen, Tests (push) Canceled after 0s
Prüfung / Images bauen (pdf) (push) Canceled after 0s
Prüfung / Images bauen (web) (push) Canceled after 0s
Prüfung / Images bauen (worker) (push) Canceled after 0s
Images / bauen (pdf, docker/pdf.Dockerfile) (push) Canceled after 0s
Images / bauen (web, docker/web.Dockerfile) (push) Canceled after 0s
Images / bauen (worker, docker/worker.Dockerfile) (push) Canceled after 0s
Gedankenstriche durch Bindestriche ersetzt
375 Vorkommen in 210 Dateien. Betrifft Kommentare, Oberflaechentexte und die
README; an keiner Stelle Bedeutung, nur Zeichensetzung.
2026-09-04 08:54:52 +02:00

160 lines
5.5 KiB
TypeScript

import type { Metadata } from 'next'
import { Suspense } from 'react'
import { requirePermission } from '@/lib/auth/current'
import { collectHealth, type ComponentState } from '@/lib/health/check'
import { de } from '@/lib/i18n/de'
import { formatVital, summarizeVitals } from '@/lib/dashboard/vitals'
import { SkeletonLine } from '@/components/Skeleton'
export const metadata: Metadata = { title: de.health.heading }
export const dynamic = 'force-dynamic'
const STATE_LABEL: Record<ComponentState, string> = {
up: de.health.ok,
degraded: de.health.degraded,
down: de.health.down,
}
async function SystemState() {
const report = await collectHealth()
return (
<table className="w-full max-w-(--container-measure) border-collapse text-sm">
<caption className="pb-2 text-left font-condensed text-xs text-text-dim">
{de.health.checkedAt(new Date(report.checkedAt).toLocaleString('de-DE'))}
</caption>
<thead>
<tr className="border-b border-line text-left text-xs text-text-dim">
<th scope="col" className="py-1.5 pr-4 font-normal">
{de.health.service}
</th>
<th scope="col" className="py-1.5 font-normal">
{de.health.state}
</th>
</tr>
</thead>
<tbody>
{Object.entries(report.components).map(([key, value]) => (
<tr key={key} className="border-b border-line/60">
<td className="py-2 pr-4">{de.health.components[key] ?? key}</td>
<td className="py-2">
{/* Zustand als Text, nicht nur als Farbe. */}
<span className="font-condensed">{STATE_LABEL[value.status]}</span>
{value.detail ? (
<span className="ml-2 font-mono text-2xs text-text-dim">{value.detail}</span>
) : null}
</td>
</tr>
))}
</tbody>
</table>
)
}
export default async function SystemPage() {
await requirePermission('admin')
return (
<section>
<h2 className="font-condensed">{de.health.heading}</h2>
<Suspense
fallback={
<div className="mt-6">
<SkeletonLine width="14rem" />
<SkeletonLine />
<SkeletonLine />
<SkeletonLine />
</div>
}
>
<div className="mt-6">
<SystemState />
</div>
</Suspense>
<section className="mt-10">
<h2 className="font-condensed">{de.vitals.heading}</h2>
<p className="mt-2 max-w-(--container-measure) text-sm text-text-dim">
{de.vitals.intro}
</p>
<div className="mt-4">
<VitalsTable />
</div>
</section>
<section className="mt-10">
<h2 className="font-condensed">{de.export.heading}</h2>
<p className="mt-2 max-w-(--container-measure) text-sm text-text-dim">
{de.export.databaseHint}
</p>
<p className="mt-3 flex flex-wrap gap-4 font-condensed text-xs">
<a href="/api/admin/export?kind=database">{de.export.database}</a>
<a href="/api/admin/export?kind=bundle">{de.export.bundle}</a>
</p>
</section>
</section>
)
}
/**
* Die Ladewerte der letzten sieben Tage. Median und 75. Perzentil, kein
* Mittelwert - ein Ausreisser auf einem alten Laptop soll die Zahl nicht
* bestimmen.
*/
async function VitalsTable() {
const { metriken, routen } = await summarizeVitals()
if (metriken.length === 0) {
return <p className="text-text-dim">{de.vitals.empty}</p>
}
return (
<div className="space-y-8">
{/* Auf schmalen Schirmen scrollt die Tabelle in sich, statt die Seite
breiter zu machen. Siehe docs/09-performance-und-ux.md. */}
<div className="overflow-x-auto">
<table className="w-full min-w-[28rem] border-collapse text-xs">
<thead>
<tr className="border-b border-line text-left font-condensed text-text-dim">
<th className="py-1">{de.vitals.metric}</th>
<th className="py-1">{de.vitals.median}</th>
<th className="py-1">{de.vitals.p75}</th>
<th className="py-1">{de.vitals.count}</th>
<th className="py-1">{de.vitals.target}</th>
</tr>
</thead>
<tbody>
{metriken.map((zeile) => (
<tr key={zeile.metric} className="border-b border-line">
<td className="py-1 font-mono">{zeile.metric}</td>
<td className="py-1">{formatVital(zeile.metric, zeile.median)}</td>
<td className="py-1">{formatVital(zeile.metric, zeile.p75)}</td>
<td className="py-1 text-text-dim">{zeile.count}</td>
<td className="py-1 text-text-dim">{de.vitals.targets[zeile.metric] ?? ''}</td>
</tr>
))}
</tbody>
</table>
</div>
{routen.length > 0 ? (
<div>
<h3 className="font-condensed text-xs text-text-dim">{de.vitals.worstRoutes}</h3>
<div className="overflow-x-auto">
<table className="mt-2 w-full min-w-[24rem] border-collapse text-xs">
<tbody>
{routen.map((zeile) => (
<tr key={zeile.route} className="border-b border-line">
<td className="py-1 font-mono">{zeile.route}</td>
<td className="py-1">{formatVital(zeile.metric, zeile.p75)}</td>
<td className="py-1 text-text-dim">{zeile.count}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
) : null}
</div>
)
}