From ccaaaded557d5ac33fc07469d44934a49c7ef456 Mon Sep 17 00:00:00 2001 From: jedarden Date: Fri, 24 Apr 2026 22:20:45 -0400 Subject: [PATCH] feat(onboarding): harden node onboarding UX with migration window and unpaired flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- dashboard/js/app.js | 49 +++++--- dashboard/js/fleet-page.js | 43 ++++++- dashboard/js/fleet-page.test.js | 104 +++++++++++++++- dashboard/js/onboard.js | 134 ++++++++++++++++++++ dashboard/js/onboard.test.js | 142 +++++++++++++++++++++- mothership/cmd/mothership/main.go | 17 +-- mothership/internal/dashboard/hub.go | 3 +- mothership/internal/dashboard/hub_test.go | 4 +- mothership/internal/fleet/handler.go | 84 +++++++++++-- mothership/internal/ingestion/server.go | 12 +- 10 files changed, 537 insertions(+), 55 deletions(-) diff --git a/dashboard/js/app.js b/dashboard/js/app.js index 69afaae..82e9b4b 100644 --- a/dashboard/js/app.js +++ b/dashboard/js/app.js @@ -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 = - 'Link: ' + abbreviateLinkID(linkID) + '
' + - 'TX: ' + txLabel + '
' + - 'RX: ' + rxLabel + '
' + - 'Fresnel radius at midpoint: ' + data.b.toFixed(3) + ' m
' + + 'Link: ' + txLabel + ' to ' + rxLabel + '
' + + 'Fresnel radius at midpoint: ' + data.b.toFixed(2) + ' m
' + 'Link distance: ' + data.d.toFixed(2) + ' m
' + 'Wavelength: ' + data.lambda.toFixed(3) + ' m (ch ' + data.channel + ')
' + 'Link health: ' + 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. diff --git a/dashboard/js/fleet-page.js b/dashboard/js/fleet-page.js index 760c970..7894d27 100644 --- a/dashboard/js/fleet-page.js +++ b/dashboard/js/fleet-page.js @@ -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'; diff --git a/dashboard/js/fleet-page.test.js b/dashboard/js/fleet-page.test.js index 81229f5..f2599e4 100644 --- a/dashboard/js/fleet-page.test.js +++ b/dashboard/js/fleet-page.test.js @@ -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 = ` + + + + + UNPAIRED + + + +
+ + +
+ + + `; + + 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 = ` + + +
+ + + +
+ + + `; + + 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 = ` + + +
+ +
+ + + `; + + // 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'; diff --git a/dashboard/js/onboard.js b/dashboard/js/onboard.js index 7eb5f82..7548f32 100644 --- a/dashboard/js/onboard.js +++ b/dashboard/js/onboard.js @@ -403,7 +403,141 @@ ''; } + // In reprove mode, skip firmware flash and send provisioning payload over serial. + function renderReprovision(contentEl) { + var cancelled = false; + + contentEl.innerHTML = + '
' + + '

Re-provision Node

' + + '

Sending updated security credentials to your ESP32-S3...

' + + '

Press the RST button on your device if the progress stalls.

' + + '
' + + '
' + + '
' + + '' + + '' + + '
' + + ' Show log' + + '
' + + '
' + + '
'; + 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 = + '
' + + (isUserError(e) ? e.message : 'Re-provisioning failed. Check that the device is connected and try again.') + + '
' + + '
' + + '' + + '
'; + 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 }; diff --git a/dashboard/js/onboard.test.js b/dashboard/js/onboard.test.js index 6539234..2d88060 100644 --- a/dashboard/js/onboard.test.js +++ b/dashboard/js/onboard.test.js @@ -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 = '
' + + 'Re-provisioning AA:BB:CC:DD:EE:FF
'; + } + }; + 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 = + '

Re-provision Node

' + + '

Sending updated security credentials

'; + }; + flashRenderer(); + expect(content.innerHTML).toContain('Re-provision'); + }); +}); diff --git a/mothership/cmd/mothership/main.go b/mothership/cmd/mothership/main.go index 8323235..3648bdb 100644 --- a/mothership/cmd/mothership/main.go +++ b/mothership/cmd/mothership/main.go @@ -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 diff --git a/mothership/internal/dashboard/hub.go b/mothership/internal/dashboard/hub.go index d740779..ef3e21d 100644 --- a/mothership/internal/dashboard/hub.go +++ b/mothership/internal/dashboard/hub.go @@ -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) diff --git a/mothership/internal/dashboard/hub_test.go b/mothership/internal/dashboard/hub_test.go index 2d9657e..8fd78c6 100644 --- a/mothership/internal/dashboard/hub_test.go +++ b/mothership/internal/dashboard/hub_test.go @@ -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) } diff --git a/mothership/internal/fleet/handler.go b/mothership/internal/fleet/handler.go index 0bc3cd8..7bdee20 100644 --- a/mothership/internal/fleet/handler.go +++ b/mothership/internal/fleet/handler.go @@ -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) { diff --git a/mothership/internal/ingestion/server.go b/mothership/internal/ingestion/server.go index a3fd014..74b7cfc 100644 --- a/mothership/internal/ingestion/server.go +++ b/mothership/internal/ingestion/server.go @@ -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 {