mirror of
https://github.com/SquidSec/SquidC5
synced 2026-08-09 12:22:26 +00:00
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.
1807 lines
72 KiB
HTML
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 & 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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
}
|
|
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>
|