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 {