diff --git a/html/css/office.css b/html/css/office.css index 539f947..9c84f0c 100644 --- a/html/css/office.css +++ b/html/css/office.css @@ -240,13 +240,23 @@ header.office-header h1 { .monitor-event-line { font-family: "Consolas", "Courier New", monospace; font-size: 13px; + /* Grundfarbe fuer Schweregrade ohne eigene Farbe (z.B. info) - ohne sie stuende der Text + schwarz auf dem dunklen Monitor. */ + fill: #cbd5e1; } -.severity-warning { +/* Kopfzeile eines Events (Zeit + System): tritt gegenueber der Meldung zurueck. */ +.monitor-event-meta { + opacity: 0.75; +} + +.severity-warning, +.severity-warning .monitor-event-line { fill: #facc15; } -.severity-error { +.severity-error, +.severity-error .monitor-event-line { fill: #f87171; } diff --git a/html/js/office-render.js b/html/js/office-render.js index 2b712b4..9d0aaab 100644 --- a/html/js/office-render.js +++ b/html/js/office-render.js @@ -20,6 +20,8 @@ import { groupTicketsByColumn, buildTicketModalContent, mapEventForDisplay, + formatEventLineText, + truncateToWidth, buildEventModalContent, buildSystemModalContent, buildKpiTiles, @@ -85,6 +87,12 @@ const DESK_ROW_Y = [540, 840]; const DESK_COL_X = [232, 644, 1056, 1468]; const COFFEE_CORNER = { x: 960, y: 780 }; +// Ein Event belegt im Event-Monitor zwei Zeilen (Kopfzeile Zeit/System, darunter die +// Meldung); EVENT_BLOCK_H ist der Abstand von Event zu Event, EVENT_MESSAGE_DY der der +// beiden Zeilen zueinander. MAX_EVENT_LINES (5) Bloecke passen damit in MONITOR_WALL.h. +const EVENT_BLOCK_H = 44; +const EVENT_MESSAGE_DY = 18; + // Fester Platz des Product Owner (Issue #19): bewusst ausserhalb des dynamischen 8er-Rasters // (DESK_COL_X/DESK_ROW_Y), in der freien Nische zwischen Board/Monitor-Wand und der ersten // Schreibtischreihe - so kollidiert er nie mit einem echten, ueber die API registrierten Agenten. @@ -286,14 +294,38 @@ function renderEventMonitor(frame, events) { }) ); + // Meldungen sind beliebig lang, der Monitor ist es nicht - einzeilig lief die Zeile ueber + // den Rahmen hinaus in die Szene. Jedes Event bekommt deshalb zwei Zeilen: Zeit + System + // oben, die Meldung eingerueckt darunter. Beide werden auf die Innenbreite gekuerzt, den + // Volltext zeigt der -Tooltip beim Hover ueber das Event. + const textX = frame.x + 18; + const availableWidth = frame.w - 36; + const messageIndent = 12; + 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}`, + const blockY = frame.y + 38 + index * EVENT_BLOCK_H; + const entry = el("g", { class: `monitor-event severity-${eventLine.severity}` }); + + const title = el("title"); + title.textContent = formatEventLineText(eventLine); + entry.appendChild(title); + + entry.appendChild( + text(truncateToWidth(`${eventLine.time} ${eventLine.system}`, availableWidth), { + x: textX, + y: blockY, + class: "monitor-event-line monitor-event-meta", }) ); + entry.appendChild( + text(truncateToWidth(eventLine.message, availableWidth - messageIndent), { + x: textX + messageIndent, + y: blockY + EVENT_MESSAGE_DY, + class: "monitor-event-line monitor-event-message", + }) + ); + + g.appendChild(entry); }); return g; diff --git a/html/js/office-scene.js b/html/js/office-scene.js index 1efe51f..7aa9dca 100644 --- a/html/js/office-scene.js +++ b/html/js/office-scene.js @@ -255,6 +255,48 @@ export function mapEventForDisplay(event) { }; } +/** + * Volltext einer Event-Zeile, wie er ungekuerzt im Tooltip steht. + * + * @param {{time: string, system: string, message: string}} eventLine + * @returns {string} + */ +export function formatEventLineText(eventLine) { + return `${eventLine.time} ${eventLine.system}: ${eventLine.message}`; +} + +// Zeichenbreite der Monospace-Schrift des Event-Monitors (13px, siehe .monitor-event-line in +// office.css). Bei Monospace ist die Breite fuer alle Zeichen gleich; der Wert ist am +// breitesten der in Frage kommenden Schriften ausgerichtet (Courier New / DejaVu Sans Mono, +// ~0.6 em; Consolas ist schmaler), damit die Zeile auf keinem System ueberlaeuft. So bleibt +// die Abschaetzung ohne DOM-Messung auskommend und damit testbar. +export const EVENT_LINE_CHAR_WIDTH = 7.9; + +/** + * Kuerzt einen Text auf die Breite, die im Monitor tatsaechlich zur Verfuegung steht, und + * markiert die Kuerzung mit einer Ellipse. Zu lange Zeilen liefen sonst ueber den + * Monitorrahmen hinaus in die Szene hinein; die volle Meldung zeigt der Tooltip + * (siehe renderEventMonitor()). + * + * @param {string} value + * @param {number} availableWidth - verfuegbare Breite in SVG-Einheiten + * @param {number} [charWidth] + * @returns {string} + */ +export function truncateToWidth(value, availableWidth, charWidth = EVENT_LINE_CHAR_WIDTH) { + const maxChars = Math.floor(availableWidth / charWidth); + if (maxChars <= 0) { + return ""; + } + if (value.length <= maxChars) { + return value; + } + if (maxChars === 1) { + return "…"; + } + return `${value.slice(0, maxChars - 1).trimEnd()}…`; +} + /** * Inhaltsmodell fuer das Event-Monitor-Modal: volle Liste (kein MAX_EVENT_LINES-Limit). * diff --git a/tests/frontend/office-scene.test.js b/tests/frontend/office-scene.test.js index 853a545..aee67d8 100644 --- a/tests/frontend/office-scene.test.js +++ b/tests/frontend/office-scene.test.js @@ -15,6 +15,9 @@ import { buildTicketModalContent, formatEventTime, mapEventForDisplay, + formatEventLineText, + truncateToWidth, + EVENT_LINE_CHAR_WIDTH, buildEventModalContent, buildSystemModalContent, buildKpiSparklinePoints, @@ -286,6 +289,32 @@ test("mapEventForDisplay uebersetzt ein rohes Event in die Wand-Zeilendarstellun assert.match(line.time, /^\d{2}:\d{2}$/); }); +test("formatEventLineText setzt Zeit, System und Meldung zur Monitorzeile zusammen", () => { + assert.equal( + formatEventLineText({ time: "14:32", system: "Datenbank", message: "Verbindung weg" }), + "14:32 Datenbank: Verbindung weg" + ); +}); + +test("truncateToWidth laesst passende Zeilen unveraendert", () => { + const value = "14:32 DB: weg"; + assert.equal(truncateToWidth(value, value.length * EVENT_LINE_CHAR_WIDTH), value); +}); + +test("truncateToWidth kuerzt zu lange Zeilen auf die verfuegbare Breite inkl. Ellipse", () => { + const value = "14:32 Datenbank: Verbindung fehlgeschlagen"; + const truncated = truncateToWidth(value, 10 * EVENT_LINE_CHAR_WIDTH); + assert.equal(truncated.length, 10); + assert.ok(truncated.endsWith("…")); + assert.ok(value.startsWith(truncated.slice(0, -1))); +}); + +test("truncateToWidth kommt mit entarteten Breiten klar", () => { + assert.equal(truncateToWidth("abc", 0), ""); + assert.equal(truncateToWidth("abc", -5), ""); + assert.equal(truncateToWidth("abc", EVENT_LINE_CHAR_WIDTH), "…"); +}); + 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",