Files
attack-defense-platform/panel/static/index.html
T
root cf47770798 Fix the topology freezing and make team drags carry their challenges
User report: the graph "disappeared". Reproduced, and the cause was not the
graph at all — the panel's main thread was blocked hard enough that the browser
stopped responding to clicks.

Root cause, found by measuring rather than guessing:
  rAF 2 FPS, setTimeout(0) lag 1353ms, with the graph rendering correctly the
  whole time. The scene was repainting continuously at 60fps even though it is
  static between the 10s data refreshes. Each repaint cost ~305ms on this
  host's software GL, so the main thread never got a free slot.

Fixes:
- Render on demand. The ticker is registered but not started; it runs only while
  an attack pulse is in flight or the user is dragging, and stops once the scene
  is quiet. applyView() (the single funnel for pan/zoom/drag/refit) repaints
  synchronously. Result: 2 FPS -> 73 FPS, 1353ms -> 2ms lag. That is faster than
  a blank page in the same harness (29 FPS), which confirms the ticker was the
  cost, not the scene complexity.
- Stop rebuilding Pixi objects every refresh. _redrawEdges destroyed and
  recreated ~70 Graphics + ~36 Text each cycle; every new Text allocates a
  canvas, rasterises glyphs and uploads a texture. Edges are now kept per
  from->to key and only their geometry is redrawn; a label's text is re-set only
  when the string actually changes. Same for node titles/subtitles.
- Bind tooltip handlers once, at node creation. Re-binding inside the refresh
  loop added a pointerover listener every 10s, so a single hover after an hour
  fired thousands of handlers.

Two correctness bugs fixed while in there:
- init() race. The Topology tab button calls showView('topo') AND loadTopo(), and
  showView() itself calls loadTopo(), so two ran concurrently. The re-entry guard
  tested `topoGraph && topoGraph.app`, but topoGraph was assigned BEFORE the
  awaited init(), so app was still null and a second renderer was built. Both
  cleared host.innerHTML and appended their own canvas, so the last init to
  finish won the DOM while the bridge still pointed at the other — a live canvas
  that was no longer on the page. Now guarded by a single-flight promise, and
  the instance is published only after init() resolves.
- Dropped the dead topoLoaded flag left over from the SVG renderer.

Hierarchical drag: dragging a team now carries its 16 challenge nodes with it.
The parent->children index is built from the edge list, the same source the
lines are drawn from, so the drag hierarchy cannot disagree with the picture;
challenges missing from the edge list still attach by id prefix. Children
translate rigidly (verified: 0.000px deviation across all 16).

Verified from a cold panel restart: all 5 topology tests pass, 73 FPS, 2ms
lag, 37 nodes drawn, graph framed at 4 viewport widths, no page errors, and
platform SLA unregressed at 32/32.
2026-09-27 01:17:56 +08:00

1028 lines
48 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 (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;
// Single-flight guard. The Topology tab button calls `showView('topo'); loadTopo()`
// and showView() also calls loadTopo() for this view, so two loadTopo() run
// concurrently on every click. Previously the re-entry check was
// `if (topoGraph && topoGraph.app)`, but `topoGraph` was assigned BEFORE the
// awaited init(), so `app` was still null and the second call built a SECOND
// renderer. Both ran `host.innerHTML = ''` and appended their own canvas, so
// whichever init() finished last won the DOM while `topoGraph` still referenced
// the other — a live canvas that was no longer on the page. Track the in-flight
// promise so every caller awaits the same init.
let topoInitPromise = null;
let topoLoadPromise = 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;
if (topoInitPromise) return topoInitPromise; // an init is already running
topoInitPromise = (async () => {
try {
const mod = await topoEngine();
const g = new mod.TopoGraph(host);
await g.init();
topoGraph = g; // publish only when ready
const lbl = document.getElementById('topoEngine');
if (lbl) lbl.textContent = '· rendered with PixiJS v8';
return g;
} finally {
topoInitPromise = null;
}
})();
return topoInitPromise;
}
async function loadTopo() {
// The 10s timer plus the tab click both land here; serialise so two refreshes
// never interleave a scene rebuild.
if (topoLoadPromise) return topoLoadPromise;
topoLoadPromise = (async () => {
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);
} finally {
topoLoadPromise = null;
}
})();
return topoLoadPromise;
}
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>