/* ============================================================ TELEMETRY DASHBOARD — live ops frontend ============================================================ */ (() => { 'use strict'; const API_BASE = '/api/telemetry'; const POLL_OVERVIEW = 5000; const POLL_HISTORY = 60000; const POLL_TAIL = 5000; const POLL_GEO = 60000; const POLL_ALERTS = 10000; const POLL_VISITORS = 15000; const POLL_COMMITS = 120000; // ─── State ────────────────────────────────────────── const state = { lastOverview: null, lastHistory: null, lastAlertSig: '', lastSync: 0, alarm: false, audioCtx: null, seenTailKeys: new Set(), }; // ─── Utils ────────────────────────────────────────── const $ = (id) => document.getElementById(id); const fmt = { bytes(n) { if (!n) return '0 B'; const u = ['B', 'KB', 'MB', 'GB', 'TB']; const i = Math.min(Math.floor(Math.log(n) / Math.log(1024)), u.length - 1); return (n / Math.pow(1024, i)).toFixed(1) + ' ' + u[i]; }, bps(n) { return fmt.bytes(n) + '/s'; }, uptime(s) { if (!s) return '—'; s = Math.floor(s); const d = Math.floor(s / 86400); const h = Math.floor((s % 86400) / 3600); const m = Math.floor((s % 3600) / 60); if (d > 0) return `${d}d ${h}h ${m}m`; if (h > 0) return `${h}h ${m}m`; return `${m}m`; }, numGrp(n) { return (n || 0).toLocaleString('en-GB'); }, iso(iso) { if (!iso) return '—'; try { const d = new Date(iso); return d.toLocaleString('en-GB', { hour12: false }); } catch { return iso; } }, ago(iso) { if (!iso) return '—'; try { const s = Math.floor((Date.now() - new Date(iso).getTime()) / 1000); if (s < 60) return `${s}s ago`; if (s < 3600) return `${Math.floor(s / 60)}m ago`; if (s < 86400) return `${Math.floor(s / 3600)}h ago`; return `${Math.floor(s / 86400)}d ago`; } catch { return iso; } }, escape(s) { return String(s || '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); } }; async function fetchJSON(url) { try { const r = await fetch(url, { credentials: 'same-origin' }); if (!r.ok) throw new Error('HTTP ' + r.status); return await r.json(); } catch (e) { console.warn('[telemetry] fetch failed', url, e); return null; } } // ─── Canvas gauge ─────────────────────────────────── function drawGauge(canvas, value, label) { if (!canvas) return; const ctx = canvas.getContext('2d'); const dpr = window.devicePixelRatio || 1; const size = canvas.clientWidth; canvas.width = size * dpr; canvas.height = size * dpr; ctx.scale(dpr, dpr); const cx = size / 2, cy = size / 2; const r = size / 2 - 6; ctx.clearRect(0, 0, size, size); // Background arc ctx.beginPath(); ctx.arc(cx, cy, r, Math.PI * 0.75, Math.PI * 2.25); ctx.lineWidth = 6; ctx.strokeStyle = '#1a1a1a'; ctx.stroke(); // Tick marks for (let i = 0; i <= 10; i++) { const a = Math.PI * 0.75 + (Math.PI * 1.5 * i / 10); const r1 = r - 2, r2 = r + 4; ctx.beginPath(); ctx.moveTo(cx + Math.cos(a) * r1, cy + Math.sin(a) * r1); ctx.lineTo(cx + Math.cos(a) * r2, cy + Math.sin(a) * r2); ctx.lineWidth = 1; ctx.strokeStyle = '#2a2a2a'; ctx.stroke(); } // Value arc const pct = Math.max(0, Math.min(100, value)) / 100; const endA = Math.PI * 0.75 + Math.PI * 1.5 * pct; const color = value > 90 ? '#ff2d2d' : value > 80 ? '#c9a227' : '#00cc33'; ctx.beginPath(); ctx.arc(cx, cy, r, Math.PI * 0.75, endA); ctx.lineWidth = 6; ctx.strokeStyle = color; ctx.shadowColor = color; ctx.shadowBlur = 8; ctx.stroke(); ctx.shadowBlur = 0; // Needle ctx.beginPath(); ctx.moveTo(cx, cy); ctx.lineTo(cx + Math.cos(endA) * (r - 12), cy + Math.sin(endA) * (r - 12)); ctx.lineWidth = 2; ctx.strokeStyle = color; ctx.stroke(); // Hub ctx.beginPath(); ctx.arc(cx, cy, 4, 0, Math.PI * 2); ctx.fillStyle = color; ctx.fill(); } // ─── Sparkline ────────────────────────────────────── function drawSpark(canvas, values, { color = '#00cc33', fill = true, max = null } = {}) { if (!canvas) return; const ctx = canvas.getContext('2d'); const dpr = window.devicePixelRatio || 1; const w = canvas.clientWidth; const h = canvas.clientHeight; canvas.width = w * dpr; canvas.height = h * dpr; ctx.scale(dpr, dpr); ctx.clearRect(0, 0, w, h); if (!values || values.length < 2) { ctx.fillStyle = '#333'; ctx.font = '10px JetBrains Mono'; ctx.fillText('awaiting history...', 6, h / 2 + 3); return; } const peak = max !== null ? max : Math.max(...values, 1); const step = w / (values.length - 1); ctx.beginPath(); values.forEach((v, i) => { const x = i * step; const y = h - (v / peak) * (h - 4) - 2; i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); }); ctx.strokeStyle = color; ctx.lineWidth = 1.2; ctx.shadowColor = color; ctx.shadowBlur = 4; ctx.stroke(); ctx.shadowBlur = 0; if (fill) { ctx.lineTo(w, h); ctx.lineTo(0, h); ctx.closePath(); ctx.fillStyle = color + '22'; ctx.fill(); } } // ─── Disk ASCII bars ──────────────────────────────── function renderDisks(disks) { const el = $('tmDisks'); if (!el) return; if (!disks || !disks.length) { el.innerHTML = '
NO MOUNTS
'; return; } const bars = 18; el.innerHTML = disks.map(d => { const filled = Math.round(d.pct / 100 * bars); const cls = d.pct > 90 ? 'crit' : d.pct > 85 ? 'warn' : ''; const bar = '█'.repeat(filled) + '░'.repeat(bars - filled); return `
${fmt.escape(d.mount)} ${d.used_gb}G / ${d.total_gb}G · ${d.pct}%
[${bar}]
`; }).join(''); } // ─── Services ────────────────────────────────────── function renderServices(services) { const el = $('tmServicesBody'); if (!el) return; if (!services || !services.length) { el.innerHTML = 'NO SERVICES'; return; } const sorted = [...services].sort((a, b) => { const rank = { down: 0, unknown: 1, up: 2 }; return (rank[a.status] ?? 3) - (rank[b.status] ?? 3); }); el.innerHTML = sorted.map(s => ` ${s.status.toUpperCase()} ${fmt.escape(s.name)} ${fmt.uptime(s.uptime_seconds)} ${s.memory_mb ? s.memory_mb.toFixed(0) + ' MB' : '—'} ${s.cpu_percent ? s.cpu_percent.toFixed(1) + '%' : '—'} `).join(''); const upCount = services.filter(s => s.status === 'up').length; const badge = $('tmSvcBadge'); if (badge) badge.textContent = `${upCount}/${services.length} UP`; } // ─── Crons ───────────────────────────────────────── function renderCrons(crons) { const el = $('tmCronsBody'); if (!el) return; if (!crons || !crons.length) { el.innerHTML = 'NO CRONS'; return; } el.innerHTML = crons.map((c, i) => ` ${fmt.escape(c.name)} ${fmt.escape(c.schedule)} ${fmt.ago(c.last_run_iso)} ${c.last_status === 'ok' ? '✓ OK' : c.last_status === 'fail' ? '✗ FAIL' : '? UNKNOWN'}
${fmt.escape(c.last_output_tail || '(no output)')}
`).join(''); el.querySelectorAll('[data-cron-idx]').forEach(row => { row.addEventListener('click', () => { const idx = row.getAttribute('data-cron-idx'); const exp = el.querySelector(`[data-cron-exp="${idx}"]`); if (exp) exp.style.display = exp.style.display === 'none' ? 'table-row' : 'none'; }); }); } // ─── Nginx ───────────────────────────────────────── function renderNginx(nginx) { const s = $('tmNginxStats'); const t = $('tmTopPages'); if (!s || !t) return; if (!nginx || nginx.total_requests === 0) { s.innerHTML = '
NO ACCESS LOG DATA YET
'; t.innerHTML = ''; return; } s.innerHTML = `
${fmt.numGrp(nginx.total_requests)}REQUESTS / 24h
${fmt.bytes(nginx.total_bytes)}BANDWIDTH
${nginx.avg_rpm}AVG REQ / MIN
${fmt.numGrp(nginx.status_4xx_count)}4xx ERRORS
${fmt.numGrp(nginx.status_5xx_count)}5xx ERRORS
`; const top = (list, head) => `

${head}

`; t.innerHTML = top(nginx.top_pages || [], 'TOP PAGES') + top(nginx.top_ips_redacted || [], 'TOP IPs (REDACTED)'); } // ─── Security ────────────────────────────────────── function renderSecurity(sec) { const s = $('tmSecurityStats'); const j = $('tmJails'); if (!s) return; if (!sec) { s.innerHTML = '
NO DATA
'; return; } s.innerHTML = `
${sec.ufw_rules_count}UFW RULES
${sec.fail2ban_bans_total}BANNED IPs
${sec.ssh_attempts_24h}SSH BRUTEFORCE 24h
${sec.last_reboot_iso ? fmt.ago(sec.last_reboot_iso) : '—'}LAST REBOOT
`; if (j) { if (!sec.fail2ban_jails || !sec.fail2ban_jails.length) { j.innerHTML = '
no fail2ban jails detected
'; } else { j.innerHTML = '

JAILS

' + sec.fail2ban_jails.map(x => `
${fmt.escape(x.name)}${x.banned}
`).join(''); } } } // ─── SSL ────────────────────────────────────────── function renderSSL(ssl) { const el = $('tmSsl'); if (!el) return; if (!ssl || !ssl.length) { el.innerHTML = '
NO CERT DATA
'; return; } el.innerHTML = ssl.map(s => { const cls = s.days_left < 0 ? 'fail' : s.days_left < 14 ? 'crit' : s.days_left < 30 ? 'warn' : ''; const daysTxt = s.days_left < 0 ? 'FAIL' : s.days_left + 'd'; return `
${fmt.escape(s.domain)} ${daysTxt}
`; }).join(''); } // ─── Stack ───────────────────────────────────────── function renderStack(stack) { const el = $('tmStack'); if (!el) return; if (!stack) { el.innerHTML = '
NO DATA
'; return; } const keys = [ ['OS', stack.os], ['kernel', stack.kernel], ['python', stack.python_version], ['node', stack.node_version], ['nginx', stack.nginx_version], ['docker', stack.docker_version], ['ffmpeg', stack.ffmpeg_version], ['arch', stack.uname], ]; el.innerHTML = keys.filter(([_, v]) => v).map(([k, v]) => `
${k}${fmt.escape(v)}
` ).join(''); } // ─── Repos ───────────────────────────────────────── function renderRepos(repos) { const el = $('tmRepos'); if (!el) return; if (!repos || !repos.length) { el.innerHTML = '
NO REPOS
'; return; } el.innerHTML = repos.map(r => `
${fmt.escape(r.name)} ${fmt.escape(r.last_commit_sha)}${r.dirty ? ' ● DIRTY' : ''}
${fmt.escape(r.last_commit_msg)}
${fmt.ago(r.last_commit_iso)} · ${fmt.escape(r.path)}
`).join(''); } // ─── Tail ────────────────────────────────────────── function renderTail(lines) { const el = $('tmTail'); if (!el) return; if (!lines || !lines.length) return; // Filter to only new lines const newOnes = []; for (const l of lines) { const key = `${l.time}|${l.ip_masked}|${l.path}|${l.status}`; if (!state.seenTailKeys.has(key)) { state.seenTailKeys.add(key); newOnes.push(l); } } if (state.seenTailKeys.size > 200) { // Trim const arr = [...state.seenTailKeys].slice(-100); state.seenTailKeys = new Set(arr); } if (!newOnes.length && el.querySelector('.tm-loading')) { el.innerHTML = '
(no recent requests)
'; return; } // Clear loading once we have data if (el.querySelector('.tm-loading')) el.innerHTML = ''; newOnes.forEach(l => { const stCls = l.status >= 500 ? 'st-5xx' : l.status >= 400 ? 'st-4xx' : l.status >= 300 ? 'st-3xx' : 'st-2xx'; const div = document.createElement('div'); div.className = 'tm-tail-line'; div.innerHTML = `${fmt.escape(l.time.split(' ')[0].split(':').slice(1).join(':'))} ${fmt.escape(l.ip_masked)} ${fmt.escape(l.method)} ${fmt.escape(l.path)} ${l.status} ${fmt.bytes(l.size)}`; el.appendChild(div); }); // Trim DOM to last 40 lines while (el.children.length > 40) el.removeChild(el.firstChild); // Scroll to bottom el.scrollTop = el.scrollHeight; } // ─── Geo ─────────────────────────────────────────── function renderGeo(geo) { const el = $('tmGeo'); const top = $('tmGeoTop'); if (!el) return; if (!geo || !geo.length) { el.innerHTML = '
GEO INTEL UNAVAILABLE
(GeoIP mmdb not installed on VPS)
'; if (top) top.innerHTML = ''; return; } const max = geo[0].count; // Simple horizontal bar chart instead of a world map (keeps dep-free, no external svg) el.innerHTML = `
${geo.slice(0, 15).map(g => { const pct = Math.round(g.count / max * 100); return `
${fmt.escape(g.country_code)}
${fmt.escape(g.country_name)} ${fmt.numGrp(g.count)}
`; }).join('')}
`; if (top) { top.innerHTML = geo.slice(0, 12).map(g => `
${fmt.escape(g.country_code)}${fmt.numGrp(g.count)}
` ).join(''); } } // ─── Burn rate ────────────────────────────────────── function renderBurn(overview, history) { const el = $('tmBurn'); if (!el) return; if (!overview) { el.innerHTML = '
NO DATA
'; return; } // Disk burn: find root /, compute daily growth from history if available const root = (overview.system.disk_per_mount || []).find(d => d.mount === '/') || (overview.system.disk_per_mount || [])[0]; const lines = []; if (root) { const freeGb = root.total_gb - root.used_gb; lines.push(`
ROOT DISK FREE${freeGb} GB of ${root.total_gb} GB (${root.pct}% used)
`); } // Network today — sum of last 24h net_tx from history if (history && history.net_tx && history.net_tx.length) { const sumTx = history.net_tx.reduce((a, b) => a + b, 0); const sumRx = history.net_rx.reduce((a, b) => a + b, 0); const avgInterval = 300; // 5min const totalTx = sumTx * avgInterval; const totalRx = sumRx * avgInterval; lines.push(`
BANDWIDTH 24h↑ ${fmt.bytes(totalTx)} · ↓ ${fmt.bytes(totalRx)}
`); } lines.push(`
LOAD AVG${overview.system.load_1.toFixed(2)} / ${overview.system.load_5.toFixed(2)} / ${overview.system.load_15.toFixed(2)} (${overview.system.ncpu} CPU)
`); lines.push(`
KERNEL${fmt.escape(overview.system.kernel)} @ ${fmt.escape(overview.system.hostname)}
`); el.innerHTML = lines.join(''); } // ─── Ticker ───────────────────────────────────────── function renderTicker(commits) { const el = $('tmTicker'); if (!el) return; if (!commits || !commits.length) { el.innerHTML = '
NO COMMITS
'; return; } el.innerHTML = `
${commits.map(c => `${fmt.escape(c.sha)}${fmt.escape(c.msg)}${fmt.ago(c.iso)}` ).join('')}
`; } // ─── Alerts ───────────────────────────────────────── function renderAlerts(alerts) { const banner = $('tmAlerts'); const content = $('tmAlertsContent'); if (!banner || !content) return; if (!alerts || !alerts.length) { banner.classList.remove('visible', 'amber'); state.lastAlertSig = ''; return; } const sig = alerts.map(a => a.level + a.message).join('|'); const hasRed = alerts.some(a => a.level === 'red'); banner.classList.add('visible'); banner.classList.toggle('amber', !hasRed); if (sig !== state.lastAlertSig) { content.innerHTML = alerts.map(a => `${fmt.escape(a.message)}` ).join('') + alerts.map(a => `${fmt.escape(a.message)}` ).join(''); // doubled for smooth marquee state.lastAlertSig = sig; if (state.alarm) playBeep(hasRed); } } // ─── Alarm beep (Web Audio) ───────────────────────── function playBeep(urgent) { try { if (!state.audioCtx) state.audioCtx = new (window.AudioContext || window.webkitAudioContext)(); const ctx = state.audioCtx; const o = ctx.createOscillator(); const g = ctx.createGain(); o.connect(g); g.connect(ctx.destination); o.type = 'square'; o.frequency.value = urgent ? 880 : 440; g.gain.setValueAtTime(0.15, ctx.currentTime); g.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.2); o.start(ctx.currentTime); o.stop(ctx.currentTime + 0.22); } catch (e) { } } // ─── Overview render ──────────────────────────────── function applyOverview(data) { if (!data) return; state.lastOverview = data; state.lastSync = Date.now(); const sys = data.system || {}; // CPU drawGauge($('tmCpuGauge'), sys.cpu_percent); const cpuVal = $('tmCpuVal'); if (cpuVal) { cpuVal.textContent = sys.cpu_percent + '%'; cpuVal.className = 'tm-gauge-value ' + (sys.cpu_percent > 90 ? 'crit' : sys.cpu_percent > 80 ? 'warn' : ''); } const cpuLoad = $('tmCpuLoad'); if (cpuLoad) cpuLoad.textContent = `load ${sys.load_1?.toFixed(2)} · ${sys.ncpu} cores`; const cpuBadge = $('tmCpuBadge'); if (cpuBadge) cpuBadge.textContent = `${sys.ncpu} CPU`; // MEM drawGauge($('tmMemGauge'), sys.mem_percent); const memVal = $('tmMemVal'); if (memVal) { memVal.textContent = sys.mem_percent + '%'; memVal.className = 'tm-gauge-value ' + (sys.mem_percent > 90 ? 'crit' : sys.mem_percent > 80 ? 'warn' : ''); } const memSize = $('tmMemSize'); if (memSize) memSize.textContent = `${fmt.bytes(sys.mem_used_bytes)} / ${fmt.bytes(sys.mem_total_bytes)}`; const memBadge = $('tmMemBadge'); if (memBadge) memBadge.textContent = fmt.bytes(sys.mem_total_bytes); // Disks renderDisks(sys.disk_per_mount); // Net const rx = $('tmNetRx'); if (rx) rx.textContent = fmt.bps(sys.net_rx_bps); const tx = $('tmNetTx'); if (tx) tx.textContent = fmt.bps(sys.net_tx_bps); const up = $('tmUptime'); if (up) up.textContent = `uptime ${fmt.uptime(sys.uptime_seconds)}`; // Tables & sections renderServices(data.services); renderCrons(data.crons); renderNginx(data.nginx_24h); renderSecurity(data.security); renderSSL(data.ssl); renderStack(data.stack); renderRepos(data.repos); renderBurn(data, state.lastHistory); } function applyHistory(data) { if (!data) return; state.lastHistory = data; drawSpark($('tmCpuSpark'), data.cpu, { color: '#00cc33', max: 100 }); drawSpark($('tmMemSpark'), data.mem, { color: '#c9a227', max: 100 }); // Combined net: overlay const canvas = $('tmNetSpark'); if (canvas && (data.net_rx?.length || data.net_tx?.length)) { drawSpark(canvas, data.net_rx, { color: '#14F195', fill: true }); // Overlay tx on top without clearing const ctx = canvas.getContext('2d'); ctx.globalCompositeOperation = 'source-over'; // redraw-ish: tx overlay const dpr = window.devicePixelRatio || 1; const w = canvas.clientWidth, h = canvas.clientHeight; const values = data.net_tx || []; if (values.length > 1) { const peak = Math.max(...(data.net_rx || []), ...values, 1); const step = w / (values.length - 1); ctx.beginPath(); values.forEach((v, i) => { const x = i * step; const y = h - (v / peak) * (h - 4) - 2; i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); }); ctx.strokeStyle = '#00cc33'; ctx.lineWidth = 1.2; ctx.shadowColor = '#00cc33'; ctx.shadowBlur = 4; ctx.stroke(); ctx.shadowBlur = 0; } } // Re-render burn with fresh history if (state.lastOverview) renderBurn(state.lastOverview, data); } function applyVisitors(data) { if (!data) return; const a = $('tmVisitorsActive'); if (a) a.textContent = fmt.numGrp(data.active); const r = $('tmVisitorsRpm'); if (r) r.textContent = data.req_per_min; } // ─── Live indicator ───────────────────────────────── function tickLive() { const secs = state.lastSync ? Math.floor((Date.now() - state.lastSync) / 1000) : 0; const el = $('tmLastSync'); const dot = $('tmLiveDot'); if (el) el.textContent = state.lastSync ? secs : '—'; if (dot) { dot.className = 'tm-live-dot ' + (secs < 30 ? '' : secs < 90 ? 'amber' : 'red'); } } // ─── Polling loops ────────────────────────────────── async function pollOverview() { const d = await fetchJSON(API_BASE + '/overview'); if (d) applyOverview(d); } async function pollHistory() { const d = await fetchJSON(API_BASE + '/history'); if (d) applyHistory(d); } async function pollTail() { const d = await fetchJSON(API_BASE + '/nginx-tail'); if (d) renderTail(d); } async function pollGeo() { const d = await fetchJSON(API_BASE + '/geo'); renderGeo(d || []); } async function pollAlerts() { const d = await fetchJSON(API_BASE + '/alerts'); if (Array.isArray(d)) renderAlerts(d); } async function pollVisitors() { const d = await fetchJSON(API_BASE + '/visitors'); if (d) applyVisitors(d); } async function pollCommits() { const d = await fetchJSON(API_BASE + '/commits'); if (d) renderTicker(d); } // ─── Sound toggle ─────────────────────────────────── function bindSoundToggle() { const btn = $('tmSoundToggle'); if (!btn) return; btn.addEventListener('click', () => { state.alarm = !state.alarm; btn.classList.toggle('on', state.alarm); btn.textContent = state.alarm ? '🔊 ALARM ON' : '🔇 ALARM OFF'; if (state.alarm) { // Init audio context on user gesture if (!state.audioCtx) state.audioCtx = new (window.AudioContext || window.webkitAudioContext)(); playBeep(false); } }); } // ─── Boot ────────────────────────────────────────── function hideBoot() { const boot = $('tmBoot'); if (boot) setTimeout(() => { boot.classList.add('hidden'); setTimeout(() => boot.remove(), 600); }, 1500); } async function init() { hideBoot(); bindSoundToggle(); // Initial fetches — parallel await Promise.all([pollOverview(), pollHistory(), pollCommits(), pollGeo(), pollVisitors(), pollAlerts(), pollTail()]); // Intervals setInterval(pollOverview, POLL_OVERVIEW); setInterval(pollHistory, POLL_HISTORY); setInterval(pollTail, POLL_TAIL); setInterval(pollGeo, POLL_GEO); setInterval(pollAlerts, POLL_ALERTS); setInterval(pollVisitors, POLL_VISITORS); setInterval(pollCommits, POLL_COMMITS); setInterval(tickLive, 1000); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } })();