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
@@ -23,7 +23,7 @@ jobs:
|
||||
run: node scripts/generate-ghcr-compose.mjs --check
|
||||
|
||||
frontend:
|
||||
name: Frontend — Lint, Type-check & Build
|
||||
name: Frontend — Lint, Type-check, Test & Build
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
@@ -48,6 +48,9 @@ jobs:
|
||||
- name: Type-check
|
||||
run: npx tsc --noEmit
|
||||
|
||||
- name: Tests
|
||||
run: npm test
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/version-0.23.35_Beta-blue" alt="Version" />
|
||||
<img src="https://img.shields.io/badge/version-0.24.0_Beta-blue" alt="Version" />
|
||||
<img src="https://img.shields.io/badge/license-AGPLv3-blue" alt="License" />
|
||||
<img src="https://img.shields.io/badge/docker-compose-2496ED?logo=docker&logoColor=white" alt="Docker" />
|
||||
<img src="https://img.shields.io/badge/TypeScript-5.3-3178C6?logo=typescript&logoColor=white" alt="TypeScript" />
|
||||
@@ -122,6 +122,7 @@ links the relevant MikroTik documentation.
|
||||
| **CAPsMAN aware** | Centrally provisioned APs are recognised, grouped under their controller, and protected from local writes the controller would discard |
|
||||
| **Rogue AP detection** | A foreign BSSID broadcasting one of your SSIDs is flagged as an evil twin |
|
||||
| **Anomaly insights** | Client counts and CPU compared against each device's own same-hour 14-day baseline, not a fixed threshold |
|
||||
| **Multi-site** | Group devices into sites and view each one's clients, events and topology separately, with an all-sites map. One install, many customer networks |
|
||||
| **Enterprise auth** | OIDC/SSO, TOTP two-factor, role-based access, and scoped API tokens |
|
||||
|
||||
---
|
||||
@@ -343,6 +344,7 @@ Each network service supports multi-device management with conflict detection:
|
||||
- **Configuration templates** — reusable sets (DNS, NTP, syslog) pushed to many devices with per-device result reporting
|
||||
- **Bulk device add** — "Try All" on discovered devices runs as a server-side job that survives a closed browser tab, with live progress and cancel
|
||||
- **Device organisation** — colour-coded tags, rack location, physical address with map support, and per-device notes
|
||||
- **Sites** — group devices into sites and see each one's devices, clients, events, topology and dashboards on their own. A selector under the logo switches between them, and an all-sites view pins every site on a world map. Single-network installs are collected into one "Default Site" and never meet the concept. Polling stays fleet-wide — sites are a view over the data, not a change to how it is gathered. See [the sites guide](docs/sites.md)
|
||||
|
||||
### Access control and platform
|
||||
|
||||
@@ -367,6 +369,7 @@ so it moves when behaviour does.
|
||||
|---|---|
|
||||
| [Configuration](docs/configuration.md) | Environment variables, secret management, key rotation, TLS |
|
||||
| [Change Guard and Config Health](docs/change-guard.md) | How the safety system works, its settings, and what to expect when it fires |
|
||||
| [Sites](docs/sites.md) | Grouping devices into sites, switching between them, and what each site scopes |
|
||||
| [Alerting](docs/alerting.md) | Rules, channels, ntfy priority mapping, webhooks, maintenance windows |
|
||||
| [Single sign-on (OIDC)](docs/sso-oidc.md) | Identity provider setup, group-to-role mapping, break-glass behaviour |
|
||||
| [CAPsMAN](docs/capsman.md) | How centrally provisioned APs are modelled, and current limits |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mikrotik-manager-backend",
|
||||
"version": "0.23.35-beta",
|
||||
"version": "0.24.0-beta",
|
||||
"description": "Mikrotik Network Management Platform - Backend API",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -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,16 +61,34 @@ 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) {
|
||||
// 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")
|
||||
|> filter(fn: (r) => r.device_id != "_global")
|
||||
${deviceFilter}
|
||||
|> aggregateWindow(every: 5m, fn: max, createEmpty: false)
|
||||
|> group()
|
||||
|> aggregateWindow(every: 5m, fn: max, createEmpty: false)
|
||||
@@ -64,6 +104,7 @@ router.get('/clients-over-time', async (req: Request, res: Response) => {
|
||||
// No data yet
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const points = rawPoints.sort((a, b) => a.time.localeCompare(b.time));
|
||||
res.json(points);
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -6,6 +6,7 @@ whether to try it, the [project README](https://github.com/2GT-Media-Group-LLC/m
|
||||
| Document | Covers |
|
||||
|---|---|
|
||||
| [Configuration](configuration.md) | Environment variables, secret management and key rotation |
|
||||
| [Sites](sites.md) | Grouping devices into sites, switching between them, and what each site scopes |
|
||||
| [Change Guard and Config Health](change-guard.md) | How the safety system works, its settings, and what to expect when it fires |
|
||||
| [Alerting](alerting.md) | Alert rules, delivery channels, webhooks, scheduled reports, maintenance windows |
|
||||
| [Single sign-on (OIDC)](sso-oidc.md) | Identity provider setup, group-to-role mapping, break-glass behaviour |
|
||||
|
||||
+19
@@ -43,6 +43,25 @@ or 2FA changes.
|
||||
| `GET /api/devices/:id/config-health` | Latest standing-audit findings |
|
||||
| `GET /api/topology` | Graph of devices, links, external nodes, and distrusted identifiers |
|
||||
| `GET /api/operations/insights` | The dashboard's "things to handle" feed |
|
||||
| `GET /api/sites` | Sites with device counts (see [Sites](sites.md)) |
|
||||
|
||||
### Scoping a request to one site
|
||||
|
||||
Collection endpoints accept an `X-Site-Id` header (or `?site_id=`) that limits the
|
||||
response to devices in that site:
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: Bearer $TOKEN" -H "X-Site-Id: 2" \
|
||||
https://mtm.example.com/api/devices
|
||||
```
|
||||
|
||||
**Omitting it means unscoped**, returning the whole fleet exactly as before sites
|
||||
existed — existing scripts and tokens need no change. Per-device endpoints
|
||||
(`/api/devices/:id/...`) are already scoped by the device itself and ignore the header.
|
||||
|
||||
One thing to know if you create devices via the API: a device joins the site the
|
||||
request was scoped to, falling back to the default site. A device belonging to no site
|
||||
is invisible whenever a site is selected, so this is set rather than left null.
|
||||
|
||||
`preflight` is the useful one for automation: it returns the same verdict the UI shows,
|
||||
so a pipeline can refuse its own change before touching the device. See
|
||||
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
# Sites
|
||||
|
||||
[← Documentation index](README.md)
|
||||
|
||||
A **site** is a named collection of devices whose data — clients, events, topology,
|
||||
backups, statistics — is viewed separately from every other site's.
|
||||
|
||||
This is multi-*site*, not multi-tenancy. Sites separate what you **see**; they do not
|
||||
separate who you **are**. There is deliberately no per-site access control: the existing
|
||||
admin / manage / read-only roles are unchanged, and every user can see every site.
|
||||
|
||||
## If you run one network, ignore this page
|
||||
|
||||
On upgrade, a single site named **Default Site** is created and every existing device is
|
||||
assigned to it. Nothing moves, no figure changes, and the site selector does not appear.
|
||||
You will never meet the concept unless you create a second site.
|
||||
|
||||
## Creating and switching
|
||||
|
||||
The selector sits under the logo in the top-left, below the brand block. It lists your
|
||||
sites and offers **New site**, **rename** (the pencil, on hover) and **All sites**.
|
||||
|
||||
Switching site reloads the data you are looking at. This is intentional and not a
|
||||
performance bug: every cached figure belonged to the site you just left, and showing it
|
||||
under another site's name would be worse than a brief reload.
|
||||
|
||||
## Moving devices
|
||||
|
||||
A device belongs to exactly one site. Move it from **Device → Physical Details → Site**;
|
||||
the picker only appears when more than one site exists.
|
||||
|
||||
Moving a device takes its history with it — clients, events, backups, interface
|
||||
statistics. Nothing is copied or lost, because none of that data carries a site of its
|
||||
own: it hangs off the device, and the device carries the site.
|
||||
|
||||
Devices added through the UI or API join whichever site you were viewing, falling back
|
||||
to the default site. A device belonging to no site would be invisible the moment any
|
||||
site is selected, so the field is always set rather than left empty.
|
||||
|
||||
## The all-sites view
|
||||
|
||||
**All sites** in the selector opens a table of every site with its address and device
|
||||
count, plus a world map with a pin per geocoded site. Selecting a row switches to that
|
||||
site.
|
||||
|
||||
Choosing **All sites** also makes every other page fleet-wide, which is the honest reading
|
||||
of "all sites" — useful for a fleet-wide search, less so for a dashboard that averages
|
||||
several unrelated networks.
|
||||
|
||||
Pins require an address, which is geocoded via Nominatim when you save it. If
|
||||
**Settings → Maps enabled** is off, no geocoding or tile request is made and the map is
|
||||
hidden; addresses are still stored and shown as text.
|
||||
|
||||
## What sites scope, and what they do not
|
||||
|
||||
Scoped to the selected site:
|
||||
|
||||
- Devices, clients, events, topology, search
|
||||
- Dashboard and Operations insights, metrics summary
|
||||
- Wireless (APs, CAPsMAN, RF channels and signals)
|
||||
- Backups, Firmware, Bulk commands
|
||||
|
||||
Also scoped: the Operations feed (Things to Handle, Recent Activity, capacity), Config
|
||||
Health findings, anomaly detection, and the Connected Clients chart.
|
||||
|
||||
Deliberately **not** scoped:
|
||||
|
||||
- **Polling.** Collection always runs across the whole fleet. Sites are a view over data
|
||||
already gathered, not a change to how it is gathered — a device does not stop being
|
||||
monitored because you are looking at another site.
|
||||
- **The audit log**, in Recent Activity. Audit rows record who changed the install; they
|
||||
carry no device and so belong to no site. Repeating "admin changed a setting" into every
|
||||
site would misattribute it, so the audit feed appears only in the all-sites view.
|
||||
- **NetFlow collector health**, which is one service for the install.
|
||||
- **Rogue AP detection.** The scans are site-scoped, but the "these access points are
|
||||
ours" set stays fleet-wide. An AP of yours in another site is still yours, and scoping
|
||||
that set would report your own hardware as a rogue.
|
||||
- **Install-wide configuration**: users, alert rules, webhooks, config templates,
|
||||
credential presets, tags, maintenance windows and settings.
|
||||
|
||||
Bulk actions — Backup all, Sync all, firmware check-all, bulk commands — obey the
|
||||
selector. A bulk operation that silently spanned two customers is precisely the blast
|
||||
radius the wave and halt-on-failure guards exist to contain.
|
||||
|
||||
## Deleting a site
|
||||
|
||||
Deleting a site never deletes the devices in it. If devices remain, the deletion is
|
||||
refused and you are asked to move them first, so their destination is a decision rather
|
||||
than a side effect.
|
||||
|
||||
## API
|
||||
|
||||
The active site travels as an `X-Site-Id` header, or a `?site_id=` query parameter.
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: Bearer $TOKEN" -H "X-Site-Id: 2" https://mtm.example.com/api/devices
|
||||
```
|
||||
|
||||
**Omitting the header means unscoped**, returning the whole fleet exactly as the API did
|
||||
before sites existed. Existing scripts and API tokens keep working untouched.
|
||||
|
||||
| Method | Path | Purpose |
|
||||
|---|---|---|
|
||||
| `GET` | `/api/sites` | List sites with device counts |
|
||||
| `POST` | `/api/sites` | Create a site |
|
||||
| `PUT` | `/api/sites/:id` | Rename, set address or notes |
|
||||
| `DELETE` | `/api/sites/:id` | Delete (refused while devices remain) |
|
||||
| `POST` | `/api/sites/:id/devices` | Move devices into the site |
|
||||
|
||||
## The Connected Clients chart
|
||||
|
||||
Client totals are deduplicated — a client seen by two access points is one client — so the
|
||||
chart cannot be a sum of per-device series. A per-site deduplicated series is recorded
|
||||
alongside the fleet-wide one, and it starts accumulating from the upgrade.
|
||||
|
||||
That would have cost every existing install its chart history, so there is one shortcut,
|
||||
and it is an identity rather than an approximation: **when a site contains every device,
|
||||
its total is the fleet total**, and the chart reads the fleet-wide series with all of its
|
||||
history. Single-site installs therefore lose nothing. Once you genuinely split the fleet,
|
||||
each site's chart builds its own history from that point.
|
||||
|
||||
## A note on client counts
|
||||
|
||||
A client is counted in a site when a device in that site has seen it. On genuinely
|
||||
separate networks the sites do not overlap. If two sites share one broadcast domain —
|
||||
common in a lab — the same MAC is legitimately seen by hardware in both, and appears in
|
||||
both counts.
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mikrotik-manager-frontend",
|
||||
"version": "0.23.35-beta",
|
||||
"version": "0.24.0-beta",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -3,6 +3,8 @@ import { useAuthStore } from './store/authStore';
|
||||
import AppLayout from './components/layout/AppLayout';
|
||||
import LoginPage from './pages/LoginPage';
|
||||
import DashboardPage from './pages/DashboardPage';
|
||||
import SitesPage from './pages/SitesPage';
|
||||
import SiteLanding from './components/SiteLanding';
|
||||
import DevicesPage from './pages/DevicesPage';
|
||||
import DeviceDetailPage from './pages/DeviceDetailPage';
|
||||
import ClientsPage from './pages/ClientsPage';
|
||||
@@ -48,7 +50,7 @@ export default function App() {
|
||||
</ProtectedRoute>
|
||||
}
|
||||
>
|
||||
<Route index element={<Navigate to="/dashboard" replace />} />
|
||||
<Route index element={<SiteLanding />} />
|
||||
<Route path="dashboard" element={<DashboardPage />} />
|
||||
<Route path="devices" element={<DevicesPage />} />
|
||||
<Route path="devices/:id" element={<DeviceDetailPage />} />
|
||||
@@ -59,6 +61,7 @@ export default function App() {
|
||||
<Route path="backups" element={<BackupsPage />} />
|
||||
<Route path="firmware" element={<FirmwarePage />} />
|
||||
<Route path="commands" element={<CommandsPage />} />
|
||||
<Route path="sites" element={<SitesPage />} />
|
||||
<Route path="settings" element={<SettingsPage />} />
|
||||
{/* Legacy routes — consolidated in the v0.16.4 UI reorganization */}
|
||||
<Route path="switches" element={<Navigate to="/devices?type=SW" replace />} />
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { sitesApi } from '../services/api';
|
||||
import { useSiteStore } from '../store/siteStore';
|
||||
|
||||
/**
|
||||
* Where "/" goes (issue #130).
|
||||
*
|
||||
* One site means a single-network install, which must never meet the concept:
|
||||
* straight to the dashboard. More than one, with no site chosen, means the
|
||||
* all-sites overview is the honest landing page — a dashboard aggregating
|
||||
* several customers' networks would be a number nobody asked for.
|
||||
*/
|
||||
export default function SiteLanding() {
|
||||
const { currentSiteId } = useSiteStore();
|
||||
const { data: sites, isLoading } = useQuery({
|
||||
queryKey: ['sites'],
|
||||
queryFn: () => sitesApi.list().then((r) => r.data),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
// Don't bounce through /sites while the list is still in flight.
|
||||
if (isLoading) return null;
|
||||
if (currentSiteId == null && (sites?.length ?? 0) > 1) return <Navigate to="/sites" replace />;
|
||||
return <Navigate to="/dashboard" replace />;
|
||||
}
|
||||
@@ -5,9 +5,11 @@ import {
|
||||
} from 'lucide-react';
|
||||
import L from 'leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import { devicesApi, settingsApi } from '../../services/api';
|
||||
import { devicesApi, settingsApi, sitesApi } from '../../services/api';
|
||||
import type { Device } from '../../types';
|
||||
import { useCanWrite } from '../../hooks/useCanWrite';
|
||||
import { geocodeAddress, OSM_TILE_URL, OSM_ATTRIBUTION } from '../../utils/geocode';
|
||||
import { Network } from 'lucide-react';
|
||||
|
||||
interface Props {
|
||||
device: Device;
|
||||
@@ -20,22 +22,6 @@ interface LocationForm {
|
||||
notes: string;
|
||||
}
|
||||
|
||||
async function geocodeAddress(address: string): Promise<{ lat: number; lng: number } | null> {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(address)}&format=json&limit=1`,
|
||||
{ headers: { Accept: 'application/json' } }
|
||||
);
|
||||
const data = await res.json();
|
||||
if (Array.isArray(data) && data.length > 0) {
|
||||
return { lat: parseFloat(data[0].lat), lng: parseFloat(data[0].lon) };
|
||||
}
|
||||
} catch {
|
||||
// ignore geocoding failures
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function MapEmbed({ lat, lng, address }: { lat: number | string; lng: number | string; address: string }) {
|
||||
const latN = Number(lat);
|
||||
const lngN = Number(lng);
|
||||
@@ -44,9 +30,7 @@ function MapEmbed({ lat, lng, address }: { lat: number | string; lng: number | s
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
const map = L.map(containerRef.current, { scrollWheelZoom: false });
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
||||
}).addTo(map);
|
||||
L.tileLayer(OSM_TILE_URL, { attribution: OSM_ATTRIBUTION }).addTo(map);
|
||||
L.circleMarker([latN, lngN], {
|
||||
radius: 9, color: '#fff', weight: 2, fillColor: '#3b82f6', fillOpacity: 0.9,
|
||||
}).bindPopup(`<b>${address}</b>`).addTo(map);
|
||||
@@ -87,6 +71,26 @@ export default function DeviceLocationSection({ device }: Props) {
|
||||
staleTime: 300_000,
|
||||
});
|
||||
const mapsEnabled = settings?.['maps_enabled'] !== false;
|
||||
|
||||
// Which site this device belongs to (issue #130). Shown only when more than
|
||||
// one site exists — on a single-network install it is noise.
|
||||
const { data: sites = [] } = useQuery({
|
||||
queryKey: ['sites'],
|
||||
queryFn: () => sitesApi.list().then((r) => r.data),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
const showSitePicker = sites.length > 1;
|
||||
const currentSite = sites.find((s) => s.id === device.site_id) ?? null;
|
||||
|
||||
const moveSite = useMutation({
|
||||
mutationFn: (siteId: number) => sitesApi.assignDevices(siteId, [device.id]),
|
||||
onSuccess: () => {
|
||||
// The device may have just left the site being viewed, so every scoped
|
||||
// figure on screen is now potentially wrong. Invalidate everything and
|
||||
// let the active queries refetch.
|
||||
queryClient.invalidateQueries();
|
||||
},
|
||||
});
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [geocoding, setGeocoding] = useState(false);
|
||||
const [saveError, setSaveError] = useState('');
|
||||
@@ -173,6 +177,28 @@ export default function DeviceLocationSection({ device }: Props) {
|
||||
|
||||
{editing ? (
|
||||
<div className="space-y-4">
|
||||
{showSitePicker && (
|
||||
<div>
|
||||
<label className="label flex items-center gap-1.5">
|
||||
<Network className="w-3.5 h-3.5 text-blue-500" /> Site
|
||||
</label>
|
||||
<select
|
||||
className="input"
|
||||
value={device.site_id ?? ''}
|
||||
disabled={moveSite.isPending}
|
||||
onChange={(e) => {
|
||||
const id = parseInt(e.target.value, 10);
|
||||
if (Number.isInteger(id)) moveSite.mutate(id);
|
||||
}}
|
||||
>
|
||||
{device.site_id == null && <option value="">Unassigned</option>}
|
||||
{sites.map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}
|
||||
</select>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
Moving a device takes its clients, events and history with it. Applies immediately.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="label flex items-center gap-1.5">
|
||||
<MapPin className="w-3.5 h-3.5 text-blue-500" /> Physical Location
|
||||
@@ -247,6 +273,11 @@ export default function DeviceLocationSection({ device }: Props) {
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3 text-sm">
|
||||
{showSitePicker && (
|
||||
<InfoRow icon={<Network className="w-3.5 h-3.5 text-blue-500" />} label="Site"
|
||||
value={currentSite?.name}
|
||||
emptyText="Unassigned" />
|
||||
)}
|
||||
<InfoRow icon={<MapPin className="w-3.5 h-3.5 text-blue-500" />} label="Location"
|
||||
value={device.location_address}
|
||||
emptyText="No location set" />
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
LayoutGrid, TerminalSquare,
|
||||
} from 'lucide-react';
|
||||
import clsx from 'clsx';
|
||||
import SiteSelector from './SiteSelector';
|
||||
import { APP_VERSION } from '../../version';
|
||||
import { devicesApi } from '../../services/api';
|
||||
|
||||
@@ -243,6 +244,9 @@ export default function Sidebar({ mobileOpen, onMobileClose }: SidebarProps) {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Site selector — under the brand block, deliberately separate from it */}
|
||||
<SiteSelector isCollapsed={isCollapsed} onNavigate={handleNavClick} />
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 px-[14px] py-3 space-y-[1px] overflow-y-auto">
|
||||
{/* ── Monitor ── */}
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query';
|
||||
import { Building2, ChevronDown, Check, Plus, Pencil, Globe, X } from 'lucide-react';
|
||||
import clsx from 'clsx';
|
||||
import { sitesApi } from '../../services/api';
|
||||
import { useSiteStore, type Site } from '../../store/siteStore';
|
||||
import { useCanWrite } from '../../hooks/useCanWrite';
|
||||
|
||||
interface Props {
|
||||
isCollapsed: boolean;
|
||||
onNavigate?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Site picker, mounted under the brand block (issue #130).
|
||||
*
|
||||
* Hidden entirely when only one site exists: a single-network install should
|
||||
* never have to learn what a site is.
|
||||
*/
|
||||
export default function SiteSelector({ isCollapsed, onNavigate }: Props) {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const canWrite = useCanWrite();
|
||||
const { currentSiteId, setCurrentSite } = useSiteStore();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [renaming, setRenaming] = useState<Site | null>(null);
|
||||
const [name, setName] = useState('');
|
||||
const [address, setAddress] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const wrapRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { data: sites = [] } = useQuery({
|
||||
queryKey: ['sites'],
|
||||
queryFn: () => sitesApi.list().then((r) => r.data),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
function closeAll() {
|
||||
setOpen(false);
|
||||
setCreating(false);
|
||||
setRenaming(null);
|
||||
setError('');
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDown = (e: MouseEvent) => {
|
||||
if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) closeAll();
|
||||
};
|
||||
document.addEventListener('mousedown', onDown);
|
||||
return () => document.removeEventListener('mousedown', onDown);
|
||||
}, [open]);
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) throw new Error('Site name is required');
|
||||
const payload = { name: trimmed, address: address.trim() };
|
||||
if (renaming) return sitesApi.update(renaming.id, payload).then((r) => r.data);
|
||||
return sitesApi.create(payload).then((r) => r.data);
|
||||
},
|
||||
onSuccess: (site) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['sites'] });
|
||||
// A newly created site is empty, so switching to it immediately is the
|
||||
// useful move: the next thing you do is add devices to it.
|
||||
if (!renaming) switchTo(site.id);
|
||||
closeAll();
|
||||
},
|
||||
onError: (e: unknown) => {
|
||||
const msg = (e as { response?: { data?: { error?: string } }; message?: string });
|
||||
setError(msg.response?.data?.error ?? msg.message ?? 'Could not save site');
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Changing the site changes the QueryClient's key in main.tsx, which remounts
|
||||
* the tree against an empty cache. Nothing to clear here -- and clearing was
|
||||
* in fact the bug: queryClient.clear() drops queries but leaves mounted
|
||||
* observers holding their last result, so the pages never updated.
|
||||
*/
|
||||
function switchTo(id: number | null) {
|
||||
setCurrentSite(id);
|
||||
closeAll();
|
||||
onNavigate?.();
|
||||
}
|
||||
|
||||
function openAllSites() {
|
||||
closeAll();
|
||||
onNavigate?.();
|
||||
// Navigate first: setting the site remounts the tree, and the route must
|
||||
// already be /sites when it comes back.
|
||||
navigate('/sites');
|
||||
setCurrentSite(null);
|
||||
}
|
||||
|
||||
// Nothing to show until the site list arrives.
|
||||
if (sites.length === 0) return null;
|
||||
|
||||
const current = currentSiteId == null ? null : sites.find((s) => s.id === currentSiteId) ?? null;
|
||||
|
||||
// With one site there is nothing to choose between, so "all sites" and "that
|
||||
// site" describe the same set. Name it rather than saying "All sites", so a
|
||||
// single-network install sees "Default Site" and not a concept it never asked
|
||||
// for. Deliberately a display rule and not a state change: selecting it for
|
||||
// real would rekey the QueryClient and remount the app on every page load.
|
||||
const soleSite = sites.length === 1 ? sites[0] : null;
|
||||
const shown = current ?? soleSite;
|
||||
const label = shown?.name ?? 'All sites';
|
||||
|
||||
return (
|
||||
<div ref={wrapRef} className="relative px-[14px] pt-3">
|
||||
<button
|
||||
onClick={() => (open ? closeAll() : setOpen(true))}
|
||||
className={clsx(
|
||||
'w-full flex items-center gap-2 rounded-lg px-2 py-2 transition-colors',
|
||||
'hover:bg-black/5 dark:hover:bg-white/5',
|
||||
isCollapsed && 'md:justify-center md:px-0'
|
||||
)}
|
||||
style={{ border: '1px solid var(--line)' }}
|
||||
title={label}
|
||||
>
|
||||
{shown
|
||||
? <Building2 className="w-4 h-4 flex-shrink-0" style={{ color: 'var(--ink-3)' }} />
|
||||
: <Globe className="w-4 h-4 flex-shrink-0" style={{ color: 'var(--ink-3)' }} />}
|
||||
<span
|
||||
className={clsx('flex-1 text-left text-[12.5px] font-medium truncate', isCollapsed && 'md:hidden')}
|
||||
style={{ color: 'var(--ink)' }}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={clsx('w-3.5 h-3.5 flex-shrink-0 transition-transform', open && 'rotate-180', isCollapsed && 'md:hidden')}
|
||||
style={{ color: 'var(--ink-4)' }}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
className="absolute left-[14px] right-[14px] mt-1 z-50 rounded-lg shadow-lg overflow-hidden"
|
||||
style={{ background: 'var(--surface)', border: '1px solid var(--line)', minWidth: 200 }}
|
||||
>
|
||||
{creating || renaming ? (
|
||||
<div className="p-3 space-y-2">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-wide" style={{ color: 'var(--ink-4)' }}>
|
||||
{renaming ? 'Rename site' : 'New site'}
|
||||
</div>
|
||||
<input
|
||||
autoFocus
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') saveMutation.mutate(); if (e.key === 'Escape') closeAll(); }}
|
||||
placeholder="Site name"
|
||||
className="w-full px-2 py-1.5 text-[12.5px] rounded border bg-transparent"
|
||||
style={{ borderColor: 'var(--line)', color: 'var(--ink)' }}
|
||||
/>
|
||||
<input
|
||||
value={address}
|
||||
onChange={(e) => setAddress(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') saveMutation.mutate(); if (e.key === 'Escape') closeAll(); }}
|
||||
placeholder="Address (optional)"
|
||||
className="w-full px-2 py-1.5 text-[12.5px] rounded border bg-transparent"
|
||||
style={{ borderColor: 'var(--line)', color: 'var(--ink)' }}
|
||||
/>
|
||||
{error && <div className="text-[11px] text-red-500">{error}</div>}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => saveMutation.mutate()}
|
||||
disabled={saveMutation.isPending}
|
||||
className="flex-1 px-2 py-1.5 text-[12px] rounded bg-blue-600 text-white hover:bg-blue-500 disabled:opacity-50"
|
||||
>
|
||||
{saveMutation.isPending ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
<button
|
||||
onClick={closeAll}
|
||||
className="px-2 py-1.5 text-[12px] rounded"
|
||||
style={{ color: 'var(--ink-3)', border: '1px solid var(--line)' }}
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-[10.5px] leading-snug" style={{ color: 'var(--ink-4)' }}>
|
||||
The address is geocoded for the all-sites map. Devices are moved between
|
||||
sites from the device list.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-1 max-h-[320px] overflow-y-auto">
|
||||
{sites.length > 1 && (
|
||||
<>
|
||||
<button
|
||||
onClick={openAllSites}
|
||||
className="w-full flex items-center gap-2 px-3 py-1.5 text-[12.5px] hover:bg-black/5 dark:hover:bg-white/5"
|
||||
style={{ color: 'var(--ink)' }}
|
||||
>
|
||||
<Globe className="w-3.5 h-3.5 flex-shrink-0" style={{ color: 'var(--ink-3)' }} />
|
||||
<span className="flex-1 text-left">All sites</span>
|
||||
{currentSiteId == null && <Check className="w-3.5 h-3.5 text-blue-500" />}
|
||||
</button>
|
||||
<div className="my-1 h-px" style={{ background: 'var(--line)' }} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{sites.map((s) => (
|
||||
<div key={s.id} className="group flex items-center hover:bg-black/5 dark:hover:bg-white/5">
|
||||
<button
|
||||
onClick={() => switchTo(s.id)}
|
||||
className="flex-1 flex items-center gap-2 px-3 py-1.5 text-[12.5px] min-w-0"
|
||||
style={{ color: 'var(--ink)' }}
|
||||
>
|
||||
<Building2 className="w-3.5 h-3.5 flex-shrink-0" style={{ color: 'var(--ink-3)' }} />
|
||||
<span className="flex-1 text-left truncate">{s.name}</span>
|
||||
<span className="text-[10.5px] flex-shrink-0" style={{ color: 'var(--ink-4)' }}>
|
||||
{s.device_count}
|
||||
</span>
|
||||
{shown?.id === s.id && <Check className="w-3.5 h-3.5 text-blue-500 flex-shrink-0" />}
|
||||
</button>
|
||||
{canWrite && (
|
||||
<button
|
||||
onClick={() => { setRenaming(s); setName(s.name); setAddress(s.address ?? ''); setError(''); }}
|
||||
className="px-2 py-1.5 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
style={{ color: 'var(--ink-4)' }}
|
||||
title={`Rename ${s.name}`}
|
||||
>
|
||||
<Pencil className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{canWrite && (
|
||||
<>
|
||||
<div className="my-1 h-px" style={{ background: 'var(--line)' }} />
|
||||
<button
|
||||
onClick={() => { setCreating(true); setName(''); setAddress(''); setError(''); }}
|
||||
className="w-full flex items-center gap-2 px-3 py-1.5 text-[12.5px] hover:bg-black/5 dark:hover:bg-white/5"
|
||||
style={{ color: 'var(--ink-3)' }}
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5 flex-shrink-0" />
|
||||
<span className="flex-1 text-left">New site</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+37
-4
@@ -2,6 +2,7 @@ import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import App from './App';
|
||||
import { useSiteStore } from './store/siteStore';
|
||||
import './index.css';
|
||||
|
||||
// Initialize theme from storage immediately
|
||||
@@ -21,7 +22,8 @@ if (storedTheme === 'dark') {
|
||||
document.documentElement.classList.add('dark');
|
||||
}
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
function makeQueryClient() {
|
||||
return new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 30_000,
|
||||
@@ -29,12 +31,43 @@ const queryClient = new QueryClient({
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Scopes the whole React Query cache to the active site (issue #130).
|
||||
*
|
||||
* The obvious move -- queryClient.clear() on switch -- does not work, and the
|
||||
* reason is worth recording. clear() destroys each Query and drops it from the
|
||||
* cache, but a *mounted* observer keeps its own reference and last result, and
|
||||
* nothing re-subscribes it. Every already-rendered page therefore went on
|
||||
* displaying the previous site's data.
|
||||
*
|
||||
* Keying the provider on the site is what actually holds: a changed key
|
||||
* unmounts the entire subtree and remounts it against a brand-new, empty cache,
|
||||
* so every query refetches with the new X-Site-Id header. There is no path by
|
||||
* which one site's data can survive into another's view.
|
||||
*
|
||||
* The cost is a full remount on switch. That is the honest price of changing
|
||||
* context, and it only happens when the operator deliberately switches.
|
||||
*/
|
||||
function SiteScopedQueryProvider({ children }: { children: React.ReactNode }) {
|
||||
const siteId = useSiteStore((s) => s.currentSiteId);
|
||||
const key = siteId == null ? 'all' : String(siteId);
|
||||
// A new cache per site; `key` is the dependency on purpose.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const client = React.useMemo(() => makeQueryClient(), [key]);
|
||||
return (
|
||||
<QueryClientProvider key={key} client={client}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<SiteScopedQueryProvider>
|
||||
<App />
|
||||
</QueryClientProvider>
|
||||
</SiteScopedQueryProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
@@ -20,6 +20,7 @@ import { useQueryClient, useMutation } from '@tanstack/react-query';
|
||||
import { format, formatDistanceToNow } from 'date-fns';
|
||||
import type { Device, DeviceEvent } from '../types';
|
||||
import clsx from 'clsx';
|
||||
import { fleetStatus } from '../utils/fleetStatus';
|
||||
|
||||
// ─── Primitives ───────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -167,7 +168,8 @@ function HealthBar({
|
||||
wirelessCount: number;
|
||||
clientSparkline: number[];
|
||||
}) {
|
||||
const allOnline = (summary?.devices.offline ?? 0) === 0 && (summary?.devices.total ?? 0) > 0;
|
||||
const status = fleetStatus(summary?.devices);
|
||||
const allOnline = status.kind === 'healthy';
|
||||
const reachFraction = summary?.devices.total
|
||||
? (summary.devices.online / summary.devices.total)
|
||||
: 1;
|
||||
@@ -182,13 +184,14 @@ function HealthBar({
|
||||
style={{ background: 'var(--accent-soft)' }}
|
||||
>
|
||||
<StatusDot
|
||||
color={allOnline ? 'var(--accent)' : 'var(--warn)'}
|
||||
color={status.tone === 'good' ? 'var(--accent)'
|
||||
: status.tone === 'warn' ? 'var(--warn)' : 'var(--ink-4)'}
|
||||
glow size={10}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[14px] font-semibold" style={{ color: 'var(--ink)' }}>
|
||||
{allOnline ? 'All systems nominal' : `${summary?.devices.offline ?? 0} device(s) unreachable`}
|
||||
{status.headline}
|
||||
</div>
|
||||
<div className="text-[12px]" style={{ color: 'var(--ink-3)' }}>
|
||||
{summary?.devices.online ?? 0} of {summary?.devices.total ?? 0} reachable
|
||||
@@ -594,9 +597,10 @@ function OperationsView({
|
||||
}) {
|
||||
const qc = useQueryClient();
|
||||
const canWrite = useCanWrite();
|
||||
const allOnline = (summary?.devices.offline ?? 0) === 0 && (summary?.devices.total ?? 0) > 0;
|
||||
const statusText = allOnline ? "Everything's running." : `${summary?.devices.offline} device${summary?.devices.offline !== 1 ? 's' : ''} unreachable.`;
|
||||
const statusColor = allOnline ? 'var(--accent)' : 'var(--warn)';
|
||||
const status = fleetStatus(summary?.devices);
|
||||
const statusText = status.sentence;
|
||||
const statusColor = status.tone === 'good' ? 'var(--accent)'
|
||||
: status.tone === 'warn' ? 'var(--warn)' : 'var(--ink-4)';
|
||||
|
||||
const deviceList = devices as Device[];
|
||||
const onlineDevices = deviceList.filter(d => d.status === 'online');
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query';
|
||||
import L from 'leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import { Building2, MapPin, Plus, Pencil, Trash2, Check, X, AlertCircle, RefreshCw } from 'lucide-react';
|
||||
import { sitesApi, settingsApi } from '../services/api';
|
||||
import { useSiteStore, type Site } from '../store/siteStore';
|
||||
import { useCanWrite } from '../hooks/useCanWrite';
|
||||
import { geocodeAddress, OSM_TILE_URL, OSM_ATTRIBUTION } from '../utils/geocode';
|
||||
|
||||
/** World map with one pin per site that has coordinates. */
|
||||
function SitesMap({ sites, onSelect }: { sites: Site[]; onSelect: (id: number) => void }) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const pinned = sites.filter((s) => s.location_lat != null && s.location_lng != null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
const map = L.map(containerRef.current, { scrollWheelZoom: false, worldCopyJump: true });
|
||||
L.tileLayer(OSM_TILE_URL, { attribution: OSM_ATTRIBUTION }).addTo(map);
|
||||
|
||||
const points: [number, number][] = [];
|
||||
for (const s of pinned) {
|
||||
const lat = Number(s.location_lat);
|
||||
const lng = Number(s.location_lng);
|
||||
points.push([lat, lng]);
|
||||
const marker = L.circleMarker([lat, lng], {
|
||||
radius: 10, color: '#fff', weight: 2,
|
||||
fillColor: s.online_count > 0 ? '#3b82f6' : '#94a3b8',
|
||||
fillOpacity: 0.9,
|
||||
}).addTo(map);
|
||||
marker.bindPopup(
|
||||
`<b>${s.name}</b><br/>${s.address ?? ''}<br/>` +
|
||||
`${s.device_count} device${s.device_count === 1 ? '' : 's'}` +
|
||||
`${s.device_count ? ` · ${s.online_count} online` : ''}`
|
||||
);
|
||||
marker.on('click', () => onSelect(s.id));
|
||||
}
|
||||
|
||||
// Fit to the sites we have; fall back to a whole-world view when none are
|
||||
// geocoded yet, so the map is never a blank grey box.
|
||||
if (points.length === 1) map.setView(points[0], 11);
|
||||
else if (points.length > 1) map.fitBounds(L.latLngBounds(points).pad(0.25));
|
||||
else map.setView([20, 0], 2);
|
||||
|
||||
return () => { map.remove(); };
|
||||
}, [JSON.stringify(pinned.map((s) => [s.id, s.location_lat, s.location_lng, s.online_count])), onSelect]);
|
||||
|
||||
return (
|
||||
<div className="card overflow-hidden">
|
||||
<div className="flex items-center gap-2 px-4 py-3 border-b border-gray-200 dark:border-slate-700">
|
||||
<MapPin className="w-4 h-4 text-blue-500" />
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white flex-1">Site locations</h3>
|
||||
<span className="text-xs text-gray-400">
|
||||
{pinned.length} of {sites.length} mapped
|
||||
</span>
|
||||
</div>
|
||||
<div ref={containerRef} style={{ height: 420, isolation: 'isolate' }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SitesPage() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const canWrite = useCanWrite();
|
||||
const { setCurrentSite } = useSiteStore();
|
||||
const [editing, setEditing] = useState<Site | null>(null);
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [name, setName] = useState('');
|
||||
const [address, setAddress] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const { data: sites = [], isLoading } = useQuery({
|
||||
queryKey: ['sites'],
|
||||
queryFn: () => sitesApi.list().then((r) => r.data),
|
||||
});
|
||||
|
||||
// Geocoding and tiles are third-party requests that disclose where your
|
||||
// hardware is. Same off-switch as device locations (issue #106).
|
||||
const { data: settings } = useQuery({
|
||||
queryKey: ['app-settings'],
|
||||
queryFn: () => settingsApi.get().then((r) => r.data),
|
||||
staleTime: 300_000,
|
||||
});
|
||||
const mapsEnabled = settings?.['maps_enabled'] !== false;
|
||||
|
||||
function openSite(id: number) {
|
||||
// Navigate before switching: setting the site remounts the tree (see
|
||||
// SiteScopedQueryProvider in main.tsx), so the route must be settled first.
|
||||
navigate('/dashboard');
|
||||
setCurrentSite(id);
|
||||
}
|
||||
|
||||
async function save() {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) { setError('Site name is required'); return; }
|
||||
setBusy(true);
|
||||
setError('');
|
||||
try {
|
||||
const addr = address.trim();
|
||||
// Only geocode when maps are enabled and the address actually changed —
|
||||
// Nominatim is a courtesy service, not something to hammer on every save.
|
||||
let coords: { lat: number; lng: number } | null = null;
|
||||
const addressChanged = addr && addr !== (editing?.address ?? '');
|
||||
if (mapsEnabled && addressChanged) coords = await geocodeAddress(addr);
|
||||
|
||||
const payload = {
|
||||
name: trimmed,
|
||||
address: addr,
|
||||
...(addressChanged
|
||||
? { location_lat: coords?.lat ?? null, location_lng: coords?.lng ?? null }
|
||||
: {}),
|
||||
};
|
||||
if (editing) await sitesApi.update(editing.id, payload);
|
||||
else await sitesApi.create(payload);
|
||||
await queryClient.invalidateQueries({ queryKey: ['sites'] });
|
||||
cancel();
|
||||
} catch (e) {
|
||||
const err = e as { response?: { data?: { error?: string } }; message?: string };
|
||||
setError(err.response?.data?.error ?? err.message ?? 'Could not save site');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const removeMutation = useMutation({
|
||||
mutationFn: (id: number) => sitesApi.remove(id),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['sites'] }),
|
||||
onError: (e: unknown) => {
|
||||
const err = e as { response?: { data?: { error?: string } } };
|
||||
setError(err.response?.data?.error ?? 'Could not delete site');
|
||||
},
|
||||
});
|
||||
|
||||
function cancel() {
|
||||
setEditing(null); setAdding(false); setName(''); setAddress(''); setError('');
|
||||
}
|
||||
|
||||
function startEdit(s: Site) {
|
||||
setEditing(s); setAdding(false); setName(s.name); setAddress(s.address ?? ''); setError('');
|
||||
}
|
||||
|
||||
const totalDevices = sites.reduce((n, s) => n + s.device_count, 0);
|
||||
const totalOnline = sites.reduce((n, s) => n + s.online_count, 0);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-gray-900 dark:text-white">All sites</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-slate-400">
|
||||
{sites.length} site{sites.length === 1 ? '' : 's'} · {totalDevices} device
|
||||
{totalDevices === 1 ? '' : 's'} · {totalOnline} online
|
||||
</p>
|
||||
</div>
|
||||
{canWrite && !adding && !editing && (
|
||||
<button
|
||||
onClick={() => { setAdding(true); setName(''); setAddress(''); setError(''); }}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-lg bg-blue-600 text-white hover:bg-blue-500"
|
||||
>
|
||||
<Plus className="w-4 h-4" /> New site
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-start gap-2 px-3 py-2 rounded-lg bg-red-50 dark:bg-red-950/40 text-sm text-red-700 dark:text-red-300">
|
||||
<AlertCircle className="w-4 h-4 mt-0.5 flex-shrink-0" />
|
||||
<span className="flex-1">{error}</span>
|
||||
<button onClick={() => setError('')}><X className="w-4 h-4" /></button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(adding || editing) && (
|
||||
<div className="card p-4 space-y-3">
|
||||
<div className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{editing ? `Rename “${editing.name}”` : 'New site'}
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<input
|
||||
autoFocus
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Site name"
|
||||
className="input"
|
||||
/>
|
||||
<input
|
||||
value={address}
|
||||
onChange={(e) => setAddress(e.target.value)}
|
||||
placeholder="Address (optional)"
|
||||
className="input"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400">
|
||||
{mapsEnabled
|
||||
? 'The address is geocoded so the site appears on the map above.'
|
||||
: 'Maps are disabled in Settings, so the address is stored as text only.'}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={save}
|
||||
disabled={busy}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-lg bg-blue-600 text-white hover:bg-blue-500 disabled:opacity-50"
|
||||
>
|
||||
{busy ? <RefreshCw className="w-4 h-4 animate-spin" /> : <Check className="w-4 h-4" />}
|
||||
{busy ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
<button onClick={cancel} className="px-3 py-1.5 text-sm rounded-lg border border-gray-300 dark:border-slate-600">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mapsEnabled && sites.length > 0 && <SitesMap sites={sites} onSelect={openSite} />}
|
||||
|
||||
<div className="card overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-xs uppercase tracking-wide text-gray-500 dark:text-slate-400 border-b border-gray-200 dark:border-slate-700">
|
||||
<th className="px-4 py-2.5 font-medium">Site</th>
|
||||
<th className="px-4 py-2.5 font-medium">Address</th>
|
||||
<th className="px-4 py-2.5 font-medium text-right">Devices</th>
|
||||
<th className="px-4 py-2.5 font-medium text-right">Online</th>
|
||||
{canWrite && <th className="px-4 py-2.5 w-20" />}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{isLoading && (
|
||||
<tr><td colSpan={5} className="px-4 py-6 text-center text-gray-400">Loading…</td></tr>
|
||||
)}
|
||||
{!isLoading && sites.length === 0 && (
|
||||
<tr><td colSpan={5} className="px-4 py-6 text-center text-gray-400">No sites yet.</td></tr>
|
||||
)}
|
||||
{sites.map((s) => (
|
||||
<tr
|
||||
key={s.id}
|
||||
className="border-b border-gray-100 dark:border-slate-800 last:border-0 hover:bg-gray-50 dark:hover:bg-slate-800/50 cursor-pointer"
|
||||
onClick={() => openSite(s.id)}
|
||||
>
|
||||
<td className="px-4 py-2.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Building2 className="w-4 h-4 text-gray-400 flex-shrink-0" />
|
||||
<span className="font-medium text-gray-900 dark:text-white">{s.name}</span>
|
||||
{s.is_default && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-gray-100 dark:bg-slate-700 text-gray-500 dark:text-slate-400">
|
||||
default
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-gray-500 dark:text-slate-400">
|
||||
{s.address || <span className="text-gray-300 dark:text-slate-600">—</span>}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-right text-gray-900 dark:text-white">{s.device_count}</td>
|
||||
<td className="px-4 py-2.5 text-right">
|
||||
<span className={s.online_count === s.device_count && s.device_count > 0
|
||||
? 'text-green-600 dark:text-green-400'
|
||||
: 'text-gray-500 dark:text-slate-400'}>
|
||||
{s.online_count}
|
||||
</span>
|
||||
</td>
|
||||
{canWrite && (
|
||||
<td className="px-4 py-2.5" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<button
|
||||
onClick={() => startEdit(s)}
|
||||
className="p-1.5 rounded hover:bg-gray-100 dark:hover:bg-slate-700 text-gray-400"
|
||||
title="Rename"
|
||||
>
|
||||
<Pencil className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => removeMutation.mutate(s.id)}
|
||||
className="p-1.5 rounded hover:bg-red-50 dark:hover:bg-red-950/40 text-gray-400 hover:text-red-500"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import axios, { AxiosError } from 'axios';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useSiteStore } from '../store/siteStore';
|
||||
import type {
|
||||
Device,
|
||||
Interface,
|
||||
@@ -32,6 +33,12 @@ const api = axios.create({
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = useAuthStore.getState().token;
|
||||
if (token) config.headers.Authorization = `Bearer ${token}`;
|
||||
// Active site travels with every request (issue #130). Injecting it here
|
||||
// rather than threading it through ~160 query keys means a new query is
|
||||
// scoped correctly by default and cannot be forgotten. null is the all-sites
|
||||
// view, which sends no header and so behaves exactly as before sites existed.
|
||||
const siteId = useSiteStore.getState().currentSiteId;
|
||||
if (siteId != null) config.headers['X-Site-Id'] = String(siteId);
|
||||
return config;
|
||||
});
|
||||
|
||||
@@ -1610,3 +1617,23 @@ export const configTemplatesApi = {
|
||||
};
|
||||
|
||||
export default api;
|
||||
|
||||
// ─── Sites (issue #130) ───────────────────────────────────────────────────────
|
||||
export interface SitePayload {
|
||||
name?: string;
|
||||
address?: string;
|
||||
notes?: string;
|
||||
location_lat?: number | null;
|
||||
location_lng?: number | null;
|
||||
}
|
||||
|
||||
export const sitesApi = {
|
||||
list: () => api.get<import('../store/siteStore').Site[]>('/sites'),
|
||||
create: (payload: SitePayload) =>
|
||||
api.post<import('../store/siteStore').Site>('/sites', payload),
|
||||
update: (id: number, payload: SitePayload) =>
|
||||
api.put<import('../store/siteStore').Site>(`/sites/${id}`, payload),
|
||||
remove: (id: number) => api.delete<{ ok: boolean }>(`/sites/${id}`),
|
||||
assignDevices: (id: number, deviceIds: number[]) =>
|
||||
api.post<{ ok: boolean; moved: number }>(`/sites/${id}/devices`, { deviceIds }),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
export interface Site {
|
||||
id: number;
|
||||
name: string;
|
||||
address: string | null;
|
||||
location_lat: number | null;
|
||||
location_lng: number | null;
|
||||
notes: string | null;
|
||||
is_default: boolean;
|
||||
device_count: number;
|
||||
online_count: number;
|
||||
}
|
||||
|
||||
/** null = the all-sites view; a number = that site. */
|
||||
export type SiteSelection = number | null;
|
||||
|
||||
interface SiteState {
|
||||
currentSiteId: SiteSelection;
|
||||
setCurrentSite: (id: SiteSelection) => void;
|
||||
}
|
||||
|
||||
export const useSiteStore = create<SiteState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
// Undefined until the site list loads. A brand-new install has exactly one
|
||||
// site, and App resolves the selection to it, so single-site deployments
|
||||
// never see the concept.
|
||||
currentSiteId: null,
|
||||
setCurrentSite: (id) => set({ currentSiteId: id }),
|
||||
}),
|
||||
{
|
||||
name: 'mikrotik-site',
|
||||
partialize: (state) => ({ currentSiteId: state.currentSiteId }),
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -0,0 +1,88 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { QueryClient, QueryObserver } from '@tanstack/react-query';
|
||||
|
||||
/**
|
||||
* Why site switching is done by rekeying the QueryClient rather than clearing it.
|
||||
*
|
||||
* The first implementation called queryClient.clear() on switch and looked
|
||||
* correct in review: the cache really is emptied. But every already-rendered
|
||||
* page went on showing the previous site's data, because a *mounted* observer
|
||||
* keeps its own reference to the destroyed query and its last result, and
|
||||
* nothing re-subscribes it.
|
||||
*
|
||||
* These tests pin that behaviour down so the shortcut cannot come back.
|
||||
*/
|
||||
|
||||
function makeClient() {
|
||||
return new QueryClient({
|
||||
defaultOptions: { queries: { staleTime: 30_000, retry: false } },
|
||||
});
|
||||
}
|
||||
|
||||
/** Stands in for a page: subscribes and exposes what it would render. */
|
||||
function mountObserver(client: QueryClient, fetcher: () => Promise<string>) {
|
||||
const observer = new QueryObserver(client, { queryKey: ['devices'], queryFn: fetcher });
|
||||
const unsubscribe = observer.subscribe(() => {});
|
||||
return { observer, unsubscribe, current: () => observer.getCurrentResult().data };
|
||||
}
|
||||
|
||||
const settle = () => new Promise((r) => setTimeout(r, 20));
|
||||
|
||||
describe('site switching and the query cache', () => {
|
||||
it('clear() does NOT update a mounted observer — this was the bug', async () => {
|
||||
const client = makeClient();
|
||||
let site = 'site-1';
|
||||
const { unsubscribe, current } = mountObserver(client, async () => `data-for-${site}`);
|
||||
await settle();
|
||||
expect(current()).toBe('data-for-site-1');
|
||||
|
||||
// Switch site the way the broken version did.
|
||||
site = 'site-2';
|
||||
client.clear();
|
||||
await settle();
|
||||
|
||||
// The cache is empty, yet the mounted page still renders site 1's data.
|
||||
expect(client.getQueryCache().getAll()).toHaveLength(0);
|
||||
expect(current()).toBe('data-for-site-1');
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
it('a fresh QueryClient gives a mounted page the new site data', async () => {
|
||||
let site = 'site-1';
|
||||
const fetcher = async () => `data-for-${site}`;
|
||||
|
||||
const first = makeClient();
|
||||
const a = mountObserver(first, fetcher);
|
||||
await settle();
|
||||
expect(a.current()).toBe('data-for-site-1');
|
||||
|
||||
// What the keyed provider does: the subtree remounts against a new client.
|
||||
a.unsubscribe();
|
||||
site = 'site-2';
|
||||
const second = makeClient();
|
||||
const b = mountObserver(second, fetcher);
|
||||
await settle();
|
||||
|
||||
expect(b.current()).toBe('data-for-site-2');
|
||||
// Nothing from the previous site survived into the new cache.
|
||||
expect(second.getQueryCache().getAll()).toHaveLength(1);
|
||||
b.unsubscribe();
|
||||
});
|
||||
|
||||
it('invalidateQueries() does refetch a mounted observer', async () => {
|
||||
// The in-place alternative, used where the tree must not remount (moving a
|
||||
// device between sites from the device page).
|
||||
const client = makeClient();
|
||||
let site = 'site-1';
|
||||
const { unsubscribe, current } = mountObserver(client, async () => `data-for-${site}`);
|
||||
await settle();
|
||||
expect(current()).toBe('data-for-site-1');
|
||||
|
||||
site = 'site-2';
|
||||
await client.invalidateQueries();
|
||||
await settle();
|
||||
|
||||
expect(current()).toBe('data-for-site-2');
|
||||
unsubscribe();
|
||||
});
|
||||
});
|
||||
@@ -64,6 +64,7 @@ export interface Device {
|
||||
status: DeviceStatus;
|
||||
last_seen?: string;
|
||||
notes?: string;
|
||||
site_id?: number | null;
|
||||
location_address?: string;
|
||||
location_lat?: number;
|
||||
location_lng?: number;
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { fleetStatus } from './fleetStatus';
|
||||
|
||||
describe('fleetStatus', () => {
|
||||
// The reported bug: switching site remounts with no summary, and the header
|
||||
// interpolated it straight into the string.
|
||||
it('reports loading rather than "undefined devices unreachable"', () => {
|
||||
for (const absent of [undefined, null]) {
|
||||
const s = fleetStatus(absent);
|
||||
expect(s.kind).toBe('loading');
|
||||
expect(s.headline).not.toMatch(/undefined|NaN/);
|
||||
expect(s.sentence).not.toMatch(/undefined|NaN/);
|
||||
}
|
||||
});
|
||||
|
||||
// Defaulting the missing summary to 0 would have swapped one wrong claim for
|
||||
// another: an unheard-from fleet is not a healthy one.
|
||||
it('does not claim health while loading', () => {
|
||||
expect(fleetStatus(undefined).tone).toBe('neutral');
|
||||
expect(fleetStatus(undefined).kind).not.toBe('healthy');
|
||||
});
|
||||
|
||||
it('distinguishes an empty site from a healthy one', () => {
|
||||
const empty = fleetStatus({ total: 0, online: 0, offline: 0 });
|
||||
expect(empty.kind).toBe('empty');
|
||||
expect(empty.sentence).toBe('No devices in this site yet.');
|
||||
|
||||
const healthy = fleetStatus({ total: 3, online: 3, offline: 0 });
|
||||
expect(healthy.kind).toBe('healthy');
|
||||
expect(healthy.tone).toBe('good');
|
||||
});
|
||||
|
||||
it('counts and pluralises unreachable devices', () => {
|
||||
expect(fleetStatus({ total: 4, online: 3, offline: 1 }).headline)
|
||||
.toBe('1 device unreachable');
|
||||
expect(fleetStatus({ total: 4, online: 2, offline: 2 }).sentence)
|
||||
.toBe('2 devices unreachable.');
|
||||
expect(fleetStatus({ total: 4, online: 2, offline: 2 }).tone).toBe('warn');
|
||||
});
|
||||
|
||||
it('never emits undefined or NaN for any input', () => {
|
||||
const cases = [
|
||||
undefined, null,
|
||||
{ total: 0, online: 0, offline: 0 },
|
||||
{ total: 1, online: 0, offline: 1 },
|
||||
{ total: 9, online: 9, offline: 0 },
|
||||
];
|
||||
for (const c of cases) {
|
||||
const s = fleetStatus(c);
|
||||
expect(`${s.headline} ${s.sentence}`).not.toMatch(/undefined|NaN|null/);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Resolves the dashboard's headline device status.
|
||||
*
|
||||
* Exists because the summary can legitimately be *absent* rather than zero, and
|
||||
* the two are not the same claim. Switching site remounts the tree against an
|
||||
* empty cache (see SiteScopedQueryProvider), so there is always a moment with no
|
||||
* summary — which rendered "undefined device(s) unreachable". Substituting 0
|
||||
* would have been just as wrong in the other direction: reporting a healthy
|
||||
* fleet we have not heard from yet.
|
||||
*
|
||||
* A site with no devices is a third distinct state, and one multi-site makes
|
||||
* ordinary: "0 devices unreachable" is technically true and reads as nonsense.
|
||||
*/
|
||||
|
||||
export interface FleetSummaryDevices {
|
||||
total: number;
|
||||
online: number;
|
||||
offline: number;
|
||||
}
|
||||
|
||||
export type FleetStatusKind = 'loading' | 'empty' | 'healthy' | 'degraded';
|
||||
|
||||
export interface FleetStatus {
|
||||
kind: FleetStatusKind;
|
||||
/** Compact label for the summary header. */
|
||||
headline: string;
|
||||
/** Sentence form for the operations hero. */
|
||||
sentence: string;
|
||||
tone: 'neutral' | 'good' | 'warn';
|
||||
}
|
||||
|
||||
export function fleetStatus(devices?: FleetSummaryDevices | null): FleetStatus {
|
||||
if (!devices) {
|
||||
return { kind: 'loading', headline: 'Checking…', sentence: 'Checking…', tone: 'neutral' };
|
||||
}
|
||||
if (devices.total === 0) {
|
||||
return {
|
||||
kind: 'empty',
|
||||
headline: 'No devices yet',
|
||||
sentence: 'No devices in this site yet.',
|
||||
tone: 'neutral',
|
||||
};
|
||||
}
|
||||
if (devices.offline === 0) {
|
||||
return {
|
||||
kind: 'healthy',
|
||||
headline: 'All systems nominal',
|
||||
sentence: "Everything's running.",
|
||||
tone: 'good',
|
||||
};
|
||||
}
|
||||
const n = devices.offline;
|
||||
const noun = `device${n === 1 ? '' : 's'}`;
|
||||
return {
|
||||
kind: 'degraded',
|
||||
headline: `${n} ${noun} unreachable`,
|
||||
sentence: `${n} ${noun} unreachable.`,
|
||||
tone: 'warn',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Address → coordinates via Nominatim.
|
||||
*
|
||||
* Extracted from DeviceLocationSection so device locations and site locations
|
||||
* share one implementation (issue #130). Callers must check the `maps_enabled`
|
||||
* setting before calling: geocoding is a third-party request that discloses
|
||||
* where your hardware is, and issue #106 exists to let operators refuse it.
|
||||
*/
|
||||
export async function geocodeAddress(address: string): Promise<{ lat: number; lng: number } | null> {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(address)}&format=json&limit=1`,
|
||||
{ headers: { Accept: 'application/json' } }
|
||||
);
|
||||
const data = await res.json();
|
||||
if (Array.isArray(data) && data.length > 0) {
|
||||
return { lat: parseFloat(data[0].lat), lng: parseFloat(data[0].lon) };
|
||||
}
|
||||
} catch {
|
||||
// Geocoding is best-effort: a site without coordinates simply has no pin.
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The tile source used by every map in the app, so attribution stays consistent. */
|
||||
export const OSM_TILE_URL = 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png';
|
||||
export const OSM_ATTRIBUTION =
|
||||
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors';
|
||||
@@ -1 +1 @@
|
||||
export const APP_VERSION = 'v0.23.35 Beta';
|
||||
export const APP_VERSION = 'v0.24.0 Beta';
|
||||
|
||||
@@ -59,6 +59,7 @@ nav:
|
||||
- Getting started:
|
||||
- Configuration: configuration.md
|
||||
- Operating:
|
||||
- Sites: sites.md
|
||||
- Change Guard and Config Health: change-guard.md
|
||||
- Polling and scaling: scaling.md
|
||||
- Alerting: alerting.md
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
# Run the same gates GitHub Actions runs, locally, before pushing.
|
||||
#
|
||||
# Mirrors .github/workflows/ci.yml exactly (frontend lint/type-check/build,
|
||||
# Mirrors .github/workflows/ci.yml exactly (frontend lint/type-check/test/build,
|
||||
# backend lint/type-check/build, prod-dependency audits) and additionally runs
|
||||
# the backend Jest suite, which CI does not currently cover.
|
||||
#
|
||||
@@ -49,6 +49,7 @@ run "backend: build" backend npm run build
|
||||
run "backend: tests" backend npx jest --silent
|
||||
run "frontend: lint" frontend npm run lint
|
||||
run "frontend: type-check" frontend npx tsc --noEmit
|
||||
run "frontend: tests" frontend npm test
|
||||
run "frontend: build" frontend npm run build
|
||||
|
||||
if [[ $SKIP_AUDIT -eq 0 ]]; then
|
||||
|
||||
Reference in New Issue
Block a user