/* * Arbeitsflaeche (Umsetzungsauftrag_Sonnet5.md Teil F.3): Seitenleiste + * Hauptbereich. Nutzt dieselben Bausteine wie /terminal/{id} und /rdp/{id} * (SshTerminalSession/RdpSession aus terminal.js/rdp.js, Stufe F1) -- keine * zweite Implementierung (F.2). * * Stufe F4 (Umsetzungsauftrag_Sonnet5.md Teil F.3.2-F.3.4): mehrere * gleichzeitige Sitzungen. Jede geoeffnete Sitzung bleibt als eigene * SshTerminalSession/RdpSession-Instanz im DOM bestehen (this.element wird * NICHT entfernt, wenn man wegschaltet) -- nur ueber die bestehende * `.hidden`-Klasse (app.css, CSP-konformer Sichtbarkeits-Toggle wie an * anderer Stelle im Projekt) ein-/ausgeblendet. Genau eine ist gleichzeitig * sichtbar/aktiv (setActive(true)); alle anderen bleiben im Hintergrund * voll verbunden (Betreiberentscheidung dieser Session, siehe * FORTSETZUNG_Teil_F.md Abschnitt 0 Punkt 2 -- kein Pausenzustand). * * Bekannter Rest-Umfang dieser Stufe (siehe FORTSETZUNG_Teil_F.md): * - Server-Sitzungs-ID-Zuordnung ist "best effort" (claimServerSessionId * unten), weil terminal_ws.py/ws_tunnel.py dem Client seine eigene * session_id an keiner Stelle mitteilen (siehe F3-Limitierung). Loest * das Duplikat-/Verwechslungsrisiko fuer den Normalfall zuverlaessig, * mit einem sehr kleinen, dokumentierten Restrisiko bei echten * Wettlaeufen (siehe unten). * - Keine Live-Aktualisierung der Seitenleiste fuer Sitzungen, die in * ANDEREN Tabs geoeffnet/beendet werden, waehrend diese Seite laeuft -- * nur bei Laden/Sitzungsende dieser Seite neu abgefragt (kein Polling). * * Stufe F5 (Umsetzungsauftrag_Sonnet5.md Teil F.3.8 + Feinschliff): * - Obergrenzen sichtbar: GET /catalog/session-limits liefert die * konfigurierten Werte (app/config.py) plus den aktuellen Stand: dieselbe * Logik wie die serverseitige Durchsetzung in terminal_ws.py/ * ws_tunnel.py (E.4), NICHT neu erfunden -- nur zum Anzeigen VOR dem * Verbindungsversuch. "+ Neue Sitzung" und jeder "Verbinden"-Knopf im * Katalog werden deaktiviert, sobald eine der beiden Grenzen erreicht * ist. Die serverseitige Durchsetzung (WS-Code 4429) bleibt die * verbindliche Kontrolle -- diese Anzeige ist rein informativ und kann * durch die inhaerente Race zwischen Anzeige-Refresh und tatsaechlichem * Verbindungsaufbau leicht veralten (siehe unten, wie bei * claimServerSessionId: gleiche Klasse von Eventual-Consistency). * - Sammelzustand-Kennzeichnung: eine Hintergrundsitzung mit Fehler oder * Trennung wird auf ihrer Kachel UND in einer Sammel-Zeile ueber der * Kachelliste markiert, damit man das Problem nicht erst durch * Umschalten auf jede einzelne Sitzung entdeckt. */ (() => { "use strict"; const tilesEl = document.getElementById("workspace-tiles"); const catalogViewEl = document.getElementById("workspace-catalog-view"); const sessionsAreaEl = document.getElementById("workspace-sessions-area"); const newBtn = document.getElementById("workspace-new-btn"); const whoamiEl = document.getElementById("whoami"); const limitHintEl = document.getElementById("workspace-limit-hint"); const attentionSummaryEl = document.getElementById("workspace-attention-summary"); let cachedHosts = []; let remoteSessions = []; const claimedSessionIds = new Set(); const openSessions = []; // { uid, instance, hostId, hostname, protocol, startedAtMs, statusText, isError, elapsedSpan, tileEl, serverId } let activeUid = null; // uid der gerade sichtbaren Sitzung, oder null (Katalog sichtbar) let nextUid = 1; let elapsedTimer = null; let sessionLimits = null; // { max_per_user, current_user_count, user_limit_applies, at_user_limit, max_global, current_global_count, at_global_limit } async function getJson(url, opts) { const res = await fetch(url, { credentials: "same-origin", ...(opts || {}) }); if (res.status === 401) { window.location.href = "/"; throw new Error("nicht angemeldet"); } return res.json(); } function findOpenSession(uid) { return openSessions.find((s) => s.uid === uid) || null; } // --- Server-Sitzungs-ID best effort zuordnen ------------------------- async function claimServerSessionId(record) { for (let attempt = 0; attempt < 5; attempt++) { try { const rows = await getJson("/catalog/sessions?active_only=true"); const candidates = rows .filter((r) => r.host_id === record.hostId && !claimedSessionIds.has(r.id)) .sort((a, b) => new Date(b.started_at) - new Date(a.started_at)); if (candidates.length > 0) { record.serverId = candidates[0].id; claimedSessionIds.add(candidates[0].id); return; } } catch (err) { console.error("Sitzungs-ID konnte nicht abgefragt werden:", err); } await new Promise((resolve) => window.setTimeout(resolve, 250)); } console.warn( `Server-Sitzungs-ID fuer ${record.hostname} konnte nicht ermittelt werden (best effort -- ` + "siehe FORTSETZUNG_Teil_F.md, Stufe F4, bekannte Limitierung)." ); } // --- Obergrenzen (F5, F.3.8) ------------------------------------------- // // Nur eine Anzeige der bereits bestehenden serverseitigen Durchsetzung // (E.4, terminal_ws.py/ws_tunnel.py) -- siehe Datei-Kopfkommentar. async function refreshSessionLimits() { try { sessionLimits = await getJson("/catalog/session-limits"); } catch (err) { console.error("Obergrenzen konnten nicht geladen werden:", err); sessionLimits = null; } } function atAnyLimit() { return !!sessionLimits && (sessionLimits.at_user_limit || sessionLimits.at_global_limit); } function renderLimitHint() { if (!limitHintEl) return; if (!sessionLimits) { limitHintEl.textContent = ""; limitHintEl.classList.remove("workspace-limit-hint--at-limit"); } else { const parts = []; if (sessionLimits.user_limit_applies) { parts.push(`Eigene Sitzungen: ${sessionLimits.current_user_count}/${sessionLimits.max_per_user}`); } parts.push(`Server gesamt: ${sessionLimits.current_global_count}/${sessionLimits.max_global}`); limitHintEl.textContent = parts.join(" · "); limitHintEl.classList.toggle("workspace-limit-hint--at-limit", atAnyLimit()); } const atLimit = atAnyLimit(); newBtn.disabled = atLimit; newBtn.title = atLimit ? "Obergrenze erreicht (F.3.8) -- erst eine bestehende Sitzung schliessen." : ""; } // --- Sidebar ---------------------------------------------------------- function statusClass(text, isError) { if (isError) return "error"; if (text === "Verbunden") return "connected"; if (text === "Getrennt" || text.startsWith("Verbindung beendet")) return "disconnected"; return "connecting"; } // F5: eine Hintergrundsitzung mit Fehler oder Trennung braucht // Aufmerksamkeit, ohne dass man erst umschaltet, um es zu sehen. function hasAttention(record) { const cls = statusClass(record.statusText, record.isError); return cls === "error" || cls === "disconnected"; } function formatElapsed(startedAtMs) { const secs = Math.max(0, Math.floor((Date.now() - startedAtMs) / 1000)); const m = Math.floor(secs / 60); const s = secs % 60; return `${m}:${String(s).padStart(2, "0")}`; } function buildLocalTile(record) { const isActive = record.uid === activeUid; const tile = document.createElement("div"); tile.className = "workspace-tile " + (isActive ? "workspace-tile-active" : "workspace-tile-background"); tile.tabIndex = 0; const top = document.createElement("div"); top.className = "workspace-tile-top"; const dot = document.createElement("span"); dot.className = "workspace-status-dot " + statusClass(record.statusText, record.isError); top.appendChild(dot); const name = document.createElement("span"); name.className = "workspace-tile-hostname"; name.textContent = record.hostname; top.appendChild(name); const proto = document.createElement("span"); proto.className = "badge"; proto.textContent = record.protocol.toUpperCase(); top.appendChild(proto); tile.appendChild(top); const meta = document.createElement("div"); meta.className = "workspace-tile-meta"; const elapsedSpan = document.createElement("span"); elapsedSpan.className = "workspace-tile-elapsed"; elapsedSpan.textContent = formatElapsed(record.startedAtMs); meta.appendChild(elapsedSpan); const statusSpan = document.createElement("span"); let bgLabel = "im Hintergrund verbunden"; if (!isActive && hasAttention(record)) { tile.classList.add("workspace-tile-attention"); bgLabel = statusClass(record.statusText, record.isError) === "error" ? "im Hintergrund: Fehler" : "im Hintergrund: getrennt"; } statusSpan.textContent = isActive ? record.statusText : bgLabel; meta.appendChild(statusSpan); tile.appendChild(meta); const closeBtn = document.createElement("button"); closeBtn.type = "button"; closeBtn.className = "btn-secondary btn-small workspace-tile-close-btn"; closeBtn.textContent = "Schliessen"; closeBtn.addEventListener("click", (ev) => { ev.stopPropagation(); closeLocalSession(record.uid); }); tile.appendChild(closeBtn); if (!isActive) { tile.addEventListener("click", () => showSession(record.uid)); tile.addEventListener("keydown", (ev) => { if (ev.key === "Enter" || ev.key === " ") showSession(record.uid); }); } record.elapsedSpan = elapsedSpan; return tile; } function buildRemoteTile(session) { const tile = document.createElement("div"); tile.className = "workspace-tile workspace-tile-remote"; const top = document.createElement("div"); top.className = "workspace-tile-top"; const dot = document.createElement("span"); dot.className = "workspace-status-dot connected"; top.appendChild(dot); const name = document.createElement("span"); name.className = "workspace-tile-hostname"; name.textContent = session.hostname; top.appendChild(name); const proto = document.createElement("span"); proto.className = "badge"; proto.textContent = session.protocol.toUpperCase(); top.appendChild(proto); tile.appendChild(top); const meta = document.createElement("div"); meta.className = "workspace-tile-meta"; meta.textContent = "laeuft in einem anderen Tab/Fenster"; tile.appendChild(meta); const closeBtn = document.createElement("button"); closeBtn.type = "button"; closeBtn.className = "btn-secondary btn-small workspace-tile-close-btn"; closeBtn.textContent = "Beenden"; closeBtn.addEventListener("click", async () => { closeBtn.disabled = true; closeBtn.textContent = "Wird beendet ..."; try { const res = await fetch(`/catalog/sessions/${session.id}/terminate`, { method: "POST", credentials: "same-origin", }); if (!res.ok && res.status !== 409) throw new Error(`HTTP ${res.status}`); } catch (err) { console.error("Sitzung konnte nicht beendet werden:", err); } await Promise.all([refreshRemoteSessions(), refreshSessionLimits()]); renderSidebar(); }); tile.appendChild(closeBtn); return tile; } function renderSidebar() { renderLimitHint(); // F5: Sammelzustand -- Hintergrundsitzungen mit Fehler/Trennung ueber // der Kachelliste zusammenfassen (die aktive Sitzung zaehlt nicht mit, // deren Zustand sieht man ohnehin direkt). if (attentionSummaryEl) { const attentionCount = openSessions.filter((r) => r.uid !== activeUid && hasAttention(r)).length; if (attentionCount > 0) { attentionSummaryEl.textContent = attentionCount === 1 ? "⚠ 1 Sitzung im Hintergrund mit Problem (Fehler/getrennt)." : `⚠ ${attentionCount} Sitzungen im Hintergrund mit Problem (Fehler/getrennt).`; attentionSummaryEl.classList.remove("hidden"); } else { attentionSummaryEl.textContent = ""; attentionSummaryEl.classList.add("hidden"); } } tilesEl.innerHTML = ""; for (const record of openSessions) { tilesEl.appendChild(buildLocalTile(record)); } for (const s of remoteSessions) { tilesEl.appendChild(buildRemoteTile(s)); } if (openSessions.length === 0 && remoteSessions.length === 0) { const empty = document.createElement("div"); empty.className = "hint workspace-empty-hint"; empty.textContent = "Keine offenen Sitzungen."; tilesEl.appendChild(empty); } if (elapsedTimer) { window.clearInterval(elapsedTimer); elapsedTimer = null; } if (openSessions.length > 0) { elapsedTimer = window.setInterval(() => { for (const record of openSessions) { if (record.elapsedSpan) record.elapsedSpan.textContent = formatElapsed(record.startedAtMs); } }, 1000); } } async function refreshRemoteSessions() { try { const rows = await getJson("/catalog/sessions?active_only=true"); // Best-effort-Filter: alle bereits als lokal geoeffnet zugeordneten // Server-Sitzungen (claimServerSessionId) duerfen NICHT zusaetzlich // als "laeuft anderswo" auftauchen -- sonst erschiene jede eigene // Sitzung dieser Seite doppelt. remoteSessions = rows.filter((r) => !claimedSessionIds.has(r.id)); } catch (err) { console.error("Eigene Sitzungen konnten nicht geladen werden:", err); remoteSessions = []; } } // --- Hauptbereich: Hostkatalog --------------------------------------- function hostCard(host) { const div = document.createElement("div"); div.className = "host-card"; const name = document.createElement("div"); name.className = "hostname"; name.textContent = host.hostname; div.appendChild(name); const meta = document.createElement("div"); meta.className = "meta"; meta.textContent = `${host.protocol.toUpperCase()} · ${host.os_type} · ${host.address}`; div.appendChild(meta); const actions = document.createElement("div"); actions.className = "actions"; const connectBtn = document.createElement("button"); connectBtn.type = "button"; connectBtn.textContent = "Verbinden"; if (atAnyLimit()) { // F5 (F.3.8): Obergrenze bereits VOR dem Verbindungsversuch sichtbar // machen, statt den Nutzer erst beim WS-Handshake mit Code 4429 // abzuweisen (serverseitige Durchsetzung bleibt unveraendert, siehe // Datei-Kopfkommentar). connectBtn.disabled = true; connectBtn.title = "Obergrenze erreicht (F.3.8) -- erst eine bestehende Sitzung schliessen."; } connectBtn.addEventListener("click", () => startSession(host)); actions.appendChild(connectBtn); div.appendChild(actions); return div; } function renderCatalog() { catalogViewEl.innerHTML = ""; const wrap = document.createElement("div"); wrap.className = "container wide workspace-catalog"; const heading = document.createElement("h2"); heading.textContent = "Host waehlen"; wrap.appendChild(heading); if (cachedHosts.length === 0) { const hint = document.createElement("p"); hint.className = "hint"; hint.textContent = "Keine Hosts zugewiesen. Bitte an einen Administrator wenden."; wrap.appendChild(hint); } else { const list = document.createElement("div"); list.className = "host-list"; for (const host of cachedHosts) list.appendChild(hostCard(host)); wrap.appendChild(list); } catalogViewEl.appendChild(wrap); } // --- Umschalten zwischen Katalog und Sitzungen ------------------------- function showCatalog() { activeUid = null; for (const record of openSessions) { record.instance.element.classList.add("hidden"); record.instance.setActive(false); } sessionsAreaEl.classList.add("hidden"); catalogViewEl.classList.remove("hidden"); renderCatalog(); renderSidebar(); } function showSession(uid) { const target = findOpenSession(uid); if (!target) return; activeUid = uid; for (const record of openSessions) { const visible = record.uid === uid; record.instance.element.classList.toggle("hidden", !visible); // F4 (F.3.2/F.3.3): setActive() steuert sowohl den Tastatur-/Paste- // Filter (RdpSession) als auch den Refit beim Sichtbarwerden (beide // Klassen) -- siehe terminal.js/rdp.js. record.instance.setActive(visible); } catalogViewEl.classList.add("hidden"); sessionsAreaEl.classList.remove("hidden"); renderSidebar(); } // --- Sitzung starten/beenden ------------------------------------------- function startSession(host) { const uid = nextUid++; const options = { onStatusChange: (text, isError) => { const record = findOpenSession(uid); if (!record) return; record.statusText = text; record.isError = isError; renderSidebar(); }, onExitRequested: () => { // Der interne Exit-Knopf der Sitzung selbst wurde geklickt -- dessen // dispose() lief bereits, hier nur noch den Arbeitsflaechen-Zustand // nachziehen (kein zweiter dispose()-Aufruf). handleSessionClosed(uid, /* alreadyDisposed */ true); }, }; const instance = host.protocol === "rdp" ? new RdpSession(host.id, options) : new SshTerminalSession(host.id, options); const record = { uid, instance, hostId: host.id, hostname: host.hostname, protocol: host.protocol, startedAtMs: Date.now(), statusText: "Verbinde ...", isError: false, serverId: null, }; openSessions.push(record); instance.element.classList.add("hidden"); sessionsAreaEl.appendChild(instance.element); instance.connect(); claimServerSessionId(record); showSession(uid); // F5: Obergrenzen-Anzeige nachziehen -- der Server registriert die neue // Sitzung (app/security/active_sessions.py::register) erst, nachdem der // WS-Handshake durchgelaufen ist, daher dieselbe kleine Verzoegerung wie // bei handleSessionClosed() unten statt eines sofortigen (noch // veralteten) Refreshs. window.setTimeout(() => { refreshSessionLimits().then(() => renderSidebar()); }, 300); } function closeLocalSession(uid) { const record = findOpenSession(uid); if (!record) return; record.instance.dispose(); // idempotent handleSessionClosed(uid, /* alreadyDisposed */ true); } function handleSessionClosed(uid, alreadyDisposed) { const idx = openSessions.findIndex((s) => s.uid === uid); if (idx === -1) return; const [record] = openSessions.splice(idx, 1); if (!alreadyDisposed) record.instance.dispose(); if (record.serverId !== null) claimedSessionIds.delete(record.serverId); if (activeUid === uid) { const next = openSessions[openSessions.length - 1]; if (next) { showSession(next.uid); } else { showCatalog(); } } else { renderSidebar(); } // Kleine Verzoegerung: der serverseitige finally-Block (WS-Handler) // setzt ended_at erst, nachdem der Socket-Close tatsaechlich verarbeitet // wurde -- ohne Verzoegerung koennte die soeben beendete Sitzung noch // kurz als aktiv gelistet werden (unkritisch, naechster Refresh raeumt // es auf, siehe FORTSETZUNG_Teil_F.md). window.setTimeout(() => { Promise.all([refreshRemoteSessions(), refreshSessionLimits()]).then(() => renderSidebar()); }, 300); } newBtn.addEventListener("click", () => showCatalog()); // --- Start ------------------------------------------------------------ document.getElementById("logout-btn").addEventListener("click", async () => { await fetch("/auth/logout", { method: "POST", credentials: "same-origin" }); window.location.href = "/"; }); async function main() { const me = await getJson("/auth/me"); whoamiEl.textContent = `${me.username}${me.is_admin ? " (Admin)" : ""}`; cachedHosts = await getJson("/catalog/hosts"); await refreshRemoteSessions(); await refreshSessionLimits(); renderCatalog(); renderSidebar(); // ?host= (von dashboard.js "Verbinden" gesetzt): direkt verbinden // statt erst den Katalog zu zeigen. const params = new URLSearchParams(window.location.search); const hostParam = params.get("host"); if (hostParam) { const host = cachedHosts.find((h) => String(h.id) === hostParam); if (host) { startSession(host); return; } } showCatalog(); } main().catch((err) => console.error(err)); })();