(() => { 'use strict'; const COLORS = { grid:'#202126', text:'#777780', strong:'#d4d4d8', green:'#3ecf8e', blue:'#60a5fa', red:'#f87171', amber:'#fbbf24', palette:['#3ecf8e','#60a5fa','#fbbf24','#a78bfa','#f87171','#22d3ee','#fb7185','#94a3b8'] }; function setup(canvas) { if (!canvas || !canvas.isConnected || canvas.offsetParent === null) return null; const rect = canvas.getBoundingClientRect(); if (rect.width < 20) return null; const DPR = Math.max(1, Math.min(window.devicePixelRatio || 1, 2)); const width = Math.max(1, Math.floor(rect.width)); const cssHeight = Number.parseFloat(getComputedStyle(canvas).height) || 0; const height = Math.max(170, Math.floor(cssHeight || Number(canvas.getAttribute('height')) || 220)); canvas.width = Math.floor(width * DPR); canvas.height = Math.floor(height * DPR); canvas.style.height = `${height}px`; const ctx = canvas.getContext('2d'); ctx.setTransform(DPR, 0, 0, DPR, 0, 0); return {ctx, width, height}; } function seriesMax(rows, key) { return Math.max(1, ...rows.map(row => Number(row?.[key] || 0))); } function formatTick(ts) { const d = new Date(Number(ts || 0)); return d.toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'}); } function compactNumber(value) { const n = Number(value || 0); if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`; return String(Math.round(n)); } function formatRate(value) { let n = Math.max(0, Number(value || 0)); const units = ['bps','Kbps','Mbps','Gbps','Tbps']; let i = 0; while (n >= 1000 && i < units.length - 1) { n /= 1000; i++; } return `${n < 10 && i ? n.toFixed(1) : Math.round(n)} ${units[i]}`; } function drawGrid(ctx, width, height, pad, rows) { ctx.clearRect(0, 0, width, height); ctx.lineWidth = 1; ctx.strokeStyle = COLORS.grid; ctx.fillStyle = COLORS.text; ctx.font = '10px ui-sans-serif, system-ui'; for (let i = 0; i <= 4; i++) { const y = pad.t + ((height - pad.t - pad.b) / 4) * i; ctx.beginPath(); ctx.moveTo(pad.l, y); ctx.lineTo(width - pad.r, y); ctx.stroke(); } const labelIndexes = [0, Math.floor((rows.length - 1) / 2), rows.length - 1]; ctx.textBaseline = 'bottom'; labelIndexes.forEach((idx, i) => { if (!rows[idx]) return; const x = pad.l + ((width - pad.l - pad.r) * idx / Math.max(rows.length - 1, 1)); ctx.textAlign = i === 0 ? 'left' : (i === labelIndexes.length - 1 ? 'right' : 'center'); ctx.fillText(formatTick(rows[idx].ts_ms), x, height - 2); }); } function drawLine(ctx, rows, key, max, width, height, pad, color, fillAlpha=0) { if (!rows.length || !(Number(max) > 0)) return; const iw = width - pad.l - pad.r; const ih = height - pad.t - pad.b; const points = rows.map((row, idx) => ({ x: pad.l + iw * idx / Math.max(rows.length - 1, 1), y: pad.t + ih - (Number(row[key] || 0) / Number(max)) * ih, })); if (fillAlpha) { const grad = ctx.createLinearGradient(0, pad.t, 0, height - pad.b); grad.addColorStop(0, hexToRgba(color, fillAlpha)); grad.addColorStop(1, hexToRgba(color, 0)); ctx.fillStyle = grad; ctx.beginPath(); ctx.moveTo(points[0].x, height - pad.b); points.forEach(p => ctx.lineTo(p.x, p.y)); ctx.lineTo(points.at(-1).x, height - pad.b); ctx.closePath(); ctx.fill(); } ctx.strokeStyle = color; ctx.lineWidth = 1.6; ctx.lineJoin = 'round'; ctx.lineCap = 'round'; ctx.beginPath(); points.forEach((p, idx) => idx ? ctx.lineTo(p.x, p.y) : ctx.moveTo(p.x, p.y)); ctx.stroke(); } function hexToRgba(hex, alpha) { const value = hex.replace('#',''); const n = parseInt(value, 16); return `rgba(${(n>>16)&255},${(n>>8)&255},${n&255},${alpha})`; } function normalizedDonutRows(rows) { const positive = (rows || []).map(row => ({ name:String(row?.name || 'unknown'), count:Math.max(0, Number(row?.count || 0)) })).filter(row => row.count > 0); if (positive.length <= 6) return positive; const head = positive.slice(0, 5); const other = positive.slice(5).reduce((sum, row) => sum + row.count, 0); if (other) head.push({name:'other', count:other}); return head; } function drawDonut(canvas, rows) { if (!canvas) return; const prepared = setup(canvas); if (!prepared) return; const {ctx,width,height} = prepared; ctx.clearRect(0, 0, width, height); const items = normalizedDonutRows(rows); const total = items.reduce((sum, row) => sum + row.count, 0); if (!total) { ctx.fillStyle = COLORS.text; ctx.font = '11px ui-sans-serif, system-ui'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText('No data in this window', width / 2, height / 2); return; } const wide = width >= 360; const cx = wide ? Math.min(width * .34, 135) : width / 2; const cy = wide ? height / 2 : Math.min(88, height * .42); const radius = Math.min(70, Math.max(48, Math.min(width * .22, height * .32))); const inner = radius * .64; let angle = -Math.PI / 2; items.forEach((row, idx) => { const portion = row.count / total; const end = angle + portion * Math.PI * 2; ctx.beginPath(); ctx.arc(cx, cy, radius, angle, end); ctx.arc(cx, cy, inner, end, angle, true); ctx.closePath(); ctx.fillStyle = COLORS.palette[idx % COLORS.palette.length]; ctx.fill(); angle = end; }); ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillStyle = COLORS.strong; ctx.font = '600 18px ui-sans-serif, system-ui'; ctx.fillText(compactNumber(total), cx, cy - 5); ctx.fillStyle = COLORS.text; ctx.font = '9px ui-sans-serif, system-ui'; ctx.fillText('events', cx, cy + 14); const legendX = wide ? Math.min(width * .60, cx + radius + 35) : 14; const legendY = wide ? Math.max(18, cy - Math.min(items.length * 16, 84) / 2) : Math.min(height - 74, cy + radius + 16); const legendWidth = wide ? Math.max(90, width - legendX - 12) : width - 28; items.forEach((row, idx) => { const y = legendY + idx * 18; ctx.fillStyle = COLORS.palette[idx % COLORS.palette.length]; ctx.fillRect(legendX, y + 3, 7, 7); ctx.fillStyle = COLORS.text; ctx.font = '10px ui-sans-serif, system-ui'; ctx.textAlign = 'left'; ctx.textBaseline = 'top'; const label = row.name.length > 18 ? `${row.name.slice(0,17)}…` : row.name; ctx.fillText(label, legendX + 13, y, Math.max(40, legendWidth - 45)); ctx.textAlign = 'right'; ctx.fillText(`${Math.round(row.count / total * 100)}%`, legendX + legendWidth, y); }); } function drawLoading(canvas, label='Building selected range…') { if (!canvas) return; const prepared = setup(canvas); if (!prepared) return; const {ctx,width,height} = prepared; ctx.clearRect(0, 0, width, height); ctx.strokeStyle = COLORS.grid; ctx.lineWidth = 1; for (let i = 1; i <= 3; i++) { const y = height * i / 4; ctx.beginPath(); ctx.moveTo(12, y); ctx.lineTo(width - 12, y); ctx.stroke(); } ctx.fillStyle = COLORS.text; ctx.font = '11px ui-sans-serif, system-ui'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(label, width / 2, height / 2); } function drawTraffic(canvas, rows) { if (!canvas) return; const prepared = setup(canvas); if (!prepared) return; const {ctx,width,height} = prepared; const pad={l:8,r:8,t:12,b:22}; drawGrid(ctx,width,height,pad,rows); drawLine(ctx, rows, 'bytes', seriesMax(rows,'bytes'), width,height,pad,COLORS.blue,.10); drawLine(ctx, rows, 'events', seriesMax(rows,'events'), width,height,pad,COLORS.green,.12); } function drawThroughput(canvas, rows) { if (!canvas) return; const prepared = setup(canvas); if (!prepared) return; const {ctx,width,height} = prepared; const pad={l:56,r:10,t:12,b:22}; drawGrid(ctx,width,height,pad,rows); const max = Math.max(seriesMax(rows,'bps'), seriesMax(rows,'in_bps'), seriesMax(rows,'out_bps')); ctx.fillStyle = COLORS.text; ctx.font = '10px ui-sans-serif, system-ui'; ctx.textAlign = 'right'; ctx.textBaseline = 'middle'; if (!(max > 0)) { ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText('No TZSP throughput samples in this window', width / 2, height / 2); return; } for (let i = 0; i <= 4; i++) { const value = max * (4 - i) / 4; const y = pad.t + ((height - pad.t - pad.b) / 4) * i; ctx.fillText(formatRate(value), pad.l - 7, y); } // Total is always available from raw TZSP byte counters, even when LAN // direction classification is not configured correctly. drawLine(ctx, rows, 'bps', max, width,height,pad,COLORS.amber,.04); drawLine(ctx, rows, 'in_bps', max, width,height,pad,COLORS.blue,.05); drawLine(ctx, rows, 'out_bps', max, width,height,pad,COLORS.green,.04); } function drawEvents(canvas, rows) { if (!canvas) return; const prepared = setup(canvas); if (!prepared) return; const {ctx,width,height} = prepared; const pad={l:8,r:8,t:12,b:22}; drawGrid(ctx,width,height,pad,rows); const max = Math.max(seriesMax(rows,'events'), seriesMax(rows,'alerts')); drawLine(ctx, rows, 'events', max, width,height,pad,COLORS.green,.10); drawLine(ctx, rows, 'alerts', max, width,height,pad,COLORS.red,0); } window.MikroSuricataCharts = {drawTraffic, drawThroughput, drawEvents, drawDonut, drawLoading}; })();