feat(sites): one install, many networks (v0.24.0-beta)
Closes #130. Multi-site, not multi-tenancy: a site is a named collection of devices whose clients, events, topology and dashboards are viewed separately. Sites separate what you see, not who you are — the admin/manage/read-only roles are unchanged and every user sees every site. Per-site permissions would have multiplied the work for a need nobody stated. Two measurements decided the design. Every device-derived table hangs off devices(id) — 25 of 50 tables, 35 foreign keys — so a single site_id column on devices scopes clients, events, topology, backups and the rest transitively. No other table needed one. The frontend has 162 useQuery call sites across 57 files. Threading a site through each would be a large change that leaves a permanent trap: every future query another chance to forget. The site is injected in the existing axios interceptor instead, beside the auth token, so a new query is scoped by default. Only 23 collection routes needed filtering; the 131 per-device routes are already scoped by their device. Existing installs are untouched. Migration creates one "Default Site", adopts every device into it, and a single-site install never meets the concept. An absent X-Site-Id header means unscoped, so existing API tokens and scripts behave exactly as before. Polling stays fleet-wide. Sites are a view over data already gathered, not a change to how it is gathered — a device does not stop being monitored because you are looking elsewhere. Rogue-AP detection scopes the scans but keeps the "these are ours" BSSID set fleet-wide, or your own AP in another site reads as an evil twin. Bulk actions do obey the selector: an operation that silently spanned two customers is the blast radius the wave guards exist to contain. Four rounds of defects came out of testing the UI rather than the API, and the first is the one worth recording. Switching sites called queryClient.clear(), which empties the cache and reviews as correct. It isn't: clear() destroys each query but a *mounted* observer keeps its own reference and last result, so every rendered page went on showing the previous site's data. The QueryClient is now keyed on the site, which remounts the tree against an empty cache. Three tests pin the old behaviour down so the shortcut cannot return. The others: an unscoped fallback path behind the Connected Clients chart; Config Health, TX retries and the activity feed reading fleet-wide; and site_id added to the device list query but none of the other four that build a device payload, so the Site field could not read its own value and appeared to reject every change while the writes were landing. That last one is now structural — DEVICE_BASE_COLUMNS is shared by all five, with a test that fails when a device payload is hand-rolled, and which found two further instances while being written. Frontend tests were never run by CI despite vitest being configured and six suites existing; they now run in both the workflow and ci-preflight. Verified against a live four-device fleet: the migration left every figure identical, an empty site returns zero from every read endpoint, moving a device carries its history with it, and deleting a site with devices is refused. Minor bump rather than patch: sites change the shape of the product rather than adding to it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
e47819e232
commit
7b994ad935
@@ -967,6 +967,27 @@ CREATE TABLE IF NOT EXISTS device_ssh_keys (
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Multi-site (issue #130). A site is a named collection of devices whose data
|
||||
-- is viewed separately. Because ~25 tables hang off devices(id), a single
|
||||
-- site_id on devices scopes clients, events, topology, backups and the rest
|
||||
-- transitively -- no other table needs a site column.
|
||||
CREATE TABLE IF NOT EXISTS sites (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(120) NOT NULL UNIQUE,
|
||||
address TEXT,
|
||||
location_lat NUMERIC(10,7),
|
||||
location_lng NUMERIC(10,7),
|
||||
notes TEXT,
|
||||
is_default BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Deliberately NOT "ON DELETE CASCADE": removing a site must never remove the
|
||||
-- devices in it. The delete endpoint refuses while devices remain.
|
||||
ALTER TABLE devices ADD COLUMN IF NOT EXISTS site_id INTEGER REFERENCES sites(id);
|
||||
CREATE INDEX IF NOT EXISTS idx_devices_site ON devices(site_id);
|
||||
`;
|
||||
|
||||
const DEFAULT_SETTINGS = [
|
||||
@@ -1028,6 +1049,25 @@ export async function runMigrations(): Promise<void> {
|
||||
);
|
||||
}
|
||||
|
||||
// Multi-site backfill. Existing single-network installs must come out of
|
||||
// this indistinguishable from how they went in: one site, every device in
|
||||
// it, nothing moved. Idempotent -- reruns are a no-op.
|
||||
const siteCount = await client.query('SELECT COUNT(*) FROM sites');
|
||||
if (parseInt(siteCount.rows[0].count, 10) === 0) {
|
||||
await client.query(
|
||||
`INSERT INTO sites (name, is_default) VALUES ($1, TRUE)`,
|
||||
['Default Site']
|
||||
);
|
||||
console.log('Default Site created');
|
||||
}
|
||||
// Adopt any device without a site (covers both the initial backfill and
|
||||
// devices added by an older build mid-upgrade).
|
||||
const adopted = await client.query(
|
||||
`UPDATE devices SET site_id = (SELECT id FROM sites WHERE is_default = TRUE ORDER BY id LIMIT 1)
|
||||
WHERE site_id IS NULL`
|
||||
);
|
||||
if (adopted.rowCount) console.log(`Assigned ${adopted.rowCount} device(s) to the default site`);
|
||||
|
||||
// Create default admin user if no users exist
|
||||
const userCount = await client.query('SELECT COUNT(*) FROM users');
|
||||
if (parseInt(userCount.rows[0].count, 10) === 0) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
import { Router } from 'express';
|
||||
import type { Request, Response } from 'express';
|
||||
import { requireAuth, requireWrite } from '../middleware/auth';
|
||||
import { siteScopeByDevice } from '../utils/siteScope';
|
||||
import { activeSite } from '../middleware/site';
|
||||
import { query, queryOne } from '../config/database';
|
||||
import { commandRunner } from '../services/CommandRunner';
|
||||
|
||||
@@ -122,7 +124,9 @@ router.post('/runs', requireWrite, async (req: Request, res: Response) => {
|
||||
});
|
||||
|
||||
// GET /api/commands/runs — recent runs with progress
|
||||
router.get('/runs', async (_req: Request, res: Response) => {
|
||||
router.get('/runs', async (req: Request, res: Response) => {
|
||||
// A run is shown when it touched at least one device in the active site.
|
||||
const memberFilter = siteScopeByDevice(activeSite(req), 'crd.device_id');
|
||||
const runs = await query(
|
||||
`SELECT r.*,
|
||||
COUNT(d.*)::int AS total,
|
||||
@@ -130,6 +134,8 @@ router.get('/runs', async (_req: Request, res: Response) => {
|
||||
COUNT(*) FILTER (WHERE d.status IN ('failed','reverted'))::int AS failed
|
||||
FROM command_runs r
|
||||
LEFT JOIN command_run_devices d ON d.run_id = r.id
|
||||
${memberFilter ? `WHERE EXISTS (SELECT 1 FROM command_run_devices crd
|
||||
WHERE crd.run_id = r.id AND ${memberFilter})` : ''}
|
||||
GROUP BY r.id ORDER BY r.id DESC LIMIT 25`
|
||||
);
|
||||
res.json(runs);
|
||||
|
||||
@@ -20,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);
|
||||
|
||||
@@ -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}` : ''}`
|
||||
),
|
||||
]);
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { query, queryOne } from '../config/database';
|
||||
import { requireAuth, requireWrite } from '../middleware/auth';
|
||||
import { siteScopeDevices, siteScopeByDevice } from '../utils/siteScope';
|
||||
import { activeSite } from '../middleware/site';
|
||||
import { DeviceCollector, DeviceRow } from '../services/mikrotik/DeviceCollector';
|
||||
import { firmwareOrchestrator } from '../services/FirmwareOrchestrator';
|
||||
|
||||
@@ -8,13 +10,22 @@ const router = Router();
|
||||
router.use(requireAuth);
|
||||
|
||||
// GET /api/firmware/overview — fleet versions + latest rollout
|
||||
router.get('/overview', async (_req: Request, res: Response) => {
|
||||
router.get('/overview', async (req: Request, res: Response) => {
|
||||
const siteFilter = siteScopeDevices(activeSite(req));
|
||||
const [devices, latestRollout] = await Promise.all([
|
||||
query(`SELECT id, name, device_type, status, model, ros_version, latest_ros_version,
|
||||
firmware_update_available, firmware_version, upgrade_firmware_version,
|
||||
routerboard_upgrade_available
|
||||
FROM devices ORDER BY name ASC`),
|
||||
queryOne<{ id: number }>(`SELECT id FROM firmware_rollouts ORDER BY created_at DESC LIMIT 1`),
|
||||
FROM devices ${siteFilter ? `WHERE ${siteFilter}` : ''} ORDER BY name ASC`),
|
||||
// The banner links to this rollout, so it must be one this site took part in.
|
||||
queryOne<{ id: number }>(`
|
||||
SELECT r.id FROM firmware_rollouts r
|
||||
${siteScopeByDevice(activeSite(req), 'frd.device_id')
|
||||
? `WHERE EXISTS (SELECT 1 FROM firmware_rollout_devices frd
|
||||
WHERE frd.rollout_id = r.id
|
||||
AND ${siteScopeByDevice(activeSite(req), 'frd.device_id')})`
|
||||
: ''}
|
||||
ORDER BY r.created_at DESC LIMIT 1`),
|
||||
]);
|
||||
res.json({
|
||||
devices,
|
||||
@@ -24,8 +35,11 @@ router.get('/overview', async (_req: Request, res: Response) => {
|
||||
});
|
||||
|
||||
// POST /api/firmware/check-all — refresh update availability on all online devices
|
||||
router.post('/check-all', requireWrite, async (_req: Request, res: Response) => {
|
||||
const devices = await query<DeviceRow>(`SELECT * FROM devices WHERE status='online'`);
|
||||
router.post('/check-all', requireWrite, async (req: Request, res: Response) => {
|
||||
const siteFilter = siteScopeDevices(activeSite(req));
|
||||
const devices = await query<DeviceRow>(
|
||||
`SELECT * FROM devices WHERE status='online' ${siteFilter ? `AND ${siteFilter}` : ''}`
|
||||
);
|
||||
const settled = await Promise.allSettled(devices.map(async (d) => {
|
||||
const c = new DeviceCollector(d);
|
||||
try {
|
||||
@@ -81,7 +95,9 @@ router.post('/rollouts', requireWrite, async (req: Request, res: Response) => {
|
||||
});
|
||||
|
||||
// GET /api/firmware/rollouts — recent rollouts with progress counts
|
||||
router.get('/rollouts', async (_req: Request, res: Response) => {
|
||||
router.get('/rollouts', async (req: Request, res: Response) => {
|
||||
// A rollout is shown when it touched at least one device in the active site.
|
||||
const memberFilter = siteScopeByDevice(activeSite(req), 'frd.device_id');
|
||||
const rows = await query(`
|
||||
SELECT r.*,
|
||||
COUNT(d.id)::int AS device_count,
|
||||
@@ -89,6 +105,8 @@ router.get('/rollouts', async (_req: Request, res: Response) => {
|
||||
COUNT(d.id) FILTER (WHERE d.status = 'failed')::int AS failed_count
|
||||
FROM firmware_rollouts r
|
||||
LEFT JOIN firmware_rollout_devices d ON d.rollout_id = r.id
|
||||
${memberFilter ? `WHERE EXISTS (SELECT 1 FROM firmware_rollout_devices frd
|
||||
WHERE frd.rollout_id = r.id AND ${memberFilter})` : ''}
|
||||
GROUP BY r.id ORDER BY r.created_at DESC LIMIT 20`);
|
||||
res.json(rows);
|
||||
});
|
||||
|
||||
@@ -2,6 +2,8 @@ import { Router, Request, Response } from 'express';
|
||||
import { getQueryApi, bucket } from '../config/influxdb';
|
||||
import { query } from '../config/database';
|
||||
import { requireAuth } from '../middleware/auth';
|
||||
import { siteScopeDevices, siteScopeByDevice, siteScopeByNullableDevice, clientSeriesTag } from '../utils/siteScope';
|
||||
import { activeSite } from '../middleware/site';
|
||||
|
||||
const router = Router();
|
||||
router.use(requireAuth);
|
||||
@@ -15,6 +17,26 @@ function rangeToFlux(range: string): string {
|
||||
router.get('/clients-over-time', async (req: Request, res: Response) => {
|
||||
const range = rangeToFlux(String(req.query.range || '24h'));
|
||||
const queryApi = getQueryApi();
|
||||
// Per-site deduplicated series when a site is selected, fleet-wide otherwise.
|
||||
// Written by DeviceCollector alongside the global series (issue #130).
|
||||
//
|
||||
// A site holding the entire fleet is a special case worth taking: its total
|
||||
// *is* the global total, by definition rather than by approximation. Using
|
||||
// the global series there is not a shortcut -- it keeps the chart's full
|
||||
// history, which the per-site series only accumulates from the upgrade
|
||||
// onward. That covers every single-site install.
|
||||
const siteId = activeSite(req);
|
||||
let seriesTag = '_global';
|
||||
if (siteId !== null) {
|
||||
const counts = await query<{ in_site: number; total: number }>(
|
||||
`SELECT COUNT(*) FILTER (WHERE site_id = $1)::int AS in_site,
|
||||
COUNT(*)::int AS total
|
||||
FROM devices`,
|
||||
[siteId]
|
||||
);
|
||||
const { in_site = 0, total = 0 } = counts[0] ?? {};
|
||||
seriesTag = clientSeriesTag(siteId, in_site, total);
|
||||
}
|
||||
|
||||
// Use the global deduplicated metric (_global tag) written by DeviceCollector.
|
||||
// This avoids double-counting clients seen by multiple devices simultaneously.
|
||||
@@ -23,7 +45,7 @@ router.get('/clients-over-time', async (req: Request, res: Response) => {
|
||||
|> range(start: -${range})
|
||||
|> filter(fn: (r) => r._measurement == "client_counts")
|
||||
|> filter(fn: (r) => r._field == "total_clients")
|
||||
|> filter(fn: (r) => r.device_id == "_global")
|
||||
|> filter(fn: (r) => r.device_id == "${seriesTag}")
|
||||
|> aggregateWindow(every: 5m, fn: last, createEmpty: false)
|
||||
|> yield(name: "clients_over_time")
|
||||
`;
|
||||
@@ -39,29 +61,48 @@ router.get('/clients-over-time', async (req: Request, res: Response) => {
|
||||
// InfluxDB might not have data yet
|
||||
}
|
||||
|
||||
// If no global metric exists yet (first run or old data), fall back to max across devices.
|
||||
// max() is better than sum() since the device with the most clients is the gateway that
|
||||
// sees everyone — summing would double-count clients visible from multiple devices.
|
||||
// If no deduplicated series exists yet (first run, or a site whose history
|
||||
// predates the per-site series), fall back to max across real devices. max()
|
||||
// is better than sum() since the device with the most clients is the gateway
|
||||
// that sees everyone — summing would double-count clients visible from
|
||||
// multiple devices.
|
||||
if (rawPoints.length === 0) {
|
||||
const fallbackQuery = `
|
||||
from(bucket: "${bucket}")
|
||||
|> range(start: -${range})
|
||||
|> filter(fn: (r) => r._measurement == "client_counts")
|
||||
|> filter(fn: (r) => r._field == "total_clients")
|
||||
|> filter(fn: (r) => r.device_id != "_global")
|
||||
|> aggregateWindow(every: 5m, fn: max, createEmpty: false)
|
||||
|> group()
|
||||
|> aggregateWindow(every: 5m, fn: max, createEmpty: false)
|
||||
|> yield(name: "clients_over_time_fallback")
|
||||
`;
|
||||
try {
|
||||
await queryApi.collectRows(fallbackQuery, (row, tableMeta) => {
|
||||
const time = tableMeta.get(row, '_time') as string;
|
||||
const value = tableMeta.get(row, '_value') as number;
|
||||
rawPoints.push({ time, value: Math.round(value) });
|
||||
});
|
||||
} catch {
|
||||
// No data yet
|
||||
// The synthetic series are tagged _global and _site_<id>; the leading
|
||||
// underscore is what distinguishes them from real device ids. Matching on
|
||||
// it matters: without it the fleet-wide fallback would fold the per-site
|
||||
// totals back in and count everyone twice.
|
||||
let deviceFilter: string | null = `|> filter(fn: (r) => not (r.device_id =~ /^_/))`;
|
||||
if (siteId !== null) {
|
||||
const ids = await query<{ id: number }>(
|
||||
`SELECT id FROM devices WHERE site_id = $1`, [siteId]
|
||||
);
|
||||
// An empty site has no series to fall back to, and must show an empty
|
||||
// chart rather than the fleet's (issue #130).
|
||||
deviceFilter = ids.length
|
||||
? `|> filter(fn: (r) => ${ids.map((d) => `r.device_id == "${d.id}"`).join(' or ')})`
|
||||
: null;
|
||||
}
|
||||
if (deviceFilter) {
|
||||
const fallbackQuery = `
|
||||
from(bucket: "${bucket}")
|
||||
|> range(start: -${range})
|
||||
|> filter(fn: (r) => r._measurement == "client_counts")
|
||||
|> filter(fn: (r) => r._field == "total_clients")
|
||||
${deviceFilter}
|
||||
|> aggregateWindow(every: 5m, fn: max, createEmpty: false)
|
||||
|> group()
|
||||
|> aggregateWindow(every: 5m, fn: max, createEmpty: false)
|
||||
|> yield(name: "clients_over_time_fallback")
|
||||
`;
|
||||
try {
|
||||
await queryApi.collectRows(fallbackQuery, (row, tableMeta) => {
|
||||
const time = tableMeta.get(row, '_time') as string;
|
||||
const value = tableMeta.get(row, '_value') as number;
|
||||
rawPoints.push({ time, value: Math.round(value) });
|
||||
});
|
||||
} catch {
|
||||
// No data yet
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,6 +113,7 @@ router.get('/clients-over-time', async (req: Request, res: Response) => {
|
||||
// GET /api/metrics/top-clients?limit=10&range=24h
|
||||
router.get('/top-clients', async (req: Request, res: Response) => {
|
||||
const limit = Math.min(parseInt(String(req.query.limit || '10'), 10), 50);
|
||||
const topFilter = siteScopeByDevice(activeSite(req), 'c.device_id');
|
||||
|
||||
// Use postgres data (most recent client data)
|
||||
const clients = await query(
|
||||
@@ -80,6 +122,7 @@ router.get('/top-clients', async (req: Request, res: Response) => {
|
||||
c.tx_bytes, c.rx_bytes, c.client_type, d.name as device_name
|
||||
FROM clients c JOIN devices d ON d.id = c.device_id
|
||||
WHERE c.active = TRUE AND (c.tx_bytes + c.rx_bytes) > 0
|
||||
${topFilter ? `AND ${topFilter}` : ''}
|
||||
ORDER BY total_bytes DESC LIMIT $1`,
|
||||
[limit]
|
||||
);
|
||||
@@ -308,30 +351,36 @@ router.get('/device/:deviceId/availability', async (req: Request, res: Response)
|
||||
});
|
||||
|
||||
// GET /api/metrics/summary - dashboard summary stats
|
||||
router.get('/summary', async (_req: Request, res: Response) => {
|
||||
router.get('/summary', async (req: Request, res: Response) => {
|
||||
const siteId = activeSite(req);
|
||||
const devFilter = siteScopeDevices(siteId);
|
||||
const cliFilter = siteScopeByDevice(siteId, 'device_id');
|
||||
const evtFilter = siteScopeByNullableDevice(siteId, 'device_id');
|
||||
const [deviceStats, clientStats, alertStats, availStats] = await Promise.all([
|
||||
query<{ total: string; online: string; offline: string }>(
|
||||
`SELECT
|
||||
COUNT(*) as total,
|
||||
COUNT(*) FILTER (WHERE status='online') as online,
|
||||
COUNT(*) FILTER (WHERE status='offline') as offline
|
||||
FROM devices`
|
||||
FROM devices ${devFilter ? `WHERE ${devFilter}` : ''}`
|
||||
),
|
||||
query<{ total: string; active: string }>(
|
||||
`SELECT COUNT(DISTINCT mac_address) as total,
|
||||
COUNT(DISTINCT mac_address) FILTER (WHERE active=TRUE) as active
|
||||
FROM clients`
|
||||
FROM clients ${cliFilter ? `WHERE ${cliFilter}` : ''}`
|
||||
),
|
||||
query<{ critical: string; warning: string }>(
|
||||
`SELECT
|
||||
COUNT(*) FILTER (WHERE severity='error') as critical,
|
||||
COUNT(*) FILTER (WHERE severity='warning') as warning
|
||||
FROM events WHERE event_time > NOW() - INTERVAL '24 hours'`
|
||||
FROM events WHERE event_time > NOW() - INTERVAL '24 hours'
|
||||
${evtFilter ? `AND ${evtFilter}` : ''}`
|
||||
),
|
||||
query<{ total_outage_sec: string }>(
|
||||
`SELECT COALESCE(SUM(COALESCE(duration_seconds, 0)), 0)::text AS total_outage_sec
|
||||
FROM device_availability
|
||||
WHERE went_offline_at > NOW() - INTERVAL '30 days'`
|
||||
WHERE went_offline_at > NOW() - INTERVAL '30 days'
|
||||
${cliFilter ? `AND ${cliFilter}` : ''}`
|
||||
),
|
||||
]);
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { query } from '../config/database';
|
||||
import { requireAuth, requireWrite } from '../middleware/auth';
|
||||
import { siteScopeDevices } from '../utils/siteScope';
|
||||
import { activeSite } from '../middleware/site';
|
||||
import { DeviceCollector, DeviceRow } from '../services/mikrotik/DeviceCollector';
|
||||
import { netflowCollector } from '../services/netflow/NetflowCollector';
|
||||
|
||||
@@ -37,8 +39,12 @@ function deviceIdParam(req: Request, res: Response): number | null {
|
||||
|
||||
// ─── Overview (all online devices, all services) ──────────────────────────────
|
||||
|
||||
router.get('/overview', async (_req: Request, res: Response) => {
|
||||
const devices = await query<DeviceRow>(`SELECT * FROM devices WHERE status = 'online' ORDER BY name`);
|
||||
router.get('/overview', async (req: Request, res: Response) => {
|
||||
const siteFilter = siteScopeDevices(activeSite(req));
|
||||
const devices = await query<DeviceRow>(
|
||||
`SELECT * FROM devices WHERE status = 'online'
|
||||
${siteFilter ? `AND ${siteFilter}` : ''} ORDER BY name`
|
||||
);
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
devices.map(async (device: DeviceRow) => {
|
||||
@@ -520,11 +526,15 @@ function targetMatchesCollector(
|
||||
}
|
||||
|
||||
// GET /api/network-services/netflow/fleet — traffic-flow state for all online devices
|
||||
router.get('/netflow/fleet', async (_req: Request, res: Response) => {
|
||||
router.get('/netflow/fleet', async (req: Request, res: Response) => {
|
||||
const settings = await getNetflowAppSettings();
|
||||
const stats = netflowCollector.getStats();
|
||||
const statsByDevice = new Map(stats.exporters.map((e) => [e.deviceId, e]));
|
||||
const devices = await query<DeviceRow>(`SELECT * FROM devices WHERE status = 'online' ORDER BY name`);
|
||||
const siteFilter = siteScopeDevices(activeSite(req));
|
||||
const devices = await query<DeviceRow>(
|
||||
`SELECT * FROM devices WHERE status = 'online'
|
||||
${siteFilter ? `AND ${siteFilter}` : ''} ORDER BY name`
|
||||
);
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
devices.map(async (device: DeviceRow) => {
|
||||
|
||||
@@ -2,6 +2,8 @@ import { Router, Request, Response } from 'express';
|
||||
import { query } from '../config/database';
|
||||
import { getQueryApi } from '../config/influxdb';
|
||||
import { requireAuth, requireWrite } from '../middleware/auth';
|
||||
import { siteScopeDevices, siteScopeByDevice, siteScopeByNullableDevice } from '../utils/siteScope';
|
||||
import { activeSite } from '../middleware/site';
|
||||
import { DeviceCollector, DeviceRow } from '../services/mikrotik/DeviceCollector';
|
||||
import { BackupService, BackupDevice } from '../services/BackupService';
|
||||
|
||||
@@ -99,7 +101,13 @@ async function latestRetries(): Promise<{ device_id: string; device_name: string
|
||||
|
||||
// GET /api/operations/insights — aggregated operational intelligence (no live
|
||||
// device connections; all from Postgres + InfluxDB so it's fast).
|
||||
router.get('/insights', async (_req: Request, res: Response) => {
|
||||
router.get('/insights', async (req: Request, res: Response) => {
|
||||
const siteId = activeSite(req);
|
||||
const devFilter = siteScopeDevices(siteId);
|
||||
const devFilterD = siteScopeDevices(siteId, 'd');
|
||||
const cliFilter = siteScopeByDevice(siteId, 'device_id');
|
||||
const availFilter = siteScopeByDevice(siteId, 'a.device_id');
|
||||
const evtFilter = siteScopeByNullableDevice(siteId, 'device_id');
|
||||
const [
|
||||
devices, missingBackup, recentOutages, weakWifi, coChannel, eventCounts,
|
||||
] = await Promise.all([
|
||||
@@ -112,30 +120,35 @@ router.get('/insights', async (_req: Request, res: Response) => {
|
||||
}>(`SELECT id, name, status, last_seen, device_type, ros_version,
|
||||
firmware_update_available, latest_ros_version,
|
||||
routerboard_upgrade_available, firmware_version, upgrade_firmware_version
|
||||
FROM devices`),
|
||||
FROM devices ${devFilter ? `WHERE ${devFilter}` : ''}`),
|
||||
query<{ id: number; name: string; last_backup: string | null }>(`
|
||||
SELECT d.id, d.name, MAX(b.created_at) AS last_backup
|
||||
FROM devices d LEFT JOIN backups b ON b.device_id = d.id
|
||||
${devFilterD ? `WHERE ${devFilterD}` : ''}
|
||||
GROUP BY d.id
|
||||
HAVING MAX(b.created_at) IS NULL OR MAX(b.created_at) < NOW() - INTERVAL '7 days'`),
|
||||
query<{ device_id: number; name: string; outages: string }>(`
|
||||
SELECT a.device_id, d.name, COUNT(*) AS outages
|
||||
FROM device_availability a JOIN devices d ON d.id = a.device_id
|
||||
WHERE a.went_offline_at > NOW() - INTERVAL '7 days'
|
||||
${availFilter ? `AND ${availFilter}` : ''}
|
||||
GROUP BY a.device_id, d.name`),
|
||||
query<{ count: string }>(`
|
||||
SELECT COUNT(*) AS count FROM clients
|
||||
WHERE active = TRUE AND client_type = 'wireless'
|
||||
AND signal_strength IS NOT NULL AND signal_strength < -75`),
|
||||
AND signal_strength IS NOT NULL AND signal_strength < -75
|
||||
${cliFilter ? `AND ${cliFilter}` : ''}`),
|
||||
query<{ frequency: number; radios: string }>(`
|
||||
SELECT frequency, COUNT(*) AS radios FROM wireless_interfaces
|
||||
WHERE disabled = FALSE AND frequency IS NOT NULL AND frequency > 0
|
||||
AND (config_json->>'master-interface') IS NULL
|
||||
${cliFilter ? `AND ${cliFilter}` : ''}
|
||||
GROUP BY frequency HAVING COUNT(*) > 1`),
|
||||
query<{ errors: string; warnings: string }>(`
|
||||
SELECT COUNT(*) FILTER (WHERE severity = 'error') AS errors,
|
||||
COUNT(*) FILTER (WHERE severity = 'warning') AS warnings
|
||||
FROM events WHERE event_time > NOW() - INTERVAL '24 hours'`),
|
||||
FROM events WHERE event_time > NOW() - INTERVAL '24 hours'
|
||||
${evtFilter ? `AND ${evtFilter}` : ''}`),
|
||||
]);
|
||||
|
||||
const [resources, retries] = await Promise.all([latestResources(), latestRetries()]);
|
||||
@@ -266,7 +279,10 @@ router.get('/insights', async (_req: Request, res: Response) => {
|
||||
}
|
||||
|
||||
// WiFi — high TX retries
|
||||
const badRetries = retries.filter(r => r.pct >= 15);
|
||||
// retries comes from Influx across the whole fleet; keep only radios on
|
||||
// devices in this site.
|
||||
const scopedIds = new Set(devices.map((d) => String(d.id)));
|
||||
const badRetries = retries.filter(r => r.pct >= 15 && scopedIds.has(r.device_id));
|
||||
for (const r of badRetries) {
|
||||
attention.push({
|
||||
sev: 'warn', category: 'wifi',
|
||||
@@ -294,6 +310,7 @@ router.get('/insights', async (_req: Request, res: Response) => {
|
||||
query<{ device_id: number; device_name: string; scanned_at: string; data: unknown }>(`
|
||||
SELECT DISTINCT ON (a.device_id) a.device_id, d.name AS device_name, a.scanned_at, a.data
|
||||
FROM ap_scan_data a JOIN devices d ON d.id = a.device_id
|
||||
${siteScopeByDevice(siteId, 'a.device_id') ? `WHERE ${siteScopeByDevice(siteId, 'a.device_id')}` : ''}
|
||||
ORDER BY a.device_id, a.scanned_at DESC`),
|
||||
query<{ ssid: string | null; mac_address: string | null }>(`SELECT ssid, mac_address FROM wireless_interfaces`),
|
||||
query<{ mac_address: string }>(`SELECT mac_address FROM interfaces WHERE mac_address IS NOT NULL`),
|
||||
@@ -329,6 +346,7 @@ router.get('/insights', async (_req: Request, res: Response) => {
|
||||
FROM device_config_findings f
|
||||
JOIN devices d ON d.id = f.device_id
|
||||
WHERE f.severity IN ('critical', 'warning')
|
||||
${siteScopeByDevice(siteId, 'f.device_id') ? `AND ${siteScopeByDevice(siteId, 'f.device_id')}` : ''}
|
||||
ORDER BY f.device_id, CASE f.severity WHEN 'critical' THEN 0 ELSE 1 END, f.rule`);
|
||||
|
||||
const seen = new Set<string>();
|
||||
@@ -350,7 +368,7 @@ router.get('/insights', async (_req: Request, res: Response) => {
|
||||
} catch { /* findings table unavailable */ }
|
||||
|
||||
// Baseline anomalies (client counts, CPU, error bursts)
|
||||
const anomalies = await detectAnomalies();
|
||||
const anomalies = await detectAnomalies(siteId);
|
||||
for (const a of anomalies) attention.push(a);
|
||||
|
||||
// Dismissals (#102). Expired rows are cleared on read, so a dismissal lapses
|
||||
@@ -370,7 +388,7 @@ router.get('/insights', async (_req: Request, res: Response) => {
|
||||
visible.sort((a, b) => sevRank[a.sev] - sevRank[b.sev]);
|
||||
|
||||
// Activity feed — recent config changes, user actions, and notable events
|
||||
const activity = await buildActivity();
|
||||
const activity = await buildActivity(siteId);
|
||||
|
||||
res.json({ attention: visible, dismissedCount: attention.length - visible.length, capacity, activity });
|
||||
});
|
||||
@@ -436,11 +454,16 @@ async function influxGroupValues(flux: string): Promise<Map<string, number>> {
|
||||
return out;
|
||||
}
|
||||
|
||||
async function detectAnomalies(): Promise<AttentionItem[]> {
|
||||
async function detectAnomalies(siteId: number | null): Promise<AttentionItem[]> {
|
||||
const items: AttentionItem[] = [];
|
||||
const hour = new Date().getUTCHours();
|
||||
const nameById = new Map<string, { id: number; name: string }>();
|
||||
const devices = await query<{ id: number; name: string }>(`SELECT id, name FROM devices`);
|
||||
const devFilter = siteScopeDevices(siteId);
|
||||
// Influx series are matched back to this map by device id, so scoping the map
|
||||
// scopes the metric anomalies too -- an unknown id is skipped.
|
||||
const devices = await query<{ id: number; name: string }>(
|
||||
`SELECT id, name FROM devices ${devFilter ? `WHERE ${devFilter}` : ''}`
|
||||
);
|
||||
for (const d of devices) nameById.set(String(d.id), d);
|
||||
|
||||
const metric = async (measurement: string, field: string) => {
|
||||
@@ -504,6 +527,7 @@ async function detectAnomalies(): Promise<AttentionItem[]> {
|
||||
WITH cur AS (
|
||||
SELECT device_id, COUNT(*) AS n FROM events
|
||||
WHERE severity = 'error' AND event_time > NOW() - INTERVAL '1 hour'
|
||||
${siteScopeByNullableDevice(siteId, 'device_id') ? `AND ${siteScopeByNullableDevice(siteId, 'device_id')}` : ''}
|
||||
GROUP BY device_id
|
||||
), hist AS (
|
||||
SELECT device_id, COUNT(*) / 168.0 AS avg_hourly FROM events
|
||||
@@ -527,13 +551,22 @@ async function detectAnomalies(): Promise<AttentionItem[]> {
|
||||
}
|
||||
|
||||
// Recent activity merged from config snapshots, audit log, and events.
|
||||
async function buildActivity() {
|
||||
//
|
||||
// When a site is selected the audit log is left out entirely. Audit rows record
|
||||
// who did what to the install -- they carry no device and so belong to no site,
|
||||
// and repeating "admin changed a setting" into every site would misattribute it.
|
||||
// Device-scoped history (config changes, events) is filtered normally.
|
||||
async function buildActivity(siteId: number | null) {
|
||||
const cfgFilter = siteScopeByDevice(siteId, 'dc.device_id');
|
||||
const evtFilter = siteScopeByNullableDevice(siteId, 'e.device_id');
|
||||
const [configs, audits, events] = await Promise.all([
|
||||
query<{ at: string; name: string; summary: string | null }>(`
|
||||
SELECT dc.collected_at AS at, d.name, dc.change_summary AS summary
|
||||
FROM device_configs dc JOIN devices d ON d.id = dc.device_id
|
||||
WHERE dc.change_summary IS NOT NULL AND dc.change_summary != ''
|
||||
${cfgFilter ? `AND ${cfgFilter}` : ''}
|
||||
ORDER BY dc.collected_at DESC LIMIT 8`),
|
||||
siteId !== null ? Promise.resolve([]) :
|
||||
query<{ at: string; username: string | null; method: string; summary: string | null }>(`
|
||||
SELECT created_at AS at, username, method, summary
|
||||
FROM audit_log
|
||||
@@ -544,6 +577,7 @@ async function buildActivity() {
|
||||
SELECT e.event_time AS at, e.severity, e.message, d.name
|
||||
FROM events e LEFT JOIN devices d ON d.id = e.device_id
|
||||
WHERE e.severity IN ('error', 'warning')
|
||||
${evtFilter ? `AND ${evtFilter}` : ''}
|
||||
ORDER BY e.event_time DESC LIMIT 8`),
|
||||
]);
|
||||
|
||||
@@ -557,8 +591,14 @@ async function buildActivity() {
|
||||
}
|
||||
|
||||
// POST /api/operations/backup-all — back up every online device (parallel, best-effort)
|
||||
router.post('/backup-all', requireWrite, async (_req: Request, res: Response) => {
|
||||
const devices = await query<DeviceRow>(`SELECT * FROM devices WHERE status = 'online'`);
|
||||
router.post('/backup-all', requireWrite, async (req: Request, res: Response) => {
|
||||
// Scoped to the active site. A bulk action that silently spans customers is
|
||||
// exactly the blast radius the bulk-command guards exist to contain (#130).
|
||||
const siteFilter = siteScopeDevices(activeSite(req));
|
||||
const devices = await query<DeviceRow>(
|
||||
`SELECT * FROM devices WHERE status = 'online'
|
||||
${siteFilter ? `AND ${siteFilter}` : ''}`
|
||||
);
|
||||
const settled = await Promise.allSettled(devices.map(d => {
|
||||
const dev: BackupDevice = {
|
||||
id: d.id, name: d.name, ip_address: d.ip_address,
|
||||
@@ -579,8 +619,14 @@ router.post('/backup-all', requireWrite, async (_req: Request, res: Response) =>
|
||||
});
|
||||
|
||||
// POST /api/operations/sync-all — pull latest config/state from every online device
|
||||
router.post('/sync-all', requireWrite, async (_req: Request, res: Response) => {
|
||||
const devices = await query<DeviceRow>(`SELECT * FROM devices WHERE status = 'online'`);
|
||||
router.post('/sync-all', requireWrite, async (req: Request, res: Response) => {
|
||||
// Scoped to the active site. A bulk action that silently spans customers is
|
||||
// exactly the blast radius the bulk-command guards exist to contain (#130).
|
||||
const siteFilter = siteScopeDevices(activeSite(req));
|
||||
const devices = await query<DeviceRow>(
|
||||
`SELECT * FROM devices WHERE status = 'online'
|
||||
${siteFilter ? `AND ${siteFilter}` : ''}`
|
||||
);
|
||||
const settled = await Promise.allSettled(devices.map(async (d) => {
|
||||
const c = new DeviceCollector(d);
|
||||
try { await c.connect(); await c.collectAll(); }
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { query } from '../config/database';
|
||||
import { requireAuth, requireWrite } from '../middleware/auth';
|
||||
import { siteScopeDevices } from '../utils/siteScope';
|
||||
import { activeSite } from '../middleware/site';
|
||||
import { DeviceCollector, DeviceRow } from '../services/mikrotik/DeviceCollector';
|
||||
|
||||
const router = Router();
|
||||
router.use(requireAuth);
|
||||
|
||||
// GET /api/routers/lldp — LLDP enabled/disabled status per online router
|
||||
router.get('/lldp', async (_req: Request, res: Response) => {
|
||||
router.get('/lldp', async (req: Request, res: Response) => {
|
||||
const siteFilter = siteScopeDevices(activeSite(req));
|
||||
const routers = await query<DeviceRow>(
|
||||
`SELECT * FROM devices WHERE device_type = 'router' AND status = 'online'`
|
||||
`SELECT * FROM devices WHERE device_type = 'router' AND status = 'online'
|
||||
${siteFilter ? `AND ${siteFilter}` : ''}`
|
||||
);
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
@@ -82,9 +86,11 @@ router.put('/lldp', requireWrite, async (req: Request, res: Response) => {
|
||||
});
|
||||
|
||||
// GET /api/routers/snmp — SNMP config/status per online router
|
||||
router.get('/snmp', async (_req: Request, res: Response) => {
|
||||
router.get('/snmp', async (req: Request, res: Response) => {
|
||||
const siteFilter = siteScopeDevices(activeSite(req));
|
||||
const routers = await query<DeviceRow>(
|
||||
`SELECT * FROM devices WHERE device_type = 'router' AND status = 'online'`
|
||||
`SELECT * FROM devices WHERE device_type = 'router' AND status = 'online'
|
||||
${siteFilter ? `AND ${siteFilter}` : ''}`
|
||||
);
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { query, queryOne } from '../config/database';
|
||||
import { requireAuth, requireWrite } from '../middleware/auth';
|
||||
|
||||
const router = Router();
|
||||
router.use(requireAuth);
|
||||
|
||||
const MAX_NAME = 120;
|
||||
|
||||
export interface SiteRow {
|
||||
id: number;
|
||||
name: string;
|
||||
address: string | null;
|
||||
location_lat: number | null;
|
||||
location_lng: number | null;
|
||||
notes: string | null;
|
||||
is_default: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
/** Shared validation so create and rename cannot drift apart. */
|
||||
function normaliseName(raw: unknown): { name?: string; error?: string } {
|
||||
const name = typeof raw === 'string' ? raw.trim() : '';
|
||||
if (!name) return { error: 'Site name is required' };
|
||||
if (name.length > MAX_NAME) return { error: `Site name must be ${MAX_NAME} characters or fewer` };
|
||||
return { name };
|
||||
}
|
||||
|
||||
/** Coordinates are optional, but a half-pair is a bug rather than a location. */
|
||||
function normaliseCoords(lat: unknown, lng: unknown): { lat: number | null; lng: number | null } {
|
||||
const latN = lat === null || lat === undefined || lat === '' ? null : Number(lat);
|
||||
const lngN = lng === null || lng === undefined || lng === '' ? null : Number(lng);
|
||||
const ok = latN !== null && lngN !== null
|
||||
&& Number.isFinite(latN) && Number.isFinite(lngN)
|
||||
&& Math.abs(latN) <= 90 && Math.abs(lngN) <= 180;
|
||||
return ok ? { lat: latN, lng: lngN } : { lat: null, lng: null };
|
||||
}
|
||||
|
||||
// GET /api/sites — list with device counts, for the selector and the all-sites table
|
||||
router.get('/', async (_req: Request, res: Response) => {
|
||||
const sites = await query<SiteRow & { device_count: string }>(
|
||||
`SELECT s.id, s.name, s.address,
|
||||
s.location_lat::float8 AS location_lat,
|
||||
s.location_lng::float8 AS location_lng,
|
||||
s.notes, s.is_default, s.created_at,
|
||||
COUNT(d.id)::text AS device_count,
|
||||
COUNT(d.id) FILTER (WHERE d.status = 'online')::text AS online_count
|
||||
FROM sites s
|
||||
LEFT JOIN devices d ON d.site_id = s.id
|
||||
GROUP BY s.id
|
||||
ORDER BY s.is_default DESC, LOWER(s.name) ASC`
|
||||
);
|
||||
res.json(sites.map((s) => ({
|
||||
...s,
|
||||
device_count: parseInt(s.device_count, 10),
|
||||
online_count: parseInt((s as unknown as { online_count: string }).online_count, 10),
|
||||
})));
|
||||
});
|
||||
|
||||
// POST /api/sites — create
|
||||
router.post('/', requireWrite, async (req: Request, res: Response) => {
|
||||
const { name, error } = normaliseName((req.body as { name?: string }).name);
|
||||
if (error) { res.status(400).json({ error }); return; }
|
||||
|
||||
const { address, notes } = req.body as { address?: string; notes?: string };
|
||||
const { lat, lng } = normaliseCoords(
|
||||
(req.body as { location_lat?: unknown }).location_lat,
|
||||
(req.body as { location_lng?: unknown }).location_lng
|
||||
);
|
||||
|
||||
const existing = await queryOne<{ id: number }>(
|
||||
`SELECT id FROM sites WHERE LOWER(name) = LOWER($1)`, [name]
|
||||
);
|
||||
if (existing) { res.status(409).json({ error: 'A site with that name already exists' }); return; }
|
||||
|
||||
const rows = await query<SiteRow>(
|
||||
`INSERT INTO sites (name, address, location_lat, location_lng, notes)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id, name, address, location_lat::float8 AS location_lat,
|
||||
location_lng::float8 AS location_lng, notes, is_default, created_at`,
|
||||
[name, address?.trim() || null, lat, lng, notes?.trim() || null]
|
||||
);
|
||||
res.status(201).json({ ...rows[0], device_count: 0, online_count: 0 });
|
||||
});
|
||||
|
||||
// PUT /api/sites/:id — rename / set address / notes
|
||||
router.put('/:id', requireWrite, async (req: Request, res: Response) => {
|
||||
const id = parseInt(req.params.id, 10);
|
||||
if (!Number.isInteger(id)) { res.status(400).json({ error: 'Invalid site id' }); return; }
|
||||
|
||||
const body = req.body as {
|
||||
name?: string; address?: string; notes?: string;
|
||||
location_lat?: unknown; location_lng?: unknown;
|
||||
};
|
||||
|
||||
let name: string | null = null;
|
||||
if (body.name !== undefined) {
|
||||
const parsed = normaliseName(body.name);
|
||||
if (parsed.error) { res.status(400).json({ error: parsed.error }); return; }
|
||||
name = parsed.name!;
|
||||
const clash = await queryOne<{ id: number }>(
|
||||
`SELECT id FROM sites WHERE LOWER(name) = LOWER($1) AND id <> $2`, [name, id]
|
||||
);
|
||||
if (clash) { res.status(409).json({ error: 'A site with that name already exists' }); return; }
|
||||
}
|
||||
|
||||
// Address and coordinates travel together: changing the address without
|
||||
// re-geocoding would leave the pin on the old location, which is worse than
|
||||
// no pin at all.
|
||||
const addressGiven = body.address !== undefined;
|
||||
const { lat, lng } = normaliseCoords(body.location_lat, body.location_lng);
|
||||
|
||||
const rows = await query<SiteRow>(
|
||||
`UPDATE sites SET
|
||||
name = COALESCE($1, name),
|
||||
address = CASE WHEN $2::boolean THEN $3 ELSE address END,
|
||||
location_lat = CASE WHEN $2::boolean THEN $4 ELSE location_lat END,
|
||||
location_lng = CASE WHEN $2::boolean THEN $5 ELSE location_lng END,
|
||||
notes = COALESCE($6, notes),
|
||||
updated_at = NOW()
|
||||
WHERE id = $7
|
||||
RETURNING id, name, address, location_lat::float8 AS location_lat,
|
||||
location_lng::float8 AS location_lng, notes, is_default, created_at`,
|
||||
[name, addressGiven, body.address?.trim() || null, lat, lng, body.notes?.trim() ?? null, id]
|
||||
);
|
||||
if (!rows[0]) { res.status(404).json({ error: 'Site not found' }); return; }
|
||||
res.json(rows[0]);
|
||||
});
|
||||
|
||||
// DELETE /api/sites/:id — refused while devices remain, and never for the last site
|
||||
router.delete('/:id', requireWrite, async (req: Request, res: Response) => {
|
||||
const id = parseInt(req.params.id, 10);
|
||||
if (!Number.isInteger(id)) { res.status(400).json({ error: 'Invalid site id' }); return; }
|
||||
|
||||
const site = await queryOne<{ id: number; name: string }>(`SELECT id, name FROM sites WHERE id = $1`, [id]);
|
||||
if (!site) { res.status(404).json({ error: 'Site not found' }); return; }
|
||||
|
||||
// Deleting a site must never delete the devices in it. Make the operator move
|
||||
// them first, so the destination is a decision rather than a side effect.
|
||||
const count = await queryOne<{ n: string }>(
|
||||
`SELECT COUNT(*)::text AS n FROM devices WHERE site_id = $1`, [id]
|
||||
);
|
||||
const devices = parseInt(count?.n ?? '0', 10);
|
||||
if (devices > 0) {
|
||||
res.status(409).json({
|
||||
error: `"${site.name}" still contains ${devices} device${devices === 1 ? '' : 's'}. `
|
||||
+ 'Move them to another site before deleting it.',
|
||||
device_count: devices,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const total = await queryOne<{ n: string }>(`SELECT COUNT(*)::text AS n FROM sites`);
|
||||
if (parseInt(total?.n ?? '0', 10) <= 1) {
|
||||
res.status(409).json({ error: 'Cannot delete the only site' });
|
||||
return;
|
||||
}
|
||||
|
||||
await query(`DELETE FROM sites WHERE id = $1`, [id]);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// POST /api/sites/:id/devices — move devices into this site
|
||||
router.post('/:id/devices', requireWrite, async (req: Request, res: Response) => {
|
||||
const id = parseInt(req.params.id, 10);
|
||||
if (!Number.isInteger(id)) { res.status(400).json({ error: 'Invalid site id' }); return; }
|
||||
|
||||
const { deviceIds } = req.body as { deviceIds: number[] };
|
||||
if (!Array.isArray(deviceIds) || deviceIds.length === 0) {
|
||||
res.status(400).json({ error: 'deviceIds array required' });
|
||||
return;
|
||||
}
|
||||
const ids = deviceIds.map((d) => parseInt(String(d), 10)).filter(Number.isInteger);
|
||||
if (ids.length === 0) { res.status(400).json({ error: 'deviceIds array required' }); return; }
|
||||
|
||||
const site = await queryOne<{ id: number }>(`SELECT id FROM sites WHERE id = $1`, [id]);
|
||||
if (!site) { res.status(404).json({ error: 'Site not found' }); return; }
|
||||
|
||||
await query(`UPDATE devices SET site_id = $1 WHERE id = ANY($2::int[])`, [id, ids]);
|
||||
res.json({ ok: true, moved: ids.length });
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -1,13 +1,16 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { query } from '../config/database';
|
||||
import { requireAuth, requireWrite } from '../middleware/auth';
|
||||
import { siteScopeDevices } from '../utils/siteScope';
|
||||
import { activeSite } from '../middleware/site';
|
||||
import { DeviceCollector, DeviceRow } from '../services/mikrotik/DeviceCollector';
|
||||
|
||||
const router = Router();
|
||||
router.use(requireAuth);
|
||||
|
||||
// GET /api/switches — all switch devices with port statistics
|
||||
router.get('/', async (_req: Request, res: Response) => {
|
||||
router.get('/', async (req: Request, res: Response) => {
|
||||
const siteFilter = siteScopeDevices(activeSite(req), 'd');
|
||||
const switches = await query(`
|
||||
SELECT d.id, d.name, d.ip_address, d.model, d.device_type, d.status, d.last_seen,
|
||||
d.ros_version, d.firmware_version, d.serial_number, d.rack_name, d.rack_slot,
|
||||
@@ -19,7 +22,7 @@ router.get('/', async (_req: Request, res: Response) => {
|
||||
LEFT JOIN interfaces i ON i.device_id = d.id
|
||||
AND (i.type ILIKE 'ether%' OR i.type ILIKE 'sfp%'
|
||||
OR i.name ILIKE 'ether%' OR i.name ILIKE 'sfp%')
|
||||
WHERE d.device_type = 'switch'
|
||||
WHERE d.device_type = 'switch' ${siteFilter ? `AND ${siteFilter}` : ''}
|
||||
GROUP BY d.id
|
||||
ORDER BY d.name ASC
|
||||
`);
|
||||
@@ -27,9 +30,11 @@ router.get('/', async (_req: Request, res: Response) => {
|
||||
});
|
||||
|
||||
// GET /api/switches/lldp — LLDP enabled/disabled status per online switch
|
||||
router.get('/lldp', async (_req: Request, res: Response) => {
|
||||
router.get('/lldp', async (req: Request, res: Response) => {
|
||||
const siteFilter = siteScopeDevices(activeSite(req));
|
||||
const switches = await query<DeviceRow>(
|
||||
`SELECT * FROM devices WHERE device_type = 'switch' AND status = 'online'`
|
||||
`SELECT * FROM devices WHERE device_type = 'switch' AND status = 'online'
|
||||
${siteFilter ? `AND ${siteFilter}` : ''}`
|
||||
);
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
@@ -104,9 +109,11 @@ router.put('/lldp', requireWrite, async (req: Request, res: Response) => {
|
||||
// GET /api/switches/snmp — SNMP config/status for all managed switches
|
||||
// Includes offline/unknown devices so the list never disappears during a poll cycle.
|
||||
// Unreachable devices fall through to the Promise.allSettled error path and render with an error note.
|
||||
router.get('/snmp', async (_req: Request, res: Response) => {
|
||||
router.get('/snmp', async (req: Request, res: Response) => {
|
||||
const siteFilter = siteScopeDevices(activeSite(req));
|
||||
const switches = await query<DeviceRow>(
|
||||
`SELECT * FROM devices WHERE device_type = 'switch' ORDER BY name`
|
||||
`SELECT * FROM devices WHERE device_type = 'switch'
|
||||
${siteFilter ? `AND ${siteFilter}` : ''} ORDER BY name`
|
||||
);
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { query, queryOne } from '../config/database';
|
||||
import { requireAuth, requireWrite } from '../middleware/auth';
|
||||
import { siteScopeDevices, siteScopeByDevice } from '../utils/siteScope';
|
||||
import { activeSite } from '../middleware/site';
|
||||
import { PollerService } from '../services/PollerService';
|
||||
import {
|
||||
buildTopology,
|
||||
@@ -31,11 +33,20 @@ interface BridgeRow {
|
||||
}
|
||||
|
||||
// GET /api/topology
|
||||
router.get('/', async (_req: Request, res: Response) => {
|
||||
router.get('/', async (req: Request, res: Response) => {
|
||||
// Scope the graph to the active site. Links are filtered on their *from*
|
||||
// device: a link out of the site still shows, and resolves to an external
|
||||
// node, which is the honest picture of an uplink to somewhere else (#130).
|
||||
const siteId = activeSite(req);
|
||||
const devFilter = siteScopeDevices(siteId);
|
||||
const fromFilter = siteScopeByDevice(siteId, 'tl.from_device_id');
|
||||
const mlFilter = siteScopeByDevice(siteId, 'ml.from_device_id');
|
||||
const ifFilter = siteScopeByDevice(siteId, 'device_id');
|
||||
const brFilter = siteScopeByDevice(siteId, 'device_id');
|
||||
const [devices, allLinks, manualLinks, deviceMacs, bridges] = await Promise.all([
|
||||
query<TopoDevice>(
|
||||
`SELECT id, name, ip_address, model, device_type, status, ros_version, ip_addresses_jsonb
|
||||
FROM devices ORDER BY name ASC`
|
||||
FROM devices ${devFilter ? `WHERE ${devFilter}` : ''} ORDER BY name ASC`
|
||||
),
|
||||
query<LinkRow>(
|
||||
`SELECT tl.*,
|
||||
@@ -44,27 +55,31 @@ router.get('/', async (_req: Request, res: Response) => {
|
||||
FROM topology_links tl
|
||||
LEFT JOIN devices fd ON fd.id = tl.from_device_id
|
||||
LEFT JOIN devices td ON td.id = tl.to_device_id
|
||||
${fromFilter ? `WHERE ${fromFilter}` : ''}
|
||||
ORDER BY tl.discovered_at DESC`
|
||||
),
|
||||
query<ManualLinkRow>(
|
||||
`SELECT ml.*, fd.name AS from_name, td.name AS to_name
|
||||
FROM manual_topology_links ml
|
||||
JOIN devices fd ON fd.id = ml.from_device_id
|
||||
JOIN devices td ON td.id = ml.to_device_id`
|
||||
JOIN devices td ON td.id = ml.to_device_id
|
||||
${mlFilter ? `WHERE ${mlFilter}` : ''}`
|
||||
),
|
||||
// Every interface MAC in the fleet, so a neighbour seen only by MAC — an LLDP
|
||||
// sighting across a trunk port carries no address — still resolves to its device.
|
||||
query<{ device_id: number; mac_address: string }>(
|
||||
`SELECT device_id, mac_address FROM interfaces WHERE mac_address IS NOT NULL
|
||||
${ifFilter ? `AND ${ifFilter}` : ''}
|
||||
UNION
|
||||
SELECT device_id, mac_address FROM wireless_interfaces WHERE mac_address IS NOT NULL`
|
||||
SELECT device_id, mac_address FROM wireless_interfaces WHERE mac_address IS NOT NULL
|
||||
${ifFilter ? `AND ${ifFilter}` : ''}`
|
||||
),
|
||||
// What each bridge reports about the spanning tree. Authoritative, and the
|
||||
// reason the root is no longer guessed from port roles (#131).
|
||||
query<BridgeRow>(
|
||||
`SELECT device_id, bridge_name, bridge_id, root_bridge, root_bridge_id,
|
||||
root_port, root_path_cost, protocol_mode
|
||||
FROM device_bridges`
|
||||
FROM device_bridges ${brFilter ? `WHERE ${brFilter}` : ''}`
|
||||
),
|
||||
]);
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { query } from '../config/database';
|
||||
import { requireAuth, requireWrite } from '../middleware/auth';
|
||||
import { siteScopeDevices, siteScopeByDevice } from '../utils/siteScope';
|
||||
import { activeSite } from '../middleware/site';
|
||||
import { DeviceCollector, DeviceRow } from '../services/mikrotik/DeviceCollector';
|
||||
import { lookupVendor } from '../utils/oui';
|
||||
import { analyzeSpectrum, summarize, BAND_RANGES, parseChannelSpec } from '../utils/rfSpectrum';
|
||||
@@ -106,7 +108,8 @@ router.post('/ssid/bulk', requireWrite, async (req: Request, res: Response) => {
|
||||
// ─── Section overview ─────────────────────────────────────────────────────────
|
||||
|
||||
// GET /api/wireless — all wireless_ap devices with aggregated stats
|
||||
router.get('/', async (_req: Request, res: Response) => {
|
||||
router.get('/', async (req: Request, res: Response) => {
|
||||
const siteFilter = siteScopeDevices(activeSite(req), 'd');
|
||||
const aps = await query(`
|
||||
SELECT d.id, d.name, d.ip_address, d.model, d.device_type, d.status, d.last_seen,
|
||||
d.ros_version, d.firmware_version, d.serial_number, d.rack_name, d.rack_slot,
|
||||
@@ -124,6 +127,7 @@ router.get('/', async (_req: Request, res: Response) => {
|
||||
FROM devices d
|
||||
LEFT JOIN wireless_interfaces wi ON wi.device_id = d.id
|
||||
WHERE (d.device_type = 'wireless_ap' OR d.wifi_role IN ('cap','controller','controller_cap'))
|
||||
${siteFilter ? `AND ${siteFilter}` : ''}
|
||||
GROUP BY d.id
|
||||
ORDER BY d.name ASC
|
||||
`);
|
||||
@@ -139,7 +143,8 @@ router.get('/', async (_req: Request, res: Response) => {
|
||||
* Read-only. Reads the cache rather than the devices, so the page loads instantly
|
||||
* and works while a CAP is offline — the controller still knows what it provisioned.
|
||||
*/
|
||||
router.get('/capsman', async (_req: Request, res: Response) => {
|
||||
router.get('/capsman', async (req: Request, res: Response) => {
|
||||
const siteFilter = siteScopeDevices(activeSite(req));
|
||||
const controllers = await query<{
|
||||
id: number; name: string; ip_address: string; model: string | null;
|
||||
status: string; wifi_role: string | null;
|
||||
@@ -147,6 +152,7 @@ router.get('/capsman', async (_req: Request, res: Response) => {
|
||||
SELECT id, name, ip_address, model, status, wifi_role
|
||||
FROM devices
|
||||
WHERE wifi_role IN ('controller', 'controller_cap')
|
||||
${siteFilter ? `AND ${siteFilter}` : ''}
|
||||
ORDER BY name ASC
|
||||
`);
|
||||
|
||||
@@ -874,6 +880,7 @@ function deviceScope(req: Request): number | null {
|
||||
*/
|
||||
router.get('/rf/channels', async (req: Request, res: Response) => {
|
||||
const deviceId = deviceScope(req);
|
||||
const siteFilter = siteScopeByDevice(activeSite(req), 'wi.device_id');
|
||||
|
||||
// Two sources, because a CAPsMAN-managed AP stores no frequency of its own — the
|
||||
// controller owns the channel. Reading only wireless_interfaces silently dropped
|
||||
@@ -905,6 +912,7 @@ router.get('/rf/channels', async (req: Request, res: Response) => {
|
||||
OR cr.current_channel IS NOT NULL
|
||||
)
|
||||
${deviceId ? 'AND wi.device_id = $1' : ''}
|
||||
${siteFilter ? `AND ${siteFilter}` : ''}
|
||||
ORDER BY d.name ASC, wi.name ASC
|
||||
`, deviceId ? [deviceId] : []);
|
||||
|
||||
@@ -967,6 +975,7 @@ router.get('/rf/channels', async (req: Request, res: Response) => {
|
||||
// GET /api/wireless/rf/signals — active wireless clients' RSSI (for density view)
|
||||
router.get('/rf/signals', async (req: Request, res: Response) => {
|
||||
const deviceId = deviceScope(req);
|
||||
const siteFilter = siteScopeByDevice(activeSite(req), 'c.device_id');
|
||||
const rows = await query(`
|
||||
SELECT c.mac_address, c.signal_strength, c.device_id, d.name AS device_name,
|
||||
c.custom_name, c.hostname
|
||||
@@ -977,6 +986,7 @@ router.get('/rf/signals', async (req: Request, res: Response) => {
|
||||
AND c.signal_strength IS NOT NULL
|
||||
AND c.signal_strength < 0
|
||||
${deviceId ? 'AND c.device_id = $1' : ''}
|
||||
${siteFilter ? `AND ${siteFilter}` : ''}
|
||||
ORDER BY c.signal_strength DESC
|
||||
`, deviceId ? [deviceId] : []);
|
||||
res.json(rows);
|
||||
@@ -989,11 +999,13 @@ router.get('/rf/signals', async (req: Request, res: Response) => {
|
||||
* driver exposes. On an all-RouterOS-7 fleet that panel can never have data, so the
|
||||
* UI needs to know rather than rendering an empty box forever (issue #96).
|
||||
*/
|
||||
router.get('/rf/capabilities', async (_req: Request, res: Response) => {
|
||||
router.get('/rf/capabilities', async (req: Request, res: Response) => {
|
||||
const siteFilter = siteScopeDevices(activeSite(req));
|
||||
const rows = await query<{ wifi_package: string | null; n: string }>(`
|
||||
SELECT wifi_package, COUNT(*) AS n
|
||||
FROM devices
|
||||
WHERE wifi_package IS NOT NULL AND wifi_package <> 'none'
|
||||
${siteFilter ? `AND ${siteFilter}` : ''}
|
||||
GROUP BY wifi_package`);
|
||||
|
||||
const counts = Object.fromEntries(rows.map((r) => [r.wifi_package ?? 'unknown', Number(r.n)]));
|
||||
@@ -1065,6 +1077,7 @@ const RE_DHCP_FAIL = /no.*address|declined|nak|offer.*fail|pool.*exhaust/i;
|
||||
// GET /api/wireless/rf/connectivity?range=24h — Association/Auth/DHCP success funnel
|
||||
router.get('/rf/connectivity', async (req: Request, res: Response) => {
|
||||
const deviceId = deviceScope(req);
|
||||
const siteFilter = siteScopeByDevice(activeSite(req), 'device_id');
|
||||
const intervals: Record<string, string> = { '1h': '1 hour', '6h': '6 hours', '24h': '24 hours', '7d': '7 days' };
|
||||
const interval = intervals[String(req.query.range || '24h')] || '24 hours';
|
||||
|
||||
@@ -1072,6 +1085,7 @@ router.get('/rf/connectivity', async (req: Request, res: Response) => {
|
||||
SELECT topic, message FROM events
|
||||
WHERE event_time > NOW() - INTERVAL '${interval}'
|
||||
${deviceId ? 'AND device_id = $1' : ''}
|
||||
${siteFilter ? `AND ${siteFilter}` : ''}
|
||||
AND (topic ILIKE '%wireless%' OR topic ILIKE '%wifi%' OR topic ILIKE '%dhcp%')
|
||||
`, deviceId ? [deviceId] : []);
|
||||
|
||||
@@ -1109,13 +1123,19 @@ router.get('/rf/connectivity', async (req: Request, res: Response) => {
|
||||
// ─── Rogue / neighbor AP detection ────────────────────────────────────────────
|
||||
|
||||
// GET /api/wireless/rf/rogue — classify latest AP scans against our own SSIDs/BSSIDs
|
||||
router.get('/rf/rogue', async (_req: Request, res: Response) => {
|
||||
router.get('/rf/rogue', async (req: Request, res: Response) => {
|
||||
const { classifyScans } = await import('../utils/rogueAp');
|
||||
|
||||
// Only the scans are site-scoped. The "these are ours" SSID and BSSID sets stay
|
||||
// fleet-wide on purpose: an AP of yours in another site is still yours, and
|
||||
// scoping those sets would report your own hardware as a rogue (issue #130).
|
||||
const scanFilter = siteScopeByDevice(activeSite(req), 'a.device_id');
|
||||
|
||||
const [scans, radios, ifaceMacs] = await Promise.all([
|
||||
query<{ device_id: number; device_name: string; scanned_at: string; data: unknown }>(`
|
||||
SELECT DISTINCT ON (a.device_id) a.device_id, d.name AS device_name, a.scanned_at, a.data
|
||||
FROM ap_scan_data a JOIN devices d ON d.id = a.device_id
|
||||
${scanFilter ? `WHERE ${scanFilter}` : ''}
|
||||
ORDER BY a.device_id, a.scanned_at DESC`),
|
||||
query<{ ssid: string | null; mac_address: string | null }>(
|
||||
`SELECT ssid, mac_address FROM wireless_interfaces`),
|
||||
|
||||
@@ -33,6 +33,8 @@ export type BulkAddResultRow = {
|
||||
interface BulkJobPayload {
|
||||
jobId: string;
|
||||
items: CreateDeviceInput[];
|
||||
/** Site the batch was queued from, so queued adds land where the operator was looking. */
|
||||
siteId?: number | null;
|
||||
}
|
||||
|
||||
async function readMeta(jobId: string): Promise<Record<string, unknown>> {
|
||||
@@ -68,7 +70,7 @@ async function appendResults(jobId: string, rows: BulkAddResultRow[]): Promise<v
|
||||
}
|
||||
|
||||
async function processJob(job: Job<BulkJobPayload>): Promise<void> {
|
||||
const { jobId, items } = job.data;
|
||||
const { jobId, items, siteId } = job.data;
|
||||
await writeMeta(jobId, { status: 'active', processed: 0 });
|
||||
const poller = pollerService;
|
||||
|
||||
@@ -96,7 +98,8 @@ async function processJob(job: Job<BulkJobPayload>): Promise<void> {
|
||||
...item,
|
||||
device_type: item.device_type || 'router',
|
||||
},
|
||||
poller
|
||||
poller,
|
||||
{ siteId: siteId ?? null }
|
||||
);
|
||||
|
||||
let failMsg = 'Failed';
|
||||
@@ -128,11 +131,15 @@ async function processJob(job: Job<BulkJobPayload>): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
export async function enqueueBulkAddJob(jobId: string, items: CreateDeviceInput[]): Promise<void> {
|
||||
export async function enqueueBulkAddJob(
|
||||
jobId: string,
|
||||
items: CreateDeviceInput[],
|
||||
siteId: number | null = null
|
||||
): Promise<void> {
|
||||
if (!queue) {
|
||||
queue = new Queue(QUEUE_NAME, { connection: createRedisConnection() });
|
||||
}
|
||||
await queue.add('run', { jobId, items } satisfies BulkJobPayload, {
|
||||
await queue.add('run', { jobId, items, siteId } satisfies BulkJobPayload, {
|
||||
attempts: 1,
|
||||
removeOnComplete: { age: 3600, count: 100 },
|
||||
removeOnFail: { age: 86400 },
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { DEVICE_BASE_COLUMNS } from '../deviceColumns';
|
||||
|
||||
const SRC = join(__dirname, '..', '..');
|
||||
const read = (p: string) => readFileSync(join(SRC, p), 'utf8');
|
||||
|
||||
describe('DEVICE_BASE_COLUMNS', () => {
|
||||
it('includes the fields a device payload cannot be useful without', () => {
|
||||
for (const col of ['id', 'name', 'ip_address', 'status', 'device_type', 'site_id']) {
|
||||
expect(DEVICE_BASE_COLUMNS).toContain(col);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* The regression this file exists for: site_id was added to the device list
|
||||
* but not to the detail read, so the Site field on the device page could not
|
||||
* see its own value. It rendered "Unassigned" and looked like it rejected
|
||||
* every change, while the writes were landing correctly.
|
||||
*
|
||||
* Every query that returns a device to a client must go through the shared
|
||||
* column list, so a field added there reaches all of them at once.
|
||||
*/
|
||||
it('is used by every query that builds a device response', () => {
|
||||
for (const f of ['routes/devices.ts', 'services/deviceCreation.ts']) {
|
||||
const src = read(f);
|
||||
const selects = src.match(/`SELECT[^`]*FROM devices[^`]*`/g) ?? [];
|
||||
// A *payload* select is one whose COLUMN LIST reads display fields.
|
||||
// Inspecting the column list rather than the whole statement matters:
|
||||
// matching the full text catches WHERE clauses that merely mention
|
||||
// name or status, which is how an unrelated SSH-target query first
|
||||
// tripped this. Internal reads -- credentials for a connection,
|
||||
// existence probes, `SELECT *` for the collector -- are a different
|
||||
// thing and rightly don't carry the set.
|
||||
const columnList = (q: string) => q.slice(0, q.search(/\bFROM\b/));
|
||||
// Either already using the shared list, or hand-rolling display columns.
|
||||
// Both count as payloads; the assertion below is what separates them, so
|
||||
// a newly hand-rolled device SELECT fails rather than passing unnoticed.
|
||||
const payloadSelects = selects.filter((q) => {
|
||||
const cols = columnList(q);
|
||||
if (/SELECT \*/.test(cols)) return false;
|
||||
return cols.includes('DEVICE_BASE_COLUMNS')
|
||||
|| (/\bname\b/.test(cols) && /\bstatus\b/.test(cols));
|
||||
});
|
||||
expect(payloadSelects.length).toBeGreaterThan(0);
|
||||
for (const q of payloadSelects) {
|
||||
expect(columnList(q)).toContain('DEVICE_BASE_COLUMNS');
|
||||
// Free guard while we are here: a payload must never carry a secret.
|
||||
expect(columnList(q)).not.toMatch(/_encrypted/);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
@@ -5,10 +5,17 @@ import { safeConnectionError } from '../utils/safeClientError';
|
||||
import { RouterOSClient } from './mikrotik/RouterOSClient';
|
||||
import type { PollerService } from './PollerService';
|
||||
import type { CredentialPresetRow } from '../routes/credentialPresets';
|
||||
import { DEVICE_BASE_COLUMNS } from './deviceColumns';
|
||||
|
||||
export type CreateDeviceContext = {
|
||||
/** JWT role of the caller ('admin' | 'operator' | 'viewer'). Preset use may be restricted for operators. */
|
||||
requestingUserRole?: string;
|
||||
/**
|
||||
* Site the device should join (issue #130). A device with no site is invisible
|
||||
* the moment any site is selected, so this must never be left unset: when the
|
||||
* caller has no active site we fall back to the default site in SQL.
|
||||
*/
|
||||
siteId?: number | null;
|
||||
};
|
||||
|
||||
async function loadCredentialPreset(
|
||||
@@ -191,9 +198,7 @@ export async function createDeviceFromBody(
|
||||
}
|
||||
|
||||
const updatedExisting = await queryOne(
|
||||
`SELECT id, name, ip_address, api_port, api_username, model, serial_number,
|
||||
firmware_version, ros_version, device_type, status, last_seen, notes, created_at
|
||||
FROM devices WHERE id = $1`,
|
||||
`SELECT ${DEVICE_BASE_COLUMNS}, created_at FROM devices WHERE id = $1`,
|
||||
[existingBySerial.id]
|
||||
);
|
||||
if (!updatedExisting) {
|
||||
@@ -219,10 +224,14 @@ export async function createDeviceFromBody(
|
||||
|
||||
const rows = await query<{ id: number }>(
|
||||
`INSERT INTO devices (name, ip_address, api_port, api_username, api_password_encrypted,
|
||||
ssh_port, ssh_username, ssh_password_encrypted, device_type, notes, status)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,'unknown') RETURNING id`,
|
||||
ssh_port, ssh_username, ssh_password_encrypted, device_type, notes, status,
|
||||
site_id)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,'unknown',
|
||||
COALESCE($11::int, (SELECT id FROM sites ORDER BY is_default DESC, id LIMIT 1)))
|
||||
RETURNING id`,
|
||||
[name, ip_address, api_port, api_username, encryptedPass,
|
||||
ssh_port, ssh_username || null, encryptedSshPass, device_type, notes || null]
|
||||
ssh_port, ssh_username || null, encryptedSshPass, device_type, notes || null,
|
||||
ctx?.siteId ?? null]
|
||||
);
|
||||
|
||||
const newId = rows[0].id;
|
||||
@@ -232,9 +241,7 @@ export async function createDeviceFromBody(
|
||||
}
|
||||
|
||||
const device = await queryOne(
|
||||
`SELECT id, name, ip_address, api_port, api_username, model, serial_number,
|
||||
firmware_version, ros_version, device_type, status, last_seen, notes, created_at
|
||||
FROM devices WHERE id = $1`,
|
||||
`SELECT ${DEVICE_BASE_COLUMNS}, created_at FROM devices WHERE id = $1`,
|
||||
[newId]
|
||||
);
|
||||
|
||||
|
||||
@@ -743,6 +743,30 @@ export class DeviceCollector {
|
||||
.timestamp(new Date());
|
||||
writeApi.writePoint(globalPoint);
|
||||
|
||||
// The same deduplicated count, but for this device's site (issue #130).
|
||||
// Summing the per-device series instead would double-count a client seen
|
||||
// by two access points -- which is the exact error the global series was
|
||||
// introduced to avoid, so the site series has to be deduplicated too.
|
||||
const siteRows = await query<{ site_id: number | null; count: string }>(
|
||||
`SELECT d0.site_id,
|
||||
(SELECT COUNT(DISTINCT c.mac_address)
|
||||
FROM clients c
|
||||
JOIN devices d ON d.id = c.device_id
|
||||
WHERE c.active = TRUE AND d.site_id = d0.site_id) AS count
|
||||
FROM devices d0 WHERE d0.id = $1`,
|
||||
[this.device.id]
|
||||
);
|
||||
const siteId = siteRows[0]?.site_id ?? null;
|
||||
if (siteId != null) {
|
||||
writeApi.writePoint(
|
||||
new Point('client_counts')
|
||||
.tag('device_id', `_site_${siteId}`)
|
||||
.tag('device_name', `_site_${siteId}`)
|
||||
.intField('total_clients', parseInt(siteRows[0]?.count || '0', 10))
|
||||
.timestamp(new Date())
|
||||
);
|
||||
}
|
||||
|
||||
await writeApi.flush().catch(() => {});
|
||||
} catch (err) {
|
||||
console.error(`[${this.device.name}] Failed to update clients:`, err);
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import {
|
||||
resolveSiteId,
|
||||
siteScopeDevices,
|
||||
siteScopeByDevice,
|
||||
siteScopeByNullableDevice,
|
||||
andSite,
|
||||
clientSeriesTag,
|
||||
} from '../siteScope';
|
||||
|
||||
describe('resolveSiteId', () => {
|
||||
it('accepts positive integers', () => {
|
||||
expect(resolveSiteId('3')).toBe(3);
|
||||
expect(resolveSiteId(7)).toBe(7);
|
||||
expect(resolveSiteId(' 12 ')).toBe(12);
|
||||
});
|
||||
|
||||
// Unscoped is the compatibility path: anything that isn't a real site id must
|
||||
// behave exactly as the API did before sites existed, never as an error.
|
||||
it('treats absent, empty and "all" as unscoped', () => {
|
||||
expect(resolveSiteId(undefined)).toBeNull();
|
||||
expect(resolveSiteId(null)).toBeNull();
|
||||
expect(resolveSiteId('')).toBeNull();
|
||||
expect(resolveSiteId(' ')).toBeNull();
|
||||
expect(resolveSiteId('all')).toBeNull();
|
||||
expect(resolveSiteId('ALL')).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects zero and negatives', () => {
|
||||
expect(resolveSiteId('0')).toBeNull();
|
||||
expect(resolveSiteId('-4')).toBeNull();
|
||||
});
|
||||
|
||||
// The id is inlined into SQL, so this is the boundary that makes that safe.
|
||||
// Every one of these must come back null rather than reaching a query.
|
||||
it('rejects anything that is not a plain integer', () => {
|
||||
const hostile = [
|
||||
'1; DROP TABLE devices',
|
||||
'1 OR 1=1',
|
||||
"1'",
|
||||
'1.5',
|
||||
'1e3',
|
||||
'abc',
|
||||
'0x1',
|
||||
'١٢',
|
||||
{},
|
||||
[],
|
||||
];
|
||||
for (const raw of hostile) expect(resolveSiteId(raw)).toBeNull();
|
||||
});
|
||||
|
||||
// Surrounding whitespace is stripped, not rejected -- '\n3' is the number 3
|
||||
// with padding, which is what a header or query string may legitimately carry.
|
||||
it('trims surrounding whitespace rather than rejecting it', () => {
|
||||
expect(resolveSiteId('\n3')).toBe(3);
|
||||
expect(resolveSiteId('\t9 ')).toBe(9);
|
||||
});
|
||||
|
||||
it('never returns a value whose string form contains non-digits', () => {
|
||||
for (const raw of ['5', ' 5 ', 5]) {
|
||||
const id = resolveSiteId(raw);
|
||||
expect(id).not.toBeNull();
|
||||
expect(String(id)).toMatch(/^\d+$/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('siteScopeDevices', () => {
|
||||
it('returns null when unscoped so callers can skip the filter', () => {
|
||||
expect(siteScopeDevices(null)).toBeNull();
|
||||
expect(siteScopeDevices(null, 'd')).toBeNull();
|
||||
});
|
||||
|
||||
it('filters on site_id, qualified when an alias is given', () => {
|
||||
expect(siteScopeDevices(3)).toBe('site_id = 3');
|
||||
expect(siteScopeDevices(3, 'd')).toBe('d.site_id = 3');
|
||||
});
|
||||
});
|
||||
|
||||
describe('siteScopeByDevice', () => {
|
||||
it('returns null when unscoped', () => {
|
||||
expect(siteScopeByDevice(null)).toBeNull();
|
||||
});
|
||||
|
||||
it('scopes any device_id column through the devices table', () => {
|
||||
expect(siteScopeByDevice(4)).toBe('device_id IN (SELECT id FROM devices WHERE site_id = 4)');
|
||||
expect(siteScopeByDevice(4, 'c.device_id'))
|
||||
.toBe('c.device_id IN (SELECT id FROM devices WHERE site_id = 4)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('siteScopeByNullableDevice', () => {
|
||||
it('returns null when unscoped, so fleet-wide rows still appear', () => {
|
||||
expect(siteScopeByNullableDevice(null, 'e.device_id')).toBeNull();
|
||||
});
|
||||
|
||||
// A row with no device belongs to no site. Showing it in every site would
|
||||
// misrepresent it as that site's own, so it is hidden while one is selected.
|
||||
it('excludes rows with no device when a site is selected', () => {
|
||||
const sql = siteScopeByNullableDevice(2, 'e.device_id');
|
||||
expect(sql).toContain('e.device_id IS NOT NULL');
|
||||
expect(sql).toContain('site_id = 2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('andSite', () => {
|
||||
it('returns the base untouched when unscoped', () => {
|
||||
expect(andSite('WHERE a = 1', null)).toBe('WHERE a = 1');
|
||||
expect(andSite('', null)).toBe('');
|
||||
});
|
||||
|
||||
it('starts a WHERE clause when the base is empty', () => {
|
||||
expect(andSite('', 'site_id = 1')).toBe('WHERE site_id = 1');
|
||||
expect(andSite(' ', 'site_id = 1')).toBe('WHERE site_id = 1');
|
||||
});
|
||||
|
||||
it('appends with AND when the base already has a clause', () => {
|
||||
expect(andSite('WHERE a = 1', 'site_id = 1')).toBe('WHERE a = 1 AND site_id = 1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('clientSeriesTag', () => {
|
||||
it('uses the global series when unscoped', () => {
|
||||
expect(clientSeriesTag(null, 0, 4)).toBe('_global');
|
||||
});
|
||||
|
||||
// The case that matters for every existing single-site install: the site
|
||||
// holds the whole fleet, so the global series is exactly right and keeps the
|
||||
// chart's history instead of restarting it at the upgrade.
|
||||
it('uses the global series when the site holds every device', () => {
|
||||
expect(clientSeriesTag(1, 4, 4)).toBe('_global');
|
||||
});
|
||||
|
||||
it('uses the per-site series once the fleet is actually split', () => {
|
||||
expect(clientSeriesTag(1, 3, 4)).toBe('_site_1');
|
||||
expect(clientSeriesTag(2, 1, 4)).toBe('_site_2');
|
||||
});
|
||||
|
||||
// An empty site must read a series nothing writes, so the chart is empty
|
||||
// rather than showing the fleet's.
|
||||
it('uses the per-site series for an empty site', () => {
|
||||
expect(clientSeriesTag(3, 0, 4)).toBe('_site_3');
|
||||
});
|
||||
|
||||
// No devices at all: nothing to be equal to, so don't claim the global total.
|
||||
it('does not treat an empty install as a full site', () => {
|
||||
expect(clientSeriesTag(1, 0, 0)).toBe('_site_1');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* Site scoping helpers (issue #130).
|
||||
*
|
||||
* Every device-derived table hangs off devices(id), so a single site_id on
|
||||
* devices scopes clients, events, topology, backups and the rest transitively.
|
||||
* These helpers turn an active site into the SQL predicate that does it.
|
||||
*
|
||||
* The site id is inlined rather than parameterised, and that is a deliberate
|
||||
* choice worth justifying: the collection routes build their WHERE clauses by
|
||||
* incrementing a shared $n counter, so injecting a parameter mid-build would
|
||||
* mean renumbering every filter in ~23 routes -- exactly the kind of manual
|
||||
* bookkeeping that produces silent off-by-one bugs. Inlining is safe here
|
||||
* because resolveSiteId() below admits nothing but a positive integer; a value
|
||||
* that is not one becomes null (unscoped), never text that reaches SQL.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Normalise an untrusted site id (header, query string) into either a positive
|
||||
* integer or null, where null means "no scoping -- show the whole fleet".
|
||||
*
|
||||
* 'all' is the explicit all-sites view. Anything unparseable is also treated as
|
||||
* unscoped rather than rejected, so an existing API consumer that knows nothing
|
||||
* about sites keeps working exactly as it did.
|
||||
*/
|
||||
export function resolveSiteId(raw: unknown): number | null {
|
||||
if (raw === null || raw === undefined) return null;
|
||||
const s = String(raw).trim();
|
||||
if (s === '' || s.toLowerCase() === 'all') return null;
|
||||
// Matched as a literal digit string rather than via Number(), deliberately.
|
||||
// Number() would accept '1e3', '0x1' and even Arabic-Indic '\u0661\u0662' as
|
||||
// integers -- all harmless here, but it makes the safety of inlining rest on
|
||||
// coercion trivia. A digits-only regex makes it self-evident instead.
|
||||
if (!/^\d+$/.test(s)) return null;
|
||||
const n = Number(s);
|
||||
if (!Number.isSafeInteger(n) || n <= 0) return null;
|
||||
return n;
|
||||
}
|
||||
|
||||
/**
|
||||
* Predicate for a query that selects from `devices` itself.
|
||||
* Returns null when unscoped, so callers can skip pushing a filter.
|
||||
*/
|
||||
export function siteScopeDevices(siteId: number | null, alias = ''): string | null {
|
||||
if (siteId === null) return null;
|
||||
const col = alias ? `${alias}.site_id` : 'site_id';
|
||||
return `${col} = ${siteId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Predicate for a query on any table carrying a device_id.
|
||||
* `column` is the fully-qualified device id column, e.g. 'c.device_id'.
|
||||
*/
|
||||
export function siteScopeByDevice(siteId: number | null, column = 'device_id'): string | null {
|
||||
if (siteId === null) return null;
|
||||
return `${column} IN (SELECT id FROM devices WHERE site_id = ${siteId})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Same, but tolerant of a nullable device id. Rows not attached to any device
|
||||
* (fleet-wide events, for instance) belong to no site and are hidden while a
|
||||
* site is selected -- showing them in every site would misrepresent them as
|
||||
* that site's own.
|
||||
*/
|
||||
export function siteScopeByNullableDevice(siteId: number | null, column = 'device_id'): string | null {
|
||||
if (siteId === null) return null;
|
||||
return `(${column} IS NOT NULL AND ${column} IN (SELECT id FROM devices WHERE site_id = ${siteId}))`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Join a base WHERE clause with an optional site predicate.
|
||||
* Handles the empty-base case so routes don't each reinvent it.
|
||||
*/
|
||||
export function andSite(base: string, predicate: string | null): string {
|
||||
if (!predicate) return base;
|
||||
const trimmed = base.trim();
|
||||
if (!trimmed) return `WHERE ${predicate}`;
|
||||
return `${base} AND ${predicate}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Which deduplicated client-count series to read for a site (issue #130).
|
||||
*
|
||||
* DeviceCollector writes a fleet-wide `_global` series and a per-site
|
||||
* `_site_<id>` series. The per-site one only accumulates from the upgrade
|
||||
* onward, so preferring it unconditionally would throw away the existing
|
||||
* history of every install that has one site.
|
||||
*
|
||||
* When a site holds every device, its total *is* the global total -- an
|
||||
* identity, not an approximation -- so `_global` is both correct and complete.
|
||||
*/
|
||||
export function clientSeriesTag(
|
||||
siteId: number | null,
|
||||
devicesInSite: number,
|
||||
devicesTotal: number
|
||||
): string {
|
||||
if (siteId === null) return '_global';
|
||||
if (devicesTotal > 0 && devicesInSite === devicesTotal) return '_global';
|
||||
return `_site_${siteId}`;
|
||||
}
|
||||
Reference in New Issue
Block a user