288 lines
7.8 KiB
JavaScript
288 lines
7.8 KiB
JavaScript
|
|
(() => {
|
||
|
|
const $ = (sel) => document.querySelector(sel);
|
||
|
|
|
||
|
|
let term = null;
|
||
|
|
let fitAddon = null;
|
||
|
|
let socket = null;
|
||
|
|
let connected = false;
|
||
|
|
let connectInFlight = false;
|
||
|
|
let currentHost = "";
|
||
|
|
|
||
|
|
function protoWs() {
|
||
|
|
return location.protocol === "https:" ? "wss" : "ws";
|
||
|
|
}
|
||
|
|
|
||
|
|
function rememberUser(user) {
|
||
|
|
if (user) localStorage.setItem("cockpit_ssh_user", user);
|
||
|
|
}
|
||
|
|
|
||
|
|
function rememberedUser() {
|
||
|
|
return localStorage.getItem("cockpit_ssh_user") || "";
|
||
|
|
}
|
||
|
|
|
||
|
|
function openSshModal(opts = {}) {
|
||
|
|
const modal = $("#ssh-modal");
|
||
|
|
const scrim = $("#scrim");
|
||
|
|
if (!modal) return;
|
||
|
|
|
||
|
|
currentHost = opts.host || "";
|
||
|
|
$("#ssh-host").value = currentHost;
|
||
|
|
$("#ssh-port").value = opts.port || 22;
|
||
|
|
$("#ssh-user").value = opts.username || rememberedUser();
|
||
|
|
$("#ssh-pass").value = "";
|
||
|
|
$("#ssh-device").textContent = opts.name || currentHost || "SSH session";
|
||
|
|
$("#ssh-sub").textContent = opts.model
|
||
|
|
? `${opts.model}${opts.ip ? " · " + opts.ip : ""}`
|
||
|
|
: opts.ip || currentHost || "—";
|
||
|
|
$("#ssh-status").textContent = "Enter username and password, then Connect.";
|
||
|
|
$("#ssh-status").className = "ssh-status";
|
||
|
|
$("#ssh-connect-btn").disabled = false;
|
||
|
|
$("#ssh-term-wrap").classList.add("hidden");
|
||
|
|
$("#ssh-login").classList.remove("hidden");
|
||
|
|
|
||
|
|
modal.classList.remove("hidden");
|
||
|
|
modal.setAttribute("aria-hidden", "false");
|
||
|
|
scrim?.classList.add("open");
|
||
|
|
if (scrim) scrim.dataset.mode = "ssh";
|
||
|
|
|
||
|
|
setTimeout(() => {
|
||
|
|
const u = $("#ssh-user");
|
||
|
|
if (u && !u.value) u.focus();
|
||
|
|
else $("#ssh-pass")?.focus();
|
||
|
|
}, 50);
|
||
|
|
}
|
||
|
|
|
||
|
|
function closeSshModal() {
|
||
|
|
disconnectSsh();
|
||
|
|
const modal = $("#ssh-modal");
|
||
|
|
modal?.classList.add("hidden");
|
||
|
|
modal?.setAttribute("aria-hidden", "true");
|
||
|
|
const scrim = $("#scrim");
|
||
|
|
if (scrim?.dataset.mode === "ssh") {
|
||
|
|
scrim.classList.remove("open");
|
||
|
|
delete scrim.dataset.mode;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function setStatus(msg, kind) {
|
||
|
|
const el = $("#ssh-status");
|
||
|
|
if (!el) return;
|
||
|
|
el.textContent = msg;
|
||
|
|
el.className = "ssh-status" + (kind ? " " + kind : "");
|
||
|
|
}
|
||
|
|
|
||
|
|
function ensureTerminal() {
|
||
|
|
if (term) return term;
|
||
|
|
if (!window.Terminal) {
|
||
|
|
setStatus("Terminal library missing — hard refresh (Ctrl+Shift+R) or check /vendor/xterm/", "error");
|
||
|
|
$("#ssh-login")?.classList.remove("hidden");
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
term = new window.Terminal({
|
||
|
|
cursorBlink: true,
|
||
|
|
fontFamily: "IBM Plex Mono, ui-monospace, Menlo, monospace",
|
||
|
|
fontSize: 13,
|
||
|
|
lineHeight: 1.2,
|
||
|
|
theme: {
|
||
|
|
background: "#071018",
|
||
|
|
foreground: "#e8f4ff",
|
||
|
|
cursor: "#3dffe0",
|
||
|
|
selectionBackground: "rgba(0,168,232,0.35)",
|
||
|
|
},
|
||
|
|
allowProposedApi: true,
|
||
|
|
});
|
||
|
|
const FitCtor = window.FitAddon?.FitAddon || window.FitAddon;
|
||
|
|
fitAddon = FitCtor ? new FitCtor() : null;
|
||
|
|
if (fitAddon) term.loadAddon(fitAddon);
|
||
|
|
term.open($("#ssh-term"));
|
||
|
|
term.onData((data) => {
|
||
|
|
if (!socket || socket.readyState !== WebSocket.OPEN || !connected) return;
|
||
|
|
socket.send(data);
|
||
|
|
});
|
||
|
|
window.addEventListener("resize", () => {
|
||
|
|
try {
|
||
|
|
fitAddon?.fit();
|
||
|
|
sendResize();
|
||
|
|
} catch (_) {}
|
||
|
|
});
|
||
|
|
return term;
|
||
|
|
}
|
||
|
|
|
||
|
|
function sendResize() {
|
||
|
|
if (!socket || socket.readyState !== WebSocket.OPEN || !term) return;
|
||
|
|
socket.send(
|
||
|
|
JSON.stringify({
|
||
|
|
type: "resize",
|
||
|
|
cols: term.cols,
|
||
|
|
rows: term.rows,
|
||
|
|
})
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
function disconnectSsh() {
|
||
|
|
connected = false;
|
||
|
|
try {
|
||
|
|
socket?.close();
|
||
|
|
} catch (_) {}
|
||
|
|
socket = null;
|
||
|
|
try {
|
||
|
|
term?.reset();
|
||
|
|
} catch (_) {}
|
||
|
|
}
|
||
|
|
|
||
|
|
async function connectSsh() {
|
||
|
|
if (connectInFlight) return;
|
||
|
|
const host = ($("#ssh-host")?.value || "").trim();
|
||
|
|
const username = ($("#ssh-user")?.value || "").trim();
|
||
|
|
const password = $("#ssh-pass")?.value || "";
|
||
|
|
const port = Number($("#ssh-port")?.value || 22);
|
||
|
|
|
||
|
|
if (!host || !username) {
|
||
|
|
setStatus("Host and username are required", "error");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (!password) {
|
||
|
|
setStatus("Password is required", "error");
|
||
|
|
$("#ssh-pass")?.focus();
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
rememberUser(username);
|
||
|
|
connectInFlight = true;
|
||
|
|
disconnectSsh();
|
||
|
|
$("#ssh-login").classList.add("hidden");
|
||
|
|
$("#ssh-term-wrap").classList.remove("hidden");
|
||
|
|
const t = ensureTerminal();
|
||
|
|
if (!t) return;
|
||
|
|
t.reset();
|
||
|
|
t.focus();
|
||
|
|
try {
|
||
|
|
fitAddon?.fit();
|
||
|
|
} catch (_) {}
|
||
|
|
|
||
|
|
setStatus(`Connecting as ${username}@${host}:${port}…`, "info");
|
||
|
|
$("#ssh-connect-btn").disabled = true;
|
||
|
|
|
||
|
|
const ws = new WebSocket(`${protoWs()}://${location.host}/ws/ssh`);
|
||
|
|
socket = ws;
|
||
|
|
|
||
|
|
ws.onopen = () => {
|
||
|
|
ws.send(
|
||
|
|
JSON.stringify({
|
||
|
|
type: "auth",
|
||
|
|
host,
|
||
|
|
port,
|
||
|
|
username,
|
||
|
|
password,
|
||
|
|
cols: t.cols || 120,
|
||
|
|
rows: t.rows || 36,
|
||
|
|
term: "xterm-256color",
|
||
|
|
})
|
||
|
|
);
|
||
|
|
// clear password from DOM after send
|
||
|
|
if ($("#ssh-pass")) $("#ssh-pass").value = "";
|
||
|
|
};
|
||
|
|
|
||
|
|
ws.onmessage = (ev) => {
|
||
|
|
const data = String(ev.data || "");
|
||
|
|
if (data.startsWith("{") && data.includes('"type"')) {
|
||
|
|
try {
|
||
|
|
const msg = JSON.parse(data);
|
||
|
|
if (msg.type === "ready") {
|
||
|
|
connected = true;
|
||
|
|
connectInFlight = false;
|
||
|
|
setStatus(msg.message || "Connected", "ok");
|
||
|
|
$("#ssh-connect-btn").disabled = false;
|
||
|
|
$("#ssh-connect-btn").textContent = "Reconnect";
|
||
|
|
try {
|
||
|
|
fitAddon?.fit();
|
||
|
|
sendResize();
|
||
|
|
t.focus();
|
||
|
|
} catch (_) {}
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (msg.type === "status") {
|
||
|
|
setStatus(msg.message || "", "info");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (msg.type === "error") {
|
||
|
|
connected = false;
|
||
|
|
connectInFlight = false;
|
||
|
|
setStatus(msg.message || "SSH error", "error");
|
||
|
|
$("#ssh-connect-btn").disabled = false;
|
||
|
|
$("#ssh-login").classList.remove("hidden");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (msg.type === "pong") return;
|
||
|
|
} catch (_) {
|
||
|
|
// fall through as terminal output
|
||
|
|
}
|
||
|
|
}
|
||
|
|
t.write(data);
|
||
|
|
};
|
||
|
|
|
||
|
|
ws.onerror = () => {
|
||
|
|
setStatus("WebSocket error", "error");
|
||
|
|
$("#ssh-connect-btn").disabled = false;
|
||
|
|
$("#ssh-login").classList.remove("hidden");
|
||
|
|
};
|
||
|
|
|
||
|
|
ws.onclose = () => {
|
||
|
|
connected = false;
|
||
|
|
connectInFlight = false;
|
||
|
|
setStatus("Disconnected", "info");
|
||
|
|
$("#ssh-connect-btn").disabled = false;
|
||
|
|
$("#ssh-connect-btn").textContent = "Connect";
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
function openSshForNode(node) {
|
||
|
|
if (!node?.ip) {
|
||
|
|
alert("No management IP for this device");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
openSshModal({
|
||
|
|
host: node.ip,
|
||
|
|
ip: node.ip,
|
||
|
|
name: node.name,
|
||
|
|
model: node.model,
|
||
|
|
username: rememberedUser(),
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
function bind() {
|
||
|
|
$("#btn-ssh-close")?.addEventListener("click", closeSshModal);
|
||
|
|
$("#btn-ssh-disconnect")?.addEventListener("click", () => {
|
||
|
|
disconnectSsh();
|
||
|
|
$("#ssh-login").classList.remove("hidden");
|
||
|
|
setStatus("Disconnected. Enter credentials to reconnect.", "info");
|
||
|
|
});
|
||
|
|
$("#ssh-form")?.addEventListener("submit", (e) => {
|
||
|
|
e.preventDefault();
|
||
|
|
connectSsh();
|
||
|
|
});
|
||
|
|
$("#ssh-connect-btn")?.addEventListener("click", (e) => {
|
||
|
|
e.preventDefault();
|
||
|
|
connectSsh();
|
||
|
|
});
|
||
|
|
|
||
|
|
// Scrim close for SSH
|
||
|
|
$("#scrim")?.addEventListener("click", () => {
|
||
|
|
if ($("#scrim")?.dataset.mode === "ssh") closeSshModal();
|
||
|
|
});
|
||
|
|
window.addEventListener("keydown", (e) => {
|
||
|
|
if (e.key === "Escape" && !$("#ssh-modal")?.classList.contains("hidden")) {
|
||
|
|
closeSshModal();
|
||
|
|
}
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
bind();
|
||
|
|
|
||
|
|
window.cockpitSsh = {
|
||
|
|
open: openSshForNode,
|
||
|
|
openModal: openSshModal,
|
||
|
|
close: closeSshModal,
|
||
|
|
};
|
||
|
|
})();
|