umbau 1.0
This commit is contained in:
@ -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");
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
456
static/js/rdp.js
456
static/js/rdp.js
@ -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);
|
||||
}
|
||||
})();
|
||||
|
||||
37
static/js/session-keepalive.js
Normal file
37
static/js/session-keepalive.js
Normal 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
532
static/js/workspace.js
Normal 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));
|
||||
})();
|
||||
Reference in New Issue
Block a user