Merge pull request 'Hooks-KPI im KPI-Monitor ergaenzen' (#41) from fds2610-bot/monitor:main into main

Reviewed-on: https://codeberg.org/fds2610/monitor/pulls/41
This commit is contained in:
fds2610 2026-08-04 13:52:43 +02:00
commit 6ff5c16197
19 changed files with 401 additions and 19 deletions

21
API.md
View file

@ -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 F7: Konkrete Kennzahlen sind noch offen ("kommt spaeter"). Phase 1 stellt nur die
generische Speicher-/Abfragestruktur bereit. 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 ### `POST /api/v1/kpis/{key}` — KPI-Datenpunkt melden
Auth: `X-Api-Key`. 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 „Letzte Zustellungen" (inkl. Antwort-Body). Der periodische Sync laeuft unabhaengig weiter
und ist das Sicherheitsnetz fuer verpasste Zustellungen (Container-Neustart, Stoerung). 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 ## Fehlerformat
Alle Fehlerantworten haben die Form `{ "error": "‹Meldung›" }` mit passendem HTTP-Status: Alle Fehlerantworten haben die Form `{ "error": "‹Meldung›" }` mit passendem HTTP-Status:

View file

@ -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) `office-realtime.js`), KPI-Monitor zeichnet eine eigene SVG-Sparkline (F10, kein Chart-Lib)
sobald KPIs gemeldet wurden. "Agenten" (registrierte Agenten) und "Online" (davon sobald KPIs gemeldet wurden. "Agenten" (registrierte Agenten) und "Online" (davon
praesent, isAgentPresent()) sind als erste zwei KPI-Kacheln immer sichtbar, direkt aus der praesent, isAgentPresent()) sind als erste zwei KPI-Kacheln immer sichtbar, direkt aus der
Agentenliste abgeleitet statt gemeldet (Issue #39) - darueber hinaus ist F7 inhaltlich Agentenliste abgeleitet statt gemeldet (Issue #39). Ganz rechts kommt als vierte, ebenfalls
weiterhin offen, ohne gemeldete KPIs zeigt die Wand nur diese beiden Kacheln. 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, 6. **Phase 5 – Agenten & Animation**: Zustandsmaschine, Laufwege, Tipp-/Lese-/Kaffee-Animation,
Reaktion auf neue Ticket-/Event-/Systemstatus-Meldungen. ✅ erledigt: `resolveAgentVisualState` Reaktion auf neue Ticket-/Event-/Systemstatus-Meldungen. ✅ erledigt: `resolveAgentVisualState`
bildet die von der API gemeldeten Zustaende auf Ort/Pose ab, Agenten-Avatare sind eine bildet die von der API gemeldeten Zustaende auf Ort/Pose ab, Agenten-Avatare sind eine

View file

@ -7,4 +7,16 @@ if [ ! -d /app/vendor ]; then
composer install --no-interaction --no-progress --working-dir=/app composer install --no-interaction --no-progress --working-dir=/app
fi 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 "$@" exec "$@"

View file

@ -22,6 +22,7 @@ use Monitor\Api\Http\NotFoundException;
use Monitor\Api\Http\Request; use Monitor\Api\Http\Request;
use Monitor\Api\Http\Response; use Monitor\Api\Http\Response;
use Monitor\Api\Http\Router; use Monitor\Api\Http\Router;
use Monitor\Api\Metrics\HookCounter;
use Monitor\Api\Realtime\ChangeFeed; use Monitor\Api\Realtime\ChangeFeed;
use Monitor\Api\Repository\AgentRepository; use Monitor\Api\Repository\AgentRepository;
use Monitor\Api\Repository\EventRepository; use Monitor\Api\Repository\EventRepository;
@ -53,6 +54,7 @@ final class Application
$systems = new SystemRepository($db); $systems = new SystemRepository($db);
$kpis = new KpiRepository($db); $kpis = new KpiRepository($db);
$repoConfigs = new RepoConfigRepository($db); $repoConfigs = new RepoConfigRepository($db);
$hooks = new HookCounter($config->hooksCounterPath());
$this->agentAuth = new AgentAuthenticator($agents); $this->agentAuth = new AgentAuthenticator($agents);
$this->adminAuth = new AdminAuthenticator($config->adminApiKey()); $this->adminAuth = new AdminAuthenticator($config->adminApiKey());
@ -64,9 +66,9 @@ final class Application
$systemController = new SystemController($systems, new LegacyStatusFileReader(), $config->statusFilePath()); $systemController = new SystemController($systems, new LegacyStatusFileReader(), $config->statusFilePath());
$kpiController = new KpiController($kpis); $kpiController = new KpiController($kpis);
$repoConfigController = new RepoConfigController($repoConfigs); $repoConfigController = new RepoConfigController($repoConfigs);
$webhookController = new WebhookController(new SyncTrigger($config->syncTriggerPath())); $webhookController = new WebhookController(new SyncTrigger($config->syncTriggerPath()), $hooks);
$streamController = new StreamController( $streamController = new StreamController(
new ChangeFeed($tickets, $events, $systems, $kpis, $agents), new ChangeFeed($tickets, $events, $systems, $kpis, $agents, $hooks),
$config->sseMaxIterations(), $config->sseMaxIterations(),
$config->ssePollIntervalSeconds(), $config->ssePollIntervalSeconds(),
); );
@ -115,6 +117,10 @@ final class Application
fn (Request $r, array $p) => $webhookController->codeberg($r), fn (Request $r, array $p) => $webhookController->codeberg($r),
'webhook' '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 public function handle(Request $request): Response

View file

@ -108,4 +108,14 @@ final class Config
{ {
return (int) $this->get('MONITOR_SSE_POLL_INTERVAL', '2'); 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');
}
} }

