Merge pull request 'Phase 2: Statische Buero-Szene (SVG, Demo-Daten)' (#7) from fds2610-bot/monitor:main into main
Reviewed-on: https://codeberg.org/fds2610/monitor/pulls/7 added minor corrections
This commit is contained in:
commit
954ef47302
11 changed files with 1024 additions and 12 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1,3 +1,4 @@
|
||||||
api/vendor/
|
api/vendor/
|
||||||
api/data/
|
api/data/
|
||||||
.env
|
.env
|
||||||
|
node_modules/
|
||||||
|
|
|
||||||
50
README.md
50
README.md
|
|
@ -1,24 +1,52 @@
|
||||||
# 🖥️ monitor
|
# 🖥️ monitor
|
||||||
|
|
||||||
**monitor** ist ein leichtgewichtiges Monitoring-Tool, das mehrere Systeme per **Ping** überwacht und die Ergebnisse in einer **statischen HTML-Webseite** anzeigt.
|
**monitor** überwacht mehrere Systeme (per Ping und/oder per REST-API-Meldungen entfernter
|
||||||
Ideal für kleine Netzwerke, Homelabs oder interne Statusseiten.
|
Agenten) und zeigt den Zustand als interaktive **Büro-Visualisierung** (Kanban-Board +
|
||||||
|
Monitor-Wand mit Agenten an Schreibtischen).
|
||||||
|
|
||||||
|
Der vollständige Umbauplan steht in [Visualization.md](Visualization.md) (Roadmap Phase 0-7),
|
||||||
|
die dazugehörigen Klärungen in [Detailfragen.md](Detailfragen.md) und
|
||||||
|
[Detailantworten.md](Detailantworten.md).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🚀 Features
|
## 🚀 Features
|
||||||
|
|
||||||
- Überwacht beliebig viele Hosts per **ICMP-Ping**
|
- REST-API (`api/`, siehe [API.md](API.md)) für Tickets, Events, Systeme, KPIs und
|
||||||
- Generiert automatisch eine **statische HTML-Statusseite**
|
Agenten-Aktivität, mit statischen API-Keys pro Agent
|
||||||
- Anzeige von:
|
- Büro-Visualisierung (`html/office.html`) als Vanilla-SVG-Szene: Kanban-Board, Monitor-Wand
|
||||||
- Antwortzeit (ms)
|
(Events/Systeme/KPIs), 8 Schreibtische - aktuell Phase 2 (statisches Layout mit
|
||||||
- Online-/Offline-Status
|
Demo-Daten, siehe Visualization.md Abschnitt 9)
|
||||||
- Letztem Check-Zeitpunkt
|
- Weiterhin: klassische Ping-Überwachung (`check_fds.sh`, `hosts.conf`, `mail_fds.py`) als
|
||||||
- Einfache Konfiguration über JSON oder YAML
|
eine Datenquelle für den System-Monitor; die alte Tabellenansicht (`html/mon.html`)
|
||||||
- Keine Datenbank, kein Serverprozess – alles rein statisch
|
bleibt bis zur Migration in einer späteren Phase bestehen (F16)
|
||||||
- Optional: automatischer Refresh der Webseite
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📁 Projektstruktur
|
## 📁 Projektstruktur
|
||||||
|
|
||||||
|
- `api/` — REST-API (PHP, SQLite), siehe [api/README.md](api/README.md)
|
||||||
|
- `html/` — Frontend: `office.html` (neue Büro-Visualisierung), `mon.html` (alte
|
||||||
|
Tabellenansicht), `js/`, `css/`
|
||||||
|
- `tests/frontend/` — Node-Tests für die Frontend-Layoutlogik (`node --test`)
|
||||||
|
- `check_fds.sh`, `mail_fds.py`, `hosts-sample.conf` — bestehende Ping-Überwachung
|
||||||
|
- `docker-compose.yml` — komplette Entwicklungsumgebung (siehe unten)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🐳 Entwicklung (nur Docker, kein lokales PHP/Node nötig)
|
||||||
|
|
||||||
|
Analog zum NC-Gantt-Projekt läuft die gesamte Ausführung (Bauen, Entwickeln, Testen) in
|
||||||
|
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 run --rm api composer test # PHPUnit (API)
|
||||||
|
docker compose run --rm frontend # Node-Tests (Frontend-Layoutlogik)
|
||||||
|
```
|
||||||
|
|
||||||
|
Details zur API: [api/README.md](api/README.md). Details zu den Endpunkten: [API.md](API.md).
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ services:
|
||||||
# - ./status.txt:/status.txt:ro
|
# - ./status.txt:/status.txt:ro
|
||||||
working_dir: /app
|
working_dir: /app
|
||||||
ports:
|
ports:
|
||||||
- "8080:8080"
|
- "8091:8080"
|
||||||
environment:
|
environment:
|
||||||
MONITOR_DB_PATH: /app/data/monitor.sqlite
|
MONITOR_DB_PATH: /app/data/monitor.sqlite
|
||||||
MONITOR_ADMIN_API_KEY: ${MONITOR_ADMIN_API_KEY:-dev-admin-key}
|
MONITOR_ADMIN_API_KEY: ${MONITOR_ADMIN_API_KEY:-dev-admin-key}
|
||||||
|
|
@ -19,3 +19,24 @@ services:
|
||||||
CODEBERG_OWNER: ${CODEBERG_OWNER:-fds2610}
|
CODEBERG_OWNER: ${CODEBERG_OWNER:-fds2610}
|
||||||
CODEBERG_REPO: ${CODEBERG_REPO:-monitor}
|
CODEBERG_REPO: ${CODEBERG_REPO:-monitor}
|
||||||
CODEBERG_TOKEN: ${CODEBERG_TOKEN:-}
|
CODEBERG_TOKEN: ${CODEBERG_TOKEN:-}
|
||||||
|
|
||||||
|
# Statisches Frontend (html/) - Phase 2 der Neuausrichtung (Buero-Visualisierung).
|
||||||
|
# Nur zur lokalen Ansicht/Entwicklung, kein Build-Schritt noetig (Vanilla HTML/CSS/JS).
|
||||||
|
web:
|
||||||
|
image: nginx:alpine
|
||||||
|
volumes:
|
||||||
|
- ./html:/usr/share/nginx/html:ro
|
||||||
|
# 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
|
||||||
|
ports:
|
||||||
|
- "8090:80"
|
||||||
|
|
||||||
|
# Fuehrt die Node-basierten Frontend-Tests (tests/frontend/) aus. Node/npm sind nur im
|
||||||
|
# Container noetig, nicht auf dem Host (analog zum api-Service mit PHP/Composer).
|
||||||
|
frontend:
|
||||||
|
image: node:20-alpine
|
||||||
|
working_dir: /app
|
||||||
|
volumes:
|
||||||
|
- .:/app
|
||||||
|
command: npm test
|
||||||
|
|
|
||||||
6
html/API.md
Normal file
6
html/API.md
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
Platzhalter.
|
||||||
|
|
||||||
|
Diese Datei wird im web-Container per Bind-Mount durch die echte API.md aus dem
|
||||||
|
Repo-Wurzelverzeichnis ueberlagert (siehe docker-compose.yml). Sie muss im Repo
|
||||||
|
existieren, weil html/ read-only in den Container gemountet wird und Docker den
|
||||||
|
Mountpoint dort sonst nicht anlegen kann.
|
||||||
251
html/css/office.css
Normal file
251
html/css/office.css
Normal file
|
|
@ -0,0 +1,251 @@
|
||||||
|
/* Buero-Visualisierung (Phase 2, statische Szene). Bewusst ohne UI-Framework, siehe
|
||||||
|
Visualization.md Abschnitt 5. Farbschema hell/freundlich (F9: "Zeichentrick"-Anmutung). */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--color-floor: #eef2f7;
|
||||||
|
--color-wall: #cfe0f0;
|
||||||
|
--color-wall-side: #bcd3ea;
|
||||||
|
--color-door: #8fb8dd;
|
||||||
|
--color-board-frame: #94622f;
|
||||||
|
--color-note: #fde68a;
|
||||||
|
--color-note-border: #d9b34f;
|
||||||
|
--color-note-stack-badge: #d97706;
|
||||||
|
--color-monitor-frame: #2b2f38;
|
||||||
|
--color-monitor-screen: #10131a;
|
||||||
|
--color-monitor-placeholder: #1a2436;
|
||||||
|
--color-desk-table: #b98a54;
|
||||||
|
--color-desk-chair: #6b7280;
|
||||||
|
--color-desk-screen-idle: #3a3f4b;
|
||||||
|
--color-text: #1f2937;
|
||||||
|
--color-text-inverse: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: "Segoe UI", Verdana, Arial, sans-serif;
|
||||||
|
background: #dbe7f3;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
header.office-header {
|
||||||
|
padding: 16px 24px;
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
header.office-header h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
header.office-header p {
|
||||||
|
margin: 0;
|
||||||
|
color: #475569;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#office-scene {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1920px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 16px 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.office-svg {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
display: block;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.floor {
|
||||||
|
fill: var(--color-floor);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wall {
|
||||||
|
fill: var(--color-wall);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wall-side {
|
||||||
|
fill: var(--color-wall-side);
|
||||||
|
}
|
||||||
|
|
||||||
|
.door {
|
||||||
|
fill: var(--color-door);
|
||||||
|
stroke: #5f86ab;
|
||||||
|
stroke-width: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wall-label {
|
||||||
|
fill: #f8fafc;
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Kanban-Board */
|
||||||
|
|
||||||
|
.board-frame {
|
||||||
|
fill: #7a5230;
|
||||||
|
stroke: var(--color-board-frame);
|
||||||
|
stroke-width: 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-column-label {
|
||||||
|
fill: var(--color-text-inverse);
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-column-divider {
|
||||||
|
stroke: rgba(255, 255, 255, 0.25);
|
||||||
|
stroke-width: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note {
|
||||||
|
fill: var(--color-note);
|
||||||
|
stroke: var(--color-note-border);
|
||||||
|
stroke-width: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-stack-badge {
|
||||||
|
fill: var(--color-note-stack-badge);
|
||||||
|
stroke: #92400e;
|
||||||
|
stroke-width: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-stack-count {
|
||||||
|
fill: #ffffff;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Monitor-Wand */
|
||||||
|
|
||||||
|
.monitor-frame {
|
||||||
|
fill: var(--color-monitor-frame);
|
||||||
|
}
|
||||||
|
|
||||||
|
.monitor-screen {
|
||||||
|
fill: var(--color-monitor-screen);
|
||||||
|
}
|
||||||
|
|
||||||
|
.monitor-screen-placeholder {
|
||||||
|
fill: var(--color-monitor-placeholder);
|
||||||
|
}
|
||||||
|
|
||||||
|
.monitor-event-line {
|
||||||
|
font-family: "Consolas", "Courier New", monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.severity-warning {
|
||||||
|
fill: #facc15;
|
||||||
|
}
|
||||||
|
|
||||||
|
.severity-error {
|
||||||
|
fill: #f87171;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-tile-bg {
|
||||||
|
fill: #1e293b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-tile-bar {
|
||||||
|
fill: #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-tile-led {
|
||||||
|
fill: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-ok .system-tile-led {
|
||||||
|
fill: #4ade80;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-error .system-tile-led {
|
||||||
|
fill: #f87171;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-tile-label {
|
||||||
|
fill: var(--color-text-inverse);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.monitor-placeholder-text {
|
||||||
|
fill: #64748b;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Schreibtische */
|
||||||
|
|
||||||
|
.desk-table {
|
||||||
|
fill: var(--color-desk-table);
|
||||||
|
stroke: #8a6238;
|
||||||
|
stroke-width: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desk-screen {
|
||||||
|
fill: var(--color-desk-screen-idle);
|
||||||
|
stroke: #1f2430;
|
||||||
|
stroke-width: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desk-screen.pose-working {
|
||||||
|
fill: #7dd3fc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desk-chair {
|
||||||
|
fill: var(--color-desk-chair);
|
||||||
|
}
|
||||||
|
|
||||||
|
.person-head {
|
||||||
|
fill: #f3d9b1;
|
||||||
|
stroke: #d8b98a;
|
||||||
|
stroke-width: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.person-body {
|
||||||
|
fill: var(--role-color, #94a3b8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.desk-name {
|
||||||
|
fill: #334155;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desk-name-empty {
|
||||||
|
fill: #94a3b8;
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desk-deco-star {
|
||||||
|
fill: #fbbf24;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desk-deco-flower circle {
|
||||||
|
fill: #f9a8d4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desk-deco-flower-center {
|
||||||
|
fill: #fde047 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desk-deco-check {
|
||||||
|
fill: none;
|
||||||
|
stroke: #22c55e;
|
||||||
|
stroke-width: 4;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desk-empty .desk-chair {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
52
html/js/demo-data.js
Normal file
52
html/js/demo-data.js
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
// Statische Demo-Daten fuer Phase 2 ("Statische Buero-Szene", Visualization.md Abschnitt 9).
|
||||||
|
// Die Form entspricht bewusst den echten API-Responses (siehe API.md), damit ab Phase 3 nur
|
||||||
|
// noch die Quelle (fetch() statt dieses Moduls) getauscht werden muss.
|
||||||
|
|
||||||
|
// Entspricht der Struktur von GET /api/v1/tickets, hier aber bereits zu Zaehlern je Spalte
|
||||||
|
// aggregiert (die eigentliche Aggregation aus Einzel-Tickets kommt mit der Live-Anbindung
|
||||||
|
// in Phase 3).
|
||||||
|
export const DEMO_TICKET_COUNTS = {
|
||||||
|
backlog: 8,
|
||||||
|
bereit: 3,
|
||||||
|
in_arbeit: 2,
|
||||||
|
qa: 1,
|
||||||
|
testing: 0,
|
||||||
|
fertig: 2,
|
||||||
|
done: 12,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Entspricht GET /api/v1/agents, "pose" ist reine Darstellungs-Deko fuer Phase 2 (ohne
|
||||||
|
// Animation) und kein Feld der echten API.
|
||||||
|
export const DEMO_AGENTS = [
|
||||||
|
{ name: "Alice", role: "entwickler", pose: "working" },
|
||||||
|
{ name: "Bob", role: "entwickler", pose: "reading_newspaper" },
|
||||||
|
{ name: "Petra", role: "product_owner", pose: "working" },
|
||||||
|
{ name: "Quentin", role: "qa", pose: "idle" },
|
||||||
|
{ name: "Dirk", role: "entwickler", pose: "working" },
|
||||||
|
{ name: "Nadja", role: "qa", pose: "coffee" },
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
];
|
||||||
|
|
||||||
|
// Entspricht GET /api/v1/systems.
|
||||||
|
export const DEMO_SYSTEMS = [
|
||||||
|
{ id: "srv1", name: "Webserver", status: "ok" },
|
||||||
|
{ id: "srv2", name: "Mailserver", status: "ok" },
|
||||||
|
{ id: "srv3", name: "Datenbank", status: "error" },
|
||||||
|
{ id: "srv4", name: "Backup", status: "ok" },
|
||||||
|
{ id: "srv5", name: "VPN", status: "ok" },
|
||||||
|
{ id: "srv6", name: "DNS", status: "ok" },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Entspricht GET /api/v1/events, bereits absteigend nach Zeit sortiert (neuestes zuerst).
|
||||||
|
export const DEMO_EVENTS = [
|
||||||
|
{ time: "14:32", system: "Datenbank", message: "Verbindung fehlgeschlagen", severity: "error" },
|
||||||
|
{ time: "14:20", system: "Backup", message: "Backup dauert laenger als ueblich", severity: "warning" },
|
||||||
|
{ time: "13:58", system: "Webserver", message: "Hohe Antwortzeit", severity: "warning" },
|
||||||
|
{ time: "12:10", system: "VPN", message: "Client getrennt", severity: "warning" },
|
||||||
|
{ time: "09:05", system: "Datenbank", message: "Reconnect erfolgreich", severity: "warning" },
|
||||||
|
];
|
||||||
|
|
||||||
|
// KPI-Monitor: F7 ist noch offen ("kommt spaeter") - Phase 2 zeigt daher nur einen leeren
|
||||||
|
// Rahmen mit Platzhaltertext, bewusst keine erfundenen Kennzahlen.
|
||||||
|
export const DEMO_KPI_PLACEHOLDER = "KPI-Monitor – Kennzahlen folgen in einer späteren Phase";
|
||||||
424
html/js/office-render.js
Normal file
424
html/js/office-render.js
Normal file
|
|
@ -0,0 +1,424 @@
|
||||||
|
// 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.
|
||||||
|
|
||||||
|
import {
|
||||||
|
ROLES,
|
||||||
|
buildBoardColumnLayout,
|
||||||
|
buildDeskLayout,
|
||||||
|
buildSystemTiles,
|
||||||
|
buildEventLines,
|
||||||
|
} from "./office-scene.js";
|
||||||
|
import {
|
||||||
|
DEMO_TICKET_COUNTS,
|
||||||
|
DEMO_AGENTS,
|
||||||
|
DEMO_SYSTEMS,
|
||||||
|
DEMO_EVENTS,
|
||||||
|
DEMO_KPI_PLACEHOLDER,
|
||||||
|
} from "./demo-data.js";
|
||||||
|
|
||||||
|
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||||
|
|
||||||
|
const VIEW_W = 1920;
|
||||||
|
const VIEW_H = 1080;
|
||||||
|
const WALL_BAND_H = 420;
|
||||||
|
|
||||||
|
const BOARD_FRAME = { x: 60, y: 40, w: 1120, h: 340 };
|
||||||
|
const MONITOR_WALL = { x: 1240, y: 40, w: 620, h: 340 };
|
||||||
|
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 NOTE_W = 96;
|
||||||
|
const NOTE_H = 50;
|
||||||
|
const NOTE_STEP_Y = 56;
|
||||||
|
|
||||||
|
function el(tag, attrs = {}, children = []) {
|
||||||
|
const node = document.createElementNS(SVG_NS, tag);
|
||||||
|
for (const [key, value] of Object.entries(attrs)) {
|
||||||
|
node.setAttribute(key, value);
|
||||||
|
}
|
||||||
|
for (const child of children) {
|
||||||
|
node.appendChild(child);
|
||||||
|
}
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
function text(content, attrs = {}) {
|
||||||
|
const node = el("text", attrs);
|
||||||
|
node.textContent = content;
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRoom() {
|
||||||
|
const g = el("g", { class: "office-room" });
|
||||||
|
g.appendChild(el("rect", { x: 0, y: 0, width: VIEW_W, height: VIEW_H, class: "floor" }));
|
||||||
|
g.appendChild(el("rect", { x: 0, y: 0, width: VIEW_W, height: WALL_BAND_H, class: "wall" }));
|
||||||
|
g.appendChild(el("rect", { x: 0, y: 0, width: 40, height: VIEW_H, class: "wall wall-side" }));
|
||||||
|
g.appendChild(el("rect", { x: VIEW_W - 40, y: 0, width: 40, height: VIEW_H, class: "wall wall-side" }));
|
||||||
|
g.appendChild(el("rect", { x: 0, y: DOOR.y, width: DOOR.w, height: DOOR.h, class: "door" }));
|
||||||
|
g.appendChild(
|
||||||
|
el("rect", { x: VIEW_W - DOOR.w, y: DOOR.y, width: DOOR.w, height: DOOR.h, class: "door" })
|
||||||
|
);
|
||||||
|
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", {
|
||||||
|
x: x - offset,
|
||||||
|
y: y - offset,
|
||||||
|
width: NOTE_W,
|
||||||
|
height: NOTE_H,
|
||||||
|
rx: 6,
|
||||||
|
class: `note note-stack-layer-${i}`,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const cx = x + NOTE_W / 2;
|
||||||
|
const cy = y + NOTE_H / 2;
|
||||||
|
g.appendChild(el("circle", { cx, cy, r: 20, class: "note-stack-badge" }));
|
||||||
|
g.appendChild(
|
||||||
|
text(String(count), { x: cx, y: cy + 7, class: "note-stack-count", "text-anchor": "middle" })
|
||||||
|
);
|
||||||
|
return g;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBoard(ticketCounts) {
|
||||||
|
const columns = buildBoardColumnLayout(ticketCounts);
|
||||||
|
const g = el("g", { class: "board" });
|
||||||
|
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" })
|
||||||
|
);
|
||||||
|
|
||||||
|
const columnWidth = BOARD_FRAME.w / columns.length;
|
||||||
|
|
||||||
|
columns.forEach((column, index) => {
|
||||||
|
const columnX = BOARD_FRAME.x + index * columnWidth;
|
||||||
|
const colGroup = el("g", { class: `board-column board-column-${column.key}` });
|
||||||
|
|
||||||
|
if (index > 0) {
|
||||||
|
colGroup.appendChild(
|
||||||
|
el("line", {
|
||||||
|
x1: columnX,
|
||||||
|
y1: BOARD_FRAME.y,
|
||||||
|
x2: columnX,
|
||||||
|
y2: BOARD_FRAME.y + BOARD_FRAME.h,
|
||||||
|
class: "board-column-divider",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
colGroup.appendChild(
|
||||||
|
text(column.label, {
|
||||||
|
x: columnX + columnWidth / 2,
|
||||||
|
y: BOARD_FRAME.y + 26,
|
||||||
|
class: "board-column-label",
|
||||||
|
"text-anchor": "middle",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const noteX = columnX + (columnWidth - NOTE_W) / 2;
|
||||||
|
let noteY = BOARD_FRAME.y + 44;
|
||||||
|
|
||||||
|
for (let i = 0; i < column.visibleNotes; i++) {
|
||||||
|
colGroup.appendChild(
|
||||||
|
el("rect", { x: noteX, y: noteY, width: NOTE_W, height: NOTE_H, rx: 6, class: "note" })
|
||||||
|
);
|
||||||
|
noteY += NOTE_STEP_Y;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (column.stackCount > 0) {
|
||||||
|
colGroup.appendChild(renderStackBadge(noteX, noteY, column.stackCount));
|
||||||
|
}
|
||||||
|
|
||||||
|
g.appendChild(colGroup);
|
||||||
|
});
|
||||||
|
|
||||||
|
return g;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderEventMonitor(frame, events) {
|
||||||
|
const lines = buildEventLines(events);
|
||||||
|
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" }));
|
||||||
|
g.appendChild(
|
||||||
|
el("rect", {
|
||||||
|
x: frame.x + 10,
|
||||||
|
y: frame.y + 10,
|
||||||
|
width: frame.w - 20,
|
||||||
|
height: frame.h - 20,
|
||||||
|
class: "monitor-screen",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
lines.forEach((eventLine, index) => {
|
||||||
|
g.appendChild(
|
||||||
|
text(`${eventLine.time} ${eventLine.system}: ${eventLine.message}`, {
|
||||||
|
x: frame.x + 18,
|
||||||
|
y: frame.y + 38 + index * 26,
|
||||||
|
class: `monitor-event-line severity-${eventLine.severity}`,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return g;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSystemMonitor(frame, systems) {
|
||||||
|
const tiles = buildSystemTiles(systems);
|
||||||
|
const g = el("g", { class: "monitor monitor-systems" });
|
||||||
|
g.appendChild(el("rect", { ...frame, rx: 8, class: "monitor-frame" }));
|
||||||
|
g.appendChild(text("Systeme", { x: frame.x + 12, y: frame.y - 10, class: "wall-label" }));
|
||||||
|
g.appendChild(
|
||||||
|
el("rect", {
|
||||||
|
x: frame.x + 10,
|
||||||
|
y: frame.y + 10,
|
||||||
|
width: frame.w - 20,
|
||||||
|
height: frame.h - 20,
|
||||||
|
class: "monitor-screen",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const cols = 3;
|
||||||
|
const tileMargin = 8;
|
||||||
|
const gridX = frame.x + 18;
|
||||||
|
const gridY = frame.y + 18;
|
||||||
|
const tileSize = (frame.w - 36 - tileMargin * (cols - 1)) / cols;
|
||||||
|
|
||||||
|
tiles.forEach((system, index) => {
|
||||||
|
const col = index % cols;
|
||||||
|
const row = Math.floor(index / cols);
|
||||||
|
const tileX = gridX + col * (tileSize + tileMargin);
|
||||||
|
const tileY = gridY + row * (tileSize + tileMargin);
|
||||||
|
|
||||||
|
const tileGroup = el("g", { class: `system-tile status-${system.status}` });
|
||||||
|
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" })
|
||||||
|
);
|
||||||
|
tileGroup.appendChild(
|
||||||
|
el("rect", { x: tileX + 10, y: tileY + 30, width: tileSize - 20, height: 14, rx: 2, class: "system-tile-bar" })
|
||||||
|
);
|
||||||
|
tileGroup.appendChild(el("circle", { cx: tileX + 18, cy: tileY + 17, r: 3, class: "system-tile-led" }));
|
||||||
|
tileGroup.appendChild(el("circle", { cx: tileX + 18, cy: tileY + 37, r: 3, class: "system-tile-led" }));
|
||||||
|
tileGroup.appendChild(
|
||||||
|
text(system.name, {
|
||||||
|
x: tileX + tileSize / 2,
|
||||||
|
y: tileY + tileSize - 8,
|
||||||
|
class: "system-tile-label",
|
||||||
|
"text-anchor": "middle",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
g.appendChild(tileGroup);
|
||||||
|
});
|
||||||
|
|
||||||
|
return g;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderKpiMonitor(frame, placeholder) {
|
||||||
|
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" }));
|
||||||
|
g.appendChild(
|
||||||
|
el("rect", {
|
||||||
|
x: frame.x + 10,
|
||||||
|
y: frame.y + 10,
|
||||||
|
width: frame.w - 20,
|
||||||
|
height: frame.h - 20,
|
||||||
|
class: "monitor-screen monitor-screen-placeholder",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const words = placeholder.split(" ");
|
||||||
|
const lineHeight = 22;
|
||||||
|
const maxCharsPerLine = 16;
|
||||||
|
const lines = [];
|
||||||
|
let current = "";
|
||||||
|
for (const word of words) {
|
||||||
|
const candidate = current ? `${current} ${word}` : word;
|
||||||
|
if (candidate.length > maxCharsPerLine && current) {
|
||||||
|
lines.push(current);
|
||||||
|
current = word;
|
||||||
|
} else {
|
||||||
|
current = candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (current) lines.push(current);
|
||||||
|
|
||||||
|
const startY = frame.y + frame.h / 2 - ((lines.length - 1) * lineHeight) / 2;
|
||||||
|
lines.forEach((line, index) => {
|
||||||
|
g.appendChild(
|
||||||
|
text(line, {
|
||||||
|
x: frame.x + frame.w / 2,
|
||||||
|
y: startY + index * lineHeight,
|
||||||
|
class: "monitor-placeholder-text",
|
||||||
|
"text-anchor": "middle",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return g;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMonitorWall(systems, events) {
|
||||||
|
const g = el("g", { class: "monitor-wall" });
|
||||||
|
const gap = 20;
|
||||||
|
const monitorWidth = (MONITOR_WALL.w - gap * 2) / 3;
|
||||||
|
const frames = [0, 1, 2].map((i) => ({
|
||||||
|
x: MONITOR_WALL.x + i * (monitorWidth + gap),
|
||||||
|
y: MONITOR_WALL.y,
|
||||||
|
w: monitorWidth,
|
||||||
|
h: MONITOR_WALL.h,
|
||||||
|
}));
|
||||||
|
|
||||||
|
g.appendChild(renderEventMonitor(frames[0], events));
|
||||||
|
g.appendChild(renderSystemMonitor(frames[1], systems));
|
||||||
|
g.appendChild(renderKpiMonitor(frames[2], DEMO_KPI_PLACEHOLDER));
|
||||||
|
|
||||||
|
return g;
|
||||||
|
}
|
||||||
|
|
||||||
|
function personPose(pose) {
|
||||||
|
// Rein statische Pose-Auswahl (kein Animationszustand) - Zustandsmaschine/Animation
|
||||||
|
// folgt erst in Phase 5.
|
||||||
|
switch (pose) {
|
||||||
|
case "reading_newspaper":
|
||||||
|
return "pose-newspaper";
|
||||||
|
case "coffee":
|
||||||
|
return "pose-coffee";
|
||||||
|
case "working":
|
||||||
|
return "pose-working";
|
||||||
|
default:
|
||||||
|
return "pose-idle";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 role = agent ? ROLES[agent.role] : null;
|
||||||
|
const g = el("g", {
|
||||||
|
class: `desk ${agent ? "desk-occupied" : "desk-empty"}`,
|
||||||
|
transform: `translate(${x}, ${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) : ""}` })
|
||||||
|
);
|
||||||
|
g.appendChild(el("circle", { cx: DESK.w / 2, cy: 40 + DESK.h + 30, 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", {
|
||||||
|
d: "M170,20 l4,10 11,0 -9,7 3,11 -9,-7 -9,7 3,-11 -9,-7 11,0 z",
|
||||||
|
class: "desk-deco desk-deco-star",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} else if (role.deco === "flower") {
|
||||||
|
const cx = 175;
|
||||||
|
const cy = 22;
|
||||||
|
g.appendChild(
|
||||||
|
el("g", { class: "desk-deco desk-deco-flower" }, [
|
||||||
|
el("circle", { cx: cx - 7, cy, r: 6 }),
|
||||||
|
el("circle", { cx: cx + 7, cy, r: 6 }),
|
||||||
|
el("circle", { cx, cy: cy - 7, r: 6 }),
|
||||||
|
el("circle", { cx, cy: cy + 7, r: 6 }),
|
||||||
|
el("circle", { cx, cy, r: 4, class: "desk-deco-flower-center" }),
|
||||||
|
])
|
||||||
|
);
|
||||||
|
} else if (role.deco === "check") {
|
||||||
|
g.appendChild(
|
||||||
|
el("path", { d: "M162,24 l8,8 16,-18", class: "desk-deco desk-deco-check" })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
y: 40 + DESK.h + 74,
|
||||||
|
class: "desk-name",
|
||||||
|
"text-anchor": "middle",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
g.appendChild(
|
||||||
|
text("frei", {
|
||||||
|
x: DESK.w / 2,
|
||||||
|
y: 40 + DESK.h + 74,
|
||||||
|
class: "desk-name desk-name-empty",
|
||||||
|
"text-anchor": "middle",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return g;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDesks(agents) {
|
||||||
|
const g = el("g", { class: "desks" });
|
||||||
|
buildDeskLayout(agents).forEach((desk) => g.appendChild(renderDesk(desk)));
|
||||||
|
return g;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderOfficeScene(container, data = {}) {
|
||||||
|
const {
|
||||||
|
ticketCounts = DEMO_TICKET_COUNTS,
|
||||||
|
agents = DEMO_AGENTS,
|
||||||
|
systems = DEMO_SYSTEMS,
|
||||||
|
events = DEMO_EVENTS,
|
||||||
|
} = data;
|
||||||
|
|
||||||
|
const 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));
|
||||||
|
|
||||||
|
container.replaceChildren(svg);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
const container = document.getElementById("office-scene");
|
||||||
|
if (container) {
|
||||||
|
renderOfficeScene(container);
|
||||||
|
}
|
||||||
|
});
|
||||||
105
html/js/office-scene.js
Normal file
105
html/js/office-scene.js
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
// Reine Layout-/Datenlogik fuer die Buero-Visualisierung (Phase 2, siehe Visualization.md
|
||||||
|
// Abschnitt 9). Enthaelt keine DOM-Zugriffe, damit die Funktionen ohne Browser (z.B. mit dem
|
||||||
|
// Node-Testrunner) getestet werden koennen. Das eigentliche Zeichnen uebernimmt office-render.js.
|
||||||
|
|
||||||
|
// Spaltenreihenfolge exakt wie API.md ("Tickets" -> Spalten, F3).
|
||||||
|
export const BOARD_COLUMNS = [
|
||||||
|
{ key: "backlog", label: "Backlog" },
|
||||||
|
{ key: "bereit", label: "Bereit" },
|
||||||
|
{ key: "in_arbeit", label: "In Arbeit" },
|
||||||
|
{ key: "qa", label: "QA" },
|
||||||
|
{ key: "testing", label: "Testing" },
|
||||||
|
{ key: "fertig", label: "Fertig" },
|
||||||
|
{ key: "done", label: "Done" },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Rollen exakt wie API.md ("Agenten", F1/F2). "user" hat nie einen Schreibtisch.
|
||||||
|
export const ROLES = {
|
||||||
|
entwickler: { label: "Entwickler:in", accentColor: "#3b82f6", deco: "star" },
|
||||||
|
product_owner: { label: "Product Owner:in", accentColor: "#ec4899", deco: "flower" },
|
||||||
|
qa: { label: "QA / Tester:in", accentColor: "#22c55e", deco: "check" },
|
||||||
|
user: { label: "User", accentColor: "#f59e0b", deco: "none" },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MAX_VISIBLE_NOTES_PER_COLUMN = 4;
|
||||||
|
export const MAX_SYSTEM_TILES = 9;
|
||||||
|
export const MAX_EVENT_LINES = 5;
|
||||||
|
export const DEFAULT_DESK_COUNT = 8;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Baut aus rohen Ticket-Zaehlern pro Spalte die Darstellung fuer das Board.
|
||||||
|
* Regel (Detailantworten F3): mehr als 5 Tickets in einer Spalte -> die aeltesten 4 werden
|
||||||
|
* einzeln dargestellt, der Rest als ein "Stapel" mit der Anzahl der zusaetzlichen Tickets.
|
||||||
|
* Regel (Detailantworten F2): die Spalte "done" wird immer als ein Stapel (Zeit-Historie)
|
||||||
|
* dargestellt, unabhaengig von der Anzahl.
|
||||||
|
*
|
||||||
|
* @param {Record<string, number>} ticketCounts - Anzahl Tickets je Spalten-Key.
|
||||||
|
* @returns {Array<{key: string, label: string, count: number, visibleNotes: number, stackCount: number}>}
|
||||||
|
*/
|
||||||
|
export function buildBoardColumnLayout(ticketCounts = {}) {
|
||||||
|
return BOARD_COLUMNS.map(({ key, label }) => {
|
||||||
|
const count = Math.max(0, ticketCounts[key] ?? 0);
|
||||||
|
|
||||||
|
if (key === "done") {
|
||||||
|
return { key, label, count, visibleNotes: 0, stackCount: count };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count <= MAX_VISIBLE_NOTES_PER_COLUMN + 1) {
|
||||||
|
return { key, label, count, visibleNotes: count, stackCount: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
label,
|
||||||
|
count,
|
||||||
|
visibleNotes: MAX_VISIBLE_NOTES_PER_COLUMN,
|
||||||
|
stackCount: count - MAX_VISIBLE_NOTES_PER_COLUMN,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ordnet Agenten-Demo-Daten den Schreibtischen zu. Unbesetzte Schreibtische bleiben leer.
|
||||||
|
* Ueberzaehlige Agenten (mehr als deskCount) werden ignoriert - Phase 2 zeigt nur den
|
||||||
|
* statischen Grundzustand, die "weitere Agenten kommen durch die Tuer"-Logik ist Teil von
|
||||||
|
* Phase 5 (Animation).
|
||||||
|
*
|
||||||
|
* @param {Array<{name: string, role: string, pose?: string}>} agents
|
||||||
|
* @param {number} deskCount
|
||||||
|
* @returns {Array<{deskIndex: number, agent: object|null}>}
|
||||||
|
*/
|
||||||
|
export function buildDeskLayout(agents = [], deskCount = DEFAULT_DESK_COUNT) {
|
||||||
|
const desks = [];
|
||||||
|
for (let deskIndex = 0; deskIndex < deskCount; deskIndex++) {
|
||||||
|
const agent = agents[deskIndex] ?? null;
|
||||||
|
desks.push({ deskIndex, agent: agent ? { pose: "idle", ...agent } : null });
|
||||||
|
}
|
||||||
|
return desks;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bereitet Systemkacheln fuer den System-Monitor auf: Fehler zuerst (wie GET /api/v1/systems),
|
||||||
|
* maximal MAX_SYSTEM_TILES Kacheln (F6).
|
||||||
|
*
|
||||||
|
* @param {Array<{id: string, name: string, status: "ok"|"error"}>} systems
|
||||||
|
* @returns {Array<object>}
|
||||||
|
*/
|
||||||
|
export function buildSystemTiles(systems = []) {
|
||||||
|
const sorted = [...systems].sort((a, b) => {
|
||||||
|
if (a.status === b.status) return 0;
|
||||||
|
return a.status === "error" ? -1 : 1;
|
||||||
|
});
|
||||||
|
return sorted.slice(0, MAX_SYSTEM_TILES);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bereitet die Zeilen des Event-Monitors auf: neuestes Event zuerst, maximal
|
||||||
|
* MAX_EVENT_LINES Zeilen (F5). Erwartet bereits absteigend nach Zeit sortierte Events
|
||||||
|
* (wie GET /api/v1/events sie liefert).
|
||||||
|
*
|
||||||
|
* @param {Array<{severity: "warning"|"error"}>} events
|
||||||
|
* @returns {Array<object>}
|
||||||
|
*/
|
||||||
|
export function buildEventLines(events = []) {
|
||||||
|
return events.slice(0, MAX_EVENT_LINES);
|
||||||
|
}
|
||||||
24
html/office.html
Normal file
24
html/office.html
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<link rel="icon" type="image/png" href="/favicon.png">
|
||||||
|
<title>FDS Buero-Visualisierung</title>
|
||||||
|
<link rel="stylesheet" href="css/office.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<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).
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main id="office-scene" aria-label="Buero-Szene"></main>
|
||||||
|
|
||||||
|
<script type="module" src="js/office-render.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
9
package.json
Normal file
9
package.json
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"name": "monitor-frontend",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"description": "Test-Tooling fuer das Vanilla-JS-Frontend (html/js/). Laufzeitcode selbst hat keine Abhaengigkeiten (siehe Visualization.md Abschnitt 5).",
|
||||||
|
"scripts": {
|
||||||
|
"test": "node --test --experimental-test-coverage tests/frontend"
|
||||||
|
}
|
||||||
|
}
|
||||||
91
tests/frontend/office-scene.test.js
Normal file
91
tests/frontend/office-scene.test.js
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import {
|
||||||
|
BOARD_COLUMNS,
|
||||||
|
buildBoardColumnLayout,
|
||||||
|
buildDeskLayout,
|
||||||
|
buildSystemTiles,
|
||||||
|
buildEventLines,
|
||||||
|
MAX_SYSTEM_TILES,
|
||||||
|
MAX_EVENT_LINES,
|
||||||
|
DEFAULT_DESK_COUNT,
|
||||||
|
} from "../../html/js/office-scene.js";
|
||||||
|
|
||||||
|
test("buildBoardColumnLayout liefert alle Spalten in API.md-Reihenfolge", () => {
|
||||||
|
const layout = buildBoardColumnLayout({});
|
||||||
|
assert.deepEqual(
|
||||||
|
layout.map((c) => c.key),
|
||||||
|
BOARD_COLUMNS.map((c) => c.key)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("buildBoardColumnLayout zeigt Tickets bis einschliesslich 5 einzeln an", () => {
|
||||||
|
const layout = buildBoardColumnLayout({ bereit: 5 });
|
||||||
|
const bereit = layout.find((c) => c.key === "bereit");
|
||||||
|
assert.equal(bereit.visibleNotes, 5);
|
||||||
|
assert.equal(bereit.stackCount, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("buildBoardColumnLayout stapelt ab dem 5. Ticket (F3): aelteste 4 einzeln + Rest als Stapel", () => {
|
||||||
|
const layout = buildBoardColumnLayout({ backlog: 8 });
|
||||||
|
const backlog = layout.find((c) => c.key === "backlog");
|
||||||
|
assert.equal(backlog.visibleNotes, 4);
|
||||||
|
assert.equal(backlog.stackCount, 4);
|
||||||
|
assert.equal(backlog.count, 8);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("buildBoardColumnLayout stellt die Spalte done immer als Stapel dar (F2), unabhaengig von der Anzahl", () => {
|
||||||
|
const wenige = buildBoardColumnLayout({ done: 2 }).find((c) => c.key === "done");
|
||||||
|
assert.equal(wenige.visibleNotes, 0);
|
||||||
|
assert.equal(wenige.stackCount, 2);
|
||||||
|
|
||||||
|
const viele = buildBoardColumnLayout({ done: 42 }).find((c) => c.key === "done");
|
||||||
|
assert.equal(viele.visibleNotes, 0);
|
||||||
|
assert.equal(viele.stackCount, 42);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("buildBoardColumnLayout behandelt fehlende/negative Zaehler als 0", () => {
|
||||||
|
const layout = buildBoardColumnLayout({ qa: -3 });
|
||||||
|
const qa = layout.find((c) => c.key === "qa");
|
||||||
|
assert.equal(qa.count, 0);
|
||||||
|
assert.equal(qa.visibleNotes, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("buildDeskLayout erzeugt genau DEFAULT_DESK_COUNT Schreibtische", () => {
|
||||||
|
const desks = buildDeskLayout([]);
|
||||||
|
assert.equal(desks.length, DEFAULT_DESK_COUNT);
|
||||||
|
assert.ok(desks.every((d) => d.agent === null));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("buildDeskLayout ordnet Agenten der Reihe nach Schreibtischen zu und ignoriert Ueberschuss", () => {
|
||||||
|
const agents = Array.from({ length: DEFAULT_DESK_COUNT + 2 }, (_, i) => ({
|
||||||
|
name: `Agent${i}`,
|
||||||
|
role: "entwickler",
|
||||||
|
}));
|
||||||
|
const desks = buildDeskLayout(agents);
|
||||||
|
assert.equal(desks.length, DEFAULT_DESK_COUNT);
|
||||||
|
assert.equal(desks[0].agent.name, "Agent0");
|
||||||
|
assert.equal(desks[DEFAULT_DESK_COUNT - 1].agent.name, `Agent${DEFAULT_DESK_COUNT - 1}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("buildDeskLayout ergaenzt fehlende pose auf idle", () => {
|
||||||
|
const desks = buildDeskLayout([{ name: "Alice", role: "entwickler" }], 1);
|
||||||
|
assert.equal(desks[0].agent.pose, "idle");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("buildSystemTiles sortiert Fehler vor ok und kappt bei MAX_SYSTEM_TILES", () => {
|
||||||
|
const systems = Array.from({ length: MAX_SYSTEM_TILES + 3 }, (_, i) => ({
|
||||||
|
id: `s${i}`,
|
||||||
|
status: i === MAX_SYSTEM_TILES ? "error" : "ok",
|
||||||
|
}));
|
||||||
|
const tiles = buildSystemTiles(systems);
|
||||||
|
assert.equal(tiles.length, MAX_SYSTEM_TILES);
|
||||||
|
assert.equal(tiles[0].status, "error");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("buildEventLines kappt bei MAX_EVENT_LINES und behaelt die Reihenfolge bei", () => {
|
||||||
|
const events = Array.from({ length: MAX_EVENT_LINES + 4 }, (_, i) => ({ id: i }));
|
||||||
|
const lines = buildEventLines(events);
|
||||||
|
assert.equal(lines.length, MAX_EVENT_LINES);
|
||||||
|
assert.equal(lines[0].id, 0);
|
||||||
|
});
|
||||||
Loading…
Add table
Reference in a new issue