Add tabbed dashboard at /dashboard/ with three D3.js visualizations: - Vendor treemap (devices grouped by type and vendor) - SSID social graph (force-directed, shared probed SSIDs as edges) - Fingerprint clusters (packed circles by device behavior) Intelligence API endpoints at /api/v1/intelligence/ with param validation. Dashboard built on htmx + Pico CSS dark theme + D3 v7, all vendored locally (make vendor). 13 new tests (59 total).
111 lines
3.6 KiB
JavaScript
111 lines
3.6 KiB
JavaScript
/* Fingerprint Clusters — D3 packed circles grouping devices by behavior */
|
|
function renderFingerprintClusters(selector, apiUrl) {
|
|
const container = document.querySelector(selector);
|
|
const tooltip = createTooltip();
|
|
const { width, height } = getVizSize(container);
|
|
|
|
fetch(apiUrl + '?hours=24')
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
if (!data.clusters || data.clusters.length === 0) {
|
|
container.innerHTML = '<div class="viz-empty">No active devices in the last 24 hours</div>';
|
|
return;
|
|
}
|
|
|
|
// Build hierarchy for d3.pack
|
|
const hierarchy = {
|
|
name: 'root',
|
|
children: data.clusters.map(c => ({
|
|
name: c.label,
|
|
value: c.device_count,
|
|
cluster: c,
|
|
})),
|
|
};
|
|
|
|
const activityColor = d3.scaleOrdinal()
|
|
.domain(['Low', 'Medium', 'High'])
|
|
.range(['#495057', '#f59f00', '#ff6b6b']);
|
|
|
|
const root = d3.hierarchy(hierarchy)
|
|
.sum(d => d.value || 0)
|
|
.sort((a, b) => b.value - a.value);
|
|
|
|
d3.pack()
|
|
.size([width, height])
|
|
.padding(8)(root);
|
|
|
|
const svg = d3.select(selector)
|
|
.append('svg')
|
|
.attr('viewBox', `0 0 ${width} ${height}`)
|
|
.attr('preserveAspectRatio', 'xMidYMid meet');
|
|
|
|
const leaves = svg.selectAll('g')
|
|
.data(root.leaves())
|
|
.join('g')
|
|
.attr('transform', d => `translate(${d.x},${d.y})`);
|
|
|
|
leaves.append('circle')
|
|
.attr('class', 'cluster-circle')
|
|
.attr('r', d => d.r)
|
|
.attr('fill', d => activityColor(d.data.cluster.activity))
|
|
.on('mouseover', (event, d) => {
|
|
const c = d.data.cluster;
|
|
tooltip.show(
|
|
`<strong>${c.label}</strong><br>` +
|
|
`Devices: ${c.device_count}<br>` +
|
|
`Avg probe rate: ${c.centroid.probe_rate}/device<br>` +
|
|
`Avg sighting rate: ${c.centroid.sighting_rate}/device<br>` +
|
|
`Avg RSSI: ${c.centroid.avg_rssi !== null ? c.centroid.avg_rssi + ' dBm' : 'N/A'}`,
|
|
event
|
|
);
|
|
d3.select(event.target).attr('fill-opacity', 1).attr('stroke', '#fff');
|
|
})
|
|
.on('mousemove', (event) => {
|
|
tooltip.move(event);
|
|
})
|
|
.on('mouseout', (event) => {
|
|
tooltip.hide();
|
|
d3.select(event.target).attr('fill-opacity', 0.7).attr('stroke', null);
|
|
});
|
|
|
|
// Labels for circles big enough
|
|
leaves.append('text')
|
|
.attr('class', 'cluster-label')
|
|
.attr('dy', '-0.3em')
|
|
.text(d => {
|
|
if (d.r < 25) return '';
|
|
const name = d.data.cluster.vendor;
|
|
const maxChars = Math.floor(d.r * 2 / 7);
|
|
return name.length > maxChars ? name.slice(0, maxChars - 1) + '\u2026' : name;
|
|
});
|
|
|
|
leaves.append('text')
|
|
.attr('class', 'cluster-label')
|
|
.attr('dy', '1em')
|
|
.style('font-size', '10px')
|
|
.style('opacity', 0.8)
|
|
.text(d => d.r >= 20 ? d.data.cluster.device_count : '');
|
|
|
|
// Legend
|
|
const legend = svg.append('g')
|
|
.attr('transform', `translate(${width - 140}, 20)`);
|
|
|
|
['Low', 'Medium', 'High'].forEach((level, i) => {
|
|
const g = legend.append('g')
|
|
.attr('transform', `translate(0, ${i * 22})`);
|
|
g.append('circle')
|
|
.attr('r', 6)
|
|
.attr('fill', activityColor(level));
|
|
g.append('text')
|
|
.attr('x', 14)
|
|
.attr('y', 4)
|
|
.attr('fill', '#adb5bd')
|
|
.style('font-size', '12px')
|
|
.text(level + ' Activity');
|
|
});
|
|
})
|
|
.catch(err => {
|
|
container.innerHTML = `<div class="viz-empty">Failed to load data: ${err.message}</div>`;
|
|
});
|
|
}
|