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 = `
+
Sending updated security credentials to your ESP32-S3...
' + + 'Press the RST button on your device if the progress stalls.
' + + '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 {