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:
commit
be34718cdb
13 changed files with 145 additions and 38 deletions
14
API.md
14
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 (<Commit>)"`,
|
||||
`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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 (<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,
|
||||
keine Abhaengigkeiten) auf Basis von `http.server`. Prueft eingehende Codeberg-Webhooks per
|
||||
HMAC-Signatur (`X-Forgejo-Signature`/`X-Gitea-Signature`, analog zum bestehenden
|
||||
|
|
|
|||
|
|
@ -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 "$@"
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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 "<Datum> <Anzahl>"; 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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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: [] };
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
|
|
|
|||
37
update.sh
37
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))."
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue