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 @@

- Version + Version License Docker 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 | 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> { return out; } -async function detectAnomalies(): Promise { +async function detectAnomalies(siteId: number | null): Promise { const items: AttentionItem[] = []; const hour = new Date().getUTCHours(); const nameById = new Map(); - 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 { 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 { } // 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(`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( + `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(`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( + `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(); } diff --git a/backend/src/routes/routers.ts b/backend/src/routes/routers.ts index 5786a75..0221aec 100644 --- a/backend/src/routes/routers.ts +++ b/backend/src/routes/routers.ts @@ -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( - `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( - `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( diff --git a/backend/src/routes/search.ts b/backend/src/routes/search.ts index 117abd1..d5b6941 100644 --- a/backend/src/routes/search.ts +++ b/backend/src/routes/search.ts @@ -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] diff --git a/backend/src/routes/sites.ts b/backend/src/routes/sites.ts new file mode 100644 index 0000000..4ccff37 --- /dev/null +++ b/backend/src/routes/sites.ts @@ -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( + `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( + `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( + `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; diff --git a/backend/src/routes/switches.ts b/backend/src/routes/switches.ts index 19f7929..8bc530d 100644 --- a/backend/src/routes/switches.ts +++ b/backend/src/routes/switches.ts @@ -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( - `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( - `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( diff --git a/backend/src/routes/topology.ts b/backend/src/routes/topology.ts index 002f43b..6d7a6a5 100644 --- a/backend/src/routes/topology.ts +++ b/backend/src/routes/topology.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 { 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( `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( `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( `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( `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}` : ''}` ), ]); diff --git a/backend/src/routes/wireless.ts b/backend/src/routes/wireless.ts index 0800fdd..cbfb0fc 100644 --- a/backend/src/routes/wireless.ts +++ b/backend/src/routes/wireless.ts @@ -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 = { '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`), diff --git a/backend/src/services/DeviceBulkAddWorker.ts b/backend/src/services/DeviceBulkAddWorker.ts index 0605efc..e420740 100644 --- a/backend/src/services/DeviceBulkAddWorker.ts +++ b/backend/src/services/DeviceBulkAddWorker.ts @@ -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> { @@ -68,7 +70,7 @@ async function appendResults(jobId: string, rows: BulkAddResultRow[]): Promise): Promise { - 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): Promise { ...item, device_type: item.device_type || 'router', }, - poller + poller, + { siteId: siteId ?? null } ); let failMsg = 'Failed'; @@ -128,11 +131,15 @@ async function processJob(job: Job): Promise { }); } -export async function enqueueBulkAddJob(jobId: string, items: CreateDeviceInput[]): Promise { +export async function enqueueBulkAddJob( + jobId: string, + items: CreateDeviceInput[], + siteId: number | null = null +): Promise { 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 }, diff --git a/backend/src/services/__tests__/deviceColumns.test.ts b/backend/src/services/__tests__/deviceColumns.test.ts new file mode 100644 index 0000000..4d33f90 --- /dev/null +++ b/backend/src/services/__tests__/deviceColumns.test.ts @@ -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/); + } + } + }); +}); diff --git a/backend/src/services/deviceColumns.ts b/backend/src/services/deviceColumns.ts new file mode 100644 index 0000000..0a49fd5 --- /dev/null +++ b/backend/src/services/deviceColumns.ts @@ -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(); diff --git a/backend/src/services/deviceCreation.ts b/backend/src/services/deviceCreation.ts index 377e716..eff1777 100644 --- a/backend/src/services/deviceCreation.ts +++ b/backend/src/services/deviceCreation.ts @@ -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] ); diff --git a/backend/src/services/mikrotik/DeviceCollector.ts b/backend/src/services/mikrotik/DeviceCollector.ts index 9102e56..1fee020 100644 --- a/backend/src/services/mikrotik/DeviceCollector.ts +++ b/backend/src/services/mikrotik/DeviceCollector.ts @@ -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); diff --git a/backend/src/utils/__tests__/siteScope.test.ts b/backend/src/utils/__tests__/siteScope.test.ts new file mode 100644 index 0000000..6688e7e --- /dev/null +++ b/backend/src/utils/__tests__/siteScope.test.ts @@ -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'); + }); +}); diff --git a/backend/src/utils/siteScope.ts b/backend/src/utils/siteScope.ts new file mode 100644 index 0000000..958735b --- /dev/null +++ b/backend/src/utils/siteScope.ts @@ -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_` 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}`; +} diff --git a/docs/README.md b/docs/README.md index 8db6473..1ebd251 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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 | diff --git a/docs/api.md b/docs/api.md index e546b32..bdc8fa9 100644 --- a/docs/api.md +++ b/docs/api.md @@ -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 diff --git a/docs/sites.md b/docs/sites.md new file mode 100644 index 0000000..fb2623a --- /dev/null +++ b/docs/sites.md @@ -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. diff --git a/frontend/package.json b/frontend/package.json index fc69477..a3d1f0e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mikrotik-manager-frontend", - "version": "0.23.35-beta", + "version": "0.24.0-beta", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 10b4af9..7b232ed 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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() { } > - } /> + } /> } /> } /> } /> @@ -59,6 +61,7 @@ export default function App() { } /> } /> } /> + } /> } /> {/* Legacy routes — consolidated in the v0.16.4 UI reorganization */} } /> diff --git a/frontend/src/components/SiteLanding.tsx b/frontend/src/components/SiteLanding.tsx new file mode 100644 index 0000000..b79c693 --- /dev/null +++ b/frontend/src/components/SiteLanding.tsx @@ -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 ; + return ; +} diff --git a/frontend/src/components/device-detail/DeviceLocationSection.tsx b/frontend/src/components/device-detail/DeviceLocationSection.tsx index 5381ec1..c16062c 100644 --- a/frontend/src/components/device-detail/DeviceLocationSection.tsx +++ b/frontend/src/components/device-detail/DeviceLocationSection.tsx @@ -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: '© OpenStreetMap 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(`${address}`).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 ? (

+ {showSitePicker && ( +
+ + +

+ Moving a device takes its clients, events and history with it. Applies immediately. +

+
+ )}
) : (
+ {showSitePicker && ( + } label="Site" + value={currentSite?.name} + emptyText="Unassigned" /> + )} } label="Location" value={device.location_address} emptyText="No location set" /> diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index 615155e..87e4dcb 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -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) {
+ {/* Site selector — under the brand block, deliberately separate from it */} + + {/* Navigation */}