327 lines
19 KiB
HTML
Executable File
327 lines
19 KiB
HTML
Executable File
<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||
<title>Tracking Command Center — Arsenal</title>
|
||
<style>
|
||
*{margin:0;padding:0;box-sizing:border-box}
|
||
:root{--bg:#f8fafc;--s:#ffffff;--c:#f1f5f9;--cy:#22d3ee;--gn:#10b981;--rd:#ef4444;--or:#f59e0b;--pk:#ec4899;--pu:#a78bfa;--tx:#1e293b;--t2:#64748b;--b:#e2e8f0}
|
||
body{background:var(--bg);color:var(--tx);font-family:'DM Sans',system-ui,sans-serif;padding:20px}
|
||
.hdr{display:flex;align-items:center;gap:12px;margin-bottom:20px}
|
||
.hdr h1{font-size:20px;font-weight:700}.hdr .badge{background:var(--gn);color:#000;padding:2px 10px;border-radius:12px;font-size:11px;font-weight:700}
|
||
.hdr .badge.warn{background:var(--or)}.hdr .badge.err{background:var(--rd);color:#fff}
|
||
.tabs{display:flex;gap:6px;margin-bottom:16px}
|
||
.tab{padding:6px 14px;background:var(--s);border:1px solid var(--b);border-radius:6px;cursor:pointer;font-size:12px;color:var(--t2);transition:all .2s}
|
||
.tab.active,.tab:hover{background:var(--c);color:var(--cy);border-color:var(--cy)}
|
||
.g4{display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin-bottom:16px}
|
||
.g3{display:grid;grid-template-columns:repeat(3,1fr);gap:12px;margin-bottom:16px}
|
||
.g2{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:16px}
|
||
.card{background:var(--s);border:1px solid var(--b);border-radius:8px;padding:14px}
|
||
.kpi{text-align:center}.kpi .v{font-size:28px;font-weight:800}.kpi .l{font-size:10px;color:var(--t2);text-transform:uppercase;letter-spacing:1px;margin-top:2px}
|
||
.kpi.cyan .v{color:var(--cy)}.kpi.green .v{color:var(--gn)}.kpi.pink .v{color:var(--pk)}.kpi.orange .v{color:var(--or)}.kpi.purple .v{color:var(--pu)}
|
||
table{width:100%;border-collapse:collapse;font-size:12px}
|
||
th{text-align:left;padding:6px 8px;color:var(--t2);font-size:10px;text-transform:uppercase;border-bottom:1px solid var(--b)}
|
||
td{padding:6px 8px;border-bottom:1px solid rgba(30,41,59,.5)}
|
||
.dot{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:4px}.dot-g{background:var(--gn)}.dot-r{background:var(--rd)}.dot-o{background:var(--or)}
|
||
.btn{padding:6px 14px;border:1px solid var(--b);background:var(--s);color:var(--tx);border-radius:6px;cursor:pointer;font-size:11px;transition:all .2s}
|
||
.btn:hover{border-color:var(--cy);color:var(--cy)}
|
||
.btn-action{background:linear-gradient(135deg,rgba(34,211,238,.15),rgba(168,85,247,.15));border-color:var(--cy)}
|
||
.log{background:#f8fafc;border-radius:6px;padding:10px;font-family:monospace;font-size:11px;max-height:300px;overflow-y:auto;line-height:1.6}
|
||
.log .ok{color:var(--gn)}.log .err{color:var(--rd)}.log .info{color:var(--cy)}.log .warn{color:var(--or)}
|
||
.flow-box{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin:12px 0}
|
||
.flow-step{background:var(--c);border:1px solid var(--b);border-radius:6px;padding:8px 12px;font-size:11px;position:relative}
|
||
.flow-step.active{border-color:var(--gn);box-shadow:0 0 8px rgba(16,185,129,.3)}
|
||
.flow-step.error{border-color:var(--rd);box-shadow:0 0 8px rgba(239,68,68,.3)}
|
||
.flow-arrow{color:var(--t2);font-size:16px}
|
||
.section{display:none}.section.active{display:block}
|
||
.srv{display:flex;align-items:center;gap:8px;padding:8px;background:var(--c);border-radius:6px;margin-bottom:8px}
|
||
.srv-dot{width:10px;height:10px;border-radius:50%}.srv-name{font-weight:600;font-size:13px}.srv-ip{color:var(--t2);font-size:11px}
|
||
pre{white-space:pre-wrap;word-break:break-all}
|
||
</style></head><body>
|
||
<div class="hdr">
|
||
<span style="font-size:24px">📡</span>
|
||
<h1>Tracking Command Center</h1>
|
||
<span class="badge" id="srvBadge">CHECKING...</span>
|
||
<span style="flex:1"></span>
|
||
<span style="color:var(--t2);font-size:11px" id="lastUpdate"></span>
|
||
<button class="btn btn-action" onclick="fullRefresh()">🔄 Refresh All</button>
|
||
</div>
|
||
|
||
<div class="tabs">
|
||
<div class="tab active" onclick="showTab('overview')">📊 Overview</div>
|
||
<div class="tab" onclick="showTab('endpoints')">🔌 Endpoints</div>
|
||
<div class="tab" onclick="showTab('e2e')">🔗 E2E Flow Test</div>
|
||
<div class="tab" onclick="showTab('activity')">📋 Activity</div>
|
||
<div class="tab" onclick="showTab('servers')">🖥️ Servers</div>
|
||
</div>
|
||
|
||
<!-- OVERVIEW -->
|
||
<div class="section active" id="sec-overview">
|
||
<div class="g4">
|
||
<div class="card kpi cyan"><div class="v" id="kOpens">—</div><div class="l">Opens</div></div>
|
||
<div class="card kpi green"><div class="v" id="kClicks">—</div><div class="l">Clicks</div></div>
|
||
<div class="card kpi pink"><div class="v" id="kLeads">—</div><div class="l">Leads</div></div>
|
||
<div class="card kpi orange"><div class="v" id="kUnsubs">—</div><div class="l">Unsubs</div></div>
|
||
</div>
|
||
<div class="g2">
|
||
<div class="card">
|
||
<h3 style="font-size:13px;margin-bottom:10px">🖥️ Server Status</h3>
|
||
<div class="srv"><div class="srv-dot" id="srvOvh" style="background:var(--t2)"></div><div><div class="srv-name">OVH Tracking</div><div class="srv-ip">151.80.235.110 — nginx + php7.4-fpm</div></div></div>
|
||
<div class="srv"><div class="srv-dot" id="srvHtz" style="background:var(--t2)"></div><div><div class="srv-name">Hetzner WEVADS</div><div class="srv-ip">89.167.40.150 — PostgreSQL DB</div></div></div>
|
||
<div id="srvDetail" style="font-size:11px;color:var(--t2);margin-top:8px"></div>
|
||
</div>
|
||
<div class="card">
|
||
<h3 style="font-size:13px;margin-bottom:10px">📊 Tracking Flow</h3>
|
||
<div class="flow-box">
|
||
<div class="flow-step" id="fEmail">📧 Email Sent</div><span class="flow-arrow">→</span>
|
||
<div class="flow-step" id="fPixel">👁️ Pixel Open</div><span class="flow-arrow">→</span>
|
||
<div class="flow-step" id="fClick">🖱️ Click</div><span class="flow-arrow">→</span>
|
||
<div class="flow-step" id="fOffer">🎯 Offer</div><span class="flow-arrow">→</span>
|
||
<div class="flow-step" id="fLead">💰 Lead</div>
|
||
</div>
|
||
<div style="font-size:11px;color:var(--t2);margin-top:8px">
|
||
OVH captures open/click → logs to Hetzner DB → redirects to offer URL<br>
|
||
Domain: <strong style="color:var(--cy)">culturellemejean.charity</strong>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="card">
|
||
<h3 style="font-size:13px;margin-bottom:10px">📋 Recent Activity</h3>
|
||
<table><thead><tr><th>Time</th><th>Type</th><th>Country</th><th>Device</th><th>Browser</th></tr></thead>
|
||
<tbody id="actTable"></tbody></table>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ENDPOINTS -->
|
||
<div class="section" id="sec-endpoints">
|
||
<div class="card" style="margin-bottom:12px">
|
||
<h3 style="font-size:13px;margin-bottom:10px">🔌 Endpoint Health Check</h3>
|
||
<button class="btn btn-action" onclick="testEndpoints()" style="margin-bottom:12px">▶ Run All Tests</button>
|
||
<div class="log" id="epLog">Click "Run All Tests" to check all tracking endpoints...</div>
|
||
</div>
|
||
<div class="card">
|
||
<h3 style="font-size:13px;margin-bottom:10px">📡 Endpoint Map</h3>
|
||
<table><thead><tr><th>Endpoint</th><th>URL</th><th>Purpose</th><th>Status</th></tr></thead>
|
||
<tbody id="epTable">
|
||
<tr><td>Open Pixel</td><td style="font-family:monospace;font-size:10px">/track.php?t=TOKEN&e=open</td><td>1x1 GIF tracking pixel</td><td id="epOpen">—</td></tr>
|
||
<tr><td>Click Redirect</td><td style="font-family:monospace;font-size:10px">/track.php?t=TOKEN&e=click&u=BASE64_URL</td><td>302 redirect to offer</td><td id="epClick">—</td></tr>
|
||
<tr><td>Click (legacy)</td><td style="font-family:monospace;font-size:10px">/click.php?url=BASE64&oid=ID&e=EMAIL</td><td>302 redirect (old format)</td><td id="epClickLeg">—</td></tr>
|
||
<tr><td>Lead</td><td style="font-family:monospace;font-size:10px">/lead.php</td><td>Lead postback</td><td id="epLead">—</td></tr>
|
||
<tr><td>Unsubscribe</td><td style="font-family:monospace;font-size:10px">/cl/TYPE/PARAMS</td><td>Unsubscribe handler</td><td id="epUnsub">—</td></tr>
|
||
</tbody></table>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- E2E FLOW TEST -->
|
||
<div class="section" id="sec-e2e">
|
||
<div class="card">
|
||
<h3 style="font-size:13px;margin-bottom:10px">🔗 End-to-End Flow Test</h3>
|
||
<p style="font-size:11px;color:var(--t2);margin-bottom:12px">Tests the complete tracking chain: Email → Open Pixel → Click → Offer Redirect → Lead capture</p>
|
||
<button class="btn btn-action" onclick="runE2E()" style="margin-bottom:12px">▶ Run E2E Test</button>
|
||
<div class="flow-box" id="e2eFlow">
|
||
<div class="flow-step" id="e2e1">1️⃣ Pixel Request</div><span class="flow-arrow">→</span>
|
||
<div class="flow-step" id="e2e2">2️⃣ DB Insert</div><span class="flow-arrow">→</span>
|
||
<div class="flow-step" id="e2e3">3️⃣ Click Redirect</div><span class="flow-arrow">→</span>
|
||
<div class="flow-step" id="e2e4">4️⃣ Offer Landing</div><span class="flow-arrow">→</span>
|
||
<div class="flow-step" id="e2e5">5️⃣ Lead Callback</div>
|
||
</div>
|
||
<div class="log" id="e2eLog">Click "Run E2E Test" to verify the complete tracking chain...</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ACTIVITY -->
|
||
<div class="section" id="sec-activity">
|
||
<div class="g3">
|
||
<div class="card kpi purple"><div class="v" id="kToday">—</div><div class="l">Today</div></div>
|
||
<div class="card kpi cyan"><div class="v" id="k7d">—</div><div class="l">Last 7 days</div></div>
|
||
<div class="card kpi green"><div class="v" id="kTotal">—</div><div class="l">All time</div></div>
|
||
</div>
|
||
<div class="g2">
|
||
<div class="card">
|
||
<h3 style="font-size:13px;margin-bottom:10px">🌍 By Country</h3>
|
||
<table><thead><tr><th>Country</th><th>Opens</th><th>Clicks</th></tr></thead><tbody id="countryTable"></tbody></table>
|
||
</div>
|
||
<div class="card">
|
||
<h3 style="font-size:13px;margin-bottom:10px">📱 By Device</h3>
|
||
<table><thead><tr><th>Device</th><th>Count</th><th>%</th></tr></thead><tbody id="deviceTable"></tbody></table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- SERVERS -->
|
||
<div class="section" id="sec-servers">
|
||
<div class="g2">
|
||
<div class="card">
|
||
<h3 style="font-size:13px;margin-bottom:10px">📡 OVH Tracking Server</h3>
|
||
<div id="ovhInfo" class="log">Loading...</div>
|
||
</div>
|
||
<div class="card">
|
||
<h3 style="font-size:13px;margin-bottom:10px">🖥️ Hetzner Database</h3>
|
||
<div id="htzInfo" class="log">Loading...</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
const API = '/api/sentinel-brain.php';
|
||
const TRACK_API = '/api/tracking-status.php';
|
||
|
||
function showTab(t) {
|
||
document.querySelectorAll('.section').forEach(s=>s.classList.remove('active'));
|
||
document.querySelectorAll('.tab').forEach(s=>s.classList.remove('active'));
|
||
document.getElementById('sec-'+t).classList.add('active');
|
||
event.target.classList.add('active');
|
||
}
|
||
|
||
async function fetchJSON(url) {
|
||
try { const r = await fetch(url); return await r.json(); } catch(e) { return null; }
|
||
}
|
||
|
||
async function loadStats() {
|
||
// Tracking stats from global dashboard API
|
||
const d = await fetchJSON('/tracking-global-dashboard.php?api=stats');
|
||
if (d) {
|
||
document.getElementById('kOpens').textContent = d.opens ?? '—';
|
||
document.getElementById('kClicks').textContent = d.clicks ?? '—';
|
||
document.getElementById('kLeads').textContent = d.leads ?? '—';
|
||
document.getElementById('kUnsubs').textContent = d.unsubscribes ?? '—';
|
||
}
|
||
// Fallback: direct API
|
||
const s = await fetchJSON(API+'?action=arch');
|
||
if (s) {
|
||
const db = s.databases || {};
|
||
document.getElementById('kTotal').textContent = (db.tracking_events || 0);
|
||
}
|
||
}
|
||
|
||
async function loadServers() {
|
||
// Test OVH via sentinel remote
|
||
const ovh = await fetchJSON(API+'?action=exec_remote&server=ovh&cmd='+encodeURIComponent('uptime;df -h /|tail -1;sudo systemctl is-active nginx php7.4-fpm;curl -s -o /dev/null -w "HTTP:%{http_code}" http://127.0.0.1/'));
|
||
if (ovh?.ok) {
|
||
document.getElementById('srvOvh').style.background = 'var(--gn)';
|
||
document.getElementById('srvBadge').textContent = 'ONLINE';
|
||
document.getElementById('srvBadge').className = 'badge';
|
||
document.getElementById('ovhInfo').innerHTML = '<pre class="ok">'+ovh.output+'</pre>';
|
||
document.getElementById('srvDetail').innerHTML = '<span class="ok">✅ OVH: nginx+fpm active</span>';
|
||
} else {
|
||
document.getElementById('srvOvh').style.background = 'var(--rd)';
|
||
document.getElementById('srvBadge').textContent = 'OFFLINE';
|
||
document.getElementById('srvBadge').className = 'badge err';
|
||
document.getElementById('ovhInfo').innerHTML = '<pre class="err">'+(ovh?.error||'Connection failed')+'</pre>';
|
||
}
|
||
// Hetzner is always local
|
||
document.getElementById('srvHtz').style.background = 'var(--gn)';
|
||
const htz = await fetchJSON(API+'?action=exec&cmd='+encodeURIComponent('sudo -u postgres psql adx_system -c "SELECT count(*) FROM actions.clicks" 2>&1;echo "---";uptime'));
|
||
if (htz?.ok) document.getElementById('htzInfo').innerHTML = '<pre class="ok">'+htz.output+'</pre>';
|
||
}
|
||
|
||
async function loadActivity() {
|
||
const d = await fetchJSON(API+'?action=exec&cmd='+encodeURIComponent("sudo -u postgres psql -t adx_system -c \"SELECT json_agg(r) FROM (SELECT action_time::text,country_code,device_type,browser_name FROM actions.clicks ORDER BY action_time DESC LIMIT 15) r\""));
|
||
if (d?.ok) {
|
||
try {
|
||
const rows = JSON.parse(d.output.trim());
|
||
if (rows) {
|
||
document.getElementById('actTable').innerHTML = rows.map(r=>`<tr><td>${(r.action_time||'').substring(0,16)}</td><td>🖱️ click</td><td>${r.country_code||'—'}</td><td>${r.device_type||'—'}</td><td>${r.browser_name||'—'}</td></tr>`).join('');
|
||
}
|
||
} catch(e) {}
|
||
}
|
||
}
|
||
|
||
async function testEndpoints() {
|
||
const log = document.getElementById('epLog');
|
||
log.innerHTML = '<span class="info">🔍 Testing all tracking endpoints...</span>\n';
|
||
|
||
const tests = [
|
||
{name:'OVH HTTP',id:'epOpen',url:API+'?action=exec_remote&server=ovh&cmd='+encodeURIComponent('curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1/track.php?t=test123&e=open')},
|
||
{name:'Click Redirect',id:'epClick',url:API+'?action=exec_remote&server=ovh&cmd='+encodeURIComponent('curl -s -o /dev/null -w "%{http_code}:%{redirect_url}" -L http://127.0.0.1/track.php?t=test123&e=click&u='+btoa('https://google.com'))},
|
||
{name:'Click Legacy',id:'epClickLeg',url:API+'?action=exec_remote&server=ovh&cmd='+encodeURIComponent('curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1/click.php?url='+btoa('https://google.com')+'&oid=test')},
|
||
{name:'Lead',id:'epLead',url:API+'?action=exec_remote&server=ovh&cmd='+encodeURIComponent('curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1/lead.php')},
|
||
{name:'Unsub',id:'epUnsub',url:API+'?action=exec_remote&server=ovh&cmd='+encodeURIComponent('curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1/cl/1_md/1/1/1/0/0')},
|
||
];
|
||
|
||
for (const t of tests) {
|
||
log.innerHTML += `<span class="info">Testing ${t.name}...</span>\n`;
|
||
const r = await fetchJSON(t.url);
|
||
const code = r?.output?.trim();
|
||
const ok = code && (code.startsWith('200') || code.startsWith('302'));
|
||
document.getElementById(t.id).innerHTML = ok ? '<span class="dot dot-g"></span>'+code : '<span class="dot dot-r"></span>'+(code||'FAIL');
|
||
log.innerHTML += ok ? `<span class="ok">✅ ${t.name}: ${code}</span>\n` : `<span class="err">❌ ${t.name}: ${code||'ERROR'}</span>\n`;
|
||
}
|
||
log.innerHTML += '\n<span class="info">✅ Endpoint testing complete</span>';
|
||
}
|
||
|
||
async function runE2E() {
|
||
const log = document.getElementById('e2eLog');
|
||
const steps = ['e2e1','e2e2','e2e3','e2e4','e2e5'];
|
||
steps.forEach(s=>document.getElementById(s).className='flow-step');
|
||
log.innerHTML = '<span class="info">🔗 Starting E2E tracking flow test...</span>\n';
|
||
|
||
// Step 1: Send open pixel
|
||
log.innerHTML += '\n<span class="info">1️⃣ Sending tracking pixel request...</span>\n';
|
||
const testId = 'e2e_test_'+Date.now();
|
||
let r = await fetchJSON(API+'?action=exec_remote&server=ovh&cmd='+encodeURIComponent(`curl -s -o /dev/null -w "%{http_code}" "http://127.0.0.1/track.php?t=${testId}&e=open"`));
|
||
if (r?.output?.trim()==='200') {
|
||
document.getElementById('e2e1').classList.add('active');
|
||
log.innerHTML += '<span class="ok">✅ Pixel returned 200 (1x1 GIF)</span>\n';
|
||
} else {
|
||
document.getElementById('e2e1').classList.add('error');
|
||
log.innerHTML += '<span class="err">❌ Pixel failed: '+(r?.output||'timeout')+'</span>\n'; return;
|
||
}
|
||
|
||
// Step 2: Check DB insert
|
||
log.innerHTML += '\n<span class="info">2️⃣ Checking database insert...</span>\n';
|
||
r = await fetchJSON(API+'?action=exec&cmd='+encodeURIComponent(`sudo -u postgres psql -t adx_system -c "SELECT count(*) FROM admin.tracking_events WHERE tracking_id='${testId}'"`));
|
||
const cnt = parseInt(r?.output?.trim());
|
||
if (cnt > 0) {
|
||
document.getElementById('e2e2').classList.add('active');
|
||
log.innerHTML += `<span class="ok">✅ DB insert confirmed (${cnt} record)</span>\n`;
|
||
} else {
|
||
document.getElementById('e2e2').classList.add('error');
|
||
log.innerHTML += '<span class="warn">⚠️ DB insert not found (OVH→Hetzner DB connection may be blocked)</span>\n';
|
||
}
|
||
|
||
// Step 3: Click redirect
|
||
log.innerHTML += '\n<span class="info">3️⃣ Testing click redirect...</span>\n';
|
||
const testUrl = btoa('https://www.google.com');
|
||
r = await fetchJSON(API+'?action=exec_remote&server=ovh&cmd='+encodeURIComponent(`curl -s -o /dev/null -w "%{http_code} %{redirect_url}" "http://127.0.0.1/track.php?t=${testId}&e=click&u=${testUrl}"`));
|
||
if (r?.output?.includes('302')) {
|
||
document.getElementById('e2e3').classList.add('active');
|
||
log.innerHTML += '<span class="ok">✅ Click redirect: '+r.output.trim()+'</span>\n';
|
||
} else {
|
||
document.getElementById('e2e3').classList.add('error');
|
||
log.innerHTML += '<span class="err">❌ Click redirect failed: '+(r?.output||'timeout')+'</span>\n';
|
||
}
|
||
|
||
// Step 4: Offer landing (verify redirect target is reachable)
|
||
log.innerHTML += '\n<span class="info">4️⃣ Verifying offer URL reachable...</span>\n';
|
||
r = await fetchJSON(API+'?action=exec_remote&server=ovh&cmd='+encodeURIComponent(`curl -s -o /dev/null -w "%{http_code}" -L "http://127.0.0.1/track.php?t=${testId}&e=click&u=${testUrl}" --max-time 5`));
|
||
if (r?.output?.includes('200')) {
|
||
document.getElementById('e2e4').classList.add('active');
|
||
log.innerHTML += '<span class="ok">✅ Offer page loaded: HTTP '+r.output.trim()+'</span>\n';
|
||
} else {
|
||
document.getElementById('e2e4').classList.add('error');
|
||
log.innerHTML += '<span class="warn">⚠️ Offer page: '+(r?.output||'timeout')+'</span>\n';
|
||
}
|
||
|
||
// Step 5: Lead callback test
|
||
log.innerHTML += '\n<span class="info">5️⃣ Testing lead postback...</span>\n';
|
||
r = await fetchJSON(API+'?action=exec_remote&server=ovh&cmd='+encodeURIComponent(`curl -s -o /dev/null -w "%{http_code}" "http://127.0.0.1/lead.php?oid=${testId}&payout=1.50"`));
|
||
if (r?.output?.trim()==='200'||r?.output?.trim()==='302') {
|
||
document.getElementById('e2e5').classList.add('active');
|
||
log.innerHTML += '<span class="ok">✅ Lead postback: HTTP '+r.output.trim()+'</span>\n';
|
||
} else {
|
||
document.getElementById('e2e5').classList.add('error');
|
||
log.innerHTML += '<span class="warn">⚠️ Lead postback: '+(r?.output||'N/A')+'</span>\n';
|
||
}
|
||
|
||
log.innerHTML += '\n<span class="info">🏁 E2E test complete</span>';
|
||
}
|
||
|
||
async function fullRefresh() {
|
||
document.getElementById('lastUpdate').textContent = 'Refreshing...';
|
||
await Promise.all([loadStats(), loadServers(), loadActivity()]);
|
||
document.getElementById('lastUpdate').textContent = 'Updated: '+new Date().toLocaleTimeString();
|
||
}
|
||
|
||
fullRefresh();
|
||
setInterval(fullRefresh, 60000);
|
||
</script>
|
||
</body></html>
|