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
@@ -6,7 +6,7 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="https://img.shields.io/badge/version-0.23.5_Beta-blue" alt="Version" />
|
<img src="https://img.shields.io/badge/version-0.23.6_Beta-blue" alt="Version" />
|
||||||
<img src="https://img.shields.io/badge/license-AGPLv3-blue" alt="License" />
|
<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/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" />
|
<img src="https://img.shields.io/badge/TypeScript-5.3-3178C6?logo=typescript&logoColor=white" alt="TypeScript" />
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mikrotik-manager-backend",
|
"name": "mikrotik-manager-backend",
|
||||||
"version": "0.23.5-beta",
|
"version": "0.23.6-beta",
|
||||||
"description": "Mikrotik Network Management Platform - Backend API",
|
"description": "Mikrotik Network Management Platform - Backend API",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -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).
|
-- 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);
|
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
|
-- 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.
|
-- constraint rather than a lookup table, so widening it means replacing it.
|
||||||
ALTER TABLE alert_channels DROP CONSTRAINT IF EXISTS alert_channels_type_check;
|
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: 'change_guard_timeout_sec', value: 120 },
|
||||||
{ key: 'config_health_enabled', value: true },
|
{ key: 'config_health_enabled', value: true },
|
||||||
{ key: 'config_health_interval_min', value: 60 },
|
{ 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> {
|
export async function runMigrations(): Promise<void> {
|
||||||
|
|||||||
@@ -113,6 +113,49 @@ router.get('/routers/overview', async (_req: Request, res: Response) => {
|
|||||||
res.json(routers);
|
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
|
// GET /api/devices/discovered — unresolved MikroTik neighbors from topology_links
|
||||||
router.get('/discovered', async (_req: Request, res: Response) => {
|
router.get('/discovered', async (_req: Request, res: Response) => {
|
||||||
const rows = await query<{
|
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
|
// 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
|
// 0 (which falsely implies "maximally insecure"). It's a relative hardening
|
||||||
// indicator, not an absolute grade.
|
// indicator, not an absolute grade.
|
||||||
const WEIGHT: Record<Sev, number> = { high: 15, medium: 7, low: 3 };
|
const WEIGHT: Record<Sev, number> = { high: 15, medium: 7, low: 3 };
|
||||||
const penalty = checks.reduce((n, c2) => n + WEIGHT[c2.severity], 0);
|
const counted = marked.filter((c2) => !('suppressed' in c2));
|
||||||
const score = checks.length === 0 ? 100 : Math.max(5, 100 - penalty);
|
const penalty = counted.reduce((n, c2) => n + WEIGHT[c2.severity], 0);
|
||||||
return { score, checks };
|
const score = counted.length === 0 ? 100 : Math.max(5, 100 - penalty);
|
||||||
|
return { score, checks: marked };
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -958,6 +958,31 @@ router.get('/rf/signals', async (req: Request, res: Response) => {
|
|||||||
res.json(rows);
|
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)
|
// 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) => {
|
router.get('/rf/tx-quality', async (req: Request, res: Response) => {
|
||||||
const deviceId = deviceScope(req);
|
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
|
// 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.
|
// controller rather than leaving it looking like an unconfigured radio.
|
||||||
const role = await this.detectWifiRole();
|
const role = await this.detectWifiRole();
|
||||||
await query(`UPDATE devices SET wifi_role = $2 WHERE id = $1`, [this.device.id, role])
|
// The package is recorded alongside the role because TX-retry data only
|
||||||
.catch(() => { /* role is advisory; never fail collection over it */ });
|
// 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
|
// null = the fetch itself failed. Distinguished from an empty list (device
|
||||||
// genuinely has no wireless interfaces) so a transient API error can never
|
// genuinely has no wireless interfaces) so a transient API error can never
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mikrotik-manager-frontend",
|
"name": "mikrotik-manager-frontend",
|
||||||
"version": "0.23.5-beta",
|
"version": "0.23.6-beta",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import {
|
import {
|
||||||
MapPin, Building2, Hash, FileText, Pencil, Check, X, RefreshCw, AlertCircle,
|
MapPin, Building2, Hash, FileText, Pencil, Check, X, RefreshCw, AlertCircle,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import L from 'leaflet';
|
import L from 'leaflet';
|
||||||
import 'leaflet/dist/leaflet.css';
|
import 'leaflet/dist/leaflet.css';
|
||||||
import { devicesApi } from '../../services/api';
|
import { devicesApi, settingsApi } from '../../services/api';
|
||||||
import type { Device } from '../../types';
|
import type { Device } from '../../types';
|
||||||
import { useCanWrite } from '../../hooks/useCanWrite';
|
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) {
|
export default function DeviceLocationSection({ device }: Props) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const canWrite = useCanWrite();
|
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 [editing, setEditing] = useState(false);
|
||||||
const [geocoding, setGeocoding] = useState(false);
|
const [geocoding, setGeocoding] = useState(false);
|
||||||
const [saveError, setSaveError] = useState('');
|
const [saveError, setSaveError] = useState('');
|
||||||
@@ -99,7 +108,7 @@ export default function DeviceLocationSection({ device }: Props) {
|
|||||||
|
|
||||||
// Re-geocode if address changed
|
// Re-geocode if address changed
|
||||||
const addressChanged = f.location_address !== (device.location_address ?? '');
|
const addressChanged = f.location_address !== (device.location_address ?? '');
|
||||||
if (f.location_address && addressChanged) {
|
if (mapsEnabled && f.location_address && addressChanged) {
|
||||||
setGeocoding(true);
|
setGeocoding(true);
|
||||||
const coords = await geocodeAddress(f.location_address);
|
const coords = await geocodeAddress(f.location_address);
|
||||||
setGeocoding(false);
|
setGeocoding(false);
|
||||||
@@ -263,14 +272,26 @@ export default function DeviceLocationSection({ device }: Props) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Map card — only when we have coordinates and not editing */}
|
{/* Map card — only when we have coordinates, are not editing, and outbound
|
||||||
{hasMap && !editing && (
|
map requests are permitted. */}
|
||||||
|
{hasMap && !editing && mapsEnabled && (
|
||||||
<MapEmbed
|
<MapEmbed
|
||||||
lat={device.location_lat!}
|
lat={device.location_lat!}
|
||||||
lng={device.location_lng!}
|
lng={device.location_lng!}
|
||||||
address={device.location_address!}
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
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 { devicesApi } from '../../services/api';
|
||||||
import type { SecurityCheck } from '../../services/api';
|
import type { SecurityCheck } from '../../services/api';
|
||||||
import { useCanWrite } from '../../hooks/useCanWrite';
|
import { useCanWrite } from '../../hooks/useCanWrite';
|
||||||
@@ -50,7 +50,27 @@ export default function SecurityTab({ deviceId, deviceName }: { deviceId: number
|
|||||||
const invalidate = () => {
|
const invalidate = () => {
|
||||||
qc.invalidateQueries({ queryKey: ['security-posture', deviceId] });
|
qc.invalidateQueries({ queryKey: ['security-posture', deviceId] });
|
||||||
qc.invalidateQueries({ queryKey: ['services', 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({
|
const toggleSvc = useMutation({
|
||||||
mutationFn: ({ id, disabled, confirm = false }: { id: string; disabled: boolean; confirm?: boolean }) =>
|
mutationFn: ({ id, disabled, confirm = false }: { id: string; disabled: boolean; confirm?: boolean }) =>
|
||||||
devicesApi.setServiceDisabled(deviceId, id, disabled, confirm),
|
devicesApi.setServiceDisabled(deviceId, id, disabled, confirm),
|
||||||
@@ -111,19 +131,44 @@ export default function SecurityTab({ deviceId, deviceName }: { deviceId: number
|
|||||||
{checks.map(c => {
|
{checks.map(c => {
|
||||||
const s = SEV_STYLE[c.severity] ?? SEV_STYLE.low;
|
const s = SEV_STYLE[c.severity] ?? SEV_STYLE.low;
|
||||||
return (
|
return (
|
||||||
<div key={c.id} className={clsx('border rounded-lg p-3 flex items-start gap-3', s.ring)}>
|
<div key={c.id} className={clsx('border rounded-lg p-3 flex items-start gap-3',
|
||||||
<AlertTriangle className={clsx('w-4 h-4 flex-shrink-0 mt-0.5', s.text)} />
|
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-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="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>
|
</div>
|
||||||
<p className="text-xs text-gray-600 dark:text-slate-400 mt-0.5">{c.detail}</p>
|
<p className="text-xs text-gray-600 dark:text-slate-400 mt-0.5">{c.detail}</p>
|
||||||
</div>
|
</div>
|
||||||
{canWrite && c.serviceId && (
|
<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}
|
<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>
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -390,13 +390,28 @@ export function ConnectivitySuccess({ deviceId }: { deviceId?: number }) {
|
|||||||
// ─── Combined section ─────────────────────────────────────────────────────────
|
// ─── Combined section ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function RfHealth({ deviceId }: { deviceId?: number }) {
|
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<ChannelMap deviceId={deviceId} />
|
<ChannelMap deviceId={deviceId} />
|
||||||
|
{showRetries ? (
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 items-start">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 items-start">
|
||||||
<RssiDensity deviceId={deviceId} />
|
<RssiDensity deviceId={deviceId} />
|
||||||
<TxRetries deviceId={deviceId} />
|
<TxRetries deviceId={deviceId} />
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<RssiDensity deviceId={deviceId} />
|
||||||
|
)}
|
||||||
<ConnectivitySuccess deviceId={deviceId} />
|
<ConnectivitySuccess deviceId={deviceId} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -609,7 +609,19 @@ function OperationsView({
|
|||||||
});
|
});
|
||||||
const thingsToHandle: OpsAttentionItem[] = insights?.attention ?? [];
|
const thingsToHandle: OpsAttentionItem[] = insights?.attention ?? [];
|
||||||
const capacity: OpsCapacityRow[] = insights?.capacity ?? [];
|
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)
|
// Platform update availability (checked against GitHub, cached server-side)
|
||||||
const { data: versionInfo } = useQuery({
|
const { data: versionInfo } = useQuery({
|
||||||
@@ -732,6 +744,35 @@ function OperationsView({
|
|||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* Platform update available */}
|
||||||
{versionInfo?.update_available && versionInfo.latest && (
|
{versionInfo?.update_available && versionInfo.latest && (
|
||||||
<div className="card flex items-center gap-3 px-5 py-[13px]" style={{ borderLeft: '3px solid var(--info)' }}>
|
<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>
|
</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' }}>
|
<div className="grid gap-4" style={{ gridTemplateColumns: '1.3fr 1fr' }}>
|
||||||
{/* Things to handle */}
|
{/* Things to handle */}
|
||||||
<div className="card" style={{ overflow: 'hidden' }}>
|
<div className="card" style={{ overflow: 'hidden' }}>
|
||||||
@@ -808,46 +849,6 @@ function OperationsView({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Capacity + Security rollup */}
|
{/* 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)' }}>
|
<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)' }} />
|
<Clock className="w-4 h-4" style={{ color: 'var(--accent)' }} />
|
||||||
<span className="text-[13px] font-semibold" style={{ color: 'var(--ink)' }}>Recent activity</span>
|
<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>
|
</div>
|
||||||
{activity.length === 0 ? (
|
{activity.length === 0 ? (
|
||||||
<div className="py-8 text-center text-[13px]" style={{ color: 'var(--ink-3)' }}>No recent activity.</div>
|
<div className="py-8 text-center text-[13px]" style={{ color: 'var(--ink-3)' }}>No recent activity.</div>
|
||||||
|
|||||||
@@ -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[] }) {
|
function LoadSparkline({ data }: { data: number[] }) {
|
||||||
if (data.length < 2) return <span className="mono text-[11px]" style={{ color: 'var(--ink-4)' }}>—</span>;
|
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 w = 72, h = 20;
|
||||||
const pts = data.map((v, i) => [
|
const pts = data.map((v, i) => [
|
||||||
(i / (data.length - 1)) * w,
|
(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');
|
const d = 'M' + pts.map(p => `${p[0].toFixed(1)},${p[1].toFixed(1)}`).join(' L');
|
||||||
return (
|
return (
|
||||||
<svg width={w} height={h} style={{ display: 'block' }}>
|
<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>
|
</svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -174,6 +174,20 @@ export interface SecurityCheck {
|
|||||||
title: string;
|
title: string;
|
||||||
detail: string;
|
detail: string;
|
||||||
serviceId?: 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) =>
|
setServiceDisabled: (id: number, serviceId: string, disabled: boolean, confirmLockout = false) =>
|
||||||
api.put<Record<string, string>[]>(`/devices/${id}/services/${encodeURIComponent(serviceId)}`,
|
api.put<Record<string, string>[]>(`/devices/${id}/services/${encodeURIComponent(serviceId)}`,
|
||||||
{ disabled, ...(confirmLockout ? { confirm_lockout: true } : {}) }),
|
{ 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) =>
|
getSecurityPosture: (id: number) =>
|
||||||
api.get<{ score: number; checks: SecurityCheck[] }>(`/devices/${id}/security-posture`),
|
api.get<{ score: number; checks: SecurityCheck[] }>(`/devices/${id}/security-posture`),
|
||||||
// Config Health — cached findings load instantly; the scan re-reads the device.
|
// 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)
|
// RF Health (fleet-wide, or scoped to one AP via deviceId)
|
||||||
getChannelUsage: (deviceId?: number) =>
|
getChannelUsage: (deviceId?: number) =>
|
||||||
api.get<import('../types').RfChannelUsage>('/wireless/rf/channels', { params: { deviceId } }),
|
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) =>
|
getClientSignals: (deviceId?: number) =>
|
||||||
api.get<import('../types').RfSignalRow[]>('/wireless/rf/signals', { params: { deviceId } }),
|
api.get<import('../types').RfSignalRow[]>('/wireless/rf/signals', { params: { deviceId } }),
|
||||||
getTxQuality: (deviceId?: number, range = '6h') =>
|
getTxQuality: (deviceId?: number, range = '6h') =>
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
export const APP_VERSION = 'v0.23.5 Beta';
|
export const APP_VERSION = 'v0.23.6 Beta';
|
||||||
|
|||||||
Reference in New Issue
Block a user