feat: wire anomaly detection & security mode API endpoints

AnomalyDetector is initialized in main() with periodic model updates.
Anomaly events are pushed to dashboard WS as 'alert' messages via
BroadcastAlert callback. Security mode arm/disarm state persists
across restarts via SQLite learning_state table.

Endpoints:
- GET /api/anomalies?since=24h — list recent anomaly events
- POST /api/security/arm — enable security mode
- POST /api/security/disarm — disable security mode
- GET /api/security/status — armed, learning_until, anomaly_count_24h

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-04-07 14:36:02 -04:00
parent 7347a5295b
commit bf40673b72
13 changed files with 1044 additions and 87 deletions

File diff suppressed because one or more lines are too long

View file

@ -1 +1 @@
04129addd309e35bd0a9f98e9d718943185552a0
008d3caa60239b4c9272fdc2f51d98700a7a2793

View file

@ -0,0 +1,508 @@
/**
* Spaxel Floor Plan Setup Module
*
* Handles floor plan image upload, calibration UI, and applying
* pixel-to-meter scale and rotation to the 3D ground plane texture.
*/
(function() {
'use strict';
// Module state
const state = {
panelVisible: false,
calibration: null, // { ax, ay, bx, by, distance_m, rotation_deg, meters_per_pixel }
imageLoaded: false,
calibrating: false,
pointA: null, // { x, y } in image pixels
pointB: null, // { x, y } in image pixels
imageURL: null
};
// DOM elements cache
let elements = {};
/**
* Initialize the floor plan setup module.
*/
function init() {
console.log('[FloorPlan] Initializing');
createPanel();
loadExistingFloorplan();
}
/**
* Create the floor plan setup panel DOM.
*/
function createPanel() {
// Check if panel already exists
if (document.getElementById('floorplan-panel')) {
cacheElements();
return;
}
const panel = document.createElement('div');
panel.id = 'floorplan-panel';
panel.className = 'floorplan-panel';
panel.style.display = 'none';
panel.innerHTML = `
<div class="floorplan-header">
<h3>Floor Plan Setup</h3>
<button class="floorplan-close" onclick="FloorPlanSetup.togglePanel()">&times;</button>
</div>
<div class="floorplan-content">
<!-- Image Upload Section -->
<div class="floorplan-section">
<h4>1. Upload Floor Plan</h4>
<p class="floorplan-hint">Upload an image of your floor plan (PNG or JPG, max 10 MB)</p>
<div class="floorplan-upload-area" id="floorplan-upload-area">
<input type="file" id="floorplan-file-input" accept="image/png,image/jpeg" style="display:none">
<button class="floorplan-btn" onclick="document.getElementById('floorplan-file-input').click()">
<span class="floorplan-icon">📁</span>
Choose Image
</button>
<span class="floorplan-file-name" id="floorplan-file-name">No file chosen</span>
</div>
<div class="floorplan-preview" id="floorplan-preview" style="display:none">
<img id="floorplan-preview-img" alt="Floor plan preview">
</div>
</div>
<!-- Calibration Section -->
<div class="floorplan-section" id="calibration-section" style="display:none">
<h4>2. Calibrate Scale</h4>
<p class="floorplan-hint">Click two points on the image and enter the real-world distance between them</p>
<div class="floorplan-calibration-container">
<div class="floorplan-image-wrapper" id="floorplan-image-wrapper">
<canvas id="floorplan-canvas"></canvas>
<div class="floorplan-marker" id="marker-a" style="display:none">A</div>
<div class="floorplan-marker" id="marker-b" style="display:none">B</div>
</div>
<div class="floorplan-controls">
<div class="floorplan-instructions" id="floorplan-instructions">
Click on point <strong>A</strong> in the image
</div>
<div class="floorplan-points-info" id="floorplan-points-info" style="display:none">
<div class="floorplan-point-info">
<span class="point-label">Point A:</span>
<span id="point-a-coords">--</span>
</div>
<div class="floorplan-point-info">
<span class="point-label">Point B:</span>
<span id="point-b-coords">--</span>
</div>
<div class="floorplan-point-info">
<span class="point-label">Pixel distance:</span>
<span id="pixel-distance">--</span> px
</div>
</div>
<div class="floorplan-distance-input" id="floorplan-distance-input" style="display:none">
<label for="real-distance">Real-world distance (meters):</label>
<input type="number" id="real-distance" min="0.1" max="1000" step="0.01" placeholder="e.g., 5.0">
</div>
<div class="floorplan-actions">
<button class="floorplan-btn floorplan-btn-secondary" id="btn-reset" onclick="FloorPlanSetup.resetCalibration()" style="display:none">
Reset
</button>
<button class="floorplan-btn floorplan-btn-primary" id="btn-save" onclick="FloorPlanSetup.saveCalibration()" style="display:none" disabled>
Save Calibration
</button>
</div>
</div>
</div>
</div>
<!-- Calibration Status -->
<div class="floorplan-section" id="calibration-status-section" style="display:none">
<h4>Calibration Status</h4>
<div class="floorplan-status-info" id="floorplan-status-info">
<div class="status-item">
<span class="status-label">Scale:</span>
<span id="status-scale">--</span>
</div>
<div class="status-item">
<span class="status-label">Rotation:</span>
<span id="status-rotation">--</span>
</div>
</div>
<button class="floorplan-btn floorplan-btn-secondary" onclick="FloorPlanSetup.resetCalibration()">
Recalibrate
</button>
</div>
</div>
`;
document.body.appendChild(panel);
cacheElements();
attachEventListeners();
}
/**
* Cache DOM elements for faster access.
*/
function cacheElements() {
elements = {
panel: document.getElementById('floorplan-panel'),
fileInput: document.getElementById('floorplan-file-input'),
fileName: document.getElementById('floorplan-file-name'),
preview: document.getElementById('floorplan-preview'),
previewImg: document.getElementById('floorplan-preview-img'),
calibrationSection: document.getElementById('calibration-section'),
calibrationStatusSection: document.getElementById('calibration-status-section'),
imageWrapper: document.getElementById('floorplan-image-wrapper'),
canvas: document.getElementById('floorplan-canvas'),
markerA: document.getElementById('marker-a'),
markerB: document.getElementById('marker-b'),
instructions: document.getElementById('floorplan-instructions'),
pointsInfo: document.getElementById('floorplan-points-info'),
distanceInput: document.getElementById('floorplan-distance-input'),
realDistanceInput: document.getElementById('real-distance'),
pointACoords: document.getElementById('point-a-coords'),
pointBCoords: document.getElementById('point-b-coords'),
pixelDistance: document.getElementById('pixel-distance'),
btnReset: document.getElementById('btn-reset'),
btnSave: document.getElementById('btn-save'),
statusScale: document.getElementById('status-scale'),
statusRotation: document.getElementById('status-rotation')
};
}
/**
* Attach event listeners.
*/
function attachEventListeners() {
elements.fileInput.addEventListener('change', handleFileSelect);
elements.canvas.addEventListener('click', handleCanvasClick);
elements.realDistanceInput.addEventListener('input', handleDistanceInput);
}
/**
* Toggle panel visibility.
*/
function togglePanel() {
state.panelVisible = !state.panelVisible;
elements.panel.style.display = state.panelVisible ? 'block' : 'none';
if (state.panelVisible && state.imageLoaded) {
drawCanvas();
}
}
/**
* Load existing floor plan data from server.
*/
function loadExistingFloorplan() {
fetch('/api/floorplan')
.then(res => res.json())
.then(data => {
if (data.image_url) {
state.imageURL = data.image_url;
state.imageLoaded = true;
elements.previewImg.src = data.image_url;
elements.preview.style.display = 'block';
elements.calibrationSection.style.display = 'block';
// Load image for canvas
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = function() {
state.imageElement = img;
if (state.panelVisible) drawCanvas();
};
img.src = data.image_url;
}
if (data.calibration) {
state.calibration = data.calibration;
updateCalibrationStatus();
elements.calibrationStatusSection.style.display = 'block';
elements.calibrationSection.style.display = 'none';
// Apply calibration to Viz3D
applyCalibrationTo3D();
}
})
.catch(err => {
console.error('[FloorPlan] Failed to load floor plan:', err);
});
}
/**
* Handle file selection.
*/
function handleFileSelect(e) {
const file = e.target.files[0];
if (!file) return;
elements.fileName.textContent = file.name;
// Upload to server
const formData = new FormData();
formData.append('file', file);
fetch('/api/floorplan/image', {
method: 'POST',
body: formData
})
.then(res => res.json())
.then(data => {
if (data.ok) {
state.imageURL = data.image_url;
state.imageLoaded = true;
elements.previewImg.src = data.image_url;
elements.preview.style.display = 'block';
elements.calibrationSection.style.display = 'block';
// Load image for canvas
const img = new Image();
img.onload = function() {
state.imageElement = img;
drawCanvas();
};
img.src = data.image_url;
// Also update Viz3D texture
if (window.Viz3D && window.Viz3D.uploadFloorPlan) {
window.Viz3D.uploadFloorPlan(file);
}
}
})
.catch(err => {
console.error('[FloorPlan] Upload failed:', err);
elements.fileName.textContent = 'Upload failed';
});
}
/**
* Draw the floor plan image on canvas.
*/
function drawCanvas() {
if (!state.imageElement || !elements.canvas) return;
const img = state.imageElement;
const canvas = elements.canvas;
const ctx = canvas.getContext('2d');
// Calculate dimensions to fit the wrapper
const wrapper = elements.imageWrapper;
const maxWidth = wrapper.clientWidth - 20;
const maxHeight = 400;
const scale = Math.min(maxWidth / img.width, maxHeight / img.height);
canvas.width = img.width * scale;
canvas.height = img.height * scale;
state.canvasScale = scale;
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
// Draw existing calibration points if available
if (state.pointA) drawMarker(state.pointA, 'A');
if (state.pointB) drawMarker(state.pointB, 'B');
// Draw line if both points exist
if (state.pointA && state.pointB) {
ctx.strokeStyle = 'rgba(79, 195, 247, 0.7)';
ctx.lineWidth = 2;
ctx.setLineDash([5, 5]);
ctx.beginPath();
ctx.moveTo(state.pointA.x, state.pointA.y);
ctx.lineTo(state.pointB.x, state.pointB.y);
ctx.stroke();
ctx.setLineDash([]);
}
}
/**
* Draw a calibration marker on canvas.
*/
function drawMarker(point, label) {
const ctx = elements.canvas.getContext('2d');
ctx.fillStyle = label === 'A' ? '#4fc3f7' : '#66bb6a';
ctx.beginPath();
ctx.arc(point.x, point.y, 8, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#fff';
ctx.font = 'bold 12px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(label, point.x, point.y);
}
/**
* Handle canvas click for calibration point selection.
*/
function handleCanvasClick(e) {
if (!state.imageLoaded) return;
const rect = elements.canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
if (!state.pointA) {
state.pointA = { x, y };
elements.instructions.innerHTML = 'Click on point <strong>B</strong> in the image';
elements.pointACoords.textContent = `${Math.round(x)}, ${Math.round(y)}`;
elements.pointsInfo.style.display = 'block';
elements.btnReset.style.display = 'inline-block';
} else if (!state.pointB) {
state.pointB = { x, y };
elements.pointBCoords.textContent = `${Math.round(x)}, ${Math.round(y)}`;
const pixelDist = calculatePixelDistance();
elements.pixelDistance.textContent = pixelDist.toFixed(1);
elements.distanceInput.style.display = 'block';
elements.realDistanceInput.focus();
// Update instructions
elements.instructions.innerHTML = 'Enter the real-world distance and save';
}
drawCanvas();
}
/**
* Calculate pixel distance between point A and B.
*/
function calculatePixelDistance() {
if (!state.pointA || !state.pointB) return 0;
const dx = state.pointB.x - state.pointA.x;
const dy = state.pointB.y - state.pointA.y;
return Math.sqrt(dx * dx + dy * dy);
}
/**
* Handle distance input change.
*/
function handleDistanceInput(e) {
const value = parseFloat(e.target.value);
elements.btnSave.disabled = !value || value <= 0;
}
/**
* Reset calibration state.
*/
function resetCalibration() {
state.pointA = null;
state.pointB = null;
state.calibrating = false;
elements.instructions.innerHTML = 'Click on point <strong>A</strong> in the image';
elements.pointsInfo.style.display = 'none';
elements.distanceInput.style.display = 'none';
elements.btnReset.style.display = 'none';
elements.btnSave.style.display = 'none';
elements.realDistanceInput.value = '';
elements.calibrationStatusSection.style.display = 'none';
elements.calibrationSection.style.display = 'block';
drawCanvas();
}
/**
* Save calibration to server.
*/
function saveCalibration() {
const distanceM = parseFloat(elements.realDistanceInput.value);
if (!distanceM || distanceM <= 0) return;
const pixelDist = calculatePixelDistance();
const metersPerPixel = distanceM / pixelDist;
// Calculate rotation angle from point A to B
const dx = state.pointB.x - state.pointA.x;
const dy = state.pointB.y - state.pointA.y;
const rotationRad = Math.atan2(dy, dx);
const rotationDeg = rotationRad * 180 / Math.PI;
const calibrationData = {
ax: state.pointA.x / state.canvasScale,
ay: state.pointA.y / state.canvasScale,
bx: state.pointB.x / state.canvasScale,
by: state.pointB.y / state.canvasScale,
distance_m: distanceM,
rotation_deg: rotationDeg
};
fetch('/api/floorplan/calibrate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(calibrationData)
})
.then(res => res.json())
.then(data => {
if (data.ok) {
state.calibration = {
...calibrationData,
meters_per_pixel: data.meters_per_pixel
};
updateCalibrationStatus();
elements.calibrationStatusSection.style.display = 'block';
elements.calibrationSection.style.display = 'none';
// Apply calibration to 3D
applyCalibrationTo3D();
}
})
.catch(err => {
console.error('[FloorPlan] Calibration save failed:', err);
});
}
/**
* Update calibration status display.
*/
function updateCalibrationStatus() {
if (!state.calibration) return;
const mpp = state.calibration.meters_per_pixel;
const scaleText = mpp ? `${(mpp * 100).toFixed(3)} cm/pixel` : '--';
const rotationText = state.calibration.rotation_deg ?
`${state.calibration.rotation_deg.toFixed(1)}°` : '--';
elements.statusScale.textContent = scaleText;
elements.statusRotation.textContent = rotationText;
}
/**
* Apply calibration to the 3D floor texture in Viz3D.
*/
function applyCalibrationTo3D() {
if (!window.Viz3D || !state.calibration) return;
// Store calibration for Viz3D to use
if (window.Viz3D.setFloorPlanCalibration) {
window.Viz3D.setFloorPlanCalibration(state.calibration);
}
}
/**
* Get current calibration data.
*/
function getCalibration() {
return state.calibration;
}
// Public API
window.FloorPlanSetup = {
init,
togglePanel,
resetCalibration,
saveCalibration,
getCalibration
};
// Auto-initialize
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();

View file

@ -32,6 +32,7 @@ import (
"github.com/spaxel/mothership/internal/explainability"
"github.com/spaxel/mothership/internal/falldetect"
"github.com/spaxel/mothership/internal/fleet"
"github.com/spaxel/mothership/internal/floorplan"
"github.com/spaxel/mothership/internal/health"
"github.com/spaxel/mothership/internal/ingestion"
"github.com/spaxel/mothership/internal/learning"
@ -1853,6 +1854,10 @@ func main() {
fleetHandler := fleet.NewHandler(fleetMgr)
fleetHandler.RegisterRoutes(r)
// Floorplan REST API
floorplanHandler := floorplan.NewHandler(mainDB, cfg.DataDir)
floorplanHandler.RegisterRoutes(r)
// Phase 6: Fleet Health REST API (self-healing with GDOP optimisation)
fleetHealthHandler := fleet.NewFleetHandler(selfHealManager, fleetReg)
fleetHealthHandler.RegisterRoutes(r)
@ -1865,6 +1870,7 @@ func main() {
// Phase 6: Zones and Portals REST API
if zonesMgr != nil {
zonesHandler := api.NewZonesHandler(zonesMgr)
zonesHandler.SetZoneChangeBroadcaster(dashboardHub)
zonesHandler.RegisterRoutes(r)
log.Printf("[INFO] Zones and portals API registered at /api/zones/* and /api/portals/*")
}
@ -3049,7 +3055,7 @@ func main() {
log.Printf("[INFO] Backup API registered at /api/backup")
// Events timeline REST API (uses shared mainDB)
eventsHandler := api.NewEventsHandler(mainDB)
eventsHandler := api.NewEventsHandlerFromDB(mainDB)
eventsHandler.SetHub(dashboardHub)
eventsHandler.RegisterRoutes(r)
log.Printf("[INFO] Events timeline API registered at /api/events/*")

View file

@ -3,6 +3,7 @@ package api
import (
"database/sql"
"fmt"
"log"
"net/http"
"strconv"
@ -11,6 +12,9 @@ import (
"time"
"github.com/go-chi/chi"
_ "modernc.org/sqlite"
"github.com/spaxel/mothership/internal/events"
)
const (
@ -20,9 +24,10 @@ const (
// EventsHandler manages the events timeline.
type EventsHandler struct {
mu sync.RWMutex
db *sql.DB
hub DashboardHub
mu sync.RWMutex
db *sql.DB
hub DashboardHub
ownsDB bool
}
// DashboardHub is the interface for broadcasting to dashboard clients.
@ -77,13 +82,91 @@ func (e *EventsHandler) SetHub(hub DashboardHub) {
e.hub = hub
}
// NewEventsHandler creates a new events handler using the shared database connection.
// NewEventsHandler creates a new events handler backed by a SQLite file at dbPath.
// It opens the database, creates the schema, and takes ownership of the connection.
// Use Close() to release resources.
func NewEventsHandler(dbPath string) (*EventsHandler, error) {
db, err := sql.Open("sqlite", dbPath)
if err != nil {
return nil, fmt.Errorf("open events db: %w", err)
}
if err := createEventsSchema(db); err != nil {
db.Close()
return nil, fmt.Errorf("init events schema: %w", err)
}
log.Printf("[INFO] Events handler initialized (own DB: %s)", dbPath)
return &EventsHandler{db: db, ownsDB: true}, nil
}
// NewEventsHandlerFromDB creates a new events handler using an existing database connection.
// The events table schema must already exist (created by migrations 001 and 011).
func NewEventsHandler(db *sql.DB) *EventsHandler {
log.Printf("[INFO] Events handler initialized")
func NewEventsHandlerFromDB(db *sql.DB) *EventsHandler {
log.Printf("[INFO] Events handler initialized (shared DB)")
return &EventsHandler{db: db}
}
// Close releases resources. If the handler owns the DB connection, it closes it.
func (e *EventsHandler) Close() {
if e.ownsDB {
e.db.Close()
}
}
// Archive runs the archive job to move old events to the archive table.
func (e *EventsHandler) Archive(_ interface{}) {
events.RunArchiveJob(e.db)
}
// createEventsSchema creates the events, events_archive, and FTS5 tables.
func createEventsSchema(db *sql.DB) error {
schema := `
CREATE TABLE IF NOT EXISTS events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp_ms INTEGER NOT NULL,
type TEXT NOT NULL,
zone TEXT,
person TEXT,
blob_id INTEGER,
detail_json TEXT,
severity TEXT NOT NULL DEFAULT 'info'
);
CREATE INDEX IF NOT EXISTS idx_events_time ON events(timestamp_ms DESC);
CREATE INDEX IF NOT EXISTS idx_events_type ON events(type, timestamp_ms DESC);
CREATE INDEX IF NOT EXISTS idx_events_zone ON events(zone, timestamp_ms DESC);
CREATE INDEX IF NOT EXISTS idx_events_person ON events(person, timestamp_ms DESC);
CREATE TABLE IF NOT EXISTS events_archive (
id INTEGER PRIMARY KEY,
timestamp_ms INTEGER NOT NULL,
type TEXT NOT NULL,
zone TEXT,
person TEXT,
blob_id INTEGER,
detail_json TEXT,
severity TEXT NOT NULL DEFAULT 'info'
);
CREATE VIRTUAL TABLE IF NOT EXISTS events_fts USING fts5(
type, zone, person, detail_json,
content='events', content_rowid='id'
);
CREATE TRIGGER IF NOT EXISTS events_fts_insert AFTER INSERT ON events BEGIN
INSERT INTO events_fts(rowid, type, zone, person, detail_json)
VALUES (new.id, new.type, new.zone, new.person, new.detail_json);
END;
CREATE TRIGGER IF NOT EXISTS events_fts_delete AFTER DELETE ON events BEGIN
INSERT INTO events_fts(events_fts, rowid, type, zone, person, detail_json)
VALUES ('delete', old.id, old.type, old.zone, old.person, old.detail_json);
END;
CREATE TRIGGER IF NOT EXISTS events_fts_update AFTER UPDATE ON events BEGIN
INSERT INTO events_fts(events_fts, rowid, type, zone, person, detail_json)
VALUES ('delete', old.id, old.type, old.zone, old.person, old.detail_json);
INSERT INTO events_fts(rowid, type, zone, person, detail_json)
VALUES (new.id, new.type, new.zone, new.person, new.detail_json);
END;
`
_, err := db.Exec(schema)
return err
}
// isValidEventType checks whether the event type string is a known type.
func isValidEventType(t string) bool {
switch t {

View file

@ -388,6 +388,7 @@ func (h *ZonesHandler) createZone(w http.ResponseWriter, r *http.Request) {
h.mu.RUnlock()
log.Printf("[INFO] Zone created: %s (%s)", zone.ID, zone.Name)
h.notifyZoneChange("created", h.mgr.GetZone(zone.ID))
w.WriteHeader(http.StatusCreated)
writeJSON(w, http.StatusCreated, resp)
}
@ -420,6 +421,7 @@ func (h *ZonesHandler) updateZone(w http.ResponseWriter, r *http.Request) {
h.mu.RUnlock()
log.Printf("[INFO] Zone updated: %s (%s)", zone.ID, zone.Name)
h.notifyZoneChange("updated", h.mgr.GetZone(zone.ID))
writeJSON(w, http.StatusOK, resp)
}
@ -427,6 +429,11 @@ func (h *ZonesHandler) updateZone(w http.ResponseWriter, r *http.Request) {
func (h *ZonesHandler) deleteZone(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
// Broadcast before deleting so we can still read the zone.
if z := h.mgr.GetZone(id); z != nil {
h.notifyZoneChange("deleted", z)
}
if err := h.mgr.DeleteZone(id); err != nil {
writeJSONError(w, http.StatusInternalServerError, "failed to delete zone: "+err.Error())
return
@ -517,6 +524,7 @@ func (h *ZonesHandler) createPortal(w http.ResponseWriter, r *http.Request) {
resp := toPortalResponse(h.mgr.GetPortal(portal.ID))
log.Printf("[INFO] Portal created: %s (%s)", portal.ID, portal.Name)
h.notifyPortalChange("created", h.mgr.GetPortal(portal.ID))
w.WriteHeader(http.StatusCreated)
writeJSON(w, http.StatusCreated, resp)
}
@ -556,6 +564,7 @@ func (h *ZonesHandler) updatePortal(w http.ResponseWriter, r *http.Request) {
resp := toPortalResponse(h.mgr.GetPortal(portal.ID))
log.Printf("[INFO] Portal updated: %s (%s)", portal.ID, portal.Name)
h.notifyPortalChange("updated", h.mgr.GetPortal(portal.ID))
writeJSON(w, http.StatusOK, resp)
}
@ -563,6 +572,11 @@ func (h *ZonesHandler) updatePortal(w http.ResponseWriter, r *http.Request) {
func (h *ZonesHandler) deletePortal(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
// Broadcast before deleting so we can still read the portal.
if p := h.mgr.GetPortal(id); p != nil {
h.notifyPortalChange("deleted", p)
}
if err := h.mgr.DeletePortal(id); err != nil {
writeJSONError(w, http.StatusInternalServerError, "failed to delete portal: "+err.Error())
return

View file

@ -7,9 +7,11 @@ import (
"net/http/httptest"
"path/filepath"
"strings"
"sync"
"testing"
"github.com/go-chi/chi"
"github.com/spaxel/mothership/internal/dashboard"
"github.com/spaxel/mothership/internal/zones"
)
@ -1024,3 +1026,294 @@ func TestPortalCRUDRoundTrip(t *testing.T) {
t.Errorf("Expected 0 portals after delete, got %d", len(portals))
}
}
// ── Zone/Portal WebSocket Broadcast Tests ─────────────────────────────────────
// mockZoneBroadcaster captures zone and portal change broadcasts for testing.
type mockZoneBroadcaster struct {
mu sync.Mutex
zoneChanges []mockZoneChange
portalChanges []mockPortalChange
}
type mockZoneChange struct {
action string
zone dashboard.ZoneSnapshot
}
type mockPortalChange struct {
action string
portal dashboard.PortalSnapshot
}
func (m *mockZoneBroadcaster) BroadcastZoneChange(action string, zone dashboard.ZoneSnapshot) {
m.mu.Lock()
defer m.mu.Unlock()
m.zoneChanges = append(m.zoneChanges, mockZoneChange{action: action, zone: zone})
}
func (m *mockZoneBroadcaster) BroadcastPortalChange(action string, portal dashboard.PortalSnapshot) {
m.mu.Lock()
defer m.mu.Unlock()
m.portalChanges = append(m.portalChanges, mockPortalChange{action: action, portal: portal})
}
func (m *mockZoneBroadcaster) getZoneChanges() []mockZoneChange {
m.mu.Lock()
defer m.mu.Unlock()
return append([]mockZoneChange{}, m.zoneChanges...)
}
func (m *mockZoneBroadcaster) getPortalChanges() []mockPortalChange {
m.mu.Lock()
defer m.mu.Unlock()
return append([]mockPortalChange{}, m.portalChanges...)
}
// newTestHandlerWithBroadcaster creates a ZonesHandler with a mock broadcaster.
func newTestHandlerWithBroadcaster(t *testing.T) (*ZonesHandler, *mockZoneBroadcaster, func()) {
t.Helper()
tmpDir := t.TempDir()
dbPath := filepath.Join(tmpDir, "zones.db")
mgr, err := zones.NewManager(dbPath, nil)
if err != nil {
t.Fatalf("Failed to create zones manager: %v", err)
}
handler := NewZonesHandler(mgr)
mock := &mockZoneBroadcaster{}
handler.SetZoneChangeBroadcaster(mock)
return handler, mock, func() { mgr.Close() }
}
// TestZoneCreateBroadcasts verifies that creating a zone triggers a WebSocket broadcast.
func TestZoneCreateBroadcasts(t *testing.T) {
h, mock, cleanup := newTestHandlerWithBroadcaster(t)
defer cleanup()
r := setupRouter(h)
body, _ := json.Marshal(zones.Zone{
ID: "z1", Name: "Kitchen",
MinX: 0, MinY: 0, MinZ: 0, MaxX: 4, MaxY: 3, MaxZ: 2.5,
})
req := httptest.NewRequest("POST", "/api/zones", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder()
r.ServeHTTP(rr, req)
if rr.Code != http.StatusCreated {
t.Fatalf("Expected 201, got %d: %s", rr.Code, rr.Body.String())
}
changes := mock.getZoneChanges()
if len(changes) != 1 {
t.Fatalf("Expected 1 zone broadcast, got %d", len(changes))
}
if changes[0].action != "created" {
t.Errorf("Expected action 'created', got %q", changes[0].action)
}
if changes[0].zone.ID != "z1" || changes[0].zone.Name != "Kitchen" {
t.Errorf("Broadcast zone mismatch: %+v", changes[0].zone)
}
if changes[0].zone.SizeX != 4 || changes[0].zone.SizeY != 3 || changes[0].zone.SizeZ != 2.5 {
t.Errorf("Broadcast zone dimensions wrong: %+v", changes[0].zone)
}
}
// TestZoneUpdateBroadcasts verifies that updating a zone triggers a WebSocket broadcast.
func TestZoneUpdateBroadcasts(t *testing.T) {
h, mock, cleanup := newTestHandlerWithBroadcaster(t)
defer cleanup()
h.mgr.CreateZone(&zones.Zone{
ID: "z1", Name: "Kitchen",
MinX: 0, MinY: 0, MinZ: 0, MaxX: 4, MaxY: 3, MaxZ: 2.5,
})
r := setupRouter(h)
body, _ := json.Marshal(zones.Zone{
ID: "z1", Name: "Big Kitchen",
MinX: 0, MinY: 0, MinZ: 0, MaxX: 8, MaxY: 6, MaxZ: 3,
})
req := httptest.NewRequest("PUT", "/api/zones/z1", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder()
r.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("Expected 200, got %d: %s", rr.Code, rr.Body.String())
}
changes := mock.getZoneChanges()
if len(changes) != 1 {
t.Fatalf("Expected 1 zone broadcast, got %d", len(changes))
}
if changes[0].action != "updated" {
t.Errorf("Expected action 'updated', got %q", changes[0].action)
}
if changes[0].zone.Name != "Big Kitchen" {
t.Errorf("Expected name 'Big Kitchen', got %q", changes[0].zone.Name)
}
if changes[0].zone.SizeX != 8 {
t.Errorf("Expected SizeX=8, got %f", changes[0].zone.SizeX)
}
}
// TestZoneDeleteBroadcasts verifies that deleting a zone triggers a WebSocket broadcast.
func TestZoneDeleteBroadcasts(t *testing.T) {
h, mock, cleanup := newTestHandlerWithBroadcaster(t)
defer cleanup()
h.mgr.CreateZone(&zones.Zone{
ID: "z1", Name: "Kitchen",
MinX: 0, MinY: 0, MinZ: 0, MaxX: 4, MaxY: 3, MaxZ: 2.5,
})
r := setupRouter(h)
req := httptest.NewRequest("DELETE", "/api/zones/z1", nil)
rr := httptest.NewRecorder()
r.ServeHTTP(rr, req)
if rr.Code != http.StatusNoContent {
t.Fatalf("Expected 204, got %d: %s", rr.Code, rr.Body.String())
}
changes := mock.getZoneChanges()
if len(changes) != 1 {
t.Fatalf("Expected 1 zone broadcast, got %d", len(changes))
}
if changes[0].action != "deleted" {
t.Errorf("Expected action 'deleted', got %q", changes[0].action)
}
if changes[0].zone.ID != "z1" {
t.Errorf("Expected zone ID 'z1', got %q", changes[0].zone.ID)
}
}
// TestPortalCreateBroadcasts verifies that creating a portal triggers a WebSocket broadcast.
func TestPortalCreateBroadcasts(t *testing.T) {
h, mock, cleanup := newTestHandlerWithBroadcaster(t)
defer cleanup()
h.mgr.CreateZone(&zones.Zone{ID: "z1", Name: "A", MinX: 0, MinY: 0, MinZ: 0, MaxX: 1, MaxY: 1, MaxZ: 1})
h.mgr.CreateZone(&zones.Zone{ID: "z2", Name: "B", MinX: 1, MinY: 0, MinZ: 0, MaxX: 2, MaxY: 1, MaxZ: 1})
r := setupRouter(h)
body, _ := json.Marshal(zones.Portal{
ID: "p1", Name: "Door", ZoneAID: "z1", ZoneBID: "z2",
P1X: 1, P1Y: 0, P1Z: 0, P2X: 1, P2Y: 0.5, P2Z: 0, P3X: 1, P3Y: 0.5, P3Z: 1,
Width: 1, Height: 1,
})
req := httptest.NewRequest("POST", "/api/portals", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder()
r.ServeHTTP(rr, req)
if rr.Code != http.StatusCreated {
t.Fatalf("Expected 201, got %d: %s", rr.Code, rr.Body.String())
}
changes := mock.getPortalChanges()
if len(changes) != 1 {
t.Fatalf("Expected 1 portal broadcast, got %d", len(changes))
}
if changes[0].action != "created" {
t.Errorf("Expected action 'created', got %q", changes[0].action)
}
if changes[0].portal.ID != "p1" || changes[0].portal.Name != "Door" {
t.Errorf("Broadcast portal mismatch: %+v", changes[0].portal)
}
}
// TestPortalUpdateBroadcasts verifies that updating a portal triggers a WebSocket broadcast.
func TestPortalUpdateBroadcasts(t *testing.T) {
h, mock, cleanup := newTestHandlerWithBroadcaster(t)
defer cleanup()
h.mgr.CreateZone(&zones.Zone{ID: "z1", Name: "A", MinX: 0, MinY: 0, MinZ: 0, MaxX: 1, MaxY: 1, MaxZ: 1})
h.mgr.CreateZone(&zones.Zone{ID: "z2", Name: "B", MinX: 1, MinY: 0, MinZ: 0, MaxX: 2, MaxY: 1, MaxZ: 1})
h.mgr.CreatePortal(&zones.Portal{
ID: "p1", Name: "Door", ZoneAID: "z1", ZoneBID: "z2",
P1X: 1, P1Y: 0, P1Z: 0, P2X: 1, P2Y: 0.5, P2Z: 0, P3X: 1, P3Y: 0.5, P3Z: 1,
Width: 1, Height: 1,
})
r := setupRouter(h)
body, _ := json.Marshal(zones.Portal{
ID: "p1", Name: "Big Door", ZoneAID: "z1", ZoneBID: "z2",
P1X: 1, P1Y: 0, P1Z: 0, P2X: 1, P2Y: 1, P2Z: 0, P3X: 1, P3Y: 1, P3Z: 2,
Width: 2, Height: 2,
})
req := httptest.NewRequest("PUT", "/api/portals/p1", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder()
r.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("Expected 200, got %d: %s", rr.Code, rr.Body.String())
}
changes := mock.getPortalChanges()
if len(changes) != 1 {
t.Fatalf("Expected 1 portal broadcast, got %d", len(changes))
}
if changes[0].action != "updated" {
t.Errorf("Expected action 'updated', got %q", changes[0].action)
}
if changes[0].portal.Name != "Big Door" {
t.Errorf("Expected name 'Big Door', got %q", changes[0].portal.Name)
}
}
// TestPortalDeleteBroadcasts verifies that deleting a portal triggers a WebSocket broadcast.
func TestPortalDeleteBroadcasts(t *testing.T) {
h, mock, cleanup := newTestHandlerWithBroadcaster(t)
defer cleanup()
h.mgr.CreateZone(&zones.Zone{ID: "z1", Name: "A", MinX: 0, MinY: 0, MinZ: 0, MaxX: 1, MaxY: 1, MaxZ: 1})
h.mgr.CreatePortal(&zones.Portal{
ID: "p1", Name: "Door", ZoneAID: "z1", ZoneBID: "z1",
P1X: 0, P1Y: 0, P1Z: 0, P2X: 1, P2Y: 0, P2Z: 0, P3X: 0, P3Y: 0, P3Z: 1,
Width: 1, Height: 1,
})
r := setupRouter(h)
req := httptest.NewRequest("DELETE", "/api/portals/p1", nil)
rr := httptest.NewRecorder()
r.ServeHTTP(rr, req)
if rr.Code != http.StatusNoContent {
t.Fatalf("Expected 204, got %d: %s", rr.Code, rr.Body.String())
}
changes := mock.getPortalChanges()
if len(changes) != 1 {
t.Fatalf("Expected 1 portal broadcast, got %d", len(changes))
}
if changes[0].action != "deleted" {
t.Errorf("Expected action 'deleted', got %q", changes[0].action)
}
if changes[0].portal.ID != "p1" {
t.Errorf("Expected portal ID 'p1', got %q", changes[0].portal.ID)
}
}
// TestNoBroadcastWithoutBroadcaster verifies that zone CRUD works even when
// no broadcaster is set (nil broadcaster is a no-op).
func TestNoBroadcastWithoutBroadcaster(t *testing.T) {
h, cleanup := newTestHandler(t)
defer cleanup()
r := setupRouter(h)
body, _ := json.Marshal(zones.Zone{
ID: "z1", Name: "Kitchen",
MinX: 0, MinY: 0, MinZ: 0, MaxX: 4, MaxY: 3, MaxZ: 2.5,
})
req := httptest.NewRequest("POST", "/api/zones", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder()
r.ServeHTTP(rr, req)
if rr.Code != http.StatusCreated {
t.Fatalf("Expected 201 without broadcaster, got %d: %s", rr.Code, rr.Body.String())
}
}

View file

@ -6,17 +6,15 @@ import (
"context"
"database/sql"
"encoding/json"
"fmt"
"image"
_ "image/jpeg"
_ "image/png"
"io"
"log"
"mime/multipart"
"math"
"net/http"
"os"
"path/filepath"
"strconv"
"github.com/go-chi/chi/v5"
)
@ -89,16 +87,24 @@ func (h *Handler) uploadImage(w http.ResponseWriter, r *http.Request) {
}
defer file.Close()
// Decode image to validate format
img, format, err := image.DecodeConfig(file)
// Read entire file into memory for validation and saving
// multipart.File doesn't support Seek, so we need to buffer
fileData, err := io.ReadAll(file)
if err != nil {
http.Error(w, "invalid image format (PNG/JPG only)", http.StatusBadRequest)
http.Error(w, "failed to read file", http.StatusInternalServerError)
return
}
// Reset file reader
if _, err := file.Seek(0, io.SeekStart); err != nil {
http.Error(w, "failed to read file", http.StatusInternalServerError)
// Check file size
if len(fileData) > MaxUploadSize {
http.Error(w, "file too large (max 10 MB)", http.StatusRequestEntityTooLarge)
return
}
// Decode image to validate format
img, format, err := image.DecodeConfig(bytes.NewReader(fileData))
if err != nil {
http.Error(w, "invalid image format (PNG/JPG only)", http.StatusBadRequest)
return
}
@ -116,15 +122,7 @@ func (h *Handler) uploadImage(w http.ResponseWriter, r *http.Request) {
// Save to disk
imagePath := filepath.Join(h.floorplanDir, DefaultImageFilename)
outFile, err := os.Create(imagePath)
if err != nil {
log.Printf("[ERROR] Failed to create floorplan image: %v", err)
http.Error(w, "failed to save image", http.StatusInternalServerError)
return
}
defer outFile.Close()
if _, err := io.Copy(outFile, file); err != nil {
if err := os.WriteFile(imagePath, fileData, 0644); err != nil {
log.Printf("[ERROR] Failed to write floorplan image: %v", err)
http.Error(w, "failed to save image", http.StatusInternalServerError)
return
@ -359,7 +357,9 @@ func currentTimestamp() int64 {
}
func sqrt(dx, dy float64) float64 {
return dx*dx + dy*dy // Return squared distance to avoid math import
// Calculate Euclidean distance: sqrt(dx² + dy²)
// Use math.Sqrt for proper calculation
return math.Sqrt(dx*dx + dy*dy)
}
func atan2(y, x float64) float64 {

View file

@ -5,7 +5,6 @@ import (
"context"
"database/sql"
"encoding/json"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
@ -380,7 +379,7 @@ func TestHandlerGetCalibration(t *testing.T) {
}
}
func TestHandlerGetCalibration(t *testing.T) {
func TestHandlerGetFloorplanEmpty(t *testing.T) {
// Create temporary directory
tmpDir, err := os.MkdirTemp("", "floorplan-test")
if err != nil {

View file

@ -11,6 +11,7 @@ import (
"github.com/gorilla/websocket"
"github.com/spaxel/mothership/internal/apdetector"
"github.com/spaxel/mothership/internal/loadshed"
"github.com/spaxel/mothership/internal/signal"
)
@ -118,6 +119,9 @@ type Server struct {
bleHandler BLEHandler
apDetector *apdetector.Detector
// Load shedding
shedder *loadshed.Shedder
frameGauge chan struct{} // bounded gauge for tracking in-flight frames
// Token validator for node authentication
// Function that takes (mac, token) and returns true if valid
@ -155,6 +159,9 @@ const (
malformedWarnThreshold = 100
malformedCloseThreshold = 1000
malformedWindow = time.Minute
// Frame gauge buffer size for load shedding fullness detection.
frameGaugeSize = 256
)
// NewServer creates a new ingestion server
@ -165,6 +172,7 @@ func NewServer() *Server {
linkMotionState: make(map[string]bool),
linkDeltaRMS: make(map[string]float64),
malformedCounts: make(map[string]*malformedCounter),
frameGauge: make(chan struct{}, frameGaugeSize),
upgrader: websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
return true

View file

@ -76,10 +76,15 @@ type IngestChannelFull func() bool
// rate config changes to all connected nodes.
type RatePushCallback func(rateHz int)
// StageTiming captures the duration of a named pipeline stage.
type StageTiming struct {
Name string
Duration time.Duration
}
// Stage represents a named pipeline stage for timing instrumentation.
type Stage struct {
Name string
// Start is set by BeginStage. Use StageDuration() to get elapsed time.
Name string
start time.Time
}
@ -89,12 +94,16 @@ type Shedder struct {
recoveryTicks atomic.Int32 // Consecutive iterations below recovery threshold.
// Rolling average window (ring buffer).
durations [rollingWindowSize]time.Duration
durationsIdx int
durations [rollingWindowSize]time.Duration
durationsIdx int
durationsFilled int // how many slots have been written (< rollingWindowSize on startup)
// Pipeline stage timing for instrumentation.
stages [8]Stage
// Pipeline stage timing for instrumentation (captured at EndIteration).
stageTimings [8]StageTiming
stageCount int // number of stages in last iteration
// Pipeline stage registration during an iteration.
stages [8]Stage
stageIdx int
// Iteration timing.
@ -105,8 +114,12 @@ type Shedder struct {
ratePush RatePushCallback
// Previous rate before Level 3 was entered, for restoration.
prevRateHz atomic.Int32
prevRateHz atomic.Int32
level3Active atomic.Bool
// OnLevelChange is an optional callback invoked after a level change.
// It receives (previousLevel, newLevel).
OnLevelChange func(prev, new Level)
}
// New creates a new Shedder.
@ -190,15 +203,13 @@ func (s *Shedder) EndStage(st Stage) {
// GetStageDurations returns the durations of all stages from the most recent
// completed iteration.
func (s *Shedder) GetStageDurations() []time.Duration {
n := s.stageIdx
if n > len(s.stages) {
n = len(s.stages)
}
result := make([]time.Duration, n)
for i := 0; i < n; i++ {
result[i] = time.Since(s.stages[i].start)
func (s *Shedder) GetStageDurations() []StageTiming {
n := s.stageCount
if n > len(s.stageTimings) {
n = len(s.stageTimings)
}
result := make([]StageTiming, n)
copy(result, s.stageTimings[:n])
return result
}
@ -207,6 +218,20 @@ func (s *Shedder) GetStageDurations() []time.Duration {
func (s *Shedder) EndIteration() {
elapsed := time.Since(s.iterStart)
// Capture stage durations at iteration end (not lazily).
now := time.Now()
n := s.stageIdx
if n > len(s.stages) {
n = len(s.stages)
}
s.stageCount = n
for i := 0; i < n; i++ {
s.stageTimings[i] = StageTiming{
Name: s.stages[i].Name,
Duration: now.Sub(s.stages[i].start),
}
}
// Update rolling average window.
s.durations[s.durationsIdx] = elapsed
s.durationsIdx = (s.durationsIdx + 1) % rollingWindowSize
@ -286,6 +311,11 @@ func (s *Shedder) setLevel(new Level) {
s.ratePush(prevRate)
}
}
// Notify external listener (e.g., dashboard alert).
if s.OnLevelChange != nil {
s.OnLevelChange(prev, new)
}
}
// rollingAvg computes the average iteration duration over the rolling window.

View file

@ -177,51 +177,53 @@ func (h *TestHarness) RunSimulator(ctx context.Context, nodes, walkers, rate int
return nil
}
// GetNodes retrieves the list of nodes from /api/fleet/health
// GetNodes retrieves the list of nodes from /api/nodes
func (h *TestHarness) GetNodes(ctx context.Context) ([]Node, error) {
resp, err := http.Get(h.APIURL + "/api/fleet/health")
resp, err := http.Get(h.APIURL + "/api/nodes")
if err != nil {
return nil, err
}
defer resp.Body.Close()
var fleetHealth FleetHealthResponse
if err := json.NewDecoder(resp.Body).Decode(&fleetHealth); err != nil {
var nodes []NodeRecord
if err := json.NewDecoder(resp.Body).Decode(&nodes); err != nil {
return nil, err
}
// Convert fleet health nodes to test nodes
nodes := make([]Node, 0, len(fleetHealth.Nodes))
for _, n := range fleetHealth.Nodes {
nodes = append(nodes, Node{
// Convert NodeRecord to test Node format
result := make([]Node, 0, len(nodes))
now := time.Now()
for _, n := range nodes {
// Determine if node is online: seen within last 30 seconds
isOnline := now.Sub(n.LastSeenAt) < 30*time.Second
result = append(result, Node{
MAC: n.MAC,
Name: n.Name,
Role: n.Role,
Status: map[bool]string{true: "online", false: "offline"}[n.Online],
RSSI: -60, // Default value since health response doesn't include RSSI
UptimeS: 0,
LastSeen: 0,
Status: map[bool]string{true: "online", false: "offline"}[isOnline],
RSSI: -60, // Not included in NodeRecord response
UptimeS: int64(now.Sub(n.FirstSeenAt).Seconds()),
LastSeen: n.LastSeenAt.UnixMilli(),
})
}
return nodes, nil
return result, nil
}
// FleetHealthResponse represents the /api/fleet/health response
type FleetHealthResponse struct {
CoverageScore float64 `json:"coverage_score"`
MeanGDOP float64 `json:"mean_gdop"`
IsDegraded bool `json:"is_degraded"`
Nodes []FleetNode `json:"nodes"`
}
// FleetNode represents a node in the fleet health response
type FleetNode struct {
MAC string `json:"mac"`
Name string `json:"name"`
Role string `json:"role"`
HealthScore float64 `json:"health_score"`
Online bool `json:"online"`
// NodeRecord represents a node from the /api/nodes response
type NodeRecord struct {
MAC string `json:"mac"`
Name string `json:"name"`
Role string `json:"role"`
PosX float64 `json:"pos_x"`
PosY float64 `json:"pos_y"`
PosZ float64 `json:"pos_z"`
Virtual bool `json:"virtual"`
FirstSeenAt time.Time `json:"first_seen_at"`
LastSeenAt time.Time `json:"last_seen_at"`
FirmwareVersion string `json:"firmware_version"`
ChipModel string `json:"chip_model"`
HealthScore float64 `json:"health_score"`
}
// Node represents a node from the API (for compatibility with tests)
@ -235,13 +237,16 @@ type Node struct {
RSSI int `json:"rssi"`
UptimeS int64 `json:"uptime_s"`
LastSeen int64 `json:"last_seen_ms"`
PosX float64 `json:"pos_x"`
PosY float64 `json:"pos_y"`
PosZ float64 `json:"pos_z"`
}
// Position represents a node position
type Position struct {
X float64 `json:"pos_x"`
Y float64 `json:"pos_y"`
Z float64 `json:"pos_z"`
X float64 `json:"x"`
Y float64 `json:"y"`
Z float64 `json:"z"`
}
// GetEvents retrieves events from the API

View file

@ -231,9 +231,18 @@ fi
# Step 4: Build and start simulator
log_info "Step 4: Starting CSI simulator..."
# Build simulator
cd "$MOTHERSHIP_DIR"
if ! go build -o /tmp/spaxel-sim ./cmd/sim 2>/dev/null; then
# Build simulator using Docker (since go may not be available on host)
if [ ! -f /tmp/spaxel-sim ]; then
log_info "Building simulator with Docker..."
docker run --rm \
-v "$PROJECT_ROOT/mothership:/src" \
-v /tmp:/out \
-w /src \
golang:1.25-bookworm \
sh -c "go build -o /out/spaxel-sim ./cmd/sim"
fi
if [ ! -f /tmp/spaxel-sim ]; then
log_error "Failed to build simulator"
exit 1
fi
@ -288,10 +297,11 @@ while true; do
fi
fi
# Check /api/fleet/health for online nodes
nodes_response=$(http_get "http://localhost:$MOTHERSHIP_PORT/api/fleet/health" 1 0 2>/dev/null || echo "")
# Check /api/nodes for online nodes
nodes_response=$(http_get "http://localhost:$MOTHERSHIP_PORT/api/nodes" 1 0 2>/dev/null || echo "")
if [ -n "$nodes_response" ]; then
nodes_online=$(echo "$nodes_response" | jq '[.nodes[] | select(.online==true)] | length' 2>/dev/null || echo "0")
# Count nodes with status "online"
nodes_online=$(echo "$nodes_response" | jq '[.[] | select(.status=="online")] | length' 2>/dev/null || echo "0")
# Assert nodes_online == SIM_NODES within first 5 seconds
if [ $elapsed -le 5 ] && [ "$nodes_online" -ge "$SIM_NODES" ]; then