update.sh meldet nach dem Neustart ein Event (POST /api/v1/events/system, Admin-Key). upsertPullRequest markiert bereits vorhandene Tickets als Pull Request, damit sie den /pulls/-Link bekommen. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
268 lines
14 KiB
PHP
268 lines
14 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Monitor\Api;
|
|
|
|
use InvalidArgumentException;
|
|
use Monitor\Api\Auth\AdminAuthenticator;
|
|
use Monitor\Api\Auth\AgentAuthenticator;
|
|
use Monitor\Api\Auth\WebhookAuthenticator;
|
|
use Monitor\Api\Controller\AgentController;
|
|
use Monitor\Api\Controller\EventController;
|
|
use Monitor\Api\Controller\HostConfigController;
|
|
use Monitor\Api\Controller\KpiController;
|
|
use Monitor\Api\Controller\RepoConfigController;
|
|
use Monitor\Api\Controller\StreamController;
|
|
use Monitor\Api\Controller\SystemController;
|
|
use Monitor\Api\Controller\TicketController;
|
|
use Monitor\Api\Controller\VoiceRecordingController;
|
|
use Monitor\Api\Controller\WebhookController;
|
|
use Monitor\Api\Db\Database;
|
|
use Monitor\Api\Http\MethodNotAllowedException;
|
|
use Monitor\Api\Http\NotFoundException;
|
|
use Monitor\Api\Http\Request;
|
|
use Monitor\Api\Http\Response;
|
|
use Monitor\Api\Http\Router;
|
|
use Monitor\Api\Metrics\HookCounter;
|
|
use Monitor\Api\Realtime\ChangeFeed;
|
|
use Monitor\Api\Repository\AgentRepository;
|
|
use Monitor\Api\Repository\EventRepository;
|
|
use Monitor\Api\Repository\HostConfigRepository;
|
|
use Monitor\Api\Repository\KpiRepository;
|
|
use Monitor\Api\Repository\LegacyStatusFileReader;
|
|
use Monitor\Api\Repository\RepoConfigRepository;
|
|
use Monitor\Api\Repository\SystemRepository;
|
|
use Monitor\Api\Repository\TicketRepository;
|
|
use Monitor\Api\Repository\VoiceRecordingRepository;
|
|
use Monitor\Api\Sync\CodebergSyncService;
|
|
use Monitor\Api\Sync\CurlCodebergClient;
|
|
use Monitor\Api\Sync\RepoHealthChecker;
|
|
use Monitor\Api\Sync\SyncTrigger;
|
|
use Throwable;
|
|
|
|
/**
|
|
* Verdrahtet Router, Controller und Auth fuer alle /api/v1/*-Endpunkte aus
|
|
* Visualization.md Abschnitt 6.1. Bewusst ohne Framework/DI-Container - eine handvoll
|
|
* Objekte reichen fuer den Umfang von Phase 1.
|
|
*/
|
|
final class Application
|
|
{
|
|
private readonly Router $router;
|
|
private readonly AgentAuthenticator $agentAuth;
|
|
private readonly AdminAuthenticator $adminAuth;
|
|
private readonly WebhookAuthenticator $webhookAuth;
|
|
private readonly AgentRepository $agents;
|
|
|
|
public function __construct(private readonly Database $db, private readonly Config $config)
|
|
{
|
|
$agents = new AgentRepository($db, $config->deskCount());
|
|
$this->agents = $agents;
|
|
$tickets = new TicketRepository($db);
|
|
$events = new EventRepository($db);
|
|
$systems = new SystemRepository($db);
|
|
$kpis = new KpiRepository($db);
|
|
$repoConfigs = new RepoConfigRepository($db);
|
|
$hostConfigs = new HostConfigRepository($db);
|
|
$hooks = new HookCounter($config->hooksCounterPath());
|
|
$recordings = new VoiceRecordingRepository($db, $config->recordingsPath(), $config->maxRecordingUploadBytes());
|
|
|
|
$this->agentAuth = new AgentAuthenticator($agents);
|
|
$this->adminAuth = new AdminAuthenticator($config->adminApiKey());
|
|
$this->webhookAuth = new WebhookAuthenticator($config->codebergWebhookSecret());
|
|
|
|
$agentController = new AgentController($agents, $recordings);
|
|
$recordingController = new VoiceRecordingController($recordings);
|
|
// Issue #51: der "Board neu einlesen"-Knopf synchronisiert direkt im api-Container,
|
|
// unabhaengig vom periodisch/webhook-getriebenen sync-Container (bin/sync-codeberg.php)
|
|
// - beide teilen sich denselben CodebergSyncService, nur der Aufloeser ist verschieden.
|
|
$codebergClient = new CurlCodebergClient($config->codebergApiBase(), $config->codebergToken());
|
|
$syncService = new CodebergSyncService($codebergClient, $tickets, $repoConfigs, $kpis, new SyncTrigger($config->deployTriggerPath()));
|
|
$ticketController = new TicketController($tickets, $syncService);
|
|
$eventController = new EventController($events);
|
|
$systemController = new SystemController($systems, new LegacyStatusFileReader(), $config->statusFilePath());
|
|
$kpiController = new KpiController($kpis);
|
|
$hostConfigController = new HostConfigController($hostConfigs, $systems);
|
|
$repoConfigController = new RepoConfigController($repoConfigs, $tickets, new RepoHealthChecker($codebergClient));
|
|
$webhookController = new WebhookController(new SyncTrigger($config->syncTriggerPath()), $hooks);
|
|
$streamController = new StreamController(
|
|
new ChangeFeed($tickets, $events, $systems, $kpis, $agents, $hooks, $recordings),
|
|
$config->sseMaxIterations(),
|
|
$config->ssePollIntervalSeconds(),
|
|
);
|
|
|
|
$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');
|
|
// Issue #51: schreibend wie die Projekte-Verwaltung (Admin-Key), da ein manueller
|
|
// Voll-Reset des Kanban-Boards potenziell teuer ist (mehrere Codeberg-Requests) und
|
|
// kein normaler Agenten-Vorgang.
|
|
$this->router->add('POST', '/api/v1/tickets/resync', fn (Request $r, array $p) => $ticketController->resync($r), 'admin');
|
|
|
|
$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');
|
|
// Issue #70: Kachel eines Systems entfernen, das niemand mehr meldet - Admin-Key wie
|
|
// die uebrige Konfiguration aus dem Frontend.
|
|
$this->router->add('DELETE', '/api/v1/systems/{id}', fn (Request $r, array $p) => $systemController->delete($r, $p), 'admin');
|
|
|
|
// Issue #70: Hosts der Ping-Ueberwachung, gepflegt ueber das Zahnrad am Wandmonitor
|
|
// "Systeme". Die beiden Endpunkte fuer den Ping-Check selbst stehen zuerst, damit sie
|
|
// nicht von einem spaeteren "/{id}"-Muster verdeckt werden koennen.
|
|
$this->router->add(
|
|
'GET',
|
|
'/api/v1/hosts/conf',
|
|
fn (Request $r, array $p, ?array $agent) => $hostConfigController->conf($r, $p, $agent),
|
|
'agent'
|
|
);
|
|
$this->router->add(
|
|
'POST',
|
|
'/api/v1/hosts/import',
|
|
fn (Request $r, array $p, ?array $agent) => $hostConfigController->import($r, $p, $agent),
|
|
'agent'
|
|
);
|
|
$this->router->add('GET', '/api/v1/hosts', fn (Request $r, array $p) => $hostConfigController->index($r));
|
|
$this->router->add('POST', '/api/v1/hosts', fn (Request $r, array $p) => $hostConfigController->create($r), 'admin');
|
|
$this->router->add('PATCH', '/api/v1/hosts/{id}', fn (Request $r, array $p) => $hostConfigController->update($r, $p), 'admin');
|
|
$this->router->add('DELETE', '/api/v1/hosts/{id}', fn (Request $r, array $p) => $hostConfigController->delete($r, $p), 'admin');
|
|
|
|
$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');
|
|
|
|
// Issue #27: Welche Codeberg-Projekte der Sync liest, ist ueber ein Modal im Frontend
|
|
// (Klick auf den PO) editierbar - schreibend daher hinter dem Admin-Key wie die
|
|
// Agenten-Registrierung, nicht hinter einem Agenten-Key (kein Bot-Vorgang).
|
|
$this->router->add('GET', '/api/v1/repo-configs', fn (Request $r, array $p) => $repoConfigController->index($r));
|
|
$this->router->add('POST', '/api/v1/repo-configs', fn (Request $r, array $p) => $repoConfigController->create($r), 'admin');
|
|
$this->router->add('PATCH', '/api/v1/repo-configs/{id}', fn (Request $r, array $p) => $repoConfigController->update($r, $p), 'admin');
|
|
$this->router->add('DELETE', '/api/v1/repo-configs/{id}', fn (Request $r, array $p) => $repoConfigController->delete($r, $p), 'admin');
|
|
// Issue #53: Konfig-Pruefung (Schreibrechte + Webhook) nach dem Anlegen/Bearbeiten eines
|
|
// Projekts im Frontend-Modal - schreibend wie die uebrigen Projekte-Endpunkte, da jeder
|
|
// Aufruf zwei zusaetzliche Codeberg-Requests ausloest.
|
|
$this->router->add('POST', '/api/v1/repo-configs/{id}/check', fn (Request $r, array $p) => $repoConfigController->check($r, $p), 'admin');
|
|
|
|
$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(
|
|
'GET',
|
|
'/api/v1/agents/me',
|
|
fn (Request $r, array $p, array $agent) => $agentController->me($r, $agent),
|
|
'agent'
|
|
);
|
|
$this->router->add(
|
|
'POST',
|
|
'/api/v1/agents/{id}/activity',
|
|
fn (Request $r, array $p, array $agent) => $agentController->activity($r, $p, $agent),
|
|
'agent'
|
|
);
|
|
|
|
// Sprachaufnahmen: Upload aus record.html ist bewusst unauthentifiziert - die
|
|
// Aufnahme-Webseite laeuft im Handy-Browser eines Menschen, nicht eines registrierten
|
|
// Agenten, es gibt dort also keinen Api-/Admin-Key einzugeben. Abholen/Markieren
|
|
// dagegen hinter dem Agenten-Key wie die uebrigen Bot-Vorgaenge.
|
|
//
|
|
// Eine Warteschlange fuer alle, ohne Agent-Id im Pfad (bis 006_voice_recordings.sql
|
|
// hing jede Aufnahme an einem Agenten): der abholende Voice-Bot entscheidet anhand
|
|
// des Transkripts, in welchem Repo das Ticket entsteht.
|
|
$this->router->add(
|
|
'POST',
|
|
'/api/v1/recordings',
|
|
fn (Request $r, array $p) => $recordingController->upload($r)
|
|
);
|
|
$this->router->add(
|
|
'GET',
|
|
'/api/v1/recordings',
|
|
fn (Request $r, array $p, array $agent) => $recordingController->index($r, $agent),
|
|
'agent'
|
|
);
|
|
$this->router->add(
|
|
'GET',
|
|
'/api/v1/recordings/{id}/audio',
|
|
fn (Request $r, array $p, array $agent) => $recordingController->download($r, $p, $agent),
|
|
'agent'
|
|
);
|
|
$this->router->add(
|
|
'PATCH',
|
|
'/api/v1/recordings/{id}',
|
|
fn (Request $r, array $p, array $agent) => $recordingController->markProcessed($r, $p, $agent),
|
|
'agent'
|
|
);
|
|
|
|
// SSE-Push (F13): oeffentlich wie die anderen GET-Endpunkte, liefert nur bereits
|
|
// oeffentliche Daten (welche Ressourcen sich geaendert haben, keine Inhalte).
|
|
$this->router->add('GET', '/api/v1/stream', fn (Request $r, array $p) => $streamController->stream($r));
|
|
|
|
// Codeberg-Webhook: kein API-Key, sondern HMAC-Signatur - Codeberg kennt unsere
|
|
// Keys nicht, signiert den Body aber mit dem im Webhook hinterlegten Secret.
|
|
$this->router->add(
|
|
'POST',
|
|
'/api/v1/webhooks/codeberg',
|
|
fn (Request $r, array $p) => $webhookController->codeberg($r),
|
|
'webhook'
|
|
);
|
|
|
|
// Hooks-KPI (Issue #38): oeffentlich wie die anderen GET-Endpunkte, liefert nur die
|
|
// Anzahl seit dem letzten Start des api-Containers, keine Payload-Details.
|
|
$this->router->add('GET', '/api/v1/webhooks/stats', fn (Request $r, array $p) => $webhookController->stats($r));
|
|
}
|
|
|
|
public function handle(Request $request): Response
|
|
{
|
|
// CORS-Preflight generisch beantworten, bevor der Router laeuft: OPTIONS ist fuer
|
|
// keinen Pfad registriert, der Router wuerde also MethodNotAllowedException (405)
|
|
// werfen. Der Browser sendet den eigentlichen POST/PATCH/DELETE dann gar nicht mehr
|
|
// und meldet nur einen Netzwerkfehler - genau das brach das Projekte-Modal am PO.
|
|
if ($request->method === 'OPTIONS') {
|
|
return Response::preflight();
|
|
}
|
|
|
|
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);
|
|
}
|
|
// Jeder gueltige Key-Request zaehlt als Lebenszeichen (siehe AgentRepository::touch()).
|
|
$this->agents->touch((int) $agent['id']);
|
|
} elseif ($match->auth === 'admin') {
|
|
if (!$this->adminAuth->authenticate($request)) {
|
|
return Response::error('Ungueltiger oder fehlender X-Admin-Key-Header.', 401);
|
|
}
|
|
} elseif ($match->auth === 'webhook') {
|
|
if (!$this->webhookAuth->authenticate($request)) {
|
|
return Response::error('Ungueltige oder fehlende Webhook-Signatur.', 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);
|
|
} catch (Throwable $e) {
|
|
// Unerwartete Fehler (z.B. PDOException) duerfen nie als PHP-Fatal-Error mit
|
|
// Stacktrace und Dateipfaden im Response-Body landen - Details nur ins Log.
|
|
error_log(sprintf('[monitor-api] %s: %s in %s:%d', $e::class, $e->getMessage(), $e->getFile(), $e->getLine()));
|
|
|
|
return Response::error('Interner Serverfehler.', 500);
|
|
}
|
|
}
|
|
}
|