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
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "mikrotik-manager-frontend",
"version": "0.23.5-beta",
"version": "0.23.6-beta",
"private": true,
"type": "module",
"scripts": {
@@ -1,11 +1,11 @@
import { useState, useEffect, useRef } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
MapPin, Building2, Hash, FileText, Pencil, Check, X, RefreshCw, AlertCircle,
} from 'lucide-react';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import { devicesApi } from '../../services/api';
import { devicesApi, settingsApi } from '../../services/api';
import type { Device } from '../../types';
import { useCanWrite } from '../../hooks/useCanWrite';
@@ -78,6 +78,15 @@ function MapEmbed({ lat, lng, address }: { lat: number | string; lng: number | s
export default function DeviceLocationSection({ device }: Props) {
const queryClient = useQueryClient();
const canWrite = useCanWrite();
// Geocoding and tiles are third-party requests that also disclose where your
// devices are. Operators on isolated networks can turn the whole thing off
// (issue #106); coordinates already stored are still shown as plain text.
const { data: settings } = useQuery({
queryKey: ['app-settings'],
queryFn: () => settingsApi.get().then(r => r.data),
staleTime: 300_000,
});
const mapsEnabled = settings?.['maps_enabled'] !== false;
const [editing, setEditing] = useState(false);
const [geocoding, setGeocoding] = useState(false);
const [saveError, setSaveError] = useState('');
@@ -99,7 +108,7 @@ export default function DeviceLocationSection({ device }: Props) {
// Re-geocode if address changed
const addressChanged = f.location_address !== (device.location_address ?? '');
if (f.location_address && addressChanged) {
if (mapsEnabled && f.location_address && addressChanged) {
setGeocoding(true);
const coords = await geocodeAddress(f.location_address);
setGeocoding(false);
@@ -263,14 +272,26 @@ export default function DeviceLocationSection({ device }: Props) {
)}
</div>
{/* Map card — only when we have coordinates and not editing */}
{hasMap && !editing && (
{/* Map card — only when we have coordinates, are not editing, and outbound
map requests are permitted. */}
{hasMap && !editing && mapsEnabled && (
<MapEmbed
lat={device.location_lat!}
lng={device.location_lng!}
address={device.location_address!}
/>
)}
{hasMap && !editing && !mapsEnabled && (
<div className="card p-4 flex items-start gap-2">
<MapPin className="w-4 h-4 mt-0.5 flex-shrink-0 text-gray-400" />
<div className="text-xs text-gray-500 dark:text-slate-400">
Map display is off, so no request is made to a third-party tile or geocoding
service. Stored position:{' '}
<span className="mono">{Number(device.location_lat).toFixed(5)}, {Number(device.location_lng).toFixed(5)}</span>.
Re-enable under Settings.
</div>
</div>
)}
</div>
);
}
@@ -1,6 +1,6 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { ShieldCheck, ShieldAlert, RefreshCw, Check, AlertTriangle, Lock } from 'lucide-react';
import { ShieldCheck, ShieldAlert, RefreshCw, Check, AlertTriangle, Lock, BellOff, Bell } from 'lucide-react';
import { devicesApi } from '../../services/api';
import type { SecurityCheck } from '../../services/api';
import { useCanWrite } from '../../hooks/useCanWrite';
@@ -50,7 +50,27 @@ export default function SecurityTab({ deviceId, deviceName }: { deviceId: number
const invalidate = () => {
qc.invalidateQueries({ queryKey: ['security-posture', deviceId] });
qc.invalidateQueries({ queryKey: ['services', deviceId] });
qc.invalidateQueries({ queryKey: ['security-suppressions'] });
};
// Suppression (issue #101): a heuristic the operator has judged inapplicable is
// marked and excluded from the score rather than hidden, so posture stays auditable.
const suppress = useMutation({
mutationFn: ({ checkId, scope }: { checkId: string; scope: 'device' | 'fleet' }) =>
devicesApi.suppressSecurityCheck(checkId, scope === 'device' ? deviceId : null),
onSuccess: invalidate,
});
const { data: suppressions = [] } = useQuery({
queryKey: ['security-suppressions'],
queryFn: () => devicesApi.listSecuritySuppressions().then(r => r.data),
});
const unsuppress = useMutation({
mutationFn: (id: number) => devicesApi.unsuppressSecurityCheck(id),
onSuccess: invalidate,
});
const suppressionFor = (checkId: string) =>
suppressions.find(s => s.check_id === checkId && s.device_id === deviceId)
?? suppressions.find(s => s.check_id === checkId && s.device_id === null);
const toggleSvc = useMutation({
mutationFn: ({ id, disabled, confirm = false }: { id: string; disabled: boolean; confirm?: boolean }) =>
devicesApi.setServiceDisabled(deviceId, id, disabled, confirm),
@@ -111,19 +131,44 @@ export default function SecurityTab({ deviceId, deviceName }: { deviceId: number
{checks.map(c => {
const s = SEV_STYLE[c.severity] ?? SEV_STYLE.low;
return (
<div key={c.id} className={clsx('border rounded-lg p-3 flex items-start gap-3', s.ring)}>
<AlertTriangle className={clsx('w-4 h-4 flex-shrink-0 mt-0.5', s.text)} />
<div key={c.id} className={clsx('border rounded-lg p-3 flex items-start gap-3',
c.suppressed ? 'border-gray-200 dark:border-slate-700 opacity-60' : s.ring)}>
<AlertTriangle className={clsx('w-4 h-4 flex-shrink-0 mt-0.5', c.suppressed ? 'text-gray-400' : s.text)} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-semibold text-gray-900 dark:text-white">{c.title}</span>
<span className={clsx('text-[10px] font-bold uppercase px-1.5 py-0.5 rounded', s.text)}>{s.label}</span>
<span className={clsx('text-[10px] font-bold uppercase px-1.5 py-0.5 rounded',
c.suppressed ? 'text-gray-400' : s.text)}>{s.label}</span>
{c.suppressed && (
<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">
muted {c.suppressed_scope === 'fleet' ? 'fleet-wide' : 'on this device'} · not counted
</span>
)}
</div>
<p className="text-xs text-gray-600 dark:text-slate-400 mt-0.5">{c.detail}</p>
</div>
{canWrite && c.serviceId && (
<button onClick={() => toggleSvc.mutate({ id: c.serviceId!, disabled: true })} disabled={toggleSvc.isPending}
className="btn-secondary text-xs py-1 flex items-center gap-1.5 flex-shrink-0"><Lock className="w-3 h-3" /> Disable</button>
)}
<div className="flex items-center gap-1.5 flex-shrink-0">
{canWrite && c.serviceId && !c.suppressed && (
<button onClick={() => toggleSvc.mutate({ id: c.serviceId!, disabled: true })} disabled={toggleSvc.isPending}
className="btn-secondary text-xs py-1 flex items-center gap-1.5"><Lock className="w-3 h-3" /> Disable</button>
)}
{canWrite && (c.suppressed ? (
<button
onClick={() => { const hit = suppressionFor(c.id); if (hit) unsuppress.mutate(hit.id); }}
disabled={unsuppress.isPending}
title="Count this check again"
className="btn-secondary text-xs py-1 flex items-center gap-1.5"><Bell className="w-3 h-3" /> Unmute</button>
) : (
<>
<button onClick={() => suppress.mutate({ checkId: c.id, scope: 'device' })} disabled={suppress.isPending}
title="Stop counting this check on this device"
className="btn-secondary text-xs py-1 flex items-center gap-1.5"><BellOff className="w-3 h-3" /> Mute</button>
<button onClick={() => suppress.mutate({ checkId: c.id, scope: 'fleet' })} disabled={suppress.isPending}
title="Stop counting this check on every device"
className="btn-secondary text-xs py-1">Fleet</button>
</>
))}
</div>
</div>
);
})}
+18 -3
View File
@@ -390,13 +390,28 @@ export function ConnectivitySuccess({ deviceId }: { deviceId?: number }) {
// ─── Combined section ─────────────────────────────────────────────────────────
export default function RfHealth({ deviceId }: { deviceId?: number }) {
// TX retries come from CCQ, which only the legacy `wireless` driver reports. On
// an all-RouterOS-7 fleet the panel can never fill, so it is dropped and the
// density chart takes the full width rather than sitting beside an empty box
// (issue #96).
const { data: caps } = useQuery({
queryKey: ['rf-capabilities'],
queryFn: () => wirelessApi.getRfCapabilities().then(r => r.data),
staleTime: 300_000,
});
const showRetries = caps?.txRetriesAvailable ?? true;
return (
<div className="space-y-6">
<ChannelMap deviceId={deviceId} />
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 items-start">
{showRetries ? (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 items-start">
<RssiDensity deviceId={deviceId} />
<TxRetries deviceId={deviceId} />
</div>
) : (
<RssiDensity deviceId={deviceId} />
<TxRetries deviceId={deviceId} />
</div>
)}
<ConnectivitySuccess deviceId={deviceId} />
</div>
);
+53 -42
View File
@@ -609,7 +609,19 @@ function OperationsView({
});
const thingsToHandle: OpsAttentionItem[] = insights?.attention ?? [];
const capacity: OpsCapacityRow[] = insights?.capacity ?? [];
const activity: OpsActivityItem[] = insights?.activity ?? [];
const activityAll: OpsActivityItem[] = insights?.activity ?? [];
// The app's own write requests are audit-log noise from an operations point of
// view (issue #103). Remembered per browser, since it is a viewing preference.
const [showRequests, setShowRequests] = useState<boolean>(() => {
try { return localStorage.getItem('dash.showRequests') === '1'; } catch { return false; }
});
const toggleRequests = () => setShowRequests((v) => {
const next = !v;
try { localStorage.setItem('dash.showRequests', next ? '1' : '0'); } catch { /* private mode */ }
return next;
});
const activity = showRequests ? activityAll : activityAll.filter(a => a.kind !== 'audit');
const hiddenRequests = activityAll.length - activity.length;
// Platform update availability (checked against GitHub, cached server-side)
const { data: versionInfo } = useQuery({
@@ -732,6 +744,35 @@ function OperationsView({
</div>
</div>
{/* Quick actions — in the header strip, matching the other sections (issue #104) */}
<div className="flex flex-wrap items-center gap-2">
{actions.map(({ key, label, icon: Icon, color, run }) => {
const isBusy = busy === key;
return (
<button
key={key}
onClick={run}
disabled={!!busy}
className="text-[12px] rounded-[6px] px-3 py-[7px] flex items-center gap-2 transition-colors disabled:opacity-50"
style={{ background: 'var(--surface-2)', border: '1px solid var(--line)', color: 'var(--ink-2)' }}
onMouseEnter={(e) => { if (!busy) e.currentTarget.style.background = 'var(--surface-3)'; }}
onMouseLeave={(e) => (e.currentTarget.style.background = 'var(--surface-2)')}
>
{isBusy
? <RefreshCw className="w-3.5 h-3.5 animate-spin" style={{ color }} />
: <Icon className="w-3.5 h-3.5" style={{ color }} />}
{label}
</button>
);
})}
{actionMsg && (
<span className="text-[11.5px] ml-1"
style={{ color: actionMsg.ok ? 'var(--good)' : 'var(--bad)' }}>
{actionMsg.text}
</span>
)}
</div>
{/* Platform update available */}
{versionInfo?.update_available && versionInfo.latest && (
<div className="card flex items-center gap-3 px-5 py-[13px]" style={{ borderLeft: '3px solid var(--info)' }}>
@@ -758,7 +799,7 @@ function OperationsView({
</div>
)}
{/* Things to handle + Quick actions */}
{/* Things to handle — full width now that quick actions live in the header */}
<div className="grid gap-4" style={{ gridTemplateColumns: '1.3fr 1fr' }}>
{/* Things to handle */}
<div className="card" style={{ overflow: 'hidden' }}>
@@ -808,46 +849,6 @@ function OperationsView({
)}
</div>
{/* Quick actions */}
<div className="card" style={{ padding: '18px 20px' }}>
<div className="text-[13px] font-semibold mb-3" style={{ color: 'var(--ink)' }}>Quick actions</div>
<div className="grid gap-2" style={{ gridTemplateColumns: '1fr 1fr' }}>
{actions.map(({ key, label, sub, icon: Icon, color, run }) => {
const isBusy = busy === key;
return (
<button
key={key}
onClick={run}
disabled={!!busy}
className="text-left rounded-[6px] px-[14px] py-[12px] transition-colors disabled:opacity-60"
style={{ background: 'var(--surface-2)', border: '1px solid var(--line)' }}
onMouseEnter={(e) => { if (!busy) e.currentTarget.style.background = 'var(--surface-3)'; }}
onMouseLeave={(e) => (e.currentTarget.style.background = 'var(--surface-2)')}
>
<div className="flex items-center gap-2 mb-[3px]">
{isBusy
? <RefreshCw className="w-3.5 h-3.5 flex-shrink-0 animate-spin" style={{ color }} />
: <Icon className="w-3.5 h-3.5 flex-shrink-0" style={{ color }} />}
<span className="text-[12.5px] font-semibold" style={{ color }}>{label}</span>
</div>
<div className="text-[11px]" style={{ color: 'var(--ink-3)' }}>{isBusy ? 'Working…' : sub}</div>
</button>
);
})}
</div>
{actionMsg && (
<div
className="mt-3 flex items-center gap-2 text-[12px] rounded-[6px] px-3 py-2"
style={{
background: actionMsg.ok ? 'var(--good-bg, rgba(34,197,94,0.1))' : 'var(--bad-bg, rgba(239,68,68,0.1))',
color: actionMsg.ok ? 'var(--good)' : 'var(--bad)',
}}
>
{actionMsg.ok ? <CheckCircle2 className="w-3.5 h-3.5 flex-shrink-0" /> : <AlertTriangle className="w-3.5 h-3.5 flex-shrink-0" />}
{actionMsg.text}
</div>
)}
</div>
</div>
{/* Capacity + Security rollup */}
@@ -930,6 +931,16 @@ function OperationsView({
<div className="flex items-center gap-2 px-5 py-[14px]" style={{ borderBottom: '1px solid var(--line)' }}>
<Clock className="w-4 h-4" style={{ color: 'var(--accent)' }} />
<span className="text-[13px] font-semibold" style={{ color: 'var(--ink)' }}>Recent activity</span>
<button
onClick={toggleRequests}
className="ml-auto text-[11px]"
style={{ color: 'var(--ink-3)' }}
title={showRequests ? 'Hide requests made through this app' : 'Include requests made through this app'}
>
{showRequests
? 'Hide app requests'
: `Show app requests${hiddenRequests > 0 ? ` (${hiddenRequests})` : ''}`}
</button>
</div>
{activity.length === 0 ? (
<div className="py-8 text-center text-[13px]" style={{ color: 'var(--ink-3)' }}>No recent activity.</div>
+17 -2
View File
@@ -95,9 +95,24 @@ function CpuValue({ value }: { value: number | undefined }) {
);
}
/**
* A single-colour sparkline says nothing about magnitude — an idle device and a
* saturated one draw the same shape (issue #111). The line takes the colour of the
* band its peak falls into, and is plotted against a fixed 0–100 scale so height
* means load rather than "relative to this device's own worst moment".
*/
function loadColor(pct: number): string {
if (pct >= 85) return 'var(--bad)';
if (pct >= 60) return 'var(--warn)';
if (pct >= 30) return '#84cc16';
return 'var(--accent)';
}
function LoadSparkline({ data }: { data: number[] }) {
if (data.length < 2) return <span className="mono text-[11px]" style={{ color: 'var(--ink-4)' }}>—</span>;
const max = Math.max(...data) || 1;
const peak = Math.max(...data);
// Fixed scale: a flat line low in the box is an idle device, and looks like one.
const max = 100;
const w = 72, h = 20;
const pts = data.map((v, i) => [
(i / (data.length - 1)) * w,
@@ -106,7 +121,7 @@ function LoadSparkline({ data }: { data: number[] }) {
const d = 'M' + pts.map(p => `${p[0].toFixed(1)},${p[1].toFixed(1)}`).join(' L');
return (
<svg width={w} height={h} style={{ display: 'block' }}>
<path d={d} fill="none" stroke="var(--ink-3)" strokeWidth="1.2" />
<path d={d} fill="none" stroke={loadColor(peak)} strokeWidth="1.3" />
</svg>
);
}
+23
View File
@@ -174,6 +174,20 @@ export interface SecurityCheck {
title: string;
detail: string;
serviceId?: string;
/** Operator judged this inapplicable — still shown, but excluded from the score. */
suppressed?: boolean;
suppressed_scope?: 'device' | 'fleet';
suppressed_reason?: string | null;
}
export interface SecuritySuppression {
id: number;
check_id: string;
device_id: number | null;
device_name: string | null;
reason: string | null;
created_by: string | null;
created_at: string;
}
/**
@@ -340,6 +354,12 @@ export const devicesApi = {
setServiceDisabled: (id: number, serviceId: string, disabled: boolean, confirmLockout = false) =>
api.put<Record<string, string>[]>(`/devices/${id}/services/${encodeURIComponent(serviceId)}`,
{ disabled, ...(confirmLockout ? { confirm_lockout: true } : {}) }),
listSecuritySuppressions: () =>
api.get<SecuritySuppression[]>('/devices/security/suppressions'),
suppressSecurityCheck: (check_id: string, device_id: number | null, reason?: string) =>
api.post('/devices/security/suppressions', { check_id, device_id, reason }),
unsuppressSecurityCheck: (id: number) =>
api.delete(`/devices/security/suppressions/${id}`),
getSecurityPosture: (id: number) =>
api.get<{ score: number; checks: SecurityCheck[] }>(`/devices/${id}/security-posture`),
// Config Health — cached findings load instantly; the scan re-reads the device.
@@ -1035,6 +1055,9 @@ export const wirelessApi = {
// RF Health (fleet-wide, or scoped to one AP via deviceId)
getChannelUsage: (deviceId?: number) =>
api.get<import('../types').RfChannelUsage>('/wireless/rf/channels', { params: { deviceId } }),
getRfCapabilities: () =>
api.get<{ packages: Record<string, number>; legacyRadios: number; txRetriesAvailable: boolean }>(
'/wireless/rf/capabilities'),
getClientSignals: (deviceId?: number) =>
api.get<import('../types').RfSignalRow[]>('/wireless/rf/signals', { params: { deviceId } }),
getTxQuality: (deviceId?: number, range = '6h') =>
+1 -1
View File
@@ -1 +1 @@
export const APP_VERSION = 'v0.23.5 Beta';
export const APP_VERSION = 'v0.23.6 Beta';