umbau 1.0

This commit is contained in:
2026-09-02 20:30:44 +02:00
parent afe6719f51
commit 5c95b21be7
77 changed files with 10733 additions and 1914 deletions

View File

@ -53,6 +53,8 @@ button {
cursor: pointer;
}
button:hover { filter: brightness(1.1); }
button:disabled { opacity: 0.5; cursor: not-allowed; }
button:disabled:hover { filter: none; }
.error { color: var(--danger); font-size: 0.85rem; margin-top: 0.75rem; min-height: 1em; }
.hint { color: var(--muted); font-size: 0.8rem; margin-top: 0.5rem; }
.qr { display: block; margin: 1rem auto; border-radius: 6px; }
@ -187,16 +189,90 @@ pre.log-view {
.endpoint-row .summary { color: var(--muted); font-size: 0.8rem; margin-left: auto; }
.endpoint-detail { padding: 0 0 0.75rem 4.1rem; }
.session-shell { display: flex; flex-direction: column; height: 100vh; }
/* Aufgabe #11: Anker fuer .term-paste-menu weiter unten (position:absolute
dagegen, statt gegen den ganzen Viewport) -- rein CSS-basierte
Positionierung, siehe terminal.js::_showPasteMenu(). */
.session-shell { display: flex; flex-direction: column; height: 100vh; position: relative; }
.session-toolbar {
display: flex; align-items: center; gap: 0.75rem; padding: 0.5rem 0.9rem;
background: var(--panel); border-bottom: 1px solid var(--border);
}
.session-toolbar button { width: auto; margin: 0; padding: 0.35rem 0.8rem; font-size: 0.8rem; }
.session-toolbar select { width: auto; margin: 0; padding: 0.3rem 0.5rem; font-size: 0.8rem; }
.session-toolbar .spacer { flex: 1; }
.session-toolbar .status { font-size: 0.8rem; color: var(--muted); }
#terminal, #rdp-display { flex: 1; background: black; }
#rdp-display canvas { display: block; margin: 0 auto; }
/* Aufgabe #11 (SSH Copy-Paste): Ruecksicherungs-Kontextmenue fuer Einfuegen
per Rechtsklick, nur wenn der direkte Clipboard-Zugriff ohne explizite
Benutzergeste verweigert wurde (terminal.js::_attemptDirectPaste()). Vier
feste Ecken statt Cursor-Koordinaten -- CSP (style-src 'self') verbietet
JS-gesetzte element.style-Werte, siehe Kommentar dort. */
.term-paste-menu {
position: absolute; z-index: 50; background: var(--panel); border: 1px solid var(--border);
border-radius: 6px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4); padding: 0.25rem; min-width: 140px;
}
.term-paste-menu-top { top: 3.2rem; }
.term-paste-menu-bottom { bottom: 0.5rem; }
.term-paste-menu-left { left: 0.5rem; }
.term-paste-menu-right { right: 0.5rem; }
.term-paste-menu button {
width: 100%; margin: 0; padding: 0.4rem 0.7rem; text-align: left; background: transparent;
color: var(--text); font-weight: 400; font-size: 0.85rem; border-radius: 4px;
}
.term-paste-menu button:hover { background: var(--border); filter: none; }
.session-toolbar .status.warning { color: #f5b942; }
/* F1 (Umsetzungsauftrag_Sonnet5.md Teil F.3.1): vormals #terminal/#rdp-display
(feste, dokumentweit eindeutige IDs -- eine Seite hatte immer genau eine
Sitzung). Jetzt Klassen, weil static/js/terminal.js bzw. rdp.js ihr DOM
dynamisch je Instanz bauen (SshTerminalSession/RdpSession) -- mit
mehreren gleichzeitigen Instanzen (kuenftige Arbeitsflaeche /workspace,
Teil F.3, spaetere Stufe) waeren feste IDs nicht mehr eindeutig. */
.session-terminal, .session-rdp-display { flex: 1; background: black; }
.session-rdp-display canvas { display: block; margin: 0 auto; }
/* B.1 (Umsetzungsauftrag Teil B): Fehler-Overlay ueber .session-rdp-display,
statt einer 0,85rem-Randnotiz in der Toolbar auf sonst komplett schwarzem
Grund (siehe .error weiter oben, .status.error unten). */
.session-rdp-display { position: relative; }
.rdp-error-overlay {
position: absolute;
inset: 0;
z-index: 20;
display: flex;
align-items: center;
justify-content: center;
background: rgba(15, 17, 21, 0.92);
padding: 1rem;
}
.rdp-error-panel {
background: var(--panel);
border: 1px solid var(--danger);
border-radius: 10px;
padding: 1.75rem 2rem;
width: 100%;
max-width: 480px;
}
.rdp-error-panel h2 {
margin: 0 0 0.75rem;
font-size: 1.15rem;
color: var(--danger);
}
.rdp-error-message {
margin: 0 0 1rem;
font-size: 0.95rem;
line-height: 1.4;
word-break: break-word;
}
.rdp-error-meta {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.25rem 0.75rem;
margin: 0 0 1.25rem;
font-size: 0.8rem;
}
.rdp-error-meta dt { color: var(--muted); }
.rdp-error-meta dd { margin: 0; font-family: monospace; word-break: break-all; }
.rdp-error-panel button { margin-top: 0; }
/* --- Dateitransfer-Panel (Terminal-Sitzung) ---------------------------- */
@ -242,3 +318,67 @@ pre.log-view {
.rec-controls input[type=range] { flex: 1; }
.rec-controls select { width: auto; padding: 0.3rem 0.4rem; }
.rec-controls .hint { margin: 0; white-space: nowrap; }
/* --- Arbeitsflaeche (F3, Umsetzungsauftrag_Sonnet5.md Teil F.3) --------- */
.workspace-layout { display: flex; height: calc(100vh - 53px); /* 53px ~ .topbar */ }
.workspace-sidebar {
width: 280px; flex-shrink: 0; background: var(--panel); border-right: 1px solid var(--border);
display: flex; flex-direction: column; overflow-y: auto;
}
.workspace-sidebar-header { padding: 1rem 1rem 0.5rem; }
.workspace-sidebar-header h2 {
font-size: 0.9rem; margin: 0 0 0.4rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--muted);
}
.workspace-sidebar-header .hint { margin: 0; font-size: 0.75rem; }
/* F5 (F.3.8): Obergrenzen-Anzeige -- normal wie jeder andere Hinweistext,
nur bei erreichter Grenze in Warnfarbe (dann ist auch "+ Neue Sitzung"
deaktiviert, siehe workspace.js renderLimitHint()). */
.workspace-limit-hint { margin-top: 0.3rem; }
.workspace-limit-hint--at-limit { color: var(--danger); }
/* F5: Sammelzustand-Kennzeichnung -- fasst Hintergrundsitzungen mit Fehler
oder Trennung zusammen, damit man sie nicht erst durch Umschalten auf
jede einzelne Kachel entdeckt. */
.workspace-attention-summary {
margin: 0 0.5rem 0.5rem; padding: 0.4rem 0.6rem; border-radius: 6px;
background: rgba(239, 68, 68, 0.15); border: 1px solid var(--danger);
color: var(--danger); font-size: 0.75rem;
}
.workspace-tiles { flex: 1; padding: 0.5rem; display: flex; flex-direction: column; gap: 0.5rem; }
.workspace-empty-hint { padding: 0.5rem; }
.workspace-tile {
background: var(--bg); border: 1px solid var(--border); border-radius: 8px; padding: 0.65rem 0.75rem;
}
.workspace-tile-active { border-color: var(--accent); }
/* F4: eine offene, aber gerade nicht sichtbare Sitzung -- anklickbar
(Umschalten), optisch etwas zurueckhaltender als die aktive Kachel. */
.workspace-tile-background { cursor: pointer; }
.workspace-tile-background:hover { border-color: var(--muted); }
/* F5: eine Hintergrundsitzung mit Fehler/Trennung -- sichtbar markiert,
ohne dass man erst umschalten muss (siehe workspace.js hasAttention()). */
.workspace-tile-attention { border-color: var(--danger); box-shadow: 0 0 0 1px var(--danger); }
.workspace-tile-remote { opacity: 0.85; }
.workspace-tile-top { display: flex; align-items: center; gap: 0.4rem; }
.workspace-tile-hostname { font-weight: 600; font-size: 0.85rem; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.workspace-status-dot {
width: 0.55rem; height: 0.55rem; border-radius: 50%; flex-shrink: 0; background: var(--muted);
}
.workspace-status-dot.connecting { background: #f5b942; }
.workspace-status-dot.connected { background: var(--ok); }
.workspace-status-dot.error { background: var(--danger); }
.workspace-status-dot.disconnected { background: var(--muted); }
.workspace-tile-meta {
display: flex; justify-content: space-between; gap: 0.5rem; color: var(--muted); font-size: 0.75rem;
margin: 0.3rem 0 0.5rem;
}
.workspace-tile-close-btn { width: 100%; margin: 0; }
.workspace-new-btn { margin: 0.5rem; width: calc(100% - 1rem); }
.workspace-main { flex: 1; overflow: auto; background: var(--bg); position: relative; height: 100%; }
.workspace-catalog-view, .workspace-sessions-area { height: 100%; }
/* F4: mehrere Sitzungs-Elemente liegen gleichzeitig in
#workspace-sessions-area -- jedes einzelne wird ueber die globale
.hidden-Klasse (siehe oben) ein-/ausgeblendet (auf dem Element selbst,
von workspace.js gesetzt), nicht der gemeinsame Container. */
.workspace-sessions-area .session-instance { height: 100%; }
.workspace-sessions-area .session-instance .session-shell { height: 100%; }
.workspace-catalog { padding-top: 1.5rem; }

View File

@ -1,13 +1,14 @@
(() => {
"use strict";
const ROLE_NAMES = [
"ssh_connect", "rdp_connect", "file_transfer", "clipboard",
"session_recording_view", "admin_hostgroup", "credentials_view", "credentials_manage",
];
// Wird beim ersten Aufruf von loadRolesTab() aus GET /admin/roles/names
// gefuellt (Teil D.6 Schritt 1: keine Hartkodierung mehr, siehe
// Umsetzungsauftrag_Sonnet5.md D.5 -- die Rollenliste soll aus der DB
// kommen, nicht aus dem Frontend-Code dupliziert werden).
let ROLE_NAMES = [];
const bannerBox = document.getElementById("banner-box");
let meInfo = { is_admin: false, tenant_admin_of: [] };
let meInfo = { is_admin: false };
function showBanner(message, type) {
const div = document.createElement("div");
@ -111,7 +112,6 @@
credentials: loadCredentialsTab,
roles: loadRolesTab,
tokens: loadTokensTab,
tenants: loadTenantsTab,
sessions: loadSessionsTab,
connlog: loadConnLogTab,
audit: loadAuditTab,
@ -139,28 +139,6 @@
}
});
// ---------------------------------------------------------------------
// Mandanten-Auswahlfelder (fuer Super-Admins Dropdown aller Mandanten,
// fuer Mandanten-Admins nur die eigenen -- kein Server-Request noetig,
// steht bereits in meInfo.tenant_admin_of).
// ---------------------------------------------------------------------
let cachedTenants = [];
async function populateTenantSelect(select) {
if (meInfo.is_admin) {
if (cachedTenants.length === 0) {
const result = await getJson("/admin/tenants");
cachedTenants = result;
}
select.replaceChildren(...optionsFromList(cachedTenants, "id", (t) => `${t.name} (#${t.id})`));
} else {
select.replaceChildren(
...optionsFromList(meInfo.tenant_admin_of, "id", (t) => `${t.name} (#${t.id})`)
);
}
}
// ---------------------------------------------------------------------
// Benutzer
// ---------------------------------------------------------------------
@ -173,16 +151,7 @@
let cachedUsers = [];
let editingUserId = null;
function tenantName(tenantId) {
if (tenantId === null || tenantId === undefined) return "-";
const t = cachedTenants.find((x) => x.id === tenantId);
return t ? t.name : `#${tenantId}`;
}
async function refreshUsers() {
if (meInfo.is_admin && cachedTenants.length === 0) {
cachedTenants = await getJson("/admin/tenants");
}
cachedUsers = await getJson("/admin/users");
const tbody = document.querySelector("#users-table tbody");
fillTable(
@ -217,7 +186,6 @@
el("td", { textContent: u.is_admin ? "ja" : "nein" }),
el("td", { textContent: u.is_active ? "ja" : "nein" }),
el("td", { textContent: u.totp_enrolled ? "ja" : "nein" }),
el("td", { textContent: tenantName(u.home_tenant_id) }),
el("td", { textContent: u.created_at }),
actions,
]);
@ -277,8 +245,6 @@
let editingGroupId = null;
async function loadGroupsTab() {
document.getElementById("gc-tenant-box").classList.toggle("hidden", !meInfo.is_admin && meInfo.tenant_admin_of.length <= 1);
await populateTenantSelect(document.getElementById("gc-tenant"));
await refreshGroups();
}
@ -292,7 +258,6 @@
el("td", { textContent: String(g.id) }),
el("td", { textContent: g.name }),
el("td", { textContent: g.description || "" }),
el("td", { textContent: g.tenant_name }),
el("td", { textContent: String(g.member_count) }),
el("td", {}, [
actionButton("Mitglieder", "btn-secondary", () => showGroupMembers(g)),
@ -367,9 +332,7 @@
try {
const name = document.getElementById("gc-name").value.trim();
const description = document.getElementById("gc-description").value.trim() || null;
const tenantSelect = document.getElementById("gc-tenant");
const tenant_id = tenantSelect.value ? Number(tenantSelect.value) : null;
await sendJson("/admin/user-groups", "POST", { name, description, tenant_id });
await sendJson("/admin/user-groups", "POST", { name, description });
showBanner(`Benutzergruppe '${name}' angelegt.`, "ok");
ev.target.reset();
await refreshGroups();
@ -400,12 +363,11 @@
let cachedHosts = [];
let cachedSshKeys = [];
let cachedRdpCredentials = [];
let cachedSshPasswordCredentials = [];
let activeHostId = null;
let editingHostGroupId = null;
async function loadHostsTab() {
document.getElementById("hgc-tenant-box").classList.toggle("hidden", !meInfo.is_admin && meInfo.tenant_admin_of.length <= 1);
await populateTenantSelect(document.getElementById("hgc-tenant"));
await Promise.all([refreshHostGroups(), refreshSshKeysCache(), refreshRdpCredentialsCache()]);
await refreshHosts();
}
@ -420,7 +382,6 @@
el("td", { textContent: String(g.id) }),
el("td", { textContent: g.name }),
el("td", { textContent: g.description || "" }),
el("td", { textContent: g.tenant_name }),
el("td", {}, [
actionButton("Bearbeiten", "btn-secondary", () => showHostGroupEdit(g)),
actionButton("Loeschen", "btn-danger", async () => {
@ -472,7 +433,6 @@
el("td", { textContent: h.protocol }),
el("td", { textContent: h.os_type }),
el("td", { textContent: groupName(h.host_group_id) }),
el("td", { textContent: h.tenant_name }),
el("td", {}, [el("span", { className: `badge ${h.is_active ? "ok" : "danger"}`, textContent: h.is_active ? "aktiv" : "inaktiv" })]),
el("td", {}, [
actionButton("Details", "btn-secondary", () => showHostDetail(h.id)),
@ -501,6 +461,10 @@
cachedRdpCredentials = await getJson("/admin/rdp-credentials");
}
async function refreshSshPasswordCredentialsCache() {
cachedSshPasswordCredentials = await getJson("/admin/ssh-password-credentials");
}
function setDetailBanner(message, type) {
const box = document.getElementById("host-detail-banner");
if (!message) {
@ -540,7 +504,7 @@
const host = await getJson(`/admin/hosts/${hostId}`);
activeHostId = host.id;
document.getElementById("host-detail-panel").classList.remove("hidden");
document.getElementById("host-detail-name").textContent = `${host.hostname} (#${host.id}, ${host.tenant_name})`;
document.getElementById("host-detail-name").textContent = `${host.hostname} (#${host.id})`;
document.getElementById("he-hostname").value = host.hostname;
document.getElementById("he-address").value = host.address;
@ -640,9 +604,7 @@
try {
const name = document.getElementById("hgc-name").value.trim();
const description = document.getElementById("hgc-description").value.trim() || null;
const tenantSelect = document.getElementById("hgc-tenant");
const tenant_id = tenantSelect.value ? Number(tenantSelect.value) : null;
await sendJson("/admin/host-groups", "POST", { name, description, tenant_id });
await sendJson("/admin/host-groups", "POST", { name, description });
showBanner(`Hostgruppe '${name}' angelegt.`, "ok");
ev.target.reset();
await refreshHostGroups();
@ -760,12 +722,6 @@
let editingSshKeyId = null;
async function loadCredentialsTab() {
document.getElementById("skc-tenant-box").classList.toggle("hidden", !meInfo.is_admin && meInfo.tenant_admin_of.length <= 1);
document.getElementById("rcc-tenant-box").classList.toggle("hidden", !meInfo.is_admin && meInfo.tenant_admin_of.length <= 1);
await Promise.all([
populateTenantSelect(document.getElementById("skc-tenant")),
populateTenantSelect(document.getElementById("rcc-tenant")),
]);
await Promise.all([refreshSshKeys(), refreshRdpCredentials()]);
}
@ -780,7 +736,6 @@
el("td", { textContent: k.label }),
el("td", { textContent: k.username || "-" }),
el("td", { textContent: k.key_type }),
el("td", { textContent: k.tenant_name }),
el("td", { textContent: k.has_passphrase ? "hinterlegt" : "-" }),
el("td", { textContent: k.created_at }),
el("td", { textContent: k.rotated_at || "-" }),
@ -899,7 +854,6 @@
document.getElementById("ssh-key-create-form").addEventListener("submit", async (ev) => {
ev.preventDefault();
try {
const tenantSelect = document.getElementById("skc-tenant");
const payload = {
label: document.getElementById("skc-label").value.trim(),
username: document.getElementById("skc-username").value.trim() || null,
@ -908,7 +862,6 @@
public_key: document.getElementById("skc-public").value,
// Leeres Feld -> null: der Schluessel gilt dann als unverschluesselt.
passphrase: document.getElementById("skc-passphrase").value || null,
tenant_id: tenantSelect.value ? Number(tenantSelect.value) : null,
};
await sendJson("/admin/ssh-keys", "POST", payload);
showBanner(`SSH-Key '${payload.label}' angelegt.`, "ok");
@ -932,7 +885,6 @@
el("td", { textContent: r.label }),
el("td", { textContent: r.username || "-" }),
el("td", { textContent: r.domain || "-" }),
el("td", { textContent: r.tenant_name }),
el("td", {
textContent: r.assigned_hosts.length
? r.assigned_hosts.map((h) => h.hostname).join(", ")
@ -998,13 +950,11 @@
document.getElementById("rdp-cred-create-form").addEventListener("submit", async (ev) => {
ev.preventDefault();
try {
const tenantSelect = document.getElementById("rcc-tenant");
const payload = {
label: document.getElementById("rcc-label").value.trim(),
username: document.getElementById("rcc-username").value.trim(),
domain: document.getElementById("rcc-domain").value.trim() || null,
password: document.getElementById("rcc-password").value,
tenant_id: tenantSelect.value ? Number(tenantSelect.value) : null,
};
await sendJson("/admin/rdp-credentials", "POST", payload);
showBanner(`RDP-Zugangsdaten '${payload.label}' angelegt.`, "ok");
@ -1020,13 +970,21 @@
// ---------------------------------------------------------------------
async function loadRolesTab() {
roleCheckboxGrid(document.getElementById("rg-role-grid"));
if (ROLE_NAMES.length === 0) {
ROLE_NAMES = await getJson("/admin/roles/names");
}
roleCheckboxGrid(document.getElementById("grg-role-grid"));
await refreshSharedSelects();
await Promise.all([refreshRoleGrants(), refreshGroupRoleGrants()]);
await Promise.all([refreshSshKeysCache(), refreshRdpCredentialsCache(), refreshSshPasswordCredentialsCache()]);
populateCredentialSelect();
await Promise.all([refreshRoleGrants(), refreshGroupRoleGrants(), refreshGroupCredentialGrants()]);
}
async function refreshRoleGrants() {
// Teil D Schritt 5: abgeleitete Sicht (GET /admin/roles liefert seither
// effektive Rechte statt einer Vergabe-Tabelle) -- nicht editierbar,
// daher keine Aktionen-Spalte mehr. Aendern laeuft ausschliesslich ueber
// die Gruppenvergabe (refreshGroupRoleGrants() unten).
const grants = await getJson("/admin/roles");
const tbody = document.querySelector("#role-grants-table tbody");
fillTable(
@ -1036,15 +994,8 @@
el("td", { textContent: g.username }),
el("td", { textContent: g.host_group_name }),
el("td", { textContent: g.role_name }),
el("td", { textContent: g.via_group_name }),
el("td", { textContent: g.expires_at || "-" }),
el("td", {}, [
actionButton("Entziehen", "btn-danger", async () => {
await sendJson("/admin/roles/revoke", "POST", {
user_id: g.user_id, host_group_id: g.host_group_id, role_name: g.role_name,
});
await refreshRoleGrants();
}),
]),
])
)
);
@ -1074,25 +1025,6 @@
);
}
document.getElementById("role-grant-form").addEventListener("submit", async (ev) => {
ev.preventDefault();
try {
const role_names = checkedValues(document.getElementById("rg-role-grid"));
if (role_names.length === 0) throw new Error("Mindestens eine Rolle auswaehlen.");
await sendJson("/admin/roles/grant", "POST", {
user_id: Number(document.getElementById("rg-user").value),
host_group_id: Number(document.getElementById("rg-hostgroup").value),
role_names,
expires_at: document.getElementById("rg-expires").value.trim() || null,
});
showBanner(`Rolle(n) vergeben: ${role_names.join(", ")}.`, "ok");
document.querySelectorAll("#rg-role-grid input:checked").forEach((c) => { c.checked = false; });
await refreshRoleGrants();
} catch (err) {
showBanner(err.message, "error");
}
});
document.getElementById("group-role-grant-form").addEventListener("submit", async (ev) => {
ev.preventDefault();
try {
@ -1112,6 +1044,84 @@
}
});
// ---------------------------------------------------------------------
// Zugangsdaten-Freigabe an Benutzergruppen (Achse B, Teil D.6 Schritt 5)
// ---------------------------------------------------------------------
const CREDENTIAL_KIND_LABELS = {
ssh_key: "SSH-Schluessel",
rdp_credential: "RDP-Zugangsdaten",
ssh_password_credential: "SSH-Passwort",
};
function credentialListForKind(kind) {
if (kind === "ssh_key") return cachedSshKeys;
if (kind === "rdp_credential") return cachedRdpCredentials;
return cachedSshPasswordCredentials;
}
function credentialLabel(kind, credentialId) {
const item = credentialListForKind(kind).find((c) => c.id === credentialId);
return item ? `${item.label} (#${item.id})` : `#${credentialId}`;
}
function populateCredentialSelect() {
const kind = document.getElementById("gcg-kind").value;
const select = document.getElementById("gcg-credential");
select.replaceChildren(...optionsFromList(credentialListForKind(kind), "id", (c) => `${c.label} (#${c.id})`));
}
document.getElementById("gcg-kind").addEventListener("change", populateCredentialSelect);
async function refreshGroupCredentialGrants() {
const kinds = ["ssh_key", "rdp_credential", "ssh_password_credential"];
const perKind = await Promise.all(kinds.map((kind) => getJson(`/admin/group-credentials/${kind}`)));
const rows = [];
kinds.forEach((kind, idx) => {
for (const g of perKind[idx]) rows.push({ kind, ...g });
});
rows.sort((a, b) => a.user_group_name.localeCompare(b.user_group_name) || a.kind.localeCompare(b.kind));
const tbody = document.querySelector("#group-credential-grants-table tbody");
fillTable(
tbody,
rows.map((g) =>
el("tr", {}, [
el("td", { textContent: g.user_group_name }),
el("td", { textContent: CREDENTIAL_KIND_LABELS[g.kind] || g.kind }),
el("td", { textContent: credentialLabel(g.kind, g.credential_id) }),
el("td", { textContent: g.expires_at || "-" }),
el("td", {}, [
actionButton("Entziehen", "btn-danger", async () => {
await sendJson(`/admin/group-credentials/${g.kind}/revoke`, "POST", {
user_group_id: g.user_group_id, credential_id: g.credential_id,
});
await refreshGroupCredentialGrants();
}),
]),
])
)
);
}
document.getElementById("group-credential-grant-form").addEventListener("submit", async (ev) => {
ev.preventDefault();
try {
const kind = document.getElementById("gcg-kind").value;
const credentialId = Number(document.getElementById("gcg-credential").value);
if (!credentialId) throw new Error("Zugangsdatensatz auswaehlen.");
await sendJson(`/admin/group-credentials/${kind}/grant`, "POST", {
user_group_id: Number(document.getElementById("gcg-group").value),
credential_id: credentialId,
expires_at: document.getElementById("gcg-expires").value.trim() || null,
});
showBanner(`Zugangsdatensatz freigegeben: ${CREDENTIAL_KIND_LABELS[kind]}.`, "ok");
document.getElementById("gcg-expires").value = "";
await refreshGroupCredentialGrants();
} catch (err) {
showBanner(err.message, "error");
}
});
// ---------------------------------------------------------------------
// API-Tokens
// ---------------------------------------------------------------------
@ -1135,8 +1145,6 @@
})
);
}
document.getElementById("tc-tenant-box").classList.toggle("hidden", !meInfo.is_admin && meInfo.tenant_admin_of.length <= 1);
await populateTenantSelect(document.getElementById("tc-tenant"));
await refreshSharedSelects();
await refreshTokens();
}
@ -1166,7 +1174,6 @@
el("td", { textContent: t.label }),
el("td", { textContent: t.username }),
el("td", { textContent: t.prefix }),
el("td", { textContent: t.tenant_name }),
scopesCell,
el("td", { textContent: t.last_used_at || "nie" }),
el("td", { textContent: status }),
@ -1183,13 +1190,11 @@
if (scopes.length === 0) {
throw new Error("Mindestens einen Scope auswaehlen.");
}
const tenantSelect = document.getElementById("tc-tenant");
const payload = {
label: document.getElementById("tc-label").value.trim(),
user_id: Number(document.getElementById("tc-user").value),
scopes,
expires_at: document.getElementById("tc-expires").value.trim() || null,
tenant_id: tenantSelect.value ? Number(tenantSelect.value) : null,
};
const result = await sendJson("/admin/tokens", "POST", payload);
const box = document.getElementById("token-reveal-box");
@ -1204,125 +1209,6 @@
}
});
// ---------------------------------------------------------------------
// Mandanten (nur Super-Admin -- Tab-Button ist fuer alle anderen hidden)
// ---------------------------------------------------------------------
let editingTenantId = null;
let activeTenantAdminsId = null;
async function loadTenantsTab() {
await refreshTenants();
}
async function refreshTenants() {
cachedTenants = await getJson("/admin/tenants");
const tbody = document.querySelector("#tenants-table tbody");
fillTable(
tbody,
cachedTenants.map((t) =>
el("tr", {}, [
el("td", { textContent: String(t.id) }),
el("td", { textContent: t.name }),
el("td", { textContent: t.description || "" }),
el("td", { textContent: t.is_active ? "ja" : "nein" }),
el("td", { textContent: String(t.host_group_count) }),
el("td", { textContent: String(t.user_group_count) }),
el("td", {}, [
actionButton("Admins", "btn-secondary", () => showTenantAdmins(t)),
actionButton("Bearbeiten", "btn-secondary", () => showTenantEdit(t)),
actionButton("Loeschen", "btn-danger", async () => {
await apiFetch(`/admin/tenants/${t.id}`, { method: "DELETE" });
showBanner(`Mandant '${t.name}' geloescht.`, "ok");
await refreshTenants();
}),
]),
])
)
);
}
function showTenantEdit(t) {
editingTenantId = t.id;
document.getElementById("tenant-edit-panel").classList.remove("hidden");
document.getElementById("tenant-edit-name").textContent = t.name;
document.getElementById("tne-name").value = t.name;
document.getElementById("tne-description").value = t.description || "";
document.getElementById("tne-active").checked = t.is_active;
}
document.getElementById("tenant-edit-form").addEventListener("submit", async (ev) => {
ev.preventDefault();
try {
await sendJson(`/admin/tenants/${editingTenantId}`, "PUT", {
name: document.getElementById("tne-name").value.trim(),
description: document.getElementById("tne-description").value.trim() || null,
is_active: document.getElementById("tne-active").checked,
});
showBanner("Mandant aktualisiert.", "ok");
await refreshTenants();
} catch (err) {
showBanner(err.message, "error");
}
});
async function showTenantAdmins(t) {
activeTenantAdminsId = t.id;
document.getElementById("tenant-admins-panel").classList.remove("hidden");
document.getElementById("tenant-admins-name").textContent = t.name;
await refreshTenantAdmins();
const select = document.getElementById("ta-user-select");
if (cachedUsers.length === 0) await refreshUsers();
select.replaceChildren(...optionsFromList(cachedUsers, "id", (u) => `${u.username} (#${u.id})`));
}
async function refreshTenantAdmins() {
const admins = await getJson(`/admin/tenants/${activeTenantAdminsId}/admins`);
const tbody = document.querySelector("#tenant-admins-table tbody");
fillTable(
tbody,
admins.map((a) =>
el("tr", {}, [
el("td", { textContent: String(a.user_id) }),
el("td", { textContent: a.username }),
el("td", { textContent: a.granted_at }),
el("td", {}, [
actionButton("Entfernen", "btn-danger", async () => {
await apiFetch(`/admin/tenants/${activeTenantAdminsId}/admins/${a.user_id}`, { method: "DELETE" });
await refreshTenantAdmins();
}),
]),
])
)
);
}
document.getElementById("tenant-create-form").addEventListener("submit", async (ev) => {
ev.preventDefault();
try {
const name = document.getElementById("tnc-name").value.trim();
const description = document.getElementById("tnc-description").value.trim() || null;
await sendJson("/admin/tenants", "POST", { name, description });
showBanner(`Mandant '${name}' angelegt.`, "ok");
ev.target.reset();
await refreshTenants();
} catch (err) {
showBanner(err.message, "error");
}
});
document.getElementById("tenant-admin-add-form").addEventListener("submit", async (ev) => {
ev.preventDefault();
try {
const userId = Number(document.getElementById("ta-user-select").value);
await sendJson(`/admin/tenants/${activeTenantAdminsId}/admins`, "POST", { user_id: userId });
showBanner("Mandanten-Admin hinzugefuegt.", "ok");
await refreshTenantAdmins();
} catch (err) {
showBanner(err.message, "error");
}
});
// ---------------------------------------------------------------------
// Sessions (nur Super-Admin) -- aktive + historische Sitzungen, Beenden,
// Link zur Aufzeichnung.
@ -1874,12 +1760,10 @@
const hostGroupOpts = () => optionsFromList(cachedHostGroups, "id", (g) => `${g.name} (#${g.id})`);
const groupOpts = () => optionsFromList(cachedGroups, "id", (g) => `${g.name} (#${g.id})`);
const rgUser = document.getElementById("rg-user");
if (rgUser) rgUser.replaceChildren(...userOpts());
const rgHostgroup = document.getElementById("rg-hostgroup");
if (rgHostgroup) rgHostgroup.replaceChildren(...hostGroupOpts());
const grgGroup = document.getElementById("grg-group");
if (grgGroup) grgGroup.replaceChildren(...groupOpts());
const gcgGroup = document.getElementById("gcg-group");
if (gcgGroup) gcgGroup.replaceChildren(...groupOpts());
const grgHostgroup = document.getElementById("grg-hostgroup");
if (grgHostgroup) grgHostgroup.replaceChildren(...hostGroupOpts());
const tcUser = document.getElementById("tc-user");
@ -1897,16 +1781,12 @@
async function main() {
const me = await getJson("/auth/me");
if (!me.is_any_admin) {
if (!me.is_admin) {
window.location.href = "/dashboard";
return;
}
meInfo = me;
const roleLabel = me.is_admin
? "Super-Admin"
: `Mandanten-Admin: ${me.tenant_admin_of.map((t) => t.name).join(", ")}`;
document.getElementById("whoami").textContent = `${me.username} (${roleLabel})`;
document.getElementById("tenants-tab-btn").classList.toggle("hidden", !me.is_admin);
document.getElementById("whoami").textContent = `${me.username} (Admin)`;
document.getElementById("sessions-tab-btn").classList.toggle("hidden", !me.is_admin);
document.getElementById("connlog-tab-btn").classList.toggle("hidden", !me.is_admin);
loadedTabs.add("users");

View File

@ -52,12 +52,17 @@
actions.className = "actions";
const connect = document.createElement("a");
connect.href = host.protocol === "ssh" ? `/terminal/${host.id}` : `/rdp/${host.id}`;
// F3 (Umsetzungsauftrag_Sonnet5.md Teil F.2): "Verbinden" fuehrt in die
// Arbeitsflaeche statt (wie vor Teil F) direkt in einen neuen Tab mit
// /terminal/{id} bzw. /rdp/{id} -- diese Seiten bleiben als duenner
// Rahmen fuer Lesezeichen/Fehlersuche bestehen (F.2), sind aber nicht
// mehr der reguraere Einstiegspunkt. ?host= wird von workspace.js beim
// Laden ausgewertet und startet die Sitzung dort direkt.
connect.href = `/workspace?host=${host.id}`;
connect.textContent = "Verbinden";
// Oeffnet die Sitzung in einem eigenen Tab statt im Dashboard-Tab zu
// navigieren -- das Dashboard bleibt dadurch erreichbar, waehrend die
// Sitzung laeuft, und der Exit-Button der Sitzung (session-toolbar in
// terminal.html/rdp.html) kann genau diesen Tab wieder schliessen.
// Weiterhin ein eigener Tab: das Dashboard bleibt dadurch erreichbar,
// waehrend die Arbeitsflaeche laeuft (dort fuehrt "Dashboard" im
// Topbar-Link zurueck, falls im selben Tab genavigiert wird).
connect.target = "_blank";
actions.appendChild(connect);

View File

@ -1,5 +1,29 @@
/*
* RDP-Session-Client: guacamole-common-js <-> /ws/rdp/{host_id}.
* RDP-Sitzungs-Baustein: guacamole-common-js <-> /ws/rdp/{host_id}.
*
* F1 (Umsetzungsauftrag_Sonnet5.md Teil F.3.1): vorher ein Dokument-Singleton
* (ein Skript pro Seite, feste Element-IDs). Jetzt eine Klasse
* (RdpSession) mit genau einer Instanz pro Sitzung: eigenes, dynamisch
* gebautes DOM-Fragment (this.element), connect(), dispose(), setActive(bool)
* und ein Statusrueckruf (options.onStatusChange). Die eigenstaendige Seite
* /rdp/{host_id} (templates/rdp.html) ist ab jetzt nur noch ein duenner
* Rahmen, der GENAU EINE Instanz erzeugt (F.2, letzter Absatz).
*
* ACHTUNG Tastatur/Zwischenablage (F.3.2, noch NICHT geloest -- folgt in
* Stufe F4): guacamole-common-js' Guacamole.Keyboard haengt sich immer an
* ein von aussen uebergebenes Element -- bisher `document`, und das bleibt
* in dieser Stufe zunaechst so, weil die Bibliothek keine saubere
* Abmeldung/Umschaltung bietet. Bei mehreren GLEICHZEITIGEN RdpSession-
* Instanzen wuerden ohne Gegenmassnahme alle Tastatureingaben an ALLE
* Instanzen gehen. Deshalb: jede Instanz filtert selbst ueber setActive() --
* Tastatur-/Paste-Ereignisse werden nur weitergeleitet, wenn genau diese
* Instanz aktiv ist (this._active). Das ist die Grundlage fuer F.3.2, aber
* noch NICHT die vollstaendige Loesung (fehlende Modifiertasten-Ruecksetzung
* beim Umschalten, siehe F.3.2 Satz 2) -- die kommt mit der eigentlichen
* Mehrfach-Sitzungs-Umschaltung in Stufe F4, wenn es ueberhaupt zwei
* gleichzeitig existierende Instanzen geben kann. Bei genau einer Instanz
* (heutiger Stand: Stufe F1/F2/F3, dort niemals zwei RdpSession gleichzeitig
* auf derselben Seite) ist das Verhalten exakt wie vorher.
*
* Vollbild: native Browser-Fullscreen-API (Konzept 4.3).
* Copy & Paste: bidirektionale Synchronisation ueber Guacamole.Client
@ -11,111 +35,337 @@
(() => {
"use strict";
const shell = document.getElementById("session-shell");
const hostId = shell.dataset.hostId;
const statusEl = document.getElementById("status");
const displayDiv = document.getElementById("rdp-display");
let _nextInstanceId = 1;
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
const width = Math.round(window.innerWidth);
const height = Math.round(window.innerHeight - 40);
const dpi = Math.round(window.devicePixelRatio * 96) || 96;
class RdpSession {
/**
* @param {number|string} hostId
* @param {object} [options]
* @param {(text: string, isError: boolean) => void} [options.onStatusChange]
* @param {() => void} [options.onExitRequested]
* Wird aufgerufen, NACHDEM der interne Exit-Knopf dispose()
* bereits ausgeloest hat.
*/
constructor(hostId, options = {}) {
this.hostId = hostId;
this.options = options;
this._uid = _nextInstanceId++;
this._active = false;
this._disposed = false;
this._intentionalDisconnect = false;
this._resizeDebounceHandle = null;
// WICHTIG: die Verbindungsparameter gehoeren NICHT in die Tunnel-URL.
// Guacamole.WebSocketTunnel.connect(data) baut die Socket-URL selbst als
// `tunnelURL + "?" + data` zusammen. Standen die Parameter schon in der
// URL, entstand daraus `...?width=1280&height=800&dpi=96?undefined` -- der
// letzte Query-Parameter war damit kein gueltiger Integer mehr, FastAPI
// wies den WebSocket noch vor dem Routenhandler ab (HTTP 422) und im
// Verbindungslog des Servers tauchte kein einziger Eintrag auf.
const tunnelUrl = `${proto}//${window.location.host}/ws/rdp/${hostId}`;
const connectParams = `width=${width}&height=${height}&dpi=${dpi}`;
const tunnel = new Guacamole.WebSocketTunnel(tunnelUrl);
const client = new Guacamole.Client(tunnel);
displayDiv.appendChild(client.getDisplay().getElement());
client.onstatechange = (state) => {
// 0=idle,1=connecting,2=waiting,3=connected,4=disconnecting,5=disconnected
const labels = ["Idle", "Verbinde ...", "Warte auf Server ...", "Verbunden", "Trenne ...", "Getrennt"];
statusEl.textContent = labels[state] || `Status ${state}`;
};
const showError = (err) => {
// Der Server gibt den Abbruchgrund als WebSocket-Close-Reason mit
// (siehe _reject() in app/rdp_proxy/ws_tunnel.py); guacamole-common-js
// reicht ihn als Guacamole.Status.message hierher durch.
statusEl.textContent = "Fehler: " + ((err && err.message) || "unbekannt");
statusEl.classList.add("error");
// Troubleshooting: Statuscode + Rohobjekt zusaetzlich in die
// Browser-Konsole, fuer den Fall, dass die Klartextmeldung allein nicht
// reicht (z.B. guacd/FreeRDP-interne Codes).
console.error("RDP-Sitzung beendet/Fehler:", err);
};
client.onerror = showError;
tunnel.onerror = showError;
client.onclipboard = (stream, mimetype) => {
if (!mimetype.startsWith("text/")) return;
const reader = new Guacamole.StringReader(stream);
let data = "";
reader.ontext = (text) => { data += text; };
reader.onend = () => {
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(data).catch(() => {});
}
};
};
client.connect(connectParams);
window.addEventListener("beforeunload", () => client.disconnect());
const mouse = new Guacamole.Mouse(client.getDisplay().getElement());
mouse.onmousedown = mouse.onmouseup = mouse.onmousemove = (mouseState) => {
client.sendMouseState(mouseState);
};
const keyboard = new Guacamole.Keyboard(document);
keyboard.onkeydown = (keysym) => client.sendKeyEvent(1, keysym);
keyboard.onkeyup = (keysym) => client.sendKeyEvent(0, keysym);
document.addEventListener("paste", (ev) => {
const text = (ev.clipboardData || window.clipboardData).getData("text");
if (!text) return;
const stream = client.createClipboardStream("text/plain");
const writer = new Guacamole.StringWriter(stream);
writer.sendText(text);
writer.sendEnd();
});
document.getElementById("fullscreen-btn").addEventListener("click", () => {
if (!document.fullscreenElement) {
shell.requestFullscreen().catch(() => {});
} else {
document.exitFullscreen();
this.element = this._buildElement();
this._cacheRefs();
this._wireToolbar();
}
});
// Strg+Alt+Entf: der Browser faengt diese Kombination selbst ab (Windows
// reserviert sie systemweit), sie kommt also nie als normales Tastatur-
// Event beim RDP-Ziel an. Abhilfe wie im offiziellen Guacamole-Client:
// die drei Tasten einzeln als synthetische Key-Events senden (X11-
// Keysyms: Ctrl=0xFFE3, Alt=0xFFE9, Delete=0xFFFF), erst alle drei
// herunter- dann in umgekehrter Reihenfolge wieder hochdruecken.
document.getElementById("ctrlaltdel-btn").addEventListener("click", () => {
const keys = [0xffe3, 0xffe9, 0xffff];
for (const keysym of keys) client.sendKeyEvent(1, keysym);
for (const keysym of keys.slice().reverse()) client.sendKeyEvent(0, keysym);
});
// --- Aufbau -------------------------------------------------------
document.getElementById("exit-btn").addEventListener("click", () => {
statusEl.textContent = "Sitzung wird beendet ...";
statusEl.classList.remove("error");
try { client.disconnect(); } catch (_) { /* bereits getrennt */ }
window.close();
// Siehe terminal.js fuer die ausfuehrliche Begruendung: window.close()
// schliesst den Tab nur unter bestimmten Voraussetzungen lautlos --
// deshalb zusaetzlich immer ein Fallback zum Dashboard.
window.setTimeout(() => { window.location.href = "/dashboard"; }, 300);
});
_buildElement() {
const wrapper = document.createElement("div");
wrapper.className = "session-instance";
wrapper.innerHTML = `
<div class="session-shell">
<div class="session-toolbar">
<button type="button" class="fullscreen-btn">Vollbild</button>
<button type="button" class="ctrlaltdel-btn">Strg+Alt+Entf</button>
<span class="spacer"></span>
<span class="status">Verbinde ...</span>
<button type="button" class="exit-btn btn-danger btn-small" title="Sitzung beenden">Exit</button>
</div>
<div class="session-rdp-display">
<!-- B.1 (Umsetzungsauftrag Teil B): Overlay-Panel fuer
Verbindungsfehler, siehe rdp-error-overlay unten. -->
<div class="rdp-error-overlay" hidden>
<div class="rdp-error-panel">
<h2 class="rdp-error-title">Verbindungsfehler</h2>
<p class="rdp-error-message"></p>
<dl class="rdp-error-meta">
<dt>Sitzungs-ID</dt><dd class="rdp-error-session">-</dd>
<dt>Zeitpunkt</dt><dd class="rdp-error-time">-</dd>
<dt>Ziel-Host</dt><dd class="rdp-error-host">-</dd>
</dl>
<button type="button" class="rdp-error-retry-btn">Wiederholen</button>
</div>
</div>
</div>
</div>
`;
return wrapper;
}
_cacheRefs() {
const el = this.element;
this.shellEl = el.querySelector(".session-shell");
this.statusEl = el.querySelector(".status");
this.displayDiv = el.querySelector(".session-rdp-display");
this.fullscreenBtn = el.querySelector(".fullscreen-btn");
this.ctrlAltDelBtn = el.querySelector(".ctrlaltdel-btn");
this.exitBtn = el.querySelector(".exit-btn");
this.errorOverlay = el.querySelector(".rdp-error-overlay");
this.errorTitleEl = el.querySelector(".rdp-error-title");
this.errorMessageEl = el.querySelector(".rdp-error-message");
this.errorSessionEl = el.querySelector(".rdp-error-session");
this.errorTimeEl = el.querySelector(".rdp-error-time");
this.errorHostEl = el.querySelector(".rdp-error-host");
this.errorRetryBtn = el.querySelector(".rdp-error-retry-btn");
}
_setStatus(text, isError) {
this.statusEl.textContent = text;
this.statusEl.classList.toggle("error", !!isError);
if (this.options.onStatusChange) {
try {
this.options.onStatusChange(text, !!isError);
} catch (_) { /* Aufrufer-Fehler duerfen die Sitzung nicht stoeren */ }
}
}
// --- Verbindungsaufbau ------------------------------------------------
connect() {
if (this.client) return; // bereits verbunden/am Verbinden
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
const width = Math.round(window.innerWidth);
const height = Math.round(window.innerHeight - 40);
const dpi = Math.round(window.devicePixelRatio * 96) || 96;
// WICHTIG: die Verbindungsparameter gehoeren NICHT in die Tunnel-URL.
// Guacamole.WebSocketTunnel.connect(data) baut die Socket-URL selbst als
// `tunnelURL + "?" + data` zusammen. Standen die Parameter schon in der
// URL, entstand daraus `...?width=1280&height=800&dpi=96?undefined` --
// der letzte Query-Parameter war damit kein gueltiger Integer mehr,
// FastAPI wies den WebSocket noch vor dem Routenhandler ab (HTTP 422).
const tunnelUrl = `${proto}//${window.location.host}/ws/rdp/${this.hostId}`;
const connectParams = `width=${width}&height=${height}&dpi=${dpi}`;
this.tunnel = new Guacamole.WebSocketTunnel(tunnelUrl);
this.client = new Guacamole.Client(this.tunnel);
this.displayDiv.appendChild(this.client.getDisplay().getElement());
this.client.onstatechange = (state) => {
// 0=idle,1=connecting,2=waiting,3=connected,4=disconnecting,5=disconnected
const labels = ["Idle", "Verbinde ...", "Warte auf Server ...", "Verbunden", "Trenne ...", "Getrennt"];
this._setStatus(labels[state] || `Status ${state}`, false);
};
const showError = (err) => {
// Ein vom Benutzer ueber den Exit-Knopf ausgeloestes client.disconnect()
// schliesst den Tunnel sauber (Guacamole.Status.Code.SUCCESS) und loest
// damit laut guacamole-common-js KEIN onerror aus -- dieser Schutz greift
// trotzdem zusaetzlich, falls sich das je aendert.
if (this._intentionalDisconnect) return;
const message = (err && err.message) || "Unbekannter Fehler (keine Meldung vom Server empfangen)";
this._setStatus("Fehler: " + message, true);
this.errorTitleEl.textContent = "Verbindungsfehler";
this.errorMessageEl.textContent = message;
this.errorSessionEl.textContent = this.tunnel.uuid || "unbekannt";
this.errorTimeEl.textContent = new Date().toLocaleString("de-AT");
this.errorHostEl.textContent = `Host-ID ${this.hostId}`;
this.errorOverlay.hidden = false;
console.error("RDP-Sitzung beendet/Fehler:", err);
};
this.client.onerror = showError;
this.tunnel.onerror = showError;
this.errorRetryBtn.addEventListener("click", () => {
// Einfachste zuverlaessige Wiederholung: die Sitzung (Tunnel, Client,
// Maus-/Tastatur-Listener) neu aufzubauen wuerde denselben Zustand wie
// ein frischer Seitenaufruf erfordern -- ein Reload erreicht das direkt
// und erzeugt serverseitig eine neue Korrelations-ID/Sitzung. Betrifft
// bei /workspace (Stufe F3/F4) nur diese eine Kachel -- ein voller
// Seiten-Reload dort wuerde ALLE offenen Sitzungen der Arbeitsflaeche
// beenden (siehe F.3.7: kein Wiederanschluss nach Reload).
window.location.reload();
});
this.client.onclipboard = (stream, mimetype) => {
if (!mimetype.startsWith("text/")) return;
const reader = new Guacamole.StringReader(stream);
let data = "";
reader.ontext = (text) => { data += text; };
reader.onend = () => {
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(data).catch(() => {});
}
};
};
this.client.connect(connectParams);
this._onBeforeUnload = () => { try { this.client.disconnect(); } catch (_) { /* ignorieren */ } };
window.addEventListener("beforeunload", this._onBeforeUnload);
// D4 (Umsetzungsauftrag Teil A): der Verbindungsaufbau setzt bereits
// "resize-method: display-update" -- das RDP-Ziel unterstuetzt damit
// dynamische Aufloesungsaenderungen waehrend der laufenden Sitzung.
// Debounce: "resize" kann waehrend eines Fenster-Drags sehr haeufig
// feuern -- ohne Verzoegerung wuerde jede Zwischengroesse eine eigene
// "size"-Instruktion ueber den Tunnel schicken.
// F4 (Umsetzungsauftrag_Sonnet5.md Teil F.3.3): mehrere Instanzen
// koennen jetzt gleichzeitig existieren (Arbeitsflaeche /workspace) --
// eine ausgeblendete Instanz hat Groesse 0 (display:none). Ein Refit
// waehrend ausgeblendet waere sinnlos und wuerde bei Sichtbarwerden
// ohnehin durch setActive(true) -> _sendCurrentSize() nachgeholt --
// daher nur reagieren, wenn diese Instanz gerade sichtbar ist.
this._onWindowResize = () => { if (this._active) this._scheduleResize(); };
window.addEventListener("resize", this._onWindowResize);
document.addEventListener("fullscreenchange", this._onWindowResize);
this.mouse = new Guacamole.Mouse(this.client.getDisplay().getElement());
this.mouse.onmousedown = this.mouse.onmouseup = this.mouse.onmousemove = (mouseState) => {
this.client.sendMouseState(mouseState);
};
// Siehe Moduldoc oben: dokumentweit angeheftet (Bibliotheksgrenze),
// Weiterleitung aber ueber this._active gefiltert.
this.keyboard = new Guacamole.Keyboard(document);
this.keyboard.onkeydown = (keysym) => { if (this._active) this.client.sendKeyEvent(1, keysym); };
this.keyboard.onkeyup = (keysym) => { if (this._active) this.client.sendKeyEvent(0, keysym); };
this._onPaste = (ev) => {
if (!this._active) return;
const text = (ev.clipboardData || window.clipboardData).getData("text");
if (!text) return;
const stream = this.client.createClipboardStream("text/plain");
const writer = new Guacamole.StringWriter(stream);
writer.sendText(text);
writer.sendEnd();
};
document.addEventListener("paste", this._onPaste);
}
_sendCurrentSize() {
const newWidth = Math.round(this.displayDiv.clientWidth || window.innerWidth);
const newHeight = Math.round(this.displayDiv.clientHeight || (window.innerHeight - 40));
if (newWidth > 0 && newHeight > 0) {
this.client.sendSize(newWidth, newHeight);
}
}
_scheduleResize() {
if (this._resizeDebounceHandle !== null) {
window.clearTimeout(this._resizeDebounceHandle);
}
this._resizeDebounceHandle = window.setTimeout(() => {
this._resizeDebounceHandle = null;
this._sendCurrentSize();
}, 250);
}
/** Wird von einer kuenftigen Seitenleiste (Teil F.3/F.4) beim Umschalten
* aufgerufen. Steuert, ob diese Instanz Tastatur-/Paste-Ereignisse
* entgegennimmt (siehe Moduldoc) -- bei genau einer Instanz (heutiger
* Stand) ohne sichtbaren Effekt ausser dem anfaenglichen Aktivieren. */
setActive(active) {
const wasActive = this._active;
this._active = active;
// F4 (F.3.2, Satz 2): beim Umschalten muessen gedrueckte Modifiertasten
// zurueckgesetzt werden -- sonst bleibt z.B. Strg in der verlassenen
// Sitzung haengen (dort kommt ja kein keyup mehr an, sobald diese
// Instanz keine Ereignisse mehr weiterleitet). Keyboard.reset() ist
// Teil der guacamole-common-js-API (dieselbe Methode, die der
// offizielle Client bei Fokusverlust des Fensters aufruft) -- per
// Feature-Check abgesichert, falls eine kuenftige Bibliotheksversion
// sie umbenennt.
if (!active && wasActive && this.keyboard && typeof this.keyboard.reset === "function") {
this.keyboard.reset();
}
if (active && this.client) {
this._sendCurrentSize();
if (this.keyboard && typeof this.keyboard.reset === "function") {
this.keyboard.reset();
}
}
}
// --- Toolbar --------------------------------------------------------
_wireToolbar() {
this.fullscreenBtn.addEventListener("click", () => {
if (!document.fullscreenElement) {
this.shellEl.requestFullscreen().catch(() => {});
} else {
document.exitFullscreen();
}
});
// Strg+Alt+Entf: der Browser faengt diese Kombination selbst ab (Windows
// reserviert sie systemweit), sie kommt also nie als normales Tastatur-
// Event beim RDP-Ziel an. Abhilfe wie im offiziellen Guacamole-Client:
// die drei Tasten einzeln als synthetische Key-Events senden (X11-
// Keysyms: Ctrl=0xFFE3, Alt=0xFFE9, Delete=0xFFFF), erst alle drei
// herunter- dann in umgekehrter Reihenfolge wieder hochdruecken.
this.ctrlAltDelBtn.addEventListener("click", () => {
if (!this.client) return;
const keys = [0xffe3, 0xffe9, 0xffff];
for (const keysym of keys) this.client.sendKeyEvent(1, keysym);
for (const keysym of keys.slice().reverse()) this.client.sendKeyEvent(0, keysym);
});
this.exitBtn.addEventListener("click", () => {
this._intentionalDisconnect = true;
this._setStatus("Sitzung wird beendet ...", false);
this.dispose();
if (this.options.onExitRequested) this.options.onExitRequested();
});
}
// --- Abbau ------------------------------------------------------------
/** Idempotent. Siehe terminal.js::SshTerminalSession.dispose() fuer die
* gleiche Begruendung. Einschraenkung: guacamole-common-js bietet keine
* saubere Abmeldung fuer Guacamole.Keyboard(document) -- der zugrunde-
* liegende DOM-Listener bleibt technisch bestehen, sendet aber nach
* dispose() nichts mehr (this._active bleibt false). Das ist bei genau
* einer Instanz (heutiger Stand) folgenlos; bei mehreren gleichzeitigen
* Instanzen (Stufe F4) muss dieser Punkt beim Kachel-Schliessen erneut
* betrachtet werden. */
dispose() {
if (this._disposed) return;
this._disposed = true;
try {
if (this.client) this.client.disconnect();
} catch (_) { /* bereits getrennt */ }
if (this._onWindowResize) {
window.removeEventListener("resize", this._onWindowResize);
document.removeEventListener("fullscreenchange", this._onWindowResize);
}
if (this._onBeforeUnload) window.removeEventListener("beforeunload", this._onBeforeUnload);
if (this._onPaste) document.removeEventListener("paste", this._onPaste);
if (this._resizeDebounceHandle !== null) {
window.clearTimeout(this._resizeDebounceHandle);
this._resizeDebounceHandle = null;
}
if (this.element.parentNode) this.element.parentNode.removeChild(this.element);
}
}
window.RdpSession = RdpSession;
// --- Bootstrap fuer die eigenstaendige Seite /rdp/{host_id} --------------
//
// F1: templates/rdp.html liefert nur noch einen leeren Container mit
// data-host-id -- diese Seite bleibt als duenner Rahmen um denselben
// Baustein bestehen (F.2), keine zweite Implementierung.
const container = document.getElementById("session-container");
if (container) {
const hostId = container.dataset.hostId;
const session = new RdpSession(hostId, {
onExitRequested: () => {
window.close();
// Siehe terminal.js fuer die ausfuehrliche Begruendung: window.close()
// schliesst den Tab nur unter bestimmten Voraussetzungen lautlos --
// deshalb zusaetzlich immer ein Fallback zum Dashboard.
window.setTimeout(() => { window.location.href = "/dashboard"; }, 300);
},
});
container.appendChild(session.element);
session.connect();
session.setActive(true);
}
})();

View File

@ -0,0 +1,37 @@
/*
* E9 (Umsetzungsauftrag Teil E): der gleitende Idle-Timeout des Session-
* Cookies (siehe app/auth/deps.py::get_current_user, refresh_session_token)
* wird nur bei regulaeren HTTP-Anfragen verlaengert. Ein WebSocket-Handshake
* kann das nicht (WebSockets erlauben nach dem Handshake kein Set-Cookie
* mehr, siehe get_current_user_ws()) -- ein Benutzer, der eine Stunde
* ausschliesslich in einem offenen Terminal- oder RDP-Tab arbeitet, haette
* sonst ein abgelaufenes Cookie: die laufende Sitzung selbst laeuft zwar
* weiter, aber jede WEITERE Aktion (zweite Sitzung oeffnen, Dateitransfer,
* Katalog neu laden) scheitert mit "Session abgelaufen".
*
* Fix: waehrend ein Sitzungstab (Terminal/RDP) offen ist, periodisch einen
* billigen, bereits authentifizierten HTTP-Endpunkt aufrufen (GET /auth/me,
* siehe app/auth/routes.py) -- das durchlaeuft dieselbe get_current_user()-
* Dependency wie jede andere Anfrage und verlaengert den Cookie ganz
* regulaer, ohne einen eigenen Endpunkt oder Serveraenderungen zu brauchen.
*
* Intervall: deutlich unter dem serverseitig konfigurierten
* session_idle_timeout_s (Standard 900s/15min, app/config.py) -- 3 Minuten
* Sicherheitsmarge reichen fuer jede sinnvolle Konfiguration.
*/
(() => {
"use strict";
const KEEPALIVE_INTERVAL_MS = 3 * 60 * 1000;
function ping() {
fetch("/auth/me", { method: "GET", credentials: "same-origin" }).catch(() => {
// Absichtlich stumm: ein einzelner fehlgeschlagener Keepalive-Ping
// (z.B. kurzer Netzwerkaussetzer) soll die laufende Sitzung nicht
// stoeren -- schlaegt die Session tatsaechlich fehl, faellt das beim
// naechsten regulaeren Request (Dateitransfer, Reload, ...) ohnehin auf.
});
}
window.setInterval(ping, KEEPALIVE_INTERVAL_MS);
})();

File diff suppressed because it is too large Load Diff

532
static/js/workspace.js Normal file
View File

@ -0,0 +1,532 @@
/*
* 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));
})();