Merge pull request 'Phase 3-5 der Neuausrichtung: Board/Monitor-Wand live, SSE-Push, Agenten-Animation' (#8) from fds2610-bot/monitor:main into main
Reviewed-on: https://codeberg.org/fds2610/monitor/pulls/8
This commit is contained in:
commit
f383ce2d33
33 changed files with 2223 additions and 88 deletions
34
API.md
34
API.md
|
|
@ -17,6 +17,7 @@ Implementierung: Vanilla-PHP (kein Framework), Persistenz in SQLite
|
|||
- [Events](#events)
|
||||
- [Systeme](#systeme)
|
||||
- [KPIs](#kpis)
|
||||
- [Live-Updates (SSE)](#live-updates-sse)
|
||||
- [Codeberg-Sync (Backlog/Done)](#codeberg-sync-backlogdone)
|
||||
- [Fehlerformat](#fehlerformat)
|
||||
|
||||
|
|
@ -29,6 +30,9 @@ Implementierung: Vanilla-PHP (kein Framework), Persistenz in SQLite
|
|||
- Lesende Endpunkte (`GET`) sind oeffentlich, da sie das Frontend bedienen.
|
||||
- Schreibende Endpunkte (`POST`/`PATCH`) erfordern einen Agenten- oder Admin-Key
|
||||
(siehe [Authentifizierung](#authentifizierung)).
|
||||
- Alle Antworten tragen `Access-Control-Allow-Origin: *` (CORS), da Frontend und API im
|
||||
Docker-Compose-Setup auf unterschiedlichen Ports laufen (siehe
|
||||
[docker-compose.yml](docker-compose.yml): Frontend `:8090`, API `:8091`).
|
||||
|
||||
## Authentifizierung
|
||||
|
||||
|
|
@ -194,6 +198,36 @@ dem KPI inkl. Verlauf.
|
|||
|
||||
Response: `{ "kpis": [ { "key", "label", "unit", "current_value", "history": [ { "value", "recorded_at" }, ... ] }, ... ] }`
|
||||
|
||||
## Live-Updates (SSE)
|
||||
|
||||
F13: Das Frontend haelt sich per [Server-Sent Events](https://developer.mozilla.org/de/docs/Web/API/Server-sent_events)
|
||||
ueber Aenderungen auf dem Laufenden, statt staendig zu pollen.
|
||||
|
||||
### `GET /api/v1/stream` — Aenderungs-Feed
|
||||
|
||||
Auth: keine (oeffentlich wie die anderen `GET`-Endpunkte). Antwort:
|
||||
`Content-Type: text/event-stream`.
|
||||
|
||||
Die Verbindung bleibt fuer `MONITOR_SSE_MAX_ITERATIONS * MONITOR_SSE_POLL_INTERVAL` Sekunden
|
||||
offen (Default: `30 * 2s` = ca. 1 Minute) und prueft alle `MONITOR_SSE_POLL_INTERVAL` Sekunden
|
||||
serverseitig, ob sich Tickets, Events, Systeme, KPIs oder Agenten veraendert haben (billiger
|
||||
"Fingerabdruck" aus Anzahl + juengstem Zeitstempel je Ressource, keine vollstaendige
|
||||
Änderungshistorie). Danach schliesst der Server die Verbindung; `EventSource` im Browser
|
||||
verbindet automatisch neu (Standardverhalten).
|
||||
|
||||
Bei einer Aenderung wird ein `update`-Event gesendet, das nur benennt, **welche** Ressourcen
|
||||
sich geaendert haben - das Frontend laedt die betroffenen Endpunkte anschliessend regulaer per
|
||||
`GET` neu:
|
||||
|
||||
```
|
||||
event: update
|
||||
data: {"changed": ["tickets", "events"]}
|
||||
```
|
||||
|
||||
Ohne Aenderung sendet der Server stattdessen alle `MONITOR_SSE_POLL_INTERVAL` Sekunden einen
|
||||
Kommentar (`: heartbeat`) als Keep-Alive, damit Proxys die Verbindung nicht vorzeitig
|
||||
schliessen.
|
||||
|
||||
## Codeberg-Sync (Backlog/Done)
|
||||
|
||||
`api/bin/sync-codeberg.php` (per Cron oder manuell auszufuehren) liest offene und
|
||||
|
|
|
|||
|
|
@ -15,8 +15,11 @@ die dazugehörigen Klärungen in [Detailfragen.md](Detailfragen.md) und
|
|||
- REST-API (`api/`, siehe [API.md](API.md)) für Tickets, Events, Systeme, KPIs und
|
||||
Agenten-Aktivität, mit statischen API-Keys pro Agent
|
||||
- Büro-Visualisierung (`html/office.html`) als Vanilla-SVG-Szene: Kanban-Board, Monitor-Wand
|
||||
(Events/Systeme/KPIs), 8 Schreibtische - aktuell Phase 2 (statisches Layout mit
|
||||
Demo-Daten, siehe Visualization.md Abschnitt 9)
|
||||
(Events/Systeme/KPIs), 8 Schreibtische - zeigt live die Daten aus der REST-API (Push per
|
||||
Server-Sent Events, Polling-Fallback), Klick auf Board/Monitore öffnet Detail-Modals,
|
||||
Agenten laufen animiert zwischen Schreibtisch/Board/Monitor-Wand/Kaffee-Ecke (Phase 3-5,
|
||||
siehe Visualization.md Abschnitt 9). Ist die API nicht erreichbar, zeigt die Szene
|
||||
ersatzweise Demo-Daten.
|
||||
- Weiterhin: klassische Ping-Überwachung (`check_fds.sh`, `hosts.conf`, `mail_fds.py`) als
|
||||
eine Datenquelle für den System-Monitor; die alte Tabellenansicht (`html/mon.html`)
|
||||
bleibt bis zur Migration in einer späteren Phase bestehen (F16)
|
||||
|
|
@ -42,7 +45,7 @@ Docker-Compose-Containern; auf dem Host muss nichts installiert werden.
|
|||
```bash
|
||||
cp .env.example .env # einmalig, MONITOR_ADMIN_API_KEY anpassen
|
||||
|
||||
docker compose up --build # API auf :8080, Buero-Visualisierung auf :8081
|
||||
docker compose up --build # API auf :8091, Buero-Visualisierung auf :8090
|
||||
docker compose run --rm api composer test # PHPUnit (API)
|
||||
docker compose run --rm frontend # Node-Tests (Frontend-Layoutlogik)
|
||||
```
|
||||
|
|
|
|||
|
|
@ -231,20 +231,37 @@ externe Datenbankserver (nur PHP/Python-Standardbibliothek) auskommen, um die
|
|||
## 9. Migrationsplan / Phasen
|
||||
|
||||
1. **Phase 0 – Grundlagen & Klärung**: Detailfragen klären ([Detailfragen.md](Detailfragen.md)),
|
||||
Tech-Entscheidungen fixieren (Backend-Sprache, Persistenz, Chart-Lib).
|
||||
Tech-Entscheidungen fixieren (Backend-Sprache, Persistenz, Chart-Lib). ✅ erledigt.
|
||||
2. **Phase 1 – REST-API**: Datenmodell, Endpunkte, Auth, Persistenz, `API.md`-Dokumentation,
|
||||
Tests (≥60 % Coverage).
|
||||
Tests (≥60 % Coverage). ✅ erledigt (PR #5).
|
||||
3. **Phase 2 – Statische Büro-Szene**: SVG/Canvas-Grundlayout (Wände, Türen, 8 Schreibtische,
|
||||
Board-Rahmen, 3 Monitor-Rahmen) ohne Animation, gespeist mit statischen/Demo-Daten.
|
||||
✅ erledigt (PR #7).
|
||||
4. **Phase 3 – Kanban-Board live**: Board zeigt echte Ticket-Daten aus der API, Klick-Modal mit
|
||||
Issue-Details.
|
||||
Issue-Details. ✅ erledigt: `html/js/office-api.js` laedt `GET /api/v1/tickets`,
|
||||
`office-scene.js` aggregiert/gruppiert sie je Spalte, Klick auf eine Spalte oeffnet ein
|
||||
Modal mit Titel/Beschreibung/Codeberg-Link je Ticket (F4).
|
||||
5. **Phase 4 – Monitor-Wand live**: Event-, System- und KPI-Monitor zeigen echte Daten,
|
||||
Klick-Modals.
|
||||
Klick-Modals. ✅ erledigt: Events/Systeme/KPIs kommen live von der API, ein neuer
|
||||
SSE-Endpoint (`GET /api/v1/stream`, F13) stoesst Frontend-Updates an (Polling-Fallback in
|
||||
`office-realtime.js`), KPI-Monitor zeichnet eine eigene SVG-Sparkline (F10, kein Chart-Lib)
|
||||
sobald KPIs gemeldet wurden - vorher weiterhin der Platzhalter aus Phase 2 (F7 ist
|
||||
inhaltlich weiterhin offen).
|
||||
6. **Phase 5 – Agenten & Animation**: Zustandsmaschine, Laufwege, Tipp-/Lese-/Kaffee-Animation,
|
||||
Reaktion auf neue Ticket-/Event-/Systemstatus-Meldungen.
|
||||
Reaktion auf neue Ticket-/Event-/Systemstatus-Meldungen. ✅ erledigt: `resolveAgentVisualState`
|
||||
bildet die von der API gemeldeten Zustaende auf Ort/Pose ab, Agenten-Avatare sind eine
|
||||
eigenstaendige, per CSS `transition` animierte Schicht (`office-render.js`), idle-Varianten
|
||||
(Zeitung/Kaffee-Ecke/Schwaetzchen) werden rein kosmetisch client-seitig zufaellig ausgeloest
|
||||
(F8), eine `AnimationQueue` serialisiert Update-Animationen bei vielen gleichzeitigen
|
||||
Ereignissen (F9).
|
||||
7. **Phase 6 – Externe Rollen & Türen**: Betreten/Verlassen durch Türen für Kunden/User-Events.
|
||||
Noch offen - Phase 5 platziert Agenten ohne Schreibtisch (Rolle `user`, Ueberlauf) bereits
|
||||
an einem Ruheplatz vor der linken Tuer, ein echtes Betreten/Verlassen mit Tuer-Animation
|
||||
anlaesslich konkreter Backlog/Fertig/Done-Ereignisse (F2) ist aber noch nicht umgesetzt.
|
||||
8. **Phase 7 – Tests & Feinschliff**: Unit-/E2E-Testabdeckung komplettieren, Migration/
|
||||
Abschaltung der alten Tabellenansicht klären ([F16]).
|
||||
Abschaltung der alten Tabellenansicht klären ([F16]). Noch offen - insbesondere die in
|
||||
Abschnitt 8 vorgesehenen Playwright-E2E-Tests fuer Board-/Monitor-Interaktion fehlen noch,
|
||||
`html/mon.html` besteht laut F16 weiterhin bis zur expliziten Abschaltung.
|
||||
|
||||
Jede Phase liefert ein für sich lauffähiges Zwischenergebnis, sodass die Umwidmung
|
||||
schrittweise (und überprüfbar per PR) erfolgen kann, statt in einem Großumbau.
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ use Monitor\Api\Auth\AgentAuthenticator;
|
|||
use Monitor\Api\Controller\AgentController;
|
||||
use Monitor\Api\Controller\EventController;
|
||||
use Monitor\Api\Controller\KpiController;
|
||||
use Monitor\Api\Controller\StreamController;
|
||||
use Monitor\Api\Controller\SystemController;
|
||||
use Monitor\Api\Controller\TicketController;
|
||||
use Monitor\Api\Db\Database;
|
||||
|
|
@ -18,6 +19,7 @@ use Monitor\Api\Http\NotFoundException;
|
|||
use Monitor\Api\Http\Request;
|
||||
use Monitor\Api\Http\Response;
|
||||
use Monitor\Api\Http\Router;
|
||||
use Monitor\Api\Realtime\ChangeFeed;
|
||||
use Monitor\Api\Repository\AgentRepository;
|
||||
use Monitor\Api\Repository\EventRepository;
|
||||
use Monitor\Api\Repository\KpiRepository;
|
||||
|
|
@ -53,6 +55,11 @@ final class Application
|
|||
$eventController = new EventController($events);
|
||||
$systemController = new SystemController($systems, new LegacyStatusFileReader(), $config->statusFilePath());
|
||||
$kpiController = new KpiController($kpis);
|
||||
$streamController = new StreamController(
|
||||
new ChangeFeed($tickets, $events, $systems, $kpis, $agents),
|
||||
$config->sseMaxIterations(),
|
||||
$config->ssePollIntervalSeconds(),
|
||||
);
|
||||
|
||||
$this->router = new Router();
|
||||
|
||||
|
|
@ -77,6 +84,10 @@ final class Application
|
|||
fn (Request $r, array $p, array $agent) => $agentController->activity($r, $p, $agent),
|
||||
'agent'
|
||||
);
|
||||
|
||||
// SSE-Push (F13): oeffentlich wie die anderen GET-Endpunkte, liefert nur bereits
|
||||
// oeffentliche Daten (welche Ressourcen sich geaendert haben, keine Inhalte).
|
||||
$this->router->add('GET', '/api/v1/stream', fn (Request $r, array $p) => $streamController->stream($r));
|
||||
}
|
||||
|
||||
public function handle(Request $request): Response
|
||||
|
|
|
|||
|
|
@ -73,4 +73,20 @@ final class Config
|
|||
{
|
||||
return (int) $this->get('MONITOR_DESK_COUNT', '8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Wie viele Polling-Runden der SSE-Endpoint (F13, StreamController) maximal offen bleibt,
|
||||
* bevor er die Verbindung schliesst - EventSource im Frontend verbindet danach automatisch
|
||||
* neu. Mit dem Default (30 * 2s) bleibt eine Verbindung ca. eine Minute offen.
|
||||
*/
|
||||
public function sseMaxIterations(): int
|
||||
{
|
||||
return (int) $this->get('MONITOR_SSE_MAX_ITERATIONS', '30');
|
||||
}
|
||||
|
||||
/** Wartezeit zwischen zwei Aenderungs-Checks des SSE-Endpoints in Sekunden. */
|
||||
public function ssePollIntervalSeconds(): int
|
||||
{
|
||||
return (int) $this->get('MONITOR_SSE_POLL_INTERVAL', '2');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
56
api/src/Controller/StreamController.php
Normal file
56
api/src/Controller/StreamController.php
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Monitor\Api\Controller;
|
||||
|
||||
use Monitor\Api\Http\Request;
|
||||
use Monitor\Api\Http\Response;
|
||||
use Monitor\Api\Realtime\ChangeFeed;
|
||||
|
||||
/**
|
||||
* SSE-Push-Endpoint (F13: "Frontend via PHP Push aktualisieren (Server-Sent)", Phase 4).
|
||||
* Der eingebaute PHP-Entwicklungsserver (siehe docker-compose.yml) kennt keinen echten
|
||||
* Daemon-Prozess, daher ist der "Push" als kurzlebige Polling-Schleife umgesetzt: Die
|
||||
* Verbindung bleibt bis zu maxIterations * pollIntervalSeconds offen und meldet per SSE-Event,
|
||||
* sobald sich ein ChangeFeed-Fingerabdruck aendert. EventSource im Frontend verbindet nach
|
||||
* Ablauf automatisch neu (Standard-SSE-Reconnect-Verhalten), siehe html/js/office-realtime.js.
|
||||
*/
|
||||
final class StreamController
|
||||
{
|
||||
/** @param callable(int): void $sleep */
|
||||
public function __construct(
|
||||
private readonly ChangeFeed $changeFeed,
|
||||
private readonly int $maxIterations = 30,
|
||||
private readonly int $pollIntervalSeconds = 2,
|
||||
private $sleep = 'sleep',
|
||||
) {
|
||||
}
|
||||
|
||||
public function stream(Request $request): Response
|
||||
{
|
||||
return Response::stream(function (): void {
|
||||
$previous = $this->changeFeed->currentFingerprint();
|
||||
echo ': verbunden' . "\n\n";
|
||||
|
||||
for ($i = 0; $i < $this->maxIterations; $i++) {
|
||||
($this->sleep)($this->pollIntervalSeconds);
|
||||
|
||||
if (function_exists('connection_aborted') && connection_aborted()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$current = $this->changeFeed->currentFingerprint();
|
||||
$changed = $this->changeFeed->diff($previous, $current);
|
||||
|
||||
if ($changed !== []) {
|
||||
echo "event: update\n";
|
||||
echo 'data: ' . json_encode(['changed' => $changed], JSON_UNESCAPED_SLASHES) . "\n\n";
|
||||
$previous = $current;
|
||||
} else {
|
||||
echo ': heartbeat' . "\n\n";
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -6,10 +6,16 @@ namespace Monitor\Api\Http;
|
|||
|
||||
final class Response
|
||||
{
|
||||
/** @param mixed $data */
|
||||
/**
|
||||
* @param mixed $data
|
||||
* @param \Closure|null $streamWriter - gesetzt fuer SSE-Antworten (F13, Phase 4): statt
|
||||
* eines JSON-Bodys schreibt dieser Callback selbst Bytes an den Client (siehe
|
||||
* StreamController). $data bleibt in dem Fall null.
|
||||
*/
|
||||
private function __construct(
|
||||
public readonly int $status,
|
||||
private readonly mixed $data,
|
||||
private readonly ?\Closure $streamWriter = null,
|
||||
) {
|
||||
}
|
||||
|
||||
|
|
@ -24,8 +30,55 @@ final class Response
|
|||
return new self($status, ['error' => $message]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param \Closure $writer - wird von send() mit gesetzten SSE-Headern aufgerufen und
|
||||
* schreibt selbst per echo/flush() an den Client (typischerweise eine Polling-Schleife).
|
||||
*/
|
||||
public static function stream(\Closure $writer): self
|
||||
{
|
||||
return new self(200, null, $writer);
|
||||
}
|
||||
|
||||
public function isStream(): bool
|
||||
{
|
||||
return $this->streamWriter !== null;
|
||||
}
|
||||
|
||||
/** Fuer Tests: den Stream-Callback ohne echte HTTP-Header/Output-Handling ausfuehren. */
|
||||
public function writer(): ?\Closure
|
||||
{
|
||||
return $this->streamWriter;
|
||||
}
|
||||
|
||||
public function send(): void
|
||||
{
|
||||
// Frontend (html/, Port 8090 im Docker-Compose-Setup) und API (Port 8091) laufen auf
|
||||
// unterschiedlichen Origins - ohne CORS wuerde der Browser sowohl die fetch()-Aufrufe
|
||||
// aus office-api.js als auch den EventSource-Stream (F13) blockieren. Alle Endpunkte
|
||||
// liefern ohnehin nur oeffentliche GET-Daten bzw. verlangen fuer POST/PATCH einen
|
||||
// gueltigen API-Key, ein offener Origin ist daher unkritisch.
|
||||
header('Access-Control-Allow-Origin: *');
|
||||
|
||||
if ($this->streamWriter !== null) {
|
||||
http_response_code($this->status);
|
||||
header('Content-Type: text/event-stream; charset=utf-8');
|
||||
header('Cache-Control: no-cache');
|
||||
header('Connection: keep-alive');
|
||||
// Verhindert, dass ein vorgeschalteter nginx die Antwort puffert und SSE-Events
|
||||
// erst nach Verbindungsende ausliefert.
|
||||
header('X-Accel-Buffering: no');
|
||||
if (function_exists('ob_implicit_flush')) {
|
||||
ob_implicit_flush(true);
|
||||
}
|
||||
while (ob_get_level() > 0) {
|
||||
ob_end_flush();
|
||||
}
|
||||
|
||||
($this->streamWriter)();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
http_response_code($this->status);
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode($this->data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
|
|
|
|||
61
api/src/Realtime/ChangeFeed.php
Normal file
61
api/src/Realtime/ChangeFeed.php
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Monitor\Api\Realtime;
|
||||
|
||||
use Monitor\Api\Repository\AgentRepository;
|
||||
use Monitor\Api\Repository\EventRepository;
|
||||
use Monitor\Api\Repository\KpiRepository;
|
||||
use Monitor\Api\Repository\SystemRepository;
|
||||
use Monitor\Api\Repository\TicketRepository;
|
||||
|
||||
/**
|
||||
* Erkennt, ob sich seit dem letzten Blick etwas an Tickets/Events/Systemen/KPIs/Agenten
|
||||
* veraendert hat - Grundlage fuer den SSE-Push-Endpoint (F13, Phase 4:
|
||||
* "Frontend via PHP Push aktualisieren (Server-Sent)"). Bewusst als billiger "Fingerabdruck"
|
||||
* (COUNT + juengster Zeitstempel je Ressource, siehe Repository::fingerprint()) statt echtem
|
||||
* Event-Log umgesetzt - reicht aus, um dem Frontend zu sagen "Ressource X hat sich geaendert,
|
||||
* bitte neu laden", ohne eine Nachrichten-Warteschlange einzufuehren.
|
||||
*/
|
||||
final class ChangeFeed
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TicketRepository $tickets,
|
||||
private readonly EventRepository $events,
|
||||
private readonly SystemRepository $systems,
|
||||
private readonly KpiRepository $kpis,
|
||||
private readonly AgentRepository $agents,
|
||||
) {
|
||||
}
|
||||
|
||||
/** @return array<string, string> */
|
||||
public function currentFingerprint(): array
|
||||
{
|
||||
return [
|
||||
'tickets' => $this->tickets->fingerprint(),
|
||||
'events' => $this->events->fingerprint(),
|
||||
'systems' => $this->systems->fingerprint(),
|
||||
'kpis' => $this->kpis->fingerprint(),
|
||||
'agents' => $this->agents->fingerprint(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $previous
|
||||
* @param array<string, string> $current
|
||||
* @return string[] Keys der Ressourcen, die sich geaendert haben (Reihenfolge wie
|
||||
* currentFingerprint()).
|
||||
*/
|
||||
public function diff(array $previous, array $current): array
|
||||
{
|
||||
$changed = [];
|
||||
foreach ($current as $key => $value) {
|
||||
if (($previous[$key] ?? null) !== $value) {
|
||||
$changed[] = $key;
|
||||
}
|
||||
}
|
||||
|
||||
return $changed;
|
||||
}
|
||||
}
|
||||
|
|
@ -146,4 +146,12 @@ final class AgentRepository
|
|||
|
||||
return $row;
|
||||
}
|
||||
|
||||
/** Fingerabdruck fuer den SSE-Endpoint (Realtime\ChangeFeed, F13). */
|
||||
public function fingerprint(): string
|
||||
{
|
||||
$row = $this->db->pdo()->query('SELECT COUNT(*) AS c, MAX(last_seen_at) AS m FROM agents')->fetch();
|
||||
|
||||
return $row['c'] . ':' . ($row['m'] ?? '');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -70,4 +70,15 @@ final class EventRepository
|
|||
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fingerabdruck fuer den SSE-Endpoint (Realtime\ChangeFeed, F13). Events sind unveraenderlich
|
||||
* (kein UPDATE), daher reicht Anzahl + hoechste ID, um ein neu gemeldetes Event zu erkennen.
|
||||
*/
|
||||
public function fingerprint(): string
|
||||
{
|
||||
$row = $this->db->pdo()->query('SELECT COUNT(*) AS c, MAX(id) AS m FROM events')->fetch();
|
||||
|
||||
return $row['c'] . ':' . ($row['m'] ?? '');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -77,4 +77,12 @@ final class KpiRepository
|
|||
|
||||
return array_map(fn (string $key) => $this->find($key), $keys);
|
||||
}
|
||||
|
||||
/** Fingerabdruck fuer den SSE-Endpoint (Realtime\ChangeFeed, F13). */
|
||||
public function fingerprint(): string
|
||||
{
|
||||
$row = $this->db->pdo()->query('SELECT COUNT(*) AS c, MAX(recorded_at) AS m FROM kpi_values')->fetch();
|
||||
|
||||
return $row['c'] . ':' . ($row['m'] ?? '');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -102,4 +102,12 @@ final class SystemRepository
|
|||
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
/** Fingerabdruck fuer den SSE-Endpoint (Realtime\ChangeFeed, F13). */
|
||||
public function fingerprint(): string
|
||||
{
|
||||
$row = $this->db->pdo()->query('SELECT COUNT(*) AS c, MAX(last_check_at) AS m FROM systems')->fetch();
|
||||
|
||||
return $row['c'] . ':' . ($row['m'] ?? '');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -155,6 +155,17 @@ final class TicketRepository
|
|||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Billiger "Fingerabdruck" (Anzahl + juengster Aenderungszeitpunkt) fuer den SSE-Endpoint
|
||||
* (Realtime\ChangeFeed, F13) - aendert sich bei jedem Anlegen/Verschieben eines Tickets.
|
||||
*/
|
||||
public function fingerprint(): string
|
||||
{
|
||||
$row = $this->db->pdo()->query('SELECT COUNT(*) AS c, MAX(updated_at) AS m FROM tickets')->fetch();
|
||||
|
||||
return $row['c'] . ':' . ($row['m'] ?? '');
|
||||
}
|
||||
|
||||
private function assertValidColumn(mixed $column): void
|
||||
{
|
||||
if (!is_string($column) || !in_array($column, self::COLUMNS, true)) {
|
||||
|
|
|
|||
|
|
@ -118,4 +118,18 @@ final class ApplicationTest extends TestCase
|
|||
|
||||
self::assertSame(404, $response->status);
|
||||
}
|
||||
|
||||
/**
|
||||
* Der SSE-Endpoint (F13) ist wie die anderen GET-Endpunkte oeffentlich und liefert eine
|
||||
* Stream-Antwort - der eigentliche Polling-Loop wird hier bewusst NICHT ausgefuehrt
|
||||
* (siehe StreamControllerTest fuer die Schleifenlogik selbst), das wuerde echte sleep()-
|
||||
* Aufrufe ausloesen.
|
||||
*/
|
||||
public function testStreamRouteIsPublicAndReturnsAStreamResponse(): void
|
||||
{
|
||||
$response = $this->app->handle(Request::create('GET', '/api/v1/stream'));
|
||||
|
||||
self::assertSame(200, $response->status);
|
||||
self::assertTrue($response->isStream());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@ final class ConfigTest extends TestCase
|
|||
self::assertNull($config->codebergToken());
|
||||
self::assertNull($config->adminApiKey());
|
||||
self::assertSame(8, $config->deskCount());
|
||||
self::assertSame(30, $config->sseMaxIterations());
|
||||
self::assertSame(2, $config->ssePollIntervalSeconds());
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -46,6 +48,8 @@ final class ConfigTest extends TestCase
|
|||
'CODEBERG_TOKEN' => 'tok',
|
||||
'MONITOR_ADMIN_API_KEY' => 'secret',
|
||||
'MONITOR_DESK_COUNT' => '3',
|
||||
'MONITOR_SSE_MAX_ITERATIONS' => '5',
|
||||
'MONITOR_SSE_POLL_INTERVAL' => '1',
|
||||
]);
|
||||
|
||||
self::assertSame('https://example.org/api/v1', $config->codebergApiBase());
|
||||
|
|
@ -54,5 +58,7 @@ final class ConfigTest extends TestCase
|
|||
self::assertSame('tok', $config->codebergToken());
|
||||
self::assertSame('secret', $config->adminApiKey());
|
||||
self::assertSame(3, $config->deskCount());
|
||||
self::assertSame(5, $config->sseMaxIterations());
|
||||
self::assertSame(1, $config->ssePollIntervalSeconds());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
103
api/tests/Unit/Controller/StreamControllerTest.php
Normal file
103
api/tests/Unit/Controller/StreamControllerTest.php
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Monitor\Api\Tests\Controller;
|
||||
|
||||
use Monitor\Api\Controller\StreamController;
|
||||
use Monitor\Api\Db\Database;
|
||||
use Monitor\Api\Http\Request;
|
||||
use Monitor\Api\Realtime\ChangeFeed;
|
||||
use Monitor\Api\Repository\AgentRepository;
|
||||
use Monitor\Api\Repository\EventRepository;
|
||||
use Monitor\Api\Repository\KpiRepository;
|
||||
use Monitor\Api\Repository\SystemRepository;
|
||||
use Monitor\Api\Repository\TicketRepository;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class StreamControllerTest extends TestCase
|
||||
{
|
||||
private TicketRepository $tickets;
|
||||
private ChangeFeed $changeFeed;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$db = Database::inMemory();
|
||||
$this->tickets = new TicketRepository($db);
|
||||
$this->changeFeed = new ChangeFeed(
|
||||
$this->tickets,
|
||||
new EventRepository($db),
|
||||
new SystemRepository($db),
|
||||
new KpiRepository($db),
|
||||
new AgentRepository($db)
|
||||
);
|
||||
}
|
||||
|
||||
public function testStreamSendsHeartbeatsWhenNothingChanges(): void
|
||||
{
|
||||
$controller = new StreamController(
|
||||
$this->changeFeed,
|
||||
maxIterations: 2,
|
||||
pollIntervalSeconds: 0,
|
||||
sleep: function (int $seconds): void {
|
||||
},
|
||||
);
|
||||
|
||||
$response = $controller->stream(Request::create('GET', '/api/v1/stream'));
|
||||
self::assertTrue($response->isStream());
|
||||
|
||||
$output = $this->captureWriterOutput($response->writer());
|
||||
|
||||
self::assertStringContainsString(': heartbeat', $output);
|
||||
self::assertStringNotContainsString('event: update', $output);
|
||||
}
|
||||
|
||||
public function testStreamEmitsUpdateEventWhenAResourceChangesBetweenPolls(): void
|
||||
{
|
||||
$tickets = $this->tickets;
|
||||
$pollCount = 0;
|
||||
$controller = new StreamController(
|
||||
$this->changeFeed,
|
||||
maxIterations: 2,
|
||||
pollIntervalSeconds: 0,
|
||||
sleep: function (int $seconds) use (&$pollCount, $tickets): void {
|
||||
$pollCount++;
|
||||
if ($pollCount === 1) {
|
||||
$tickets->create(['title' => 'Neu waehrend des Wartens']);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
$response = $controller->stream(Request::create('GET', '/api/v1/stream'));
|
||||
$output = $this->captureWriterOutput($response->writer());
|
||||
|
||||
self::assertStringContainsString('event: update', $output);
|
||||
self::assertStringContainsString('"tickets"', $output);
|
||||
self::assertSame(2, $pollCount);
|
||||
}
|
||||
|
||||
public function testStreamStopsAfterMaxIterations(): void
|
||||
{
|
||||
$pollCount = 0;
|
||||
$controller = new StreamController(
|
||||
$this->changeFeed,
|
||||
maxIterations: 3,
|
||||
pollIntervalSeconds: 0,
|
||||
sleep: function (int $seconds) use (&$pollCount): void {
|
||||
$pollCount++;
|
||||
},
|
||||
);
|
||||
|
||||
$this->captureWriterOutput($controller->stream(Request::create('GET', '/api/v1/stream'))->writer());
|
||||
|
||||
self::assertSame(3, $pollCount);
|
||||
}
|
||||
|
||||
private function captureWriterOutput(\Closure $writer): string
|
||||
{
|
||||
ob_start();
|
||||
$writer();
|
||||
|
||||
return (string) ob_get_clean();
|
||||
}
|
||||
}
|
||||
|
|
@ -24,4 +24,27 @@ final class ResponseTest extends TestCase
|
|||
self::assertSame(404, $response->status);
|
||||
self::assertSame(['error' => 'nope'], $response->body());
|
||||
}
|
||||
|
||||
public function testJsonResponseIsNotAStream(): void
|
||||
{
|
||||
$response = Response::json(['foo' => 'bar']);
|
||||
|
||||
self::assertFalse($response->isStream());
|
||||
self::assertNull($response->writer());
|
||||
}
|
||||
|
||||
public function testStreamResponseExposesTheWriterWithoutBody(): void
|
||||
{
|
||||
$response = Response::stream(function (): void {
|
||||
echo 'hi';
|
||||
});
|
||||
|
||||
self::assertTrue($response->isStream());
|
||||
self::assertSame(200, $response->status);
|
||||
self::assertNull($response->body());
|
||||
|
||||
ob_start();
|
||||
($response->writer())();
|
||||
self::assertSame('hi', ob_get_clean());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
68
api/tests/Unit/Realtime/ChangeFeedTest.php
Normal file
68
api/tests/Unit/Realtime/ChangeFeedTest.php
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Monitor\Api\Tests\Realtime;
|
||||
|
||||
use Monitor\Api\Db\Database;
|
||||
use Monitor\Api\Realtime\ChangeFeed;
|
||||
use Monitor\Api\Repository\AgentRepository;
|
||||
use Monitor\Api\Repository\EventRepository;
|
||||
use Monitor\Api\Repository\KpiRepository;
|
||||
use Monitor\Api\Repository\SystemRepository;
|
||||
use Monitor\Api\Repository\TicketRepository;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class ChangeFeedTest extends TestCase
|
||||
{
|
||||
private ChangeFeed $changeFeed;
|
||||
private TicketRepository $tickets;
|
||||
private EventRepository $events;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$db = Database::inMemory();
|
||||
$this->tickets = new TicketRepository($db);
|
||||
$this->events = new EventRepository($db);
|
||||
$systems = new SystemRepository($db);
|
||||
$kpis = new KpiRepository($db);
|
||||
$agents = new AgentRepository($db);
|
||||
|
||||
$this->changeFeed = new ChangeFeed($this->tickets, $this->events, $systems, $kpis, $agents);
|
||||
}
|
||||
|
||||
public function testCurrentFingerprintCoversAllFiveResources(): void
|
||||
{
|
||||
$fingerprint = $this->changeFeed->currentFingerprint();
|
||||
|
||||
self::assertSame(['tickets', 'events', 'systems', 'kpis', 'agents'], array_keys($fingerprint));
|
||||
}
|
||||
|
||||
public function testDiffIsEmptyWithoutChanges(): void
|
||||
{
|
||||
$fingerprint = $this->changeFeed->currentFingerprint();
|
||||
|
||||
self::assertSame([], $this->changeFeed->diff($fingerprint, $fingerprint));
|
||||
}
|
||||
|
||||
public function testDiffReportsOnlyTheChangedResource(): void
|
||||
{
|
||||
$before = $this->changeFeed->currentFingerprint();
|
||||
$this->tickets->create(['title' => 'Neu']);
|
||||
$after = $this->changeFeed->currentFingerprint();
|
||||
|
||||
self::assertSame(['tickets'], $this->changeFeed->diff($before, $after));
|
||||
}
|
||||
|
||||
public function testDiffReportsMultipleChangedResources(): void
|
||||
{
|
||||
$before = $this->changeFeed->currentFingerprint();
|
||||
$this->tickets->create(['title' => 'Neu']);
|
||||
$this->events->create(['name' => 'Ping', 'message' => 'down', 'severity' => 'error']);
|
||||
$after = $this->changeFeed->currentFingerprint();
|
||||
|
||||
$changed = $this->changeFeed->diff($before, $after);
|
||||
sort($changed);
|
||||
self::assertSame(['events', 'tickets'], $changed);
|
||||
}
|
||||
}
|
||||
|
|
@ -112,4 +112,12 @@ final class AgentRepositoryTest extends TestCase
|
|||
self::assertArrayNotHasKey('api_key_hash', $agent);
|
||||
}
|
||||
}
|
||||
|
||||
public function testFingerprintChangesWhenAnAgentIsRegistered(): void
|
||||
{
|
||||
$before = $this->repository->fingerprint();
|
||||
$this->repository->register('Alice', 'entwickler');
|
||||
|
||||
self::assertNotSame($before, $this->repository->fingerprint());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -69,4 +69,12 @@ final class EventRepositoryTest extends TestCase
|
|||
|
||||
self::assertCount(1, $this->repository->latest(0));
|
||||
}
|
||||
|
||||
public function testFingerprintChangesWhenAnEventIsCreated(): void
|
||||
{
|
||||
$before = $this->repository->fingerprint();
|
||||
$this->repository->create(['name' => 'A', 'message' => 'a', 'severity' => 'warning']);
|
||||
|
||||
self::assertNotSame($before, $this->repository->fingerprint());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -58,4 +58,12 @@ final class KpiRepositoryTest extends TestCase
|
|||
|
||||
self::assertCount(2, $this->repository->all());
|
||||
}
|
||||
|
||||
public function testFingerprintChangesWhenAValueIsReported(): void
|
||||
{
|
||||
$before = $this->repository->fingerprint();
|
||||
$this->repository->report('open_tickets', 1.0, null, null, '2026-01-01T00:00:00Z');
|
||||
|
||||
self::assertNotSame($before, $this->repository->fingerprint());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -78,4 +78,12 @@ final class SystemRepositoryTest extends TestCase
|
|||
self::assertCount(SystemRepository::MAX_TILES, $all);
|
||||
self::assertSame('error', $all[0]['status']);
|
||||
}
|
||||
|
||||
public function testFingerprintChangesWhenStatusIsReported(): void
|
||||
{
|
||||
$before = $this->repository->fingerprint();
|
||||
$this->repository->reportStatus('srv1', ['status' => 'ok']);
|
||||
|
||||
self::assertNotSame($before, $this->repository->fingerprint());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -132,4 +132,19 @@ final class TicketRepositoryTest extends TestCase
|
|||
|
||||
self::assertSame('in_arbeit', $result['column_name']);
|
||||
}
|
||||
|
||||
public function testFingerprintChangesWhenATicketIsCreated(): void
|
||||
{
|
||||
$before = $this->repository->fingerprint();
|
||||
$this->repository->create(['title' => 'X']);
|
||||
|
||||
self::assertNotSame($before, $this->repository->fingerprint());
|
||||
}
|
||||
|
||||
public function testFingerprintIsStableWithoutChanges(): void
|
||||
{
|
||||
$this->repository->create(['title' => 'X']);
|
||||
|
||||
self::assertSame($this->repository->fingerprint(), $this->repository->fingerprint());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ services:
|
|||
MONITOR_ADMIN_API_KEY: ${MONITOR_ADMIN_API_KEY:-dev-admin-key}
|
||||
MONITOR_STATUS_FILE: /status.txt
|
||||
MONITOR_DESK_COUNT: 8
|
||||
MONITOR_SSE_MAX_ITERATIONS: 30
|
||||
MONITOR_SSE_POLL_INTERVAL: 2
|
||||
CODEBERG_API_BASE: https://codeberg.org/api/v1
|
||||
CODEBERG_OWNER: ${CODEBERG_OWNER:-fds2610}
|
||||
CODEBERG_REPO: ${CODEBERG_REPO:-monitor}
|
||||
|
|
@ -29,6 +31,8 @@ services:
|
|||
# Ueberlagert den Platzhalter html/API.md - der muss existieren, weil in
|
||||
# einem read-only gemounteten Verzeichnis kein Mountpoint angelegt werden kann.
|
||||
- ./API.md:/usr/share/nginx/html/API.md:ro
|
||||
# Setzt office.html als Einstiegsseite (sonst 403 auf der Basis-URL).
|
||||
- ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
ports:
|
||||
- "8090:80"
|
||||
|
||||
|
|
|
|||
15
docker/nginx/default.conf
Normal file
15
docker/nginx/default.conf
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
|
||||
# Einstiegsseite ist die Buero-Visualisierung - ohne diese Direktive liefert
|
||||
# nginx beim Aufruf der Basis-URL ein 403 (Directory-Listing ist deaktiviert,
|
||||
# und eine index.html gibt es nicht).
|
||||
index office.html;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ =404;
|
||||
}
|
||||
}
|
||||
|
|
@ -24,15 +24,32 @@
|
|||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Die Szene soll immer vollstaendig sichtbar sein - auch im normalen Fenstermodus,
|
||||
ohne Scrollen. Dazu spannt der body eine Flex-Spalte ueber die Viewport-Hoehe auf:
|
||||
Header nimmt seine natuerliche Hoehe, #office-scene bekommt den Rest, und das SVG
|
||||
skaliert sich per aspect-ratio in diesen Rest hinein (siehe unten). */
|
||||
html {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: "Segoe UI", Verdana, Arial, sans-serif;
|
||||
background: #dbe7f3;
|
||||
color: var(--color-text);
|
||||
/* height (nicht min-height): Nur eine definite Hoehe laesst die prozentualen
|
||||
max-height-Angaben weiter unten aufloesen. dvh rechnet mobile Browserleisten
|
||||
mit, vh ist der Fallback fuer aeltere Engines. */
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
header.office-header {
|
||||
padding: 16px 24px;
|
||||
flex: 0 0 auto;
|
||||
padding: 12px 24px;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 16px;
|
||||
|
|
@ -50,20 +67,65 @@ header.office-header p {
|
|||
}
|
||||
|
||||
#office-scene {
|
||||
width: 100%;
|
||||
max-width: 1920px;
|
||||
margin: 0 auto;
|
||||
padding: 0 16px 32px;
|
||||
flex: 1 1 auto;
|
||||
/* min-height: 0 hebt die Flex-Default-Mindesthoehe (min-content) auf - sonst kann
|
||||
das Kind den Container nicht unterschreiten und die Szene laeuft aus dem Bild. */
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 16px 16px;
|
||||
}
|
||||
|
||||
/* Das SVG soll sich an der jeweils knapperen Achse ausrichten. Welche das ist, haengt
|
||||
vom Seitenverhaeltnis des Fensters ab - daher die beiden Media-Queries unten. Die
|
||||
max-*-Grenzen sind das Sicherheitsnetz fuer den Grenzbereich (die Header-Hoehe geht
|
||||
in das Verhaeltnis der Szenenflaeche ein, in die Media-Query aber nicht): Dort wird
|
||||
die Elementbox notfalls leicht gestaucht - der Szeneninhalt bleibt dank
|
||||
preserveAspectRatio unverzerrt und vollstaendig sichtbar. */
|
||||
.office-svg {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
aspect-ratio: 1920 / 1080;
|
||||
display: block;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.25);
|
||||
}
|
||||
|
||||
/* Fenster breiter als 16:9 -> die Hoehe ist der Engpass. */
|
||||
@media (min-aspect-ratio: 16 / 9) {
|
||||
.office-svg {
|
||||
height: 100%;
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* Fenster schmaler als 16:9 -> die Breite ist der Engpass. */
|
||||
@media (max-aspect-ratio: 16 / 9) {
|
||||
.office-svg {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* Bei knappem Platz weicht zuerst der erklaerende Fliesstext - er kostet in flachen
|
||||
oder schmalen Fenstern mehrere Zeilen Hoehe, die der Szene fehlen. */
|
||||
@media (max-height: 640px), (max-width: 760px) {
|
||||
header.office-header p {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-height: 640px) {
|
||||
header.office-header {
|
||||
padding: 8px 16px;
|
||||
}
|
||||
|
||||
header.office-header h1 {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.floor {
|
||||
fill: var(--color-floor);
|
||||
}
|
||||
|
|
@ -249,3 +311,240 @@ header.office-header p {
|
|||
.desk-empty .desk-chair {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Klickbare Bereiche (Phase 3/4: Board-Spalten + Monitore oeffnen ein Modal) */
|
||||
|
||||
.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.board-column-hit {
|
||||
fill: transparent;
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
.just-updated .board-frame,
|
||||
.just-updated .monitor-frame {
|
||||
animation: office-flash 0.9s ease-out;
|
||||
}
|
||||
|
||||
@keyframes office-flash {
|
||||
0% {
|
||||
filter: drop-shadow(0 0 0 rgba(250, 204, 21, 0));
|
||||
}
|
||||
25% {
|
||||
filter: drop-shadow(0 0 12px rgba(250, 204, 21, 0.9));
|
||||
}
|
||||
100% {
|
||||
filter: drop-shadow(0 0 0 rgba(250, 204, 21, 0));
|
||||
}
|
||||
}
|
||||
|
||||
/* Kaffee-Ecke (Phase 5, Raum-Prop zwischen den Schreibtischreihen) */
|
||||
|
||||
.coffee-table {
|
||||
fill: #8a6238;
|
||||
stroke: #6b4a28;
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.coffee-cup {
|
||||
fill: #f8fafc;
|
||||
stroke: #94a3b8;
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
|
||||
.coffee-icon {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
/* Agenten-Avatare (Phase 5: bewegliche Schicht, siehe office-render.js) */
|
||||
|
||||
.agent-avatar {
|
||||
transition: transform 1.1s ease-in-out;
|
||||
}
|
||||
|
||||
.agent-avatar .person-head {
|
||||
fill: #f3d9b1;
|
||||
stroke: #d8b98a;
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
|
||||
.agent-avatar .person-body {
|
||||
fill: var(--role-color, #94a3b8);
|
||||
}
|
||||
|
||||
.agent-avatar.pose-walking .person-leg {
|
||||
fill: #475569;
|
||||
transform-box: fill-box;
|
||||
transform-origin: top center;
|
||||
animation: office-leg-swing 0.6s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
.agent-avatar.pose-walking .person-leg-right {
|
||||
animation-delay: 0.3s;
|
||||
}
|
||||
|
||||
@keyframes office-leg-swing {
|
||||
from {
|
||||
transform: rotate(-18deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(18deg);
|
||||
}
|
||||
}
|
||||
|
||||
.person-prop-cup {
|
||||
fill: #f8fafc;
|
||||
stroke: #94a3b8;
|
||||
}
|
||||
|
||||
.person-prop-newspaper {
|
||||
fill: #f8fafc;
|
||||
stroke: #94a3b8;
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
.person-prop-speech {
|
||||
fill: #f8fafc;
|
||||
stroke: #94a3b8;
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
.avatar-name {
|
||||
fill: #334155;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
text-anchor: middle;
|
||||
}
|
||||
|
||||
/* KPI-Sparklines (Phase 4, F10: kein Chart-Lib, eigenes SVG-Zeichnen) */
|
||||
|
||||
.kpi-tile-label {
|
||||
fill: var(--color-text-inverse);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.kpi-sparkline-bg {
|
||||
fill: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.kpi-sparkline-line {
|
||||
fill: none;
|
||||
stroke: #7dd3fc;
|
||||
stroke-width: 2;
|
||||
stroke-linejoin: round;
|
||||
stroke-linecap: round;
|
||||
}
|
||||
|
||||
/* Modal (Phase 3/4: Board-/Monitor-Detailansicht, Visualization.md 4.5) */
|
||||
|
||||
.office-modal {
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
padding: 0;
|
||||
width: min(640px, 92vw);
|
||||
max-height: 80vh;
|
||||
box-shadow: 0 20px 50px rgba(15, 23, 42, 0.4);
|
||||
}
|
||||
|
||||
.office-modal::backdrop {
|
||||
background: rgba(15, 23, 42, 0.55);
|
||||
}
|
||||
|
||||
.office-modal-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 80vh;
|
||||
}
|
||||
|
||||
.office-modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.office-modal-title {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.office-modal-close {
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-size: 1.4rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.office-modal-body {
|
||||
padding: 16px 20px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.office-modal-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.office-modal-item {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.office-modal-item p {
|
||||
margin: 6px 0 0;
|
||||
color: #475569;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.office-modal-item-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.office-modal-badge {
|
||||
font-size: 0.75rem;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
background: #e2e8f0;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.office-modal-item.severity-error {
|
||||
border-color: #fca5a5;
|
||||
background: #fef2f2;
|
||||
}
|
||||
|
||||
.office-modal-item.severity-warning {
|
||||
border-color: #fde68a;
|
||||
background: #fffbeb;
|
||||
}
|
||||
|
||||
.office-modal-item.status-error {
|
||||
border-color: #fca5a5;
|
||||
background: #fef2f2;
|
||||
}
|
||||
|
||||
.office-modal-empty {
|
||||
color: #64748b;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.kpi-modal-sparkline {
|
||||
width: 100%;
|
||||
height: 80px;
|
||||
margin-top: 8px;
|
||||
background: #10131a;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
|
|
|||
52
html/js/office-api.js
Normal file
52
html/js/office-api.js
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
// Duenne fetch()-Wrapper fuer die Phase-1-REST-API (siehe API.md). Reine Daten-Ebene, kein
|
||||
// DOM-Zugriff - office-render.js ruft diese Funktionen auf und reicht die Ergebnisse an die
|
||||
// Layoutlogik (office-scene.js) weiter. Basis-URL ist per window.MONITOR_API_BASE
|
||||
// ueberschreibbar (z.B. wenn Frontend und API auf unterschiedlichen Ports laufen, siehe
|
||||
// docker-compose.yml: web auf :8090, api auf :8091).
|
||||
|
||||
const DEFAULT_API_BASE = "/api/v1";
|
||||
|
||||
function apiBase() {
|
||||
return (typeof window !== "undefined" && window.MONITOR_API_BASE) || DEFAULT_API_BASE;
|
||||
}
|
||||
|
||||
async function getJson(path) {
|
||||
const response = await fetch(`${apiBase()}${path}`, { headers: { Accept: "application/json" } });
|
||||
if (!response.ok) {
|
||||
throw new Error(`GET ${path} fehlgeschlagen: HTTP ${response.status}`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function fetchTickets() {
|
||||
const { tickets } = await getJson("/tickets");
|
||||
return tickets;
|
||||
}
|
||||
|
||||
export async function fetchEvents(limit = 200) {
|
||||
const { events } = await getJson(`/events?limit=${encodeURIComponent(limit)}`);
|
||||
return events;
|
||||
}
|
||||
|
||||
export async function fetchSystems() {
|
||||
const { systems } = await getJson("/systems");
|
||||
return systems;
|
||||
}
|
||||
|
||||
export async function fetchKpis() {
|
||||
const { kpis } = await getJson("/kpis");
|
||||
return kpis;
|
||||
}
|
||||
|
||||
export async function fetchAgents() {
|
||||
const { agents } = await getJson("/agents");
|
||||
return agents;
|
||||
}
|
||||
|
||||
/**
|
||||
* Baut die Stream-URL fuer den SSE-Push-Endpoint (F13, Phase 4). Wird von office-realtime.js
|
||||
* an EventSource() uebergeben.
|
||||
*/
|
||||
export function streamUrl() {
|
||||
return `${apiBase()}/stream`;
|
||||
}
|
||||
59
html/js/office-modal.js
Normal file
59
html/js/office-modal.js
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
// Generisches Modal fuer Board-/Monitor-Detailansichten (Visualization.md 4.5: "Modals sind
|
||||
// reines HTML/CSS/JS ohne fremde UI-Bibliothek, per <dialog>-Element ... umgesetzt"). Dieses
|
||||
// Modul kuemmert sich nur um Erzeugen/Oeffnen/Schliessen des <dialog>-Elements; welchen Inhalt
|
||||
// es zeigt, entscheidet der Aufrufer (office-render.js) per renderBody-Callback.
|
||||
|
||||
let dialogEl = null;
|
||||
|
||||
function ensureDialog() {
|
||||
if (dialogEl) {
|
||||
return dialogEl;
|
||||
}
|
||||
|
||||
dialogEl = document.createElement("dialog");
|
||||
dialogEl.className = "office-modal";
|
||||
dialogEl.innerHTML = `
|
||||
<form method="dialog" class="office-modal-form">
|
||||
<header class="office-modal-header">
|
||||
<h2 class="office-modal-title"></h2>
|
||||
<button type="submit" class="office-modal-close" aria-label="Schliessen">×</button>
|
||||
</header>
|
||||
<div class="office-modal-body"></div>
|
||||
</form>
|
||||
`;
|
||||
document.body.appendChild(dialogEl);
|
||||
return dialogEl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Oeffnet das Modal mit einem Titel und laesst den Aufrufer den Body fuellen.
|
||||
*
|
||||
* @param {{title: string}} content
|
||||
* @param {(body: HTMLElement, content: object) => void} renderBody
|
||||
*/
|
||||
export function openModal(content, renderBody) {
|
||||
const dialog = ensureDialog();
|
||||
dialog.querySelector(".office-modal-title").textContent = content.title;
|
||||
|
||||
const body = dialog.querySelector(".office-modal-body");
|
||||
body.replaceChildren();
|
||||
renderBody(body, content);
|
||||
|
||||
if (typeof dialog.showModal === "function") {
|
||||
dialog.showModal();
|
||||
} else {
|
||||
// Fallback fuer Umgebungen ohne <dialog>-Unterstuetzung (z.B. sehr alte Browser).
|
||||
dialog.setAttribute("open", "");
|
||||
}
|
||||
}
|
||||
|
||||
export function closeModal() {
|
||||
if (dialogEl === null) {
|
||||
return;
|
||||
}
|
||||
if (typeof dialogEl.close === "function") {
|
||||
dialogEl.close();
|
||||
} else {
|
||||
dialogEl.removeAttribute("open");
|
||||
}
|
||||
}
|
||||
45
html/js/office-realtime.js
Normal file
45
html/js/office-realtime.js
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
// Verbindet das Frontend per Server-Sent Events mit dem Push-Endpoint der API (F13:
|
||||
// "Frontend via PHP Push aktualisieren (Server-Sent)", Phase 4). Faellt auf Polling zurueck,
|
||||
// wenn EventSource nicht verfuegbar ist oder der Stream dauerhaft nicht funktioniert (z.B.
|
||||
// ein Proxy ohne SSE-Unterstuetzung) - reine DOM-/Browser-API-Glue, daher ungetestet wie
|
||||
// office-render.js (siehe [[project-monitor-visualization]]-Notizen zur Teststrategie).
|
||||
|
||||
import { streamUrl } from "./office-api.js";
|
||||
|
||||
const FALLBACK_POLL_INTERVAL_MS = 8000;
|
||||
const SAFETY_NET_POLL_INTERVAL_MS = 30000;
|
||||
|
||||
/**
|
||||
* @param {() => void} onChange - wird aufgerufen, wenn sich serverseitig etwas geaendert hat
|
||||
* (oder im Polling-Fallback einfach regelmaessig).
|
||||
* @returns {() => void} Aufraeumfunktion (Stream schliessen/Timer stoppen).
|
||||
*/
|
||||
export function startRealtimeUpdates(onChange) {
|
||||
if (typeof window === "undefined" || typeof window.EventSource !== "function") {
|
||||
return startPolling(onChange, FALLBACK_POLL_INTERVAL_MS);
|
||||
}
|
||||
|
||||
let source;
|
||||
try {
|
||||
source = new window.EventSource(streamUrl());
|
||||
} catch {
|
||||
return startPolling(onChange, FALLBACK_POLL_INTERVAL_MS);
|
||||
}
|
||||
|
||||
source.addEventListener("update", () => onChange());
|
||||
|
||||
// EventSource verbindet nach Verbindungsabbruch automatisch neu. Als Sicherheitsnetz laeuft
|
||||
// zusaetzlich ein sehr seltenes Polling mit, falls der Stream (z.B. wegen eines Proxys ohne
|
||||
// SSE-Unterstuetzung) dauerhaft keine Events liefert.
|
||||
const safetyNet = window.setInterval(onChange, SAFETY_NET_POLL_INTERVAL_MS);
|
||||
|
||||
return () => {
|
||||
source.close();
|
||||
window.clearInterval(safetyNet);
|
||||
};
|
||||
}
|
||||
|
||||
function startPolling(onChange, intervalMs) {
|
||||
const timer = window.setInterval(onChange, intervalMs);
|
||||
return () => window.clearInterval(timer);
|
||||
}
|
||||
|
|
@ -1,14 +1,34 @@
|
|||
// Zeichnet die statische Buero-Szene (Phase 2, siehe Visualization.md Abschnitt 9) als
|
||||
// Vanilla-SVG in den Container #office-scene. Keine Animation, keine Klick-Interaktion
|
||||
// (Board-/Monitor-Modals kommen erst mit den Live-Daten in Phase 3/4). Reine DOM-Glue-Logik -
|
||||
// die eigentliche Layoutberechnung steckt testbar in office-scene.js.
|
||||
// Zeichnet die Buero-Szene als Vanilla-SVG in den Container #office-scene und haelt sie mit
|
||||
// echten Daten aktuell (Phase 3/4: Board/Monitor-Wand live, Phase 5: Agenten-Animation, siehe
|
||||
// Visualization.md Abschnitt 9). Reine DOM-Glue-Logik - Layoutberechnung/Datenmodelle stecken
|
||||
// testbar in office-scene.js.
|
||||
//
|
||||
// Architektur-Hinweis fuer die Animation (Phase 5): Board, Monitor-Wand und Schreibtisch-
|
||||
// Moebel werden bei jedem Update komplett neu gezeichnet (kein Animationsbedarf dort), die
|
||||
// Agenten-Avatare dagegen sind stabile DOM-Knoten pro Agent-ID, deren transform-Attribut nur
|
||||
// aktualisiert wird - so kann CSS transition den Laufweg animieren, statt bei jedem Update
|
||||
// hart zu springen.
|
||||
|
||||
import {
|
||||
ROLES,
|
||||
AGENT_LOCATIONS,
|
||||
buildBoardColumnLayout,
|
||||
buildDeskLayout,
|
||||
buildSystemTiles,
|
||||
buildEventLines,
|
||||
aggregateTicketsByColumn,
|
||||
groupTicketsByColumn,
|
||||
buildTicketModalContent,
|
||||
mapEventForDisplay,
|
||||
buildEventModalContent,
|
||||
buildSystemModalContent,
|
||||
buildKpiTiles,
|
||||
buildKpiModalContent,
|
||||
mapAgentsToDesks,
|
||||
normalizeAgentForDisplay,
|
||||
resolveAgentVisualState,
|
||||
pickIdleActivity,
|
||||
AnimationQueue,
|
||||
} from "./office-scene.js";
|
||||
import {
|
||||
DEMO_TICKET_COUNTS,
|
||||
|
|
@ -17,6 +37,9 @@ import {
|
|||
DEMO_EVENTS,
|
||||
DEMO_KPI_PLACEHOLDER,
|
||||
} from "./demo-data.js";
|
||||
import { fetchTickets, fetchEvents, fetchSystems, fetchKpis, fetchAgents } from "./office-api.js";
|
||||
import { startRealtimeUpdates } from "./office-realtime.js";
|
||||
import { openModal } from "./office-modal.js";
|
||||
|
||||
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
|
||||
|
|
@ -30,16 +53,29 @@ const DOOR = { w: 40, h: 220, y: 700 };
|
|||
const DESK = { w: 220, h: 110 };
|
||||
const DESK_ROW_Y = [540, 840];
|
||||
const DESK_COL_X = [232, 644, 1056, 1468];
|
||||
const COFFEE_CORNER = { x: 960, y: 780 };
|
||||
|
||||
const NOTE_W = 96;
|
||||
const NOTE_H = 50;
|
||||
const NOTE_STEP_Y = 56;
|
||||
|
||||
// Feste "Aufenthaltsorte" fuer die Agenten-Avatare (Phase 5), abgeleitet aus den Rahmen der
|
||||
// jeweiligen Wandelemente - der Avatar steht direkt davor.
|
||||
const BOARD_FEET = { x: BOARD_FRAME.x + BOARD_FRAME.w / 2, y: BOARD_FRAME.y + BOARD_FRAME.h + 40 };
|
||||
const MONITOR_FEET = { x: MONITOR_WALL.x + MONITOR_WALL.w / 2, y: MONITOR_WALL.y + MONITOR_WALL.h + 40 };
|
||||
const COFFEE_FEET = { x: COFFEE_CORNER.x, y: COFFEE_CORNER.y + 26 };
|
||||
|
||||
const IDLE_MIN_DELAY_MS = 15000;
|
||||
const IDLE_MAX_DELAY_MS = 40000;
|
||||
const IDLE_ACTIVITY_DURATION_MS = 8000;
|
||||
|
||||
function el(tag, attrs = {}, children = []) {
|
||||
const node = document.createElementNS(SVG_NS, tag);
|
||||
for (const [key, value] of Object.entries(attrs)) {
|
||||
if (value !== null && value !== undefined) {
|
||||
node.setAttribute(key, value);
|
||||
}
|
||||
}
|
||||
for (const child of children) {
|
||||
node.appendChild(child);
|
||||
}
|
||||
|
|
@ -62,12 +98,24 @@ function renderRoom() {
|
|||
g.appendChild(
|
||||
el("rect", { x: VIEW_W - DOOR.w, y: DOOR.y, width: DOOR.w, height: DOOR.h, class: "door" })
|
||||
);
|
||||
g.appendChild(renderCoffeeCorner());
|
||||
return g;
|
||||
}
|
||||
|
||||
// Kaffee-Ecke als fester Raum-Prop zwischen den Schreibtischreihen (Ziel fuer den
|
||||
// "at_coffee_corner"-Zustand, Vision-Text: "gehen in eine Kaffee-Ecke").
|
||||
function renderCoffeeCorner() {
|
||||
const { x, y } = COFFEE_CORNER;
|
||||
const g = el("g", { class: "coffee-corner" });
|
||||
g.appendChild(el("rect", { x: x - 34, y: y - 20, width: 68, height: 40, rx: 8, class: "coffee-table" }));
|
||||
g.appendChild(el("circle", { cx: x - 14, cy: y - 26, r: 8, class: "coffee-cup" }));
|
||||
g.appendChild(el("circle", { cx: x + 14, cy: y - 26, r: 8, class: "coffee-cup" }));
|
||||
g.appendChild(text("☕", { x, y: y - 34, class: "coffee-icon", "text-anchor": "middle" }));
|
||||
return g;
|
||||
}
|
||||
|
||||
function renderStackBadge(x, y, count) {
|
||||
const g = el("g", { class: "note-stack" });
|
||||
// Mehrere leicht versetzte Zettel als "Stapel"-Andeutung, darueber die Anzahl (F3).
|
||||
[6, 3, 0].forEach((offset, i) => {
|
||||
g.appendChild(
|
||||
el("rect", {
|
||||
|
|
@ -89,12 +137,14 @@ function renderStackBadge(x, y, count) {
|
|||
return g;
|
||||
}
|
||||
|
||||
function renderBoard(ticketCounts) {
|
||||
/**
|
||||
* @param {Record<string, number>} ticketCounts
|
||||
* @param {(columnKey: string) => void} onColumnClick
|
||||
*/
|
||||
function renderBoard(ticketCounts, onColumnClick) {
|
||||
const columns = buildBoardColumnLayout(ticketCounts);
|
||||
const g = el("g", { class: "board" });
|
||||
g.appendChild(
|
||||
el("rect", { ...BOARD_FRAME, rx: 10, class: "board-frame" })
|
||||
);
|
||||
g.appendChild(el("rect", { ...BOARD_FRAME, rx: 10, class: "board-frame" }));
|
||||
g.appendChild(
|
||||
text("Kanban-Board", { x: BOARD_FRAME.x + 16, y: BOARD_FRAME.y - 14, class: "wall-label" })
|
||||
);
|
||||
|
|
@ -103,7 +153,17 @@ function renderBoard(ticketCounts) {
|
|||
|
||||
columns.forEach((column, index) => {
|
||||
const columnX = BOARD_FRAME.x + index * columnWidth;
|
||||
const colGroup = el("g", { class: `board-column board-column-${column.key}` });
|
||||
const colGroup = el("g", { class: `board-column board-column-${column.key} clickable` });
|
||||
colGroup.appendChild(
|
||||
el("rect", {
|
||||
x: columnX,
|
||||
y: BOARD_FRAME.y,
|
||||
width: columnWidth,
|
||||
height: BOARD_FRAME.h,
|
||||
class: "board-column-hit",
|
||||
})
|
||||
);
|
||||
colGroup.addEventListener("click", () => onColumnClick(column.key));
|
||||
|
||||
if (index > 0) {
|
||||
colGroup.appendChild(
|
||||
|
|
@ -147,7 +207,7 @@ function renderBoard(ticketCounts) {
|
|||
}
|
||||
|
||||
function renderEventMonitor(frame, events) {
|
||||
const lines = buildEventLines(events);
|
||||
const lines = buildEventLines(events).map(mapEventForDisplay);
|
||||
const g = el("g", { class: "monitor monitor-events" });
|
||||
g.appendChild(el("rect", { ...frame, rx: 8, class: "monitor-frame" }));
|
||||
g.appendChild(text("Events", { x: frame.x + 12, y: frame.y - 10, class: "wall-label" }));
|
||||
|
|
@ -205,8 +265,6 @@ function renderSystemMonitor(frame, systems) {
|
|||
tileGroup.appendChild(
|
||||
el("rect", { x: tileX, y: tileY, width: tileSize, height: tileSize, rx: 6, class: "system-tile-bg" })
|
||||
);
|
||||
// Einfaches "Server"-Icon: zwei Balken mit je einer Status-Leuchte (F6: ein Icon-Typ
|
||||
// fuer alle Systeme, unabhaengig von der Art).
|
||||
tileGroup.appendChild(
|
||||
el("rect", { x: tileX + 10, y: tileY + 10, width: tileSize - 20, height: 14, rx: 2, class: "system-tile-bar" })
|
||||
);
|
||||
|
|
@ -229,7 +287,17 @@ function renderSystemMonitor(frame, systems) {
|
|||
return g;
|
||||
}
|
||||
|
||||
function renderKpiMonitor(frame, placeholder) {
|
||||
function renderSparkline(points, width, height, x, y) {
|
||||
const g = el("g", { class: "kpi-sparkline", transform: `translate(${x}, ${y})` });
|
||||
g.appendChild(el("rect", { x: 0, y: 0, width, height, class: "kpi-sparkline-bg" }));
|
||||
if (points) {
|
||||
g.appendChild(el("polyline", { points, class: "kpi-sparkline-line" }));
|
||||
}
|
||||
return g;
|
||||
}
|
||||
|
||||
function renderKpiMonitor(frame, kpis) {
|
||||
const tiles = buildKpiTiles(kpis);
|
||||
const g = el("g", { class: "monitor monitor-kpi" });
|
||||
g.appendChild(el("rect", { ...frame, rx: 8, class: "monitor-frame" }));
|
||||
g.appendChild(text("KPIs", { x: frame.x + 12, y: frame.y - 10, class: "wall-label" }));
|
||||
|
|
@ -239,9 +307,32 @@ function renderKpiMonitor(frame, placeholder) {
|
|||
y: frame.y + 10,
|
||||
width: frame.w - 20,
|
||||
height: frame.h - 20,
|
||||
class: "monitor-screen monitor-screen-placeholder",
|
||||
class: `monitor-screen ${tiles.length === 0 ? "monitor-screen-placeholder" : ""}`,
|
||||
})
|
||||
);
|
||||
|
||||
if (tiles.length === 0) {
|
||||
renderPlaceholderText(g, frame, DEMO_KPI_PLACEHOLDER);
|
||||
return g;
|
||||
}
|
||||
|
||||
const rowH = (frame.h - 30) / tiles.length;
|
||||
tiles.forEach((tile, index) => {
|
||||
const rowY = frame.y + 20 + index * rowH;
|
||||
g.appendChild(
|
||||
text(`${tile.label}: ${tile.currentValue ?? "–"} ${tile.unit}`.trim(), {
|
||||
x: frame.x + 16,
|
||||
y: rowY + 14,
|
||||
class: "kpi-tile-label",
|
||||
})
|
||||
);
|
||||
g.appendChild(renderSparkline(tile.sparklinePoints, frame.w - 32, rowH - 26, frame.x + 16, rowY + 20));
|
||||
});
|
||||
|
||||
return g;
|
||||
}
|
||||
|
||||
function renderPlaceholderText(g, frame, placeholder) {
|
||||
const words = placeholder.split(" ");
|
||||
const lineHeight = 22;
|
||||
const maxCharsPerLine = 16;
|
||||
|
|
@ -269,11 +360,12 @@ function renderKpiMonitor(frame, placeholder) {
|
|||
})
|
||||
);
|
||||
});
|
||||
|
||||
return g;
|
||||
}
|
||||
|
||||
function renderMonitorWall(systems, events) {
|
||||
/**
|
||||
* @param {{onEventsClick: () => void, onSystemsClick: () => void, onKpiClick: () => void}} handlers
|
||||
*/
|
||||
function renderMonitorWall(systems, events, kpis, handlers) {
|
||||
const g = el("g", { class: "monitor-wall" });
|
||||
const gap = 20;
|
||||
const monitorWidth = (MONITOR_WALL.w - gap * 2) / 3;
|
||||
|
|
@ -284,21 +376,38 @@ function renderMonitorWall(systems, events) {
|
|||
h: MONITOR_WALL.h,
|
||||
}));
|
||||
|
||||
g.appendChild(renderEventMonitor(frames[0], events));
|
||||
g.appendChild(renderSystemMonitor(frames[1], systems));
|
||||
g.appendChild(renderKpiMonitor(frames[2], DEMO_KPI_PLACEHOLDER));
|
||||
const eventsGroup = renderEventMonitor(frames[0], events);
|
||||
eventsGroup.classList.add("clickable");
|
||||
eventsGroup.addEventListener("click", handlers.onEventsClick);
|
||||
|
||||
const systemsGroup = renderSystemMonitor(frames[1], systems);
|
||||
systemsGroup.classList.add("clickable");
|
||||
systemsGroup.addEventListener("click", handlers.onSystemsClick);
|
||||
|
||||
const kpiGroup = renderKpiMonitor(frames[2], kpis);
|
||||
kpiGroup.classList.add("clickable");
|
||||
kpiGroup.addEventListener("click", handlers.onKpiClick);
|
||||
|
||||
g.appendChild(eventsGroup);
|
||||
g.appendChild(systemsGroup);
|
||||
g.appendChild(kpiGroup);
|
||||
|
||||
return g;
|
||||
}
|
||||
|
||||
function personPose(pose) {
|
||||
// Rein statische Pose-Auswahl (kein Animationszustand) - Zustandsmaschine/Animation
|
||||
// folgt erst in Phase 5.
|
||||
function deskSeat(deskIndex) {
|
||||
const col = deskIndex % DESK_COL_X.length;
|
||||
const row = Math.floor(deskIndex / DESK_COL_X.length);
|
||||
const x = DESK_COL_X[col];
|
||||
const y = DESK_ROW_Y[row];
|
||||
return {
|
||||
origin: { x, y },
|
||||
feet: { x: x + DESK.w / 2, y: y + 40 + DESK.h + 30 },
|
||||
};
|
||||
}
|
||||
|
||||
function deskScreenPoseClass(pose) {
|
||||
switch (pose) {
|
||||
case "reading_newspaper":
|
||||
return "pose-newspaper";
|
||||
case "coffee":
|
||||
return "pose-coffee";
|
||||
case "working":
|
||||
return "pose-working";
|
||||
default:
|
||||
|
|
@ -306,26 +415,32 @@ function personPose(pose) {
|
|||
}
|
||||
}
|
||||
|
||||
// Zeichnet nur die Moebel/Deko + den Namen des Schreibtisch-Besitzers - die eigentliche
|
||||
// Person ist ein eigenstaendiger, beweglicher Avatar (siehe renderAgentAvatar/
|
||||
// updateAgentsLayer), damit sie zum Board/Monitor "laufen" kann, ohne den Schreibtisch neu
|
||||
// zeichnen zu muessen.
|
||||
function renderDesk({ deskIndex, agent }) {
|
||||
const col = deskIndex % DESK_COL_X.length;
|
||||
const row = Math.floor(deskIndex / DESK_COL_X.length);
|
||||
const x = DESK_COL_X[col];
|
||||
const y = DESK_ROW_Y[row];
|
||||
|
||||
const { origin, feet } = deskSeat(deskIndex);
|
||||
const role = agent ? ROLES[agent.role] : null;
|
||||
const g = el("g", {
|
||||
class: `desk ${agent ? "desk-occupied" : "desk-empty"}`,
|
||||
transform: `translate(${x}, ${y})`,
|
||||
transform: `translate(${origin.x}, ${origin.y})`,
|
||||
});
|
||||
|
||||
g.appendChild(el("rect", { x: 0, y: 40, width: DESK.w, height: DESK.h, rx: 6, class: "desk-table" }));
|
||||
g.appendChild(
|
||||
el("rect", { x: 20, y: 10, width: 60, height: 40, rx: 4, class: `desk-screen ${agent ? personPose(agent.pose) : ""}` })
|
||||
el("rect", {
|
||||
x: 20,
|
||||
y: 10,
|
||||
width: 60,
|
||||
height: 40,
|
||||
rx: 4,
|
||||
class: `desk-screen ${agent ? deskScreenPoseClass(agent.pose) : ""}`,
|
||||
})
|
||||
);
|
||||
g.appendChild(el("circle", { cx: DESK.w / 2, cy: 40 + DESK.h + 30, r: 22, class: "desk-chair" }));
|
||||
g.appendChild(el("circle", { cx: DESK.w / 2, cy: feet.y - origin.y, r: 22, class: "desk-chair" }));
|
||||
|
||||
if (role) {
|
||||
// Deko-Icon je Rolle (F1): Stern (Entwickler:in), Bluete (Product Owner:in), Haken (QA).
|
||||
if (role.deco === "star") {
|
||||
g.appendChild(
|
||||
el("path", {
|
||||
|
|
@ -351,20 +466,6 @@ function renderDesk({ deskIndex, agent }) {
|
|||
);
|
||||
}
|
||||
|
||||
g.appendChild(
|
||||
el("circle", { cx: DESK.w / 2, cy: 40 + DESK.h + 30 - 34, r: 16, class: "person-head" })
|
||||
);
|
||||
g.appendChild(
|
||||
el("rect", {
|
||||
x: DESK.w / 2 - 22,
|
||||
y: 40 + DESK.h + 30 - 20,
|
||||
width: 44,
|
||||
height: 34,
|
||||
rx: 12,
|
||||
class: "person-body",
|
||||
style: `--role-color: ${role.accentColor}`,
|
||||
})
|
||||
);
|
||||
g.appendChild(
|
||||
text(agent.name, {
|
||||
x: DESK.w / 2,
|
||||
|
|
@ -387,38 +488,464 @@ function renderDesk({ deskIndex, agent }) {
|
|||
return g;
|
||||
}
|
||||
|
||||
function renderDesks(agents) {
|
||||
function renderDesks(desks) {
|
||||
const g = el("g", { class: "desks" });
|
||||
buildDeskLayout(agents).forEach((desk) => g.appendChild(renderDesk(desk)));
|
||||
desks.forEach((desk) => g.appendChild(renderDesk(desk)));
|
||||
return g;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// Phase 5 - Agenten-Avatare (bewegliche Schicht, siehe Architektur-Hinweis oben)
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
||||
const agentAvatarElements = new Map();
|
||||
const idleCosmeticTimers = new Map();
|
||||
|
||||
function feetForLocation(location, homeFeet) {
|
||||
switch (location) {
|
||||
case AGENT_LOCATIONS.BOARD:
|
||||
return BOARD_FEET;
|
||||
case AGENT_LOCATIONS.MONITOR_EVENTS:
|
||||
case AGENT_LOCATIONS.MONITOR_SYSTEMS:
|
||||
case AGENT_LOCATIONS.MONITOR_KPI:
|
||||
return MONITOR_FEET;
|
||||
case AGENT_LOCATIONS.COFFEE_CORNER:
|
||||
return COFFEE_FEET;
|
||||
case AGENT_LOCATIONS.DESK:
|
||||
default:
|
||||
return homeFeet;
|
||||
}
|
||||
}
|
||||
|
||||
function poseExtras(pose) {
|
||||
const nodes = [];
|
||||
if (pose === "walking") {
|
||||
nodes.push(el("rect", { x: -10, y: 14, width: 8, height: 18, rx: 3, class: "person-leg person-leg-left" }));
|
||||
nodes.push(el("rect", { x: 2, y: 14, width: 8, height: 18, rx: 3, class: "person-leg person-leg-right" }));
|
||||
} else if (pose === "coffee") {
|
||||
nodes.push(el("rect", { x: 16, y: -30, width: 10, height: 10, rx: 2, class: "person-prop-cup" }));
|
||||
} else if (pose === "reading_newspaper") {
|
||||
nodes.push(el("rect", { x: -16, y: -48, width: 32, height: 18, rx: 1, class: "person-prop-newspaper" }));
|
||||
} else if (pose === "talking") {
|
||||
nodes.push(el("circle", { cx: 20, cy: -40, r: 7, class: "person-prop-speech" }));
|
||||
}
|
||||
return nodes;
|
||||
}
|
||||
|
||||
function createAgentAvatarElement() {
|
||||
const g = el("g", { class: "agent-avatar" });
|
||||
g.appendChild(el("circle", { cx: 0, cy: -34, r: 16, class: "person-head" }));
|
||||
g.appendChild(el("rect", { x: -22, y: -20, width: 44, height: 34, rx: 12, class: "person-body" }));
|
||||
g.appendChild(el("g", { class: "person-extras" }));
|
||||
g.appendChild(text("", { x: 0, y: 46, class: "avatar-name", "text-anchor": "middle" }));
|
||||
return g;
|
||||
}
|
||||
|
||||
function updateAgentAvatarElement(g, agent, feet, showName) {
|
||||
g.setAttribute("transform", `translate(${feet.x}, ${feet.y})`);
|
||||
g.setAttribute("class", `agent-avatar pose-${agent.pose}`);
|
||||
g.dataset.state = agent.state || "idle";
|
||||
|
||||
const role = ROLES[agent.role];
|
||||
const body = g.querySelector(".person-body");
|
||||
body.style.setProperty("--role-color", role ? role.accentColor : "#94a3b8");
|
||||
|
||||
const extras = g.querySelector(".person-extras");
|
||||
extras.replaceChildren(...poseExtras(agent.pose));
|
||||
|
||||
const nameEl = g.querySelector(".avatar-name");
|
||||
nameEl.textContent = showName ? agent.name : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktualisiert die Agenten-Avatar-Schicht per DOM-Diff (statt Neuzeichnen), damit CSS
|
||||
* transition das transform-Attribut zwischen zwei Aufenthaltsorten weich animiert
|
||||
* (Detailantworten F9: "Laufwege und Zustandswechsel realistisch animieren").
|
||||
*
|
||||
* @param {SVGGElement} layer
|
||||
* @param {Array<{agent: object, homeFeet: {x:number,y:number}}>} placements
|
||||
*/
|
||||
function updateAgentsLayer(layer, placements) {
|
||||
const seen = new Set();
|
||||
|
||||
placements.forEach(({ agent, homeFeet }, index) => {
|
||||
const id = String(agent.id ?? `guest-${index}`);
|
||||
seen.add(id);
|
||||
|
||||
let node = agentAvatarElements.get(id);
|
||||
if (!node) {
|
||||
node = createAgentAvatarElement();
|
||||
agentAvatarElements.set(id, node);
|
||||
layer.appendChild(node);
|
||||
}
|
||||
|
||||
const feet = feetForLocation(agent.location, homeFeet);
|
||||
const showName = agent.location !== AGENT_LOCATIONS.DESK;
|
||||
updateAgentAvatarElement(node, agent, feet, showName);
|
||||
});
|
||||
|
||||
for (const [id, node] of agentAvatarElements) {
|
||||
if (!seen.has(id)) {
|
||||
node.remove();
|
||||
agentAvatarElements.delete(id);
|
||||
clearIdleCosmeticTimer(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function clearIdleCosmeticTimer(agentId) {
|
||||
const handle = idleCosmeticTimers.get(agentId);
|
||||
if (handle) {
|
||||
window.clearTimeout(handle.timeoutId);
|
||||
idleCosmeticTimers.delete(agentId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rein kosmetische, zufaellige idle-Aktivitaeten (F8: "Nur idle Aktivitaeten ... steuert das
|
||||
* System selber random" - wird bewusst NICHT an die API gemeldet, ein echter Zustandswechsel
|
||||
* kommt ausschliesslich vom Agenten selbst). Greift nur, solange die API weiterhin "idle"
|
||||
* meldet; sobald ein echter Zustand reinkommt, wird der Timer verworfen (siehe
|
||||
* scheduleIdleCosmeticsForCurrentAgents()).
|
||||
*/
|
||||
function scheduleIdleCosmetic(agentId, node) {
|
||||
if (idleCosmeticTimers.has(agentId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const delay = IDLE_MIN_DELAY_MS + Math.random() * (IDLE_MAX_DELAY_MS - IDLE_MIN_DELAY_MS);
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
if (!agentAvatarElements.has(agentId) || node.dataset.state !== "idle") {
|
||||
idleCosmeticTimers.delete(agentId);
|
||||
return;
|
||||
}
|
||||
|
||||
const activity = pickIdleActivity();
|
||||
const visual = resolveAgentVisualState(activity);
|
||||
const homeFeet = node.__homeFeet;
|
||||
node.setAttribute("transform", `translate(${feetForLocation(visual.location, homeFeet).x}, ${feetForLocation(visual.location, homeFeet).y})`);
|
||||
node.setAttribute("class", `agent-avatar pose-${visual.pose}`);
|
||||
node.querySelector(".person-extras").replaceChildren(...poseExtras(visual.pose));
|
||||
node.querySelector(".avatar-name").textContent = node.__agentName || "";
|
||||
|
||||
window.setTimeout(() => {
|
||||
if (agentAvatarElements.has(agentId) && node.dataset.state === "idle") {
|
||||
const restingPose = node.__idlePose || "idle";
|
||||
node.setAttribute("transform", `translate(${homeFeet.x}, ${homeFeet.y})`);
|
||||
node.setAttribute("class", `agent-avatar pose-${restingPose}`);
|
||||
node.querySelector(".person-extras").replaceChildren(...poseExtras(restingPose));
|
||||
node.querySelector(".avatar-name").textContent = "";
|
||||
}
|
||||
idleCosmeticTimers.delete(agentId);
|
||||
}, IDLE_ACTIVITY_DURATION_MS);
|
||||
}, delay);
|
||||
|
||||
idleCosmeticTimers.set(agentId, { timeoutId });
|
||||
}
|
||||
|
||||
function scheduleIdleCosmeticsForCurrentAgents(placements) {
|
||||
placements.forEach(({ agent, homeFeet }, index) => {
|
||||
const id = String(agent.id ?? `guest-${index}`);
|
||||
const node = agentAvatarElements.get(id);
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
node.__homeFeet = homeFeet;
|
||||
node.__agentName = agent.name;
|
||||
node.__idlePose = agent.pose || "idle";
|
||||
|
||||
if (agent.state === "idle" || !agent.state) {
|
||||
scheduleIdleCosmetic(id, node);
|
||||
} else {
|
||||
clearIdleCosmeticTimer(id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// Modal-Inhalte (Phase 3/4 - Board-/Monitor-Klick, siehe Visualization.md 4.5)
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
||||
function renderTicketModalBody(body, content) {
|
||||
if (content.items.length === 0) {
|
||||
body.appendChild(emptyMessage("Keine Tickets in dieser Spalte."));
|
||||
return;
|
||||
}
|
||||
const list = document.createElement("ul");
|
||||
list.className = "office-modal-list";
|
||||
for (const item of content.items) {
|
||||
const li = document.createElement("li");
|
||||
li.className = "office-modal-item";
|
||||
|
||||
const heading = document.createElement("div");
|
||||
heading.className = "office-modal-item-heading";
|
||||
const titleEl = document.createElement("strong");
|
||||
titleEl.textContent = item.title;
|
||||
heading.appendChild(titleEl);
|
||||
if (item.type) {
|
||||
const badge = document.createElement("span");
|
||||
badge.className = "office-modal-badge";
|
||||
badge.textContent = item.type;
|
||||
if (item.color) badge.style.backgroundColor = item.color;
|
||||
heading.appendChild(badge);
|
||||
}
|
||||
li.appendChild(heading);
|
||||
|
||||
if (item.description) {
|
||||
const desc = document.createElement("p");
|
||||
desc.textContent = item.description;
|
||||
li.appendChild(desc);
|
||||
}
|
||||
if (item.link) {
|
||||
const a = document.createElement("a");
|
||||
a.href = item.link;
|
||||
a.target = "_blank";
|
||||
a.rel = "noopener noreferrer";
|
||||
a.textContent = "Auf Codeberg öffnen";
|
||||
li.appendChild(a);
|
||||
}
|
||||
list.appendChild(li);
|
||||
}
|
||||
body.appendChild(list);
|
||||
}
|
||||
|
||||
function renderEventModalBody(body, content) {
|
||||
if (content.items.length === 0) {
|
||||
body.appendChild(emptyMessage("Keine Events vorhanden."));
|
||||
return;
|
||||
}
|
||||
const list = document.createElement("ul");
|
||||
list.className = "office-modal-list";
|
||||
for (const item of content.items) {
|
||||
const li = document.createElement("li");
|
||||
li.className = `office-modal-item severity-${item.severity}`;
|
||||
li.textContent = `${item.time} · ${item.name}${item.systemId ? ` (${item.systemId})` : ""}: ${item.message}`;
|
||||
list.appendChild(li);
|
||||
}
|
||||
body.appendChild(list);
|
||||
}
|
||||
|
||||
function renderSystemModalBody(body, content) {
|
||||
if (content.items.length === 0) {
|
||||
body.appendChild(emptyMessage("Keine Systeme gemeldet."));
|
||||
return;
|
||||
}
|
||||
const list = document.createElement("ul");
|
||||
list.className = "office-modal-list";
|
||||
for (const item of content.items) {
|
||||
const li = document.createElement("li");
|
||||
li.className = `office-modal-item status-${item.status}`;
|
||||
li.textContent = `${item.name} (${item.type}) – ${item.status === "ok" ? "OK" : "Fehler"}${item.detail ? `: ${item.detail}` : ""}`;
|
||||
list.appendChild(li);
|
||||
}
|
||||
body.appendChild(list);
|
||||
}
|
||||
|
||||
function renderKpiModalBody(body, content) {
|
||||
if (content.items.length === 0) {
|
||||
body.appendChild(emptyMessage("Noch keine KPIs gemeldet – kommt in einer späteren Phase."));
|
||||
return;
|
||||
}
|
||||
const list = document.createElement("ul");
|
||||
list.className = "office-modal-list";
|
||||
for (const item of content.items) {
|
||||
const li = document.createElement("li");
|
||||
li.className = "office-modal-item";
|
||||
const label = document.createElement("div");
|
||||
label.textContent = `${item.label}: ${item.currentValue ?? "–"} ${item.unit}`;
|
||||
li.appendChild(label);
|
||||
|
||||
const svg = document.createElementNS(SVG_NS, "svg");
|
||||
svg.setAttribute("viewBox", "0 0 320 80");
|
||||
svg.setAttribute("class", "kpi-modal-sparkline");
|
||||
svg.appendChild(el("polyline", { points: item.sparklinePoints, class: "kpi-sparkline-line" }));
|
||||
li.appendChild(svg);
|
||||
|
||||
list.appendChild(li);
|
||||
}
|
||||
body.appendChild(list);
|
||||
}
|
||||
|
||||
function emptyMessage(message) {
|
||||
const p = document.createElement("p");
|
||||
p.className = "office-modal-empty";
|
||||
p.textContent = message;
|
||||
return p;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// Datenbeschaffung + Orchestrierung
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
||||
async function loadLiveData() {
|
||||
const [tickets, events, systems, kpis, agents] = await Promise.all([
|
||||
fetchTickets(),
|
||||
fetchEvents(),
|
||||
fetchSystems(),
|
||||
fetchKpis(),
|
||||
fetchAgents(),
|
||||
]);
|
||||
return { tickets, events, systems, kpis, agents, isLive: true };
|
||||
}
|
||||
|
||||
const animationQueue = new AnimationQueue({ minIntervalMs: 120 });
|
||||
window.setInterval(() => animationQueue.processDue(), 50);
|
||||
|
||||
function flash(node) {
|
||||
animationQueue.enqueue(() => {
|
||||
if (!node.isConnected) return;
|
||||
node.classList.add("just-updated");
|
||||
window.setTimeout(() => node.classList.remove("just-updated"), 900);
|
||||
});
|
||||
}
|
||||
|
||||
export function renderOfficeScene(container, data = {}) {
|
||||
const {
|
||||
ticketCounts = DEMO_TICKET_COUNTS,
|
||||
agents = DEMO_AGENTS,
|
||||
systems = DEMO_SYSTEMS,
|
||||
events = DEMO_EVENTS,
|
||||
kpis = [],
|
||||
tickets = [],
|
||||
} = data;
|
||||
|
||||
const svg = el("svg", {
|
||||
let svg = container.querySelector(".office-svg");
|
||||
let firstRender = false;
|
||||
|
||||
if (!svg) {
|
||||
firstRender = true;
|
||||
svg = el("svg", {
|
||||
viewBox: `0 0 ${VIEW_W} ${VIEW_H}`,
|
||||
class: "office-svg",
|
||||
role: "img",
|
||||
"aria-label": "Buero-Visualisierung mit Kanban-Board, Monitor-Wand und Schreibtischen",
|
||||
});
|
||||
|
||||
svg.appendChild(renderRoom());
|
||||
svg.appendChild(renderBoard(ticketCounts));
|
||||
svg.appendChild(renderMonitorWall(systems, events));
|
||||
svg.appendChild(renderDesks(agents));
|
||||
|
||||
svg.appendChild(el("g", { class: "agents-layer" }));
|
||||
container.replaceChildren(svg);
|
||||
agentAvatarElements.clear();
|
||||
idleCosmeticTimers.forEach((handle) => window.clearTimeout(handle.timeoutId));
|
||||
idleCosmeticTimers.clear();
|
||||
}
|
||||
|
||||
const groupedTickets = groupTicketsByColumn(tickets);
|
||||
|
||||
const boardGroup = renderBoard(ticketCounts, (columnKey) => {
|
||||
openModal(
|
||||
buildTicketModalContent(columnKey, groupedTickets[columnKey] || []),
|
||||
renderTicketModalBody
|
||||
);
|
||||
});
|
||||
const existingBoard = svg.querySelector(".board");
|
||||
if (existingBoard) {
|
||||
existingBoard.replaceWith(boardGroup);
|
||||
if (!firstRender) flash(boardGroup);
|
||||
} else {
|
||||
svg.insertBefore(boardGroup, svg.querySelector(".agents-layer"));
|
||||
}
|
||||
|
||||
const wallGroup = renderMonitorWall(systems, events, kpis, {
|
||||
onEventsClick: () => openModal(buildEventModalContent(events), renderEventModalBody),
|
||||
onSystemsClick: () => openModal(buildSystemModalContent(systems), renderSystemModalBody),
|
||||
onKpiClick: () => openModal(buildKpiModalContent(kpis), renderKpiModalBody),
|
||||
});
|
||||
const existingWall = svg.querySelector(".monitor-wall");
|
||||
if (existingWall) {
|
||||
existingWall.replaceWith(wallGroup);
|
||||
if (!firstRender) flash(wallGroup);
|
||||
} else {
|
||||
svg.insertBefore(wallGroup, svg.querySelector(".agents-layer"));
|
||||
}
|
||||
|
||||
const deskLayout = buildDeskLayout(normalizedDeskAgents(agents));
|
||||
const desksGroup = renderDesks(deskLayout);
|
||||
const existingDesks = svg.querySelector(".desks");
|
||||
if (existingDesks) {
|
||||
existingDesks.replaceWith(desksGroup);
|
||||
} else {
|
||||
svg.insertBefore(desksGroup, svg.querySelector(".agents-layer"));
|
||||
}
|
||||
|
||||
const placements = agentPlacements(agents, deskLayout);
|
||||
const agentsLayer = svg.querySelector(".agents-layer");
|
||||
updateAgentsLayer(agentsLayer, placements);
|
||||
scheduleIdleCosmeticsForCurrentAgents(placements);
|
||||
}
|
||||
|
||||
// Demo-Agenten (Phase 2) tragen bereits {name, role, pose} ohne desk_index/state - diese
|
||||
// Helfer machen buildDeskLayout()/renderAgentAvatar() fuer beide Formen (Demo- und
|
||||
// Live-API-Agenten) nutzbar, ohne die Demo-Daten anfassen zu muessen.
|
||||
function normalizedDeskAgents(agents) {
|
||||
const hasDeskIndex = agents.some((a) => a && typeof a.desk_index !== "undefined");
|
||||
if (!hasDeskIndex) {
|
||||
return agents;
|
||||
}
|
||||
return mapAgentsToDesks(agents).map((agent) => (agent ? normalizeAgentForDisplay(agent) : null));
|
||||
}
|
||||
|
||||
function agentPlacements(agents, deskLayout) {
|
||||
const hasDeskIndex = agents.some((a) => a && typeof a.desk_index !== "undefined");
|
||||
|
||||
if (!hasDeskIndex) {
|
||||
// Demo-Daten: Positionsindex in der Liste == Schreibtisch-Index (Phase 2-Verhalten).
|
||||
return deskLayout
|
||||
.filter((d) => d.agent)
|
||||
.map((d) => ({ agent: { ...d.agent, location: AGENT_LOCATIONS.DESK, state: "idle" }, homeFeet: deskSeat(d.deskIndex).feet }));
|
||||
}
|
||||
|
||||
const placements = [];
|
||||
const seatedIds = new Set();
|
||||
deskLayout.forEach((desk) => {
|
||||
if (desk.agent) {
|
||||
seatedIds.add(desk.agent.id);
|
||||
placements.push({ agent: desk.agent, homeFeet: deskSeat(desk.deskIndex).feet });
|
||||
}
|
||||
});
|
||||
|
||||
// Agenten ohne Schreibtisch (Rolle "user" oder alle Plaetze belegt, F2) bekommen einen
|
||||
// Ruheplatz vor der linken Tuer zugewiesen, gestaffelt nebeneinander.
|
||||
let overflowIndex = 0;
|
||||
for (const raw of agents) {
|
||||
if (seatedIds.has(raw.id)) continue;
|
||||
const agent = normalizeAgentForDisplay(raw);
|
||||
const homeFeet = {
|
||||
x: 70 + (overflowIndex % 3) * 26,
|
||||
y: DOOR.y + 40 + Math.floor(overflowIndex / 3) * 40,
|
||||
};
|
||||
placements.push({ agent, homeFeet });
|
||||
overflowIndex += 1;
|
||||
}
|
||||
|
||||
return placements;
|
||||
}
|
||||
|
||||
async function boot() {
|
||||
const container = document.getElementById("office-scene");
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
const data = await loadLiveData();
|
||||
renderOfficeScene(container, {
|
||||
ticketCounts: aggregateTicketsByColumn(data.tickets),
|
||||
tickets: data.tickets,
|
||||
agents: data.agents,
|
||||
systems: data.systems,
|
||||
events: data.events,
|
||||
kpis: data.kpis,
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn("[office] Live-API nicht erreichbar, zeige Demo-Daten.", err);
|
||||
renderOfficeScene(container);
|
||||
}
|
||||
}
|
||||
|
||||
await refresh();
|
||||
startRealtimeUpdates(refresh);
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const container = document.getElementById("office-scene");
|
||||
if (container) {
|
||||
renderOfficeScene(container);
|
||||
}
|
||||
boot();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -103,3 +103,347 @@ export function buildSystemTiles(systems = []) {
|
|||
export function buildEventLines(events = []) {
|
||||
return events.slice(0, MAX_EVENT_LINES);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// Phase 3 - Kanban-Board live (siehe Visualization.md Abschnitt 9, API.md "Tickets")
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Aggregiert eine rohe Ticketliste (GET /api/v1/tickets) zu Zaehlern je Spalte, wie sie
|
||||
* buildBoardColumnLayout() erwartet. Unbekannte Spalten-Keys werden ignoriert (defensiv
|
||||
* gegen zukuenftige/kaputte Daten).
|
||||
*
|
||||
* @param {Array<{column_name: string}>} tickets
|
||||
* @returns {Record<string, number>}
|
||||
*/
|
||||
export function aggregateTicketsByColumn(tickets = []) {
|
||||
const counts = Object.fromEntries(BOARD_COLUMNS.map(({ key }) => [key, 0]));
|
||||
for (const ticket of tickets) {
|
||||
if (ticket.column_name in counts) {
|
||||
counts[ticket.column_name] += 1;
|
||||
}
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gruppiert eine rohe Ticketliste je Spalte. GET /api/v1/tickets liefert bereits pro Spalte
|
||||
* nach Alter aufsteigend sortiert (siehe TicketRepository::all()), die Reihenfolge wird hier
|
||||
* beibehalten.
|
||||
*
|
||||
* @param {Array<{column_name: string}>} tickets
|
||||
* @returns {Record<string, Array<object>>}
|
||||
*/
|
||||
export function groupTicketsByColumn(tickets = []) {
|
||||
const groups = Object.fromEntries(BOARD_COLUMNS.map(({ key }) => [key, []]));
|
||||
for (const ticket of tickets) {
|
||||
if (ticket.column_name in groups) {
|
||||
groups[ticket.column_name].push(ticket);
|
||||
}
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
/**
|
||||
* Baut das Inhaltsmodell fuer das Board-Spalten-Modal (Visualization.md 4.2/4.5): echter
|
||||
* Titel/Beschreibung je Ticket, plus Link auf die Codeberg-Quelle sofern das Ticket per
|
||||
* Codeberg-Sync angelegt wurde (F4).
|
||||
*
|
||||
* @param {string} columnKey
|
||||
* @param {Array<object>} tickets
|
||||
* @param {{codebergOwner?: string, codebergRepo?: string}} [opts]
|
||||
*/
|
||||
export function buildTicketModalContent(columnKey, tickets = [], opts = {}) {
|
||||
const { codebergOwner = "fds2610", codebergRepo = "monitor" } = opts;
|
||||
const column = BOARD_COLUMNS.find((c) => c.key === columnKey);
|
||||
|
||||
return {
|
||||
title: column ? column.label : columnKey,
|
||||
items: tickets.map((ticket) => ({
|
||||
id: ticket.id,
|
||||
title: ticket.title,
|
||||
description: ticket.description || "",
|
||||
type: ticket.type || null,
|
||||
color: ticket.color || null,
|
||||
link:
|
||||
ticket.source === "codeberg" && ticket.external_ref
|
||||
? `https://codeberg.org/${codebergOwner}/${codebergRepo}/issues/${ticket.external_ref}`
|
||||
: null,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// Phase 4 - Monitor-Wand live (Events/Systeme/KPIs, siehe API.md)
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Formatiert einen ISO-8601-Zeitstempel als lokale Uhrzeit (F16: keine eigene
|
||||
* Zeitzonen-Auswahl, es gilt schlicht die Zeitzone des Browsers/Servers, in der Praxis
|
||||
* CEST/CET).
|
||||
*
|
||||
* @param {string} isoString
|
||||
* @returns {string}
|
||||
*/
|
||||
export function formatEventTime(isoString) {
|
||||
const date = new Date(isoString);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return "--:--";
|
||||
}
|
||||
return date.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
|
||||
/**
|
||||
* Uebersetzt ein rohes Event (GET /api/v1/events) in die kompakte Zeilendarstellung der
|
||||
* Monitor-Wand ({time, system, message, severity}, wie sie renderEventMonitor erwartet).
|
||||
*
|
||||
* @param {{occurred_at: string, name: string, message: string, severity: string}} event
|
||||
*/
|
||||
export function mapEventForDisplay(event) {
|
||||
return {
|
||||
time: formatEventTime(event.occurred_at),
|
||||
system: event.name,
|
||||
message: event.message,
|
||||
severity: event.severity,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Inhaltsmodell fuer das Event-Monitor-Modal: volle Liste (kein MAX_EVENT_LINES-Limit).
|
||||
*
|
||||
* @param {Array<object>} events
|
||||
*/
|
||||
export function buildEventModalContent(events = []) {
|
||||
return {
|
||||
title: "Events",
|
||||
items: events.map((event) => ({
|
||||
time: formatEventTime(event.occurred_at),
|
||||
name: event.name,
|
||||
message: event.message,
|
||||
severity: event.severity,
|
||||
systemId: event.system_id ?? null,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Inhaltsmodell fuer das System-Monitor-Modal.
|
||||
*
|
||||
* @param {Array<object>} systems
|
||||
*/
|
||||
export function buildSystemModalContent(systems = []) {
|
||||
return {
|
||||
title: "Systeme",
|
||||
items: systems.map((system) => ({
|
||||
id: system.id,
|
||||
name: system.name,
|
||||
status: system.status,
|
||||
type: system.type || "host",
|
||||
detail: system.detail || "",
|
||||
lastCheckAt: system.last_check_at,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Erzeugt die Punkte-Liste (SVG <polyline points="...">) fuer eine einfache
|
||||
* KPI-Sparkline - bewusst ohne Chart-Lib gezeichnet (F10). Die API liefert die Historie
|
||||
* absteigend (neuester Wert zuerst, siehe KpiRepository::history()), fuer die Sparkline
|
||||
* wird chronologisch (aeltester zuerst) von links nach rechts gezeichnet.
|
||||
*
|
||||
* @param {Array<{value: number}>} history
|
||||
* @param {number} [width]
|
||||
* @param {number} [height]
|
||||
* @param {number} [padding]
|
||||
* @returns {string}
|
||||
*/
|
||||
export function buildKpiSparklinePoints(history = [], width = 140, height = 32, padding = 4) {
|
||||
if (history.length === 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const chronological = [...history].reverse();
|
||||
const values = chronological.map((point) => point.value);
|
||||
const min = Math.min(...values);
|
||||
const max = Math.max(...values);
|
||||
const range = max - min || 1;
|
||||
const stepX = chronological.length > 1 ? (width - padding * 2) / (chronological.length - 1) : 0;
|
||||
|
||||
return chronological
|
||||
.map((point, index) => {
|
||||
const x = padding + index * stepX;
|
||||
const y = height - padding - ((point.value - min) / range) * (height - padding * 2);
|
||||
return `${x.toFixed(1)},${y.toFixed(1)}`;
|
||||
})
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
export const MAX_KPI_TILES = 3;
|
||||
|
||||
/**
|
||||
* Bereitet bis zu MAX_KPI_TILES KPI-Kacheln fuer die Monitor-Wand auf. F7 ist weiterhin
|
||||
* offen ("kommt spaeter") - ist noch kein KPI gemeldet, bleibt das Ergebnis leer und die
|
||||
* Wand zeigt weiterhin den Platzhaltertext (keine erfundenen Kennzahlen).
|
||||
*
|
||||
* @param {Array<object>} kpis
|
||||
*/
|
||||
export function buildKpiTiles(kpis = []) {
|
||||
return kpis.slice(0, MAX_KPI_TILES).map((kpi) => ({
|
||||
key: kpi.key,
|
||||
label: kpi.label || kpi.key,
|
||||
unit: kpi.unit || "",
|
||||
currentValue: kpi.current_value,
|
||||
sparklinePoints: buildKpiSparklinePoints(kpi.history || []),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Inhaltsmodell fuer das KPI-Monitor-Modal (groessere Sparkline als die Wandkachel).
|
||||
*
|
||||
* @param {Array<object>} kpis
|
||||
*/
|
||||
export function buildKpiModalContent(kpis = []) {
|
||||
return {
|
||||
title: "KPIs",
|
||||
items: kpis.map((kpi) => ({
|
||||
key: kpi.key,
|
||||
label: kpi.label || kpi.key,
|
||||
unit: kpi.unit || "",
|
||||
currentValue: kpi.current_value,
|
||||
history: kpi.history || [],
|
||||
sparklinePoints: buildKpiSparklinePoints(kpi.history || [], 320, 80),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// Phase 5 - Agenten-Zustandsmaschine & Animation (siehe Visualization.md Abschnitt 4.4)
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
||||
/** Orte, an denen ein Agent sichtbar sein kann. */
|
||||
export const AGENT_LOCATIONS = {
|
||||
DESK: "desk",
|
||||
BOARD: "board",
|
||||
MONITOR_EVENTS: "monitor-events",
|
||||
MONITOR_SYSTEMS: "monitor-systems",
|
||||
MONITOR_KPI: "monitor-kpi",
|
||||
COFFEE_CORNER: "coffee-corner",
|
||||
};
|
||||
|
||||
// Zustandsmaschine wie in Visualization.md 4.4 vorgeschlagen: jeder von der API gemeldete
|
||||
// "state" (POST /api/v1/agents/{id}/activity) wird auf einen Ort + eine Pose abgebildet.
|
||||
// Unbekannte/fehlende Zustaende fallen defensiv auf "idle am Schreibtisch" zurueck.
|
||||
const AGENT_STATE_VISUALS = {
|
||||
idle: { location: AGENT_LOCATIONS.DESK, pose: "idle" },
|
||||
working: { location: AGENT_LOCATIONS.DESK, pose: "working" },
|
||||
walking_to_board: { location: AGENT_LOCATIONS.BOARD, pose: "walking" },
|
||||
interacting_with_board: { location: AGENT_LOCATIONS.BOARD, pose: "interacting" },
|
||||
walking_to_monitor: { location: AGENT_LOCATIONS.MONITOR_EVENTS, pose: "walking" },
|
||||
interacting_with_monitor: { location: AGENT_LOCATIONS.MONITOR_EVENTS, pose: "interacting" },
|
||||
walking_back: { location: AGENT_LOCATIONS.DESK, pose: "walking" },
|
||||
reading_newspaper: { location: AGENT_LOCATIONS.DESK, pose: "reading_newspaper" },
|
||||
at_coffee_corner: { location: AGENT_LOCATIONS.COFFEE_CORNER, pose: "coffee" },
|
||||
talking_to_colleague: { location: AGENT_LOCATIONS.DESK, pose: "talking" },
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {string} state - Wert aus Agent.state (API).
|
||||
* @returns {{location: string, pose: string}}
|
||||
*/
|
||||
export function resolveAgentVisualState(state) {
|
||||
return AGENT_STATE_VISUALS[state] ?? AGENT_STATE_VISUALS.idle;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ordnet eine rohe Agentenliste (GET /api/v1/agents) den Schreibtischen zu - anders als
|
||||
* buildDeskLayout()/Demo-Daten steht der Sitzplatz hier nicht in der Array-Reihenfolge,
|
||||
* sondern im Feld desk_index (API.md "Agenten"). Agenten ohne Schreibtisch (role "user"
|
||||
* oder alle Schreibtische belegt, desk_index === null) erscheinen hier nicht - sie kommen
|
||||
* gemaess F2 nur anlassbezogen durch eine Tuer und haben keinen festen Platz.
|
||||
*
|
||||
* @param {Array<object>} agents
|
||||
* @param {number} [deskCount]
|
||||
* @returns {Array<object|null>}
|
||||
*/
|
||||
export function mapAgentsToDesks(agents = [], deskCount = DEFAULT_DESK_COUNT) {
|
||||
const desks = new Array(deskCount).fill(null);
|
||||
for (const agent of agents) {
|
||||
const deskIndex = agent.desk_index;
|
||||
if (typeof deskIndex === "number" && deskIndex >= 0 && deskIndex < deskCount) {
|
||||
desks[deskIndex] = agent;
|
||||
}
|
||||
}
|
||||
return desks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bereitet einen rohen API-Agenten fuer die Darstellung auf: Anzeigename + Pose/Ort aus
|
||||
* dem gemeldeten state ableiten (statt der Demo-only "pose").
|
||||
*
|
||||
* @param {object} agent
|
||||
*/
|
||||
export function normalizeAgentForDisplay(agent) {
|
||||
const visual = resolveAgentVisualState(agent.state);
|
||||
return {
|
||||
...agent,
|
||||
name: agent.display_name ?? agent.name,
|
||||
pose: visual.pose,
|
||||
location: visual.location,
|
||||
};
|
||||
}
|
||||
|
||||
const IDLE_ACTIVITIES = ["reading_newspaper", "at_coffee_corner", "talking_to_colleague"];
|
||||
|
||||
/**
|
||||
* Waehlt zufaellig eine idle-Aktivitaet (F8: "Nur idle Aktivitaeten wie Kaffeetrinken oder
|
||||
* Schwaetzchen halten steuert das System selber random" - rein kosmetisch, wird nicht an
|
||||
* die API gemeldet). Der Zufallsgenerator ist injizierbar, damit die Auswahl testbar ist.
|
||||
*
|
||||
* @param {() => number} [random]
|
||||
* @returns {string}
|
||||
*/
|
||||
export function pickIdleActivity(random = Math.random) {
|
||||
const index = Math.min(Math.floor(random() * IDLE_ACTIVITIES.length), IDLE_ACTIVITIES.length - 1);
|
||||
return IDLE_ACTIVITIES[index];
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialisiert Animations-Trigger, damit bei sehr vielen gleichzeitigen Ereignissen (F9:
|
||||
* "20 Tickets in einer Sekunde") nicht alle Animationen gleichzeitig starten, sondern in
|
||||
* einer Warteschlange mit Mindestabstand abgearbeitet werden. Reine Logik ohne DOM-Zugriff,
|
||||
* damit sie ohne Browser testbar ist - office-render.js haengt eine window.setInterval-
|
||||
* Schleife an processDue() an.
|
||||
*/
|
||||
export class AnimationQueue {
|
||||
/** @param {{minIntervalMs?: number, now?: () => number}} [opts] */
|
||||
constructor(opts = {}) {
|
||||
const { minIntervalMs = 150, now = () => Date.now() } = opts;
|
||||
this.minIntervalMs = minIntervalMs;
|
||||
this.now = now;
|
||||
this.queue = [];
|
||||
this.lastRunAt = -Infinity;
|
||||
}
|
||||
|
||||
/** @param {() => void} task */
|
||||
enqueue(task) {
|
||||
this.queue.push(task);
|
||||
}
|
||||
|
||||
get size() {
|
||||
return this.queue.length;
|
||||
}
|
||||
|
||||
/** Fuehrt so viele faellige Tasks wie moeglich aus, mindestens minIntervalMs auseinander. */
|
||||
processDue() {
|
||||
let ranCount = 0;
|
||||
let current = this.now();
|
||||
while (this.queue.length > 0 && current - this.lastRunAt >= this.minIntervalMs) {
|
||||
const task = this.queue.shift();
|
||||
task();
|
||||
ranCount += 1;
|
||||
this.lastRunAt = current;
|
||||
current = this.now();
|
||||
}
|
||||
return ranCount;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,14 +11,25 @@
|
|||
<header class="office-header">
|
||||
<h1>Buero</h1>
|
||||
<p>
|
||||
Statische Vorschau (Phase 2) mit Demo-Daten - Kanban-Board, Monitor-Wand und
|
||||
Schreibtische werden ab den naechsten Phasen mit echten Daten aus der
|
||||
<a href="/API.md">REST-API</a> gespeist und interaktiv (Klick auf Board/Monitore).
|
||||
Kanban-Board, Monitor-Wand und Schreibtische zeigen live die Daten aus der
|
||||
<a href="/API.md">REST-API</a> (Push-Updates per Server-Sent Events, Fallback:
|
||||
Polling). Klick auf das Board oder einen Monitor oeffnet die Detailansicht. Ist
|
||||
die API nicht erreichbar, zeigt die Szene ersatzweise Demo-Daten.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<main id="office-scene" aria-label="Buero-Szene"></main>
|
||||
|
||||
<script>
|
||||
// Docker-Compose-Dev-Setup (siehe docker-compose.yml): Frontend auf Port 8090, API auf
|
||||
// Port 8091 - unterschiedliche Origins, daher die explizite Basis-URL (die API liefert
|
||||
// dafuer CORS-Header, siehe api/src/Http/Response.php). Bei einem Produktions-Setup mit
|
||||
// gemeinsamem Origin/Reverse-Proxy kann dieser Block entfernt werden, Default ist dann
|
||||
// das gleiche Origin unter /api/v1 (siehe html/js/office-api.js).
|
||||
if (location.port === "8090") {
|
||||
window.MONITOR_API_BASE = `${location.protocol}//${location.hostname}:8091/api/v1`;
|
||||
}
|
||||
</script>
|
||||
<script type="module" src="js/office-render.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -9,6 +9,23 @@ import {
|
|||
MAX_SYSTEM_TILES,
|
||||
MAX_EVENT_LINES,
|
||||
DEFAULT_DESK_COUNT,
|
||||
aggregateTicketsByColumn,
|
||||
groupTicketsByColumn,
|
||||
buildTicketModalContent,
|
||||
formatEventTime,
|
||||
mapEventForDisplay,
|
||||
buildEventModalContent,
|
||||
buildSystemModalContent,
|
||||
buildKpiSparklinePoints,
|
||||
buildKpiTiles,
|
||||
buildKpiModalContent,
|
||||
MAX_KPI_TILES,
|
||||
AGENT_LOCATIONS,
|
||||
resolveAgentVisualState,
|
||||
mapAgentsToDesks,
|
||||
normalizeAgentForDisplay,
|
||||
pickIdleActivity,
|
||||
AnimationQueue,
|
||||
} from "../../html/js/office-scene.js";
|
||||
|
||||
test("buildBoardColumnLayout liefert alle Spalten in API.md-Reihenfolge", () => {
|
||||
|
|
@ -89,3 +106,207 @@ test("buildEventLines kappt bei MAX_EVENT_LINES und behaelt die Reihenfolge bei"
|
|||
assert.equal(lines.length, MAX_EVENT_LINES);
|
||||
assert.equal(lines[0].id, 0);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// Phase 3 - Kanban-Board live
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
||||
test("aggregateTicketsByColumn zaehlt Tickets je Spalte und ignoriert unbekannte Spalten", () => {
|
||||
const counts = aggregateTicketsByColumn([
|
||||
{ column_name: "backlog" },
|
||||
{ column_name: "backlog" },
|
||||
{ column_name: "qa" },
|
||||
{ column_name: "unbekannt" },
|
||||
]);
|
||||
assert.equal(counts.backlog, 2);
|
||||
assert.equal(counts.qa, 1);
|
||||
assert.equal(counts.done, 0);
|
||||
assert.equal(Object.keys(counts).length, BOARD_COLUMNS.length);
|
||||
});
|
||||
|
||||
test("groupTicketsByColumn gruppiert und behaelt die Reihenfolge je Spalte bei", () => {
|
||||
const groups = groupTicketsByColumn([
|
||||
{ id: 1, column_name: "backlog" },
|
||||
{ id: 2, column_name: "done" },
|
||||
{ id: 3, column_name: "backlog" },
|
||||
]);
|
||||
assert.deepEqual(
|
||||
groups.backlog.map((t) => t.id),
|
||||
[1, 3]
|
||||
);
|
||||
assert.deepEqual(
|
||||
groups.done.map((t) => t.id),
|
||||
[2]
|
||||
);
|
||||
assert.deepEqual(groups.qa, []);
|
||||
});
|
||||
|
||||
test("buildTicketModalContent traegt Titel/Spaltenlabel und baut Codeberg-Links nur fuer source=codeberg", () => {
|
||||
const content = buildTicketModalContent("bereit", [
|
||||
{ id: 1, title: "A", description: "", source: "codeberg", external_ref: "42" },
|
||||
{ id: 2, title: "B", description: "", source: "api", external_ref: null },
|
||||
]);
|
||||
assert.equal(content.title, "Bereit");
|
||||
assert.equal(content.items[0].link, "https://codeberg.org/fds2610/monitor/issues/42");
|
||||
assert.equal(content.items[1].link, null);
|
||||
});
|
||||
|
||||
test("buildTicketModalContent respektiert benutzerdefinierten owner/repo", () => {
|
||||
const content = buildTicketModalContent(
|
||||
"backlog",
|
||||
[{ id: 1, title: "A", source: "codeberg", external_ref: "7" }],
|
||||
{ codebergOwner: "acme", codebergRepo: "widgets" }
|
||||
);
|
||||
assert.equal(content.items[0].link, "https://codeberg.org/acme/widgets/issues/7");
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// Phase 4 - Monitor-Wand live
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
||||
test("formatEventTime liefert HH:MM und faengt ungueltige Zeitstempel ab", () => {
|
||||
assert.match(formatEventTime("2026-07-27T10:05:00Z"), /^\d{2}:\d{2}$/);
|
||||
assert.equal(formatEventTime("nicht-valide"), "--:--");
|
||||
});
|
||||
|
||||
test("mapEventForDisplay uebersetzt ein rohes Event in die Wand-Zeilendarstellung", () => {
|
||||
const line = mapEventForDisplay({
|
||||
occurred_at: "2026-07-27T10:05:00Z",
|
||||
name: "Ping",
|
||||
message: "Host down",
|
||||
severity: "error",
|
||||
});
|
||||
assert.equal(line.system, "Ping");
|
||||
assert.equal(line.message, "Host down");
|
||||
assert.equal(line.severity, "error");
|
||||
assert.match(line.time, /^\d{2}:\d{2}$/);
|
||||
});
|
||||
|
||||
test("buildEventModalContent zeigt die volle Liste (kein MAX_EVENT_LINES-Limit)", () => {
|
||||
const events = Array.from({ length: MAX_EVENT_LINES + 3 }, (_, i) => ({
|
||||
occurred_at: "2026-07-27T10:00:00Z",
|
||||
name: `Event${i}`,
|
||||
message: "m",
|
||||
severity: "warning",
|
||||
system_id: "srv1",
|
||||
}));
|
||||
const content = buildEventModalContent(events);
|
||||
assert.equal(content.items.length, events.length);
|
||||
assert.equal(content.items[0].systemId, "srv1");
|
||||
});
|
||||
|
||||
test("buildSystemModalContent uebernimmt Status/Detail je System", () => {
|
||||
const content = buildSystemModalContent([
|
||||
{ id: "srv1", name: "Webserver", status: "ok", type: "host", detail: null, last_check_at: "t" },
|
||||
]);
|
||||
assert.equal(content.items[0].status, "ok");
|
||||
assert.equal(content.items[0].detail, "");
|
||||
});
|
||||
|
||||
test("buildKpiSparklinePoints liefert leeren String ohne Historie und einen Punkt pro Messwert sonst", () => {
|
||||
assert.equal(buildKpiSparklinePoints([]), "");
|
||||
const points = buildKpiSparklinePoints([{ value: 3 }, { value: 1 }, { value: 2 }], 100, 20);
|
||||
assert.equal(points.split(" ").length, 3);
|
||||
});
|
||||
|
||||
test("buildKpiSparklinePoints behandelt konstante Werte ohne Division durch 0", () => {
|
||||
const points = buildKpiSparklinePoints([{ value: 5 }, { value: 5 }], 100, 20);
|
||||
assert.equal(points.split(" ").length, 2);
|
||||
assert.ok(points.length > 0);
|
||||
});
|
||||
|
||||
test("buildKpiTiles kappt bei MAX_KPI_TILES", () => {
|
||||
const kpis = Array.from({ length: MAX_KPI_TILES + 2 }, (_, i) => ({
|
||||
key: `k${i}`,
|
||||
current_value: i,
|
||||
history: [],
|
||||
}));
|
||||
const tiles = buildKpiTiles(kpis);
|
||||
assert.equal(tiles.length, MAX_KPI_TILES);
|
||||
});
|
||||
|
||||
test("buildKpiModalContent zeigt alle KPIs (kein Limit) inkl. Historie", () => {
|
||||
const content = buildKpiModalContent([
|
||||
{ key: "open_tickets", label: "Offene Tickets", unit: "count", current_value: 4, history: [{ value: 4 }] },
|
||||
]);
|
||||
assert.equal(content.items.length, 1);
|
||||
assert.equal(content.items[0].label, "Offene Tickets");
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// Phase 5 - Agenten-Zustandsmaschine & Animation
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
||||
test("resolveAgentVisualState bildet bekannte Zustaende auf Ort/Pose ab", () => {
|
||||
assert.deepEqual(resolveAgentVisualState("working"), { location: AGENT_LOCATIONS.DESK, pose: "working" });
|
||||
assert.deepEqual(resolveAgentVisualState("walking_to_board"), {
|
||||
location: AGENT_LOCATIONS.BOARD,
|
||||
pose: "walking",
|
||||
});
|
||||
assert.deepEqual(resolveAgentVisualState("at_coffee_corner"), {
|
||||
location: AGENT_LOCATIONS.COFFEE_CORNER,
|
||||
pose: "coffee",
|
||||
});
|
||||
});
|
||||
|
||||
test("resolveAgentVisualState faellt bei unbekanntem/fehlendem Zustand auf idle-am-Schreibtisch zurueck", () => {
|
||||
assert.deepEqual(resolveAgentVisualState("was-auch-immer"), { location: AGENT_LOCATIONS.DESK, pose: "idle" });
|
||||
assert.deepEqual(resolveAgentVisualState(undefined), { location: AGENT_LOCATIONS.DESK, pose: "idle" });
|
||||
});
|
||||
|
||||
test("mapAgentsToDesks platziert Agenten anhand von desk_index, nicht Array-Reihenfolge", () => {
|
||||
const desks = mapAgentsToDesks(
|
||||
[
|
||||
{ id: 1, desk_index: 2 },
|
||||
{ id: 2, desk_index: 0 },
|
||||
],
|
||||
4
|
||||
);
|
||||
assert.equal(desks[0].id, 2);
|
||||
assert.equal(desks[2].id, 1);
|
||||
assert.equal(desks[1], null);
|
||||
assert.equal(desks[3], null);
|
||||
});
|
||||
|
||||
test("mapAgentsToDesks ignoriert Agenten ohne Schreibtisch (desk_index null, Rolle user)", () => {
|
||||
const desks = mapAgentsToDesks([{ id: 1, desk_index: null, role: "user" }], 2);
|
||||
assert.ok(desks.every((d) => d === null));
|
||||
});
|
||||
|
||||
test("normalizeAgentForDisplay leitet name/pose/location aus display_name/state ab", () => {
|
||||
const normalized = normalizeAgentForDisplay({ id: 1, display_name: "Alice", state: "working" });
|
||||
assert.equal(normalized.name, "Alice");
|
||||
assert.equal(normalized.pose, "working");
|
||||
assert.equal(normalized.location, AGENT_LOCATIONS.DESK);
|
||||
});
|
||||
|
||||
test("pickIdleActivity nutzt den injizierten Zufallsgenerator deterministisch", () => {
|
||||
assert.equal(pickIdleActivity(() => 0), "reading_newspaper");
|
||||
assert.equal(pickIdleActivity(() => 0.999), "talking_to_colleague");
|
||||
});
|
||||
|
||||
test("AnimationQueue fuehrt Tasks mit Mindestabstand aus (Warteschlange bei Buerst-Events, F9)", () => {
|
||||
let now = 0;
|
||||
const queue = new AnimationQueue({ minIntervalMs: 100, now: () => now });
|
||||
const ran = [];
|
||||
queue.enqueue(() => ran.push("a"));
|
||||
queue.enqueue(() => ran.push("b"));
|
||||
queue.enqueue(() => ran.push("c"));
|
||||
|
||||
queue.processDue();
|
||||
assert.deepEqual(ran, ["a"]);
|
||||
assert.equal(queue.size, 2);
|
||||
|
||||
now = 50;
|
||||
queue.processDue();
|
||||
assert.deepEqual(ran, ["a"]);
|
||||
|
||||
now = 120;
|
||||
queue.processDue();
|
||||
assert.deepEqual(ran, ["a", "b"]);
|
||||
|
||||
now = 500;
|
||||
queue.processDue();
|
||||
assert.deepEqual(ran, ["a", "b", "c"]);
|
||||
assert.equal(queue.size, 0);
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue