Files
attack-defense-platform/panel/static/index.html
T
root 1461c874c3 Render the topology graph with PixiJS v8
Replaces the hand-rolled inline-SVG topology with a PixiJS 8 scene graph.

Why: the old renderer rebuilt all 37 nodes / 36 edges as one innerHTML string
every 10s, which tore down and recreated every DOM node. That restarted CSS
animations mid-flight and made dragging fight the browser's own hit-testing.
The scene graph gives per-node transforms, so pan/zoom is a single container
transform instead of getScreenCTM() matrix math.

Changes:
- static/topo_pixi.js: new self-contained renderer. Owns its Application and
  tears it down on tab exit so a second WebGL context cannot leak.
- static/index.html: the <svg id=topoSvg> host becomes a <div id=topoHost>;
  the 188-line SVG renderer is replaced by a bridge to the module.
- static/vendor/pixi.mjs: PixiJS 8.21.0 self-hosted (MIT). The .mjs build is
  required; the .js build exports no global. See vendor/README.md.
- main.py: mount /static. Pages were served as inline HTMLResponse, so the
  directory was never mounted and the module had no URL to load from.

Two real bugs found by measuring pixels rather than trusting init():
- preserveDrawingBuffer: without it WebGL clears the back buffer after
  compositing, so any readback or screenshot of the canvas is a coin flip
  depending on which frame it lands on. The graph rendered intermittently
  blank. Now enabled: cheap for a 2D scene, and it makes the view capturable.
- Layout was centred on the SCROLLABLE width (nodes.length * 130), not the
  viewport, so with 37 nodes every team and challenge node landed at
  x=2230-2650 on a 1310px canvas: entirely off-screen. Layout now centres on
  the visible width and reset() frames the whole graph to fit.

test_topo_pixels.js documents three wrong test designs it replaces, all of
which reported false failures against a working graph: counting scene-graph
children (passes on a blank canvas), diffing against the background colour
(the theme is dark by design, so a perfect render measures ~0%), and diffing
two Playwright screenshots (both can be captured after the scene was mutated).
The check now reads the GL back buffer via readPixels in one evaluate.

Verified: 37 nodes / 36 edges drawn (7.94% of frame, max channel delta 225),
graph bbox [437,46,881,476] inside the 1310x520 canvas, glGetError=0, no page
errors, zoom and frame-to-fit reset working. Platform unregressed: SLA 32/32.
2026-09-27 00:31:47 +08:00

1002 lines
47 KiB
HTML

