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
/* SSID Social Graph — D3 force-directed graph linking devices by shared probed SSIDs */
|
|
function renderSsidGraph(selector, apiUrl) {
|
|
const container = document.querySelector(selector);
|
|
const tooltip = createTooltip();
|
|
const { width, height } = getVizSize(container);
|
|
|
|
fetch(apiUrl + '?hours=24&min_shared=1&limit=200')
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
if (!data.nodes || data.nodes.length === 0) {
|
|
container.innerHTML = '<div class="viz-empty">No probe data available</div>';
|
|
return;
|
|
}
|
|
|
|
const color = d3.scaleOrdinal()
|
|
.domain(['wifi', 'ble'])
|
|
.range(['#4dabf7', '#69db7c']);
|
|
|
|
const sizeScale = d3.scaleSqrt()
|
|
.domain([1, d3.max(data.nodes, d => d.ssid_count) || 1])
|
|
.range([4, 16]);
|
|
|
|
const svg = d3.select(selector)
|
|
.append('svg')
|
|
.attr('viewBox', `0 0 ${width} ${height}`)
|
|
.attr('preserveAspectRatio', 'xMidYMid meet');
|
|
|
|
const simulation = d3.forceSimulation(data.nodes)
|
|
.force('link', d3.forceLink(data.links).id(d => d.id).distance(80))
|
|
.force('charge', d3.forceManyBody().strength(-100))
|
|
.force('center', d3.forceCenter(width / 2, height / 2))
|
|
.force('collision', d3.forceCollide().radius(d => sizeScale(d.ssid_count) + 2));
|
|
|
|
const link = svg.append('g')
|
|
.selectAll('line')
|
|
.data(data.links)
|
|
.join('line')
|
|
.attr('class', 'graph-link')
|
|
.attr('stroke-width', d => Math.max(1, d.weight));
|
|
|
|
const node = svg.append('g')
|
|
.selectAll('circle')
|
|
.data(data.nodes)
|
|
.join('circle')
|
|
.attr('class', 'graph-node')
|
|
.attr('r', d => sizeScale(d.ssid_count))
|
|
.attr('fill', d => color(d.type))
|
|
.on('mouseover', (event, d) => {
|
|
tooltip.show(
|
|
`<strong>${d.id}</strong><br>` +
|
|
`Vendor: ${d.vendor}<br>` +
|
|
`Type: ${d.type}<br>` +
|
|
`Probed SSIDs: ${d.ssid_count}`,
|
|
event
|
|
);
|
|
d3.select(event.target).attr('stroke', '#fff').attr('stroke-width', 3);
|
|
})
|
|
.on('mousemove', (event) => {
|
|
tooltip.move(event);
|
|
})
|
|
.on('mouseout', (event) => {
|
|
tooltip.hide();
|
|
d3.select(event.target).attr('stroke', null).attr('stroke-width', 1.5);
|
|
})
|
|
.call(drag(simulation));
|
|
|
|
// Link hover
|
|
link.on('mouseover', (event, d) => {
|
|
const ssids = d.shared_ssids.slice(0, 5).join(', ');
|
|
const more = d.shared_ssids.length > 5 ? ` (+${d.shared_ssids.length - 5} more)` : '';
|
|
tooltip.show(
|
|
`<strong>Shared SSIDs (${d.weight})</strong><br>${ssids}${more}`,
|
|
event
|
|
);
|
|
}).on('mousemove', (event) => tooltip.move(event))
|
|
.on('mouseout', () => tooltip.hide());
|
|
|
|
simulation.on('tick', () => {
|
|
link
|
|
.attr('x1', d => d.source.x)
|
|
.attr('y1', d => d.source.y)
|
|
.attr('x2', d => d.target.x)
|
|
.attr('y2', d => d.target.y);
|
|
node
|
|
.attr('cx', d => d.x)
|
|
.attr('cy', d => d.y);
|
|
});
|
|
|
|
function drag(sim) {
|
|
return d3.drag()
|
|
.on('start', (event, d) => {
|
|
if (!event.active) sim.alphaTarget(0.3).restart();
|
|
d.fx = d.x;
|
|
d.fy = d.y;
|
|
})
|
|
.on('drag', (event, d) => {
|
|
d.fx = event.x;
|
|
d.fy = event.y;
|
|
})
|
|
.on('end', (event, d) => {
|
|
if (!event.active) sim.alphaTarget(0);
|
|
d.fx = null;
|
|
d.fy = null;
|
|
});
|
|
}
|
|
})
|
|
.catch(err => {
|
|
container.innerHTML = `<div class="viz-empty">Failed to load data: ${err.message}</div>`;
|
|
});
|
|
}
|