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 <noreply@anthropic.com>
This commit is contained in:
parent
042a535c78
commit
79ccef099d
19 changed files with 401 additions and 19 deletions
21
API.md
21
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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 "$@"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
65
api/src/Metrics/HookCounter.php
Normal file
65
api/src/Metrics/HookCounter.php
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
68
api/tests/Unit/Metrics/HookCounterTest.php
Normal file
68
api/tests/Unit/Metrics/HookCounterTest.php
Normal 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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue