Wer einen Server aus der Ping-Ueberwachung nehmen wollte, musste sich bisher auf den Rechner mit check_fds.sh einloggen und die hosts.conf editieren - und die Kachel blieb danach trotzdem im System-Monitor stehen, weil ein einmal gemeldetes System nirgends wieder geloescht werden konnte. Die Liste steht jetzt in der Datenbank und ist ueber ein Zahnrad neben der Ueberschrift "Systeme" pflegbar, analog zum Zahnrad am PO-Platz (Issue #27). Jeder Host gehoert dem OPs-Agenten (Issue #66), der ihn anpingt: API und Ping-Check laufen nicht zwangslaeufig auf demselben Rechner, die API kann die hosts.conf also nicht selbst schreiben. check_fds.sh holt sie stattdessen bei jedem Lauf mit seinem Agenten-Key ab und schreibt daraus die lokale Datei; ist die API nicht erreichbar, pingt es unveraendert nach der zuletzt geschriebenen Liste weiter. Beim ersten Lauf laedt der Check seine bestehende hosts.conf hoch, damit die bereits ueberwachten Server nicht von Hand nachgetragen werden muessen. Dass die API das nur einmal annimmt (Marker in host_config_imports) und "noch nie konfiguriert" per 204 von "im Frontend geleert" per 200 unterscheidet, ist noetig, damit ein Ping-Rechner mit alter lokaler Datei die dort geloeschten Hosts nicht wieder anlegt. Loeschen raeumt mit auf: Ein entfernter Host nimmt seine Kachel mit - beide Ids, denn der status.txt-Import benennt ihn nach dem Hostnamen, die REST-Meldung nach dessen Slug. Kacheln ohne zugehoerigen Host (Selbstmelder oder Reste laengst entfernter Hosts) stehen im selben Dialog unter "Weitere Kacheln im System-Monitor" und sind dort einzeln loeschbar - dafuer gibt es neu DELETE /api/v1/systems/{id}. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015vNjbfr8M3HSfWMee92GTE
219 lines
12 KiB
PHP
219 lines
12 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\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\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;
|
|
|
|
public function __construct(private readonly Database $db, private readonly Config $config)
|
|
{
|
|
$agents = new AgentRepository($db, $config->deskCount());
|
|
$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());
|
|
|
|
$this->agentAuth = new AgentAuthenticator($agents);
|
|
$this->adminAuth = new AdminAuthenticator($config->adminApiKey());
|
|
$this->webhookAuth = new WebhookAuthenticator($config->codebergWebhookSecret());
|
|
|
|
$agentController = new AgentController($agents);
|
|
// 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);
|
|
$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),
|
|
$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');
|
|
|
|
$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(
|
|
'POST',
|
|
'/api/v1/agents/{id}/activity',
|
|
fn (Request $r, array $p, array $agent) => $agentController->activity($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);
|
|
}
|
|
} 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);
|
|
}
|
|
}
|
|
}
|