mirror of
https://github.com/SquidSec/SquidC5
synced 2026-08-09 12:22:26 +00:00
Session claim/lock, handoff packs, spectator, presence, team chat, per-op audit; workbench, events rail, teams panel, presets, file crumbs, pivot map, mobile hit targets. Docs + tests.
1613 lines
88 KiB
HTML
1613 lines
88 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="apple-mobile-web-app-capable" content="yes" />
|
||
<meta name="theme-color" content="#09090b" />
|
||
<link rel="icon" href="https://i.imgur.com/m1FBjqj.png" type="image/png" />
|
||
<link rel="stylesheet" href="https://squidoffense.com/css/typography.css" />
|
||
<title>SquidC5 — Command • Control • Cognitive • Collaborative • Coordination</title>
|
||
<style>
|
||
:root {
|
||
--pink: #e91e8c;
|
||
--pink-hot: #f472b6;
|
||
--purple: #a855f7;
|
||
--ok: #34d399;
|
||
--warn: #fbbf24;
|
||
--bad: #f87171;
|
||
--cyan: #38bdf8;
|
||
--muted: #a1a1aa;
|
||
--border: rgba(255, 255, 255, 0.1);
|
||
--card: rgba(24, 24, 27, 0.92);
|
||
--glow: none;
|
||
--radius: 14px;
|
||
--font: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif;
|
||
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||
}
|
||
* { box-sizing: border-box; }
|
||
body {
|
||
margin: 0; font-family: var(--font); color: #f4f4f5;
|
||
background: linear-gradient(180deg, #09090b 0%, #121016 55%, #0c0a0f 100%);
|
||
min-height: 100dvh;
|
||
padding: max(12px, env(safe-area-inset-top)) 12px calc(20px + env(safe-area-inset-bottom));
|
||
}
|
||
.wrap { max-width: 520px; margin: 0 auto; width: 100%; }
|
||
.brand { display: flex; align-items: center; gap: 12px; margin-bottom: 10px; flex-wrap: wrap; }
|
||
.brand .brand-text { min-width: 0; }
|
||
.toolbar { margin-bottom: 10px; }
|
||
.overview { display: flex; flex-direction: column; }
|
||
.overview > * { min-width: 0; }
|
||
#adminMount { display: flex; flex-direction: column; }
|
||
#adminMount > * { margin-bottom: 10px; }
|
||
#adminMount > *:last-child { margin-bottom: 0; }
|
||
/* Full-width work surfaces (used by multi-column layouts) */
|
||
.col-span-all { width: 100%; }
|
||
|
||
.brand img { width: 48px; height: 48px; object-fit: contain; filter: drop-shadow(0 0 8px rgba(233,30,140,0.35)); }
|
||
.brand h1 { margin: 0; font-size: 1.25rem; letter-spacing: -0.02em; }
|
||
.brand h1 .p { color: var(--pink); text-shadow: none; }
|
||
.brand .sub { font-size: 0.68rem; color: var(--muted); letter-spacing: 0.12em; text-transform: uppercase; }
|
||
.brand .c5-expand {
|
||
font-size: 0.62rem; color: var(--pink-hot); letter-spacing: 0.04em;
|
||
margin-top: 4px; line-height: 1.35; font-weight: 600;
|
||
text-transform: none;
|
||
}
|
||
.brand .c5-expand .dot { color: var(--muted); margin: 0 0.15em; font-weight: 400; }
|
||
.badge {
|
||
margin-left: auto; font: 700 0.65rem/1 var(--mono); letter-spacing: 0.06em;
|
||
text-transform: uppercase; padding: 6px 10px; border-radius: 8px;
|
||
border: 1px solid rgba(255,255,255,0.16); color: var(--muted);
|
||
}
|
||
.badge.ok { color: var(--ok); border-color: rgba(52,211,153,0.35); }
|
||
.badge.bad { color: var(--bad); border-color: rgba(248,113,113,0.35); }
|
||
.badge.admin { color: var(--pink-hot); border-color: rgba(233,30,140,0.35); margin-left: 6px; }
|
||
.hero {
|
||
border: 1px solid rgba(233,30,140,0.28); border-radius: 16px; padding: 12px;
|
||
margin-bottom: 12px; background: rgba(18,18,22,0.95);
|
||
position: relative;
|
||
}
|
||
.hero::before {
|
||
content: ""; position: absolute; top: 0; left: 0; width: 3px; height: 100%;
|
||
border-radius: 16px 0 0 16px;
|
||
background: var(--pink);
|
||
box-shadow: none;
|
||
}
|
||
.hero-host { font: 0.85rem var(--mono); color: var(--pink-hot); word-break: break-all; margin-bottom: 10px; }
|
||
.stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
|
||
.stat {
|
||
background: rgba(0,0,0,0.35);
|
||
border: 1px solid var(--border); border-radius: 12px;
|
||
padding: 10px 6px; text-align: center;
|
||
}
|
||
.stat .n { font-size: 1.35rem; font-weight: 800; color: #fafafa; text-shadow: none; }
|
||
.stat .l { font-size: 0.62rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.06em; margin-top: 2px; }
|
||
.stat.tone-ok { border-color: rgba(52,211,153,0.28); }
|
||
.stat.tone-ok .n { color: var(--ok); text-shadow: none; }
|
||
.stat.tone-pink { border-color: rgba(233,30,140,0.3); }
|
||
.stat.tone-pink .n { color: var(--pink-hot); text-shadow: none; }
|
||
.stat.tone-purple { border-color: rgba(168,85,247,0.28); }
|
||
.stat.tone-purple .n { color: #d8b4fe; text-shadow: none; }
|
||
.stat.tone-cyan { border-color: rgba(56,189,248,0.28); }
|
||
.stat.tone-cyan .n { color: var(--cyan); text-shadow: none; }
|
||
.stat.tone-warn { border-color: rgba(251,191,36,0.28); }
|
||
.stat.tone-warn .n { color: var(--warn); text-shadow: none; }
|
||
.stat.tone-bad { border-color: rgba(248,113,113,0.28); }
|
||
.stat.tone-bad .n { color: var(--bad); text-shadow: none; }
|
||
.card {
|
||
background: var(--card); border: 1px solid var(--border); border-radius: var(--radius);
|
||
padding: 12px; margin-bottom: 10px;
|
||
}
|
||
.card h2 {
|
||
margin: 0 0 8px; font-size: 0.72rem; letter-spacing: 0.12em;
|
||
text-transform: uppercase; color: #e4e4e7;
|
||
}
|
||
.muted { color: var(--muted); font-size: 0.8rem; }
|
||
.hint {
|
||
color: var(--muted); font-size: 0.78rem; line-height: 1.45;
|
||
margin: 0 0 10px; padding: 8px 10px;
|
||
background: rgba(255,255,255,0.03); border-left: 3px solid rgba(233,30,140,0.55);
|
||
border-radius: 0 8px 8px 0;
|
||
}
|
||
.hint code {
|
||
font-family: var(--mono); font-size: 0.72rem; color: #e9d5ff;
|
||
}
|
||
.hint strong { color: #e4e4e7; font-weight: 700; }
|
||
a.doc-link {
|
||
color: var(--pink-hot); font-weight: 600; text-decoration: none;
|
||
border-bottom: 1px solid rgba(233,30,140,0.35);
|
||
}
|
||
a.doc-link:hover { color: #fff; border-bottom-color: var(--pink-hot); }
|
||
a.doc-link.summary-doc {
|
||
font: 700 0.65rem/1 var(--mono); letter-spacing: 0.06em; text-transform: uppercase;
|
||
border: 1px solid rgba(255,255,255,0.14); border-radius: 6px; padding: 4px 8px;
|
||
border-bottom: 1px solid rgba(255,255,255,0.14); flex-shrink: 0;
|
||
background: rgba(255,255,255,0.04); color: var(--muted);
|
||
}
|
||
a.doc-link.summary-doc:hover { color: var(--pink-hot); border-color: rgba(233,30,140,0.4); background: rgba(233,30,140,0.08); }
|
||
.mono { font-family: var(--mono); font-size: 0.75rem; word-break: break-all; }
|
||
table { width: 100%; border-collapse: collapse; font-size: 0.8rem; }
|
||
th, td { text-align: left; padding: 8px 4px; border-bottom: 1px solid rgba(255,255,255,0.06); vertical-align: top; }
|
||
th { color: #d4d4d8; font-size: 0.65rem; text-transform: uppercase; letter-spacing: 0.05em; }
|
||
tr:last-child td { border-bottom: 0; }
|
||
.pill {
|
||
display: inline-block; font: 700 0.62rem var(--mono); padding: 2px 6px;
|
||
border-radius: 6px; border: 1px solid var(--border); color: var(--muted); text-transform: uppercase;
|
||
}
|
||
.pill.ok { color: var(--ok); border-color: rgba(0,255,157,0.35); }
|
||
.pill.bad { color: var(--bad); border-color: rgba(251,113,133,0.35); }
|
||
:root {
|
||
--panel-tile-h: 340px;
|
||
--panel-tile-h-wide: 400px;
|
||
--panel-head-h: 40px;
|
||
}
|
||
details.panel {
|
||
background: var(--card);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius);
|
||
padding: 10px 12px; margin-bottom: 10px;
|
||
box-sizing: border-box;
|
||
}
|
||
details.panel summary {
|
||
cursor: pointer; font-weight: 700; font-size: 0.9rem; list-style: none;
|
||
display: flex; align-items: center; gap: 8px; user-select: none;
|
||
flex-shrink: 0;
|
||
min-height: var(--panel-head-h);
|
||
box-sizing: border-box;
|
||
}
|
||
details.panel summary::-webkit-details-marker { display: none; }
|
||
details.panel summary::before {
|
||
content: "▸"; color: var(--pink); font-size: 0.75rem; line-height: 1;
|
||
transition: transform 0.12s ease;
|
||
}
|
||
details.panel[open] summary::before { transform: rotate(90deg); }
|
||
details.panel[open] summary { margin-bottom: 6px; color: var(--pink-hot); }
|
||
details.panel .panel-title { flex: 1; min-width: 0; }
|
||
/* Equal-size open tiles; body scrolls when content overflows.
|
||
Use grid (not flex) — details+flex overflow is unreliable in Chromium. */
|
||
details.panel[open] {
|
||
height: var(--panel-tile-h);
|
||
max-height: var(--panel-tile-h);
|
||
display: grid;
|
||
grid-template-rows: auto minmax(0, 1fr);
|
||
overflow: hidden;
|
||
}
|
||
details.panel[open].is-wide {
|
||
height: var(--panel-tile-h-wide);
|
||
max-height: var(--panel-tile-h-wide);
|
||
}
|
||
/* Status overview + Connection: height-fit content, never a scroll tile */
|
||
details.panel.hero[open],
|
||
details.panel#heroPanel[open],
|
||
details.panel#settingsPanel[open] {
|
||
height: auto !important;
|
||
max-height: none !important;
|
||
display: block !important;
|
||
overflow: visible !important;
|
||
}
|
||
details.panel.hero[open] > .panel-body,
|
||
details.panel#heroPanel[open] > .panel-body,
|
||
details.panel#settingsPanel[open] > .panel-body {
|
||
height: auto !important;
|
||
max-height: none !important;
|
||
overflow: visible !important;
|
||
padding-bottom: 12px;
|
||
}
|
||
/* Connection actions: one full-width horizontal row */
|
||
#settingsPanel .conn-actions {
|
||
display: flex;
|
||
flex-wrap: nowrap;
|
||
gap: 8px;
|
||
margin-top: 12px;
|
||
width: 100%;
|
||
}
|
||
#settingsPanel .conn-actions button {
|
||
flex: 1 1 0;
|
||
min-width: 0;
|
||
width: auto;
|
||
}
|
||
details.panel:not([open]) {
|
||
height: auto;
|
||
max-height: none;
|
||
display: block;
|
||
overflow: visible;
|
||
}
|
||
details.panel > .panel-body {
|
||
min-height: 0;
|
||
max-height: 100%;
|
||
overflow-x: hidden;
|
||
overflow-y: auto;
|
||
/* Do not trap wheel by default — only when focused + actually scrollable (JS). */
|
||
overscroll-behavior: auto;
|
||
-webkit-overflow-scrolling: touch;
|
||
scrollbar-width: thin;
|
||
scrollbar-color: rgba(233,30,140,0.35) rgba(0,0,0,0.2);
|
||
padding-right: 4px;
|
||
padding-bottom: 28px;
|
||
box-sizing: border-box;
|
||
}
|
||
/* Extra bottom room for dense admin panels */
|
||
#identityCard > .panel-body,
|
||
#quickRunCard > .panel-body,
|
||
#sessionsPanel > .panel-body,
|
||
#tasksPanel > .panel-body,
|
||
#listenersPanel > .panel-body,
|
||
#payloadsPanel > .panel-body,
|
||
#pluginsPanel > .panel-body,
|
||
#deployPanel > .panel-body,
|
||
#obsPanel > .panel-body,
|
||
#aiCard > .panel-body,
|
||
#llmPanel > .panel-body,
|
||
#tokensPanel > .panel-body,
|
||
#obsExtraPanel > .panel-body,
|
||
#chatPanel > .panel-body,
|
||
#profilesPanel > .panel-body,
|
||
#featuresPanel > .panel-body,
|
||
#policyPanel > .panel-body,
|
||
#mcpPanel > .panel-body,
|
||
#filesPanel > .panel-body,
|
||
#socksPanel > .panel-body,
|
||
#modulesPanel > .panel-body,
|
||
#hitlPanel > .panel-body,
|
||
#engagementPanel > .panel-body,
|
||
#workbenchPanel > .panel-body,
|
||
#eventsRailPanel > .panel-body,
|
||
#teamsPanel > .panel-body,
|
||
#auditMePanel > .panel-body,
|
||
#pivotMapPanel > .panel-body {
|
||
padding-bottom: 40px;
|
||
}
|
||
/* U7 mobile: larger hit targets */
|
||
@media (max-width: 767px) {
|
||
button.touch-lg, .touch-lg {
|
||
min-height: 44px;
|
||
font-size: 1rem;
|
||
padding: 10px 14px;
|
||
}
|
||
input.touch-lg, textarea.touch-lg, select.touch-lg {
|
||
min-height: 44px;
|
||
font-size: 1rem;
|
||
}
|
||
details.panel summary {
|
||
min-height: 48px;
|
||
}
|
||
}
|
||
#eventsRail .mono { word-break: break-all; }
|
||
#fileTable table { width: 100%; font-size: 0.75rem; }
|
||
#fileCrumbs .chip { cursor: pointer; }
|
||
|
||
details.panel > .panel-body.scroll-focus {
|
||
outline: 1px solid rgba(233,30,140,0.25);
|
||
outline-offset: -1px;
|
||
overscroll-behavior: contain;
|
||
}
|
||
details.panel:not([open]) > .panel-body {
|
||
display: none;
|
||
}
|
||
details.panel[open] .outbox {
|
||
max-height: none;
|
||
}
|
||
details.panel[open] .event-feed {
|
||
max-height: none;
|
||
}
|
||
details.panel[open] textarea {
|
||
max-height: 120px;
|
||
}
|
||
.wide-btn {
|
||
appearance: none; flex: 0 0 auto !important; min-width: 0 !important;
|
||
width: auto; margin-left: auto; padding: 4px 8px !important;
|
||
font: 700 0.75rem/1 var(--mono) !important;
|
||
border-radius: 8px; border: 1px solid var(--border);
|
||
background: rgba(255,255,255,0.06); color: var(--muted);
|
||
cursor: pointer; box-shadow: none !important;
|
||
}
|
||
.wide-btn:hover { color: var(--pink-hot); border-color: rgba(233,30,140,0.35); }
|
||
details.panel.is-wide .wide-btn {
|
||
color: var(--pink-hot); border-color: rgba(233,30,140,0.4);
|
||
background: rgba(233,30,140,0.1);
|
||
}
|
||
.drag-handle {
|
||
cursor: grab; color: var(--muted); font: 700 0.85rem/1 var(--mono);
|
||
letter-spacing: -0.08em; padding: 4px 6px; margin: -4px 0 -4px -2px;
|
||
border-radius: 6px; flex-shrink: 0; touch-action: none;
|
||
}
|
||
.drag-handle:hover { color: var(--pink-hot); background: rgba(233,30,140,0.1); }
|
||
.drag-handle:active { cursor: grabbing; }
|
||
details.panel.is-dragging {
|
||
opacity: 0.55; outline: 1px dashed rgba(233,30,140,0.45);
|
||
}
|
||
details.panel.drag-over {
|
||
box-shadow: 0 0 0 2px rgba(233,30,140,0.35);
|
||
}
|
||
.layout-hint {
|
||
font-size: 0.68rem; color: var(--muted); margin: 0 0 8px;
|
||
letter-spacing: 0.04em;
|
||
}
|
||
label {
|
||
display: block; font-size: 0.68rem; color: var(--muted); text-transform: uppercase;
|
||
letter-spacing: 0.06em; font-weight: 700; margin: 8px 0 4px;
|
||
}
|
||
input, select, textarea {
|
||
width: 100%; padding: 10px 12px; border-radius: 10px; border: 1px solid var(--border);
|
||
background: rgba(0,0,0,0.45); color: #fff; font: 0.95rem var(--font);
|
||
}
|
||
textarea { min-height: 72px; resize: vertical; font-family: var(--mono); font-size: 0.85rem; }
|
||
input:focus, select:focus, textarea:focus {
|
||
outline: none; border-color: rgba(233,30,140,0.55); box-shadow: 0 0 0 2px rgba(233,30,140,0.12);
|
||
}
|
||
.row { display: flex; gap: 8px; margin-top: 10px; flex-wrap: wrap; }
|
||
button {
|
||
appearance: none; flex: 1; min-width: 110px; border: 1px solid var(--border);
|
||
background: rgba(255,255,255,0.06); color: #fff; border-radius: 12px;
|
||
padding: 11px 12px; font: 700 0.85rem var(--font); cursor: pointer;
|
||
}
|
||
button.primary {
|
||
background: var(--pink);
|
||
color: #fff; border-color: transparent;
|
||
box-shadow: none;
|
||
}
|
||
button.primary:hover { background: #d4157a; }
|
||
button.danger { border-color: rgba(251,113,133,0.4); color: #fecdd3; }
|
||
button:disabled { opacity: 0.45; cursor: not-allowed; }
|
||
button:active:not(:disabled) { transform: scale(0.98); }
|
||
.err {
|
||
display: none; background: rgba(251,113,133,0.1); border: 1px solid rgba(251,113,133,0.35);
|
||
color: #fecdd3; border-radius: var(--radius); padding: 10px 12px; margin-bottom: 10px; font-size: 0.84rem;
|
||
}
|
||
.err.show { display: block; }
|
||
.okmsg {
|
||
display: none; background: rgba(0,255,157,0.08); border: 1px solid rgba(0,255,157,0.3);
|
||
color: #bbf7d0; border-radius: var(--radius); padding: 10px 12px; margin-bottom: 10px; font-size: 0.84rem;
|
||
}
|
||
.okmsg.show { display: block; }
|
||
.chips { display: flex; flex-wrap: wrap; gap: 6px; }
|
||
.chip {
|
||
font: 0.68rem var(--mono); padding: 5px 8px; border-radius: 8px;
|
||
background: rgba(255,255,255,0.04); border: 1px solid var(--border); color: #e4e4e7;
|
||
}
|
||
.chip b { color: var(--pink-hot); }
|
||
.outbox {
|
||
margin-top: 8px; background: #0a0a0b; border: 1px solid rgba(52,211,153,0.28);
|
||
border-radius: 10px; padding: 12px; font: 0.85rem/1.45 var(--mono);
|
||
white-space: pre-wrap; word-break: break-word; max-height: 280px; overflow: auto;
|
||
color: var(--ok); min-height: 3.2em;
|
||
}
|
||
.outbox.empty-out { color: var(--muted); border-color: var(--border); }
|
||
.empty { color: var(--muted); font-size: 0.85rem; padding: 6px 0; }
|
||
.modal-bg {
|
||
display: none; position: fixed; inset: 0; z-index: 50; background: rgba(0,0,0,0.82);
|
||
align-items: center; justify-content: center; padding: 16px;
|
||
}
|
||
.modal-bg.show { display: flex; }
|
||
.modal {
|
||
width: min(100%, 380px); background: #0a0a0b; border: 1px solid rgba(255,0,170,0.4);
|
||
border-radius: 16px; padding: 16px; box-shadow: 0 0 30px rgba(255,0,170,0.25);
|
||
}
|
||
.qr-box { background: #fff; border-radius: 12px; padding: 12px; width: fit-content; margin: 0 auto 12px; }
|
||
.qr-box img { width: 220px; height: 220px; display: block; }
|
||
.share-url { font: 0.65rem var(--mono); color: var(--pink); word-break: break-all; margin-bottom: 10px; }
|
||
footer { text-align: center; color: var(--muted); font-size: 0.72rem; margin-top: 14px; }
|
||
.hidden { display: none !important; }
|
||
|
||
/* Live event stream (top of ops surface) */
|
||
details.panel.event-stream {
|
||
background: var(--card);
|
||
border: 1px solid rgba(233,30,140,0.22);
|
||
border-radius: 16px;
|
||
margin-bottom: 12px;
|
||
}
|
||
.event-stream-head {
|
||
display: flex; align-items: center; gap: 10px; flex: 1; min-width: 0;
|
||
font-weight: 700;
|
||
}
|
||
.event-stream-head .live-dot {
|
||
width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0;
|
||
background: var(--ok); box-shadow: 0 0 6px rgba(52,211,153,0.5);
|
||
animation: pulse-live 1.6s ease-in-out infinite;
|
||
}
|
||
@keyframes pulse-live {
|
||
0%, 100% { opacity: 1; transform: scale(1); }
|
||
50% { opacity: 0.45; transform: scale(0.85); }
|
||
}
|
||
.event-stream-head .count {
|
||
font: 700 0.65rem var(--mono); color: var(--muted);
|
||
letter-spacing: 0.04em; margin-left: auto;
|
||
}
|
||
.event-feed {
|
||
max-height: 220px; overflow: auto;
|
||
display: flex; flex-direction: column; gap: 6px;
|
||
scrollbar-width: thin;
|
||
scrollbar-color: rgba(255,0,170,0.35) transparent;
|
||
}
|
||
.event-item {
|
||
display: grid;
|
||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||
gap: 8px 10px;
|
||
align-items: start;
|
||
padding: 8px 10px;
|
||
border-radius: 10px;
|
||
background: rgba(0,0,0,0.4);
|
||
border: 1px solid rgba(255,255,255,0.06);
|
||
border-left: 3px solid rgba(255,0,170,0.45);
|
||
}
|
||
.event-item.ok { border-left-color: var(--ok); }
|
||
.event-item.warn { border-left-color: var(--warn); }
|
||
.event-item.bad { border-left-color: var(--bad); }
|
||
.event-item .etype {
|
||
font: 700 0.62rem var(--mono); letter-spacing: 0.04em;
|
||
text-transform: uppercase; color: #ffb3ff;
|
||
white-space: nowrap;
|
||
}
|
||
.event-item.ok .etype { color: var(--ok); }
|
||
.event-item.warn .etype { color: var(--warn); }
|
||
.event-item.bad .etype { color: var(--bad); }
|
||
.event-item .edetail {
|
||
font: 0.78rem/1.35 var(--mono); color: #e4e4e7;
|
||
word-break: break-word; min-width: 0;
|
||
}
|
||
.event-item .etime {
|
||
font: 0.62rem var(--mono); color: var(--muted);
|
||
white-space: nowrap;
|
||
}
|
||
.event-feed .empty { padding: 16px 8px; text-align: center; }
|
||
|
||
/* Masonry packing (JS positions children into shortest column) */
|
||
.masonry {
|
||
position: relative;
|
||
width: 100%;
|
||
}
|
||
.masonry > * {
|
||
box-sizing: border-box;
|
||
min-width: 0;
|
||
}
|
||
.masonry.is-packed > * {
|
||
position: absolute;
|
||
margin: 0 !important;
|
||
}
|
||
.masonry-span, details.panel.is-wide {
|
||
/* full-width row — set by user toggle / JS */
|
||
}
|
||
|
||
/* Tablet+ */
|
||
@media (min-width: 768px) {
|
||
body {
|
||
padding: max(16px, env(safe-area-inset-top)) 24px calc(24px + env(safe-area-inset-bottom));
|
||
}
|
||
.wrap { max-width: 1000px; }
|
||
.overview { margin-bottom: 4px; }
|
||
#adminMount { margin-top: 12px; }
|
||
.event-stream { margin-bottom: 14px; }
|
||
.event-feed { max-height: 280px; }
|
||
.outbox { max-height: 380px; }
|
||
button { min-width: 120px; flex: 0 1 auto; }
|
||
.stats { gap: 12px; grid-template-columns: repeat(3, 1fr); }
|
||
.stat .n { font-size: 1.5rem; }
|
||
.modal { width: min(100%, 420px); }
|
||
}
|
||
|
||
/* Desktop */
|
||
@media (min-width: 1100px) {
|
||
.wrap { max-width: 1440px; }
|
||
.brand h1 { font-size: 1.45rem; }
|
||
.hero { padding: 16px 18px; margin-bottom: 16px; }
|
||
.card, details.panel { padding: 14px 16px; }
|
||
table { font-size: 0.85rem; }
|
||
.outbox { max-height: 420px; }
|
||
.event-feed { max-height: 320px; }
|
||
.event-item {
|
||
grid-template-columns: minmax(120px, auto) minmax(0, 1fr) auto;
|
||
}
|
||
.stats { grid-template-columns: repeat(3, 1fr); }
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="wrap">
|
||
<div class="brand">
|
||
<img src="/ops/assets/squidsec-logo-256.png"
|
||
onerror="this.onerror=null;this.src='https://i.imgur.com/m1FBjqj.png'"
|
||
alt="SquidC5" width="48" height="48" />
|
||
<div class="brand-text">
|
||
<h1>SQUID<span class="p">C5</span></h1>
|
||
<div class="sub">operator console</div>
|
||
<div class="c5-expand" title="What C5 stands for">
|
||
Command<span class="dot">•</span>Control<span class="dot">•</span>Cognitive<span class="dot">•</span>Collaborative<span class="dot">•</span>Coordination
|
||
</div>
|
||
</div>
|
||
<span id="statusBadge" class="badge">offline</span>
|
||
<span id="roleBadge" class="badge admin hidden">—</span>
|
||
</div>
|
||
|
||
<div id="error" class="err"></div>
|
||
<div id="okmsg" class="okmsg"></div>
|
||
|
||
<details class="panel" id="settingsPanel">
|
||
<summary>Connection <a class="doc-link summary-doc" href="https://github.com/DotNetRussell/SquidC5/blob/master/docs/user-guide.md#connection" target="_blank" rel="noopener noreferrer" onclick="event.stopPropagation()">Docs</a></summary>
|
||
<p class="hint">Browser credentials for this console. Server URL is the SquidC5 base (usually this host when opened via <code>/ops</code>). API token is a scoped <code>sc5_…</code> secret stored only in localStorage. Admin panels load only after the server accepts the token. Refresh interval polls sessions/listeners/metrics. <a class="doc-link" href="https://github.com/DotNetRussell/SquidC5/blob/master/docs/user-guide.md#connection" target="_blank" rel="noopener noreferrer">Full docs →</a></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="refresh">Refresh (sec)</label>
|
||
<input id="refresh" type="number" min="5" max="120" value="10" />
|
||
<div class="conn-actions">
|
||
<button type="button" class="primary" id="saveBtn">Save & Connect</button>
|
||
<button type="button" id="toggleToken">Show token</button>
|
||
<button type="button" id="refreshBtn">Refresh</button>
|
||
<button type="button" class="primary" id="shareBtn">Phone QR</button>
|
||
</div>
|
||
</details>
|
||
|
||
<details class="panel event-stream" id="eventStream" aria-label="Live event stream">
|
||
<summary>
|
||
<span class="event-stream-head">
|
||
<span class="live-dot" title="Live"></span>
|
||
<span>Event stream</span>
|
||
<span class="count" id="eventCount">0 events</span>
|
||
</span>
|
||
<a class="doc-link summary-doc" href="https://github.com/DotNetRussell/SquidC5/blob/master/docs/user-guide.md#event-stream" target="_blank" rel="noopener noreferrer" onclick="event.stopPropagation()">Docs</a>
|
||
</summary>
|
||
<p class="hint">Live activity from this C2: shell connect / verify / reject, listener changes, tasks, and other server events. Newest first; refreshes with the poll interval. <a class="doc-link" href="https://github.com/DotNetRussell/SquidC5/blob/master/docs/user-guide.md#event-stream" target="_blank" rel="noopener noreferrer">Full docs →</a></p>
|
||
<div class="event-feed" id="eventsBox">
|
||
<div class="empty">Connect to load events</div>
|
||
</div>
|
||
</details>
|
||
|
||
<details class="panel hero" id="heroPanel">
|
||
<summary>Status overview <a class="doc-link summary-doc" href="https://github.com/DotNetRussell/SquidC5/blob/master/docs/user-guide.md#status-overview" target="_blank" rel="noopener noreferrer" onclick="event.stopPropagation()">Docs</a></summary>
|
||
<div class="hero-host" id="hostLine">not connected</div>
|
||
<p class="hint" style="margin-top:0">At-a-glance C2 health: sessions, listeners, implant traffic, AI usage, and host uptime. Values refresh with the poll interval. <a class="doc-link" href="https://github.com/DotNetRussell/SquidC5/blob/master/docs/user-guide.md#status-overview" target="_blank" rel="noopener noreferrer">Full docs →</a></p>
|
||
<div class="stats">
|
||
<div class="stat tone-ok"><div class="n" id="nVerified">—</div><div class="l">Verified shells</div></div>
|
||
<div class="stat tone-pink"><div class="n" id="nListeners">—</div><div class="l">Listeners up</div></div>
|
||
<div class="stat tone-cyan"><div class="n" id="nSessions">—</div><div class="l">Active sessions</div></div>
|
||
<div class="stat tone-purple"><div class="n" id="nTasks">—</div><div class="l">Open tasks</div></div>
|
||
<div class="stat tone-ok"><div class="n" id="nBeacons">—</div><div class="l">HTTP hits</div></div>
|
||
<div class="stat tone-warn"><div class="n" id="nStabilized">—</div><div class="l">Stabilized</div></div>
|
||
<div class="stat tone-bad"><div class="n" id="nFalsePos">—</div><div class="l">False shells</div></div>
|
||
<div class="stat tone-purple"><div class="n" id="nAi">—</div><div class="l">AI calls</div></div>
|
||
<div class="stat tone-cyan"><div class="n" id="nUptime">—</div><div class="l">Uptime</div></div>
|
||
</div>
|
||
</details>
|
||
|
||
<div class="overview masonry" id="overviewMount">
|
||
<details class="panel shells-card" id="shellsPanel">
|
||
<summary>Verified reverse shells <a class="doc-link summary-doc" href="https://github.com/DotNetRussell/SquidC5/blob/master/docs/user-guide.md#verified-reverse-shells-overview-card" target="_blank" rel="noopener noreferrer" onclick="event.stopPropagation()">Docs</a></summary>
|
||
<p class="hint">Live TCP reverse shells that passed false-shell filtering and the exec probe (real command execution). Prefer these for interactive Shell commands. Dead/echo-only connections are reaped automatically. <a class="doc-link" href="https://github.com/DotNetRussell/SquidC5/blob/master/docs/user-guide.md#shell" target="_blank" rel="noopener noreferrer">Full docs →</a></p>
|
||
<div id="shellsBox" class="empty">No verified shells</div>
|
||
</details>
|
||
|
||
<details class="panel listeners-card" id="listenersOverviewPanel">
|
||
<summary>Listeners <a class="doc-link summary-doc" href="https://github.com/DotNetRussell/SquidC5/blob/master/docs/user-guide.md#listeners" target="_blank" rel="noopener noreferrer" onclick="event.stopPropagation()">Docs</a></summary>
|
||
<p class="hint">Bound acceptors on this host (name, port, kind, status). Running listeners are what implants and reverse shells connect to. Create/start more under the Listeners panel below. <a class="doc-link" href="https://github.com/DotNetRussell/SquidC5/blob/master/docs/user-guide.md#listeners" target="_blank" rel="noopener noreferrer">Full docs →</a></p>
|
||
<div id="listenersBox" class="empty">—</div>
|
||
</details>
|
||
|
||
<details class="panel signal-card" id="signalPanel">
|
||
<summary>Signal <a class="doc-link summary-doc" href="https://github.com/DotNetRussell/SquidC5/blob/master/docs/user-guide.md#signal" target="_blank" rel="noopener noreferrer" onclick="event.stopPropagation()">Docs</a></summary>
|
||
<p class="hint">Quick status chips from the last poll (health, feature hints, session counts). Use this to confirm the API is reachable and the server is not in a degraded state. <a class="doc-link" href="https://github.com/DotNetRussell/SquidC5/blob/master/docs/user-guide.md#signal" target="_blank" rel="noopener noreferrer">Full docs →</a></p>
|
||
<div class="chips" id="chips"><span class="chip">—</span></div>
|
||
<p class="muted" id="healthLine" style="margin:8px 0 0">—</p>
|
||
</details>
|
||
</div>
|
||
|
||
<!-- Admin UI injected after server-side scope check -->
|
||
<div id="adminMount" class="masonry"></div>
|
||
|
||
<footer>
|
||
<div id="footer">Last update: never</div>
|
||
<div style="margin-top:6px;font-size:0.65rem;color:var(--muted);line-height:1.4">
|
||
<strong style="color:var(--pink)">SquidC5</strong>
|
||
— Command • Control • Cognitive • Collaborative • Coordination
|
||
</div>
|
||
<div style="margin-top:4px;letter-spacing:0.12em;text-transform:uppercase;font-size:0.62rem">
|
||
authorized ops only
|
||
</div>
|
||
<div style="margin-top:8px;font-size:0.72rem">
|
||
<a class="doc-link" href="https://github.com/DotNetRussell/SquidC5/blob/master/docs/user-guide.md" target="_blank" rel="noopener noreferrer">User guide (GitHub)</a>
|
||
·
|
||
<a class="doc-link" href="https://github.com/DotNetRussell/SquidC5/blob/master/docs/README.md" target="_blank" rel="noopener noreferrer">Docs index</a>
|
||
</div>
|
||
</footer>
|
||
</div>
|
||
|
||
<div class="modal-bg" id="qrModal">
|
||
<div class="modal">
|
||
<h2 style="margin:0 0 6px;font-size:1.05rem">Scan with phone</h2>
|
||
<p class="muted">Opens this console pre-configured. QR embeds your token — don’t share.</p>
|
||
<div class="qr-box" id="qrCanvas"></div>
|
||
<div class="share-url" id="shareUrl">—</div>
|
||
<div class="row">
|
||
<button type="button" id="copyShareBtn">Copy link</button>
|
||
<button type="button" class="primary" id="closeQrBtn">Done</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
/* qrcode-generator 1.4.4 — offline */
|
||
/**
|
||
* Minified by jsDelivr using Terser v5.37.0.
|
||
* Original file: /npm/qrcode-generator@1.4.4/qrcode.js
|
||
*
|
||
* Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files
|
||
*/
|
||
var qrcode=function(){var t=function(t,r){var e=t,n=g[r],o=null,i=0,a=null,u=[],f={},c=function(t,r){o=function(t){for(var r=new Array(t),e=0;e<t;e+=1){r[e]=new Array(t);for(var n=0;n<t;n+=1)r[e][n]=null}return r}(i=4*e+17),l(0,0),l(i-7,0),l(0,i-7),s(),h(),d(t,r),e>=7&&v(t),null==a&&(a=p(e,n,u)),w(a,r)},l=function(t,r){for(var e=-1;e<=7;e+=1)if(!(t+e<=-1||i<=t+e))for(var n=-1;n<=7;n+=1)r+n<=-1||i<=r+n||(o[t+e][r+n]=0<=e&&e<=6&&(0==n||6==n)||0<=n&&n<=6&&(0==e||6==e)||2<=e&&e<=4&&2<=n&&n<=4)},h=function(){for(var t=8;t<i-8;t+=1)null==o[t][6]&&(o[t][6]=t%2==0);for(var r=8;r<i-8;r+=1)null==o[6][r]&&(o[6][r]=r%2==0)},s=function(){for(var t=B.getPatternPosition(e),r=0;r<t.length;r+=1)for(var n=0;n<t.length;n+=1){var i=t[r],a=t[n];if(null==o[i][a])for(var u=-2;u<=2;u+=1)for(var f=-2;f<=2;f+=1)o[i+u][a+f]=-2==u||2==u||-2==f||2==f||0==u&&0==f}},v=function(t){for(var r=B.getBCHTypeNumber(e),n=0;n<18;n+=1){var a=!t&&1==(r>>n&1);o[Math.floor(n/3)][n%3+i-8-3]=a}for(n=0;n<18;n+=1){a=!t&&1==(r>>n&1);o[n%3+i-8-3][Math.floor(n/3)]=a}},d=function(t,r){for(var e=n<<3|r,a=B.getBCHTypeInfo(e),u=0;u<15;u+=1){var f=!t&&1==(a>>u&1);u<6?o[u][8]=f:u<8?o[u+1][8]=f:o[i-15+u][8]=f}for(u=0;u<15;u+=1){f=!t&&1==(a>>u&1);u<8?o[8][i-u-1]=f:u<9?o[8][15-u-1+1]=f:o[8][15-u-1]=f}o[i-8][8]=!t},w=function(t,r){for(var e=-1,n=i-1,a=7,u=0,f=B.getMaskFunction(r),c=i-1;c>0;c-=2)for(6==c&&(c-=1);;){for(var g=0;g<2;g+=1)if(null==o[n][c-g]){var l=!1;u<t.length&&(l=1==(t[u]>>>a&1)),f(n,c-g)&&(l=!l),o[n][c-g]=l,-1==(a-=1)&&(u+=1,a=7)}if((n+=e)<0||i<=n){n-=e,e=-e;break}}},p=function(t,r,e){for(var n=A.getRSBlocks(t,r),o=b(),i=0;i<e.length;i+=1){var a=e[i];o.put(a.getMode(),4),o.put(a.getLength(),B.getLengthInBits(a.getMode(),t)),a.write(o)}var u=0;for(i=0;i<n.length;i+=1)u+=n[i].dataCount;if(o.getLengthInBits()>8*u)throw"code length overflow. ("+o.getLengthInBits()+">"+8*u+")";for(o.getLengthInBits()+4<=8*u&&o.put(0,4);o.getLengthInBits()%8!=0;)o.putBit(!1);for(;!(o.getLengthInBits()>=8*u||(o.put(236,8),o.getLengthInBits()>=8*u));)o.put(17,8);return function(t,r){for(var e=0,n=0,o=0,i=new Array(r.length),a=new Array(r.length),u=0;u<r.length;u+=1){var f=r[u].dataCount,c=r[u].totalCount-f;n=Math.max(n,f),o=Math.max(o,c),i[u]=new Array(f);for(var g=0;g<i[u].length;g+=1)i[u][g]=255&t.getBuffer()[g+e];e+=f;var l=B.getErrorCorrectPolynomial(c),h=k(i[u],l.getLength()-1).mod(l);for(a[u]=new Array(l.getLength()-1),g=0;g<a[u].length;g+=1){var s=g+h.getLength()-a[u].length;a[u][g]=s>=0?h.getAt(s):0}}var v=0;for(g=0;g<r.length;g+=1)v+=r[g].totalCount;var d=new Array(v),w=0;for(g=0;g<n;g+=1)for(u=0;u<r.length;u+=1)g<i[u].length&&(d[w]=i[u][g],w+=1);for(g=0;g<o;g+=1)for(u=0;u<r.length;u+=1)g<a[u].length&&(d[w]=a[u][g],w+=1);return d}(o,n)};f.addData=function(t,r){var e=null;switch(r=r||"Byte"){case"Numeric":e=M(t);break;case"Alphanumeric":e=x(t);break;case"Byte":e=m(t);break;case"Kanji":e=L(t);break;default:throw"mode:"+r}u.push(e),a=null},f.isDark=function(t,r){if(t<0||i<=t||r<0||i<=r)throw t+","+r;return o[t][r]},f.getModuleCount=function(){return i},f.make=function(){if(e<1){for(var t=1;t<40;t++){for(var r=A.getRSBlocks(t,n),o=b(),i=0;i<u.length;i++){var a=u[i];o.put(a.getMode(),4),o.put(a.getLength(),B.getLengthInBits(a.getMode(),t)),a.write(o)}var g=0;for(i=0;i<r.length;i++)g+=r[i].dataCount;if(o.getLengthInBits()<=8*g)break}e=t}c(!1,function(){for(var t=0,r=0,e=0;e<8;e+=1){c(!0,e);var n=B.getLostPoint(f);(0==e||t>n)&&(t=n,r=e)}return r}())},f.createTableTag=function(t,r){t=t||2;var e="";e+='<table style="',e+=" border-width: 0px; border-style: none;",e+=" border-collapse: collapse;",e+=" padding: 0px; margin: "+(r=void 0===r?4*t:r)+"px;",e+='">',e+="<tbody>";for(var n=0;n<f.getModuleCount();n+=1){e+="<tr>";for(var o=0;o<f.getModuleCount();o+=1)e+='<td style="',e+=" border-width: 0px; border-style: none;",e+=" border-collapse: collapse;",e+=" padding: 0px; margin: 0px;",e+=" width: "+t+"px;",e+=" height: "+t+"px;",e+=" background-color: ",e+=f.isDark(n,o)?"#000000":"#ffffff",e+=";",e+='"/>';e+="</tr>"}return e+="</tbody>",e+="</table>"},f.createSvgTag=function(t,r,e,n){var o={};"object"==typeof arguments[0]&&(t=(o=arguments[0]).cellSize,r=o.margin,e=o.alt,n=o.title),t=t||2,r=void 0===r?4*t:r,(e="string"==typeof e?{text:e}:e||{}).text=e.text||null,e.id=e.text?e.id||"qrcode-description":null,(n="string"==typeof n?{text:n}:n||{}).text=n.text||null,n.id=n.text?n.id||"qrcode-title":null;var i,a,u,c,g=f.getModuleCount()*t+2*r,l="";for(c="l"+t+",0 0,"+t+" -"+t+",0 0,-"+t+"z ",l+='<svg version="1.1" xmlns="http://www.w3.org/2000/svg"',l+=o.scalable?"":' width="'+g+'px" height="'+g+'px"',l+=' viewBox="0 0 '+g+" "+g+'" ',l+=' preserveAspectRatio="xMinYMin meet"',l+=n.text||e.text?' role="img" aria-labelledby="'+y([n.id,e.id].join(" ").trim())+'"':"",l+=">",l+=n.text?'<title id="'+y(n.id)+'">'+y(n.text)+"</title>":"",l+=e.text?'<description id="'+y(e.id)+'">'+y(e.text)+"</description>":"",l+='<rect width="100%" height="100%" fill="white" cx="0" cy="0"/>',l+='<path d="',a=0;a<f.getModuleCount();a+=1)for(u=a*t+r,i=0;i<f.getModuleCount();i+=1)f.isDark(a,i)&&(l+="M"+(i*t+r)+","+u+c);return l+='" stroke="transparent" fill="black"/>',l+="</svg>"},f.createDataURL=function(t,r){t=t||2,r=void 0===r?4*t:r;var e=f.getModuleCount()*t+2*r,n=r,o=e-r;return I(e,e,(function(r,e){if(n<=r&&r<o&&n<=e&&e<o){var i=Math.floor((r-n)/t),a=Math.floor((e-n)/t);return f.isDark(a,i)?0:1}return 1}))},f.createImgTag=function(t,r,e){t=t||2,r=void 0===r?4*t:r;var n=f.getModuleCount()*t+2*r,o="";return o+="<img",o+=' src="',o+=f.createDataURL(t,r),o+='"',o+=' width="',o+=n,o+='"',o+=' height="',o+=n,o+='"',e&&(o+=' alt="',o+=y(e),o+='"'),o+="/>"};var y=function(t){for(var r="",e=0;e<t.length;e+=1){var n=t.charAt(e);switch(n){case"<":r+="<";break;case">":r+=">";break;case"&":r+="&";break;case'"':r+=""";break;default:r+=n}}return r};return f.createASCII=function(t,r){if((t=t||1)<2)return function(t){t=void 0===t?2:t;var r,e,n,o,i,a=1*f.getModuleCount()+2*t,u=t,c=a-t,g={"██":"█","█ ":"▀"," █":"▄"," ":" "},l={"██":"▀","█ ":"▀"," █":" "," ":" "},h="";for(r=0;r<a;r+=2){for(n=Math.floor((r-u)/1),o=Math.floor((r+1-u)/1),e=0;e<a;e+=1)i="█",u<=e&&e<c&&u<=r&&r<c&&f.isDark(n,Math.floor((e-u)/1))&&(i=" "),u<=e&&e<c&&u<=r+1&&r+1<c&&f.isDark(o,Math.floor((e-u)/1))?i+=" ":i+="█",h+=t<1&&r+1>=c?l[i]:g[i];h+="\n"}return a%2&&t>0?h.substring(0,h.length-a-1)+Array(a+1).join("▀"):h.substring(0,h.length-1)}(r);t-=1,r=void 0===r?2*t:r;var e,n,o,i,a=f.getModuleCount()*t+2*r,u=r,c=a-r,g=Array(t+1).join("██"),l=Array(t+1).join(" "),h="",s="";for(e=0;e<a;e+=1){for(o=Math.floor((e-u)/t),s="",n=0;n<a;n+=1)i=1,u<=n&&n<c&&u<=e&&e<c&&f.isDark(o,Math.floor((n-u)/t))&&(i=0),s+=i?g:l;for(o=0;o<t;o+=1)h+=s+"\n"}return h.substring(0,h.length-1)},f.renderTo2dContext=function(t,r){r=r||2;for(var e=f.getModuleCount(),n=0;n<e;n++)for(var o=0;o<e;o++)t.fillStyle=f.isDark(n,o)?"black":"white",t.fillRect(n*r,o*r,r,r)},f};t.stringToBytes=(t.stringToBytesFuncs={default:function(t){for(var r=[],e=0;e<t.length;e+=1){var n=t.charCodeAt(e);r.push(255&n)}return r}}).default,t.createStringToBytes=function(t,r){var e=function(){for(var e=S(t),n=function(){var t=e.read();if(-1==t)throw"eof";return t},o=0,i={};;){var a=e.read();if(-1==a)break;var u=n(),f=n()<<8|n();i[String.fromCharCode(a<<8|u)]=f,o+=1}if(o!=r)throw o+" != "+r;return i}(),n="?".charCodeAt(0);return function(t){for(var r=[],o=0;o<t.length;o+=1){var i=t.charCodeAt(o);if(i<128)r.push(i);else{var a=e[t.charAt(o)];"number"==typeof a?(255&a)==a?r.push(a):(r.push(a>>>8),r.push(255&a)):r.push(n)}}return r}};var r,e,n,o,i,a=1,u=2,f=4,c=8,g={L:1,M:0,Q:3,H:2},l=0,h=1,s=2,v=3,d=4,w=5,p=6,y=7,B=(r=[[],[6,18],[6,22],[6,26],[6,30],[6,34],[6,22,38],[6,24,42],[6,26,46],[6,28,50],[6,30,54],[6,32,58],[6,34,62],[6,26,46,66],[6,26,48,70],[6,26,50,74],[6,30,54,78],[6,30,56,82],[6,30,58,86],[6,34,62,90],[6,28,50,72,94],[6,26,50,74,98],[6,30,54,78,102],[6,28,54,80,106],[6,32,58,84,110],[6,30,58,86,114],[6,34,62,90,118],[6,26,50,74,98,122],[6,30,54,78,102,126],[6,26,52,78,104,130],[6,30,56,82,108,134],[6,34,60,86,112,138],[6,30,58,86,114,142],[6,34,62,90,118,146],[6,30,54,78,102,126,150],[6,24,50,76,102,128,154],[6,28,54,80,106,132,158],[6,32,58,84,110,136,162],[6,26,54,82,110,138,166],[6,30,58,86,114,142,170]],e=1335,n=7973,i=function(t){for(var r=0;0!=t;)r+=1,t>>>=1;return r},(o={}).getBCHTypeInfo=function(t){for(var r=t<<10;i(r)-i(e)>=0;)r^=e<<i(r)-i(e);return 21522^(t<<10|r)},o.getBCHTypeNumber=function(t){for(var r=t<<12;i(r)-i(n)>=0;)r^=n<<i(r)-i(n);return t<<12|r},o.getPatternPosition=function(t){return r[t-1]},o.getMaskFunction=function(t){switch(t){case l:return function(t,r){return(t+r)%2==0};case h:return function(t,r){return t%2==0};case s:return function(t,r){return r%3==0};case v:return function(t,r){return(t+r)%3==0};case d:return function(t,r){return(Math.floor(t/2)+Math.floor(r/3))%2==0};case w:return function(t,r){return t*r%2+t*r%3==0};case p:return function(t,r){return(t*r%2+t*r%3)%2==0};case y:return function(t,r){return(t*r%3+(t+r)%2)%2==0};default:throw"bad maskPattern:"+t}},o.getErrorCorrectPolynomial=function(t){for(var r=k([1],0),e=0;e<t;e+=1)r=r.multiply(k([1,C.gexp(e)],0));return r},o.getLengthInBits=function(t,r){if(1<=r&&r<10)switch(t){case a:return 10;case u:return 9;case f:case c:return 8;default:throw"mode:"+t}else if(r<27)switch(t){case a:return 12;case u:return 11;case f:return 16;case c:return 10;default:throw"mode:"+t}else{if(!(r<41))throw"type:"+r;switch(t){case a:return 14;case u:return 13;case f:return 16;case c:return 12;default:throw"mode:"+t}}},o.getLostPoint=function(t){for(var r=t.getModuleCount(),e=0,n=0;n<r;n+=1)for(var o=0;o<r;o+=1){for(var i=0,a=t.isDark(n,o),u=-1;u<=1;u+=1)if(!(n+u<0||r<=n+u))for(var f=-1;f<=1;f+=1)o+f<0||r<=o+f||0==u&&0==f||a==t.isDark(n+u,o+f)&&(i+=1);i>5&&(e+=3+i-5)}for(n=0;n<r-1;n+=1)for(o=0;o<r-1;o+=1){var c=0;t.isDark(n,o)&&(c+=1),t.isDark(n+1,o)&&(c+=1),t.isDark(n,o+1)&&(c+=1),t.isDark(n+1,o+1)&&(c+=1),0!=c&&4!=c||(e+=3)}for(n=0;n<r;n+=1)for(o=0;o<r-6;o+=1)t.isDark(n,o)&&!t.isDark(n,o+1)&&t.isDark(n,o+2)&&t.isDark(n,o+3)&&t.isDark(n,o+4)&&!t.isDark(n,o+5)&&t.isDark(n,o+6)&&(e+=40);for(o=0;o<r;o+=1)for(n=0;n<r-6;n+=1)t.isDark(n,o)&&!t.isDark(n+1,o)&&t.isDark(n+2,o)&&t.isDark(n+3,o)&&t.isDark(n+4,o)&&!t.isDark(n+5,o)&&t.isDark(n+6,o)&&(e+=40);var g=0;for(o=0;o<r;o+=1)for(n=0;n<r;n+=1)t.isDark(n,o)&&(g+=1);return e+=Math.abs(100*g/r/r-50)/5*10},o),C=function(){for(var t=new Array(256),r=new Array(256),e=0;e<8;e+=1)t[e]=1<<e;for(e=8;e<256;e+=1)t[e]=t[e-4]^t[e-5]^t[e-6]^t[e-8];for(e=0;e<255;e+=1)r[t[e]]=e;var n={glog:function(t){if(t<1)throw"glog("+t+")";return r[t]},gexp:function(r){for(;r<0;)r+=255;for(;r>=256;)r-=255;return t[r]}};return n}();function k(t,r){if(void 0===t.length)throw t.length+"/"+r;var e=function(){for(var e=0;e<t.length&&0==t[e];)e+=1;for(var n=new Array(t.length-e+r),o=0;o<t.length-e;o+=1)n[o]=t[o+e];return n}(),n={getAt:function(t){return e[t]},getLength:function(){return e.length},multiply:function(t){for(var r=new Array(n.getLength()+t.getLength()-1),e=0;e<n.getLength();e+=1)for(var o=0;o<t.getLength();o+=1)r[e+o]^=C.gexp(C.glog(n.getAt(e))+C.glog(t.getAt(o)));return k(r,0)},mod:function(t){if(n.getLength()-t.getLength()<0)return n;for(var r=C.glog(n.getAt(0))-C.glog(t.getAt(0)),e=new Array(n.getLength()),o=0;o<n.getLength();o+=1)e[o]=n.getAt(o);for(o=0;o<t.getLength();o+=1)e[o]^=C.gexp(C.glog(t.getAt(o))+r);return k(e,0).mod(t)}};return n}var A=function(){var t=[[1,26,19],[1,26,16],[1,26,13],[1,26,9],[1,44,34],[1,44,28],[1,44,22],[1,44,16],[1,70,55],[1,70,44],[2,35,17],[2,35,13],[1,100,80],[2,50,32],[2,50,24],[4,25,9],[1,134,108],[2,67,43],[2,33,15,2,34,16],[2,33,11,2,34,12],[2,86,68],[4,43,27],[4,43,19],[4,43,15],[2,98,78],[4,49,31],[2,32,14,4,33,15],[4,39,13,1,40,14],[2,121,97],[2,60,38,2,61,39],[4,40,18,2,41,19],[4,40,14,2,41,15],[2,146,116],[3,58,36,2,59,37],[4,36,16,4,37,17],[4,36,12,4,37,13],[2,86,68,2,87,69],[4,69,43,1,70,44],[6,43,19,2,44,20],[6,43,15,2,44,16],[4,101,81],[1,80,50,4,81,51],[4,50,22,4,51,23],[3,36,12,8,37,13],[2,116,92,2,117,93],[6,58,36,2,59,37],[4,46,20,6,47,21],[7,42,14,4,43,15],[4,133,107],[8,59,37,1,60,38],[8,44,20,4,45,21],[12,33,11,4,34,12],[3,145,115,1,146,116],[4,64,40,5,65,41],[11,36,16,5,37,17],[11,36,12,5,37,13],[5,109,87,1,110,88],[5,65,41,5,66,42],[5,54,24,7,55,25],[11,36,12,7,37,13],[5,122,98,1,123,99],[7,73,45,3,74,46],[15,43,19,2,44,20],[3,45,15,13,46,16],[1,135,107,5,136,108],[10,74,46,1,75,47],[1,50,22,15,51,23],[2,42,14,17,43,15],[5,150,120,1,151,121],[9,69,43,4,70,44],[17,50,22,1,51,23],[2,42,14,19,43,15],[3,141,113,4,142,114],[3,70,44,11,71,45],[17,47,21,4,48,22],[9,39,13,16,40,14],[3,135,107,5,136,108],[3,67,41,13,68,42],[15,54,24,5,55,25],[15,43,15,10,44,16],[4,144,116,4,145,117],[17,68,42],[17,50,22,6,51,23],[19,46,16,6,47,17],[2,139,111,7,140,112],[17,74,46],[7,54,24,16,55,25],[34,37,13],[4,151,121,5,152,122],[4,75,47,14,76,48],[11,54,24,14,55,25],[16,45,15,14,46,16],[6,147,117,4,148,118],[6,73,45,14,74,46],[11,54,24,16,55,25],[30,46,16,2,47,17],[8,132,106,4,133,107],[8,75,47,13,76,48],[7,54,24,22,55,25],[22,45,15,13,46,16],[10,142,114,2,143,115],[19,74,46,4,75,47],[28,50,22,6,51,23],[33,46,16,4,47,17],[8,152,122,4,153,123],[22,73,45,3,74,46],[8,53,23,26,54,24],[12,45,15,28,46,16],[3,147,117,10,148,118],[3,73,45,23,74,46],[4,54,24,31,55,25],[11,45,15,31,46,16],[7,146,116,7,147,117],[21,73,45,7,74,46],[1,53,23,37,54,24],[19,45,15,26,46,16],[5,145,115,10,146,116],[19,75,47,10,76,48],[15,54,24,25,55,25],[23,45,15,25,46,16],[13,145,115,3,146,116],[2,74,46,29,75,47],[42,54,24,1,55,25],[23,45,15,28,46,16],[17,145,115],[10,74,46,23,75,47],[10,54,24,35,55,25],[19,45,15,35,46,16],[17,145,115,1,146,116],[14,74,46,21,75,47],[29,54,24,19,55,25],[11,45,15,46,46,16],[13,145,115,6,146,116],[14,74,46,23,75,47],[44,54,24,7,55,25],[59,46,16,1,47,17],[12,151,121,7,152,122],[12,75,47,26,76,48],[39,54,24,14,55,25],[22,45,15,41,46,16],[6,151,121,14,152,122],[6,75,47,34,76,48],[46,54,24,10,55,25],[2,45,15,64,46,16],[17,152,122,4,153,123],[29,74,46,14,75,47],[49,54,24,10,55,25],[24,45,15,46,46,16],[4,152,122,18,153,123],[13,74,46,32,75,47],[48,54,24,14,55,25],[42,45,15,32,46,16],[20,147,117,4,148,118],[40,75,47,7,76,48],[43,54,24,22,55,25],[10,45,15,67,46,16],[19,148,118,6,149,119],[18,75,47,31,76,48],[34,54,24,34,55,25],[20,45,15,61,46,16]],r=function(t,r){var e={};return e.totalCount=t,e.dataCount=r,e},e={};return e.getRSBlocks=function(e,n){var o=function(r,e){switch(e){case g.L:return t[4*(r-1)+0];case g.M:return t[4*(r-1)+1];case g.Q:return t[4*(r-1)+2];case g.H:return t[4*(r-1)+3];default:return}}(e,n);if(void 0===o)throw"bad rs block @ typeNumber:"+e+"/errorCorrectionLevel:"+n;for(var i=o.length/3,a=[],u=0;u<i;u+=1)for(var f=o[3*u+0],c=o[3*u+1],l=o[3*u+2],h=0;h<f;h+=1)a.push(r(c,l));return a},e}(),b=function(){var t=[],r=0,e={getBuffer:function(){return t},getAt:function(r){var e=Math.floor(r/8);return 1==(t[e]>>>7-r%8&1)},put:function(t,r){for(var n=0;n<r;n+=1)e.putBit(1==(t>>>r-n-1&1))},getLengthInBits:function(){return r},putBit:function(e){var n=Math.floor(r/8);t.length<=n&&t.push(0),e&&(t[n]|=128>>>r%8),r+=1}};return e},M=function(t){var r=a,e=t,n={getMode:function(){return r},getLength:function(t){return e.length},write:function(t){for(var r=e,n=0;n+2<r.length;)t.put(o(r.substring(n,n+3)),10),n+=3;n<r.length&&(r.length-n==1?t.put(o(r.substring(n,n+1)),4):r.length-n==2&&t.put(o(r.substring(n,n+2)),7))}},o=function(t){for(var r=0,e=0;e<t.length;e+=1)r=10*r+i(t.charAt(e));return r},i=function(t){if("0"<=t&&t<="9")return t.charCodeAt(0)-"0".charCodeAt(0);throw"illegal char :"+t};return n},x=function(t){var r=u,e=t,n={getMode:function(){return r},getLength:function(t){return e.length},write:function(t){for(var r=e,n=0;n+1<r.length;)t.put(45*o(r.charAt(n))+o(r.charAt(n+1)),11),n+=2;n<r.length&&t.put(o(r.charAt(n)),6)}},o=function(t){if("0"<=t&&t<="9")return t.charCodeAt(0)-"0".charCodeAt(0);if("A"<=t&&t<="Z")return t.charCodeAt(0)-"A".charCodeAt(0)+10;switch(t){case" ":return 36;case"$":return 37;case"%":return 38;case"*":return 39;case"+":return 40;case"-":return 41;case".":return 42;case"/":return 43;case":":return 44;default:throw"illegal char :"+t}};return n},m=function(r){var e=f,n=t.stringToBytes(r),o={getMode:function(){return e},getLength:function(t){return n.length},write:function(t){for(var r=0;r<n.length;r+=1)t.put(n[r],8)}};return o},L=function(r){var e=c,n=t.stringToBytesFuncs.SJIS;if(!n)throw"sjis not supported.";!function(){var t=n("友");if(2!=t.length||38726!=(t[0]<<8|t[1]))throw"sjis not supported."}();var o=n(r),i={getMode:function(){return e},getLength:function(t){return~~(o.length/2)},write:function(t){for(var r=o,e=0;e+1<r.length;){var n=(255&r[e])<<8|255&r[e+1];if(33088<=n&&n<=40956)n-=33088;else{if(!(57408<=n&&n<=60351))throw"illegal char at "+(e+1)+"/"+n;n-=49472}n=192*(n>>>8&255)+(255&n),t.put(n,13),e+=2}if(e<r.length)throw"illegal char at "+(e+1)}};return i},D=function(){var t=[],r={writeByte:function(r){t.push(255&r)},writeShort:function(t){r.writeByte(t),r.writeByte(t>>>8)},writeBytes:function(t,e,n){e=e||0,n=n||t.length;for(var o=0;o<n;o+=1)r.writeByte(t[o+e])},writeString:function(t){for(var e=0;e<t.length;e+=1)r.writeByte(t.charCodeAt(e))},toByteArray:function(){return t},toString:function(){var r="";r+="[";for(var e=0;e<t.length;e+=1)e>0&&(r+=","),r+=t[e];return r+="]"}};return r},S=function(t){var r=t,e=0,n=0,o=0,i={read:function(){for(;o<8;){if(e>=r.length){if(0==o)return-1;throw"unexpected end of file./"+o}var t=r.charAt(e);if(e+=1,"="==t)return o=0,-1;t.match(/^\s$/)||(n=n<<6|a(t.charCodeAt(0)),o+=6)}var i=n>>>o-8&255;return o-=8,i}},a=function(t){if(65<=t&&t<=90)return t-65;if(97<=t&&t<=122)return t-97+26;if(48<=t&&t<=57)return t-48+52;if(43==t)return 62;if(47==t)return 63;throw"c:"+t};return i},I=function(t,r,e){for(var n=function(t,r){var e=t,n=r,o=new Array(t*r),i={setPixel:function(t,r,n){o[r*e+t]=n},write:function(t){t.writeString("GIF87a"),t.writeShort(e),t.writeShort(n),t.writeByte(128),t.writeByte(0),t.writeByte(0),t.writeByte(0),t.writeByte(0),t.writeByte(0),t.writeByte(255),t.writeByte(255),t.writeByte(255),t.writeString(","),t.writeShort(0),t.writeShort(0),t.writeShort(e),t.writeShort(n),t.writeByte(0);var r=a(2);t.writeByte(2);for(var o=0;r.length-o>255;)t.writeByte(255),t.writeBytes(r,o,255),o+=255;t.writeByte(r.length-o),t.writeBytes(r,o,r.length-o),t.writeByte(0),t.writeString(";")}},a=function(t){for(var r=1<<t,e=1+(1<<t),n=t+1,i=u(),a=0;a<r;a+=1)i.add(String.fromCharCode(a));i.add(String.fromCharCode(r)),i.add(String.fromCharCode(e));var f,c,g,l=D(),h=(f=l,c=0,g=0,{write:function(t,r){if(t>>>r!=0)throw"length over";for(;c+r>=8;)f.writeByte(255&(t<<c|g)),r-=8-c,t>>>=8-c,g=0,c=0;g|=t<<c,c+=r},flush:function(){c>0&&f.writeByte(g)}});h.write(r,n);var s=0,v=String.fromCharCode(o[s]);for(s+=1;s<o.length;){var d=String.fromCharCode(o[s]);s+=1,i.contains(v+d)?v+=d:(h.write(i.indexOf(v),n),i.size()<4095&&(i.size()==1<<n&&(n+=1),i.add(v+d)),v=d)}return h.write(i.indexOf(v),n),h.write(e,n),h.flush(),l.toByteArray()},u=function(){var t={},r=0,e={add:function(n){if(e.contains(n))throw"dup key:"+n;t[n]=r,r+=1},size:function(){return r},indexOf:function(r){return t[r]},contains:function(r){return void 0!==t[r]}};return e};return i}(t,r),o=0;o<r;o+=1)for(var i=0;i<t;i+=1)n.setPixel(i,o,e(i,o));var a=D();n.write(a);for(var u=function(){var t=0,r=0,e=0,n="",o={},i=function(t){n+=String.fromCharCode(a(63&t))},a=function(t){if(t<0);else{if(t<26)return 65+t;if(t<52)return t-26+97;if(t<62)return t-52+48;if(62==t)return 43;if(63==t)return 47}throw"n:"+t};return o.writeByte=function(n){for(t=t<<8|255&n,r+=8,e+=1;r>=6;)i(t>>>r-6),r-=6},o.flush=function(){if(r>0&&(i(t<<6-r),t=0,r=0),e%3!=0)for(var o=3-e%3,a=0;a<o;a+=1)n+="="},o.toString=function(){return n},o}(),f=a.toByteArray(),c=0;c<f.length;c+=1)u.writeByte(f[c]);return u.flush(),"data:image/gif;base64,"+u};return t}();qrcode.stringToBytesFuncs["UTF-8"]=function(t){return function(t){for(var r=[],e=0;e<t.length;e++){var n=t.charCodeAt(e);n<128?r.push(n):n<2048?r.push(192|n>>6,128|63&n):n<55296||n>=57344?r.push(224|n>>12,128|n>>6&63,128|63&n):(e++,n=65536+((1023&n)<<10|1023&t.charCodeAt(e)),r.push(240|n>>18,128|n>>12&63,128|n>>6&63,128|63&n))}return r}(t)},function(t){"function"==typeof define&&define.amd?define([],t):"object"==typeof exports&&(module.exports=t())}((function(){return qrcode}));
|
||
//# sourceMappingURL=/sm/26b4b0d0b1e283d6b3ec9857ac597d7a60c76ac17be1ef4c965f03086de426bb.map
|
||
</script>
|
||
|
||
<script>
|
||
const $ = (id) => document.getElementById(id);
|
||
const KEYS = {
|
||
url: "sc5_dash_url",
|
||
token: "sc5_dash_token",
|
||
refresh: "sc5_dash_refresh",
|
||
layoutOverview: "sc5_layout_overview",
|
||
layoutAdmin: "sc5_layout_admin",
|
||
layoutWide: "sc5_layout_wide",
|
||
};
|
||
let timer = null;
|
||
let tokenVisible = false;
|
||
let lastShare = "";
|
||
let scopes = new Set();
|
||
let isAdmin = false;
|
||
const state = { features: {}, adminLoaded: false };
|
||
|
||
function defaultServerUrl() {
|
||
try { if (location.protocol.startsWith("http")) return location.origin.replace(/\/$/, ""); } catch (_) {}
|
||
return "";
|
||
}
|
||
function b64urlEncode(obj) {
|
||
const b64 = btoa(unescape(encodeURIComponent(JSON.stringify(obj))));
|
||
return b64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
|
||
}
|
||
function b64urlDecode(s) {
|
||
let b64 = s.replace(/-/g, "+").replace(/_/g, "/");
|
||
while (b64.length % 4) b64 += "=";
|
||
return JSON.parse(decodeURIComponent(escape(atob(b64))));
|
||
}
|
||
function showError(msg) {
|
||
const el = $("error");
|
||
if (!msg) { el.classList.remove("show"); el.textContent = ""; return; }
|
||
el.textContent = msg; el.classList.add("show");
|
||
}
|
||
function showOk(msg) {
|
||
const el = $("okmsg");
|
||
if (!msg) { el.classList.remove("show"); el.textContent = ""; return; }
|
||
el.textContent = msg; el.classList.add("show");
|
||
setTimeout(() => el.classList.remove("show"), 3500);
|
||
}
|
||
function showOutput(text, meta) {
|
||
const box = $("shellOut");
|
||
if (!box) return;
|
||
const body = (text == null || text === "") ? "(no output)" : String(text);
|
||
box.textContent = meta ? meta + "\n\n" + body : body;
|
||
box.classList.toggle("empty-out", body === "(no output)");
|
||
try { box.scrollIntoView({ behavior: "smooth", block: "nearest" }); } catch (_) {}
|
||
}
|
||
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)),
|
||
};
|
||
}
|
||
/** Prefer same-origin when the page is served from this host (/ops) — avoids CORS breakage. */
|
||
function apiBase() {
|
||
try {
|
||
if (location.protocol.startsWith("http") && location.pathname.indexOf("/ops") === 0) {
|
||
return location.origin.replace(/\/$/, "");
|
||
}
|
||
} catch (_) {}
|
||
const u = cfg().url;
|
||
return (u || "").replace(/\/$/, "");
|
||
}
|
||
function saveSettings() {
|
||
let url = $("url").value.trim().replace(/\/$/, "");
|
||
// If user left URL blank while on /ops, default to this host
|
||
if (!url) url = defaultServerUrl();
|
||
// If on /ops, force same-origin base so connect always works
|
||
try {
|
||
if (location.pathname.indexOf("/ops") === 0 && location.protocol.startsWith("http")) {
|
||
url = location.origin.replace(/\/$/, "");
|
||
$("url").value = url;
|
||
}
|
||
} catch (_) {}
|
||
localStorage.setItem(KEYS.url, url);
|
||
localStorage.setItem(KEYS.token, $("token").value.trim());
|
||
localStorage.setItem(KEYS.refresh, String(Math.max(5, Number($("refresh").value) || 10)));
|
||
}
|
||
function applyHashConfig() {
|
||
const hash = (location.hash || "").replace(/^#/, "");
|
||
if (!hash.startsWith("sc5=") && !hash.startsWith("ss2=") && !hash.startsWith("cfg=")) return false;
|
||
try {
|
||
const payload = b64urlDecode(hash.split("=")[1]);
|
||
if (!payload.token) return false;
|
||
if (payload.url) localStorage.setItem(KEYS.url, String(payload.url).replace(/\/$/, ""));
|
||
localStorage.setItem(KEYS.token, payload.token);
|
||
if (payload.refresh) localStorage.setItem(KEYS.refresh, String(payload.refresh));
|
||
history.replaceState(null, "", location.pathname + location.search);
|
||
return true;
|
||
} catch (_) { return false; }
|
||
}
|
||
function loadSettings() {
|
||
const imported = applyHashConfig();
|
||
let url = localStorage.getItem(KEYS.url) || defaultServerUrl();
|
||
// Always prefer the host serving this page when opened via /ops
|
||
try {
|
||
if (location.pathname.indexOf("/ops") === 0 && location.protocol.startsWith("http")) {
|
||
url = location.origin.replace(/\/$/, "");
|
||
localStorage.setItem(KEYS.url, url);
|
||
}
|
||
} catch (_) {}
|
||
$("url").value = url || "";
|
||
$("token").value = localStorage.getItem(KEYS.token) || "";
|
||
$("refresh").value = localStorage.getItem(KEYS.refresh) || "10";
|
||
// Mobile: collapse panels; open Connection only if no token yet.
|
||
// Desktop: expand all details.panel by default.
|
||
applyPanelDefaults();
|
||
return imported;
|
||
}
|
||
function isDesktopLayout() {
|
||
try {
|
||
return window.matchMedia && window.matchMedia("(min-width: 768px)").matches;
|
||
} catch (_) {
|
||
return false;
|
||
}
|
||
}
|
||
function applyPanelDefaults() {
|
||
const desktop = isDesktopLayout();
|
||
const hasToken = !!(($("token") && $("token").value) || "").trim();
|
||
// Every container is a details.panel — expand on desktop, collapse on mobile
|
||
document.querySelectorAll("details.panel").forEach((el) => {
|
||
if (el.id === "settingsPanel") return;
|
||
el.open = desktop;
|
||
});
|
||
// Connection stays collapsed when already configured
|
||
$("settingsPanel").open = !hasToken;
|
||
scheduleMasonry();
|
||
}
|
||
|
||
let masonryTimer = null;
|
||
function isFullWidthPanel(el) {
|
||
return !!(el && (el.classList.contains("is-wide") || el.classList.contains("masonry-span")));
|
||
}
|
||
function masonryCols() {
|
||
const w = window.innerWidth || document.documentElement.clientWidth || 0;
|
||
if (w >= 1100) return 3;
|
||
if (w >= 768) return 2;
|
||
return 1;
|
||
}
|
||
function masonryGap() {
|
||
return (window.innerWidth || 0) >= 1100 ? 18 : 14;
|
||
}
|
||
function clearMasonry(container) {
|
||
if (!container) return;
|
||
container.classList.remove("is-packed");
|
||
container.style.height = "";
|
||
Array.from(container.children).forEach((el) => {
|
||
el.style.position = "";
|
||
el.style.left = "";
|
||
el.style.top = "";
|
||
el.style.width = "";
|
||
el.style.transform = "";
|
||
el.style.height = "";
|
||
el.style.maxHeight = "";
|
||
el.style.overflow = "";
|
||
const body = el.querySelector && el.querySelector(":scope > .panel-body");
|
||
if (body) {
|
||
body.style.height = "";
|
||
body.style.maxHeight = "";
|
||
body.style.overflowY = "";
|
||
body.style.overflowX = "";
|
||
}
|
||
});
|
||
}
|
||
function tileHeightPx(el) {
|
||
if (!el || !el.open) return 0;
|
||
if (isAutoHeightPanel(el)) return 0;
|
||
const cs = getComputedStyle(document.documentElement);
|
||
const raw = isFullWidthPanel(el)
|
||
? (cs.getPropertyValue("--panel-tile-h-wide") || "400px")
|
||
: (cs.getPropertyValue("--panel-tile-h") || "340px");
|
||
const n = parseFloat(raw);
|
||
return Number.isFinite(n) ? n : (isFullWidthPanel(el) ? 400 : 340);
|
||
}
|
||
function isAutoHeightPanel(el) {
|
||
return !!(el && (
|
||
el.id === "heroPanel" ||
|
||
el.id === "settingsPanel" ||
|
||
el.classList.contains("hero")
|
||
));
|
||
}
|
||
function applyTileScroll(el) {
|
||
const body = el && el.querySelector(":scope > .panel-body");
|
||
if (!body) return;
|
||
if (!el.open) {
|
||
body.style.height = "";
|
||
body.style.maxHeight = "";
|
||
body.style.overflowY = "";
|
||
body.classList.remove("scroll-focus", "is-scrollable");
|
||
el.style.height = "";
|
||
el.style.maxHeight = "";
|
||
el.style.overflow = "";
|
||
return;
|
||
}
|
||
// Status overview: natural height, never a fixed scroll tile
|
||
if (isAutoHeightPanel(el)) {
|
||
el.style.height = "auto";
|
||
el.style.maxHeight = "none";
|
||
el.style.overflow = "visible";
|
||
body.style.height = "auto";
|
||
body.style.maxHeight = "none";
|
||
body.style.overflowY = "visible";
|
||
body.style.overflowX = "visible";
|
||
body.classList.remove("scroll-focus", "is-scrollable");
|
||
return;
|
||
}
|
||
const tileH = tileHeightPx(el);
|
||
el.style.height = tileH + "px";
|
||
el.style.maxHeight = tileH + "px";
|
||
el.style.overflow = "hidden";
|
||
// Reserve summary + padding so body is the only scrollport
|
||
const sum = el.querySelector(":scope > summary");
|
||
const sumH = sum ? sum.getBoundingClientRect().height : 40;
|
||
const pad = 22; // vertical padding inside details
|
||
const bodyH = Math.max(80, tileH - sumH - pad);
|
||
body.style.height = bodyH + "px";
|
||
body.style.maxHeight = bodyH + "px";
|
||
body.style.overflowY = "auto";
|
||
body.style.overflowX = "hidden";
|
||
// Mark whether this body can scroll (for wheel policy)
|
||
requestAnimationFrame(() => {
|
||
const canScroll = body.scrollHeight > body.clientHeight + 1;
|
||
body.classList.toggle("is-scrollable", canScroll);
|
||
if (!canScroll) body.classList.remove("scroll-focus");
|
||
});
|
||
}
|
||
|
||
// Wheel: page scrolls freely unless a scrollable panel-body was clicked (focused).
|
||
let scrollFocusBody = null;
|
||
function clearScrollFocus() {
|
||
document.querySelectorAll(".panel-body.scroll-focus").forEach((el) => {
|
||
el.classList.remove("scroll-focus");
|
||
});
|
||
scrollFocusBody = null;
|
||
}
|
||
function bodyCanScroll(el) {
|
||
return !!(el && el.scrollHeight > el.clientHeight + 1);
|
||
}
|
||
document.addEventListener("pointerdown", (e) => {
|
||
const body = e.target.closest && e.target.closest(".panel-body");
|
||
if (body && bodyCanScroll(body)) {
|
||
if (scrollFocusBody && scrollFocusBody !== body) {
|
||
scrollFocusBody.classList.remove("scroll-focus");
|
||
}
|
||
scrollFocusBody = body;
|
||
body.classList.add("scroll-focus");
|
||
body.classList.add("is-scrollable");
|
||
} else if (!e.target.closest || !e.target.closest(".panel-body")) {
|
||
// Click outside any panel body → release focus so page can scroll
|
||
clearScrollFocus();
|
||
} else {
|
||
// Clicked a non-scrollable panel body — do not trap
|
||
clearScrollFocus();
|
||
}
|
||
}, true);
|
||
document.addEventListener(
|
||
"wheel",
|
||
(e) => {
|
||
const body = e.target.closest && e.target.closest(".panel-body");
|
||
// Default: never stop page scroll
|
||
if (!body) return;
|
||
// Only the focused, actually-scrollable body may consume wheel
|
||
if (body !== scrollFocusBody || !bodyCanScroll(body)) return;
|
||
const dy = e.deltaY;
|
||
if (dy === 0) return;
|
||
const max = body.scrollHeight - body.clientHeight;
|
||
const top = body.scrollTop;
|
||
const atTop = top <= 0;
|
||
const atBottom = top >= max - 1;
|
||
// Scroll the body ourselves; prevent page only while this focused body can move
|
||
if ((dy < 0 && !atTop) || (dy > 0 && !atBottom)) {
|
||
e.preventDefault();
|
||
body.scrollTop = Math.min(max, Math.max(0, top + dy));
|
||
}
|
||
// At edges: let the event bubble so the page can scroll
|
||
},
|
||
{ passive: false, capture: true }
|
||
);
|
||
function packMasonry(container) {
|
||
if (!container) return;
|
||
const cols = masonryCols();
|
||
const gap = masonryGap();
|
||
if (cols <= 1) {
|
||
clearMasonry(container);
|
||
// still enforce tile scroll on single column
|
||
Array.from(container.children).forEach((el) => {
|
||
if (el.matches && el.matches("details.panel")) applyTileScroll(el);
|
||
});
|
||
return;
|
||
}
|
||
const items = Array.from(container.children).filter((el) => el.nodeType === 1 && !el.classList.contains("hidden"));
|
||
if (!items.length) {
|
||
clearMasonry(container);
|
||
return;
|
||
}
|
||
const width = container.clientWidth;
|
||
if (width < 40) return;
|
||
const colW = (width - gap * (cols - 1)) / cols;
|
||
container.classList.add("is-packed");
|
||
|
||
// Size tiles first (equal height + scrollport), then pack.
|
||
items.forEach((el) => {
|
||
const full = isFullWidthPanel(el);
|
||
el.style.position = "absolute";
|
||
el.style.left = "0px";
|
||
el.style.top = "0px";
|
||
el.style.width = (full ? width : colW) + "px";
|
||
if (el.matches && el.matches("details.panel")) applyTileScroll(el);
|
||
else el.style.height = "";
|
||
});
|
||
void container.offsetHeight;
|
||
|
||
// Pack in DOM order. Full-width tiles force a hard row break:
|
||
// all columns advance past that tile so nothing sits beside it.
|
||
const heights = new Array(cols).fill(0);
|
||
items.forEach((el) => {
|
||
const full = isFullWidthPanel(el);
|
||
const h = el.open ? tileHeightPx(el) : Math.max(el.offsetHeight, 44);
|
||
if (full) {
|
||
const y = Math.max.apply(null, heights);
|
||
el.style.left = "0px";
|
||
el.style.top = y + "px";
|
||
el.style.width = width + "px";
|
||
const next = y + h + gap;
|
||
for (let i = 0; i < cols; i++) heights[i] = next;
|
||
return;
|
||
}
|
||
let col = 0;
|
||
for (let i = 1; i < cols; i++) if (heights[i] < heights[col]) col = i;
|
||
const y = heights[col];
|
||
el.style.left = (col * (colW + gap)) + "px";
|
||
el.style.top = y + "px";
|
||
el.style.width = colW + "px";
|
||
heights[col] = y + h + gap;
|
||
});
|
||
|
||
container.style.height = Math.max.apply(null, heights) + "px";
|
||
}
|
||
function layoutMasonry() {
|
||
// Non-masonry panels (connection / status / events) still need scrollports
|
||
document.querySelectorAll("details.panel").forEach((el) => {
|
||
const inMasonry = el.closest(".masonry");
|
||
if (!inMasonry) applyTileScroll(el);
|
||
});
|
||
packMasonry(document.querySelector(".overview"));
|
||
packMasonry($("adminMount"));
|
||
}
|
||
function scheduleMasonry() {
|
||
if (masonryTimer) clearTimeout(masonryTimer);
|
||
masonryTimer = setTimeout(() => {
|
||
requestAnimationFrame(() => {
|
||
layoutMasonry();
|
||
// second pass after fonts/details open reflow
|
||
requestAnimationFrame(layoutMasonry);
|
||
});
|
||
}, 40);
|
||
}
|
||
window.addEventListener("resize", scheduleMasonry);
|
||
document.addEventListener("toggle", (e) => {
|
||
if (e.target && e.target.matches && e.target.matches("details.panel")) scheduleMasonry();
|
||
}, true);
|
||
|
||
// ----- Card reorder (browser localStorage only — never sent to C2) -----
|
||
function loadLayoutOrder(key) {
|
||
try {
|
||
const raw = localStorage.getItem(key);
|
||
if (!raw) return null;
|
||
const arr = JSON.parse(raw);
|
||
return Array.isArray(arr) ? arr.filter((x) => typeof x === "string") : null;
|
||
} catch (_) {
|
||
return null;
|
||
}
|
||
}
|
||
function saveLayoutOrder(container, key) {
|
||
if (!container || !key) return;
|
||
const ids = Array.from(container.children)
|
||
.map((el) => el.id)
|
||
.filter((id) => id);
|
||
try {
|
||
localStorage.setItem(key, JSON.stringify(ids));
|
||
} catch (_) {}
|
||
}
|
||
function applyLayoutOrder(container, key) {
|
||
if (!container || !key) return;
|
||
const order = loadLayoutOrder(key);
|
||
if (!order || !order.length) return;
|
||
const byId = new Map();
|
||
Array.from(container.children).forEach((el) => {
|
||
if (el.id) byId.set(el.id, el);
|
||
});
|
||
order.forEach((id) => {
|
||
const el = byId.get(id);
|
||
if (el) {
|
||
container.appendChild(el);
|
||
byId.delete(id);
|
||
}
|
||
});
|
||
byId.forEach((el) => container.appendChild(el));
|
||
}
|
||
function loadWideIds() {
|
||
try {
|
||
const raw = localStorage.getItem(KEYS.layoutWide);
|
||
if (!raw) return new Set();
|
||
const arr = JSON.parse(raw);
|
||
return new Set(Array.isArray(arr) ? arr.filter((x) => typeof x === "string") : []);
|
||
} catch (_) {
|
||
return new Set();
|
||
}
|
||
}
|
||
function saveWideIds(set) {
|
||
try {
|
||
localStorage.setItem(KEYS.layoutWide, JSON.stringify(Array.from(set)));
|
||
} catch (_) {}
|
||
}
|
||
function applyWideState(root) {
|
||
const wide = loadWideIds();
|
||
(root || document).querySelectorAll("details.panel[id]").forEach((el) => {
|
||
el.classList.toggle("is-wide", wide.has(el.id));
|
||
const btn = el.querySelector(":scope > summary .wide-btn");
|
||
if (btn) {
|
||
btn.setAttribute("aria-pressed", wide.has(el.id) ? "true" : "false");
|
||
btn.title = wide.has(el.id) ? "Use normal tile width" : "Expand to full row";
|
||
}
|
||
});
|
||
}
|
||
function toggleWide(panel) {
|
||
if (!panel || !panel.id) return;
|
||
const wide = loadWideIds();
|
||
if (wide.has(panel.id)) wide.delete(panel.id);
|
||
else wide.add(panel.id);
|
||
saveWideIds(wide);
|
||
panel.classList.toggle("is-wide", wide.has(panel.id));
|
||
const btn = panel.querySelector(":scope > summary .wide-btn");
|
||
if (btn) {
|
||
btn.setAttribute("aria-pressed", wide.has(panel.id) ? "true" : "false");
|
||
btn.title = wide.has(panel.id) ? "Use normal tile width" : "Expand to full row";
|
||
}
|
||
// Full-row cards force a hard row break — repack so neighbors drop below
|
||
scheduleMasonry();
|
||
}
|
||
function ensurePanelBodies(root) {
|
||
const scope = root || document;
|
||
scope.querySelectorAll("details.panel").forEach((el) => {
|
||
if (el.querySelector(":scope > .panel-body")) return;
|
||
const body = document.createElement("div");
|
||
body.className = "panel-body";
|
||
Array.from(el.childNodes).forEach((n) => {
|
||
if (n.nodeType === 1 && n.tagName === "SUMMARY") return;
|
||
body.appendChild(n);
|
||
});
|
||
el.appendChild(body);
|
||
});
|
||
}
|
||
function ensureDragHandles(container) {
|
||
if (!container) return;
|
||
Array.from(container.children).forEach((el) => {
|
||
if (!el.matches || !el.matches("details.panel")) return;
|
||
if (!el.id) el.id = "panel_" + Math.random().toString(36).slice(2, 9);
|
||
const sum = el.querySelector(":scope > summary");
|
||
if (!sum) return;
|
||
if (!sum.querySelector(".drag-handle")) {
|
||
const h = document.createElement("span");
|
||
h.className = "drag-handle";
|
||
h.draggable = true;
|
||
h.title = "Drag to reorder (saved in this browser only)";
|
||
h.textContent = "⋮⋮";
|
||
h.addEventListener("click", (e) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
});
|
||
sum.insertBefore(h, sum.firstChild);
|
||
}
|
||
if (!sum.querySelector(".panel-title")) {
|
||
const title = document.createElement("span");
|
||
title.className = "panel-title";
|
||
// move existing text nodes / non-control children into title
|
||
const nodes = Array.from(sum.childNodes).filter((n) => {
|
||
if (n.nodeType === 1 && (n.classList.contains("drag-handle") || n.classList.contains("wide-btn") || n.classList.contains("event-stream-head"))) return false;
|
||
return true;
|
||
});
|
||
if (nodes.length) {
|
||
nodes.forEach((n) => title.appendChild(n));
|
||
const handle = sum.querySelector(".drag-handle");
|
||
if (handle && handle.nextSibling) sum.insertBefore(title, handle.nextSibling);
|
||
else sum.appendChild(title);
|
||
}
|
||
}
|
||
if (!sum.querySelector(".wide-btn")) {
|
||
const b = document.createElement("button");
|
||
b.type = "button";
|
||
b.className = "wide-btn";
|
||
b.textContent = "⟷";
|
||
b.title = "Expand to full row";
|
||
b.addEventListener("click", (e) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
toggleWide(el);
|
||
});
|
||
sum.appendChild(b);
|
||
}
|
||
});
|
||
}
|
||
function ensurePanelChrome(root) {
|
||
ensurePanelBodies(root);
|
||
const overview = document.querySelector(".overview");
|
||
const admin = $("adminMount");
|
||
ensureDragHandles(overview);
|
||
ensureDragHandles(admin);
|
||
// wide buttons on non-masonry panels too (connection/hero/events)
|
||
document.querySelectorAll("details.panel").forEach((el) => {
|
||
if (overview && overview.contains(el)) return;
|
||
if (admin && admin.contains(el)) return;
|
||
if (!el.id) return;
|
||
const sum = el.querySelector(":scope > summary");
|
||
if (!sum || sum.querySelector(".wide-btn")) return;
|
||
if (!sum.querySelector(".panel-title") && !sum.querySelector(".event-stream-head")) {
|
||
const title = document.createElement("span");
|
||
title.className = "panel-title";
|
||
while (sum.firstChild) title.appendChild(sum.firstChild);
|
||
sum.appendChild(title);
|
||
}
|
||
const b = document.createElement("button");
|
||
b.type = "button";
|
||
b.className = "wide-btn";
|
||
b.textContent = "⟷";
|
||
b.title = "Expand to full row";
|
||
b.addEventListener("click", (e) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
toggleWide(el);
|
||
});
|
||
sum.appendChild(b);
|
||
});
|
||
// wire wide buttons from admin HTML templates
|
||
document.querySelectorAll("details.panel > summary .wide-btn").forEach((btn) => {
|
||
if (btn.dataset.bound === "1") return;
|
||
btn.dataset.bound = "1";
|
||
btn.addEventListener("click", (e) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
const panel = btn.closest("details.panel");
|
||
toggleWide(panel);
|
||
});
|
||
});
|
||
applyWideState(root);
|
||
}
|
||
function enableCardReorder(container, storageKey) {
|
||
if (!container || container.dataset.reorderBound === "1") return;
|
||
container.dataset.reorderBound = "1";
|
||
let dragEl = null;
|
||
|
||
container.addEventListener("dragstart", (e) => {
|
||
const handle = e.target.closest(".drag-handle");
|
||
if (!handle || !container.contains(handle)) return;
|
||
const panel = handle.closest("details.panel");
|
||
if (!panel || panel.parentElement !== container) return;
|
||
dragEl = panel;
|
||
panel.classList.add("is-dragging");
|
||
try {
|
||
e.dataTransfer.effectAllowed = "move";
|
||
e.dataTransfer.setData("text/plain", panel.id || "panel");
|
||
} catch (_) {}
|
||
});
|
||
|
||
container.addEventListener("dragend", () => {
|
||
if (dragEl) dragEl.classList.remove("is-dragging");
|
||
container.querySelectorAll(".drag-over").forEach((el) => el.classList.remove("drag-over"));
|
||
dragEl = null;
|
||
saveLayoutOrder(container, storageKey);
|
||
scheduleMasonry();
|
||
});
|
||
|
||
container.addEventListener("dragover", (e) => {
|
||
if (!dragEl) return;
|
||
e.preventDefault();
|
||
try { e.dataTransfer.dropEffect = "move"; } catch (_) {}
|
||
const over = e.target.closest("details.panel");
|
||
if (!over || over === dragEl || over.parentElement !== container) return;
|
||
container.querySelectorAll(".drag-over").forEach((el) => {
|
||
if (el !== over) el.classList.remove("drag-over");
|
||
});
|
||
over.classList.add("drag-over");
|
||
const rect = over.getBoundingClientRect();
|
||
const before = e.clientY < rect.top + rect.height / 2;
|
||
if (before) container.insertBefore(dragEl, over);
|
||
else container.insertBefore(dragEl, over.nextSibling);
|
||
});
|
||
|
||
container.addEventListener("drop", (e) => {
|
||
e.preventDefault();
|
||
container.querySelectorAll(".drag-over").forEach((el) => el.classList.remove("drag-over"));
|
||
if (dragEl) {
|
||
saveLayoutOrder(container, storageKey);
|
||
scheduleMasonry();
|
||
}
|
||
});
|
||
}
|
||
function setupReorderableLayouts() {
|
||
const overview = document.querySelector(".overview");
|
||
const admin = $("adminMount");
|
||
ensurePanelChrome(document);
|
||
applyLayoutOrder(overview, KEYS.layoutOverview);
|
||
applyLayoutOrder(admin, KEYS.layoutAdmin);
|
||
enableCardReorder(overview, KEYS.layoutOverview);
|
||
enableCardReorder(admin, KEYS.layoutAdmin);
|
||
scheduleMasonry();
|
||
}
|
||
window.__SC5_setupReorder = setupReorderableLayouts;
|
||
|
||
function fmtUptime(sec) {
|
||
sec = Math.floor(Number(sec) || 0);
|
||
const h = Math.floor(sec / 3600), m = Math.floor((sec % 3600) / 60);
|
||
if (h > 0) return `${h}h ${m}m`;
|
||
if (m > 0) return `${m}m`;
|
||
return `${sec}s`;
|
||
}
|
||
function shortId(id) {
|
||
return id && id.length > 14 ? id.slice(0, 12) + "…" : (id || "—");
|
||
}
|
||
function hostFromUrl(url) {
|
||
try { return new URL(url).host; } catch { return url || "—"; }
|
||
}
|
||
function can(scope) {
|
||
return isAdmin || scopes.has(scope) || scopes.has("admin");
|
||
}
|
||
|
||
async function api(method, path, body) {
|
||
const c = cfg();
|
||
const base = apiBase();
|
||
if (!base) throw new Error("Set Server URL (or open this page from the SquidC5 /ops URL)");
|
||
if (!c.token) throw new Error("Set API token");
|
||
const opts = {
|
||
method,
|
||
headers: { Authorization: "Bearer " + c.token, Accept: "application/json" },
|
||
mode: "cors",
|
||
credentials: "omit",
|
||
cache: "no-store",
|
||
};
|
||
if (body !== undefined) {
|
||
opts.headers["Content-Type"] = "application/json";
|
||
opts.body = JSON.stringify(body);
|
||
}
|
||
let res;
|
||
try {
|
||
res = await fetch(base + path, opts);
|
||
} catch (e) {
|
||
throw new Error(
|
||
"Failed to reach SquidC5 at " + base + path + " (" + (e.message || e) + "). " +
|
||
"Open http://YOUR_HOST:8443/ops directly (same host), check token, and hard-refresh."
|
||
);
|
||
}
|
||
if (!res.ok) {
|
||
let detail = res.statusText;
|
||
try { const j = await res.json(); detail = j.detail || JSON.stringify(j); } catch (_) {}
|
||
throw new Error(res.status + " " + detail);
|
||
}
|
||
if (res.status === 204) return null;
|
||
const ct = res.headers.get("content-type") || "";
|
||
if (ct.includes("application/json")) return res.json();
|
||
return res.text();
|
||
}
|
||
|
||
/** Load ops console after auth — panels self-gate by token scopes (not in public HTML). */
|
||
async function ensureConsoleUi() {
|
||
if (state.adminLoaded) return true;
|
||
if (!cfg().token) {
|
||
$("adminMount").innerHTML = "";
|
||
delete $("adminMount").dataset.reorderBound;
|
||
return false;
|
||
}
|
||
try {
|
||
const c = cfg();
|
||
const res = await fetch(apiBase() + "/api/v1/ops/console.js", {
|
||
headers: { Authorization: "Bearer " + c.token, Accept: "application/javascript" },
|
||
mode: "cors",
|
||
credentials: "omit",
|
||
cache: "no-store",
|
||
});
|
||
if (res.status === 403 || res.status === 401) {
|
||
$("adminMount").innerHTML = "";
|
||
delete $("adminMount").dataset.reorderBound;
|
||
return false;
|
||
}
|
||
if (!res.ok) {
|
||
showError("Ops console load failed (" + res.status + ") — status view still works");
|
||
return false;
|
||
}
|
||
const code = await res.text();
|
||
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;
|
||
const s = document.createElement("script");
|
||
s.textContent = code;
|
||
document.body.appendChild(s);
|
||
state.adminLoaded = true;
|
||
applyPanelDefaults();
|
||
setupReorderableLayouts();
|
||
scheduleMasonry();
|
||
return true;
|
||
} catch (e) {
|
||
showError("Ops console unavailable: " + (e.message || e));
|
||
return false;
|
||
}
|
||
}
|
||
|
||
function renderShells(rows) {
|
||
const box = $("shellsBox");
|
||
if (!rows || !rows.length) {
|
||
box.innerHTML = '<div class="empty">No verified reverse shells</div>';
|
||
$("nVerified").textContent = "0";
|
||
if (window.__SC5_renderAdminShells) window.__SC5_renderAdminShells([]);
|
||
return;
|
||
}
|
||
$("nVerified").textContent = String(rows.length);
|
||
let html = "<table><tr><th>Session</th><th>Remote</th></tr>";
|
||
for (const s of rows) {
|
||
html += `<tr>
|
||
<td class="mono">${shortId(s.id)} <span class="pill ok">verified</span></td>
|
||
<td class="mono">${s.remote_addr || "—"}</td>
|
||
</tr>`;
|
||
}
|
||
html += "</table>";
|
||
box.innerHTML = html;
|
||
if (window.__SC5_renderAdminShells) window.__SC5_renderAdminShells(rows);
|
||
}
|
||
|
||
function renderListeners(rows) {
|
||
const up = (rows || []).filter((l) => l.status === "running");
|
||
$("nListeners").textContent = String(up.length);
|
||
const box = $("listenersBox");
|
||
if (!rows || !rows.length) { box.innerHTML = '<div class="empty">No listeners</div>'; return; }
|
||
let html = "<table><tr><th>Name</th><th>Port</th><th>Kind</th><th>Status</th></tr>";
|
||
for (const l of rows) {
|
||
const st = l.status || "—";
|
||
html += `<tr>
|
||
<td>${l.name || shortId(l.id)}</td>
|
||
<td class="mono">${l.port}</td>
|
||
<td>${l.kind}</td>
|
||
<td><span class="pill ${st === "running" ? "ok" : "bad"}">${st}</span></td>
|
||
</tr>`;
|
||
}
|
||
html += "</table>";
|
||
box.innerHTML = html;
|
||
if (window.__SC5_renderAdminListeners) window.__SC5_renderAdminListeners(rows);
|
||
}
|
||
|
||
function eventTone(type) {
|
||
const t = String(type || "").toLowerCase();
|
||
if (/reject|false_positive|error|fail|drop|denied|close/.test(t)) return "bad";
|
||
if (/verify|connected|start|ok|create|stabiliz|running/.test(t)) return "ok";
|
||
if (/warn|reap|orphan|timeout/.test(t)) return "warn";
|
||
return "";
|
||
}
|
||
function eventDetail(e) {
|
||
const p = e.payload || e.data || {};
|
||
if (typeof p === "string") return p;
|
||
const parts = [];
|
||
for (const k of ["session_id", "id", "remote", "remote_addr", "name", "kind", "reason", "status", "port", "command", "actor"]) {
|
||
if (p[k] != null && p[k] !== "") parts.push(`${k}=${p[k]}`);
|
||
}
|
||
if (!parts.length) {
|
||
try {
|
||
const s = JSON.stringify(p);
|
||
if (s && s !== "{}") return s.length > 120 ? s.slice(0, 117) + "…" : s;
|
||
} catch (_) {}
|
||
return "—";
|
||
}
|
||
const s = parts.join(" · ");
|
||
return s.length > 140 ? s.slice(0, 137) + "…" : s;
|
||
}
|
||
function eventTime(e) {
|
||
const ts = e.ts || e.timestamp || e.time || e.created_at;
|
||
if (ts == null) return "";
|
||
try {
|
||
const n = Number(ts);
|
||
const d = n > 1e12 ? new Date(n) : n > 1e9 ? new Date(n * 1000) : new Date(ts);
|
||
if (Number.isNaN(d.getTime())) return String(ts).slice(0, 12);
|
||
return d.toLocaleTimeString();
|
||
} catch (_) {
|
||
return "";
|
||
}
|
||
}
|
||
function escapeHtml(s) {
|
||
return String(s)
|
||
.replace(/&/g, "&")
|
||
.replace(/</g, "<")
|
||
.replace(/>/g, ">")
|
||
.replace(/"/g, """);
|
||
}
|
||
function renderEvents(snap) {
|
||
const events = (snap && snap.recent_events) || [];
|
||
const box = $("eventsBox");
|
||
const countEl = $("eventCount");
|
||
const list = events.slice().reverse().slice(0, 40);
|
||
if (countEl) countEl.textContent = list.length + (list.length === 1 ? " event" : " events");
|
||
if (!list.length) {
|
||
box.innerHTML = '<div class="empty">No recent events yet</div>';
|
||
return;
|
||
}
|
||
box.innerHTML = list.map((e) => {
|
||
const type = e.type || e.event || "event";
|
||
const tone = eventTone(type);
|
||
return `<div class="event-item ${tone}">
|
||
<span class="etype">${escapeHtml(type)}</span>
|
||
<span class="edetail">${escapeHtml(eventDetail(e))}</span>
|
||
<span class="etime">${escapeHtml(eventTime(e))}</span>
|
||
</div>`;
|
||
}).join("");
|
||
}
|
||
|
||
function renderChips(metrics) {
|
||
const m = metrics || {};
|
||
const keys = [
|
||
"shell.stabilized", "shell.false_positive", "sessions.orphaned_closed",
|
||
"shell.unverified_drop", "http.hits", "ai.admin.calls", "ai.admin.llm_ok",
|
||
];
|
||
const parts = keys.filter((k) => m[k] != null)
|
||
.map((k) => `<span class="chip"><b>${m[k]}</b> ${k.replace(/^(shell|sessions|ai)\./, "")}</span>`);
|
||
$("chips").innerHTML = parts.length ? parts.join("") : '<span class="chip">no counters</span>';
|
||
}
|
||
|
||
async function refresh() {
|
||
try {
|
||
const c = cfg();
|
||
try { await api("POST", "/api/v1/sessions/reap", { probe: false }); } catch (_) {}
|
||
|
||
const who = await api("GET", "/api/v1/meta");
|
||
scopes = new Set(who.scopes || []);
|
||
isAdmin = scopes.has("admin") || who.actor_type === "admin";
|
||
$("roleBadge").classList.toggle("hidden", false);
|
||
$("roleBadge").textContent = isAdmin ? "admin" : (who.actor_type || "operator");
|
||
|
||
// Scope-gated console (must load after scopes are known so can() works)
|
||
try { await ensureConsoleUi(); } catch (_) {}
|
||
|
||
const soft = async (fn) => { try { return await fn(); } catch (_) { return null; } };
|
||
const [health, metrics, shellRows, allSessions, tasks, lis, aiSt] = await Promise.all([
|
||
soft(() => api("GET", "/api/v1/health")),
|
||
soft(() => api("GET", "/api/v1/metrics")),
|
||
soft(() => api("GET", "/api/v1/sessions?status=active&live_only=true&kind=reverse_shell,tcp")),
|
||
soft(() => api("GET", "/api/v1/sessions?status=active")),
|
||
soft(() => api("GET", "/api/v1/tasks")),
|
||
soft(() => api("GET", "/api/v1/listeners")),
|
||
soft(() => api("GET", "/api/v1/ai/status")),
|
||
]);
|
||
|
||
showError("");
|
||
$("statusBadge").textContent = "online";
|
||
$("statusBadge").className = "badge ok";
|
||
$("hostLine").textContent = hostFromUrl(c.url) + (who.actor ? ` · ${who.actor}` : "");
|
||
$("nUptime").textContent = fmtUptime((metrics && metrics.uptime_seconds) || 0);
|
||
$("healthLine").textContent = health
|
||
? `${health.app || "SquidC5"} v${health.version || "?"} · ${health.status}`
|
||
: "connected";
|
||
|
||
const verified = (shellRows || []).filter((s) => s.kind === "reverse_shell" || s.kind === "tcp");
|
||
const m = (metrics && metrics.metrics) || {};
|
||
const setStat = (id, val) => { if ($(id)) $(id).textContent = val == null || val === "" ? "—" : String(val); };
|
||
setStat("nSessions", Array.isArray(allSessions) ? allSessions.length : "—");
|
||
const taskList = Array.isArray(tasks) ? tasks : (tasks && tasks.tasks) || [];
|
||
const openTasks = (taskList || []).filter((t) => {
|
||
const st = String((t && t.status) || "").toLowerCase();
|
||
return !st || st === "pending" || st === "queued" || st === "running" || st === "in_progress";
|
||
});
|
||
setStat("nTasks", Array.isArray(taskList) ? openTasks.length : "—");
|
||
setStat("nBeacons", m["http.hits"] != null ? m["http.hits"] : (m["implant.beacon"] != null ? m["implant.beacon"] : 0));
|
||
setStat("nStabilized", m["shell.stabilized"] != null ? m["shell.stabilized"] : 0);
|
||
setStat("nFalsePos", m["shell.false_positive"] != null ? m["shell.false_positive"] : 0);
|
||
setStat("nAi", m["ai.admin.calls"] != null ? m["ai.admin.calls"] : 0);
|
||
|
||
renderShells(verified);
|
||
renderListeners(lis || []);
|
||
if (metrics) {
|
||
renderEvents(metrics);
|
||
renderChips(m);
|
||
}
|
||
if (aiSt && window.__SC5_renderAi) window.__SC5_renderAi(aiSt);
|
||
|
||
$("footer").textContent =
|
||
`Updated ${new Date().toLocaleTimeString()} · ${verified.length} verified · refresh ${c.refresh}s`;
|
||
scheduleMasonry();
|
||
} catch (e) {
|
||
$("statusBadge").textContent = "error";
|
||
$("statusBadge").className = "badge bad";
|
||
showError(String(e.message || e));
|
||
}
|
||
}
|
||
|
||
function schedule() {
|
||
if (timer) clearInterval(timer);
|
||
timer = setInterval(refresh, cfg().refresh * 1000);
|
||
}
|
||
|
||
function buildShareUrl() {
|
||
saveSettings();
|
||
const c = cfg();
|
||
if (!c.url || !c.token) throw new Error("Save URL and token first");
|
||
let pageBase = c.url.replace(/\/$/, "") + "/ops";
|
||
try {
|
||
if (location.pathname.indexOf("/ops") === 0) pageBase = location.origin.replace(/\/$/, "") + "/ops";
|
||
} catch (_) {}
|
||
return pageBase + "#sc5=" + b64urlEncode({ url: c.url, token: c.token, refresh: c.refresh, v: 1 });
|
||
}
|
||
function showQr() {
|
||
try {
|
||
const share = buildShareUrl();
|
||
lastShare = share;
|
||
$("shareUrl").textContent = share.length > 120 ? share.slice(0, 100) + "…" : share;
|
||
const box = $("qrCanvas");
|
||
box.innerHTML = "";
|
||
if (typeof qrcode === "undefined") throw new Error("QR library missing");
|
||
const qr = qrcode(0, "M");
|
||
qr.addData(share);
|
||
qr.make();
|
||
box.innerHTML = qr.createImgTag(4, 2);
|
||
const img = box.querySelector("img");
|
||
if (img) { img.style.width = "220px"; img.style.height = "220px"; }
|
||
$("qrModal").classList.add("show");
|
||
} catch (e) { showError(String(e.message || e)); }
|
||
}
|
||
|
||
$("saveBtn").onclick = () => {
|
||
saveSettings();
|
||
state.adminLoaded = false;
|
||
$("adminMount").innerHTML = "";
|
||
delete $("adminMount").dataset.reorderBound;
|
||
$("settingsPanel").open = !(($("token").value || "").trim());
|
||
schedule();
|
||
refresh();
|
||
};
|
||
$("refreshBtn").onclick = () => refresh();
|
||
$("shareBtn").onclick = () => showQr();
|
||
$("closeQrBtn").onclick = () => $("qrModal").classList.remove("show");
|
||
$("qrModal").onclick = (e) => { if (e.target === $("qrModal")) $("qrModal").classList.remove("show"); };
|
||
$("copyShareBtn").onclick = async () => {
|
||
try {
|
||
await navigator.clipboard.writeText(lastShare || buildShareUrl());
|
||
$("copyShareBtn").textContent = "Copied!";
|
||
setTimeout(() => { $("copyShareBtn").textContent = "Copy link"; }, 1200);
|
||
} catch (_) { showError("Copy failed"); }
|
||
};
|
||
$("toggleToken").onclick = () => {
|
||
tokenVisible = !tokenVisible;
|
||
$("token").type = tokenVisible ? "text" : "password";
|
||
$("toggleToken").textContent = tokenVisible ? "Hide token" : "Show token";
|
||
};
|
||
|
||
// file:// opens break CORS (Origin: null) and logo paths — force hosted /ops
|
||
if (location.protocol === "file:") {
|
||
document.body.innerHTML = `
|
||
<div style="max-width:520px;margin:40px auto;padding:20px;font-family:system-ui;background:#111;color:#fff;border:1px solid #ff00aa;border-radius:12px">
|
||
<h1 style="color:#ff00aa;margin-top:0">Do not open this file locally</h1>
|
||
<p>You opened <code>phone-dashboard.html</code> via <code>file://</code>. Browsers block API calls from that origin.</p>
|
||
<p><strong>Open the hosted console instead:</strong></p>
|
||
<p class="mono" style="color:#00ff9d">http://YOUR_HOST:8443/ops</p>
|
||
<p style="color:#aaa;font-size:0.9rem">Use your C2 public host/IP. Paste your admin token there → Save & Connect.</p>
|
||
</div>`;
|
||
} else {
|
||
loadSettings();
|
||
setupReorderableLayouts();
|
||
if (cfg().url && cfg().token) { schedule(); refresh(); }
|
||
else showError("Enter Server URL + token, then Save & Connect.");
|
||
}
|
||
</script>
|
||
</body>
|
||
</html>
|