feat(sites): one install, many networks (v0.24.0-beta)

Closes #130. Multi-site, not multi-tenancy: a site is a named collection of
devices whose clients, events, topology and dashboards are viewed separately.
Sites separate what you see, not who you are — the admin/manage/read-only
roles are unchanged and every user sees every site. Per-site permissions
would have multiplied the work for a need nobody stated.

Two measurements decided the design.

Every device-derived table hangs off devices(id) — 25 of 50 tables, 35
foreign keys — so a single site_id column on devices scopes clients, events,
topology, backups and the rest transitively. No other table needed one.

The frontend has 162 useQuery call sites across 57 files. Threading a site
through each would be a large change that leaves a permanent trap: every
future query another chance to forget. The site is injected in the existing
axios interceptor instead, beside the auth token, so a new query is scoped by
default. Only 23 collection routes needed filtering; the 131 per-device
routes are already scoped by their device.

Existing installs are untouched. Migration creates one "Default Site", adopts
every device into it, and a single-site install never meets the concept. An
absent X-Site-Id header means unscoped, so existing API tokens and scripts
behave exactly as before.

Polling stays fleet-wide. Sites are a view over data already gathered, not a
change to how it is gathered — a device does not stop being monitored because
you are looking elsewhere. Rogue-AP detection scopes the scans but keeps the
"these are ours" BSSID set fleet-wide, or your own AP in another site reads
as an evil twin. Bulk actions do obey the selector: an operation that
silently spanned two customers is the blast radius the wave guards exist to
contain.

Four rounds of defects came out of testing the UI rather than the API, and
the first is the one worth recording. Switching sites called
queryClient.clear(), which empties the cache and reviews as correct. It
isn't: clear() destroys each query but a *mounted* observer keeps its own
reference and last result, so every rendered page went on showing the
previous site's data. The QueryClient is now keyed on the site, which
remounts the tree against an empty cache. Three tests pin the old behaviour
down so the shortcut cannot return.

The others: an unscoped fallback path behind the Connected Clients chart;
Config Health, TX retries and the activity feed reading fleet-wide; and
site_id added to the device list query but none of the other four that build
a device payload, so the Site field could not read its own value and appeared
to reject every change while the writes were landing. That last one is now
structural — DEVICE_BASE_COLUMNS is shared by all five, with a test that
fails when a device payload is hand-rolled, and which found two further
instances while being written.

Frontend tests were never run by CI despite vitest being configured and six
suites existing; they now run in both the workflow and ci-preflight.

Verified against a live four-device fleet: the migration left every figure
identical, an empty site returns zero from every read endpoint, moving a
device carries its history with it, and deleting a site with devices is
refused.

