From 7b994ad935727a231a3b609e4ce4d4c64420ba23 Mon Sep 17 00:00:00 2001
From: 2GT-Rich <107219997+2GT-Rich@users.noreply.github.com>
Date: Mon, 7 Sep 2026 12:02:00 -0700
Subject: [PATCH] feat(sites): one install, many networks (v0.24.0-beta)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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
---
.github/workflows/ci.yml | 5 +-
README.md | 5 +-
backend/package.json | 2 +-
backend/src/db/migrate.ts | 40 +++
backend/src/index.ts | 5 +
backend/src/middleware/site.ts | 34 ++
backend/src/routes/backups.ts | 4 +
backend/src/routes/clients.ts | 8 +
backend/src/routes/commands.ts | 8 +-
backend/src/routes/devices.ts | 38 +--
backend/src/routes/events.ts | 16 +-
backend/src/routes/firmware.ts | 30 +-
backend/src/routes/metrics.ts | 105 +++++--
backend/src/routes/networkServices.ts | 18 +-
backend/src/routes/operations.ts | 74 ++++-
backend/src/routes/routers.ts | 14 +-
backend/src/routes/search.ts | 15 +-
backend/src/routes/sites.ts | 183 +++++++++++
backend/src/routes/switches.ts | 19 +-
backend/src/routes/topology.ts | 25 +-
backend/src/routes/wireless.ts | 28 +-
backend/src/services/DeviceBulkAddWorker.ts | 15 +-
.../services/__tests__/deviceColumns.test.ts | 53 ++++
backend/src/services/deviceColumns.ts | 17 +
backend/src/services/deviceCreation.ts | 25 +-
.../src/services/mikrotik/DeviceCollector.ts | 24 ++
backend/src/utils/__tests__/siteScope.test.ts | 148 +++++++++
backend/src/utils/siteScope.ts | 99 ++++++
docs/README.md | 1 +
docs/api.md | 19 ++
docs/sites.md | 127 ++++++++
frontend/package.json | 2 +-
frontend/src/App.tsx | 5 +-
frontend/src/components/SiteLanding.tsx | 26 ++
.../device-detail/DeviceLocationSection.tsx | 71 +++--
frontend/src/components/layout/Sidebar.tsx | 4 +
.../src/components/layout/SiteSelector.tsx | 251 +++++++++++++++
frontend/src/main.tsx | 53 +++-
frontend/src/pages/DashboardPage.tsx | 16 +-
frontend/src/pages/SitesPage.tsx | 292 ++++++++++++++++++
frontend/src/services/api.ts | 27 ++
frontend/src/store/siteStore.ts | 38 +++
frontend/src/store/siteSwitch.test.ts | 88 ++++++
frontend/src/types/index.ts | 1 +
frontend/src/utils/fleetStatus.test.ts | 53 ++++
frontend/src/utils/fleetStatus.ts | 60 ++++
frontend/src/utils/geocode.ts | 28 ++
frontend/src/version.ts | 2 +-
mkdocs.yml | 1 +
scripts/ci-preflight.sh | 3 +-
50 files changed, 2074 insertions(+), 151 deletions(-)
create mode 100644 backend/src/middleware/site.ts
create mode 100644 backend/src/routes/sites.ts
create mode 100644 backend/src/services/__tests__/deviceColumns.test.ts
create mode 100644 backend/src/services/deviceColumns.ts
create mode 100644 backend/src/utils/__tests__/siteScope.test.ts
create mode 100644 backend/src/utils/siteScope.ts
create mode 100644 docs/sites.md
create mode 100644 frontend/src/components/SiteLanding.tsx
create mode 100644 frontend/src/components/layout/SiteSelector.tsx
create mode 100644 frontend/src/pages/SitesPage.tsx
create mode 100644 frontend/src/store/siteStore.ts
create mode 100644 frontend/src/store/siteSwitch.test.ts
create mode 100644 frontend/src/utils/fleetStatus.test.ts
create mode 100644 frontend/src/utils/fleetStatus.ts
create mode 100644 frontend/src/utils/geocode.ts
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