Event-Monitor: Zeilen passen in den Monitor, Volltext im Tooltip

Lange Meldungen liefen ueber den Monitorrahmen hinaus in die Szene. Jedes
Event belegt jetzt zwei Zeilen (Zeit + System, darunter eingerueckt die
Meldung), beide auf die Innenbreite gekuerzt. Die vollstaendige Meldung
zeigt ein <title>-Tooltip beim Hover ueber das Event.

Die Kuerzung selbst sitzt als truncateToWidth() in office-scene.js und ist
damit ohne DOM testbar; die Zeichenbreite ist an der breitesten der in
Frage kommenden Monospace-Schriften ausgerichtet, damit auf keinem System
etwas ueberlaeuft.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
fds2610-bot 2026-08-07 17:49:09 +02:00
parent eb48e34bfd
commit e51b373f07
4 changed files with 120 additions and 7 deletions

View file

@ -240,13 +240,23 @@ header.office-header h1 {
.monitor-event-line { .monitor-event-line {
font-family: "Consolas", "Courier New", monospace; font-family: "Consolas", "Courier New", monospace;
font-size: 13px; 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; fill: #facc15;
} }
.severity-error { .severity-error,
.severity-error .monitor-event-line {
fill: #f87171; fill: #f87171;
} }

View file

@ -20,6 +20,8 @@ import {
groupTicketsByColumn, groupTicketsByColumn,
buildTicketModalContent, buildTicketModalContent,
mapEventForDisplay, mapEventForDisplay,
formatEventLineText,
truncateToWidth,
buildEventModalContent, buildEventModalContent,
buildSystemModalContent, buildSystemModalContent,
buildKpiTiles, buildKpiTiles,
@ -85,6 +87,12 @@ const DESK_ROW_Y = [540, 840];
const DESK_COL_X = [232, 644, 1056, 1468]; const DESK_COL_X = [232, 644, 1056, 1468];
const COFFEE_CORNER = { x: 960, y: 780 }; 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 // 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 // (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. // 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 <title>-Tooltip beim Hover ueber das Event.
const textX = frame.x + 18;
const availableWidth = frame.w - 36;
const messageIndent = 12;
lines.forEach((eventLine, index) => { lines.forEach((eventLine, index) => {
g.appendChild( const blockY = frame.y + 38 + index * EVENT_BLOCK_H;
text(`${eventLine.time} ${eventLine.system}: ${eventLine.message}`, { const entry = el("g", { class: `monitor-event severity-${eventLine.severity}` });
x: frame.x + 18,
y: frame.y + 38 + index * 26, const title = el("title");
class: `monitor-event-line severity-${eventLine.severity}`, 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; return g;

View file

@ -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). * Inhaltsmodell fuer das Event-Monitor-Modal: volle Liste (kein MAX_EVENT_LINES-Limit).
* *

View file

@ -15,6 +15,9 @@ import {
buildTicketModalContent, buildTicketModalContent,
formatEventTime, formatEventTime,
mapEventForDisplay, mapEventForDisplay,
formatEventLineText,
truncateToWidth,
EVENT_LINE_CHAR_WIDTH,
buildEventModalContent, buildEventModalContent,
buildSystemModalContent, buildSystemModalContent,
buildKpiSparklinePoints, buildKpiSparklinePoints,
@ -286,6 +289,32 @@ test("mapEventForDisplay uebersetzt ein rohes Event in die Wand-Zeilendarstellun
assert.match(line.time, /^\d{2}:\d{2}$/); 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)", () => { test("buildEventModalContent zeigt die volle Liste (kein MAX_EVENT_LINES-Limit)", () => {
const events = Array.from({ length: MAX_EVENT_LINES + 3 }, (_, i) => ({ const events = Array.from({ length: MAX_EVENT_LINES + 3 }, (_, i) => ({
occurred_at: "2026-07-27T10:00:00Z", occurred_at: "2026-07-27T10:00:00Z",