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:
co-authored by
Claude Opus 5
parent
4cc8c84ea6
commit
dcd89fe86b
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user