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 = `
`;
- 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(),
})
}
|