(function(){ function ensureLibs(){ return new Promise((resolve)=>{ const tick=()=>{ if(window.io && window.Chart) return resolve(); setTimeout(tick,50); }; tick(); }); } const charts=new Map(); const seriesData=new Map(); const seriesUnit=new Map(); const selectedRange=new Map(); const maxWindowMs=10*60*60*1000; const rangeMap={ '1m': 1*60*1000, '10m': 10*60*1000, '1h': 60*60*1000, '3h': 3*60*60*1000, '10h': 10*60*60*1000, }; function isNum(v){ return typeof v==='number' && Number.isFinite(v); } function scale(v, unit){ if(!isNum(v)) return {v, u:''}; const u=(unit||'').toLowerCase(); if(u==='%' || u==='pct' || u==='percent') return {v, u:'%'}; if(u==='bps' || u==='bit/s' || u==='bits/s'){ const steps=['bps','Kbps','Mbps','Gbps','Tbps']; let i=0, x=v; while(Math.abs(x)>=1000 && i=1024 && i=1024 && i=100 ? s.v.toFixed(0) : a>=10 ? s.v.toFixed(1) : s.v.toFixed(2); return n + (s.u ? ' '+s.u : ''); } function hash32(str){ let h=2166136261; for(let i=0;i>>0; } function colorFor(name){ const hue = (hash32(String(name)) % 360); return { border:`hsl(${hue} 70% 45%)`, fill:`hsl(${hue} 70% 45% / 0.20)` }; } function getRangeMs(widgetId){ return rangeMap[selectedRange.get(widgetId) || '1h'] || rangeMap['1h']; } function pruneStore(store, latestTs){ const minTs=latestTs-maxWindowMs; for(const name of Object.keys(store)){ store[name]=store[name].filter(p=>p.x.getTime()>=minTs); } } function upsertChart(widgetId){ const el=document.getElementById('chart-'+widgetId); if(!el) return null; if(charts.has(widgetId)) return charts.get(widgetId); const chart=new Chart(el.getContext('2d'),{ type:'line', data:{datasets:[]}, options:{ animation:false, parsing:false, normalized:true, responsive:true, maintainAspectRatio:false, interaction:{mode:'nearest', intersect:false}, scales:{ x:{type:'time', time:{unit:'minute'}}, y:{ beginAtZero:false, ticks:{ callback:(tick)=>{ const uMap = seriesUnit.get(widgetId) || {}; const units = Object.values(uMap).filter(Boolean); const uniq = [...new Set(units)]; const u = uniq.length===1 ? uniq[0] : ''; return u ? fmt(Number(tick), u) : tick; } } } }, plugins:{ legend:{display:true}, tooltip:{ callbacks:{ label:(ctx)=>{ const name=ctx.dataset.label||''; const y=ctx.parsed && isNum(ctx.parsed.y) ? ctx.parsed.y : null; const u=(seriesUnit.get(widgetId)||{})[name] || ''; return name+': '+(y===null?'':fmt(y,u)); } } } }, elements:{point:{radius:0}} } }); charts.set(widgetId, chart); seriesData.set(widgetId, {}); seriesUnit.set(widgetId, {}); if(!selectedRange.has(widgetId)) selectedRange.set(widgetId, '1h'); return chart; } function setMeta(widgetId, txt){ const m=document.getElementById('meta-'+widgetId); if(m) m.textContent=txt; } function escapeHtml(s){ return String(s) .replaceAll('&','&') .replaceAll('<','<') .replaceAll('>','>') .replaceAll('"','"') .replaceAll("'",'''); } function handleTable(msg){ const tbl=document.querySelector("table[data-table-widget='"+msg.widget_id+"']"); if(!tbl) return; const thead=tbl.querySelector('thead'); const tbody=tbl.querySelector('tbody'); const cols=msg.columns||[]; thead.innerHTML=''+cols.map(c=>''+escapeHtml(c)+'').join('')+''; tbody.innerHTML=(msg.rows||[]).map(r=>''+cols.map(c=>''+escapeHtml(r[c] ?? '')+'').join('')+'').join(''); } function renderChart(widgetId, latestTs){ const chart=upsertChart(widgetId); if(!chart) return; const store=seriesData.get(widgetId) || {}; const rangeMs=getRangeMs(widgetId); const minTs=latestTs-rangeMs; chart.data.datasets = Object.keys(store).map(name=>{ const c=colorFor(name); const data=(store[name]||[]).filter(p=>p.x.getTime()>=minTs); return { label:name, data, borderWidth:2, pointRadius:0, tension:0.15, fill:true, borderColor:c.border, backgroundColor:c.fill }; }); chart.options.scales.x.min=minTs; chart.options.scales.x.max=latestTs; chart.update('none'); } function handleTimeseries(msg){ const t=new Date(msg.ts); const widgetId=msg.widget_id; upsertChart(widgetId); const store=seriesData.get(widgetId) || {}; const uMap=seriesUnit.get(widgetId) || {}; for(const p of (msg.points||[])){ if(p.value===null || p.value===undefined) continue; const s=p.series || 'value'; if(!store[s]) store[s]=[]; store[s].push({x:t, y:Number(p.value)}); if(p.unit) uMap[s]=String(p.unit); } pruneStore(store, t.getTime()); seriesData.set(widgetId, store); seriesUnit.set(widgetId, uMap); renderChart(widgetId, t.getTime()); setMeta(widgetId, 'Updated: '+t.toLocaleTimeString()+' · Range: '+(selectedRange.get(widgetId)||'1h')); } function initRangeSelectors(){ document.querySelectorAll('[data-range-widget]').forEach((el)=>{ const widgetId=Number(el.getAttribute('data-range-widget')); selectedRange.set(widgetId, el.value || '1h'); el.addEventListener('change', ()=>{ selectedRange.set(widgetId, el.value || '1h'); const store=seriesData.get(widgetId) || {}; let latestTs=Date.now(); for(const points of Object.values(store)){ if(points.length){ const ts=points[points.length-1].x.getTime(); if(ts>latestTs) latestTs=ts; } } renderChart(widgetId, latestTs); setMeta(widgetId, 'Updated: '+new Date(latestTs).toLocaleTimeString()+' · Range: '+(selectedRange.get(widgetId)||'1h')); }); }); } async function main(){ if(!window.MIKROMON || !window.MIKROMON.dashboardId) return; await ensureLibs(); initRangeSelectors(); const socket=io({transports:['polling'], upgrade:false}); socket.on('connect', ()=>{ socket.emit('join_dashboard', {dashboard_id: window.MIKROMON.dashboardId, public_token: window.MIKROMON.publicToken}); }); socket.on('metric', (msg)=>{ if(!msg || !msg.widget_id) return; if(msg.type==='table') return handleTable(msg); return handleTimeseries(msg); }); } document.addEventListener('DOMContentLoaded', main); })();