Minor bump rather than patch: sites change the shape of the product rather
than adding to it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2GT-Rich
2026-09-07 12:02:00 -07:00
co-authored by Claude Opus 5
parent e47819e232
commit 7b994ad935
50 changed files with 2074 additions and 151 deletions
+4 -1
View File
@@ -23,7 +23,7 @@ jobs:
run: node scripts/generate-ghcr-compose.mjs --check
frontend:
name: Frontend — Lint, Type-check & Build
name: Frontend — Lint, Type-check, Test & Build
runs-on: ubuntu-latest
defaults:
run:
@@ -48,6 +48,9 @@ jobs:
- name: Type-check
run: npx tsc --noEmit
- name: Tests
run: npm test
- name: Build
run: npm run build
+4 -1
View File
@@ -6,7 +6,7 @@
</p>
<p align="center">
<img src="https://img.shields.io/badge/version-0.23.35_Beta-blue" alt="Version" />
<img src="https://img.shields.io/badge/version-0.24.0_Beta-blue" alt="Version" />
<img src="https://img.shields.io/badge/license-AGPLv3-blue" alt="License" />
<img src="https://img.shields.io/badge/docker-compose-2496ED?logo=docker&logoColor=white" alt="Docker" />
<img src="https://img.shields.io/badge/TypeScript-5.3-3178C6?logo=typescript&logoColor=white" alt="TypeScript" />
@@ -122,6 +122,7 @@ links the relevant MikroTik documentation.
| **CAPsMAN aware** | Centrally provisioned APs are recognised, grouped under their controller, and protected from local writes the controller would discard |
| **Rogue AP detection** | A foreign BSSID broadcasting one of your SSIDs is flagged as an evil twin |
| **Anomaly insights** | Client counts and CPU compared against each device's own same-hour 14-day baseline, not a fixed threshold |
| **Multi-site** | Group devices into sites and view each one's clients, events and topology separately, with an all-sites map. One install, many customer networks |
| **Enterprise auth** | OIDC/SSO, TOTP two-factor, role-based access, and scoped API tokens |
---
@@ -343,6 +344,7 @@ Each network service supports multi-device management with conflict detection:
- **Configuration templates** — reusable sets (DNS, NTP, syslog) pushed to many devices with per-device result reporting
- **Bulk device add** — "Try All" on discovered devices runs as a server-side job that survives a closed browser tab, with live progress and cancel
- **Device organisation** — colour-coded tags, rack location, physical address with map support, and per-device notes
- **Sites** — group devices into sites and see each one's devices, clients, events, topology and dashboards on their own. A selector under the logo switches between them, and an all-sites view pins every site on a world map. Single-network installs are collected into one "Default Site" and never meet the concept. Polling stays fleet-wide — sites are a view over the data, not a change to how it is gathered. See [the sites guide](docs/sites.md)
### Access control and platform
@@ -367,6 +369,7 @@ so it moves when behaviour does.
|---|---|
| [Configuration](docs/configuration.md) | Environment variables, secret management, key rotation, TLS |
| [Change Guard and Config Health](docs/change-guard.md) | How the safety system works, its settings, and what to expect when it fires |
| [Sites](docs/sites.md) | Grouping devices into sites, switching between them, and what each site scopes |
| [Alerting](docs/alerting.md) | Rules, channels, ntfy priority mapping, webhooks, maintenance windows |
| [Single sign-on (OIDC)](docs/sso-oidc.md) | Identity provider setup, group-to-role mapping, break-glass behaviour |
| [CAPsMAN](docs/capsman.md) | How centrally provisioned APs are modelled, and current limits |
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "mikrotik-manager-backend",
"version": "0.23.35-beta",
"version": "0.24.0-beta",
"description": "Mikrotik Network Management Platform - Backend API",
"main": "dist/index.js",
"scripts": {
+40
View File
@@ -967,6 +967,27 @@ CREATE TABLE IF NOT EXISTS device_ssh_keys (
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Multi-site (issue #130). A site is a named collection of devices whose data
-- is viewed separately. Because ~25 tables hang off devices(id), a single
-- site_id on devices scopes clients, events, topology, backups and the rest
-- transitively -- no other table needs a site column.
CREATE TABLE IF NOT EXISTS sites (
id SERIAL PRIMARY KEY,
name VARCHAR(120) NOT NULL UNIQUE,
address TEXT,
location_lat NUMERIC(10,7),
location_lng NUMERIC(10,7),
notes TEXT,
is_default BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Deliberately NOT "ON DELETE CASCADE": removing a site must never remove the
-- devices in it. The delete endpoint refuses while devices remain.
ALTER TABLE devices ADD COLUMN IF NOT EXISTS site_id INTEGER REFERENCES sites(id);
CREATE INDEX IF NOT EXISTS idx_devices_site ON devices(site_id);
`;
const DEFAULT_SETTINGS = [
@@ -1028,6 +1049,25 @@ export async function runMigrations(): Promise<void> {
);
}
// Multi-site backfill. Existing single-network installs must come out of
// this indistinguishable from how they went in: one site, every device in
// it, nothing moved. Idempotent -- reruns are a no-op.
const siteCount = await client.query('SELECT COUNT(*) FROM sites');
if (parseInt(siteCount.rows[0].count, 10) === 0) {
await client.query(
`INSERT INTO sites (name, is_default) VALUES ($1, TRUE)`,
['Default Site']
);
console.log('Default Site created');
}
// Adopt any device without a site (covers both the initial backfill and
// devices added by an older build mid-upgrade).
const adopted = await client.query(
`UPDATE devices SET site_id = (SELECT id FROM sites WHERE is_default = TRUE ORDER BY id LIMIT 1)
WHERE site_id IS NULL`
);
if (adopted.rowCount) console.log(`Assigned ${adopted.rowCount} device(s) to the default site`);
// Create default admin user if no users exist
const userCount = await client.query('SELECT COUNT(*) FROM users');
if (parseInt(userCount.rows[0].count, 10) === 0) {
+5
View File
@@ -64,6 +64,8 @@ import trafficAnalyticsRoutes from './routes/trafficAnalytics';
import credentialPresetsRoutes from './routes/credentialPresets';
import commandRoutes from './routes/commands';
import systemRoutes, { setPollerService as setSystemPoller } from './routes/system';
import sitesRoutes from './routes/sites';
import { siteContext } from './middleware/site';
import { auditMiddleware } from './middleware/auditMiddleware';
// ─── Secret hygiene ───────────────────────────────────────────────────────────
@@ -293,6 +295,8 @@ app.use(auditMiddleware);
// Global backstop rate limit on mutating API requests (per user, falling back to
// per IP before auth runs). Endpoints with stricter needs add their own limiter.
app.use('/api', rateLimitRedis({ windowSec: 60, max: 120, keyPrefix: 'api-global' }));
// Resolve the active site once, before any route reads it (issue #130).
app.use('/api', siteContext);
// ─── Health Check ─────────────────────────────────────────────────────────────
app.get('/api/health', (_req, res) => {
@@ -329,6 +333,7 @@ app.use('/api/config-templates', configTemplatesRoutes);
app.use('/api/config-history', configHistoryRoutes);
app.use('/api/system', systemRoutes);
app.use('/api/commands', commandRoutes);
app.use('/api/sites', sitesRoutes);
// ─── Error Handler ────────────────────────────────────────────────────────────
app.use(errorHandler);
+34
View File
@@ -0,0 +1,34 @@
import { Request, Response, NextFunction } from 'express';
import { resolveSiteId } from '../utils/siteScope';
declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace Express {
interface Request {
/** Active site, or null for the whole fleet (all-sites view). */
siteId?: number | null;
}
}
}
/**
* Reads the active site from the X-Site-Id header (issue #130).
*
* An absent, empty or 'all' header means unscoped. That default is what keeps
* every pre-existing API consumer -- scripts, API tokens, the docs' curl
* examples -- behaving exactly as it did before sites existed.
*
* A query-string ?site_id= is also honoured, for links and for anything that
* cannot set headers.
*/
export function siteContext(req: Request, _res: Response, next: NextFunction): void {
const header = req.headers['x-site-id'];
const raw = Array.isArray(header) ? header[0] : header;
req.siteId = resolveSiteId(raw ?? req.query.site_id);
next();
}
/** Convenience accessor so routes don't repeat the `?? null`. */
export function activeSite(req: Request): number | null {
return req.siteId ?? null;
}
+4
View File
@@ -3,6 +3,8 @@ import * as fs from 'fs';
import * as path from 'path';
import { query, queryOne } from '../config/database';
import { requireAuth, requireWrite } from '../middleware/auth';
import { siteScopeByDevice } from '../utils/siteScope';
import { activeSite } from '../middleware/site';
import { BackupService } from '../services/BackupService';
const router = Router();
@@ -23,6 +25,8 @@ router.get('/', async (req: Request, res: Response) => {
sql += ` AND b.device_id = $1`;
params.push(deviceId);
}
const siteFilter = siteScopeByDevice(activeSite(req), 'b.device_id');
if (siteFilter) sql += ` AND ${siteFilter}`;
sql += ' ORDER BY b.created_at DESC';
const backups = await query(sql, params);
+8
View File
@@ -3,6 +3,8 @@ import * as dgram from 'dgram';
import { query } from '../config/database';
import { logSafe } from '../utils/logSafe';
import { requireAuth, requireAdmin, requireWrite } from '../middleware/auth';
import { siteScopeByDevice } from '../utils/siteScope';
import { activeSite } from '../middleware/site';
import { PollerService } from '../services/PollerService';
import { getQueryApi, bucket } from '../config/influxdb';
import { fingerprintClient, DEVICE_CATEGORIES, DeviceCategory } from '../utils/clientFingerprint';
@@ -132,6 +134,12 @@ router.get('/', async (req: Request, res: Response) => {
filters.push(`c.device_id = $${idx++}`);
filterParams.push(deviceId);
}
// Site scoping belongs on the sighting side: a MAC seen by a device in
// another site is that site's client, not this one's. Expressed as a
// subquery on device_id rather than d.site_id because the count query below
// does not join devices (issue #130).
const siteFilter = siteScopeByDevice(activeSite(req), 'c.device_id');
if (siteFilter) filters.push(siteFilter);
if (active === 'true') {
filters.push(`c.active = TRUE`);
}
+7 -1
View File
@@ -8,6 +8,8 @@
import { Router } from 'express';
import type { Request, Response } from 'express';
import { requireAuth, requireWrite } from '../middleware/auth';
import { siteScopeByDevice } from '../utils/siteScope';
import { activeSite } from '../middleware/site';
import { query, queryOne } from '../config/database';
import { commandRunner } from '../services/CommandRunner';
@@ -122,7 +124,9 @@ router.post('/runs', requireWrite, async (req: Request, res: Response) => {
});
// GET /api/commands/runs — recent runs with progress
router.get('/runs', async (_req: Request, res: Response) => {
router.get('/runs', async (req: Request, res: Response) => {
// A run is shown when it touched at least one device in the active site.
const memberFilter = siteScopeByDevice(activeSite(req), 'crd.device_id');
const runs = await query(
`SELECT r.*,
COUNT(d.*)::int AS total,
@@ -130,6 +134,8 @@ router.get('/runs', async (_req: Request, res: Response) => {
COUNT(*) FILTER (WHERE d.status IN ('failed','reverted'))::int AS failed
FROM command_runs r
LEFT JOIN command_run_devices d ON d.run_id = r.id
${memberFilter ? `WHERE EXISTS (SELECT 1 FROM command_run_devices crd
WHERE crd.run_id = r.id AND ${memberFilter})` : ''}
GROUP BY r.id ORDER BY r.id DESC LIMIT 25`
);
res.json(runs);
+20 -18
View File
@@ -20,7 +20,10 @@ import { isMultiVlanSpec } from '../utils/vlan';
import { redis } from '../config/redis';
import { enqueueBulkAddJob, getBulkAddJobState } from '../services/DeviceBulkAddWorker';
import { logSafe } from '../utils/logSafe';
import { DEVICE_BASE_COLUMNS } from '../services/deviceColumns';
import { buildSegments, summarise, summariseBands } from '../utils/lteDwell';
import { siteScopeDevices, andSite } from '../utils/siteScope';
import { activeSite } from '../middleware/site';
/** Postgres interval text to milliseconds, for the few windows we offer. */
function intervalToMs(interval: string): number {
@@ -74,15 +77,15 @@ export function setPollerService(p: PollerService): void {
}
// GET /api/devices
router.get('/', async (_req: Request, res: Response) => {
router.get('/', async (req: Request, res: Response) => {
const where = andSite('', siteScopeDevices(activeSite(req)));
const devices = await query(
`SELECT id, name, ip_address, api_port, api_username, model, serial_number,
firmware_version, ros_version, latest_ros_version, firmware_update_available,
`SELECT ${DEVICE_BASE_COLUMNS},
latest_ros_version, firmware_update_available,
routerboard_upgrade_available, upgrade_firmware_version,
device_type, status, last_seen, notes,
location_address, location_lat::float8 AS location_lat, location_lng::float8 AS location_lng,
rack_name, rack_slot, created_at
FROM devices ORDER BY name ASC`
FROM devices ${where} ORDER BY name ASC`
);
// Attach tags to each device
@@ -104,7 +107,8 @@ router.get('/', async (_req: Request, res: Response) => {
});
// ─── Routers overview (router-type devices with route counts) ─────────────────
router.get('/routers/overview', async (_req: Request, res: Response) => {
router.get('/routers/overview', async (req: Request, res: Response) => {
const siteFilter = siteScopeDevices(activeSite(req), 'd');
const routers = await query(`
SELECT d.id, d.name, d.ip_address, d.model, d.device_type, d.status, d.last_seen,
d.ros_version, d.firmware_version, d.serial_number, d.rack_name, d.rack_slot,
@@ -116,7 +120,7 @@ router.get('/routers/overview', async (_req: Request, res: Response) => {
LEFT JOIN interfaces i ON i.device_id = d.id
AND (i.type ILIKE 'ether%' OR i.type ILIKE 'sfp%'
OR i.name ILIKE 'ether%' OR i.name ILIKE 'sfp%')
WHERE d.device_type = 'router'
WHERE d.device_type = 'router' ${siteFilter ? `AND ${siteFilter}` : ''}
GROUP BY d.id
ORDER BY d.name ASC
`);
@@ -280,6 +284,8 @@ router.get('/discovered', async (_req: Request, res: Response) => {
router.post('/', requireWrite, async (req: Request, res: Response) => {
const result = await createDeviceFromBody(req.body, pollerService, {
requestingUserRole: req.user?.role,
// A device added while viewing a site joins that site (issue #130).
siteId: activeSite(req),
});
return res.status(result.status).json(result.body);
});
@@ -310,7 +316,7 @@ router.post('/bulk-add/jobs', requireWrite, async (req: Request, res: Response)
BULK_ADD_META_TTL_SEC
);
await redis.set(`device-bulk-add:${jobId}:results`, '[]', 'EX', BULK_ADD_META_TTL_SEC);
await enqueueBulkAddJob(jobId, items as CreateDeviceInput[]);
await enqueueBulkAddJob(jobId, items as CreateDeviceInput[], activeSite(req));
return res.status(202).json({ job_id: jobId, total: items.length });
});
@@ -348,11 +354,10 @@ router.get('/bulk-add/jobs/:jobId', requireWrite, async (req: Request, res: Resp
// GET /api/devices/:id
router.get('/:id', async (req: Request, res: Response) => {
const device = await queryOne(
`SELECT id, name, ip_address, api_port, api_username, ssh_port, ssh_username, model,
serial_number, firmware_version, ros_version, latest_ros_version,
`SELECT ${DEVICE_BASE_COLUMNS},
ssh_port, ssh_username, latest_ros_version,
firmware_update_available, routerboard_upgrade_available, upgrade_firmware_version,
device_type, status, last_seen,
notes, location_address,
location_address,
location_lat::float8 AS location_lat,
location_lng::float8 AS location_lng,
rack_name, rack_slot, wifi_role, has_lte, created_at, updated_at
@@ -391,9 +396,8 @@ router.patch('/:id/location', requireWrite, async (req: Request, res: Response)
);
const updated = await queryOne(
`SELECT id, name, ip_address, api_port, api_username, ssh_port, ssh_username, model,
serial_number, firmware_version, ros_version, device_type, status, last_seen, wifi_role,
notes, location_address,
`SELECT ${DEVICE_BASE_COLUMNS},
ssh_port, ssh_username, location_address,
location_lat::float8 AS location_lat,
location_lng::float8 AS location_lng,
rack_name, rack_slot, wifi_role, has_lte, created_at, updated_at
@@ -486,9 +490,7 @@ router.put('/:id', requireWrite, async (req: Request, res: Response) => {
);
const updated = await queryOne(
`SELECT id, name, ip_address, api_port, api_username, model, serial_number,
firmware_version, ros_version, device_type, status, last_seen, notes
FROM devices WHERE id = $1`,
`SELECT ${DEVICE_BASE_COLUMNS} FROM devices WHERE id = $1`,
[req.params.id]
);
return res.json(updated);
+14 -2
View File
@@ -1,6 +1,8 @@
import { Router, Request, Response } from 'express';
import { query } from '../config/database';
import { requireAuth, requireWrite } from '../middleware/auth';
import { siteScopeByNullableDevice } from '../utils/siteScope';
import { activeSite } from '../middleware/site';
const router = Router();
router.use(requireAuth);
@@ -14,7 +16,8 @@ router.use(requireAuth);
* string. Splitting server-side keeps the badge list honest across the entire table
* instead of only the page currently loaded (issue #108).
*/
router.get('/topics', async (_req: Request, res: Response) => {
router.get('/topics', async (req: Request, res: Response) => {
const siteFilter = siteScopeByNullableDevice(activeSite(req), 'device_id');
const rows = await query<{ token: string; n: string }>(`
SELECT token, COUNT(*) AS n
FROM (
@@ -22,6 +25,7 @@ router.get('/topics', async (_req: Request, res: Response) => {
FROM events
WHERE topic IS NOT NULL AND topic <> ''
AND event_time > NOW() - INTERVAL '30 days'
${siteFilter ? `AND ${siteFilter}` : ''}
) t
WHERE btrim(token) <> ''
GROUP BY token
@@ -49,6 +53,12 @@ router.get('/', async (req: Request, res: Response) => {
filters.push(`e.device_id = $${idx++}`);
params.push(deviceId);
}
// Events with no device belong to no site, so they are hidden while a site is
// selected rather than repeated into every one of them (issue #130).
const siteFilter = siteScopeByNullableDevice(activeSite(req), 'e.device_id');
if (siteFilter) filters.push(siteFilter);
// Same predicate for the unaliased critical-count query below.
const siteFilterBare = siteScopeByNullableDevice(activeSite(req), 'device_id');
if (severity) {
// Accept comma-separated list e.g. "error,warning"
// 'error' also includes 'critical' rows
@@ -95,7 +105,9 @@ router.get('/', async (req: Request, res: Response) => {
params
),
query<{ count: string }>(
`SELECT COUNT(*) as count FROM events WHERE severity IN ('error','critical') AND event_time > NOW() - INTERVAL '24 hours'`
`SELECT COUNT(*) as count FROM events
WHERE severity IN ('error','critical') AND event_time > NOW() - INTERVAL '24 hours'
${siteFilterBare ? `AND ${siteFilterBare}` : ''}`
),
]);
+24 -6
View File
@@ -1,6 +1,8 @@
import { Router, Request, Response } from 'express';
import { query, queryOne } from '../config/database';
import { requireAuth, requireWrite } from '../middleware/auth';
import { siteScopeDevices, siteScopeByDevice } from '../utils/siteScope';
import { activeSite } from '../middleware/site';
import { DeviceCollector, DeviceRow } from '../services/mikrotik/DeviceCollector';
import { firmwareOrchestrator } from '../services/FirmwareOrchestrator';
@@ -8,13 +10,22 @@ const router = Router();
router.use(requireAuth);
// GET /api/firmware/overview — fleet versions + latest rollout
router.get('/overview', async (_req: Request, res: Response) => {
router.get('/overview', async (req: Request, res: Response) => {
const siteFilter = siteScopeDevices(activeSite(req));
const [devices, latestRollout] = await Promise.all([
query(`SELECT id, name, device_type, status, model, ros_version, latest_ros_version,
firmware_update_available, firmware_version, upgrade_firmware_version,
routerboard_upgrade_available
FROM devices ORDER BY name ASC`),
queryOne<{ id: number }>(`SELECT id FROM firmware_rollouts ORDER BY created_at DESC LIMIT 1`),
FROM devices ${siteFilter ? `WHERE ${siteFilter}` : ''} ORDER BY name ASC`),
// The banner links to this rollout, so it must be one this site took part in.
queryOne<{ id: number }>(`
SELECT r.id FROM firmware_rollouts r
${siteScopeByDevice(activeSite(req), 'frd.device_id')
? `WHERE EXISTS (SELECT 1 FROM firmware_rollout_devices frd
WHERE frd.rollout_id = r.id
AND ${siteScopeByDevice(activeSite(req), 'frd.device_id')})`
: ''}
ORDER BY r.created_at DESC LIMIT 1`),
]);
res.json({
devices,
@@ -24,8 +35,11 @@ router.get('/overview', async (_req: Request, res: Response) => {
});
// POST /api/firmware/check-all — refresh update availability on all online devices
router.post('/check-all', requireWrite, async (_req: Request, res: Response) => {
const devices = await query<DeviceRow>(`SELECT * FROM devices WHERE status='online'`);
router.post('/check-all', requireWrite, async (req: Request, res: Response) => {
const siteFilter = siteScopeDevices(activeSite(req));
const devices = await query<DeviceRow>(
`SELECT * FROM devices WHERE status='online' ${siteFilter ? `AND ${siteFilter}` : ''}`
);
const settled = await Promise.allSettled(devices.map(async (d) => {
const c = new DeviceCollector(d);
try {
@@ -81,7 +95,9 @@ router.post('/rollouts', requireWrite, async (req: Request, res: Response) => {
});
// GET /api/firmware/rollouts — recent rollouts with progress counts
router.get('/rollouts', async (_req: Request, res: Response) => {
router.get('/rollouts', async (req: Request, res: Response) => {
// A rollout is shown when it touched at least one device in the active site.
const memberFilter = siteScopeByDevice(activeSite(req), 'frd.device_id');
const rows = await query(`
SELECT r.*,
COUNT(d.id)::int AS device_count,
@@ -89,6 +105,8 @@ router.get('/rollouts', async (_req: Request, res: Response) => {
COUNT(d.id) FILTER (WHERE d.status = 'failed')::int AS failed_count
FROM firmware_rollouts r
LEFT JOIN firmware_rollout_devices d ON d.rollout_id = r.id
${memberFilter ? `WHERE EXISTS (SELECT 1 FROM firmware_rollout_devices frd
WHERE frd.rollout_id = r.id AND ${memberFilter})` : ''}
GROUP BY r.id ORDER BY r.created_at DESC LIMIT 20`);
res.json(rows);
});
+77 -28
View File
@@ -2,6 +2,8 @@ import { Router, Request, Response } from 'express';
import { getQueryApi, bucket } from '../config/influxdb';
import { query } from '../config/database';
import { requireAuth } from '../middleware/auth';
import { siteScopeDevices, siteScopeByDevice, siteScopeByNullableDevice, clientSeriesTag } from '../utils/siteScope';
import { activeSite } from '../middleware/site';
const router = Router();
router.use(requireAuth);
@@ -15,6 +17,26 @@ function rangeToFlux(range: string): string {
router.get('/clients-over-time', async (req: Request, res: Response) => {
const range = rangeToFlux(String(req.query.range || '24h'));
const queryApi = getQueryApi();
// Per-site deduplicated series when a site is selected, fleet-wide otherwise.
// Written by DeviceCollector alongside the global series (issue #130).
//
// A site holding the entire fleet is a special case worth taking: its total
// *is* the global total, by definition rather than by approximation. Using
// the global series there is not a shortcut -- it keeps the chart's full
// history, which the per-site series only accumulates from the upgrade
// onward. That covers every single-site install.
const siteId = activeSite(req);
let seriesTag = '_global';
if (siteId !== null) {
const counts = await query<{ in_site: number; total: number }>(
`SELECT COUNT(*) FILTER (WHERE site_id = $1)::int AS in_site,
COUNT(*)::int AS total
FROM devices`,
[siteId]
);
const { in_site = 0, total = 0 } = counts[0] ?? {};
seriesTag = clientSeriesTag(siteId, in_site, total);
}
// Use the global deduplicated metric (_global tag) written by DeviceCollector.
// This avoids double-counting clients seen by multiple devices simultaneously.
@@ -23,7 +45,7 @@ router.get('/clients-over-time', async (req: Request, res: Response) => {
|> range(start: -${range})
|> filter(fn: (r) => r._measurement == "client_counts")
|> filter(fn: (r) => r._field == "total_clients")
|> filter(fn: (r) => r.device_id == "_global")
|> filter(fn: (r) => r.device_id == "${seriesTag}")
|> aggregateWindow(every: 5m, fn: last, createEmpty: false)
|> yield(name: "clients_over_time")
`;
@@ -39,29 +61,48 @@ router.get('/clients-over-time', async (req: Request, res: Response) => {
// InfluxDB might not have data yet
}
// If no global metric exists yet (first run or old data), fall back to max across devices.
// max() is better than sum() since the device with the most clients is the gateway that
// sees everyone — summing would double-count clients visible from multiple devices.
// If no deduplicated series exists yet (first run, or a site whose history
// predates the per-site series), fall back to max across real devices. max()
// is better than sum() since the device with the most clients is the gateway
// that sees everyone — summing would double-count clients visible from
// multiple devices.
if (rawPoints.length === 0) {
const fallbackQuery = `
from(bucket: "${bucket}")
|> range(start: -${range})
|> filter(fn: (r) => r._measurement == "client_counts")
|> filter(fn: (r) => r._field == "total_clients")
|> filter(fn: (r) => r.device_id != "_global")
|> aggregateWindow(every: 5m, fn: max, createEmpty: false)
|> group()
|> aggregateWindow(every: 5m, fn: max, createEmpty: false)
|> yield(name: "clients_over_time_fallback")
`;
try {
await queryApi.collectRows(fallbackQuery, (row, tableMeta) => {
const time = tableMeta.get(row, '_time') as string;
const value = tableMeta.get(row, '_value') as number;
rawPoints.push({ time, value: Math.round(value) });
});
} catch {
// No data yet
// The synthetic series are tagged _global and _site_<id>; the leading
// underscore is what distinguishes them from real device ids. Matching on
// it matters: without it the fleet-wide fallback would fold the per-site
// totals back in and count everyone twice.
let deviceFilter: string | null = `|> filter(fn: (r) => not (r.device_id =~ /^_/))`;
if (siteId !== null) {
const ids = await query<{ id: number }>(
`SELECT id FROM devices WHERE site_id = $1`, [siteId]
);
// An empty site has no series to fall back to, and must show an empty
// chart rather than the fleet's (issue #130).
deviceFilter = ids.length
? `|> filter(fn: (r) => ${ids.map((d) => `r.device_id == "${d.id}"`).join(' or ')})`
: null;
}
if (deviceFilter) {
const fallbackQuery = `
from(bucket: "${bucket}")
|> range(start: -${range})
|> filter(fn: (r) => r._measurement == "client_counts")
|> filter(fn: (r) => r._field == "total_clients")
${deviceFilter}
|> aggregateWindow(every: 5m, fn: max, createEmpty: false)
|> group()
|> aggregateWindow(every: 5m, fn: max, createEmpty: false)
|> yield(name: "clients_over_time_fallback")
`;
try {
await queryApi.collectRows(fallbackQuery, (row, tableMeta) => {
const time = tableMeta.get(row, '_time') as string;
const value = tableMeta.get(row, '_value') as number;
rawPoints.push({ time, value: Math.round(value) });
});
} catch {
// No data yet
}
}
}
@@ -72,6 +113,7 @@ router.get('/clients-over-time', async (req: Request, res: Response) => {
// GET /api/metrics/top-clients?limit=10&range=24h
router.get('/top-clients', async (req: Request, res: Response) => {
const limit = Math.min(parseInt(String(req.query.limit || '10'), 10), 50);
const topFilter = siteScopeByDevice(activeSite(req), 'c.device_id');
// Use postgres data (most recent client data)
const clients = await query(
@@ -80,6 +122,7 @@ router.get('/top-clients', async (req: Request, res: Response) => {
c.tx_bytes, c.rx_bytes, c.client_type, d.name as device_name
FROM clients c JOIN devices d ON d.id = c.device_id
WHERE c.active = TRUE AND (c.tx_bytes + c.rx_bytes) > 0
${topFilter ? `AND ${topFilter}` : ''}
ORDER BY total_bytes DESC LIMIT $1`,
[limit]
);
@@ -308,30 +351,36 @@ router.get('/device/:deviceId/availability', async (req: Request, res: Response)
});
// GET /api/metrics/summary - dashboard summary stats
router.get('/summary', async (_req: Request, res: Response) => {
router.get('/summary', async (req: Request, res: Response) => {
const siteId = activeSite(req);
const devFilter = siteScopeDevices(siteId);
const cliFilter = siteScopeByDevice(siteId, 'device_id');
const evtFilter = siteScopeByNullableDevice(siteId, 'device_id');
const [deviceStats, clientStats, alertStats, availStats] = await Promise.all([
query<{ total: string; online: string; offline: string }>(
`SELECT
COUNT(*) as total,
COUNT(*) FILTER (WHERE status='online') as online,
COUNT(*) FILTER (WHERE status='offline') as offline
FROM devices`
FROM devices ${devFilter ? `WHERE ${devFilter}` : ''}`
),
query<{ total: string; active: string }>(
`SELECT COUNT(DISTINCT mac_address) as total,
COUNT(DISTINCT mac_address) FILTER (WHERE active=TRUE) as active
FROM clients`
FROM clients ${cliFilter ? `WHERE ${cliFilter}` : ''}`
),
query<{ critical: string; warning: string }>(
`SELECT
COUNT(*) FILTER (WHERE severity='error') as critical,
COUNT(*) FILTER (WHERE severity='warning') as warning
FROM events WHERE event_time > NOW() - INTERVAL '24 hours'`
FROM events WHERE event_time > NOW() - INTERVAL '24 hours'
${evtFilter ? `AND ${evtFilter}` : ''}`
),
query<{ total_outage_sec: string }>(
`SELECT COALESCE(SUM(COALESCE(duration_seconds, 0)), 0)::text AS total_outage_sec
FROM device_availability
WHERE went_offline_at > NOW() - INTERVAL '30 days'`
WHERE went_offline_at > NOW() - INTERVAL '30 days'
${cliFilter ? `AND ${cliFilter}` : ''}`
),
]);
+14 -4
View File
@@ -1,6 +1,8 @@
import { Router, Request, Response } from 'express';
import { query } from '../config/database';
import { requireAuth, requireWrite } from '../middleware/auth';
import { siteScopeDevices } from '../utils/siteScope';
import { activeSite } from '../middleware/site';
import { DeviceCollector, DeviceRow } from '../services/mikrotik/DeviceCollector';
import { netflowCollector } from '../services/netflow/NetflowCollector';
@@ -37,8 +39,12 @@ function deviceIdParam(req: Request, res: Response): number | null {
// ─── Overview (all online devices, all services) ──────────────────────────────
router.get('/overview', async (_req: Request, res: Response) => {
const devices = await query<DeviceRow>(`SELECT * FROM devices WHERE status = 'online' ORDER BY name`);
router.get('/overview', async (req: Request, res: Response) => {
const siteFilter = siteScopeDevices(activeSite(req));
const devices = await query<DeviceRow>(
`SELECT * FROM devices WHERE status = 'online'
${siteFilter ? `AND ${siteFilter}` : ''} ORDER BY name`
);
const results = await Promise.allSettled(
devices.map(async (device: DeviceRow) => {
@@ -520,11 +526,15 @@ function targetMatchesCollector(
}
// GET /api/network-services/netflow/fleet — traffic-flow state for all online devices
router.get('/netflow/fleet', async (_req: Request, res: Response) => {
router.get('/netflow/fleet', async (req: Request, res: Response) => {
const settings = await getNetflowAppSettings();
const stats = netflowCollector.getStats();
const statsByDevice = new Map(stats.exporters.map((e) => [e.deviceId, e]));
const devices = await query<DeviceRow>(`SELECT * FROM devices WHERE status = 'online' ORDER BY name`);
const siteFilter = siteScopeDevices(activeSite(req));
const devices = await query<DeviceRow>(
`SELECT * FROM devices WHERE status = 'online'
${siteFilter ? `AND ${siteFilter}` : ''} ORDER BY name`
);
const results = await Promise.allSettled(
devices.map(async (device: DeviceRow) => {
+60 -14
View File
@@ -2,6 +2,8 @@ import { Router, Request, Response } from 'express';
import { query } from '../config/database';
import { getQueryApi } from '../config/influxdb';
import { requireAuth, requireWrite } from '../middleware/auth';
import { siteScopeDevices, siteScopeByDevice, siteScopeByNullableDevice } from '../utils/siteScope';
import { activeSite } from '../middleware/site';
import { DeviceCollector, DeviceRow } from '../services/mikrotik/DeviceCollector';
import { BackupService, BackupDevice } from '../services/BackupService';
@@ -99,7 +101,13 @@ async function latestRetries(): Promise<{ device_id: string; device_name: string
// GET /api/operations/insights — aggregated operational intelligence (no live
// device connections; all from Postgres + InfluxDB so it's fast).
router.get('/insights', async (_req: Request, res: Response) => {
router.get('/insights', async (req: Request, res: Response) => {
const siteId = activeSite(req);
const devFilter = siteScopeDevices(siteId);
const devFilterD = siteScopeDevices(siteId, 'd');
const cliFilter = siteScopeByDevice(siteId, 'device_id');
const availFilter = siteScopeByDevice(siteId, 'a.device_id');
const evtFilter = siteScopeByNullableDevice(siteId, 'device_id');
const [
devices, missingBackup, recentOutages, weakWifi, coChannel, eventCounts,
] = await Promise.all([
@@ -112,30 +120,35 @@ router.get('/insights', async (_req: Request, res: Response) => {
}>(`SELECT id, name, status, last_seen, device_type, ros_version,
firmware_update_available, latest_ros_version,
routerboard_upgrade_available, firmware_version, upgrade_firmware_version
FROM devices`),
FROM devices ${devFilter ? `WHERE ${devFilter}` : ''}`),
query<{ id: number; name: string; last_backup: string | null }>(`
SELECT d.id, d.name, MAX(b.created_at) AS last_backup
FROM devices d LEFT JOIN backups b ON b.device_id = d.id
${devFilterD ? `WHERE ${devFilterD}` : ''}
GROUP BY d.id
HAVING MAX(b.created_at) IS NULL OR MAX(b.created_at) < NOW() - INTERVAL '7 days'`),
query<{ device_id: number; name: string; outages: string }>(`
SELECT a.device_id, d.name, COUNT(*) AS outages
FROM device_availability a JOIN devices d ON d.id = a.device_id
WHERE a.went_offline_at > NOW() - INTERVAL '7 days'
${availFilter ? `AND ${availFilter}` : ''}
GROUP BY a.device_id, d.name`),
query<{ count: string }>(`
SELECT COUNT(*) AS count FROM clients
WHERE active = TRUE AND client_type = 'wireless'
AND signal_strength IS NOT NULL AND signal_strength < -75`),
AND signal_strength IS NOT NULL AND signal_strength < -75
${cliFilter ? `AND ${cliFilter}` : ''}`),
query<{ frequency: number; radios: string }>(`
SELECT frequency, COUNT(*) AS radios FROM wireless_interfaces
WHERE disabled = FALSE AND frequency IS NOT NULL AND frequency > 0
AND (config_json->>'master-interface') IS NULL
${cliFilter ? `AND ${cliFilter}` : ''}
GROUP BY frequency HAVING COUNT(*) > 1`),
query<{ errors: string; warnings: string }>(`
SELECT COUNT(*) FILTER (WHERE severity = 'error') AS errors,
COUNT(*) FILTER (WHERE severity = 'warning') AS warnings
FROM events WHERE event_time > NOW() - INTERVAL '24 hours'`),
FROM events WHERE event_time > NOW() - INTERVAL '24 hours'
${evtFilter ? `AND ${evtFilter}` : ''}`),
]);
const [resources, retries] = await Promise.all([latestResources(), latestRetries()]);
@@ -266,7 +279,10 @@ router.get('/insights', async (_req: Request, res: Response) => {
}
// WiFi — high TX retries
const badRetries = retries.filter(r => r.pct >= 15);
// retries comes from Influx across the whole fleet; keep only radios on
// devices in this site.
const scopedIds = new Set(devices.map((d) => String(d.id)));
const badRetries = retries.filter(r => r.pct >= 15 && scopedIds.has(r.device_id));
for (const r of badRetries) {
attention.push({
sev: 'warn', category: 'wifi',
@@ -294,6 +310,7 @@ router.get('/insights', async (_req: Request, res: Response) => {
query<{ device_id: number; device_name: string; scanned_at: string; data: unknown }>(`
SELECT DISTINCT ON (a.device_id) a.device_id, d.name AS device_name, a.scanned_at, a.data
FROM ap_scan_data a JOIN devices d ON d.id = a.device_id
${siteScopeByDevice(siteId, 'a.device_id') ? `WHERE ${siteScopeByDevice(siteId, 'a.device_id')}` : ''}
ORDER BY a.device_id, a.scanned_at DESC`),
query<{ ssid: string | null; mac_address: string | null }>(`SELECT ssid, mac_address FROM wireless_interfaces`),
query<{ mac_address: string }>(`SELECT mac_address FROM interfaces WHERE mac_address IS NOT NULL`),
@@ -329,6 +346,7 @@ router.get('/insights', async (_req: Request, res: Response) => {
FROM device_config_findings f
JOIN devices d ON d.id = f.device_id
WHERE f.severity IN ('critical', 'warning')
${siteScopeByDevice(siteId, 'f.device_id') ? `AND ${siteScopeByDevice(siteId, 'f.device_id')}` : ''}
ORDER BY f.device_id, CASE f.severity WHEN 'critical' THEN 0 ELSE 1 END, f.rule`);
const seen = new Set<string>();
@@ -350,7 +368,7 @@ router.get('/insights', async (_req: Request, res: Response) => {
} catch { /* findings table unavailable */ }
// Baseline anomalies (client counts, CPU, error bursts)
const anomalies = await detectAnomalies();
const anomalies = await detectAnomalies(siteId);
for (const a of anomalies) attention.push(a);
// Dismissals (#102). Expired rows are cleared on read, so a dismissal lapses
@@ -370,7 +388,7 @@ router.get('/insights', async (_req: Request, res: Response) => {
visible.sort((a, b) => sevRank[a.sev] - sevRank[b.sev]);
// Activity feed — recent config changes, user actions, and notable events
const activity = await buildActivity();
const activity = await buildActivity(siteId);
res.json({ attention: visible, dismissedCount: attention.length - visible.length, capacity, activity });
});
@@ -436,11 +454,16 @@ async function influxGroupValues(flux: string): Promise<Map<string, number>> {
return out;
}
async function detectAnomalies(): Promise<AttentionItem[]> {
async function detectAnomalies(siteId: number | null): Promise<AttentionItem[]> {
const items: AttentionItem[] = [];
const hour = new Date().getUTCHours();
const nameById = new Map<string, { id: number; name: string }>();
const devices = await query<{ id: number; name: string }>(`SELECT id, name FROM devices`);
const devFilter = siteScopeDevices(siteId);
// Influx series are matched back to this map by device id, so scoping the map
// scopes the metric anomalies too -- an unknown id is skipped.
const devices = await query<{ id: number; name: string }>(
`SELECT id, name FROM devices ${devFilter ? `WHERE ${devFilter}` : ''}`
);
for (const d of devices) nameById.set(String(d.id), d);
const metric = async (measurement: string, field: string) => {
@@ -504,6 +527,7 @@ async function detectAnomalies(): Promise<AttentionItem[]> {
WITH cur AS (
SELECT device_id, COUNT(*) AS n FROM events
WHERE severity = 'error' AND event_time > NOW() - INTERVAL '1 hour'
${siteScopeByNullableDevice(siteId, 'device_id') ? `AND ${siteScopeByNullableDevice(siteId, 'device_id')}` : ''}
GROUP BY device_id
), hist AS (
SELECT device_id, COUNT(*) / 168.0 AS avg_hourly FROM events
@@ -527,13 +551,22 @@ async function detectAnomalies(): Promise<AttentionItem[]> {
}
// Recent activity merged from config snapshots, audit log, and events.
async function buildActivity() {
//
// When a site is selected the audit log is left out entirely. Audit rows record
// who did what to the install -- they carry no device and so belong to no site,
// and repeating "admin changed a setting" into every site would misattribute it.
// Device-scoped history (config changes, events) is filtered normally.
async function buildActivity(siteId: number | null) {
const cfgFilter = siteScopeByDevice(siteId, 'dc.device_id');
const evtFilter = siteScopeByNullableDevice(siteId, 'e.device_id');
const [configs, audits, events] = await Promise.all([
query<{ at: string; name: string; summary: string | null }>(`
SELECT dc.collected_at AS at, d.name, dc.change_summary AS summary
FROM device_configs dc JOIN devices d ON d.id = dc.device_id
WHERE dc.change_summary IS NOT NULL AND dc.change_summary != ''
${cfgFilter ? `AND ${cfgFilter}` : ''}
ORDER BY dc.collected_at DESC LIMIT 8`),
siteId !== null ? Promise.resolve([]) :
query<{ at: string; username: string | null; method: string; summary: string | null }>(`
SELECT created_at AS at, username, method, summary
FROM audit_log
@@ -544,6 +577,7 @@ async function buildActivity() {
SELECT e.event_time AS at, e.severity, e.message, d.name
FROM events e LEFT JOIN devices d ON d.id = e.device_id
WHERE e.severity IN ('error', 'warning')
${evtFilter ? `AND ${evtFilter}` : ''}
ORDER BY e.event_time DESC LIMIT 8`),
]);
@@ -557,8 +591,14 @@ async function buildActivity() {
}
// POST /api/operations/backup-all — back up every online device (parallel, best-effort)
router.post('/backup-all', requireWrite, async (_req: Request, res: Response) => {
const devices = await query<DeviceRow>(`SELECT * FROM devices WHERE status = 'online'`);
router.post('/backup-all', requireWrite, async (req: Request, res: Response) => {
// Scoped to the active site. A bulk action that silently spans customers is
// exactly the blast radius the bulk-command guards exist to contain (#130).
const siteFilter = siteScopeDevices(activeSite(req));
const devices = await query<DeviceRow>(
`SELECT * FROM devices WHERE status = 'online'
${siteFilter ? `AND ${siteFilter}` : ''}`
);
const settled = await Promise.allSettled(devices.map(d => {
const dev: BackupDevice = {
id: d.id, name: d.name, ip_address: d.ip_address,
@@ -579,8 +619,14 @@ router.post('/backup-all', requireWrite, async (_req: Request, res: Response) =>
});
// POST /api/operations/sync-all — pull latest config/state from every online device
router.post('/sync-all', requireWrite, async (_req: Request, res: Response) => {
const devices = await query<DeviceRow>(`SELECT * FROM devices WHERE status = 'online'`);
router.post('/sync-all', requireWrite, async (req: Request, res: Response) => {
// Scoped to the active site. A bulk action that silently spans customers is
// exactly the blast radius the bulk-command guards exist to contain (#130).
const siteFilter = siteScopeDevices(activeSite(req));
const devices = await query<DeviceRow>(
`SELECT * FROM devices WHERE status = 'online'
${siteFilter ? `AND ${siteFilter}` : ''}`
);
const settled = await Promise.allSettled(devices.map(async (d) => {
const c = new DeviceCollector(d);
try { await c.connect(); await c.collectAll(); }
+10 -4
View File
@@ -1,15 +1,19 @@
import { Router, Request, Response } from 'express';
import { query } from '../config/database';
import { requireAuth, requireWrite } from '../middleware/auth';
import { siteScopeDevices } from '../utils/siteScope';
import { activeSite } from '../middleware/site';
import { DeviceCollector, DeviceRow } from '../services/mikrotik/DeviceCollector';
const router = Router();
router.use(requireAuth);
// GET /api/routers/lldp — LLDP enabled/disabled status per online router
router.get('/lldp', async (_req: Request, res: Response) => {
router.get('/lldp', async (req: Request, res: Response) => {
const siteFilter = siteScopeDevices(activeSite(req));
const routers = await query<DeviceRow>(
`SELECT * FROM devices WHERE device_type = 'router' AND status = 'online'`
`SELECT * FROM devices WHERE device_type = 'router' AND status = 'online'
${siteFilter ? `AND ${siteFilter}` : ''}`
);
const results = await Promise.allSettled(
@@ -82,9 +86,11 @@ router.put('/lldp', requireWrite, async (req: Request, res: Response) => {
});
// GET /api/routers/snmp — SNMP config/status per online router
router.get('/snmp', async (_req: Request, res: Response) => {
router.get('/snmp', async (req: Request, res: Response) => {
const siteFilter = siteScopeDevices(activeSite(req));
const routers = await query<DeviceRow>(
`SELECT * FROM devices WHERE device_type = 'router' AND status = 'online'`
`SELECT * FROM devices WHERE device_type = 'router' AND status = 'online'
${siteFilter ? `AND ${siteFilter}` : ''}`
);
const results = await Promise.allSettled(
+12 -3
View File
@@ -1,6 +1,8 @@
import { Router, Request, Response } from 'express';
import { query } from '../config/database';
import { requireAuth } from '../middleware/auth';
import { siteScopeDevices, siteScopeByNullableDevice } from '../utils/siteScope';
import { activeSite } from '../middleware/site';
const router = Router();
router.use(requireAuth);
@@ -13,12 +15,17 @@ router.get('/', async (req: Request, res: Response) => {
}
const like = `%${q}%`;
const siteId = activeSite(req);
const devFilter = siteScopeDevices(siteId);
const clientFilter = siteScopeByNullableDevice(siteId, 'c.device_id');
const eventFilter = siteScopeByNullableDevice(siteId, 'e.device_id');
const [devices, clients, events] = await Promise.all([
query(
`SELECT id, name, ip_address, model, device_type, status
FROM devices
WHERE name ILIKE $1 OR ip_address ILIKE $1 OR model ILIKE $1 OR serial_number ILIKE $1
WHERE (name ILIKE $1 OR ip_address ILIKE $1 OR model ILIKE $1 OR serial_number ILIKE $1)
${devFilter ? `AND ${devFilter}` : ''}
ORDER BY (status = 'online') DESC, name ASC
LIMIT 6`,
[like]
@@ -28,7 +35,8 @@ router.get('/', async (req: Request, res: Response) => {
d.name as device_name
FROM clients c
LEFT JOIN devices d ON d.id = c.device_id
WHERE c.mac_address ILIKE $1 OR c.hostname ILIKE $1 OR c.ip_address ILIKE $1
WHERE (c.mac_address ILIKE $1 OR c.hostname ILIKE $1 OR c.ip_address ILIKE $1)
${clientFilter ? `AND ${clientFilter}` : ''}
ORDER BY c.active DESC, c.last_seen DESC
LIMIT 6`,
[like]
@@ -38,7 +46,8 @@ router.get('/', async (req: Request, res: Response) => {
d.name as device_name
FROM events e
LEFT JOIN devices d ON d.id = e.device_id
WHERE e.message ILIKE $1 OR e.topic ILIKE $1 OR d.name ILIKE $1
WHERE (e.message ILIKE $1 OR e.topic ILIKE $1 OR d.name ILIKE $1)
${eventFilter ? `AND ${eventFilter}` : ''}
ORDER BY e.event_time DESC
LIMIT 6`,
[like]
+183
View File
@@ -0,0 +1,183 @@
import { Router, Request, Response } from 'express';
import { query, queryOne } from '../config/database';
import { requireAuth, requireWrite } from '../middleware/auth';
const router = Router();
router.use(requireAuth);
const MAX_NAME = 120;
export interface SiteRow {
id: number;
name: string;
address: string | null;
location_lat: number | null;
location_lng: number | null;
notes: string | null;
is_default: boolean;
created_at: string;
}
/** Shared validation so create and rename cannot drift apart. */
function normaliseName(raw: unknown): { name?: string; error?: string } {
const name = typeof raw === 'string' ? raw.trim() : '';
if (!name) return { error: 'Site name is required' };
if (name.length > MAX_NAME) return { error: `Site name must be ${MAX_NAME} characters or fewer` };
return { name };
}
/** Coordinates are optional, but a half-pair is a bug rather than a location. */
function normaliseCoords(lat: unknown, lng: unknown): { lat: number | null; lng: number | null } {
const latN = lat === null || lat === undefined || lat === '' ? null : Number(lat);
const lngN = lng === null || lng === undefined || lng === '' ? null : Number(lng);
const ok = latN !== null && lngN !== null
&& Number.isFinite(latN) && Number.isFinite(lngN)
&& Math.abs(latN) <= 90 && Math.abs(lngN) <= 180;
return ok ? { lat: latN, lng: lngN } : { lat: null, lng: null };
}
// GET /api/sites — list with device counts, for the selector and the all-sites table
router.get('/', async (_req: Request, res: Response) => {
const sites = await query<SiteRow & { device_count: string }>(
`SELECT s.id, s.name, s.address,
s.location_lat::float8 AS location_lat,
s.location_lng::float8 AS location_lng,
s.notes, s.is_default, s.created_at,
COUNT(d.id)::text AS device_count,
COUNT(d.id) FILTER (WHERE d.status = 'online')::text AS online_count
FROM sites s
LEFT JOIN devices d ON d.site_id = s.id
GROUP BY s.id
ORDER BY s.is_default DESC, LOWER(s.name) ASC`
);
res.json(sites.map((s) => ({
...s,
device_count: parseInt(s.device_count, 10),
online_count: parseInt((s as unknown as { online_count: string }).online_count, 10),
})));
});
// POST /api/sites — create
router.post('/', requireWrite, async (req: Request, res: Response) => {
const { name, error } = normaliseName((req.body as { name?: string }).name);
if (error) { res.status(400).json({ error }); return; }
const { address, notes } = req.body as { address?: string; notes?: string };
const { lat, lng } = normaliseCoords(
(req.body as { location_lat?: unknown }).location_lat,
(req.body as { location_lng?: unknown }).location_lng
);
const existing = await queryOne<{ id: number }>(
`SELECT id FROM sites WHERE LOWER(name) = LOWER($1)`, [name]
);
if (existing) { res.status(409).json({ error: 'A site with that name already exists' }); return; }
const rows = await query<SiteRow>(
`INSERT INTO sites (name, address, location_lat, location_lng, notes)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, name, address, location_lat::float8 AS location_lat,
location_lng::float8 AS location_lng, notes, is_default, created_at`,
[name, address?.trim() || null, lat, lng, notes?.trim() || null]
);
res.status(201).json({ ...rows[0], device_count: 0, online_count: 0 });
});
// PUT /api/sites/:id — rename / set address / notes
router.put('/:id', requireWrite, async (req: Request, res: Response) => {
const id = parseInt(req.params.id, 10);
if (!Number.isInteger(id)) { res.status(400).json({ error: 'Invalid site id' }); return; }
const body = req.body as {
name?: string; address?: string; notes?: string;
location_lat?: unknown; location_lng?: unknown;
};
let name: string | null = null;
if (body.name !== undefined) {
const parsed = normaliseName(body.name);
if (parsed.error) { res.status(400).json({ error: parsed.error }); return; }
name = parsed.name!;
const clash = await queryOne<{ id: number }>(
`SELECT id FROM sites WHERE LOWER(name) = LOWER($1) AND id <> $2`, [name, id]
);
if (clash) { res.status(409).json({ error: 'A site with that name already exists' }); return; }
}
// Address and coordinates travel together: changing the address without
// re-geocoding would leave the pin on the old location, which is worse than
// no pin at all.
const addressGiven = body.address !== undefined;
const { lat, lng } = normaliseCoords(body.location_lat, body.location_lng);
const rows = await query<SiteRow>(
`UPDATE sites SET
name = COALESCE($1, name),
address = CASE WHEN $2::boolean THEN $3 ELSE address END,
location_lat = CASE WHEN $2::boolean THEN $4 ELSE location_lat END,
location_lng = CASE WHEN $2::boolean THEN $5 ELSE location_lng END,
notes = COALESCE($6, notes),
updated_at = NOW()
WHERE id = $7
RETURNING id, name, address, location_lat::float8 AS location_lat,
location_lng::float8 AS location_lng, notes, is_default, created_at`,
[name, addressGiven, body.address?.trim() || null, lat, lng, body.notes?.trim() ?? null, id]
);
if (!rows[0]) { res.status(404).json({ error: 'Site not found' }); return; }
res.json(rows[0]);
});
// DELETE /api/sites/:id — refused while devices remain, and never for the last site
router.delete('/:id', requireWrite, async (req: Request, res: Response) => {
const id = parseInt(req.params.id, 10);
if (!Number.isInteger(id)) { res.status(400).json({ error: 'Invalid site id' }); return; }
const site = await queryOne<{ id: number; name: string }>(`SELECT id, name FROM sites WHERE id = $1`, [id]);
if (!site) { res.status(404).json({ error: 'Site not found' }); return; }
// Deleting a site must never delete the devices in it. Make the operator move
// them first, so the destination is a decision rather than a side effect.
const count = await queryOne<{ n: string }>(
`SELECT COUNT(*)::text AS n FROM devices WHERE site_id = $1`, [id]
);
const devices = parseInt(count?.n ?? '0', 10);
if (devices > 0) {
res.status(409).json({
error: `"${site.name}" still contains ${devices} device${devices === 1 ? '' : 's'}. `
+ 'Move them to another site before deleting it.',
device_count: devices,
});
return;
}
const total = await queryOne<{ n: string }>(`SELECT COUNT(*)::text AS n FROM sites`);
if (parseInt(total?.n ?? '0', 10) <= 1) {
res.status(409).json({ error: 'Cannot delete the only site' });
return;
}
await query(`DELETE FROM sites WHERE id = $1`, [id]);
res.json({ ok: true });
});
// POST /api/sites/:id/devices — move devices into this site
router.post('/:id/devices', requireWrite, async (req: Request, res: Response) => {
const id = parseInt(req.params.id, 10);
if (!Number.isInteger(id)) { res.status(400).json({ error: 'Invalid site id' }); return; }
const { deviceIds } = req.body as { deviceIds: number[] };
if (!Array.isArray(deviceIds) || deviceIds.length === 0) {
res.status(400).json({ error: 'deviceIds array required' });
return;
}
const ids = deviceIds.map((d) => parseInt(String(d), 10)).filter(Number.isInteger);
if (ids.length === 0) { res.status(400).json({ error: 'deviceIds array required' }); return; }
const site = await queryOne<{ id: number }>(`SELECT id FROM sites WHERE id = $1`, [id]);
if (!site) { res.status(404).json({ error: 'Site not found' }); return; }
await query(`UPDATE devices SET site_id = $1 WHERE id = ANY($2::int[])`, [id, ids]);
res.json({ ok: true, moved: ids.length });
});
export default router;
+13 -6
View File
@@ -1,13 +1,16 @@
import { Router, Request, Response } from 'express';
import { query } from '../config/database';
import { requireAuth, requireWrite } from '../middleware/auth';
import { siteScopeDevices } from '../utils/siteScope';
import { activeSite } from '../middleware/site';
import { DeviceCollector, DeviceRow } from '../services/mikrotik/DeviceCollector';
const router = Router();
router.use(requireAuth);
// GET /api/switches — all switch devices with port statistics
router.get('/', async (_req: Request, res: Response) => {
router.get('/', async (req: Request, res: Response) => {
const siteFilter = siteScopeDevices(activeSite(req), 'd');
const switches = await query(`
SELECT d.id, d.name, d.ip_address, d.model, d.device_type, d.status, d.last_seen,
d.ros_version, d.firmware_version, d.serial_number, d.rack_name, d.rack_slot,
@@ -19,7 +22,7 @@ router.get('/', async (_req: Request, res: Response) => {
LEFT JOIN interfaces i ON i.device_id = d.id
AND (i.type ILIKE 'ether%' OR i.type ILIKE 'sfp%'
OR i.name ILIKE 'ether%' OR i.name ILIKE 'sfp%')
WHERE d.device_type = 'switch'
WHERE d.device_type = 'switch' ${siteFilter ? `AND ${siteFilter}` : ''}
GROUP BY d.id
ORDER BY d.name ASC
`);
@@ -27,9 +30,11 @@ router.get('/', async (_req: Request, res: Response) => {
});
// GET /api/switches/lldp — LLDP enabled/disabled status per online switch
router.get('/lldp', async (_req: Request, res: Response) => {
router.get('/lldp', async (req: Request, res: Response) => {
const siteFilter = siteScopeDevices(activeSite(req));
const switches = await query<DeviceRow>(
`SELECT * FROM devices WHERE device_type = 'switch' AND status = 'online'`
`SELECT * FROM devices WHERE device_type = 'switch' AND status = 'online'
${siteFilter ? `AND ${siteFilter}` : ''}`
);
const results = await Promise.allSettled(
@@ -104,9 +109,11 @@ router.put('/lldp', requireWrite, async (req: Request, res: Response) => {
// GET /api/switches/snmp — SNMP config/status for all managed switches
// Includes offline/unknown devices so the list never disappears during a poll cycle.
// Unreachable devices fall through to the Promise.allSettled error path and render with an error note.
router.get('/snmp', async (_req: Request, res: Response) => {
router.get('/snmp', async (req: Request, res: Response) => {
const siteFilter = siteScopeDevices(activeSite(req));
const switches = await query<DeviceRow>(
`SELECT * FROM devices WHERE device_type = 'switch' ORDER BY name`
`SELECT * FROM devices WHERE device_type = 'switch'
${siteFilter ? `AND ${siteFilter}` : ''} ORDER BY name`
);
const results = await Promise.allSettled(
+20 -5
View File
@@ -1,6 +1,8 @@
import { Router, Request, Response } from 'express';
import { query, queryOne } from '../config/database';
import { requireAuth, requireWrite } from '../middleware/auth';
import { siteScopeDevices, siteScopeByDevice } from '../utils/siteScope';
import { activeSite } from '../middleware/site';
import { PollerService } from '../services/PollerService';
import {
buildTopology,
@@ -31,11 +33,20 @@ interface BridgeRow {
}
// GET /api/topology
router.get('/', async (_req: Request, res: Response) => {
router.get('/', async (req: Request, res: Response) => {
// Scope the graph to the active site. Links are filtered on their *from*
// device: a link out of the site still shows, and resolves to an external
// node, which is the honest picture of an uplink to somewhere else (#130).
const siteId = activeSite(req);
const devFilter = siteScopeDevices(siteId);
const fromFilter = siteScopeByDevice(siteId, 'tl.from_device_id');
const mlFilter = siteScopeByDevice(siteId, 'ml.from_device_id');
const ifFilter = siteScopeByDevice(siteId, 'device_id');
const brFilter = siteScopeByDevice(siteId, 'device_id');
const [devices, allLinks, manualLinks, deviceMacs, bridges] = await Promise.all([
query<TopoDevice>(
`SELECT id, name, ip_address, model, device_type, status, ros_version, ip_addresses_jsonb
FROM devices ORDER BY name ASC`
FROM devices ${devFilter ? `WHERE ${devFilter}` : ''} ORDER BY name ASC`
),
query<LinkRow>(
`SELECT tl.*,
@@ -44,27 +55,31 @@ router.get('/', async (_req: Request, res: Response) => {
FROM topology_links tl
LEFT JOIN devices fd ON fd.id = tl.from_device_id
LEFT JOIN devices td ON td.id = tl.to_device_id
${fromFilter ? `WHERE ${fromFilter}` : ''}
ORDER BY tl.discovered_at DESC`
),
query<ManualLinkRow>(
`SELECT ml.*, fd.name AS from_name, td.name AS to_name
FROM manual_topology_links ml
JOIN devices fd ON fd.id = ml.from_device_id
JOIN devices td ON td.id = ml.to_device_id`
JOIN devices td ON td.id = ml.to_device_id
${mlFilter ? `WHERE ${mlFilter}` : ''}`
),
// Every interface MAC in the fleet, so a neighbour seen only by MAC — an LLDP
// sighting across a trunk port carries no address — still resolves to its device.
query<{ device_id: number; mac_address: string }>(
`SELECT device_id, mac_address FROM interfaces WHERE mac_address IS NOT NULL
${ifFilter ? `AND ${ifFilter}` : ''}
UNION
SELECT device_id, mac_address FROM wireless_interfaces WHERE mac_address IS NOT NULL`
SELECT device_id, mac_address FROM wireless_interfaces WHERE mac_address IS NOT NULL
${ifFilter ? `AND ${ifFilter}` : ''}`
),
// What each bridge reports about the spanning tree. Authoritative, and the
// reason the root is no longer guessed from port roles (#131).
query<BridgeRow>(
`SELECT device_id, bridge_name, bridge_id, root_bridge, root_bridge_id,
root_port, root_path_cost, protocol_mode
FROM device_bridges`
FROM device_bridges ${brFilter ? `WHERE ${brFilter}` : ''}`
),
]);
+24 -4
View File
@@ -1,6 +1,8 @@
import { Router, Request, Response } from 'express';
import { query } from '../config/database';
import { requireAuth, requireWrite } from '../middleware/auth';
import { siteScopeDevices, siteScopeByDevice } from '../utils/siteScope';
import { activeSite } from '../middleware/site';
import { DeviceCollector, DeviceRow } from '../services/mikrotik/DeviceCollector';
import { lookupVendor } from '../utils/oui';
import { analyzeSpectrum, summarize, BAND_RANGES, parseChannelSpec } from '../utils/rfSpectrum';
@@ -106,7 +108,8 @@ router.post('/ssid/bulk', requireWrite, async (req: Request, res: Response) => {
// ─── Section overview ─────────────────────────────────────────────────────────
// GET /api/wireless — all wireless_ap devices with aggregated stats
router.get('/', async (_req: Request, res: Response) => {
router.get('/', async (req: Request, res: Response) => {
const siteFilter = siteScopeDevices(activeSite(req), 'd');
const aps = await query(`
SELECT d.id, d.name, d.ip_address, d.model, d.device_type, d.status, d.last_seen,
d.ros_version, d.firmware_version, d.serial_number, d.rack_name, d.rack_slot,
@@ -124,6 +127,7 @@ router.get('/', async (_req: Request, res: Response) => {
FROM devices d
LEFT JOIN wireless_interfaces wi ON wi.device_id = d.id
WHERE (d.device_type = 'wireless_ap' OR d.wifi_role IN ('cap','controller','controller_cap'))
${siteFilter ? `AND ${siteFilter}` : ''}
GROUP BY d.id
ORDER BY d.name ASC
`);
@@ -139,7 +143,8 @@ router.get('/', async (_req: Request, res: Response) => {
* Read-only. Reads the cache rather than the devices, so the page loads instantly
* and works while a CAP is offline — the controller still knows what it provisioned.
*/
router.get('/capsman', async (_req: Request, res: Response) => {
router.get('/capsman', async (req: Request, res: Response) => {
const siteFilter = siteScopeDevices(activeSite(req));
const controllers = await query<{
id: number; name: string; ip_address: string; model: string | null;
status: string; wifi_role: string | null;
@@ -147,6 +152,7 @@ router.get('/capsman', async (_req: Request, res: Response) => {
SELECT id, name, ip_address, model, status, wifi_role
FROM devices
WHERE wifi_role IN ('controller', 'controller_cap')
${siteFilter ? `AND ${siteFilter}` : ''}
ORDER BY name ASC
`);
@@ -874,6 +880,7 @@ function deviceScope(req: Request): number | null {
*/
router.get('/rf/channels', async (req: Request, res: Response) => {
const deviceId = deviceScope(req);
const siteFilter = siteScopeByDevice(activeSite(req), 'wi.device_id');
// Two sources, because a CAPsMAN-managed AP stores no frequency of its own — the
// controller owns the channel. Reading only wireless_interfaces silently dropped
@@ -905,6 +912,7 @@ router.get('/rf/channels', async (req: Request, res: Response) => {
OR cr.current_channel IS NOT NULL
)
${deviceId ? 'AND wi.device_id = $1' : ''}
${siteFilter ? `AND ${siteFilter}` : ''}
ORDER BY d.name ASC, wi.name ASC
`, deviceId ? [deviceId] : []);
@@ -967,6 +975,7 @@ router.get('/rf/channels', async (req: Request, res: Response) => {
// GET /api/wireless/rf/signals — active wireless clients' RSSI (for density view)
router.get('/rf/signals', async (req: Request, res: Response) => {
const deviceId = deviceScope(req);
const siteFilter = siteScopeByDevice(activeSite(req), 'c.device_id');
const rows = await query(`
SELECT c.mac_address, c.signal_strength, c.device_id, d.name AS device_name,
c.custom_name, c.hostname
@@ -977,6 +986,7 @@ router.get('/rf/signals', async (req: Request, res: Response) => {
AND c.signal_strength IS NOT NULL
AND c.signal_strength < 0
${deviceId ? 'AND c.device_id = $1' : ''}
${siteFilter ? `AND ${siteFilter}` : ''}
ORDER BY c.signal_strength DESC
`, deviceId ? [deviceId] : []);
res.json(rows);
@@ -989,11 +999,13 @@ router.get('/rf/signals', async (req: Request, res: Response) => {
* driver exposes. On an all-RouterOS-7 fleet that panel can never have data, so the
* UI needs to know rather than rendering an empty box forever (issue #96).
*/
router.get('/rf/capabilities', async (_req: Request, res: Response) => {
router.get('/rf/capabilities', async (req: Request, res: Response) => {
const siteFilter = siteScopeDevices(activeSite(req));
const rows = await query<{ wifi_package: string | null; n: string }>(`
SELECT wifi_package, COUNT(*) AS n
FROM devices
WHERE wifi_package IS NOT NULL AND wifi_package <> 'none'
${siteFilter ? `AND ${siteFilter}` : ''}
GROUP BY wifi_package`);
const counts = Object.fromEntries(rows.map((r) => [r.wifi_package ?? 'unknown', Number(r.n)]));
@@ -1065,6 +1077,7 @@ const RE_DHCP_FAIL = /no.*address|declined|nak|offer.*fail|pool.*exhaust/i;
// GET /api/wireless/rf/connectivity?range=24h — Association/Auth/DHCP success funnel
router.get('/rf/connectivity', async (req: Request, res: Response) => {
const deviceId = deviceScope(req);
const siteFilter = siteScopeByDevice(activeSite(req), 'device_id');
const intervals: Record<string, string> = { '1h': '1 hour', '6h': '6 hours', '24h': '24 hours', '7d': '7 days' };
const interval = intervals[String(req.query.range || '24h')] || '24 hours';
@@ -1072,6 +1085,7 @@ router.get('/rf/connectivity', async (req: Request, res: Response) => {
SELECT topic, message FROM events
WHERE event_time > NOW() - INTERVAL '${interval}'
${deviceId ? 'AND device_id = $1' : ''}
${siteFilter ? `AND ${siteFilter}` : ''}
AND (topic ILIKE '%wireless%' OR topic ILIKE '%wifi%' OR topic ILIKE '%dhcp%')
`, deviceId ? [deviceId] : []);
@@ -1109,13 +1123,19 @@ router.get('/rf/connectivity', async (req: Request, res: Response) => {
// ─── Rogue / neighbor AP detection ────────────────────────────────────────────
// GET /api/wireless/rf/rogue — classify latest AP scans against our own SSIDs/BSSIDs
router.get('/rf/rogue', async (_req: Request, res: Response) => {
router.get('/rf/rogue', async (req: Request, res: Response) => {
const { classifyScans } = await import('../utils/rogueAp');
// Only the scans are site-scoped. The "these are ours" SSID and BSSID sets stay
// fleet-wide on purpose: an AP of yours in another site is still yours, and
// scoping those sets would report your own hardware as a rogue (issue #130).
const scanFilter = siteScopeByDevice(activeSite(req), 'a.device_id');
const [scans, radios, ifaceMacs] = await Promise.all([
query<{ device_id: number; device_name: string; scanned_at: string; data: unknown }>(`
SELECT DISTINCT ON (a.device_id) a.device_id, d.name AS device_name, a.scanned_at, a.data
FROM ap_scan_data a JOIN devices d ON d.id = a.device_id
${scanFilter ? `WHERE ${scanFilter}` : ''}
ORDER BY a.device_id, a.scanned_at DESC`),
query<{ ssid: string | null; mac_address: string | null }>(
`SELECT ssid, mac_address FROM wireless_interfaces`),
+11 -4
View File
@@ -33,6 +33,8 @@ export type BulkAddResultRow = {
interface BulkJobPayload {
jobId: string;
items: CreateDeviceInput[];
/** Site the batch was queued from, so queued adds land where the operator was looking. */
siteId?: number | null;
}
async function readMeta(jobId: string): Promise<Record<string, unknown>> {
@@ -68,7 +70,7 @@ async function appendResults(jobId: string, rows: BulkAddResultRow[]): Promise<v
}
async function processJob(job: Job<BulkJobPayload>): Promise<void> {
const { jobId, items } = job.data;
const { jobId, items, siteId } = job.data;
await writeMeta(jobId, { status: 'active', processed: 0 });
const poller = pollerService;
@@ -96,7 +98,8 @@ async function processJob(job: Job<BulkJobPayload>): Promise<void> {
...item,
device_type: item.device_type || 'router',
},
poller
poller,
{ siteId: siteId ?? null }
);
let failMsg = 'Failed';
@@ -128,11 +131,15 @@ async function processJob(job: Job<BulkJobPayload>): Promise<void> {
});
}
export async function enqueueBulkAddJob(jobId: string, items: CreateDeviceInput[]): Promise<void> {
export async function enqueueBulkAddJob(
jobId: string,
items: CreateDeviceInput[],
siteId: number | null = null
): Promise<void> {
if (!queue) {
queue = new Queue(QUEUE_NAME, { connection: createRedisConnection() });
}
await queue.add('run', { jobId, items } satisfies BulkJobPayload, {
await queue.add('run', { jobId, items, siteId } satisfies BulkJobPayload, {
attempts: 1,
removeOnComplete: { age: 3600, count: 100 },
removeOnFail: { age: 86400 },
@@ -0,0 +1,53 @@
import { readFileSync } from 'fs';
import { join } from 'path';
import { DEVICE_BASE_COLUMNS } from '../deviceColumns';
const SRC = join(__dirname, '..', '..');
const read = (p: string) => readFileSync(join(SRC, p), 'utf8');
describe('DEVICE_BASE_COLUMNS', () => {
it('includes the fields a device payload cannot be useful without', () => {
for (const col of ['id', 'name', 'ip_address', 'status', 'device_type', 'site_id']) {
expect(DEVICE_BASE_COLUMNS).toContain(col);
}
});
/**
* The regression this file exists for: site_id was added to the device list
* but not to the detail read, so the Site field on the device page could not
* see its own value. It rendered "Unassigned" and looked like it rejected
* every change, while the writes were landing correctly.
*
* Every query that returns a device to a client must go through the shared
* column list, so a field added there reaches all of them at once.
*/
it('is used by every query that builds a device response', () => {
for (const f of ['routes/devices.ts', 'services/deviceCreation.ts']) {
const src = read(f);
const selects = src.match(/`SELECT[^`]*FROM devices[^`]*`/g) ?? [];
// A *payload* select is one whose COLUMN LIST reads display fields.
// Inspecting the column list rather than the whole statement matters:
// matching the full text catches WHERE clauses that merely mention
// name or status, which is how an unrelated SSH-target query first
// tripped this. Internal reads -- credentials for a connection,
// existence probes, `SELECT *` for the collector -- are a different
// thing and rightly don't carry the set.
const columnList = (q: string) => q.slice(0, q.search(/\bFROM\b/));
// Either already using the shared list, or hand-rolling display columns.
// Both count as payloads; the assertion below is what separates them, so
// a newly hand-rolled device SELECT fails rather than passing unnoticed.
const payloadSelects = selects.filter((q) => {
const cols = columnList(q);
if (/SELECT \*/.test(cols)) return false;
return cols.includes('DEVICE_BASE_COLUMNS')
|| (/\bname\b/.test(cols) && /\bstatus\b/.test(cols));
});
expect(payloadSelects.length).toBeGreaterThan(0);
for (const q of payloadSelects) {
expect(columnList(q)).toContain('DEVICE_BASE_COLUMNS');
// Free guard while we are here: a payload must never carry a secret.
expect(columnList(q)).not.toMatch(/_encrypted/);
}
}
});
});
+17
View File
@@ -0,0 +1,17 @@
/**
* The columns every device-shaped API response must carry.
*
* Five separate queries build a device payload -- the list, the detail read,
* the location patch, and two paths in device creation -- and they had drifted:
* adding site_id to the list alone left the detail page unable to see which
* site a device was in, so the Site field showed "Unassigned" and appeared to
* reject every change while the writes were in fact landing.
*
* Endpoints append their own extras (ssh_port, wifi_role, timestamps). This
* constant only guarantees the shared core, so a column added here reaches
* every response at once and cannot be half-applied again.
*/
export const DEVICE_BASE_COLUMNS = `
id, name, ip_address, api_port, api_username, model, serial_number,
firmware_version, ros_version, device_type, status, last_seen, notes, site_id
`.trim();
+16 -9
View File
@@ -5,10 +5,17 @@ import { safeConnectionError } from '../utils/safeClientError';
import { RouterOSClient } from './mikrotik/RouterOSClient';
import type { PollerService } from './PollerService';
import type { CredentialPresetRow } from '../routes/credentialPresets';
import { DEVICE_BASE_COLUMNS } from './deviceColumns';
export type CreateDeviceContext = {
/** JWT role of the caller ('admin' | 'operator' | 'viewer'). Preset use may be restricted for operators. */
requestingUserRole?: string;
/**
* Site the device should join (issue #130). A device with no site is invisible
* the moment any site is selected, so this must never be left unset: when the
* caller has no active site we fall back to the default site in SQL.
*/
siteId?: number | null;
};
async function loadCredentialPreset(
@@ -191,9 +198,7 @@ export async function createDeviceFromBody(
}
const updatedExisting = await queryOne(
`SELECT id, name, ip_address, api_port, api_username, model, serial_number,
firmware_version, ros_version, device_type, status, last_seen, notes, created_at
FROM devices WHERE id = $1`,
`SELECT ${DEVICE_BASE_COLUMNS}, created_at FROM devices WHERE id = $1`,
[existingBySerial.id]
);
if (!updatedExisting) {
@@ -219,10 +224,14 @@ export async function createDeviceFromBody(
const rows = await query<{ id: number }>(
`INSERT INTO devices (name, ip_address, api_port, api_username, api_password_encrypted,
ssh_port, ssh_username, ssh_password_encrypted, device_type, notes, status)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,'unknown') RETURNING id`,
ssh_port, ssh_username, ssh_password_encrypted, device_type, notes, status,
site_id)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,'unknown',
COALESCE($11::int, (SELECT id FROM sites ORDER BY is_default DESC, id LIMIT 1)))
RETURNING id`,
[name, ip_address, api_port, api_username, encryptedPass,
ssh_port, ssh_username || null, encryptedSshPass, device_type, notes || null]
ssh_port, ssh_username || null, encryptedSshPass, device_type, notes || null,
ctx?.siteId ?? null]
);
const newId = rows[0].id;
@@ -232,9 +241,7 @@ export async function createDeviceFromBody(
}
const device = await queryOne(
`SELECT id, name, ip_address, api_port, api_username, model, serial_number,
firmware_version, ros_version, device_type, status, last_seen, notes, created_at
FROM devices WHERE id = $1`,
`SELECT ${DEVICE_BASE_COLUMNS}, created_at FROM devices WHERE id = $1`,
[newId]
);
@@ -743,6 +743,30 @@ export class DeviceCollector {
.timestamp(new Date());
writeApi.writePoint(globalPoint);
// The same deduplicated count, but for this device's site (issue #130).
// Summing the per-device series instead would double-count a client seen
// by two access points -- which is the exact error the global series was
// introduced to avoid, so the site series has to be deduplicated too.
const siteRows = await query<{ site_id: number | null; count: string }>(
`SELECT d0.site_id,
(SELECT COUNT(DISTINCT c.mac_address)
FROM clients c
JOIN devices d ON d.id = c.device_id
WHERE c.active = TRUE AND d.site_id = d0.site_id) AS count
FROM devices d0 WHERE d0.id = $1`,
[this.device.id]
);
const siteId = siteRows[0]?.site_id ?? null;
if (siteId != null) {
writeApi.writePoint(
new Point('client_counts')
.tag('device_id', `_site_${siteId}`)
.tag('device_name', `_site_${siteId}`)
.intField('total_clients', parseInt(siteRows[0]?.count || '0', 10))
.timestamp(new Date())
);
}
await writeApi.flush().catch(() => {});
} catch (err) {
console.error(`[${this.device.name}] Failed to update clients:`, err);
@@ -0,0 +1,148 @@
import {
resolveSiteId,
siteScopeDevices,
siteScopeByDevice,
siteScopeByNullableDevice,
andSite,
clientSeriesTag,
} from '../siteScope';
describe('resolveSiteId', () => {
it('accepts positive integers', () => {
expect(resolveSiteId('3')).toBe(3);
expect(resolveSiteId(7)).toBe(7);
expect(resolveSiteId(' 12 ')).toBe(12);
});
// Unscoped is the compatibility path: anything that isn't a real site id must
// behave exactly as the API did before sites existed, never as an error.
it('treats absent, empty and "all" as unscoped', () => {
expect(resolveSiteId(undefined)).toBeNull();
expect(resolveSiteId(null)).toBeNull();
expect(resolveSiteId('')).toBeNull();
expect(resolveSiteId(' ')).toBeNull();
expect(resolveSiteId('all')).toBeNull();
expect(resolveSiteId('ALL')).toBeNull();
});
it('rejects zero and negatives', () => {
expect(resolveSiteId('0')).toBeNull();
expect(resolveSiteId('-4')).toBeNull();
});
// The id is inlined into SQL, so this is the boundary that makes that safe.
// Every one of these must come back null rather than reaching a query.
it('rejects anything that is not a plain integer', () => {
const hostile = [
'1; DROP TABLE devices',
'1 OR 1=1',
"1'",
'1.5',
'1e3',
'abc',
'0x1',
'١٢',
{},
[],
];
for (const raw of hostile) expect(resolveSiteId(raw)).toBeNull();
});
// Surrounding whitespace is stripped, not rejected -- '\n3' is the number 3
// with padding, which is what a header or query string may legitimately carry.
it('trims surrounding whitespace rather than rejecting it', () => {
expect(resolveSiteId('\n3')).toBe(3);
expect(resolveSiteId('\t9 ')).toBe(9);
});
it('never returns a value whose string form contains non-digits', () => {
for (const raw of ['5', ' 5 ', 5]) {
const id = resolveSiteId(raw);
expect(id).not.toBeNull();
expect(String(id)).toMatch(/^\d+$/);
}
});
});
describe('siteScopeDevices', () => {
it('returns null when unscoped so callers can skip the filter', () => {
expect(siteScopeDevices(null)).toBeNull();
expect(siteScopeDevices(null, 'd')).toBeNull();
});
it('filters on site_id, qualified when an alias is given', () => {
expect(siteScopeDevices(3)).toBe('site_id = 3');
expect(siteScopeDevices(3, 'd')).toBe('d.site_id = 3');
});
});
describe('siteScopeByDevice', () => {
it('returns null when unscoped', () => {
expect(siteScopeByDevice(null)).toBeNull();
});
it('scopes any device_id column through the devices table', () => {
expect(siteScopeByDevice(4)).toBe('device_id IN (SELECT id FROM devices WHERE site_id = 4)');
expect(siteScopeByDevice(4, 'c.device_id'))
.toBe('c.device_id IN (SELECT id FROM devices WHERE site_id = 4)');
});
});
describe('siteScopeByNullableDevice', () => {
it('returns null when unscoped, so fleet-wide rows still appear', () => {
expect(siteScopeByNullableDevice(null, 'e.device_id')).toBeNull();
});
// A row with no device belongs to no site. Showing it in every site would
// misrepresent it as that site's own, so it is hidden while one is selected.
it('excludes rows with no device when a site is selected', () => {
const sql = siteScopeByNullableDevice(2, 'e.device_id');
expect(sql).toContain('e.device_id IS NOT NULL');
expect(sql).toContain('site_id = 2');
});
});
describe('andSite', () => {
it('returns the base untouched when unscoped', () => {
expect(andSite('WHERE a = 1', null)).toBe('WHERE a = 1');
expect(andSite('', null)).toBe('');
});
it('starts a WHERE clause when the base is empty', () => {
expect(andSite('', 'site_id = 1')).toBe('WHERE site_id = 1');
expect(andSite(' ', 'site_id = 1')).toBe('WHERE site_id = 1');
});
it('appends with AND when the base already has a clause', () => {
expect(andSite('WHERE a = 1', 'site_id = 1')).toBe('WHERE a = 1 AND site_id = 1');
});
});
describe('clientSeriesTag', () => {
it('uses the global series when unscoped', () => {
expect(clientSeriesTag(null, 0, 4)).toBe('_global');
});
// The case that matters for every existing single-site install: the site
// holds the whole fleet, so the global series is exactly right and keeps the
// chart's history instead of restarting it at the upgrade.
it('uses the global series when the site holds every device', () => {
expect(clientSeriesTag(1, 4, 4)).toBe('_global');
});
it('uses the per-site series once the fleet is actually split', () => {
expect(clientSeriesTag(1, 3, 4)).toBe('_site_1');
expect(clientSeriesTag(2, 1, 4)).toBe('_site_2');
});
// An empty site must read a series nothing writes, so the chart is empty
// rather than showing the fleet's.
it('uses the per-site series for an empty site', () => {
expect(clientSeriesTag(3, 0, 4)).toBe('_site_3');
});
// No devices at all: nothing to be equal to, so don't claim the global total.
it('does not treat an empty install as a full site', () => {
expect(clientSeriesTag(1, 0, 0)).toBe('_site_1');
});
});
+99
View File
@@ -0,0 +1,99 @@
/**
* Site scoping helpers (issue #130).
*
* Every device-derived table hangs off devices(id), so a single site_id on
* devices scopes clients, events, topology, backups and the rest transitively.
* These helpers turn an active site into the SQL predicate that does it.
*
* The site id is inlined rather than parameterised, and that is a deliberate
* choice worth justifying: the collection routes build their WHERE clauses by
* incrementing a shared $n counter, so injecting a parameter mid-build would
* mean renumbering every filter in ~23 routes -- exactly the kind of manual
* bookkeeping that produces silent off-by-one bugs. Inlining is safe here
* because resolveSiteId() below admits nothing but a positive integer; a value
* that is not one becomes null (unscoped), never text that reaches SQL.
*/
/**
* Normalise an untrusted site id (header, query string) into either a positive
* integer or null, where null means "no scoping -- show the whole fleet".
*
* 'all' is the explicit all-sites view. Anything unparseable is also treated as
* unscoped rather than rejected, so an existing API consumer that knows nothing
* about sites keeps working exactly as it did.
*/
export function resolveSiteId(raw: unknown): number | null {
if (raw === null || raw === undefined) return null;
const s = String(raw).trim();
if (s === '' || s.toLowerCase() === 'all') return null;
// Matched as a literal digit string rather than via Number(), deliberately.
// Number() would accept '1e3', '0x1' and even Arabic-Indic '\u0661\u0662' as
// integers -- all harmless here, but it makes the safety of inlining rest on
// coercion trivia. A digits-only regex makes it self-evident instead.
if (!/^\d+$/.test(s)) return null;
const n = Number(s);
if (!Number.isSafeInteger(n) || n <= 0) return null;
return n;
}
/**
* Predicate for a query that selects from `devices` itself.
* Returns null when unscoped, so callers can skip pushing a filter.
*/
export function siteScopeDevices(siteId: number | null, alias = ''): string | null {
if (siteId === null) return null;
const col = alias ? `${alias}.site_id` : 'site_id';
return `${col} = ${siteId}`;
}
/**
* Predicate for a query on any table carrying a device_id.
* `column` is the fully-qualified device id column, e.g. 'c.device_id'.
*/
export function siteScopeByDevice(siteId: number | null, column = 'device_id'): string | null {
if (siteId === null) return null;
return `${column} IN (SELECT id FROM devices WHERE site_id = ${siteId})`;
}
/**
* Same, but tolerant of a nullable device id. Rows not attached to any device
* (fleet-wide events, for instance) belong to no site and are hidden while a
* site is selected -- showing them in every site would misrepresent them as
* that site's own.
*/
export function siteScopeByNullableDevice(siteId: number | null, column = 'device_id'): string | null {
if (siteId === null) return null;
return `(${column} IS NOT NULL AND ${column} IN (SELECT id FROM devices WHERE site_id = ${siteId}))`;
}
/**
* Join a base WHERE clause with an optional site predicate.
* Handles the empty-base case so routes don't each reinvent it.
*/
export function andSite(base: string, predicate: string | null): string {
if (!predicate) return base;
const trimmed = base.trim();
if (!trimmed) return `WHERE ${predicate}`;
return `${base} AND ${predicate}`;
}
/**
* Which deduplicated client-count series to read for a site (issue #130).
*
* DeviceCollector writes a fleet-wide `_global` series and a per-site
* `_site_<id>` series. The per-site one only accumulates from the upgrade
* onward, so preferring it unconditionally would throw away the existing
* history of every install that has one site.
*
* When a site holds every device, its total *is* the global total -- an
* identity, not an approximation -- so `_global` is both correct and complete.
*/
export function clientSeriesTag(
siteId: number | null,
devicesInSite: number,
devicesTotal: number
): string {
if (siteId === null) return '_global';
if (devicesTotal > 0 && devicesInSite === devicesTotal) return '_global';
return `_site_${siteId}`;
}
+1
View File
@@ -6,6 +6,7 @@ whether to try it, the [project README](https://github.com/2GT-Media-Group-LLC/m
| Document | Covers |
|---|---|
| [Configuration](configuration.md) | Environment variables, secret management and key rotation |
| [Sites](sites.md) | Grouping devices into sites, switching between them, and what each site scopes |
| [Change Guard and Config Health](change-guard.md) | How the safety system works, its settings, and what to expect when it fires |
| [Alerting](alerting.md) | Alert rules, delivery channels, webhooks, scheduled reports, maintenance windows |
| [Single sign-on (OIDC)](sso-oidc.md) | Identity provider setup, group-to-role mapping, break-glass behaviour |
+19
View File
@@ -43,6 +43,25 @@ or 2FA changes.
| `GET /api/devices/:id/config-health` | Latest standing-audit findings |
| `GET /api/topology` | Graph of devices, links, external nodes, and distrusted identifiers |
| `GET /api/operations/insights` | The dashboard's "things to handle" feed |
| `GET /api/sites` | Sites with device counts (see [Sites](sites.md)) |
### Scoping a request to one site
Collection endpoints accept an `X-Site-Id` header (or `?site_id=`) that limits the
response to devices in that site:
```bash
curl -H "Authorization: Bearer $TOKEN" -H "X-Site-Id: 2" \
https://mtm.example.com/api/devices
```
**Omitting it means unscoped**, returning the whole fleet exactly as before sites
existed — existing scripts and tokens need no change. Per-device endpoints
(`/api/devices/:id/...`) are already scoped by the device itself and ignore the header.
One thing to know if you create devices via the API: a device joins the site the
request was scoped to, falling back to the default site. A device belonging to no site
is invisible whenever a site is selected, so this is set rather than left null.
`preflight` is the useful one for automation: it returns the same verdict the UI shows,
so a pipeline can refuse its own change before touching the device. See
+127
View File
@@ -0,0 +1,127 @@
# Sites
[← Documentation index](README.md)
A **site** is a named collection of devices whose data — clients, events, topology,
backups, statistics — is viewed separately from every other site's.
This is multi-*site*, not multi-tenancy. Sites separate what you **see**; they do not
separate who you **are**. There is deliberately no per-site access control: the existing
admin / manage / read-only roles are unchanged, and every user can see every site.
## If you run one network, ignore this page
On upgrade, a single site named **Default Site** is created and every existing device is
assigned to it. Nothing moves, no figure changes, and the site selector does not appear.
You will never meet the concept unless you create a second site.
## Creating and switching
The selector sits under the logo in the top-left, below the brand block. It lists your
sites and offers **New site**, **rename** (the pencil, on hover) and **All sites**.
Switching site reloads the data you are looking at. This is intentional and not a
performance bug: every cached figure belonged to the site you just left, and showing it
under another site's name would be worse than a brief reload.
## Moving devices
A device belongs to exactly one site. Move it from **Device → Physical Details → Site**;
the picker only appears when more than one site exists.
Moving a device takes its history with it — clients, events, backups, interface
statistics. Nothing is copied or lost, because none of that data carries a site of its
own: it hangs off the device, and the device carries the site.
Devices added through the UI or API join whichever site you were viewing, falling back
to the default site. A device belonging to no site would be invisible the moment any
site is selected, so the field is always set rather than left empty.
## The all-sites view
**All sites** in the selector opens a table of every site with its address and device
count, plus a world map with a pin per geocoded site. Selecting a row switches to that
site.
Choosing **All sites** also makes every other page fleet-wide, which is the honest reading
of "all sites" — useful for a fleet-wide search, less so for a dashboard that averages
several unrelated networks.
Pins require an address, which is geocoded via Nominatim when you save it. If
**Settings → Maps enabled** is off, no geocoding or tile request is made and the map is
hidden; addresses are still stored and shown as text.
## What sites scope, and what they do not
Scoped to the selected site:
- Devices, clients, events, topology, search
- Dashboard and Operations insights, metrics summary
- Wireless (APs, CAPsMAN, RF channels and signals)
- Backups, Firmware, Bulk commands
Also scoped: the Operations feed (Things to Handle, Recent Activity, capacity), Config
Health findings, anomaly detection, and the Connected Clients chart.
Deliberately **not** scoped:
- **Polling.** Collection always runs across the whole fleet. Sites are a view over data
already gathered, not a change to how it is gathered — a device does not stop being
monitored because you are looking at another site.
- **The audit log**, in Recent Activity. Audit rows record who changed the install; they
carry no device and so belong to no site. Repeating "admin changed a setting" into every
site would misattribute it, so the audit feed appears only in the all-sites view.
- **NetFlow collector health**, which is one service for the install.
- **Rogue AP detection.** The scans are site-scoped, but the "these access points are
ours" set stays fleet-wide. An AP of yours in another site is still yours, and scoping
that set would report your own hardware as a rogue.
- **Install-wide configuration**: users, alert rules, webhooks, config templates,
credential presets, tags, maintenance windows and settings.
Bulk actions — Backup all, Sync all, firmware check-all, bulk commands — obey the
selector. A bulk operation that silently spanned two customers is precisely the blast
radius the wave and halt-on-failure guards exist to contain.
## Deleting a site
Deleting a site never deletes the devices in it. If devices remain, the deletion is
refused and you are asked to move them first, so their destination is a decision rather
than a side effect.
## API
The active site travels as an `X-Site-Id` header, or a `?site_id=` query parameter.
```bash
curl -H "Authorization: Bearer $TOKEN" -H "X-Site-Id: 2" https://mtm.example.com/api/devices
```
**Omitting the header means unscoped**, returning the whole fleet exactly as the API did
before sites existed. Existing scripts and API tokens keep working untouched.
| Method | Path | Purpose |
|---|---|---|
| `GET` | `/api/sites` | List sites with device counts |
| `POST` | `/api/sites` | Create a site |
| `PUT` | `/api/sites/:id` | Rename, set address or notes |
| `DELETE` | `/api/sites/:id` | Delete (refused while devices remain) |
| `POST` | `/api/sites/:id/devices` | Move devices into the site |
## The Connected Clients chart
Client totals are deduplicated — a client seen by two access points is one client — so the
chart cannot be a sum of per-device series. A per-site deduplicated series is recorded
alongside the fleet-wide one, and it starts accumulating from the upgrade.
That would have cost every existing install its chart history, so there is one shortcut,
and it is an identity rather than an approximation: **when a site contains every device,
its total is the fleet total**, and the chart reads the fleet-wide series with all of its
history. Single-site installs therefore lose nothing. Once you genuinely split the fleet,
each site's chart builds its own history from that point.
## A note on client counts
A client is counted in a site when a device in that site has seen it. On genuinely
separate networks the sites do not overlap. If two sites share one broadcast domain —
common in a lab — the same MAC is legitimately seen by hardware in both, and appears in
both counts.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "mikrotik-manager-frontend",
"version": "0.23.35-beta",
"version": "0.24.0-beta",
"private": true,
"type": "module",
"scripts": {
+4 -1
View File
@@ -3,6 +3,8 @@ import { useAuthStore } from './store/authStore';
import AppLayout from './components/layout/AppLayout';
import LoginPage from './pages/LoginPage';
import DashboardPage from './pages/DashboardPage';
import SitesPage from './pages/SitesPage';
import SiteLanding from './components/SiteLanding';
import DevicesPage from './pages/DevicesPage';
import DeviceDetailPage from './pages/DeviceDetailPage';
import ClientsPage from './pages/ClientsPage';
@@ -48,7 +50,7 @@ export default function App() {
</ProtectedRoute>
}
>
<Route index element={<Navigate to="/dashboard" replace />} />
<Route index element={<SiteLanding />} />
<Route path="dashboard" element={<DashboardPage />} />
<Route path="devices" element={<DevicesPage />} />
<Route path="devices/:id" element={<DeviceDetailPage />} />
@@ -59,6 +61,7 @@ export default function App() {
<Route path="backups" element={<BackupsPage />} />
<Route path="firmware" element={<FirmwarePage />} />
<Route path="commands" element={<CommandsPage />} />
<Route path="sites" element={<SitesPage />} />
<Route path="settings" element={<SettingsPage />} />
{/* Legacy routes — consolidated in the v0.16.4 UI reorganization */}
<Route path="switches" element={<Navigate to="/devices?type=SW" replace />} />
+26
View File
@@ -0,0 +1,26 @@
import { Navigate } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { sitesApi } from '../services/api';
import { useSiteStore } from '../store/siteStore';
/**
* Where "/" goes (issue #130).
*
* One site means a single-network install, which must never meet the concept:
* straight to the dashboard. More than one, with no site chosen, means the
* all-sites overview is the honest landing page — a dashboard aggregating
* several customers' networks would be a number nobody asked for.
*/
export default function SiteLanding() {
const { currentSiteId } = useSiteStore();
const { data: sites, isLoading } = useQuery({
queryKey: ['sites'],
queryFn: () => sitesApi.list().then((r) => r.data),
staleTime: 60_000,
});
// Don't bounce through /sites while the list is still in flight.
if (isLoading) return null;
if (currentSiteId == null && (sites?.length ?? 0) > 1) return <Navigate to="/sites" replace />;
return <Navigate to="/dashboard" replace />;
}
@@ -5,9 +5,11 @@ import {
} from 'lucide-react';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import { devicesApi, settingsApi } from '../../services/api';
import { devicesApi, settingsApi, sitesApi } from '../../services/api';
import type { Device } from '../../types';
import { useCanWrite } from '../../hooks/useCanWrite';
import { geocodeAddress, OSM_TILE_URL, OSM_ATTRIBUTION } from '../../utils/geocode';
import { Network } from 'lucide-react';
interface Props {
device: Device;
@@ -20,22 +22,6 @@ interface LocationForm {
notes: string;
}
async function geocodeAddress(address: string): Promise<{ lat: number; lng: number } | null> {
try {
const res = await fetch(
`https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(address)}&format=json&limit=1`,
{ headers: { Accept: 'application/json' } }
);
const data = await res.json();
if (Array.isArray(data) && data.length > 0) {
return { lat: parseFloat(data[0].lat), lng: parseFloat(data[0].lon) };
}
} catch {
// ignore geocoding failures
}
return null;
}
function MapEmbed({ lat, lng, address }: { lat: number | string; lng: number | string; address: string }) {
const latN = Number(lat);
const lngN = Number(lng);
@@ -44,9 +30,7 @@ function MapEmbed({ lat, lng, address }: { lat: number | string; lng: number | s
useEffect(() => {
if (!containerRef.current) return;
const map = L.map(containerRef.current, { scrollWheelZoom: false });
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
}).addTo(map);
L.tileLayer(OSM_TILE_URL, { attribution: OSM_ATTRIBUTION }).addTo(map);
L.circleMarker([latN, lngN], {
radius: 9, color: '#fff', weight: 2, fillColor: '#3b82f6', fillOpacity: 0.9,
}).bindPopup(`<b>${address}</b>`).addTo(map);
@@ -87,6 +71,26 @@ export default function DeviceLocationSection({ device }: Props) {
staleTime: 300_000,
});
const mapsEnabled = settings?.['maps_enabled'] !== false;
// Which site this device belongs to (issue #130). Shown only when more than
// one site exists — on a single-network install it is noise.
const { data: sites = [] } = useQuery({
queryKey: ['sites'],
queryFn: () => sitesApi.list().then((r) => r.data),
staleTime: 60_000,
});
const showSitePicker = sites.length > 1;
const currentSite = sites.find((s) => s.id === device.site_id) ?? null;
const moveSite = useMutation({
mutationFn: (siteId: number) => sitesApi.assignDevices(siteId, [device.id]),
onSuccess: () => {
// The device may have just left the site being viewed, so every scoped
// figure on screen is now potentially wrong. Invalidate everything and
// let the active queries refetch.
queryClient.invalidateQueries();
},
});
const [editing, setEditing] = useState(false);
const [geocoding, setGeocoding] = useState(false);
const [saveError, setSaveError] = useState('');
@@ -173,6 +177,28 @@ export default function DeviceLocationSection({ device }: Props) {
{editing ? (
<div className="space-y-4">
{showSitePicker && (
<div>
<label className="label flex items-center gap-1.5">
<Network className="w-3.5 h-3.5 text-blue-500" /> Site
</label>
<select
className="input"
value={device.site_id ?? ''}
disabled={moveSite.isPending}
onChange={(e) => {
const id = parseInt(e.target.value, 10);
if (Number.isInteger(id)) moveSite.mutate(id);
}}
>
{device.site_id == null && <option value="">Unassigned</option>}
{sites.map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}
</select>
<p className="text-xs text-gray-400 mt-1">
Moving a device takes its clients, events and history with it. Applies immediately.
</p>
</div>
)}
<div>
<label className="label flex items-center gap-1.5">
<MapPin className="w-3.5 h-3.5 text-blue-500" /> Physical Location
@@ -247,6 +273,11 @@ export default function DeviceLocationSection({ device }: Props) {
</div>
) : (
<div className="space-y-3 text-sm">
{showSitePicker && (
<InfoRow icon={<Network className="w-3.5 h-3.5 text-blue-500" />} label="Site"
value={currentSite?.name}
emptyText="Unassigned" />
)}
<InfoRow icon={<MapPin className="w-3.5 h-3.5 text-blue-500" />} label="Location"
value={device.location_address}
emptyText="No location set" />
@@ -8,6 +8,7 @@ import {
LayoutGrid, TerminalSquare,
} from 'lucide-react';
import clsx from 'clsx';
import SiteSelector from './SiteSelector';
import { APP_VERSION } from '../../version';
import { devicesApi } from '../../services/api';
@@ -243,6 +244,9 @@ export default function Sidebar({ mobileOpen, onMobileClose }: SidebarProps) {
</button>
</div>
{/* Site selector — under the brand block, deliberately separate from it */}
<SiteSelector isCollapsed={isCollapsed} onNavigate={handleNavClick} />
{/* Navigation */}
<nav className="flex-1 px-[14px] py-3 space-y-[1px] overflow-y-auto">
{/* ── Monitor ── */}
@@ -0,0 +1,251 @@
import { useState, useRef, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query';
import { Building2, ChevronDown, Check, Plus, Pencil, Globe, X } from 'lucide-react';
import clsx from 'clsx';
import { sitesApi } from '../../services/api';
import { useSiteStore, type Site } from '../../store/siteStore';
import { useCanWrite } from '../../hooks/useCanWrite';
interface Props {
isCollapsed: boolean;
onNavigate?: () => void;
}
/**
* Site picker, mounted under the brand block (issue #130).
*
* Hidden entirely when only one site exists: a single-network install should
* never have to learn what a site is.
*/
export default function SiteSelector({ isCollapsed, onNavigate }: Props) {
const navigate = useNavigate();
const queryClient = useQueryClient();
const canWrite = useCanWrite();
const { currentSiteId, setCurrentSite } = useSiteStore();
const [open, setOpen] = useState(false);
const [creating, setCreating] = useState(false);
const [renaming, setRenaming] = useState<Site | null>(null);
const [name, setName] = useState('');
const [address, setAddress] = useState('');
const [error, setError] = useState('');
const wrapRef = useRef<HTMLDivElement>(null);
const { data: sites = [] } = useQuery({
queryKey: ['sites'],
queryFn: () => sitesApi.list().then((r) => r.data),
staleTime: 60_000,
});
function closeAll() {
setOpen(false);
setCreating(false);
setRenaming(null);
setError('');
}
useEffect(() => {
if (!open) return;
const onDown = (e: MouseEvent) => {
if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) closeAll();
};
document.addEventListener('mousedown', onDown);
return () => document.removeEventListener('mousedown', onDown);
}, [open]);
const saveMutation = useMutation({
mutationFn: async () => {
const trimmed = name.trim();
if (!trimmed) throw new Error('Site name is required');
const payload = { name: trimmed, address: address.trim() };
if (renaming) return sitesApi.update(renaming.id, payload).then((r) => r.data);
return sitesApi.create(payload).then((r) => r.data);
},
onSuccess: (site) => {
queryClient.invalidateQueries({ queryKey: ['sites'] });
// A newly created site is empty, so switching to it immediately is the
// useful move: the next thing you do is add devices to it.
if (!renaming) switchTo(site.id);
closeAll();
},
onError: (e: unknown) => {
const msg = (e as { response?: { data?: { error?: string } }; message?: string });
setError(msg.response?.data?.error ?? msg.message ?? 'Could not save site');
},
});
/**
* Changing the site changes the QueryClient's key in main.tsx, which remounts
* the tree against an empty cache. Nothing to clear here -- and clearing was
* in fact the bug: queryClient.clear() drops queries but leaves mounted
* observers holding their last result, so the pages never updated.
*/
function switchTo(id: number | null) {
setCurrentSite(id);
closeAll();
onNavigate?.();
}
function openAllSites() {
closeAll();
onNavigate?.();
// Navigate first: setting the site remounts the tree, and the route must
// already be /sites when it comes back.
navigate('/sites');
setCurrentSite(null);
}
// Nothing to show until the site list arrives.
if (sites.length === 0) return null;
const current = currentSiteId == null ? null : sites.find((s) => s.id === currentSiteId) ?? null;
// With one site there is nothing to choose between, so "all sites" and "that
// site" describe the same set. Name it rather than saying "All sites", so a
// single-network install sees "Default Site" and not a concept it never asked
// for. Deliberately a display rule and not a state change: selecting it for
// real would rekey the QueryClient and remount the app on every page load.
const soleSite = sites.length === 1 ? sites[0] : null;
const shown = current ?? soleSite;
const label = shown?.name ?? 'All sites';
return (
<div ref={wrapRef} className="relative px-[14px] pt-3">
<button
onClick={() => (open ? closeAll() : setOpen(true))}
className={clsx(
'w-full flex items-center gap-2 rounded-lg px-2 py-2 transition-colors',
'hover:bg-black/5 dark:hover:bg-white/5',
isCollapsed && 'md:justify-center md:px-0'
)}
style={{ border: '1px solid var(--line)' }}
title={label}
>
{shown
? <Building2 className="w-4 h-4 flex-shrink-0" style={{ color: 'var(--ink-3)' }} />
: <Globe className="w-4 h-4 flex-shrink-0" style={{ color: 'var(--ink-3)' }} />}
<span
className={clsx('flex-1 text-left text-[12.5px] font-medium truncate', isCollapsed && 'md:hidden')}
style={{ color: 'var(--ink)' }}
>
{label}
</span>
<ChevronDown
className={clsx('w-3.5 h-3.5 flex-shrink-0 transition-transform', open && 'rotate-180', isCollapsed && 'md:hidden')}
style={{ color: 'var(--ink-4)' }}
/>
</button>
{open && (
<div
className="absolute left-[14px] right-[14px] mt-1 z-50 rounded-lg shadow-lg overflow-hidden"
style={{ background: 'var(--surface)', border: '1px solid var(--line)', minWidth: 200 }}
>
{creating || renaming ? (
<div className="p-3 space-y-2">
<div className="text-[11px] font-semibold uppercase tracking-wide" style={{ color: 'var(--ink-4)' }}>
{renaming ? 'Rename site' : 'New site'}
</div>
<input
autoFocus
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') saveMutation.mutate(); if (e.key === 'Escape') closeAll(); }}
placeholder="Site name"
className="w-full px-2 py-1.5 text-[12.5px] rounded border bg-transparent"
style={{ borderColor: 'var(--line)', color: 'var(--ink)' }}
/>
<input
value={address}
onChange={(e) => setAddress(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') saveMutation.mutate(); if (e.key === 'Escape') closeAll(); }}
placeholder="Address (optional)"
className="w-full px-2 py-1.5 text-[12.5px] rounded border bg-transparent"
style={{ borderColor: 'var(--line)', color: 'var(--ink)' }}
/>
{error && <div className="text-[11px] text-red-500">{error}</div>}
<div className="flex gap-2">
<button
onClick={() => saveMutation.mutate()}
disabled={saveMutation.isPending}
className="flex-1 px-2 py-1.5 text-[12px] rounded bg-blue-600 text-white hover:bg-blue-500 disabled:opacity-50"
>
{saveMutation.isPending ? 'Saving…' : 'Save'}
</button>
<button
onClick={closeAll}
className="px-2 py-1.5 text-[12px] rounded"
style={{ color: 'var(--ink-3)', border: '1px solid var(--line)' }}
>
<X className="w-3.5 h-3.5" />
</button>
</div>
<p className="text-[10.5px] leading-snug" style={{ color: 'var(--ink-4)' }}>
The address is geocoded for the all-sites map. Devices are moved between
sites from the device list.
</p>
</div>
) : (
<div className="py-1 max-h-[320px] overflow-y-auto">
{sites.length > 1 && (
<>
<button
onClick={openAllSites}
className="w-full flex items-center gap-2 px-3 py-1.5 text-[12.5px] hover:bg-black/5 dark:hover:bg-white/5"
style={{ color: 'var(--ink)' }}
>
<Globe className="w-3.5 h-3.5 flex-shrink-0" style={{ color: 'var(--ink-3)' }} />
<span className="flex-1 text-left">All sites</span>
{currentSiteId == null && <Check className="w-3.5 h-3.5 text-blue-500" />}
</button>
<div className="my-1 h-px" style={{ background: 'var(--line)' }} />
</>
)}
{sites.map((s) => (
<div key={s.id} className="group flex items-center hover:bg-black/5 dark:hover:bg-white/5">
<button
onClick={() => switchTo(s.id)}
className="flex-1 flex items-center gap-2 px-3 py-1.5 text-[12.5px] min-w-0"
style={{ color: 'var(--ink)' }}
>
<Building2 className="w-3.5 h-3.5 flex-shrink-0" style={{ color: 'var(--ink-3)' }} />
<span className="flex-1 text-left truncate">{s.name}</span>
<span className="text-[10.5px] flex-shrink-0" style={{ color: 'var(--ink-4)' }}>
{s.device_count}
</span>
{shown?.id === s.id && <Check className="w-3.5 h-3.5 text-blue-500 flex-shrink-0" />}
</button>
{canWrite && (
<button
onClick={() => { setRenaming(s); setName(s.name); setAddress(s.address ?? ''); setError(''); }}
className="px-2 py-1.5 opacity-0 group-hover:opacity-100 transition-opacity"
style={{ color: 'var(--ink-4)' }}
title={`Rename ${s.name}`}
>
<Pencil className="w-3 h-3" />
</button>
)}
</div>
))}
{canWrite && (
<>
<div className="my-1 h-px" style={{ background: 'var(--line)' }} />
<button
onClick={() => { setCreating(true); setName(''); setAddress(''); setError(''); }}
className="w-full flex items-center gap-2 px-3 py-1.5 text-[12.5px] hover:bg-black/5 dark:hover:bg-white/5"
style={{ color: 'var(--ink-3)' }}
>
<Plus className="w-3.5 h-3.5 flex-shrink-0" />
<span className="flex-1 text-left">New site</span>
</button>
</>
)}
</div>
)}
</div>
)}
</div>
);
}
+43 -10
View File
@@ -2,6 +2,7 @@ import React from 'react';
import ReactDOM from 'react-dom/client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import App from './App';
import { useSiteStore } from './store/siteStore';
import './index.css';
// Initialize theme from storage immediately
@@ -21,20 +22,52 @@ if (storedTheme === 'dark') {
document.documentElement.classList.add('dark');
}
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000,
retry: 1,
refetchOnWindowFocus: false,
function makeQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000,
retry: 1,
refetchOnWindowFocus: false,
},
},
},
});
});
}
/**
* Scopes the whole React Query cache to the active site (issue #130).
*
* The obvious move -- queryClient.clear() on switch -- does not work, and the
* reason is worth recording. clear() destroys each Query and drops it from the
* cache, but a *mounted* observer keeps its own reference and last result, and
* nothing re-subscribes it. Every already-rendered page therefore went on
* displaying the previous site's data.
*
* Keying the provider on the site is what actually holds: a changed key
* unmounts the entire subtree and remounts it against a brand-new, empty cache,
* so every query refetches with the new X-Site-Id header. There is no path by
* which one site's data can survive into another's view.
*
* The cost is a full remount on switch. That is the honest price of changing
* context, and it only happens when the operator deliberately switches.
*/
function SiteScopedQueryProvider({ children }: { children: React.ReactNode }) {
const siteId = useSiteStore((s) => s.currentSiteId);
const key = siteId == null ? 'all' : String(siteId);
// A new cache per site; `key` is the dependency on purpose.
// eslint-disable-next-line react-hooks/exhaustive-deps
const client = React.useMemo(() => makeQueryClient(), [key]);
return (
<QueryClientProvider key={key} client={client}>
{children}
</QueryClientProvider>
);
}
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<SiteScopedQueryProvider>
<App />
</QueryClientProvider>
</SiteScopedQueryProvider>
</React.StrictMode>
);
+10 -6
View File
@@ -20,6 +20,7 @@ import { useQueryClient, useMutation } from '@tanstack/react-query';
import { format, formatDistanceToNow } from 'date-fns';
import type { Device, DeviceEvent } from '../types';
import clsx from 'clsx';
import { fleetStatus } from '../utils/fleetStatus';
// ─── Primitives ───────────────────────────────────────────────────────────────
@@ -167,7 +168,8 @@ function HealthBar({
wirelessCount: number;
clientSparkline: number[];
}) {
const allOnline = (summary?.devices.offline ?? 0) === 0 && (summary?.devices.total ?? 0) > 0;
const status = fleetStatus(summary?.devices);
const allOnline = status.kind === 'healthy';
const reachFraction = summary?.devices.total
? (summary.devices.online / summary.devices.total)
: 1;
@@ -182,13 +184,14 @@ function HealthBar({
style={{ background: 'var(--accent-soft)' }}
>
<StatusDot
color={allOnline ? 'var(--accent)' : 'var(--warn)'}
color={status.tone === 'good' ? 'var(--accent)'
: status.tone === 'warn' ? 'var(--warn)' : 'var(--ink-4)'}
glow size={10}
/>
</div>
<div>
<div className="text-[14px] font-semibold" style={{ color: 'var(--ink)' }}>
{allOnline ? 'All systems nominal' : `${summary?.devices.offline ?? 0} device(s) unreachable`}
{status.headline}
</div>
<div className="text-[12px]" style={{ color: 'var(--ink-3)' }}>
{summary?.devices.online ?? 0} of {summary?.devices.total ?? 0} reachable
@@ -594,9 +597,10 @@ function OperationsView({
}) {
const qc = useQueryClient();
const canWrite = useCanWrite();
const allOnline = (summary?.devices.offline ?? 0) === 0 && (summary?.devices.total ?? 0) > 0;
const statusText = allOnline ? "Everything's running." : `${summary?.devices.offline} device${summary?.devices.offline !== 1 ? 's' : ''} unreachable.`;
const statusColor = allOnline ? 'var(--accent)' : 'var(--warn)';
const status = fleetStatus(summary?.devices);
const statusText = status.sentence;
const statusColor = status.tone === 'good' ? 'var(--accent)'
: status.tone === 'warn' ? 'var(--warn)' : 'var(--ink-4)';
const deviceList = devices as Device[];
const onlineDevices = deviceList.filter(d => d.status === 'online');
+292
View File
@@ -0,0 +1,292 @@
import { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import { Building2, MapPin, Plus, Pencil, Trash2, Check, X, AlertCircle, RefreshCw } from 'lucide-react';
import { sitesApi, settingsApi } from '../services/api';
import { useSiteStore, type Site } from '../store/siteStore';
import { useCanWrite } from '../hooks/useCanWrite';
import { geocodeAddress, OSM_TILE_URL, OSM_ATTRIBUTION } from '../utils/geocode';
/** World map with one pin per site that has coordinates. */
function SitesMap({ sites, onSelect }: { sites: Site[]; onSelect: (id: number) => void }) {
const containerRef = useRef<HTMLDivElement>(null);
const pinned = sites.filter((s) => s.location_lat != null && s.location_lng != null);
useEffect(() => {
if (!containerRef.current) return;
const map = L.map(containerRef.current, { scrollWheelZoom: false, worldCopyJump: true });
L.tileLayer(OSM_TILE_URL, { attribution: OSM_ATTRIBUTION }).addTo(map);
const points: [number, number][] = [];
for (const s of pinned) {
const lat = Number(s.location_lat);
const lng = Number(s.location_lng);
points.push([lat, lng]);
const marker = L.circleMarker([lat, lng], {
radius: 10, color: '#fff', weight: 2,
fillColor: s.online_count > 0 ? '#3b82f6' : '#94a3b8',
fillOpacity: 0.9,
}).addTo(map);
marker.bindPopup(
`<b>${s.name}</b><br/>${s.address ?? ''}<br/>` +
`${s.device_count} device${s.device_count === 1 ? '' : 's'}` +
`${s.device_count ? ` &middot; ${s.online_count} online` : ''}`
);
marker.on('click', () => onSelect(s.id));
}
// Fit to the sites we have; fall back to a whole-world view when none are
// geocoded yet, so the map is never a blank grey box.
if (points.length === 1) map.setView(points[0], 11);
else if (points.length > 1) map.fitBounds(L.latLngBounds(points).pad(0.25));
else map.setView([20, 0], 2);
return () => { map.remove(); };
}, [JSON.stringify(pinned.map((s) => [s.id, s.location_lat, s.location_lng, s.online_count])), onSelect]);
return (
<div className="card overflow-hidden">
<div className="flex items-center gap-2 px-4 py-3 border-b border-gray-200 dark:border-slate-700">
<MapPin className="w-4 h-4 text-blue-500" />
<h3 className="text-sm font-semibold text-gray-900 dark:text-white flex-1">Site locations</h3>
<span className="text-xs text-gray-400">
{pinned.length} of {sites.length} mapped
</span>
</div>
<div ref={containerRef} style={{ height: 420, isolation: 'isolate' }} />
</div>
);
}
export default function SitesPage() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const canWrite = useCanWrite();
const { setCurrentSite } = useSiteStore();
const [editing, setEditing] = useState<Site | null>(null);
const [adding, setAdding] = useState(false);
const [name, setName] = useState('');
const [address, setAddress] = useState('');
const [error, setError] = useState('');
const [busy, setBusy] = useState(false);
const { data: sites = [], isLoading } = useQuery({
queryKey: ['sites'],
queryFn: () => sitesApi.list().then((r) => r.data),
});
// Geocoding and tiles are third-party requests that disclose where your
// hardware is. Same off-switch as device locations (issue #106).
const { data: settings } = useQuery({
queryKey: ['app-settings'],
queryFn: () => settingsApi.get().then((r) => r.data),
staleTime: 300_000,
});
const mapsEnabled = settings?.['maps_enabled'] !== false;
function openSite(id: number) {
// Navigate before switching: setting the site remounts the tree (see
// SiteScopedQueryProvider in main.tsx), so the route must be settled first.
navigate('/dashboard');
setCurrentSite(id);
}
async function save() {
const trimmed = name.trim();
if (!trimmed) { setError('Site name is required'); return; }
setBusy(true);
setError('');
try {
const addr = address.trim();
// Only geocode when maps are enabled and the address actually changed —
// Nominatim is a courtesy service, not something to hammer on every save.
let coords: { lat: number; lng: number } | null = null;
const addressChanged = addr && addr !== (editing?.address ?? '');
if (mapsEnabled && addressChanged) coords = await geocodeAddress(addr);
const payload = {
name: trimmed,
address: addr,
...(addressChanged
? { location_lat: coords?.lat ?? null, location_lng: coords?.lng ?? null }
: {}),
};
if (editing) await sitesApi.update(editing.id, payload);
else await sitesApi.create(payload);
await queryClient.invalidateQueries({ queryKey: ['sites'] });
cancel();
} catch (e) {
const err = e as { response?: { data?: { error?: string } }; message?: string };
setError(err.response?.data?.error ?? err.message ?? 'Could not save site');
} finally {
setBusy(false);
}
}
const removeMutation = useMutation({
mutationFn: (id: number) => sitesApi.remove(id),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['sites'] }),
onError: (e: unknown) => {
const err = e as { response?: { data?: { error?: string } } };
setError(err.response?.data?.error ?? 'Could not delete site');
},
});
function cancel() {
setEditing(null); setAdding(false); setName(''); setAddress(''); setError('');
}
function startEdit(s: Site) {
setEditing(s); setAdding(false); setName(s.name); setAddress(s.address ?? ''); setError('');
}
const totalDevices = sites.reduce((n, s) => n + s.device_count, 0);
const totalOnline = sites.reduce((n, s) => n + s.online_count, 0);
return (
<div className="space-y-4">
<div className="flex items-center justify-between flex-wrap gap-2">
<div>
<h1 className="text-xl font-semibold text-gray-900 dark:text-white">All sites</h1>
<p className="text-sm text-gray-500 dark:text-slate-400">
{sites.length} site{sites.length === 1 ? '' : 's'} · {totalDevices} device
{totalDevices === 1 ? '' : 's'} · {totalOnline} online
</p>
</div>
{canWrite && !adding && !editing && (
<button
onClick={() => { setAdding(true); setName(''); setAddress(''); setError(''); }}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-lg bg-blue-600 text-white hover:bg-blue-500"
>
<Plus className="w-4 h-4" /> New site
</button>
)}
</div>
{error && (
<div className="flex items-start gap-2 px-3 py-2 rounded-lg bg-red-50 dark:bg-red-950/40 text-sm text-red-700 dark:text-red-300">
<AlertCircle className="w-4 h-4 mt-0.5 flex-shrink-0" />
<span className="flex-1">{error}</span>
<button onClick={() => setError('')}><X className="w-4 h-4" /></button>
</div>
)}
{(adding || editing) && (
<div className="card p-4 space-y-3">
<div className="text-sm font-semibold text-gray-900 dark:text-white">
{editing ? `Rename “${editing.name}”` : 'New site'}
</div>
<div className="grid gap-3 sm:grid-cols-2">
<input
autoFocus
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Site name"
className="input"
/>
<input
value={address}
onChange={(e) => setAddress(e.target.value)}
placeholder="Address (optional)"
className="input"
/>
</div>
<p className="text-xs text-gray-400">
{mapsEnabled
? 'The address is geocoded so the site appears on the map above.'
: 'Maps are disabled in Settings, so the address is stored as text only.'}
</p>
<div className="flex gap-2">
<button
onClick={save}
disabled={busy}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-lg bg-blue-600 text-white hover:bg-blue-500 disabled:opacity-50"
>
{busy ? <RefreshCw className="w-4 h-4 animate-spin" /> : <Check className="w-4 h-4" />}
{busy ? 'Saving…' : 'Save'}
</button>
<button onClick={cancel} className="px-3 py-1.5 text-sm rounded-lg border border-gray-300 dark:border-slate-600">
Cancel
</button>
</div>
</div>
)}
{mapsEnabled && sites.length > 0 && <SitesMap sites={sites} onSelect={openSite} />}
<div className="card overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-xs uppercase tracking-wide text-gray-500 dark:text-slate-400 border-b border-gray-200 dark:border-slate-700">
<th className="px-4 py-2.5 font-medium">Site</th>
<th className="px-4 py-2.5 font-medium">Address</th>
<th className="px-4 py-2.5 font-medium text-right">Devices</th>
<th className="px-4 py-2.5 font-medium text-right">Online</th>
{canWrite && <th className="px-4 py-2.5 w-20" />}
</tr>
</thead>
<tbody>
{isLoading && (
<tr><td colSpan={5} className="px-4 py-6 text-center text-gray-400">Loading…</td></tr>
)}
{!isLoading && sites.length === 0 && (
<tr><td colSpan={5} className="px-4 py-6 text-center text-gray-400">No sites yet.</td></tr>
)}
{sites.map((s) => (
<tr
key={s.id}
className="border-b border-gray-100 dark:border-slate-800 last:border-0 hover:bg-gray-50 dark:hover:bg-slate-800/50 cursor-pointer"
onClick={() => openSite(s.id)}
>
<td className="px-4 py-2.5">
<div className="flex items-center gap-2">
<Building2 className="w-4 h-4 text-gray-400 flex-shrink-0" />
<span className="font-medium text-gray-900 dark:text-white">{s.name}</span>
{s.is_default && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-gray-100 dark:bg-slate-700 text-gray-500 dark:text-slate-400">
default
</span>
)}
</div>
</td>
<td className="px-4 py-2.5 text-gray-500 dark:text-slate-400">
{s.address || <span className="text-gray-300 dark:text-slate-600">—</span>}
</td>
<td className="px-4 py-2.5 text-right text-gray-900 dark:text-white">{s.device_count}</td>
<td className="px-4 py-2.5 text-right">
<span className={s.online_count === s.device_count && s.device_count > 0
? 'text-green-600 dark:text-green-400'
: 'text-gray-500 dark:text-slate-400'}>
{s.online_count}
</span>
</td>
{canWrite && (
<td className="px-4 py-2.5" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-end gap-1">
<button
onClick={() => startEdit(s)}
className="p-1.5 rounded hover:bg-gray-100 dark:hover:bg-slate-700 text-gray-400"
title="Rename"
>
<Pencil className="w-3.5 h-3.5" />
</button>
<button
onClick={() => removeMutation.mutate(s.id)}
className="p-1.5 rounded hover:bg-red-50 dark:hover:bg-red-950/40 text-gray-400 hover:text-red-500"
title="Delete"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
+27
View File
@@ -1,5 +1,6 @@
import axios, { AxiosError } from 'axios';
import { useAuthStore } from '../store/authStore';
import { useSiteStore } from '../store/siteStore';
import type {
Device,
Interface,
@@ -32,6 +33,12 @@ const api = axios.create({
api.interceptors.request.use((config) => {
const token = useAuthStore.getState().token;
if (token) config.headers.Authorization = `Bearer ${token}`;
// Active site travels with every request (issue #130). Injecting it here
// rather than threading it through ~160 query keys means a new query is
// scoped correctly by default and cannot be forgotten. null is the all-sites
// view, which sends no header and so behaves exactly as before sites existed.
const siteId = useSiteStore.getState().currentSiteId;
if (siteId != null) config.headers['X-Site-Id'] = String(siteId);
return config;
});
@@ -1610,3 +1617,23 @@ export const configTemplatesApi = {
};
export default api;
// ─── Sites (issue #130) ───────────────────────────────────────────────────────
export interface SitePayload {
name?: string;
address?: string;
notes?: string;
location_lat?: number | null;
location_lng?: number | null;
}
export const sitesApi = {
list: () => api.get<import('../store/siteStore').Site[]>('/sites'),
create: (payload: SitePayload) =>
api.post<import('../store/siteStore').Site>('/sites', payload),
update: (id: number, payload: SitePayload) =>
api.put<import('../store/siteStore').Site>(`/sites/${id}`, payload),
remove: (id: number) => api.delete<{ ok: boolean }>(`/sites/${id}`),
assignDevices: (id: number, deviceIds: number[]) =>
api.post<{ ok: boolean; moved: number }>(`/sites/${id}/devices`, { deviceIds }),
};
+38
View File
@@ -0,0 +1,38 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
export interface Site {
id: number;
name: string;
address: string | null;
location_lat: number | null;
location_lng: number | null;
notes: string | null;
is_default: boolean;
device_count: number;
online_count: number;
}
/** null = the all-sites view; a number = that site. */
export type SiteSelection = number | null;
interface SiteState {
currentSiteId: SiteSelection;
setCurrentSite: (id: SiteSelection) => void;
}
export const useSiteStore = create<SiteState>()(
persist(
(set) => ({
// Undefined until the site list loads. A brand-new install has exactly one
// site, and App resolves the selection to it, so single-site deployments
// never see the concept.
currentSiteId: null,
setCurrentSite: (id) => set({ currentSiteId: id }),
}),
{
name: 'mikrotik-site',
partialize: (state) => ({ currentSiteId: state.currentSiteId }),
}
)
);
+88
View File
@@ -0,0 +1,88 @@
import { describe, it, expect } from 'vitest';
import { QueryClient, QueryObserver } from '@tanstack/react-query';
/**
* Why site switching is done by rekeying the QueryClient rather than clearing it.
*
* The first implementation called queryClient.clear() on switch and looked
* correct in review: the cache really is emptied. But every already-rendered
* page went on showing the previous site's data, because a *mounted* observer
* keeps its own reference to the destroyed query and its last result, and
* nothing re-subscribes it.
*
* These tests pin that behaviour down so the shortcut cannot come back.
*/
function makeClient() {
return new QueryClient({
defaultOptions: { queries: { staleTime: 30_000, retry: false } },
});
}
/** Stands in for a page: subscribes and exposes what it would render. */
function mountObserver(client: QueryClient, fetcher: () => Promise<string>) {
const observer = new QueryObserver(client, { queryKey: ['devices'], queryFn: fetcher });
const unsubscribe = observer.subscribe(() => {});
return { observer, unsubscribe, current: () => observer.getCurrentResult().data };
}
const settle = () => new Promise((r) => setTimeout(r, 20));
describe('site switching and the query cache', () => {
it('clear() does NOT update a mounted observer — this was the bug', async () => {
const client = makeClient();
let site = 'site-1';
const { unsubscribe, current } = mountObserver(client, async () => `data-for-${site}`);
await settle();
expect(current()).toBe('data-for-site-1');
// Switch site the way the broken version did.
site = 'site-2';
client.clear();
await settle();
// The cache is empty, yet the mounted page still renders site 1's data.
expect(client.getQueryCache().getAll()).toHaveLength(0);
expect(current()).toBe('data-for-site-1');
unsubscribe();
});
it('a fresh QueryClient gives a mounted page the new site data', async () => {
let site = 'site-1';
const fetcher = async () => `data-for-${site}`;
const first = makeClient();
const a = mountObserver(first, fetcher);
await settle();
expect(a.current()).toBe('data-for-site-1');
// What the keyed provider does: the subtree remounts against a new client.
a.unsubscribe();
site = 'site-2';
const second = makeClient();
const b = mountObserver(second, fetcher);
await settle();
expect(b.current()).toBe('data-for-site-2');
// Nothing from the previous site survived into the new cache.
expect(second.getQueryCache().getAll()).toHaveLength(1);
b.unsubscribe();
});
it('invalidateQueries() does refetch a mounted observer', async () => {
// The in-place alternative, used where the tree must not remount (moving a
// device between sites from the device page).
const client = makeClient();
let site = 'site-1';
const { unsubscribe, current } = mountObserver(client, async () => `data-for-${site}`);
await settle();
expect(current()).toBe('data-for-site-1');
site = 'site-2';
await client.invalidateQueries();
await settle();
expect(current()).toBe('data-for-site-2');
unsubscribe();
});
});
+1
View File
@@ -64,6 +64,7 @@ export interface Device {
status: DeviceStatus;
last_seen?: string;
notes?: string;
site_id?: number | null;
location_address?: string;
location_lat?: number;
location_lng?: number;
+53
View File
@@ -0,0 +1,53 @@
import { describe, it, expect } from 'vitest';
import { fleetStatus } from './fleetStatus';
describe('fleetStatus', () => {
// The reported bug: switching site remounts with no summary, and the header
// interpolated it straight into the string.
it('reports loading rather than "undefined devices unreachable"', () => {
for (const absent of [undefined, null]) {
const s = fleetStatus(absent);
expect(s.kind).toBe('loading');
expect(s.headline).not.toMatch(/undefined|NaN/);
expect(s.sentence).not.toMatch(/undefined|NaN/);
}
});
// Defaulting the missing summary to 0 would have swapped one wrong claim for
// another: an unheard-from fleet is not a healthy one.
it('does not claim health while loading', () => {
expect(fleetStatus(undefined).tone).toBe('neutral');
expect(fleetStatus(undefined).kind).not.toBe('healthy');
});
it('distinguishes an empty site from a healthy one', () => {
const empty = fleetStatus({ total: 0, online: 0, offline: 0 });
expect(empty.kind).toBe('empty');
expect(empty.sentence).toBe('No devices in this site yet.');
const healthy = fleetStatus({ total: 3, online: 3, offline: 0 });
expect(healthy.kind).toBe('healthy');
expect(healthy.tone).toBe('good');
});
it('counts and pluralises unreachable devices', () => {
expect(fleetStatus({ total: 4, online: 3, offline: 1 }).headline)
.toBe('1 device unreachable');
expect(fleetStatus({ total: 4, online: 2, offline: 2 }).sentence)
.toBe('2 devices unreachable.');
expect(fleetStatus({ total: 4, online: 2, offline: 2 }).tone).toBe('warn');
});
it('never emits undefined or NaN for any input', () => {
const cases = [
undefined, null,
{ total: 0, online: 0, offline: 0 },
{ total: 1, online: 0, offline: 1 },
{ total: 9, online: 9, offline: 0 },
];
for (const c of cases) {
const s = fleetStatus(c);
expect(`${s.headline} ${s.sentence}`).not.toMatch(/undefined|NaN|null/);
}
});
});
+60
View File
@@ -0,0 +1,60 @@
/**
* Resolves the dashboard's headline device status.
*
* Exists because the summary can legitimately be *absent* rather than zero, and
* the two are not the same claim. Switching site remounts the tree against an
* empty cache (see SiteScopedQueryProvider), so there is always a moment with no
* summary — which rendered "undefined device(s) unreachable". Substituting 0
* would have been just as wrong in the other direction: reporting a healthy
* fleet we have not heard from yet.
*
* A site with no devices is a third distinct state, and one multi-site makes
* ordinary: "0 devices unreachable" is technically true and reads as nonsense.
*/
export interface FleetSummaryDevices {
total: number;
online: number;
offline: number;
}
export type FleetStatusKind = 'loading' | 'empty' | 'healthy' | 'degraded';
export interface FleetStatus {
kind: FleetStatusKind;
/** Compact label for the summary header. */
headline: string;
/** Sentence form for the operations hero. */
sentence: string;
tone: 'neutral' | 'good' | 'warn';
}
export function fleetStatus(devices?: FleetSummaryDevices | null): FleetStatus {
if (!devices) {
return { kind: 'loading', headline: 'Checking…', sentence: 'Checking…', tone: 'neutral' };
}
if (devices.total === 0) {
return {
kind: 'empty',
headline: 'No devices yet',
sentence: 'No devices in this site yet.',
tone: 'neutral',
};
}
if (devices.offline === 0) {
return {
kind: 'healthy',
headline: 'All systems nominal',
sentence: "Everything's running.",
tone: 'good',
};
}
const n = devices.offline;
const noun = `device${n === 1 ? '' : 's'}`;
return {
kind: 'degraded',
headline: `${n} ${noun} unreachable`,
sentence: `${n} ${noun} unreachable.`,
tone: 'warn',
};
}
+28
View File
@@ -0,0 +1,28 @@
/**
* Address → coordinates via Nominatim.
*
* Extracted from DeviceLocationSection so device locations and site locations
* share one implementation (issue #130). Callers must check the `maps_enabled`
* setting before calling: geocoding is a third-party request that discloses
* where your hardware is, and issue #106 exists to let operators refuse it.
*/
export async function geocodeAddress(address: string): Promise<{ lat: number; lng: number } | null> {
try {
const res = await fetch(
`https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(address)}&format=json&limit=1`,
{ headers: { Accept: 'application/json' } }
);
const data = await res.json();
if (Array.isArray(data) && data.length > 0) {
return { lat: parseFloat(data[0].lat), lng: parseFloat(data[0].lon) };
}
} catch {
// Geocoding is best-effort: a site without coordinates simply has no pin.
}
return null;
}
/** The tile source used by every map in the app, so attribution stays consistent. */
export const OSM_TILE_URL = 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png';
export const OSM_ATTRIBUTION =
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors';
+1 -1
View File
@@ -1 +1 @@
export const APP_VERSION = 'v0.23.35 Beta';
export const APP_VERSION = 'v0.24.0 Beta';
+1
View File
@@ -59,6 +59,7 @@ nav:
- Getting started:
- Configuration: configuration.md
- Operating:
- Sites: sites.md
- Change Guard and Config Health: change-guard.md
- Polling and scaling: scaling.md
- Alerting: alerting.md
+2 -1
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env bash
# Run the same gates GitHub Actions runs, locally, before pushing.
#
# Mirrors .github/workflows/ci.yml exactly (frontend lint/type-check/build,
# Mirrors .github/workflows/ci.yml exactly (frontend lint/type-check/test/build,
# backend lint/type-check/build, prod-dependency audits) and additionally runs
# the backend Jest suite, which CI does not currently cover.
#
@@ -49,6 +49,7 @@ run "backend: build" backend npm run build
run "backend: tests" backend npx jest --silent
run "frontend: lint" frontend npm run lint
run "frontend: type-check" frontend npx tsc --noEmit
run "frontend: tests" frontend npm test
run "frontend: build" frontend npm run build
if [[ $SKIP_AUDIT -eq 0 ]]; then