mirror of
https://github.com/smittix/intercept.git
synced 2026-04-24 22:59:59 -07:00
Table fixes: - Add BSSID column header to match data columns - Remove vendor column from table rows (6 columns total) - Update placeholder colspan to 6 Layout fixes: - Use minmax() for right columns to allow shrinking - Add overflow handling to layout container - Add min-width: 0 to analysis panel for proper grid behavior - Add overflow-x: auto to channel chart container Channel chart fixes: - Reduce bar width from 20px to 14px - Reduce bar spacing from 4px to 2px - Reduce padding for more compact display - Use viewBox for responsive SVG scaling Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
287 lines
12 KiB
JavaScript
287 lines
12 KiB
JavaScript
/**
|
|
* WiFi Channel Utilization Chart Component
|
|
*
|
|
* Displays channel utilization as a bar chart with recommendations.
|
|
* Shows AP count, client count, and utilization score per channel.
|
|
*/
|
|
|
|
const ChannelChart = (function() {
|
|
'use strict';
|
|
|
|
// ==========================================================================
|
|
// Configuration
|
|
// ==========================================================================
|
|
|
|
const CONFIG = {
|
|
height: 120,
|
|
barWidth: 14,
|
|
barSpacing: 2,
|
|
padding: { top: 15, right: 10, bottom: 25, left: 30 },
|
|
colors: {
|
|
low: '#22c55e', // Green - low utilization
|
|
medium: '#eab308', // Yellow - medium
|
|
high: '#ef4444', // Red - high
|
|
recommended: '#3b82f6', // Blue - recommended
|
|
},
|
|
thresholds: {
|
|
low: 0.3,
|
|
medium: 0.6,
|
|
},
|
|
};
|
|
|
|
// 2.4 GHz non-overlapping channels
|
|
const CHANNELS_2_4 = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11];
|
|
const NON_OVERLAPPING_2_4 = [1, 6, 11];
|
|
|
|
// 5 GHz channels (non-DFS)
|
|
const CHANNELS_5 = [36, 40, 44, 48, 149, 153, 157, 161, 165];
|
|
|
|
// ==========================================================================
|
|
// State
|
|
// ==========================================================================
|
|
|
|
let container = null;
|
|
let currentBand = '2.4';
|
|
let channelStats = [];
|
|
let recommendations = [];
|
|
|
|
// ==========================================================================
|
|
// Initialization
|
|
// ==========================================================================
|
|
|
|
function init(containerId, options = {}) {
|
|
container = document.getElementById(containerId);
|
|
if (!container) {
|
|
console.warn('[ChannelChart] Container not found:', containerId);
|
|
return;
|
|
}
|
|
|
|
Object.assign(CONFIG, options);
|
|
render();
|
|
}
|
|
|
|
// ==========================================================================
|
|
// Update
|
|
// ==========================================================================
|
|
|
|
function update(stats, recs) {
|
|
channelStats = stats || [];
|
|
recommendations = recs || [];
|
|
render();
|
|
}
|
|
|
|
function setBand(band) {
|
|
currentBand = band;
|
|
render();
|
|
}
|
|
|
|
// ==========================================================================
|
|
// Rendering
|
|
// ==========================================================================
|
|
|
|
function render() {
|
|
if (!container) return;
|
|
|
|
const channels = currentBand === '2.4' ? CHANNELS_2_4 : CHANNELS_5;
|
|
const nonOverlapping = currentBand === '2.4' ? NON_OVERLAPPING_2_4 : CHANNELS_5;
|
|
|
|
// Build stats map
|
|
const statsMap = {};
|
|
channelStats.forEach(s => {
|
|
statsMap[s.channel] = s;
|
|
});
|
|
|
|
// Build recommendations map
|
|
const recsMap = {};
|
|
recommendations.forEach((r, i) => {
|
|
recsMap[r.channel] = { rank: i + 1, ...r };
|
|
});
|
|
|
|
// Calculate dimensions
|
|
const width = channels.length * (CONFIG.barWidth + CONFIG.barSpacing) + CONFIG.padding.left + CONFIG.padding.right;
|
|
const height = CONFIG.height + CONFIG.padding.top + CONFIG.padding.bottom;
|
|
const chartHeight = CONFIG.height;
|
|
|
|
// Find max values for scaling
|
|
let maxApCount = 1;
|
|
channelStats.forEach(s => {
|
|
if (s.ap_count > maxApCount) maxApCount = s.ap_count;
|
|
});
|
|
|
|
// Build SVG with viewBox for responsive scaling
|
|
let svg = `
|
|
<svg viewBox="0 0 ${width} ${height}" class="channel-chart-svg" style="width: 100%; height: auto; max-height: ${height}px;">
|
|
<defs>
|
|
<linearGradient id="utilGradientLow" x1="0%" y1="0%" x2="0%" y2="100%">
|
|
<stop offset="0%" style="stop-color:${CONFIG.colors.low};stop-opacity:0.9" />
|
|
<stop offset="100%" style="stop-color:${CONFIG.colors.low};stop-opacity:0.5" />
|
|
</linearGradient>
|
|
<linearGradient id="utilGradientMed" x1="0%" y1="0%" x2="0%" y2="100%">
|
|
<stop offset="0%" style="stop-color:${CONFIG.colors.medium};stop-opacity:0.9" />
|
|
<stop offset="100%" style="stop-color:${CONFIG.colors.medium};stop-opacity:0.5" />
|
|
</linearGradient>
|
|
<linearGradient id="utilGradientHigh" x1="0%" y1="0%" x2="0%" y2="100%">
|
|
<stop offset="0%" style="stop-color:${CONFIG.colors.high};stop-opacity:0.9" />
|
|
<stop offset="100%" style="stop-color:${CONFIG.colors.high};stop-opacity:0.5" />
|
|
</linearGradient>
|
|
</defs>
|
|
|
|
<!-- Y-axis label -->
|
|
<text x="10" y="${height / 2}" fill="#666" font-size="10" transform="rotate(-90, 10, ${height / 2})" text-anchor="middle">APs</text>
|
|
|
|
<!-- Y-axis ticks -->
|
|
${renderYAxis(chartHeight, maxApCount)}
|
|
|
|
<!-- Bars -->
|
|
<g transform="translate(${CONFIG.padding.left}, ${CONFIG.padding.top})">
|
|
${channels.map((ch, i) => {
|
|
const stats = statsMap[ch] || { ap_count: 0, utilization_score: 0 };
|
|
const rec = recsMap[ch];
|
|
const isNonOverlapping = nonOverlapping.includes(ch);
|
|
return renderBar(i, ch, stats, rec, isNonOverlapping, chartHeight, maxApCount);
|
|
}).join('')}
|
|
</g>
|
|
|
|
<!-- X-axis labels -->
|
|
<g transform="translate(${CONFIG.padding.left}, ${CONFIG.padding.top + chartHeight + 5})">
|
|
${channels.map((ch, i) => {
|
|
const x = i * (CONFIG.barWidth + CONFIG.barSpacing) + CONFIG.barWidth / 2;
|
|
const isNonOverlapping = nonOverlapping.includes(ch);
|
|
return `<text x="${x}" y="12" fill="${isNonOverlapping ? '#fff' : '#666'}" font-size="9" text-anchor="middle">${ch}</text>`;
|
|
}).join('')}
|
|
</g>
|
|
</svg>
|
|
`;
|
|
|
|
// Add legend
|
|
svg += renderLegend();
|
|
|
|
// Add recommendations
|
|
if (recommendations.length > 0) {
|
|
svg += renderRecommendations();
|
|
}
|
|
|
|
container.innerHTML = svg;
|
|
}
|
|
|
|
function renderYAxis(chartHeight, maxApCount) {
|
|
const ticks = [];
|
|
const tickCount = Math.min(5, maxApCount);
|
|
const step = Math.ceil(maxApCount / tickCount);
|
|
|
|
for (let i = 0; i <= maxApCount; i += step) {
|
|
const y = CONFIG.padding.top + chartHeight - (i / maxApCount * chartHeight);
|
|
ticks.push(`
|
|
<line x1="${CONFIG.padding.left - 5}" y1="${y}" x2="${CONFIG.padding.left}" y2="${y}" stroke="#444" />
|
|
<text x="${CONFIG.padding.left - 8}" y="${y + 3}" fill="#666" font-size="9" text-anchor="end">${i}</text>
|
|
`);
|
|
}
|
|
|
|
return ticks.join('');
|
|
}
|
|
|
|
function renderBar(index, channel, stats, rec, isNonOverlapping, chartHeight, maxApCount) {
|
|
const x = index * (CONFIG.barWidth + CONFIG.barSpacing);
|
|
const barHeight = (stats.ap_count / maxApCount) * chartHeight;
|
|
const y = chartHeight - barHeight;
|
|
|
|
// Determine color based on utilization
|
|
let gradient = 'utilGradientLow';
|
|
if (stats.utilization_score >= CONFIG.thresholds.medium) {
|
|
gradient = 'utilGradientHigh';
|
|
} else if (stats.utilization_score >= CONFIG.thresholds.low) {
|
|
gradient = 'utilGradientMed';
|
|
}
|
|
|
|
// Recommended channel indicator
|
|
const isRecommended = rec && rec.rank <= 3;
|
|
const recIndicator = isRecommended ?
|
|
`<circle cx="${x + CONFIG.barWidth / 2}" cy="${chartHeight + 20}" r="4" fill="${CONFIG.colors.recommended}" />
|
|
<text x="${x + CONFIG.barWidth / 2}" y="${chartHeight + 23}" fill="#fff" font-size="7" text-anchor="middle">${rec.rank}</text>` : '';
|
|
|
|
// Non-overlapping channel marker
|
|
const channelMarker = isNonOverlapping ?
|
|
`<rect x="${x}" y="${chartHeight}" width="${CONFIG.barWidth}" height="2" fill="#3b82f6" />` : '';
|
|
|
|
return `
|
|
<g class="channel-bar" data-channel="${channel}">
|
|
<!-- Bar background -->
|
|
<rect x="${x}" y="0" width="${CONFIG.barWidth}" height="${chartHeight}"
|
|
fill="#1a1a2e" rx="2" />
|
|
|
|
<!-- Utilization bar -->
|
|
<rect x="${x}" y="${y}" width="${CONFIG.barWidth}" height="${barHeight}"
|
|
fill="url(#${gradient})" rx="2" />
|
|
|
|
<!-- AP count label -->
|
|
${stats.ap_count > 0 ? `
|
|
<text x="${x + CONFIG.barWidth / 2}" y="${y - 4}" fill="#fff" font-size="9" text-anchor="middle">
|
|
${stats.ap_count}
|
|
</text>
|
|
` : ''}
|
|
|
|
${channelMarker}
|
|
${recIndicator}
|
|
|
|
<!-- Hover area -->
|
|
<rect x="${x}" y="0" width="${CONFIG.barWidth}" height="${chartHeight}"
|
|
fill="transparent" class="channel-hover" />
|
|
</g>
|
|
`;
|
|
}
|
|
|
|
function renderLegend() {
|
|
return `
|
|
<div class="channel-chart-legend" style="display: flex; gap: 16px; justify-content: center; margin-top: 8px; font-size: 10px;">
|
|
<div style="display: flex; align-items: center; gap: 4px;">
|
|
<span style="width: 12px; height: 12px; background: ${CONFIG.colors.low}; border-radius: 2px;"></span>
|
|
<span style="color: #888;">Low</span>
|
|
</div>
|
|
<div style="display: flex; align-items: center; gap: 4px;">
|
|
<span style="width: 12px; height: 12px; background: ${CONFIG.colors.medium}; border-radius: 2px;"></span>
|
|
<span style="color: #888;">Medium</span>
|
|
</div>
|
|
<div style="display: flex; align-items: center; gap: 4px;">
|
|
<span style="width: 12px; height: 12px; background: ${CONFIG.colors.high}; border-radius: 2px;"></span>
|
|
<span style="color: #888;">High</span>
|
|
</div>
|
|
<div style="display: flex; align-items: center; gap: 4px;">
|
|
<span style="width: 12px; height: 3px; background: #3b82f6; border-radius: 1px;"></span>
|
|
<span style="color: #888;">Non-overlapping</span>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function renderRecommendations() {
|
|
const topRecs = recommendations.slice(0, 3);
|
|
if (topRecs.length === 0) return '';
|
|
|
|
return `
|
|
<div class="channel-chart-recommendations" style="margin-top: 12px; padding: 8px; background: #1a1a2e; border-radius: 4px;">
|
|
<div style="font-size: 10px; color: #888; margin-bottom: 6px;">Recommended Channels:</div>
|
|
<div style="display: flex; gap: 8px; flex-wrap: wrap;">
|
|
${topRecs.map((rec, i) => `
|
|
<div style="display: flex; align-items: center; gap: 4px; padding: 4px 8px; background: ${i === 0 ? 'rgba(59, 130, 246, 0.2)' : '#0d0d1a'}; border-radius: 4px; border: 1px solid ${i === 0 ? '#3b82f6' : '#333'};">
|
|
<span style="font-size: 11px; font-weight: bold; color: ${i === 0 ? '#3b82f6' : '#666'};">#${i + 1}</span>
|
|
<span style="font-size: 12px; color: #fff;">Ch ${rec.channel}</span>
|
|
<span style="font-size: 9px; color: #666;">(${rec.band})</span>
|
|
${rec.is_dfs ? '<span style="font-size: 8px; color: #ff6b6b; margin-left: 4px;">DFS</span>' : ''}
|
|
</div>
|
|
`).join('')}
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
// ==========================================================================
|
|
// Public API
|
|
// ==========================================================================
|
|
|
|
return {
|
|
init,
|
|
update,
|
|
setBand,
|
|
};
|
|
})();
|