Ueberwachte Hosts ueber ein Zahnrad am System-Monitor pflegen (Issue #70)

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
This commit is contained in:
fds2610-bot 2026-09-01 13:14:11 +02:00
parent dee0f5f304
commit 55d5c34ca6
19 changed files with 1633 additions and 30 deletions

57
API.md
View file

@ -19,6 +19,7 @@ Implementierung: Vanilla-PHP (kein Framework), Persistenz in SQLite
- [KPIs](#kpis) - [KPIs](#kpis)
- [Live-Updates (SSE)](#live-updates-sse) - [Live-Updates (SSE)](#live-updates-sse)
- [Projekte (Codeberg-Sync konfigurieren)](#projekte-codeberg-sync-konfigurieren) - [Projekte (Codeberg-Sync konfigurieren)](#projekte-codeberg-sync-konfigurieren)
- [Hosts (Ping-Ueberwachung konfigurieren)](#hosts-ping-ueberwachung-konfigurieren)
- [Codeberg-Sync (Backlog/Done)](#codeberg-sync-backlogdone) - [Codeberg-Sync (Backlog/Done)](#codeberg-sync-backlogdone)
- [Fehlerformat](#fehlerformat) - [Fehlerformat](#fehlerformat)
@ -283,6 +284,18 @@ ungueltigem `status`.
Response: `{ "systems": [ { "id", "name", "type", "status", "detail", "source", "last_check_at" }, ... ] }` Response: `{ "systems": [ { "id", "name", "type", "status", "detail", "source", "last_check_at" }, ... ] }`
(maximal 9 Eintraege). (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 ## KPIs
F7: Konkrete Kennzahlen sind noch offen ("kommt spaeter"). Phase 1 stellt nur die 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 Hooks erfordert Admin-Rechte auf das Projekt - fehlen die, liefert der Check `ok: false` mit
entsprechender Meldung statt eines Fehlers. 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) ## Codeberg-Sync (Backlog/Done)
`api/bin/sync-codeberg.php` liest offene und geschlossene Issues aus den konfigurierten `api/bin/sync-codeberg.php` liest offene und geschlossene Issues aus den konfigurierten

View file

@ -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 (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 wirken sofort im laufenden `sync`-Dienst, ohne Neustart. Schreibende Aktionen fragen einmalig
den `MONITOR_ADMIN_API_KEY` ab und merken ihn sich im Browser. 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 - 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 aus den Issues mehrerer konfigurierter Codeberg-Projekte (je Projekt mit eigener
Ticket-Farbe, siehe oben) — periodisch alle `MONITOR_SYNC_INTERVAL` Sekunden und, wenn ein 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`)** **Weg 1: Klassische Ping-Überwachung (`hosts.conf`)**
1. `hosts-sample.conf` nach `hosts.conf` kopieren und um den neuen Server erweitern 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 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 der Originalname bleibt als Anzeigename erhalten. Ist die API nicht erreichbar, läuft die
Ping-Überwachung normal weiter und protokolliert nur eine Warnung. 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.

View file

@ -10,6 +10,7 @@ use Monitor\Api\Auth\AgentAuthenticator;
use Monitor\Api\Auth\WebhookAuthenticator; use Monitor\Api\Auth\WebhookAuthenticator;
use Monitor\Api\Controller\AgentController; use Monitor\Api\Controller\AgentController;
use Monitor\Api\Controller\EventController; use Monitor\Api\Controller\EventController;
use Monitor\Api\Controller\HostConfigController;
use Monitor\Api\Controller\KpiController; use Monitor\Api\Controller\KpiController;
use Monitor\Api\Controller\RepoConfigController; use Monitor\Api\Controller\RepoConfigController;
use Monitor\Api\Controller\StreamController; use Monitor\Api\Controller\StreamController;
@ -26,6 +27,7 @@ use Monitor\Api\Metrics\HookCounter;
use Monitor\Api\Realtime\ChangeFeed; use Monitor\Api\Realtime\ChangeFeed;
use Monitor\Api\Repository\AgentRepository; use Monitor\Api\Repository\AgentRepository;
use Monitor\Api\Repository\EventRepository; use Monitor\Api\Repository\EventRepository;
use Monitor\Api\Repository\HostConfigRepository;
use Monitor\Api\Repository\KpiRepository; use Monitor\Api\Repository\KpiRepository;
use Monitor\Api\Repository\LegacyStatusFileReader; use Monitor\Api\Repository\LegacyStatusFileReader;
use Monitor\Api\Repository\RepoConfigRepository; use Monitor\Api\Repository\RepoConfigRepository;
@ -57,6 +59,7 @@ final class Application
$systems = new SystemRepository($db); $systems = new SystemRepository($db);
$kpis = new KpiRepository($db); $kpis = new KpiRepository($db);
$repoConfigs = new RepoConfigRepository($db); $repoConfigs = new RepoConfigRepository($db);
$hostConfigs = new HostConfigRepository($db);
$hooks = new HookCounter($config->hooksCounterPath()); $hooks = new HookCounter($config->hooksCounterPath());
$this->agentAuth = new AgentAuthenticator($agents); $this->agentAuth = new AgentAuthenticator($agents);
@ -73,6 +76,7 @@ final class Application
$eventController = new EventController($events); $eventController = new EventController($events);
$systemController = new SystemController($systems, new LegacyStatusFileReader(), $config->statusFilePath()); $systemController = new SystemController($systems, new LegacyStatusFileReader(), $config->statusFilePath());
$kpiController = new KpiController($kpis); $kpiController = new KpiController($kpis);
$hostConfigController = new HostConfigController($hostConfigs, $systems);
$repoConfigController = new RepoConfigController($repoConfigs, $tickets, new RepoHealthChecker($codebergClient)); $repoConfigController = new RepoConfigController($repoConfigs, $tickets, new RepoHealthChecker($codebergClient));
$webhookController = new WebhookController(new SyncTrigger($config->syncTriggerPath()), $hooks); $webhookController = new WebhookController(new SyncTrigger($config->syncTriggerPath()), $hooks);
$streamController = new StreamController( $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('GET', '/api/v1/systems', fn (Request $r, array $p) => $systemController->index($r));
$this->router->add('POST', '/api/v1/systems/{id}/status', fn (Request $r, array $p) => $systemController->reportStatus($r, $p), 'agent'); $this->router->add('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('GET', '/api/v1/kpis', fn (Request $r, array $p) => $kpiController->index($r));
$this->router->add('POST', '/api/v1/kpis/{key}', fn (Request $r, array $p) => $kpiController->report($r, $p), 'agent'); $this->router->add('POST', '/api/v1/kpis/{key}', fn (Request $r, array $p) => $kpiController->report($r, $p), 'agent');

View file

@ -0,0 +1,136 @@
<?php
declare(strict_types=1);
namespace Monitor\Api\Controller;
use InvalidArgumentException;
use Monitor\Api\Http\Request;
use Monitor\Api\Http\Response;
use Monitor\Api\Repository\HostConfigRepository;
use Monitor\Api\Repository\SystemRepository;
/**
* Issue #70: Die Hosts der Ping-Ueberwachung, bisher nur in der hosts.conf neben
* check_fds.sh. Zwei Zugriffsarten mit unterschiedlicher Berechtigung:
*
* - Pflege aus dem Frontend (Zahnrad am Wandmonitor "Systeme") - schreibend hinter dem
* Admin-Key wie die Projekte-Verwaltung (Issue #27), kein Bot-Vorgang.
* - Abruf durch den Ping-Check selbst - hinter dem Agenten-Key, weil erst der sagt,
* welcher Ping-Rechner fragt und damit welche Liste er bekommt.
*/
final class HostConfigController
{
public function __construct(
private readonly HostConfigRepository $hosts,
private readonly SystemRepository $systems,
) {
}
public function index(Request $request): Response
{
// system_ids kommen mit, damit das Frontend eine Kachel im System-Monitor sicher
// ihrem Host zuordnen kann, ohne die Id-Ableitung (Hostname vs. Slug) nachzubauen.
$hosts = array_map(
fn (array $host) => $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<string, string> $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<string, string> $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<string, string> $params
* @param array<string, mixed> $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<string, string> $params
* @param array<string, mixed> $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]);
}
}

View file

@ -26,6 +26,22 @@ final class SystemController
return Response::json(['systems' => $this->systems->all()]); 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<string, string> $params
*/
public function delete(Request $request, array $params): Response
{
return Response::json(['deleted' => $this->systems->delete([$params['id']]) > 0]);
}
/** @param array<string, string> $params */ /** @param array<string, string> $params */
public function reportStatus(Request $request, array $params): Response public function reportStatus(Request $request, array $params): Response
{ {

View file

@ -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)
);

View file

@ -16,6 +16,9 @@ final class Response
public readonly int $status, public readonly int $status,
private readonly mixed $data, private readonly mixed $data,
private readonly ?\Closure $streamWriter = null, 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]); 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 * Antwort auf einen CORS-Preflight (OPTIONS): 204 ohne Body, die eigentliche Auskunft
* stehen in den Access-Control-Headern aus send(). * stehen in den Access-Control-Headern aus send().
@ -101,6 +115,13 @@ final class Response
return; return;
} }
if ($this->contentType !== null) {
header('Content-Type: ' . $this->contentType);
echo (string) $this->data;
return;
}
header('Content-Type: application/json; charset=utf-8'); header('Content-Type: application/json; charset=utf-8');
echo json_encode($this->data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); echo json_encode($this->data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
} }

View file

@ -0,0 +1,312 @@
<?php
declare(strict_types=1);
namespace Monitor\Api\Repository;
use InvalidArgumentException;
use Monitor\Api\Db\Database;
use Monitor\Api\Http\NotFoundException;
/**
* Verwaltet die Hosts der Ping-Ueberwachung (Issue #70: vorher ausschliesslich die hosts.conf
* neben check_fds.sh, siehe 005_host_configs.sql). Jede Zeile gehoert dem OPs-Agenten, der
* diesen Host anpingt; check_fds.sh holt seine Liste bei jedem Lauf ueber den eigenen
* Agenten-Key ab, damit eine Aenderung im Frontend ohne Login auf dem Ping-Rechner wirkt.
*/
final class HostConfigRepository
{
public function __construct(private readonly Database $db)
{
}
/**
* Alle Hosts ueber alle Ping-Hosts hinweg, inklusive Anzeigename des zustaendigen
* Agenten - so kann das Frontend-Modal ohne zweiten Request zeigen, wer den Host prueft.
*
* @return array<int, array<string, mixed>>
*/
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<int, array<string, mixed>> */
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<string, mixed>|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<string, mixed> $data @return array<string, mixed> */
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<string, mixed> $data @return array<string, mixed> */
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<string, mixed> - 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<int, array{hostname: string, address: string}> $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<int, array{hostname: string, address: string}>
*/
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<string, mixed> $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'];
}
}

View file

@ -103,6 +103,44 @@ final class SystemRepository
return $stmt->fetchAll(); 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<int, string> $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<int, string>
*/
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). */ /** Fingerabdruck fuer den SSE-Endpoint (Realtime\ChangeFeed, F13). */
public function fingerprint(): string public function fingerprint(): string
{ {

View file

@ -0,0 +1,162 @@
<?php
declare(strict_types=1);
namespace Monitor\Api\Tests\Controller;
use Monitor\Api\Controller\HostConfigController;
use Monitor\Api\Db\Database;
use Monitor\Api\Http\Request;
use Monitor\Api\Repository\AgentRepository;
use Monitor\Api\Repository\HostConfigRepository;
use Monitor\Api\Repository\SystemRepository;
use PHPUnit\Framework\TestCase;
final class HostConfigControllerTest extends TestCase
{
private HostConfigController $controller;
private HostConfigRepository $hosts;
private SystemRepository $systems;
/** @var array<string, mixed> */
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']));
}
}

View file

@ -0,0 +1,166 @@
<?php
declare(strict_types=1);
namespace Monitor\Api\Tests\Repository;
use InvalidArgumentException;
use Monitor\Api\Db\Database;
use Monitor\Api\Repository\AgentRepository;
use Monitor\Api\Repository\HostConfigRepository;
use PHPUnit\Framework\TestCase;
final class HostConfigRepositoryTest extends TestCase
{
private HostConfigRepository $repository;
private int $opsAgentId;
private int $devAgentId;
protected function setUp(): void
{
$db = Database::inMemory();
$agents = new AgentRepository($db);
$this->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));
}
}

View file

@ -86,4 +86,23 @@ final class SystemRepositoryTest extends TestCase
self::assertNotSame($before, $this->repository->fingerprint()); 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'));
}
} }

View file

@ -25,6 +25,72 @@ if [ -f "$API_CONF" ]; then
. "$API_CONF" . "$API_CONF"
fi 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 # 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 # 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. # also kodiert in der Datenbank. Der lesbare Originalname geht im Feld "name" mit.
@ -121,6 +187,7 @@ check_ip() {
# Hauptskript # Hauptskript
echo "Pruefung: $(date)" echo "Pruefung: $(date)"
sync_hosts_from_api
if [ ! -f "$HOST_FILE" ]; then if [ ! -f "$HOST_FILE" ]; then
echo "Fehler: Datei $HOST_FILE nicht gefunden." echo "Fehler: Datei $HOST_FILE nicht gefunden."
exit 1 exit 1

View file

@ -1,4 +1,9 @@
# Nach hosts.conf kopieren und um eigene Server erweitern. # 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") # Format: Hostname IP (ein Server pro Zeile, siehe README.md "Einen Server hinzufügen")
Server1 192.168.178.2 Server1 192.168.178.2
Server2 192.168.178.3 Server2 192.168.178.3

View file

@ -410,34 +410,44 @@ header.office-header h1 {
pointer-events: all; pointer-events: all;
} }
/* Zahnrad am PO-Platz (Issue #27): zeigt ueberhaupt erst, dass sich hier die /* Konfig-Zahnrad (Issue #27 am PO-Platz, Issue #70 am Systeme-Monitor): zeigt ueberhaupt
Projektkonfiguration oeffnen laesst. Beim Hover ueber den Platz tritt es hervor. */ erst, dass sich hier etwas einstellen laesst. Aussehen ist ueberall gleich, nur der
.po-config-hint-bg { 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; fill: #ffffff;
stroke: #94a3b8; stroke: #94a3b8;
stroke-width: 1.5; stroke-width: 1.5;
opacity: 0.85; opacity: 0.85;
} }
.po-config-hint-tooth, .config-hint-tooth,
.po-config-hint-hub { .config-hint-hub {
stroke: #475569; stroke: #475569;
stroke-width: 2; stroke-width: 2;
stroke-linecap: round; stroke-linecap: round;
fill: none; fill: none;
} }
.po-desk:hover .po-config-hint-bg { .po-desk:hover .config-hint-bg,
.system-config-hint:hover .config-hint-bg {
fill: #e0f2fe; fill: #e0f2fe;
stroke: #0284c7; stroke: #0284c7;
opacity: 1; opacity: 1;
} }
.po-desk:hover .po-config-hint-tooth, .po-desk:hover .config-hint-tooth,
.po-desk:hover .po-config-hint-hub { .po-desk:hover .config-hint-hub,
.system-config-hint:hover .config-hint-tooth,
.system-config-hint:hover .config-hint-hub {
stroke: #0284c7; stroke: #0284c7;
} }
.system-config-hint-hit {
fill: transparent;
pointer-events: all;
}
/* Reload-Symbol neben der Board-Ueberschrift (Issue #51): stoesst ein komplettes /* Reload-Symbol neben der Board-Ueberschrift (Issue #51): stoesst ein komplettes
Neueinlesen des Kanban-Boards an, analog zum Zahnrad am PO-Platz. */ Neueinlesen des Kanban-Boards an, analog zum Zahnrad am PO-Platz. */
.board-reload-hint-bg { .board-reload-hint-bg {
@ -885,3 +895,21 @@ header.office-header h1 {
.repo-config-status-icon.is-fail { .repo-config-status-icon.is-fail {
color: #dc2626; 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;
}

View file

@ -166,3 +166,39 @@ export async function resyncTickets(adminKey) {
const { tickets } = await adminJson("POST", "/tickets/resync", undefined, adminKey); const { tickets } = await adminJson("POST", "/tickets/resync", undefined, adminKey);
return tickets; 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);
}

View file

@ -51,6 +51,7 @@ import {
PO_DISPLAY_NAME, PO_DISPLAY_NAME,
resolveReloadIntervalSeconds, resolveReloadIntervalSeconds,
nextCountdownSeconds, nextCountdownSeconds,
systemsWithoutHostConfig,
} from "./office-scene.js"; } from "./office-scene.js";
import { import {
DEMO_TICKET_COUNTS, DEMO_TICKET_COUNTS,
@ -72,6 +73,11 @@ import {
deleteRepoConfig, deleteRepoConfig,
checkRepoConfig, checkRepoConfig,
resyncTickets, resyncTickets,
fetchHostConfigs,
createHostConfig,
updateHostConfig,
deleteHostConfig,
deleteSystem,
getStoredAdminKey, getStoredAdminKey,
setStoredAdminKey, setStoredAdminKey,
} from "./office-api.js"; } from "./office-api.js";
@ -379,11 +385,12 @@ function renderEventMonitor(frame, events) {
return g; return g;
} }
function renderSystemMonitor(frame, systems) { function renderSystemMonitor(frame, systems, onConfigClick) {
const tiles = buildSystemTiles(systems); const tiles = buildSystemTiles(systems);
const g = el("g", { class: "monitor monitor-systems" }); const g = el("g", { class: "monitor monitor-systems" });
g.appendChild(el("rect", { ...frame, rx: 8, class: "monitor-frame" })); 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(text("Systeme", { x: frame.x + 12, y: frame.y - 10, class: "wall-label" }));
g.appendChild(renderSystemConfigHint(frame, onConfigClick));
g.appendChild( g.appendChild(
el("rect", { el("rect", {
x: frame.x + 10, 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) { function renderMonitorWall(systems, events, kpis, handlers) {
const g = el("g", { class: "monitor-wall" }); const g = el("g", { class: "monitor-wall" });
@ -544,7 +552,7 @@ function renderMonitorWall(systems, events, kpis, handlers) {
eventsGroup.classList.add("clickable"); eventsGroup.classList.add("clickable");
eventsGroup.addEventListener("click", handlers.onEventsClick); eventsGroup.addEventListener("click", handlers.onEventsClick);
const systemsGroup = renderSystemMonitor(frames[1], systems); const systemsGroup = renderSystemMonitor(frames[1], systems, handlers.onSystemsConfigClick);
systemsGroup.classList.add("clickable"); systemsGroup.classList.add("clickable");
systemsGroup.addEventListener("click", handlers.onSystemsClick); systemsGroup.addEventListener("click", handlers.onSystemsClick);
@ -740,23 +748,67 @@ function renderPoConfigHint() {
const title = document.createElementNS(SVG_NS, "title"); const title = document.createElementNS(SVG_NS, "title");
title.textContent = "Projekte konfigurieren"; title.textContent = "Projekte konfigurieren";
g.appendChild(title); 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++) { for (let i = 0; i < 8; i++) {
const angle = (i * Math.PI) / 4; const angle = (i * Math.PI) / 4;
g.appendChild( nodes.push(
el("line", { el("line", {
x1: Math.cos(angle) * 5.5, x1: Math.cos(angle) * 5.5 * scale,
y1: Math.sin(angle) * 5.5, y1: Math.sin(angle) * 5.5 * scale,
x2: Math.cos(angle) * 9.5, x2: Math.cos(angle) * 9.5 * scale,
y2: Math.sin(angle) * 9.5, y2: Math.sin(angle) * 9.5 * scale,
class: "po-config-hint-tooth", 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; return g;
} }
@ -1652,12 +1704,15 @@ async function handleRepoConfigDelete(repo) {
* der naechste Versuch erneut fragt statt denselben ungueltigen Wert wiederzuverwenden. * der naechste Versuch erneut fragt statt denselben ungueltigen Wert wiederzuverwenden.
* *
* @param {(adminKey: string) => Promise<unknown>} action * @param {(adminKey: string) => Promise<unknown>} 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<boolean>} * @returns {Promise<boolean>}
*/ */
async function withAdminKey(action) { async function withAdminKey(action, ui = REPO_MODAL_ADMIN_UI) {
let adminKey = getStoredAdminKey(); let adminKey = getStoredAdminKey();
if (!adminKey) { if (!adminKey) {
adminKey = window.prompt("Admin-Key für Änderungen an Projekten:") || ""; adminKey = window.prompt(ui.prompt) || "";
if (!adminKey) { if (!adminKey) {
return false; return false;
} }
@ -1670,14 +1725,28 @@ async function withAdminKey(action) {
} catch (err) { } catch (err) {
if (err && err.isAuthError) { if (err && err.isAuthError) {
setStoredAdminKey(""); setStoredAdminKey("");
repoModalError = "Admin-Key ungültig."; ui.setError("Admin-Key ungültig.");
} else { } else {
repoModalError = err instanceof Error ? err.message : "Aktion fehlgeschlagen."; ui.setError(err instanceof Error ? err.message : "Aktion fehlgeschlagen.");
} }
return false; 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 * 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 * 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<T>} action * @param {(adminKey: string) => Promise<T>} action
* @returns {Promise<T|undefined>} * @returns {Promise<T|undefined>}
*/ */
async function withAdminKeyResult(action) { async function withAdminKeyResult(action, ui = REPO_MODAL_ADMIN_UI) {
let adminKey = getStoredAdminKey(); let adminKey = getStoredAdminKey();
if (!adminKey) { if (!adminKey) {
adminKey = window.prompt("Admin-Key für Änderungen an Projekten:") || ""; adminKey = window.prompt(ui.prompt) || "";
if (!adminKey) { if (!adminKey) {
return undefined; return undefined;
} }
@ -1703,14 +1772,336 @@ async function withAdminKeyResult(action) {
} catch (err) { } catch (err) {
if (err && err.isAuthError) { if (err && err.isAuthError) {
setStoredAdminKey(""); setStoredAdminKey("");
repoModalError = "Admin-Key ungültig."; ui.setError("Admin-Key ungültig.");
} else { } else {
repoModalError = err instanceof Error ? err.message : "Aktion fehlgeschlagen."; ui.setError(err instanceof Error ? err.message : "Aktion fehlgeschlagen.");
} }
return undefined; 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" | <id des bearbeiteten Hosts>
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 <form method="dialog"> 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 // Datenbeschaffung + Orchestrierung
// --------------------------------------------------------------------------------------- // ---------------------------------------------------------------------------------------
@ -1812,6 +2203,9 @@ export function renderOfficeScene(container, data = {}) {
const wallGroup = renderMonitorWall(systems, events, displayKpis, { const wallGroup = renderMonitorWall(systems, events, displayKpis, {
onEventsClick: () => openModal(buildEventModalContent(events), renderEventModalBody), onEventsClick: () => openModal(buildEventModalContent(events), renderEventModalBody),
onSystemsClick: () => openModal(buildSystemModalContent(systems), renderSystemModalBody), 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), onKpiClick: () => openModal(buildKpiModalContent(displayKpis), renderKpiModalBody),
}); });
const existingWall = svg.querySelector(".monitor-wall"); const existingWall = svg.querySelector(".monitor-wall");

View file

@ -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<string>}>} 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 * 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 * erfolgreiche Ping war") fuers Modal. Zwei Quellformate kommen vor: ISO-8601 UTC (per API

View file

@ -20,6 +20,7 @@ import {
EVENT_LINE_CHAR_WIDTH, EVENT_LINE_CHAR_WIDTH,
buildEventModalContent, buildEventModalContent,
buildSystemModalContent, buildSystemModalContent,
systemsWithoutHostConfig,
formatSystemCheckTime, formatSystemCheckTime,
buildKpiSparklinePoints, buildKpiSparklinePoints,
buildKpiTiles, 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(2, 30), 1);
assert.equal(nextCountdownSeconds(1, 30), 30); 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(), []);
});