533 lines
20 KiB
JavaScript
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));
|
|
})();
|