feat: v0.1.4 — device intelligence dashboard
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).
This commit is contained in:
110
static/js/viz/fingerprint_clusters.js
Normal file
110
static/js/viz/fingerprint_clusters.js
Normal file
@@ -0,0 +1,110 @@
|
||||
/* 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>`;
|
||||
});
|
||||
}
|
||||
110
static/js/viz/ssid_graph.js
Normal file
110
static/js/viz/ssid_graph.js
Normal file
@@ -0,0 +1,110 @@
|
||||
/* 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>`;
|
||||
});
|
||||
}
|
||||
95
static/js/viz/vendor_treemap.js
Normal file
95
static/js/viz/vendor_treemap.js
Normal file
@@ -0,0 +1,95 @@
|
||||
/* Vendor Treemap — D3 treemap of device vendors by type */
|
||||
function renderVendorTreemap(selector, apiUrl) {
|
||||
const container = document.querySelector(selector);
|
||||
const tooltip = createTooltip();
|
||||
const { width, height } = getVizSize(container);
|
||||
|
||||
fetch(apiUrl)
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (!data.children || data.children.length === 0) {
|
||||
container.innerHTML = '<div class="viz-empty">No device data available</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const color = d3.scaleOrdinal()
|
||||
.domain(['wifi', 'ble'])
|
||||
.range(['#4dabf7', '#69db7c']);
|
||||
|
||||
const root = d3.hierarchy(data)
|
||||
.sum(d => d.value || 0)
|
||||
.sort((a, b) => b.value - a.value);
|
||||
|
||||
d3.treemap()
|
||||
.size([width, height])
|
||||
.padding(2)
|
||||
.round(true)(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.x0},${d.y0})`);
|
||||
|
||||
leaves.append('rect')
|
||||
.attr('class', 'treemap-cell')
|
||||
.attr('width', d => Math.max(0, d.x1 - d.x0))
|
||||
.attr('height', d => Math.max(0, d.y1 - d.y0))
|
||||
.attr('fill', d => {
|
||||
const type = d.parent ? d.parent.data.name : 'wifi';
|
||||
return color(type);
|
||||
})
|
||||
.attr('fill-opacity', 0.8)
|
||||
.on('mouseover', (event, d) => {
|
||||
const type = d.parent ? d.parent.data.name : '';
|
||||
tooltip.show(
|
||||
`<strong>${d.data.name}</strong><br>` +
|
||||
`Type: ${type}<br>` +
|
||||
`Devices: ${d.value}`,
|
||||
event
|
||||
);
|
||||
d3.select(event.target).attr('fill-opacity', 1);
|
||||
})
|
||||
.on('mousemove', (event) => {
|
||||
tooltip.move(event);
|
||||
})
|
||||
.on('mouseout', (event) => {
|
||||
tooltip.hide();
|
||||
d3.select(event.target).attr('fill-opacity', 0.8);
|
||||
});
|
||||
|
||||
// Labels only for cells big enough
|
||||
leaves.append('text')
|
||||
.attr('class', 'treemap-label')
|
||||
.attr('x', 4)
|
||||
.attr('y', 14)
|
||||
.text(d => {
|
||||
const w = d.x1 - d.x0;
|
||||
const h = d.y1 - d.y0;
|
||||
if (w < 40 || h < 18) return '';
|
||||
const name = d.data.name;
|
||||
const maxChars = Math.floor(w / 7);
|
||||
return name.length > maxChars ? name.slice(0, maxChars - 1) + '\u2026' : name;
|
||||
});
|
||||
|
||||
leaves.append('text')
|
||||
.attr('class', 'treemap-label')
|
||||
.attr('x', 4)
|
||||
.attr('y', 28)
|
||||
.style('font-size', '10px')
|
||||
.style('opacity', 0.8)
|
||||
.text(d => {
|
||||
const w = d.x1 - d.x0;
|
||||
const h = d.y1 - d.y0;
|
||||
if (w < 30 || h < 32) return '';
|
||||
return d.value;
|
||||
});
|
||||
})
|
||||
.catch(err => {
|
||||
container.innerHTML = `<div class="viz-empty">Failed to load data: ${err.message}</div>`;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user