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:
parent
7347a5295b
commit
bf40673b72
13 changed files with 1044 additions and 87 deletions
File diff suppressed because one or more lines are too long
|
|
@ -1 +1 @@
|
|||
04129addd309e35bd0a9f98e9d718943185552a0
|
||||
008d3caa60239b4c9272fdc2f51d98700a7a2793
|
||||
|
|
|
|||
508
dashboard/js/floorplan-setup.js
Normal file
508
dashboard/js/floorplan-setup.js
Normal 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()">×</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();
|
||||
}
|
||||
})();
|
||||
|
|
@ -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/*")
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue