Files
ssh-jumphost/static/js/terminal.js
2026-08-21 08:45:36 +02:00

266 lines
9.4 KiB
JavaScript

/*
* SSH-Terminal-Client: xterm.js <-> /ws/ssh/{host_id}.
*
* Vollbild: native Browser-Fullscreen-API auf dem Session-Container (Konzept 4.3).
* Copy & Paste: xterm.js liefert dies bei SSH bereits nativ ueber die
* System-Zwischenablage (Markieren-zum-Kopieren / Strg+Umschalt+V) -- keine
* serverseitige Sonderbehandlung noetig, im Gegensatz zu RDP (siehe rdp.js).
*
* Dateitransfer: eigenes Panel (statt frueherem prompt()-basiertem Upload)
* mit Formularen fuer Upload UND Download in beide Richtungen. Der Download
* laeuft ueber fetch() + Blob + synthetischen <a download>-Link, damit
* Fehler inline im Panel angezeigt werden koennen statt die Seite zu
* verlassen (siehe GET /ssh/{host_id}/files/download).
*/
(() => {
"use strict";
const shell = document.getElementById("session-shell");
const hostId = shell.dataset.hostId;
const statusEl = document.getElementById("status");
const term = new Terminal({
cursorBlink: true,
fontFamily: "Menlo, Consolas, monospace",
fontSize: 14,
theme: { background: "#000000" },
});
const fitAddon = new FitAddon.FitAddon();
term.loadAddon(fitAddon);
term.open(document.getElementById("terminal"));
fitAddon.fit();
function b64encode(str) {
return btoa(unescape(encodeURIComponent(str)));
}
function b64decode(b64) {
return decodeURIComponent(escape(atob(b64)));
}
// Troubleshooting: der Server liefert bei einem Fehlschlag entweder eine
// {"type":"error",...}-Nachricht ODER (wenn die Sitzung nie so weit kam,
// dass eine Nachricht gesendet werden konnte -- z.B. fehlende
// Berechtigung) eine WebSocket-Close-Reason (siehe
// app/ssh_proxy/terminal_ws.py::_reject). Beides wird angezeigt statt
// eines generischen "Verbindung beendet" ohne jeden Grund.
let lastErrorMessage = null;
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
const ws = new WebSocket(`${proto}//${window.location.host}/ws/ssh/${hostId}`);
ws.addEventListener("open", () => {
statusEl.textContent = "Verbunden";
sendResize();
});
ws.addEventListener("close", (ev) => {
let text;
if (lastErrorMessage) {
text = "Verbindung beendet: " + lastErrorMessage;
} else if (ev.reason) {
text = `Verbindung beendet (Code ${ev.code}): ${ev.reason}`;
} else if (ev.code && ev.code !== 1000) {
text = `Verbindung beendet (Code ${ev.code})`;
} else {
text = "Verbindung beendet";
}
statusEl.textContent = text;
statusEl.classList.add("error");
console.error("SSH-Sitzung beendet:", { code: ev.code, reason: ev.reason, lastErrorMessage });
});
ws.addEventListener("error", (ev) => {
statusEl.textContent = "Verbindungsfehler (WebSocket) -- Details siehe Browser-Konsole";
statusEl.classList.add("error");
console.error("SSH-WebSocket-Fehler:", ev);
});
ws.addEventListener("message", (ev) => {
const msg = JSON.parse(ev.data);
if (msg.type === "output") {
term.write(b64decode(msg.data));
} else if (msg.type === "error") {
lastErrorMessage = msg.message;
statusEl.textContent = "Fehler: " + msg.message;
statusEl.classList.add("error");
}
});
term.onData((data) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "input", data: b64encode(data) }));
}
});
function sendResize() {
fitAddon.fit();
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "resize", cols: term.cols, rows: term.rows }));
}
}
window.addEventListener("resize", sendResize);
document.getElementById("fullscreen-btn").addEventListener("click", () => {
if (!document.fullscreenElement) {
shell.requestFullscreen().catch(() => {});
} else {
document.exitFullscreen();
}
});
document.addEventListener("fullscreenchange", sendResize);
// --- Dateitransfer-Panel ------------------------------------------------
const ftOverlay = document.getElementById("ft-overlay");
const ftStatus = document.getElementById("ft-status");
const ftList = document.getElementById("ft-list");
const transfers = [];
function openFt() {
ftOverlay.classList.remove("hidden");
ftStatus.textContent = "";
}
function closeFt() {
ftOverlay.classList.add("hidden");
}
document.getElementById("filetransfer-btn").addEventListener("click", openFt);
document.getElementById("ft-close-btn").addEventListener("click", closeFt);
function formatSize(bytes) {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function recordTransfer(direction, filename, size, ok, message) {
transfers.unshift({ direction, filename, size, ok, message });
if (transfers.length > 20) transfers.length = 20;
renderTransfers();
}
function renderTransfers() {
ftList.innerHTML = "";
if (transfers.length === 0) {
const empty = document.createElement("div");
empty.className = "hint";
empty.textContent = "Noch keine Transfers in dieser Sitzung.";
ftList.appendChild(empty);
return;
}
for (const t of transfers) {
const row = document.createElement("div");
row.className = "ft-list-row";
const dir = document.createElement("span");
dir.className = "badge " + (t.ok ? "ok" : "danger");
dir.textContent = t.direction === "upload" ? "↑ Upload" : "↓ Download";
row.appendChild(dir);
const name = document.createElement("span");
name.className = "name";
name.textContent = t.ok ? t.filename : `${t.filename} -- ${t.message}`;
row.appendChild(name);
if (t.ok) {
const size = document.createElement("span");
size.className = "size";
size.textContent = formatSize(t.size);
row.appendChild(size);
}
ftList.appendChild(row);
}
}
renderTransfers();
// Upload
const uploadRemotePathInput = document.getElementById("ft-upload-remote-path");
const uploadFileInput = document.getElementById("ft-upload-file-input");
const uploadFilenameLabel = document.getElementById("ft-upload-filename");
document.getElementById("ft-upload-pick-btn").addEventListener("click", () => uploadFileInput.click());
uploadFileInput.addEventListener("change", () => {
const file = uploadFileInput.files[0];
uploadFilenameLabel.textContent = file ? file.name : "Keine Datei ausgewaehlt";
if (file && !uploadRemotePathInput.value) {
uploadRemotePathInput.value = `/tmp/${file.name}`;
}
});
document.getElementById("ft-upload-submit-btn").addEventListener("click", async () => {
const file = uploadFileInput.files[0];
const remotePath = uploadRemotePathInput.value.trim();
ftStatus.textContent = "";
if (!file) {
ftStatus.textContent = "Bitte zuerst eine Datei auswaehlen.";
return;
}
if (!remotePath) {
ftStatus.textContent = "Bitte einen Zielpfad angeben.";
return;
}
const formData = new FormData();
formData.append("file", file);
ftStatus.textContent = `Lade ${file.name} hoch ...`;
try {
const res = await fetch(
`/ssh/${hostId}/files/upload?remote_path=${encodeURIComponent(remotePath)}`,
{ method: "POST", credentials: "same-origin", body: formData }
);
const data = await res.json();
if (!res.ok) throw new Error(data.detail || "Upload fehlgeschlagen");
ftStatus.textContent = `Upload abgeschlossen (AV: ${data.av_scan_result})`;
recordTransfer("upload", file.name, data.size, true, "");
uploadFileInput.value = "";
uploadFilenameLabel.textContent = "Keine Datei ausgewaehlt";
uploadRemotePathInput.value = "";
} catch (err) {
ftStatus.textContent = "Upload-Fehler: " + err.message;
recordTransfer("upload", file.name, 0, false, err.message);
}
});
// Download
const downloadRemotePathInput = document.getElementById("ft-download-remote-path");
document.getElementById("ft-download-submit-btn").addEventListener("click", async () => {
const remotePath = downloadRemotePathInput.value.trim();
ftStatus.textContent = "";
if (!remotePath) {
ftStatus.textContent = "Bitte einen Pfad angeben.";
return;
}
const filename = remotePath.split("/").filter(Boolean).pop() || "download";
ftStatus.textContent = `Lade ${filename} herunter ...`;
try {
const res = await fetch(
`/ssh/${hostId}/files/download?remote_path=${encodeURIComponent(remotePath)}`,
{ method: "GET", credentials: "same-origin" }
);
if (!res.ok) {
let message = "Download fehlgeschlagen";
try {
const data = await res.json();
message = data.detail || message;
} catch (_) { /* Antwort war kein JSON */ }
throw new Error(message);
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
ftStatus.textContent = `Download abgeschlossen: ${filename}`;
recordTransfer("download", filename, blob.size, true, "");
downloadRemotePathInput.value = "";
} catch (err) {
ftStatus.textContent = "Download-Fehler: " + err.message;
recordTransfer("download", filename, 0, false, err.message);
}
});
})();