Merge pull request 'Buero-Headline durch Agent-Status ersetzen + kosmetischer Reload-Countdown' (#13) from fds2610-bot/monitor:main into main

Reviewed-on: https://codeberg.org/fds2610/monitor/pulls/13
This commit is contained in:
fds2610 2026-07-29 00:05:34 +02:00
commit b135d112fd
8 changed files with 103 additions and 27 deletions

View file

@ -20,4 +20,7 @@ CODEBERG_TOKEN=
# Abstand zwischen zwei Codeberg-Sync-Laeufen in Sekunden (Service "sync" in # Abstand zwischen zwei Codeberg-Sync-Laeufen in Sekunden (Service "sync" in
# docker-compose.yml). Der erste Lauf passiert direkt beim Start des Services. # docker-compose.yml). Der erste Lauf passiert direkt beim Start des Services.
# Speist ausserdem den rein kosmetischen Reload-Countdown im Header der Buero-Visualisierung
# (Service "web", siehe docker/nginx/default.conf.template) - der Countdown zaehlt nur mit,
# er loest selbst keinen echten Reload/Sync aus.
MONITOR_SYNC_INTERVAL=600 MONITOR_SYNC_INTERVAL=600

View file

@ -77,6 +77,10 @@ services:
# Wird in docker/nginx/default.conf.template eingesetzt und als /config.js an den # Wird in docker/nginx/default.conf.template eingesetzt und als /config.js an den
# Browser ausgeliefert. Leer = gleicher Origin (relatives /api/v1). # Browser ausgeliefert. Leer = gleicher Origin (relatives /api/v1).
MONITOR_API_BASE: ${MONITOR_API_BASE:-} MONITOR_API_BASE: ${MONITOR_API_BASE:-}
# Speist nur den kosmetischen Reload-Countdown im Header (Issue #12) - gleicher
# Default wie beim sync-Service oben, damit die Anzeige zur tatsaechlichen
# Sync-Frequenz passt, wenn MONITOR_SYNC_INTERVAL nicht gesetzt ist.
MONITOR_SYNC_INTERVAL: ${MONITOR_SYNC_INTERVAL:-600}
ports: ports:
- "8090:80" - "8090:80"

View file

@ -10,17 +10,20 @@ server {
index office.html; index office.html;
# Laufzeit-Konfiguration fuers Frontend: Das html/-Verzeichnis ist read-only gemountet, # Laufzeit-Konfiguration fuers Frontend: Das html/-Verzeichnis ist read-only gemountet,
# deshalb wird die API-Basis-URL nicht als Datei abgelegt, sondern hier aus der Umgebung # deshalb werden API-Basis-URL und Sync-Intervall nicht als Datei abgelegt, sondern hier
# ausgeliefert (MONITOR_API_BASE, siehe .env). Das nginx-Image ersetzt ${MONITOR_API_BASE} # aus der Umgebung ausgeliefert (MONITOR_API_BASE, MONITOR_SYNC_INTERVAL, siehe .env).
# beim Containerstart per envsubst - nur Variablen, die tatsaechlich in der Umgebung # Das nginx-Image ersetzt ${MONITOR_API_BASE}/${MONITOR_SYNC_INTERVAL} beim Containerstart
# definiert sind, nginx-eigene wie $uri bleiben unangetastet. Ist MONITOR_API_BASE gar # per envsubst - nur Variablen, die tatsaechlich in der Umgebung definiert sind,
# nicht definiert, bleibt der Platzhalter stehen und nginx verweigert den Start # nginx-eigene wie $uri bleiben unangetastet. Ist eine der beiden Variablen gar nicht
# definiert, bleibt ihr Platzhalter stehen und nginx verweigert den Start
# ("unknown \"monitor_api_base\" variable") - besser ein lauter Fehler als eine still # ("unknown \"monitor_api_base\" variable") - besser ein lauter Fehler als eine still
# falsche URL. docker-compose.yml setzt die Variable daher immer (ggf. leer). # falsche URL. docker-compose.yml setzt beide Variablen daher immer (ggf. mit Default).
# MONITOR_SYNC_INTERVAL speist nur den rein kosmetischen Reload-Countdown im Header
# (Issue #12) - hat keinen Einfluss auf den tatsaechlichen Update-Takt der Szene.
location = /config.js { location = /config.js {
default_type application/javascript; default_type application/javascript;
add_header Cache-Control "no-store"; add_header Cache-Control "no-store";
return 200 'window.MONITOR_API_BASE = "${MONITOR_API_BASE}";'; return 200 'window.MONITOR_API_BASE = "${MONITOR_API_BASE}"; window.MONITOR_SYNC_INTERVAL = ${MONITOR_SYNC_INTERVAL};';
} }
location / { location / {

View file

@ -52,6 +52,7 @@ header.office-header {
padding: 12px 24px; padding: 12px 24px;
display: flex; display: flex;
align-items: baseline; align-items: baseline;
justify-content: space-between;
gap: 16px; gap: 16px;
} }
@ -60,10 +61,14 @@ header.office-header h1 {
font-size: 1.4rem; font-size: 1.4rem;
} }
header.office-header p { /* Rein kosmetischer Countdown bis zum naechsten Codeberg-Sync (Issue #12), rechtsbuendig
margin: 0; anstelle des frueheren erklaerenden Fliesstexts. Tabular-nums verhindert Wackeln der
Breite, wenn sich die Ziffernzahl aendert (z.B. 9s -> 10s). */
.reload-countdown {
color: #475569; color: #475569;
font-size: 0.9rem; font-size: 0.9rem;
font-variant-numeric: tabular-nums;
white-space: nowrap;
} }
/* Warnbanner, wenn die API nicht erreichbar ist und die Szene auf Demo-Daten zurueckfaellt /* Warnbanner, wenn die API nicht erreichbar ist und die Szene auf Demo-Daten zurueckfaellt
@ -126,14 +131,6 @@ header.office-header p {
} }
} }
/* Bei knappem Platz weicht zuerst der erklaerende Fliesstext - er kostet in flachen
oder schmalen Fenstern mehrere Zeilen Hoehe, die der Szene fehlen. */
@media (max-height: 640px), (max-width: 760px) {
header.office-header p {
display: none;
}
}
@media (max-height: 640px) { @media (max-height: 640px) {
header.office-header { header.office-header {
padding: 8px 16px; padding: 8px 16px;

View file

@ -33,6 +33,8 @@ import {
resolveDoorVisitVisual, resolveDoorVisitVisual,
advanceDoorVisitStage, advanceDoorVisitStage,
DOOR_VISIT_STAGES, DOOR_VISIT_STAGES,
resolveReloadIntervalSeconds,
nextCountdownSeconds,
} from "./office-scene.js"; } from "./office-scene.js";
import { import {
DEMO_TICKET_COUNTS, DEMO_TICKET_COUNTS,
@ -1024,6 +1026,25 @@ function setApiBanner(visible) {
} }
} }
// Rein kosmetischer Countdown im Header (Issue #12): zaehlt unabhaengig vom tatsaechlichen
// SSE-/Polling-Takt von MONITOR_SYNC_INTERVAL (aus config.js, siehe office.html) auf 0
// herunter und startet danach von vorn - loest selbst keinen Reload aus.
function startReloadCountdown() {
const countdownEl = document.getElementById("reload-countdown");
if (!countdownEl) {
return;
}
const intervalSeconds = resolveReloadIntervalSeconds(window.MONITOR_SYNC_INTERVAL);
let remaining = intervalSeconds;
countdownEl.textContent = `${remaining}s`;
window.setInterval(() => {
remaining = nextCountdownSeconds(remaining, intervalSeconds);
countdownEl.textContent = `${remaining}s`;
}, 1000);
}
async function boot() { async function boot() {
const container = document.getElementById("office-scene"); const container = document.getElementById("office-scene");
if (!container) { if (!container) {
@ -1064,4 +1085,5 @@ async function boot() {
document.addEventListener("DOMContentLoaded", () => { document.addEventListener("DOMContentLoaded", () => {
boot(); boot();
startReloadCountdown();
}); });

View file

@ -538,3 +538,30 @@ export function advanceDoorVisitStage(stage) {
} }
return DOOR_VISIT_STAGES[index + 1]; return DOOR_VISIT_STAGES[index + 1];
} }
// ---------------------------------------------------------------------------------------
// Reload-Countdown im Header (Issue #12): rein kosmetischer Sekundenzaehler, der die
// Sync-Frequenz des Backends (MONITOR_SYNC_INTERVAL, siehe .env.example) im Header
// widerspiegelt - ohne selbst irgendetwas neu zu laden (siehe office-render.js).
// ---------------------------------------------------------------------------------------
export const DEFAULT_RELOAD_INTERVAL_SECONDS = 600;
/**
* @param {unknown} rawValue - z.B. window.MONITOR_SYNC_INTERVAL (aus config.js).
* @returns {number} gueltige Intervall-Laenge in Sekunden, faellt auf den Default zurueck,
* wenn der Rohwert fehlt oder keine positive Zahl ist.
*/
export function resolveReloadIntervalSeconds(rawValue) {
const parsed = Number(rawValue);
return Number.isFinite(parsed) && parsed > 0 ? Math.round(parsed) : DEFAULT_RELOAD_INTERVAL_SECONDS;
}
/**
* @param {number} current - aktueller Zaehlerstand in Sekunden.
* @param {number} intervalSeconds - Intervall-Laenge, auf die nach Ablauf zurueckgesetzt wird.
* @returns {number} naechster Zaehlerstand (zaehlt herunter, springt bei <=1 zurueck auf den vollen Wert).
*/
export function nextCountdownSeconds(current, intervalSeconds) {
return current <= 1 ? intervalSeconds : current - 1;
}

View file

@ -9,22 +9,19 @@
</head> </head>
<body> <body>
<header class="office-header"> <header class="office-header">
<h1>Buero</h1> <h1>Agent-Status</h1>
<p> <span id="reload-countdown" class="reload-countdown" aria-hidden="true"></span>
Kanban-Board, Monitor-Wand und Schreibtische zeigen live die Daten aus der
<a href="/API.md">REST-API</a> (Push-Updates per Server-Sent Events, Fallback:
Polling). Klick auf das Board oder einen Monitor oeffnet die Detailansicht. Ist
die API nicht erreichbar, zeigt die Szene ersatzweise Demo-Daten.
</p>
</header> </header>
<div id="api-status-banner" class="api-banner" role="alert" hidden>API nicht erreichbar!</div> <div id="api-status-banner" class="api-banner" role="alert" hidden>API nicht erreichbar!</div>
<main id="office-scene" aria-label="Buero-Szene"></main> <main id="office-scene" aria-label="Buero-Szene"></main>
<!-- Laufzeit-Konfiguration (API-Basis-URL): wird vom Webserver aus der Umgebungsvariable <!-- Laufzeit-Konfiguration (API-Basis-URL, Sync-Intervall fuer den kosmetischen
MONITOR_API_BASE erzeugt, siehe docker/nginx/default.conf.template und .env.example. Reload-Countdown): wird vom Webserver aus den Umgebungsvariablen MONITOR_API_BASE
Muss vor dem Modul stehen - ein klassisches Script laeuft synchron vor jedem Modul. --> und MONITOR_SYNC_INTERVAL erzeugt, siehe docker/nginx/default.conf.template und
.env.example. Muss vor dem Modul stehen - ein klassisches Script laeuft synchron vor
jedem Modul. -->
<script src="/config.js"></script> <script src="/config.js"></script>
<script type="module" src="js/office-render.js"></script> <script type="module" src="js/office-render.js"></script>
</body> </body>

View file

@ -31,6 +31,9 @@ import {
detectDoorVisits, detectDoorVisits,
resolveDoorVisitVisual, resolveDoorVisitVisual,
advanceDoorVisitStage, advanceDoorVisitStage,
DEFAULT_RELOAD_INTERVAL_SECONDS,
resolveReloadIntervalSeconds,
nextCountdownSeconds,
} from "../../html/js/office-scene.js"; } from "../../html/js/office-scene.js";
test("buildBoardColumnLayout liefert alle Spalten in API.md-Reihenfolge", () => { test("buildBoardColumnLayout liefert alle Spalten in API.md-Reihenfolge", () => {
@ -404,3 +407,23 @@ test("advanceDoorVisitStage durchlaeuft alle Stages und liefert am Ende null", (
test("advanceDoorVisitStage liefert bei unbekannter Stage null", () => { test("advanceDoorVisitStage liefert bei unbekannter Stage null", () => {
assert.equal(advanceDoorVisitStage("unbekannt"), null); assert.equal(advanceDoorVisitStage("unbekannt"), null);
}); });
test("resolveReloadIntervalSeconds uebernimmt gueltige positive Zahlen (auch als String)", () => {
assert.equal(resolveReloadIntervalSeconds(30), 30);
assert.equal(resolveReloadIntervalSeconds("45"), 45);
assert.equal(resolveReloadIntervalSeconds(12.4), 12);
});
test("resolveReloadIntervalSeconds faellt bei fehlendem/ungueltigem Wert auf den Default zurueck", () => {
assert.equal(resolveReloadIntervalSeconds(undefined), DEFAULT_RELOAD_INTERVAL_SECONDS);
assert.equal(resolveReloadIntervalSeconds(null), DEFAULT_RELOAD_INTERVAL_SECONDS);
assert.equal(resolveReloadIntervalSeconds(0), DEFAULT_RELOAD_INTERVAL_SECONDS);
assert.equal(resolveReloadIntervalSeconds(-5), DEFAULT_RELOAD_INTERVAL_SECONDS);
assert.equal(resolveReloadIntervalSeconds("nicht-numerisch"), DEFAULT_RELOAD_INTERVAL_SECONDS);
});
test("nextCountdownSeconds zaehlt herunter und springt bei 1 auf das volle Intervall zurueck", () => {
assert.equal(nextCountdownSeconds(30, 30), 29);
assert.equal(nextCountdownSeconds(2, 30), 1);
assert.equal(nextCountdownSeconds(1, 30), 30);
});