Files
ssh-jumphost/static/js/workspace.js
2026-09-02 20:30:44 +02:00

533 lines
20 KiB
JavaScript

/*
* 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=<id> (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));
})();