Phase 2 der Neuausrichtung: Statische Buero-Szene (SVG, Demo-Daten)
Setzt Phase 2 aus Visualization.md um: Vanilla-SVG-Buero-Szene (Waende, 2 Tueren, 8 Schreibtische, Kanban-Board-Rahmen mit 7 Spalten, Monitor-Wand mit Event-/System-/ KPI-Monitor) ohne Animation, gespeist mit statischen Demo-Daten im Format der Phase-1-API-Responses. Board-Stapel-Logik (>5 Tickets, Done-Spalte als Dauerstapel) folgt Detailantworten.md F2/F3. Reine Layout-Logik (html/js/office-scene.js) ist ausgelagert und mit dem Node- Testrunner abgedeckt (100% Zeilenabdeckung, tests/frontend/), das DOM-Rendering (office-render.js) bleibt bewusst duenne Glue-Logik. Docker-Compose um web- (nginx, serviert html/) und frontend-Service (node, "npm test") erweitert, analog zum bestehenden api-Service und zum NC-Gantt-Vorbild: kein PHP/Node auf dem Host noetig. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
68a57b2e1f
commit
37693a09a1
10 changed files with 1015 additions and 11 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1,3 +1,4 @@
|
|||
api/vendor/
|
||||
api/data/
|
||||
.env
|
||||
node_modules/
|
||||
|
|
|
|||
50
README.md
50
README.md
|
|
@ -1,24 +1,52 @@
|
|||
# 🖥️ monitor
|
||||
|
||||
**monitor** ist ein leichtgewichtiges Monitoring-Tool, das mehrere Systeme per **Ping** überwacht und die Ergebnisse in einer **statischen HTML-Webseite** anzeigt.
|
||||
Ideal für kleine Netzwerke, Homelabs oder interne Statusseiten.
|
||||
**monitor** überwacht mehrere Systeme (per Ping und/oder per REST-API-Meldungen entfernter
|
||||
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
|
||||
|
||||
- Überwacht beliebig viele Hosts per **ICMP-Ping**
|
||||
- Generiert automatisch eine **statische HTML-Statusseite**
|
||||
- Anzeige von:
|
||||
- Antwortzeit (ms)
|
||||
- Online-/Offline-Status
|
||||
- Letztem Check-Zeitpunkt
|
||||
- Einfache Konfiguration über JSON oder YAML
|
||||
- Keine Datenbank, kein Serverprozess – alles rein statisch
|
||||
- Optional: automatischer Refresh der Webseite
|
||||
- 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)
|
||||
- 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)
|
||||
|
||||
---
|
||||
|
||||
## 📁 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).
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -19,3 +19,22 @@ services:
|
|||
CODEBERG_OWNER: ${CODEBERG_OWNER:-fds2610}
|
||||
CODEBERG_REPO: ${CODEBERG_REPO:-monitor}
|
||||
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
|
||||
- ./API.md:/usr/share/nginx/html/API.md:ro
|
||||
ports:
|
||||
- "8081: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
|
||||
|
|
|
|||
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