<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Attack Defense Platform</title>
<style>
* { margin:0; padding:0; box-sizing:border-box; }
body {
font-family: 'JetBrains Mono','Fira Code',monospace;
background: radial-gradient(ellipse at top, #0d1b2a 0%, #0a0f1c 55%, #05070d 100%);
color: #c9d4e3; min-height:100vh; padding:24px;
}
header {
display:flex; justify-content:space-between; align-items:center; margin-bottom:20px;
border-bottom:1px solid #1e3a5f; padding-bottom:16px; flex-wrap:wrap; gap:12px;
}
h1 { font-size:22px; color:#5ad1ff; letter-spacing:1px; }
.actions { display:flex; gap:10px; align-items:center; }
.tabs { display:flex; gap:6px; margin-bottom:20px; flex-wrap:wrap; }
.tab {
background:#0e1526; border:1px solid #1e3a5f; color:#8aa0b8; padding:9px 16px;
border-radius:8px; cursor:pointer; font-family:inherit; font-size:13px; transition:all .2s;
}
.tab.active { background:#2563eb33; border-color:#3b82f6; color:#fff; }
.tab:hover { border-color:#3b82f6; }
.pill {
background:#0e1526; border:1px solid #1e3a5f; color:#8aa0b8; font-size:12px;
padding:7px 13px; border-radius:20px;
}
.pill.green { color:#34d399; border-color:#34d39955; background:#34d39911; }
.pill.red { color:#f87171; border-color:#f8717155; background:#f8717111; }
button {
background:#0e1526; border:1px solid #1e3a5f; color:#a8c3e0; font-family:inherit;
font-size:12px; padding:8px 14px; border-radius:8px; cursor:pointer; transition:all .2s;
}
button:hover { border-color:#3b82f6; color:#fff; }
button.primary { background:linear-gradient(135deg,#0ea5e9,#2563eb); border:none; color:#fff; font-weight:700; }
button.danger { border-color:#f8717155; color:#f87171; }
button.danger:hover { background:#f8717122; }
.view { display:none; }
.view.active { display:block; }
.grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(340px,1fr)); gap:16px; }
.card {
background:#0e1526cc; border:1px solid #1e3a5f; border-radius:12px; padding:18px;
display:flex; flex-direction:column; gap:10px; position:relative;
}
.card.dead { border-color:#f8717155; background:#1a0f16cc; }
.card-top { display:flex; justify-content:space-between; align-items:center; }
.ch-name { font-size:16px; font-weight:700; color:#e2ecfa; }
.ch-cat {
font-size:10px; text-transform:uppercase; letter-spacing:1px; padding:3px 8px;
border-radius:10px; background:#1e3a5f44; color:#8aa0b8;
}
.status { font-size:11px; font-weight:700; letter-spacing:.5px; text-transform:uppercase; }
.status.up { color:#34d399; }
.status.down { color:#f87171; }
.flag {
background:#0a101f; border:1px dashed #2a4a6f; border-radius:8px; padding:10px 12px;
font-size:12px; color:#7dd3fc; word-break:break-all; min-height:18px;
}
.creds { font-size:11px; color:#718096; }
.creds b { color:#a8c3e0; font-weight:600; }
.btn-row { display:flex; gap:6px; flex-wrap:wrap; margin-top:2px; }
.btn-row button { font-size:11px; padding:6px 10px; }
textarea, input[type=text], input[type=number], select {
width:100%; background:#0a101f; border:1px solid #1e3a5f; color:#dbe6f4;
border-radius:8px; padding:10px; font-size:13px; font-family:inherit; outline:none; resize:vertical;
}
textarea:focus, input:focus, select:focus { border-color:#3b82f6; }
.modal-back {
position:fixed; inset:0; background:#000a; display:none; align-items:center; justify-content:center; z-index:50;
}
.modal-back.open { display:flex; }
.modal {
background:#0d1526; border:1px solid #2a4a6f; border-radius:14px; padding:26px; width:440px; max-width:92vw;
}
.modal h2 { font-size:16px; color:#5ad1ff; margin-bottom:16px; }
.toast {
position:fixed; bottom:24px; right:24px; background:#0e1526; border:1px solid #3b82f6;
color:#dbe6f4; padding:13px 18px; border-radius:10px; font-size:13px; z-index:80;
opacity:0; transform:translateY(10px); transition:all .3s;
}
.toast.show { opacity:1; transform:translateY(0); }
.toast.err { border-color:#f87171; color:#fca5a5; }
/* loading overlay */
#loadingOverlay {
display:none; position:fixed; inset:0; background:rgba(4,8,14,0.82); z-index:2000;
align-items:center; justify-content:center; flex-direction:column; gap:18px;
}
#loadingOverlay.show { display:flex; }
.loader { width:44px; height:44px; border:4px solid #2a3444; border-top-color:#38bdf8; border-radius:50%; animation:spin 0.9s linear infinite; }
@keyframes spin { to { transform:rotate(360deg); } }
#loadingText { color:#dbe6f4; font-size:14px; text-align:center; padding:0 24px; line-height:1.6; }
.history-box {
background:#0a101f; border:1px solid #1e3a5f; border-radius:10px; padding:14px;
font-size:11px; color:#7dd3fc; max-height:300px; overflow-y:auto; white-space:pre-wrap;
}
.footer-note { color:#4a5a70; font-size:11px; margin-top:20px; text-align:center; }
.spin { display:inline-block; width:12px; height:12px; border:2px solid #2a4a6f; border-top-color:#5ad1ff; border-radius:50%; animation:sp .7s linear infinite; }
@keyframes sp { to { transform:rotate(360deg); } }
.topo-wrap { background:#0a0f1c; border:1px solid #1e3a5f; border-radius:12px; padding:20px; overflow-x:auto; }
/* Now a DIV host for the PixiJS canvas (was an inline <svg>). It needs an
explicit height: the Pixi Application is created with resizeTo:this element
and a zero-height box would size the renderer to nothing. */
.topo-svg { width:100%; min-width:800px; height:520px; }
/* attack visualizer: recent attacks pulse */
@keyframes attackPulse { 0%,100% { stroke-opacity:0.4; } 50% { stroke-opacity:1; } }
@keyframes attackBlink { 0%,100% { opacity:1; } 50% { opacity:0.35; } }
.attack-line { animation: attackPulse 1.2s ease-in-out infinite; }
.attack-icon { animation: attackBlink 1s ease-in-out infinite; }
.topo-hint { font-size:12px; color:var(--dim); margin-top:8px; }
.team-head { display:flex; justify-content:space-between; align-items:center; gap:10px; flex-wrap:wrap; }
.team-status { font-size:11px; }
.kv { display:grid; grid-template-columns:120px 1fr; gap:6px 12px; font-size:12px; }
.kv b { color:#8aa0b8; font-weight:600; }
.kv span { color:#dbe6f4; word-break:break-all; }
.mono { font-family:inherit; }
</style>
</head>
<body>
<header>
<h1>⚔️ ATTACK DEFENSE PLATFORM — NODE CONTROL</h1>
<div class="actions">
<span class="pill" id="clock">--:--:--</span>
<button onclick="refresh()">🔄 Refresh</button>
<button class="danger" onclick="resetScores()" style="background:#dc2626;border:none;border-radius:8px;padding:8px 14px;color:#fff;font-weight:600;cursor:pointer">🧹 Reset Skor</button>
<button class="danger" onclick="resetEnv()" style="background:#7f1d1d;border:none;border-radius:8px;padding:8px 14px;color:#fff;font-weight:600;cursor:pointer">💥 Reset Environment</button>
<button onclick="logout()">Keluar</button>
</div>
</header>
<div class="tabs">
<button class="tab active" data-view="challs" onclick="showView('challs')">🎯 Challenges</button>
<button class="tab" data-view="topo" onclick="showView('topo'); loadTopo()">🗺️ Topology</button>
<button class="tab" data-view="teams" onclick="showView('teams'); loadTeams()">👥 Teams</button>
<button class="tab" data-view="logs" onclick="showView('logs'); loadLogs()">📜 Logs</button>
<button class="tab" data-view="creds" onclick="showView('creds'); loadCreds()">🔑 Creds</button>
<button class="tab" data-view="targets" onclick="showView('targets'); loadTargetMatrix()">🎯 Target Matrix</button>
<button class="tab" data-view="sla" onclick="showView('sla'); loadSla()">📊 SLA & Skor</button>
<button class="tab" data-view="chmgr" onclick="showView('chmgr'); loadChMgr()">🏗️ Challenge Manager</button>
</div>
<!-- Challenges view -->
<div class="view active" id="view-challs">
<div class="grid" id="grid"></div>
</div>
<!-- Topology view -->
<div class="view" id="view-topo">
<div class="topo-toolbar" style="display:flex;align-items:center;gap:8px;margin-bottom:8px;flex-wrap:wrap">
<button onclick="topoZoom(1.25)">🔍 + Zoom In</button>
<button onclick="topoZoom(0.8)">🔎 Zoom Out</button>
<button onclick="topoReset()">⟳ Reset</button>
<span id="topoZoomLabel" style="font-size:12px;color:var(--dim);min-width:40px">100%</span>
</div>
<div class="topo-wrap"><div id="topoHost" class="topo-svg"></div></div>
<div class="topo-hint">💡 <b>Geser node</b> untuk atur layout • <b>scroll / ctrl+scroll</b> untuk zoom in-out • <b>drag area kosong</b> untuk geser canvas • ⚔️ garis merah = serangan (attacker → target) • garis tebal = serangan 60 detik terakhir &nbsp;<span id="topoEngine" style="opacity:.6"></span></div>
</div>
<!-- Teams view -->
<div class="view" id="view-teams">
<div class="card" style="margin-bottom:16px">
<div class="team-head">
<div><b style="color:#5ad1ff">Jumlah Team</b> <span style="font-size:11px;color:#718096">(auto-create node + domain per team)</span></div>
<div style="display:flex;gap:8px;align-items:center">
<input type="number" id="teamCount" min="0" max="50" value="0" style="width:90px" oninput="teamCountChanged()">
<button class="primary" onclick="setTeams()">Set & Buat</button>
</div>
</div>
<div id="teamNameInputs" style="margin-top:10px;display:flex;flex-direction:column;gap:6px"></div>
<div style="margin-top:10px;display:flex;gap:8px;flex-wrap:wrap">
<button class="primary" onclick="startAllTeams()">▶ Start CTF (semua)</button>
<button class="danger" onclick="stopAllTeams()">⏹ Stop CTF (semua)</button>
</div>
</div>
<div id="bulkDelBar" style="display:none;margin:10px 0"></div>
<div class="card" style="margin-bottom:10px;display:flex;gap:10px;align-items:center;flex-wrap:wrap">
<button onclick="selectAllTeams(true)">☑️ Pilih semua</button>
<button onclick="clearTeamSelection()">☐ Kosongkan</button>
<span style="font-size:11px;color:#718096">Centang tim di kiri nama untuk hapus massal sekaligus</span>
</div>
<div class="grid" id="teamsGrid"></div>
<div class="card" style="margin-top:16px">
<div class="team-head">
<div><b style="color:#5ad1ff">🏆 Leaderboard</b> <span style="font-size:11px;color:#718096">solve flag per team (dari halaman submit publik)</span></div>
<a href="/submit" target="_blank" class="primary" style="text-decoration:none;padding:8px 14px;border-radius:8px">🚩 Halaman Submit Team →</a>
</div>
<table style="width:100%;border-collapse:collapse;margin-top:10px;font-size:13px">
<thead><tr style="color:#718096;text-align:left"><th style="padding:6px">#</th><th>Tim</th><th>Solved</th><th>Challenges</th></tr></thead>
<tbody id="lbBody"></tbody>
</table>
</div>
</div>
<!-- Logs view -->
<div class="view" id="view-logs">
<div class="card" style="margin-bottom:16px">
<div class="team-head">
<div><b style="color:#5ad1ff">Log Server</b> <span style="font-size:11px;color:#718096">docker logs per team/service</span></div>
<div style="display:flex;gap:8px;align-items:center">
<select id="logTeam" style="width:130px"></select>
<select id="logService" style="width:150px">
<option value="">semua service</option>
<option>blogpost</option><option>carbeat</option><option>cdn</option>
<option>phew</option><option>sheesh</option><option>warmup</option>
</select>
<button class="primary" onclick="loadLogs()">Muat</button>
</div>
</div>
</div>
<div class="history-box" id="logsBox" style="max-height:70vh">Pilih team & service…</div>
</div>
<!-- Creds view -->
<div class="view" id="view-creds">
<div class="grid" id="credsGrid"></div>
</div>
<!-- Target Matrix view -->
<div class="view" id="view-targets">
<div class="card">
<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">
<b style="color:#5ad1ff">🎯 Target Matrix</b>
<span style="font-size:11px;color:#718096">Domain:port semua service semua tim — buat dibagikan ke peserta sebagai daftar target</span>
</div>
<div id="targetMatrix" style="margin-top:14px;font-family:ui-monospace,monospace;font-size:12px;line-height:1.9;color:#dbe6f4;background:#0a101f;border:1px solid #1e3a5f;border-radius:10px;padding:14px;overflow-x:auto">Memuat…</div>
</div>
</div>
<!-- SLA & Scoreboard view -->
<div class="view" id="view-sla">
<div class="card">
<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">
<b style="color:#5ad1ff">📊 SLA & Skor Tim</b>
<span style="font-size:11px;color:#718096">Status service tiap tim (dicek otomatis tiap ~30 detik) + poin: +100/flag dicuri, +50 bonus SLA saat 6/6 UP. Auto-refresh 30 detik.</span>
</div>
<div id="slaAdminList" style="margin-top:14px;font-family:ui-monospace,monospace;font-size:12px;line-height:1.9;color:#dbe6f4;background:#0a101f;border:1px solid #1e3a5f;border-radius:10px;padding:14px;overflow-x:auto">Memuat…</div>
</div>
</div>
<!-- Challenge Manager view -->
<div class="view" id="view-chmgr">
<div class="card">
<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">
<b style="color:#5ad1ff">🏗️ Challenge Manager</b>
<span style="font-size:11px;color:#718096">Semua challenge dari registry (GEMASTIK 18, XVI, XVII). Toggle untuk aktif/nonaktif global di semua tim — diterapkan langsung (build/up atau stop/remove + restart receiver).</span>
<button class="primary" onclick="loadChMgr()" style="padding:4px 10px;font-size:12px">🔄 Muat ulang</button>
</div>
<div id="chMgrList" style="margin-top:14px;font-family:ui-monospace,monospace;font-size:12px;line-height:1.9;color:#dbe6f4;background:#0a101f;border:1px solid #1e3a5f;border-radius:10px;padding:14px;overflow-x:auto">Memuat…</div>
</div>
</div>
<div class="footer-note">Receiver: https://attackdefense.imrnes.team · Panel: https://panel.attackdefense.imrnes.team · Auto-refresh tiap 15 detik</div>
<!-- modal flag -->
<div class="modal-back" id="modalFlag">
<div class="modal">
<h2 id="mfTitle">Rotate Flag</h2>
<input type="text" id="mfFlag" placeholder="GEMASTIK18{...}" style="margin-bottom:12px">
<div class="btn-row" style="justify-content:flex-end">
<button onclick="closeModal('modalFlag')">Batal</button>
<button class="primary" onclick="submitFlag()">Submit Flag</button>
</div>
</div>
</div>
<!-- modal creds -->
<div class="modal-back" id="modalCred">
<div class="modal">
<h2 id="mcTitle">SSH Credentials</h2>
<div id="mcBody" style="font-size:13px;color:#dbe6f4;line-height:1.8"></div>
<div class="btn-row" style="justify-content:flex-end;margin-top:14px">
<button onclick="closeModal('modalCred')">Tutup</button>
</div>
</div>
</div>
<!-- modal team creds -->
<div class="modal-back" id="modalTeamCred">
<div class="modal" style="width:520px">
<h2 id="mtcTitle">Kredensial Team</h2>
<div id="mtcBody" style="font-size:12px;color:#dbe6f4;line-height:1.9"></div>
<div class="btn-row" style="justify-content:flex-end;margin-top:14px">
<button onclick="closeModal('modalTeamCred')">Tutup</button>
</div>
</div>
</div>
<div class="toast" id="toast"></div>
<!-- loading overlay -->
<div id="loadingOverlay">
<div class="loader"></div>
<div id="loadingText">Memproses…</div>
</div>
<script>
let CURRENT = null;
let REFRESH_MS = 15000;
async function api(path, opts={}) {
const r = await fetch(path, opts);
if (r.status === 401) { location.href = '/login'; throw new Error('unauth'); }
const data = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(data.detail || data.receiver_body || r.statusText);
return data;
}
function toast(msg, err=false) {
const t = document.getElementById('toast');
t.textContent = msg; t.className = 'toast show' + (err ? ' err' : '');
setTimeout(() => t.className = 'toast', 3000);
}
function showLoading(html) {
const ov = document.getElementById('loadingOverlay');
document.getElementById('loadingText').innerHTML = html;
ov.classList.add('show');
return ov;
}
function hideLoading() {
document.getElementById('loadingOverlay').classList.remove('show');
}
function esc(s) {
return String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
// Alias used by the Challenge Manager render. Kept as a separate name so
// existing esc() call sites stay untouched, but it MUST exist: a missing
// helper throws ReferenceError mid-render and the tab hangs on "Memuat…"
// forever with no visible error.
function escapeHtml(s) { return esc(s); }
function showView(v) {
document.querySelectorAll('.tab').forEach(b => b.classList.toggle('active', b.dataset.view === v));
document.querySelectorAll('.view').forEach(x => x.classList.toggle('active', x.id === 'view-' + v));
if (v === 'topo') loadTopo(); // refresh attacks immediately on tab switch + every 10s
if (v === 'sla') loadSla();
if (v === 'chmgr') loadChMgr();
}
let topoTimer = null;
function startTopoTimer() {
if (!topoTimer) topoTimer = setInterval(() => {
if (document.getElementById('view-topo').classList.contains('active')) loadTopo();
}, 10000);
}
startTopoTimer();
let slaTimer = null;
function startSlaTimer() {
if (!slaTimer) slaTimer = setInterval(() => {
if (document.getElementById('view-sla').classList.contains('active')) loadSla();
}, 30000);
}
startSlaTimer();
async function loadSla() {
const box = document.getElementById('slaAdminList');
if (!box) return;
box.innerHTML = 'Memuat…';
try {
const d = await api('/api/scoreboard');
const rows = (d.teams || []).map(t => {
const pct = Math.round(t.sla_pct || 0);
const color = pct === 100 ? '#2ecc71' : (pct >= 60 ? '#f1c40f' : '#e74c3c');
const bar = `<span style="display:inline-block;vertical-align:middle;width:120px;height:8px;background:#1e3a5f;border-radius:4px;margin:0 8px;overflow:hidden"><span style="display:block;height:100%;width:${pct}%;background:${color}"></span></span>`;
return `<div style="padding:8px 4px;border-bottom:1px solid #16233a">
<b>${t.badge ? t.badge + ' ' : '#' + t.rank + ' '}</b>${esc(t.label)} ${bar}<span style="color:${color}">${t.alive}/${t.total} (${pct}%)</span>
<span style="float:right;color:#5ad1ff">⚑ ${t.solves} flag · 🏆 ${t.points} pts</span>
</div>`;
}).join('');
box.innerHTML = rows || '<div style="color:#718096">Belum ada tim.</div>';
} catch (e) {
box.innerHTML = '<div style="color:#e74c3c">Gagal memuat SLA.</div>';
}
}
// ---------- Challenge Manager ----------
async function loadChMgr() {
const box = document.getElementById('chMgrList');
if (!box) return;
box.innerHTML = 'Memuat…';
try {
const d = await api('/api/challenges');
const list = d.challenges || [];
const setLabels = { 'gemastik18': 'GEMASTIK 18', 'xvi': 'GEMASTIK XVI', 'xvii': 'GEMASTIK XVII' };
const bySet = {};
for (const c of list) (bySet[c.set] = bySet[c.set] || []).push(c);
let html = '';
for (const [set, chs] of Object.entries(bySet)) {
html += `<div style="margin:8px 0 4px;color:#5ad1ff;font-weight:700">📦 ${escapeHtml(setLabels[set] || set)} (${chs.length})</div>`;
for (const c of chs) {
const on = c.enabled ? 'checked' : '';
html += `<div style="display:flex;align-items:center;gap:10px;padding:6px 8px;border-bottom:1px solid #13233d;border-radius:6px">
<input type="checkbox" id="chmgr-${escapeHtml(c.name)}" ${on} onchange="toggleChallenge('${escapeHtml(c.name)}', this.checked)" style="width:16px;height:16px;accent-color:#22c55e">
<b style="min-width:150px">${escapeHtml(c.name)}</b>
<span style="color:#718096;font-size:11px">${escapeHtml(c.set)} · ${escapeHtml(c.category || '-')}</span>
<span style="color:#3d5a80;font-size:11px">chall+${c.chall_offset}/ssh+${c.ssh_offset}</span>
<span style="margin-left:auto;font-size:11px;color:${c.enabled ? '#22c55e' : '#e74c3c'}">${c.enabled ? '● AKTIF' : '○ NONAKTIF'}</span>
<span id="chmgr-msg-${escapeHtml(c.name)}" style="font-size:11px;color:#718096"></span>
</div>`;
}
}
box.innerHTML = html || '<div style="color:#718096">Tidak ada challenge di registry.</div>';
} catch (e) {
box.innerHTML = `<div style="color:#e74c3c">Gagal memuat: ${escapeHtml(e.message)}</div>`;
}
}
async function toggleChallenge(name, enabled) {
const msg = document.getElementById('chmgr-msg-' + name);
if (!msg) return;
msg.textContent = '⏳ menerapkan…';
msg.style.color = '#f5c542';
try {
const d = await api('/api/challenges/' + encodeURIComponent(name), { method: 'PATCH', body: JSON.stringify({ enabled }) });
if (d.unchanged) {
msg.textContent = `✓ ${d.enabled ? 'diaktifkan' : 'dinonaktifkan'} (sudah sesuai)`;
msg.style.color = '#22c55e';
setTimeout(() => { msg.textContent = ''; loadChMgr(); }, 3000);
return;
}
// Docker build runs on the server; poll the job until it reports per-team results.
if (!d.job) throw new Error('server tidak mengembalikan job id');
const started = Date.now();
const poll = async () => {
const j = await api('/api/challenges/jobs/' + encodeURIComponent(d.job));
if (j.state === 'running') {
const secs = Math.round((Date.now() - started) / 1000);
msg.textContent = `⏳ build ${secs}s…`;
setTimeout(poll, 3000);
return;
}
if (j.state === 'error') {
msg.textContent = '✗ ' + (j.detail || 'gagal');
msg.style.color = '#e74c3c';
loadChMgr();
return;
}
const okCount = (j.applied || []).filter(t => t.ok).length;
const bad = (j.applied || []).filter(t => !t.ok);
msg.textContent = bad.length
? `⚠ ${okCount}/${(j.applied || []).length} tim OK — ${bad.map(t => `team${t.team}: ${(t.detail || '').split('\n').slice(-1)[0]}`).join('; ').slice(0, 120)}`
: `✓ ${d.enabled ? 'diaktifkan' : 'dinonaktifkan'} (${okCount} tim)`;
msg.style.color = bad.length ? '#f5c542' : '#22c55e';
setTimeout(() => { msg.textContent = ''; loadChMgr(); }, 6000);
};
poll();
} catch (e) {
msg.textContent = '✗ ' + e.message;
msg.style.color = '#e74c3c';
loadChMgr();
}
}
// ---------- Challenges ----------
// The admin challenge grid is a global node view with no team of its own, so it
// asks for credentials without ?team= and the server falls back to the receiver.
// Team-scoped pages pass their own index instead.
function TEAM_Q() { return ''; }
async function refresh() {
const grid = document.getElementById('grid');
try {
const d = await api('/api/status');
const chs = await api('/api/challenges');
const name2cat = Object.fromEntries((chs.challenges||[]).map(c => [c.name, c]));
let html = '';
for (const ch of d.results) {
const meta = name2cat[ch.name] || {};
const alive = ch.alive;
html += `<div class="card ${alive ? '' : 'dead'}">
<div class="card-top">
<span class="ch-name">${esc(ch.name)}</span>
<span class="ch-cat">${esc(meta.category || ch.category || '')}</span>
</div>
<div><span class="status ${alive ? 'up' : 'down'}">${alive ? '● SLA OK' : '● SLA DOWN'}</span>
<span style="font-size:11px;color:#718096;margin-left:8px">
${esc(ch.port)} / ssh ${esc(ch.ssh)}</span></div>
<div class="flag" title="Flag saat ini">🏳️ ${esc(ch.flag || '(kosong — belum di-set)')}</div>
<div class="creds" id="creds-${esc(ch.name)}"><span class="spin"></span> ambil kredensial…</div>
<div class="btn-row">
<button class="primary" onclick="openFlag('${esc(ch.name)}')">Rotate Flag</button>
<button onclick="act('restart','${esc(ch.name)}')">Restart</button>
<button onclick="act('rollback','${esc(ch.name)}')">Rollback</button>
<button onclick="act('activate','${esc(ch.name)}')">Activate</button>
<button class="danger" onclick="act('deactivate','${esc(ch.name)}')">Deactivate</button>
<button onclick="viewCred('${esc(ch.name)}')">SSH</button>
</div>
</div>`;
fetch(`/api/credential/${ch.name}${TEAM_Q()}`).then(r=>r.json()).then(c => {
const el = document.getElementById('creds-' + ch.name);
// Use the username the server reports: it is per-challenge (ctfuser for
// the 6 native XVIII images, root for the imported XVI/XVII ones).
if (el && c.username) el.innerHTML = `<b>${esc(c.username)}</b> / <b>${esc(c.password)}</b>`;
}).catch(()=>{});
}
grid.innerHTML = html;
} catch (e) {
toast('Gagal refresh: ' + e.message, true);
}
}
async function act(action, challenge) {
try {
const d = await api(`/api/${action}/${challenge}`, {method:'POST'});
toast(`${action} ${challenge} → receiver ${d.receiver_status}`, false);
setTimeout(refresh, 2500);
} catch (e) { toast(e.message, true); }
}
let flagChallenge = null;
function openFlag(ch) {
flagChallenge = ch;
document.getElementById('mfTitle').textContent = 'Rotate Flag — ' + ch;
document.getElementById('mfFlag').value = '';
document.getElementById('modalFlag').classList.add('open');
document.getElementById('mfFlag').focus();
}
async function submitFlag() {
const flag = document.getElementById('mfFlag').value.trim();
if (!flag) return toast('Flag kosong', true);
try {
const d = await api('/api/flag', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({challenge: flagChallenge, flag})});
toast(`Flag ${flagChallenge} di-rotate → receiver ${d.receiver_status}`, false);
closeModal('modalFlag');
setTimeout(refresh, 1500);
} catch (e) { toast(e.message, true); }
}
async function viewCred(ch) {
try {
const c = await api(`/api/credential/${ch}${TEAM_Q()}`);
// user + port both come from the server: the SSH login is per-challenge and
// the port is per-team, so a hardcoded table went stale for every imported
// challenge and for any team other than the first.
const user = c.username || 'ctfuser';
const port = c.port || 10022;
const host = `${ch}.attackdefense.imrnes.team`;
document.getElementById('mcTitle').textContent = 'SSH Credentials — ' + ch;
const cmd = `ssh ${user}@${host} -p ${port}`;
document.getElementById('mcBody').innerHTML =
`<div>User: <b>${esc(user)}</b></div>
<div>Pass: <b>${esc(c.password)}</b></div>
<div style="margin-top:10px">SSH:</div>
<div class="flag" style="margin-top:6px">${esc(cmd)}</div>`;
document.getElementById('modalCred').classList.add('open');
} catch (e) { toast(e.message, true); }
}
// ---------- Topology ----------
let topoLoaded = false;
// ---------- Topology (PixiJS v8) ----------
// The renderer lives in topo_pixi.js as an ES module so the ~810 KB Pixi bundle is
// only parsed when this tab is actually opened. These functions are the thin
// bridge the tab buttons and the 10s refresh timer already call.
let topoAttacks = [];
let topoGraph = null;
let topoModule = null;
async function topoEngine() {
if (!topoModule) topoModule = await import('/static/topo_pixi.js');
return topoModule;
}
async function ensureTopoGraph() {
const host = document.getElementById('topoHost');
if (!host) return null;
if (topoGraph && topoGraph.app) return topoGraph;
const mod = await topoEngine();
topoGraph = new mod.TopoGraph(host);
await topoGraph.init();
const lbl = document.getElementById('topoEngine');
if (lbl) lbl.textContent = '· rendered with PixiJS v8';
return topoGraph;
}
async function loadTopo() {
try {
const g = await ensureTopoGraph();
if (!g) return;
const [d, a] = await Promise.all([api('/api/topology'), api('/api/attacks').catch(() => ({events: []}))]);
topoAttacks = a.events || [];
g.setData(d.nodes, d.edges, topoAttacks);
} catch (e) {
toast('Topologi gagal: ' + e.message, true);
}
}
function topoZoom(factor) {
if (topoGraph && topoGraph.app) topoGraph.zoomBy(factor);
}
function topoReset() {
if (topoGraph && topoGraph.app) topoGraph.reset();
}
// Debug/automation hook: the inline script's top-level `let topoGraph` is not a
// window property, so headless tests and devtools have no way to inspect the
// live scene graph. Expose the instance deliberately.
window.__topoProbe = () => topoGraph;
// ---------- Teams ----------
let TEAM_LABELS = {}; // idx -> label, filled by loadTeams (for confirm dialogs)
let TEAM_SELECTED = new Set(); // idx ticked for bulk delete
function renderTeamSelectBar() {
const bar = document.getElementById('bulkDelBar');
if (!bar) return;
const n = TEAM_SELECTED.size;
if (!n) { bar.style.display = 'none'; bar.innerHTML = ''; return; }
const names = [...TEAM_SELECTED].sort((a, b) => a - b)
.map(i => `#${i} ${esc(TEAM_LABELS[i] || '')}`).join(', ');
bar.style.display = 'block';
bar.innerHTML =
`<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">
<b style="color:#f6ad55">${n} tim dipilih</b>
<span style="color:#a0aec0;font-size:12px">${esc(names)}</span>
<button class="danger" onclick="bulkDeleteTeams()">🗑️ Hapus ${n} Tim Sekaligus</button>
<button onclick="clearTeamSelection()">Batalkan pilihan</button>
</div>`;
}
function toggleTeamSelect(idx, on) {
if (on) TEAM_SELECTED.add(idx); else TEAM_SELECTED.delete(idx);
const cb = document.getElementById(`teamSel${idx}`);
if (cb) cb.checked = on;
renderTeamSelectBar();
}
function selectAllTeams(on) {
TEAM_SELECTED.clear();
if (on) for (const k of Object.keys(TEAM_LABELS)) TEAM_SELECTED.add(Number(k));
for (const k of Object.keys(TEAM_LABELS)) {
const cb = document.getElementById(`teamSel${k}`);
if (cb) cb.checked = on && TEAM_SELECTED.has(Number(k));
}
renderTeamSelectBar();
}
function clearTeamSelection() { selectAllTeams(false); }
async function bulkDeleteTeams() {
const idx = [...TEAM_SELECTED].sort((a, b) => a - b);
if (!idx.length) { toast('Pilih minimal satu tim dulu', true); return; }
const names = idx.map(i => `#${i} ${TEAM_LABELS[i] || ''}`).join(', ');
if (!confirm(
`🗑️ HAPUS ${idx.length} TIM SEKALIGUS?\n\n${names}\n\n` +
`Yang akan dihapus (semua tim di atas):\n` +
`• Semua container challenge\n• Receiver + systemd unit\n` +
`• Folder team (port, flag, kredensial)\n• Port firewall UFW\n` +
`• Domain Traefik\n• Skor & riwayat leaderboard\n\n` +
`⚠️ TIDAK BISA dibatalkan.`)) return;
showLoading(`Menghapus ${idx.length} tim (${names})…<br>Compose down 16 service per tim, bisa beberapa menit`);
try {
const d = await api('/api/teams/bulk-delete', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({indices: idx, purge_scores: true})
});
// One team takes ~100s, so poll a single job instead of blocking here.
const final = await pollJob(`/api/teams/bulk-delete/${d.job}`, 1500);
const done = final.done || [];
const errs = final.errors || {};
let msg = `Terhapus ${done.length}/${idx.length} tim`;
if (Object.keys(errs).length) msg += ` · gagal: ${Object.keys(errs).join(', ')}`;
const purged = done.reduce((a, x) =>
a + Object.values(x.purged || {}).reduce((p, q) => p + q, 0), 0);
if (purged) msg += ` · ${purged} skor dibersihkan`;
toast(msg, Object.keys(errs).length > 0);
TEAM_SELECTED.clear();
loadTeams();
if (document.getElementById('view-topo').classList.contains('active')) loadTopo();
} catch (e) { toast('Bulk delete gagal: ' + e.message, true); }
finally { hideLoading(); }
}
async function pollJob(url, everyMs) {
for (;;) {
const j = await api(url);
if (j.state !== 'running') return j;
const el = document.querySelector('#loadingText');
if (el && j.detail) el.innerHTML = `${esc(j.detail)}…<br><span style="font-size:11px">${esc(url.split('/').pop())}</span>`;
await new Promise(r => setTimeout(r, everyMs));
}
}
async function loadTeams() {
try {
const d = await api('/api/teams');
document.getElementById('teamCount').value = d.teams.length;
loadLeaderboard();
TEAM_LABELS = {};
for (const t of d.teams) TEAM_LABELS[t.index] = t.label || ('Team ' + t.index);
// drop selections for teams that no longer exist
for (const i of [...TEAM_SELECTED]) if (!(i in TEAM_LABELS)) TEAM_SELECTED.delete(i);
const grid = document.getElementById('teamsGrid');
if (!d.teams.length) { grid.innerHTML = '<div class="card"><span style="color:#718096">Belum ada team. Set jumlah team untuk auto-create.</span></div>'; renderTeamSelectBar(); return; }
let html = '';
for (const t of d.teams) {
const alive = t.status === 'running';
const dom = t.domain || '';
const sel = TEAM_SELECTED.has(t.index);
html += `<div class="card ${alive ? '' : 'dead'}">
<div class="team-head">
<label style="display:flex;align-items:center;gap:6px;cursor:pointer;user-select:none" title="Pilih untuk hapus massal">
<input type="checkbox" id="teamSel${t.index}" ${sel ? 'checked' : ''}
onchange="toggleTeamSelect(${t.index}, this.checked)">
<span class="ch-name">${esc(t.label || ('Team ' + t.index))}</span>
</label>
<span class="status ${alive ? 'up' : 'down'}">${alive ? '● RUNNING' : '● STOPPED'}</span>
</div>
<div class="kv">
<b>Receiver</b><span>${t.ports.receiver}</span>
<b>Admin</b><span>${esc(t.admin_user)}</span>
<b>SSH user</b><span>ctfuser</span>
<b>Status</b><span>${esc(t.status)}</span>${dom ? `<b>Domain</b><span><a href="https://${esc(dom)}" target="_blank" style="color:#5ad1ff">${esc(dom)} →</a></span>` : ''}
</div>
<div class="btn-row">
<button class="primary" onclick="startTeam(${t.index})">▶ Start</button>
<button class="danger" onclick="stopTeam(${t.index})">⏹ Stop</button>
<button onclick="viewTeamCred(${t.index})">🔑 SSH & Pass</button>
<button onclick="editTeam(${t.index})">✏️ Edit Nama/Domain</button>
<button onclick="openTeamLogs(${t.index})">📜 Logs</button>
<button onclick="randomizeTeam(${t.index})">🎲 Randomize Flag</button>
<button class="danger" onclick="deleteTeam(${t.index})">🗑️ Hapus Team</button>
</div>
</div>`;
}
grid.innerHTML = html;
renderTeamSelectBar();
} catch (e) { toast('Teams gagal: ' + e.message, true); }
}
async function setTeams() {
const n = parseInt(document.getElementById('teamCount').value || '0', 10);
const labels = {}, domains = {};
for (let i = 1; i <= n; i++) {
const inpN = document.getElementById('teamNameInputs')?.querySelector(`input[data-idx="${i}"][data-field="name"]`);
if (inpN && inpN.value.trim()) labels[i] = inpN.value.trim();
const inpD = document.getElementById('teamNameInputs')?.querySelector(`input[data-idx="${i}"][data-field="domain"]`);
if (inpD && inpD.value.trim()) domains[i] = inpD.value.trim();
}
try {
showLoading(`Membuat ${n} team…<br>Generate compose, flags, domain, Traefik config`);
const d = await api('/api/teams/set', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({count: n, labels, domains})});
toast(`Team dibuat: ${d.created.join(', ') || 'tidak ada yang baru'} · total ${d.total}`, false);
loadTeams();
} catch (e) { toast(e.message, true); }
finally { hideLoading(); }
}
function teamCountChanged() {
const n = parseInt(document.getElementById('teamCount').value || '0', 10);
const box = document.getElementById('teamNameInputs');
let html = '<div style="font-size:11px;color:#718096;margin-bottom:4px">Nama team & domain custom (opsional — kosongkan untuk auto dari nama)</div>';
for (let i = 1; i <= n; i++) {
html += `<div style="display:flex;align-items:center;gap:6px">
<span style="color:#718096;font-size:12px;width:60px">Team ${i}</span>
<input data-idx="${i}" data-field="name" placeholder="Nama team (contoh: Cyber Warriors)" style="flex:1;min-width:120px">
<input data-idx="${i}" data-field="domain" placeholder="domain custom (contoh: team-cyber)" style="flex:1;min-width:120px">
<span style="color:#3b4a63;font-size:11px">.attackdefense.imrnes.team</span>
</div>`;
}
box.innerHTML = html;
}
async function editTeam(idx) {
// fetch current team info
let t = null;
try { t = (await api(`/api/team/${idx}/creds`)).team; } catch (e) { toast('Gagal ambil data team', true); return; }
const newLabel = prompt(`✏️ Nama team ${idx} (sekarang: ${t.label || ('Team ' + idx)}):`, t.label || '');
if (newLabel === null) return;
const newDomain = prompt(`🌐 Domain custom team ${idx} (sekarang: ${t.domain || ''} — ketik subdomain saja, contoh: team-cyber):`, (t.domain || '').split('.')[0]);
if (newDomain === null) return;
try {
showLoading('Update nama/domain team…');
const d = await api(`/api/teams/${idx}`, {method:'PUT', headers:{'Content-Type':'application/json'}, body: JSON.stringify({label: newLabel.trim(), domain: newDomain.trim()})});
toast(`Team ${idx} diupdate: ${d.team.label} @ ${d.team.domain}`, false);
loadTeams();
if (document.getElementById('view-topo').classList.contains('active')) loadTopo();
} catch (e) { toast(e.message, true); }
finally { hideLoading(); }
}
async function startTeam(idx) {
const ov = showLoading(`Menyiapkan Team ${idx}…<br>Membangun & start container (bisa butuh 1-3 menit)`);
try { await api('/api/teams/start', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({index: idx})}); toast(`Team ${idx} start`, false); }
catch (e) { toast(e.message, true); }
finally { hideLoading(); setTimeout(loadTeams, 2500); }
}
async function stopTeam(idx) {
const ov = showLoading(`Menghentikan Team ${idx}…`);
try { await api('/api/teams/stop', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({index: idx})}); toast(`Team ${idx} stop`, false); loadTeams(); }
catch (e) { toast(e.message, true); }
finally { hideLoading(); }
}
async function startAllTeams() {
const ov = showLoading('Start SEMUA team…<br>Ini bisa butuh beberapa menit (build + boot + set password)');
try { const d = await api('/api/teams/start', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({})}); toast(`Start semua: ${d.results.filter(r=>r.ok).length}/${d.results.length} ok`, false); setTimeout(loadTeams, 4000); }
catch (e) { toast(e.message, true); }
finally { hideLoading(); }
}
async function stopAllTeams() {
const ov = showLoading('Menghentikan SEMUA team…');
try { const d = await api('/api/teams/stop', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({})}); toast(`Stop semua: ${d.results.filter(r=>r.ok).length}/${d.results.length} ok`, false); loadTeams(); }
catch (e) { toast(e.message, true); }
finally { hideLoading(); }
}
async function resetScores() {
if (!confirm('⚠️ Apakah anda yakin ingin mereset skor?\n\nSemua poin & solve di leaderboard akan DIHAPUS.\nTindakan ini tidak bisa dibatalkan.')) return;
showLoading('Menghapus semua skor…');
try {
const d = await api('/api/reset/scores', {method:'POST', headers:{'Content-Type':'application/json'}, body: '{}'});
toast('Skor direset: semua solve dihapus', false);
} catch (e) { toast(e.message, true); }
finally { hideLoading(); }
}
async function resetEnv() {
if (!confirm('⚠️ Apakah anda yakin ingin mereset environment?\n\nSEMUA team akan DISTOP:\n• Container challenge dihapus\n• Receiver team dihapus\n• Folder team dihapus (perlu create ulang)\n• Skor leaderboard direset\n\nTindakan ini TIDAK BISA dibatalkan.')) return;
showLoading('Reset environment…\nMenghentikan semua team + menghapus container/receiver (beberapa menit)');
try {
const d = await api('/api/reset/environment', {method:'POST', headers:{'Content-Type':'application/json'}, body: '{}'});
const n = (d.stopped || []).length;
toast(`Environment direset: ${n} team dihentikan & dihapus`, false);
setTimeout(() => location.reload(), 1200);
} catch (e) { toast(e.message, true); }
finally { hideLoading(); }
}
async function randomizeTeam(idx) {
if (!confirm(`Randomize SEMUA flag untuk Team ${idx}? Container akan di-recreate.`)) return;
try {
const d = await api(`/api/teams/${idx}/randomize`, {method:'POST'});
const flags = Object.values(d.flags || {}).join('\n');
toast(`Flag Team ${idx} di-randomize!`, false);
console.log('NEW FLAGS team', idx, flags);
setTimeout(loadTeams, 4000);
} catch (e) { toast(e.message, true); }
}
async function deleteTeam(idx) {
// Per-team delete. Deliberately NOT the same as resetEnv: this removes ONE
// team (containers, network, receiver unit, ports, dir, domain) and leaves
// the other teams untouched.
const label = TEAM_LABELS[idx] || ('Team ' + idx);
if (!confirm(
`🗑️ HAPUS PERMANEN Team ${idx} (${label})?\n\n` +
`Yang akan dihapus:\n` +
`• Semua container challenge team ini\n` +
`• Receiver team + systemd unit\n` +
`• Folder team (port, flag, kredensial)\n` +
`• Port firewall UFW team ini\n` +
`• Domain ${label}.attackdefense.imrnes.team\n` +
`• Skor & riwayat di leaderboard\n\n` +
`Tindakan ini TIDAK BISA dibatalkan.\n` +
`Team lain tidak terpengaruh.`)) return;
// second gate: type the team number to confirm
if (prompt(`Ketik angka ${idx} untuk konfirmasi hapus:`)?.trim() !== String(idx)) {
toast('Dibatalkan', false);
return;
}
showLoading(`Menghapus Team ${idx} (${label})…<br>Stop container + receiver, hapus port & domain`);
try {
const d = await api(`/api/teams/${idx}`, {method:'DELETE', headers:{'Content-Type':'application/json'}, body: JSON.stringify({purge_scores: true})});
const n = (d.purged ? Object.values(d.purged).reduce((a, b) => a + b, 0) : 0);
toast(`Team ${idx} (${label}) dihapus: ${(d.steps || []).join(' · ')}${n ? ` · ${n} skor dibersihkan` : ''}`, false);
loadTeams();
if (document.getElementById('view-topo').classList.contains('active')) loadTopo();
} catch (e) { toast('Hapus team gagal: ' + e.message, true); }
finally { hideLoading(); }
}
async function loadLeaderboard() {
try {
const d = await api('/api/leaderboard');
const body = document.getElementById('lbBody');
if (!body) return;
if (!d.teams.length) { body.innerHTML = '<tr><td colspan="4" style="color:#718096">Belum ada solve.</td></tr>'; return; }
body.innerHTML = d.teams.map((t,i) => `<tr>
<td>${i+1}</td><td>${esc(t.name)}</td><td>${t.solves}</td>
<td>${t.challs.map(c=>`<span class="chip">${esc(c)}</span>`).join('')}</td>
</tr>`).join('');
} catch (e) {}
}
setInterval(loadLeaderboard, 15000);
async function viewTeamCred(idx) {
try {
const t = await api(`/api/teams/${idx}/creds`);
let html = `<div class="kv">
<b>Label</b><span>${esc(t.team.label || ('Team ' + idx))}</span>
<b>Admin receiver</b><span>${esc(t.team.admin_user)} / ${esc(t.team.admin_pass)}</span>
<b>Receiver port</b><span>${esc(t.team.ports.receiver)}</span>
<b>SSH user</b><span>ctfuser</span>
<b>SSH pass</b><span>${esc(t.team.ssh_pass)}</span>
</div><div style="margin-top:10px"><b style="color:#5ad1ff">Per-challenge SSH:</b></div>`;
for (const [name, p] of Object.entries(t.team.ports)) {
if (name === 'receiver' || name === 'panel') continue;
html += `<div class="kv" style="margin-top:6px">
<b>${name}</b><span>ssh ctfuser@${name}.attackdefense.imrnes.team -p ${p.ssh} &nbsp;·&nbsp; pass: ${esc(t.team.chall_passwords[name])}</span>
</div>`;
}
document.getElementById('mtcTitle').textContent = `Kredensial Team ${idx}`;
document.getElementById('mtcBody').innerHTML = html;
document.getElementById('modalTeamCred').classList.add('open');
} catch (e) { toast(e.message, true); }
}
function openTeamLogs(idx) {
document.getElementById('logTeam').value = idx;
showView('logs'); loadLogs();
}
// ---------- Logs ----------
async function loadLogs() {
const idx = document.getElementById('logTeam').value;
const svc = document.getElementById('logService').value;
await populateLogTeams();
if (!idx) { document.getElementById('logsBox').textContent = 'Pilih team dulu.'; return; }
try {
const d = await api(`/api/teams/${idx}/logs` + (svc ? `?service=${svc}` : '') + '&tail=200');
let out = '';
for (const [name, log] of Object.entries(d.logs)) {
out += `\n━━━ ${name} (team ${idx}) ━━━\n${log}\n`;
}
document.getElementById('logsBox').textContent = out || '(kosong)';
} catch (e) { document.getElementById('logsBox').textContent = 'Error: ' + e.message; }
}
async function populateLogTeams() {
try {
const d = await api('/api/teams');
const sel = document.getElementById('logTeam');
if (sel.options.length === 0 || sel.value === '') {
sel.innerHTML = d.teams.map(t => `<option value="${t.index}">Team ${t.index}</option>`).join('');
}
} catch (e) {}
}
// ---------- Creds ----------
async function loadCreds() {
try {
const d = await api('/api/teams');
const grid = document.getElementById('credsGrid');
if (!d.teams.length) { grid.innerHTML = '<div class="card"><span style="color:#718096">Belum ada team.</span></div>'; return; }
let html = '';
for (const t of d.teams) {
html += `<div class="card">
<div class="team-head">
<span class="ch-name">${esc(t.label || ('Team ' + t.index))}</span>
<button style="font-size:11px" onclick="viewTeamCred(${t.index})">Lihat Semua</button>
</div>
<div class="kv">
<b>Admin</b><span>${esc(t.admin_user)} / ${esc(t.admin_pass)}</span>
<b>SSH</b><span>ctfuser / ${esc(t.ssh_pass)}</span>
</div>
</div>`;
}
grid.innerHTML = html;
} catch (e) { toast(e.message, true); }
}
// ---------- Target Matrix ----------
async function loadTargetMatrix() {
const box = document.getElementById('targetMatrix');
try {
const d = await api('/api/targets');
const targets = d.targets || [];
if (!targets.length) { box.textContent = 'Belum ada team.'; return; }
// group by team
const rows = [];
let curTeam = null;
for (const t of targets) {
if (t.team !== curTeam) {
rows.push(`\n[${esc(t.team_label || ('Team ' + t.team))}]`);
curTeam = t.team;
}
rows.push(` ${esc(t.domain)}:${t.port} (${esc(t.challenge)})`);
}
box.textContent = rows.join('\n');
} catch (e) { box.textContent = 'Gagal: ' + e.message; }
}
// ---------- misc ----------
function closeModal(id) { document.getElementById(id).classList.remove('open'); }
async function logout() {
await fetch('/api/logout', {method:'POST'});
location.href = '/login';
}
['modalFlag','modalCred','modalTeamCred'].forEach(id => {
document.getElementById(id).addEventListener('click', e => { if (e.target === e.currentTarget) closeModal(id); });
});
function tick() {
document.getElementById('clock').textContent = new Date().toLocaleTimeString('id-ID');
}
setInterval(tick, 1000); tick();
refresh();
setInterval(refresh, REFRESH_MS);
</script>
</body>
</html>