Adressdaten-Bereich, Mock hinter einem Profil, Webhook

Drei Stränge, die sich über dieselben Dateien ziehen (HomePage, index.css,
README, application.properties) und deshalb nicht getrennt committet werden
können, ohne einen nicht übersetzbaren Zwischenstand zu hinterlassen:

Adressdaten: Der Bereich zeigt den gesamten Bestand des globalen Telefon-
buchs - zuerst aus dem Adress-Cache der App (Protokoll 8), sonst über das
Kommando "addresses" und ersatzweise aus rund 110 Einzelabfragen hinter
einem Wartedialog. Der Mock gibt denselben Bestand heraus und lässt sich
über /api/mock/address-cache leeren, um die Rückfallebene zu prüfen.

Mock hinter dem Profil "mock": Seine Bohnen (/ws und /api/mock/**) hängen
jetzt an @Profile, sind ohne das Profil also nicht vorhanden. Damit kann
der Mock nicht versehentlich in einer Produktivumgebung mitlaufen; das
Container-Image setzt das Profil nicht. Je ein Test hält beide Richtungen
fest.

Webhook: POST /api/webhook nimmt beliebiges JSON eines fremden Systems an
und reicht es über Server-Sent Events an die offenen Browser weiter, wo es
der neue Bereich "Webhook" unverändert anzeigt. Der WebSocket kam dafür
nicht in Frage - er gehört der SwyxTray-App. Das Backend hält die letzten
50 Nachrichten vor und liefert sie beim Wiederverbinden anhand der
Last-Event-ID nach; ein Heartbeat und X-Accel-Buffering: no halten die
Verbindung durch Reverse Proxys hindurch offen. Ein Token (app.webhook.token)
ist vorgesehen, aber nicht voreingestellt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-24 15:11:55 +02:00
co-authored by Claude Opus 5
parent b2f608055a
commit 01594a1f3d
31 changed files with 2687 additions and 32 deletions
+92
View File
@@ -0,0 +1,92 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import {
clearHistory,
fetchHistory,
isWebhookEvent,
mergeEvent,
mergeEvents,
type WebhookEvent,
} from '../webhook'
export type WebhookStatus = 'connecting' | 'open' | 'closed'
export interface WebhookState {
events: WebhookEvent[]
status: WebhookStatus
error: string | null
clear: () => Promise<void>
}
/**
* Hört auf die Nachrichten des Webhooks.
*
* Der Strom läuft über eine `EventSource` auf `/api/webhook/events` und wird
* **unabhängig vom geöffneten Bereich** gehalten: So ist schon alles da, wenn
* der Bereich „Webhook" aufgeschlagen wird. Bricht die Verbindung ab, verbindet
* der Browser von selbst neu und schickt dabei die `Last-Event-ID` mit das
* Backend liefert dann nach, was in der Lücke ankam.
*
* Zusätzlich wird beim Start der Verlauf geholt, damit auch Nachrichten von vor
* dem Laden der Seite erscheinen.
*/
export function useWebhook(): WebhookState {
const [events, setEvents] = useState<WebhookEvent[]>([])
const [status, setStatus] = useState<WebhookStatus>('connecting')
const [error, setError] = useState<string | null>(null)
const sourceRef = useRef<EventSource | null>(null)
useEffect(() => {
const controller = new AbortController()
fetchHistory(controller.signal)
.then((history) => {
if (controller.signal.aborted) return
setEvents((current) => mergeEvents(current, history))
})
.catch((e: unknown) => {
if (controller.signal.aborted) return
setError(e instanceof Error ? e.message : String(e))
})
const source = new EventSource('/api/webhook/events')
sourceRef.current = source
source.onopen = () => {
setStatus('open')
setError(null)
}
source.addEventListener('webhook', (event) => {
try {
const parsed: unknown = JSON.parse((event as MessageEvent<string>).data)
if (isWebhookEvent(parsed)) setEvents((current) => mergeEvent(current, parsed))
} catch {
// Unlesbare Nachricht: lieber übergehen als die Anzeige abstürzen lassen.
}
})
source.onerror = () => {
// Der Browser verbindet von selbst neu; nur der endgültige Abbruch ist final.
setStatus(source.readyState === EventSource.CLOSED ? 'closed' : 'connecting')
}
return () => {
controller.abort()
source.close()
sourceRef.current = null
}
}, [])
const clear = useCallback(async () => {
try {
await clearHistory()
setEvents([])
setError(null)
} catch (e: unknown) {
setError(e instanceof Error ? e.message : String(e))
}
}, [])
return { events, status, error, clear }
}