improved rev console
This commit is contained in:
@@ -10,58 +10,39 @@
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.console-topbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 14px 18px;
|
||||
background: rgba(7, 12, 22, 0.9);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.console-topbar .brand {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.console-topbar .console-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.console-main {
|
||||
flex: 1;
|
||||
padding: 18px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.console-card {
|
||||
width: min(1100px, 96vw);
|
||||
max-width: 1280px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
box-sizing: border-box;
|
||||
background: rgba(8, 12, 22, 0.8);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.4);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
padding: 16px;
|
||||
gap: 12px;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.console-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.console-meta {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
gap: 10px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(120px, max-content));
|
||||
gap: 14px 24px;
|
||||
color: #a9b8d4;
|
||||
}
|
||||
|
||||
@@ -70,67 +51,37 @@
|
||||
color: #e5edff;
|
||||
}
|
||||
|
||||
.console-output {
|
||||
min-height: 420px;
|
||||
max-height: 70vh;
|
||||
.console-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.console-terminal {
|
||||
min-height: 65vh;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
background: #050913;
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 12px;
|
||||
padding: 14px;
|
||||
overflow: auto;
|
||||
overflow-x: auto;
|
||||
box-sizing: border-box;
|
||||
white-space: normal;
|
||||
text-align: left;
|
||||
overflow-wrap: normal;
|
||||
word-break: normal;
|
||||
line-height: 1.5;
|
||||
font-size: 14px;
|
||||
color: #e8edf2;
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
.console-pre {
|
||||
margin: 0;
|
||||
white-space: pre;
|
||||
display: block;
|
||||
width: max-content;
|
||||
min-width: 0;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.console-input-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 10px;
|
||||
padding: 10px 12px;
|
||||
box-sizing: border-box;
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.02);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.console-input-row .prompt {
|
||||
color: #6ee7b7;
|
||||
.console-terminal .xterm,
|
||||
.console-terminal .xterm-viewport,
|
||||
.console-terminal .xterm-screen {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.console-input-row textarea {
|
||||
flex: 1;
|
||||
resize: vertical;
|
||||
min-height: 44px;
|
||||
max-height: 160px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #e8edf2;
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
.console-terminal .xterm-viewport::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
}
|
||||
|
||||
.console-buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
.console-terminal .xterm-viewport::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.btn,
|
||||
@@ -160,12 +111,35 @@
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.status-pill {
|
||||
padding: 8px 12px;
|
||||
.pill {
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 13px;
|
||||
font-size: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.pill i {
|
||||
font-size: 8px;
|
||||
}
|
||||
|
||||
.pill.pill-online {
|
||||
color: #6ee7b7;
|
||||
border-color: rgba(110, 231, 183, 0.35);
|
||||
background: rgba(110, 231, 183, 0.08);
|
||||
}
|
||||
|
||||
.pill.pill-offline {
|
||||
color: #fca5a5;
|
||||
border-color: rgba(252, 165, 165, 0.35);
|
||||
background: rgba(252, 165, 165, 0.08);
|
||||
}
|
||||
|
||||
.pill.pill-ghost {
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
@@ -173,21 +147,12 @@
|
||||
background: #050913;
|
||||
}
|
||||
.console-main {
|
||||
padding: 12px;
|
||||
padding: 10px;
|
||||
}
|
||||
.console-card {
|
||||
padding: 12px;
|
||||
padding: 10px;
|
||||
}
|
||||
.console-input-row {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
.console-buttons {
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.console-buttons .btn,
|
||||
.console-buttons .button {
|
||||
width: auto;
|
||||
.console-terminal {
|
||||
min-height: 70vh;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,33 +1,72 @@
|
||||
import { ansiToHtml } from "./ansi.js";
|
||||
import { Terminal } from "/vendor/xterm/xterm.mjs";
|
||||
import { FitAddon } from "/vendor/xterm/addon-fit.mjs";
|
||||
import { WebLinksAddon } from "/vendor/xterm/addon-web-links.mjs";
|
||||
import { encodeMsgpack, decodeMsgpack } from "./msgpack-helpers.js";
|
||||
import { checkFeatureAccess } from "./feature-gate.js";
|
||||
const outputEl = document.getElementById("console-output");
|
||||
|
||||
const containerEl = document.getElementById("terminal");
|
||||
const statusPill = document.getElementById("status-pill");
|
||||
const clientLabel = document.getElementById("client-label");
|
||||
const hostLabel = document.getElementById("host-label");
|
||||
const userLabel = document.getElementById("user-label");
|
||||
const osLabel = document.getElementById("os-label");
|
||||
const form = document.getElementById("console-form");
|
||||
const input = document.getElementById("console-input");
|
||||
const ctrlcBtn = document.getElementById("ctrlc-btn");
|
||||
const reconnectBtn = document.getElementById("reconnect-btn");
|
||||
const clearBtn = document.getElementById("clear-btn");
|
||||
|
||||
const clientId = decodeURIComponent(
|
||||
location.pathname.split("/").filter(Boolean)[0] || "",
|
||||
);
|
||||
const wsProto = location.protocol === "https:" ? "wss" : "ws";
|
||||
const wsUrl = `${wsProto}://${location.host}/api/clients/${encodeURIComponent(clientId)}/console/ws`;
|
||||
|
||||
const term = new Terminal({
|
||||
cursorBlink: true,
|
||||
fontFamily: '"JetBrains Mono", ui-monospace, Menlo, Consolas, monospace',
|
||||
fontSize: 13,
|
||||
scrollback: 10000,
|
||||
allowProposedApi: true,
|
||||
theme: {
|
||||
background: "#050913",
|
||||
foreground: "#e8edf2",
|
||||
cursor: "#6ee7b7",
|
||||
cursorAccent: "#050913",
|
||||
selectionBackground: "rgba(110, 231, 183, 0.25)",
|
||||
black: "#1a1d29",
|
||||
red: "#ef4444",
|
||||
green: "#22c55e",
|
||||
yellow: "#eab308",
|
||||
blue: "#60a5fa",
|
||||
magenta: "#c084fc",
|
||||
cyan: "#22d3ee",
|
||||
white: "#e8edf2",
|
||||
brightBlack: "#475569",
|
||||
brightRed: "#f87171",
|
||||
brightGreen: "#4ade80",
|
||||
brightYellow: "#facc15",
|
||||
brightBlue: "#93c5fd",
|
||||
brightMagenta: "#d8b4fe",
|
||||
brightCyan: "#67e8f9",
|
||||
brightWhite: "#f8fafc",
|
||||
},
|
||||
});
|
||||
const fit = new FitAddon();
|
||||
term.loadAddon(fit);
|
||||
term.loadAddon(new WebLinksAddon());
|
||||
term.open(containerEl);
|
||||
|
||||
let ws = null;
|
||||
let connected = false;
|
||||
let outputBuffer = "";
|
||||
let hasOutput = false;
|
||||
let sessionClosed = false;
|
||||
let offlineNotified = false;
|
||||
let lastSize = { cols: 0, rows: 0 };
|
||||
let resizeTimer = 0;
|
||||
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const prefilledCommand = urlParams.get("cmd");
|
||||
if (prefilledCommand && input) {
|
||||
setTimeout(() => {
|
||||
input.value = prefilledCommand;
|
||||
input.focus();
|
||||
}, 1000);
|
||||
function tryFit() {
|
||||
try {
|
||||
fit.fit();
|
||||
} catch {
|
||||
/* ignore: container not yet measured */
|
||||
}
|
||||
}
|
||||
|
||||
function setStatus(label, tone = "pill-offline") {
|
||||
@@ -36,32 +75,46 @@ function setStatus(label, tone = "pill-offline") {
|
||||
statusPill.innerHTML = `<i class="fa-solid fa-circle"></i> ${label}`;
|
||||
}
|
||||
|
||||
function appendSystem(text) {
|
||||
outputBuffer += `\n[system] ${text}\n`;
|
||||
renderOutput();
|
||||
function writeSystem(msg) {
|
||||
term.writeln(`\x1b[2m[${msg}]\x1b[0m`);
|
||||
}
|
||||
|
||||
function appendOutput(text) {
|
||||
outputBuffer += text;
|
||||
renderOutput();
|
||||
function sendResize() {
|
||||
if (sessionClosed) return;
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||
const cols = term.cols;
|
||||
const rows = term.rows;
|
||||
if (!cols || !rows) return;
|
||||
if (cols === lastSize.cols && rows === lastSize.rows) return;
|
||||
lastSize = { cols, rows };
|
||||
ws.send(encodeMsgpack({ type: "resize", cols, rows }));
|
||||
}
|
||||
|
||||
function renderOutput() {
|
||||
if (!outputEl) return;
|
||||
const normalized = outputBuffer.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
||||
outputEl.innerHTML = `<pre class="console-pre">${ansiToHtml(normalized)}</pre>`;
|
||||
outputEl.scrollTop = outputEl.scrollHeight;
|
||||
function applyOutput(data) {
|
||||
if (!data) return;
|
||||
if (data instanceof Uint8Array) {
|
||||
term.write(data);
|
||||
} else if (typeof data === "string") {
|
||||
term.write(data);
|
||||
} else if (data?.buffer instanceof ArrayBuffer) {
|
||||
term.write(new Uint8Array(data.buffer, data.byteOffset ?? 0, data.byteLength));
|
||||
}
|
||||
}
|
||||
|
||||
function connect() {
|
||||
appendSystem("Connecting to console...");
|
||||
setStatus("Connecting...", "pill-offline");
|
||||
sessionClosed = false;
|
||||
setStatus("Connecting...", "pill-ghost");
|
||||
writeSystem("Connecting...");
|
||||
|
||||
ws = new WebSocket(wsUrl);
|
||||
ws.binaryType = "arraybuffer";
|
||||
|
||||
ws.addEventListener("open", () => {
|
||||
connected = true;
|
||||
setStatus("Connected", "pill-online");
|
||||
term.options.disableStdin = false;
|
||||
tryFit();
|
||||
sendResize();
|
||||
});
|
||||
|
||||
ws.addEventListener("message", (event) => {
|
||||
@@ -74,32 +127,36 @@ function connect() {
|
||||
if (hostLabel) hostLabel.textContent = payload.host || "unknown";
|
||||
if (userLabel) userLabel.textContent = payload.user || "unknown";
|
||||
if (osLabel) osLabel.textContent = payload.os || "unknown";
|
||||
setStatus("Connected", "pill-online");
|
||||
setStatus("Live", "pill-online");
|
||||
break;
|
||||
case "status":
|
||||
if (payload.status === "offline") {
|
||||
setStatus("Offline", "pill-offline");
|
||||
appendSystem(payload.reason || "Client offline");
|
||||
} else if (payload.status === "connecting" && !hasOutput) {
|
||||
setStatus("Connecting...", "pill-ghost");
|
||||
if (!offlineNotified) {
|
||||
offlineNotified = true;
|
||||
writeSystem(payload.reason || "Client offline");
|
||||
}
|
||||
sessionClosed = true;
|
||||
term.options.disableStdin = true;
|
||||
} else if (payload.status === "closed") {
|
||||
setStatus("Closed", "pill-offline");
|
||||
appendSystem(payload.reason || "Console closed");
|
||||
writeSystem(payload.reason || "Console closed");
|
||||
sessionClosed = true;
|
||||
term.options.disableStdin = true;
|
||||
} else if (payload.status === "online") {
|
||||
offlineNotified = false;
|
||||
}
|
||||
break;
|
||||
case "output": {
|
||||
if (payload.data) {
|
||||
hasOutput = true;
|
||||
setStatus("Live", "pill-online");
|
||||
appendOutput(payload.data);
|
||||
}
|
||||
if (payload.error) appendSystem(payload.error);
|
||||
case "output":
|
||||
applyOutput(payload.data);
|
||||
if (payload.error) writeSystem(payload.error);
|
||||
if (typeof payload.exitCode === "number") {
|
||||
appendSystem(`Process exited (${payload.exitCode})`);
|
||||
term.writeln(`\r\n\x1b[33m[Process exited (${payload.exitCode})]\x1b[0m`);
|
||||
setStatus("Closed", "pill-offline");
|
||||
sessionClosed = true;
|
||||
term.options.disableStdin = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
@@ -109,7 +166,8 @@ function connect() {
|
||||
if (!connected) return;
|
||||
connected = false;
|
||||
setStatus("Disconnected", "pill-offline");
|
||||
appendSystem("Connection closed");
|
||||
if (!sessionClosed) writeSystem("Connection closed");
|
||||
term.options.disableStdin = true;
|
||||
});
|
||||
|
||||
ws.addEventListener("error", () => {
|
||||
@@ -117,40 +175,41 @@ function connect() {
|
||||
});
|
||||
}
|
||||
|
||||
function sendInput(value) {
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
||||
appendSystem("Socket not ready");
|
||||
return;
|
||||
}
|
||||
ws.send(encodeMsgpack({ type: "input", data: value }));
|
||||
}
|
||||
term.onData((d) => {
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||
if (sessionClosed) return;
|
||||
ws.send(encodeMsgpack({ type: "input", data: d }));
|
||||
});
|
||||
|
||||
function wireInput() {
|
||||
if (!form || !input) return;
|
||||
term.onResize(() => {
|
||||
sendResize();
|
||||
});
|
||||
|
||||
form.addEventListener("submit", (e) => {
|
||||
e.preventDefault();
|
||||
const value = input.value;
|
||||
if (!value.trim()) {
|
||||
input.value = "";
|
||||
return;
|
||||
window.addEventListener("resize", () => {
|
||||
clearTimeout(resizeTimer);
|
||||
resizeTimer = setTimeout(tryFit, 120);
|
||||
});
|
||||
|
||||
reconnectBtn?.addEventListener("click", () => {
|
||||
try { ws?.close(); } catch { /* ignore */ }
|
||||
term.reset();
|
||||
connect();
|
||||
});
|
||||
|
||||
clearBtn?.addEventListener("click", () => {
|
||||
term.clear();
|
||||
});
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
tryFit();
|
||||
checkFeatureAccess("console", clientId).then((ok) => ok && connect());
|
||||
});
|
||||
|
||||
const prefilledCommand = new URLSearchParams(window.location.search).get("cmd");
|
||||
if (prefilledCommand) {
|
||||
setTimeout(() => {
|
||||
if (ws?.readyState === WebSocket.OPEN && !sessionClosed) {
|
||||
ws.send(encodeMsgpack({ type: "input", data: prefilledCommand + "\r" }));
|
||||
}
|
||||
const text = value.endsWith("\n") ? value : `${value}\n`;
|
||||
sendInput(text);
|
||||
input.value = "";
|
||||
input.focus();
|
||||
});
|
||||
|
||||
input.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
form.dispatchEvent(new Event("submit"));
|
||||
}
|
||||
});
|
||||
|
||||
ctrlcBtn?.addEventListener("click", () => {
|
||||
sendInput("\u0003");
|
||||
});
|
||||
}, 1200);
|
||||
}
|
||||
wireInput();
|
||||
checkFeatureAccess("console", clientId).then(ok => ok && connect());
|
||||
|
||||
Reference in New Issue
Block a user