Files
bluehound/web_dashboard.html
2026-07-29 00:39:00 -04:00

1382 lines
53 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Bluehound</title>
<style>
/* minimal terminal theme — blue phosphor */
:root {
--bg: #05070d;
--panel: #0a0e18;
--border: #16202f;
--fg: #bcd3e8;
--dim: #5f7186;
--accent: #33b5ff;
--accent-dim: #1f5f8f;
--amber: #ffb454;
--red: #ff5f56;
--mono: 'SF Mono', 'JetBrains Mono', 'Fira Code', Menlo, Consolas, 'Courier New', monospace;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: var(--mono);
background: var(--bg);
color: var(--fg);
padding: 24px 16px;
font-size: 13px;
line-height: 1.5;
}
.container {
max-width: 1400px;
margin: 0 auto;
border: 1px solid var(--border);
background: var(--panel);
}
/* window chrome bar */
.container > h1 {
font-size: 13px;
font-weight: 600;
color: var(--fg);
padding: 8px 12px;
border-bottom: 1px solid var(--border);
background: #121212;
display: flex;
align-items: center;
gap: 8px;
}
h1::before {
content: "\25CF \25CF \25CF";
color: #333;
letter-spacing: 2px;
margin-right: 6px;
}
.container > p {
padding: 8px 12px 0;
}
.prompt { color: var(--accent); }
.prompt::before { content: "$ "; color: var(--accent-dim); }
.stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 0;
margin: 12px 0 0;
border-top: 1px solid var(--border);
}
.stat-card {
background: transparent;
padding: 12px;
border-right: 1px solid var(--border);
border-bottom: 1px solid var(--border);
}
.stat-value {
font-size: 1.4rem;
font-weight: 600;
color: var(--accent);
margin: 4px 0 0;
}
.stat-label {
color: var(--dim);
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 1px;
}
.stat-label::before { content: "> "; color: var(--accent-dim); }
.controls {
display: flex;
gap: 8px;
margin: 0;
padding: 12px;
flex-wrap: wrap;
border-bottom: 1px solid var(--border);
}
input, button, select {
padding: 6px 10px;
border-radius: 0;
border: 1px solid var(--border);
background: var(--bg);
color: var(--fg);
font-size: 0.85rem;
font-family: var(--mono);
}
input:focus, select:focus {
outline: none;
border-color: var(--accent-dim);
}
button {
cursor: pointer;
background: var(--bg);
border-color: var(--accent-dim);
color: var(--accent);
text-transform: lowercase;
}
button:hover {
background: var(--accent-dim);
color: var(--bg);
}
.table-container {
background: transparent;
border-radius: 0;
overflow: hidden;
}
table {
width: 100%;
border-collapse: collapse;
}
th, td {
padding: 6px 12px;
text-align: left;
}
th {
background: #121212;
font-weight: 600;
text-transform: uppercase;
font-size: 0.72rem;
letter-spacing: 1px;
color: var(--dim);
border-bottom: 1px solid var(--border);
}
.device-row {
cursor: pointer;
border-bottom: 1px solid var(--border);
}
.device-row:hover {
background: #151515;
}
.device-row:hover td:first-child {
box-shadow: inset 2px 0 0 var(--accent);
}
.device-row.expanded {
background: #151515;
border-bottom: none;
}
.details-row {
display: none;
border-bottom: 1px solid var(--border);
}
.details-row.show {
display: table-row;
}
.details-panel {
padding: 16px;
background: var(--bg);
border-left: 2px solid var(--accent-dim);
}
.details-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
padding-bottom: 8px;
border-bottom: 1px solid var(--border);
}
.details-title {
font-size: 0.95rem;
color: var(--accent);
display: flex;
align-items: center;
gap: 10px;
}
.nickname-container {
display: flex;
align-items: center;
gap: 10px;
}
.nickname-display {
color: var(--accent);
font-weight: 500;
}
.edit-icon {
cursor: pointer;
color: var(--dim);
font-size: 1rem;
padding: 4px;
}
.edit-icon:hover {
color: var(--accent);
}
.risk-badge {
display: inline-block;
padding: 1px 6px;
border-radius: 0;
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
margin-left: 8px;
border: 1px solid;
}
.risk-high {
background: transparent;
color: var(--red);
border-color: var(--red);
}
.risk-medium {
background: transparent;
color: var(--amber);
border-color: var(--amber);
}
.risk-low {
background: transparent;
color: var(--accent);
border-color: var(--accent-dim);
}
.risk-none {
background: transparent;
color: var(--dim);
border-color: var(--border);
}
.protocol-badge {
display: inline-block;
padding: 1px 5px;
border-radius: 0;
font-size: 0.68rem;
font-weight: 600;
text-transform: uppercase;
margin-right: 4px;
border: 1px solid var(--border);
}
.protocol-ble {
background: transparent;
color: var(--accent);
border-color: var(--accent-dim);
}
.protocol-classic {
background: transparent;
color: var(--amber);
border-color: var(--amber);
}
.protocol-dual {
background: transparent;
color: var(--fg);
border-color: var(--fg);
}
.vulnerability-list {
margin-top: 15px;
}
.vulnerability-item {
background: var(--panel);
border-left: 3px solid;
padding: 12px;
margin-bottom: 8px;
border-radius: 0;
}
.vulnerability-item.high {
border-left-color: var(--red);
}
.vulnerability-item.medium {
border-left-color: var(--amber);
}
.vulnerability-item.low {
border-left-color: var(--accent-dim);
}
.vuln-title {
font-weight: 600;
color: var(--fg);
margin-bottom: 4px;
}
.vuln-description {
color: var(--dim);
font-size: 0.85rem;
margin-bottom: 4px;
}
.vuln-cve {
color: var(--accent);
font-size: 0.8rem;
font-family: var(--mono);
}
.recommendation-list {
margin-top: 10px;
}
.recommendation-item {
padding: 8px;
background: var(--panel);
border-left: 2px solid var(--accent-dim);
margin-bottom: 6px;
border-radius: 0;
font-size: 0.85rem;
}
.nickname-input {
padding: 6px 10px;
font-size: 0.9rem;
flex: 1;
max-width: 300px;
}
.nickname-btn {
padding: 6px 12px;
font-size: 0.85rem;
}
.close-btn {
background: var(--bg);
border-color: var(--red);
color: var(--red);
padding: 6px 12px;
font-size: 0.85rem;
}
.close-btn:hover {
background: var(--red);
color: var(--bg);
}
.details-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 8px;
margin-bottom: 16px;
}
.detail-item {
background: var(--panel);
padding: 10px;
border-radius: 0;
border: 1px solid var(--border);
}
.detail-label {
color: var(--dim);
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 4px;
}
.detail-value {
color: var(--fg);
font-size: 0.9rem;
font-weight: 500;
}
.section-title {
color: var(--dim);
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 1px;
margin-top: 20px;
margin-bottom: 10px;
}
.section-title::before { content: "// "; color: var(--accent-dim); }
.characteristics-table {
background: var(--panel);
border-radius: 0;
overflow: hidden;
border: 1px solid var(--border);
}
.characteristics-table th {
background: #121212;
padding: 8px;
font-size: 0.75rem;
border-bottom: 1px solid var(--border);
}
.characteristics-table td {
padding: 8px;
font-size: 0.82rem;
border-bottom: 1px solid var(--border);
}
.distance-list {
max-height: 250px;
overflow-y: auto;
background: var(--panel);
border-radius: 0;
padding: 10px;
border: 1px solid var(--border);
}
.distance-entry {
display: flex;
justify-content: space-between;
padding: 6px 8px;
border-bottom: 1px solid var(--border);
font-size: 0.82rem;
}
.distance-entry:last-child {
border-bottom: none;
}
.rssi {
font-family: var(--mono);
font-weight: bold;
}
.rssi-strong { color: var(--accent); }
.rssi-medium { color: var(--amber); }
.rssi-weak { color: var(--red); }
.loading {
text-align: center;
padding: 32px;
color: var(--dim);
}
.loading::after {
content: "_";
animation: blink 1s step-end infinite;
color: var(--accent);
}
.error {
background: transparent;
border: 1px solid var(--red);
border-left-width: 3px;
padding: 12px;
border-radius: 0;
margin: 16px;
color: var(--red);
}
.timestamp {
font-family: var(--mono);
font-size: 0.85rem;
color: var(--dim);
}
.device-address {
font-family: var(--mono);
color: var(--accent);
}
.device-row.has-nickname {
border-left: 2px solid;
}
.nickname-badge {
display: inline-block;
padding: 1px 6px;
border-radius: 0;
font-size: 0.8rem;
font-weight: 600;
margin-right: 8px;
color: var(--bg);
}
.refresh-indicator {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 0;
background: var(--accent);
margin-left: auto;
animation: blink 1.2s step-end infinite;
}
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0.15; }
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
.no-data {
text-align: center;
padding: 20px;
color: var(--dim);
font-style: normal;
}
/* terminal scrollbars */
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-track { background: var(--bg); }
::-webkit-scrollbar-thumb { background: var(--border); }
::-webkit-scrollbar-thumb:hover { background: var(--accent-dim); }
</style>
</head>
<body>
<div class="container">
<h1>bluehound &mdash; ~/bluehound <span class="refresh-indicator"></span></h1>
<p class="prompt" style="color: var(--dim); margin-bottom: 4px;">bluehound --monitor --realtime</p>
<div class="stats" id="stats">
<div class="stat-card">
<div class="stat-label">Total Scans</div>
<div class="stat-value" id="total-scans">-</div>
</div>
<div class="stat-card">
<div class="stat-label">Unique Devices</div>
<div class="stat-value" id="unique-devices">-</div>
</div>
<div class="stat-card">
<div class="stat-label">First Scan</div>
<div class="stat-value" style="font-size: 1.2rem;" id="first-scan">-</div>
</div>
<div class="stat-card">
<div class="stat-label">Last Scan</div>
<div class="stat-value" style="font-size: 1.2rem;" id="last-scan">-</div>
</div>
</div>
<div class="controls">
<input type="date" id="date-input" />
<input type="number" id="hour-input" placeholder="Hour (0-23)" min="0" max="23" />
<input type="number" id="limit-input" placeholder="Limit" value="100" min="1" max="1000" />
<button onclick="fetchRecent()">Recent Scans</button>
<button onclick="fetchByDate()">Search by Date</button>
<button onclick="fetchByHour()">Search by Hour</button>
<button onclick="toggleAutoRefresh()">Auto-refresh: <span id="auto-refresh-status">OFF</span></button>
</div>
<div class="table-container">
<table>
<thead>
<tr>
<th>Last Seen</th>
<th>Device Address</th>
<th>Name / Company</th>
<th>Signal</th>
<th>Distance</th>
<th>Type</th>
<th>Services</th>
</tr>
</thead>
<tbody id="scans-table">
<tr><td colspan="7" class="loading">Loading...</td></tr>
</tbody>
</table>
</div>
</div>
<script>
const API_BASE = (window.location.protocol === 'file:' ||
window.location.hostname === 'localhost' ||
window.location.hostname === '127.0.0.1' ||
window.location.hostname === '')
? 'http://localhost:8080'
: `http://${window.location.hostname}:8080`;
let autoRefreshInterval = null;
let expandedRow = null;
let nicknameCache = {};
let deviceColors = {};
// Configuration: Only show devices from the most recent scan cycle
const DEVICE_TIMEOUT_SECONDS = 30; // remove if not seen within a couple scan cycles
const DEVICE_TIMEOUT_MS = DEVICE_TIMEOUT_SECONDS * 1000;
document.getElementById('date-input').valueAsDate = new Date();
function getDeviceColor(address) {
if (!deviceColors[address]) {
const colors = [
'#10b981', '#3b82f6', '#f59e0b', '#ef4444',
'#8b5cf6', '#ec4899', '#14b8a6', '#f97316',
'#06b6d4', '#84cc16', '#a855f7', '#fb923c'
];
const hash = address.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0);
deviceColors[address] = colors[hash % colors.length];
}
return deviceColors[address];
}
async function loadNicknames() {
try {
const response = await fetch(`${API_BASE}/nicknames`);
const data = await response.json();
nicknameCache = data.nicknames || {};
} catch (error) {
console.error('Failed to load nicknames:', error);
}
}
function getRSSIClass(rssi) {
if (rssi >= -60) return 'rssi-strong';
if (rssi >= -75) return 'rssi-medium';
return 'rssi-weak';
}
function formatTimestamp(timestamp) {
if (!timestamp || timestamp === '-') return '-';
// Database stores timestamps in UTC without 'Z' suffix (e.g., "2026-07-01 11:18:42")
// Add 'Z' to treat it as UTC, then convert to local timezone
const date = new Date(timestamp.replace(' ', 'T') + 'Z');
// If parsing fails, return original
if (isNaN(date.getTime())) return timestamp;
// Format in local system timezone with readable format
const options = {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false
};
return date.toLocaleString(undefined, options);
}
async function loadSecurityBadges(scans) {
for (const scan of scans) {
try {
const response = await fetch(`${API_BASE}/security/${scan.address}`);
if (response.ok) {
const security = await response.json();
const badge = document.getElementById(`risk-${scan.address}`);
if (badge) {
const riskClass = `risk-${security.risk_level.toLowerCase()}`;
badge.className = `risk-badge ${riskClass}`;
badge.textContent = security.risk_level;
}
}
} catch (error) {
// No security assessment yet
}
}
}
function renderScans(scans) {
const tbody = document.getElementById('scans-table');
if (!scans || scans.length === 0) {
tbody.innerHTML = '<tr><td colspan="7" class="loading">No scans found</td></tr>';
return;
}
// Group by device address, keep only most recent scan per device
const deviceMap = new Map();
const now = new Date();
scans.forEach(scan => {
if (!deviceMap.has(scan.address)) {
// Parse timestamp and check if device is still reachable
const scanTime = new Date(scan.timestamp.replace(' ', 'T') + 'Z');
const ageMs = now - scanTime;
// Only include devices seen within the timeout window
if (ageMs < DEVICE_TIMEOUT_MS) {
scan._ageSeconds = Math.floor(ageMs / 1000);
deviceMap.set(scan.address, scan);
}
}
});
const uniqueScans = Array.from(deviceMap.values());
// Load security assessments for all devices
loadSecurityBadges(uniqueScans);
tbody.innerHTML = uniqueScans.map((scan, index) => {
const name = scan.name || '<unnamed>';
const company = scan.company_name || '';
const displayName = company ? `${name} (${company})` : name;
const distance = scan.distance ? `~${scan.distance}m` : '-';
const protocolType = scan.protocol_type || 'BLE';
const addressType = scan.address_type || '-';
const isConnectable = scan.is_connectable ? '🔗' : '📡';
// Protocol type badge
let protocolBadge = '';
if (protocolType === 'CLASSIC') {
protocolBadge = '<span class="protocol-badge protocol-classic">Classic BT</span>';
} else if (protocolType === 'DUAL') {
protocolBadge = '<span class="protocol-badge protocol-dual">BLE + Classic</span>';
} else {
protocolBadge = '<span class="protocol-badge protocol-ble">BLE</span>';
}
// Device class or services
let typeInfo = '';
if (protocolType === 'CLASSIC' && scan.device_class_major) {
typeInfo = `${scan.device_class_major}`;
if (scan.device_class_minor && scan.device_class_minor !== 'Uncategorized') {
typeInfo += ` (${scan.device_class_minor})`;
}
} else if (scan.service_uuids) {
typeInfo = scan.service_uuids.split(',').length + ' services';
} else {
typeInfo = '-';
}
const nickname = nicknameCache[scan.address] || '';
const hasNickname = nickname ? 'has-nickname' : '';
const deviceColor = nickname ? getDeviceColor(scan.address) : '';
const nicknameBadge = nickname
? `<span class="nickname-badge" style="background-color: ${deviceColor};">${nickname}</span>`
: '';
const borderStyle = nickname ? `border-left-color: ${deviceColor};` : '';
return `
<tr class="device-row ${hasNickname}" style="${borderStyle}" onclick="toggleDeviceDetails('${scan.address}', ${index})" data-address="${scan.address}">
<td class="timestamp">${formatTimestamp(scan.timestamp)}</td>
<td class="device-address">${scan.address || '-'}</td>
<td>${nicknameBadge}${displayName}<span class="risk-badge-placeholder" id="risk-${scan.address}"></span></td>
<td class="rssi ${getRSSIClass(scan.rssi)}">${scan.rssi} dBm</td>
<td>${distance}</td>
<td>${protocolBadge} ${isConnectable} ${addressType}</td>
<td>${typeInfo}</td>
</tr>
<tr class="details-row" id="details-${index}">
<td colspan="7">
<div class="details-panel" id="details-panel-${index}">
<div class="loading">Loading details...</div>
</div>
</td>
</tr>
`}).join('');
}
function showError(message) {
const tbody = document.getElementById('scans-table');
tbody.innerHTML = `<tr><td colspan="7" class="error">Error: ${message}</td></tr>`;
}
async function toggleDeviceDetails(address, index) {
const detailsRow = document.getElementById(`details-${index}`);
const deviceRow = detailsRow.previousElementSibling;
if (expandedRow === index) {
detailsRow.classList.remove('show');
deviceRow.classList.remove('expanded');
expandedRow = null;
return;
}
if (expandedRow !== null) {
const prevDetailsRow = document.getElementById(`details-${expandedRow}`);
const prevDeviceRow = prevDetailsRow.previousElementSibling;
prevDetailsRow.classList.remove('show');
prevDeviceRow.classList.remove('expanded');
}
detailsRow.classList.add('show');
deviceRow.classList.add('expanded');
expandedRow = index;
await loadDeviceDetails(address, index);
}
async function loadDeviceDetails(address, index) {
const panel = document.getElementById(`details-panel-${index}`);
try {
const [distanceData, characteristicsData, nicknameData, recentScanData] = await Promise.all([
fetch(`${API_BASE}/distance/${address}?limit=20`).then(r => r.json()).catch(() => ({history: []})),
fetch(`${API_BASE}/characteristics/${address}`).then(r => r.json()).catch(() => ({characteristics: []})),
fetch(`${API_BASE}/nickname/${address}`).then(r => r.ok ? r.json() : {}).catch(() => ({})),
fetch(`${API_BASE}/scans/recent?limit=1000`).then(r => r.json()).then(data =>
data.scans.find(s => s.address === address) || {}
).catch(() => ({}))
]);
const nickname = nicknameData.nickname || nicknameCache[address] || '';
let html = `
<div class="details-header">
<div class="details-title">
<span>Device Details: ${address}</span>
<div class="nickname-container">
<span class="nickname-display" id="nickname-display-${index}" style="display: ${nickname ? 'inline' : 'none'};">
"${nickname}"
</span>
<span class="edit-icon" onclick="editNickname('${address}', ${index}, event)" title="Edit nickname">✏️</span>
</div>
</div>
<button class="close-btn" onclick="toggleDeviceDetails('${address}', ${index})">Close</button>
</div>
<div id="nickname-edit-${index}" style="display: none; margin-bottom: 20px;">
<input type="text" id="nickname-input-${index}" class="nickname-input"
placeholder="Enter device nickname" value="${nickname}">
<button class="nickname-btn" onclick="saveNickname('${address}', ${index})">Save</button>
<button class="nickname-btn close-btn" onclick="deleteNickname('${address}', ${index})">Delete</button>
<button class="nickname-btn" onclick="cancelEditNickname(${index})">Cancel</button>
</div>
`;
const lastSeen = distanceData.history?.[0]?.timestamp || 'Unknown';
const avgDistance = distanceData.history?.length > 0
? (distanceData.history.reduce((sum, h) => sum + h.distance, 0) / distanceData.history.length).toFixed(1)
: 'N/A';
// Get total scan count from database for this device
const countResponse = await fetch(`${API_BASE}/device/${address}/count`).catch(() => ({json: () => ({count: 0})}));
const countData = await countResponse.json().catch(() => ({count: 0}));
const totalScans = countData.count || 0;
html += `
<div class="details-grid">
<div class="detail-item">
<div class="detail-label">Last Seen</div>
<div class="detail-value">${lastSeen}</div>
</div>
<div class="detail-item">
<div class="detail-label">Total Scans</div>
<div class="detail-value" style="color:#60a5fa;">${totalScans.toLocaleString()}</div>
</div>
<div class="detail-item">
<div class="detail-label">Average Distance</div>
<div class="detail-value">${avgDistance} m</div>
</div>
<div class="detail-item">
<div class="detail-label">Characteristics</div>
<div class="detail-value">${characteristicsData.characteristics?.length || 0}</div>
</div>
</div>
`;
// Show Classic Bluetooth info if available
if (recentScanData.protocol_type === 'CLASSIC') {
html += `<div class="section-title">📻 Classic Bluetooth Information</div>`;
html += '<div class="details-grid">';
if (recentScanData.device_class_major) {
html += `
<div class="detail-item">
<div class="detail-label">Device Class</div>
<div class="detail-value">${recentScanData.device_class_major}</div>
</div>
`;
}
if (recentScanData.device_class_minor && recentScanData.device_class_minor !== 'Uncategorized') {
html += `
<div class="detail-item">
<div class="detail-label">Device Type</div>
<div class="detail-value">${recentScanData.device_class_minor}</div>
</div>
`;
}
html += '</div>';
// Show SDP services
if (recentScanData.sdp_services) {
try {
const sdpServices = JSON.parse(recentScanData.sdp_services);
if (sdpServices && sdpServices.length > 0) {
html += `<div class="section-title" style="margin-top:15px;">🔧 SDP Services</div>`;
html += '<div class="characteristics-table" style="max-height:300px; overflow-y:auto;">';
html += '<table><thead><tr><th>UUID</th><th>Service Name</th><th>RFCOMM Channel</th></tr></thead><tbody>';
sdpServices.forEach(service => {
html += `
<tr>
<td><code>${service.uuid || '-'}</code></td>
<td>${service.name || 'Unknown Service'}</td>
<td>${service.rfcomm_channel > 0 ? service.rfcomm_channel : '-'}</td>
</tr>
`;
});
html += '</tbody></table></div>';
}
} catch (e) {
console.error('Failed to parse SDP services:', e);
}
}
}
// Load security assessment
try {
const securityResponse = await fetch(`${API_BASE}/security/${address}`);
if (securityResponse.ok) {
const securityData = await securityResponse.json();
const riskClass = `risk-${securityData.risk_level.toLowerCase()}`;
const riskIcon = securityData.risk_level === 'HIGH' ? '🔴' :
securityData.risk_level === 'MEDIUM' ? '🟡' :
securityData.risk_level === 'LOW' ? '🟢' : '⚪';
html += `
<div class="section-title">🛡️ Security Assessment</div>
<div class="detail-item" style="margin-bottom: 15px;">
<div class="detail-label">Risk Level</div>
<div class="detail-value">
${riskIcon} <span class="risk-badge ${riskClass}">${securityData.risk_level}</span>
<small style="color:#94a3b8; margin-left:10px;">Assessed: ${securityData.last_assessed}</small>
</div>
</div>
`;
if (securityData.vulnerabilities && securityData.vulnerabilities.length > 0) {
html += '<div class="vulnerability-list"><strong style="color:#e2e8f0;">Vulnerabilities Found:</strong>';
securityData.vulnerabilities.forEach(vuln => {
const vulnClass = vuln.severity.toLowerCase();
html += `
<div class="vulnerability-item ${vulnClass}">
<div class="vuln-title">${vuln.name} <span style="color:#f59e0b; font-size:0.8rem;">[${vuln.severity}]</span></div>
<div class="vuln-description">${vuln.description}</div>
${vuln.cve ? `<div class="vuln-cve">🔗 ${vuln.cve}</div>` : ''}
</div>
`;
});
html += '</div>';
}
if (securityData.recommendations && securityData.recommendations.length > 0) {
html += '<div class="section-title" style="margin-top:15px;">💡 Recommendations</div>';
html += '<div class="recommendation-list">';
securityData.recommendations.forEach(rec => {
html += `<div class="recommendation-item">✓ ${rec}</div>`;
});
html += '</div>';
}
} else {
html += `
<div class="section-title">🛡️ Security Assessment</div>
<div class="no-data">No security assessment yet. Device must be connected for assessment.</div>
`;
}
} catch (error) {
console.error('Error loading security assessment:', error);
}
// Load tracking data (Apple Continuity, device info, etc.)
try {
const trackingResponse = await fetch(`${API_BASE}/tracking/${address}`);
if (trackingResponse.ok) {
const trackingData = await trackingResponse.json();
html += `<div class="section-title">📱 Device Tracking Information</div>`;
html += '<div class="details-grid">';
if (trackingData.manufacturer_name) {
html += `
<div class="detail-item">
<div class="detail-label">Manufacturer</div>
<div class="detail-value">${trackingData.manufacturer_name}</div>
</div>
`;
}
if (trackingData.device_model) {
html += `
<div class="detail-item">
<div class="detail-label">Model</div>
<div class="detail-value">${trackingData.device_model}</div>
</div>
`;
}
if (trackingData.firmware_version) {
html += `
<div class="detail-item">
<div class="detail-label">Firmware</div>
<div class="detail-value">${trackingData.firmware_version}</div>
</div>
`;
}
if (trackingData.hardware_version) {
html += `
<div class="detail-item">
<div class="detail-label">Hardware</div>
<div class="detail-value">${trackingData.hardware_version}</div>
</div>
`;
}
if (trackingData.serial_number) {
html += `
<div class="detail-item">
<div class="detail-label">Serial Number</div>
<div class="detail-value" style="font-family:monospace;">${trackingData.serial_number}</div>
</div>
`;
}
html += '</div>';
// Apple Continuity Data
if (trackingData.apple_continuity) {
const apple = trackingData.apple_continuity;
html += `<div class="section-title" style="margin-top:15px;">🍎 Apple Continuity Data</div>`;
html += '<div class="detail-item" style="background:#1e293b; padding:15px; border-radius:6px; border:1px solid #334155;">';
if (apple.device_type) {
html += `<strong>Device Type:</strong> ${apple.device_type}<br>`;
}
if (apple.battery >= 0) {
const batteryColor = apple.battery > 50 ? '#10b981' : apple.battery > 20 ? '#f59e0b' : '#ef4444';
html += `<strong>Battery:</strong> <span style="color:${batteryColor};">🔋 ${apple.battery}%</span><br>`;
}
if (apple.airpods_left && apple.airpods_left !== '0%') {
html += `<strong>AirPods Battery:</strong><br>`;
html += `&nbsp;&nbsp;Left: ${apple.airpods_left}<br>`;
html += `&nbsp;&nbsp;Right: ${apple.airpods_right}<br>`;
html += `&nbsp;&nbsp;Case: ${apple.airpods_case}<br>`;
}
if (apple.in_ear !== undefined) {
html += `<strong>In Ear:</strong> ${apple.in_ear ? 'Yes ✓' : 'No'}<br>`;
}
if (apple.case_open !== undefined) {
html += `<strong>Case Open:</strong> ${apple.case_open ? 'Yes' : 'No'}<br>`;
}
if (apple.handoff) {
html += `<strong>Handoff:</strong> ${apple.handoff}<br>`;
}
if (apple.raw) {
html += `<details style="margin-top:10px;"><summary style="cursor:pointer;color:#60a5fa;">Raw Data</summary>`;
html += `<code style="font-family:monospace;font-size:0.75rem;word-break:break-all;">${apple.raw}</code></details>`;
}
html += '</div>';
html += `
<div style="margin-top:10px; padding:10px; background:#7f1d1d; border:1px solid #991b1b; border-radius:4px;">
<strong style="color:#fca5a5;">⚠️ Privacy Concern:</strong><br>
<small style="color:#fca5a5;">This device is broadcasting detailed information (device type, battery level, in-ear status) that can be used for tracking your location and movements. This data is visible to anyone scanning Bluetooth in the area.</small>
</div>
`;
}
}
} catch (error) {
// No tracking data available
}
if (distanceData.history && distanceData.history.length > 0) {
html += `
<div class="section-title">📊 Distance History (Last 20)</div>
<div class="distance-list">
${distanceData.history.map(h => `
<div class="distance-entry">
<span>${h.timestamp}</span>
<span><strong>${h.distance.toFixed(1)}m</strong> (RSSI: <span class="rssi ${getRSSIClass(h.rssi)}">${h.rssi} dBm</span>)</span>
</div>
`).join('')}
</div>
`;
} else {
html += '<div class="no-data">No distance history available</div>';
}
if (characteristicsData.characteristics && characteristicsData.characteristics.length > 0) {
const services = {};
characteristicsData.characteristics.forEach(c => {
if (!services[c.service]) services[c.service] = [];
services[c.service].push(c);
});
html += '<div class="section-title">🔧 Device Characteristics</div>';
html += '<div class="characteristics-table"><table>';
html += '<thead><tr><th>Service</th><th>Characteristic</th><th>Properties</th><th>Action</th></tr></thead>';
html += '<tbody>';
for (const [service, chars] of Object.entries(services)) {
const serviceName = chars[0].service_name || service;
const serviceDisplay = chars[0].service_name ? `${serviceName}<br><small style="color:#64748b;">${service}</small>` : service;
chars.forEach((c, i) => {
const charName = c.characteristic_name || c.characteristic;
const charDisplay = c.characteristic_name
? `${charName}<br><small style="color:#64748b;">${c.characteristic}</small>`
: c.characteristic;
const description = c.description ? `<br><small style="color:#94a3b8; font-style:italic;">${c.description}</small>` : '';
const canRead = c.properties.toLowerCase().includes('read');
const readButton = canRead
? `<button class="nickname-btn" onclick="readCharacteristic('${address}', '${service}', '${c.characteristic}', ${index}, event)" style="font-size:0.75rem; padding:4px 8px;">📖 Read</button>`
: '<small style="color:#64748b;">—</small>';
html += `<tr>
${i === 0 ? `<td rowspan="${chars.length}">${serviceDisplay}</td>` : ''}
<td>${charDisplay}${description}<div id="char-value-${index}-${c.characteristic}" style="margin-top:8px;"></div></td>
<td><small>${c.properties}</small></td>
<td>${readButton}</td>
</tr>`;
});
}
html += '</tbody></table></div>';
} else {
html += '<div class="section-title">🔧 Device Characteristics</div>';
html += '<div class="no-data">No characteristics available (device may not be connectable)</div>';
}
panel.innerHTML = html;
} catch (error) {
console.error('Error loading device details:', error);
panel.innerHTML = '<div class="error">Failed to load device details</div>';
}
}
function editNickname(address, index, event) {
event.stopPropagation();
document.getElementById(`nickname-edit-${index}`).style.display = 'block';
document.getElementById(`nickname-input-${index}`).focus();
}
function cancelEditNickname(index) {
document.getElementById(`nickname-edit-${index}`).style.display = 'none';
}
async function readCharacteristic(address, service, characteristic, index, event) {
event.stopPropagation();
const valueDiv = document.getElementById(`char-value-${index}-${characteristic}`);
valueDiv.innerHTML = '<small style="color:#60a5fa;">🔄 Reading...</small>';
try {
const response = await fetch(`${API_BASE}/read/${address}/${service}/${characteristic}`);
if (!response.ok) {
const error = await response.json();
if (error.requires_pairing) {
valueDiv.innerHTML = `
<div style="background:#7f1d1d; border:1px solid #991b1b; padding:10px; border-radius:4px; margin-top:8px;">
<strong style="color:#fca5a5;">🔒 Pairing Required</strong><br>
<small style="color:#fca5a5;">${error.details}</small>
</div>
`;
} else if (response.status === 404) {
valueDiv.innerHTML = `<small style="color:#f59e0b;">⚠️ ${error.error} - ${error.details}</small>`;
} else if (response.status === 504) {
valueDiv.innerHTML = `<small style="color:#f59e0b;">⏱️ ${error.error}</small>`;
} else {
valueDiv.innerHTML = `<small style="color:#ef4444;">❌ ${error.error}</small>`;
}
return;
}
const data = await response.json();
let displayValue = '';
if (data.type === 'battery') {
const color = data.value > 50 ? '#10b981' : data.value > 20 ? '#f59e0b' : '#ef4444';
displayValue = `<strong style="color:${color}; font-size:1.1rem;">🔋 ${data.value}%</strong>`;
} else if (data.type === 'string') {
displayValue = `<strong style="color:#10b981;">"${data.value}"</strong>`;
} else if (data.type === 'temperature') {
displayValue = `<strong style="color:#3b82f6;">🌡️ ${data.value}${data.unit}</strong>`;
} else if (data.type === 'heart_rate') {
displayValue = `<strong style="color:#ef4444;">❤️ ${data.value} ${data.unit}</strong>`;
} else {
displayValue = `<small style="color:#94a3b8;">Raw: ${data.raw}</small>`;
}
valueDiv.innerHTML = displayValue;
} catch (error) {
console.error('Error reading characteristic:', error);
valueDiv.innerHTML = '<small style="color:#ef4444;">❌ Network error</small>';
}
}
async function saveNickname(address, index) {
const nickname = document.getElementById(`nickname-input-${index}`).value.trim();
if (!nickname) {
alert('Please enter a nickname');
return;
}
try {
const response = await fetch(`${API_BASE}/nickname/${address}`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({nickname})
});
if (response.ok) {
nicknameCache[address] = nickname;
document.getElementById(`nickname-display-${index}`).textContent = `"${nickname}"`;
document.getElementById(`nickname-display-${index}`).style.display = 'inline';
document.getElementById(`nickname-edit-${index}`).style.display = 'none';
// Refresh the table to update all rows with this address
fetchRecent();
} else {
alert('Failed to save nickname');
}
} catch (error) {
console.error('Error saving nickname:', error);
alert('Failed to save nickname');
}
}
async function deleteNickname(address, index) {
if (!confirm('Delete this nickname?')) return;
try {
const response = await fetch(`${API_BASE}/nickname/${address}`, {
method: 'DELETE'
});
if (response.ok) {
delete nicknameCache[address];
document.getElementById(`nickname-display-${index}`).textContent = '';
document.getElementById(`nickname-display-${index}`).style.display = 'none';
document.getElementById(`nickname-input-${index}`).value = '';
document.getElementById(`nickname-edit-${index}`).style.display = 'none';
// Refresh the table to update all rows with this address
fetchRecent();
} else {
alert('Failed to delete nickname');
}
} catch (error) {
console.error('Error deleting nickname:', error);
alert('Failed to delete nickname');
}
}
async function fetchStats() {
try {
const response = await fetch(`${API_BASE}/stats`);
const data = await response.json();
document.getElementById('total-scans').textContent = data.total_scans.toLocaleString();
document.getElementById('unique-devices').textContent = data.unique_devices.toLocaleString();
document.getElementById('first-scan').textContent = formatTimestamp(data.first_scan).split(' ')[0];
document.getElementById('last-scan').textContent = formatTimestamp(data.last_scan).split(' ').slice(1).join(' ');
} catch (error) {
console.error('Failed to fetch stats:', error);
}
}
async function fetchRecent() {
const limit = document.getElementById('limit-input').value || 100;
try {
const response = await fetch(`${API_BASE}/scans/recent?limit=${limit}`);
const data = await response.json();
renderScans(data.scans);
fetchStats();
expandedRow = null;
} catch (error) {
showError('Failed to fetch recent scans. Is the scanner running?');
console.error(error);
}
}
async function fetchByDate() {
const date = document.getElementById('date-input').value;
if (!date) {
alert('Please select a date');
return;
}
try {
const response = await fetch(`${API_BASE}/scans/date/${date}`);
const data = await response.json();
renderScans(data.scans);
fetchStats();
expandedRow = null;
} catch (error) {
showError('Failed to fetch scans by date');
console.error(error);
}
}
async function fetchByHour() {
const hour = document.getElementById('hour-input').value;
if (hour === '' || hour < 0 || hour > 23) {
alert('Please enter a valid hour (0-23)');
return;
}
try {
const response = await fetch(`${API_BASE}/scans/hour/${hour}`);
const data = await response.json();
renderScans(data.scans);
fetchStats();
expandedRow = null;
} catch (error) {
showError('Failed to fetch scans by hour');
console.error(error);
}
}
function toggleAutoRefresh() {
if (autoRefreshInterval) {
clearInterval(autoRefreshInterval);
autoRefreshInterval = null;
document.getElementById('auto-refresh-status').textContent = 'OFF';
} else {
autoRefreshInterval = setInterval(fetchRecent, 5000);
document.getElementById('auto-refresh-status').textContent = 'ON (5s)';
fetchRecent();
}
}
loadNicknames();
fetchRecent();
// Auto-enable refresh so devices disappear when out of range
setTimeout(() => {
if (!autoRefreshInterval) {
toggleAutoRefresh(); // Enable auto-refresh by default
}
}, 1000);
</script>
</body>
</html>