fix(ops): shell Run on Session tab + larger output + UI polish

Wire context handlers per mount so duplicate ctxRun IDs no longer leave
the Session-tab Run button dead (getElementById hit the hidden rail).
Grow session/console output space, right-align Close, restyle INKO close.
This commit is contained in:
Mr. The Plague
2026-08-04 00:25:01 -04:00
parent aa1a34327e
commit 169ec40460
2 changed files with 128 additions and 38 deletions
+65 -31
View File
@@ -557,27 +557,63 @@
}
bindCtxChrome();
function bindContextHandlers() {
if (el("ctxClaim")) el("ctxClaim").onclick = async () => {
function bindContextHandlers(root) {
/* Scope to mount root — multiple panels mirror the same markup; document.getElementById
would only wire the first (often hidden ctx rail) and leave Session-tab Run dead. */
const scope = root && root.querySelector ? root : document;
const q = (sel) => scope.querySelector(sel);
const setOut = (text) => {
const box = q("#ctxOut");
if (!box) return;
box.textContent = text == null || text === "" ? "(no output)" : String(text);
box.classList.remove("empty");
};
const runShell = async () => {
const cmdEl = q("#ctxCmd");
const command = (cmdEl && cmdEl.value || "").trim();
if (!command) return showError("Command required");
if (!selectedId) return showError("Select a session");
const btn = q("#ctxRun");
if (btn) btn.disabled = true;
try {
const r = await api("POST", "/api/v1/shell/command", {
session_id: selectedId,
command,
wait_sec: 8,
});
const out = r.output != null ? r.output : (r.result != null ? r.result : JSON.stringify(r, null, 2));
if (typeof showOutput === "function") showOutput(out, "$ " + command);
else if (window.__SC5_showOutput) window.__SC5_showOutput(out, "$ " + command);
setOut(out);
if (typeof window.__SC5_focusConsole === "function") window.__SC5_focusConsole();
showOk("Shell command sent");
} catch (e) {
showError(String(e.message || e));
setOut("Error: " + String(e.message || e));
} finally {
if (btn) btn.disabled = false;
}
};
if (q("#ctxClaim")) q("#ctxClaim").onclick = async () => {
try {
const r = await api("POST", `/api/v1/sessions/${encodeURIComponent(selectedId)}/claim`, {});
showOk("Lock claimed");
if (el("ctxOut")) { el("ctxOut").textContent = JSON.stringify(r, null, 2); el("ctxOut").classList.remove("empty"); }
setOut(JSON.stringify(r, null, 2));
if (window.__SC5_refresh) await window.__SC5_refresh();
renderContext(true);
} catch (e) { showError(String(e.message || e)); }
};
if (el("ctxForceClaim")) el("ctxForceClaim").onclick = async () => {
if (q("#ctxForceClaim")) q("#ctxForceClaim").onclick = async () => {
if (!(await askConfirm("Force-steal lock from current holder?"))) return;
try {
const r = await api("POST", `/api/v1/sessions/${encodeURIComponent(selectedId)}/claim`, { force: true });
showOk("Force claimed");
if (el("ctxOut")) { el("ctxOut").textContent = JSON.stringify(r, null, 2); el("ctxOut").classList.remove("empty"); }
setOut(JSON.stringify(r, null, 2));
if (window.__SC5_refresh) await window.__SC5_refresh();
renderContext(true);
} catch (e) { showError(String(e.message || e)); }
};
if (el("ctxRelease")) el("ctxRelease").onclick = async () => {
if (q("#ctxRelease")) q("#ctxRelease").onclick = async () => {
try {
await api("POST", `/api/v1/sessions/${encodeURIComponent(selectedId)}/release`);
showOk("Lock released");
@@ -585,14 +621,14 @@
renderContext(true);
} catch (e) { showError(String(e.message || e)); }
};
if (el("ctxSpectate")) el("ctxSpectate").onclick = async () => {
if (q("#ctxSpectate")) q("#ctxSpectate").onclick = async () => {
try {
const r = await api("GET", `/api/v1/sessions/${encodeURIComponent(selectedId)}/spectator`);
if (el("ctxOut")) { el("ctxOut").textContent = JSON.stringify(r, null, 2); el("ctxOut").classList.remove("empty"); }
setOut(JSON.stringify(r, null, 2));
showOk("Spectator snapshot");
} catch (e) { showError(String(e.message || e)); }
};
if (el("ctxStabilize")) el("ctxStabilize").onclick = async () => {
if (q("#ctxStabilize")) q("#ctxStabilize").onclick = async () => {
if (!selectedId) return;
if (!(await askConfirm("Inject stage-2 stabilize agent? Detects Linux vs Windows and reconnects a durable channel."))) return;
try {
@@ -602,31 +638,29 @@
{ os: "auto" },
);
showOk(r.status === "already_stable" ? "Already stable" : ("Stabilized as " + (r.os || "?")));
if (el("ctxOut")) {
el("ctxOut").textContent = JSON.stringify(r, null, 2);
el("ctxOut").classList.remove("empty");
}
setOut(JSON.stringify(r, null, 2));
if (window.__SC5_refresh) await window.__SC5_refresh();
renderContext(true);
} catch (e) { showError(String(e.message || e)); }
};
if (el("ctxRun")) el("ctxRun").onclick = async () => {
const command = (el("ctxCmd").value || "").trim();
if (!command) return showError("Command required");
try {
const r = await api("POST", "/api/v1/shell/command", { session_id: selectedId, command, wait_sec: 5 });
const out = r.output || r.result || JSON.stringify(r, null, 2);
showOutput(out, "$ " + command);
if (el("ctxOut")) { el("ctxOut").textContent = out; el("ctxOut").classList.remove("empty"); }
} catch (e) { showError(String(e.message || e)); }
};
if (el("ctxTaskBtn")) el("ctxTaskBtn").onclick = async () => {
const command = (el("ctxTask").value || "").trim();
if (q("#ctxRun")) q("#ctxRun").onclick = () => { runShell(); };
if (q("#ctxCmd")) {
q("#ctxCmd").onkeydown = (ev) => {
if (ev.key === "Enter" && !ev.shiftKey) {
ev.preventDefault();
runShell();
}
};
}
if (q("#ctxTaskBtn")) q("#ctxTaskBtn").onclick = async () => {
const taskEl = q("#ctxTask");
const command = (taskEl && taskEl.value || "").trim();
if (!command) return showError("Command required");
try {
const r = await api("POST", "/api/v1/tasks", { session_id: selectedId, command });
showOk("Task " + (r.id || "queued"));
if (el("ctxOut")) { el("ctxOut").textContent = JSON.stringify(r, null, 2); el("ctxOut").classList.remove("empty"); }
setOut(JSON.stringify(r, null, 2));
if (typeof showOutput === "function") showOutput(JSON.stringify(r, null, 2), "task " + (r.id || ""));
} catch (e) { showError(String(e.message || e)); }
};
}
@@ -702,7 +736,7 @@
${shellTab}
${taskTab}
</div>
<div class="outbox empty" id="ctxOut" style="max-height:160px;margin-top:10px;flex-shrink:0">Output</div>
<div class="outbox empty ctx-outbox" id="ctxOut">Output</div>
</div>`;
}
@@ -730,7 +764,7 @@
body.innerHTML = ctxPanelHtml(s, claim);
ctxBoundSid = selectedId;
bindSubTabs(body);
bindContextHandlers();
bindContextHandlers(body);
}
function bindSubTabs(root) {
@@ -861,17 +895,17 @@
<div class="toolbar">
<span class="muted" id="sesCount">${rows.length} active</span>
${can("sessions:write") ? '<button type="button" class="ghost sm" id="sesReap">Reap dead</button>' : ""}
${can("sessions:write") ? '<button type="button" class="danger sm" id="sesClose">Close</button>' : ""}
<button type="button" class="ghost sm" id="sesRefresh">Refresh</button>
${can("sessions:write") ? '<button type="button" class="danger sm" id="sesClose" style="margin-left:auto">Close</button>' : ""}
</div>
<div style="flex:1;min-height:0;overflow:auto">
${rows.length
? `<table class="data"><thead><tr><th>Session</th><th>Kind</th><th>Host</th></tr></thead><tbody id="sesTbody"></tbody></table>`
: '<div class="empty-state" id="sesEmpty"><strong>No sessions</strong>Land a beacon or reverse shell.</div>'}
</div>
<div id="sesDetail" class="outbox empty" style="max-height:100px;margin-top:8px;flex-shrink:0">Select a session...</div>
<div id="sesDetail" class="outbox empty ses-detail-out">Select a session...</div>
`},
{ id: "sctx", label: "Session", html: `<div id="sesCtxMount" style="flex:1;min-height:0;overflow:auto"></div>` },
{ id: "sctx", label: "Session", html: `<div id="sesCtxMount" class="ctx-mount" style="flex:1;min-height:0;overflow:auto;display:flex;flex-direction:column"></div>` },
{ id: "stasks", label: "Tasks", html: `
<div class="toolbar">
<button type="button" class="ghost sm" id="tskReload">Reload</button>
+63 -7
View File
@@ -24,7 +24,7 @@
--purple: #a78bfa;
--sidebar-w: 200px;
--top-h: 48px;
--bottom-h: 160px;
--bottom-h: 280px;
--font: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
--radius: 8px;
@@ -54,11 +54,30 @@
}
button.accent:hover { background: rgba(52,211,153,0.22); }
button.icon-close {
width: 32px; height: 32px; min-height: 32px; padding: 0;
width: 30px; height: 30px; min-height: 30px; min-width: 30px; padding: 0;
border-radius: 8px; display: inline-flex; align-items: center; justify-content: center;
font-size: 1.1rem; line-height: 1; color: var(--muted);
font-size: 1.05rem; line-height: 1; font-weight: 500;
color: var(--muted);
background: transparent;
border: 1px solid var(--border);
box-shadow: none;
flex-shrink: 0;
}
button.icon-close:hover {
color: var(--text);
border-color: rgba(233,30,140,0.45);
background: rgba(233,30,140,0.1);
}
button.icon-close:active { background: rgba(233,30,140,0.16); }
.ai-drawer-head-actions {
display: inline-flex; align-items: center; gap: 6px;
flex-shrink: 0; margin-left: auto;
}
.ai-drawer-head-actions #aiNewChat {
min-height: 30px;
padding: 4px 12px;
border-radius: 8px;
}
button.icon-close:hover { color: var(--text); border-color: rgba(233,30,140,0.4); background: rgba(255,255,255,0.04); }
button.ghost {
background: transparent; border-color: var(--border); color: var(--muted); font-weight: 500;
}
@@ -427,6 +446,7 @@
margin-bottom: -1px;
}
.page-tab-panels { flex: 1; min-height: 0; position: relative; overflow: hidden; }
.page-tab-panel .ctx-mount { width: 100%; }
.page-tab-panel {
display: none !important;
height: 100%; min-height: 0; overflow: auto; padding: 10px 12px;
@@ -536,6 +556,24 @@
}
.ctx-compact .ctx-actions { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 10px; }
.ctx-compact label { margin-top: 6px; }
.ctx-compact {
display: flex; flex-direction: column; min-height: 0; height: 100%;
}
.ctx-compact .ctx-outbox,
.outbox.ctx-outbox {
flex: 1 1 auto;
min-height: 220px;
max-height: none;
margin-top: 10px;
overflow: auto;
}
.ses-detail-out {
flex: 0 0 auto;
min-height: 120px;
max-height: 28vh;
margin-top: 8px;
overflow: auto;
}
/* Nested sub-tabs inside a page tab (e.g. Session → Shell / Task) */
.sub-tabs { display: flex; flex-direction: column; flex: 1; min-height: 0; width: 100%; }
.sub-tab-bar {
@@ -836,7 +874,7 @@
background: rgba(244,114,182,0.9);
}
.ai-drawer-head {
display: flex; align-items: flex-start; gap: 8px; padding: 12px 12px 10px;
display: flex; align-items: center; gap: 8px; padding: 12px 12px 10px;
border-bottom: 1px solid var(--border); font-size: 0.85rem; flex-shrink: 0;
padding-top: calc(12px + env(safe-area-inset-top, 0px));
}
@@ -1389,8 +1427,10 @@
<strong class="inko-brand-full"><span class="inko-acronym">INKO</span></strong>
<span class="muted" style="font-size:0.72rem;font-weight:600;line-height:1.25">Intelligent Neural Kinetic Operator</span>
</div>
<button type="button" class="accent sm" id="aiNewChat" title="Start a new chat" style="flex-shrink:0">New chat</button>
<button type="button" class="icon-close" id="aiDrawerClose" style="margin-left:4px;flex-shrink:0" title="Close INKO" aria-label="Close INKO">&times;</button>
<div class="ai-drawer-head-actions">
<button type="button" class="accent sm" id="aiNewChat" title="Start a new chat">New chat</button>
<button type="button" class="icon-close" id="aiDrawerClose" title="Close INKO" aria-label="Close INKO">&times;</button>
</div>
</div>
<div class="ai-drawer-body" id="aiChatLog"></div>
<div class="ai-drawer-foot" id="aiDrawerFoot">
@@ -1526,6 +1566,20 @@
el.classList.add("show");
setTimeout(() => el.classList.remove("show"), 3000);
}
function focusConsolePane() {
const panes = $("bottomPanes");
if (!panes) return;
if (panes.classList.contains("mode-tabs")) {
panes.querySelectorAll(".bottom-pane").forEach((p) => {
p.classList.toggle("tab-active", p.getAttribute("data-bpane") === "console");
});
document.querySelectorAll("#bottomTabBar [data-bpane]").forEach((b) => {
b.classList.toggle("active", b.getAttribute("data-bpane") === "console");
});
}
const box = $("consoleBox");
if (box) box.scrollTop = box.scrollHeight;
}
function showOutput(text, meta) {
const box = $("consoleBox");
if (!box) return;
@@ -1537,7 +1591,9 @@
}
box.textContent = head ? head + "\n\n" + body : body;
box.scrollTop = box.scrollHeight;
focusConsolePane();
}
window.__SC5_focusConsole = focusConsolePane;
const PRIVACY_KEY = "sc5_ops_privacy";
function privacyModeOn() {
try { return localStorage.getItem(PRIVACY_KEY) === "1"; } catch (_) { return false; }