diff --git a/API.md b/API.md index 5e9c5da..83d9719 100644 --- a/API.md +++ b/API.md @@ -19,6 +19,7 @@ Implementierung: Vanilla-PHP (kein Framework), Persistenz in SQLite - [KPIs](#kpis) - [Live-Updates (SSE)](#live-updates-sse) - [Projekte (Codeberg-Sync konfigurieren)](#projekte-codeberg-sync-konfigurieren) +- [Hosts (Ping-Ueberwachung konfigurieren)](#hosts-ping-ueberwachung-konfigurieren) - [Codeberg-Sync (Backlog/Done)](#codeberg-sync-backlogdone) - [Fehlerformat](#fehlerformat) @@ -283,6 +284,18 @@ ungueltigem `status`. Response: `{ "systems": [ { "id", "name", "type", "status", "detail", "source", "last_check_at" }, ... ] }` (maximal 9 Eintraege). +### `DELETE /api/v1/systems/{id}` — Kachel entfernen + +Auth: `X-Admin-Key`. Issue #70: Entfernt ein System aus dem Monitor. Gedacht fuer Kacheln, +die niemand mehr meldet - ein einmal gemeldetes System blieb sonst fuer immer stehen, nur +eben ohne Aktualisierung. Response `200` mit `{ "deleted": true|false }` (`false` = es gab +kein System mit dieser Id). Meldet sich das System spaeter erneut, legt +`POST /api/v1/systems/{id}/status` es unveraendert neu an. + +Einen Host der Ping-Ueberwachung entfernt man dagegen ueber +[`DELETE /api/v1/hosts/{id}`](#hosts-ping-ueberwachung-konfigurieren) - der nimmt seine Kachel +selbst mit. + ## KPIs F7: Konkrete Kennzahlen sind noch offen ("kommt spaeter"). Phase 1 stellt nur die @@ -396,6 +409,50 @@ Bearbeiten-/Loeschen-Symbolen (Tooltip = `message`). Beide Checks nutzen `CODEBE Hooks erfordert Admin-Rechte auf das Projekt - fehlen die, liefert der Check `ok: false` mit entsprechender Meldung statt eines Fehlers. +## Hosts (Ping-Ueberwachung konfigurieren) + +Seit Issue #70 steht die Liste der per Ping ueberwachten Hosts in der Datenbank statt nur in +der `hosts.conf` neben `check_fds.sh` - pflegbar ueber das Zahnrad am Wandmonitor "Systeme" +in der Buero-Visualisierung. 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. Stattdessen holt `check_fds.sh` seine Liste bei +jedem Lauf mit dem eigenen Agenten-Key ab und schreibt daraus die lokale Datei. + +| Endpunkt | Auth | Beschreibung | +|---|---|---| +| `GET /api/v1/hosts` | - | Alle Hosts ueber alle Ping-Rechner, je mit `agent_name` und `system_ids` (siehe unten) | +| `POST /api/v1/hosts` | `X-Admin-Key` | Legt einen Host an (`agent_id`, `hostname`, `address`) | +| `PATCH /api/v1/hosts/{id}` | `X-Admin-Key` | Aendert `hostname`/`address`; der zustaendige Agent bleibt, wie er ist | +| `DELETE /api/v1/hosts/{id}` | `X-Admin-Key` | Nimmt den Host aus der Ueberwachung und loescht dessen Kachel im System-Monitor | +| `GET /api/v1/hosts/conf` | `X-Api-Key` | Die `hosts.conf` des anfragenden Ping-Rechners als `text/plain` | +| `POST /api/v1/hosts/import` | `X-Api-Key` | Erstbefuellung aus einer hochgeladenen `hosts.conf` (`text/plain`) | + +`hostname` und `address` duerfen keine Leerzeichen enthalten und nicht mit `#` beginnen - +`hosts.conf` ist zeilen- und leerzeichengetrennt, ein solcher Wert wuerde dort still +verstuemmelt. `hostname` muss je Ping-Rechner eindeutig sein (`422` bei einem Duplikat), +ueber verschiedene Rechner hinweg ist er erlaubt. `agent_id` muss zu einem registrierten +Agenten der Rolle `ops` gehoeren. + +`system_ids` nennt die Ids, unter denen dieser Host im System-Monitor auftauchen kann - es +sind zwei, weil beide Meldewege ihn unterschiedlich benennen: der Legacy-Import aus +`status.txt` nimmt den Hostnamen unveraendert, die REST-Meldung aus `check_fds.sh` dessen +kleingeschriebenen Slug (`Nextcloud(lokal)` -> `nextcloud-lokal`). Das Frontend erkennt daran, +welche Kachel zu welchem Host gehoert; `DELETE /api/v1/hosts/{id}` raeumt beide ab. + +### Ablauf im Ping-Check + +1. `GET /api/v1/hosts/conf` mit dem Agenten-Key des OPs-Agenten. + - `200`: der Body ist die neue `hosts.conf` - auch wenn er keinen Host enthaelt (dann + wurden im Frontend alle entfernt und es wird nichts mehr geprueft). + - `204`: fuer diesen Rechner ist noch nie etwas konfiguriert worden. Dann laedt + `check_fds.sh` seine bestehende `hosts.conf` per `POST /api/v1/hosts/import` hoch, damit + die bereits ueberwachten Hosts nicht von Hand nachgetragen werden muessen. + - Alles andere (inkl. nicht erreichbarer API): die zuletzt geschriebene lokale Datei bleibt + in Kraft - eine Stoerung der API darf die Ueberwachung nicht anhalten. +2. `POST /api/v1/hosts/import` nimmt nur den allerersten Upload eines Rechners an und + antwortet danach mit `{ "imported": 0 }`. Sonst wuerde ein Ping-Rechner mit alter lokaler + Datei die im Frontend geloeschten Hosts wieder anlegen. + ## Codeberg-Sync (Backlog/Done) `api/bin/sync-codeberg.php` liest offene und geschlossene Issues aus den konfigurierten diff --git a/README.md b/README.md index a9099e9..b2736d8 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,11 @@ die dazugehörigen Klärungen in [Detailfragen.md](Detailfragen.md) und (Owner/Repo + Farbe) hinzufügen, bearbeiten und löschen lassen (Issue #27) — Änderungen wirken sofort im laufenden `sync`-Dienst, ohne Neustart. Schreibende Aktionen fragen einmalig den `MONITOR_ADMIN_API_KEY` ab und merken ihn sich im Browser. +- Klick auf das Zahnrad neben der Überschrift „Systeme" öffnet die Verwaltung der per Ping + überwachten Hosts (Issue #70): Server hinzufügen, umbenennen und entfernen, ohne sich auf + den Rechner mit `check_fds.sh` einzuloggen — der holt seine `hosts.conf` bei jedem Lauf von + der API. Ein entfernter Host nimmt seine Kachel im System-Monitor mit; Kacheln ohne + zugehörigen Host lassen sich im selben Dialog einzeln aufräumen. - Codeberg-Sync als Dauerläufer (Compose-Service `sync`): füllt Backlog und Done automatisch aus den Issues mehrerer konfigurierter Codeberg-Projekte (je Projekt mit eigener Ticket-Farbe, siehe oben) — periodisch alle `MONITOR_SYNC_INTERVAL` Sekunden und, wenn ein @@ -95,7 +100,8 @@ kein System gemeldet wurde — dafür gibt es zwei unabhängige Wege: **Weg 1: Klassische Ping-Überwachung (`hosts.conf`)** 1. `hosts-sample.conf` nach `hosts.conf` kopieren und um den neuen Server erweitern - (eine Zeile pro Server, Format `Hostname IP`): + (eine Zeile pro Server, Format `Hostname IP`) — ist Weg 3 eingerichtet, geht das + stattdessen über die Oberfläche (siehe „Hosts über die Oberfläche pflegen" weiter unten): ``` Webserver 192.168.178.10 ``` @@ -156,4 +162,34 @@ Die System-ID entsteht dabei aus dem Hostnamen (`Nextcloud(intern)` → `nextclo der Originalname bleibt als Anzeigename erhalten. Ist die API nicht erreichbar, läuft die Ping-Überwachung normal weiter und protokolliert nur eine Warnung. +## ⚙️ Hosts über die Oberfläche pflegen + +Ist Weg 3 eingerichtet, muss für einen neuen oder entfernten Server niemand mehr die +`hosts.conf` auf dem Ping-Rechner editieren (Issue #70): Ein Klick auf das **Zahnrad neben +der Überschrift „Systeme"** öffnet die Verwaltung der überwachten Hosts — dasselbe Muster +wie das Zahnrad am PO-Platz für die Codeberg-Projekte. Änderungen verlangen den +`MONITOR_ADMIN_API_KEY`; der Browser fragt einmalig danach und merkt ihn sich lokal. + +Wie die Liste auf den Ping-Rechner kommt: + +- `check_fds.sh` holt sie bei **jedem** Lauf mit seinem Agenten-Key ab und schreibt daraus + seine lokale `hosts.conf`. Von Hand editiert man die Datei danach nicht mehr — der nächste + Cron-Lauf überschreibt sie. +- Beim ersten Lauf lädt der Check seine **bestehende** `hosts.conf` einmalig hoch, damit die + schon überwachten Server nicht von Hand nachgetragen werden müssen. Danach hat die + Oberfläche das letzte Wort: Ein dort entfernter Host kommt über diesen Weg nicht zurück. +- Ist die API nicht erreichbar, pingt der Check unverändert nach der zuletzt geschriebenen + Datei weiter und protokolliert nur eine Warnung. + +Ein hier entfernter Host nimmt seine **Kachel im System-Monitor mit** — auch die aus dem +`status.txt`-Import, die unter dem unveränderten Hostnamen läuft. Kacheln, die zu keinem +gepflegten Host gehören (etwa Server, die sich nach Weg 2 selbst melden, oder Reste eines +längst entfernten Hosts), stehen im selben Dialog unter „Weitere Kacheln im System-Monitor" +und lassen sich dort einzeln entfernen. + +> Voraussetzung ist ein registrierter OPs-Agent (`register-ops-agent.sh`, siehe Weg 3) — nur +> für Hosts, die von einem solchen Ping-Check geprüft werden, kann die Oberfläche die +> `hosts.conf` überhaupt pflegen. Server, die ihren Status nach Weg 2 selbst melden, bleiben +> unberührt. + diff --git a/api/src/Application.php b/api/src/Application.php index e4a8117..8a1b099 100644 --- a/api/src/Application.php +++ b/api/src/Application.php @@ -10,6 +10,7 @@ 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; @@ -26,6 +27,7 @@ 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; @@ -57,6 +59,7 @@ final class Application $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); @@ -73,6 +76,7 @@ final class Application $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( @@ -96,6 +100,29 @@ final class Application $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'); diff --git a/api/src/Controller/HostConfigController.php b/api/src/Controller/HostConfigController.php new file mode 100644 index 0000000..9d83cac --- /dev/null +++ b/api/src/Controller/HostConfigController.php @@ -0,0 +1,136 @@ + $host + ['system_ids' => SystemRepository::idsForHostname($host['hostname'])], + $this->hosts->all() + ); + + return Response::json(['hosts' => $hosts]); + } + + public function create(Request $request): Response + { + try { + $host = $this->hosts->create($request->body); + } catch (InvalidArgumentException $e) { + return Response::error($e->getMessage(), 422); + } + + return Response::json(['host' => $host], 201); + } + + /** @param array $params */ + public function update(Request $request, array $params): Response + { + $previous = $this->hosts->find((int) $params['id']); + + try { + $host = $this->hosts->update((int) $params['id'], $request->body); + } catch (InvalidArgumentException $e) { + return Response::error($e->getMessage(), 422); + } + + // Ein umbenannter Host meldet sich unter neuer System-Id; die alte Kachel haette + // sonst niemanden mehr, der sie aktualisiert, und bliebe fuer immer stehen. + if ($previous !== null && $previous['hostname'] !== $host['hostname']) { + $this->systems->delete(SystemRepository::idsForHostname($previous['hostname'])); + } + + return Response::json(['host' => $host]); + } + + /** + * Entfernt den Host aus der Ueberwachung und raeumt seine Kachel im System-Monitor + * gleich mit ab - analog zu Issue #43, wo ein entferntes Projekt auch seine Tickets + * mitnimmt. Ohne das bliebe die (womoeglich rote) Kachel stehen, obwohl niemand den + * Host mehr prueft. + * + * @param array $params + */ + public function delete(Request $request, array $params): Response + { + $host = $this->hosts->delete((int) $params['id']); + $this->systems->delete(SystemRepository::idsForHostname($host['hostname'])); + + return Response::json(['deleted' => true]); + } + + /** + * Die hosts.conf dieses Ping-Rechners als Klartext - check_fds.sh schreibt den Body + * direkt in seine lokale Datei. + * + * 204 statt 200 heisst "fuer dich ist hier noch nichts konfiguriert": dann soll der + * Ping-Rechner seine bestehende Datei abgeben (import()), statt sie zu ueberschreiben. + * Ohne diese Unterscheidung waere eine leere Antwort zweideutig - eine im Frontend + * geleerte Liste saehe aus wie eine unbenutzte, und der Check pingte munter nach seiner + * alten lokalen Datei weiter. + * + * @param array $params + * @param array $agent - der ueber X-Api-Key authentifizierte Agent. + */ + public function conf(Request $request, array $params, array $agent): Response + { + if (($agent['role'] ?? '') !== 'ops') { + return Response::error('Nur Agenten der Rolle "ops" haben eine hosts.conf.', 403); + } + + $agentId = (int) $agent['id']; + if (!$this->hosts->hasImported($agentId) && $this->hosts->allForAgent($agentId) === []) { + return Response::text('', 204); + } + + return Response::text($this->hosts->renderHostsConf($agentId)); + } + + /** + * Erstbefuellung: check_fds.sh schickt seine bestehende hosts.conf als Klartext, solange + * fuer diesen Agenten serverseitig noch nichts konfiguriert ist. Ist bereits etwas + * hinterlegt, passiert nichts (imported = 0) - die im Frontend gepflegte Liste hat + * Vorrang vor der lokalen Datei. + * + * @param array $params + * @param array $agent + */ + public function import(Request $request, array $params, array $agent): Response + { + if (($agent['role'] ?? '') !== 'ops') { + return Response::error('Nur Agenten der Rolle "ops" haben eine hosts.conf.', 403); + } + + $rows = HostConfigRepository::parseHostsConf($request->rawBody); + $imported = $this->hosts->importForAgent((int) $agent['id'], $rows); + + return Response::json(['imported' => $imported]); + } +} diff --git a/api/src/Controller/SystemController.php b/api/src/Controller/SystemController.php index 5c7096a..dff13e0 100644 --- a/api/src/Controller/SystemController.php +++ b/api/src/Controller/SystemController.php @@ -26,6 +26,22 @@ final class SystemController return Response::json(['systems' => $this->systems->all()]); } + /** + * Issue #70: Entfernt eine Kachel aus dem System-Monitor. Gedacht fuer Systeme, die + * niemand mehr meldet - etwa weil der Host laengst aus der Ueberwachung raus ist, seine + * letzte Meldung aber in der Datenbank steht. Meldet sich das System danach wieder, + * legt reportStatus() es unveraendert neu an. + * + * Hinter dem Admin-Key wie die uebrige Konfiguration im Frontend: Loeschen ist kein + * Agenten-Vorgang, und ein fremder Agenten-Key soll keine Kacheln abraeumen koennen. + * + * @param array $params + */ + public function delete(Request $request, array $params): Response + { + return Response::json(['deleted' => $this->systems->delete([$params['id']]) > 0]); + } + /** @param array $params */ public function reportStatus(Request $request, array $params): Response { diff --git a/api/src/Db/migrations/005_host_configs.sql b/api/src/Db/migrations/005_host_configs.sql new file mode 100644 index 0000000..1950c78 --- /dev/null +++ b/api/src/Db/migrations/005_host_configs.sql @@ -0,0 +1,39 @@ +-- Issue #70: Welche Hosts der Ping-Check ueberwacht, stand bisher ausschliesslich in der +-- hosts.conf neben check_fds.sh - aenderbar nur per Login auf dem Rechner mit dem Check. +-- Die Liste wird jetzt hier gefuehrt und ueber ein Modal im Frontend (Zahnrad am Wandmonitor +-- "Systeme") gepflegt; check_fds.sh holt sie bei jedem Lauf ab und schreibt daraus seine +-- lokale hosts.conf. +-- +-- agent_id bindet jede Zeile an den OPs-Agenten (Issue #66), der diesen Host anpingt: Der +-- Check laeuft nicht zwangslaeufig auf einem einzigen Rechner, und jeder Ping-Host darf nur +-- die Liste bekommen, die er auch erreichen kann. Die Zuordnung ergibt sich beim Abruf aus +-- dem Agenten-Key, es gibt also keine Liste "fuer alle". +-- +-- Kein ON DELETE CASCADE: Agenten werden derzeit nirgends geloescht (AgentRepository kennt +-- kein delete()), ein Cascade waere also toter Code mit Ueberraschungspotenzial. +CREATE TABLE IF NOT EXISTS host_configs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + agent_id INTEGER NOT NULL, + hostname TEXT NOT NULL, + address TEXT NOT NULL, + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY (agent_id) REFERENCES agents (id) +); + +-- Derselbe Hostname zweimal in einer hosts.conf waere ein doppelter Ping und eine doppelte +-- Kachel (die System-Id leitet sich aus dem Namen ab). Ueber verschiedene Ping-Hosts hinweg +-- ist er dagegen erlaubt - zwei Standorte duerfen denselben Namen ueberwachen. +CREATE UNIQUE INDEX IF NOT EXISTS idx_host_configs_agent_hostname ON host_configs (agent_id, hostname); + +-- Merkt, dass ein Ping-Rechner seine bestehende hosts.conf einmal hochgeladen hat +-- (HostConfigRepository::importForAgent). Ohne diesen Marker waere "die API kennt keine +-- Hosts fuer dich" zweideutig: einmal "noch nie konfiguriert" (dann soll die lokale Datei +-- uebernommen werden) und einmal "im Frontend wurden alle Hosts entfernt" - im zweiten Fall +-- wuerde der naechste Lauf die geloeschten Hosts aus der lokalen Datei wieder anlegen. +CREATE TABLE IF NOT EXISTS host_config_imports ( + agent_id INTEGER PRIMARY KEY, + imported_at TEXT NOT NULL, + FOREIGN KEY (agent_id) REFERENCES agents (id) +); diff --git a/api/src/Http/Response.php b/api/src/Http/Response.php index 6614101..e4b3972 100644 --- a/api/src/Http/Response.php +++ b/api/src/Http/Response.php @@ -16,6 +16,9 @@ final class Response public readonly int $status, private readonly mixed $data, private readonly ?\Closure $streamWriter = null, + // Gesetzt fuer Antworten, deren Body kein JSON ist (Issue #70: die hosts.conf, die + // check_fds.sh direkt in eine Datei schreibt) - dann ist $data der fertige Body. + private readonly ?string $contentType = null, ) { } @@ -30,6 +33,17 @@ final class Response return new self($status, ['error' => $message]); } + /** + * Klartext-Antwort: $body geht unveraendert raus, ohne JSON-Kodierung. Issue #70: Der + * Ping-Check holt seine hosts.conf ueber die API und schreibt den Body direkt in die + * Datei - eine JSON-Huelle muesste er in Shell wieder aufdroeseln, was bei jeder + * unerwarteten Zeile die Ueberwachungsliste beschaedigen koennte. + */ + public static function text(string $body, int $status = 200): self + { + return new self($status, $body, null, 'text/plain; charset=utf-8'); + } + /** * Antwort auf einen CORS-Preflight (OPTIONS): 204 ohne Body, die eigentliche Auskunft * stehen in den Access-Control-Headern aus send(). @@ -101,6 +115,13 @@ final class Response return; } + if ($this->contentType !== null) { + header('Content-Type: ' . $this->contentType); + echo (string) $this->data; + + return; + } + header('Content-Type: application/json; charset=utf-8'); echo json_encode($this->data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); } diff --git a/api/src/Repository/HostConfigRepository.php b/api/src/Repository/HostConfigRepository.php new file mode 100644 index 0000000..d7c8c83 --- /dev/null +++ b/api/src/Repository/HostConfigRepository.php @@ -0,0 +1,312 @@ +> + */ + public function all(): array + { + $stmt = $this->db->pdo()->query( + 'SELECT h.*, a.display_name AS agent_name + FROM host_configs h + JOIN agents a ON a.id = h.agent_id + ORDER BY h.agent_id ASC, h.sort_order ASC, h.id ASC' + ); + + return $stmt->fetchAll(); + } + + /** @return array> */ + public function allForAgent(int $agentId): array + { + $stmt = $this->db->pdo()->prepare( + 'SELECT * FROM host_configs WHERE agent_id = :agent_id ORDER BY sort_order ASC, id ASC' + ); + $stmt->execute(['agent_id' => $agentId]); + + return $stmt->fetchAll(); + } + + /** @return array|null */ + public function find(int $id): ?array + { + $stmt = $this->db->pdo()->prepare('SELECT * FROM host_configs WHERE id = :id'); + $stmt->execute(['id' => $id]); + $row = $stmt->fetch(); + + return $row === false ? null : $row; + } + + /** @param array $data @return array */ + public function create(array $data): array + { + $agentId = $this->validateAgent($data['agent_id'] ?? null); + [$hostname, $address] = $this->validate($data, $agentId, null); + $now = $this->db->now(); + + $stmt = $this->db->pdo()->prepare( + 'INSERT INTO host_configs (agent_id, hostname, address, sort_order, created_at, updated_at) + VALUES (:agent_id, :hostname, :address, :sort_order, :created_at, :updated_at)' + ); + $stmt->execute([ + 'agent_id' => $agentId, + 'hostname' => $hostname, + 'address' => $address, + 'sort_order' => $this->nextSortOrder($agentId), + 'created_at' => $now, + 'updated_at' => $now, + ]); + + return $this->find((int) $this->db->pdo()->lastInsertId()); + } + + /** @param array $data @return array */ + public function update(int $id, array $data): array + { + $existing = $this->find($id); + if ($existing === null) { + throw new NotFoundException('Host nicht gefunden: ' . $id); + } + + // Den Ping-Host wechselt man nicht per Bearbeiten-Formular: dafuer den Eintrag beim + // alten Agenten loeschen und beim neuen anlegen (sonst waere die alte hosts.conf + // stillschweigend um einen Host aermer, ohne dass die Zeile im Modal verschwindet). + $agentId = (int) $existing['agent_id']; + [$hostname, $address] = $this->validate($data, $agentId, $id); + + $stmt = $this->db->pdo()->prepare( + 'UPDATE host_configs SET hostname = :hostname, address = :address, updated_at = :updated_at + WHERE id = :id' + ); + $stmt->execute([ + 'id' => $id, + 'hostname' => $hostname, + 'address' => $address, + 'updated_at' => $this->db->now(), + ]); + + return $this->find($id); + } + + /** + * @return array - der geloeschte Eintrag, damit der Aufrufer + * (HostConfigController::delete() raeumt anschliessend die Kachel im System-Monitor ab) + * dessen Hostnamen kennt, ohne ihn erneut nachzuschlagen. + */ + public function delete(int $id): array + { + $host = $this->find($id); + if ($host === null) { + throw new NotFoundException('Host nicht gefunden: ' . $id); + } + + $stmt = $this->db->pdo()->prepare('DELETE FROM host_configs WHERE id = :id'); + $stmt->execute(['id' => $id]); + + return $host; + } + + /** + * Erstbefuellung aus der bestehenden hosts.conf eines Ping-Rechners (check_fds.sh laedt + * sie hoch, solange serverseitig noch nichts fuer ihn steht) - sonst muesste jeder + * bereits ueberwachte Host von Hand im Modal nachgetragen werden. + * + * Bewusst nur, wenn die Liste dieses Agenten leer ist: ein spaeterer Aufruf darf eine im + * Frontend gepflegte Liste nicht durch die (dann veraltete) lokale Datei ersetzen. + * + * @param array $rows + * @return int Anzahl uebernommener Hosts (0 = es war bereits etwas konfiguriert). + */ + public function importForAgent(int $agentId, array $rows): int + { + if ($this->hasImported($agentId) || $this->allForAgent($agentId) !== []) { + return 0; + } + + // Der Marker steht vor dem Import: Auch ein Rechner, dessen hosts.conf leer oder + // unbrauchbar war, hat seine Datei damit abgegeben - sonst wuerde er es bei jedem + // Lauf erneut versuchen und irgendwann eine im Frontend geleerte Liste neu befuellen. + $stmt = $this->db->pdo()->prepare( + 'INSERT INTO host_config_imports (agent_id, imported_at) VALUES (:agent_id, :imported_at)' + ); + $stmt->execute(['agent_id' => $agentId, 'imported_at' => $this->db->now()]); + + $imported = 0; + foreach ($rows as $row) { + try { + $this->create([ + 'agent_id' => $agentId, + 'hostname' => $row['hostname'] ?? '', + 'address' => $row['address'] ?? '', + ]); + $imported++; + } catch (InvalidArgumentException) { + // Eine unbrauchbare oder doppelte Zeile darf den Import der uebrigen nicht + // verhindern - die hosts.conf ist eine handgepflegte Datei. + continue; + } + } + + return $imported; + } + + /** Hat dieser Ping-Rechner seine lokale hosts.conf schon einmal abgegeben? */ + public function hasImported(int $agentId): bool + { + $stmt = $this->db->pdo()->prepare('SELECT COUNT(*) FROM host_config_imports WHERE agent_id = :agent_id'); + $stmt->execute(['agent_id' => $agentId]); + + return ((int) $stmt->fetchColumn()) > 0; + } + + /** + * Baut den Inhalt der hosts.conf fuer einen Ping-Rechner: eine Zeile "Hostname IP" je + * Host, im Format, das check_fds.sh liest. + */ + public function renderHostsConf(int $agentId): string + { + $lines = [ + '# Von der monitor-API erzeugt (Issue #70) - Aenderungen bitte im Frontend', + '# vornehmen (Zahnrad am Wandmonitor "Systeme"), nicht hier: check_fds.sh', + '# ueberschreibt diese Datei bei jedem Lauf mit der Liste aus der API.', + ]; + foreach ($this->allForAgent($agentId) as $host) { + $lines[] = $host['hostname'] . ' ' . $host['address']; + } + + return implode("\n", $lines) . "\n"; + } + + /** + * Liest eine hosts.conf im Format von check_fds.sh ("Hostname IP" je Zeile, "#" ist + * Kommentar) - Gegenstueck zu renderHostsConf(), fuer die Erstbefuellung aus der bereits + * gepflegten Datei eines Ping-Rechners. + * + * @return array + */ + public static function parseHostsConf(string $content): array + { + $rows = []; + foreach (preg_split('/\r\n|\r|\n/', $content) ?: [] as $line) { + $line = trim($line); + if ($line === '' || str_starts_with($line, '#')) { + continue; + } + + $parts = preg_split('/\s+/', $line); + if ($parts === false || count($parts) < 2) { + continue; + } + + $rows[] = ['hostname' => $parts[0], 'address' => $parts[1]]; + } + + return $rows; + } + + /** + * Prueft Pflichtfelder und Format vorab, statt eine Verletzung der UNIQUE-Constraint + * (agent_id, hostname) als PDOException auflaufen zu lassen - Konvention wie in + * RepoConfigRepository::validate(). + * + * @param array $data + * @param int|null $excludeId - beim Update die eigene Zeile von der Duplikatspruefung ausnehmen. + * @return array{0: string, 1: string} + */ + private function validate(array $data, int $agentId, ?int $excludeId): array + { + $hostname = trim((string) ($data['hostname'] ?? '')); + $address = trim((string) ($data['address'] ?? '')); + + if ($hostname === '' || $address === '') { + throw new InvalidArgumentException('Felder "hostname" und "address" sind erforderlich.'); + } + + // hosts.conf ist eine zeilen- und leerzeichengetrennte Datei: ein Wert mit Leerzeichen, + // Zeilenumbruch oder fuehrendem "#" wuerde dort als zweites Feld bzw. als Kommentar + // gelesen und den Eintrag stillschweigend verstuemmeln. + foreach (['hostname' => $hostname, 'address' => $address] as $field => $value) { + if (preg_match('/\s/', $value) === 1 || str_starts_with($value, '#')) { + throw new InvalidArgumentException(sprintf( + 'Feld "%s" darf keine Leerzeichen enthalten und nicht mit "#" beginnen.', + $field + )); + } + } + + if ($this->existsWithHostname($agentId, $hostname, $excludeId)) { + throw new InvalidArgumentException(sprintf('Host "%s" ist bereits konfiguriert.', $hostname)); + } + + return [$hostname, $address]; + } + + /** Nur registrierte Agenten der Rolle "ops" pingen - alles andere waere eine Liste ohne Leser. */ + private function validateAgent(mixed $agentId): int + { + $id = is_numeric($agentId) ? (int) $agentId : 0; + if ($id <= 0) { + throw new InvalidArgumentException('Feld "agent_id" ist erforderlich (OPs-Agent des Ping-Rechners).'); + } + + $stmt = $this->db->pdo()->prepare('SELECT role FROM agents WHERE id = :id'); + $stmt->execute(['id' => $id]); + $role = $stmt->fetchColumn(); + + if ($role === false) { + throw new InvalidArgumentException('Unbekannter Agent: ' . $id); + } + if ($role !== 'ops') { + throw new InvalidArgumentException('Agent ' . $id . ' hat nicht die Rolle "ops".'); + } + + return $id; + } + + private function existsWithHostname(int $agentId, string $hostname, ?int $excludeId): bool + { + $sql = 'SELECT COUNT(*) FROM host_configs WHERE agent_id = :agent_id AND hostname = :hostname'; + $params = ['agent_id' => $agentId, 'hostname' => $hostname]; + if ($excludeId !== null) { + $sql .= ' AND id != :exclude_id'; + $params['exclude_id'] = $excludeId; + } + + $stmt = $this->db->pdo()->prepare($sql); + $stmt->execute($params); + + return ((int) $stmt->fetchColumn()) > 0; + } + + private function nextSortOrder(int $agentId): int + { + $stmt = $this->db->pdo()->prepare( + 'SELECT COALESCE(MAX(sort_order), -1) + 1 AS next FROM host_configs WHERE agent_id = :agent_id' + ); + $stmt->execute(['agent_id' => $agentId]); + + return (int) $stmt->fetch()['next']; + } +} diff --git a/api/src/Repository/SystemRepository.php b/api/src/Repository/SystemRepository.php index 1082edf..b729cba 100644 --- a/api/src/Repository/SystemRepository.php +++ b/api/src/Repository/SystemRepository.php @@ -103,6 +103,44 @@ final class SystemRepository return $stmt->fetchAll(); } + /** + * Issue #70: Entfernt Systeme aus dem Monitor. Noetig, seit die Hosts der Ping-Ueberwachung + * im Frontend gepflegt werden - ein einmal gemeldetes System blieb sonst als Kachel stehen, + * auch wenn niemand es mehr prueft (die Kachel wurde nur nicht mehr aktualisiert). + * + * @param array $ids + * @return int Anzahl tatsaechlich geloeschter Zeilen. + */ + public function delete(array $ids): int + { + $deleted = 0; + $stmt = $this->db->pdo()->prepare('DELETE FROM systems WHERE id = :id'); + foreach (array_unique($ids) as $id) { + $stmt->execute(['id' => $id]); + $deleted += $stmt->rowCount(); + } + + return $deleted; + } + + /** + * Die System-Ids, unter denen ein Host aus der hosts.conf im Monitor auftauchen kann. + * Es sind zwei, weil beide Meldewege denselben Host unterschiedlich benennen: Der + * Legacy-Import aus status.txt nimmt den Hostnamen unveraendert (LegacyStatusFileReader), + * die REST-Meldung aus check_fds.sh dessen URL-sicheren Slug (dortige system_id()). Wer + * einen Host entfernt, will beide Kacheln los. + * + * @return array + */ + public static function idsForHostname(string $hostname): array + { + $slug = strtolower($hostname); + $slug = (string) preg_replace('/[^a-z0-9]+/', '-', $slug); + $slug = trim($slug, '-'); + + return array_values(array_unique([$hostname, $slug === '' ? 'unbekannt' : $slug])); + } + /** Fingerabdruck fuer den SSE-Endpoint (Realtime\ChangeFeed, F13). */ public function fingerprint(): string { diff --git a/api/tests/Unit/Controller/HostConfigControllerTest.php b/api/tests/Unit/Controller/HostConfigControllerTest.php new file mode 100644 index 0000000..d7d5be0 --- /dev/null +++ b/api/tests/Unit/Controller/HostConfigControllerTest.php @@ -0,0 +1,162 @@ + */ + private array $opsAgent; + + protected function setUp(): void + { + $db = Database::inMemory(); + $agents = new AgentRepository($db); + $this->opsAgent = $agents->register('OPs', 'ops')['agent']; + $this->hosts = new HostConfigRepository($db); + $this->systems = new SystemRepository($db); + $this->controller = new HostConfigController($this->hosts, $this->systems); + } + + public function testIndexReturnsDerivedSystemIds(): void + { + $this->hosts->create([ + 'agent_id' => $this->opsAgent['id'], + 'hostname' => 'Nextcloud(lokal)', + 'address' => '192.168.2.7', + ]); + + $body = $this->controller->index(Request::create('GET', '/api/v1/hosts'))->body(); + + self::assertSame( + ['Nextcloud(lokal)', 'nextcloud-lokal'], + $body['hosts'][0]['system_ids'] + ); + self::assertSame('OPs', $body['hosts'][0]['agent_name']); + } + + public function testDeleteAlsoRemovesTilesOfBothReportingPaths(): void + { + $host = $this->hosts->create([ + 'agent_id' => $this->opsAgent['id'], + 'hostname' => 'Nextcloud(lokal)', + 'address' => '192.168.2.7', + ]); + // So wuerde derselbe Host in den Monitor kommen: per REST unter dem Slug, + // per status.txt-Import unter dem Namen. + $this->systems->reportStatus('nextcloud-lokal', ['status' => 'error']); + $this->systems->importLegacyRows([[ + 'id' => 'Nextcloud(lokal)', 'name' => 'Nextcloud(lokal)', 'status' => 'error', + 'detail' => '192.168.2.7', 'last_check_at' => '2026-01-01 00:00', + ]]); + + $this->controller->delete(Request::create('DELETE', '/api/v1/hosts/1'), ['id' => (string) $host['id']]); + + self::assertNull($this->systems->find('nextcloud-lokal')); + self::assertNull($this->systems->find('Nextcloud(lokal)')); + } + + public function testUpdateRemovesTileOfPreviousHostname(): void + { + $host = $this->hosts->create([ + 'agent_id' => $this->opsAgent['id'], + 'hostname' => 'alt', + 'address' => '10.0.0.1', + ]); + $this->systems->reportStatus('alt', ['status' => 'ok']); + + $this->controller->update( + Request::create('PATCH', '/api/v1/hosts/1', [], ['hostname' => 'neu', 'address' => '10.0.0.1']), + ['id' => (string) $host['id']] + ); + + self::assertNull($this->systems->find('alt')); + } + + public function testConfReturnsPlainHostsFileForAuthenticatedAgent(): void + { + $this->hosts->create(['agent_id' => $this->opsAgent['id'], 'hostname' => 'Web', 'address' => '10.0.0.1']); + + $response = $this->controller->conf(Request::create('GET', '/api/v1/hosts/conf'), [], $this->opsAgent); + + self::assertSame(200, $response->status); + self::assertStringContainsString("Web 10.0.0.1\n", (string) $response->body()); + } + + public function testConfAnswers204BeforeTheFirstImport(): void + { + $response = $this->controller->conf(Request::create('GET', '/api/v1/hosts/conf'), [], $this->opsAgent); + + self::assertSame(204, $response->status); + } + + public function testConfAnswers200WithAnEmptyListAfterTheImport(): void + { + // Alle Hosts im Frontend entfernt: der Ping-Rechner soll seine Datei leeren, nicht + // die alte Liste behalten - also 200 mit leerer Liste statt 204. + $this->controller->import( + Request::create('POST', '/api/v1/hosts/import', [], [], [], "Web 10.0.0.1\n"), + [], + $this->opsAgent + ); + foreach ($this->hosts->allForAgent((int) $this->opsAgent['id']) as $host) { + $this->hosts->delete((int) $host['id']); + } + + $response = $this->controller->conf(Request::create('GET', '/api/v1/hosts/conf'), [], $this->opsAgent); + + self::assertSame(200, $response->status); + self::assertStringNotContainsString('Web', (string) $response->body()); + } + + public function testConfRejectsAgentWithoutOpsRole(): void + { + $response = $this->controller->conf( + Request::create('GET', '/api/v1/hosts/conf'), + [], + ['id' => 99, 'role' => 'entwickler'] + ); + + self::assertSame(403, $response->status); + } + + public function testImportFillsListFromUploadedHostsConf(): void + { + $conf = "# Kommentar\nWeb 10.0.0.1\nMail 10.0.0.2\n"; + + $response = $this->controller->import( + Request::create('POST', '/api/v1/hosts/import', [], [], [], $conf), + [], + $this->opsAgent + ); + + self::assertSame(2, $response->body()['imported']); + self::assertCount(2, $this->hosts->allForAgent((int) $this->opsAgent['id'])); + } + + public function testImportDoesNotOverwriteExistingList(): void + { + $this->hosts->create(['agent_id' => $this->opsAgent['id'], 'hostname' => 'Web', 'address' => '10.0.0.1']); + + $response = $this->controller->import( + Request::create('POST', '/api/v1/hosts/import', [], [], [], "Anders 10.0.0.9\n"), + [], + $this->opsAgent + ); + + self::assertSame(0, $response->body()['imported']); + self::assertCount(1, $this->hosts->allForAgent((int) $this->opsAgent['id'])); + } +} diff --git a/api/tests/Unit/Repository/HostConfigRepositoryTest.php b/api/tests/Unit/Repository/HostConfigRepositoryTest.php new file mode 100644 index 0000000..3131623 --- /dev/null +++ b/api/tests/Unit/Repository/HostConfigRepositoryTest.php @@ -0,0 +1,166 @@ +opsAgentId = (int) $agents->register('OPs', 'ops')['agent']['id']; + $this->devAgentId = (int) $agents->register('Dev', 'entwickler')['agent']['id']; + $this->repository = new HostConfigRepository($db); + } + + public function testCreateStoresHost(): void + { + $host = $this->repository->create([ + 'agent_id' => $this->opsAgentId, + 'hostname' => 'Webserver', + 'address' => '192.168.178.10', + ]); + + self::assertSame('Webserver', $host['hostname']); + self::assertSame('192.168.178.10', $host['address']); + self::assertSame($this->opsAgentId, (int) $host['agent_id']); + } + + public function testCreateRejectsDuplicateHostnameForSameAgent(): void + { + $this->repository->create(['agent_id' => $this->opsAgentId, 'hostname' => 'Web', 'address' => '10.0.0.1']); + + $this->expectException(InvalidArgumentException::class); + + $this->repository->create(['agent_id' => $this->opsAgentId, 'hostname' => 'Web', 'address' => '10.0.0.2']); + } + + public function testCreateRejectsValueWithWhitespace(): void + { + $this->expectException(InvalidArgumentException::class); + + $this->repository->create([ + 'agent_id' => $this->opsAgentId, + 'hostname' => 'Mein Server', + 'address' => '10.0.0.1', + ]); + } + + public function testCreateRejectsAgentWithoutOpsRole(): void + { + $this->expectException(InvalidArgumentException::class); + + $this->repository->create(['agent_id' => $this->devAgentId, 'hostname' => 'Web', 'address' => '10.0.0.1']); + } + + public function testAllForAgentOnlyReturnsOwnHosts(): void + { + $db = Database::inMemory(); + $agents = new AgentRepository($db); + $first = (int) $agents->register('OPs A', 'ops')['agent']['id']; + $second = (int) $agents->register('OPs B', 'ops')['agent']['id']; + $repository = new HostConfigRepository($db); + $repository->create(['agent_id' => $first, 'hostname' => 'A', 'address' => '10.0.0.1']); + $repository->create(['agent_id' => $second, 'hostname' => 'B', 'address' => '10.0.0.2']); + + $hosts = $repository->allForAgent($first); + + self::assertCount(1, $hosts); + self::assertSame('A', $hosts[0]['hostname']); + } + + public function testRenderHostsConfMatchesCheckScriptFormat(): void + { + $this->repository->create(['agent_id' => $this->opsAgentId, 'hostname' => 'Web', 'address' => '10.0.0.1']); + $this->repository->create(['agent_id' => $this->opsAgentId, 'hostname' => 'Mail', 'address' => '10.0.0.2']); + + $conf = $this->repository->renderHostsConf($this->opsAgentId); + $lines = array_values(array_filter( + explode("\n", $conf), + fn (string $line) => $line !== '' && !str_starts_with($line, '#') + )); + + self::assertSame(['Web 10.0.0.1', 'Mail 10.0.0.2'], $lines); + } + + public function testParseHostsConfSkipsCommentsAndBlankLines(): void + { + $rows = HostConfigRepository::parseHostsConf("# Kommentar\n\nWeb 10.0.0.1\nMail\t10.0.0.2\nkaputt\n"); + + self::assertSame( + [['hostname' => 'Web', 'address' => '10.0.0.1'], ['hostname' => 'Mail', 'address' => '10.0.0.2']], + $rows + ); + } + + public function testImportForAgentOnlyFillsEmptyList(): void + { + $imported = $this->repository->importForAgent($this->opsAgentId, [ + ['hostname' => 'Web', 'address' => '10.0.0.1'], + ['hostname' => 'Mail', 'address' => '10.0.0.2'], + ]); + self::assertSame(2, $imported); + + $again = $this->repository->importForAgent($this->opsAgentId, [ + ['hostname' => 'Alt', 'address' => '10.0.0.9'], + ]); + + self::assertSame(0, $again); + self::assertCount(2, $this->repository->allForAgent($this->opsAgentId)); + } + + public function testImportForAgentDoesNotRefillAnEmptiedList(): void + { + $this->repository->importForAgent($this->opsAgentId, [['hostname' => 'Web', 'address' => '10.0.0.1']]); + foreach ($this->repository->allForAgent($this->opsAgentId) as $host) { + $this->repository->delete((int) $host['id']); + } + + // Genau der Fall, der ohne Marker die im Frontend geloeschten Hosts zurueckholen + // wuerde: der Ping-Rechner hat seine alte hosts.conf lokal noch. + $imported = $this->repository->importForAgent($this->opsAgentId, [['hostname' => 'Web', 'address' => '10.0.0.1']]); + + self::assertSame(0, $imported); + self::assertSame([], $this->repository->allForAgent($this->opsAgentId)); + } + + public function testUpdateChangesHostnameAndAddress(): void + { + $host = $this->repository->create([ + 'agent_id' => $this->opsAgentId, + 'hostname' => 'Web', + 'address' => '10.0.0.1', + ]); + + $updated = $this->repository->update((int) $host['id'], ['hostname' => 'Web2', 'address' => '10.0.0.5']); + + self::assertSame('Web2', $updated['hostname']); + self::assertSame('10.0.0.5', $updated['address']); + } + + public function testDeleteRemovesHostAndReturnsIt(): void + { + $host = $this->repository->create([ + 'agent_id' => $this->opsAgentId, + 'hostname' => 'Web', + 'address' => '10.0.0.1', + ]); + + $deleted = $this->repository->delete((int) $host['id']); + + self::assertSame('Web', $deleted['hostname']); + self::assertSame([], $this->repository->allForAgent($this->opsAgentId)); + } +} diff --git a/api/tests/Unit/Repository/SystemRepositoryTest.php b/api/tests/Unit/Repository/SystemRepositoryTest.php index 4a4544d..427483b 100644 --- a/api/tests/Unit/Repository/SystemRepositoryTest.php +++ b/api/tests/Unit/Repository/SystemRepositoryTest.php @@ -86,4 +86,23 @@ final class SystemRepositoryTest extends TestCase self::assertNotSame($before, $this->repository->fingerprint()); } + + public function testDeleteRemovesSystems(): void + { + $this->repository->reportStatus('srv1', ['status' => 'ok']); + $this->repository->reportStatus('srv2', ['status' => 'ok']); + + $deleted = $this->repository->delete(['srv1', 'gibt-es-nicht']); + + self::assertSame(1, $deleted); + self::assertNull($this->repository->find('srv1')); + self::assertNotNull($this->repository->find('srv2')); + } + + public function testIdsForHostnameCoversBothReportingPaths(): void + { + self::assertSame(['Nextcloud(lokal)', 'nextcloud-lokal'], SystemRepository::idsForHostname('Nextcloud(lokal)')); + // Ein Name ohne Sonderzeichen ergibt nur eine Id - der Slug ist dann identisch. + self::assertSame(['monitor'], SystemRepository::idsForHostname('monitor')); + } } diff --git a/check_fds.sh b/check_fds.sh index b0fa4ca..e08b3ab 100755 --- a/check_fds.sh +++ b/check_fds.sh @@ -25,6 +25,72 @@ if [ -f "$API_CONF" ]; then . "$API_CONF" fi +# Issue #70: Die Liste der ueberwachten Hosts wird im Frontend gepflegt (Zahnrad am +# Wandmonitor "Systeme"). Dieser Aufruf holt sie vor jedem Durchlauf ab und schreibt daraus +# die lokale hosts.conf - deshalb muss auf diesem Rechner nichts mehr von Hand editiert +# werden. Die Ueberwachung darf dabei nie stehenbleiben: Ist die API nicht erreichbar oder +# antwortet sie mit einem Fehler, bleibt die zuletzt geschriebene hosts.conf in Kraft. +sync_hosts_from_api() { + if [ -z "$MONITOR_API_BASE" ] || [ -z "$MONITOR_API_KEY" ]; then + return 0 + fi + + local tmp code count + tmp=$(mktemp) || return 0 + + if ! code=$(curl -s -o "$tmp" -w '%{http_code}' --max-time 10 \ + -H "X-Api-Key: ${MONITOR_API_KEY}" \ + "${MONITOR_API_BASE%/}/api/v1/hosts/conf"); then + echo " Warnung: API unter $MONITOR_API_BASE nicht erreichbar, benutze die lokale $HOST_FILE." + rm -f "$tmp" + return 0 + fi + + # 204 heisst: fuer diesen Rechner ist serverseitig noch nichts konfiguriert. Dann geht + # die bestehende lokale Datei einmalig an die API, damit die bereits ueberwachten Hosts + # nicht von Hand im Frontend nachgetragen werden muessen. Ein spaeterer Aufruf wird mit + # 200 beantwortet - auch bei leerer Liste; eine im Frontend geleerte Liste fuellt dieser + # Zweig also nicht wieder auf. + if [ "$code" = "204" ]; then + if [ -s "$HOST_FILE" ]; then + local imported + imported=$(curl -s --max-time 10 -X POST \ + "${MONITOR_API_BASE%/}/api/v1/hosts/import" \ + -H "Content-Type: text/plain" \ + -H "X-Api-Key: ${MONITOR_API_KEY}" \ + --data-binary "@$HOST_FILE") + case "$imported" in + *'"imported":0'*) : ;; + *'"imported"'*) echo " Bestehende $HOST_FILE an die API uebergeben." ;; + *) echo " Warnung: Uebergabe der $HOST_FILE an die API fehlgeschlagen." ;; + esac + fi + rm -f "$tmp" + return 0 + fi + + if [ "$code" != "200" ]; then + echo " Warnung: API antwortet mit HTTP $code auf die Host-Liste, benutze die lokale $HOST_FILE." + rm -f "$tmp" + return 0 + fi + + # Zeilen, die check_fds.sh spaeter auch wirklich pingt (ohne Kommentare/Leerzeilen). + count=$(grep -cvE '^[[:space:]]*(#|$)' "$tmp") + + if ! cmp -s "$tmp" "$HOST_FILE"; then + # cat statt mv: so behalten Besitzer und Rechte der bestehenden Datei ihren Wert, + # und ein abgebrochener Schreibvorgang trifft keine Datei aus /tmp mit 600/root. + if cat "$tmp" > "$HOST_FILE"; then + echo " Host-Liste aus der API uebernommen ($count Hosts)." + else + echo " Warnung: $HOST_FILE ist nicht schreibbar, benutze die bisherige Liste." + fi + fi + + rm -f "$tmp" +} + # Erzeugt aus dem Hostnamen eine URL-sichere System-ID. Der Router der API matcht auf dem # rohen, nicht dekodierten Pfad (api/src/Http/Router.php) - eine prozentkodierte ID landete # also kodiert in der Datenbank. Der lesbare Originalname geht im Feld "name" mit. @@ -121,6 +187,7 @@ check_ip() { # Hauptskript echo "Pruefung: $(date)" +sync_hosts_from_api if [ ! -f "$HOST_FILE" ]; then echo "Fehler: Datei $HOST_FILE nicht gefunden." exit 1 diff --git a/hosts-sample.conf b/hosts-sample.conf index 52e0e8a..957e227 100644 --- a/hosts-sample.conf +++ b/hosts-sample.conf @@ -1,4 +1,9 @@ # Nach hosts.conf kopieren und um eigene Server erweitern. +# +# Sobald der Ping-Check bei der API angemeldet ist (register-ops-agent.sh, "Weg 3" im +# README), pflegt man die Liste stattdessen ueber das Zahnrad am Wandmonitor "Systeme": +# check_fds.sh holt sie dann bei jedem Lauf ab und ueberschreibt hosts.conf damit. Diese +# Datei ist dann nur noch der Startbestand, den der erste Lauf einmalig hochlaedt. # Format: Hostname IP (ein Server pro Zeile, siehe README.md "Einen Server hinzufügen") Server1 192.168.178.2 Server2 192.168.178.3 diff --git a/html/css/office.css b/html/css/office.css index 5c5bb89..0b3c6d7 100644 --- a/html/css/office.css +++ b/html/css/office.css @@ -410,34 +410,44 @@ header.office-header h1 { pointer-events: all; } -/* Zahnrad am PO-Platz (Issue #27): zeigt ueberhaupt erst, dass sich hier die - Projektkonfiguration oeffnen laesst. Beim Hover ueber den Platz tritt es hervor. */ -.po-config-hint-bg { +/* Konfig-Zahnrad (Issue #27 am PO-Platz, Issue #70 am Systeme-Monitor): zeigt ueberhaupt + erst, dass sich hier etwas einstellen laesst. Aussehen ist ueberall gleich, nur der + Ausloeser des Hovers unterscheidet sich - beim PO der ganze Schreibtisch, beim Monitor + das Symbol selbst (der Monitor traegt schon eine eigene Klick-Aktion). */ +.config-hint-bg { fill: #ffffff; stroke: #94a3b8; stroke-width: 1.5; opacity: 0.85; } -.po-config-hint-tooth, -.po-config-hint-hub { +.config-hint-tooth, +.config-hint-hub { stroke: #475569; stroke-width: 2; stroke-linecap: round; fill: none; } -.po-desk:hover .po-config-hint-bg { +.po-desk:hover .config-hint-bg, +.system-config-hint:hover .config-hint-bg { fill: #e0f2fe; stroke: #0284c7; opacity: 1; } -.po-desk:hover .po-config-hint-tooth, -.po-desk:hover .po-config-hint-hub { +.po-desk:hover .config-hint-tooth, +.po-desk:hover .config-hint-hub, +.system-config-hint:hover .config-hint-tooth, +.system-config-hint:hover .config-hint-hub { stroke: #0284c7; } +.system-config-hint-hit { + fill: transparent; + pointer-events: all; +} + /* Reload-Symbol neben der Board-Ueberschrift (Issue #51): stoesst ein komplettes Neueinlesen des Kanban-Boards an, analog zum Zahnrad am PO-Platz. */ .board-reload-hint-bg { @@ -885,3 +895,21 @@ header.office-header h1 { .repo-config-status-icon.is-fail { color: #dc2626; } + +/* Hosts-Modal (Issue #70) - teilt sich Zeilen-, Formular- und Symbolstile mit dem + Projekte-Modal; eigene Regeln braucht nur, was es dort nicht gibt. */ + +.host-config-subheading { + margin: 18px 0 4px; + font-size: 0.95rem; + color: #1f2937; +} + +.host-config-agent { + flex: none; + padding: 2px 8px; + border-radius: 999px; + background: #f1f5f9; + color: #475569; + font-size: 0.8rem; +} diff --git a/html/js/office-api.js b/html/js/office-api.js index 6cdbf0b..ac13c5d 100644 --- a/html/js/office-api.js +++ b/html/js/office-api.js @@ -166,3 +166,39 @@ export async function resyncTickets(adminKey) { const { tickets } = await adminJson("POST", "/tickets/resync", undefined, adminKey); return tickets; } + +// --------------------------------------------------------------------------------------- +// Hosts der Ping-Ueberwachung (Issue #70: Zahnrad am Wandmonitor "Systeme" oeffnet ein Modal, +// um die hosts.conf des Ping-Rechners ueber die GUI zu pflegen). Schreibend hinter demselben +// Admin-Key wie die Projekte-Verwaltung; der Ping-Check selbst holt seine Liste dagegen mit +// seinem Agenten-Key ab (GET /hosts/conf, siehe check_fds.sh) - das macht der Browser nicht. +// --------------------------------------------------------------------------------------- + +export async function fetchHostConfigs() { + const { hosts } = await getJson("/hosts"); + return hosts; +} + +export async function createHostConfig(data, adminKey) { + const { host } = await adminJson("POST", "/hosts", data, adminKey); + return host; +} + +export async function updateHostConfig(id, data, adminKey) { + const { host } = await adminJson("PATCH", `/hosts/${encodeURIComponent(id)}`, data, adminKey); + return host; +} + +export async function deleteHostConfig(id, adminKey) { + await adminJson("DELETE", `/hosts/${encodeURIComponent(id)}`, undefined, adminKey); +} + +/** + * Entfernt eine Kachel aus dem System-Monitor. Fuer Systeme, die zu keinem hier gepflegten + * Host gehoeren - etwa weil sie sich frueher selbst per REST gemeldet haben und das nicht + * mehr tun. Ein Host aus der Ping-Ueberwachung nimmt seine Kachel dagegen beim Loeschen + * selbst mit (serverseitig, siehe HostConfigController::delete). + */ +export async function deleteSystem(id, adminKey) { + await adminJson("DELETE", `/systems/${encodeURIComponent(id)}`, undefined, adminKey); +} diff --git a/html/js/office-render.js b/html/js/office-render.js index 622329e..1500fd1 100644 --- a/html/js/office-render.js +++ b/html/js/office-render.js @@ -51,6 +51,7 @@ import { PO_DISPLAY_NAME, resolveReloadIntervalSeconds, nextCountdownSeconds, + systemsWithoutHostConfig, } from "./office-scene.js"; import { DEMO_TICKET_COUNTS, @@ -72,6 +73,11 @@ import { deleteRepoConfig, checkRepoConfig, resyncTickets, + fetchHostConfigs, + createHostConfig, + updateHostConfig, + deleteHostConfig, + deleteSystem, getStoredAdminKey, setStoredAdminKey, } from "./office-api.js"; @@ -379,11 +385,12 @@ function renderEventMonitor(frame, events) { return g; } -function renderSystemMonitor(frame, systems) { +function renderSystemMonitor(frame, systems, onConfigClick) { const tiles = buildSystemTiles(systems); const g = el("g", { class: "monitor monitor-systems" }); g.appendChild(el("rect", { ...frame, rx: 8, class: "monitor-frame" })); g.appendChild(text("Systeme", { x: frame.x + 12, y: frame.y - 10, class: "wall-label" })); + g.appendChild(renderSystemConfigHint(frame, onConfigClick)); g.appendChild( el("rect", { x: frame.x + 10, @@ -527,7 +534,8 @@ function renderPlaceholderText(g, frame, placeholder) { } /** - * @param {{onEventsClick: () => void, onSystemsClick: () => void, onKpiClick: () => void}} handlers + * @param {{onEventsClick: () => void, onSystemsClick: () => void, onKpiClick: () => void, + * onSystemsConfigClick: () => void}} handlers */ function renderMonitorWall(systems, events, kpis, handlers) { const g = el("g", { class: "monitor-wall" }); @@ -544,7 +552,7 @@ function renderMonitorWall(systems, events, kpis, handlers) { eventsGroup.classList.add("clickable"); eventsGroup.addEventListener("click", handlers.onEventsClick); - const systemsGroup = renderSystemMonitor(frames[1], systems); + const systemsGroup = renderSystemMonitor(frames[1], systems, handlers.onSystemsConfigClick); systemsGroup.classList.add("clickable"); systemsGroup.addEventListener("click", handlers.onSystemsClick); @@ -740,23 +748,67 @@ function renderPoConfigHint() { const title = document.createElementNS(SVG_NS, "title"); title.textContent = "Projekte konfigurieren"; g.appendChild(title); + gearHintShapes().forEach((node) => g.appendChild(node)); + + return g; +} + +/** + * Zahnrad aus acht Zaehnen plus Nabe - in dieser Groesse von einem exakten Zahnradpfad nicht + * zu unterscheiden und ohne fremde Icon-Bibliothek zu haben. Gemeinsame Silhouette fuer alle + * Konfig-Hinweise (PO-Platz, Issue #27; Systeme-Monitor, Issue #70): Dass ueberall dasselbe + * Symbol fuer "hier laesst sich etwas einstellen" steht, ist der halbe Wiedererkennungswert. + * Die Farben kommen ueber die Klassen config-hint-*; das Hover-Verhalten haengt dagegen an + * der umgebenden Gruppe, weil es sich unterscheidet (der PO reagiert auf den ganzen + * Schreibtisch, das Monitor-Zahnrad nur auf sich selbst). + * + * @param {number} scale - 1 = die Groesse am PO-Platz (Radius 15). + */ +function gearHintShapes(scale = 1) { + const nodes = [el("circle", { cx: 0, cy: 0, r: 15 * scale, class: "config-hint-bg" })]; - g.appendChild(el("circle", { cx: 0, cy: 0, r: 15, class: "po-config-hint-bg" })); - // Zahnrad aus acht Zaehnen plus Nabe - in dieser Groesse von einem exakten Zahnradpfad - // nicht zu unterscheiden und ohne fremde Icon-Bibliothek zu haben. for (let i = 0; i < 8; i++) { const angle = (i * Math.PI) / 4; - g.appendChild( + nodes.push( el("line", { - x1: Math.cos(angle) * 5.5, - y1: Math.sin(angle) * 5.5, - x2: Math.cos(angle) * 9.5, - y2: Math.sin(angle) * 9.5, - class: "po-config-hint-tooth", + x1: Math.cos(angle) * 5.5 * scale, + y1: Math.sin(angle) * 5.5 * scale, + x2: Math.cos(angle) * 9.5 * scale, + y2: Math.sin(angle) * 9.5 * scale, + class: "config-hint-tooth", }) ); } - g.appendChild(el("circle", { cx: 0, cy: 0, r: 5, class: "po-config-hint-hub" })); + nodes.push(el("circle", { cx: 0, cy: 0, r: 5 * scale, class: "config-hint-hub" })); + + return nodes; +} + +/** + * Issue #70: Zahnrad neben der Ueberschrift "Systeme" - oeffnet die Verwaltung der + * ueberwachten Hosts. Sitzt an derselben Stelle wie das Reload-Symbol am Board + * (renderBoardReloadHint), damit beide Bedienelemente ein System ergeben. + */ +function renderSystemConfigHint(frame, onClick) { + const g = el("g", { + class: "system-config-hint clickable", + transform: `translate(${frame.x + 128}, ${frame.y - 15})`, + }); + + const title = document.createElementNS(SVG_NS, "title"); + title.textContent = "Überwachte Hosts konfigurieren"; + g.appendChild(title); + // 13/15 = etwas kleiner als am PO-Platz, passend zum Reload-Symbol am Board. + gearHintShapes(13 / 15).forEach((node) => g.appendChild(node)); + // Groessere unsichtbare Trefferflaeche, analog zu .board-reload-hint-hit. + g.appendChild(el("rect", { x: -16, y: -16, width: 32, height: 32, class: "system-config-hint-hit" })); + + g.addEventListener("click", (event) => { + // Der ganze Monitor traegt einen Klick-Handler (Detailliste der Systeme) - ohne das + // Stoppen laege ueber der frisch geoeffneten Host-Verwaltung sofort das Detail-Modal. + event.stopPropagation(); + onClick(); + }); return g; } @@ -1652,12 +1704,15 @@ async function handleRepoConfigDelete(repo) { * der naechste Versuch erneut fragt statt denselben ungueltigen Wert wiederzuverwenden. * * @param {(adminKey: string) => Promise} action + * @param {{setError: (message: string) => void, prompt: string}} [ui] - wohin die + * Fehlermeldung gehoert und wonach der Prompt fragt. Beides haengt am aufrufenden Modal + * (Projekte, Issue #27 - Hosts, Issue #70), die Key-Logik ist fuer beide dieselbe. * @returns {Promise} */ -async function withAdminKey(action) { +async function withAdminKey(action, ui = REPO_MODAL_ADMIN_UI) { let adminKey = getStoredAdminKey(); if (!adminKey) { - adminKey = window.prompt("Admin-Key für Änderungen an Projekten:") || ""; + adminKey = window.prompt(ui.prompt) || ""; if (!adminKey) { return false; } @@ -1670,14 +1725,28 @@ async function withAdminKey(action) { } catch (err) { if (err && err.isAuthError) { setStoredAdminKey(""); - repoModalError = "Admin-Key ungültig."; + ui.setError("Admin-Key ungültig."); } else { - repoModalError = err instanceof Error ? err.message : "Aktion fehlgeschlagen."; + ui.setError(err instanceof Error ? err.message : "Aktion fehlgeschlagen."); } return false; } } +const REPO_MODAL_ADMIN_UI = { + setError: (message) => { + repoModalError = message; + }, + prompt: "Admin-Key für Änderungen an Projekten:", +}; + +const HOST_MODAL_ADMIN_UI = { + setError: (message) => { + hostModalError = message; + }, + prompt: "Admin-Key für Änderungen an den überwachten Hosts:", +}; + /** * Wie withAdminKey(), gibt bei Erfolg aber den Rueckgabewert von action() zurueck statt nur * true - fuer Aufrufer, die (anders als Speichern/Loeschen, die den Zustand ohnehin per @@ -1687,10 +1756,10 @@ async function withAdminKey(action) { * @param {(adminKey: string) => Promise} action * @returns {Promise} */ -async function withAdminKeyResult(action) { +async function withAdminKeyResult(action, ui = REPO_MODAL_ADMIN_UI) { let adminKey = getStoredAdminKey(); if (!adminKey) { - adminKey = window.prompt("Admin-Key für Änderungen an Projekten:") || ""; + adminKey = window.prompt(ui.prompt) || ""; if (!adminKey) { return undefined; } @@ -1703,14 +1772,336 @@ async function withAdminKeyResult(action) { } catch (err) { if (err && err.isAuthError) { setStoredAdminKey(""); - repoModalError = "Admin-Key ungültig."; + ui.setError("Admin-Key ungültig."); } else { - repoModalError = err instanceof Error ? err.message : "Aktion fehlgeschlagen."; + ui.setError(err instanceof Error ? err.message : "Aktion fehlgeschlagen."); } return undefined; } } +// --------------------------------------------------------------------------------------- +// Hosts-Modal (Issue #70: Zahnrad am Wandmonitor "Systeme" -> Ping-Ueberwachung verwalten) +// +// Aufbau wie das Projekte-Modal (Issue #27): interaktiv, mit Server-Rundtrip, zeichnet sich +// nach jeder Aenderung selbst neu. Zwei Abschnitte, weil es zwei Arten von Kacheln gibt: +// oben die Hosts, die ein OPs-Agent per Ping prueft (die hier wirklich gepflegt werden), +// unten die uebrigen Kacheln des System-Monitors - fuer die kann die GUI nur aufraeumen, +// denn ihre Meldung kommt von woanders. +// --------------------------------------------------------------------------------------- + +let hostModalBodyEl = null; +let hostModalToken = null; +let hostModalHosts = []; +let hostModalSystems = []; +// Nur Agenten der Rolle "ops" - nur sie pingen, alle anderen haetten keine hosts.conf. +let hostModalOpsAgents = []; +let hostModalMode = null; // null | "new" | +let hostModalError = ""; + +function openHostConfigModal(systems = []) { + hostModalHosts = []; + hostModalSystems = Array.isArray(systems) ? systems : []; + hostModalOpsAgents = []; + hostModalMode = null; + hostModalError = ""; + hostModalToken = openModal({ title: "Überwachte Hosts" }, (body) => { + hostModalBodyEl = body; + renderHostConfigModal(); + }); + loadHostConfigModal(hostModalToken); +} + +async function loadHostConfigModal(token) { + let loaded = null; + let error = ""; + try { + const [hosts, agents, systems] = await Promise.all([fetchHostConfigs(), fetchAgents(), fetchSystems()]); + loaded = { hosts, agents, systems }; + } catch { + error = "Hosts konnten nicht geladen werden."; + } + + if (!isCurrentModal(token)) { + return; + } + if (loaded !== null) { + hostModalHosts = loaded.hosts; + hostModalOpsAgents = loaded.agents.filter((agent) => agent.role === "ops"); + hostModalSystems = loaded.systems; + } + hostModalError = error; + renderHostConfigModal(); +} + +function renderHostConfigModal() { + const body = hostModalBodyEl; + if (!body) return; + body.replaceChildren(); + + if (hostModalError) { + const errorEl = document.createElement("p"); + errorEl.className = "office-modal-error"; + errorEl.textContent = hostModalError; + body.appendChild(errorEl); + } + + // Ohne registrierten OPs-Agenten gibt es niemanden, der die Liste abholt und pingt - ein + // hier angelegter Host waere wirkungslos. Das sagt das Modal lieber, als ein Formular zu + // zeigen, das serverseitig ohnehin an der Rollenpruefung scheitert. + if (hostModalOpsAgents.length === 0) { + const hint = document.createElement("p"); + hint.className = "office-modal-empty"; + hint.textContent = + "Kein OPs-Agent registriert. Der Ping-Check meldet sich mit einem eigenen Agenten an – " + + "einzurichten mit register-ops-agent.sh auf dem Rechner, der die Hosts anpingt."; + body.appendChild(hint); + } + + if (hostModalHosts.length === 0 && hostModalMode !== "new") { + body.appendChild(emptyMessage("Keine Hosts konfiguriert.")); + } else { + const list = document.createElement("ul"); + list.className = "office-modal-list repo-config-list host-config-list"; + for (const host of hostModalHosts) { + list.appendChild(hostModalMode === host.id ? renderHostConfigForm(host) : renderHostConfigRow(host)); + } + body.appendChild(list); + } + + if (hostModalMode === "new") { + let list = body.querySelector(".host-config-list"); + if (!list) { + list = document.createElement("ul"); + list.className = "office-modal-list repo-config-list host-config-list"; + body.appendChild(list); + } + list.appendChild(renderHostConfigForm(null)); + } else if (hostModalOpsAgents.length > 0) { + const addButton = iconButton("add", "Host hinzufügen", () => { + hostModalMode = "new"; + renderHostConfigModal(); + }); + addButton.classList.add("repo-config-add-button"); + body.appendChild(addButton); + } + + renderOrphanSystems(body); +} + +function renderOrphanSystems(body) { + const orphans = systemsWithoutHostConfig(hostModalSystems, hostModalHosts); + if (orphans.length === 0) { + return; + } + + const heading = document.createElement("h3"); + heading.className = "host-config-subheading"; + heading.textContent = "Weitere Kacheln im System-Monitor"; + body.appendChild(heading); + + const note = document.createElement("p"); + note.className = "office-modal-empty"; + note.textContent = + "Gehören zu keinem hier gepflegten Host: Sie melden sich selbst per REST – oder ihr " + + "letzter Stand steht noch in der Datenbank, obwohl sie niemand mehr prüft."; + body.appendChild(note); + + const list = document.createElement("ul"); + list.className = "office-modal-list repo-config-list host-config-list"; + for (const system of orphans) { + const li = document.createElement("li"); + li.className = `office-modal-item repo-config-item status-${system.status}`; + + const label = document.createElement("span"); + label.className = "repo-config-slug"; + label.textContent = system.detail ? `${system.name} (${system.detail})` : system.name; + li.appendChild(label); + + const actions = document.createElement("span"); + actions.className = "repo-config-actions"; + actions.appendChild(iconButton("delete", "Kachel entfernen", () => handleSystemDelete(system))); + li.appendChild(actions); + + list.appendChild(li); + } + body.appendChild(list); +} + +function renderHostConfigRow(host) { + const li = document.createElement("li"); + li.className = "office-modal-item repo-config-item"; + + const label = document.createElement("span"); + label.className = "repo-config-slug"; + label.textContent = `${host.hostname} · ${host.address}`; + li.appendChild(label); + + // Wer den Host anpingt, steht nur dann dabei, wenn es ueberhaupt mehrere Ping-Rechner + // gibt - sonst waere es bei jeder Zeile dieselbe Auskunft. + if (hostModalOpsAgents.length > 1) { + const agent = document.createElement("span"); + agent.className = "host-config-agent"; + agent.textContent = host.agent_name; + li.appendChild(agent); + } + + const actions = document.createElement("span"); + actions.className = "repo-config-actions"; + actions.appendChild( + iconButton("edit", "Host bearbeiten", () => { + hostModalMode = host.id; + renderHostConfigModal(); + }) + ); + actions.appendChild(iconButton("delete", "Host entfernen", () => handleHostConfigDelete(host))); + li.appendChild(actions); + + return li; +} + +function renderHostConfigForm(host) { + const li = document.createElement("li"); + li.className = "office-modal-item repo-config-item repo-config-form"; + + const hostnameInput = document.createElement("input"); + hostnameInput.type = "text"; + hostnameInput.placeholder = "Name, z. B. Webserver"; + hostnameInput.value = host?.hostname ?? ""; + hostnameInput.setAttribute("aria-label", "Hostname"); + + const addressInput = document.createElement("input"); + addressInput.type = "text"; + addressInput.placeholder = "IP oder Adresse, z. B. 192.168.178.10"; + addressInput.value = host?.address ?? ""; + addressInput.setAttribute("aria-label", "IP-Adresse"); + + const fields = document.createElement("span"); + fields.className = "repo-config-form-fields"; + fields.append(hostnameInput, addressInput); + + // Den Ping-Rechner waehlt man nur beim Anlegen und nur, wenn es mehrere gibt; ein Wechsel + // im Nachhinein ginge sonst still an der alten hosts.conf vorbei (siehe + // HostConfigRepository::update). + let agentSelect = null; + if (host === null && hostModalOpsAgents.length > 1) { + agentSelect = document.createElement("select"); + agentSelect.setAttribute("aria-label", "Ping-Rechner"); + agentSelect.title = "Welcher OPs-Agent diesen Host anpingt"; + for (const agent of hostModalOpsAgents) { + const option = document.createElement("option"); + option.value = String(agent.id); + option.textContent = agent.display_name; + agentSelect.appendChild(option); + } + fields.appendChild(agentSelect); + } + li.appendChild(fields); + + const actions = document.createElement("span"); + actions.className = "repo-config-actions"; + + const save = () => + handleHostConfigSave(host, { + hostname: hostnameInput.value.trim(), + address: addressInput.value.trim(), + agent_id: agentSelect ? Number(agentSelect.value) : hostModalOpsAgents[0]?.id, + }); + + actions.appendChild(iconButton("confirm", "Speichern", save)); + actions.appendChild( + iconButton("cancel", "Abbrechen", () => { + hostModalMode = null; + renderHostConfigModal(); + }) + ); + li.appendChild(actions); + + // Wie im Projekte-Formular: Enter wuerde sonst das
einreichen und + // das ganze Modal schliessen, statt zu speichern. + const saveOnEnter = (event) => { + if (event.key === "Enter") { + event.preventDefault(); + save(); + } + }; + hostnameInput.addEventListener("keydown", saveOnEnter); + addressInput.addEventListener("keydown", saveOnEnter); + + window.setTimeout(() => hostnameInput.focus(), 0); + + return li; +} + +async function handleHostConfigSave(existing, data) { + const token = hostModalToken; + if (!data.hostname || !data.address) { + hostModalError = 'Felder "Name" und "IP" sind erforderlich.'; + renderHostConfigModal(); + return; + } + if (!existing && !data.agent_id) { + hostModalError = "Kein OPs-Agent vorhanden, der diesen Host anpingen könnte."; + renderHostConfigModal(); + return; + } + + const ok = await withAdminKey( + (adminKey) => (existing ? updateHostConfig(existing.id, data, adminKey) : createHostConfig(data, adminKey)), + HOST_MODAL_ADMIN_UI + ); + if (!isCurrentModal(token)) { + return; + } + if (!ok) { + renderHostConfigModal(); + return; + } + + hostModalMode = null; + await loadHostConfigModal(token); +} + +async function handleHostConfigDelete(host) { + const token = hostModalToken; + if ( + !window.confirm( + `Host "${host.hostname}" nicht mehr überwachen?\nDie Kachel im System-Monitor verschwindet mit.` + ) + ) { + return; + } + + const ok = await withAdminKey((adminKey) => deleteHostConfig(host.id, adminKey), HOST_MODAL_ADMIN_UI); + if (!isCurrentModal(token)) { + return; + } + if (!ok) { + renderHostConfigModal(); + return; + } + + await loadHostConfigModal(token); +} + +async function handleSystemDelete(system) { + const token = hostModalToken; + if (!window.confirm(`Kachel "${system.name}" aus dem System-Monitor entfernen?`)) { + return; + } + + const ok = await withAdminKey((adminKey) => deleteSystem(system.id, adminKey), HOST_MODAL_ADMIN_UI); + if (!isCurrentModal(token)) { + return; + } + if (!ok) { + renderHostConfigModal(); + return; + } + + await loadHostConfigModal(token); +} + // --------------------------------------------------------------------------------------- // Datenbeschaffung + Orchestrierung // --------------------------------------------------------------------------------------- @@ -1812,6 +2203,9 @@ export function renderOfficeScene(container, data = {}) { const wallGroup = renderMonitorWall(systems, events, displayKpis, { onEventsClick: () => openModal(buildEventModalContent(events), renderEventModalBody), onSystemsClick: () => openModal(buildSystemModalContent(systems), renderSystemModalBody), + // Issue #70: die aktuell gezeigten Systeme wandern mit ins Modal - daran erkennt es, + // welche Kacheln zu keinem konfigurierten Host mehr gehoeren. + onSystemsConfigClick: () => openHostConfigModal(systems), onKpiClick: () => openModal(buildKpiModalContent(displayKpis), renderKpiModalBody), }); const existingWall = svg.querySelector(".monitor-wall"); diff --git a/html/js/office-scene.js b/html/js/office-scene.js index 80ed31b..457299b 100644 --- a/html/js/office-scene.js +++ b/html/js/office-scene.js @@ -335,6 +335,25 @@ export function buildSystemModalContent(systems = []) { }; } +/** + * Issue #70: Die Kacheln des System-Monitors, die zu keinem konfigurierten Ping-Host + * gehoeren - fuer den unteren Abschnitt der Host-Verwaltung. Sie melden sich entweder selbst + * per REST, oder ihr letzter Stand steht nur noch in der Datenbank, weil ihr Host laengst aus + * der Ueberwachung raus ist. + * + * Die Zuordnung laeuft ueber die vom Server mitgelieferten system_ids: Ein Host kann unter + * zwei Ids melden (Hostname aus dem status.txt-Import, Slug aus der REST-Meldung), und diese + * Ableitung hier nachzubauen hiesse, sie an zwei Stellen richtig halten zu muessen. + * + * @param {Array<{id: string}>} systems + * @param {Array<{system_ids?: Array}>} hosts + */ +export function systemsWithoutHostConfig(systems = [], hosts = []) { + const known = new Set(hosts.flatMap((host) => host.system_ids || [])); + + return systems.filter((system) => !known.has(system.id)); +} + /** * Formatiert last_check_at eines Systems (GET /api/v1/systems, Issue #57: "wann der letzte * erfolgreiche Ping war") fuers Modal. Zwei Quellformate kommen vor: ISO-8601 UTC (per API diff --git a/tests/frontend/office-scene.test.js b/tests/frontend/office-scene.test.js index b37e00a..208d792 100644 --- a/tests/frontend/office-scene.test.js +++ b/tests/frontend/office-scene.test.js @@ -20,6 +20,7 @@ import { EVENT_LINE_CHAR_WIDTH, buildEventModalContent, buildSystemModalContent, + systemsWithoutHostConfig, formatSystemCheckTime, buildKpiSparklinePoints, buildKpiTiles, @@ -869,3 +870,27 @@ test("nextCountdownSeconds zaehlt herunter und springt bei 1 auf das volle Inter assert.equal(nextCountdownSeconds(2, 30), 1); assert.equal(nextCountdownSeconds(1, 30), 30); }); + +test("systemsWithoutHostConfig laesst nur Kacheln ohne konfigurierten Ping-Host uebrig", () => { + const systems = [ + { id: "nextcloud-lokal", name: "Nextcloud(lokal)" }, + { id: "Webserver", name: "Webserver" }, + { id: "fremd", name: "Selbstmelder" }, + ]; + // system_ids kommen so von GET /api/v1/hosts: derselbe Host kann unter beiden Ids melden. + const hosts = [ + { hostname: "Nextcloud(lokal)", system_ids: ["Nextcloud(lokal)", "nextcloud-lokal"] }, + { hostname: "Webserver", system_ids: ["Webserver"] }, + ]; + + assert.deepEqual( + systemsWithoutHostConfig(systems, hosts).map((system) => system.id), + ["fremd"] + ); +}); + +test("systemsWithoutHostConfig kommt ohne Hosts und ohne system_ids zurecht", () => { + assert.deepEqual(systemsWithoutHostConfig([{ id: "a" }], []), [{ id: "a" }]); + assert.deepEqual(systemsWithoutHostConfig([{ id: "a" }], [{ hostname: "a" }]), [{ id: "a" }]); + assert.deepEqual(systemsWithoutHostConfig(), []); +});