Phase 6+7 der Neuausrichtung: Tuer-Besuche der Rolle "user", alte Tabellenansicht entfernt

Phase 6 (F2): detectDoorVisits() erkennt die drei Anlaesse aus den Detailantworten
(neues per Codeberg-Sync angelegtes Backlog-Ticket, Ticket erreicht "fertig", Ticket
erreicht "done") durch Diff zweier Ticket-Snapshots. office-render.js laesst dafuer
einen "user"-Besucher durch die linke Tuer hereinkommen, zum Board laufen, kurz
interagieren und wieder hinausgehen - unabhaengig vom Daten-Refresh-Takt, damit ein
Besuch auch bei einem zwischenzeitlichen Refresh sauber zu Ende animiert.

Phase 7 (F16): html/mon.html, script.js und styles.css sind entfallen ("kann
entfallen" laut Detailantworten); check_fds.sh/mail_fds.py/status.txt speisen
weiterhin unveraendert den System-Monitor. Unit-Tests fuer die neue Tuer-Logik
ergaenzt (weiterhin 100% Line-Coverage, node --test). E2E-Tests bleiben bewusst
ausserhalb dieser Phase.

Ausserdem: update.sh (holt die neueste Version vom konfigurierten Git-Remote und
faehrt die Docker-Compose-Entwicklungsumgebung hoch) und register-agent.sh
(Hilfsskript zur Agenten-Registrierung per API) ergaenzt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
fds2610-bot 2026-07-28 21:58:29 +02:00
parent f383ce2d33
commit 9aa6836bc6
11 changed files with 444 additions and 333 deletions

View file

@ -21,19 +21,22 @@ die dazugehörigen Klärungen in [Detailfragen.md](Detailfragen.md) und
siehe Visualization.md Abschnitt 9). Ist die API nicht erreichbar, zeigt die Szene
ersatzweise Demo-Daten.
- 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)
eine Datenquelle für den System-Monitor; die alte tabellarische Statusseite ist mit
Phase 7 entfallen (F16), `status.txt` speist weiterhin den System-Monitor
---
## 📁 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/`
- `html/` — Frontend: `office.html` (Büro-Visualisierung), `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)
- `update.sh` — holt die neueste Version vom konfigurierten Git-Remote und aktualisiert die
Docker-Compose-Umgebung (Abhängigkeiten, Images, Dienste, Tests)
- `register-agent.sh` — Hilfsskript, um einen neuen Agenten per `POST /api/v1/agents` zu
registrieren (siehe [API.md](API.md) Abschnitt "Agenten")
---

View file

@ -255,13 +255,21 @@ externe Datenbankserver (nur PHP/Python-Standardbibliothek) auskommen, um die
(F8), eine `AnimationQueue` serialisiert Update-Animationen bei vielen gleichzeitigen
Ereignissen (F9).
7. **Phase 6 – Externe Rollen & Türen**: Betreten/Verlassen durch Türen für Kunden/User-Events.
Noch offen - Phase 5 platziert Agenten ohne Schreibtisch (Rolle `user`, Ueberlauf) bereits
an einem Ruheplatz vor der linken Tuer, ein echtes Betreten/Verlassen mit Tuer-Animation
anlaesslich konkreter Backlog/Fertig/Done-Ereignisse (F2) ist aber noch nicht umgesetzt.
8. **Phase 7 – Tests & Feinschliff**: Unit-/E2E-Testabdeckung komplettieren, Migration/
Abschaltung der alten Tabellenansicht klären ([F16]). Noch offen - insbesondere die in
Abschnitt 8 vorgesehenen Playwright-E2E-Tests fuer Board-/Monitor-Interaktion fehlen noch,
`html/mon.html` besteht laut F16 weiterhin bis zur expliziten Abschaltung.
✅ erledigt: `detectDoorVisits()` (`html/js/office-scene.js`) erkennt die drei Anlaesse aus
F2 (neues per Codeberg-Sync angelegtes Backlog-Ticket, Ticket erreicht `fertig`, Ticket
erreicht `done`) durch Diff zweier Ticket-Snapshots; `office-render.js` laesst dafuer einen
`user`-Besucher an der linken Tuer erscheinen, zum Board laufen, kurz interagieren und
wieder hinausgehen (`DOOR_VISIT_STAGES`), unabhaengig vom Daten-Refresh-Takt. Die Tuer
leuchtet waehrenddessen kurz auf (`.door-active`). Registrierte `user`-Agenten (Ueberlauf,
Phase 5) bleiben wie bisher an ihrem festen Ruheplatz vor der Tuer stehen, wenn sie nicht
gerade einen Besuch absolvieren.
8. **Phase 7 – Tests & Feinschliff**: Unit-Testabdeckung komplettieren, Migration/Abschaltung
der alten Tabellenansicht klären ([F16]). ✅ erledigt: `html/mon.html`/`script.js`/
`styles.css` sind entfallen (F16: "kann entfallen"), `check_fds.sh`/`mail_fds.py`/
`status.txt` speisen weiterhin unveraendert den System-Monitor. Unit-Tests fuer die
Phase-6-Logik sind in `tests/frontend/office-scene.test.js` ergaenzt (weiterhin 100 %
Line-Coverage). E2E-Tests mit Playwright sind bewusst noch nicht Teil dieser Phase (siehe
Abgrenzung im zugehoerigen Issue) und bleiben ein offener Folgeschritt.
Jede Phase liefert ein für sich lauffähiges Zwischenergebnis, sodass die Umwidmung
schrittweise (und überprüfbar per PR) erfolgen kann, statt in einem Großumbau.

View file

@ -142,6 +142,12 @@ header.office-header p {
fill: var(--color-door);
stroke: #5f86ab;
stroke-width: 3;
transition: fill 0.4s ease-in-out;
}
/* Phase 6 (F2): kurzes Aufleuchten, waehrend ein "user"-Tuerbesuch die Tuer passiert. */
.door-active {
fill: #d7ecff;
}
.wall-label {

View file

@ -29,6 +29,10 @@ import {
resolveAgentVisualState,
pickIdleActivity,
AnimationQueue,
detectDoorVisits,
resolveDoorVisitVisual,
advanceDoorVisitStage,
DOOR_VISIT_STAGES,
} from "./office-scene.js";
import {
DEMO_TICKET_COUNTS,
@ -64,10 +68,14 @@ const NOTE_STEP_Y = 56;
const BOARD_FEET = { x: BOARD_FRAME.x + BOARD_FRAME.w / 2, y: BOARD_FRAME.y + BOARD_FRAME.h + 40 };
const MONITOR_FEET = { x: MONITOR_WALL.x + MONITOR_WALL.w / 2, y: MONITOR_WALL.y + MONITOR_WALL.h + 40 };
const COFFEE_FEET = { x: COFFEE_CORNER.x, y: COFFEE_CORNER.y + 26 };
// Ruheplatz der Tuerbesucher (Phase 6, F2): direkt vor der linken Tuer, wie schon der
// Ueberlaufplatz fuer Agenten ohne Schreibtisch (siehe agentPlacements()).
const DOOR_FEET = { x: 20, y: DOOR.y + DOOR.h / 2 };
const IDLE_MIN_DELAY_MS = 15000;
const IDLE_MAX_DELAY_MS = 40000;
const IDLE_ACTIVITY_DURATION_MS = 8000;
const DOOR_VISIT_STAGE_DURATION_MS = 2200;
function el(tag, attrs = {}, children = []) {
const node = document.createElementNS(SVG_NS, tag);
@ -94,9 +102,9 @@ function renderRoom() {
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: 0, y: DOOR.y, width: DOOR.w, height: DOOR.h, class: "door door-left" }));
g.appendChild(
el("rect", { x: VIEW_W - DOOR.w, y: DOOR.y, width: DOOR.w, height: DOOR.h, class: "door" })
el("rect", { x: VIEW_W - DOOR.w, y: DOOR.y, width: DOOR.w, height: DOOR.h, class: "door door-right" })
);
g.appendChild(renderCoffeeCorner());
return g;
@ -511,6 +519,8 @@ function feetForLocation(location, homeFeet) {
return MONITOR_FEET;
case AGENT_LOCATIONS.COFFEE_CORNER:
return COFFEE_FEET;
case AGENT_LOCATIONS.DOOR:
return DOOR_FEET;
case AGENT_LOCATIONS.DESK:
default:
return homeFeet;
@ -662,6 +672,88 @@ function scheduleIdleCosmeticsForCurrentAgents(placements) {
});
}
// ---------------------------------------------------------------------------------------
// Phase 6 - Tuerbesucher (F2: Rolle "user" kommt nur anlassbezogen durch die linke Tuer,
// siehe detectDoorVisits() in office-scene.js). Laeuft unabhaengig vom Daten-Refresh-Takt
// ueber eigene Timer, damit ein Besuch auch dann sauber zu Ende animiert, wenn zwischendurch
// ein neuer SSE-/Polling-Refresh hereinkommt (siehe repaintAgentsLayer()).
// ---------------------------------------------------------------------------------------
let currentAgentsLayer = null;
let currentRegularPlacements = [];
const doorVisitors = new Map();
let doorVisitCounter = 0;
function doorVisitVisual(visitor) {
if (visitor.atDoor) {
return { location: AGENT_LOCATIONS.DOOR, pose: "walking" };
}
return resolveDoorVisitVisual(visitor.stage);
}
function doorVisitorPlacements() {
return Array.from(doorVisitors.values()).map((visitor) => {
const visual = doorVisitVisual(visitor);
return {
agent: { id: visitor.id, name: "User", role: "user", pose: visual.pose, location: visual.location },
homeFeet: DOOR_FEET,
};
});
}
// Kurzes visuelles Feedback (Tuer "leuchtet" auf), waehrend mindestens ein Besucher am
// Tuerplatz steht - rein kosmetisch, siehe .door-active in office.css.
function updateDoorActiveClass() {
if (!currentAgentsLayer) return;
const svg = currentAgentsLayer.closest("svg");
const doorLeft = svg && svg.querySelector(".door-left");
if (!doorLeft) return;
const anyAtDoor = Array.from(doorVisitors.values()).some(
(visitor) => doorVisitVisual(visitor).location === AGENT_LOCATIONS.DOOR
);
doorLeft.classList.toggle("door-active", anyAtDoor);
}
function repaintAgentsLayer() {
if (!currentAgentsLayer) return;
updateAgentsLayer(currentAgentsLayer, [...currentRegularPlacements, ...doorVisitorPlacements()]);
updateDoorActiveClass();
}
function advanceDoorVisit(id) {
const visitor = doorVisitors.get(id);
if (!visitor) {
return;
}
if (visitor.atDoor) {
visitor.atDoor = false;
repaintAgentsLayer();
window.setTimeout(() => advanceDoorVisit(id), DOOR_VISIT_STAGE_DURATION_MS);
return;
}
const nextStage = advanceDoorVisitStage(visitor.stage);
if (nextStage === null) {
doorVisitors.delete(id);
repaintAgentsLayer();
return;
}
visitor.stage = nextStage;
repaintAgentsLayer();
window.setTimeout(() => advanceDoorVisit(id), DOOR_VISIT_STAGE_DURATION_MS);
}
/** @param {{type: string, ticketId: number}} visit */
function triggerDoorVisit(visit) {
doorVisitCounter += 1;
const id = `door-visit-${visit.type}-${visit.ticketId}-${doorVisitCounter}`;
doorVisitors.set(id, { id, stage: DOOR_VISIT_STAGES[0], atDoor: true });
repaintAgentsLayer();
window.setTimeout(() => advanceDoorVisit(id), DOOR_VISIT_STAGE_DURATION_MS);
}
// ---------------------------------------------------------------------------------------
// Modal-Inhalte (Phase 3/4 - Board-/Monitor-Klick, siehe Visualization.md 4.5)
// ---------------------------------------------------------------------------------------
@ -826,6 +918,7 @@ export function renderOfficeScene(container, data = {}) {
agentAvatarElements.clear();
idleCosmeticTimers.forEach((handle) => window.clearTimeout(handle.timeoutId));
idleCosmeticTimers.clear();
doorVisitors.clear();
}
const groupedTickets = groupTicketsByColumn(tickets);
@ -867,8 +960,9 @@ export function renderOfficeScene(container, data = {}) {
}
const placements = agentPlacements(agents, deskLayout);
const agentsLayer = svg.querySelector(".agents-layer");
updateAgentsLayer(agentsLayer, placements);
currentAgentsLayer = svg.querySelector(".agents-layer");
currentRegularPlacements = placements;
repaintAgentsLayer();
scheduleIdleCosmeticsForCurrentAgents(placements);
}
@ -925,9 +1019,18 @@ async function boot() {
return;
}
// Vorheriger Ticket-Snapshot fuer detectDoorVisits() (Phase 6, F2) - bleibt null im
// Demo-Fallback (kein "echter" Anlass ohne Live-API) und wird nur bei erreichbarer API
// gepflegt, damit die Tuerbesuche ausschliesslich auf echten Zustandswechseln beruhen.
let previousTickets = null;
async function refresh() {
try {
const data = await loadLiveData();
if (previousTickets) {
detectDoorVisits(previousTickets, data.tickets).forEach(triggerDoorVisit);
}
previousTickets = data.tickets;
renderOfficeScene(container, {
ticketCounts: aggregateTicketsByColumn(data.tickets),
tickets: data.tickets,

View file

@ -328,6 +328,7 @@ export const AGENT_LOCATIONS = {
MONITOR_SYSTEMS: "monitor-systems",
MONITOR_KPI: "monitor-kpi",
COFFEE_CORNER: "coffee-corner",
DOOR: "door",
};
// Zustandsmaschine wie in Visualization.md 4.4 vorgeschlagen: jeder von der API gemeldete
@ -447,3 +448,93 @@ export class AnimationQueue {
return ranCount;
}
}
// ---------------------------------------------------------------------------------------
// Phase 6 - Externe Rollen & Tueren (siehe Visualization.md Abschnitt 9, Detailantworten F2)
// ---------------------------------------------------------------------------------------
/**
* Anlaesse, zu denen die Rolle "user" laut Detailantworten F2 durch die Tuer hereinkommt.
* Der Text auf den Klebezetteln bleibt weiterhin absichtlich unlesbar - der Anlass dient nur
* dazu, den richtigen kurzen Auftritt (Board-Besuch) auszuloesen.
*/
export const DOOR_VISIT_TYPES = {
// Neues Ticket im Backlog, das nicht von einer Rolle dort abgelegt wurde (F4: Backlog wird
// per Codeberg-Sync befuellt) - der User "traegt" es symbolisch aufs Board.
NEW_BACKLOG_TICKET: "new_backlog_ticket",
// Ticket erreicht Spalte "fertig" - der User schaut es sich an.
TICKET_READY_FOR_REVIEW: "ticket_ready_for_review",
// Ticket erreicht Spalte "done" (geschlossen) - der User legt es auf den Done-Stapel.
TICKET_DONE: "ticket_done",
};
/**
* Erkennt Tuerbesuch-Anlaesse (F2) durch Vergleich zweier Ticketlisten-Snapshots
* (z. B. zwei aufeinanderfolgende GET /api/v1/tickets-Antworten). Reine Diff-Logik ohne
* DOM-/Zeit-Abhaengigkeit, daher hier statt in office-render.js - so bleibt sie mit dem
* Node-Testrunner pruefbar (siehe Teststrategie in Visualization.md Abschnitt 8).
*
* @param {Array<{id: number, column_name: string, source?: string}>} previousTickets
* @param {Array<{id: number, column_name: string, source?: string}>} currentTickets
* @returns {Array<{type: string, ticketId: number}>}
*/
export function detectDoorVisits(previousTickets = [], currentTickets = []) {
const previousById = new Map(previousTickets.map((ticket) => [ticket.id, ticket]));
const visits = [];
for (const ticket of currentTickets) {
const before = previousById.get(ticket.id);
if (!before) {
if (ticket.column_name === "backlog" && ticket.source === "codeberg") {
visits.push({ type: DOOR_VISIT_TYPES.NEW_BACKLOG_TICKET, ticketId: ticket.id });
}
continue;
}
if (before.column_name === ticket.column_name) {
continue;
}
if (ticket.column_name === "fertig") {
visits.push({ type: DOOR_VISIT_TYPES.TICKET_READY_FOR_REVIEW, ticketId: ticket.id });
} else if (ticket.column_name === "done") {
visits.push({ type: DOOR_VISIT_TYPES.TICKET_DONE, ticketId: ticket.id });
}
}
return visits;
}
// Ablauf eines Tuerbesuchs (F2): zum Board laufen, dort kurz interagieren, wieder hinaus zur
// Tuer gehen. Der Anlass (DOOR_VISIT_TYPES) unterscheidet nur den Grund, nicht den Ablauf.
export const DOOR_VISIT_STAGES = ["walking_to_board", "interacting_with_board", "leaving"];
const DOOR_VISIT_STAGE_VISUALS = {
walking_to_board: { location: AGENT_LOCATIONS.BOARD, pose: "walking" },
interacting_with_board: { location: AGENT_LOCATIONS.BOARD, pose: "interacting" },
leaving: { location: AGENT_LOCATIONS.DOOR, pose: "walking" },
};
/**
* @param {string} stage - Wert aus DOOR_VISIT_STAGES.
* @returns {{location: string, pose: string}}
*/
export function resolveDoorVisitVisual(stage) {
return DOOR_VISIT_STAGE_VISUALS[stage] ?? DOOR_VISIT_STAGE_VISUALS[DOOR_VISIT_STAGES[0]];
}
/**
* Schaltet einen Tuerbesuch zur naechsten Stage weiter.
*
* @param {string} stage - aktuelle Stage aus DOOR_VISIT_STAGES.
* @returns {string|null} naechste Stage, oder null, wenn der Besuch abgeschlossen ist
* (Avatar verlaesst danach die Szene wieder durch die Tuer).
*/
export function advanceDoorVisitStage(stage) {
const index = DOOR_VISIT_STAGES.indexOf(stage);
if (index === -1 || index === DOOR_VISIT_STAGES.length - 1) {
return null;
}
return DOOR_VISIT_STAGES[index + 1];
}

View file

@ -1,82 +0,0 @@
<!DOCTYPE html>
<meta http-equiv="Refresh" content="60">
<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 Server-Status</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<div class="hamburger" id="hamburger">☰</div>
<div class="nav-menu" id="navMenu">
<span class="close-btn" id="closeBtn">×</span>
<a href="#" id="homeLink">Startseite</a>
<a href="#" id="settingsLink">Einstellungen</a>
</div>
<div id="content">
<h1>Server-Status &Uuml;bersicht</h1>
<table id="serverTable">
<thead>
<tr>
<th>Hostname</th>
<th>IP-Adresse</th>
<th>Status</th>
<th>Letzte Prüfung</th>
</tr>
</thead>
<tbody id="serverData">
<!-- Daten werden per JavaScript eingefügt -->
</tbody>
</table>
<div id="timezonestring"></div>
<div id ="datumuhrzeit"onload="currentTime()"></div>
</div>
<div id="settingsPage" style="display: none;">
<h1>Einstellungen</h1>
<form class="settings-form" id="timezoneForm">
<label for="timezone">Zeitzone:</label>
<select id="timezone" name="timezone">
<option value="UTC">UTC</option>
<option value="Europe/Berlin">UTC+1 / UTC+2 (Berlin, MEZ/MESZ)</option>
<option value="UTC">UTC (Koordinierte Weltzeit)</option>
<option value="Europe/London">UTC+0 / UTC+1 (London, GMT/BST)</option>
<option value="Europe/Berlin">UTC+1 / UTC+2 (Berlin, MEZ/MESZ)</option>
<option value="Europe/Moscow">UTC+3 (Moskau, MSK)</option>
<option value="Asia/Dubai">UTC+4 (Dubai, GST)</option>
<option value="Asia/Karachi">UTC+5 (Karachi, PKT)</option>
<option value="Asia/Dhaka">UTC+6 (Dhaka, BST)</option>
<option value="Asia/Bangkok">UTC+7 (Bangkok, ICT)</option>
<option value="Asia/Shanghai">UTC+8 (Shanghai, CST)</option>
<option value="Asia/Tokyo">UTC+9 (Tokio, JST)</option>
<option value="Australia/Sydney">UTC+10 / UTC+11 (Sydney, AEST/AEDT)</option>
<option value="Pacific/Auckland">UTC+12 / UTC+13 (Auckland, NZST/NZDT)</option>
<option value="Pacific/Guadalcanal">UTC+11 (Guadalcanal, SBT)</option>
<option value="Asia/Kamchatka">UTC+12 (Kamtschatka, PETT)</option>
<option value="Pacific/Chatham">UTC+12:45 (Chatham, CHAST/CHADT)</option>
<option value="America/New_York">UTC-5 / UTC-4 (New York, EST/EDT)</option>
<option value="America/Chicago">UTC-6 / UTC-5 (Chicago, CST/CDT)</option>
<option value="America/Denver">UTC-7 / UTC-6 (Denver, MST/MDT)</option>
<option value="America/Los_Angeles">UTC-8 / UTC-7 (Los Angeles, PST/PDT)</option>
<option value="America/Anchorage">UTC-9 / UTC-8 (Anchorage, AKST/AKDT)</option>
<option value="Pacific/Honolulu">UTC-10 (Honolulu, HST)</option>
<option value="Pacific/Pago_Pago">UTC-11 (Pago Pago, SST)</option>
<option value="Pacific/Kiritimati">UTC+14 (Kiritimati, LINT)</option>
<option value="Atlantic/Azores">UTC-1 / UTC+0 (Azoren, AZOT/AZOST)</option>
<option value="Atlantic/Cape_Verde">UTC-1 (Kap Verde, CVT)</option>
</select>
<button type="submit">Speichern</button>
</form>
</div>
<script src="script.js"></script>
</body>
</html>

View file

@ -1,118 +0,0 @@
// Navigation
document.getElementById('hamburger').addEventListener('click', function() {
document.getElementById('navMenu').style.display = 'block';
});
document.getElementById('closeBtn').addEventListener('click', function() {
document.getElementById('navMenu').style.display = 'none';
});
document.getElementById('homeLink').addEventListener('click', function(e) {
e.preventDefault();
document.getElementById('content').style.display = 'block';
document.getElementById('settingsPage').style.display = 'none';
document.getElementById('navMenu').style.display = 'none';
});
document.getElementById('settingsLink').addEventListener('click', function(e) {
e.preventDefault();
document.getElementById('content').style.display = 'none';
document.getElementById('settingsPage').style.display = 'block';
document.getElementById('navMenu').style.display = 'none';
});
// Timezone Cookie
function getCookie(name) {
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) return parts.pop().split(';').shift();
}
function setCookie(name, value, days = 365) {
const date = new Date();
date.setTime(date.getTime() + (days * 24 * 60 * 60 * 1000));
document.cookie = `${name}=${value}; expires=${date.toUTCString()}; path=/`;
}
function loadTimezone() {
const savedTimezone = getCookie('userTimezone');
if (savedTimezone) {
document.getElementById('timezone').value = savedTimezone;
}
}
document.getElementById('timezoneForm').addEventListener('submit', function(e) {
e.preventDefault();
const timezone = document.getElementById('timezone').value;
setCookie('userTimezone', timezone);
alert('Zeitzone gespeichert!');
loadServerData();
});
// Serverdaten laden
function loadServerData() {
fetch('status.txt')
.then(response => response.text())
.then(data => {
const lines = data.split('\n');
const tbody = document.getElementById('serverData');
tbody.innerHTML = '';
const userTimezone = getCookie('userTimezone') || 'UTC';
lines.forEach(line => {
if (line.trim() === '') return;
const [hostname, ip, status, checkdat, checktim] = line.split(' ');
const tr = document.createElement('tr');
const isoString = `${checkdat}T${checktim}Z`;
const date = new Date(isoString);
const formatter = new Intl.DateTimeFormat('de-DE', {
timeZone: userTimezone,
dateStyle: 'short',
timeStyle: 'medium',
hour12: false
});
const localTimeStr = formatter.format(date);
tr.innerHTML = `
<td>${hostname}</td>
<td>${ip}</td>
<td><span class="status ${status.toLowerCase()}">${status}</span></td>
<td class="last-check">${localTimeStr} (${userTimezone})</td>
`;
tbody.appendChild(tr);
});
})
.catch(error => {
console.error('Fehler beim Laden der status.conf:', error);
document.getElementById('serverData').innerHTML = `
<tr>
<td colspan="4" style="text-align: center; color: red;">
Fehler beim Laden der Serverdaten.
</td>
</tr>
`;
});
}
// Aktuelle Uhrzeit
function currentTime() {
let date = new Date();
let dd = date.getDate();
let m = date.getMonth() + 1;
let yy = date.getFullYear();
let hh = date.getHours();
let mm = date.getMinutes();
let ss = date.getSeconds();
dd = (dd < 10) ? "0" + dd : dd;
m = (m < 10) ? "0" + m : m;
hh = (hh < 10) ? "0" + hh : hh;
mm = (mm < 10) ? "0" + mm : mm;
ss = (ss < 10) ? "0" + ss : ss;
let time = dd + "." + m + "." + yy + "\u00A0\u00A0\u00A0\u00A0" + hh + ":" + mm + ":" + ss + " Uhr";
document.getElementById("datumuhrzeit").innerText = time;
let t = setTimeout(function(){ currentTime() }, 1000);
}
// Initialisierung
document.addEventListener('DOMContentLoaded', function() {
loadTimezone();
loadServerData();
const timezoneElement = document.getElementById('timezonestring');
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
timezoneElement.innerText = timezone;
// console.log(timezone);
currentTime();
});

View file

@ -1,118 +0,0 @@
body {
font-family: Arial, sans-serif;
line-height: 1.6;
margin: 0;
padding: 20px;
color: #333;
}
h1 {
text-align: center;
color: #2c3e50;
}
table {
width: 100%;
border-collapse: collapse;
margin: 20px 0;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
}
th, td {
padding: 12px 15px;
text-align: left;
border-bottom: 1px solid #ddd;
}
th {
background-color: #3498db;
color: white;
}
tr:nth-child(even) {
background-color: #f2f2f2;
}
tr:hover {
background-color: #e3f2fd;
}
.status {
padding: 5px 10px;
border-radius: 4px;
font-weight: bold;
text-align: center;
}
.online {
background-color: #2ecc71;
color: white;
}
.offline {
background-color: #e74c3c;
color: white;
}
.unknown {
background-color: #f39c12;
color: white;
}
.last-check {
font-size: 0.9em;
color: #7f8c8d;
}
/* Navigation */
.hamburger {
cursor: pointer;
font-size: 24px;
position: fixed;
top: 15px;
left: 15px;
z-index: 1000;
}
.nav-menu {
display: none;
position: fixed;
top: 0;
left: 0;
width: 250px;
height: 100%;
background-color: #2c3e50;
color: white;
z-index: 999;
padding-top: 60px;
}
.nav-menu a {
color: white;
text-decoration: none;
display: block;
padding: 15px;
border-bottom: 1px solid #34495e;
}
.nav-menu a:hover {
background-color: #3498db;
}
.close-btn {
position: absolute;
top: 15px;
right: 15px;
font-size: 24px;
cursor: pointer;
}
.settings-form {
padding: 20px;
max-width: 500px;
margin: 0 auto;
}
.settings-form label {
display: block;
margin-bottom: 5px;
}
.settings-form select {
width: 100%;
padding: 8px;
margin-bottom: 15px;
}
.settings-form button {
background-color: #3498db;
color: white;
border: none;
padding: 10px 15px;
cursor: pointer;
}
@media (max-width: 768px) {
.hamburger {
display: block;
}
}

50
register-agent.sh Executable file
View file

@ -0,0 +1,50 @@
#!/bin/bash
#
# Registriert einen neuen Agenten bei der monitor-REST-API (POST /api/v1/agents).
# Siehe API.md Abschnitt "Agenten" fuer Details.
#
# Wichtig: Der zurueckgegebene API-Key wird nur bei dieser Registrierung einmalig
# ausgegeben - unbedingt sicher speichern, er laesst sich danach nicht erneut abrufen.
set -euo pipefail
# --- Hier die Variablen fuer die Registrierung eintragen ---------------------
# Basis-URL der laufenden API (z.B. lokal via "docker compose up")
BASE_URL="http://localhost:8080"
# Admin-Key aus MONITOR_ADMIN_API_KEY (siehe .env)
ADMIN_API_KEY=""
# Anzeigename des neuen Agenten
DISPLAY_NAME=""
# Rolle: entwickler | product_owner | qa | user
ROLE="entwickler"
# -------------------------------------------------------------------------------
if [ -z "$ADMIN_API_KEY" ]; then
echo "Fehler: ADMIN_API_KEY ist nicht gesetzt." >&2
exit 1
fi
if [ -z "$DISPLAY_NAME" ]; then
echo "Fehler: DISPLAY_NAME ist nicht gesetzt." >&2
exit 1
fi
response=$(curl -sS -w '\n%{http_code}' -X POST "${BASE_URL}/api/v1/agents" \
-H "Content-Type: application/json" \
-H "X-Admin-Key: ${ADMIN_API_KEY}" \
-d "{\"display_name\": \"${DISPLAY_NAME}\", \"role\": \"${ROLE}\"}")
http_code=$(echo "$response" | tail -n1)
body=$(echo "$response" | sed '$d')
echo "$body"
if [ "$http_code" != "201" ]; then
echo "Fehler: HTTP $http_code" >&2
exit 1
fi

View file

@ -26,6 +26,11 @@ import {
normalizeAgentForDisplay,
pickIdleActivity,
AnimationQueue,
DOOR_VISIT_TYPES,
DOOR_VISIT_STAGES,
detectDoorVisits,
resolveDoorVisitVisual,
advanceDoorVisitStage,
} from "../../html/js/office-scene.js";
test("buildBoardColumnLayout liefert alle Spalten in API.md-Reihenfolge", () => {
@ -310,3 +315,92 @@ test("AnimationQueue fuehrt Tasks mit Mindestabstand aus (Warteschlange bei Buer
assert.deepEqual(ran, ["a", "b", "c"]);
assert.equal(queue.size, 0);
});
// ---------------------------------------------------------------------------------------
// Phase 6 - Externe Rollen & Tueren (F2)
// ---------------------------------------------------------------------------------------
test("detectDoorVisits erkennt ein neues, per Codeberg-Sync angelegtes Backlog-Ticket", () => {
const visits = detectDoorVisits([], [{ id: 1, column_name: "backlog", source: "codeberg" }]);
assert.deepEqual(visits, [{ type: DOOR_VISIT_TYPES.NEW_BACKLOG_TICKET, ticketId: 1 }]);
});
test("detectDoorVisits ignoriert neue Backlog-Tickets, die nicht per Codeberg-Sync kamen", () => {
const visits = detectDoorVisits([], [{ id: 1, column_name: "backlog", source: "api" }]);
assert.deepEqual(visits, []);
});
test("detectDoorVisits ignoriert neue Tickets ausserhalb von Backlog (von einer Rolle angelegt)", () => {
const visits = detectDoorVisits([], [{ id: 1, column_name: "bereit", source: "api" }]);
assert.deepEqual(visits, []);
});
test("detectDoorVisits erkennt ein Ticket, das die Spalte fertig erreicht", () => {
const before = [{ id: 5, column_name: "qa", source: "api" }];
const after = [{ id: 5, column_name: "fertig", source: "api" }];
assert.deepEqual(detectDoorVisits(before, after), [
{ type: DOOR_VISIT_TYPES.TICKET_READY_FOR_REVIEW, ticketId: 5 },
]);
});
test("detectDoorVisits erkennt ein Ticket, das geschlossen (done) wird", () => {
const before = [{ id: 7, column_name: "testing", source: "api" }];
const after = [{ id: 7, column_name: "done", source: "api" }];
assert.deepEqual(detectDoorVisits(before, after), [{ type: DOOR_VISIT_TYPES.TICKET_DONE, ticketId: 7 }]);
});
test("detectDoorVisits ignoriert Tickets ohne Spaltenwechsel", () => {
const before = [{ id: 1, column_name: "in_arbeit", source: "api" }];
const after = [{ id: 1, column_name: "in_arbeit", source: "api" }];
assert.deepEqual(detectDoorVisits(before, after), []);
});
test("detectDoorVisits verarbeitet mehrere gleichzeitige Anlaesse in einem Diff", () => {
const before = [
{ id: 1, column_name: "qa", source: "api" },
{ id: 2, column_name: "testing", source: "api" },
];
const after = [
{ id: 1, column_name: "fertig", source: "api" },
{ id: 2, column_name: "done", source: "api" },
{ id: 3, column_name: "backlog", source: "codeberg" },
];
const visits = detectDoorVisits(before, after);
assert.equal(visits.length, 3);
assert.deepEqual(visits, [
{ type: DOOR_VISIT_TYPES.TICKET_READY_FOR_REVIEW, ticketId: 1 },
{ type: DOOR_VISIT_TYPES.TICKET_DONE, ticketId: 2 },
{ type: DOOR_VISIT_TYPES.NEW_BACKLOG_TICKET, ticketId: 3 },
]);
});
test("resolveDoorVisitVisual bildet jede Stage auf Ort/Pose ab", () => {
assert.deepEqual(resolveDoorVisitVisual("walking_to_board"), {
location: AGENT_LOCATIONS.BOARD,
pose: "walking",
});
assert.deepEqual(resolveDoorVisitVisual("interacting_with_board"), {
location: AGENT_LOCATIONS.BOARD,
pose: "interacting",
});
assert.deepEqual(resolveDoorVisitVisual("leaving"), { location: AGENT_LOCATIONS.DOOR, pose: "walking" });
});
test("resolveDoorVisitVisual faellt bei unbekannter Stage auf die erste Stage zurueck", () => {
assert.deepEqual(resolveDoorVisitVisual("unbekannt"), resolveDoorVisitVisual(DOOR_VISIT_STAGES[0]));
});
test("advanceDoorVisitStage durchlaeuft alle Stages und liefert am Ende null", () => {
let stage = DOOR_VISIT_STAGES[0];
const seen = [stage];
for (let i = 0; i < DOOR_VISIT_STAGES.length; i++) {
stage = advanceDoorVisitStage(stage);
if (stage !== null) seen.push(stage);
}
assert.deepEqual(seen, DOOR_VISIT_STAGES);
assert.equal(advanceDoorVisitStage(DOOR_VISIT_STAGES[DOOR_VISIT_STAGES.length - 1]), null);
});
test("advanceDoorVisitStage liefert bei unbekannter Stage null", () => {
assert.equal(advanceDoorVisitStage("unbekannt"), null);
});

74
update.sh Normal file
View file

@ -0,0 +1,74 @@
#!/bin/bash
#
# Holt die neueste Version aus dem konfigurierten Git-Repo und fuehrt die noetigen Updates
# fuer die Docker-Compose-Entwicklungs-/Laufzeitumgebung durch (siehe README.md/Visualization.md
# Abschnitt "Entwicklung nur mit Docker"): PHP-Abhaengigkeiten aktualisieren, Images neu bauen,
# Dienste neu starten, Testsuiten laufen lassen. SQLite-Migrationen brauchen keinen eigenen
# Schritt - api/src/Db/Database::migrate() wendet sie bei jedem API-Start automatisch an.
#
# Konfiguration ueber Umgebungsvariablen (Defaults passen fuer den ueblichen Fall):
# UPDATE_REMOTE - Git-Remote, aus dem geholt wird (Default: origin)
# UPDATE_BRANCH - Branch, der aktualisiert wird (Default: aktuell ausgecheckter Branch)
# UPDATE_RUN_TESTS - "0" setzen, um die Testsuiten nach dem Update zu ueberspringen (Default: 1)
set -euo pipefail
REPO_DIR="$(cd "$(dirname "$(readlink -f "$0")")" && pwd)"
cd "$REPO_DIR"
log() {
printf '[update.sh] %s\n' "$1"
}
fail() {
printf '[update.sh] Fehler: %s\n' "$1" >&2
exit 1
}
command -v git >/dev/null 2>&1 || fail "git ist nicht installiert."
command -v docker >/dev/null 2>&1 || fail "docker ist nicht installiert."
git -C "$REPO_DIR" rev-parse --is-inside-work-tree >/dev/null 2>&1 \
|| fail "$REPO_DIR ist kein Git-Repository."
UPDATE_REMOTE="${UPDATE_REMOTE:-origin}"
UPDATE_BRANCH="${UPDATE_BRANCH:-$(git rev-parse --abbrev-ref HEAD)}"
UPDATE_RUN_TESTS="${UPDATE_RUN_TESTS:-1}"
if [ -n "$(git status --porcelain)" ]; then
fail "Lokale Aenderungen/unversionierte Dateien vorhanden - bitte erst committen/stashen. Breche ab, um nichts zu ueberschreiben."
fi
log "Hole Aenderungen von ${UPDATE_REMOTE}/${UPDATE_BRANCH}..."
git fetch "$UPDATE_REMOTE" "$UPDATE_BRANCH"
# --ff-only statt reset --hard: bricht kontrolliert ab, falls der lokale Branch abweicht
# (z. B. eigene, noch nicht gepushte Commits), statt Historie zu ueberschreiben.
git checkout "$UPDATE_BRANCH"
git pull --ff-only "$UPDATE_REMOTE" "$UPDATE_BRANCH"
DOCKER_COMPOSE="docker compose"
if ! docker compose version >/dev/null 2>&1; then
command -v docker-compose >/dev/null 2>&1 || fail "Weder 'docker compose' noch 'docker-compose' verfuegbar."
DOCKER_COMPOSE="docker-compose"
fi
log "Aktualisiere PHP-Abhaengigkeiten (composer install)..."
$DOCKER_COMPOSE run --rm api composer install --no-interaction --no-progress
log "Baue Docker-Images neu (falls Dockerfile/Abhaengigkeiten sich geaendert haben)..."
$DOCKER_COMPOSE build
log "Starte Dienste neu (api, web)..."
$DOCKER_COMPOSE up -d api web
if [ "$UPDATE_RUN_TESTS" = "1" ]; then
log "Fuehre PHPUnit-Tests (API) aus..."
$DOCKER_COMPOSE run --rm api composer test
log "Fuehre Frontend-Tests aus..."
$DOCKER_COMPOSE run --rm frontend
else
log "UPDATE_RUN_TESTS=0 gesetzt - ueberspringe Testsuiten."
fi
log "Update abgeschlossen (${UPDATE_BRANCH} @ $(git rev-parse --short HEAD))."