From 79ccef099dfe9590561e31272e828ede151680ed Mon Sep 17 00:00:00 2001 From: fds2610-bot Date: Tue, 4 Aug 2026 13:18:05 +0200 Subject: [PATCH] Hooks-KPI im KPI-Monitor ergaenzen (Issue #38) Die im Issue beschriebene Event-Anzeige (neuestes Event oben, aeltere schieben nach, Ueberschuss wird abgeschnitten) ist bereits seit Phase 4 umgesetzt (buildEventLines()/MAX_EVENT_LINES, F5) und braucht keine Aenderung. Neu ist die vierte, immer sichtbare KPI-Kachel ganz rechts im KPI-Monitor: "Hooks: n" zaehlt die seit dem letzten Start des api-Containers empfangenen Webhooks. Der Zaehler ist dateibasiert (Metrics\HookCounter), weil der eingebaute PHP-Server jede Anfrage als eigene Skriptausfuehrung behandelt; docker/entrypoint.sh setzt die Datei beim Start des api-Containers (nicht des sync-Containers) zurueck. Neuer oeffentlicher Endpunkt GET /api/v1/webhooks/stats liefert den Stand, das Frontend haengt ihn als eigene Kachel an (buildHooksKpi()) und reserviert ihr dafuer eine feste Kachel statt sie der MAX_KPI_TILES- Kappung der uebrigen KPIs zu unterwerfen. Der Wert fliesst zusaetzlich in den SSE-Fingerabdruck (ChangeFeed) ein. Co-Authored-By: Claude Sonnet 5 --- API.md | 21 ++++++ Visualization.md | 7 +- api/docker/entrypoint.sh | 12 ++++ api/src/Application.php | 10 ++- api/src/Config.php | 10 +++ api/src/Controller/WebhookController.php | 13 +++- api/src/Metrics/HookCounter.php | 65 ++++++++++++++++++ api/src/Realtime/ChangeFeed.php | 7 +- api/tests/Unit/ApplicationTest.php | 41 +++++++++++ api/tests/Unit/ConfigTest.php | 3 + .../Unit/Controller/StreamControllerTest.php | 16 ++++- .../Unit/Controller/WebhookControllerTest.php | 53 ++++++++++++++- api/tests/Unit/Metrics/HookCounterTest.php | 68 +++++++++++++++++++ api/tests/Unit/Realtime/ChangeFeedTest.php | 31 ++++++++- docker-compose.yml | 3 + html/js/office-api.js | 10 +++ html/js/office-render.js | 20 ++++-- html/js/office-scene.js | 18 ++++- tests/frontend/office-scene.test.js | 12 ++++ 19 files changed, 401 insertions(+), 19 deletions(-) create mode 100644 api/src/Metrics/HookCounter.php create mode 100644 api/tests/Unit/Metrics/HookCounterTest.php diff --git a/API.md b/API.md index 5cb14b8..8afdec1 100644 --- a/API.md +++ b/API.md @@ -224,6 +224,12 @@ Response: `{ "systems": [ { "id", "name", "type", "status", "detail", "source", F7: Konkrete Kennzahlen sind noch offen ("kommt spaeter"). Phase 1 stellt nur die generische Speicher-/Abfragestruktur bereit. +Zusaetzlich zu hier gemeldeten Kennzahlen zeigt der KPI-Monitor im Frontend drei weitere, +nicht per `POST /api/v1/kpis/{key}` gemeldete Kacheln, die direkt aus anderen Endpunkten +abgeleitet sind: "Agenten"/"Online" aus `GET /api/v1/agents` (Issue #39) und "Hooks" (ganz +rechts) aus [`GET /api/v1/webhooks/stats`](#get-apiv1webhooksstats--empfangene-webhooks-zaehlen-hooks-kpi), +siehe Issue #38. + ### `POST /api/v1/kpis/{key}` — KPI-Datenpunkt melden Auth: `X-Api-Key`. @@ -373,6 +379,21 @@ durchlassen. Ob eine Zustellung geklappt hat, zeigt Codeberg direkt beim Webhook „Letzte Zustellungen" (inkl. Antwort-Body). Der periodische Sync laeuft unabhaengig weiter und ist das Sicherheitsnetz fuer verpasste Zustellungen (Container-Neustart, Stoerung). +### `GET /api/v1/webhooks/stats` — Empfangene Webhooks zaehlen (Hooks-KPI) + +Auth: keine (oeffentlich wie die anderen `GET`-Endpunkte). + +```json +{ "count": 12 } +``` + +`count` zaehlt jede angenommene Zustellung an `POST /api/v1/webhooks/codeberg` (auch +`ping`) **seit dem letzten Start des `api`-Containers** - nicht seit Projektbeginn: die +Zaehldatei wird bei jedem Start dieses Containers zurueckgesetzt (`docker/entrypoint.sh`, +`MONITOR_HOOKS_COUNTER_FILE`). Ein Neustart des `sync`-Containers alleine setzt den Zaehler +nicht zurueck, da nur der `api`-Container Webhooks empfaengt. Das Frontend zeigt den Wert als +eigene, immer sichtbare Kachel ganz rechts im KPI-Monitor (Issue #38). + ## Fehlerformat Alle Fehlerantworten haben die Form `{ "error": "‹Meldung›" }` mit passendem HTTP-Status: diff --git a/Visualization.md b/Visualization.md index b5f3e11..7599e7d 100644 --- a/Visualization.md +++ b/Visualization.md @@ -247,8 +247,11 @@ externe Datenbankserver (nur PHP/Python-Standardbibliothek) auskommen, um die `office-realtime.js`), KPI-Monitor zeichnet eine eigene SVG-Sparkline (F10, kein Chart-Lib) sobald KPIs gemeldet wurden. "Agenten" (registrierte Agenten) und "Online" (davon praesent, isAgentPresent()) sind als erste zwei KPI-Kacheln immer sichtbar, direkt aus der - Agentenliste abgeleitet statt gemeldet (Issue #39) - darueber hinaus ist F7 inhaltlich - weiterhin offen, ohne gemeldete KPIs zeigt die Wand nur diese beiden Kacheln. + Agentenliste abgeleitet statt gemeldet (Issue #39). Ganz rechts kommt als vierte, ebenfalls + immer sichtbare Kachel "Hooks" hinzu: die Anzahl der seit dem letzten Start des + api-Containers empfangenen Webhooks (`GET /api/v1/webhooks/stats`, `Metrics\HookCounter`, + Issue #38) - darueber hinaus ist F7 inhaltlich weiterhin offen, ohne gemeldete KPIs zeigt + die Wand nur diese drei Kacheln. 6. **Phase 5 – Agenten & Animation**: Zustandsmaschine, Laufwege, Tipp-/Lese-/Kaffee-Animation, Reaktion auf neue Ticket-/Event-/Systemstatus-Meldungen. ✅ erledigt: `resolveAgentVisualState` bildet die von der API gemeldeten Zustaende auf Ort/Pose ab, Agenten-Avatare sind eine diff --git a/api/docker/entrypoint.sh b/api/docker/entrypoint.sh index 27a6957..1c1f9a1 100755 --- a/api/docker/entrypoint.sh +++ b/api/docker/entrypoint.sh @@ -7,4 +7,16 @@ if [ ! -d /app/vendor ]; then composer install --no-interaction --no-progress --working-dir=/app fi +# Hooks-KPI (Issue #38): die Zaehldatei liegt im geteilten ./api-Volume und ueberlebt einen +# Container-Neustart daher von sich aus - "seit dem letzten Restart" muss also hier aktiv +# hergestellt werden. Nur beim Standard-Kommando des api-Containers (php -S ..., siehe +# Dockerfile CMD) zuruecksetzen: der sync-Container teilt sich zwar dasselbe Image/Entrypoint, +# startet aber mit einem anderen Kommando und empfaengt selbst keine Webhooks - ein eigener +# Neustart von ihm darf den Zaehler des api-Containers nicht faelschlich auf 0 setzen. +if [ "$1" = "php" ] && [ "$2" = "-S" ]; then + hooks_counter_file="${MONITOR_HOOKS_COUNTER_FILE:-/app/data/hooks-count}" + mkdir -p "$(dirname "$hooks_counter_file")" + echo 0 > "$hooks_counter_file" +fi + exec "$@" diff --git a/api/src/Application.php b/api/src/Application.php index 4be28bd..aa3645a 100644 --- a/api/src/Application.php +++ b/api/src/Application.php @@ -22,6 +22,7 @@ use Monitor\Api\Http\NotFoundException; use Monitor\Api\Http\Request; use Monitor\Api\Http\Response; use Monitor\Api\Http\Router; +use Monitor\Api\Metrics\HookCounter; use Monitor\Api\Realtime\ChangeFeed; use Monitor\Api\Repository\AgentRepository; use Monitor\Api\Repository\EventRepository; @@ -53,6 +54,7 @@ final class Application $systems = new SystemRepository($db); $kpis = new KpiRepository($db); $repoConfigs = new RepoConfigRepository($db); + $hooks = new HookCounter($config->hooksCounterPath()); $this->agentAuth = new AgentAuthenticator($agents); $this->adminAuth = new AdminAuthenticator($config->adminApiKey()); @@ -64,9 +66,9 @@ final class Application $systemController = new SystemController($systems, new LegacyStatusFileReader(), $config->statusFilePath()); $kpiController = new KpiController($kpis); $repoConfigController = new RepoConfigController($repoConfigs); - $webhookController = new WebhookController(new SyncTrigger($config->syncTriggerPath())); + $webhookController = new WebhookController(new SyncTrigger($config->syncTriggerPath()), $hooks); $streamController = new StreamController( - new ChangeFeed($tickets, $events, $systems, $kpis, $agents), + new ChangeFeed($tickets, $events, $systems, $kpis, $agents, $hooks), $config->sseMaxIterations(), $config->ssePollIntervalSeconds(), ); @@ -115,6 +117,10 @@ final class Application fn (Request $r, array $p) => $webhookController->codeberg($r), 'webhook' ); + + // Hooks-KPI (Issue #38): oeffentlich wie die anderen GET-Endpunkte, liefert nur die + // Anzahl seit dem letzten Start des api-Containers, keine Payload-Details. + $this->router->add('GET', '/api/v1/webhooks/stats', fn (Request $r, array $p) => $webhookController->stats($r)); } public function handle(Request $request): Response diff --git a/api/src/Config.php b/api/src/Config.php index 47d7390..c814fb0 100644 --- a/api/src/Config.php +++ b/api/src/Config.php @@ -108,4 +108,14 @@ final class Config { return (int) $this->get('MONITOR_SSE_POLL_INTERVAL', '2'); } + + /** + * Zaehldatei fuer den Hooks-KPI (Issue #38, Metrics\HookCounter). Liegt per Default wie + * syncTriggerPath() im Datenverzeichnis, wird aber ausschliesslich vom api-Container + * geschrieben/zurueckgesetzt (siehe docker/entrypoint.sh). + */ + public function hooksCounterPath(): string + { + return $this->get('MONITOR_HOOKS_COUNTER_FILE', dirname(__DIR__) . '/data/hooks-count'); + } } diff --git a/api/src/Controller/WebhookController.php b/api/src/Controller/WebhookController.php index b334c39..f67c500 100644 --- a/api/src/Controller/WebhookController.php +++ b/api/src/Controller/WebhookController.php @@ -6,6 +6,7 @@ namespace Monitor\Api\Controller; use Monitor\Api\Http\Request; use Monitor\Api\Http\Response; +use Monitor\Api\Metrics\HookCounter; use Monitor\Api\Sync\SyncTrigger; /** @@ -20,12 +21,16 @@ use Monitor\Api\Sync\SyncTrigger; */ final class WebhookController { - public function __construct(private readonly SyncTrigger $trigger) + public function __construct(private readonly SyncTrigger $trigger, private readonly HookCounter $hooks) { } public function codeberg(Request $request): Response { + // Zaehlt jede angenommene Zustellung (auch "ping") fuer den Hooks-KPI (Issue #38) - + // die Authentifizierung ist zu diesem Zeitpunkt bereits geprueft (Application::handle). + $this->hooks->increment(); + // "ping" schickt Codeberg beim Anlegen des Webhooks zum Testen der Zustellung. $event = $request->header('x-github-event') ?? $request->header('x-gitea-event'); if ($event === 'ping') { @@ -38,4 +43,10 @@ final class WebhookController // sync-Container. Codeberg braucht nur zu wissen, dass die Zustellung geklappt hat. return Response::json(['status' => 'sync angefordert'], 202); } + + /** Liefert den Stand des Hooks-KPI (Issue #38): oeffentlich wie die anderen GET-Endpunkte. */ + public function stats(Request $request): Response + { + return Response::json(['count' => $this->hooks->count()]); + } } diff --git a/api/src/Metrics/HookCounter.php b/api/src/Metrics/HookCounter.php new file mode 100644 index 0000000..b51ab69 --- /dev/null +++ b/api/src/Metrics/HookCounter.php @@ -0,0 +1,65 @@ +path); + if (!is_dir($directory)) { + mkdir($directory, 0o775, true); + } + + $handle = fopen($this->path, 'c+'); + if ($handle === false) { + return $this->count(); + } + + flock($handle, LOCK_EX); + $current = (int) trim((string) stream_get_contents($handle)); + $next = $current + 1; + ftruncate($handle, 0); + rewind($handle); + fwrite($handle, (string) $next); + fflush($handle); + flock($handle, LOCK_UN); + fclose($handle); + + return $next; + } + + public function count(): int + { + if (!file_exists($this->path)) { + return 0; + } + + $contents = @file_get_contents($this->path); + + return $contents === false ? 0 : (int) trim($contents); + } + + /** Fingerabdruck fuer den SSE-Endpoint (Realtime\ChangeFeed, F13). */ + public function fingerprint(): string + { + return (string) $this->count(); + } +} diff --git a/api/src/Realtime/ChangeFeed.php b/api/src/Realtime/ChangeFeed.php index 0ef934b..2dcfcf2 100644 --- a/api/src/Realtime/ChangeFeed.php +++ b/api/src/Realtime/ChangeFeed.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Monitor\Api\Realtime; +use Monitor\Api\Metrics\HookCounter; use Monitor\Api\Repository\AgentRepository; use Monitor\Api\Repository\EventRepository; use Monitor\Api\Repository\KpiRepository; @@ -11,8 +12,8 @@ use Monitor\Api\Repository\SystemRepository; use Monitor\Api\Repository\TicketRepository; /** - * Erkennt, ob sich seit dem letzten Blick etwas an Tickets/Events/Systemen/KPIs/Agenten - * veraendert hat - Grundlage fuer den SSE-Push-Endpoint (F13, Phase 4: + * Erkennt, ob sich seit dem letzten Blick etwas an Tickets/Events/Systemen/KPIs/Agenten/ + * empfangenen Webhooks veraendert hat - Grundlage fuer den SSE-Push-Endpoint (F13, Phase 4: * "Frontend via PHP Push aktualisieren (Server-Sent)"). Bewusst als billiger "Fingerabdruck" * (COUNT + juengster Zeitstempel je Ressource, siehe Repository::fingerprint()) statt echtem * Event-Log umgesetzt - reicht aus, um dem Frontend zu sagen "Ressource X hat sich geaendert, @@ -26,6 +27,7 @@ final class ChangeFeed private readonly SystemRepository $systems, private readonly KpiRepository $kpis, private readonly AgentRepository $agents, + private readonly HookCounter $hooks, ) { } @@ -38,6 +40,7 @@ final class ChangeFeed 'systems' => $this->systems->fingerprint(), 'kpis' => $this->kpis->fingerprint(), 'agents' => $this->agents->fingerprint(), + 'hooks' => $this->hooks->fingerprint(), ]; } diff --git a/api/tests/Unit/ApplicationTest.php b/api/tests/Unit/ApplicationTest.php index 5b8c046..fd44337 100644 --- a/api/tests/Unit/ApplicationTest.php +++ b/api/tests/Unit/ApplicationTest.php @@ -224,11 +224,13 @@ final class ApplicationTest extends TestCase { $payload = '{"action":"opened","issue":{"number":1}}'; $triggerPath = sys_get_temp_dir() . '/monitor-app-webhook-' . uniqid() . '/sync-requested'; + $hooksPath = sys_get_temp_dir() . '/monitor-app-webhook-' . uniqid() . '/hooks-count'; $app = new Application(Database::inMemory(), new Config([ 'MONITOR_STATUS_FILE' => '/does/not/exist', 'CODEBERG_WEBHOOK_SECRET' => 'webhook-secret', 'MONITOR_SYNC_TRIGGER_FILE' => $triggerPath, + 'MONITOR_HOOKS_COUNTER_FILE' => $hooksPath, ])); $response = $app->handle(Request::create( @@ -243,5 +245,44 @@ final class ApplicationTest extends TestCase unlink($triggerPath); rmdir(dirname($triggerPath)); + unlink($hooksPath); + rmdir(dirname($hooksPath)); + } + + /** + * Hooks-KPI (Issue #38): oeffentlich wie die anderen GET-Endpunkte, zaehlt akzeptierte + * Webhook-Zustellungen unabhaengig vom Event-Typ. + */ + public function testWebhookStatsRouteIsPublicAndCountsAcceptedWebhooks(): void + { + $payload = '{"action":"opened","issue":{"number":1}}'; + $triggerPath = sys_get_temp_dir() . '/monitor-app-webhook-stats-' . uniqid() . '/sync-requested'; + $hooksPath = sys_get_temp_dir() . '/monitor-app-webhook-stats-' . uniqid() . '/hooks-count'; + + $app = new Application(Database::inMemory(), new Config([ + 'MONITOR_STATUS_FILE' => '/does/not/exist', + 'CODEBERG_WEBHOOK_SECRET' => 'webhook-secret', + 'MONITOR_SYNC_TRIGGER_FILE' => $triggerPath, + 'MONITOR_HOOKS_COUNTER_FILE' => $hooksPath, + ])); + + $emptyStatsResponse = $app->handle(Request::create('GET', '/api/v1/webhooks/stats')); + self::assertSame(200, $emptyStatsResponse->status); + self::assertSame(0, $emptyStatsResponse->body()['count']); + + $app->handle(Request::create( + 'POST', + '/api/v1/webhooks/codeberg', + headers: ['X-Forgejo-Signature' => hash_hmac('sha256', $payload, 'webhook-secret')], + rawBody: $payload + )); + + $statsResponse = $app->handle(Request::create('GET', '/api/v1/webhooks/stats')); + self::assertSame(1, $statsResponse->body()['count']); + + unlink($triggerPath); + rmdir(dirname($triggerPath)); + unlink($hooksPath); + rmdir(dirname($hooksPath)); } } diff --git a/api/tests/Unit/ConfigTest.php b/api/tests/Unit/ConfigTest.php index 6941951..b9c1d5b 100644 --- a/api/tests/Unit/ConfigTest.php +++ b/api/tests/Unit/ConfigTest.php @@ -19,6 +19,7 @@ final class ConfigTest extends TestCase self::assertSame(8, $config->deskCount()); self::assertSame(30, $config->sseMaxIterations()); self::assertSame(2, $config->ssePollIntervalSeconds()); + self::assertStringEndsWith('/data/hooks-count', $config->hooksCounterPath()); } /** @@ -46,6 +47,7 @@ final class ConfigTest extends TestCase 'MONITOR_DESK_COUNT' => '3', 'MONITOR_SSE_MAX_ITERATIONS' => '5', 'MONITOR_SSE_POLL_INTERVAL' => '1', + 'MONITOR_HOOKS_COUNTER_FILE' => '/tmp/hooks-count', ]); self::assertSame('https://example.org/api/v1', $config->codebergApiBase()); @@ -54,5 +56,6 @@ final class ConfigTest extends TestCase self::assertSame(3, $config->deskCount()); self::assertSame(5, $config->sseMaxIterations()); self::assertSame(1, $config->ssePollIntervalSeconds()); + self::assertSame('/tmp/hooks-count', $config->hooksCounterPath()); } } diff --git a/api/tests/Unit/Controller/StreamControllerTest.php b/api/tests/Unit/Controller/StreamControllerTest.php index 1d2d602..9db0c73 100644 --- a/api/tests/Unit/Controller/StreamControllerTest.php +++ b/api/tests/Unit/Controller/StreamControllerTest.php @@ -7,6 +7,7 @@ namespace Monitor\Api\Tests\Controller; use Monitor\Api\Controller\StreamController; use Monitor\Api\Db\Database; use Monitor\Api\Http\Request; +use Monitor\Api\Metrics\HookCounter; use Monitor\Api\Realtime\ChangeFeed; use Monitor\Api\Repository\AgentRepository; use Monitor\Api\Repository\EventRepository; @@ -19,20 +20,33 @@ final class StreamControllerTest extends TestCase { private TicketRepository $tickets; private ChangeFeed $changeFeed; + private string $hooksPath; protected function setUp(): void { $db = Database::inMemory(); $this->tickets = new TicketRepository($db); + $this->hooksPath = sys_get_temp_dir() . '/monitor-stream-hooks-' . uniqid() . '/hooks-count'; $this->changeFeed = new ChangeFeed( $this->tickets, new EventRepository($db), new SystemRepository($db), new KpiRepository($db), - new AgentRepository($db) + new AgentRepository($db), + new HookCounter($this->hooksPath) ); } + protected function tearDown(): void + { + if (file_exists($this->hooksPath)) { + unlink($this->hooksPath); + } + if (is_dir(dirname($this->hooksPath))) { + rmdir(dirname($this->hooksPath)); + } + } + public function testStreamSendsHeartbeatsWhenNothingChanges(): void { $controller = new StreamController( diff --git a/api/tests/Unit/Controller/WebhookControllerTest.php b/api/tests/Unit/Controller/WebhookControllerTest.php index eaa6b6f..3dc4268 100644 --- a/api/tests/Unit/Controller/WebhookControllerTest.php +++ b/api/tests/Unit/Controller/WebhookControllerTest.php @@ -6,18 +6,23 @@ namespace Monitor\Api\Tests\Controller; use Monitor\Api\Controller\WebhookController; use Monitor\Api\Http\Request; +use Monitor\Api\Metrics\HookCounter; use Monitor\Api\Sync\SyncTrigger; use PHPUnit\Framework\TestCase; final class WebhookControllerTest extends TestCase { private string $path; + private string $hooksPath; private SyncTrigger $trigger; + private HookCounter $hooks; protected function setUp(): void { $this->path = sys_get_temp_dir() . '/monitor-webhook-' . uniqid() . '/sync-requested'; + $this->hooksPath = sys_get_temp_dir() . '/monitor-webhook-hooks-' . uniqid() . '/hooks-count'; $this->trigger = new SyncTrigger($this->path); + $this->hooks = new HookCounter($this->hooksPath); } protected function tearDown(): void @@ -28,11 +33,17 @@ final class WebhookControllerTest extends TestCase if (is_dir(dirname($this->path))) { rmdir(dirname($this->path)); } + if (file_exists($this->hooksPath)) { + unlink($this->hooksPath); + } + if (is_dir(dirname($this->hooksPath))) { + rmdir(dirname($this->hooksPath)); + } } public function testIssueEventRequestsASync(): void { - $controller = new WebhookController($this->trigger); + $controller = new WebhookController($this->trigger, $this->hooks); $response = $controller->codeberg($this->request('issues')); @@ -46,7 +57,7 @@ final class WebhookControllerTest extends TestCase */ public function testPingIsAnsweredWithoutRequestingASync(): void { - $controller = new WebhookController($this->trigger); + $controller = new WebhookController($this->trigger, $this->hooks); $response = $controller->codeberg($this->request('ping')); @@ -57,7 +68,7 @@ final class WebhookControllerTest extends TestCase /** Unbekannte Events loesen einen Sync aus - lieber einmal zu viel als einen verpassten. */ public function testUnknownEventStillRequestsASync(): void { - $controller = new WebhookController($this->trigger); + $controller = new WebhookController($this->trigger, $this->hooks); $response = $controller->codeberg($this->request('issue_label')); @@ -65,6 +76,42 @@ final class WebhookControllerTest extends TestCase self::assertTrue($this->trigger->consume()); } + /** + * Hooks-KPI (Issue #38): jede angenommene Zustellung zaehlt, auch "ping" - der Endpunkt + * meldet nur, dass etwas empfangen wurde, nicht ob es fachlich relevant war. + */ + public function testCodebergIncrementsHookCounterRegardlessOfEventType(): void + { + $controller = new WebhookController($this->trigger, $this->hooks); + + $controller->codeberg($this->request('issues')); + $controller->codeberg($this->request('ping')); + $controller->codeberg($this->request('issue_label')); + + self::assertSame(3, $this->hooks->count()); + } + + public function testStatsReturnsCurrentHookCount(): void + { + $controller = new WebhookController($this->trigger, $this->hooks); + $controller->codeberg($this->request('issues')); + $controller->codeberg($this->request('issues')); + + $response = $controller->stats(Request::create('GET', '/api/v1/webhooks/stats')); + + self::assertSame(200, $response->status); + self::assertSame(2, $response->body()['count']); + } + + public function testStatsReturnsZeroWithoutAnyReceivedWebhook(): void + { + $controller = new WebhookController($this->trigger, $this->hooks); + + $response = $controller->stats(Request::create('GET', '/api/v1/webhooks/stats')); + + self::assertSame(0, $response->body()['count']); + } + private function request(string $event): Request { return Request::create( diff --git a/api/tests/Unit/Metrics/HookCounterTest.php b/api/tests/Unit/Metrics/HookCounterTest.php new file mode 100644 index 0000000..afb6938 --- /dev/null +++ b/api/tests/Unit/Metrics/HookCounterTest.php @@ -0,0 +1,68 @@ +path = sys_get_temp_dir() . '/monitor-hook-counter-' . uniqid() . '/hooks-count'; + } + + protected function tearDown(): void + { + if (file_exists($this->path)) { + unlink($this->path); + } + if (is_dir(dirname($this->path))) { + rmdir(dirname($this->path)); + } + } + + public function testCountIsZeroWithoutAnyIncrement(): void + { + self::assertSame(0, (new HookCounter($this->path))->count()); + } + + public function testIncrementReturnsTheNewCountAndPersistsIt(): void + { + $counter = new HookCounter($this->path); + + self::assertSame(1, $counter->increment()); + self::assertSame(2, $counter->increment()); + self::assertSame(3, $counter->increment()); + self::assertSame(3, $counter->count()); + } + + /** Ein zweites Objekt auf demselben Pfad muss den bereits geschriebenen Stand sehen. */ + public function testCountIsSharedAcrossInstancesOnTheSamePath(): void + { + (new HookCounter($this->path))->increment(); + (new HookCounter($this->path))->increment(); + + self::assertSame(2, (new HookCounter($this->path))->count()); + } + + public function testIncrementCreatesMissingDirectory(): void + { + (new HookCounter($this->path))->increment(); + + self::assertFileExists($this->path); + } + + public function testFingerprintReflectsTheCurrentCount(): void + { + $counter = new HookCounter($this->path); + + self::assertSame('0', $counter->fingerprint()); + $counter->increment(); + self::assertSame('1', $counter->fingerprint()); + } +} diff --git a/api/tests/Unit/Realtime/ChangeFeedTest.php b/api/tests/Unit/Realtime/ChangeFeedTest.php index fe2ce94..70dcce2 100644 --- a/api/tests/Unit/Realtime/ChangeFeedTest.php +++ b/api/tests/Unit/Realtime/ChangeFeedTest.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Monitor\Api\Tests\Realtime; use Monitor\Api\Db\Database; +use Monitor\Api\Metrics\HookCounter; use Monitor\Api\Realtime\ChangeFeed; use Monitor\Api\Repository\AgentRepository; use Monitor\Api\Repository\EventRepository; @@ -18,6 +19,8 @@ final class ChangeFeedTest extends TestCase private ChangeFeed $changeFeed; private TicketRepository $tickets; private EventRepository $events; + private HookCounter $hooks; + private string $hooksPath; protected function setUp(): void { @@ -27,15 +30,27 @@ final class ChangeFeedTest extends TestCase $systems = new SystemRepository($db); $kpis = new KpiRepository($db); $agents = new AgentRepository($db); + $this->hooksPath = sys_get_temp_dir() . '/monitor-changefeed-hooks-' . uniqid() . '/hooks-count'; + $this->hooks = new HookCounter($this->hooksPath); - $this->changeFeed = new ChangeFeed($this->tickets, $this->events, $systems, $kpis, $agents); + $this->changeFeed = new ChangeFeed($this->tickets, $this->events, $systems, $kpis, $agents, $this->hooks); } - public function testCurrentFingerprintCoversAllFiveResources(): void + protected function tearDown(): void + { + if (file_exists($this->hooksPath)) { + unlink($this->hooksPath); + } + if (is_dir(dirname($this->hooksPath))) { + rmdir(dirname($this->hooksPath)); + } + } + + public function testCurrentFingerprintCoversAllSixResources(): void { $fingerprint = $this->changeFeed->currentFingerprint(); - self::assertSame(['tickets', 'events', 'systems', 'kpis', 'agents'], array_keys($fingerprint)); + self::assertSame(['tickets', 'events', 'systems', 'kpis', 'agents', 'hooks'], array_keys($fingerprint)); } public function testDiffIsEmptyWithoutChanges(): void @@ -65,4 +80,14 @@ final class ChangeFeedTest extends TestCase sort($changed); self::assertSame(['events', 'tickets'], $changed); } + + /** Ein empfangener Webhook (Issue #38) muss denselben SSE-Push ausloesen wie andere Ressourcen. */ + public function testDiffReportsAReceivedWebhook(): void + { + $before = $this->changeFeed->currentFingerprint(); + $this->hooks->increment(); + $after = $this->changeFeed->currentFingerprint(); + + self::assertSame(['hooks'], $this->changeFeed->diff($before, $after)); + } } diff --git a/docker-compose.yml b/docker-compose.yml index e2c5af4..392b424 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -33,6 +33,9 @@ services: # jede Anfrage mit 401 ab - der Endpunkt ist aus dem Internet erreichbar und die # Signatur ist sein einziger Schutz. CODEBERG_WEBHOOK_SECRET: ${CODEBERG_WEBHOOK_SECRET:-} + # Zaehldatei fuer den Hooks-KPI (Issue #38, GET /api/v1/webhooks/stats): wird bei jedem + # Start dieses Containers von entrypoint.sh auf 0 zurueckgesetzt. + MONITOR_HOOKS_COUNTER_FILE: /app/data/hooks-count # Haelt Backlog/Done aus Codeberg aktuell (F4, siehe API.md Abschnitt "Codeberg-Sync"): # laeuft dasselbe Image wie api und teilt sich ueber das ./api-Volume dieselbe SQLite-Datei. diff --git a/html/js/office-api.js b/html/js/office-api.js index efd6477..0542491 100644 --- a/html/js/office-api.js +++ b/html/js/office-api.js @@ -53,6 +53,16 @@ export async function fetchAgents() { return agents; } +/** + * Hooks-KPI (Issue #38): Anzahl der seit dem letzten Start des api-Containers empfangenen + * Webhooks, siehe API.md "GET /api/v1/webhooks/stats". Anders als die anderen fetch*()- + * Funktionen kein eigener Datenbereich der Buero-Szene, sondern nur die Datenquelle einer + * einzelnen KPI-Monitor-Kachel (buildHooksKpi() in office-scene.js). + */ +export async function fetchWebhookStats() { + return getJson("/webhooks/stats"); +} + /** * Baut die Stream-URL fuer den SSE-Push-Endpoint (F13, Phase 4). Wird von office-realtime.js * an EventSource() uebergeben. diff --git a/html/js/office-render.js b/html/js/office-render.js index f408c9c..a8d690f 100644 --- a/html/js/office-render.js +++ b/html/js/office-render.js @@ -25,6 +25,8 @@ import { buildKpiTiles, buildKpiModalContent, buildAgentCountKpis, + buildHooksKpi, + MAX_KPI_TILES, mapAgentsToDesks, normalizeAgentForDisplay, resolveAgentVisualState, @@ -56,6 +58,7 @@ import { fetchSystems, fetchKpis, fetchAgents, + fetchWebhookStats, fetchRepoConfigs, createRepoConfig, updateRepoConfig, @@ -1343,14 +1346,15 @@ async function withAdminKey(action) { // --------------------------------------------------------------------------------------- async function loadLiveData() { - const [tickets, events, systems, kpis, agents] = await Promise.all([ + const [tickets, events, systems, kpis, agents, webhookStats] = await Promise.all([ fetchTickets(), fetchEvents(), fetchSystems(), fetchKpis(), fetchAgents(), + fetchWebhookStats(), ]); - return { tickets, events, systems, kpis, agents, isLive: true }; + return { tickets, events, systems, kpis, agents, hooksCount: webhookStats.count, isLive: true }; } const animationQueue = new AnimationQueue({ minIntervalMs: 120 }); @@ -1372,6 +1376,7 @@ export function renderOfficeScene(container, data = {}) { events = DEMO_EVENTS, kpis = [], tickets = [], + hooksCount = null, isLive = false, } = data; @@ -1404,8 +1409,14 @@ export function renderOfficeScene(container, data = {}) { // "Agenten"/"Online" (Issue #39) stehen vor den gemeldeten KPIs (F7), damit sie auf der // Wandkachel (MAX_KPI_TILES) immer sichtbar sind, statt von gemeldeten Kennzahlen verdraengt - // zu werden. - const displayKpis = [...buildAgentCountKpis(agents), ...kpis]; + // zu werden. "Hooks" (Issue #38) reserviert sich zusaetzlich die letzte (rechte) Kachel - + // hooksCount ist nur im Live-Betrieb gesetzt (loadLiveData()), im Demo-Fallback bleibt die + // Kachel bewusst weg statt eine erfundene Zahl zu zeigen (wie DEMO_KPI_PLACEHOLDER es fuer + // gemeldete KPIs bereits vormacht). + const hooksKpi = typeof hooksCount === "number" ? buildHooksKpi(hooksCount) : null; + const baseSlots = hooksKpi ? MAX_KPI_TILES - 1 : MAX_KPI_TILES; + const baseKpis = [...buildAgentCountKpis(agents), ...kpis].slice(0, baseSlots); + const displayKpis = hooksKpi ? [...baseKpis, hooksKpi] : baseKpis; const groupedTickets = groupTicketsByColumn(tickets); @@ -1562,6 +1573,7 @@ async function boot() { systems: data.systems, events: data.events, kpis: data.kpis, + hooksCount: data.hooksCount, isLive: true, }); setApiBanner(false); diff --git a/html/js/office-scene.js b/html/js/office-scene.js index f769205..98565e7 100644 --- a/html/js/office-scene.js +++ b/html/js/office-scene.js @@ -325,7 +325,7 @@ export function buildKpiSparklinePoints(history = [], width = 140, height = 32, .join(" "); } -export const MAX_KPI_TILES = 3; +export const MAX_KPI_TILES = 4; /** * Bereitet bis zu MAX_KPI_TILES KPI-Kacheln fuer die Monitor-Wand auf. F7 ist weiterhin @@ -344,6 +344,22 @@ export function buildKpiTiles(kpis = []) { })); } +/** + * KPI-foermiger Eintrag fuer den Hooks-KPI (Issue #38): Anzahl der seit dem letzten Start des + * api-Containers empfangenen Webhooks (GET /api/v1/webhooks/stats). Anders als + * buildAgentCountKpis() nicht aus bereits ohnehin geladenen Daten ableitbar, sondern ein + * eigener serverseitiger Zaehler - daher eine eigene, injizierbare Funktion statt Teil davon. + * Soll laut Issue #38 immer als letzte (rechte) Kachel im KPI-Monitor erscheinen, siehe + * renderOfficeScene() in office-render.js, das dafuer eine Kachel reserviert statt sie der + * MAX_KPI_TILES-Kappung der uebrigen KPIs zu unterwerfen. + * + * @param {number} count + * @returns {object} + */ +export function buildHooksKpi(count) { + return { key: "hooks_received", label: "Hooks", unit: "", current_value: count, history: [] }; +} + /** * Inhaltsmodell fuer das KPI-Monitor-Modal (groessere Sparkline als die Wandkachel). * diff --git a/tests/frontend/office-scene.test.js b/tests/frontend/office-scene.test.js index 60014fd..6ab0849 100644 --- a/tests/frontend/office-scene.test.js +++ b/tests/frontend/office-scene.test.js @@ -21,6 +21,7 @@ import { buildKpiTiles, buildKpiModalContent, buildAgentCountKpis, + buildHooksKpi, MAX_KPI_TILES, AGENT_LOCATIONS, resolveAgentVisualState, @@ -355,6 +356,17 @@ test("buildAgentCountKpis liefert 0/0 ohne Agenten", () => { assert.equal(kpis[1].current_value, 0); }); +test("buildHooksKpi liefert einen KPI-foermigen Eintrag mit der uebergebenen Anzahl (Issue #38)", () => { + assert.deepEqual(buildHooksKpi(12), { + key: "hooks_received", + label: "Hooks", + unit: "", + current_value: 12, + history: [], + }); + assert.equal(buildHooksKpi(0).current_value, 0); +}); + // --------------------------------------------------------------------------------------- // Phase 5 - Agenten-Zustandsmaschine & Animation // ---------------------------------------------------------------------------------------