View file

@ -6,6 +6,7 @@ namespace Monitor\Api\Controller;
use Monitor\Api\Http\Request; use Monitor\Api\Http\Request;
use Monitor\Api\Http\Response; use Monitor\Api\Http\Response;
use Monitor\Api\Metrics\HookCounter;
use Monitor\Api\Sync\SyncTrigger; use Monitor\Api\Sync\SyncTrigger;
/** /**
@ -20,12 +21,16 @@ use Monitor\Api\Sync\SyncTrigger;
*/ */
final class WebhookController 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 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. // "ping" schickt Codeberg beim Anlegen des Webhooks zum Testen der Zustellung.
$event = $request->header('x-github-event') ?? $request->header('x-gitea-event'); $event = $request->header('x-github-event') ?? $request->header('x-gitea-event');
if ($event === 'ping') { if ($event === 'ping') {
@ -38,4 +43,10 @@ final class WebhookController
// sync-Container. Codeberg braucht nur zu wissen, dass die Zustellung geklappt hat. // sync-Container. Codeberg braucht nur zu wissen, dass die Zustellung geklappt hat.
return Response::json(['status' => 'sync angefordert'], 202); 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()]);
}
} }

View file

@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace Monitor\Api\Metrics;
/**
* Zaehlt eingehende Webhooks seit dem letzten Start des api-Containers (Issue #38,
* "Hooks"-KPI). Dateibasiert statt ein einfaches Objektfeld: der eingebaute PHP-Server
* (docker-compose.yml, PHP_CLI_SERVER_WORKERS) fuehrt jede Anfrage als eigene
* Skriptausfuehrung aus, ein Klassenzustand ueberlebt also keine zwei Anfragen - nur eine
* Datei tut das. "Seit dem letzten Restart" stellt nicht diese Klasse sicher, sondern
* docker/entrypoint.sh, das die Datei beim Start des api-Containers auf 0 zuruecksetzt (der
* sync-Container teilt sich zwar dasselbe Image, darf die Zaehldatei bei einem eigenen
* Neustart aber nicht anfassen, siehe Kommentar dort).
*/
final class HookCounter
{
public function __construct(private readonly string $path)
{
}
/** Erhoeht den Zaehler atomar (mehrere PHP_CLI_SERVER_WORKERS koennen gleichzeitig schreiben). */
public function increment(): int
{
$directory = dirname($this->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();
}
}

View file

@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Monitor\Api\Realtime; namespace Monitor\Api\Realtime;
use Monitor\Api\Metrics\HookCounter;
use Monitor\Api\Repository\AgentRepository; use Monitor\Api\Repository\AgentRepository;
use Monitor\Api\Repository\EventRepository; use Monitor\Api\Repository\EventRepository;
use Monitor\Api\Repository\KpiRepository; use Monitor\Api\Repository\KpiRepository;
@ -11,8 +12,8 @@ use Monitor\Api\Repository\SystemRepository;
use Monitor\Api\Repository\TicketRepository; use Monitor\Api\Repository\TicketRepository;
/** /**
* Erkennt, ob sich seit dem letzten Blick etwas an Tickets/Events/Systemen/KPIs/Agenten * 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: * empfangenen Webhooks veraendert hat - Grundlage fuer den SSE-Push-Endpoint (F13, Phase 4:
* "Frontend via PHP Push aktualisieren (Server-Sent)"). Bewusst als billiger "Fingerabdruck" * "Frontend via PHP Push aktualisieren (Server-Sent)"). Bewusst als billiger "Fingerabdruck"
* (COUNT + juengster Zeitstempel je Ressource, siehe Repository::fingerprint()) statt echtem * (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, * 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 SystemRepository $systems,
private readonly KpiRepository $kpis, private readonly KpiRepository $kpis,
private readonly AgentRepository $agents, private readonly AgentRepository $agents,
private readonly HookCounter $hooks,
) { ) {
} }
@ -38,6 +40,7 @@ final class ChangeFeed
'systems' => $this->systems->fingerprint(), 'systems' => $this->systems->fingerprint(),
'kpis' => $this->kpis->fingerprint(), 'kpis' => $this->kpis->fingerprint(),
'agents' => $this->agents->fingerprint(), 'agents' => $this->agents->fingerprint(),
'hooks' => $this->hooks->fingerprint(),
]; ];
} }

View file

@ -224,11 +224,13 @@ final class ApplicationTest extends TestCase
{ {
$payload = '{"action":"opened","issue":{"number":1}}'; $payload = '{"action":"opened","issue":{"number":1}}';
$triggerPath = sys_get_temp_dir() . '/monitor-app-webhook-' . uniqid() . '/sync-requested'; $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([ $app = new Application(Database::inMemory(), new Config([
'MONITOR_STATUS_FILE' => '/does/not/exist', 'MONITOR_STATUS_FILE' => '/does/not/exist',
'CODEBERG_WEBHOOK_SECRET' => 'webhook-secret', 'CODEBERG_WEBHOOK_SECRET' => 'webhook-secret',
'MONITOR_SYNC_TRIGGER_FILE' => $triggerPath, 'MONITOR_SYNC_TRIGGER_FILE' => $triggerPath,
'MONITOR_HOOKS_COUNTER_FILE' => $hooksPath,
])); ]));
$response = $app->handle(Request::create( $response = $app->handle(Request::create(
@ -243,5 +245,44 @@ final class ApplicationTest extends TestCase
unlink($triggerPath); unlink($triggerPath);
rmdir(dirname($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));
} }
} }

View file

@ -19,6 +19,7 @@ final class ConfigTest extends TestCase
self::assertSame(8, $config->deskCount()); self::assertSame(8, $config->deskCount());
self::assertSame(30, $config->sseMaxIterations()); self::assertSame(30, $config->sseMaxIterations());
self::assertSame(2, $config->ssePollIntervalSeconds()); 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_DESK_COUNT' => '3',
'MONITOR_SSE_MAX_ITERATIONS' => '5', 'MONITOR_SSE_MAX_ITERATIONS' => '5',
'MONITOR_SSE_POLL_INTERVAL' => '1', 'MONITOR_SSE_POLL_INTERVAL' => '1',
'MONITOR_HOOKS_COUNTER_FILE' => '/tmp/hooks-count',
]); ]);
self::assertSame('https://example.org/api/v1', $config->codebergApiBase()); 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(3, $config->deskCount());
self::assertSame(5, $config->sseMaxIterations()); self::assertSame(5, $config->sseMaxIterations());
self::assertSame(1, $config->ssePollIntervalSeconds()); self::assertSame(1, $config->ssePollIntervalSeconds());
self::assertSame('/tmp/hooks-count', $config->hooksCounterPath());
} }
} }

View file

@ -7,6 +7,7 @@ namespace Monitor\Api\Tests\Controller;
use Monitor\Api\Controller\StreamController; use Monitor\Api\Controller\StreamController;
use Monitor\Api\Db\Database; use Monitor\Api\Db\Database;
use Monitor\Api\Http\Request; use Monitor\Api\Http\Request;
use Monitor\Api\Metrics\HookCounter;
use Monitor\Api\Realtime\ChangeFeed; use Monitor\Api\Realtime\ChangeFeed;
use Monitor\Api\Repository\AgentRepository; use Monitor\Api\Repository\AgentRepository;
use Monitor\Api\Repository\EventRepository; use Monitor\Api\Repository\EventRepository;
@ -19,20 +20,33 @@ final class StreamControllerTest extends TestCase
{ {
private TicketRepository $tickets; private TicketRepository $tickets;
private ChangeFeed $changeFeed; private ChangeFeed $changeFeed;
private string $hooksPath;
protected function setUp(): void protected function setUp(): void
{ {
$db = Database::inMemory(); $db = Database::inMemory();
$this->tickets = new TicketRepository($db); $this->tickets = new TicketRepository($db);
$this->hooksPath = sys_get_temp_dir() . '/monitor-stream-hooks-' . uniqid() . '/hooks-count';
$this->changeFeed = new ChangeFeed( $this->changeFeed = new ChangeFeed(
$this->tickets, $this->tickets,
new EventRepository($db), new EventRepository($db),
new SystemRepository($db), new SystemRepository($db),
new KpiRepository($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 public function testStreamSendsHeartbeatsWhenNothingChanges(): void
{ {
$controller = new StreamController( $controller = new StreamController(

View file

@ -6,18 +6,23 @@ namespace Monitor\Api\Tests\Controller;
use Monitor\Api\Controller\WebhookController; use Monitor\Api\Controller\WebhookController;
use Monitor\Api\Http\Request; use Monitor\Api\Http\Request;
use Monitor\Api\Metrics\HookCounter;
use Monitor\Api\Sync\SyncTrigger; use Monitor\Api\Sync\SyncTrigger;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
final class WebhookControllerTest extends TestCase final class WebhookControllerTest extends TestCase
{ {
private string $path; private string $path;
private string $hooksPath;
private SyncTrigger $trigger; private SyncTrigger $trigger;
private HookCounter $hooks;
protected function setUp(): void protected function setUp(): void
{ {
$this->path = sys_get_temp_dir() . '/monitor-webhook-' . uniqid() . '/sync-requested'; $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->trigger = new SyncTrigger($this->path);
$this->hooks = new HookCounter($this->hooksPath);
} }
protected function tearDown(): void protected function tearDown(): void
@ -28,11 +33,17 @@ final class WebhookControllerTest extends TestCase
if (is_dir(dirname($this->path))) { if (is_dir(dirname($this->path))) {
rmdir(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 public function testIssueEventRequestsASync(): void
{ {
$controller = new WebhookController($this->trigger); $controller = new WebhookController($this->trigger, $this->hooks);
$response = $controller->codeberg($this->request('issues')); $response = $controller->codeberg($this->request('issues'));
@ -46,7 +57,7 @@ final class WebhookControllerTest extends TestCase
*/ */
public function testPingIsAnsweredWithoutRequestingASync(): void public function testPingIsAnsweredWithoutRequestingASync(): void
{ {
$controller = new WebhookController($this->trigger); $controller = new WebhookController($this->trigger, $this->hooks);
$response = $controller->codeberg($this->request('ping')); $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. */ /** Unbekannte Events loesen einen Sync aus - lieber einmal zu viel als einen verpassten. */
public function testUnknownEventStillRequestsASync(): void public function testUnknownEventStillRequestsASync(): void
{ {
$controller = new WebhookController($this->trigger); $controller = new WebhookController($this->trigger, $this->hooks);
$response = $controller->codeberg($this->request('issue_label')); $response = $controller->codeberg($this->request('issue_label'));
@ -65,6 +76,42 @@ final class WebhookControllerTest extends TestCase
self::assertTrue($this->trigger->consume()); 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 private function request(string $event): Request
{ {
return Request::create( return Request::create(

View file

@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace Monitor\Api\Tests\Metrics;
use Monitor\Api\Metrics\HookCounter;
use PHPUnit\Framework\TestCase;
final class HookCounterTest extends TestCase
{
private string $path;
protected function setUp(): void
{
$this->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());
}
}

View file

@ -5,6 +5,7 @@ declare(strict_types=1);
namespace Monitor\Api\Tests\Realtime; namespace Monitor\Api\Tests\Realtime;
use Monitor\Api\Db\Database; use Monitor\Api\Db\Database;
use Monitor\Api\Metrics\HookCounter;
use Monitor\Api\Realtime\ChangeFeed; use Monitor\Api\Realtime\ChangeFeed;
use Monitor\Api\Repository\AgentRepository; use Monitor\Api\Repository\AgentRepository;
use Monitor\Api\Repository\EventRepository; use Monitor\Api\Repository\EventRepository;
@ -18,6 +19,8 @@ final class ChangeFeedTest extends TestCase
private ChangeFeed $changeFeed; private ChangeFeed $changeFeed;
private TicketRepository $tickets; private TicketRepository $tickets;
private EventRepository $events; private EventRepository $events;
private HookCounter $hooks;
private string $hooksPath;
protected function setUp(): void protected function setUp(): void
{ {
@ -27,15 +30,27 @@ final class ChangeFeedTest extends TestCase
$systems = new SystemRepository($db); $systems = new SystemRepository($db);
$kpis = new KpiRepository($db); $kpis = new KpiRepository($db);
$agents = new AgentRepository($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(); $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 public function testDiffIsEmptyWithoutChanges(): void
@ -65,4 +80,14 @@ final class ChangeFeedTest extends TestCase
sort($changed); sort($changed);
self::assertSame(['events', 'tickets'], $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));
}
} }

View file

@ -33,6 +33,9 @@ services:
# jede Anfrage mit 401 ab - der Endpunkt ist aus dem Internet erreichbar und die # jede Anfrage mit 401 ab - der Endpunkt ist aus dem Internet erreichbar und die
# Signatur ist sein einziger Schutz. # Signatur ist sein einziger Schutz.
CODEBERG_WEBHOOK_SECRET: ${CODEBERG_WEBHOOK_SECRET:-} 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"): # 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. # laeuft dasselbe Image wie api und teilt sich ueber das ./api-Volume dieselbe SQLite-Datei.

View file

@ -53,6 +53,16 @@ export async function fetchAgents() {
return agents; 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 * Baut die Stream-URL fuer den SSE-Push-Endpoint (F13, Phase 4). Wird von office-realtime.js
* an EventSource() uebergeben. * an EventSource() uebergeben.

View file

@ -25,6 +25,8 @@ import {
buildKpiTiles, buildKpiTiles,
buildKpiModalContent, buildKpiModalContent,
buildAgentCountKpis, buildAgentCountKpis,
buildHooksKpi,
MAX_KPI_TILES,
mapAgentsToDesks, mapAgentsToDesks,
normalizeAgentForDisplay, normalizeAgentForDisplay,
resolveAgentVisualState, resolveAgentVisualState,
@ -56,6 +58,7 @@ import {
fetchSystems, fetchSystems,
fetchKpis, fetchKpis,
fetchAgents, fetchAgents,
fetchWebhookStats,
fetchRepoConfigs, fetchRepoConfigs,
createRepoConfig, createRepoConfig,
updateRepoConfig, updateRepoConfig,
@ -1343,14 +1346,15 @@ async function withAdminKey(action) {
// --------------------------------------------------------------------------------------- // ---------------------------------------------------------------------------------------
async function loadLiveData() { async function loadLiveData() {
const [tickets, events, systems, kpis, agents] = await Promise.all([ const [tickets, events, systems, kpis, agents, webhookStats] = await Promise.all([
fetchTickets(), fetchTickets(),
fetchEvents(), fetchEvents(),
fetchSystems(), fetchSystems(),
fetchKpis(), fetchKpis(),
fetchAgents(), 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 }); const animationQueue = new AnimationQueue({ minIntervalMs: 120 });
@ -1372,6 +1376,7 @@ export function renderOfficeScene(container, data = {}) {
events = DEMO_EVENTS, events = DEMO_EVENTS,
kpis = [], kpis = [],
tickets = [], tickets = [],
hooksCount = null,
isLive = false, isLive = false,
} = data; } = data;
@ -1404,8 +1409,14 @@ export function renderOfficeScene(container, data = {}) {
// "Agenten"/"Online" (Issue #39) stehen vor den gemeldeten KPIs (F7), damit sie auf der // "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 // Wandkachel (MAX_KPI_TILES) immer sichtbar sind, statt von gemeldeten Kennzahlen verdraengt
// zu werden. // zu werden. "Hooks" (Issue #38) reserviert sich zusaetzlich die letzte (rechte) Kachel -
const displayKpis = [...buildAgentCountKpis(agents), ...kpis]; // 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); const groupedTickets = groupTicketsByColumn(tickets);
@ -1562,6 +1573,7 @@ async function boot() {
systems: data.systems, systems: data.systems,
events: data.events, events: data.events,
kpis: data.kpis, kpis: data.kpis,
hooksCount: data.hooksCount,
isLive: true, isLive: true,
}); });
setApiBanner(false); setApiBanner(false);

View file

@ -325,7 +325,7 @@ export function buildKpiSparklinePoints(history = [], width = 140, height = 32,
.join(" "); .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 * 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). * Inhaltsmodell fuer das KPI-Monitor-Modal (groessere Sparkline als die Wandkachel).
* *

View file

@ -21,6 +21,7 @@ import {
buildKpiTiles, buildKpiTiles,
buildKpiModalContent, buildKpiModalContent,
buildAgentCountKpis, buildAgentCountKpis,
buildHooksKpi,
MAX_KPI_TILES, MAX_KPI_TILES,
AGENT_LOCATIONS, AGENT_LOCATIONS,
resolveAgentVisualState, resolveAgentVisualState,
@ -355,6 +356,17 @@ test("buildAgentCountKpis liefert 0/0 ohne Agenten", () => {
assert.equal(kpis[1].current_value, 0); 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 // Phase 5 - Agenten-Zustandsmaschine & Animation
// --------------------------------------------------------------------------------------- // ---------------------------------------------------------------------------------------