feat(bluetooth): WiFi-style 2-line device rows

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
James Smith
2026-03-29 16:07:51 +01:00
parent 6967a44620
commit 71e5599300
2 changed files with 175 additions and 223 deletions
+85 -128
View File
@@ -4799,8 +4799,8 @@ header h1 .tagline {
/* Selected device highlight */
.bt-device-row.selected {
background: rgba(0, 212, 255, 0.1);
border-color: var(--accent-cyan);
background: rgba(74, 163, 255, 0.07);
border-left-color: var(--accent-cyan) !important;
}
.bt-device-list {
@@ -5212,57 +5212,107 @@ header h1 .tagline {
background: linear-gradient(90deg, #ef4444, #dc2626);
}
/* Bluetooth Device Row - Compact Design */
/* Bluetooth Device Row - WiFi-style 2-line layout */
.bt-device-row {
display: flex;
flex-direction: column;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-left: 4px solid #666;
border-radius: 6px;
padding: 10px 12px;
margin-bottom: 6px;
border-left: 3px solid transparent;
padding: 9px 12px;
cursor: pointer;
transition: all 0.15s ease;
border-bottom: 1px solid rgba(255, 255, 255, 0.03);
transition: background 0.12s;
}
.bt-device-row:last-child {
margin-bottom: 0;
border-bottom: none;
}
.bt-device-row:hover {
background: rgba(0, 212, 255, 0.05);
border-color: var(--accent-cyan);
}
.bt-device-row:hover { background: var(--bg-tertiary); }
.bt-device-row:focus-visible {
outline: 1px solid var(--accent-cyan);
outline-offset: 1px;
outline-offset: -1px;
}
.bt-row-main {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 12px;
}
.bt-row-left {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 4px 8px;
min-width: 0;
flex: 1;
}
.bt-row-right {
/* Bluetooth device row — 2-line WiFi-style layout */
.bt-row-top {
display: flex;
align-items: center;
gap: 10px;
justify-content: space-between;
gap: 6px;
margin-bottom: 7px;
}
.bt-row-top-left {
display: flex;
align-items: center;
gap: 5px;
min-width: 0;
flex: 1;
overflow: hidden;
}
.bt-row-top-right {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
.bt-row-name {
font-size: 12px;
font-weight: 600;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.bt-row-name.bt-unnamed {
color: var(--text-dim);
font-style: italic;
}
.bt-row-bottom {
display: flex;
align-items: center;
gap: 8px;
}
.bt-signal-bar-wrap { flex: 1; }
.bt-signal-track {
height: 4px;
background: var(--border-color);
border-radius: 2px;
overflow: hidden;
}
.bt-signal-fill {
height: 100%;
border-radius: 2px;
transition: width 0.4s ease;
}
.bt-signal-fill.strong { background: linear-gradient(90deg, var(--accent-green), #88d49b); }
.bt-signal-fill.medium { background: linear-gradient(90deg, var(--accent-green), var(--accent-orange)); }
.bt-signal-fill.weak { background: linear-gradient(90deg, var(--accent-orange), var(--accent-red)); }
.bt-row-meta {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
font-size: 10px;
color: var(--text-dim);
white-space: nowrap;
}
.bt-row-rssi { font-family: var(--font-mono); font-size: 10px; }
.bt-row-rssi.strong { color: var(--accent-green); }
.bt-row-rssi.medium { color: var(--accent-amber, #eab308); }
.bt-row-rssi.weak { color: var(--accent-red); }
.bt-proto-badge {
display: inline-block;
padding: 2px 6px;
@@ -5308,43 +5358,6 @@ header h1 .tagline {
border: 1px solid rgba(168, 85, 247, 0.3);
}
.bt-device-name {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
overflow-wrap: break-word;
word-break: break-word;
min-width: 0;
}
.bt-rssi-container {
display: flex;
align-items: center;
gap: 6px;
}
.bt-rssi-bar-bg {
width: 50px;
height: 8px;
background: var(--bg-secondary);
border-radius: 4px;
overflow: hidden;
}
.bt-rssi-bar {
height: 100%;
border-radius: 4px;
transition: width 0.3s ease;
}
.bt-rssi-value {
font-family: var(--font-mono);
font-size: 11px;
font-weight: 600;
min-width: 28px;
text-align: right;
}
.bt-status-dot {
width: 8px;
height: 8px;
@@ -5358,63 +5371,7 @@ header h1 .tagline {
}
.bt-status-dot.known {
background: #22c55e;
}
.bt-row-secondary {
font-size: 10px;
color: var(--text-dim);
margin-top: 4px;
padding-left: 42px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.bt-row-actions {
display: flex;
justify-content: flex-end;
padding: 4px 4px 0 42px;
}
/* Locate action on Bluetooth device rows (must be in index.css so it styles in scanner mode) */
.bt-row-actions .bt-locate-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
min-height: 28px;
padding: 5px 10px;
font-size: 10px;
line-height: 1;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--accent-green, #38c180);
background: linear-gradient(180deg, rgba(56, 193, 128, 0.2), rgba(56, 193, 128, 0.12));
border: 1px solid rgba(56, 193, 128, 0.42);
border-radius: 999px;
cursor: pointer;
white-space: nowrap;
transition: background 0.18s ease, border-color 0.18s ease, transform 0.18s ease, box-shadow 0.18s ease;
}
.bt-row-actions .bt-locate-btn:hover {
background: linear-gradient(180deg, rgba(56, 193, 128, 0.28), rgba(56, 193, 128, 0.18));
border-color: rgba(56, 193, 128, 0.72);
box-shadow: 0 0 0 1px rgba(56, 193, 128, 0.2), 0 6px 16px rgba(20, 80, 54, 0.35);
transform: translateY(-1px);
}
.bt-row-actions .bt-locate-btn:active {
transform: translateY(0);
}
.bt-row-actions .bt-locate-btn svg {
width: 12px;
height: 12px;
stroke: currentColor;
flex-shrink: 0;
background: #484f58;
}
.bt-device-filter-state {
+90 -95
View File
@@ -37,6 +37,7 @@ const BluetoothMode = (function() {
// Device list filter
let currentDeviceFilter = 'all';
let sortBy = 'rssi';
let sortListenersBound = false;
let currentSearchTerm = '';
let visibleDeviceCount = 0;
let pendingDeviceFlush = false;
@@ -161,6 +162,8 @@ const BluetoothMode = (function() {
}
function initSortControls() {
if (sortListenersBound) return;
sortListenersBound = true;
const sortGroup = document.getElementById('btSortGroup');
if (!sortGroup) return;
sortGroup.addEventListener('click', (e) => {
@@ -1390,6 +1393,7 @@ const BluetoothMode = (function() {
*/
function renderAllDevices() {
if (!deviceContainer) return;
if (devices.size === 0) return;
deviceContainer.innerHTML = '';
const sorted = [...devices.values()].sort((a, b) => {
@@ -1408,7 +1412,6 @@ const BluetoothMode = (function() {
function createSimpleDeviceCard(device) {
const protocol = device.protocol || 'ble';
const rssi = device.rssi_current;
const rssiColor = getRssiColor(rssi);
const inBaseline = device.in_baseline || false;
const isNew = !inBaseline;
const hasName = !!device.name;
@@ -1419,58 +1422,69 @@ const BluetoothMode = (function() {
const agentName = device._agent || 'Local';
const seenBefore = device.seen_before === true;
// Calculate RSSI bar width (0-100%)
// RSSI typically ranges from -100 (weak) to -30 (very strong)
// Signal bar
const rssiPercent = rssi != null ? Math.max(0, Math.min(100, ((rssi + 100) / 70) * 100)) : 0;
const fillClass = rssi == null ? 'weak'
: rssi >= -60 ? 'strong'
: rssi >= -75 ? 'medium' : 'weak';
const displayName = device.name || formatDeviceId(device.address);
const name = escapeHtml(displayName);
const addr = escapeHtml(isUuidAddress(device) ? formatAddress(device) : (device.address || 'Unknown'));
const mfr = device.manufacturer_name ? escapeHtml(device.manufacturer_name) : '';
const seenCount = device.seen_count || 0;
const searchIndex = [
displayName,
device.address,
device.manufacturer_name,
device.tracker_name,
device.tracker_type,
agentName
displayName, device.address, device.manufacturer_name,
device.tracker_name, device.tracker_type, agentName
].filter(Boolean).join(' ').toLowerCase();
// Protocol badge - compact
// Protocol badge
const protoBadge = protocol === 'ble'
? '<span class="bt-proto-badge ble">BLE</span>'
: '<span class="bt-proto-badge classic">CLASSIC</span>';
// Tracker badge - show if device is detected as tracker
// Tracker badge
let trackerBadge = '';
if (isTracker) {
const confColor = trackerConfidence === 'high' ? '#ef4444' :
trackerConfidence === 'medium' ? '#f97316' : '#eab308';
const confBg = trackerConfidence === 'high' ? 'rgba(239,68,68,0.15)' :
trackerConfidence === 'medium' ? 'rgba(249,115,22,0.15)' : 'rgba(234,179,8,0.15)';
const typeLabel = trackerType === 'airtag' ? 'AirTag' :
trackerType === 'tile' ? 'Tile' :
trackerType === 'samsung_smarttag' ? 'SmartTag' :
trackerType === 'findmy_accessory' ? 'FindMy' :
trackerType === 'chipolo' ? 'Chipolo' : 'TRACKER';
trackerBadge = '<span class="bt-tracker-badge" style="background:' + confBg + ';color:' + confColor + ';font-size:9px;padding:1px 4px;border-radius:3px;margin-left:4px;font-weight:600;">' + typeLabel + '</span>';
const confColor = trackerConfidence === 'high' ? '#ef4444'
: trackerConfidence === 'medium' ? '#f97316' : '#eab308';
const confBg = trackerConfidence === 'high' ? 'rgba(239,68,68,0.15)'
: trackerConfidence === 'medium' ? 'rgba(249,115,22,0.15)' : 'rgba(234,179,8,0.15)';
const typeLabel = trackerType === 'airtag' ? 'AirTag'
: trackerType === 'tile' ? 'Tile'
: trackerType === 'samsung_smarttag' ? 'SmartTag'
: trackerType === 'findmy_accessory' ? 'FindMy'
: trackerType === 'chipolo' ? 'Chipolo' : 'TRACKER';
trackerBadge = '<span class="bt-tracker-badge" style="background:' + confBg + ';color:' + confColor
+ ';font-size:9px;padding:1px 5px;border-radius:3px;font-weight:600;">' + typeLabel + '</span>';
}
// IRK badge - show if paired IRK is available
let irkBadge = '';
if (device.has_irk) {
irkBadge = '<span class="bt-irk-badge">IRK</span>';
}
// IRK badge
const irkBadge = device.has_irk ? '<span class="bt-irk-badge">IRK</span>' : '';
// Risk badge - show if risk score is significant
// Risk badge
let riskBadge = '';
if (riskScore >= 0.3) {
const riskColor = riskScore >= 0.5 ? '#ef4444' : '#f97316';
riskBadge = '<span class="bt-risk-badge" style="color:' + riskColor + ';font-size:8px;margin-left:4px;font-weight:600;">' + Math.round(riskScore * 100) + '% RISK</span>';
riskBadge = '<span class="bt-risk-badge" style="color:' + riskColor
+ ';font-size:8px;font-weight:600;">' + Math.round(riskScore * 100) + '% RISK</span>';
}
// Status indicator
// MAC cluster badge
const clusterBadge = device.mac_cluster_count > 1
? '<span class="bt-mac-cluster-badge">' + device.mac_cluster_count + ' MACs</span>'
: '';
// Flag badges (top-right, before status dot)
const hFlags = device.heuristic_flags || [];
let flagBadges = '';
if (device.is_persistent || hFlags.includes('persistent'))
flagBadges += '<span class="bt-flag-badge persistent">PERSIST</span>';
if (device.is_beacon_like || hFlags.includes('beacon_like'))
flagBadges += '<span class="bt-flag-badge beacon-like">BEACON</span>';
if (device.is_strong_stable || hFlags.includes('strong_stable'))
flagBadges += '<span class="bt-flag-badge strong-stable">STABLE</span>';
// Status dot
let statusDot;
if (isTracker && trackerConfidence === 'high') {
statusDot = '<span class="bt-status-dot tracker" style="background:#ef4444;"></span>';
@@ -1480,74 +1494,55 @@ const BluetoothMode = (function() {
statusDot = '<span class="bt-status-dot known"></span>';
}
// Distance display
// Bottom meta
const metaLabel = mfr || addr; // already HTML-escaped
const distM = device.estimated_distance_m;
let distStr = '';
if (distM != null) {
distStr = '~' + distM.toFixed(1) + 'm';
}
const distStr = distM != null ? '~' + distM.toFixed(1) + 'm' : '';
let metaHtml = '<span>' + metaLabel + '</span>';
if (distStr) metaHtml += '<span>' + distStr + '</span>';
metaHtml += '<span class="bt-row-rssi ' + fillClass + '">' + (rssi != null ? rssi : '—') + '</span>';
if (seenBefore) metaHtml += '<span class="bt-history-badge">SEEN</span>';
if (agentName !== 'Local')
metaHtml += '<span class="agent-badge agent-remote" style="font-size:8px;padding:1px 4px;">'
+ escapeHtml(agentName) + '</span>';
// Behavioral flag badges
const hFlags = device.heuristic_flags || [];
let flagBadges = '';
if (device.is_persistent || hFlags.includes('persistent')) {
flagBadges += '<span class="bt-flag-badge persistent">PERSIST</span>';
}
if (device.is_beacon_like || hFlags.includes('beacon_like')) {
flagBadges += '<span class="bt-flag-badge beacon-like">BEACON</span>';
}
if (device.is_strong_stable || hFlags.includes('strong_stable')) {
flagBadges += '<span class="bt-flag-badge strong-stable">STABLE</span>';
}
// Left border colour
const borderColor = isTracker && trackerConfidence === 'high' ? '#ef4444'
: isTracker ? '#f97316'
: rssi != null && rssi >= -60 ? 'var(--accent-green)'
: rssi != null && rssi >= -75 ? 'var(--accent-amber, #eab308)'
: 'var(--accent-red)';
// MAC cluster badge
let clusterBadge = '';
if (device.mac_cluster_count > 1) {
clusterBadge = '<span class="bt-mac-cluster-badge">' + device.mac_cluster_count + ' MACs</span>';
}
// Build secondary info line
let secondaryParts = [addr];
if (mfr) secondaryParts.push(mfr);
if (distStr) secondaryParts.push(distStr);
secondaryParts.push('Seen ' + seenCount + '×');
if (seenBefore) secondaryParts.push('<span class="bt-history-badge">SEEN BEFORE</span>');
// Add agent name if not Local
if (agentName !== 'Local') {
secondaryParts.push('<span class="agent-badge agent-remote" style="font-size:8px;padding:1px 4px;">' + escapeHtml(agentName) + '</span>');
}
const secondaryInfo = secondaryParts.join(' · ');
// Row border color - highlight trackers in red/orange
const borderColor = isTracker && trackerConfidence === 'high' ? '#ef4444' :
isTracker ? '#f97316' : rssiColor;
return '<div class="bt-device-row' + (isTracker ? ' is-tracker' : '') + '" data-bt-device-id="' + escapeAttr(device.device_id) + '" data-is-new="' + isNew + '" data-has-name="' + hasName + '" data-rssi="' + (rssi || -100) + '" data-is-tracker="' + isTracker + '" data-search="' + escapeAttr(searchIndex) + '" role="button" tabindex="0" data-keyboard-activate="true" style="border-left-color:' + borderColor + ';">' +
'<div class="bt-row-main">' +
'<div class="bt-row-left">' +
protoBadge +
'<span class="bt-device-name">' + name + '</span>' +
trackerBadge +
irkBadge +
riskBadge +
flagBadges +
clusterBadge +
'</div>' +
'<div class="bt-row-right">' +
'<div class="bt-rssi-container">' +
'<div class="bt-rssi-bar-bg"><div class="bt-rssi-bar" style="width:' + rssiPercent + '%;background:' + rssiColor + ';"></div></div>' +
'<span class="bt-rssi-value" style="color:' + rssiColor + ';">' + (rssi != null ? rssi : '--') + '</span>' +
'</div>' +
statusDot +
'</div>' +
'</div>' +
'<div class="bt-row-secondary">' + secondaryInfo + '</div>' +
'<div class="bt-row-actions">' +
'<button type="button" class="bt-locate-btn" data-locate-id="' + escapeAttr(device.device_id) + '">' +
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="10" r="3"/><path d="M12 21.7C17.3 17 20 13 20 10a8 8 0 1 0-16 0c0 3 2.7 7 8 11.7z"/></svg>' +
'Locate</button>' +
'</div>' +
'</div>';
return '<div class="bt-device-row' + (isTracker ? ' is-tracker' : '') + '"'
+ ' data-bt-device-id="' + escapeAttr(device.device_id) + '"'
+ ' data-is-new="' + isNew + '"'
+ ' data-has-name="' + hasName + '"'
+ ' data-rssi="' + (rssi ?? -100) + '"'
+ ' data-is-tracker="' + isTracker + '"'
+ ' data-search="' + escapeAttr(searchIndex) + '"'
+ ' role="button" tabindex="0" data-keyboard-activate="true"'
+ ' style="border-left-color:' + borderColor + ';">'
// Top line
+ '<div class="bt-row-top">'
+ '<div class="bt-row-top-left">'
+ protoBadge
+ '<span class="bt-row-name' + (hasName ? '' : ' bt-unnamed') + '">' + name + '</span>'
+ trackerBadge + irkBadge + riskBadge + clusterBadge
+ '</div>'
+ '<div class="bt-row-top-right">'
+ flagBadges + statusDot
+ '</div>'
+ '</div>'
// Bottom line
+ '<div class="bt-row-bottom">'
+ '<div class="bt-signal-bar-wrap">'
+ '<div class="bt-signal-track">'
+ '<div class="bt-signal-fill ' + fillClass + '" style="width:' + rssiPercent.toFixed(1) + '%"></div>'
+ '</div>'
+ '</div>'
+ '<div class="bt-row-meta">' + metaHtml + '</div>'
+ '</div>'
+ '</div>';
}
function getRssiColor(rssi) {