Design-Tokens aus docs/10-design-system.md als CSS-Variablen: Grundpalette, die sechs Klassifizierungsfarben, Eckenradius durchgehend 2 px, keine Schatten ausser dem Fokusring. Der Schwärzungsbalken mit dem einmaligen Durchwischen respektiert prefers-reduced-motion. Vier Tests halten die Tokens gegen das Dokument. IBM Plex selbst gehostet als woff2, latin und latin-ext getrennt über unicode-range, font-display: swap, immutable-Cache. Alle Oberflächentexte in lib/i18n/de.ts. Ein Test durchsucht app/ und components/ nach Umlauten im JSX und schlägt fehl, wenn dort deutscher Text steht. Er hat beim ersten Lauf gleich einen Verstoss gefunden. /api/health prüft Datenbank, Discord-Gateway und ClamAV. Die Gesamtbewertung fällt nur auf "down", wenn die Datenbank weg ist: die anderen Dienste haben eigene Healthchecks im Stack, und ein ausgefallener Virenscanner soll nicht den Web-Container als tot markieren. Strikte CSP mit Nonce, kein unsafe-inline für Skripte. Die erste Route liegt bei 100 kB JavaScript nach Kompression, Budget sind 120 kB.
78 lines
2.5 KiB
TypeScript
78 lines
2.5 KiB
TypeScript
import { db } from '@/lib/db'
|
|
import { getEnv } from '@/lib/env'
|
|
import { pingClamav } from '@/lib/health/clamav'
|
|
|
|
export type ComponentState = 'up' | 'degraded' | 'down'
|
|
|
|
export type HealthReport = {
|
|
status: ComponentState
|
|
checkedAt: string
|
|
components: Record<'database' | 'discordGateway' | 'clamav', {
|
|
status: ComponentState
|
|
detail?: string
|
|
}>
|
|
}
|
|
|
|
/** Der Worker gilt als abgehaengt, wenn sein Lebenszeichen aelter ist. */
|
|
const HEARTBEAT_MAX_AGE_MS = 3 * 60_000
|
|
|
|
async function checkDatabase(): Promise<{ status: ComponentState; detail?: string }> {
|
|
try {
|
|
await db.$queryRaw`SELECT 1`
|
|
return { status: 'up' }
|
|
} catch (error) {
|
|
return { status: 'down', detail: error instanceof Error ? error.message : 'unbekannt' }
|
|
}
|
|
}
|
|
|
|
async function checkDiscordGateway(): Promise<{ status: ComponentState; detail?: string }> {
|
|
try {
|
|
const beat = await db.serviceHeartbeat.findUnique({ where: { service: 'worker' } })
|
|
if (!beat) return { status: 'down', detail: 'kein Lebenszeichen' }
|
|
|
|
const ageMs = Date.now() - beat.at.getTime()
|
|
if (ageMs > HEARTBEAT_MAX_AGE_MS) {
|
|
return { status: 'down', detail: `Lebenszeichen ${Math.round(ageMs / 1000)} s alt` }
|
|
}
|
|
if (beat.status !== 'up') {
|
|
return { status: beat.status === 'degraded' ? 'degraded' : 'down', detail: beat.detail ?? undefined }
|
|
}
|
|
return { status: 'up' }
|
|
} catch {
|
|
return { status: 'down', detail: 'nicht abfragbar' }
|
|
}
|
|
}
|
|
|
|
async function checkClamav(): Promise<{ status: ComponentState; detail?: string }> {
|
|
const ok = await pingClamav(getEnv().CLAMAV_SOCKET)
|
|
return ok ? { status: 'up' } : { status: 'down', detail: 'clamd antwortet nicht' }
|
|
}
|
|
|
|
/**
|
|
* Sammelt den Zustand der drei Abhaengigkeiten. Die Gesamtbewertung faellt
|
|
* bewusst nur auf `down`, wenn die Datenbank weg ist: Worker, clamav und pdf
|
|
* haben eigene Healthchecks im Stack, und ein ausgefallener Virenscanner soll
|
|
* nicht den ganzen Web-Container als tot markieren. Sichtbar bleibt der Ausfall
|
|
* trotzdem, in dieser Antwort und in Dockge am jeweiligen Dienst.
|
|
*/
|
|
export async function collectHealth(): Promise<HealthReport> {
|
|
const [database, discordGateway, clamav] = await Promise.all([
|
|
checkDatabase(),
|
|
checkDiscordGateway(),
|
|
checkClamav(),
|
|
])
|
|
|
|
const status: ComponentState =
|
|
database.status === 'down'
|
|
? 'down'
|
|
: discordGateway.status === 'up' && clamav.status === 'up'
|
|
? 'up'
|
|
: 'degraded'
|
|
|
|
return {
|
|
status,
|
|
checkedAt: new Date().toISOString(),
|
|
components: { database, discordGateway, clamav },
|
|
}
|
|
}
|