Merge pull request 'Überwachte Hosts über ein Zahnrad am System-Monitor pflegen (Issue #70)' (#71) from fds2610-bot/monitor:main into main
Reviewed-on: https://codeberg.org/fds2610/monitor/pulls/71
This commit is contained in:
commit
0bad450059
19 changed files with 1633 additions and 30 deletions
57
API.md
57
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
|
||||
|
|
|
|||
38
README.md
38
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.
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
136
api/src/Controller/HostConfigController.php
Normal file
136
api/src/Controller/HostConfigController.php
Normal 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]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<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 */
|
||||
public function reportStatus(Request $request, array $params): Response
|
||||
{
|
||||
|
|
|
|||
39
api/src/Db/migrations/005_host_configs.sql
Normal file
39
api/src/Db/migrations/005_host_configs.sql
Normal 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)
|
||||
);
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
312
api/src/Repository/HostConfigRepository.php
Normal file
312
api/src/Repository/HostConfigRepository.php
Normal 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'];
|
||||
}
|
||||
}
|
||||
|
|
@ -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<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). */
|
||||
public function fingerprint(): string
|
||||
{
|
||||
|
|
|
|||
162
api/tests/Unit/Controller/HostConfigControllerTest.php
Normal file
162
api/tests/Unit/Controller/HostConfigControllerTest.php
Normal 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']));
|
||||
}
|
||||
}
|
||||
166
api/tests/Unit/Repository/HostConfigRepositoryTest.php
Normal file
166
api/tests/Unit/Repository/HostConfigRepositoryTest.php
Normal 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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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'));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
67
check_fds.sh
67
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<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>}
|
||||
*/
|
||||
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<T>} action
|
||||
* @returns {Promise<T|undefined>}
|
||||
*/
|
||||
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" | <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
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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
|
||||
* erfolgreiche Ping war") fuers Modal. Zwei Quellformate kommen vor: ISO-8601 UTC (per API
|
||||
|
|
|
|||
|
|
@ -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(), []);
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue