Files
SquidSec-SquidC5/web/phone-dashboard.html
T
Mr. The Plague 3d46734119 fix(ops): stop INKO mobile keyboard scroll jitter
Pin drawer to visualViewport instead of padding foot by kb height.
Drop smooth scrollIntoView on every resize; snap chat log instantly
once; disable overflow-anchor on the message list.
2026-08-02 12:56:12 -04:00

1807 lines
72 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#0b0b0f" />
<link rel="icon" href="/ops/assets/squidsec-logo-96.png" type="image/png" />
<title>SquidC5 Ops</title>
<style>
:root {
--bg: #0b0b0f;
--bg2: #121218;
--bg3: #1a1a22;
--border: rgba(255,255,255,0.08);
--border2: rgba(255,255,255,0.14);
--text: #e8e8ed;
--muted: #8b8b9a;
--pink: #e91e8c;
--pink2: #f472b6;
--ok: #34d399;
--warn: #fbbf24;
--bad: #f87171;
--cyan: #38bdf8;
--purple: #a78bfa;
--sidebar-w: 200px;
--top-h: 48px;
--bottom-h: 160px;
--font: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
--radius: 8px;
}
* { box-sizing: border-box; }
html, body { height: 100%; margin: 0; }
body {
font-family: var(--font);
color: var(--text);
background: var(--bg);
overflow: hidden;
}
button, input, select, textarea { font: inherit; color: inherit; }
button {
cursor: pointer; border: 1px solid var(--border2); background: var(--bg3);
border-radius: 6px; padding: 6px 12px; color: var(--text);
}
button:hover { border-color: rgba(233,30,140,0.45); background: #22222c; }
button.primary { background: rgba(233,30,140,0.2); border-color: rgba(233,30,140,0.5); color: var(--pink2); font-weight: 600; }
button.primary:hover { background: rgba(233,30,140,0.35); }
button.danger { border-color: rgba(248,113,113,0.4); color: var(--bad); }
button:disabled { opacity: 0.45; cursor: not-allowed; }
button.active, .nav-item.active {
background: rgba(233,30,140,0.18); border-color: rgba(233,30,140,0.4); color: var(--pink2);
}
input, select, textarea {
width: 100%; background: #0e0e14; border: 1px solid var(--border2);
border-radius: 6px; padding: 7px 10px; outline: none;
}
/* Room for native/custom chevron so text never collides with the arrow */
select {
padding-right: 2rem;
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath fill='%23a0a0b8' d='M1.4.6 6 5.2 10.6.6 12 2 6 8 0 2z'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 0.65rem center;
background-size: 12px 8px;
}
select::-ms-expand { display: none; }
input:focus, select:focus, textarea:focus { border-color: rgba(233,30,140,0.55); }
label { display: block; font-size: 0.7rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.06em; margin: 8px 0 4px; }
.mono { font-family: var(--mono); font-size: 0.78rem; }
.muted { color: var(--muted); }
.hidden { display: none !important; }
a { color: var(--pink2); }
/* Themed scrollbars */
* {
scrollbar-width: thin;
scrollbar-color: rgba(233,30,140,0.45) rgba(255,255,255,0.04);
}
*::-webkit-scrollbar { width: 10px; height: 10px; }
*::-webkit-scrollbar-track {
background: rgba(255,255,255,0.03);
border-radius: 999px;
}
*::-webkit-scrollbar-thumb {
background: linear-gradient(180deg, rgba(233,30,140,0.55), rgba(167,139,250,0.4));
border-radius: 999px;
border: 2px solid transparent;
background-clip: padding-box;
}
*::-webkit-scrollbar-thumb:hover {
background: linear-gradient(180deg, rgba(244,114,182,0.75), rgba(167,139,250,0.55));
background-clip: padding-box;
}
*::-webkit-scrollbar-corner { background: transparent; }
/* -- App shell -- */
.app {
display: grid;
grid-template-rows: var(--top-h) 1fr var(--bottom-h);
grid-template-columns: var(--sidebar-w) 1fr minmax(260px, 320px);
grid-template-areas:
"top top top"
"side main ctx"
"bottom bottom bottom";
height: 100dvh;
width: 100%;
}
.app.no-ctx {
grid-template-columns: var(--sidebar-w) 1fr;
grid-template-areas:
"top top"
"side main"
"bottom bottom";
}
.app.no-ctx .ctx { display: none; }
.app.no-bottom {
grid-template-rows: var(--top-h) 1fr;
--bottom-h: 0px;
}
.app.no-bottom {
grid-template-areas:
"top top top"
"side main ctx";
}
.app.no-bottom.no-ctx {
grid-template-areas:
"top top"
"side main";
}
.app.no-bottom .bottom { display: none; }
/* Top bar */
.top {
grid-area: top;
display: flex; align-items: center; gap: 12px;
padding: 0 12px;
border-bottom: 1px solid var(--border);
background: var(--bg2);
z-index: 20;
}
.top .logo { width: 28px; height: 28px; object-fit: contain; }
.top .title { font-weight: 800; letter-spacing: -0.02em; font-size: 0.95rem; }
.top .title span { color: var(--pink); }
.top .sep { width: 1px; height: 20px; background: var(--border2); }
.top .host { font-family: var(--mono); font-size: 0.75rem; color: var(--muted); max-width: 280px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.top .spacer { flex: 1; }
.pill {
font: 700 0.62rem/1 var(--mono); text-transform: uppercase; letter-spacing: 0.06em;
padding: 5px 8px; border-radius: 999px; border: 1px solid var(--border2); color: var(--muted);
}
.pill.ok { color: var(--ok); border-color: rgba(52,211,153,0.35); }
.pill.bad { color: var(--bad); border-color: rgba(248,113,113,0.35); }
.pill.live { color: var(--pink2); border-color: rgba(233,30,140,0.4); }
.top button { padding: 5px 10px; font-size: 0.78rem; }
/* Sidebar / mobile drawer */
.nav-burger {
display: none;
flex-shrink: 0;
width: 40px; height: 36px; padding: 0;
align-items: center; justify-content: center;
flex-direction: column; gap: 5px;
}
.nav-burger span {
display: block; width: 18px; height: 2px;
background: var(--text); border-radius: 2px;
transition: transform 0.15s, opacity 0.15s;
}
.app.nav-open .nav-burger span:nth-child(1) { transform: translateY(7px) rotate(45deg); }
.app.nav-open .nav-burger span:nth-child(2) { opacity: 0; }
.app.nav-open .nav-burger span:nth-child(3) { transform: translateY(-7px) rotate(-45deg); }
.nav-backdrop {
display: none; position: fixed; inset: 0; z-index: 54;
background: rgba(0,0,0,0.55);
}
.nav-backdrop.show { display: block; }
.side {
grid-area: side;
background: var(--bg2);
border-right: 1px solid var(--border);
display: flex; flex-direction: column;
overflow: hidden;
}
.side-nav {
padding: 8px 6px;
display: flex; flex-direction: column; gap: 2px;
overflow-y: auto; flex: 1;
}
.nav-item {
display: flex; align-items: center; gap: 10px;
width: 100%; text-align: left;
padding: 9px 10px; border: 1px solid transparent;
background: transparent; border-radius: 8px;
font-size: 0.84rem; font-weight: 600; color: var(--muted);
}
.nav-item:hover { background: rgba(255,255,255,0.04); color: var(--text); border-color: var(--border); }
.nav-item.active { color: var(--pink2); background: rgba(233,30,140,0.12); border-color: rgba(233,30,140,0.28); }
.nav-item .ico { width: 18px; text-align: center; opacity: 0.9; }
.nav-item .badge-n {
margin-left: auto; font: 700 0.65rem var(--mono);
background: rgba(255,255,255,0.06); padding: 2px 6px; border-radius: 999px; color: var(--text);
}
.side-foot {
border-top: 1px solid var(--border); padding: 8px;
font-size: 0.68rem; color: var(--muted);
}
/* Main workspace */
.main {
grid-area: main;
display: flex; flex-direction: column;
min-width: 0; min-height: 0;
background: var(--bg);
overflow: hidden;
}
.main-head {
display: flex; align-items: center; gap: 10px;
padding: 10px 14px; border-bottom: 1px solid var(--border);
background: rgba(18,18,24,0.6);
flex-shrink: 0;
}
.main-head h2 { margin: 0; font-size: 0.95rem; font-weight: 700; }
.main-head .sub { font-size: 0.75rem; color: var(--muted); }
.main-head > div:first-child { min-width: 0; flex: 1; }
.main-head-actions {
margin-left: auto;
display: flex; align-items: center; gap: 6px; flex-shrink: 0;
}
.main-head .tools { margin-left: 0; display: flex; gap: 6px; flex-wrap: wrap; }
.main-head .docs-drop { margin-left: 0; }
.main-body {
flex: 1; min-height: 0; overflow: auto;
padding: 12px 14px;
display: flex; flex-direction: column;
}
.view { display: none; flex: 1; min-height: 0; }
.view.active { display: flex; flex-direction: column; flex: 1; min-height: 0; }
/* Context rail (session detail) */
.ctx {
grid-area: ctx;
border-left: 1px solid var(--border);
background: var(--bg2);
display: flex; flex-direction: column;
min-height: 0; overflow: hidden;
}
.ctx-head {
display: flex; align-items: center; gap: 8px;
padding: 10px 12px; border-bottom: 1px solid var(--border);
font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.08em;
color: var(--muted); font-weight: 700; flex-shrink: 0;
}
.ctx-drag { display: none; }
.ctx-close {
display: none; margin-left: auto;
padding: 6px 10px; min-height: 36px; font-size: 0.85rem;
}
.ctx-body {
flex: 1; overflow-y: auto; overflow-x: hidden;
padding: 10px 12px; min-height: 0;
-webkit-overflow-scrolling: touch;
overscroll-behavior: contain;
touch-action: pan-y;
}
.ctx-empty { color: var(--muted); font-size: 0.85rem; line-height: 1.45; padding: 12px 4px; }
.ctx-backdrop {
display: none; position: fixed; inset: 0; z-index: 44;
background: rgba(0,0,0,0.55);
}
/* Bottom console - resizable height + pane split */
.bottom {
grid-area: bottom;
border-top: 1px solid var(--border);
background: #0a0a0e;
display: flex;
flex-direction: column;
min-height: 0;
position: relative;
}
.bottom-resize {
flex: 0 0 10px;
cursor: ns-resize;
background: transparent;
position: relative;
z-index: 6;
touch-action: none;
}
.bottom-resize::after {
content: "";
position: absolute; left: 50%; top: 3px;
width: 48px; height: 3px; margin-left: -24px;
border-radius: 999px;
background: rgba(233,30,140,0.45);
}
.bottom-resize:hover::after,
.bottom-resize.dragging::after {
background: rgba(244,114,182,0.9);
box-shadow: 0 0 10px rgba(233,30,140,0.45);
}
.bottom-panes {
flex: 1;
display: grid;
grid-template-columns: var(--pane-split, 1fr 6px 1fr);
min-height: 0;
min-width: 0;
}
.bottom-pane {
display: flex; flex-direction: column; min-height: 0; min-width: 0;
}
.pane-vsplit {
cursor: ew-resize;
background: rgba(255,255,255,0.06);
position: relative;
touch-action: none;
z-index: 5;
}
.pane-vsplit::after {
content: "";
position: absolute; top: 50%; left: 50%;
width: 3px; height: 28px; margin: -14px 0 0 -1.5px;
border-radius: 999px; background: rgba(233,30,140,0.4);
}
.pane-vsplit:hover::after,
.pane-vsplit.dragging::after {
background: rgba(244,114,182,0.9);
}
.bottom-pane .bp-head {
display: flex; align-items: center; gap: 8px;
padding: 4px 10px; border-bottom: 1px solid var(--border);
font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.08em;
color: var(--muted); font-weight: 700; flex-shrink: 0;
}
.bottom-pane .bp-head .live-dot {
width: 6px; height: 6px; border-radius: 50%; background: var(--ok);
box-shadow: 0 0 6px var(--ok);
}
.bottom-pane .bp-body {
flex: 1; overflow: auto; padding: 6px 10px;
font-family: var(--mono); font-size: 0.72rem; line-height: 1.4;
color: #c4c4d0; white-space: pre-wrap; word-break: break-word;
min-height: 0;
}
.inko-brand, .ink-brand {
background: linear-gradient(90deg, var(--pink2), var(--purple));
-webkit-background-clip: text; background-clip: text;
color: transparent; font-weight: 800; letter-spacing: 0.04em;
}
.inko-brand-full {
font-weight: 700; font-size: 0.78rem; letter-spacing: 0.01em;
color: var(--text);
}
.inko-brand-full .inko-acronym {
background: linear-gradient(90deg, var(--pink2), var(--purple));
-webkit-background-clip: text; background-clip: text;
color: transparent; font-weight: 800; letter-spacing: 0.04em;
}
/* Content primitives */
.toolbar { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 10px; align-items: center; }
.stats {
display: grid; grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
gap: 8px; margin-bottom: 12px;
}
.stat {
background: var(--bg2); border: 1px solid var(--border); border-radius: var(--radius);
padding: 10px; text-align: center;
}
.stat .n { font-size: 1.35rem; font-weight: 800; font-variant-numeric: tabular-nums; }
.stat .l { font-size: 0.62rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.06em; margin-top: 2px; }
.stat.ok .n { color: var(--ok); }
.stat.pink .n { color: var(--pink2); }
.stat.cyan .n { color: var(--cyan); }
.stat.purple .n { color: var(--purple); }
.stat.warn .n { color: var(--warn); }
.stat.bad .n { color: var(--bad); }
.split {
display: grid; grid-template-columns: minmax(240px, 340px) 1fr;
gap: 12px; flex: 1; min-height: 0; height: 100%;
}
.list-panel, .work-panel {
background: var(--bg2); border: 1px solid var(--border); border-radius: var(--radius);
display: flex; flex-direction: column; min-height: 0; overflow: hidden;
height: 100%;
}
.list-panel .lp-head, .work-panel .wp-head {
padding: 8px 10px; border-bottom: 1px solid var(--border);
font-size: 0.72rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em;
color: var(--muted); display: flex; align-items: center; gap: 8px; flex-shrink: 0;
}
.list-panel .lp-body, .work-panel .wp-body {
flex: 1 1 auto; overflow: auto; min-height: 0;
}
.work-panel .wp-body { padding: 10px 12px; }
.view.active {
display: flex; flex-direction: column; height: 100%; min-height: 0;
}
.view.active > .split,
.view.active > .form-grid,
.view.active > .work-panel {
flex: 1 1 auto; min-height: 0;
}
.view.active > .form-grid {
display: grid; grid-template-columns: 1fr 1fr; gap: 12px;
align-content: stretch;
}
.view.active > .form-grid > .work-panel { min-height: 0; height: 100%; }
.scope-grid {
display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 6px; margin: 8px 0; max-height: 220px; overflow: auto;
padding: 8px; background: #0e0e14; border: 1px solid var(--border); border-radius: 8px;
}
.scope-grid label {
display: flex; align-items: center; gap: 6px; margin: 0;
font-size: 0.72rem; text-transform: none; letter-spacing: 0; color: var(--text);
font-weight: 500; cursor: pointer;
}
.scope-grid input { width: auto; margin: 0; }
table.data { width: 100%; border-collapse: collapse; font-size: 0.8rem; }
table.data th, table.data td {
text-align: left; padding: 8px 10px; border-bottom: 1px solid rgba(255,255,255,0.05);
vertical-align: middle;
}
table.data th {
position: sticky; top: 0; background: var(--bg3);
font-size: 0.65rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--muted);
z-index: 1;
}
table.data tr { cursor: pointer; }
table.data tr:hover td { background: rgba(255,255,255,0.03); }
table.data tr.selected td { background: rgba(233,30,140,0.12); }
table.data .mono { font-size: 0.72rem; }
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0 12px; }
.form-grid .full { grid-column: 1 / -1; }
.llm-form-grid { gap: 10px 16px; }
.chk-inline { display: flex; align-items: center; gap: 8px; cursor: pointer; }
.chk-inline input { width: auto; margin: 0; }
.admin-stack {
display: flex; flex-direction: column; gap: 16px;
height: 100%; min-height: 0; overflow: auto; padding: 4px 2px 16px;
}
.admin-row {
display: grid; grid-template-columns: 1fr 1fr; gap: 16px;
align-items: stretch;
}
.admin-stack .work-panel {
height: auto; min-height: 0; display: flex; flex-direction: column;
}
.admin-stack .work-panel .wp-body { flex: 1; }
.admin-stack .admin-llm { flex: 0 0 auto; }
.admin-stack .scope-grid { max-height: 280px; }
.admin-stack .llm-form-grid { max-width: 720px; }
.view.active > .admin-stack { flex: 1 1 auto; min-height: 0; }
.row { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; align-items: center; }
.outbox {
background: #08080c; border: 1px solid var(--border); border-radius: 6px;
padding: 8px 10px; font-family: var(--mono); font-size: 0.75rem;
max-height: 240px; overflow: auto; white-space: pre-wrap; word-break: break-word;
color: #d4d4de; margin-top: 8px;
}
.outbox.empty { color: var(--muted); }
.chips { display: flex; flex-wrap: wrap; gap: 6px; }
.chip {
font: 600 0.68rem var(--mono); padding: 3px 8px; border-radius: 999px;
border: 1px solid var(--border2); color: var(--muted); background: rgba(0,0,0,0.25);
}
.chip.ok { color: var(--ok); border-color: rgba(52,211,153,0.3); }
.chip.warn { color: var(--warn); }
.toast {
position: fixed; top: 56px; right: 16px; z-index: 100;
max-width: 360px; padding: 10px 14px; border-radius: 8px;
font-size: 0.84rem; box-shadow: 0 8px 24px rgba(0,0,0,0.45);
display: none;
}
.toast.show { display: block; }
.toast.err { background: #3f1219; border: 1px solid rgba(248,113,113,0.4); color: #fecdd3; }
.toast.ok { background: #0f2e22; border: 1px solid rgba(52,211,153,0.35); color: #bbf7d0; }
.modal-bg {
position: fixed; inset: 0; background: rgba(0,0,0,0.65); z-index: 50;
display: none; align-items: center; justify-content: center; padding: 16px;
}
.modal-bg.show { display: flex; }
.modal {
background: var(--bg2); border: 1px solid var(--border2); border-radius: 12px;
width: min(420px, 100%); padding: 16px; max-height: 90dvh; overflow: auto;
}
.modal h3 { margin: 0 0 8px; font-size: 1rem; }
.qr-wrap {
background: #fff; border-radius: 12px; padding: 14px;
width: fit-content; margin: 12px auto; min-height: 200px; min-width: 200px;
display: flex; align-items: center; justify-content: center;
}
.qr-wrap img, .qr-wrap canvas { display: block; max-width: 100%; }
.share-url {
font: 0.65rem/1.35 var(--mono); color: var(--muted); word-break: break-all;
background: #0e0e14; border: 1px solid var(--border); border-radius: 6px;
padding: 8px 10px; margin: 8px 0; max-height: 72px; overflow: auto;
}
.empty-state {
text-align: center; padding: 32px 16px; color: var(--muted);
}
.empty-state strong { display: block; color: var(--text); margin-bottom: 6px; font-size: 0.95rem; }
.docs-drop { position: relative; flex-shrink: 0; }
.docs-drop > button {
font-size: 0.78rem; padding: 5px 10px;
}
.docs-menu {
display: none; position: absolute; right: 0; top: calc(100% + 4px);
min-width: 200px; background: var(--bg3); border: 1px solid var(--border2);
border-radius: 8px; padding: 6px; z-index: 30;
box-shadow: 0 12px 32px rgba(0,0,0,0.45);
}
.docs-menu.open { display: flex; flex-direction: column; gap: 2px; }
.docs-menu a {
display: block; padding: 8px 10px; border-radius: 6px;
color: var(--text); text-decoration: none; font-size: 0.8rem; font-weight: 500;
border-bottom: none;
}
.docs-menu a:hover { background: rgba(233,30,140,0.15); color: var(--pink2); }
/* INKO top-bar button + right flyout panel (not floating FAB) */
.inko-top-btn {
background: rgba(233,30,140,0.18); border-color: rgba(233,30,140,0.5);
color: var(--pink2); font-weight: 800; letter-spacing: 0.04em;
}
.inko-top-btn:hover { background: rgba(233,30,140,0.32); }
.inko-top-btn.hidden { display: none !important; }
.ai-backdrop {
display: none; position: fixed; inset: 0; z-index: 58;
background: rgba(0,0,0,0.5);
}
.ai-backdrop.show { display: block; }
.ai-drawer {
position: fixed; top: 0; right: 0; bottom: 0; z-index: 60;
width: var(--inko-w, min(440px, 100vw));
max-width: 100vw;
height: 100dvh; height: 100svh;
background: var(--bg2); border-left: 1px solid var(--border2); border-radius: 0;
display: flex; flex-direction: column;
box-shadow: -16px 0 48px rgba(0,0,0,0.45);
overflow: hidden;
transform: translateX(100%);
transition: transform 0.2s ease;
pointer-events: none;
}
.ai-drawer.open {
transform: translateX(0);
pointer-events: auto;
}
.ai-drawer.hidden { display: none !important; }
.ai-drawer-resize {
position: absolute; left: 0; top: 0; bottom: 0; width: 8px;
cursor: ew-resize; z-index: 2; touch-action: none;
}
.ai-drawer-resize::after {
content: ""; position: absolute; left: 2px; top: 50%;
width: 3px; height: 40px; margin-top: -20px; border-radius: 999px;
background: rgba(233,30,140,0.4);
}
.ai-drawer-resize:hover::after, .ai-drawer-resize.dragging::after {
background: rgba(244,114,182,0.9);
}
.ai-drawer-head {
display: flex; align-items: flex-start; 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));
}
.inko-starters {
display: grid; grid-template-columns: 1fr 1fr; gap: 8px; padding: 4px 0 8px;
}
.inko-starter {
text-align: left; padding: 10px; border-radius: 10px;
border: 1px solid var(--border); background: rgba(233,30,140,0.08);
font-size: 0.75rem; line-height: 1.35; color: var(--text);
}
.inko-starter strong { display: block; color: var(--pink2); margin-bottom: 4px; font-size: 0.78rem; }
.inko-starter:hover { border-color: rgba(233,30,140,0.45); background: rgba(233,30,140,0.14); }
body.kb-open .main-body,
body.kb-open .ctx-body {
padding-bottom: calc(24px + var(--kb-inset, 0px));
}
/* INKO drawer is resized to visualViewport when keyboard is open - do not
also pad the foot by kb-inset (that double-compensates and jitters the log). */
body.kb-open .ai-drawer-foot {
padding-bottom: calc(8px + env(safe-area-inset-bottom, 0px));
}
body.kb-open .ai-drawer.kb-pinned {
transition: none;
}
.ai-drawer-head .inko-titles { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.ai-drawer-body {
flex: 1; overflow: auto; padding: 10px; font-size: 0.82rem; min-height: 0;
overflow-anchor: none;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
}
.ai-msg { margin-bottom: 10px; padding: 8px 10px; border-radius: 8px; line-height: 1.45; }
.ai-msg.user { background: rgba(255,255,255,0.04); border: 1px solid var(--border); }
.ai-msg.bot { background: rgba(233,30,140,0.08); border: 1px solid rgba(233,30,140,0.25); }
.ai-msg.pending { opacity: 0.95; }
.ai-msg .who { font-size: 0.65rem; color: var(--muted); text-transform: uppercase; margin-bottom: 4px; }
.ai-msg .body { word-break: break-word; }
.ai-msg .body p { margin: 0 0 0.55em; }
.ai-msg .body p:last-child { margin-bottom: 0; }
.ai-msg .body ul, .ai-msg .body ol { margin: 0.35em 0 0.55em 1.2em; padding: 0; }
.ai-msg .body li { margin: 0.15em 0; }
.ai-msg .body pre {
margin: 0.45em 0; padding: 8px 10px; border-radius: 8px; overflow: auto;
background: rgba(0,0,0,0.35); border: 1px solid var(--border);
font-family: var(--mono); font-size: 0.75rem; line-height: 1.4;
}
.ai-msg .body code {
font-family: var(--mono); font-size: 0.78em;
background: rgba(255,255,255,0.06); padding: 0.1em 0.35em; border-radius: 4px;
}
.ai-msg .body pre code { background: none; padding: 0; font-size: inherit; }
.ai-msg .body a { color: var(--pink2); }
.ai-msg .body h1, .ai-msg .body h2, .ai-msg .body h3, .ai-msg .body h4 {
margin: 0.6em 0 0.35em; font-size: 0.95em; line-height: 1.3;
}
.ai-msg .body blockquote {
margin: 0.4em 0; padding: 0.25em 0 0.25em 0.75em;
border-left: 3px solid rgba(233,30,140,0.45); color: var(--muted);
}
.ai-msg .body .md-table-wrap {
overflow-x: auto; margin: 0.5em 0 0.65em;
border: 1px solid var(--border); border-radius: 8px;
}
.ai-msg .body table.md-table {
width: 100%; border-collapse: collapse; font-size: 0.78rem;
min-width: 200px;
}
.ai-msg .body table.md-table th,
.ai-msg .body table.md-table td {
border: 1px solid rgba(255,255,255,0.08);
padding: 6px 10px; text-align: left; vertical-align: top;
}
.ai-msg .body table.md-table th {
background: rgba(233,30,140,0.12); color: var(--pink2);
font-weight: 700; font-size: 0.68rem; text-transform: uppercase;
letter-spacing: 0.04em;
}
.ai-msg .body table.md-table tr:nth-child(even) td {
background: rgba(255,255,255,0.02);
}
.ai-msg-actions {
margin-top: 8px; display: flex; justify-content: flex-end; gap: 6px;
}
.ai-copy-btn {
font-size: 0.68rem; padding: 4px 10px; min-height: 28px;
color: var(--muted); border-color: var(--border);
}
.ai-copy-btn:hover { color: var(--pink2); border-color: rgba(233,30,140,0.4); }
.ai-copy-btn.copied { color: var(--ok); border-color: rgba(52,211,153,0.35); }
.inko-cap-grid {
display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 10px; margin: 10px 0;
}
.inko-cap-card {
background: var(--bg3); border: 1px solid var(--border); border-radius: 10px;
padding: 12px; min-height: 88px;
}
.inko-cap-card h4 {
margin: 0 0 6px; font-size: 0.82rem; color: var(--pink2); font-weight: 700;
}
.inko-cap-card p {
margin: 0; font-size: 0.75rem; color: var(--muted); line-height: 1.4;
}
.inko-outbox {
margin-top: 10px; min-height: 220px; max-height: min(50vh, 420px);
overflow: auto; font-family: var(--mono); font-size: 0.75rem;
white-space: pre-wrap; word-break: break-word;
}
.ai-msg .tools-used {
margin-top: 6px; display: flex; flex-wrap: wrap; gap: 4px;
}
.ai-msg .tool-chip {
font: 600 0.62rem/1 var(--mono); padding: 3px 6px; border-radius: 999px;
background: rgba(255,255,255,0.06); border: 1px solid var(--border); color: var(--muted);
}
.ai-msg .tool-chip.ok { color: var(--ok); border-color: rgba(52,211,153,0.35); }
.ai-msg .tool-chip.bad { color: var(--bad); border-color: rgba(248,113,113,0.35); }
.ai-typing {
display: inline-flex; align-items: center; gap: 8px;
color: var(--muted); font-size: 0.8rem;
}
.ai-typing-dots {
display: inline-flex; gap: 4px; align-items: center;
}
.ai-typing-dots span {
width: 6px; height: 6px; border-radius: 50%;
background: var(--pink2); opacity: 0.35;
animation: ai-bounce 1s infinite ease-in-out;
}
.ai-typing-dots span:nth-child(2) { animation-delay: 0.15s; }
.ai-typing-dots span:nth-child(3) { animation-delay: 0.3s; }
@keyframes ai-bounce {
0%, 80%, 100% { opacity: 0.3; transform: translateY(0); }
40% { opacity: 1; transform: translateY(-3px); }
}
.ai-busy .inko-top-btn,
button.ai-send-busy:disabled {
opacity: 0.65; cursor: wait;
}
.ai-drawer-foot {
border-top: 1px solid var(--border); padding: 8px; display: flex; flex-direction: column; gap: 6px;
flex-shrink: 0;
padding-bottom: calc(8px + env(safe-area-inset-bottom, 0px));
}
.ai-drawer-foot select, .ai-drawer-foot textarea { width: 100%; }
@media (max-width: 900px) {
.ai-drawer {
width: 100vw; left: 0; right: 0;
border-left: none;
}
}
/* Mobile: fixed header + scrollable nav + context bottom-sheet */
@media (max-width: 900px) {
:root {
--top-h: calc(48px + env(safe-area-inset-top, 0px));
--nav-h: 48px;
--bottom-h: 22vh;
--sidebar-w: 100%;
}
body { overflow: hidden; }
.app {
grid-template-rows: var(--top-h) 1fr var(--bottom-h);
grid-template-columns: 1fr;
grid-template-areas:
"top"
"main"
"bottom";
height: 100dvh;
height: 100svh;
overflow: hidden;
padding-bottom: env(safe-area-inset-bottom, 0px);
}
.app.no-bottom {
grid-template-rows: var(--top-h) 1fr;
grid-template-areas:
"top"
"main";
}
.app.no-ctx {
grid-template-columns: 1fr;
grid-template-areas:
"top"
"main"
"bottom";
}
.app.no-bottom.no-ctx {
grid-template-areas:
"top"
"main";
}
/* -- Fixed compact header -- */
.top {
position: sticky; top: 0; z-index: 50;
padding: 0 8px;
padding-top: env(safe-area-inset-top, 0px);
gap: 6px; flex-wrap: nowrap;
min-height: var(--top-h);
height: var(--top-h);
overflow: hidden;
background: var(--bg2);
align-items: center;
}
.top .logo { width: 24px; height: 24px; flex-shrink: 0; }
.top .title { font-size: 0.82rem; flex-shrink: 0; }
.top .host { display: none; }
.top .sep { display: none; }
.top .spacer { flex: 1 1 auto; min-width: 4px; }
.top .pill {
padding: 4px 6px; font-size: 0.58rem; flex-shrink: 0;
max-width: 72px; overflow: hidden; text-overflow: ellipsis;
}
.top #roleBadge { display: none !important; }
.top button {
padding: 0 10px; font-size: 0.72rem; min-height: 36px;
flex-shrink: 0; white-space: nowrap;
}
.top #btnPhoneQr {
padding: 0 8px; min-width: 36px;
font-size: 0; /* hide label text */
}
.top #btnPhoneQr::before {
content: "QR"; font-size: 0.72rem; font-weight: 700;
}
.top #btnRefresh { min-width: 36px; padding: 0 8px; }
/* -- Hamburger drawer nav -- */
.nav-burger { display: inline-flex; }
.side {
position: fixed;
top: 0; left: 0; bottom: 0;
width: min(300px, 86vw);
z-index: 55;
border-right: 1px solid var(--border2);
box-shadow: 16px 0 40px rgba(0,0,0,0.45);
transform: translateX(-105%);
transition: transform 0.22s ease;
height: 100dvh;
max-height: none;
padding-top: env(safe-area-inset-top, 0px);
background: var(--bg2);
}
.app.nav-open .side { transform: translateX(0); }
.side-nav {
flex-direction: column;
padding: 12px 10px;
gap: 4px;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
flex: 1;
}
.nav-item {
width: 100%;
padding: 14px 14px;
min-height: 48px;
font-size: 1rem;
border: 1px solid var(--border);
}
.nav-item .ico { display: inline-block; font-size: 1.05rem; }
.nav-item .badge-n { display: inline-block; }
.side-foot {
display: block;
padding: 12px 14px calc(12px + env(safe-area-inset-bottom, 0px));
}
/* -- Context as scrollable bottom sheet -- */
.ctx {
display: flex !important;
position: fixed;
left: 0; right: 0; bottom: 0;
top: auto;
z-index: 46;
height: min(72dvh, 560px);
max-height: calc(100dvh - var(--top-h) - 24px);
border-left: 0;
border-top: 1px solid var(--border2);
border-radius: 16px 16px 0 0;
box-shadow: 0 -12px 40px rgba(0,0,0,0.5);
transform: translateY(110%);
transition: transform 0.22s ease;
padding-bottom: env(safe-area-inset-bottom, 0px);
background: var(--bg2);
}
.ctx.open {
transform: translateY(0);
}
.app.no-ctx .ctx { display: flex !important; } /* still available as sheet */
.ctx-head {
padding: 8px 12px 10px;
position: relative;
cursor: default;
}
.ctx-drag {
display: block;
position: absolute; left: 50%; top: 6px;
width: 40px; height: 4px; margin-left: -20px;
border-radius: 999px; background: rgba(255,255,255,0.2);
}
.ctx-close { display: inline-flex !important; align-items: center; justify-content: center; }
.ctx-body {
flex: 1;
overflow-y: auto !important;
-webkit-overflow-scrolling: touch;
overscroll-behavior: contain;
padding: 12px 14px 24px;
max-height: none;
}
.ctx-backdrop:not(.hidden) {
display: block;
}
.main { min-height: 0; overflow: hidden; }
.main-head {
padding: 8px 10px;
flex-wrap: nowrap;
gap: 6px;
position: sticky; top: 0; z-index: 5;
background: var(--bg);
}
.main-head > div:first-child { min-width: 0; flex: 1; }
.main-head h2 { font-size: 0.88rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.main-head .sub {
font-size: 0.68rem;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.main-head-actions { margin-left: auto; flex-shrink: 0; }
.main-head .tools { margin-left: 0; flex-shrink: 0; }
.docs-drop { flex-shrink: 0; }
.docs-menu {
max-height: min(50vh, 320px);
overflow-y: auto;
-webkit-overflow-scrolling: touch;
right: 0; left: auto;
}
.main-body {
overflow: auto; padding: 10px;
-webkit-overflow-scrolling: touch;
min-height: 0;
}
.bottom { min-height: 0; }
.bottom-panes {
grid-template-columns: 1fr;
grid-template-rows: var(--pane-stack, 1fr 6px 1fr);
}
.pane-vsplit {
cursor: ns-resize;
width: auto; height: 6px;
}
.pane-vsplit::after {
width: 36px; height: 3px; top: 50%; left: 50%;
margin: -1.5px 0 0 -18px;
}
.bottom-pane .bp-body {
font-size: 0.68rem;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
.split {
grid-template-columns: 1fr;
height: auto;
min-height: 0;
gap: 10px;
}
.list-panel, .work-panel {
height: auto;
min-height: 160px;
max-height: none;
}
.list-panel .lp-body {
max-height: 45vh;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
.view.active { height: auto; display: block; }
.view.active > .form-grid { grid-template-columns: 1fr; }
.view.active > .form-grid > .work-panel { height: auto; min-height: 180px; }
.admin-row { grid-template-columns: 1fr; }
.stats { grid-template-columns: repeat(2, 1fr); }
.scope-grid { grid-template-columns: 1fr 1fr; max-height: 180px; }
button, .nav-item, input, select, textarea { font-size: 16px; } /* prevent iOS zoom */
input, select, textarea { padding: 10px; min-height: 42px; }
select { padding-right: 2.25rem; }
label { margin-top: 10px; }
}
@media (max-width: 420px) {
.top #btnConnect { padding: 0 8px; }
.top .title { max-width: 88px; overflow: hidden; }
}
</style>
</head>
<body>
<div class="app" id="app">
<header class="top">
<button type="button" class="nav-burger" id="navBurger" title="Menu" aria-label="Open navigation" aria-expanded="false">
<span></span><span></span><span></span>
</button>
<img class="logo" src="/ops/assets/squidsec-logo-96.png" alt=""
onerror="this.src='/ops/assets/squidsec-logo-256.png'" />
<div class="title">SQUID<span>C5</span></div>
<div class="sep"></div>
<div class="host mono" id="hostLine" title="Server">not connected</div>
<div class="spacer"></div>
<span class="pill" id="statusBadge">offline</span>
<span class="pill live hidden" id="roleBadge">-</span>
<button type="button" id="btnInko" class="inko-top-btn hidden" title="INKO - Intelligent Neural Kinetic Operator">INKO</button>
<button type="button" id="btnPhoneQr" title="Phone QR - scan to open ops and auto-connect">Phone QR</button>
<button type="button" id="btnConnect" title="Connection settings">Connect</button>
<button type="button" id="btnRefresh" title="Refresh data">Refresh</button>
</header>
<div class="nav-backdrop" id="navBackdrop" hidden></div>
<nav class="side" id="sideNavPanel" aria-label="Primary">
<div class="side-nav" id="sideNav">
<button type="button" class="nav-item active" data-view="dashboard">Dashboard</button>
<button type="button" class="nav-item" data-view="sessions">Sessions <span class="badge-n" id="navNSes">0</span></button>
<button type="button" class="nav-item" data-view="listeners">Listeners <span class="badge-n" id="navNLis">0</span></button>
<button type="button" class="nav-item" data-view="payloads">Payloads</button>
<button type="button" class="nav-item" data-view="profiles">Profiles</button>
<button type="button" class="nav-item" data-view="artifacts">Artifacts</button>
<button type="button" class="nav-item" data-view="postex">Post-Ex</button>
<button type="button" class="nav-item" data-view="collab">Collab</button>
<button type="button" class="nav-item" data-view="ai" id="navAi">INKO</button>
<button type="button" class="nav-item" data-view="observe">Observe</button>
<button type="button" class="nav-item hidden" data-view="admin" id="navAdmin">Admin</button>
</div>
<div class="side-foot" id="sideFoot">
<a class="doc-link" href="https://github.com/SquidSec/SquidC5/blob/master/docs/user-guide.md" target="_blank" rel="noopener noreferrer">User guide</a>
/ Authorized only
</div>
</nav>
<main class="main">
<div class="main-head">
<div>
<h2 id="viewTitle">Dashboard</h2>
<div class="sub" id="viewSub">Overview of this teamserver</div>
</div>
<div class="main-head-actions">
<div class="docs-drop" id="docsDrop">
<button type="button" id="viewDocsBtn" title="Documentation for this page">Docs v</button>
<div class="docs-menu" id="viewDocsMenu"></div>
</div>
<div class="tools" id="viewTools"></div>
</div>
</div>
<div class="main-body" id="mainBody">
<!-- Views filled by ops-admin.js after auth; shell shows login empty states -->
<div class="view active" id="view-dashboard">
<div class="empty-state" id="dashEmpty">
<strong>Connect to get started</strong>
Enter your server URL and API token (top-right Connect).
<div style="margin-top:10px"><a class="doc-link" href="https://github.com/SquidSec/SquidC5/blob/master/docs/user-guide.md#connection" target="_blank" rel="noopener noreferrer">Connection docs -></a></div>
</div>
<div id="dashContent" class="hidden">
<div class="stats" id="dashStats"></div>
<div class="split" style="min-height:300px">
<div class="list-panel">
<div class="lp-head">Recent sessions <a class="doc-link" style="margin-left:auto;font-size:0.65rem;text-transform:none;letter-spacing:0" href="https://github.com/SquidSec/SquidC5/blob/master/docs/user-guide.md#sessions" target="_blank" rel="noopener noreferrer">Docs</a></div>
<div class="lp-body" id="dashSes"></div>
</div>
<div class="work-panel">
<div class="wp-head">Quick actions</div>
<div class="wp-body" id="dashQuick"></div>
</div>
</div>
</div>
</div>
<div class="view" id="view-sessions"></div>
<div class="view" id="view-listeners"></div>
<div class="view" id="view-payloads"></div>
<div class="view" id="view-profiles"></div>
<div class="view" id="view-artifacts"></div>
<div class="view" id="view-postex"></div>
<div class="view" id="view-collab"></div>
<div class="view" id="view-ai"></div>
<div class="view" id="view-observe"></div>
<div class="view" id="view-admin"></div>
</div>
</main>
<!-- INKO - Intelligent Neural Kinetic Operator (top-bar flyout) -->
<div id="aiBackdrop" class="ai-backdrop" hidden aria-hidden="true"></div>
<aside id="aiDrawer" class="ai-drawer hidden" aria-label="INKO chat" aria-hidden="true">
<div class="ai-drawer-resize" id="aiDrawerResize" title="Drag to resize INKO" role="separator" aria-orientation="vertical"></div>
<div class="ai-drawer-head">
<div class="inko-titles">
<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" id="aiNewChat" title="Start a new chat" style="flex-shrink:0">New chat</button>
<a class="doc-link" href="https://github.com/SquidSec/SquidC5/blob/master/docs/user-guide.md#inko-intelligent-neural-kinetic-operator" target="_blank" rel="noopener noreferrer" style="font-size:0.7rem;flex-shrink:0">Docs</a>
<button type="button" id="aiDrawerClose" style="margin-left:4px;flex-shrink:0" title="Close INKO" aria-label="Close INKO">x</button>
</div>
<div class="ai-drawer-body" id="aiChatLog"></div>
<div class="ai-drawer-foot" id="aiDrawerFoot">
<select id="aiLlmGlobal" title="LLM connection"></select>
<select id="aiModelGlobal" title="Model" disabled><option value="">Model...</option></select>
<textarea id="aiPromptGlobal" rows="3" placeholder="Ask INKO anything - list sessions, spin up a listener..."></textarea>
<div class="row" style="margin:0;gap:6px">
<button type="button" class="primary" id="aiSendGlobal" style="flex:1">Send</button>
<button type="button" id="aiClearGlobal" title="Clear chat">Clear</button>
</div>
<p class="muted" style="margin:0;font-size:0.62rem">Enter to send / Shift+Enter newline / drag left edge to resize</p>
</div>
</aside>
<aside class="ctx" id="ctxRail">
<div class="ctx-head">
<span class="ctx-drag" aria-hidden="true"></span>
<span>Session context</span>
<button type="button" class="ctx-close" id="ctxClose" title="Close context" aria-label="Close">x</button>
</div>
<div class="ctx-body" id="ctxBody">
<div class="ctx-empty">Select a session to see details, claim/release, and interact.</div>
</div>
</aside>
<div class="ctx-backdrop hidden" id="ctxBackdrop" aria-hidden="true"></div>
<footer class="bottom" id="bottomDock">
<div class="bottom-resize" id="bottomResize" title="Drag to resize panel height" role="separator" aria-orientation="horizontal"></div>
<div class="bottom-panes" id="bottomPanes">
<div class="bottom-pane" id="paneEvents">
<div class="bp-head"><span class="live-dot"></span> Event stream <span class="muted" id="eventCount" style="margin-left:auto;text-transform:none;letter-spacing:0">0</span></div>
<div class="bp-body" id="eventsBox">Connect to stream events...</div>
</div>
<div class="pane-vsplit" id="paneSplit" title="Drag to resize panes" role="separator" aria-orientation="vertical"></div>
<div class="bottom-pane" id="paneConsole">
<div class="bp-head">Output / console</div>
<div class="bp-body" id="consoleBox">Command and task output appears here.</div>
</div>
</div>
</footer>
</div>
<div class="toast err" id="toastErr"></div>
<div class="toast ok" id="toastOk"></div>
<div class="modal-bg" id="connectModal" role="dialog" aria-modal="true" aria-labelledby="connTitle">
<div class="modal">
<h3 id="connTitle">Connect to teamserver</h3>
<p class="muted" style="font-size:0.85rem;margin:0 0 8px">Token stays in this browser only (localStorage).</p>
<label for="url">Server URL</label>
<input id="url" type="url" placeholder="https://HOST:8443" autocomplete="off" />
<label for="token">API token</label>
<input id="token" type="password" placeholder="sc5_..." autocomplete="off" />
<label for="actorName">Actor display name (optional)</label>
<input id="actorName" type="text" placeholder="Leave blank to keep token name" autocomplete="nickname" />
<label for="refresh">Poll interval (sec)</label>
<input id="refresh" type="number" min="5" max="120" value="10" />
<div class="row" style="margin-top:14px">
<button type="button" class="primary" id="saveBtn">Save &amp; Connect</button>
<button type="button" id="toggleToken">Show token</button>
<button type="button" id="closeModal">Cancel</button>
</div>
</div>
</div>
<div class="modal-bg" id="qrModal" role="dialog" aria-modal="true" aria-labelledby="qrTitle">
<div class="modal">
<h3 id="qrTitle">Phone QR</h3>
<p class="muted" style="font-size:0.85rem;margin:0">
Scan with your phone camera. Opens <code>/ops</code> and auto-fills server URL + token (hash only - not sent to the server).
</p>
<div class="qr-wrap" id="qrBox"><span class="muted">Generating...</span></div>
<div class="share-url mono" id="qrUrl" title="Share URL"></div>
<p class="muted" style="font-size:0.75rem;margin:0 0 8px">
Treat like a password. Anyone with this QR can use your token until you revoke it.
</p>
<div class="row">
<button type="button" class="primary" id="qrCopy">Copy link</button>
<button type="button" id="qrClose">Close</button>
</div>
</div>
</div>
<script src="/ops/assets/qrcode.min.js"></script>
<!-- Bridge mount for legacy hooks; content lives in views -->
<div id="adminMount" class="hidden" aria-hidden="true"></div>
<script>
(function () {
const KEYS = { url: "sc5_dash_url", token: "sc5_dash_token", refresh: "sc5_dash_refresh" };
const $ = (id) => document.getElementById(id);
const state = { scopes: [], actor: null, meta: null, token: "", sessions: [], listeners: [], selectedId: null };
function showError(msg) {
const el = $("toastErr");
if (!msg) { el.classList.remove("show"); return; }
el.textContent = msg; el.classList.add("show");
setTimeout(() => el.classList.remove("show"), 5000);
}
function showOk(msg) {
const el = $("toastOk");
if (!msg) { el.classList.remove("show"); return; }
el.textContent = msg; el.classList.add("show");
setTimeout(() => el.classList.remove("show"), 3000);
}
function showOutput(text, meta) {
const box = $("consoleBox");
if (!box) return;
const body = text == null || text === "" ? "(no output)" : String(text);
box.textContent = meta ? meta + "\n\n" + body : body;
box.scrollTop = box.scrollHeight;
}
function esc(s) {
return String(s == null ? "" : s)
.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
function defaultServerUrl() {
try {
if (location.protocol.startsWith("http")) return location.origin;
} catch (_) {}
return "";
}
function cfg() {
return {
url: (localStorage.getItem(KEYS.url) || defaultServerUrl() || "").replace(/\/$/, ""),
token: localStorage.getItem(KEYS.token) || "",
refresh: Math.max(5, Number(localStorage.getItem(KEYS.refresh) || 10)),
};
}
function apiBase() {
try {
if (location.protocol.startsWith("http") && location.pathname.indexOf("/ops") === 0)
return location.origin.replace(/\/$/, "");
} catch (_) {}
return (cfg().url || "").replace(/\/$/, "");
}
function can(scope) {
if (!state.scopes || !state.scopes.length) return false;
if (state.scopes.indexOf("admin") >= 0) return true;
return state.scopes.indexOf(scope) >= 0;
}
async function api(method, path, body) {
const base = apiBase();
const token = cfg().token;
if (!base || !token) throw new Error("Not connected");
const opts = {
method,
headers: { Authorization: "Bearer " + token, Accept: "application/json" },
};
if (body !== undefined) {
opts.headers["Content-Type"] = "application/json";
opts.body = JSON.stringify(body);
}
const r = await fetch(base + path, opts);
const ct = r.headers.get("content-type") || "";
const data = ct.includes("json") ? await r.json().catch(() => ({})) : await r.text();
if (!r.ok) {
let msg = typeof data === "object" ? (data.detail || data.error || JSON.stringify(data)) : String(data);
if (typeof msg === "object") msg = JSON.stringify(msg);
throw new Error(msg || ("HTTP " + r.status));
}
return data;
}
/* -- Navigation -- */
const DOCS = "https://github.com/SquidSec/SquidC5/blob/master/docs/user-guide.md";
const VIEWS = {
dashboard: { title: "Dashboard", sub: "Teamserver health and shortcuts", doc: "status-overview" },
sessions: { title: "Sessions", sub: "Beacons and reverse shells", doc: "sessions" },
listeners: { title: "Listeners", sub: "Bind ports and manage acceptors", doc: "listeners" },
payloads: { title: "Payloads", sub: "Generate implants and templates", doc: "payloads-and-implants" },
profiles: { title: "Profiles", sub: "Malleable C2 profiles - activate, edit, push", doc: "c2-profiles" },
artifacts: { title: "Artifacts", sub: "Saved payloads, templates, profiles, implants", doc: "payloads-and-implants" },
postex: { title: "Post-Ex", sub: "Files, SOCKS, modules on selected session", doc: "file-ops" },
collab: { title: "Collab", sub: "Teams, chat, handoff, presence", doc: "multi-operator-collab" },
ai: { title: "INKO", sub: "Capabilities, status & tools - chat opens from the top-bar flyout", doc: "inko-intelligent-neural-kinetic-operator" },
observe: { title: "Observe", sub: "Metrics, audit, timeline, reports", doc: "timeline-and-reports" },
admin: { title: "Admin", sub: "Tokens, policy, features, LLM, MCP", doc: "feature-toggles" },
};
let currentView = "dashboard";
function closeNavDrawer() {
const app = $("app");
if (app) app.classList.remove("nav-open");
const b = $("navBurger");
if (b) b.setAttribute("aria-expanded", "false");
const bd = $("navBackdrop");
if (bd) { bd.classList.remove("show"); bd.hidden = true; }
}
function openNavDrawer() {
const app = $("app");
if (app) app.classList.add("nav-open");
const b = $("navBurger");
if (b) b.setAttribute("aria-expanded", "true");
const bd = $("navBackdrop");
if (bd) { bd.hidden = false; bd.classList.add("show"); }
}
function toggleNavDrawer() {
const app = $("app");
if (app && app.classList.contains("nav-open")) closeNavDrawer();
else openNavDrawer();
}
if ($("navBurger")) $("navBurger").onclick = (e) => { e.stopPropagation(); toggleNavDrawer(); };
if ($("navBackdrop")) $("navBackdrop").onclick = () => closeNavDrawer();
function setView(name) {
if (name === "admin" && !can("admin") && !can("tokens:manage") && !can("policy:manage")) name = "dashboard";
if (name === "ai" && !can("ai:use") && !can("admin")) name = "dashboard";
currentView = name;
document.querySelectorAll(".nav-item").forEach((b) => {
b.classList.toggle("active", b.getAttribute("data-view") === name);
});
document.querySelectorAll(".view").forEach((v) => {
v.classList.toggle("active", v.id === "view-" + name);
});
const app = document.querySelector(".app");
if (app) {
const focusAdmin = name === "admin";
const focusWide = name === "admin" || name === "ai";
app.classList.toggle("no-ctx", focusWide);
app.classList.toggle("no-bottom", focusAdmin);
}
const meta = VIEWS[name] || VIEWS.dashboard;
$("viewTitle").textContent = meta.title;
$("viewSub").textContent = meta.sub;
$("viewTools").innerHTML = "";
closeNavDrawer();
if (window.__SC5_onView) window.__SC5_onView(name);
if (window.__SC5_setPageDocs) window.__SC5_setPageDocs(name);
try { localStorage.setItem("sc5_ops_view", name); } catch (_) {}
}
window.__SC5_DOCS = DOCS;
document.querySelectorAll(".nav-item").forEach((b) => {
b.addEventListener("click", () => setView(b.getAttribute("data-view")));
});
/* -- Resizable bottom dock -- */
(function setupBottomResize() {
const root = document.documentElement;
const app = $("app");
const hHandle = $("bottomResize");
const vHandle = $("paneSplit");
function loadH() {
try {
const v = parseInt(localStorage.getItem("sc5_bottom_h") || "", 10);
if (v >= 80 && v <= Math.floor(window.innerHeight * 0.7)) {
root.style.setProperty("--bottom-h", v + "px");
}
} catch (_) {}
}
function loadSplit() {
try {
const r = parseFloat(localStorage.getItem("sc5_pane_ratio") || "");
if (r > 0.15 && r < 0.85) {
const mobile = window.matchMedia("(max-width: 900px)").matches;
if (mobile) {
root.style.setProperty("--pane-stack", r + "fr 6px " + (1 - r) + "fr");
} else {
root.style.setProperty("--pane-split", r + "fr 6px " + (1 - r) + "fr");
}
}
} catch (_) {}
}
loadH();
loadSplit();
window.addEventListener("resize", () => loadSplit());
function drag(handle, onMove, onEnd) {
if (!handle) return;
let active = false;
const start = (e) => {
if (app && app.classList.contains("no-bottom")) return;
active = true;
handle.classList.add("dragging");
e.preventDefault();
const move = (ev) => {
if (!active) return;
const pt = ev.touches ? ev.touches[0] : ev;
onMove(pt.clientX, pt.clientY);
};
const end = () => {
active = false;
handle.classList.remove("dragging");
window.removeEventListener("mousemove", move);
window.removeEventListener("mouseup", end);
window.removeEventListener("touchmove", move);
window.removeEventListener("touchend", end);
if (onEnd) onEnd();
};
window.addEventListener("mousemove", move);
window.addEventListener("mouseup", end);
window.addEventListener("touchmove", move, { passive: false });
window.addEventListener("touchend", end);
};
handle.addEventListener("mousedown", start);
handle.addEventListener("touchstart", start, { passive: false });
}
drag(hHandle, (_x, y) => {
const h = Math.max(80, Math.min(window.innerHeight * 0.7, window.innerHeight - y));
root.style.setProperty("--bottom-h", Math.round(h) + "px");
}, () => {
try {
const cur = getComputedStyle(root).getPropertyValue("--bottom-h").trim();
const n = parseInt(cur, 10);
if (n) localStorage.setItem("sc5_bottom_h", String(n));
} catch (_) {}
});
drag(vHandle, (x, y) => {
const panes = $("bottomPanes");
if (!panes) return;
const rect = panes.getBoundingClientRect();
const mobile = window.matchMedia("(max-width: 900px)").matches;
let r;
if (mobile) {
r = (y - rect.top) / rect.height;
} else {
r = (x - rect.left) / rect.width;
}
r = Math.max(0.18, Math.min(0.82, r));
if (mobile) {
root.style.setProperty("--pane-stack", r + "fr 6px " + (1 - r) + "fr");
} else {
root.style.setProperty("--pane-split", r + "fr 6px " + (1 - r) + "fr");
}
try { localStorage.setItem("sc5_pane_ratio", String(r)); } catch (_) {}
});
})();
/* -- Connect modal -- */
function openModal() { $("connectModal").classList.add("show"); }
function closeModal() { $("connectModal").classList.remove("show"); }
$("btnConnect").onclick = openModal;
$("closeModal").onclick = closeModal;
$("connectModal").addEventListener("click", (e) => { if (e.target === $("connectModal")) closeModal(); });
$("toggleToken").onclick = () => {
const t = $("token");
t.type = t.type === "password" ? "text" : "password";
};
$("saveBtn").onclick = async () => {
localStorage.setItem(KEYS.url, ($("url").value || "").trim().replace(/\/$/, ""));
localStorage.setItem(KEYS.token, ($("token").value || "").trim());
localStorage.setItem(KEYS.refresh, String(Math.max(5, Number($("refresh").value) || 10)));
closeModal();
await connect();
};
$("btnRefresh").onclick = () => refresh().catch((e) => showError(String(e.message || e)));
function loadForm() {
let url = localStorage.getItem(KEYS.url) || defaultServerUrl();
if (url) {
try { localStorage.setItem(KEYS.url, url.replace(/\/$/, "")); } catch (_) {}
}
$("url").value = url || "";
$("token").value = localStorage.getItem(KEYS.token) || "";
$("refresh").value = localStorage.getItem(KEYS.refresh) || "10";
}
/* -- Data refresh -- */
let timer = null;
async function connect() {
loadForm();
const c = cfg();
state.token = c.token;
if (!c.token || !apiBase()) {
$("statusBadge").textContent = "offline";
$("statusBadge").className = "pill bad";
openModal();
return;
}
try {
const meta = await api("GET", "/api/v1/meta");
state.meta = meta;
state.scopes = meta.scopes || [];
state.actor = meta.actor || meta.name || "operator";
$("statusBadge").textContent = "online";
$("statusBadge").className = "pill ok";
$("hostLine").textContent = apiBase();
$("roleBadge").textContent = state.scopes.indexOf("admin") >= 0 ? "admin" : (state.actor || "op");
$("roleBadge").classList.remove("hidden");
$("navAdmin").classList.toggle("hidden", !(can("admin") || can("tokens:manage") || can("policy:manage")));
if ($("navAi")) $("navAi").classList.toggle("hidden", !(can("ai:use") || can("admin")));
$("dashEmpty").classList.add("hidden");
$("dashContent").classList.remove("hidden");
await loadAdminModule();
await refresh();
if (timer) clearInterval(timer);
timer = setInterval(() => refresh().catch(() => {}), c.refresh * 1000);
showOk("Connected as " + state.actor);
const wantActor = ($("actorName") && $("actorName").value || "").trim();
if (wantActor && wantActor !== state.actor) {
try {
const ren = await api("PUT", "/api/v1/me", { name: wantActor });
state.actor = ren.actor || wantActor;
if ($("roleBadge") && !can("admin")) $("roleBadge").textContent = state.actor;
} catch (e) { console.warn("actor rename", e); }
}
} catch (e) {
$("statusBadge").textContent = "error";
$("statusBadge").className = "pill bad";
showError(String(e.message || e));
openModal();
}
}
async function loadAdminModule() {
if (window.__SC5_ADMIN_LOADED__) {
if (window.__SC5_boot) window.__SC5_boot();
return;
}
window.__SC5_API__ = api;
window.__SC5_$ = $;
window.__SC5_showError = showError;
window.__SC5_showOk = showOk;
window.__SC5_showOutput = showOutput;
window.__SC5_can = can;
window.__SC5_refresh = refresh;
window.__SC5_STATE__ = state;
window.__SC5_API_BASE__ = apiBase();
window.__SC5_setView = setView;
window.__SC5_esc = esc;
// Prefer admin.js when admin; console.js sets role flag
const path = can("admin") ? "/api/v1/ops/admin.js" : "/api/v1/ops/console.js";
const base = apiBase();
const r = await fetch(base + path, { headers: { Authorization: "Bearer " + cfg().token } });
if (!r.ok) throw new Error("Failed to load ops module (" + r.status + ")");
const code = await r.text();
const s = document.createElement("script");
s.text = code;
document.body.appendChild(s);
}
async function refresh() {
if (!cfg().token) return;
const [sessions, listeners, metrics, health] = await Promise.all([
api("GET", "/api/v1/sessions?status=active").catch(() => []),
api("GET", "/api/v1/listeners").catch(() => []),
api("GET", "/api/v1/metrics").catch(() => ({})),
api("GET", "/api/v1/health").catch(() => ({})),
]);
const ses = Array.isArray(sessions) ? sessions : (sessions.sessions || []);
const lis = Array.isArray(listeners) ? listeners : (listeners.listeners || []);
state.sessions = ses;
state.listeners = lis;
const isActive = (s) => {
const st = String(s.status || "active").toLowerCase();
return st === "active" || st === "live" || st === "";
};
const activeSes = ses.filter(isActive);
const activeBeacons = activeSes.filter((s) => {
const k = String(s.kind || "").toLowerCase();
return k === "beacon" || k.includes("beacon");
});
const shells = activeSes.filter((s) => s.verified || s.kind === "reverse_shell");
const running = lis.filter((l) => l.status === "running");
$("navNSes").textContent = String(activeSes.length);
$("navNLis").textContent = String(running.length);
// Dashboard stats - live inventory only (never cumulative closed beacons)
const m = metrics.metrics || metrics || {};
const stats = [
{ n: shells.length, l: "Shells", c: "ok" },
{ n: running.length, l: "Listeners", c: "pink" },
{ n: activeSes.length, l: "Sessions", c: "cyan" },
{ n: m["tasks.pending"] != null ? m["tasks.pending"] : (m["tasks.created"] || "-"), l: "Tasks", c: "purple" },
{ n: activeBeacons.length, l: "Beacons", c: "ok" },
{ n: formatUptime(metrics.uptime_seconds), l: "Uptime", c: "cyan" },
];
$("dashStats").innerHTML = stats.map((s) =>
`<div class="stat ${s.c}"><div class="n">${esc(s.n)}</div><div class="l">${esc(s.l)}</div></div>`
).join("");
// Recent sessions table (active only)
$("dashSes").innerHTML = activeSes.length
? `<table class="data"><thead><tr><th>ID</th><th>Kind</th><th>Host</th><th>Claim</th></tr></thead><tbody>` +
activeSes.slice(0, 12).map((s) => {
const meta = s.metadata || {};
return `<tr data-sid="${esc(s.id)}">
<td class="mono">${esc((s.id || "").slice(0, 12))}...</td>
<td>${esc(s.kind || "")}</td>
<td>${esc(s.hostname || s.remote_addr || "-")}</td>
<td class="mono">${esc(meta.claimed_by || "-")}</td>
</tr>`;
}).join("") + `</tbody></table>`
: `<div class="empty-state"><strong>No active sessions</strong>Start a listener and land an implant.</div>`;
$("dashSes").querySelectorAll("tr[data-sid]").forEach((tr) => {
tr.onclick = () => {
const sid = tr.getAttribute("data-sid");
state.selectedId = sid;
try { localStorage.setItem("sc5_ops_sid", sid); } catch (_) {}
setView("sessions");
if (window.__SC5_selectSession) window.__SC5_selectSession(sid);
};
});
// keep selection highlight on dash table
const keepSid = state.selectedId || (() => { try { return localStorage.getItem("sc5_ops_sid"); } catch (_) { return null; } })();
if (keepSid) {
state.selectedId = keepSid;
$("dashSes").querySelectorAll("tr[data-sid]").forEach((tr) => {
tr.classList.toggle("selected", tr.getAttribute("data-sid") === keepSid);
});
}
$("dashQuick").innerHTML = `
<p class="muted" style="margin:0 0 8px;font-size:0.85rem">Common next steps</p>
<div class="row">
<button type="button" class="primary" id="dqSes">Open Sessions</button>
<button type="button" id="dqLis">Open Listeners</button>
<button type="button" id="dqPay">Generate payload</button>
${can("admin") ? '<button type="button" id="dqAdm">Admin</button>' : ""}
</div>
<p class="muted" style="margin-top:12px;font-size:0.8rem">Health: ${esc(health.status || "ok")} / Actor: ${esc(state.actor || "-")}</p>
`;
if ($("dqSes")) $("dqSes").onclick = () => setView("sessions");
if ($("dqLis")) $("dqLis").onclick = () => setView("listeners");
if ($("dqPay")) $("dqPay").onclick = () => setView("payloads");
if ($("dqAdm")) $("dqAdm").onclick = () => setView("admin");
// Events
const ev = (metrics.recent_events || []).slice(-40).reverse();
$("eventCount").textContent = String(ev.length);
$("eventsBox").textContent = ev.length
? ev.map((e) => {
const t = new Date((e.ts || 0) * 1000).toISOString().slice(11, 19);
const p = e.payload ? JSON.stringify(e.payload).slice(0, 100) : "";
return `${t} ${e.type || "?"} ${p}`;
}).join("\n")
: "No recent events";
$("sideFoot").textContent = (state.actor || "op") + " / " + (activeSes.length) + " sessions";
if (window.__SC5_onRefresh) window.__SC5_onRefresh({ sessions: ses, listeners: lis, metrics });
}
function formatUptime(sec) {
if (sec == null || sec === "-") return "-";
const s = Math.floor(Number(sec));
if (!Number.isFinite(s)) return "-";
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
return h + "h " + m + "m";
}
// Hash import for phone QR - auto-connect
let importedFromQr = false;
try {
if (location.hash && location.hash.indexOf("sc5=") >= 0) {
const raw = location.hash.replace(/^#/, "").replace(/^sc5=/, "");
let b64 = raw.replace(/-/g, "+").replace(/_/g, "/");
while (b64.length % 4) b64 += "=";
const json = JSON.parse(decodeURIComponent(escape(atob(b64))));
if (json.url) localStorage.setItem(KEYS.url, String(json.url).replace(/\/$/, ""));
if (json.token) localStorage.setItem(KEYS.token, String(json.token));
if (json.refresh) localStorage.setItem(KEYS.refresh, String(json.refresh));
importedFromQr = !!(json.url && json.token);
history.replaceState(null, "", location.pathname + location.search);
}
} catch (_) {}
function b64urlEncode(obj) {
const s = btoa(unescape(encodeURIComponent(JSON.stringify(obj))));
return s.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
}
function buildPhoneShareUrl() {
const c = cfg();
const base = (apiBase() || c.url || "").replace(/\/$/, "");
if (!base) throw new Error("Set server URL first (Connect)");
if (!c.token) throw new Error("Set API token first (Connect)");
// Prefer same-origin /ops path so phone hits this teamserver
let opsBase = base;
try {
const u = new URL(base);
opsBase = u.origin;
} catch (_) {}
const payload = {
url: base,
token: c.token,
refresh: c.refresh || 10,
};
return opsBase + "/ops#sc5=" + b64urlEncode(payload);
}
function openQrModal() {
const box = $("qrBox");
const urlEl = $("qrUrl");
box.innerHTML = '<span class="muted">Generating...</span>';
urlEl.textContent = "";
try {
const share = buildPhoneShareUrl();
urlEl.textContent = share;
box.innerHTML = "";
if (typeof QRCode === "undefined") {
box.innerHTML = '<span class="muted">QR library missing - copy the link below.</span>';
} else {
// qrcodejs clears and renders into element
// eslint-disable-next-line no-new
new QRCode(box, {
text: share,
width: 220,
height: 220,
colorDark: "#0b0b0f",
colorLight: "#ffffff",
correctLevel: QRCode.CorrectLevel.M,
});
}
$("qrModal").classList.add("show");
} catch (e) {
showError(String(e.message || e));
openModal();
}
}
function closeQrModal() { $("qrModal").classList.remove("show"); }
$("btnPhoneQr").onclick = openQrModal;
$("qrClose").onclick = closeQrModal;
$("qrModal").addEventListener("click", (e) => { if (e.target === $("qrModal")) closeQrModal(); });
$("qrCopy").onclick = async () => {
try {
const t = $("qrUrl").textContent || buildPhoneShareUrl();
await navigator.clipboard.writeText(t);
showOk("Link copied");
} catch (_) {
try {
const t = $("qrUrl").textContent || "";
const ta = document.createElement("textarea");
ta.value = t; document.body.appendChild(ta); ta.select();
document.execCommand("copy"); document.body.removeChild(ta);
showOk("Link copied");
} catch (e2) { showError("Copy failed"); }
}
};
loadForm();
const savedView = (() => { try { return localStorage.getItem("sc5_ops_view"); } catch (_) { return null; } })();
if (cfg().token) {
connect().then(() => {
if (importedFromQr) showOk("Phone link applied - connected");
if (savedView && VIEWS[savedView]) setView(savedView);
}).catch(() => openModal());
} else {
openModal();
}
// Expose for ops-admin
window.__SC5_api = api;
window.__SC5_setView = setView;
window.__SC5_buildPhoneShareUrl = buildPhoneShareUrl;
})();
/* Mobile keyboard: pin INKO drawer to visualViewport; avoid smooth-scroll jitter */
(function () {
const vv = window.visualViewport;
if (!vv) return;
let raf = 0;
let lastKb = -1;
let lastTop = -1;
let lastH = -1;
function pinChatLog() {
const log = document.getElementById("aiChatLog");
if (!log) return;
// Instant snap only - never smooth (smooth + vv resize = jitter)
log.scrollTop = log.scrollHeight;
}
function clearDrawerPin(drawer) {
if (!drawer) return;
drawer.classList.remove("kb-pinned");
drawer.style.top = "";
drawer.style.height = "";
drawer.style.bottom = "";
}
function apply(opts) {
const kb = Math.max(0, Math.round(window.innerHeight - vv.height - vv.offsetTop));
const open = kb > 80;
if (kb !== lastKb) {
document.documentElement.style.setProperty("--kb-inset", kb + "px");
document.body.classList.toggle("kb-open", open);
lastKb = kb;
}
const drawer = document.getElementById("aiDrawer");
const drawerOpen = drawer && drawer.classList.contains("open") && !drawer.classList.contains("hidden");
if (drawerOpen && open) {
const top = Math.round(vv.offsetTop);
const h = Math.max(120, Math.round(vv.height));
if (top !== lastTop || h !== lastH || !drawer.classList.contains("kb-pinned")) {
drawer.classList.add("kb-pinned");
drawer.style.top = top + "px";
drawer.style.height = h + "px";
drawer.style.bottom = "auto";
lastTop = top;
lastH = h;
}
} else if (drawer) {
clearDrawerPin(drawer);
lastTop = -1;
lastH = -1;
}
if (opts && opts.pinChat && drawerOpen) pinChatLog();
}
function schedule(pinChat) {
if (raf) cancelAnimationFrame(raf);
raf = requestAnimationFrame(() => {
raf = 0;
apply({ pinChat: !!pinChat });
});
}
vv.addEventListener("resize", () => schedule(false));
vv.addEventListener("scroll", () => schedule(false));
window.addEventListener("focusin", (e) => {
const a = e.target;
if (!a || !a.tagName) return;
const tag = a.tagName;
if (tag !== "TEXTAREA" && tag !== "INPUT" && tag !== "SELECT") return;
const inInko = !!(a.id === "aiPromptGlobal" || a.closest("#aiDrawer"));
// One settle after keyboard starts - no smooth scrollIntoView on the message log
setTimeout(() => {
apply({ pinChat: inInko });
try {
a.scrollIntoView({ block: "nearest", inline: "nearest", behavior: "instant" });
} catch (_) {
try { a.scrollIntoView(false); } catch (__) {}
}
}, 60);
});
window.addEventListener("focusout", () => {
setTimeout(() => apply({ pinChat: false }), 120);
});
})();
</script>
</body>
</html>