refactor(dashboard): remove duplicate component files
Delete non-canonical commandpalette.* and blepanel.js in favor of the hyphenated command-palette.* and ble-panel.* which match the fleet-page.* naming convention. Rename test file accordingly. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
c552ccd5a9
commit
3a85eaacc3
4 changed files with 1 additions and 1854 deletions
|
|
@ -1,219 +0,0 @@
|
|||
/**
|
||||
* Spaxel Dashboard — Command Palette Styles (Component 34)
|
||||
*
|
||||
* Activated by Ctrl+K / Cmd+K in expert mode only.
|
||||
*/
|
||||
|
||||
/* ===== Overlay backdrop ===== */
|
||||
.cp-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9000;
|
||||
}
|
||||
|
||||
.cp-overlay.cp-visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.cp-backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
animation: cp-fade-in 0.12s ease-out;
|
||||
}
|
||||
|
||||
@keyframes cp-fade-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
/* ===== Container ===== */
|
||||
.cp-container {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 90%;
|
||||
max-width: 600px;
|
||||
background: #1e293b;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.6), 0 0 0 1px rgba(255,255,255,0.06);
|
||||
overflow: hidden;
|
||||
animation: cp-slide-in 0.14s ease-out;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
@keyframes cp-slide-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, calc(-50% - 12px));
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== Search row ===== */
|
||||
.cp-search-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.07);
|
||||
}
|
||||
|
||||
.cp-search-icon {
|
||||
font-size: 16px;
|
||||
flex-shrink: 0;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.cp-input {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
color: #f1f5f9;
|
||||
font-size: 15px;
|
||||
font-family: inherit;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.cp-input::placeholder {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.cp-esc-hint {
|
||||
font-size: 11px;
|
||||
color: #475569;
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
border-radius: 4px;
|
||||
padding: 2px 7px;
|
||||
flex-shrink: 0;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
/* ===== Results list ===== */
|
||||
.cp-results {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 6px 0;
|
||||
max-height: 360px; /* ~8 items */
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.cp-results::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.cp-results::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.cp-results::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* Group header */
|
||||
.cp-group-header {
|
||||
padding: 6px 16px 4px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
.cp-empty {
|
||||
padding: 32px 16px;
|
||||
text-align: center;
|
||||
color: #475569;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Result item */
|
||||
.cp-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 9px 16px;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.cp-item:hover {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.cp-item-selected {
|
||||
background: rgba(59, 130, 246, 0.18) !important; /* #3b82f6 at 18% */
|
||||
}
|
||||
|
||||
.cp-item-icon {
|
||||
font-size: 16px;
|
||||
flex-shrink: 0;
|
||||
width: 20px;
|
||||
text-align: center;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.cp-item-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.cp-item-label {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #f1f5f9;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cp-item-secondary {
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cp-item-arrow {
|
||||
font-size: 18px;
|
||||
color: #334155;
|
||||
flex-shrink: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.cp-item-selected .cp-item-arrow {
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
/* ===== Responsive ===== */
|
||||
@media (max-width: 640px) {
|
||||
.cp-container {
|
||||
width: 96%;
|
||||
top: 12%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== Reduced motion ===== */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.cp-backdrop,
|
||||
.cp-container {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,656 +0,0 @@
|
|||
/**
|
||||
* Spaxel Dashboard - BLE Device Panel (Phase 6)
|
||||
*
|
||||
* Provides UI for managing BLE device registry and identity matching.
|
||||
*/
|
||||
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
// State
|
||||
const state = {
|
||||
devices: new Map(), // addr -> device record
|
||||
matches: new Map(), // blobID -> identity match
|
||||
aliases: new Map(), // addr -> list of aliases
|
||||
duplicates: [], // possible duplicate devices
|
||||
expanded: false,
|
||||
selectedDevice: null,
|
||||
editingDevice: null,
|
||||
wsConnected: false
|
||||
};
|
||||
|
||||
// DOM elements
|
||||
let panelEl, listEl, headerEl, countEl;
|
||||
|
||||
// Initialize the panel
|
||||
function init() {
|
||||
createPanel();
|
||||
startPolling();
|
||||
console.log('[BLE Panel] Initialized');
|
||||
}
|
||||
|
||||
// Create the panel DOM structure
|
||||
function createPanel() {
|
||||
// Find or create panel container
|
||||
let container = document.getElementById('ble-panel');
|
||||
if (!container) {
|
||||
container = document.createElement('div');
|
||||
container.id = 'ble-panel';
|
||||
container.className = 'side-panel';
|
||||
document.body.appendChild(container);
|
||||
}
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="panel-header" id="ble-panel-header">
|
||||
<span class="panel-title">
|
||||
<span class="panel-icon">👤</span>
|
||||
People & Devices
|
||||
</span>
|
||||
<span class="panel-count" id="ble-device-count">0</span>
|
||||
<button class="panel-toggle" id="ble-panel-toggle">▼</button>
|
||||
</div>
|
||||
<div class="panel-content" id="ble-panel-content" style="display: none;">
|
||||
<div class="panel-section">
|
||||
<div class="section-header">
|
||||
<span>People</span>
|
||||
<button class="btn-small" id="ble-add-person">+ Add</button>
|
||||
</div>
|
||||
<div id="ble-people-list" class="device-list"></div>
|
||||
</div>
|
||||
<div class="panel-section">
|
||||
<div class="section-header">
|
||||
<span>Discovered Devices</span>
|
||||
<span class="section-info" id="ble-discovered-count">0</span>
|
||||
</div>
|
||||
<div id="ble-devices-list" class="device-list"></div>
|
||||
</div>
|
||||
<div class="panel-section" id="duplicates-section" style="display: none;">
|
||||
<div class="section-header">
|
||||
<span>Possible Rotations</span>
|
||||
<span class="section-info">🔄</span>
|
||||
</div>
|
||||
<div id="ble-duplicates-list" class="duplicates-list"></div>
|
||||
</div>
|
||||
<div class="panel-section">
|
||||
<div class="section-header">
|
||||
<span>Recent Crossings</span>
|
||||
</div>
|
||||
<div id="ble-crossings-list" class="crossing-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="device-modal" id="ble-device-modal" style="display: none;">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<span id="modal-title">Edit Device</span>
|
||||
<button class="modal-close" id="modal-close">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label>Name</label>
|
||||
<input type="text" id="modal-name" placeholder="e.g., Alice's Phone">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Label</label>
|
||||
<input type="text" id="modal-label" placeholder="e.g., Alice">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Color</label>
|
||||
<input type="color" id="modal-color" value="#4fc3f7">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>
|
||||
<input type="checkbox" id="modal-is-person">
|
||||
This device represents a person
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Device Type</label>
|
||||
<select id="modal-device-type">
|
||||
<option value="unknown">Unknown</option>
|
||||
<option value="phone">Phone</option>
|
||||
<option value="watch">Watch</option>
|
||||
<option value="tracker">Tracker</option>
|
||||
<option value="tablet">Tablet</option>
|
||||
<option value="laptop">Laptop</option>
|
||||
<option value="headphones">Headphones</option>
|
||||
<option value="other">Other</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn-secondary" id="modal-cancel">Cancel</button>
|
||||
<button class="btn-primary" id="modal-save">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="device-modal" id="ble-merge-modal" style="display: none;">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<span id="merge-modal-title">Merge Devices</span>
|
||||
<button class="modal-close" id="merge-modal-close">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="merge-info">
|
||||
<p>These two devices appear to be the same device with a rotated MAC address:</p>
|
||||
<div class="merge-devices">
|
||||
<div class="merge-device" id="merge-device-1">
|
||||
<span class="merge-mac"></span>
|
||||
<span class="merge-name"></span>
|
||||
</div>
|
||||
<div class="merge-arrow">↓</div>
|
||||
<div class="merge-device" id="merge-device-2">
|
||||
<span class="merge-mac"></span>
|
||||
<span class="merge-name"></span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="merge-explanation">Merging will keep the first device and remove the second. All identity associations will be preserved.</p>
|
||||
<p class="merge-confirmation">Are these the same device? Only merge if you're certain.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn-secondary" id="merge-modal-cancel">Cancel</button>
|
||||
<button class="btn-primary btn-danger" id="merge-modal-confirm">Yes, Merge</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
panelEl = container;
|
||||
headerEl = document.getElementById('ble-panel-header');
|
||||
listEl = document.getElementById('ble-panel-content');
|
||||
countEl = document.getElementById('ble-device-count');
|
||||
|
||||
// Event listeners
|
||||
headerEl.addEventListener('click', togglePanel);
|
||||
document.getElementById('ble-add-person').addEventListener('click', showAddPersonModal);
|
||||
document.getElementById('modal-close').addEventListener('click', hideModal);
|
||||
document.getElementById('modal-cancel').addEventListener('click', hideModal);
|
||||
document.getElementById('modal-save').addEventListener('click', saveDevice);
|
||||
|
||||
// Merge modal event listeners
|
||||
document.getElementById('merge-modal-close').addEventListener('click', hideMergeModal);
|
||||
document.getElementById('merge-modal-cancel').addEventListener('click', hideMergeModal);
|
||||
document.getElementById('merge-modal-confirm').addEventListener('click', confirmMerge);
|
||||
}
|
||||
|
||||
// Toggle panel expansion
|
||||
function togglePanel() {
|
||||
state.expanded = !state.expanded;
|
||||
listEl.style.display = state.expanded ? 'block' : 'none';
|
||||
document.getElementById('ble-panel-toggle').textContent = state.expanded ? '▲' : '▼';
|
||||
}
|
||||
|
||||
// Start polling for data
|
||||
function startPolling() {
|
||||
fetchDevices();
|
||||
fetchMatches();
|
||||
fetchCrossings();
|
||||
fetchDuplicates();
|
||||
|
||||
setInterval(fetchDevices, 10000);
|
||||
setInterval(fetchMatches, 5000);
|
||||
setInterval(fetchCrossings, 15000);
|
||||
setInterval(fetchDuplicates, 30000);
|
||||
}
|
||||
|
||||
// Fetch BLE devices
|
||||
function fetchDevices() {
|
||||
fetch('/api/ble/devices')
|
||||
.then(function(res) { return res.json(); })
|
||||
.then(function(data) {
|
||||
handleDevicesUpdate(data || []);
|
||||
})
|
||||
.catch(function(err) {
|
||||
console.error('[BLE Panel] Failed to fetch devices:', err);
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch identity matches
|
||||
function fetchMatches() {
|
||||
fetch('/api/ble/matches')
|
||||
.then(function(res) { return res.json(); })
|
||||
.then(function(data) {
|
||||
handleMatchesUpdate(data || []);
|
||||
})
|
||||
.catch(function(err) {
|
||||
console.error('[BLE Panel] Failed to fetch matches:', err);
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch recent crossings
|
||||
function fetchCrossings() {
|
||||
fetch('/api/zones/crossings?limit=10')
|
||||
.then(function(res) { return res.json(); })
|
||||
.then(function(data) {
|
||||
handleCrossingsUpdate(data || []);
|
||||
})
|
||||
.catch(function(err) {
|
||||
// Silently ignore - zones may not be configured
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch possible duplicate devices (for MAC rotation)
|
||||
function fetchDuplicates() {
|
||||
fetch('/api/ble/duplicates')
|
||||
.then(function(res) { return res.json(); })
|
||||
.then(function(data) {
|
||||
state.duplicates = data.duplicates || [];
|
||||
updateDuplicatesList();
|
||||
})
|
||||
.catch(function(err) {
|
||||
console.error('[BLE Panel] Failed to fetch duplicates:', err);
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch device aliases
|
||||
function fetchDeviceAliases(addr) {
|
||||
fetch('/api/ble/devices/' + encodeURIComponent(addr) + '/aliases')
|
||||
.then(function(res) { return res.json(); })
|
||||
.then(function(data) {
|
||||
state.aliases.set(addr, data);
|
||||
updateDeviceList(); // Refresh to show rotation icon
|
||||
})
|
||||
.catch(function(err) {
|
||||
// Endpoint may not exist yet
|
||||
console.error('[BLE Panel] Failed to fetch aliases:', err);
|
||||
});
|
||||
}
|
||||
|
||||
// Handle devices update
|
||||
function handleDevicesUpdate(devices) {
|
||||
state.devices.clear();
|
||||
devices.forEach(function(d) {
|
||||
state.devices.set(d.addr, d);
|
||||
});
|
||||
|
||||
updateDeviceList();
|
||||
countEl.textContent = devices.filter(function(d) { return d.is_person; }).length;
|
||||
}
|
||||
|
||||
// Handle identity matches update
|
||||
function handleMatchesUpdate(matches) {
|
||||
state.matches.clear();
|
||||
matches.forEach(function(m) {
|
||||
state.matches.set(m.blob_id, m);
|
||||
});
|
||||
|
||||
// Update 3D visualization
|
||||
if (window.Viz3D && window.Viz3D.updateIdentities) {
|
||||
Viz3D.updateIdentities(matches);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle crossings update
|
||||
function handleCrossingsUpdate(crossings) {
|
||||
var list = document.getElementById('ble-crossings-list');
|
||||
if (!crossings || crossings.length === 0) {
|
||||
list.innerHTML = '<div class="empty-state">No recent crossings</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
var html = '';
|
||||
crossings.forEach(function(c) {
|
||||
var time = formatTime(new Date(c.timestamp));
|
||||
var identity = c.identity || 'Unknown';
|
||||
var direction = c.direction > 0 ? '→' : '←';
|
||||
html += '<div class="crossing-item">' +
|
||||
'<span class="crossing-time">' + time + '</span>' +
|
||||
'<span class="crossing-identity">' + identity + '</span>' +
|
||||
'<span class="crossing-portal">' + direction + ' Portal</span>' +
|
||||
'</div>';
|
||||
});
|
||||
list.innerHTML = html;
|
||||
}
|
||||
|
||||
// Update duplicates list
|
||||
function updateDuplicatesList() {
|
||||
var section = document.getElementById('duplicates-section');
|
||||
var list = document.getElementById('ble-duplicates-list');
|
||||
|
||||
if (!state.duplicates || state.duplicates.length === 0) {
|
||||
section.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
section.style.display = 'block';
|
||||
var html = '';
|
||||
state.duplicates.forEach(function(dup) {
|
||||
html += '<div class="duplicate-item" data-mac1="' + dup.mac1 + '" data-mac2="' + dup.mac2 + '">' +
|
||||
'<div class="duplicate-header">' +
|
||||
'<span class="duplicate-reason">' + dup.reason + '</span>' +
|
||||
'<span class="duplicate-confidence">' + Math.round(dup.confidence * 100) + '% match</span>' +
|
||||
'</div>' +
|
||||
'<div class="duplicate-devices">' +
|
||||
'<span class="dup-mac">' + dup.mac1.substr(-8) + '</span>' +
|
||||
'<span class="dup-arrow">↔</span>' +
|
||||
'<span class="dup-mac">' + dup.mac2.substr(-8) + '</span>' +
|
||||
'</div>' +
|
||||
'<div class="duplicate-actions">' +
|
||||
'<button class="btn-small btn-merge" data-mac1="' + dup.mac1 + '" data-mac2="' + dup.mac2 + '">Merge</button>' +
|
||||
'<button class="btn-small btn-dismiss" data-mac1="' + dup.mac1 + '" data-mac2="' + dup.mac2 + '">Dismiss</button>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
});
|
||||
list.innerHTML = html;
|
||||
|
||||
// Add event listeners
|
||||
list.querySelectorAll('.btn-merge').forEach(function(btn) {
|
||||
btn.addEventListener('click', function(e) {
|
||||
e.stopPropagation();
|
||||
var mac1 = this.getAttribute('data-mac1');
|
||||
var mac2 = this.getAttribute('data-mac2');
|
||||
showMergeConfirm(mac1, mac2);
|
||||
});
|
||||
});
|
||||
|
||||
list.querySelectorAll('.btn-dismiss').forEach(function(btn) {
|
||||
btn.addEventListener('click', function(e) {
|
||||
e.stopPropagation();
|
||||
var item = this.closest('.duplicate-item');
|
||||
item.style.display = 'none';
|
||||
// Remove from state
|
||||
state.duplicates = state.duplicates.filter(function(d) {
|
||||
return d.mac1 !== item.getAttribute('data-mac1') || d.mac2 !== item.getAttribute('data-mac2');
|
||||
});
|
||||
if (state.duplicates.length === 0) {
|
||||
section.style.display = 'none';
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Update device list UI
|
||||
function updateDeviceList() {
|
||||
var peopleList = document.getElementById('ble-people-list');
|
||||
var devicesList = document.getElementById('ble-devices-list');
|
||||
|
||||
var people = [];
|
||||
var otherDevices = [];
|
||||
|
||||
state.devices.forEach(function(d) {
|
||||
if (d.is_person) {
|
||||
people.push(d);
|
||||
} else {
|
||||
otherDevices.push(d);
|
||||
}
|
||||
});
|
||||
|
||||
// Sort people by name
|
||||
people.sort(function(a, b) { return (a.name || '').localeCompare(b.name || ''); });
|
||||
otherDevices.sort(function(a, b) { return (a.device_name || a.addr).localeCompare(b.device_name || b.addr); });
|
||||
|
||||
// Update people list
|
||||
if (people.length === 0) {
|
||||
peopleList.innerHTML = '<div class="empty-state">No people configured</div>';
|
||||
} else {
|
||||
var html = '';
|
||||
people.forEach(function(p) {
|
||||
var color = p.color || '#4fc3f7';
|
||||
var loc = p.last_location || {};
|
||||
var locStr = '';
|
||||
if (loc.confidence > 0) {
|
||||
locStr = '<span class="device-loc">📍</span>';
|
||||
}
|
||||
var aliasData = state.aliases.get(p.addr);
|
||||
var hasAliases = aliasData && aliasData.alias_count > 0;
|
||||
var rotationIcon = hasAliases ? '<span class="rotation-icon" title="Has address rotation history">🔄</span>' : '';
|
||||
|
||||
html += '<div class="device-item person" data-addr="' + p.addr + '">' +
|
||||
'<span class="device-color" style="background:' + color + '"></span>' +
|
||||
'<span class="device-name">' + (p.name || p.label || 'Unknown') + '</span>' +
|
||||
rotationIcon +
|
||||
locStr +
|
||||
'<button class="device-expand" data-addr="' + p.addr + '">▼</button>' +
|
||||
'<button class="device-edit" data-addr="' + p.addr + '">✏️</button>' +
|
||||
'</div>';
|
||||
|
||||
// Add aliases section if expanded
|
||||
if (hasAliases && p.expanded) {
|
||||
html += '<div class="device-aliases" data-parent="' + p.addr + '">';
|
||||
html += '<div class="aliases-header">Address History</div>';
|
||||
aliasData.aliases.forEach(function(alias) {
|
||||
var age = formatTime(new Date(alias.last_seen));
|
||||
html += '<div class="alias-item">' +
|
||||
'<span class="alias-addr">' + alias.addr + '</span>' +
|
||||
'<span class="alias-age">' + age + '</span>' +
|
||||
'</div>';
|
||||
});
|
||||
html += '<div class="aliases-note">Phones rotate addresses every 15-30 min. All entries above are the same device.</div>';
|
||||
html += '</div>';
|
||||
}
|
||||
});
|
||||
peopleList.innerHTML = html;
|
||||
|
||||
// Add click handlers
|
||||
peopleList.querySelectorAll('.device-edit').forEach(function(btn) {
|
||||
btn.addEventListener('click', function(e) {
|
||||
e.stopPropagation();
|
||||
var addr = this.getAttribute('data-addr');
|
||||
showEditModal(addr);
|
||||
});
|
||||
});
|
||||
|
||||
peopleList.querySelectorAll('.device-expand').forEach(function(btn) {
|
||||
btn.addEventListener('click', function(e) {
|
||||
e.stopPropagation();
|
||||
var addr = this.getAttribute('data-addr');
|
||||
toggleDeviceExpanded(addr);
|
||||
});
|
||||
});
|
||||
|
||||
// Make device items clickable to expand
|
||||
peopleList.querySelectorAll('.device-item.person').forEach(function(item) {
|
||||
item.addEventListener('click', function(e) {
|
||||
if (!e.target.classList.contains('device-edit') && !e.target.classList.contains('device-expand')) {
|
||||
var addr = this.getAttribute('data-addr');
|
||||
toggleDeviceExpanded(addr);
|
||||
// Fetch aliases when expanding
|
||||
if (!state.aliases.has(addr)) {
|
||||
fetchDeviceAliases(addr);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Update devices list
|
||||
document.getElementById('ble-discovered-count').textContent = otherDevices.length;
|
||||
|
||||
if (otherDevices.length === 0) {
|
||||
devicesList.innerHTML = '<div class="empty-state">No devices discovered</div>';
|
||||
} else {
|
||||
var html = '';
|
||||
otherDevices.slice(0, 10).forEach(function(d) {
|
||||
var deviceName = d.device_name || d.addr.substr(-5);
|
||||
var typeIcon = getTypeIcon(d.device_type);
|
||||
html += '<div class="device-item" data-addr="' + d.addr + '">' +
|
||||
'<span class="device-icon">' + typeIcon + '</span>' +
|
||||
'<span class="device-name">' + deviceName + '</span>' +
|
||||
'<button class="device-edit" data-addr="' + d.addr + '">+</button>' +
|
||||
'</div>';
|
||||
});
|
||||
if (otherDevices.length > 10) {
|
||||
html += '<div class="more-link">+ ' + (otherDevices.length - 10) + ' more</div>';
|
||||
}
|
||||
devicesList.innerHTML = html;
|
||||
|
||||
// Add click handlers
|
||||
devicesList.querySelectorAll('.device-edit').forEach(function(btn) {
|
||||
btn.addEventListener('click', function(e) {
|
||||
e.stopPropagation();
|
||||
var addr = this.getAttribute('data-addr');
|
||||
showEditModal(addr);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle device expanded state
|
||||
function toggleDeviceExpanded(addr) {
|
||||
var device = state.devices.get(addr);
|
||||
if (device) {
|
||||
device.expanded = !device.expanded;
|
||||
updateDeviceList();
|
||||
}
|
||||
}
|
||||
|
||||
// Show add person modal
|
||||
function showAddPersonModal() {
|
||||
state.editingDevice = null;
|
||||
document.getElementById('modal-title').textContent = 'Add Person';
|
||||
document.getElementById('modal-name').value = '';
|
||||
document.getElementById('modal-label').value = '';
|
||||
document.getElementById('modal-color').value = '#4fc3f7';
|
||||
document.getElementById('modal-is-person').checked = true;
|
||||
document.getElementById('modal-device-type').value = 'phone';
|
||||
document.getElementById('ble-device-modal').style.display = 'flex';
|
||||
}
|
||||
|
||||
// Show edit modal
|
||||
function showEditModal(addr) {
|
||||
var device = state.devices.get(addr);
|
||||
if (!device) return;
|
||||
|
||||
state.editingDevice = addr;
|
||||
document.getElementById('modal-title').textContent = 'Edit Device';
|
||||
document.getElementById('modal-name').value = device.name || '';
|
||||
document.getElementById('modal-label').value = device.label || '';
|
||||
document.getElementById('modal-color').value = device.color || '#4fc3f7';
|
||||
document.getElementById('modal-is-person').checked = device.is_person;
|
||||
document.getElementById('modal-device-type').value = device.device_type || 'unknown';
|
||||
document.getElementById('ble-device-modal').style.display = 'flex';
|
||||
}
|
||||
|
||||
// Hide modal
|
||||
function hideModal() {
|
||||
document.getElementById('ble-device-modal').style.display = 'none';
|
||||
state.editingDevice = null;
|
||||
}
|
||||
|
||||
// Save device
|
||||
function saveDevice() {
|
||||
var data = {
|
||||
name: document.getElementById('modal-name').value,
|
||||
label: document.getElementById('modal-label').value,
|
||||
color: document.getElementById('modal-color').value,
|
||||
is_person: document.getElementById('modal-is-person').checked,
|
||||
device_type: document.getElementById('modal-device-type').value,
|
||||
enabled: true
|
||||
};
|
||||
|
||||
var addr = state.editingDevice || 'new-' + Date.now();
|
||||
var url = '/api/ble/devices/' + encodeURIComponent(addr);
|
||||
var method = state.editingDevice ? 'PUT' : 'POST';
|
||||
|
||||
fetch(url, {
|
||||
method: method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
})
|
||||
.then(function(res) {
|
||||
if (res.ok) {
|
||||
hideModal();
|
||||
fetchDevices();
|
||||
} else {
|
||||
return res.json().then(function(err) {
|
||||
throw new Error(err.error || 'Failed to save');
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(function(err) {
|
||||
alert('Failed to save device: ' + err.message);
|
||||
});
|
||||
}
|
||||
|
||||
// Show merge confirmation modal
|
||||
function showMergeConfirm(mac1, mac2) {
|
||||
state.pendingMerge = { mac1: mac1, mac2: mac2 };
|
||||
|
||||
var device1 = state.devices.get(mac1);
|
||||
var device2 = state.devices.get(mac2);
|
||||
|
||||
document.getElementById('merge-device-1').querySelector('.merge-mac').textContent = mac1;
|
||||
document.getElementById('merge-device-1').querySelector('.merge-name').textContent =
|
||||
device1 ? (device1.name || device1.device_name || 'Unknown') : 'Unknown';
|
||||
document.getElementById('merge-device-2').querySelector('.merge-mac').textContent = mac2;
|
||||
document.getElementById('merge-device-2').querySelector('.merge-name').textContent =
|
||||
device2 ? (device2.name || device2.device_name || 'Unknown') : 'Unknown';
|
||||
|
||||
document.getElementById('ble-merge-modal').style.display = 'flex';
|
||||
}
|
||||
|
||||
// Hide merge modal
|
||||
function hideMergeModal() {
|
||||
document.getElementById('ble-merge-modal').style.display = 'none';
|
||||
state.pendingMerge = null;
|
||||
}
|
||||
|
||||
// Confirm and execute merge
|
||||
function confirmMerge() {
|
||||
if (!state.pendingMerge) {
|
||||
return;
|
||||
}
|
||||
|
||||
var mac1 = state.pendingMerge.mac1;
|
||||
var mac2 = state.pendingMerge.mac2;
|
||||
|
||||
fetch('/api/ble/merge', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ mac1: mac1, mac2: mac2 })
|
||||
})
|
||||
.then(function(res) {
|
||||
if (res.ok) {
|
||||
return res.json();
|
||||
} else {
|
||||
return res.json().then(function(err) {
|
||||
throw new Error(err.error || 'Failed to merge');
|
||||
});
|
||||
}
|
||||
})
|
||||
.then(function(data) {
|
||||
hideMergeModal();
|
||||
// Remove from duplicates list
|
||||
state.duplicates = state.duplicates.filter(function(d) {
|
||||
return d.mac1 !== mac1 || d.mac2 !== mac2;
|
||||
});
|
||||
updateDuplicatesList();
|
||||
fetchDevices();
|
||||
})
|
||||
.catch(function(err) {
|
||||
alert('Failed to merge devices: ' + err.message);
|
||||
});
|
||||
}
|
||||
|
||||
// Get icon for device type
|
||||
function getTypeIcon(type) {
|
||||
switch (type) {
|
||||
case 'phone': return '📱';
|
||||
case 'watch': return '⌚';
|
||||
case 'tracker': return '📍';
|
||||
case 'tablet': return '📱';
|
||||
case 'laptop': return '💻';
|
||||
case 'headphones': return '🎧';
|
||||
default: return '📡';
|
||||
}
|
||||
}
|
||||
|
||||
// Format time relative to now
|
||||
function formatTime(date) {
|
||||
var now = new Date();
|
||||
var diff = (now - date) / 1000;
|
||||
|
||||
if (diff < 60) return 'just now';
|
||||
if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
|
||||
if (diff < 86400) return Math.floor(diff / 3600) + 'h ago';
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
|
||||
// Export public interface
|
||||
window.BLEPanel = {
|
||||
init: init,
|
||||
updateMatches: handleMatchesUpdate,
|
||||
updateDevices: handleDevicesUpdate
|
||||
};
|
||||
|
||||
})();
|
||||
|
|
@ -32,7 +32,7 @@ describe('CommandPaletteManager', function () {
|
|||
|
||||
// Load the module (if not already loaded in this env)
|
||||
if (typeof window.CommandPaletteManager === 'undefined') {
|
||||
require('./commandpalette.js');
|
||||
require('./command-palette.js');
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -1,978 +0,0 @@
|
|||
/**
|
||||
* Spaxel Dashboard — Command Palette (Component 34)
|
||||
*
|
||||
* Ctrl+K / Cmd+K: universal keyboard-driven interface for expert mode.
|
||||
* Fuzzy search across zones, people, nodes, events, and commands.
|
||||
* Time navigation via "@" prefix.
|
||||
*
|
||||
* Exposes: window.CommandPaletteManager
|
||||
*/
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
// =========================================================
|
||||
// Constants
|
||||
// =========================================================
|
||||
var STORAGE_KEY = 'spaxel_palette_history';
|
||||
var MAX_RECENT = 5;
|
||||
var MAX_RESULTS = 8;
|
||||
|
||||
// Category priority (lower = higher in results)
|
||||
var CAT_PRIORITY = {
|
||||
command: 0,
|
||||
time: 1,
|
||||
person: 2,
|
||||
zone: 3,
|
||||
node: 4,
|
||||
event: 5,
|
||||
recent: -1 // shown only on empty query
|
||||
};
|
||||
|
||||
// =========================================================
|
||||
// Levenshtein distance (compact)
|
||||
// =========================================================
|
||||
function levenshteinDist(a, b) {
|
||||
var m = a.length, n = b.length;
|
||||
if (!m) return n;
|
||||
if (!n) return m;
|
||||
var prev = [], curr = [];
|
||||
for (var j = 0; j <= n; j++) prev[j] = j;
|
||||
for (var i = 1; i <= m; i++) {
|
||||
curr[0] = i;
|
||||
for (var k = 1; k <= n; k++) {
|
||||
curr[k] = a[i - 1] === b[k - 1]
|
||||
? prev[k - 1]
|
||||
: 1 + Math.min(prev[k], curr[k - 1], prev[k - 1]);
|
||||
}
|
||||
var tmp = prev; prev = curr; curr = tmp;
|
||||
}
|
||||
return prev[n];
|
||||
}
|
||||
|
||||
// =========================================================
|
||||
// Fuzzy scorer [0, 1]
|
||||
// =========================================================
|
||||
/**
|
||||
* Returns a score in [0, 1] indicating how well `needle` matches `haystack`.
|
||||
* Scores below 0.3 are considered non-matches and are excluded from results.
|
||||
*
|
||||
* Matching strategy (in priority order):
|
||||
* 1. Exact prefix of full haystack → 0.90–1.00
|
||||
* 2. Exact substring of full haystack → 0.80
|
||||
* 3. Word-level matching (prefix / typo / subseq per word)
|
||||
* 4. Character subsequence across full string → 0.30–0.40
|
||||
*/
|
||||
function fuzzyScore(needle, haystack) {
|
||||
if (!needle) return 1;
|
||||
needle = needle.toLowerCase().trim();
|
||||
haystack = haystack.toLowerCase().trim();
|
||||
if (!needle) return 1;
|
||||
if (needle === haystack) return 1;
|
||||
|
||||
// 1. Full prefix
|
||||
if (haystack.startsWith(needle)) {
|
||||
return 0.90 + 0.10 * (needle.length / haystack.length);
|
||||
}
|
||||
|
||||
// 2. Exact substring
|
||||
if (haystack.includes(needle)) {
|
||||
return 0.80;
|
||||
}
|
||||
|
||||
// 3. Word-level matching
|
||||
var needleWords = needle.split(/\s+/).filter(function (w) { return w.length > 0; });
|
||||
var haystackWords = haystack.split(/\s+/).filter(function (w) { return w.length > 0; });
|
||||
|
||||
if (needleWords.length > 0) {
|
||||
var allMatch = true;
|
||||
var totalScore = 0;
|
||||
|
||||
for (var ni = 0; ni < needleWords.length; ni++) {
|
||||
var nw = needleWords[ni];
|
||||
var bestWord = 0;
|
||||
|
||||
for (var hi = 0; hi < haystackWords.length; hi++) {
|
||||
var hw = haystackWords[hi];
|
||||
var ws = 0;
|
||||
|
||||
if (hw.startsWith(nw)) {
|
||||
ws = 0.90;
|
||||
} else if (hw.includes(nw)) {
|
||||
ws = 0.75;
|
||||
} else if (nw.length > 2 && hw.length > 2) {
|
||||
var dist = levenshteinDist(nw, hw);
|
||||
if (dist === 1) {
|
||||
ws = 0.70;
|
||||
} else if (dist === 2 && nw.length > 4) {
|
||||
ws = 0.50;
|
||||
}
|
||||
// Per-word subsequence (e.g. "rm" in "room")
|
||||
if (ws === 0) {
|
||||
var si = 0;
|
||||
for (var ci = 0; ci < hw.length && si < nw.length; ci++) {
|
||||
if (nw[si] === hw[ci]) si++;
|
||||
}
|
||||
if (si === nw.length) ws = 0.50;
|
||||
}
|
||||
} else if (nw.length <= 2) {
|
||||
// Short needle: prefix or subsequence within each haystack word
|
||||
if (hw.startsWith(nw)) {
|
||||
ws = 0.75;
|
||||
} else {
|
||||
var si2 = 0;
|
||||
for (var ci2 = 0; ci2 < hw.length && si2 < nw.length; ci2++) {
|
||||
if (nw[si2] === hw[ci2]) si2++;
|
||||
}
|
||||
if (si2 === nw.length) ws = 0.50;
|
||||
}
|
||||
}
|
||||
|
||||
if (ws > bestWord) bestWord = ws;
|
||||
}
|
||||
|
||||
if (bestWord === 0) {
|
||||
allMatch = false;
|
||||
break;
|
||||
}
|
||||
totalScore += bestWord;
|
||||
}
|
||||
|
||||
if (allMatch) {
|
||||
return 0.40 + (totalScore / needleWords.length) * 0.30;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Character subsequence across full string
|
||||
var si3 = 0;
|
||||
for (var ci3 = 0; ci3 < haystack.length && si3 < needle.length; ci3++) {
|
||||
if (needle[si3] === haystack[ci3]) si3++;
|
||||
}
|
||||
if (si3 === needle.length) {
|
||||
return 0.30 + 0.10 * (needle.length / haystack.length);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
// =========================================================
|
||||
// Time expression parser
|
||||
// =========================================================
|
||||
/**
|
||||
* Parse a "@..." time expression.
|
||||
* @param {string} query - full query string starting with "@"
|
||||
* @returns {Date|null}
|
||||
*/
|
||||
function parseTimeExpression(query) {
|
||||
var s = query.replace(/^@/, '').trim();
|
||||
if (!s) return null;
|
||||
var now = new Date();
|
||||
|
||||
// @-30min @-2h
|
||||
var rel = s.match(/^-(\d+)(min|h)$/i);
|
||||
if (rel) {
|
||||
var amount = parseInt(rel[1], 10);
|
||||
var unit = rel[2].toLowerCase();
|
||||
var d = new Date(now);
|
||||
if (unit === 'min') d.setMinutes(d.getMinutes() - amount);
|
||||
else d.setHours(d.getHours() - amount);
|
||||
return d;
|
||||
}
|
||||
|
||||
// @2026-03-27 14:23
|
||||
var abs = s.match(/^(\d{4}-\d{2}-\d{2})\s+(\d{1,2}:\d{2})$/);
|
||||
if (abs) {
|
||||
var dt = new Date(abs[1] + 'T' + abs[2] + ':00');
|
||||
if (!isNaN(dt.getTime())) return dt;
|
||||
}
|
||||
|
||||
// @yesterday ...
|
||||
var yest = s.match(/^yesterday\s+(.+)$/i);
|
||||
if (yest) {
|
||||
var base = new Date(now);
|
||||
base.setDate(base.getDate() - 1);
|
||||
return parseTimeOfDay(yest[1], base);
|
||||
}
|
||||
|
||||
// @3am @3:15pm @14:23
|
||||
return parseTimeOfDay(s, new Date(now));
|
||||
}
|
||||
|
||||
function parseTimeOfDay(s, baseDate) {
|
||||
// 12-hour: 3am, 3:15am, 11:30pm
|
||||
var m12 = s.match(/^(\d{1,2})(?::(\d{2}))?\s*(am|pm)$/i);
|
||||
if (m12) {
|
||||
var h = parseInt(m12[1], 10);
|
||||
var min = m12[2] ? parseInt(m12[2], 10) : 0;
|
||||
var ampm = m12[3].toLowerCase();
|
||||
if (ampm === 'pm' && h !== 12) h += 12;
|
||||
if (ampm === 'am' && h === 12) h = 0;
|
||||
var r = new Date(baseDate);
|
||||
r.setHours(h, min, 0, 0);
|
||||
return r;
|
||||
}
|
||||
// 24-hour: 14:23
|
||||
var m24 = s.match(/^(\d{1,2}):(\d{2})$/);
|
||||
if (m24) {
|
||||
var r2 = new Date(baseDate);
|
||||
r2.setHours(parseInt(m24[1], 10), parseInt(m24[2], 10), 0, 0);
|
||||
return r2;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// =========================================================
|
||||
// Command registry
|
||||
// =========================================================
|
||||
var COMMANDS = [
|
||||
// ---- Navigation ----
|
||||
{
|
||||
id: 'nav-settings',
|
||||
label: 'Open settings',
|
||||
category: 'command',
|
||||
group: 'Navigation',
|
||||
icon: '⚙',
|
||||
hint: '',
|
||||
action: function () { window.location.href = '/settings'; }
|
||||
},
|
||||
{
|
||||
id: 'nav-fleet',
|
||||
label: 'Open fleet page',
|
||||
category: 'command',
|
||||
group: 'Navigation',
|
||||
icon: '📡',
|
||||
hint: '',
|
||||
action: function () { window.location.href = '/fleet'; }
|
||||
},
|
||||
{
|
||||
id: 'nav-automations',
|
||||
label: 'Open automations',
|
||||
category: 'command',
|
||||
group: 'Navigation',
|
||||
icon: '⚡',
|
||||
hint: '',
|
||||
action: function () { window.location.href = '/automations'; }
|
||||
},
|
||||
{
|
||||
id: 'nav-simulator',
|
||||
label: 'Open simulator',
|
||||
category: 'command',
|
||||
group: 'Navigation',
|
||||
icon: '🔬',
|
||||
hint: '',
|
||||
action: function () { window.location.href = '/simulate'; }
|
||||
},
|
||||
// ---- View ----
|
||||
{
|
||||
id: 'view-fresnel',
|
||||
label: 'Toggle Fresnel overlay',
|
||||
category: 'command',
|
||||
group: 'View',
|
||||
icon: '◈',
|
||||
hint: '',
|
||||
action: function () {
|
||||
if (window.toggleFresnelZones) window.toggleFresnelZones();
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'view-flowmap',
|
||||
label: 'Toggle flow map',
|
||||
category: 'command',
|
||||
group: 'View',
|
||||
icon: '🌊',
|
||||
hint: '',
|
||||
action: function () {
|
||||
if (window.Viz3D && window.Viz3D.toggleFlowLayer) window.Viz3D.toggleFlowLayer();
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'view-heatmap',
|
||||
label: 'Toggle dwell heatmap',
|
||||
category: 'command',
|
||||
group: 'View',
|
||||
icon: '🔥',
|
||||
hint: '',
|
||||
action: function () {
|
||||
if (window.Viz3D && window.Viz3D.toggleDwellLayer) window.Viz3D.toggleDwellLayer();
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'view-zones',
|
||||
label: 'Toggle zone volumes',
|
||||
category: 'command',
|
||||
group: 'View',
|
||||
icon: '📦',
|
||||
hint: '',
|
||||
action: function () {
|
||||
if (window.ZoneEditor && window.ZoneEditor.toggleVolumes) window.ZoneEditor.toggleVolumes();
|
||||
else if (window.Viz3D && window.Viz3D.toggleZoneVolumes) window.Viz3D.toggleZoneVolumes();
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'view-reset-camera',
|
||||
label: 'Reset camera',
|
||||
category: 'command',
|
||||
group: 'View',
|
||||
icon: '🎥',
|
||||
hint: '',
|
||||
action: function () {
|
||||
if (window.Viz3D && window.Viz3D.setViewPreset) window.Viz3D.setViewPreset('topdown');
|
||||
}
|
||||
},
|
||||
// ---- System ----
|
||||
{
|
||||
id: 'mode-away',
|
||||
label: 'Enter away mode',
|
||||
category: 'command',
|
||||
group: 'System',
|
||||
icon: '🏠',
|
||||
hint: '',
|
||||
action: function () {
|
||||
fetch('/api/mode', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ mode: 'away' })
|
||||
});
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'mode-home',
|
||||
label: 'Enter home mode',
|
||||
category: 'command',
|
||||
group: 'System',
|
||||
icon: '🏡',
|
||||
hint: '',
|
||||
action: function () {
|
||||
fetch('/api/mode', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ mode: 'home' })
|
||||
});
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'mode-sleep',
|
||||
label: 'Enter sleep mode',
|
||||
category: 'command',
|
||||
group: 'System',
|
||||
icon: '🌙',
|
||||
hint: '',
|
||||
action: function () {
|
||||
fetch('/api/mode', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ mode: 'sleep' })
|
||||
});
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'ota-fleet',
|
||||
label: 'Trigger fleet OTA',
|
||||
category: 'command',
|
||||
group: 'System',
|
||||
icon: '⬆',
|
||||
hint: '',
|
||||
action: function () {
|
||||
if (window.SpaxelOTA && window.SpaxelOTA.openDialog) {
|
||||
window.SpaxelOTA.openDialog();
|
||||
} else {
|
||||
fetch('/api/nodes/update-all', { method: 'POST' });
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'add-person',
|
||||
label: 'Add a person',
|
||||
category: 'command',
|
||||
group: 'System',
|
||||
icon: '👤',
|
||||
hint: '',
|
||||
action: function () {
|
||||
if (window.BLEPanel && window.BLEPanel.openAddPerson) window.BLEPanel.openAddPerson();
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'add-zone',
|
||||
label: 'Add a zone',
|
||||
category: 'command',
|
||||
group: 'System',
|
||||
icon: '📍',
|
||||
hint: '',
|
||||
action: function () {
|
||||
if (window.ZoneEditor && window.ZoneEditor.startCreate) window.ZoneEditor.startCreate();
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'add-portal',
|
||||
label: 'Add a portal',
|
||||
category: 'command',
|
||||
group: 'System',
|
||||
icon: '🚪',
|
||||
hint: '',
|
||||
action: function () {
|
||||
if (window.PortalEditor && window.PortalEditor.startCreate) window.PortalEditor.startCreate();
|
||||
}
|
||||
},
|
||||
// ---- Debug ----
|
||||
{
|
||||
id: 'debug-export-csv',
|
||||
label: 'Export all events CSV',
|
||||
category: 'command',
|
||||
group: 'Debug',
|
||||
icon: '📥',
|
||||
hint: '',
|
||||
action: function () {
|
||||
var a = document.createElement('a');
|
||||
a.href = '/api/events?format=csv';
|
||||
a.download = 'spaxel-events.csv';
|
||||
a.click();
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'debug-link-health',
|
||||
label: 'Show link health table',
|
||||
category: 'command',
|
||||
group: 'Debug',
|
||||
icon: '📊',
|
||||
hint: '',
|
||||
action: function () {
|
||||
if (window.LinkHealth && window.LinkHealth.openPanel) window.LinkHealth.openPanel();
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'debug-diagnostics',
|
||||
label: 'Run diagnostics',
|
||||
category: 'command',
|
||||
group: 'Debug',
|
||||
icon: '🔧',
|
||||
hint: '',
|
||||
action: function () {
|
||||
fetch('/api/diagnostics', { method: 'POST' }).then(function (r) {
|
||||
return r.json();
|
||||
}).then(function (data) {
|
||||
if (window.showToast) window.showToast('Diagnostics: ' + (data.summary || 'done'), 'info');
|
||||
}).catch(function () {
|
||||
if (window.showToast) window.showToast('Diagnostics triggered', 'info');
|
||||
});
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'debug-firmware-check',
|
||||
label: 'Check firmware updates',
|
||||
category: 'command',
|
||||
group: 'Debug',
|
||||
icon: '🔄',
|
||||
hint: '',
|
||||
action: function () {
|
||||
fetch('/api/firmware').then(function (r) { return r.json(); }).then(function (data) {
|
||||
var latest = data && data[0] ? data[0].version : '?';
|
||||
if (window.showToast) window.showToast('Latest firmware: v' + latest, 'info');
|
||||
}).catch(function () {
|
||||
if (window.showToast) window.showToast('Could not fetch firmware info', 'warning');
|
||||
});
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
// =========================================================
|
||||
// Recent history (localStorage)
|
||||
// =========================================================
|
||||
function loadHistory() {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]');
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function saveHistory(items) {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(items.slice(0, MAX_RECENT)));
|
||||
} catch (e) {
|
||||
// quota error — ignore
|
||||
}
|
||||
}
|
||||
|
||||
function addToHistory(item) {
|
||||
// Exclude time navigation entries
|
||||
if (item.category === 'time') return;
|
||||
var hist = loadHistory().filter(function (h) { return h.id !== item.id; });
|
||||
hist.unshift({ id: item.id, label: item.label, category: item.category, icon: item.icon });
|
||||
saveHistory(hist);
|
||||
}
|
||||
|
||||
// =========================================================
|
||||
// Entity data source
|
||||
// =========================================================
|
||||
/**
|
||||
* Returns a snapshot of searchable entities from app state or cached API data.
|
||||
* @returns {{ nodes: Array, zones: Array, people: Array, events: Array }}
|
||||
*/
|
||||
function getEntityData() {
|
||||
var data = { nodes: [], zones: [], people: [], events: [] };
|
||||
|
||||
// Nodes: from app.js state exposure
|
||||
if (window.spaxelGetState) {
|
||||
var st = window.spaxelGetState();
|
||||
data.nodes = st.nodes || [];
|
||||
}
|
||||
|
||||
// Zones / people / events: use cached API snapshot if available
|
||||
var cache = Manager._entityCache;
|
||||
if (cache) {
|
||||
data.zones = cache.zones || data.zones;
|
||||
data.people = cache.people || data.people;
|
||||
data.events = cache.events || data.events;
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
// =========================================================
|
||||
// Search
|
||||
// =========================================================
|
||||
/**
|
||||
* Search all categories with the given query.
|
||||
* @param {string} query
|
||||
* @returns {Array} sorted result items
|
||||
*/
|
||||
function search(query) {
|
||||
var results = [];
|
||||
var q = query.trim();
|
||||
|
||||
// Empty query: show recent history
|
||||
if (!q) {
|
||||
var hist = loadHistory();
|
||||
return hist.slice(0, MAX_RECENT).map(function (h) {
|
||||
return {
|
||||
id: h.id,
|
||||
label: h.label,
|
||||
category: 'recent',
|
||||
icon: h.icon || '🕐',
|
||||
secondary: 'Recent',
|
||||
score: 1,
|
||||
action: findCommandAction(h.id)
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Time navigation
|
||||
if (q.startsWith('@')) {
|
||||
var dt = parseTimeExpression(q);
|
||||
if (dt) {
|
||||
var label = 'Jump to ' + dt.toLocaleString();
|
||||
results.push({
|
||||
id: 'time:' + q,
|
||||
label: label,
|
||||
category: 'time',
|
||||
icon: '🕐',
|
||||
secondary: dt.toISOString(),
|
||||
score: 1,
|
||||
action: function () {
|
||||
if (window.SpaxelReplay && window.SpaxelReplay.seekTo) {
|
||||
window.SpaxelReplay.seekTo(dt.getTime());
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
var entities = getEntityData();
|
||||
|
||||
// Commands
|
||||
COMMANDS.forEach(function (cmd) {
|
||||
var s = Math.max(
|
||||
fuzzyScore(q, cmd.label),
|
||||
fuzzyScore(q, cmd.group || '')
|
||||
);
|
||||
if (s >= 0.3) {
|
||||
results.push({
|
||||
id: cmd.id,
|
||||
label: cmd.label,
|
||||
category: 'command',
|
||||
icon: cmd.icon,
|
||||
secondary: cmd.group || '',
|
||||
score: s,
|
||||
action: cmd.action
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// People
|
||||
entities.people.forEach(function (p) {
|
||||
var name = p.name || p.label || p.addr || '';
|
||||
var s = fuzzyScore(q, name);
|
||||
if (s >= 0.3) {
|
||||
results.push({
|
||||
id: 'person:' + name,
|
||||
label: name,
|
||||
category: 'person',
|
||||
icon: '👤',
|
||||
secondary: p.zone || '',
|
||||
score: s,
|
||||
action: function () {
|
||||
if (window.Viz3D && window.Viz3D.flyToPerson) window.Viz3D.flyToPerson(name);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Zones
|
||||
entities.zones.forEach(function (z) {
|
||||
var name = z.name || '';
|
||||
var s = fuzzyScore(q, name);
|
||||
if (s >= 0.3) {
|
||||
var count = z.count != null ? z.count : (z.occupancy || 0);
|
||||
results.push({
|
||||
id: 'zone:' + name,
|
||||
label: name,
|
||||
category: 'zone',
|
||||
icon: '📍',
|
||||
secondary: count + ' people currently',
|
||||
score: s,
|
||||
action: function () {
|
||||
if (window.Viz3D && window.Viz3D.flyToZone) window.Viz3D.flyToZone(name);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Nodes
|
||||
entities.nodes.forEach(function (n) {
|
||||
var label = n.name || n.mac || '';
|
||||
var s = Math.max(
|
||||
fuzzyScore(q, label),
|
||||
n.mac ? fuzzyScore(q, n.mac) : 0
|
||||
);
|
||||
if (s >= 0.3) {
|
||||
results.push({
|
||||
id: 'node:' + (n.mac || label),
|
||||
label: label,
|
||||
category: 'node',
|
||||
icon: '📡',
|
||||
secondary: n.status || '',
|
||||
score: s,
|
||||
action: function () {
|
||||
if (window.Viz3D && window.Viz3D.flyToNode && n.mac) window.Viz3D.flyToNode(n.mac);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Recent events (last 20)
|
||||
entities.events.forEach(function (evt) {
|
||||
var title = evt.title || evt.type || '';
|
||||
var s = fuzzyScore(q, title);
|
||||
if (s >= 0.3) {
|
||||
results.push({
|
||||
id: 'event:' + (evt.id || title),
|
||||
label: title,
|
||||
category: 'event',
|
||||
icon: '🕐',
|
||||
secondary: evt.zone || '',
|
||||
score: s,
|
||||
action: function () {
|
||||
if (window.SpaxelTimeline && window.SpaxelTimeline.openEvent) {
|
||||
window.SpaxelTimeline.openEvent(evt.id);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Sort: commands first (if query starts with "/"), then by category priority, then score desc
|
||||
results.sort(function (a, b) {
|
||||
var pa = CAT_PRIORITY[a.category] != null ? CAT_PRIORITY[a.category] : 99;
|
||||
var pb = CAT_PRIORITY[b.category] != null ? CAT_PRIORITY[b.category] : 99;
|
||||
if (pa !== pb) return pa - pb;
|
||||
return b.score - a.score;
|
||||
});
|
||||
|
||||
return results.slice(0, MAX_RESULTS);
|
||||
}
|
||||
|
||||
function findCommandAction(id) {
|
||||
for (var i = 0; i < COMMANDS.length; i++) {
|
||||
if (COMMANDS[i].id === id) return COMMANDS[i].action;
|
||||
}
|
||||
return function () {};
|
||||
}
|
||||
|
||||
// =========================================================
|
||||
// Mode detection
|
||||
// =========================================================
|
||||
function isExpertMode() {
|
||||
// Palette is unavailable in simple mode or ambient mode
|
||||
if (document.body.classList.contains('simple-mode')) return false;
|
||||
if (document.body.classList.contains('ambient-mode')) return false;
|
||||
if (window.currentMode === 'simple' || window.currentMode === 'ambient') return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// =========================================================
|
||||
// DOM creation
|
||||
// =========================================================
|
||||
function createDOM() {
|
||||
if (document.getElementById('cp-root')) return;
|
||||
|
||||
var root = document.createElement('div');
|
||||
root.id = 'cp-root';
|
||||
root.className = 'cp-overlay';
|
||||
root.setAttribute('role', 'dialog');
|
||||
root.setAttribute('aria-modal', 'true');
|
||||
root.setAttribute('aria-label', 'Command palette');
|
||||
root.innerHTML =
|
||||
'<div class="cp-backdrop"></div>' +
|
||||
'<div class="cp-container" role="combobox" aria-haspopup="listbox" aria-expanded="true">' +
|
||||
' <div class="cp-search-row">' +
|
||||
' <span class="cp-search-icon">🔍</span>' +
|
||||
' <input class="cp-input" type="text" autocomplete="off" spellcheck="false"' +
|
||||
' placeholder="Search people, zones, nodes, commands..." />' +
|
||||
' <span class="cp-esc-hint">ESC</span>' +
|
||||
' </div>' +
|
||||
' <ul class="cp-results" role="listbox" id="cp-listbox"></ul>' +
|
||||
'</div>';
|
||||
|
||||
document.body.appendChild(root);
|
||||
Manager.el = root;
|
||||
}
|
||||
|
||||
// =========================================================
|
||||
// Rendering
|
||||
// =========================================================
|
||||
function renderResults(items) {
|
||||
var list = document.getElementById('cp-listbox');
|
||||
if (!list) return;
|
||||
|
||||
if (!items.length) {
|
||||
list.innerHTML = '<li class="cp-empty">No results</li>';
|
||||
return;
|
||||
}
|
||||
|
||||
var html = '';
|
||||
var lastCat = null;
|
||||
|
||||
for (var i = 0; i < items.length; i++) {
|
||||
var item = items[i];
|
||||
|
||||
// Group header for "Recent"
|
||||
if (item.category === 'recent' && lastCat !== 'recent') {
|
||||
html += '<li class="cp-group-header">Recent</li>';
|
||||
}
|
||||
|
||||
var selectedClass = (i === Manager.selectedIndex) ? ' cp-item-selected' : '';
|
||||
html +=
|
||||
'<li class="cp-item' + selectedClass + '" data-index="' + i + '" role="option"' +
|
||||
' aria-selected="' + (i === Manager.selectedIndex) + '">' +
|
||||
' <span class="cp-item-icon">' + (item.icon || '•') + '</span>' +
|
||||
' <span class="cp-item-body">' +
|
||||
' <span class="cp-item-label">' + escapeHtml(item.label) + '</span>' +
|
||||
' <span class="cp-item-secondary">' + escapeHtml(item.secondary || '') + '</span>' +
|
||||
' </span>' +
|
||||
' <span class="cp-item-arrow">›</span>' +
|
||||
'</li>';
|
||||
|
||||
lastCat = item.category;
|
||||
}
|
||||
|
||||
list.innerHTML = html;
|
||||
|
||||
// Click handlers
|
||||
list.querySelectorAll('.cp-item').forEach(function (el) {
|
||||
el.addEventListener('mousedown', function (e) {
|
||||
e.preventDefault(); // prevent input blur
|
||||
var idx = parseInt(el.getAttribute('data-index'), 10);
|
||||
Manager.selectedIndex = idx;
|
||||
Manager.execute();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
// =========================================================
|
||||
// Entity cache loader (one fetch per palette open)
|
||||
// =========================================================
|
||||
function loadEntityCache() {
|
||||
Manager._entityCache = Manager._entityCache || { zones: [], people: [], events: [] };
|
||||
|
||||
// Fetch zones
|
||||
fetch('/api/zones').then(function (r) { return r.json(); }).then(function (data) {
|
||||
Manager._entityCache.zones = Array.isArray(data) ? data : [];
|
||||
}).catch(function () {});
|
||||
|
||||
// Fetch people (BLE devices of type "person")
|
||||
fetch('/api/ble/devices?registered=true').then(function (r) { return r.json(); }).then(function (data) {
|
||||
Manager._entityCache.people = (Array.isArray(data) ? data : [])
|
||||
.filter(function (d) { return d.type === 'person'; });
|
||||
}).catch(function () {});
|
||||
|
||||
// Fetch recent events
|
||||
fetch('/api/events?limit=20').then(function (r) { return r.json(); }).then(function (data) {
|
||||
var arr = data && Array.isArray(data.events) ? data.events : (Array.isArray(data) ? data : []);
|
||||
Manager._entityCache.events = arr.slice(0, 20).map(function (e) {
|
||||
return { id: e.id, title: e.type || '', zone: e.zone || '', ts: e.timestamp_ms };
|
||||
});
|
||||
}).catch(function () {});
|
||||
}
|
||||
|
||||
// =========================================================
|
||||
// Manager
|
||||
// =========================================================
|
||||
var Manager = {
|
||||
el: null,
|
||||
isOpen: false,
|
||||
selectedIndex: 0,
|
||||
_items: [],
|
||||
_entityCache: null,
|
||||
|
||||
init: function () {
|
||||
// Register Ctrl+K / Cmd+K globally
|
||||
document.addEventListener('keydown', this._onKeydown.bind(this));
|
||||
},
|
||||
|
||||
open: function () {
|
||||
if (!isExpertMode()) return;
|
||||
|
||||
createDOM();
|
||||
|
||||
// Refresh entity cache (async, non-blocking)
|
||||
loadEntityCache();
|
||||
|
||||
this.isOpen = true;
|
||||
this.selectedIndex = 0;
|
||||
this.el.classList.add('cp-visible');
|
||||
|
||||
var input = this.el.querySelector('.cp-input');
|
||||
if (input) {
|
||||
input.value = '';
|
||||
setTimeout(function () { input.focus(); }, 10);
|
||||
input.addEventListener('input', this._onInput.bind(this));
|
||||
input.addEventListener('keydown', this._onInputKeydown.bind(this));
|
||||
}
|
||||
|
||||
var backdrop = this.el.querySelector('.cp-backdrop');
|
||||
if (backdrop) {
|
||||
backdrop.addEventListener('click', this.close.bind(this));
|
||||
}
|
||||
|
||||
this._showItems([]);
|
||||
},
|
||||
|
||||
close: function () {
|
||||
if (!this.isOpen) return;
|
||||
this.isOpen = false;
|
||||
|
||||
if (this.el) {
|
||||
this.el.classList.remove('cp-visible');
|
||||
// Detach listeners by replacing input (simple)
|
||||
var input = this.el.querySelector('.cp-input');
|
||||
if (input) {
|
||||
var newInput = input.cloneNode(true);
|
||||
input.parentNode.replaceChild(newInput, input);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
toggle: function () {
|
||||
if (this.isOpen) this.close();
|
||||
else this.open();
|
||||
},
|
||||
|
||||
execute: function () {
|
||||
var item = this._items[this.selectedIndex];
|
||||
if (!item) return;
|
||||
if (item.action) {
|
||||
addToHistory(item);
|
||||
item.action();
|
||||
}
|
||||
this.close();
|
||||
},
|
||||
|
||||
_onKeydown: function (e) {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
|
||||
e.preventDefault();
|
||||
if (!isExpertMode()) return;
|
||||
this.toggle();
|
||||
} else if (e.key === 'Escape' && this.isOpen) {
|
||||
e.preventDefault();
|
||||
this.close();
|
||||
}
|
||||
},
|
||||
|
||||
_onInput: function (e) {
|
||||
var q = e.target.value;
|
||||
this.selectedIndex = 0;
|
||||
var items = search(q);
|
||||
this._showItems(items);
|
||||
},
|
||||
|
||||
_onInputKeydown: function (e) {
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
this.selectedIndex = Math.min(this.selectedIndex + 1, this._items.length - 1);
|
||||
renderResults(this._items);
|
||||
this._scrollToSelected();
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
this.selectedIndex = Math.max(this.selectedIndex - 1, 0);
|
||||
renderResults(this._items);
|
||||
this._scrollToSelected();
|
||||
break;
|
||||
case 'Enter':
|
||||
case 'Tab':
|
||||
e.preventDefault();
|
||||
this.execute();
|
||||
break;
|
||||
case 'Escape':
|
||||
this.close();
|
||||
break;
|
||||
}
|
||||
},
|
||||
|
||||
_showItems: function (items) {
|
||||
this._items = items;
|
||||
renderResults(items);
|
||||
},
|
||||
|
||||
_scrollToSelected: function () {
|
||||
var list = document.getElementById('cp-listbox');
|
||||
if (!list) return;
|
||||
var sel = list.querySelector('.cp-item-selected');
|
||||
if (sel) sel.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
};
|
||||
|
||||
// =========================================================
|
||||
// Public API
|
||||
// =========================================================
|
||||
window.CommandPaletteManager = Manager;
|
||||
|
||||
// Expose internals for testing
|
||||
Manager._fuzzyScore = fuzzyScore;
|
||||
Manager._parseTimeExpression = parseTimeExpression;
|
||||
Manager._parseTimeOfDay = parseTimeOfDay;
|
||||
Manager._COMMANDS = COMMANDS;
|
||||
Manager._loadHistory = loadHistory;
|
||||
Manager._saveHistory = saveHistory;
|
||||
Manager._addToHistory = addToHistory;
|
||||
Manager._search = search;
|
||||
Manager._isExpertMode = isExpertMode;
|
||||
|
||||
// Auto-init when DOM is ready
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', function () { Manager.init(); });
|
||||
} else {
|
||||
Manager.init();
|
||||
}
|
||||
|
||||
})();
|
||||
Loading…
Add table
Reference in a new issue