Merge pull request 'KPI "Hooks heute", Update-Event des Monitorings, PR-Flag bestehender Tickets' (#107) from fds2610-bot/monitor:main into main

Reviewed-on: https://codeberg.org/fds2610/monitor/pulls/107
This commit is contained in:
fds2610 2026-10-01 12:53:02 +02:00
commit be34718cdb
13 changed files with 145 additions and 38 deletions

14
API.md
View file

@ -354,6 +354,12 @@ Auth: `X-Api-Key`.
Pflichtfelder: `name`, `message`, `severity`. `occurred_at` optional (Default: jetzt). Pflichtfelder: `name`, `message`, `severity`. `occurred_at` optional (Default: jetzt).
Response `201` mit dem gespeicherten Event. 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 (<Commit>)"`,
`severity: "warning"` meldet.
### `GET /api/v1/events` — Event-Liste ### `GET /api/v1/events` — Event-Liste
Query-Parameter: `limit` (Default `5`, entspricht der im Plan festgelegten Zeilenzahl des 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 `count` zaehlt jede angenommene Zustellung an `POST /api/v1/webhooks/codeberg` (auch
`ping`) **seit dem letzten Start des `api`-Containers** - nicht seit Projektbeginn: die `ping`) **am heutigen Tag** (Serverdatum); ab Mitternacht beginnt der Zaehler bei 0. Die
Zaehldatei wird bei jedem Start dieses Containers zurueckgesetzt (`docker/entrypoint.sh`, Zaehldatei (`MONITOR_HOOKS_COUNTER_FILE`) enthaelt Datum und Anzahl. Das Frontend zeigt den
`MONITOR_HOOKS_COUNTER_FILE`). Ein Neustart des `sync`-Containers alleine setzt den Zaehler Wert als eigene, immer sichtbare Kachel "Hooks heute" ganz rechts im KPI-Monitor (Issue #38).
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

View file

@ -14,6 +14,8 @@ aktualisiert.
fuehrt (per Default) die Testsuiten aus. Kann jederzeit auch manuell aufgerufen werden, siehe 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`, Kommentar am Kopf der Datei fuer die Umgebungsvariablen `UPDATE_REMOTE`, `UPDATE_BRANCH`,
`UPDATE_RUN_TESTS`. `UPDATE_RUN_TESTS`.
Nach dem Neustart meldet das Skript ein Event "Monitor: Update eingespielt (<Commit>)" 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, - **`deploy-webhook.py`** (Projekt-Wurzel) - kleiner HTTP-Server (nur Python-Standardbibliothek,
keine Abhaengigkeiten) auf Basis von `http.server`. Prueft eingehende Codeberg-Webhooks per keine Abhaengigkeiten) auf Basis von `http.server`. Prueft eingehende Codeberg-Webhooks per
HMAC-Signatur (`X-Forgejo-Signature`/`X-Gitea-Signature`, analog zum bestehenden HMAC-Signatur (`X-Forgejo-Signature`/`X-Gitea-Signature`, analog zum bestehenden

View file

@ -7,16 +7,4 @@ 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

@ -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('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'); $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('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'); $this->router->add('POST', '/api/v1/systems/{id}/status', fn (Request $r, array $p) => $systemController->reportStatus($r, $p), 'agent');

View file

@ -5,22 +5,25 @@ declare(strict_types=1);
namespace Monitor\Api\Metrics; namespace Monitor\Api\Metrics;
/** /**
* Zaehlt eingehende Webhooks seit dem letzten Start des api-Containers (Issue #38, * Zaehlt die heute eingegangenen Webhooks (Issue #38, "Hooks heute"-KPI). Dateibasiert statt
* "Hooks"-KPI). Dateibasiert statt ein einfaches Objektfeld: der eingebaute PHP-Server * ein einfaches Objektfeld: der eingebaute PHP-Server (docker-compose.yml,
* (docker-compose.yml, PHP_CLI_SERVER_WORKERS) fuehrt jede Anfrage als eigene * PHP_CLI_SERVER_WORKERS) fuehrt jede Anfrage als eigene Skriptausfuehrung aus, ein
* Skriptausfuehrung aus, ein Klassenzustand ueberlebt also keine zwei Anfragen - nur eine * Klassenzustand ueberlebt also keine zwei Anfragen - nur eine Datei tut das. Die Datei
* Datei tut das. "Seit dem letzten Restart" stellt nicht diese Klasse sicher, sondern * enthaelt "<Datum> <Anzahl>"; ein anderes Datum (neuer Tag, alter Dateiinhalt) zaehlt als 0,
* docker/entrypoint.sh, das die Datei beim Start des api-Containers auf 0 zuruecksetzt (der * ein Reset beim Containerstart ist daher nicht noetig.
* sync-Container teilt sich zwar dasselbe Image, darf die Zaehldatei bei einem eigenen
* Neustart aber nicht anfassen, siehe Kommentar dort).
*/ */
final class HookCounter 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 public function increment(): int
{ {
$directory = dirname($this->path); $directory = dirname($this->path);
@ -34,11 +37,10 @@ final class HookCounter
} }
flock($handle, LOCK_EX); flock($handle, LOCK_EX);
$current = (int) trim((string) stream_get_contents($handle)); $next = $this->parse((string) stream_get_contents($handle)) + 1;
$next = $current + 1;
ftruncate($handle, 0); ftruncate($handle, 0);
rewind($handle); rewind($handle);
fwrite($handle, (string) $next); fwrite($handle, ($this->today)() . ' ' . $next);
fflush($handle); fflush($handle);
flock($handle, LOCK_UN); flock($handle, LOCK_UN);
fclose($handle); fclose($handle);
@ -54,7 +56,7 @@ final class HookCounter
$contents = @file_get_contents($this->path); $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). */ /** Fingerabdruck fuer den SSE-Endpoint (Realtime\ChangeFeed, F13). */
@ -62,4 +64,14 @@ final class HookCounter
{ {
return (string) $this->count(); 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;
}
} }

View file

@ -185,11 +185,21 @@ final class TicketRepository
return $this->create($data + ['column' => 'fertig', 'source' => 'codeberg', 'is_pull_request' => 1]); 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'], 'title' => $data['title'],
'description' => $data['description'] ?? null, 'description' => $data['description'] ?? null,
'color' => $data['color'] ?? 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;
} }
/** /**

View file

@ -50,6 +50,23 @@ final class ApplicationTest extends TestCase
self::assertSame(401, $response->status); 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 public function testFullFlowRegisterAgentThenCreateTicket(): void
{ {
$registerResponse = $this->app->handle(Request::create( $registerResponse = $this->app->handle(Request::create(

View file

@ -65,4 +65,28 @@ final class HookCounterTest extends TestCase
$counter->increment(); $counter->increment();
self::assertSame('1', $counter->fingerprint()); 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());
}
} }

View file

@ -27,6 +27,16 @@ final class TicketRepositoryTest extends TestCase
self::assertSame('Neues Ticket', $ticket['title']); 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 public function testCreateRejectsUnknownColumn(): void
{ {
$this->expectException(InvalidArgumentException::class); $this->expectException(InvalidArgumentException::class);

View file

@ -40,8 +40,8 @@ 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 # Zaehldatei fuer den Hooks-KPI (Issue #38, GET /api/v1/webhooks/stats): speichert Datum + Anzahl der
# Start dieses Containers von entrypoint.sh auf 0 zurueckgesetzt. # heute empfangenen Webhooks (ein neuer Tag beginnt bei 0).
MONITOR_HOOKS_COUNTER_FILE: /app/data/hooks-count MONITOR_HOOKS_COUNTER_FILE: /app/data/hooks-count
# Sprachaufnahmen (Issue #85): Ablageort der Rohaufnahmen aus record.html und # Sprachaufnahmen (Issue #85): Ablageort der Rohaufnahmen aus record.html und
# Obergrenze je Aufnahme. Die Groessengrenze muss zur Dockerfile-CMD passen # Obergrenze je Aufnahme. Die Groessengrenze muss zur Dockerfile-CMD passen

View file

@ -485,8 +485,8 @@ export function buildKpiTiles(kpis = []) {
} }
/** /**
* KPI-foermiger Eintrag fuer den Hooks-KPI (Issue #38): Anzahl der seit dem letzten Start des * KPI-foermiger Eintrag fuer den Hooks-KPI (Issue #38): Anzahl der heute
* api-Containers empfangenen Webhooks (GET /api/v1/webhooks/stats). Anders als * empfangenen Webhooks (GET /api/v1/webhooks/stats). Anders als
* buildAgentCountKpis() nicht aus bereits ohnehin geladenen Daten ableitbar, sondern ein * buildAgentCountKpis() nicht aus bereits ohnehin geladenen Daten ableitbar, sondern ein
* eigener serverseitiger Zaehler - daher eine eigene, injizierbare Funktion statt Teil davon. * 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 * Soll laut Issue #38 immer als letzte (rechte) Kachel im KPI-Monitor erscheinen, siehe
@ -497,7 +497,7 @@ export function buildKpiTiles(kpis = []) {
* @returns {object} * @returns {object}
*/ */
export function buildHooksKpi(count) { 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: [] };
} }
/** /**

View file

@ -466,7 +466,7 @@ test("buildAgentCountKpis liefert 0/0 ohne Agenten", () => {
test("buildHooksKpi liefert einen KPI-foermigen Eintrag mit der uebergebenen Anzahl (Issue #38)", () => { test("buildHooksKpi liefert einen KPI-foermigen Eintrag mit der uebergebenen Anzahl (Issue #38)", () => {
assert.deepEqual(buildHooksKpi(12), { assert.deepEqual(buildHooksKpi(12), {
key: "hooks_received", key: "hooks_received",
label: "Hooks", label: "Hooks heute",
unit: "", unit: "",
current_value: 12, current_value: 12,
history: [], history: [],

View file

@ -97,4 +97,41 @@ fi
log "Starte Dienste neu (api, web, sync)..." log "Starte Dienste neu (api, web, sync)..."
$DOCKER_COMPOSE up -d 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))." log "Update abgeschlossen (${UPDATE_BRANCH} @ $(git rev-parse --short HEAD))."