diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 0a5b0f7..a098397 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -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
diff --git a/README.md b/README.md
index 6751e78..5e34f14 100644
--- a/README.md
+++ b/README.md
@@ -6,7 +6,7 @@
-
+
@@ -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 |
diff --git a/backend/package.json b/backend/package.json
index 045dbf5..f39fecf 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -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": {
diff --git a/backend/src/db/migrate.ts b/backend/src/db/migrate.ts
index 62f0a9d..a7a834f 100644
--- a/backend/src/db/migrate.ts
+++ b/backend/src/db/migrate.ts
@@ -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 {
);
}
+ // 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) {
diff --git a/backend/src/index.ts b/backend/src/index.ts
index cbbe20f..92e5990 100644
--- a/backend/src/index.ts
+++ b/backend/src/index.ts
@@ -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);
diff --git a/backend/src/middleware/site.ts b/backend/src/middleware/site.ts
new file mode 100644
index 0000000..00b23db
--- /dev/null
+++ b/backend/src/middleware/site.ts
@@ -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;
+}
diff --git a/backend/src/routes/backups.ts b/backend/src/routes/backups.ts
index 9bf65f6..ad9f58e 100644
--- a/backend/src/routes/backups.ts
+++ b/backend/src/routes/backups.ts
@@ -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);
diff --git a/backend/src/routes/clients.ts b/backend/src/routes/clients.ts
index f23e2d6..70ae868 100644
--- a/backend/src/routes/clients.ts
+++ b/backend/src/routes/clients.ts
@@ -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`);
}
diff --git a/backend/src/routes/commands.ts b/backend/src/routes/commands.ts
index 6b03ee9..f112b96 100644
--- a/backend/src/routes/commands.ts
+++ b/backend/src/routes/commands.ts
@@ -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);
diff --git a/backend/src/routes/devices.ts b/backend/src/routes/devices.ts
index 551c2be..5c5a2bd 100644
--- a/backend/src/routes/devices.ts
+++ b/backend/src/routes/devices.ts
@@ -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);
diff --git a/backend/src/routes/events.ts b/backend/src/routes/events.ts
index 4071ecb..e5735a3 100644
--- a/backend/src/routes/events.ts
+++ b/backend/src/routes/events.ts
@@ -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}` : ''}`
),
]);
diff --git a/backend/src/routes/firmware.ts b/backend/src/routes/firmware.ts
index 638488a..fa93ab0 100644
--- a/backend/src/routes/firmware.ts
+++ b/backend/src/routes/firmware.ts
@@ -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(`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(
+ `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);
});
diff --git a/backend/src/routes/metrics.ts b/backend/src/routes/metrics.ts
index a311c70..f8356b9 100644
--- a/backend/src/routes/metrics.ts
+++ b/backend/src/routes/metrics.ts
@@ -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_; 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}` : ''}`
),
]);
diff --git a/backend/src/routes/networkServices.ts b/backend/src/routes/networkServices.ts
index 82eb3b3..f1170bf 100644
--- a/backend/src/routes/networkServices.ts
+++ b/backend/src/routes/networkServices.ts
@@ -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(`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(
+ `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(`SELECT * FROM devices WHERE status = 'online' ORDER BY name`);
+ const siteFilter = siteScopeDevices(activeSite(req));
+ const devices = await query(
+ `SELECT * FROM devices WHERE status = 'online'
+ ${siteFilter ? `AND ${siteFilter}` : ''} ORDER BY name`
+ );
const results = await Promise.allSettled(
devices.map(async (device: DeviceRow) => {
diff --git a/backend/src/routes/operations.ts b/backend/src/routes/operations.ts
index 901d401..3a769f5 100644
--- a/backend/src/routes/operations.ts
+++ b/backend/src/routes/operations.ts
@@ -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();
@@ -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