feat: add Position column to fleet status table with fly-to functionality

- Add Position column showing (x, y, z) coordinates for each node
- Make position clickable to fly camera to node in 3D view
- Add bulk actions: Restart Selected, Update All, Re-baseline All, Export, Import
- Expose Viz3D.flyToNode() function for camera navigation
- Include node position, firmware version, uptime, and last seen in API response

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-04-10 05:11:23 -04:00
parent 604d70b73a
commit d9a4b22017
3 changed files with 304 additions and 20 deletions

View file

@ -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 = `
<div class="panel-header">
<h3>Fleet Health</h3>
@ -129,7 +125,7 @@
</div>
`;
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 @@
<button id="fleet-bulk-identify-btn" class="fleet-btn fleet-btn-action" disabled>
<span class="icon">&#x26A1;</span> Identify Selected
</button>
<button id="fleet-bulk-restart-btn" class="fleet-btn fleet-btn-action" disabled>
<span class="icon">&#x21BB;</span> Restart Selected
</button>
<button id="fleet-update-all-btn" class="fleet-btn fleet-btn-action">
<span class="icon">&#x2191;</span> Update All
</button>
<button id="fleet-rebaseline-all-btn" class="fleet-btn fleet-btn-action">
<span class="icon">&#x267B;</span> Re-baseline All
</button>
<div class="fleet-actions-divider"></div>
<button id="fleet-export-btn" class="fleet-btn fleet-btn-secondary">
<span class="icon">&#x2193;</span> Export
</button>
<button id="fleet-import-btn" class="fleet-btn fleet-btn-secondary">
<span class="icon">&#x2191;</span> Import
</button>
<button id="fleet-close-table-btn" class="fleet-btn fleet-btn-secondary">
<span class="icon">&times;</span> Close
</button>
@ -1408,6 +1445,7 @@
<th class="sortable" data-sort="mac">MAC Address</th>
<th class="sortable" data-sort="role">Role</th>
<th class="sortable" data-sort="status">Status</th>
<th class="sortable" data-sort="position">Position</th>
<th class="sortable" data-sort="health">Health</th>
<th class="sortable" data-sort="uptime">Uptime</th>
<th class="sortable" data-sort="fw">Firmware</th>
@ -1416,7 +1454,7 @@
</thead>
<tbody id="fleet-table-body">
<tr>
<td colspan="8" class="fleet-empty-state">Loading...</td>
<td colspan="9" class="fleet-empty-state">Loading...</td>
</tr>
</tbody>
</table>
@ -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 = '<tr><td colspan="8" class="fleet-empty-state">No nodes match the current filters</td></tr>';
tbody.innerHTML = '<tr><td colspan="9" class="fleet-empty-state">No nodes match the current filters</td></tr>';
return;
}
@ -1547,6 +1590,11 @@
'</td>' +
'<td><span class="node-role-badge ' + node.role + '">' + node.role + '</span></td>' +
'<td><span class="node-status-badge ' + statusClass + '">' + statusText + '</span></td>' +
'<td class="fleet-position-col">' +
'<span class="position-link" data-mac="' + node.mac + '" title="Click to fly to node">' +
formatPosition(node.pos_x, node.pos_y, node.pos_z) +
'</span>' +
'</td>' +
'<td>' +
'<div class="health-bar-wrapper">' +
'<div class="health-bar health-bar-' + healthClass + '" style="width: ' + healthPercent + '%"></div>' +
@ -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
// ============================================

View file

@ -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) {

View file

@ -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(),
})
}