feat(ui): quieten the dashboard — mute checks, hide app noise, honest sparkline (v0.23.6-beta)

Closes #101, #103, #104, #106, #111. Partially addresses #96.

A batch of signal-to-noise fixes reported together. Individually small; together
they are the difference between a dashboard someone reads and one they learn to
ignore.

#101 — security heuristics can be muted
  A warning you disagree with and cannot dismiss discredits every other finding
  beside it. Cleartext API is the reported case: on a fleet reachable only across
  WireGuard, TLS adds nothing to the threat model, and the check is permanent noise.
  Missing input-chain firewall is the same on a switch behind a router — the message
  even says "confirm that is the case", while offering no way to record that you did.

  Checks can now be muted per device or fleet-wide. Muted findings are still listed,
  greyed and labelled, and excluded from the score — not hidden. Hiding them would
  make the posture unauditable, which is a worse failure than noise.

#103 — the app's own requests are hidden from Recent activity
  Audit entries for writes made through the UI are noise from an operations point of
  view. Off by default now, with a toggle that names how many are hidden, remembered
  per browser since it is a viewing preference rather than fleet configuration.

#104 — quick actions moved into the header strip
  Matching the Wireless page. "Things to handle" takes the full width it was already
  overflowing.

#106 — map lookups can be turned off
  Geocoding hits nominatim.openstreetmap.org and the map pulls OSM tiles. On an
  isolated network those hang; everywhere else they disclose where your devices are
  to a third party, which the report understates. A maps_enabled setting stops both
  requests; stored coordinates are still shown as plain text so nothing is lost.

#111 — the CPU sparkline says something
  It was drawn in one colour and auto-scaled to each device's own peak, so an idle
  device and a saturated one produced an identical picture. Now plotted against a
  fixed 0-100 scale, coloured by the band its peak falls into — a flat line low in
  the box is an idle device and looks like one.

#96 — groundwork
  TX-retry figures come from CCQ, which only the legacy `wireless` driver reports, so
  the panel is permanently empty on a RouterOS 7 fleet. The wireless package each
  device runs is now recorded, and a capabilities endpoint answers the fleet-wide
  question. The panel is dropped and the density chart takes the full width when
  nothing can supply the data — and shown when nothing has been polled yet, rather
  than hiding a panel that may populate a minute later.

Verified against the fleet: muting cleartext API moved a device's score from 87 to
90 with the finding still listed and marked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2GT-Rich
2026-08-22 14:18:41 -07:00
co-authored by Claude Opus 5
parent 4cc8c84ea6
commit dcd89fe86b
14 changed files with 327 additions and 70 deletions
+31
View File
@@ -575,6 +575,34 @@ UPDATE capsman_radios SET current_channel = NULL WHERE length(current_channel) >
-- controller does know it, on its own mirror of the CAP interface (#94).
ALTER TABLE capsman_radios ADD COLUMN IF NOT EXISTS ssid VARCHAR(128);
-- Security check suppressions (github issue #101).
--
-- A heuristic you disagree with and cannot dismiss discredits the whole posture
-- score. Cleartext API is the example: on a fleet reachable only across WireGuard,
-- TLS adds nothing to the threat model, and the warning is permanent noise.
-- device_id NULL means fleet-wide; a row for a specific device silences it there
-- only. Suppressed checks are still reported, marked and excluded from the score,
-- so nothing becomes invisible.
CREATE TABLE IF NOT EXISTS security_check_suppressions (
id SERIAL PRIMARY KEY,
check_id VARCHAR(64) NOT NULL,
device_id INTEGER REFERENCES devices(id) ON DELETE CASCADE,
reason TEXT,
created_by VARCHAR(50),
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Partial indexes because a UNIQUE constraint containing NULL does not dedupe.
CREATE UNIQUE INDEX IF NOT EXISTS idx_seccheck_supp_global
ON security_check_suppressions (check_id) WHERE device_id IS NULL;
CREATE UNIQUE INDEX IF NOT EXISTS idx_seccheck_supp_device
ON security_check_suppressions (check_id, device_id) WHERE device_id IS NOT NULL;
-- Which wireless stack a device runs: the RouterOS 7 'wifi' packages or the legacy
-- 'wireless' one. Needed to answer a fleet-wide question — if nothing runs the
-- legacy driver, the TX-retries panel can never have data and should not be shown
-- (github issue #96).
ALTER TABLE devices ADD COLUMN IF NOT EXISTS wifi_package VARCHAR(16);
-- ntfy.sh notification channel (github issue #93). The channel type is a CHECK
-- constraint rather than a lookup table, so widening it means replacing it.
ALTER TABLE alert_channels DROP CONSTRAINT IF EXISTS alert_channels_type_check;
@@ -690,6 +718,9 @@ const DEFAULT_SETTINGS = [
{ key: 'change_guard_timeout_sec', value: 120 },
{ key: 'config_health_enabled', value: true },
{ key: 'config_health_interval_min', value: 60 },
// Geocoding and map tiles are third-party requests that also disclose device
// locations. Off-switch for isolated networks (issue #106).
{ key: 'maps_enabled', value: true },
];
export async function runMigrations(): Promise<void> {
+70 -3
View File
@@ -113,6 +113,49 @@ router.get('/routers/overview', async (_req: Request, res: Response) => {
res.json(routers);
});
// ─── Security check suppressions (issue #101) ────────────────────────────────
/**
* A posture check the operator has judged inapplicable — cleartext API on a fleet
* reachable only over WireGuard, or a missing input firewall on a switch that sits
* behind one. Suppressed checks stay visible and marked; they simply stop counting
* against the score, so the posture remains auditable.
*
* `device_id` omitted suppresses fleet-wide; supplying one silences that device only.
*/
router.get('/security/suppressions', async (_req: Request, res: Response) => {
const rows = await query(`
SELECT s.id, s.check_id, s.device_id, s.reason, s.created_by, s.created_at,
d.name AS device_name
FROM security_check_suppressions s
LEFT JOIN devices d ON d.id = s.device_id
ORDER BY s.check_id, d.name NULLS FIRST`);
res.json(rows);
});
router.post('/security/suppressions', requireWrite, async (req: Request, res: Response) => {
const { check_id, device_id, reason } = req.body as
{ check_id?: string; device_id?: number | null; reason?: string };
if (!check_id) return res.status(400).json({ error: 'check_id is required' });
const rows = await query(
`INSERT INTO security_check_suppressions (check_id, device_id, reason, created_by)
VALUES ($1, $2, $3, $4)
ON CONFLICT DO NOTHING
RETURNING *`,
[check_id, device_id ?? null, reason ?? null,
(req as unknown as { user?: { username?: string } }).user?.username ?? null]
);
// ON CONFLICT DO NOTHING returns no row when it already existed, which is not an
// error — the caller asked for a state that now holds either way.
res.status(201).json(rows[0] ?? { check_id, device_id: device_id ?? null, already: true });
});
router.delete('/security/suppressions/:id', requireWrite, async (req: Request, res: Response) => {
await query(`DELETE FROM security_check_suppressions WHERE id = $1`, [req.params.id]);
res.status(204).end();
});
// GET /api/devices/discovered — unresolved MikroTik neighbors from topology_links
router.get('/discovered', async (_req: Request, res: Response) => {
const rows = await query<{
@@ -1335,13 +1378,37 @@ router.get('/:id/security-posture', async (req, res) => {
});
}
// Suppressions (issue #101). A finding the operator has judged inapplicable is
// marked rather than hidden: it stays visible, greyed, and stops counting
// against the score. Hiding it outright would make the posture unauditable.
const supp = await query<{ check_id: string; device_id: number | null; reason: string | null }>(
`SELECT check_id, device_id, reason FROM security_check_suppressions
WHERE device_id IS NULL OR device_id = $1`,
[device.id]
).catch(() => []);
const suppressedBy = new Map<string, { scope: 'device' | 'fleet'; reason: string | null }>();
for (const row of supp) {
const scope = row.device_id == null ? 'fleet' : 'device';
// A device-specific row wins over a fleet-wide one, so the UI can offer the
// narrower undo.
if (scope === 'device' || !suppressedBy.has(row.check_id)) {
suppressedBy.set(row.check_id, { scope, reason: row.reason });
}
}
const marked = checks.map((c2) => {
const hit = suppressedBy.get(c2.id);
return hit ? { ...c2, suppressed: true, suppressed_scope: hit.scope, suppressed_reason: hit.reason } : c2;
});
// Graduated score: lighter weights + a floor of 5 so it never reads a hard
// 0 (which falsely implies "maximally insecure"). It's a relative hardening
// indicator, not an absolute grade.
const WEIGHT: Record<Sev, number> = { high: 15, medium: 7, low: 3 };
const penalty = checks.reduce((n, c2) => n + WEIGHT[c2.severity], 0);
const score = checks.length === 0 ? 100 : Math.max(5, 100 - penalty);
return { score, checks };
const counted = marked.filter((c2) => !('suppressed' in c2));
const penalty = counted.reduce((n, c2) => n + WEIGHT[c2.severity], 0);
const score = counted.length === 0 ? 100 : Math.max(5, 100 - penalty);
return { score, checks: marked };
});
});
+25
View File
@@ -958,6 +958,31 @@ router.get('/rf/signals', async (req: Request, res: Response) => {
res.json(rows);
});
/**
* GET /api/wireless/rf/capabilities — what the fleet's wireless hardware can report.
*
* TX-retry figures are derived from per-client CCQ, which only the legacy `wireless`
* 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) => {
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'
GROUP BY wifi_package`);
const counts = Object.fromEntries(rows.map((r) => [r.wifi_package ?? 'unknown', Number(r.n)]));
const legacy = counts['wireless'] ?? 0;
res.json({
packages: counts,
legacyRadios: legacy,
// Unknown means nothing has been polled yet; show the panel rather than
// hiding something that may well populate a minute later.
txRetriesAvailable: legacy > 0 || rows.length === 0,
});
});
// GET /api/wireless/rf/tx-quality?range=6h — latest per-radio TX retry % (from CCQ)
router.get('/rf/tx-quality', async (req: Request, res: Response) => {
const deviceId = deviceScope(req);
@@ -1659,8 +1659,12 @@ export class DeviceCollector {
// Recorded per device so the UI can explain a blank SSID as delegation to a
// controller rather than leaving it looking like an unconfigured radio.
const role = await this.detectWifiRole();
await query(`UPDATE devices SET wifi_role = $2 WHERE id = $1`, [this.device.id, role])
.catch(() => { /* role is advisory; never fail collection over it */ });
// The package is recorded alongside the role because TX-retry data only
// exists on the legacy 'wireless' driver, and a panel that can never have
// data on this fleet should not occupy space (issue #96).
await query(`UPDATE devices SET wifi_role = $2, wifi_package = $3 WHERE id = $1`,
[this.device.id, role, pkg])
.catch(() => { /* advisory; never fail collection over it */ });
// null = the fetch itself failed. Distinguished from an empty list (device
// genuinely has no wireless interfaces) so a transient API error can never