diff --git a/API.md b/API.md index e535b34..88f6b88 100644 --- a/API.md +++ b/API.md @@ -354,6 +354,12 @@ Auth: `X-Api-Key`. Pflichtfelder: `name`, `message`, `severity`. `occurred_at` optional (Default: jetzt). Response `201` mit dem gespeicherten Event. +### `POST /api/v1/events/system` — Event des Monitorings selbst melden + +Wie `POST /api/v1/events`, aber Auth per `X-Admin-Key`. Genutzt von `update.sh`, das nach einem +erfolgreichen Update `name: "Monitor"`, `message: "Update eingespielt ()"`, +`severity: "warning"` meldet. + ### `GET /api/v1/events` — Event-Liste Query-Parameter: `limit` (Default `5`, entspricht der im Plan festgelegten Zeilenzahl des @@ -646,11 +652,9 @@ Auth: keine (oeffentlich wie die anderen `GET`-Endpunkte). ``` `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). +`ping`) **am heutigen Tag** (Serverdatum); ab Mitternacht beginnt der Zaehler bei 0. Die +Zaehldatei (`MONITOR_HOOKS_COUNTER_FILE`) enthaelt Datum und Anzahl. Das Frontend zeigt den +Wert als eigene, immer sichtbare Kachel "Hooks heute" ganz rechts im KPI-Monitor (Issue #38). ## Fehlerformat diff --git a/README_UPDATE.md b/README_UPDATE.md index 71342de..ec6cb97 100644 --- a/README_UPDATE.md +++ b/README_UPDATE.md @@ -14,6 +14,8 @@ aktualisiert. fuehrt (per Default) die Testsuiten aus. Kann jederzeit auch manuell aufgerufen werden, siehe Kommentar am Kopf der Datei fuer die Umgebungsvariablen `UPDATE_REMOTE`, `UPDATE_BRANCH`, `UPDATE_RUN_TESTS`. + Nach dem Neustart meldet das Skript ein Event "Monitor: Update eingespielt ()" per + `POST /api/v1/events/system` (Admin-Key aus der `.env`); schlaegt das fehl, bleibt das Update gueltig. - **`deploy-webhook.py`** (Projekt-Wurzel) - kleiner HTTP-Server (nur Python-Standardbibliothek, keine Abhaengigkeiten) auf Basis von `http.server`. Prueft eingehende Codeberg-Webhooks per HMAC-Signatur (`X-Forgejo-Signature`/`X-Gitea-Signature`, analog zum bestehenden diff --git a/api/docker/entrypoint.sh b/api/docker/entrypoint.sh index 1c1f9a1..27a6957 100755 --- a/api/docker/entrypoint.sh +++ b/api/docker/entrypoint.sh @@ -7,16 +7,4 @@ 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 b612a04..f3e3ce2 100644 --- a/api/src/Application.php +++ b/api/src/Application.php @@ -103,6 +103,9 @@ final class Application $this->router->add('GET', '/api/v1/events', fn (Request $r, array $p) => $eventController->index($r)); $this->router->add('POST', '/api/v1/events', fn (Request $r, array $p) => $eventController->create($r), 'agent'); + // update.sh meldet hier, dass sich das Monitoring selbst aktualisiert hat. Es laeuft auf dem + // Host und kennt nur den Admin-Key aus der .env, keinen Agenten-Key. + $this->router->add('POST', '/api/v1/events/system', fn (Request $r, array $p) => $eventController->create($r), 'admin'); $this->router->add('GET', '/api/v1/systems', fn (Request $r, array $p) => $systemController->index($r)); $this->router->add('POST', '/api/v1/systems/{id}/status', fn (Request $r, array $p) => $systemController->reportStatus($r, $p), 'agent'); diff --git a/api/src/Metrics/HookCounter.php b/api/src/Metrics/HookCounter.php index b51ab69..6f7876c 100644 --- a/api/src/Metrics/HookCounter.php +++ b/api/src/Metrics/HookCounter.php @@ -5,22 +5,25 @@ 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). + * Zaehlt die heute eingegangenen Webhooks (Issue #38, "Hooks heute"-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. Die Datei + * enthaelt " "; ein anderes Datum (neuer Tag, alter Dateiinhalt) zaehlt als 0, + * ein Reset beim Containerstart ist daher nicht noetig. */ final class HookCounter { - public function __construct(private readonly string $path) + /** @var \Closure(): string */ + private readonly \Closure $today; + + /** @param (callable(): string)|null $today liefert das aktuelle Datum (Y-m-d), fuer Tests ersetzbar */ + public function __construct(private readonly string $path, ?callable $today = null) { + $this->today = $today !== null ? \Closure::fromCallable($today) : static fn (): string => date('Y-m-d'); } - /** Erhoeht den Zaehler atomar (mehrere PHP_CLI_SERVER_WORKERS koennen gleichzeitig schreiben). */ + /** Erhoeht den heutigen Zaehler atomar (mehrere PHP_CLI_SERVER_WORKERS koennen gleichzeitig schreiben). */ public function increment(): int { $directory = dirname($this->path); @@ -34,11 +37,10 @@ final class HookCounter } flock($handle, LOCK_EX); - $current = (int) trim((string) stream_get_contents($handle)); - $next = $current + 1; + $next = $this->parse((string) stream_get_contents($handle)) + 1; ftruncate($handle, 0); rewind($handle); - fwrite($handle, (string) $next); + fwrite($handle, ($this->today)() . ' ' . $next); fflush($handle); flock($handle, LOCK_UN); fclose($handle); @@ -54,7 +56,7 @@ final class HookCounter $contents = @file_get_contents($this->path); - return $contents === false ? 0 : (int) trim($contents); + return $contents === false ? 0 : $this->parse($contents); } /** Fingerabdruck fuer den SSE-Endpoint (Realtime\ChangeFeed, F13). */ @@ -62,4 +64,14 @@ final class HookCounter { return (string) $this->count(); } + + /** Anzahl aus dem Dateiinhalt - 0, wenn sie nicht von heute stammt oder unlesbar ist. */ + private function parse(string $contents): int + { + if (preg_match('/^(\d{4}-\d{2}-\d{2}) (\d+)$/', trim($contents), $m) !== 1) { + return 0; + } + + return $m[1] === ($this->today)() ? (int) $m[2] : 0; + } } diff --git a/api/src/Repository/TicketRepository.php b/api/src/Repository/TicketRepository.php index 7084c47..7112153 100644 --- a/api/src/Repository/TicketRepository.php +++ b/api/src/Repository/TicketRepository.php @@ -185,11 +185,21 @@ final class TicketRepository return $this->create($data + ['column' => 'fertig', 'source' => 'codeberg', 'is_pull_request' => 1]); } - return $this->update((int) $existing['id'], [ + $updated = $this->update((int) $existing['id'], [ 'title' => $data['title'], 'description' => $data['description'] ?? null, 'color' => $data['color'] ?? null, ]); + + // Ein Ticket, das vor der PR-Darstellung (z. B. durch einen aelteren Sync) als Issue + // angelegt wurde, behielte sonst is_pull_request = 0 und damit den /issues/-Link. + if (empty($existing['is_pull_request'])) { + $this->db->pdo()->prepare('UPDATE tickets SET is_pull_request = 1 WHERE id = :id') + ->execute(['id' => $existing['id']]); + $updated['is_pull_request'] = 1; + } + + return $updated; } /** diff --git a/api/tests/Unit/ApplicationTest.php b/api/tests/Unit/ApplicationTest.php index 724cfd4..5706fad 100644 --- a/api/tests/Unit/ApplicationTest.php +++ b/api/tests/Unit/ApplicationTest.php @@ -50,6 +50,23 @@ final class ApplicationTest extends TestCase self::assertSame(401, $response->status); } + public function testSystemEventNeedsAdminKey(): void + { + $body = ['name' => 'Monitor', 'message' => 'Update eingespielt (abc1234)', 'severity' => 'warning']; + + $denied = $this->app->handle(Request::create('POST', '/api/v1/events/system', body: $body)); + self::assertSame(401, $denied->status); + + $created = $this->app->handle(Request::create( + 'POST', + '/api/v1/events/system', + body: $body, + headers: ['X-Admin-Key' => 'admin-secret'] + )); + self::assertSame(201, $created->status); + self::assertSame('Monitor', $created->body()['event']['name']); + } + public function testFullFlowRegisterAgentThenCreateTicket(): void { $registerResponse = $this->app->handle(Request::create( diff --git a/api/tests/Unit/Metrics/HookCounterTest.php b/api/tests/Unit/Metrics/HookCounterTest.php index afb6938..a578821 100644 --- a/api/tests/Unit/Metrics/HookCounterTest.php +++ b/api/tests/Unit/Metrics/HookCounterTest.php @@ -65,4 +65,28 @@ final class HookCounterTest extends TestCase $counter->increment(); self::assertSame('1', $counter->fingerprint()); } + + public function testCountResetsOnANewDay(): void + { + $day = '2026-10-01'; + $counter = new HookCounter($this->path, static function () use (&$day): string { + return $day; + }); + + $counter->increment(); + $counter->increment(); + self::assertSame(2, $counter->count()); + + $day = '2026-10-02'; + self::assertSame(0, $counter->count()); + self::assertSame(1, $counter->increment()); + } + + public function testLegacyFileContentCountsAsZero(): void + { + mkdir(dirname($this->path), 0o775, true); + file_put_contents($this->path, '12'); + + self::assertSame(0, (new HookCounter($this->path))->count()); + } } diff --git a/api/tests/Unit/Repository/TicketRepositoryTest.php b/api/tests/Unit/Repository/TicketRepositoryTest.php index 6ab7486..0b6e011 100644 --- a/api/tests/Unit/Repository/TicketRepositoryTest.php +++ b/api/tests/Unit/Repository/TicketRepositoryTest.php @@ -27,6 +27,16 @@ final class TicketRepositoryTest extends TestCase self::assertSame('Neues Ticket', $ticket['title']); } + public function testUpsertPullRequestFlagsExistingIssueTicket(): void + { + $this->repository->create(['title' => 'PR', 'external_ref' => '107', 'repo' => 'a/b', 'source' => 'codeberg']); + + $ticket = $this->repository->upsertPullRequest(['external_ref' => '107', 'repo' => 'a/b', 'title' => 'PR neu']); + + self::assertSame(1, (int) $ticket['is_pull_request']); + self::assertSame(1, (int) $this->repository->findByExternalRef('107', 'a/b')['is_pull_request']); + } + public function testCreateRejectsUnknownColumn(): void { $this->expectException(InvalidArgumentException::class); diff --git a/docker-compose.yml b/docker-compose.yml index 773b493..4ad79b0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -40,8 +40,8 @@ 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. + # Zaehldatei fuer den Hooks-KPI (Issue #38, GET /api/v1/webhooks/stats): speichert Datum + Anzahl der + # heute empfangenen Webhooks (ein neuer Tag beginnt bei 0). MONITOR_HOOKS_COUNTER_FILE: /app/data/hooks-count # Sprachaufnahmen (Issue #85): Ablageort der Rohaufnahmen aus record.html und # Obergrenze je Aufnahme. Die Groessengrenze muss zur Dockerfile-CMD passen diff --git a/html/js/office-scene.js b/html/js/office-scene.js index 5274f96..2d246a5 100644 --- a/html/js/office-scene.js +++ b/html/js/office-scene.js @@ -485,8 +485,8 @@ 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 + * KPI-foermiger Eintrag fuer den Hooks-KPI (Issue #38): Anzahl der heute + * 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 @@ -497,7 +497,7 @@ export function buildKpiTiles(kpis = []) { * @returns {object} */ export function buildHooksKpi(count) { - return { key: "hooks_received", label: "Hooks", unit: "", current_value: count, history: [] }; + return { key: "hooks_received", label: "Hooks heute", unit: "", current_value: count, history: [] }; } /** diff --git a/tests/frontend/office-scene.test.js b/tests/frontend/office-scene.test.js index 8ee62c3..279768d 100644 --- a/tests/frontend/office-scene.test.js +++ b/tests/frontend/office-scene.test.js @@ -466,7 +466,7 @@ test("buildAgentCountKpis liefert 0/0 ohne Agenten", () => { test("buildHooksKpi liefert einen KPI-foermigen Eintrag mit der uebergebenen Anzahl (Issue #38)", () => { assert.deepEqual(buildHooksKpi(12), { key: "hooks_received", - label: "Hooks", + label: "Hooks heute", unit: "", current_value: 12, history: [], diff --git a/update.sh b/update.sh index 5ec2ff4..3511fa7 100644 --- a/update.sh +++ b/update.sh @@ -97,4 +97,41 @@ fi log "Starte Dienste neu (api, web, sync)..." $DOCKER_COMPOSE up -d api web sync +# Meldet das Update als Event (POST /api/v1/events/system, Admin-Key aus der .env). Die API ist +# direkt nach "up -d" evtl. noch nicht bereit, daher mehrere Versuche; ein Fehlschlag bricht das +# (bereits erfolgreiche) Update nicht ab. +report_update_event() { + command -v curl >/dev/null 2>&1 || { log "Hinweis: curl fehlt, Update-Event wird nicht gemeldet."; return 0; } + local env_file="$REPO_DIR/.env" admin_key api_base + env_value() { + [ -f "$env_file" ] || return 0 + sed -n "s/^[[:space:]]*$1=//p" "$env_file" | tail -n1 \ + | sed -e 's/^"\(.*\)"$/\1/' -e "s/^'\(.*\)'\$/\1/" + } + admin_key="${MONITOR_ADMIN_API_KEY:-$(env_value MONITOR_ADMIN_API_KEY)}" + api_base="${MONITOR_API_BASE:-$(env_value MONITOR_API_BASE)}" + api_base="${api_base:-http://localhost:8091}" + api_base="${api_base%/}" + api_base="${api_base%/api/v1}" + if [ -z "$admin_key" ]; then + log "Hinweis: MONITOR_ADMIN_API_KEY fehlt, Update-Event wird nicht gemeldet." + return 0 + fi + + local payload i + payload="{\"name\":\"Monitor\",\"message\":\"Update eingespielt ($(git rev-parse --short HEAD))\",\"severity\":\"warning\"}" + for i in 1 2 3 4 5 6 7 8 9 10; do + if curl -sf --max-time 10 -X POST "$api_base/api/v1/events/system" \ + -H "Content-Type: application/json" -H "X-Admin-Key: $admin_key" \ + -d "$payload" >/dev/null 2>&1; then + log "Update-Event gemeldet." + return 0 + fi + sleep 3 + done + log "Warnung: Update-Event konnte nicht gemeldet werden (API unter $api_base nicht erreichbar oder Key ungueltig)." +} + +report_update_event + log "Update abgeschlossen (${UPDATE_BRANCH} @ $(git rev-parse --short HEAD))."