diff --git a/dashboard/js/fleet.js b/dashboard/js/fleet.js index 95d1ad7..3496d02 100644 --- a/dashboard/js/fleet.js +++ b/dashboard/js/fleet.js @@ -57,20 +57,16 @@ } function createPanel() { - const sidebar = document.querySelector('.sidebar'); - if (!sidebar) { - console.warn('[Fleet] Sidebar not found'); - return; - } - // Check if panel already exists if (document.getElementById('fleet-panel')) { return; } + // Create the fleet panel container (fixed position panel) const panel = document.createElement('div'); panel.id = 'fleet-panel'; - panel.className = 'panel'; + panel.className = 'fleet-health-panel'; + panel.style.cssText = 'position: fixed; top: 60px; left: 20px; width: 280px; max-height: calc(100vh - 80px); background: rgba(0, 0, 0, 0.8); border-radius: 8px; padding: 12px; z-index: 100; overflow-y: auto; border: 1px solid rgba(255, 255, 255, 0.1);'; panel.innerHTML = `

Fleet Health

@@ -129,7 +125,7 @@
`; - sidebar.appendChild(panel); + document.body.appendChild(panel); // Add event handlers document.getElementById('fleet-optimise-btn').addEventListener('click', onOptimiseClick); @@ -508,6 +504,13 @@ cursor: not-allowed; } + .fleet-actions-divider { + width: 1px; + height: 24px; + background: rgba(255, 255, 255, 0.15); + margin: 0 4px; + } + .fleet-table-toolbar { display: flex; justify-content: space-between; @@ -737,6 +740,24 @@ white-space: nowrap; } + .fleet-position-col { + font-family: monospace; + font-size: 12px; + color: #aaa; + } + + .position-link { + color: #4fc3f7; + cursor: pointer; + text-decoration: none; + transition: color 0.2s; + } + + .position-link:hover { + color: #29b6f6; + text-decoration: underline; + } + .fleet-action-btn { background: none; border: none; @@ -1368,6 +1389,22 @@ + + + +
+ + @@ -1408,6 +1445,7 @@ MAC Address Role Status + Position Health Uptime Firmware @@ -1416,7 +1454,7 @@ - Loading... + Loading... @@ -1439,6 +1477,11 @@ document.getElementById('fleet-close-table-btn').addEventListener('click', hideFullTableView); document.getElementById('fleet-refresh-btn').addEventListener('click', refreshFullTable); document.getElementById('fleet-bulk-identify-btn').addEventListener('click', bulkIdentify); + document.getElementById('fleet-bulk-restart-btn').addEventListener('click', bulkRestart); + document.getElementById('fleet-update-all-btn').addEventListener('click', updateAllNodes); + document.getElementById('fleet-rebaseline-all-btn').addEventListener('click', rebaselineAllNodes); + document.getElementById('fleet-export-btn').addEventListener('click', exportConfig); + document.getElementById('fleet-import-btn').addEventListener('click', importConfig); document.getElementById('fleet-select-all').addEventListener('change', toggleSelectAll); document.getElementById('fleet-header-select-all').addEventListener('change', toggleSelectAll); document.getElementById('fleet-filter-role').addEventListener('change', renderFullTable); @@ -1520,7 +1563,7 @@ // Render rows if (nodes.length === 0) { - tbody.innerHTML = 'No nodes match the current filters'; + tbody.innerHTML = 'No nodes match the current filters'; return; } @@ -1547,6 +1590,11 @@ '' + '' + node.role + '' + '' + statusText + '' + + '' + + '' + + formatPosition(node.pos_x, node.pos_y, node.pos_z) + + '' + + '' + '' + '
' + '
' + @@ -1587,6 +1635,16 @@ }); }); + // Add position link click handlers + tbody.querySelectorAll('.position-link').forEach(function(link) { + link.addEventListener('click', function(e) { + e.preventDefault(); + e.stopPropagation(); + var mac = this.dataset.mac; + flyToNode(mac); + }); + }); + // Add row click handler for selection tbody.querySelectorAll('.fleet-row').forEach(function(row) { row.addEventListener('dblclick', function() { @@ -1601,6 +1659,7 @@ case 'mac': return node.mac || ''; case 'role': return node.role || ''; case 'status': return node.online ? 1 : 0; + case 'position': return (node.pos_x || 0) + (node.pos_y || 0) + (node.pos_z || 0); case 'health': return node.health_score || 0; case 'uptime': return node.uptime_seconds || 0; case 'fw': return node.firmware_version || ''; @@ -1767,6 +1826,194 @@ } } + function formatPosition(x, y, z) { + var px = (x || 0).toFixed(1); + var py = (y || 0).toFixed(1); + var pz = (z || 0).toFixed(1); + return '(' + px + ', ' + py + ', ' + pz + ')'; + } + + // ============================================ + // Bulk Actions + // ============================================ + function bulkRestart() { + if (selectedNodes.size === 0) return; + + selectedNodes.forEach(function(mac) { + restartNode(mac); + }); + + if (window.SpaxelApp && SpaxelApp.showToast) { + SpaxelApp.showToast('Restarting ' + selectedNodes.size + ' nodes', 'info'); + } + } + + function restartNode(mac) { + fetch('/api/nodes/' + mac + '/reboot', { + method: 'POST', + headers: { 'Content-Type': 'application/json' } + }) + .then(function(res) { + if (!res.ok) { + throw new Error('Restart failed: ' + res.status); + } + return res.json(); + }) + .then(function(data) { + if (window.SpaxelApp && window.SpaxelApp.showToast) { + window.SpaxelApp.showToast('Restart command sent to ' + mac, 'success'); + } + }) + .catch(function(err) { + console.error('[Fleet] Restart error:', err); + if (window.SpaxelApp && window.SpaxelApp.showToast) { + window.SpaxelApp.showToast('Failed to restart ' + mac + ': ' + err.message, 'error'); + } + }); + } + + function updateAllNodes() { + if (!confirm('Start OTA update for all nodes? This may take several minutes.')) { + return; + } + + fetch('/api/nodes/update-all', { + method: 'POST', + headers: { 'Content-Type': 'application/json' } + }) + .then(function(res) { + if (!res.ok) { + throw new Error('Update all failed: ' + res.status); + } + return res.json(); + }) + .then(function(data) { + if (window.SpaxelApp && SpaxelApp.showToast) { + SpaxelApp.showToast('OTA update started for ' + (data.count || 0) + ' nodes', 'success'); + } + }) + .catch(function(err) { + console.error('[Fleet] Update all error:', err); + if (window.SpaxelApp && SpaxelApp.showToast) { + SpaxelApp.showToast('Failed to start OTA update: ' + err.message, 'error'); + } + }); + } + + function rebaselineAllNodes() { + if (!confirm('Re-baseline all links? This requires an empty room for accurate results.')) { + return; + } + + fetch('/api/nodes/rebaseline-all', { + method: 'POST', + headers: { 'Content-Type': 'application/json' } + }) + .then(function(res) { + if (!res.ok) { + throw new Error('Re-baseline failed: ' + res.status); + } + return res.json(); + }) + .then(function(data) { + if (window.SpaxelApp && SpaxelApp.showToast) { + SpaxelApp.showToast('Re-baseline started for ' + (data.count || 0) + ' nodes', 'success'); + } + }) + .catch(function(err) { + console.error('[Fleet] Re-baseline error:', err); + if (window.SpaxelApp && SpaxelApp.showToast) { + SpaxelApp.showToast('Failed to start re-baseline: ' + err.message, 'error'); + } + }); + } + + function exportConfig() { + fetch('/api/export') + .then(function(res) { + if (!res.ok) { + throw new Error('Export failed: ' + res.status); + } + return res.json(); + }) + .then(function(data) { + var blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); + var url = URL.createObjectURL(blob); + var a = document.createElement('a'); + a.href = url; + a.download = 'spaxel-config-' + new Date().toISOString().slice(0, 10) + '.json'; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + + if (window.SpaxelApp && SpaxelApp.showToast) { + SpaxelApp.showToast('Configuration exported successfully', 'success'); + } + }) + .catch(function(err) { + console.error('[Fleet] Export error:', err); + if (window.SpaxelApp && SpaxelApp.showToast) { + SpaxelApp.showToast('Failed to export configuration: ' + err.message, 'error'); + } + }); + } + + function importConfig() { + var input = document.createElement('input'); + input.type = 'file'; + input.accept = 'application/json'; + + input.onchange = function(e) { + var file = e.target.files[0]; + if (!file) return; + + var reader = new FileReader(); + reader.onload = function(event) { + try { + var config = JSON.parse(event.target.result); + + if (!confirm('Import configuration? This will replace all existing nodes, zones, and settings.')) { + return; + } + + fetch('/api/import', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(config) + }) + .then(function(res) { + if (!res.ok) { + throw new Error('Import failed: ' + res.status); + } + return res.json(); + }) + .then(function(data) { + if (window.SpaxelApp && SpaxelApp.showToast) { + SpaxelApp.showToast('Configuration imported successfully. Reloading...', 'success'); + } + setTimeout(function() { + location.reload(); + }, 2000); + }) + .catch(function(err) { + console.error('[Fleet] Import error:', err); + if (window.SpaxelApp && SpaxelApp.showToast) { + SpaxelApp.showToast('Failed to import configuration: ' + err.message, 'error'); + } + }); + } catch (err) { + if (window.SpaxelApp && SpaxelApp.showToast) { + SpaxelApp.showToast('Invalid JSON file: ' + err.message, 'error'); + } + } + }; + reader.readAsText(file); + }; + + input.click(); + } + // ============================================ // Public API // ============================================ diff --git a/dashboard/js/viz3d.js b/dashboard/js/viz3d.js index 5aeeae7..cc5dfbb 100644 --- a/dashboard/js/viz3d.js +++ b/dashboard/js/viz3d.js @@ -2431,6 +2431,21 @@ const Viz3D = (function () { _controls.update(); } + /** + * Fly the camera to focus on a specific node. + * @param {string} mac - Node MAC address + */ + function flyToNode(mac) { + var nodeMesh = _nodeMeshes.get(mac); + if (!nodeMesh) { + console.warn('[Viz3D] Node mesh not found for MAC:', mac); + return; + } + + var pos = nodeMesh.position; + focusOnPosition(pos.x, pos.y, pos.z); + } + /** * Clear all anomaly zone overlays. */ @@ -2947,6 +2962,7 @@ const Viz3D = (function () { setAnomalyZones: setAnomalyZones, focusOnZone: focusOnZone, focusOnPosition: focusOnPosition, + flyToNode: flyToNode, clearAnomalyZones: clearAnomalyZones, // Explainability support API forEachRoomObject: function(callback) { diff --git a/mothership/internal/fleet/fleethandler.go b/mothership/internal/fleet/fleethandler.go index 0887a42..e9f7876 100644 --- a/mothership/internal/fleet/fleethandler.go +++ b/mothership/internal/fleet/fleethandler.go @@ -45,11 +45,17 @@ type fleetHealthResponse struct { } type fleetNodeEntry struct { - MAC string `json:"mac"` - Name string `json:"name"` - Role string `json:"role"` - HealthScore float64 `json:"health_score"` - Online bool `json:"online"` + MAC string `json:"mac"` + Name string `json:"name"` + Role string `json:"role"` + HealthScore float64 `json:"health_score"` + Online bool `json:"online"` + PosX float64 `json:"pos_x"` + PosY float64 `json:"pos_y"` + PosZ float64 `json:"pos_z"` + FirmwareVersion string `json:"firmware_version"` + UptimeSeconds int64 `json:"uptime_seconds"` + LastSeenMs int64 `json:"last_seen_ms"` } func (h *FleetHandler) getFleetHealth(w http.ResponseWriter, r *http.Request) { @@ -75,12 +81,27 @@ func (h *FleetHandler) getFleetHealth(w http.ResponseWriter, r *http.Request) { role = r } _, online := onlineSet[n.MAC] + + // Calculate uptime: if online, use time since first seen; otherwise, time since went offline + var uptimeSeconds int64 + if online { + uptimeSeconds = int64(time.Since(n.FirstSeenAt).Seconds()) + } else if !n.WentOfflineAt.IsZero() { + uptimeSeconds = int64(n.WentOfflineAt.Sub(n.FirstSeenAt).Seconds()) + } + entries = append(entries, fleetNodeEntry{ - MAC: n.MAC, - Name: n.Name, - Role: role, - HealthScore: n.HealthScore, - Online: online, + MAC: n.MAC, + Name: n.Name, + Role: role, + HealthScore: n.HealthScore, + Online: online, + PosX: n.PosX, + PosY: n.PosY, + PosZ: n.PosZ, + FirmwareVersion: n.FirmwareVersion, + UptimeSeconds: uptimeSeconds, + LastSeenMs: n.LastSeenAt.UnixMilli(), }) }