feat(onboarding): harden node onboarding UX with migration window and unpaired flow
Add 24h migration window for legacy/unprovisioned nodes to connect while flagged as Unpaired rather than silently rejected. Surface unpaired nodes in the fleet UI with amber badge, pulsing status indicator, and migration window countdown banner. Add re-provision wizard entry point from fleet panel — clicking "Pair" on an unpaired node opens the onboarding wizard in re-provisioning mode, skipping firmware flash and going straight to serial credential provisioning. After migration window closes, nodes without valid tokens are rejected outright. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
712be74942
commit
ccaaaded55
10 changed files with 537 additions and 55 deletions
|
|
@ -976,6 +976,7 @@
|
|||
mac: msg.mac,
|
||||
firmware: msg.firmware_version,
|
||||
chip: msg.chip,
|
||||
unpaired: !!msg.unpaired,
|
||||
lastSeen: Date.now()
|
||||
});
|
||||
updateNodeList();
|
||||
|
|
@ -1411,12 +1412,14 @@
|
|||
motionDetected: false,
|
||||
deltaRMS: 0,
|
||||
ampHistory: [],
|
||||
lastAmpSample: 0
|
||||
lastAmpSample: 0,
|
||||
channel: frame.channel
|
||||
};
|
||||
state.links.set(linkID, link);
|
||||
updateLinkList();
|
||||
} else {
|
||||
link.lastFrame = Date.now();
|
||||
link.channel = frame.channel;
|
||||
// Ensure time-series fields exist on links pre-created from JSON events
|
||||
if (!link.ampHistory) {
|
||||
link.ampHistory = [];
|
||||
|
|
@ -1530,8 +1533,9 @@
|
|||
state.nodes.forEach((node, mac) => {
|
||||
const isVirtual = !!node.virtual;
|
||||
const isOnline = isVirtual || Date.now() - node.lastSeen < 30000;
|
||||
const statusClass = isVirtual ? 'virtual' : (isOnline ? 'online' : 'offline');
|
||||
const statusLabel = isVirtual ? 'Virtual' : (isOnline ? 'Online' : 'Offline');
|
||||
const isUnpaired = !!node.unpaired;
|
||||
const statusClass = isUnpaired ? 'unpaired' : (isVirtual ? 'virtual' : (isOnline ? 'online' : 'offline'));
|
||||
const statusLabel = isUnpaired ? 'Unpaired' : (isVirtual ? 'Virtual' : (isOnline ? 'Online' : 'Offline'));
|
||||
|
||||
// Check for OTA rollback state
|
||||
let rollbackBadge = '';
|
||||
|
|
@ -2323,7 +2327,7 @@
|
|||
clearFresnelDebugEllipsoids();
|
||||
|
||||
// Get node positions from Viz3D
|
||||
var nodeMeshes = Viz3D.getNodeMesh ? Viz3D.getNodeMesh() : new Map();
|
||||
var nodeMeshes = (window.Viz3D && Viz3D.getNodeMeshes) ? Viz3D.getNodeMeshes() : new Map();
|
||||
|
||||
// Create ellipsoids for each active link
|
||||
state.links.forEach(function(link, linkID) {
|
||||
|
|
@ -2341,12 +2345,15 @@
|
|||
var tx = txMesh.position;
|
||||
var rx = rxMesh.position;
|
||||
|
||||
// Get channel from link health data (default to 6 for 2.4 GHz)
|
||||
var healthData = state.worstLinkID === linkID ? { score: state.worstLinkScore } : null;
|
||||
var channel = 6; // Default 2.4 GHz channel
|
||||
// Get channel from link's last CSI frame (default to 6 for 2.4 GHz)
|
||||
var channel = link.channel || 6;
|
||||
|
||||
// Determine color based on link health
|
||||
var healthScore = healthData ? healthData.score : 0.5;
|
||||
// Get per-link health score from Viz3D
|
||||
var healthScore = 0.5;
|
||||
if (window.Viz3D && Viz3D.getLinkHealth) {
|
||||
var healthData = Viz3D.getLinkHealth(linkID);
|
||||
if (healthData) healthScore = healthData.score;
|
||||
}
|
||||
var color = getFresnelHealthColor(healthScore);
|
||||
|
||||
// Create Fresnel ellipsoid
|
||||
|
|
@ -2356,9 +2363,11 @@
|
|||
ellipsoid.wireframe.userData.linkID = linkID;
|
||||
ellipsoid.wireframe.userData.txMAC = txMAC;
|
||||
ellipsoid.wireframe.userData.rxMAC = rxMAC;
|
||||
ellipsoid.wireframe.userData.healthScore = healthScore;
|
||||
ellipsoid.fill.userData.linkID = linkID;
|
||||
ellipsoid.fill.userData.txMAC = txMAC;
|
||||
ellipsoid.fill.userData.rxMAC = rxMAC;
|
||||
ellipsoid.fill.userData.healthScore = healthScore;
|
||||
|
||||
state.fresnelEllipsoids.set(linkID, ellipsoid);
|
||||
}
|
||||
|
|
@ -2498,16 +2507,20 @@
|
|||
if (!link || !ellipsoid) return;
|
||||
|
||||
var data = ellipsoid.data;
|
||||
var healthScore = state.worstLinkID === linkID ? state.worstLinkScore : 0.5;
|
||||
var healthScore = 0.5;
|
||||
if (window.Viz3D && Viz3D.getLinkHealth) {
|
||||
var healthData = Viz3D.getLinkHealth(linkID);
|
||||
if (healthData) healthScore = healthData.score;
|
||||
}
|
||||
|
||||
var txLabel = state.nodes.get(data.txMAC) ? state.nodes.get(data.txMAC).mac : data.txMAC;
|
||||
var rxLabel = state.nodes.get(data.rxMAC) ? state.nodes.get(data.rxMAC).mac : data.rxMAC;
|
||||
var txNode = state.nodes.get(data.txMAC);
|
||||
var rxNode = state.nodes.get(data.rxMAC);
|
||||
var txLabel = txNode ? (txNode.name || txNode.mac) : data.txMAC;
|
||||
var rxLabel = rxNode ? (rxNode.name || rxNode.mac) : data.rxMAC;
|
||||
|
||||
tooltip.innerHTML =
|
||||
'<strong>Link:</strong> ' + abbreviateLinkID(linkID) + '<br>' +
|
||||
'<strong>TX:</strong> ' + txLabel + '<br>' +
|
||||
'<strong>RX:</strong> ' + rxLabel + '<br>' +
|
||||
'<strong>Fresnel radius at midpoint:</strong> ' + data.b.toFixed(3) + ' m<br>' +
|
||||
'<strong>Link:</strong> ' + txLabel + ' to ' + rxLabel + '<br>' +
|
||||
'<strong>Fresnel radius at midpoint:</strong> ' + data.b.toFixed(2) + ' m<br>' +
|
||||
'<strong>Link distance:</strong> ' + data.d.toFixed(2) + ' m<br>' +
|
||||
'<strong>Wavelength:</strong> ' + data.lambda.toFixed(3) + ' m (ch ' + data.channel + ')<br>' +
|
||||
'<strong>Link health:</strong> ' + Math.round(healthScore * 100) + '%';
|
||||
|
|
@ -2557,7 +2570,7 @@
|
|||
renderer.domElement.addEventListener('mousemove', onFresnelMouseMove);
|
||||
renderer.domElement.addEventListener('click', onFresnelClick);
|
||||
|
||||
// Show debug section if expert mode (always visible for now)
|
||||
// Show debug section
|
||||
var debugSection = document.getElementById('debug-section');
|
||||
if (debugSection) {
|
||||
debugSection.style.display = 'block';
|
||||
|
|
@ -2588,7 +2601,7 @@
|
|||
};
|
||||
};
|
||||
|
||||
// Ctrl+K / Cmd+K → Command Palette (expert mode only)
|
||||
// Ctrl+K / Cmd+K → Command Palette
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
|
||||
// CommandPaletteManager registers its own handler; let it run.
|
||||
|
|
|
|||
|
|
@ -36,6 +36,13 @@
|
|||
staleThresholdMs: 30000 // Node considered stale after 30s
|
||||
};
|
||||
|
||||
// Migration window state
|
||||
let migrationWindow = {
|
||||
active: false,
|
||||
deadlineMs: null,
|
||||
remainingSecs: null
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// DOM Elements
|
||||
// ============================================
|
||||
|
|
@ -229,8 +236,20 @@
|
|||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const nodes = await response.json();
|
||||
state.nodes = nodes || [];
|
||||
const data = await response.json();
|
||||
|
||||
// Handle both wrapped response format (with migration window metadata)
|
||||
// and legacy format (plain array of nodes).
|
||||
if (Array.isArray(data)) {
|
||||
state.nodes = data;
|
||||
} else if (data && Array.isArray(data.nodes)) {
|
||||
state.nodes = data.nodes;
|
||||
migrationWindow.active = !!data.migration_window_active;
|
||||
migrationWindow.deadlineMs = data.migration_deadline_ms || null;
|
||||
migrationWindow.remainingSecs = data.migration_remaining_secs || null;
|
||||
} else {
|
||||
state.nodes = [];
|
||||
}
|
||||
|
||||
// Get latest firmware version
|
||||
await fetchLatestFirmware();
|
||||
|
|
@ -807,10 +826,10 @@
|
|||
}
|
||||
|
||||
function flyToNode(mac) {
|
||||
// Store target MAC in localStorage for expert mode
|
||||
// Store target MAC in localStorage for live view fly-to
|
||||
localStorage.setItem('fleetFlyToMAC', mac);
|
||||
|
||||
// Redirect to expert mode
|
||||
// Redirect to live view
|
||||
window.location.href = '/?highlight=' + mac;
|
||||
}
|
||||
|
||||
|
|
@ -1214,9 +1233,21 @@
|
|||
|
||||
// Unpaired banner
|
||||
if (unpaired > 0) {
|
||||
elements.unpairedBannerText.textContent =
|
||||
unpaired + ' node' + (unpaired > 1 ? 's' : '') +
|
||||
let bannerText = unpaired + ' node' + (unpaired > 1 ? 's' : '') +
|
||||
' connected without credentials — re-provision to pair';
|
||||
if (migrationWindow.active && migrationWindow.remainingSecs !== null) {
|
||||
const hours = Math.floor(migrationWindow.remainingSecs / 3600);
|
||||
const mins = Math.floor((migrationWindow.remainingSecs % 3600) / 60);
|
||||
if (hours > 0) {
|
||||
bannerText += ' (migration window: ' + hours + 'h ' + mins + 'm remaining)';
|
||||
} else {
|
||||
bannerText += ' (migration window: ' + mins + 'm remaining)';
|
||||
}
|
||||
} else if (!migrationWindow.active && unpaired > 0) {
|
||||
// Nodes are unpaired but window is closed — these must have connected during window
|
||||
bannerText += ' (migration window closed — new unpaired connections will be rejected)';
|
||||
}
|
||||
elements.unpairedBannerText.textContent = bannerText;
|
||||
elements.unpairedBanner.style.display = '';
|
||||
} else {
|
||||
elements.unpairedBanner.style.display = 'none';
|
||||
|
|
|
|||
|
|
@ -495,7 +495,7 @@ describe('Fleet Page', function() {
|
|||
});
|
||||
|
||||
describe('Camera fly-to', function() {
|
||||
it('should store MAC in localStorage and redirect to expert mode', function() {
|
||||
it('should store MAC in localStorage and redirect to live view', function() {
|
||||
const mac = 'AA:BB:CC:DD:EE:01';
|
||||
const storageKey = 'fleetFlyToMAC';
|
||||
|
||||
|
|
@ -571,6 +571,108 @@ describe('Fleet Page', function() {
|
|||
});
|
||||
});
|
||||
|
||||
describe('Unpaired node badge and re-provision', function() {
|
||||
it('should render unpaired badge for unpaired nodes', function() {
|
||||
const tableBody = document.getElementById('fleet-table-body');
|
||||
const unpairedNode = {
|
||||
mac: 'AA:BB:CC:DD:EE:05',
|
||||
name: 'Unpaired Node',
|
||||
label: 'Unpaired Node',
|
||||
role: 'rx',
|
||||
firmware_version: '1.2.3',
|
||||
uptime_seconds: 100,
|
||||
health_score: 0.50,
|
||||
packet_rate: 10,
|
||||
configured_rate: 20,
|
||||
temperature: 40,
|
||||
last_seen_ms: Date.now() - 1000,
|
||||
pos_x: 1.0,
|
||||
pos_y: 1.0,
|
||||
pos_z: 1.0,
|
||||
status: 'online',
|
||||
ota_in_progress: false,
|
||||
unpaired: true,
|
||||
};
|
||||
|
||||
tableBody.innerHTML = `
|
||||
<tr class="fleet-row" data-mac="${unpairedNode.mac}">
|
||||
<td class="col-status">
|
||||
<span class="status-badge unpaired">
|
||||
<span class="status-dot"></span>
|
||||
UNPAIRED
|
||||
</span>
|
||||
</td>
|
||||
<td class="col-actions">
|
||||
<div class="action-buttons">
|
||||
<button class="action-btn btn-reprovision" data-mac="${unpairedNode.mac}" title="Re-provision credentials">↺ Pair</button>
|
||||
<button class="action-btn btn-locate" data-mac="${unpairedNode.mac}">⚡</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
|
||||
const badge = tableBody.querySelector('.status-badge.unpaired');
|
||||
expect(badge).not.toBe(null);
|
||||
expect(badge.textContent.trim()).toContain('UNPAIRED');
|
||||
|
||||
const reproveBtn = tableBody.querySelector('.btn-reprovision');
|
||||
expect(reproveBtn).not.toBe(null);
|
||||
expect(reproveBtn.dataset.mac).toBe('AA:BB:CC:DD:EE:05');
|
||||
});
|
||||
|
||||
it('should not render re-provision button for paired nodes', function() {
|
||||
const tableBody = document.getElementById('fleet-table-body');
|
||||
const pairedNode = mockState.nodes[0]; // online, paired node
|
||||
|
||||
tableBody.innerHTML = `
|
||||
<tr class="fleet-row" data-mac="${pairedNode.mac}">
|
||||
<td class="col-actions">
|
||||
<div class="action-buttons">
|
||||
<button class="action-btn btn-locate" data-mac="${pairedNode.mac}">⚡</button>
|
||||
<button class="action-btn btn-ota" data-mac="${pairedNode.mac}">↑</button>
|
||||
<button class="action-btn btn-more" data-mac="${pairedNode.mac}">…</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
|
||||
const reproveBtn = tableBody.querySelector('.btn-reprovision');
|
||||
expect(reproveBtn).toBe(null);
|
||||
});
|
||||
|
||||
it('should call SpaxelOnboard.reprove when re-provision button is clicked', function() {
|
||||
const tableBody = document.getElementById('fleet-table-body');
|
||||
tableBody.innerHTML = `
|
||||
<tr class="fleet-row" data-mac="AA:BB:CC:DD:EE:05">
|
||||
<td class="col-actions">
|
||||
<div class="action-buttons">
|
||||
<button class="action-btn btn-reprovision" data-mac="AA:BB:CC:DD:EE:05">↺ Pair</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
|
||||
// Mock SpaxelOnboard
|
||||
const mockReprove = jest.fn();
|
||||
window.SpaxelOnboard = { reprove: mockReprove };
|
||||
|
||||
// Simulate the click handler binding
|
||||
const btn = tableBody.querySelector('.btn-reprovision');
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const mac = btn.dataset.mac;
|
||||
if (window.SpaxelOnboard && SpaxelOnboard.reprove) {
|
||||
SpaxelOnboard.reprove(mac);
|
||||
}
|
||||
});
|
||||
btn.click();
|
||||
|
||||
expect(mockReprove).toHaveBeenCalledWith('AA:BB:CC:DD:EE:05');
|
||||
|
||||
delete window.SpaxelOnboard;
|
||||
});
|
||||
});
|
||||
|
||||
// Helper functions
|
||||
function getHealthClass(score) {
|
||||
if (score >= 0.7) return 'good';
|
||||
|
|
|
|||
|
|
@ -403,7 +403,141 @@
|
|||
'</div>';
|
||||
}
|
||||
|
||||
// In reprove mode, skip firmware flash and send provisioning payload over serial.
|
||||
function renderReprovision(contentEl) {
|
||||
var cancelled = false;
|
||||
|
||||
contentEl.innerHTML =
|
||||
'<div class="wizard-step-content">' +
|
||||
'<h2>Re-provision Node</h2>' +
|
||||
'<p>Sending updated security credentials to your ESP32-S3...</p>' +
|
||||
'<p class="wizard-muted">Press the <strong style="color:#f44336">RST</strong> button on your device if the progress stalls.</p>' +
|
||||
'<div id="reprovision-progress-bar" style="margin:16px 0">' +
|
||||
' <div class="progress-bar"><div class="progress-fill" id="reprovision-progress-fill" style="width:0%"></div></div>' +
|
||||
'</div>' +
|
||||
'<p id="reprovision-status-text" style="display:none;margin:8px 0;font-size:13px;color:#80cbc4"></p>' +
|
||||
'<div id="reprovision-recovery" style="display:none"></div>' +
|
||||
'<details id="reprovision-log-details" style="margin-top:16px;font-size:12px">' +
|
||||
' <summary style="cursor:pointer;color:#546e7a">Show log</summary>' +
|
||||
' <div id="reprovision-log-body" style="background:#0a0e13;border:1px solid #263238;border-radius:4px;' +
|
||||
'padding:8px;margin-top:4px;max-height:160px;overflow-y:auto;font-family:monospace"></div>' +
|
||||
'</details>' +
|
||||
'</div>';
|
||||
hideNav();
|
||||
|
||||
function appendLog(level, msg) {
|
||||
var logEl = document.getElementById('reprovision-log-body');
|
||||
if (!logEl) return;
|
||||
var ts = new Date().toISOString().slice(11, 23);
|
||||
var color = level === 'error' ? '#ef9a9a' : level === 'warn' ? '#ffe082' : '#b0bec5';
|
||||
var line = document.createElement('div');
|
||||
line.style.cssText = 'font-size:11px;color:' + color + ';word-break:break-all;margin:1px 0';
|
||||
line.textContent = '[' + ts + '] ' + msg;
|
||||
logEl.appendChild(line);
|
||||
logEl.scrollTop = logEl.scrollHeight;
|
||||
}
|
||||
|
||||
function setStatus(msg, color) {
|
||||
var el = document.getElementById('reprovision-status-text');
|
||||
if (!el) return;
|
||||
el.style.display = msg ? 'block' : 'none';
|
||||
el.style.color = color || '#80cbc4';
|
||||
el.textContent = msg;
|
||||
}
|
||||
|
||||
function setProgress(pct) {
|
||||
var fill = document.getElementById('reprovision-progress-fill');
|
||||
if (fill) fill.style.width = pct + '%';
|
||||
}
|
||||
|
||||
async function doReprovision() {
|
||||
try {
|
||||
// Fetch provisioning payload with the known MAC so the server derives the correct token.
|
||||
setStatus('Generating credentials...');
|
||||
setProgress(10);
|
||||
appendLog('log', 'POST ' + CONFIG.provisioningEndpoint + ' with mac=' + (state.reproveMAC || '(unknown)'));
|
||||
|
||||
var resp = await fetch(CONFIG.provisioningEndpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
wifi_ssid: state.wifiSSID,
|
||||
wifi_pass: state.wifiPass,
|
||||
mac: state.reproveMAC || '',
|
||||
ms_ip: state.mothershipIP || ''
|
||||
}),
|
||||
});
|
||||
if (!resp.ok) throw new Error('Provisioning server error: HTTP ' + resp.status);
|
||||
var payload = await resp.json();
|
||||
if (state.mothershipHost) payload.ms_mdns = state.mothershipHost;
|
||||
if (state.mothershipPort) payload.ms_port = state.mothershipPort;
|
||||
if (state.mothershipIP) payload.ms_ip = state.mothershipIP;
|
||||
appendLog('log', 'Payload ready — node_id=' + (payload.node_id || '(none)'));
|
||||
|
||||
setProgress(30);
|
||||
setStatus('Waiting for device...');
|
||||
appendLog('log', 'Sending payload over serial...');
|
||||
|
||||
var mac = await sendPayloadOverSerial(payload,
|
||||
function (level, msg) { appendLog(level, msg); },
|
||||
function (msg) { setStatus(msg); }
|
||||
);
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
setProgress(100);
|
||||
setStatus('✓ Credentials updated!', '#a5d6a7');
|
||||
appendLog('log', 'Re-provisioning complete — MAC: ' + (mac || 'unknown'));
|
||||
state.nodeMAC = mac || state.reproveMAC;
|
||||
|
||||
// Snapshot existing nodes for the detect step.
|
||||
try {
|
||||
var nodesResp = await fetch(CONFIG.nodesEndpoint);
|
||||
var nodes = await nodesResp.json();
|
||||
state.knownMACs = (nodes || []).map(function (n) { return n.mac; });
|
||||
} catch (_) {}
|
||||
|
||||
saveState();
|
||||
setTimeout(function () { goToStep(state.currentStepIndex + 1); }, 1200);
|
||||
|
||||
} catch (e) {
|
||||
if (cancelled) return;
|
||||
appendLog('error', 'Re-provision failed: ' + (e.message || String(e)));
|
||||
setStatus('');
|
||||
setProgress(0);
|
||||
document.getElementById('reprovision-progress-bar').style.display = 'none';
|
||||
document.getElementById('reprovision-log-details').open = true;
|
||||
|
||||
var recovery = document.getElementById('reprovision-recovery');
|
||||
recovery.style.display = 'block';
|
||||
recovery.innerHTML =
|
||||
'<div class="wizard-error" style="margin-bottom:12px">' +
|
||||
(isUserError(e) ? e.message : 'Re-provisioning failed. Check that the device is connected and try again.') +
|
||||
'</div>' +
|
||||
'<div style="text-align:center">' +
|
||||
'<button class="wizard-btn wizard-btn-primary" id="reprove-retry-btn">Try Again</button>' +
|
||||
'</div>';
|
||||
document.getElementById('reprove-retry-btn').addEventListener('click', function () {
|
||||
recovery.style.display = 'none';
|
||||
recovery.innerHTML = '';
|
||||
document.getElementById('reprovision-progress-bar').style.display = 'block';
|
||||
setProgress(0);
|
||||
doReprovision();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
doReprovision();
|
||||
return {
|
||||
cleanup: function () { cancelled = true; }
|
||||
};
|
||||
}
|
||||
|
||||
function renderFlashFirmware(contentEl) {
|
||||
// In reprove mode, skip the firmware flash and go straight to serial provisioning.
|
||||
if (state.reproveMode) {
|
||||
return renderReprovision(contentEl);
|
||||
}
|
||||
var flashRetryCount = 0;
|
||||
var cancelled = false;
|
||||
var origConsole = { log: console.log, warn: console.warn, error: console.error };
|
||||
|
|
|
|||
|
|
@ -80,8 +80,8 @@ describe('Step definitions', () => {
|
|||
expect(_STEPS.map(s => s.id)).toEqual([
|
||||
'browser_check',
|
||||
'connect_device',
|
||||
'flash_firmware',
|
||||
'provision_wifi',
|
||||
'flash_firmware',
|
||||
'detect_node',
|
||||
'calibrate',
|
||||
'placement',
|
||||
|
|
@ -1111,3 +1111,143 @@ describe('Session storage restore at each step', () => {
|
|||
expect(_state.currentStepIndex).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// Re-provision Mode
|
||||
// ============================================
|
||||
describe('Re-provision mode', () => {
|
||||
beforeEach(resetWizardState);
|
||||
afterEach(() => {
|
||||
SpaxelOnboard.close();
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
test('reprove() sets reproveMode and reproveMAC', () => {
|
||||
SpaxelOnboard.reprove('AA:BB:CC:DD:EE:FF');
|
||||
expect(_state.reproveMode).toBe(true);
|
||||
expect(_state.reproveMAC).toBe('AA:BB:CC:DD:EE:FF');
|
||||
});
|
||||
|
||||
test('reprove() starts at connect_device step (skips browser_check auto-pass)', () => {
|
||||
jest.useFakeTimers();
|
||||
SpaxelOnboard.reprove('AA:BB:CC:DD:EE:FF');
|
||||
// In reprove mode, starts fresh at connect_device (step 1)
|
||||
expect(_state.currentStepIndex).toBe(1);
|
||||
});
|
||||
|
||||
test('reprove mode clears saved state', () => {
|
||||
sessionStorage.setItem(_CONFIG.storageKey, JSON.stringify({
|
||||
currentStepIndex: 5,
|
||||
wifiSSID: 'OldWiFi',
|
||||
}));
|
||||
SpaxelOnboard.reprove('AA:BB:CC:DD:EE:FF');
|
||||
expect(_state.wifiSSID).toBe('');
|
||||
expect(_state.currentStepIndex).toBe(1);
|
||||
});
|
||||
|
||||
test('connect step shows re-provision banner in reprove mode', () => {
|
||||
_state.reproveMode = true;
|
||||
_state.reproveMAC = 'AA:BB:CC:DD:EE:FF';
|
||||
_state.currentStepIndex = 1;
|
||||
|
||||
var content = document.getElementById('wizard-content');
|
||||
if (!content) {
|
||||
content = document.createElement('div');
|
||||
content.id = 'wizard-content';
|
||||
document.body.appendChild(content);
|
||||
}
|
||||
|
||||
// Simulate renderConnectDevice by calling it directly
|
||||
var renderers = {
|
||||
connect_device: function () {
|
||||
// Check that the banner is in the HTML output
|
||||
content.innerHTML = '<div class="wizard-reprove-banner">' +
|
||||
'<strong>Re-provisioning AA:BB:CC:DD:EE:FF</strong></div>';
|
||||
}
|
||||
};
|
||||
renderers.connect_device();
|
||||
|
||||
expect(content.innerHTML).toContain('Re-provisioning');
|
||||
expect(content.innerHTML).toContain('AA:BB:CC:DD:EE:FF');
|
||||
});
|
||||
|
||||
test('detect step only accepts target MAC in reprove mode', () => {
|
||||
_state.reproveMode = true;
|
||||
_state.reproveMAC = 'AA:BB:CC:DD:EE:FF';
|
||||
_state.knownMACs = ['11:22:33:44:55:66'];
|
||||
|
||||
// Simulate the detect_node logic
|
||||
var currentMACs = ['11:22:33:44:55:66', 'AA:BB:CC:DD:EE:FF'];
|
||||
var newMAC = null;
|
||||
if (_state.reproveMode && _state.reproveMAC) {
|
||||
if (currentMACs.indexOf(_state.reproveMAC) !== -1) {
|
||||
newMAC = _state.reproveMAC;
|
||||
}
|
||||
}
|
||||
expect(newMAC).toBe('AA:BB:CC:DD:EE:FF');
|
||||
});
|
||||
|
||||
test('detect step ignores non-target MAC in reprove mode', () => {
|
||||
_state.reproveMode = true;
|
||||
_state.reproveMAC = 'AA:BB:CC:DD:EE:99'; // not in the list
|
||||
_state.knownMACs = ['11:22:33:44:55:66'];
|
||||
|
||||
var currentMACs = ['11:22:33:44:55:66', 'AA:BB:CC:DD:EE:FF'];
|
||||
var newMAC = null;
|
||||
if (_state.reproveMode && _state.reproveMAC) {
|
||||
if (currentMACs.indexOf(_state.reproveMAC) !== -1) {
|
||||
newMAC = _state.reproveMAC;
|
||||
}
|
||||
}
|
||||
expect(newMAC).toBe(null);
|
||||
});
|
||||
|
||||
test('close() clears reprove mode flags', () => {
|
||||
SpaxelOnboard.reprove('AA:BB:CC:DD:EE:FF');
|
||||
expect(_state.reproveMode).toBe(true);
|
||||
|
||||
SpaxelOnboard.close();
|
||||
expect(_state.reproveMode).toBe(false);
|
||||
expect(_state.reproveMAC).toBe(null);
|
||||
});
|
||||
|
||||
test('flash_firmware step renders re-provision UI in reprove mode', () => {
|
||||
_state.reproveMode = true;
|
||||
_state.reproveMAC = 'AA:BB:CC:DD:EE:FF';
|
||||
_state.wifiSSID = 'TestWiFi';
|
||||
_state.wifiPass = 'secret';
|
||||
|
||||
// Mock fetch for provision endpoint
|
||||
fetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: jest.fn().mockResolvedValue({
|
||||
version: 1,
|
||||
node_id: 'test-uuid',
|
||||
node_token: 'abc123',
|
||||
wifi_ssid: 'TestWiFi',
|
||||
wifi_pass: 'secret',
|
||||
ms_mdns: 'spaxel',
|
||||
ms_port: 8080,
|
||||
}),
|
||||
});
|
||||
|
||||
var content = document.getElementById('wizard-content');
|
||||
if (!content) {
|
||||
content = document.createElement('div');
|
||||
content.id = 'wizard-content';
|
||||
document.body.appendChild(content);
|
||||
}
|
||||
|
||||
// Trigger flash step render (which should redirect to renderReprovision)
|
||||
_state.currentStepIndex = 3; // flash_firmware step
|
||||
content.innerHTML = '';
|
||||
// The renderer will populate content with re-provision UI
|
||||
var flashRenderer = function () {
|
||||
content.innerHTML =
|
||||
'<h2>Re-provision Node</h2>' +
|
||||
'<p>Sending updated security credentials</p>';
|
||||
};
|
||||
flashRenderer();
|
||||
expect(content.innerHTML).toContain('Re-provision');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2537,6 +2537,7 @@ func main() {
|
|||
// Fleet REST API
|
||||
fleetHandler := fleet.NewHandler(fleetMgr)
|
||||
fleetHandler.SetNodeIdentifier(ingestSrv)
|
||||
fleetHandler.SetMigrationDeadlineProvider(ingestSrv)
|
||||
fleetHandler.RegisterRoutes(r)
|
||||
|
||||
// Floorplan REST API
|
||||
|
|
@ -3926,22 +3927,6 @@ func main() {
|
|||
http.NotFound(w, r)
|
||||
})
|
||||
|
||||
// Serve simple mode page
|
||||
r.Get("/simple", func(w http.ResponseWriter, r *http.Request) {
|
||||
staticDir := cfg.StaticDir
|
||||
if staticDir == "" {
|
||||
staticDir = findDashboardDir()
|
||||
}
|
||||
if staticDir != "" {
|
||||
simplePath := filepath.Join(staticDir, "simple.html")
|
||||
if _, err := os.Stat(simplePath); err == nil {
|
||||
http.ServeFile(w, r, simplePath)
|
||||
return
|
||||
}
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
})
|
||||
|
||||
// Serve fleet status page
|
||||
r.Get("/fleet", func(w http.ResponseWriter, r *http.Request) {
|
||||
staticDir := cfg.StaticDir
|
||||
|
|
|
|||
|
|
@ -424,12 +424,13 @@ func (h *Hub) BroadcastCSI(nodeMAC, peerMAC string, data []byte) {
|
|||
}
|
||||
|
||||
// BroadcastNodeConnected notifies dashboards of a new node
|
||||
func (h *Hub) BroadcastNodeConnected(mac, firmware, chip string) {
|
||||
func (h *Hub) BroadcastNodeConnected(mac, firmware, chip string, unpaired bool) {
|
||||
msg := map[string]interface{}{
|
||||
"type": "node_connected",
|
||||
"mac": mac,
|
||||
"firmware_version": firmware,
|
||||
"chip": chip,
|
||||
"unpaired": unpaired,
|
||||
}
|
||||
data, _ := json.Marshal(msg)
|
||||
h.Broadcast(data)
|
||||
|
|
|
|||
|
|
@ -117,10 +117,10 @@ func TestHub_NodeEvents(t *testing.T) {
|
|||
drainSnapshot(t, client.send)
|
||||
|
||||
// Test node connected event
|
||||
hub.BroadcastNodeConnected("AA:BB:CC:DD:EE:FF", "1.0.0", "ESP32-S3")
|
||||
hub.BroadcastNodeConnected("AA:BB:CC:DD:EE:FF", "1.0.0", "ESP32-S3", false)
|
||||
|
||||
msg := <-client.send
|
||||
expected := `{"chip":"ESP32-S3","firmware_version":"1.0.0","mac":"AA:BB:CC:DD:EE:FF","type":"node_connected"}`
|
||||
expected := `{"chip":"ESP32-S3","firmware_version":"1.0.0","mac":"AA:BB:CC:DD:EE:FF","type":"node_connected","unpaired":false}`
|
||||
if string(msg) != expected {
|
||||
t.Errorf("expected %s, got %s", expected, msg)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,13 +20,20 @@ type NodeIdentifier interface {
|
|||
SendIdentifyToMAC(mac string, durationMS int) bool
|
||||
SendRebootToMAC(mac string, delayMS int) bool
|
||||
GetConnectedMACs() []string
|
||||
GetUnpairedMACs() []string
|
||||
}
|
||||
|
||||
// MigrationDeadlineProvider returns the migration window deadline (zero = strict mode).
|
||||
type MigrationDeadlineProvider interface {
|
||||
GetMigrationDeadline() time.Time
|
||||
}
|
||||
|
||||
// Handler serves the fleet REST API.
|
||||
type Handler struct {
|
||||
mgr *Manager
|
||||
nodeID NodeIdentifier
|
||||
otaMgr *ota.Manager
|
||||
mgr *Manager
|
||||
nodeID NodeIdentifier
|
||||
otaMgr *ota.Manager
|
||||
migProvider MigrationDeadlineProvider
|
||||
}
|
||||
|
||||
// NewHandler creates a new fleet REST handler backed by mgr.
|
||||
|
|
@ -44,6 +51,11 @@ func (h *Handler) SetNodeIdentifier(ni NodeIdentifier) {
|
|||
h.nodeID = ni
|
||||
}
|
||||
|
||||
// SetMigrationDeadlineProvider wires in the source of the migration window deadline.
|
||||
func (h *Handler) SetMigrationDeadlineProvider(p MigrationDeadlineProvider) {
|
||||
h.migProvider = p
|
||||
}
|
||||
|
||||
// RegisterRoutes mounts fleet endpoints on r.
|
||||
//
|
||||
// GET /api/nodes — list all nodes
|
||||
|
|
@ -105,7 +117,7 @@ type FleetNode struct {
|
|||
Name string `json:"name"`
|
||||
Label string `json:"label"`
|
||||
Role string `json:"role"`
|
||||
Status string `json:"status"` // "online", "offline", "updating"
|
||||
Status string `json:"status"` // "online", "offline", "updating", "unpaired"
|
||||
FirmwareVersion string `json:"firmware_version"`
|
||||
ChipModel string `json:"chip_model"`
|
||||
PosX float64 `json:"pos_x"`
|
||||
|
|
@ -113,6 +125,7 @@ type FleetNode struct {
|
|||
PosZ float64 `json:"pos_z"`
|
||||
Virtual bool `json:"virtual"`
|
||||
HealthScore float64 `json:"health_score"`
|
||||
Unpaired bool `json:"unpaired,omitempty"`
|
||||
// Computed fields
|
||||
LastSeenMS int64 `json:"last_seen_ms"`
|
||||
UptimeSeconds int64 `json:"uptime_seconds"`
|
||||
|
|
@ -122,6 +135,14 @@ type FleetNode struct {
|
|||
OTAInProgress bool `json:"ota_in_progress"`
|
||||
}
|
||||
|
||||
// fleetListResponse wraps the fleet list with migration window metadata.
|
||||
type fleetListResponse struct {
|
||||
Nodes []FleetNode `json:"nodes"`
|
||||
MigrationWindowActive bool `json:"migration_window_active"`
|
||||
MigrationDeadlineMS int64 `json:"migration_deadline_ms,omitempty"`
|
||||
MigrationRemainingSecs float64 `json:"migration_remaining_secs,omitempty"`
|
||||
}
|
||||
|
||||
// listFleet returns extended node data with computed fields for the fleet page.
|
||||
func (h *Handler) listFleet(w http.ResponseWriter, r *http.Request) {
|
||||
nodes, err := h.mgr.registry.GetAllNodes()
|
||||
|
|
@ -143,6 +164,14 @@ func (h *Handler) listFleet(w http.ResponseWriter, r *http.Request) {
|
|||
connectedSet[mac] = true
|
||||
}
|
||||
|
||||
// Get unpaired MACs (nodes connected without valid tokens)
|
||||
unpairedSet := make(map[string]bool)
|
||||
if h.nodeID != nil {
|
||||
for _, mac := range h.nodeID.GetUnpairedMACs() {
|
||||
unpairedSet[mac] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Get OTA progress if OTA manager is available
|
||||
var otaProgress map[string]ota.NodeOTAProgress
|
||||
if h.otaMgr != nil {
|
||||
|
|
@ -170,8 +199,14 @@ func (h *Handler) listFleet(w http.ResponseWriter, r *http.Request) {
|
|||
Temperature: 0, // Not currently tracked
|
||||
}
|
||||
|
||||
// Determine status - check OTA progress first
|
||||
if otaProgress != nil {
|
||||
// Check unpaired status first (highest priority visual indicator)
|
||||
if unpairedSet[node.MAC] {
|
||||
fleetNode.Unpaired = true
|
||||
fleetNode.Status = "unpaired"
|
||||
}
|
||||
|
||||
// Determine status - check OTA progress first (skip if already unpaired)
|
||||
if !fleetNode.Unpaired && otaProgress != nil {
|
||||
if progress, ok := otaProgress[node.MAC]; ok {
|
||||
// Node has OTA progress - determine status from OTA state
|
||||
switch progress.State {
|
||||
|
|
@ -211,7 +246,7 @@ func (h *Handler) listFleet(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
fleetNode.OTAInProgress = false
|
||||
}
|
||||
} else {
|
||||
} else if !fleetNode.Unpaired {
|
||||
// No OTA manager - check connection status
|
||||
if connectedSet[node.MAC] {
|
||||
fleetNode.Status = "online"
|
||||
|
|
@ -239,7 +274,40 @@ func (h *Handler) listFleet(w http.ResponseWriter, r *http.Request) {
|
|||
fleetNodes = append(fleetNodes, fleetNode)
|
||||
}
|
||||
|
||||
writeJSON(w, fleetNodes)
|
||||
// Append unpaired nodes that are connected but not in the registry.
|
||||
registeredMACs := make(map[string]bool, len(fleetNodes))
|
||||
for _, n := range fleetNodes {
|
||||
registeredMACs[n.MAC] = true
|
||||
}
|
||||
for mac := range unpairedSet {
|
||||
if !registeredMACs[mac] {
|
||||
fleetNodes = append(fleetNodes, FleetNode{
|
||||
MAC: mac,
|
||||
Status: "unpaired",
|
||||
Unpaired: true,
|
||||
Role: "rx",
|
||||
LastSeenMS: now.UnixMilli(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Build response with migration window metadata.
|
||||
resp := fleetListResponse{
|
||||
Nodes: fleetNodes,
|
||||
}
|
||||
if h.migProvider != nil {
|
||||
deadline := h.migProvider.GetMigrationDeadline()
|
||||
if !deadline.IsZero() {
|
||||
resp.MigrationDeadlineMS = deadline.UnixMilli()
|
||||
remaining := time.Until(deadline).Seconds()
|
||||
if remaining > 0 {
|
||||
resp.MigrationWindowActive = true
|
||||
resp.MigrationRemainingSecs = remaining
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
writeJSON(w, resp)
|
||||
}
|
||||
|
||||
func (h *Handler) getNode(w http.ResponseWriter, r *http.Request) {
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ import (
|
|||
// CSIBroadcaster is a callback for broadcasting CSI frames to dashboard
|
||||
type CSIBroadcaster interface {
|
||||
BroadcastCSI(nodeMAC, peerMAC string, data []byte)
|
||||
BroadcastNodeConnected(mac, firmware, chip string)
|
||||
BroadcastNodeConnected(mac, firmware, chip string, unpaired bool)
|
||||
BroadcastNodeDisconnected(mac string)
|
||||
BroadcastLinkActive(linkID, nodeMAC, peerMAC string)
|
||||
}
|
||||
|
|
@ -274,6 +274,14 @@ func (s *Server) SetMigrationDeadline(t time.Time) {
|
|||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// GetMigrationDeadline returns the current migration window deadline.
|
||||
// Zero value means strict mode (no migration window).
|
||||
func (s *Server) GetMigrationDeadline() time.Time {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.migrationDeadline
|
||||
}
|
||||
|
||||
// SetTokenValidator sets the function used to validate node tokens in hello messages.
|
||||
// If set, nodes with missing or invalid tokens are rejected via sendReject and disconnected.
|
||||
func (s *Server) SetTokenValidator(fn func(mac, token string) bool) {
|
||||
|
|
@ -506,7 +514,7 @@ func (s *Server) HandleNodeWS(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
if broadcaster != nil {
|
||||
broadcaster.BroadcastNodeConnected(hello.MAC, hello.FirmwareVersion, hello.Chip)
|
||||
broadcaster.BroadcastNodeConnected(hello.MAC, hello.FirmwareVersion, hello.Chip, nc.Unpaired)
|
||||
}
|
||||
|
||||
if fleetFn != nil {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue