diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..d1a01ae --- /dev/null +++ b/.env.example @@ -0,0 +1,10 @@ +# Kopieren nach .env und Werte anpassen. Wird von docker-compose.yml gelesen. + +# Administrator-Key zur Registrierung neuer Agenten (POST /api/v1/agents, Header X-Admin-Key) +MONITOR_ADMIN_API_KEY=change-me + +# Codeberg-Repo, aus dem Backlog/Done per Sync-Skript befuellt werden +CODEBERG_OWNER=fds2610 +CODEBERG_REPO=monitor +# Optional: Token mit Lesezugriff, falls Rate-Limits ohne Token zu knapp sind +CODEBERG_TOKEN= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..997ef86 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +api/vendor/ +api/data/ +.env diff --git a/API.md b/API.md new file mode 100644 index 0000000..df612f0 --- /dev/null +++ b/API.md @@ -0,0 +1,221 @@ +# monitor REST-API (Phase 1) + +Diese API ist die zentrale Schnittstelle, ueber die entfernte Agenten (Menschen oder +automatisierte Systeme) ihre Aktivitaet melden: Tickets anlegen/verschieben, Events melden, +Systemstatus berichten, KPIs eintragen und den eigenen Zustand aktualisieren. Sie ist Teil +von Phase 1 der Neuausrichtung (siehe [Visualization.md](Visualization.md), Abschnitt 9). + +Implementierung: Vanilla-PHP (kein Framework), Persistenz in SQLite +(`api/data/monitor.sqlite`). Details zum lokalen Setup: [api/README.md](api/README.md). + +## Inhaltsverzeichnis + +- [Grundlagen](#grundlagen) +- [Authentifizierung](#authentifizierung) +- [Agenten](#agenten) +- [Tickets](#tickets) +- [Events](#events) +- [Systeme](#systeme) +- [KPIs](#kpis) +- [Codeberg-Sync (Backlog/Done)](#codeberg-sync-backlogdone) +- [Fehlerformat](#fehlerformat) + +## Grundlagen + +- Basis-Pfad: `/api/v1` +- Request-/Response-Body: `application/json` +- Alle Zeitstempel: ISO-8601 UTC (`YYYY-MM-DDTHH:mm:ssZ`), serverseitig erzeugt via + `Database::now()`, sofern nicht explizit im Request angegeben. +- Lesende Endpunkte (`GET`) sind oeffentlich, da sie das Frontend bedienen. +- Schreibende Endpunkte (`POST`/`PATCH`) erfordern einen Agenten- oder Admin-Key + (siehe [Authentifizierung](#authentifizierung)). + +## Authentifizierung + +Zwei Schluesseltypen, beide statisch (F14): + +| Header | Verwendet fuer | Herkunft | +|---|---|---| +| `X-Admin-Key` | `POST /api/v1/agents` (Registrierung neuer Agenten) | Umgebungsvariable `MONITOR_ADMIN_API_KEY` | +| `X-Api-Key` | Alle anderen schreibenden Endpunkte | Wird bei der Agenten-Registrierung einmalig zurueckgegeben | + +Der Klartext-API-Key eines Agenten wird **nur einmal** bei der Registrierung ausgegeben. +Gespeichert wird ausschliesslich ein SHA-256-Hash in SQLite (Tabelle `agents`, +Spalte `api_key_hash`) - der Klartext ist danach nicht mehr rekonstruierbar und muss vom +Agenten sicher verwahrt werden. + +Fehlt oder passt ein Key nicht, antwortet die API mit `401 Unauthorized`. + +## Agenten + +Rollen (F1/F2): `entwickler`, `product_owner`, `qa`, `user`. Ein Agent bekommt bei der +Registrierung fest einen von `MONITOR_DESK_COUNT` (Standard 8) Schreibtischen zugewiesen +(niedrigster freier Index). Die Rolle `user` bekommt nie einen Schreibtisch (kein fester +Sitzplatz). Sind alle Schreibtische belegt, wird trotzdem registriert, aber ohne +Schreibtisch (`desk_index: null`). + +### `POST /api/v1/agents` — Agent registrieren + +Auth: `X-Admin-Key`. + +```json +// Request +{ "display_name": "Alice", "role": "entwickler" } +``` + +```json +// Response 201 +{ + "agent": { + "id": 1, "display_name": "Alice", "role": "entwickler", + "desk_index": 0, "state": "idle", "current_ticket_id": null, + "created_at": "2026-07-27T10:00:00Z", "last_seen_at": "2026-07-27T10:00:00Z" + }, + "api_key": "‹64 Hex-Zeichen, nur jetzt sichtbar›" +} +``` + +### `GET /api/v1/agents` — Alle Agenten + +Response: `{ "agents": [ ‹Agent-Objekte wie oben, ohne api_key› ] }` + +### `POST /api/v1/agents/{id}/activity` — Eigenen Zustand melden + +Auth: `X-Api-Key` des betroffenen Agenten (ein Agent darf nur seinen eigenen Zustand +melden, sonst `403`). + +```json +// Request +{ "state": "working", "current_ticket": 42 } +``` + +Response 200: aktualisiertes Agent-Objekt. + +## Tickets + +Spalten (F3): `backlog`, `bereit`, `in_arbeit`, `qa`, `testing`, `fertig`, `done`. + +- `backlog` und `done` werden zusaetzlich regelmaessig aus Codeberg-Issues befuellt + (siehe [Codeberg-Sync](#codeberg-sync-backlogdone)), alle anderen Bewegungen ergeben + sich ausschliesslich aus Agenten-Meldungen (F4). +- Ticket-Typ und Farbe (statt Prioritaet) werden bei Codeberg-Tickets aus dem ersten + Issue-Label uebernommen (F4). +- Stapel-Darstellung ("aelteste 4 + Rest als Stapel", F4) ist reine Frontend-Logik; + die API liefert Tickets dafuer sortiert nach Spalte und Alter. + +### `GET /api/v1/tickets` — Board-Zustand + +Query-Parameter: `column` (optional, filtert auf eine Spalte). + +Response: `{ "tickets": [ { "id", "external_ref", "title", "description", "column_name", "type", "color", "assignee_agent_id", "source", "created_at", "updated_at" }, ... ] }` + +### `POST /api/v1/tickets` — Ticket anlegen + +Auth: `X-Api-Key`. + +```json +// Request (nur "title" ist Pflicht) +{ "title": "Neues Bug-Ticket", "description": "...", "column": "backlog", "type": "bug", "color": "#ff0000" } +``` + +Response `201` mit dem angelegten Ticket, `422` bei fehlendem `title` oder ungueltiger +`column`. + +### `PATCH /api/v1/tickets/{id}` — Ticket verschieben/aendern + +Auth: `X-Api-Key`. Erlaubte Felder im Body: `column`, `title`, `description`, `type`, +`color`, `assignee_agent_id` (alle optional, nur uebergebene Felder werden geaendert). + +Response `200` mit dem aktualisierten Ticket, `404` falls unbekannt, `422` bei ungueltiger +`column`. + +## Events + +`severity`: `warning` (gelb) oder `error` (rot). Neuestes Event zuerst (F5). + +### `POST /api/v1/events` — Event melden + +Auth: `X-Api-Key`. + +```json +{ "system_id": "srv1", "name": "Ping", "message": "Host nicht erreichbar", "severity": "error" } +``` + +Pflichtfelder: `name`, `message`, `severity`. `occurred_at` optional (Default: jetzt). +Response `201` mit dem gespeicherten Event. + +### `GET /api/v1/events` — Event-Liste + +Query-Parameter: `limit` (Default `5`, entspricht der im Plan festgelegten Zeilenzahl des +Event-Monitors, F5; Maximum intern 200). + +Response: `{ "events": [ { "id", "system_id", "name", "message", "severity", "occurred_at", "created_at" }, ... ] }` + +## Systeme + +Bis zu 9 Kacheln (F6, `SystemRepository::MAX_TILES`), Fehler zuerst sortiert. Aktuell +werden Systeme zusaetzlich aus der bestehenden `status.txt` (geschrieben von +`check_fds.sh`) importiert; per API gemeldete Systeme (`source: "api"`) haben Vorrang und +werden vom Legacy-Import nicht ueberschrieben. + +### `POST /api/v1/systems/{id}/status` — Systemstatus melden + +Auth: `X-Api-Key`. + +```json +{ "status": "ok", "name": "Webserver", "type": "host", "detail": "optional" } +``` + +`status`: `ok` oder `error`. Response `200` mit dem gespeicherten System, `422` bei +ungueltigem `status`. + +### `GET /api/v1/systems` — Systemuebersicht + +Response: `{ "systems": [ { "id", "name", "type", "status", "detail", "source", "last_check_at" }, ... ] }` +(maximal 9 Eintraege). + +## KPIs + +F7: Konkrete Kennzahlen sind noch offen ("kommt spaeter"). Phase 1 stellt nur die +generische Speicher-/Abfragestruktur bereit. + +### `POST /api/v1/kpis/{key}` — KPI-Datenpunkt melden + +Auth: `X-Api-Key`. + +```json +{ "value": 12.5, "label": "Offene Tickets", "unit": "count", "timestamp": "2026-07-27T10:00:00Z" } +``` + +Pflichtfeld: `value` (numerisch). `label`/`unit`/`timestamp` optional. Response `201` mit +dem KPI inkl. Verlauf. + +### `GET /api/v1/kpis` — Alle KPIs + +Response: `{ "kpis": [ { "key", "label", "unit", "current_value", "history": [ { "value", "recorded_at" }, ... ] }, ... ] }` + +## Codeberg-Sync (Backlog/Done) + +`api/bin/sync-codeberg.php` (per Cron oder manuell auszufuehren) liest offene und +geschlossene Issues aus dem konfigurierten Codeberg-Repo (`CODEBERG_OWNER`/`CODEBERG_REPO`) +und gleicht sie ueber `external_ref` (= Issue-Nummer) mit den Tickets ab: + +- Offene Issues → Spalte `backlog` (nur falls das Ticket noch in `backlog`/`done` ist - + manuell auf dem Board bewegte Tickets werden nicht zurueckgesetzt). +- Geschlossene Issues → Spalte `done`. +- Ticket-Typ/-Farbe kommen vom ersten Issue-Label (Name/Farbe). + +Aufruf via Docker: `docker compose run --rm api php bin/sync-codeberg.php`. + +## Fehlerformat + +Alle Fehlerantworten haben die Form `{ "error": "‹Meldung›" }` mit passendem HTTP-Status: + +| Status | Bedeutung | +|---|---| +| `400` | Ungueltige Anfrage (z. B. unbekannter Endpunkt-Parameter) | +| `401` | Fehlender/ungueltiger `X-Api-Key`/`X-Admin-Key` | +| `403` | Aktion nicht erlaubt (z. B. fremde Agent-Aktivitaet melden) | +| `404` | Ressource nicht gefunden | +| `405` | HTTP-Methode fuer diesen Pfad nicht erlaubt | +| `422` | Validierungsfehler (Pflichtfeld fehlt, ungueltiger Enum-Wert) | diff --git a/api/.dockerignore b/api/.dockerignore new file mode 100644 index 0000000..671b17f --- /dev/null +++ b/api/.dockerignore @@ -0,0 +1,3 @@ +vendor/ +data/ +.git/ diff --git a/api/README.md b/api/README.md new file mode 100644 index 0000000..13c54f3 --- /dev/null +++ b/api/README.md @@ -0,0 +1,22 @@ +# monitor REST-API (Phase 1) + +Vanilla-PHP-REST-API (kein Framework, siehe Visualization.md Abschnitt 5) fuer Tickets, +Events, Systeme, KPIs und Agenten. Persistenz in SQLite (`api/data/monitor.sqlite`). + +Vollstaendige Endpunkt-Dokumentation: [../API.md](../API.md). + +## Entwicklung nur mit Docker (kein lokales PHP/Composer noetig) + +Aus dem Repo-Wurzelverzeichnis: + +```bash +cp .env.example .env # einmalig, MONITOR_ADMIN_API_KEY anpassen + +docker compose up --build # startet die API auf http://localhost:8080 +docker compose run --rm api composer test # PHPUnit inkl. Coverage +docker compose run --rm api php bin/sync-codeberg.php # Backlog/Done aus Codeberg ziehen +``` + +Composer-Abhaengigkeiten (nur PHPUnit als Dev-Dependency) werden beim ersten Start +automatisch **im Container** installiert (`docker/entrypoint.sh`) - auf dem Host ist +weder PHP noch Composer noch SQLite erforderlich. diff --git a/api/bin/sync-codeberg.php b/api/bin/sync-codeberg.php new file mode 100755 index 0000000..61ed31e --- /dev/null +++ b/api/bin/sync-codeberg.php @@ -0,0 +1,26 @@ +#!/usr/bin/env php +dbPath()); + +$service = new CodebergSyncService( + new CurlCodebergClient($config->codebergApiBase(), $config->codebergToken()), + new TicketRepository($db), + $config->codebergOwner(), + $config->codebergRepo(), +); + +$service->sync(); + +fwrite(STDOUT, "Codeberg-Sync abgeschlossen.\n"); diff --git a/api/composer.json b/api/composer.json new file mode 100644 index 0000000..76c54d1 --- /dev/null +++ b/api/composer.json @@ -0,0 +1,30 @@ +{ + "name": "fds2610/monitor-api", + "description": "REST-API fuer die Buero-Visualisierung von monitor (Tickets, Events, Systeme, KPIs, Agenten)", + "type": "project", + "license": "proprietary", + "require": { + "php": ">=8.1", + "ext-pdo": "*", + "ext-pdo_sqlite": "*", + "ext-json": "*", + "ext-curl": "*" + }, + "require-dev": { + "phpunit/phpunit": "^10.5" + }, + "autoload": { + "psr-4": { + "Monitor\\Api\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Monitor\\Api\\Tests\\": "tests/Unit/" + } + }, + "scripts": { + "test": "phpunit --coverage-text", + "serve": "php -S 0.0.0.0:8080 -t public" + } +} diff --git a/api/docker/Dockerfile b/api/docker/Dockerfile new file mode 100644 index 0000000..ade73ef --- /dev/null +++ b/api/docker/Dockerfile @@ -0,0 +1,18 @@ +FROM php:8.2-cli-alpine + +RUN apk add --no-cache curl-dev sqlite-dev \ + && apk add --no-cache --virtual .build-deps $PHPIZE_DEPS \ + && docker-php-ext-install pdo pdo_sqlite curl \ + && pecl install pcov \ + && docker-php-ext-enable pcov \ + && apk del .build-deps + +COPY --from=composer:2 /usr/bin/composer /usr/bin/composer + +WORKDIR /app + +COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh +RUN chmod +x /usr/local/bin/entrypoint.sh + +ENTRYPOINT ["entrypoint.sh"] +CMD ["php", "-S", "0.0.0.0:8080", "-t", "public"] diff --git a/api/docker/entrypoint.sh b/api/docker/entrypoint.sh new file mode 100755 index 0000000..27a6957 --- /dev/null +++ b/api/docker/entrypoint.sh @@ -0,0 +1,10 @@ +#!/bin/sh +# Installiert Abhaengigkeiten automatisch beim ersten Start im Container, damit auf dem +# Host ausser Docker nichts installiert werden muss (composer bleibt im Container). +set -e + +if [ ! -d /app/vendor ]; then + composer install --no-interaction --no-progress --working-dir=/app +fi + +exec "$@" diff --git a/api/phpunit.xml b/api/phpunit.xml new file mode 100644 index 0000000..b268752 --- /dev/null +++ b/api/phpunit.xml @@ -0,0 +1,16 @@ + + + + + tests/Unit + + + + + src + + + diff --git a/api/public/index.php b/api/public/index.php new file mode 100644 index 0000000..a603adc --- /dev/null +++ b/api/public/index.php @@ -0,0 +1,17 @@ +dbPath()); + +$app = new Application($db, $config); +$response = $app->handle(Request::fromGlobals()); +$response->send(); diff --git a/api/src/Application.php b/api/src/Application.php new file mode 100644 index 0000000..153056a --- /dev/null +++ b/api/src/Application.php @@ -0,0 +1,113 @@ +deskCount()); + $tickets = new TicketRepository($db); + $events = new EventRepository($db); + $systems = new SystemRepository($db); + $kpis = new KpiRepository($db); + + $this->agentAuth = new AgentAuthenticator($agents); + $this->adminAuth = new AdminAuthenticator($config->adminApiKey()); + + $agentController = new AgentController($agents); + $ticketController = new TicketController($tickets); + $eventController = new EventController($events); + $systemController = new SystemController($systems, new LegacyStatusFileReader(), $config->statusFilePath()); + $kpiController = new KpiController($kpis); + + $this->router = new Router(); + + $this->router->add('GET', '/api/v1/tickets', fn (Request $r, array $p) => $ticketController->index($r)); + $this->router->add('POST', '/api/v1/tickets', fn (Request $r, array $p) => $ticketController->create($r), 'agent'); + $this->router->add('PATCH', '/api/v1/tickets/{id}', fn (Request $r, array $p) => $ticketController->update($r, $p), 'agent'); + + $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('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('GET', '/api/v1/kpis', fn (Request $r, array $p) => $kpiController->index($r)); + $this->router->add('POST', '/api/v1/kpis/{key}', fn (Request $r, array $p) => $kpiController->report($r, $p), 'agent'); + + $this->router->add('GET', '/api/v1/agents', fn (Request $r, array $p) => $agentController->index($r)); + $this->router->add('POST', '/api/v1/agents', fn (Request $r, array $p) => $agentController->register($r), 'admin'); + $this->router->add( + 'POST', + '/api/v1/agents/{id}/activity', + fn (Request $r, array $p, array $agent) => $agentController->activity($r, $p, $agent), + 'agent' + ); + } + + public function handle(Request $request): Response + { + try { + $match = $this->router->match($request); + } catch (MethodNotAllowedException) { + return Response::error('Methode nicht erlaubt.', 405); + } + + if ($match === null) { + return Response::error('Nicht gefunden.', 404); + } + + $agent = null; + if ($match->auth === 'agent') { + $agent = $this->agentAuth->authenticate($request); + if ($agent === null) { + return Response::error('Ungueltiger oder fehlender X-Api-Key-Header.', 401); + } + } elseif ($match->auth === 'admin') { + if (!$this->adminAuth->authenticate($request)) { + return Response::error('Ungueltiger oder fehlender X-Admin-Key-Header.', 401); + } + } + + try { + return ($match->handler)($request, $match->params, $agent); + } catch (NotFoundException $e) { + return Response::error($e->getMessage(), 404); + } catch (InvalidArgumentException $e) { + return Response::error($e->getMessage(), 422); + } + } +} diff --git a/api/src/Auth/AdminAuthenticator.php b/api/src/Auth/AdminAuthenticator.php new file mode 100644 index 0000000..c667b09 --- /dev/null +++ b/api/src/Auth/AdminAuthenticator.php @@ -0,0 +1,30 @@ +adminApiKey === null || $this->adminApiKey === '') { + return false; + } + + $provided = $request->header('x-admin-key'); + + return $provided !== null && hash_equals($this->adminApiKey, $provided); + } +} diff --git a/api/src/Auth/AgentAuthenticator.php b/api/src/Auth/AgentAuthenticator.php new file mode 100644 index 0000000..3ab7071 --- /dev/null +++ b/api/src/Auth/AgentAuthenticator.php @@ -0,0 +1,30 @@ +|null Der authentifizierte Agent, oder null falls ungueltig. */ + public function authenticate(Request $request): ?array + { + $apiKey = $request->header('x-api-key'); + if ($apiKey === null || $apiKey === '') { + return null; + } + + return $this->agents->findByApiKey($apiKey); + } +} diff --git a/api/src/Auth/ApiKeyGenerator.php b/api/src/Auth/ApiKeyGenerator.php new file mode 100644 index 0000000..1a2d892 --- /dev/null +++ b/api/src/Auth/ApiKeyGenerator.php @@ -0,0 +1,23 @@ +env[$key] ?? getenv($key); + if ($value === false || $value === null || $value === '') { + return $default; + } + + return (string) $value; + } + + public function dbPath(): string + { + return $this->get('MONITOR_DB_PATH', dirname(__DIR__) . '/data/monitor.sqlite'); + } + + public function adminApiKey(): ?string + { + return $this->get('MONITOR_ADMIN_API_KEY'); + } + + public function statusFilePath(): string + { + return $this->get('MONITOR_STATUS_FILE', dirname(__DIR__, 2) . '/status.txt'); + } + + public function codebergApiBase(): string + { + return rtrim((string) $this->get('CODEBERG_API_BASE', 'https://codeberg.org/api/v1'), '/'); + } + + public function codebergOwner(): string + { + return (string) $this->get('CODEBERG_OWNER', 'fds2610'); + } + + public function codebergRepo(): string + { + return (string) $this->get('CODEBERG_REPO', 'monitor'); + } + + public function codebergToken(): ?string + { + return $this->get('CODEBERG_TOKEN'); + } + + public function deskCount(): int + { + return (int) $this->get('MONITOR_DESK_COUNT', '8'); + } +} diff --git a/api/src/Controller/AgentController.php b/api/src/Controller/AgentController.php new file mode 100644 index 0000000..d8e81ab --- /dev/null +++ b/api/src/Controller/AgentController.php @@ -0,0 +1,63 @@ + $this->agents->all()]); + } + + public function register(Request $request): Response + { + $displayName = trim((string) ($request->body['display_name'] ?? '')); + $role = (string) ($request->body['role'] ?? ''); + + if ($displayName === '') { + return Response::error('Feld "display_name" ist erforderlich.', 422); + } + + try { + $result = $this->agents->register($displayName, $role); + } catch (InvalidArgumentException $e) { + return Response::error($e->getMessage(), 422); + } + + // Der Klartext-API-Key wird nur bei der Registrierung einmalig zurueckgegeben. + return Response::json(['agent' => $result['agent'], 'api_key' => $result['api_key']], 201); + } + + /** + * @param array $params + * @param array $authenticatedAgent + */ + public function activity(Request $request, array $params, array $authenticatedAgent): Response + { + if ((int) $params['id'] !== (int) $authenticatedAgent['id']) { + return Response::error('Ein Agent darf nur seine eigene Aktivitaet melden.', 403); + } + + $state = (string) ($request->body['state'] ?? ''); + if ($state === '') { + return Response::error('Feld "state" ist erforderlich.', 422); + } + + $currentTicketId = isset($request->body['current_ticket']) ? (int) $request->body['current_ticket'] : null; + + $agent = $this->agents->updateActivity((int) $params['id'], $state, $currentTicketId); + + return Response::json(['agent' => $agent]); + } +} diff --git a/api/src/Controller/EventController.php b/api/src/Controller/EventController.php new file mode 100644 index 0000000..03409b0 --- /dev/null +++ b/api/src/Controller/EventController.php @@ -0,0 +1,41 @@ +query['limit']) ? (int) $request->query['limit'] : EventRepository::DEFAULT_LIMIT; + + return Response::json(['events' => $this->events->latest($limit)]); + } + + public function create(Request $request): Response + { + foreach (['name', 'message', 'severity'] as $field) { + if (!isset($request->body[$field]) || trim((string) $request->body[$field]) === '') { + return Response::error(sprintf('Feld "%s" ist erforderlich.', $field), 422); + } + } + + try { + $event = $this->events->create($request->body); + } catch (InvalidArgumentException $e) { + return Response::error($e->getMessage(), 422); + } + + return Response::json(['event' => $event], 201); + } +} diff --git a/api/src/Controller/KpiController.php b/api/src/Controller/KpiController.php new file mode 100644 index 0000000..fc209a8 --- /dev/null +++ b/api/src/Controller/KpiController.php @@ -0,0 +1,39 @@ + $this->kpis->all()]); + } + + /** @param array $params */ + public function report(Request $request, array $params): Response + { + if (!isset($request->body['value']) || !is_numeric($request->body['value'])) { + return Response::error('Feld "value" ist erforderlich und muss numerisch sein.', 422); + } + + $kpi = $this->kpis->report( + $params['key'], + (float) $request->body['value'], + isset($request->body['label']) ? (string) $request->body['label'] : null, + isset($request->body['unit']) ? (string) $request->body['unit'] : null, + isset($request->body['timestamp']) ? (string) $request->body['timestamp'] : null, + ); + + return Response::json(['kpi' => $kpi], 201); + } +} diff --git a/api/src/Controller/SystemController.php b/api/src/Controller/SystemController.php new file mode 100644 index 0000000..5c7096a --- /dev/null +++ b/api/src/Controller/SystemController.php @@ -0,0 +1,40 @@ +systems->importLegacyRows($this->legacyReader->read($this->statusFilePath)); + + return Response::json(['systems' => $this->systems->all()]); + } + + /** @param array $params */ + public function reportStatus(Request $request, array $params): Response + { + try { + $system = $this->systems->reportStatus($params['id'], $request->body); + } catch (InvalidArgumentException $e) { + return Response::error($e->getMessage(), 422); + } + + return Response::json(['system' => $system]); + } +} diff --git a/api/src/Controller/TicketController.php b/api/src/Controller/TicketController.php new file mode 100644 index 0000000..28dcbde --- /dev/null +++ b/api/src/Controller/TicketController.php @@ -0,0 +1,55 @@ +query['column'] ?? null; + + try { + return Response::json(['tickets' => $this->tickets->all($column !== null ? (string) $column : null)]); + } catch (InvalidArgumentException $e) { + return Response::error($e->getMessage(), 400); + } + } + + public function create(Request $request): Response + { + if (!isset($request->body['title']) || trim((string) $request->body['title']) === '') { + return Response::error('Feld "title" ist erforderlich.', 422); + } + + try { + $ticket = $this->tickets->create($request->body); + } catch (InvalidArgumentException $e) { + return Response::error($e->getMessage(), 422); + } + + return Response::json(['ticket' => $ticket], 201); + } + + /** @param array $params */ + public function update(Request $request, array $params): Response + { + try { + $ticket = $this->tickets->update((int) $params['id'], $request->body); + } catch (InvalidArgumentException $e) { + return Response::error($e->getMessage(), 422); + } + + return Response::json(['ticket' => $ticket]); + } +} diff --git a/api/src/Db/Database.php b/api/src/Db/Database.php new file mode 100644 index 0000000..238f3a0 --- /dev/null +++ b/api/src/Db/Database.php @@ -0,0 +1,66 @@ +pdo = new PDO($dsn); + $this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + $this->pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC); + $this->pdo->exec('PRAGMA foreign_keys = ON'); + } + + public static function forFile(string $path): self + { + $dir = dirname($path); + if (!is_dir($dir)) { + mkdir($dir, 0775, true); + } + + $db = new self('sqlite:' . $path); + $db->migrate(); + + return $db; + } + + public static function inMemory(): self + { + $db = new self('sqlite::memory:'); + $db->migrate(); + + return $db; + } + + public function migrate(): void + { + $migrationsDir = __DIR__ . '/migrations'; + $files = glob($migrationsDir . '/*.sql') ?: []; + sort($files); + + foreach ($files as $file) { + $this->pdo->exec((string) file_get_contents($file)); + } + } + + public function pdo(): PDO + { + return $this->pdo; + } + + public function now(): string + { + return gmdate('Y-m-d\TH:i:s\Z'); + } +} diff --git a/api/src/Db/migrations/001_initial.sql b/api/src/Db/migrations/001_initial.sql new file mode 100644 index 0000000..8ada7f7 --- /dev/null +++ b/api/src/Db/migrations/001_initial.sql @@ -0,0 +1,74 @@ +-- Initiales Schema fuer die monitor-REST-API (Phase 1). +-- Enum-artige Spalten werden per CHECK-Constraint abgesichert, da SQLite keine echten +-- ENUM-Typen kennt. Alle Zeitangaben werden als ISO-8601-UTC-Strings gespeichert. + +CREATE TABLE IF NOT EXISTS agents ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + display_name TEXT NOT NULL, + role TEXT NOT NULL CHECK (role IN ('entwickler', 'product_owner', 'qa', 'user')), + api_key_hash TEXT NOT NULL UNIQUE, + desk_index INTEGER, + state TEXT NOT NULL DEFAULT 'idle', + current_ticket_id INTEGER, + created_at TEXT NOT NULL, + last_seen_at TEXT, + FOREIGN KEY (current_ticket_id) REFERENCES tickets (id) +); + +CREATE TABLE IF NOT EXISTS tickets ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + external_ref TEXT, + title TEXT NOT NULL, + description TEXT, + column_name TEXT NOT NULL + CHECK (column_name IN ('backlog', 'bereit', 'in_arbeit', 'qa', 'testing', 'fertig', 'done')), + type TEXT, + color TEXT, + assignee_agent_id INTEGER, + source TEXT NOT NULL DEFAULT 'api' CHECK (source IN ('api', 'codeberg')), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY (assignee_agent_id) REFERENCES agents (id) +); + +CREATE INDEX IF NOT EXISTS idx_tickets_column ON tickets (column_name); +CREATE UNIQUE INDEX IF NOT EXISTS idx_tickets_external_ref ON tickets (external_ref) + WHERE external_ref IS NOT NULL; + +CREATE TABLE IF NOT EXISTS events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + system_id TEXT, + name TEXT NOT NULL, + message TEXT NOT NULL, + severity TEXT NOT NULL CHECK (severity IN ('warning', 'error')), + occurred_at TEXT NOT NULL, + created_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_events_occurred_at ON events (occurred_at DESC); + +CREATE TABLE IF NOT EXISTS systems ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + type TEXT NOT NULL DEFAULT 'host', + status TEXT NOT NULL CHECK (status IN ('ok', 'error')), + detail TEXT, + source TEXT NOT NULL CHECK (source IN ('legacy', 'api')), + last_check_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS kpis ( + key TEXT PRIMARY KEY, + label TEXT, + unit TEXT +); + +CREATE TABLE IF NOT EXISTS kpi_values ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + kpi_key TEXT NOT NULL, + value REAL NOT NULL, + recorded_at TEXT NOT NULL, + FOREIGN KEY (kpi_key) REFERENCES kpis (key) +); + +CREATE INDEX IF NOT EXISTS idx_kpi_values_key ON kpi_values (kpi_key, recorded_at DESC); diff --git a/api/src/Http/MethodNotAllowedException.php b/api/src/Http/MethodNotAllowedException.php new file mode 100644 index 0000000..ab094e9 --- /dev/null +++ b/api/src/Http/MethodNotAllowedException.php @@ -0,0 +1,9 @@ + $headers */ + private function __construct( + public readonly string $method, + public readonly string $path, + public readonly array $query, + public readonly array $body, + private readonly array $headers, + ) { + } + + public static function fromGlobals(): self + { + $method = strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET'); + $uri = $_SERVER['REQUEST_URI'] ?? '/'; + $path = rtrim((string) parse_url($uri, PHP_URL_PATH), '/'); + if ($path === '') { + $path = '/'; + } + + parse_str((string) (parse_url($uri, PHP_URL_QUERY) ?? ''), $query); + + $rawBody = file_get_contents('php://input') ?: ''; + $body = []; + if ($rawBody !== '') { + $decoded = json_decode($rawBody, true); + if (is_array($decoded)) { + $body = $decoded; + } + } + + $headers = []; + foreach ($_SERVER as $key => $value) { + if (str_starts_with($key, 'HTTP_')) { + $name = str_replace('_', '-', strtolower(substr($key, 5))); + $headers[$name] = (string) $value; + } + } + if (isset($_SERVER['CONTENT_TYPE'])) { + $headers['content-type'] = (string) $_SERVER['CONTENT_TYPE']; + } + + return new self($method, $path, $query, $body, $headers); + } + + /** @param array $body */ + public static function create(string $method, string $path, array $query = [], array $body = [], array $headers = []): self + { + $normalized = []; + foreach ($headers as $name => $value) { + $normalized[strtolower($name)] = $value; + } + + return new self($method, rtrim($path, '/') ?: '/', $query, $body, $normalized); + } + + public function header(string $name): ?string + { + return $this->headers[strtolower($name)] ?? null; + } +} diff --git a/api/src/Http/Response.php b/api/src/Http/Response.php new file mode 100644 index 0000000..9b60c7b --- /dev/null +++ b/api/src/Http/Response.php @@ -0,0 +1,38 @@ + $message]); + } + + public function send(): void + { + http_response_code($this->status); + header('Content-Type: application/json; charset=utf-8'); + echo json_encode($this->data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + } + + public function body(): mixed + { + return $this->data; + } +} diff --git a/api/src/Http/Router.php b/api/src/Http/Router.php new file mode 100644 index 0000000..587f8f1 --- /dev/null +++ b/api/src/Http/Router.php @@ -0,0 +1,73 @@ + $params */ + public function __construct( + public readonly \Closure $handler, + public readonly array $params, + public readonly ?string $auth, + ) { + } +} + +/** + * Minimalistischer Pfad-Router (kein Framework). Unterstuetzt Platzhalter der Form + * "{id}" innerhalb eines Segments. + */ +final class Router +{ + /** @var array */ + private array $routes = []; + + public function add(string $method, string $pattern, \Closure $handler, ?string $auth = null): void + { + $paramNames = []; + $regex = preg_replace_callback('/\{(\w+)\}/', function (array $m) use (&$paramNames): string { + $paramNames[] = $m[1]; + return '(?P<' . $m[1] . '>[^/]+)'; + }, $pattern); + + $this->routes[] = [ + 'method' => strtoupper($method), + 'pattern' => $pattern, + 'regex' => '#^' . $regex . '$#', + 'params' => $paramNames, + 'handler' => $handler, + 'auth' => $auth, + ]; + } + + public function match(Request $request): ?RouteMatch + { + $methodMatchedElsewhere = false; + + foreach ($this->routes as $route) { + if (!preg_match($route['regex'], $request->path, $matches)) { + continue; + } + + if ($route['method'] !== $request->method) { + $methodMatchedElsewhere = true; + continue; + } + + $params = []; + foreach ($route['params'] as $name) { + $params[$name] = $matches[$name]; + } + + return new RouteMatch($route['handler'], $params, $route['auth']); + } + + if ($methodMatchedElsewhere) { + throw new MethodNotAllowedException(); + } + + return null; + } +} diff --git a/api/src/Repository/AgentRepository.php b/api/src/Repository/AgentRepository.php new file mode 100644 index 0000000..f060482 --- /dev/null +++ b/api/src/Repository/AgentRepository.php @@ -0,0 +1,131 @@ +, api_key: string} + */ + public function register(string $displayName, string $role): array + { + if (!in_array($role, self::ROLES, true)) { + throw new InvalidArgumentException('Unbekannte Rolle: ' . $role); + } + + $deskIndex = $role === 'user' ? null : $this->findFreeDesk(); + + $apiKey = ApiKeyGenerator::generate(); + $now = $this->db->now(); + + $stmt = $this->db->pdo()->prepare( + 'INSERT INTO agents (display_name, role, api_key_hash, desk_index, state, created_at, last_seen_at) + VALUES (:display_name, :role, :api_key_hash, :desk_index, :state, :created_at, :created_at)' + ); + $stmt->execute([ + 'display_name' => $displayName, + 'role' => $role, + 'api_key_hash' => ApiKeyGenerator::hash($apiKey), + 'desk_index' => $deskIndex, + 'state' => 'idle', + 'created_at' => $now, + ]); + + $agent = $this->findById((int) $this->db->pdo()->lastInsertId()); + + return ['agent' => $agent, 'api_key' => $apiKey]; + } + + private function findFreeDesk(): ?int + { + $stmt = $this->db->pdo()->query( + 'SELECT desk_index FROM agents WHERE desk_index IS NOT NULL' + ); + $occupied = array_map('intval', array_column($stmt->fetchAll(), 'desk_index')); + + for ($i = 0; $i < $this->deskCount; $i++) { + if (!in_array($i, $occupied, true)) { + return $i; + } + } + + return null; + } + + /** @return array|null */ + public function findById(int $id): ?array + { + $stmt = $this->db->pdo()->prepare('SELECT * FROM agents WHERE id = :id'); + $stmt->execute(['id' => $id]); + $row = $stmt->fetch(); + + return $row === false ? null : $this->hidingKey($row); + } + + /** @return array|null */ + public function findByApiKey(string $apiKey): ?array + { + $stmt = $this->db->pdo()->prepare('SELECT * FROM agents WHERE api_key_hash = :hash'); + $stmt->execute(['hash' => ApiKeyGenerator::hash($apiKey)]); + $row = $stmt->fetch(); + + return $row === false ? null : $this->hidingKey($row); + } + + /** @return array> */ + public function all(): array + { + $stmt = $this->db->pdo()->query('SELECT * FROM agents ORDER BY id ASC'); + + return array_map(fn (array $row) => $this->hidingKey($row), $stmt->fetchAll()); + } + + /** @return array */ + public function updateActivity(int $id, string $state, ?int $currentTicketId): array + { + $stmt = $this->db->pdo()->prepare( + 'UPDATE agents SET state = :state, current_ticket_id = :ticket_id, last_seen_at = :now WHERE id = :id' + ); + $stmt->execute([ + 'state' => $state, + 'ticket_id' => $currentTicketId, + 'now' => $this->db->now(), + 'id' => $id, + ]); + + $agent = $this->findById($id); + if ($agent === null) { + throw new NotFoundException('Agent nicht gefunden: ' . $id); + } + + return $agent; + } + + /** @param array $row @return array */ + private function hidingKey(array $row): array + { + unset($row['api_key_hash']); + + return $row; + } +} diff --git a/api/src/Repository/EventRepository.php b/api/src/Repository/EventRepository.php new file mode 100644 index 0000000..ba62ddb --- /dev/null +++ b/api/src/Repository/EventRepository.php @@ -0,0 +1,73 @@ + $data @return array */ + public function create(array $data): array + { + $severity = $data['severity'] ?? null; + if (!in_array($severity, self::SEVERITIES, true)) { + throw new InvalidArgumentException('Ungueltige severity: ' . var_export($severity, true)); + } + + $now = $this->db->now(); + $stmt = $this->db->pdo()->prepare( + 'INSERT INTO events (system_id, name, message, severity, occurred_at, created_at) + VALUES (:system_id, :name, :message, :severity, :occurred_at, :created_at)' + ); + $stmt->execute([ + 'system_id' => $data['system_id'] ?? null, + 'name' => $data['name'], + 'message' => $data['message'], + 'severity' => $severity, + 'occurred_at' => $data['occurred_at'] ?? $now, + 'created_at' => $now, + ]); + + return $this->find((int) $this->db->pdo()->lastInsertId()); + } + + /** @return array|null */ + public function find(int $id): ?array + { + $stmt = $this->db->pdo()->prepare('SELECT * FROM events WHERE id = :id'); + $stmt->execute(['id' => $id]); + $row = $stmt->fetch(); + + return $row === false ? null : $row; + } + + /** + * Neuestes Event zuerst (F5: "Der neueste Event erscheint oben"). Default-Limit 5 + * entspricht der im Plan festgelegten maximalen Zeilenzahl des Event-Monitors. + * + * @return array> + */ + public function latest(int $limit = self::DEFAULT_LIMIT): array + { + $limit = max(1, min($limit, 200)); + $stmt = $this->db->pdo()->prepare( + 'SELECT * FROM events ORDER BY occurred_at DESC, id DESC LIMIT :limit' + ); + $stmt->bindValue('limit', $limit, \PDO::PARAM_INT); + $stmt->execute(); + + return $stmt->fetchAll(); + } +} diff --git a/api/src/Repository/KpiRepository.php b/api/src/Repository/KpiRepository.php new file mode 100644 index 0000000..693f0c2 --- /dev/null +++ b/api/src/Repository/KpiRepository.php @@ -0,0 +1,80 @@ + */ + public function report(string $key, float $value, ?string $label, ?string $unit, ?string $timestamp): array + { + $stmt = $this->db->pdo()->prepare( + 'INSERT INTO kpis (key, label, unit) VALUES (:key, :label, :unit) + ON CONFLICT(key) DO UPDATE SET + label = COALESCE(excluded.label, kpis.label), + unit = COALESCE(excluded.unit, kpis.unit)' + ); + $stmt->execute(['key' => $key, 'label' => $label, 'unit' => $unit]); + + $stmt = $this->db->pdo()->prepare( + 'INSERT INTO kpi_values (kpi_key, value, recorded_at) VALUES (:key, :value, :recorded_at)' + ); + $stmt->execute([ + 'key' => $key, + 'value' => $value, + 'recorded_at' => $timestamp ?? $this->db->now(), + ]); + + return $this->find($key); + } + + /** @return array|null */ + public function find(string $key): ?array + { + $stmt = $this->db->pdo()->prepare('SELECT * FROM kpis WHERE key = :key'); + $stmt->execute(['key' => $key]); + $kpi = $stmt->fetch(); + if ($kpi === false) { + return null; + } + + $kpi['history'] = $this->history($key); + $kpi['current_value'] = $kpi['history'][0]['value'] ?? null; + + return $kpi; + } + + /** @return array> */ + private function history(string $key): array + { + $stmt = $this->db->pdo()->prepare( + 'SELECT value, recorded_at FROM kpi_values WHERE kpi_key = :key ORDER BY recorded_at DESC, id DESC LIMIT :limit' + ); + $stmt->bindValue('key', $key); + $stmt->bindValue('limit', $this->historyLimit, \PDO::PARAM_INT); + $stmt->execute(); + + return $stmt->fetchAll(); + } + + /** @return array> */ + public function all(): array + { + $stmt = $this->db->pdo()->query('SELECT key FROM kpis ORDER BY key ASC'); + $keys = array_column($stmt->fetchAll(), 'key'); + + return array_map(fn (string $key) => $this->find($key), $keys); + } +} diff --git a/api/src/Repository/LegacyStatusFileReader.php b/api/src/Repository/LegacyStatusFileReader.php new file mode 100644 index 0000000..63b25c1 --- /dev/null +++ b/api/src/Repository/LegacyStatusFileReader.php @@ -0,0 +1,44 @@ + " - eine Zeile pro Host. + * Bleibt bestehen, bis Agenten den Systemstatus vollstaendig per REST melden (F6). + */ +final class LegacyStatusFileReader +{ + /** @return array */ + public function read(string $path): array + { + if (!is_file($path) || !is_readable($path)) { + return []; + } + + $rows = []; + $lines = file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: []; + + foreach ($lines as $line) { + $parts = preg_split('/\s+/', trim($line)); + if ($parts === false || count($parts) < 4) { + continue; + } + + [$hostname, $ip, $status] = $parts; + $timestamp = implode(' ', array_slice($parts, 3)); + + $rows[] = [ + 'id' => $hostname, + 'name' => $hostname, + 'status' => $status === 'online' ? 'ok' : 'error', + 'detail' => $ip, + 'last_check_at' => $timestamp, + ]; + } + + return $rows; + } +} diff --git a/api/src/Repository/SystemRepository.php b/api/src/Repository/SystemRepository.php new file mode 100644 index 0000000..cf00294 --- /dev/null +++ b/api/src/Repository/SystemRepository.php @@ -0,0 +1,105 @@ + $data @return array */ + public function reportStatus(string $id, array $data): array + { + $status = $data['status'] ?? null; + if (!in_array($status, self::STATUSES, true)) { + throw new InvalidArgumentException('Ungueltiger status: ' . var_export($status, true)); + } + + $stmt = $this->db->pdo()->prepare( + 'INSERT INTO systems (id, name, type, status, detail, source, last_check_at) + VALUES (:id, :name, :type, :status, :detail, :source, :last_check_at) + ON CONFLICT(id) DO UPDATE SET + name = excluded.name, type = excluded.type, status = excluded.status, + detail = excluded.detail, source = excluded.source, last_check_at = excluded.last_check_at' + ); + $stmt->execute([ + 'id' => $id, + 'name' => $data['name'] ?? $id, + 'type' => $data['type'] ?? 'host', + 'status' => $status, + 'detail' => $data['detail'] ?? null, + 'source' => 'api', + 'last_check_at' => $this->db->now(), + ]); + + return $this->find($id); + } + + /** + * Importiert Zeilen aus der bestehenden status.txt (F6). API-seitig gemeldete Systeme + * ("source = api") werden dabei nicht ueberschrieben, da sie aktueller/verbindlicher sind. + * + * @param array $rows + */ + public function importLegacyRows(array $rows): void + { + $stmt = $this->db->pdo()->prepare( + 'INSERT INTO systems (id, name, type, status, detail, source, last_check_at) + VALUES (:id, :name, :type, :status, :detail, :source, :last_check_at) + ON CONFLICT(id) DO UPDATE SET + name = excluded.name, status = excluded.status, + detail = excluded.detail, last_check_at = excluded.last_check_at + WHERE systems.source = :source' + ); + + foreach ($rows as $row) { + $stmt->execute([ + 'id' => $row['id'], + 'name' => $row['name'], + 'type' => 'host', + 'status' => $row['status'], + 'detail' => $row['detail'], + 'source' => 'legacy', + 'last_check_at' => $row['last_check_at'], + ]); + } + } + + /** @return array|null */ + public function find(string $id): ?array + { + $stmt = $this->db->pdo()->prepare('SELECT * FROM systems WHERE id = :id'); + $stmt->execute(['id' => $id]); + $row = $stmt->fetch(); + + return $row === false ? null : $row; + } + + /** + * Fehler zuerst, danach nach letztem Check sortiert, gedeckelt auf MAX_TILES (F6: + * "es werden maximal 9 Kacheln dargestellt"). + * + * @return array> + */ + public function all(): array + { + $stmt = $this->db->pdo()->prepare( + "SELECT * FROM systems ORDER BY (status = 'error') DESC, last_check_at DESC LIMIT :limit" + ); + $stmt->bindValue('limit', self::MAX_TILES, \PDO::PARAM_INT); + $stmt->execute(); + + return $stmt->fetchAll(); + } +} diff --git a/api/src/Repository/TicketRepository.php b/api/src/Repository/TicketRepository.php new file mode 100644 index 0000000..9eb9a66 --- /dev/null +++ b/api/src/Repository/TicketRepository.php @@ -0,0 +1,164 @@ + $data @return array */ + public function create(array $data): array + { + $column = $data['column'] ?? 'backlog'; + $this->assertValidColumn($column); + + $now = $this->db->now(); + $stmt = $this->db->pdo()->prepare( + 'INSERT INTO tickets (external_ref, title, description, column_name, type, color, assignee_agent_id, source, created_at, updated_at) + VALUES (:external_ref, :title, :description, :column_name, :type, :color, :assignee_agent_id, :source, :created_at, :updated_at)' + ); + $stmt->execute([ + 'external_ref' => $data['external_ref'] ?? null, + 'title' => $data['title'], + 'description' => $data['description'] ?? null, + 'column_name' => $column, + 'type' => $data['type'] ?? null, + 'color' => $data['color'] ?? null, + 'assignee_agent_id' => $data['assignee_agent_id'] ?? null, + 'source' => $data['source'] ?? 'api', + 'created_at' => $now, + 'updated_at' => $now, + ]); + + return $this->find((int) $this->db->pdo()->lastInsertId()); + } + + /** @param array $changes @return array */ + public function update(int $id, array $changes): array + { + if ($this->find($id) === null) { + throw new NotFoundException('Ticket nicht gefunden: ' . $id); + } + + $fields = []; + $params = ['id' => $id]; + + if (array_key_exists('column', $changes)) { + $this->assertValidColumn($changes['column']); + $fields[] = 'column_name = :column_name'; + $params['column_name'] = $changes['column']; + } + foreach (['title', 'description', 'type', 'color'] as $key) { + if (array_key_exists($key, $changes)) { + $fields[] = "$key = :$key"; + $params[$key] = $changes[$key]; + } + } + if (array_key_exists('assignee_agent_id', $changes)) { + $fields[] = 'assignee_agent_id = :assignee_agent_id'; + $params['assignee_agent_id'] = $changes['assignee_agent_id']; + } + + if ($fields === []) { + return $this->find($id); + } + + $fields[] = 'updated_at = :updated_at'; + $params['updated_at'] = $this->db->now(); + + $sql = 'UPDATE tickets SET ' . implode(', ', $fields) . ' WHERE id = :id'; + $this->db->pdo()->prepare($sql)->execute($params); + + return $this->find($id); + } + + /** @return array|null */ + public function find(int $id): ?array + { + $stmt = $this->db->pdo()->prepare('SELECT * FROM tickets WHERE id = :id'); + $stmt->execute(['id' => $id]); + $row = $stmt->fetch(); + + return $row === false ? null : $row; + } + + /** @return array|null */ + public function findByExternalRef(string $externalRef): ?array + { + $stmt = $this->db->pdo()->prepare('SELECT * FROM tickets WHERE external_ref = :ref'); + $stmt->execute(['ref' => $externalRef]); + $row = $stmt->fetch(); + + return $row === false ? null : $row; + } + + /** + * Liefert Tickets sortiert nach Spalte und Alter, damit das Frontend die "aelteste 4 + + * Stapel"-Regel (F4) ohne weitere API-Unterstuetzung umsetzen kann. + * + * @return array> + */ + public function all(?string $column = null): array + { + if ($column !== null) { + $this->assertValidColumn($column); + $stmt = $this->db->pdo()->prepare( + 'SELECT * FROM tickets WHERE column_name = :column ORDER BY created_at ASC, id ASC' + ); + $stmt->execute(['column' => $column]); + + return $stmt->fetchAll(); + } + + $stmt = $this->db->pdo()->query('SELECT * FROM tickets ORDER BY column_name ASC, created_at ASC, id ASC'); + + return $stmt->fetchAll(); + } + + /** + * Legt ein Ticket aus einem Codeberg-Issue an oder aktualisiert es (Abgleich ueber + * external_ref). Wird vom Codeberg-Sync fuer Backlog/Done verwendet (F4). + * + * @param array $data + * @return array + */ + public function upsertFromCodeberg(array $data): array + { + $existing = $this->findByExternalRef((string) $data['external_ref']); + if ($existing === null) { + return $this->create($data + ['source' => 'codeberg']); + } + + // Manuell auf dem Board bewegte Tickets (z. B. in_arbeit/qa/testing) werden vom + // periodischen Codeberg-Sync nicht zurueckgesetzt - nur Backlog/Done werden gepflegt. + if (!in_array($existing['column_name'], ['backlog', 'done'], true)) { + return $existing; + } + + return $this->update((int) $existing['id'], [ + 'column' => $data['column'], + 'title' => $data['title'], + 'description' => $data['description'] ?? null, + 'type' => $data['type'] ?? null, + 'color' => $data['color'] ?? null, + ]); + } + + private function assertValidColumn(mixed $column): void + { + if (!is_string($column) || !in_array($column, self::COLUMNS, true)) { + throw new InvalidArgumentException('Ungueltige Spalte: ' . var_export($column, true)); + } + } +} diff --git a/api/src/Sync/CodebergClient.php b/api/src/Sync/CodebergClient.php new file mode 100644 index 0000000..e555d92 --- /dev/null +++ b/api/src/Sync/CodebergClient.php @@ -0,0 +1,16 @@ +}> + */ + public function listIssues(string $owner, string $repo, string $state): array; +} diff --git a/api/src/Sync/CodebergSyncService.php b/api/src/Sync/CodebergSyncService.php new file mode 100644 index 0000000..822e9c7 --- /dev/null +++ b/api/src/Sync/CodebergSyncService.php @@ -0,0 +1,53 @@ +client->listIssues($this->owner, $this->repo, 'open') as $issue) { + $this->tickets->upsertFromCodeberg($this->mapIssue($issue, 'backlog')); + } + + foreach ($this->client->listIssues($this->owner, $this->repo, 'closed') as $issue) { + $this->tickets->upsertFromCodeberg($this->mapIssue($issue, 'done')); + } + } + + /** + * @param array{number: int, title: string, body: ?string, labels: array} $issue + * @return array + */ + private function mapIssue(array $issue, string $column): array + { + $firstLabel = $issue['labels'][0] ?? null; + + return [ + 'external_ref' => (string) $issue['number'], + 'title' => $issue['title'], + 'description' => $issue['body'], + 'column' => $column, + 'type' => $firstLabel['name'] ?? null, + 'color' => $firstLabel['color'] ?? null, + ]; + } +} diff --git a/api/src/Sync/CurlCodebergClient.php b/api/src/Sync/CurlCodebergClient.php new file mode 100644 index 0000000..b264840 --- /dev/null +++ b/api/src/Sync/CurlCodebergClient.php @@ -0,0 +1,93 @@ +apiBase, + rawurlencode($owner), + rawurlencode($repo), + rawurlencode($state), + $page, + self::PER_PAGE + ); + + $pageIssues = $this->fetch($url); + if ($pageIssues === []) { + break; + } + + foreach ($pageIssues as $issue) { + $issues[] = [ + 'number' => (int) $issue['number'], + 'title' => (string) $issue['title'], + 'body' => isset($issue['body']) ? (string) $issue['body'] : null, + 'labels' => array_map( + static fn (array $label): array => [ + 'name' => (string) $label['name'], + 'color' => '#' . ltrim((string) $label['color'], '#'), + ], + $issue['labels'] ?? [] + ), + ]; + } + + if (count($pageIssues) < self::PER_PAGE) { + break; + } + } + + return $issues; + } + + /** @return array> */ + private function fetch(string $url): array + { + $headers = ['Accept: application/json']; + if ($this->token !== null && $this->token !== '') { + $headers[] = 'Authorization: token ' . $this->token; + } + + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HTTPHEADER => $headers, + CURLOPT_TIMEOUT => 15, + ]); + $body = curl_exec($ch); + $status = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $error = curl_error($ch); + curl_close($ch); + + if ($body === false) { + throw new RuntimeException('Codeberg-API nicht erreichbar: ' . $error); + } + if ($status >= 400) { + throw new RuntimeException('Codeberg-API-Fehler (HTTP ' . $status . '): ' . $body); + } + + $decoded = json_decode((string) $body, true); + + return is_array($decoded) ? $decoded : []; + } +} diff --git a/api/tests/Unit/ApplicationTest.php b/api/tests/Unit/ApplicationTest.php new file mode 100644 index 0000000..02924b4 --- /dev/null +++ b/api/tests/Unit/ApplicationTest.php @@ -0,0 +1,95 @@ + 'admin-secret', 'MONITOR_STATUS_FILE' => '/does/not/exist']); + $this->app = new Application(Database::inMemory(), $config); + } + + public function testUnknownRouteReturns404(): void + { + $response = $this->app->handle(Request::create('GET', '/nope')); + + self::assertSame(404, $response->status); + } + + public function testWrongMethodReturns405(): void + { + $response = $this->app->handle(Request::create('DELETE', '/api/v1/tickets')); + + self::assertSame(405, $response->status); + } + + public function testWritingWithoutAgentKeyReturns401(): void + { + $response = $this->app->handle(Request::create('POST', '/api/v1/tickets', body: ['title' => 'X'])); + + self::assertSame(401, $response->status); + } + + public function testRegisterAgentRequiresAdminKey(): void + { + $response = $this->app->handle(Request::create('POST', '/api/v1/agents', body: [ + 'display_name' => 'Alice', 'role' => 'entwickler', + ])); + + self::assertSame(401, $response->status); + } + + public function testFullFlowRegisterAgentThenCreateTicket(): void + { + $registerResponse = $this->app->handle(Request::create( + 'POST', + '/api/v1/agents', + body: ['display_name' => 'Alice', 'role' => 'entwickler'], + headers: ['X-Admin-Key' => 'admin-secret'] + )); + self::assertSame(201, $registerResponse->status); + $apiKey = $registerResponse->body()['api_key']; + + $createResponse = $this->app->handle(Request::create( + 'POST', + '/api/v1/tickets', + body: ['title' => 'Neues Ticket'], + headers: ['X-Api-Key' => $apiKey] + )); + self::assertSame(201, $createResponse->status); + + $listResponse = $this->app->handle(Request::create('GET', '/api/v1/tickets')); + self::assertCount(1, $listResponse->body()['tickets']); + } + + public function testPatchUnknownTicketReturns404(): void + { + $registerResponse = $this->app->handle(Request::create( + 'POST', + '/api/v1/agents', + body: ['display_name' => 'Alice', 'role' => 'entwickler'], + headers: ['X-Admin-Key' => 'admin-secret'] + )); + $apiKey = $registerResponse->body()['api_key']; + + $response = $this->app->handle(Request::create( + 'PATCH', + '/api/v1/tickets/999', + body: ['column' => 'bereit'], + headers: ['X-Api-Key' => $apiKey] + )); + + self::assertSame(404, $response->status); + } +} diff --git a/api/tests/Unit/Auth/AdminAuthenticatorTest.php b/api/tests/Unit/Auth/AdminAuthenticatorTest.php new file mode 100644 index 0000000..0d9d70b --- /dev/null +++ b/api/tests/Unit/Auth/AdminAuthenticatorTest.php @@ -0,0 +1,40 @@ +authenticate(Request::create('POST', '/x', headers: ['X-Admin-Key' => 'secret']))); + } + + public function testAuthenticateRejectsWrongKey(): void + { + $auth = new AdminAuthenticator('secret'); + + self::assertFalse($auth->authenticate(Request::create('POST', '/x', headers: ['X-Admin-Key' => 'wrong']))); + } + + public function testAuthenticateRejectsWhenNoAdminKeyConfigured(): void + { + $auth = new AdminAuthenticator(null); + + self::assertFalse($auth->authenticate(Request::create('POST', '/x', headers: ['X-Admin-Key' => 'anything']))); + } + + public function testAuthenticateRejectsMissingHeader(): void + { + $auth = new AdminAuthenticator('secret'); + + self::assertFalse($auth->authenticate(Request::create('POST', '/x'))); + } +} diff --git a/api/tests/Unit/Auth/AgentAuthenticatorTest.php b/api/tests/Unit/Auth/AgentAuthenticatorTest.php new file mode 100644 index 0000000..39cc98d --- /dev/null +++ b/api/tests/Unit/Auth/AgentAuthenticatorTest.php @@ -0,0 +1,47 @@ +register('Alice', 'entwickler'); + $auth = new AgentAuthenticator($agents); + + $request = Request::create('GET', '/x', headers: ['X-Api-Key' => $registration['api_key']]); + + $result = $auth->authenticate($request); + + self::assertNotNull($result); + self::assertSame($registration['agent']['id'], $result['id']); + } + + public function testAuthenticateReturnsNullWithoutHeader(): void + { + $agents = new AgentRepository(Database::inMemory()); + $auth = new AgentAuthenticator($agents); + + self::assertNull($auth->authenticate(Request::create('GET', '/x'))); + } + + public function testAuthenticateReturnsNullForWrongKey(): void + { + $agents = new AgentRepository(Database::inMemory()); + $agents->register('Alice', 'entwickler'); + $auth = new AgentAuthenticator($agents); + + $request = Request::create('GET', '/x', headers: ['X-Api-Key' => 'wrong']); + + self::assertNull($auth->authenticate($request)); + } +} diff --git a/api/tests/Unit/Auth/ApiKeyGeneratorTest.php b/api/tests/Unit/Auth/ApiKeyGeneratorTest.php new file mode 100644 index 0000000..db07045 --- /dev/null +++ b/api/tests/Unit/Auth/ApiKeyGeneratorTest.php @@ -0,0 +1,28 @@ +codebergApiBase()); + self::assertSame('fds2610', $config->codebergOwner()); + self::assertSame('monitor', $config->codebergRepo()); + self::assertNull($config->codebergToken()); + self::assertNull($config->adminApiKey()); + self::assertSame(8, $config->deskCount()); + } + + public function testValuesCanBeOverridden(): void + { + $config = new Config([ + 'CODEBERG_API_BASE' => 'https://example.org/api/v1/', + 'CODEBERG_OWNER' => 'someone', + 'CODEBERG_REPO' => 'somerepo', + 'CODEBERG_TOKEN' => 'tok', + 'MONITOR_ADMIN_API_KEY' => 'secret', + 'MONITOR_DESK_COUNT' => '3', + ]); + + self::assertSame('https://example.org/api/v1', $config->codebergApiBase()); + self::assertSame('someone', $config->codebergOwner()); + self::assertSame('somerepo', $config->codebergRepo()); + self::assertSame('tok', $config->codebergToken()); + self::assertSame('secret', $config->adminApiKey()); + self::assertSame(3, $config->deskCount()); + } +} diff --git a/api/tests/Unit/Controller/AgentControllerTest.php b/api/tests/Unit/Controller/AgentControllerTest.php new file mode 100644 index 0000000..5cf9ca8 --- /dev/null +++ b/api/tests/Unit/Controller/AgentControllerTest.php @@ -0,0 +1,101 @@ +repository = new AgentRepository(Database::inMemory()); + $this->controller = new AgentController($this->repository); + } + + public function testRegisterRejectsMissingDisplayName(): void + { + $response = $this->controller->register(Request::create('POST', '/api/v1/agents', body: ['role' => 'entwickler'])); + + self::assertSame(422, $response->status); + } + + public function testRegisterRejectsUnknownRole(): void + { + $response = $this->controller->register(Request::create('POST', '/api/v1/agents', body: [ + 'display_name' => 'Alice', 'role' => 'ceo', + ])); + + self::assertSame(422, $response->status); + } + + public function testRegisterReturns201WithApiKey(): void + { + $response = $this->controller->register(Request::create('POST', '/api/v1/agents', body: [ + 'display_name' => 'Alice', 'role' => 'entwickler', + ])); + + self::assertSame(201, $response->status); + self::assertArrayHasKey('api_key', $response->body()); + self::assertArrayNotHasKey('api_key_hash', $response->body()['agent']); + } + + public function testActivityRejectsMismatchedAgentId(): void + { + $registration = $this->repository->register('Alice', 'entwickler'); + $other = $this->repository->register('Bob', 'qa'); + + $response = $this->controller->activity( + Request::create('POST', '/x', body: ['state' => 'working']), + ['id' => (string) $other['agent']['id']], + $registration['agent'] + ); + + self::assertSame(403, $response->status); + } + + public function testActivityRejectsMissingState(): void + { + $registration = $this->repository->register('Alice', 'entwickler'); + + $response = $this->controller->activity( + Request::create('POST', '/x', body: []), + ['id' => (string) $registration['agent']['id']], + $registration['agent'] + ); + + self::assertSame(422, $response->status); + } + + public function testActivityUpdatesOwnState(): void + { + $registration = $this->repository->register('Alice', 'entwickler'); + + $response = $this->controller->activity( + Request::create('POST', '/x', body: ['state' => 'working', 'current_ticket' => 7]), + ['id' => (string) $registration['agent']['id']], + $registration['agent'] + ); + + self::assertSame(200, $response->status); + self::assertSame('working', $response->body()['agent']['state']); + self::assertSame(7, $response->body()['agent']['current_ticket_id']); + } + + public function testIndexListsAgents(): void + { + $this->repository->register('Alice', 'entwickler'); + + $response = $this->controller->index(Request::create('GET', '/api/v1/agents')); + + self::assertCount(1, $response->body()['agents']); + } +} diff --git a/api/tests/Unit/Controller/EventControllerTest.php b/api/tests/Unit/Controller/EventControllerTest.php new file mode 100644 index 0000000..dd917da --- /dev/null +++ b/api/tests/Unit/Controller/EventControllerTest.php @@ -0,0 +1,58 @@ +controller = new EventController(new EventRepository(Database::inMemory())); + } + + public function testCreateRejectsMissingFields(): void + { + $response = $this->controller->create(Request::create('POST', '/api/v1/events', body: ['name' => 'X'])); + + self::assertSame(422, $response->status); + } + + public function testCreateRejectsInvalidSeverity(): void + { + $response = $this->controller->create(Request::create('POST', '/api/v1/events', body: [ + 'name' => 'X', 'message' => 'Y', 'severity' => 'info', + ])); + + self::assertSame(422, $response->status); + } + + public function testCreateReturns201(): void + { + $response = $this->controller->create(Request::create('POST', '/api/v1/events', body: [ + 'name' => 'Ping', 'message' => 'down', 'severity' => 'error', + ])); + + self::assertSame(201, $response->status); + self::assertSame('error', $response->body()['event']['severity']); + } + + public function testIndexReturnsEvents(): void + { + $this->controller->create(Request::create('POST', '/api/v1/events', body: [ + 'name' => 'Ping', 'message' => 'down', 'severity' => 'error', + ])); + + $response = $this->controller->index(Request::create('GET', '/api/v1/events')); + + self::assertCount(1, $response->body()['events']); + } +} diff --git a/api/tests/Unit/Controller/KpiControllerTest.php b/api/tests/Unit/Controller/KpiControllerTest.php new file mode 100644 index 0000000..dfc551a --- /dev/null +++ b/api/tests/Unit/Controller/KpiControllerTest.php @@ -0,0 +1,58 @@ +controller = new KpiController(new KpiRepository(Database::inMemory())); + } + + public function testReportRejectsMissingValue(): void + { + $response = $this->controller->report(Request::create('POST', '/api/v1/kpis/open_tickets', body: []), ['key' => 'open_tickets']); + + self::assertSame(422, $response->status); + } + + public function testReportRejectsNonNumericValue(): void + { + $response = $this->controller->report( + Request::create('POST', '/api/v1/kpis/open_tickets', body: ['value' => 'abc']), + ['key' => 'open_tickets'] + ); + + self::assertSame(422, $response->status); + } + + public function testReportReturns201WithKpi(): void + { + $response = $this->controller->report( + Request::create('POST', '/api/v1/kpis/open_tickets', body: ['value' => 3]), + ['key' => 'open_tickets'] + ); + + self::assertSame(201, $response->status); + self::assertSame(3.0, $response->body()['kpi']['current_value']); + } + + public function testIndexListsKpis(): void + { + $this->controller->report(Request::create('POST', '/x', body: ['value' => 1]), ['key' => 'a']); + + $response = $this->controller->index(Request::create('GET', '/api/v1/kpis')); + + self::assertCount(1, $response->body()['kpis']); + } +} diff --git a/api/tests/Unit/Controller/SystemControllerTest.php b/api/tests/Unit/Controller/SystemControllerTest.php new file mode 100644 index 0000000..d095de8 --- /dev/null +++ b/api/tests/Unit/Controller/SystemControllerTest.php @@ -0,0 +1,67 @@ +tmpFile = tempnam(sys_get_temp_dir(), 'status_'); + file_put_contents($this->tmpFile, "Server1 1.2.3.4 online 2026-07-27 10:00\n"); + + $this->controller = new SystemController( + new SystemRepository(Database::inMemory()), + new LegacyStatusFileReader(), + $this->tmpFile + ); + } + + protected function tearDown(): void + { + if (is_file($this->tmpFile)) { + unlink($this->tmpFile); + } + } + + public function testIndexImportsLegacyStatusFile(): void + { + $response = $this->controller->index(Request::create('GET', '/api/v1/systems')); + + self::assertSame(200, $response->status); + self::assertCount(1, $response->body()['systems']); + self::assertSame('Server1', $response->body()['systems'][0]['id']); + } + + public function testReportStatusRejectsInvalidStatus(): void + { + $response = $this->controller->reportStatus( + Request::create('POST', '/api/v1/systems/srv1/status', body: ['status' => 'weird']), + ['id' => 'srv1'] + ); + + self::assertSame(422, $response->status); + } + + public function testReportStatusReturnsUpdatedSystem(): void + { + $response = $this->controller->reportStatus( + Request::create('POST', '/api/v1/systems/srv1/status', body: ['status' => 'ok']), + ['id' => 'srv1'] + ); + + self::assertSame(200, $response->status); + self::assertSame('ok', $response->body()['system']['status']); + } +} diff --git a/api/tests/Unit/Controller/TicketControllerTest.php b/api/tests/Unit/Controller/TicketControllerTest.php new file mode 100644 index 0000000..ec62134 --- /dev/null +++ b/api/tests/Unit/Controller/TicketControllerTest.php @@ -0,0 +1,79 @@ +repository = new TicketRepository(Database::inMemory()); + $this->controller = new TicketController($this->repository); + } + + public function testIndexReturnsAllTickets(): void + { + $this->repository->create(['title' => 'A']); + + $response = $this->controller->index(Request::create('GET', '/api/v1/tickets')); + + self::assertSame(200, $response->status); + self::assertCount(1, $response->body()['tickets']); + } + + public function testCreateRejectsMissingTitle(): void + { + $response = $this->controller->create(Request::create('POST', '/api/v1/tickets', body: [])); + + self::assertSame(422, $response->status); + } + + public function testCreateReturns201WithTicket(): void + { + $response = $this->controller->create(Request::create('POST', '/api/v1/tickets', body: ['title' => 'Neu'])); + + self::assertSame(201, $response->status); + self::assertSame('Neu', $response->body()['ticket']['title']); + } + + public function testCreateRejectsInvalidColumnWith422(): void + { + $response = $this->controller->create(Request::create('POST', '/api/v1/tickets', body: ['title' => 'X', 'column' => 'invalid'])); + + self::assertSame(422, $response->status); + } + + public function testUpdateMovesTicket(): void + { + $ticket = $this->repository->create(['title' => 'X']); + + $response = $this->controller->update( + Request::create('PATCH', '/api/v1/tickets/' . $ticket['id'], body: ['column' => 'bereit']), + ['id' => (string) $ticket['id']] + ); + + self::assertSame(200, $response->status); + self::assertSame('bereit', $response->body()['ticket']['column_name']); + } + + public function testUpdatePropagatesNotFoundForUnknownTicket(): void + { + $this->expectException(NotFoundException::class); + + $this->controller->update( + Request::create('PATCH', '/api/v1/tickets/999', body: ['column' => 'bereit']), + ['id' => '999'] + ); + } +} diff --git a/api/tests/Unit/Db/DatabaseTest.php b/api/tests/Unit/Db/DatabaseTest.php new file mode 100644 index 0000000..7c9b7ed --- /dev/null +++ b/api/tests/Unit/Db/DatabaseTest.php @@ -0,0 +1,40 @@ +pdo()->query("SELECT name FROM sqlite_master WHERE type = 'table'"); + $tables = array_column($stmt->fetchAll(), 'name'); + + foreach (['agents', 'tickets', 'events', 'systems', 'kpis', 'kpi_values'] as $expected) { + self::assertContains($expected, $tables); + } + } + + public function testMigrateIsIdempotent(): void + { + $db = Database::inMemory(); + + // Zweiter Aufruf darf nicht fehlschlagen (CREATE TABLE IF NOT EXISTS). + $db->migrate(); + + self::assertTrue(true); + } + + public function testNowReturnsIso8601Utc(): void + { + $db = Database::inMemory(); + + self::assertMatchesRegularExpression('/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/', $db->now()); + } +} diff --git a/api/tests/Unit/Http/RequestTest.php b/api/tests/Unit/Http/RequestTest.php new file mode 100644 index 0000000..3a6feeb --- /dev/null +++ b/api/tests/Unit/Http/RequestTest.php @@ -0,0 +1,48 @@ +path); + } + + public function testCreateKeepsRootPath(): void + { + $request = Request::create('GET', '/'); + + self::assertSame('/', $request->path); + } + + public function testHeaderLookupIsCaseInsensitive(): void + { + $request = Request::create('GET', '/x', headers: ['X-Api-Key' => 'abc']); + + self::assertSame('abc', $request->header('x-api-key')); + self::assertSame('abc', $request->header('X-API-KEY')); + } + + public function testHeaderReturnsNullWhenMissing(): void + { + $request = Request::create('GET', '/x'); + + self::assertNull($request->header('x-api-key')); + } + + public function testCreateStoresQueryAndBody(): void + { + $request = Request::create('GET', '/x', query: ['limit' => '5'], body: ['title' => 'Hi']); + + self::assertSame(['limit' => '5'], $request->query); + self::assertSame(['title' => 'Hi'], $request->body); + } +} diff --git a/api/tests/Unit/Http/ResponseTest.php b/api/tests/Unit/Http/ResponseTest.php new file mode 100644 index 0000000..a8475b1 --- /dev/null +++ b/api/tests/Unit/Http/ResponseTest.php @@ -0,0 +1,27 @@ + 'bar'], 201); + + self::assertSame(201, $response->status); + self::assertSame(['foo' => 'bar'], $response->body()); + } + + public function testErrorResponseDefaultsToGivenStatus(): void + { + $response = Response::error('nope', 404); + + self::assertSame(404, $response->status); + self::assertSame(['error' => 'nope'], $response->body()); + } +} diff --git a/api/tests/Unit/Http/RouterTest.php b/api/tests/Unit/Http/RouterTest.php new file mode 100644 index 0000000..00e3ff0 --- /dev/null +++ b/api/tests/Unit/Http/RouterTest.php @@ -0,0 +1,62 @@ +add('GET', '/api/v1/tickets', fn () => 'tickets'); + + $match = $router->match(Request::create('GET', '/api/v1/tickets')); + + self::assertNotNull($match); + self::assertSame('tickets', ($match->handler)()); + } + + public function testExtractsPathParams(): void + { + $router = new Router(); + $router->add('PATCH', '/api/v1/tickets/{id}', fn () => null); + + $match = $router->match(Request::create('PATCH', '/api/v1/tickets/42')); + + self::assertSame(['id' => '42'], $match->params); + } + + public function testReturnsNullForUnknownPath(): void + { + $router = new Router(); + $router->add('GET', '/api/v1/tickets', fn () => null); + + self::assertNull($router->match(Request::create('GET', '/does/not/exist'))); + } + + public function testThrowsMethodNotAllowedForKnownPathWrongMethod(): void + { + $router = new Router(); + $router->add('GET', '/api/v1/tickets', fn () => null); + + $this->expectException(MethodNotAllowedException::class); + + $router->match(Request::create('DELETE', '/api/v1/tickets')); + } + + public function testCarriesAuthRequirement(): void + { + $router = new Router(); + $router->add('POST', '/api/v1/tickets', fn () => null, 'agent'); + + $match = $router->match(Request::create('POST', '/api/v1/tickets')); + + self::assertSame('agent', $match->auth); + } +} diff --git a/api/tests/Unit/Repository/AgentRepositoryTest.php b/api/tests/Unit/Repository/AgentRepositoryTest.php new file mode 100644 index 0000000..4fdaccc --- /dev/null +++ b/api/tests/Unit/Repository/AgentRepositoryTest.php @@ -0,0 +1,101 @@ +repository = new AgentRepository(Database::inMemory(), deskCount: 2); + } + + public function testRegisterAssignsLowestFreeDesk(): void + { + $first = $this->repository->register('Alice', 'entwickler'); + $second = $this->repository->register('Bob', 'qa'); + + self::assertSame(0, $first['agent']['desk_index']); + self::assertSame(1, $second['agent']['desk_index']); + self::assertNotSame($first['api_key'], $second['api_key']); + self::assertArrayNotHasKey('api_key_hash', $first['agent']); + } + + public function testUserRoleNeverGetsADesk(): void + { + $result = $this->repository->register('Carol', 'user'); + + self::assertNull($result['agent']['desk_index']); + } + + public function testOverflowAgentGetsNoDeskWhenAllOccupied(): void + { + $this->repository->register('Alice', 'entwickler'); + $this->repository->register('Bob', 'qa'); + $third = $this->repository->register('Dan', 'product_owner'); + + self::assertNull($third['agent']['desk_index']); + } + + public function testRegisterRejectsUnknownRole(): void + { + $this->expectException(InvalidArgumentException::class); + + $this->repository->register('Eve', 'ceo'); + } + + public function testFindByApiKeyReturnsMatchingAgent(): void + { + $result = $this->repository->register('Alice', 'entwickler'); + + $found = $this->repository->findByApiKey($result['api_key']); + + self::assertNotNull($found); + self::assertSame($result['agent']['id'], $found['id']); + } + + public function testFindByApiKeyReturnsNullForUnknownKey(): void + { + self::assertNull($this->repository->findByApiKey('does-not-exist')); + } + + public function testUpdateActivityChangesStateAndTicket(): void + { + $result = $this->repository->register('Alice', 'entwickler'); + $id = (int) $result['agent']['id']; + + $updated = $this->repository->updateActivity($id, 'working', 42); + + self::assertSame('working', $updated['state']); + self::assertSame(42, $updated['current_ticket_id']); + } + + public function testUpdateActivityThrowsForUnknownAgent(): void + { + $this->expectException(NotFoundException::class); + + $this->repository->updateActivity(999, 'working', null); + } + + public function testAllListsAgentsWithoutApiKeyHash(): void + { + $this->repository->register('Alice', 'entwickler'); + $this->repository->register('Bob', 'qa'); + + $all = $this->repository->all(); + + self::assertCount(2, $all); + foreach ($all as $agent) { + self::assertArrayNotHasKey('api_key_hash', $agent); + } + } +} diff --git a/api/tests/Unit/Repository/EventRepositoryTest.php b/api/tests/Unit/Repository/EventRepositoryTest.php new file mode 100644 index 0000000..2f8160b --- /dev/null +++ b/api/tests/Unit/Repository/EventRepositoryTest.php @@ -0,0 +1,72 @@ +repository = new EventRepository(Database::inMemory()); + } + + public function testCreateStoresEvent(): void + { + $event = $this->repository->create([ + 'system_id' => 'srv1', + 'name' => 'Ping', + 'message' => 'Host nicht erreichbar', + 'severity' => 'error', + ]); + + self::assertSame('error', $event['severity']); + self::assertSame('srv1', $event['system_id']); + } + + public function testCreateRejectsInvalidSeverity(): void + { + $this->expectException(InvalidArgumentException::class); + + $this->repository->create(['name' => 'X', 'message' => 'Y', 'severity' => 'info']); + } + + public function testLatestReturnsNewestFirst(): void + { + $this->repository->create(['name' => 'A', 'message' => 'a', 'severity' => 'warning', 'occurred_at' => '2026-01-01T00:00:00Z']); + $this->repository->create(['name' => 'B', 'message' => 'b', 'severity' => 'error', 'occurred_at' => '2026-01-02T00:00:00Z']); + + $latest = $this->repository->latest(); + + self::assertSame('B', $latest[0]['name']); + self::assertSame('A', $latest[1]['name']); + } + + public function testLatestRespectsDefaultLimitOfFive(): void + { + for ($i = 0; $i < 10; $i++) { + $this->repository->create([ + 'name' => 'Event ' . $i, + 'message' => 'msg', + 'severity' => 'warning', + 'occurred_at' => sprintf('2026-01-01T00:00:%02dZ', $i), + ]); + } + + self::assertCount(EventRepository::DEFAULT_LIMIT, $this->repository->latest()); + } + + public function testLatestClampsLimitToAtLeastOne(): void + { + $this->repository->create(['name' => 'A', 'message' => 'a', 'severity' => 'warning']); + + self::assertCount(1, $this->repository->latest(0)); + } +} diff --git a/api/tests/Unit/Repository/KpiRepositoryTest.php b/api/tests/Unit/Repository/KpiRepositoryTest.php new file mode 100644 index 0000000..b7f65b6 --- /dev/null +++ b/api/tests/Unit/Repository/KpiRepositoryTest.php @@ -0,0 +1,61 @@ +repository = new KpiRepository(Database::inMemory(), historyLimit: 3); + } + + public function testReportCreatesKpiWithCurrentValue(): void + { + $kpi = $this->repository->report('open_tickets', 5.0, 'Offene Tickets', 'count', '2026-01-01T00:00:00Z'); + + self::assertSame('open_tickets', $kpi['key']); + self::assertSame(5.0, $kpi['current_value']); + self::assertCount(1, $kpi['history']); + } + + public function testReportAppendsHistoryAndUpdatesCurrentValue(): void + { + $this->repository->report('open_tickets', 5.0, null, null, '2026-01-01T00:00:00Z'); + $kpi = $this->repository->report('open_tickets', 7.0, null, null, '2026-01-02T00:00:00Z'); + + self::assertSame(7.0, $kpi['current_value']); + self::assertCount(2, $kpi['history']); + } + + public function testHistoryIsLimited(): void + { + for ($i = 0; $i < 5; $i++) { + $this->repository->report('kpi', (float) $i, null, null, sprintf('2026-01-0%dT00:00:00Z', $i + 1)); + } + + $kpi = $this->repository->find('kpi'); + + self::assertCount(3, $kpi['history']); + } + + public function testFindReturnsNullForUnknownKey(): void + { + self::assertNull($this->repository->find('unknown')); + } + + public function testAllListsAllKpis(): void + { + $this->repository->report('a', 1.0, null, null, '2026-01-01T00:00:00Z'); + $this->repository->report('b', 2.0, null, null, '2026-01-01T00:00:00Z'); + + self::assertCount(2, $this->repository->all()); + } +} diff --git a/api/tests/Unit/Repository/LegacyStatusFileReaderTest.php b/api/tests/Unit/Repository/LegacyStatusFileReaderTest.php new file mode 100644 index 0000000..52d3b91 --- /dev/null +++ b/api/tests/Unit/Repository/LegacyStatusFileReaderTest.php @@ -0,0 +1,53 @@ +tmpFile = tempnam(sys_get_temp_dir(), 'status_'); + } + + protected function tearDown(): void + { + if (is_file($this->tmpFile)) { + unlink($this->tmpFile); + } + } + + public function testReadsValidLines(): void + { + file_put_contents($this->tmpFile, "Server1 192.168.178.2 online 2026-07-27 10:00\nServer2 192.168.178.3 offline 2026-07-27 10:01\n"); + + $rows = (new LegacyStatusFileReader())->read($this->tmpFile); + + self::assertCount(2, $rows); + self::assertSame('Server1', $rows[0]['id']); + self::assertSame('ok', $rows[0]['status']); + self::assertSame('192.168.178.2', $rows[0]['detail']); + self::assertSame('2026-07-27 10:00', $rows[0]['last_check_at']); + self::assertSame('error', $rows[1]['status']); + } + + public function testSkipsMalformedLines(): void + { + file_put_contents($this->tmpFile, "incomplete line\nServer1 1.2.3.4 online 2026-07-27 10:00\n"); + + $rows = (new LegacyStatusFileReader())->read($this->tmpFile); + + self::assertCount(1, $rows); + } + + public function testReturnsEmptyArrayForMissingFile(): void + { + self::assertSame([], (new LegacyStatusFileReader())->read('/does/not/exist.txt')); + } +} diff --git a/api/tests/Unit/Repository/SystemRepositoryTest.php b/api/tests/Unit/Repository/SystemRepositoryTest.php new file mode 100644 index 0000000..f75eb01 --- /dev/null +++ b/api/tests/Unit/Repository/SystemRepositoryTest.php @@ -0,0 +1,81 @@ +repository = new SystemRepository(Database::inMemory()); + } + + public function testReportStatusInsertsSystem(): void + { + $system = $this->repository->reportStatus('srv1', ['status' => 'ok', 'name' => 'Server 1']); + + self::assertSame('ok', $system['status']); + self::assertSame('api', $system['source']); + } + + public function testReportStatusRejectsInvalidStatus(): void + { + $this->expectException(InvalidArgumentException::class); + + $this->repository->reportStatus('srv1', ['status' => 'unknown']); + } + + public function testReportStatusUpdatesExistingSystem(): void + { + $this->repository->reportStatus('srv1', ['status' => 'ok']); + $updated = $this->repository->reportStatus('srv1', ['status' => 'error', 'detail' => 'Timeout']); + + self::assertSame('error', $updated['status']); + self::assertSame('Timeout', $updated['detail']); + } + + public function testImportLegacyRowsDoesNotOverwriteApiSourcedSystem(): void + { + $this->repository->reportStatus('srv1', ['status' => 'ok']); + + $this->repository->importLegacyRows([ + ['id' => 'srv1', 'name' => 'srv1', 'status' => 'error', 'detail' => '1.2.3.4', 'last_check_at' => '2026-01-01 00:00'], + ]); + + $system = $this->repository->find('srv1'); + self::assertSame('ok', $system['status']); + self::assertSame('api', $system['source']); + } + + public function testImportLegacyRowsCreatesNewLegacySystem(): void + { + $this->repository->importLegacyRows([ + ['id' => 'srv2', 'name' => 'srv2', 'status' => 'ok', 'detail' => '1.2.3.5', 'last_check_at' => '2026-01-01 00:00'], + ]); + + $system = $this->repository->find('srv2'); + self::assertNotNull($system); + self::assertSame('legacy', $system['source']); + } + + public function testAllOrdersErrorsFirstAndLimitsToNineTiles(): void + { + for ($i = 0; $i < 12; $i++) { + $this->repository->reportStatus('srv' . $i, ['status' => 'ok']); + } + $this->repository->reportStatus('broken', ['status' => 'error']); + + $all = $this->repository->all(); + + self::assertCount(SystemRepository::MAX_TILES, $all); + self::assertSame('error', $all[0]['status']); + } +} diff --git a/api/tests/Unit/Repository/TicketRepositoryTest.php b/api/tests/Unit/Repository/TicketRepositoryTest.php new file mode 100644 index 0000000..e708a7a --- /dev/null +++ b/api/tests/Unit/Repository/TicketRepositoryTest.php @@ -0,0 +1,135 @@ +repository = new TicketRepository(Database::inMemory()); + } + + public function testCreateDefaultsToBacklogColumn(): void + { + $ticket = $this->repository->create(['title' => 'Neues Ticket']); + + self::assertSame('backlog', $ticket['column_name']); + self::assertSame('Neues Ticket', $ticket['title']); + } + + public function testCreateRejectsUnknownColumn(): void + { + $this->expectException(InvalidArgumentException::class); + + $this->repository->create(['title' => 'X', 'column' => 'unknown']); + } + + public function testUpdateMovesTicketToAnotherColumn(): void + { + $ticket = $this->repository->create(['title' => 'X']); + + $updated = $this->repository->update((int) $ticket['id'], ['column' => 'in_arbeit']); + + self::assertSame('in_arbeit', $updated['column_name']); + } + + public function testUpdateRejectsUnknownColumn(): void + { + $ticket = $this->repository->create(['title' => 'X']); + + $this->expectException(InvalidArgumentException::class); + + $this->repository->update((int) $ticket['id'], ['column' => 'unknown']); + } + + public function testUpdateThrowsNotFoundForMissingTicket(): void + { + $this->expectException(NotFoundException::class); + + $this->repository->update(999, ['column' => 'bereit']); + } + + public function testAllFiltersByColumn(): void + { + $this->repository->create(['title' => 'A', 'column' => 'backlog']); + $this->repository->create(['title' => 'B', 'column' => 'done']); + + $backlog = $this->repository->all('backlog'); + + self::assertCount(1, $backlog); + self::assertSame('A', $backlog[0]['title']); + } + + public function testAllOrdersByColumnThenCreatedAt(): void + { + $this->repository->create(['title' => 'first', 'column' => 'backlog']); + $this->repository->create(['title' => 'second', 'column' => 'backlog']); + + $tickets = $this->repository->all(); + + self::assertSame('first', $tickets[0]['title']); + self::assertSame('second', $tickets[1]['title']); + } + + public function testUpsertFromCodebergCreatesNewTicket(): void + { + $ticket = $this->repository->upsertFromCodeberg([ + 'external_ref' => '42', + 'title' => 'Issue 42', + 'description' => 'Body', + 'column' => 'backlog', + 'type' => 'bug', + 'color' => '#ff0000', + ]); + + self::assertSame('42', $ticket['external_ref']); + self::assertSame('codeberg', $ticket['source']); + } + + public function testUpsertFromCodebergUpdatesExistingBacklogTicket(): void + { + $this->repository->upsertFromCodeberg([ + 'external_ref' => '42', + 'title' => 'Issue 42', + 'column' => 'backlog', + ]); + + $updated = $this->repository->upsertFromCodeberg([ + 'external_ref' => '42', + 'title' => 'Issue 42 (closed)', + 'column' => 'done', + ]); + + self::assertSame('done', $updated['column_name']); + self::assertSame('Issue 42 (closed)', $updated['title']); + } + + public function testUpsertFromCodebergDoesNotResetManuallyMovedTicket(): void + { + $this->repository->upsertFromCodeberg([ + 'external_ref' => '42', + 'title' => 'Issue 42', + 'column' => 'backlog', + ]); + $ticket = $this->repository->findByExternalRef('42'); + $this->repository->update((int) $ticket['id'], ['column' => 'in_arbeit']); + + $result = $this->repository->upsertFromCodeberg([ + 'external_ref' => '42', + 'title' => 'Issue 42 geaendert', + 'column' => 'backlog', + ]); + + self::assertSame('in_arbeit', $result['column_name']); + } +} diff --git a/api/tests/Unit/Sync/CodebergSyncServiceTest.php b/api/tests/Unit/Sync/CodebergSyncServiceTest.php new file mode 100644 index 0000000..4b8fbf2 --- /dev/null +++ b/api/tests/Unit/Sync/CodebergSyncServiceTest.php @@ -0,0 +1,74 @@ +> $issuesByState */ + public function __construct(private readonly array $issuesByState) + { + } + + public function listIssues(string $owner, string $repo, string $state): array + { + return $this->issuesByState[$state] ?? []; + } +} + +final class CodebergSyncServiceTest extends TestCase +{ + public function testSyncCreatesBacklogAndDoneTickets(): void + { + $tickets = new TicketRepository(Database::inMemory()); + $client = new FakeCodebergClient([ + 'open' => [ + ['number' => 1, 'title' => 'Offenes Issue', 'body' => 'Beschreibung', 'labels' => [['name' => 'bug', 'color' => 'ff0000']]], + ], + 'closed' => [ + ['number' => 2, 'title' => 'Geschlossenes Issue', 'body' => null, 'labels' => []], + ], + ]); + + (new CodebergSyncService($client, $tickets, 'fds2610', 'monitor'))->sync(); + + $backlog = $tickets->all('backlog'); + $done = $tickets->all('done'); + + self::assertCount(1, $backlog); + self::assertSame('Offenes Issue', $backlog[0]['title']); + self::assertSame('bug', $backlog[0]['type']); + self::assertSame('#ff0000', $backlog[0]['color']); + self::assertSame('codeberg', $backlog[0]['source']); + + self::assertCount(1, $done); + self::assertSame('Geschlossenes Issue', $done[0]['title']); + self::assertNull($done[0]['type']); + } + + public function testSyncDoesNotResetManuallyMovedTicket(): void + { + $tickets = new TicketRepository(Database::inMemory()); + $client = new FakeCodebergClient([ + 'open' => [['number' => 1, 'title' => 'Issue', 'body' => null, 'labels' => []]], + 'closed' => [], + ]); + $service = new CodebergSyncService($client, $tickets, 'fds2610', 'monitor'); + + $service->sync(); + $ticket = $tickets->findByExternalRef('1'); + $tickets->update((int) $ticket['id'], ['column' => 'qa']); + + $service->sync(); + + $stillQa = $tickets->findByExternalRef('1'); + self::assertSame('qa', $stillQa['column_name']); + } +} diff --git a/api/tests/Unit/Sync/CurlCodebergClientTest.php b/api/tests/Unit/Sync/CurlCodebergClientTest.php new file mode 100644 index 0000000..d0a5cd0 --- /dev/null +++ b/api/tests/Unit/Sync/CurlCodebergClientTest.php @@ -0,0 +1,121 @@ + */ + public static array $queue = []; + + /** @var string[] */ + public static array $requestedUrls = []; + + public static function reset(): void + { + self::$queue = []; + self::$requestedUrls = []; + } + } + + function curl_init(string $url = '') + { + CurlStub::$requestedUrls[] = $url; + $handle = new \stdClass(); + $handle->response = array_shift(CurlStub::$queue) ?? ['status' => 200, 'body' => '[]']; + + return $handle; + } + + function curl_setopt_array($handle, array $options): bool + { + return true; + } + + function curl_exec($handle) + { + return $handle->response['body']; + } + + function curl_getinfo($handle, int $option) + { + return $handle->response['status']; + } + + function curl_error($handle): string + { + return $handle->response['error'] ?? ''; + } + + function curl_close($handle): void + { + } +} + +namespace Monitor\Api\Tests\Sync { + use Monitor\Api\Sync\CurlCodebergClient; + use Monitor\Api\Sync\CurlStub; + use PHPUnit\Framework\TestCase; + use RuntimeException; + + final class CurlCodebergClientTest extends TestCase + { + protected function setUp(): void + { + CurlStub::reset(); + } + + public function testListIssuesParsesSinglePage(): void + { + CurlStub::$queue = [ + ['status' => 200, 'body' => json_encode([ + ['number' => 1, 'title' => 'A', 'body' => 'desc', 'labels' => [['name' => 'bug', 'color' => 'ff0000']]], + ])], + ]; + + $client = new CurlCodebergClient('https://codeberg.org/api/v1'); + $issues = $client->listIssues('fds2610', 'monitor', 'open'); + + self::assertCount(1, $issues); + self::assertSame(1, $issues[0]['number']); + self::assertSame('#ff0000', $issues[0]['labels'][0]['color']); + self::assertStringContainsString('state=open', CurlStub::$requestedUrls[0]); + } + + public function testListIssuesStopsWhenPageEmpty(): void + { + CurlStub::$queue = [ + ['status' => 200, 'body' => '[]'], + ]; + + $client = new CurlCodebergClient('https://codeberg.org/api/v1'); + $issues = $client->listIssues('fds2610', 'monitor', 'closed'); + + self::assertSame([], $issues); + self::assertCount(1, CurlStub::$requestedUrls); + } + + public function testListIssuesSendsAuthorizationHeaderWhenTokenGiven(): void + { + CurlStub::$queue = [['status' => 200, 'body' => '[]']]; + + $client = new CurlCodebergClient('https://codeberg.org/api/v1', 'my-token'); + $client->listIssues('fds2610', 'monitor', 'open'); + + self::assertNotEmpty(CurlStub::$requestedUrls); + } + + public function testThrowsOnHttpError(): void + { + CurlStub::$queue = [['status' => 500, 'body' => 'boom']]; + + $this->expectException(RuntimeException::class); + + (new CurlCodebergClient('https://codeberg.org/api/v1'))->listIssues('fds2610', 'monitor', 'open'); + } + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..bd15d0d --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,21 @@ +services: + api: + build: + context: ./api + dockerfile: docker/Dockerfile + volumes: + - ./api:/app + # Optional: echte status.txt aus der Produktion zum Testen einbinden, z.B. + # - ./status.txt:/status.txt:ro + working_dir: /app + ports: + - "8080:8080" + environment: + MONITOR_DB_PATH: /app/data/monitor.sqlite + MONITOR_ADMIN_API_KEY: ${MONITOR_ADMIN_API_KEY:-dev-admin-key} + MONITOR_STATUS_FILE: /status.txt + MONITOR_DESK_COUNT: 8 + CODEBERG_API_BASE: https://codeberg.org/api/v1 + CODEBERG_OWNER: ${CODEBERG_OWNER:-fds2610} + CODEBERG_REPO: ${CODEBERG_REPO:-monitor} + CODEBERG_TOKEN: ${CODEBERG_TOKEN:-}