fix(ui): UKMesh visual QA fixes — cookie banner, map labels, feed, charts, site pages

Deployed fixes from visual QA of ukmesh.com / app.ukmesh.com / healthcheck.ukmesh.com:

- Cookie consent: static full-width bottom bar with reserved page spacing (no longer overlays content)
- Landing: Live Map nav marked external with arrow, hero buttons and secondary text contrast
- Map app: readable place labels in both themes, packet feed top row no longer clipped,
  aligned feed columns, bordered header tool buttons
- Site pages: dark-themed Region select, brighter topology graph, Repeaters empty state,
  Companions bar scale legend, themed Spam controls/checkbox, Install badges
- Health: firmware chart label dedupe/rotation with Include Unknown toggle
- Docs: mobile code blocks scrollable
- Health Check: No data yet empty states, contrast, status pill a11y
- Healthcheck overrides: merged empty-state and a11y changes into deployed bind-mounted files
This commit is contained in:
gadgethd
2026-08-01 16:48:27 +01:00
parent b9b2ecdbfa
commit 83d770a2b2
24 changed files with 7527 additions and 1211 deletions
+236 -410
View File
@@ -27,6 +27,8 @@ import {
DEFAULT_ZOOM,
EMPTY_FC,
MAP_ARC_REFRESH_INTERVAL_MS,
MAP_LABEL_COLORS,
MAP_RASTER_PAINT,
MAP_REFRESH_INTERVAL_MS,
MAP_STYLE,
MAP_STYLE_LIGHT,
@@ -65,40 +67,51 @@ import type {
} from './types.js';
import { sampleElevationAt } from '../../utils/terrainSampler.js';
import { computeCustomLos } from '../../utils/customLos.js';
import { ApiResponseError, fetchJson, withScopeParams, type ApiScope } from '../../utils/api.js';
import { ScopedCache } from '../../utils/scopedCache.js';
import { createVisibilityPoller } from '../../hooks/useVisibilityPoll.js';
const NODE_LINK_CACHE_TTL_MS = 5 * 60_000;
const nodeLinksCache = new Map<string, { rows?: NodeLink[]; fetchedAt?: number; pending?: Promise<NodeLink[]> }>();
const NODE_DOCK_RIGHT_PADDING = 372;
function fetchNodeLinks(nodeId: string): Promise<NodeLink[]> {
const cached = nodeLinksCache.get(nodeId);
if (cached?.rows && Date.now() - (cached.fetchedAt ?? 0) < NODE_LINK_CACHE_TTL_MS) {
return Promise.resolve(cached.rows);
}
if (cached?.pending) return cached.pending;
const pending = fetch(`/api/nodes/${encodeURIComponent(nodeId)}/links`, {
signal: AbortSignal.timeout(15_000),
})
.then(async (response) => {
if (!response.ok) throw new Error(`links request failed: ${response.status}`);
const payload = await response.json() as NodeLink[];
const rows = Array.isArray(payload) ? payload : [];
nodeLinksCache.set(nodeId, { rows, fetchedAt: Date.now() });
return rows;
})
.catch((error) => {
nodeLinksCache.delete(nodeId);
throw error;
});
nodeLinksCache.set(nodeId, { ...cached, pending });
return pending;
function mapPaddingForNode(selected: string | null): { top: number; right: number; bottom: number; left: number } {
const desktop = window.matchMedia('(min-width: 641px)').matches;
return {
top: 0,
right: selected && desktop ? NODE_DOCK_RIGHT_PADDING : 0,
bottom: 0,
left: 0,
};
}
const NODE_LINK_CACHE_TTL_MS = 5 * 60_000;
const nodeLinksCache = new ScopedCache<NodeLink[]>({
name: 'map-node-links',
ttlMs: NODE_LINK_CACHE_TTL_MS,
maxEntries: 512,
maxBytes: 8 * 1024 * 1024,
maxInflight: 8,
});
function fetchNodeLinks(nodeId: string, scopeKey: string, scope: ApiScope): Promise<NodeLink[]> {
return nodeLinksCache.getOrLoad(scopeKey, nodeId.toUpperCase(), async () => {
const payload = await fetchJson<unknown>(
withScopeParams(`/api/nodes/${encodeURIComponent(nodeId)}/links`, scope),
{},
{ timeoutMs: 15_000, maxBytes: 2 * 1024 * 1024 },
);
if (!Array.isArray(payload)) throw new Error('node links response was not an array');
return payload as NodeLink[];
});
}
import {
installMapSourcesAndLayers,
} from './mapSourceLayers.js';
// ── Main Component ────────────────────────────────────────────────────────────
export function MapLibreMap({
inferredNodes,
inferredActiveNodeIds: _inferredActiveNodeIds,
inferredActiveNodeIds,
showLinks,
showTerrain,
showClientNodes,
@@ -110,16 +123,23 @@ export function MapLibreMap({
onNodeSelect,
onMapReady,
mapLight,
network,
observer,
privacyGeneration,
}: MapLibreMapProps) {
const requestScope = useMemo(() => ({ network, observer }), [network, observer]);
const requestScopeKey = `${network ?? 'all'}|${observer ?? 'all'}|privacy-${privacyGeneration}`;
const containerRef = useRef<HTMLDivElement>(null);
const mapRef = useRef<maplibregl.Map | null>(null);
const mapLoadedRef = useRef(false);
const nodesRef = useRef(nodeStore.getState().nodes);
const coverageRef = useRef(coverageStore.getState().coverage);
const selectedCoverageRef = useRef<NodeCoverage | null>(null);
const coverageRequestRef = useRef<AbortController | null>(null);
const viablePairsRef = useRef(linkStateStore.getState().viablePairsArr);
const linkMetricsRef = useRef(linkStateStore.getState().linkMetrics);
const inferredNodesRef = useRef(inferredNodes);
const inferredActiveNodeIdsRef = useRef(inferredActiveNodeIds);
const showLinksRef = useRef(showLinks);
const showTerrainRef = useRef(showTerrain);
const showClientNodesRef = useRef(showClientNodes);
@@ -149,7 +169,7 @@ export function MapLibreMap({
const handleCustomLosPointRef = useRef<(point: CustomLosPoint) => Promise<void>>(null as any);
// Planned repeater placement
const plannedRepeatersRef = useRef<PlannedRepeater[]>([]);
const plannedPollRefs = useRef<Map<string, number>>(new Map());
const plannedPollRefs = useRef<Map<string, { stop: () => void }>>(new Map());
// Plans whose LOS overlay has already been auto-applied (so we don't re-apply
// it or fight a user who manually hid it from the popup).
const plannedLosAppliedRef = useRef<Set<string>>(new Set());
@@ -181,13 +201,22 @@ export function MapLibreMap({
void import('maplibre-gl/dist/maplibre-gl.css');
}, []);
useEffect(() => {
coverageRequestRef.current?.abort();
coverageRequestRef.current = null;
selectedCoverageRef.current = null;
setSelectedCoverageNodeId(null);
setCoverageLoadingNodeId(null);
setCoverageMessage(null);
}, [requestScopeKey]);
// -- Map theme (light/dark) -------------------------------------------------
useEffect(() => {
const map = mapRef.current;
if (map && mapLoadedRef.current) {
const oldId = mapLight ? 'carto-dark' : 'carto-light';
const newId = mapLight ? 'carto-light' : 'carto-dark';
const variant = mapLight ? 'light_all' : 'dark_all';
const variant = mapLight ? 'light_nolabels' : 'dark_nolabels';
if (map.getSource(newId)) return;
if (map.getLayer('background')) map.removeLayer('background');
if (map.getLayer('bg-fill')) map.removeLayer('bg-fill');
@@ -205,16 +234,41 @@ export function MapLibreMap({
// Insert bg-fill + basemap at the very bottom
const firstLayer = map.getStyle().layers[0]?.id;
map.addLayer(
{ id: 'bg-fill', type: 'background', paint: { 'background-color': mapLight ? '#e8e8e8' : '#080d14' } },
{ id: 'bg-fill', type: 'background', paint: { 'background-color': mapLight ? '#edf2f7' : '#080d14' } },
firstLayer,
);
map.addLayer(
{ id: 'background', type: 'raster', source: newId },
map.getStyle().layers[1]?.id, // after bg-fill, before everything else
{
id: 'background',
type: 'raster',
source: newId,
paint: MAP_RASTER_PAINT[mapLight ? 'light' : 'dark'],
},
map.getStyle().layers[1]?.id, // after bg-fill, before vector labels
);
const labelColors = mapLight ? MAP_LABEL_COLORS.light : MAP_LABEL_COLORS.dark;
for (const [layerId, colorKey] of [
['map-labels-place', 'place'],
['map-labels-water', 'water'],
['map-labels-road', 'road'],
] as const) {
if (!map.getLayer(layerId)) continue;
map.setPaintProperty(layerId, 'text-color', labelColors[colorKey]);
map.setPaintProperty(layerId, 'text-halo-color', labelColors.halo);
map.setPaintProperty(layerId, 'text-halo-width', colorKey === 'place' ? 1.7 : 1.5);
map.setPaintProperty(layerId, 'text-halo-blur', 0.1);
}
}
}, [mapLight]);
// Keep the map's usable camera area clear of the right-side node dock.
useEffect(() => {
const map = mapRef.current;
if (!map || !mapLoadedRef.current) return;
map.setPadding(mapPaddingForNode(selectedNodeId));
}, [selectedNodeId]);
// -- LOS profiles (client-side, multi-node, auto-expire) -------------------
const addLosLoading = useOverlayStore((state) => state.addLosLoading);
@@ -257,7 +311,7 @@ export function MapLibreMap({
const ANTENNA_H = 10;
const EXAG = TERRAIN_CONFIG.exaggeration;
try {
const links = await fetchNodeLinks(nodeId);
const links = await fetchNodeLinks(nodeId, requestScopeKey, requestScope);
const sourceNode = nodesRef.current.get(nodeId);
if (!sourceNode || !hasCoords(sourceNode)) {
setLosProfilesForNode(nodeId, []);
@@ -292,7 +346,14 @@ export function MapLibreMap({
removeLosNode(nodeId);
}, 15_000);
losTimersRef.current.set(nodeId, handle);
}, [addLosLoading, setLosProfilesForNode, removeLosNode, clearLosTimer]);
}, [
addLosLoading,
clearLosTimer,
removeLosNode,
requestScope,
requestScopeKey,
setLosProfilesForNode,
]);
// -- Custom LOS (two-point terrain-sampled LOS) ----------------------------
@@ -328,38 +389,78 @@ export function MapLibreMap({
const pollPlannedCoverage = useCallback((planId: string) => {
if (!viewshedEnabledRef.current) return;
const iv = window.setInterval(() => {
void fetch(`/api/coverage/planned/${planId}`)
.then((r) => {
if (r.status === 404 || r.status === 410) {
return {
status: 'failed',
coverage: undefined,
} satisfies { status: string; coverage?: PlannedRepeater['coverage'] };
}
if (!r.ok) throw new Error('planned coverage temporarily unavailable');
return r.json() as Promise<{ status: string; coverage?: PlannedRepeater['coverage'] }>;
})
.then((data) => {
if (data.status === 'ready') {
window.clearInterval(iv);
plannedPollRefs.current.delete(planId);
useOverlayStore.getState().updatePlannedRepeater(planId, { status: 'ready', coverage: data.coverage });
} else if (data.status === 'failed') {
window.clearInterval(iv);
plannedPollRefs.current.delete(planId);
useOverlayStore.getState().updatePlannedRepeater(planId, { status: 'error' });
}
})
.catch(() => {});
}, 2000);
plannedPollRefs.current.set(planId, iv);
}, []);
plannedPollRefs.current.get(planId)?.stop();
const deadlineAt = Date.now() + 2 * 60_000;
let deadlineTimer: number | null = null;
let poller: ReturnType<typeof createVisibilityPoller> | null = null;
let stopped = false;
const finish = (patch: Partial<PlannedRepeater>) => {
if (stopped) return;
stopped = true;
if (deadlineTimer !== null) window.clearTimeout(deadlineTimer);
poller?.stop();
plannedPollRefs.current.delete(planId);
useOverlayStore.getState().updatePlannedRepeater(planId, patch);
};
const handle = {
stop: () => {
if (stopped) return;
stopped = true;
if (deadlineTimer !== null) window.clearTimeout(deadlineTimer);
poller?.stop();
},
};
plannedPollRefs.current.set(planId, handle);
poller = createVisibilityPoller({
poll: async (signal) => {
if (Date.now() >= deadlineAt) {
finish({ status: 'error' });
return;
}
const data = await fetchJson<{
status?: unknown;
coverage?: PlannedRepeater['coverage'];
}>(
withScopeParams(`/api/coverage/planned/${encodeURIComponent(planId)}`, requestScope),
{ cache: 'no-store', signal },
{ timeoutMs: 8_000, maxBytes: 8 * 1024 * 1024 },
);
if (signal.aborted || stopped) return;
if (data.status === 'ready' && data.coverage) {
finish({ status: 'ready', coverage: data.coverage });
} else if (data.status === 'failed') {
finish({ status: 'error' });
} else if (data.status !== 'queued' && data.status !== 'processing') {
throw new Error('Planned coverage returned an unknown state');
}
},
intervalMs: 2_000,
timeoutMs: 8_000,
maxBackoffMs: 8_000,
jitterRatio: 0.1,
isVisible: () => document.visibilityState === 'visible',
subscribeVisibility: (listener) => {
document.addEventListener('visibilitychange', listener);
return () => document.removeEventListener('visibilitychange', listener);
},
onError: (pollError) => {
if (
Date.now() >= deadlineAt
|| (pollError instanceof ApiResponseError && [404, 410].includes(pollError.status))
) {
finish({ status: 'error' });
}
},
});
deadlineTimer = window.setTimeout(() => finish({ status: 'error' }), Math.max(1, deadlineAt - Date.now()));
}, [requestScope]);
const handleRemovePlannedRepeater = useCallback((planId: string) => {
const iv = plannedPollRefs.current.get(planId);
if (iv !== undefined) {
window.clearInterval(iv);
const poll = plannedPollRefs.current.get(planId);
if (poll) {
poll.stop();
plannedPollRefs.current.delete(planId);
}
setPlannedPopupState((prev) => (prev?.planId === planId ? null : prev));
@@ -367,9 +468,12 @@ export function MapLibreMap({
useOverlayStore.getState().removeLosNode(planId);
useOverlayStore.getState().removePlannedRepeater(planId);
if (viewshedEnabledRef.current) {
void fetch(`/api/coverage/planned/${planId}`, { method: 'DELETE' }).catch(() => {});
void fetch(
withScopeParams(`/api/coverage/planned/${encodeURIComponent(planId)}`, requestScope),
{ method: 'DELETE' },
).catch(() => {});
}
}, []);
}, [requestScope]);
// Rebuild the planned-link lines from the current plans + live node positions,
// and toggle their visibility with the global Links toggle.
@@ -460,19 +564,22 @@ export function MapLibreMap({
const placePlannedRepeater = useCallback(async (lat: number, lon: number) => {
if (!viewshedEnabledRef.current) return;
try {
const res = await fetch('/api/coverage/planned', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ lat, lon }),
});
if (!res.ok) return;
const data = await res.json() as { plan_id: string };
const data = await fetchJson<{ plan_id: string }>(
withScopeParams('/api/coverage/planned', requestScope),
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ lat, lon }),
},
{ timeoutMs: 15_000, maxBytes: 64 * 1024 },
);
if (typeof data.plan_id !== 'string' || !data.plan_id) return;
useOverlayStore.getState().addPlannedRepeater({ id: data.plan_id, lat, lon, status: 'queued' });
pollPlannedCoverage(data.plan_id);
} catch {
// non-fatal
}
}, [pollPlannedCoverage]);
}, [pollPlannedCoverage, requestScope]);
// Keep handler refs in sync for map event handlers
useEffect(() => {
@@ -504,12 +611,24 @@ export function MapLibreMap({
updatePlannedLinks();
}, [viewshedEnabled, plannedRepeaters, updatePlannedLinks]);
// Clean up all planned repeaters, intervals, and LOS overlays on unmount
// Stop old-scope jobs before they can publish into a new network/observer.
useEffect(() => () => {
for (const [planId, iv] of plannedPollRefs.current) {
window.clearInterval(iv);
for (const [planId, poll] of plannedPollRefs.current) {
poll.stop();
useOverlayStore.getState().updatePlannedRepeater(planId, { status: 'error' });
}
plannedPollRefs.current.clear();
}, [requestScopeKey]);
// Clean up all planned repeaters, pollers, and LOS overlays on unmount
useEffect(() => () => {
for (const [planId, poll] of plannedPollRefs.current) {
poll.stop();
if (viewshedEnabledRef.current) {
void fetch(`/api/coverage/planned/${planId}`, { method: 'DELETE' }).catch(() => {});
void fetch(
withScopeParams(`/api/coverage/planned/${encodeURIComponent(planId)}`, requestScope),
{ method: 'DELETE' },
).catch(() => {});
}
}
plannedPollRefs.current.clear();
@@ -517,7 +636,7 @@ export function MapLibreMap({
useOverlayStore.getState().removeLosNode(planId);
}
plannedLosAppliedRef.current.clear();
}, []);
}, [requestScope]);
// Cursor crosshair while in custom LOS mode or plan repeater mode
useEffect(() => {
@@ -609,6 +728,9 @@ export function MapLibreMap({
clash.clashModeActive,
clash.clashModeActive ? null : currentPathNodeIds,
replayNodeIdsRef.current,
Date.now(),
inferredNodesRef.current,
inferredActiveNodeIdsRef.current,
);
(mapRef.current.getSource('nodes') as maplibregl.GeoJSONSource | undefined)?.setData(nodeGeoJSON);
}
@@ -672,8 +794,8 @@ export function MapLibreMap({
setPlanRepeaterMode(false);
setPlannedPopupState(null);
plannedPopupRef.current?.remove();
for (const [, iv] of plannedPollRefs.current) {
window.clearInterval(iv);
for (const [, poll] of plannedPollRefs.current) {
poll.stop();
}
plannedPollRefs.current.clear();
for (const repeater of useOverlayStore.getState().plannedRepeaters) {
@@ -730,328 +852,9 @@ export function MapLibreMap({
map.on('load', () => {
mapLoadedRef.current = true;
// ── Node dots source + layer ───────────────────────────────────────────
map.addSource('nodes', { type: 'geojson', data: EMPTY_FC });
map.addLayer({
id: 'node-dots',
type: 'circle',
source: 'nodes',
filter: ['==', ['get', 'visible'], true],
paint: {
'circle-radius': [
'interpolate', ['linear'], ['zoom'],
6, 3, 9, 4, 11, 5, 13, 7, 16, 9,
],
'circle-color': [
'case',
['==', ['get', 'hex_clash_state'], 'offender'], '#ef4444',
['==', ['get', 'hex_clash_state'], 'relay'], '#22c55e',
['get', 'replay_active'], '#fbbf24',
['get', 'is_link_only_stale'], '#4b5563',
['get', 'is_inferred'], '#7dd3fc',
['get', 'is_stale'], '#6b7280',
['!', ['get', 'is_online']], '#6b7280',
['==', ['get', 'role'], 1], '#ff9f43',
['==', ['get', 'role'], 3], '#a78bfa',
['==', ['get', 'role'], 4], '#34d399',
'#00c4ff', // repeater (role 2 / default)
],
'circle-opacity': [
'case',
['all', ['get', 'replay_mode'], ['!', ['get', 'replay_active']]], 0.12,
['get', 'is_link_only_stale'], 0.22,
['get', 'is_stale'], 0.4,
['!', ['get', 'is_online']], 0.4,
['get', 'is_inferred'], 0.7,
1.0,
],
'circle-stroke-width': 0,
'circle-stroke-color': '#00c4ff',
'circle-stroke-opacity': 0.7,
},
installMapSourcesAndLayers(map, {
showLinks: showLinksRef.current,
});
// Observer quality is a separate, cached overlay so live node refreshes do
// not rebuild health metrics. A coloured ring keeps the role colour intact.
map.addSource('observer-health', { type: 'geojson', data: EMPTY_FC });
map.addLayer({
id: 'observer-health-rings',
type: 'circle',
source: 'observer-health',
paint: {
'circle-radius': [
'interpolate', ['linear'], ['zoom'],
6, 5.5, 9, 7, 11, 8.5, 13, 11, 16, 14,
],
'circle-color': 'rgba(0,0,0,0)',
'circle-stroke-width': 2,
'circle-stroke-color': [
'match', ['get', 'quality'],
'good', '#22c55e',
'watch', '#fbbf24',
'#ef4444',
],
'circle-stroke-opacity': 0.95,
},
});
void fetch('/api/observers/health', { signal: AbortSignal.timeout(10_000) })
.then((response) => response.ok ? response.json() as Promise<Array<{
node_id: string; name: string | null; lat: number; lon: number;
score: number; quality: 'good' | 'watch' | 'poor';
}>> : [])
.then((observers) => {
const source = map.getSource('observer-health') as maplibregl.GeoJSONSource | undefined;
source?.setData({
type: 'FeatureCollection',
features: observers.map((observer) => ({
type: 'Feature',
geometry: { type: 'Point', coordinates: [observer.lon, observer.lat] },
properties: {
node_id: observer.node_id,
name: observer.name,
score: observer.score,
quality: observer.quality,
},
})),
});
})
.catch(() => {});
// ── Selected-node highlight (recolour + ring) ──────────────────────────
// Two circle layers over node-dots, filtered to the selected node id.
// A soft halo underneath, a bright solid marker on top.
map.addLayer({
id: 'node-dots-selected-halo',
type: 'circle',
source: 'nodes',
filter: ['==', ['get', 'node_id'], '__none__'],
paint: {
'circle-radius': [
'interpolate', ['linear'], ['zoom'],
6, 11, 9, 13, 11, 15, 13, 18, 16, 22,
],
'circle-color': '#22e0ff',
'circle-opacity': 0.16,
'circle-blur': 0.5,
},
});
map.addLayer({
id: 'node-dots-selected',
type: 'circle',
source: 'nodes',
filter: ['==', ['get', 'node_id'], '__none__'],
paint: {
'circle-radius': [
'interpolate', ['linear'], ['zoom'],
6, 5, 9, 6.5, 11, 8, 13, 10, 16, 13,
],
'circle-color': '#8af4ff',
'circle-opacity': 1,
'circle-stroke-color': '#ffffff',
'circle-stroke-width': 2.5,
'circle-stroke-opacity': 0.95,
},
});
// ── Privacy rings source + layer ───────────────────────────────────────
map.addSource('privacy-rings', { type: 'geojson', data: EMPTY_FC });
map.addLayer({
id: 'privacy-rings-layer',
type: 'line',
source: 'privacy-rings',
paint: {
'line-color': '#f59e0b',
'line-width': 1.4,
'line-opacity': 0.55,
'line-dasharray': [4, 6],
},
});
// ── Viable links source + layer ───────────────────────────────────────
map.addSource('viable-links', { type: 'geojson', data: EMPTY_FC });
map.addLayer({
id: 'viable-links-layer',
type: 'line',
source: 'viable-links',
layout: {
visibility: 'none',
'line-cap': 'round',
'line-join': 'round',
},
paint: {
'line-color': ['get', 'color'],
'line-width': ['get', 'width'],
'line-opacity': ['get', 'opacity'],
},
});
// ── Coverage source + layer ────────────────────────────────────────────
map.addSource('coverage', { type: 'geojson', data: EMPTY_FC });
map.addLayer({
id: 'coverage-fill',
type: 'fill',
source: 'coverage',
layout: { visibility: 'none' },
paint: {
'fill-color': [
'match', ['get', 'band'],
'green', '#22c55e',
'amber', '#fbbf24',
'red', '#ef4444',
'#22c55e',
],
'fill-opacity': [
'match', ['get', 'band'],
'green', 0.22,
'amber', 0.16,
'red', 0.10,
0.18,
],
},
});
// ── Clash lines source + layer ─────────────────────────────────────────
map.addSource('clash-lines', { type: 'geojson', data: EMPTY_FC });
map.addLayer({
id: 'clash-lines-layer',
type: 'line',
source: 'clash-lines',
layout: { visibility: 'none' },
paint: {
'line-color': '#f97316',
'line-width': 2.2,
'line-opacity': 0.9,
},
});
// ── Planned coverage source + layers ──────────────────────────────────
map.addSource('planned-coverage', { type: 'geojson', data: EMPTY_FC });
map.addLayer({
id: 'planned-coverage-fill',
type: 'fill',
source: 'planned-coverage',
paint: {
'fill-color': [
'match', ['get', 'band'],
'green', '#2dd4bf', // teal-400
'amber', '#818cf8', // indigo-400
'red', '#c084fc', // purple-400
'#2dd4bf',
],
'fill-opacity': [
'match', ['get', 'band'],
'green', 0.30,
'amber', 0.25,
'red', 0.20,
0.25,
],
},
});
map.addLayer({
id: 'planned-coverage-outline',
type: 'line',
source: 'planned-coverage',
paint: {
'line-color': '#22d3ee', // cyan-400
'line-width': 1.5,
'line-opacity': 0.6,
},
});
// ── Predicted planned-repeater links source + layer ───────────────────
// Dashed lines (coloured by predicted path loss) so they read as
// hypothetical, distinct from the solid observed-link lines. Visibility
// follows the global Links toggle.
map.addSource('planned-links', { type: 'geojson', data: EMPTY_FC });
map.addLayer({
id: 'planned-links-layer',
type: 'line',
source: 'planned-links',
layout: {
visibility: showLinksRef.current ? 'visible' : 'none',
'line-cap': 'round',
'line-join': 'round',
},
paint: {
'line-color': ['get', 'color'],
'line-width': ['get', 'width'],
'line-opacity': 0.9,
'line-dasharray': [2, 1.5],
},
});
// ── Planned repeater pins source + layers ──────────────────────────────
// Styled to match real repeater nodes (role 2, #00c4ff) but visually
// distinct via white stroke + glow halo + "Planned" label.
map.addSource('planned-pins', { type: 'geojson', data: EMPTY_FC });
// Halo: soft glow behind the pin
map.addLayer({
id: 'planned-pins-halo',
type: 'circle',
source: 'planned-pins',
paint: {
'circle-radius': [
'interpolate', ['linear'], ['zoom'],
6, 8, 9, 11, 11, 14, 13, 18, 16, 22,
],
'circle-color': '#22d3ee',
'circle-opacity': [
'match', ['get', 'status'],
'ready', 0.20,
0.10,
],
'circle-stroke-width': 0,
},
});
// Core dot: same size/colour as a real online repeater, white stroke to mark as planned
map.addLayer({
id: 'planned-pins-dot',
type: 'circle',
source: 'planned-pins',
paint: {
'circle-radius': [
'interpolate', ['linear'], ['zoom'],
6, 3, 9, 4, 11, 5, 13, 7, 16, 9,
],
'circle-color': [
'match', ['get', 'status'],
'ready', '#00c4ff', // identical to real online repeater
'#4b5563', // dark grey while computing
],
'circle-opacity': [
'match', ['get', 'status'],
'ready', 1.0,
0.6,
],
'circle-stroke-color': '#ffffff',
'circle-stroke-width': 2,
'circle-stroke-opacity': 0.95,
},
});
// Label: status ("Planned"/"Computing…") + the placement coordinates below the dot
map.addLayer({
id: 'planned-pins-label',
type: 'symbol',
source: 'planned-pins',
layout: {
'text-field': ['get', 'label'],
'text-size': 10,
'text-anchor': 'top',
'text-offset': [0, 1.0],
'text-allow-overlap': true,
'text-ignore-placement': true,
},
paint: {
'text-color': '#22d3ee',
'text-halo-color': 'rgba(0,0,0,0.8)',
'text-halo-width': 1.2,
},
});
// ── Click handler ──────────────────────────────────────────────────────
map.on('click', 'planned-pins-dot', (e) => {
if (!viewshedEnabledRef.current) return;
@@ -1150,6 +953,7 @@ export function MapLibreMap({
});
mapRef.current = map;
map.setPadding(mapPaddingForNode(selectedNodeIdRef.current));
onMapReady?.(map);
refreshMapSources();
@@ -1196,8 +1000,9 @@ export function MapLibreMap({
useEffect(() => {
inferredNodesRef.current = inferredNodes;
inferredActiveNodeIdsRef.current = inferredActiveNodeIds;
scheduleRefresh({ nodes: true });
}, [inferredNodes, scheduleRefresh]);
}, [inferredActiveNodeIds, inferredNodes, scheduleRefresh]);
useEffect(() => {
showLinksRef.current = showLinks;
@@ -1310,6 +1115,8 @@ export function MapLibreMap({
if (!viewshedEnabled) return;
if (coverageLoadingNodeId === nodeId) return;
if (selectedCoverageNodeId === nodeId) {
coverageRequestRef.current?.abort();
coverageRequestRef.current = null;
selectedCoverageRef.current = null;
setSelectedCoverageNodeId(null);
setCoverageMessage(null);
@@ -1317,32 +1124,48 @@ export function MapLibreMap({
return;
}
coverageRequestRef.current?.abort();
const controller = new AbortController();
coverageRequestRef.current = controller;
setCoverageLoadingNodeId(nodeId);
setCoverageMessage(null);
void fetch(`/api/coverage/${encodeURIComponent(nodeId)}`, { cache: 'no-store' })
.then(async (response) => {
const payload = await response.json().catch(() => ({})) as { status?: string; coverage?: NodeCoverage };
if (response.status === 202 || payload.status === 'queued') {
void fetchJson<{ status?: string; coverage?: NodeCoverage }>(
withScopeParams(`/api/coverage/${encodeURIComponent(nodeId)}`, requestScope),
{ cache: 'no-store', signal: controller.signal },
{ timeoutMs: 15_000, maxBytes: 8 * 1024 * 1024 },
)
.then((payload) => {
if (controller.signal.aborted || coverageRequestRef.current !== controller) return;
if (payload.status === 'queued') {
selectedCoverageRef.current = null;
setSelectedCoverageNodeId(null);
setCoverageMessage('Coverage is being calculated.');
return;
}
if (!response.ok || !payload.coverage) throw new Error('coverage unavailable');
if (!payload.coverage) throw new Error('coverage unavailable');
selectedCoverageRef.current = payload.coverage;
setSelectedCoverageNodeId(nodeId);
setCoverageMessage(null);
})
.catch(() => {
if (controller.signal.aborted || coverageRequestRef.current !== controller) return;
selectedCoverageRef.current = null;
setSelectedCoverageNodeId(null);
setCoverageMessage('Coverage unavailable.');
})
.finally(() => {
if (coverageRequestRef.current !== controller) return;
coverageRequestRef.current = null;
setCoverageLoadingNodeId(null);
scheduleRefresh();
});
}, [viewshedEnabled, coverageLoadingNodeId, selectedCoverageNodeId, scheduleRefresh]);
}, [
viewshedEnabled,
coverageLoadingNodeId,
requestScope,
selectedCoverageNodeId,
scheduleRefresh,
]);
// -- Popup management ------------------------------------------------------
@@ -1358,11 +1181,11 @@ export function MapLibreMap({
if (!node) return;
let cancelled = false;
setPopupLinks(null);
void fetchNodeLinks(selectedNodeId)
void fetchNodeLinks(selectedNodeId, requestScopeKey, requestScope)
.then((rows) => { if (!cancelled) setPopupLinks(rows); })
.catch(() => { if (!cancelled) setPopupLinks([]); });
return () => { cancelled = true; };
}, [selectedNodeId, getNode]); // eslint-disable-line react-hooks/exhaustive-deps
}, [getNode, requestScope, requestScopeKey, selectedNodeId]);
// Highlight the selected node on the map (bright recolour + ring). Cleared
// when no selection. Uses setFilter so we never rebuild the whole node source.
@@ -1612,6 +1435,9 @@ export function MapLibreMap({
losActive={popupLosActive}
losLoading={popupLosLoading}
onToggleLos={handleToggleLos}
network={network}
observer={observer}
privacyGeneration={privacyGeneration}
/>
</div>
</aside>
+143 -11
View File
@@ -19,6 +19,132 @@ export const EMPTY_FC: GeoJSON.FeatureCollection = {
export const TERRAIN_CONFIG = { source: 'terrain-dem', exaggeration: 3 };
const CARTO_VECTOR_TILES = 'https://tiles.basemaps.cartocdn.com/vector/carto.streets/v1/tiles.json';
export const CARTO_GLYPHS = 'https://tiles.basemaps.cartocdn.com/fonts/{fontstack}/{range}.pbf';
type MapLabelColors = {
place: string;
water: string;
road: string;
halo: string;
};
export const MAP_LABEL_COLORS = {
dark: {
place: '#f8fafc',
water: '#bfdbfe',
road: '#e2e8f0',
halo: '#080d14',
},
light: {
place: '#1f2937',
water: '#1d4ed8',
road: '#334155',
halo: '#ffffff',
},
} as const;
// The no-label raster variants leave label rendering to the vector layers
// below, so labels stay readable in both themes instead of being baked into
// a tile with the wrong contrast.
export const MAP_RASTER_PAINT = {
dark: {
'raster-contrast': 0.18,
'raster-brightness-min': 0.02,
'raster-brightness-max': 0.92,
},
light: {
'raster-contrast': 0.08,
'raster-brightness-min': 0.04,
'raster-brightness-max': 1,
},
} as const;
const CARTO_LABEL_SOURCE: maplibregl.VectorSourceSpecification = {
type: 'vector',
url: CARTO_VECTOR_TILES,
};
const mapLabelLayers = (
colors: MapLabelColors,
): maplibregl.LayerSpecification[] => [
{
id: 'map-labels-water',
type: 'symbol',
source: 'carto-labels',
'source-layer': 'water_name',
minzoom: 5,
filter: ['all', ['has', 'name'], ['==', '$type', 'Point']],
layout: {
'text-field': ['coalesce', ['get', 'name_en'], ['get', 'name']],
'text-font': ['Open Sans Regular', 'Noto Sans Regular'],
'text-size': ['interpolate', ['linear'], ['zoom'], 5, 10, 10, 12, 14, 15],
'text-padding': 2,
'text-allow-overlap': false,
'text-ignore-placement': false,
},
paint: {
'text-color': colors.water,
'text-halo-color': colors.halo,
'text-halo-width': 1.6,
'text-halo-blur': 0.1,
},
},
{
id: 'map-labels-place',
type: 'symbol',
source: 'carto-labels',
'source-layer': 'place',
minzoom: 5,
filter: [
'all',
['has', 'name'],
['==', '$type', 'Point'],
['in', 'class', 'city', 'town', 'village', 'suburb', 'hamlet', 'municipality'],
],
layout: {
'text-field': ['coalesce', ['get', 'name_en'], ['get', 'name']],
'text-font': ['Open Sans Regular', 'Noto Sans Regular'],
'text-size': ['interpolate', ['linear'], ['zoom'], 5, 9, 9, 10, 13, 13, 16, 15],
'text-max-width': 10,
'text-padding': 2,
'text-allow-overlap': false,
'text-ignore-placement': false,
},
paint: {
'text-color': colors.place,
'text-halo-color': colors.halo,
'text-halo-width': 1.7,
'text-halo-blur': 0.1,
},
},
{
id: 'map-labels-road',
type: 'symbol',
source: 'carto-labels',
'source-layer': 'transportation_name',
minzoom: 10,
filter: ['all', ['has', 'name'], ['==', '$type', 'LineString']],
layout: {
'text-field': ['get', 'name'],
'text-font': ['Open Sans Regular', 'Noto Sans Regular'],
'text-size': ['interpolate', ['linear'], ['zoom'], 10, 8, 14, 10, 17, 12],
'symbol-placement': 'line',
'symbol-spacing': 300,
'text-padding': 2,
'text-max-angle': 30,
'text-allow-overlap': false,
'text-ignore-placement': false,
},
paint: {
'text-color': colors.road,
'text-halo-color': colors.halo,
'text-halo-width': 1.4,
'text-halo-blur': 0.1,
},
},
];
export const TERRAIN_DEM_SOURCE: maplibregl.RasterDEMSourceSpecification = {
type: 'raster-dem',
tiles: ['/terrain-tiles/{z}/{x}/{y}.png'],
@@ -34,20 +160,23 @@ export const MAP_STYLE: maplibregl.StyleSpecification = {
'carto-dark': {
type: 'raster',
tiles: [
'https://a.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png',
'https://b.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png',
'https://c.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png',
'https://d.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png',
'https://a.basemaps.cartocdn.com/dark_nolabels/{z}/{x}/{y}{r}.png',
'https://b.basemaps.cartocdn.com/dark_nolabels/{z}/{x}/{y}{r}.png',
'https://c.basemaps.cartocdn.com/dark_nolabels/{z}/{x}/{y}{r}.png',
'https://d.basemaps.cartocdn.com/dark_nolabels/{z}/{x}/{y}{r}.png',
],
tileSize: 256,
attribution:
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> &copy; <a href="https://carto.com/attributions">CARTO</a>',
maxzoom: 19,
},
'carto-labels': CARTO_LABEL_SOURCE,
},
glyphs: CARTO_GLYPHS,
layers: [
{ id: 'bg-fill', type: 'background', paint: { 'background-color': '#080d14' } },
{ id: 'background', type: 'raster', source: 'carto-dark' },
{ id: 'background', type: 'raster', source: 'carto-dark', paint: MAP_RASTER_PAINT.dark },
...mapLabelLayers(MAP_LABEL_COLORS.dark),
],
};
@@ -57,19 +186,22 @@ export const MAP_STYLE_LIGHT: maplibregl.StyleSpecification = {
'carto-light': {
type: 'raster',
tiles: [
'https://a.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png',
'https://b.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png',
'https://c.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png',
'https://d.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png',
'https://a.basemaps.cartocdn.com/light_nolabels/{z}/{x}/{y}{r}.png',
'https://b.basemaps.cartocdn.com/light_nolabels/{z}/{x}/{y}{r}.png',
'https://c.basemaps.cartocdn.com/light_nolabels/{z}/{x}/{y}{r}.png',
'https://d.basemaps.cartocdn.com/light_nolabels/{z}/{x}/{y}{r}.png',
],
tileSize: 256,
attribution:
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> &copy; <a href="https://carto.com/attributions">CARTO</a>',
maxzoom: 19,
},
'carto-labels': CARTO_LABEL_SOURCE,
},
glyphs: CARTO_GLYPHS,
layers: [
{ id: 'bg-fill', type: 'background', paint: { 'background-color': '#e8e8e8' } },
{ id: 'background', type: 'raster', source: 'carto-light' },
{ id: 'bg-fill', type: 'background', paint: { 'background-color': '#edf2f7' } },
{ id: 'background', type: 'raster', source: 'carto-light', paint: MAP_RASTER_PAINT.light },
...mapLabelLayers(MAP_LABEL_COLORS.light),
],
};
@@ -0,0 +1,267 @@
import type maplibregl from 'maplibre-gl';
import { EMPTY_FC } from './mapConfig.js';
export function installMapSourcesAndLayers(
map: maplibregl.Map,
options: { showLinks: boolean },
): void {
const { showLinks } = options;
// ── Node dots source + layer ───────────────────────────────────────────
map.addSource('nodes', { type: 'geojson', data: EMPTY_FC });
map.addLayer({
id: 'node-dots',
type: 'circle',
source: 'nodes',
filter: ['==', ['get', 'visible'], true],
paint: {
'circle-radius': [
'interpolate', ['linear'], ['zoom'],
6, 3, 9, 4, 11, 5, 13, 7, 16, 9,
],
'circle-color': [
'case',
['==', ['get', 'hex_clash_state'], 'offender'], '#ef4444',
['==', ['get', 'hex_clash_state'], 'relay'], '#22c55e',
['get', 'replay_active'], '#fbbf24',
['get', 'is_link_only_stale'], '#4b5563',
['get', 'is_inferred'], '#7dd3fc',
['get', 'is_stale'], '#6b7280',
['!', ['get', 'is_online']], '#6b7280',
['==', ['get', 'role'], 1], '#ff9f43',
['==', ['get', 'role'], 3], '#a78bfa',
['==', ['get', 'role'], 4], '#34d399',
'#00c4ff', // repeater (role 2 / default)
],
'circle-opacity': [
'case',
['all', ['get', 'replay_mode'], ['!', ['get', 'replay_active']]], 0.12,
['get', 'is_link_only_stale'], 0.22,
['get', 'is_stale'], 0.4,
['!', ['get', 'is_online']], 0.4,
['get', 'is_inferred'], 0.7,
1.0,
],
'circle-stroke-width': 0,
'circle-stroke-color': '#00c4ff',
'circle-stroke-opacity': 0.7,
},
});
// ── Selected-node highlight (recolour + ring) ──────────────────────────
// Two circle layers over node-dots, filtered to the selected node id.
// A soft halo underneath, a bright solid marker on top.
map.addLayer({
id: 'node-dots-selected-halo',
type: 'circle',
source: 'nodes',
filter: ['==', ['get', 'node_id'], '__none__'],
paint: {
'circle-radius': [
'interpolate', ['linear'], ['zoom'],
6, 11, 9, 13, 11, 15, 13, 18, 16, 22,
],
'circle-color': '#22e0ff',
'circle-opacity': 0.16,
'circle-blur': 0.5,
},
});
map.addLayer({
id: 'node-dots-selected',
type: 'circle',
source: 'nodes',
filter: ['==', ['get', 'node_id'], '__none__'],
paint: {
'circle-radius': [
'interpolate', ['linear'], ['zoom'],
6, 5, 9, 6.5, 11, 8, 13, 10, 16, 13,
],
'circle-color': '#8af4ff',
'circle-opacity': 1,
'circle-stroke-color': '#ffffff',
'circle-stroke-width': 2.5,
'circle-stroke-opacity': 0.95,
},
});
// ── Privacy rings source + layer ───────────────────────────────────────
map.addSource('privacy-rings', { type: 'geojson', data: EMPTY_FC });
map.addLayer({
id: 'privacy-rings-layer',
type: 'line',
source: 'privacy-rings',
paint: {
'line-color': '#f59e0b',
'line-width': 1.4,
'line-opacity': 0.55,
'line-dasharray': [4, 6],
},
});
// ── Viable links source + layer ───────────────────────────────────────
map.addSource('viable-links', { type: 'geojson', data: EMPTY_FC });
map.addLayer({
id: 'viable-links-layer',
type: 'line',
source: 'viable-links',
layout: {
visibility: 'none',
'line-cap': 'round',
'line-join': 'round',
},
paint: {
'line-color': ['get', 'color'],
'line-width': ['get', 'width'],
'line-opacity': ['get', 'opacity'],
},
});
// ── Coverage source + layer ────────────────────────────────────────────
map.addSource('coverage', { type: 'geojson', data: EMPTY_FC });
map.addLayer({
id: 'coverage-fill',
type: 'fill',
source: 'coverage',
layout: { visibility: 'none' },
paint: {
'fill-color': [
'match', ['get', 'band'],
'green', '#22c55e',
'amber', '#fbbf24',
'red', '#ef4444',
'#22c55e',
],
'fill-opacity': [
'match', ['get', 'band'],
'green', 0.22,
'amber', 0.16,
'red', 0.10,
0.18,
],
},
});
// ── Clash lines source + layer ─────────────────────────────────────────
map.addSource('clash-lines', { type: 'geojson', data: EMPTY_FC });
map.addLayer({
id: 'clash-lines-layer',
type: 'line',
source: 'clash-lines',
layout: { visibility: 'none' },
paint: {
'line-color': '#f97316',
'line-width': 2.2,
'line-opacity': 0.9,
},
});
// ── Planned coverage source + layers ──────────────────────────────────
map.addSource('planned-coverage', { type: 'geojson', data: EMPTY_FC });
map.addLayer({
id: 'planned-coverage-fill',
type: 'fill',
source: 'planned-coverage',
paint: {
'fill-color': [
'match', ['get', 'band'],
'green', '#2dd4bf', // teal-400
'amber', '#818cf8', // indigo-400
'red', '#c084fc', // purple-400
'#2dd4bf',
],
'fill-opacity': [
'match', ['get', 'band'],
'green', 0.30,
'amber', 0.25,
'red', 0.20,
0.25,
],
},
});
map.addLayer({
id: 'planned-coverage-outline',
type: 'line',
source: 'planned-coverage',
paint: {
'line-color': '#22d3ee', // cyan-400
'line-width': 1.5,
'line-opacity': 0.6,
},
});
// ── Predicted planned-repeater links source + layer ───────────────────
// Dashed lines (coloured by predicted path loss) so they read as
// hypothetical, distinct from the solid observed-link lines. Visibility
// follows the global Links toggle.
map.addSource('planned-links', { type: 'geojson', data: EMPTY_FC });
map.addLayer({
id: 'planned-links-layer',
type: 'line',
source: 'planned-links',
layout: {
visibility: showLinks ? 'visible' : 'none',
'line-cap': 'round',
'line-join': 'round',
},
paint: {
'line-color': ['get', 'color'],
'line-width': ['get', 'width'],
'line-opacity': 0.9,
'line-dasharray': [2, 1.5],
},
});
// ── Planned repeater pins source + layers ──────────────────────────────
// Styled to match real repeater nodes (role 2, #00c4ff) but visually
// distinct via a white stroke and glow halo. Shared map labels and glyphs
// belong to the base style; planned status text stays in the popup so
// temporary planning markers do not add clutter to the map.
map.addSource('planned-pins', { type: 'geojson', data: EMPTY_FC });
// Halo: soft glow behind the pin
map.addLayer({
id: 'planned-pins-halo',
type: 'circle',
source: 'planned-pins',
paint: {
'circle-radius': [
'interpolate', ['linear'], ['zoom'],
6, 8, 9, 11, 11, 14, 13, 18, 16, 22,
],
'circle-color': '#22d3ee',
'circle-opacity': [
'match', ['get', 'status'],
'ready', 0.20,
0.10,
],
'circle-stroke-width': 0,
},
});
// Core dot: same size/colour as a real online repeater, white stroke to mark as planned
map.addLayer({
id: 'planned-pins-dot',
type: 'circle',
source: 'planned-pins',
paint: {
'circle-radius': [
'interpolate', ['linear'], ['zoom'],
6, 3, 9, 4, 11, 5, 13, 7, 16, 9,
],
'circle-color': [
'match', ['get', 'status'],
'ready', '#00c4ff', // identical to real online repeater
'#4b5563', // dark grey while computing
],
'circle-opacity': [
'match', ['get', 'status'],
'ready', 1.0,
0.6,
],
'circle-stroke-color': '#ffffff',
'circle-stroke-width': 2,
'circle-stroke-opacity': 0.95,
},
});
}
+37 -11
View File
@@ -52,28 +52,54 @@ const PacketFeedItem: React.FC<PacketFeedItemProps> = React.memo(({
onClick={() => onTogglePacket(p)}
role="button"
tabIndex={0}
onKeyDown={(event) => event.key === 'Enter' && onTogglePacket(p)}
aria-pressed={isPinned}
aria-label={`${isPinned ? 'Unpin' : 'Pin'} ${typeLabel} packet${display ? `: ${display}` : ''}`}
onKeyDown={(event) => {
if (event.key !== 'Enter' && event.key !== ' ') return;
event.preventDefault();
onTogglePacket(p);
}}
>
{observerIata && <span className="packet-item__iata">{observerIata}</span>}
{p.pathHashSizeBytes !== undefined && p.pathHashSizeBytes > 0 && <span className="packet-item__path-bytes">{p.pathHashSizeBytes}</span>}
<span className="packet-item__type">{typeLabel}</span>
{advertBadge && <span className="packet-item__advert-badge">{advertBadge}</span>}
<span className={`packet-item__summary${display ? '' : ' packet-item__summary--empty'}`}>{display ?? '\u00A0'}</span>
{p.hopCount !== undefined && p.hopCount > 0 && <span className="packet-item__hops">{p.hopCount}</span>}
<span className="packet-item__counts">
<span
className={'packet-item__iata' + (observerIata ? '' : ' packet-item__placeholder')}
aria-hidden={!observerIata}
title={observerIata}
>{observerIata ?? ''}</span>
<span
className={'packet-item__path-bytes' + (p.pathHashSizeBytes !== undefined && p.pathHashSizeBytes > 0 ? '' : ' packet-item__placeholder')}
aria-hidden={p.pathHashSizeBytes === undefined || p.pathHashSizeBytes <= 0}
title={p.pathHashSizeBytes !== undefined && p.pathHashSizeBytes > 0 ? String(p.pathHashSizeBytes) + ' path bytes' : undefined}
>{p.pathHashSizeBytes !== undefined && p.pathHashSizeBytes > 0 ? p.pathHashSizeBytes : '—'}</span>
<span className="packet-item__type" title={typeLabel}>{typeLabel}</span>
<span
className={'packet-item__advert-badge' + (advertBadge ? '' : ' packet-item__placeholder')}
aria-hidden={!advertBadge}
>{advertBadge ?? '—'}</span>
<span
className={'packet-item__summary' + (display ? '' : ' packet-item__summary--empty')}
title={display ?? undefined}
>{display ?? '—'}</span>
<span
className={'packet-item__hops' + (p.hopCount !== undefined && p.hopCount > 0 ? '' : ' packet-item__placeholder')}
aria-hidden={p.hopCount === undefined || p.hopCount <= 0}
>{p.hopCount !== undefined && p.hopCount > 0 ? '↑' + p.hopCount : '—'}</span>
<span className="packet-item__counts" aria-hidden={p.observerIds.length === 0 && p.txCount <= 0}>
{p.observerIds.length > 0 && <span className="count count--rx">{p.observerIds.length}rx</span>}
{p.txCount > 0 && <span className="count count--tx">{p.txCount}tx</span>}
</span>
<button
type="button"
className="packet-item__watch"
aria-label={`${isWatched ? 'Stop watching' : 'Watch'} ${typeLabel} packets`}
aria-label={ (isWatched ? 'Stop watching' : 'Watch') + ' ' + typeLabel + ' packets' }
onClick={(event) => {
event.stopPropagation();
onToggleWatch('packet_type', packetTypeId, `${typeLabel} packets`);
onToggleWatch('packet_type', packetTypeId, typeLabel + ' packets');
}}
>{isWatched ? '★' : '☆'}</button>
{isPinned && <span className="packet-item__pin"></span>}
<span
className={'packet-item__pin' + (isPinned ? '' : ' packet-item__placeholder')}
aria-hidden={!isPinned}
></span>
</div>
);
});
+8 -15
View File
@@ -10,8 +10,6 @@ type AppTopBarProps = {
stats: DashboardStats;
mapLight: boolean;
onToggleMapTheme: () => void;
network: string;
onNetworkChange: (network: string) => void;
annotation: string;
onEditAnnotation: () => void;
onShowShortcuts: () => void;
@@ -47,8 +45,6 @@ export const AppTopBar: React.FC<AppTopBarProps> = ({
stats,
mapLight,
onToggleMapTheme,
network,
onNetworkChange,
annotation,
onEditAnnotation,
onShowShortcuts,
@@ -67,29 +63,26 @@ export const AppTopBar: React.FC<AppTopBarProps> = ({
</div>
<div className="topbar__divider" />
<ConnIndicator state={wsState} />
<select
className="topbar__network"
value={network}
onChange={(event) => onNetworkChange(event.target.value)}
aria-label="Network region"
title="Switch network"
<button
type="button"
className={'topbar__tool-btn' + (mapLight ? ' topbar__tool-btn--active' : '')}
onClick={onToggleMapTheme}
aria-pressed={mapLight}
title="Toggle map theme"
>
<option value="ukmesh">UK Mesh</option>
<option value="teesside">Teesside</option>
</select>
<button type="button" className="topbar__tool-btn" onClick={onToggleMapTheme} title="Toggle map theme">
{mapLight ? '☀ Light' : '☾ Dark'}
</button>
<button type="button" className={`topbar__tool-btn${highContrast ? ' topbar__tool-btn--active' : ''}`} onClick={onToggleContrast} aria-pressed={highContrast} title="Toggle high contrast">
Contrast
</button>
<button type="button" className={`topbar__tool-btn${annotation ? ' topbar__tool-btn--active' : ''}`} onClick={onEditAnnotation} title="Add a shareable note">
<button type="button" className={`topbar__tool-btn${annotation ? ' topbar__tool-btn--active' : ''}`} onClick={onEditAnnotation} aria-pressed={Boolean(annotation)} title="Add a shareable note">
Note
</button>
<button type="button" className="topbar__tool-btn topbar__shortcut-btn" onClick={onShowShortcuts} title="Keyboard shortcuts">
?
</button>
<button
type="button"
className="topbar__info-btn"
onClick={onShowDisclaimer}
title="Data disclaimer"
@@ -72,7 +72,7 @@ export const TimelineControl: React.FC<Props> = ({ network, observer }) => {
return (
<section className={`timeline-control${open ? ' timeline-control--open' : ''}`} aria-label="Network activity replay">
<button type="button" className="timeline-control__toggle" onClick={() => setOpen((value) => !value)} aria-expanded={open}>
<span>Activity replay</span><strong>{label}</strong>
<span className="timeline-control__name"><span aria-hidden="true"></span> Activity replay</span><strong>{label}</strong>
</button>
{open && (
<div className="timeline-control__body">
+215 -42
View File
@@ -1,46 +1,160 @@
import React, { useEffect, useState } from 'react';
import { LoadingIndicator } from '../components/LoadingIndicator.js';
import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
import { useRuntimeFeatures } from '../config/runtimeFeatures.js';
import { getCurrentSite } from '../config/site.js';
import { useVisibilityPoll } from '../hooks/useVisibilityPoll.js';
import { fetchJson, withScopeParams } from '../utils/api.js';
import './network-intelligence.css';
type HealthPayload = {
type PublicHealthPayload = {
status: 'healthy' | 'degraded' | 'critical';
problems: Array<{ code: string; severity: 'warning' | 'critical'; message: string }>;
generatedAt: string;
maintenance: { active: boolean; message: string | null };
ingest: { active_nodes: number; stale_nodes: number; global_last_packet_at: string | null; packet_age_minutes: number | null };
operational_checks: Array<{ check_name: string; status: string; latency_ms: number; detail: string | null; ts: string }>;
database: { size_bytes: number; dead_rows: number; oldest_vacuum_at: string | null; tables_needing_vacuum: number; connection_count: number; max_connections: number; cache_hit_ratio: number };
workers: Array<{ worker_name: string; status: string; queue_depth: number; processed_1h?: number; last_activity_at: string | null }>;
incidents: Array<{ code: string; severity: 'warning' | 'critical' }>;
components: {
ingest: { status: string };
workers: { status: string };
storage: { status: string };
};
};
type FirmwarePayload = { total: number; versions: Array<{ hardware_model: string; firmware_version: string; count: number }> };
type FirmwareDistributionRow = {
firmware_version: string;
count: number;
hardware_models: string[];
};
function formatBytes(value: number): string {
if (!Number.isFinite(value) || value <= 0) return 'Unknown';
return `${(value / 1_073_741_824).toFixed(1)} GB`;
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function hasStringStatus(value: unknown): value is { status: string } {
return isRecord(value) && typeof value['status'] === 'string';
}
function isPublicHealthPayload(value: unknown): value is PublicHealthPayload {
if (!isRecord(value) || !isRecord(value['maintenance']) || !isRecord(value['components'])) return false;
const components = value['components'];
return isRecord(value)
&& (value['status'] === 'healthy' || value['status'] === 'degraded' || value['status'] === 'critical')
&& typeof value['generatedAt'] === 'string'
&& typeof value['maintenance']['active'] === 'boolean'
&& (typeof value['maintenance']['message'] === 'string' || value['maintenance']['message'] === null)
&& Array.isArray(value['incidents'])
&& value['incidents'].every((incident) => (
isRecord(incident)
&& typeof incident['code'] === 'string'
&& (incident['severity'] === 'warning' || incident['severity'] === 'critical')
))
&& hasStringStatus(components['ingest'])
&& hasStringStatus(components['workers'])
&& hasStringStatus(components['storage']);
}
function isFirmwarePayload(value: unknown): value is FirmwarePayload {
return isRecord(value)
&& typeof value['total'] === 'number'
&& Array.isArray(value['versions']);
}
function aggregateFirmwareVersions(versions: FirmwarePayload['versions']): FirmwareDistributionRow[] {
const byVersion = new Map<string, FirmwareDistributionRow>();
for (const row of versions) {
const firmwareVersion = row.firmware_version || 'Unknown';
const existing = byVersion.get(firmwareVersion);
if (existing) {
existing.count += row.count;
if (!existing.hardware_models.includes(row.hardware_model)) {
existing.hardware_models.push(row.hardware_model);
}
continue;
}
byVersion.set(firmwareVersion, {
firmware_version: firmwareVersion,
count: row.count,
hardware_models: [row.hardware_model],
});
}
return Array.from(byVersion.values());
}
function isUnknownFirmwareVersion(version: string): boolean {
return version.toLowerCase() === 'unknown';
}
function statusPresentation(status: string): { dot: string; label: string } {
switch (status) {
case 'ok': return { dot: 'ok', label: 'Operational' };
case 'running': return { dot: 'ok', label: 'Running' };
case 'idle': return { dot: 'ok', label: 'Ready' };
case 'warning': return { dot: 'warning', label: 'Needs attention' };
case 'stale': return { dot: 'warning', label: 'Delayed' };
case 'critical': return { dot: 'critical', label: 'Critical' };
case 'failed': return { dot: 'critical', label: 'Failed' };
default: return { dot: 'unknown', label: 'Checking' };
}
}
function reportTime(generatedAt: string): string {
const value = new Date(generatedAt);
return Number.isNaN(value.getTime())
? 'just now'
: value.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
export const StatusPage: React.FC = () => {
const [data, setData] = useState<HealthPayload | null>(null);
const site = getCurrentSite();
const network = site.networkFilter ?? site.network;
const observer = site.observerId;
const { privacyGeneration } = useRuntimeFeatures();
const [data, setData] = useState<PublicHealthPayload | null>(null);
const [error, setError] = useState(false);
const [firmware, setFirmware] = useState<FirmwarePayload | null>(null);
const [includeUnknownFirmware, setIncludeUnknownFirmware] = useState(false);
useEffect(() => {
let active = true;
const load = () => fetch('/api/health', { cache: 'no-store' })
.then((response) => response.ok ? response.json() as Promise<HealthPayload> : Promise.reject(new Error('health unavailable')))
.then((next) => { if (active) { setData(next); setError(false); } })
.catch(() => { if (active) setError(true); });
void load();
const timer = window.setInterval(() => void load(), 60_000);
return () => { active = false; window.clearInterval(timer); };
}, []);
useEffect(() => {
const controller = new AbortController();
fetch('/api/repeaters/firmware', { signal: controller.signal })
.then((response) => response.ok ? response.json() as Promise<FirmwarePayload> : null)
.then(setFirmware)
.catch(() => {});
return () => controller.abort();
}, []);
setData(null);
setFirmware(null);
setError(false);
setIncludeUnknownFirmware(false);
}, [network, observer, privacyGeneration]);
useVisibilityPoll(async (signal) => {
const next = await fetchJson<PublicHealthPayload>(
withScopeParams('/api/health', { network, observer }),
{ cache: 'no-store', signal },
{ timeoutMs: 15_000, maxBytes: 2 * 1024 * 1024, validate: isPublicHealthPayload },
);
if (signal.aborted) return;
setData(next);
setError(false);
}, {
scopeKey: `platform-health:${network}:${observer ?? 'all'}:${privacyGeneration}`,
intervalMs: 60_000,
timeoutMs: 15_000,
onError: () => setError(true),
});
useVisibilityPoll(async (signal) => {
const next = await fetchJson<FirmwarePayload>(
withScopeParams('/api/repeaters/firmware', { network, observer }),
{ cache: 'no-store', signal },
{ timeoutMs: 15_000, maxBytes: 2 * 1024 * 1024, validate: isFirmwarePayload },
);
if (!signal.aborted) setFirmware(next);
}, {
scopeKey: `firmware-health:${network}:${observer ?? 'all'}:${privacyGeneration}`,
intervalMs: 10 * 60_000,
timeoutMs: 15_000,
});
const firmwareDistribution = firmware ? aggregateFirmwareVersions(firmware.versions) : [];
const unknownFirmware = firmwareDistribution.find((row) => isUnknownFirmwareVersion(row.firmware_version));
const visibleFirmwareDistribution = includeUnknownFirmware
? firmwareDistribution
: firmwareDistribution.filter((row) => !isUnknownFirmwareVersion(row.firmware_version));
const xAxisInterval = Math.max(0, Math.ceil(visibleFirmwareDistribution.length / 8) - 1);
return (
<div className="status-page site-content">
@@ -51,28 +165,87 @@ export const StatusPage: React.FC = () => {
<>
<div className={`status-page__banner status-page__banner--${data.status}`} role="status">
<strong>{data.status === 'healthy' ? 'All monitored systems operational' : `Platform ${data.status}`}</strong>
<span>Updated {new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</span>
<span>Updated {reportTime(data.generatedAt)}</span>
</div>
{data.maintenance.active && <div className="status-page__maintenance"><strong>Planned maintenance</strong><span>{data.maintenance.message ?? 'Maintenance is currently in progress.'}</span></div>}
{data.problems.length > 0 && <section className="status-page__problems"><h2>Current notices</h2>{data.problems.map((problem) => <article key={`${problem.code}:${problem.message}`} className={`status-page__problem status-page__problem--${problem.severity}`}><strong>{problem.code.replace(/_/g, ' ')}</strong><span>{problem.message}</span></article>)}</section>}
{data.incidents.length > 0 && <section className="status-page__problems"><h2>Current notices</h2>{data.incidents.map((incident) => <article key={`${incident.code}:${incident.severity}`} className={`status-page__problem status-page__problem--${incident.severity}`}><strong>{incident.code.replace(/_/g, ' ')}</strong><span>{incident.severity === 'critical' ? 'A monitored service is disrupted.' : 'A monitored service needs attention.'}</span></article>)}</section>}
<div className="status-page__grid">
<section><h2>Public ingest</h2><dl><div><dt>Active observer nodes</dt><dd>{data.ingest.active_nodes}</dd></div><div><dt>Stale observers</dt><dd>{data.ingest.stale_nodes}</dd></div><div><dt>Latest packet age</dt><dd>{data.ingest.packet_age_minutes == null ? 'Unknown' : `${data.ingest.packet_age_minutes} min`}</dd></div></dl></section>
<section><h2>Synthetic journeys</h2><div className="status-page__checks">{data.operational_checks.length === 0 ? <p>Monitoring is starting.</p> : data.operational_checks.map((check) => <div key={check.check_name}><span className={`status-page__dot status-page__dot--${check.status}`} /><strong>{check.check_name.replace(/_/g, ' ')}</strong><small>{check.latency_ms} ms</small></div>)}</div></section>
<section><h2>Background workers</h2><div className="status-page__checks">{data.workers.map((worker) => <div key={worker.worker_name}><span className={`status-page__dot status-page__dot--${worker.status === 'running' || worker.status === 'completed' || worker.status === 'idle' ? 'ok' : worker.status}`} /><strong>{worker.worker_name}</strong><small>{worker.queue_depth} queued · {worker.processed_1h ?? 0}/h · {worker.last_activity_at ? `seen ${Math.max(0, Math.floor((Date.now() - Date.parse(worker.last_activity_at)) / 60_000))}m ago` : 'no activity'}</small></div>)}</div></section>
<section><h2>Database</h2><dl><div><dt>Disk footprint</dt><dd>{formatBytes(data.database.size_bytes)}</dd></div><div><dt>Connections</dt><dd>{data.database.connection_count}/{data.database.max_connections}</dd></div><div><dt>Cache hit ratio</dt><dd>{data.database.cache_hit_ratio.toFixed(2)}%</dd></div><div><dt>Vacuum attention</dt><dd>{data.database.tables_needing_vacuum} tables</dd></div></dl></section>
{([
['Public ingest', 'Packet intake and freshness', data.components.ingest.status],
['Background workers', 'Scheduled processing services', data.components.workers.status],
['Storage', 'Durable platform storage', data.components.storage.status],
] as const).map(([title, description, status]) => {
const presentation = statusPresentation(status);
return (
<section key={title}>
<h2>{title}</h2>
<div className="status-page__checks">
<div>
<span className={`status-page__dot status-page__dot--${presentation.dot}`} />
<strong>{presentation.label}</strong>
<small>{description}</small>
</div>
</div>
</section>
);
})}
</div>
<section className="status-page__firmware">
<h2>Repeater firmware distribution</h2>
{firmware && firmware.versions.length > 0 ? (
<ResponsiveContainer width="100%" height={300}>
<BarChart data={firmware.versions.slice(0, 16)} margin={{ left: 8, right: 8, bottom: 70 }}>
<CartesianGrid stroke="rgba(255,255,255,.1)" />
<XAxis dataKey="firmware_version" angle={-35} textAnchor="end" interval={0} height={80} />
<YAxis allowDecimals={false} />
<Tooltip formatter={(value: number, _name, item) => [value, item.payload.hardware_model]} />
<Bar dataKey="count" fill="var(--color-primary)" />
</BarChart>
</ResponsiveContainer>
<>
{unknownFirmware && (
<p style={{ margin: '0 0 12px', color: 'var(--text-secondary)', fontSize: '12px' }}>
<label style={{ display: 'inline-flex', alignItems: 'center', gap: '8px', cursor: 'pointer' }}>
<input
type="checkbox"
checked={includeUnknownFirmware}
onChange={(event) => setIncludeUnknownFirmware(event.target.checked)}
style={{ accentColor: 'var(--accent)' }}
/>
<span>Include Unknown ({unknownFirmware.count.toLocaleString()})</span>
</label>
</p>
)}
{visibleFirmwareDistribution.length > 0 ? (
<>
<ResponsiveContainer width="100%" height={320}>
<BarChart data={visibleFirmwareDistribution} margin={{ left: 8, right: 8, bottom: 88 }}>
<CartesianGrid stroke="rgba(255,255,255,.1)" />
<XAxis
dataKey="firmware_version"
angle={-45}
textAnchor="end"
interval={xAxisInterval}
height={96}
minTickGap={10}
tick={{ fill: 'var(--text-secondary)', fontSize: 11 }}
/>
<YAxis allowDecimals={false} />
<Tooltip formatter={(value: number) => [value.toLocaleString(), 'Repeaters']} />
<Bar dataKey="count" name="Repeaters" fill="var(--color-primary)" />
</BarChart>
</ResponsiveContainer>
<div className="ui-visually-hidden">
<table>
<caption>Repeater firmware distribution{includeUnknownFirmware ? '' : ' (Unknown excluded)'}</caption>
<thead><tr><th>Firmware</th><th>Hardware models</th><th>Repeaters</th></tr></thead>
<tbody>
{visibleFirmwareDistribution.map((row) => (
<tr key={row.firmware_version}>
<td>{row.firmware_version}</td>
<td>{row.hardware_models.join(', ')}</td>
<td>{row.count}</td>
</tr>
))}
</tbody>
</table>
</div>
</>
) : (
<p role="status">Only repeaters with unknown firmware were reported. Select "Include Unknown" to show that bucket.</p>
)}
</>
) : <p>Firmware telemetry is not yet available.</p>}
</section>
<p className="status-page__privacy">Status values are deliberately aggregated. Hostnames, addresses, credentials, and private node identities are never included.</p>
+70 -17
View File
@@ -2,7 +2,10 @@ import React, { useEffect, useMemo, useRef, useState } from 'react';
import { forceCenter, forceCollide, forceLink, forceManyBody, forceSimulation, type Simulation, type SimulationNodeDatum } from 'd3-force';
import { LoadingIndicator } from '../components/LoadingIndicator.js';
import { getCurrentSite } from '../config/site.js';
import { withScopeParams } from '../utils/api.js';
import { useRuntimeFeatures } from '../config/runtimeFeatures.js';
import { fetchJson, withScopeParams } from '../utils/api.js';
import { createFrameSnapshotScheduler } from '../utils/frameSnapshotScheduler.js';
import { filterTopologyLinks } from './topologyModel.js';
import './network-intelligence.css';
type TopologyNode = {
@@ -48,6 +51,7 @@ type RfValidationPayload = {
};
type PlotNode = TopologyNode & SimulationNodeDatum & { x: number; y: number };
const TOPOLOGY_SNAPSHOT_INTERVAL_MS = 66;
function compactNumber(value: number): string {
return new Intl.NumberFormat('en-GB', { notation: 'compact', maximumFractionDigits: 1 }).format(value);
@@ -55,6 +59,9 @@ function compactNumber(value: number): string {
export const TopologyPage: React.FC = () => {
const site = getCurrentSite();
const { privacyGeneration } = useRuntimeFeatures();
const network = site.networkFilter ?? site.network;
const observer = site.observerId;
const [payload, setPayload] = useState<TopologyPayload | null>(null);
const [error, setError] = useState<string | null>(null);
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
@@ -66,26 +73,33 @@ export const TopologyPage: React.FC = () => {
useEffect(() => {
const controller = new AbortController();
fetch(withScopeParams('/api/topology?limit=300', { network: site.networkFilter }), { signal: controller.signal })
.then(async (response) => {
if (!response.ok) throw new Error(`Topology request failed (${response.status})`);
return response.json() as Promise<TopologyPayload>;
})
setPayload(null);
setError(null);
setSelectedNodeId(null);
fetchJson<TopologyPayload>(
withScopeParams('/api/topology?limit=300', { network, observer }),
{ signal: controller.signal, cache: 'no-store' },
{ timeoutMs: 15_000, maxBytes: 8 * 1024 * 1024 },
)
.then(setPayload)
.catch((reason: unknown) => {
if ((reason as DOMException).name !== 'AbortError') setError((reason as Error).message);
});
return () => controller.abort();
}, [site.networkFilter]);
}, [network, observer, privacyGeneration]);
useEffect(() => {
const controller = new AbortController();
fetch(withScopeParams('/api/rf-validation?limit=100', { network: site.networkFilter }), { signal: controller.signal })
.then((response) => response.ok ? response.json() as Promise<RfValidationPayload> : Promise.reject(new Error('RF validation unavailable')))
setRfValidation(null);
fetchJson<RfValidationPayload>(
withScopeParams('/api/rf-validation?limit=100', { network, observer }),
{ signal: controller.signal, cache: 'no-store' },
{ timeoutMs: 15_000, maxBytes: 4 * 1024 * 1024 },
)
.then(setRfValidation)
.catch(() => setRfValidation(null));
return () => controller.abort();
}, [site.networkFilter]);
}, [network, observer, privacyGeneration]);
useEffect(() => {
const located = (payload?.nodes ?? []).filter(
@@ -108,17 +122,47 @@ export const TopologyPage: React.FC = () => {
x: 40 + ((node.lon - minLon) / lonSpan) * 920,
y: 560 - ((node.lat - minLat) / latSpan) * 520,
}));
const simulationLinks = filterTopologyLinks(
nodes.map((node) => node.nodeId),
payload?.links ?? [],
);
setPlotNodes(nodes.map((node) => ({ ...node })));
const snapshotScheduler = createFrameSnapshotScheduler({
minIntervalMs: TOPOLOGY_SNAPSHOT_INTERVAL_MS,
isVisible: () => document.visibilityState === 'visible',
emit: () => {
setPlotNodes(nodes.map((node) => ({
...node,
x: node.x ?? 500,
y: node.y ?? 300,
})));
},
});
const simulation = forceSimulation(nodes)
.force('charge', forceManyBody().strength(-28))
.force('center', forceCenter(500, 300).strength(0.035))
.force('collision', forceCollide<PlotNode>().radius((node) => Math.min(14, 5 + Math.sqrt(node.degree))))
.force('links', forceLink<PlotNode, { source: string | PlotNode; target: string | PlotNode }>(
(payload?.links ?? []).map((link) => ({ source: link.source, target: link.target })),
simulationLinks.map((link) => ({ source: link.source, target: link.target })),
).id((node) => node.nodeId).distance(40).strength(0.08))
.alpha(0.5)
.on('tick', () => setPlotNodes(nodes.map((node) => ({ ...node, x: node.x ?? 500, y: node.y ?? 300 }))));
.on('tick', () => snapshotScheduler.noteMutation());
simulationRef.current = simulation;
return () => { simulation.stop(); simulationRef.current = null; };
const onVisibilityChange = () => {
if (document.visibilityState === 'visible') {
simulation.restart();
snapshotScheduler.noteMutation();
} else {
simulation.stop();
}
};
document.addEventListener('visibilitychange', onVisibilityChange);
return () => {
document.removeEventListener('visibilitychange', onVisibilityChange);
snapshotScheduler.stop();
simulation.stop();
simulationRef.current = null;
};
}, [payload]);
const plot = useMemo(() => {
@@ -152,8 +196,13 @@ export const TopologyPage: React.FC = () => {
<input type="checkbox" checked={strongOnly} onChange={(event) => setStrongOnly(event.target.checked)} />
Multibyte evidence only
</label>
<label className="topology-page__toggle">Region
<select value={region} onChange={(event) => setRegion(event.target.value)}>
<label className="topology-page__toggle topology-page__region-toggle">Region
<select
className="topology-page__region-select"
aria-label="Filter topology by region"
value={region}
onChange={(event) => setRegion(event.target.value)}
>
<option value="all">All regions</option>
{regions.map((value) => <option key={value} value={value}>{value}</option>)}
</select>
@@ -258,8 +307,12 @@ export const TopologyPage: React.FC = () => {
<ol>
{payload.nodes.filter((node) => node.degree > 0).slice(0, 12).map((node) => (
<li key={node.nodeId}>
<button type="button" onClick={() => setSelectedNodeId(node.nodeId)}>
<span>{node.name ?? node.nodeId.slice(0, 10)}</span>
<button
type="button"
title={node.name ?? node.nodeId}
onClick={() => setSelectedNodeId(node.nodeId)}
>
<span title={node.name ?? node.nodeId}>{node.name ?? node.nodeId.slice(0, 10)}</span>
<strong>{node.degree}</strong>
</button>
</li>
+22 -8
View File
@@ -51,8 +51,12 @@
color: var(--text-secondary);
margin: 0 0 16px;
}
.prose-section a:not(.site-btn) { color: var(--accent); text-decoration: none; }
.prose-section a:not(.site-btn):hover { text-decoration: underline; }
.prose-section a:not(.site-btn) {
color: var(--accent);
text-decoration: underline;
text-underline-offset: 0.16em;
}
.prose-section a:not(.site-btn):hover { text-decoration-thickness: 2px; }
.prose-section ul, .prose-section ol {
padding-left: 24px;
margin: 0 0 16px;
@@ -142,7 +146,11 @@
border-radius: 8px;
padding: 16px 18px;
margin: 16px 0;
max-width: 100%;
min-width: 0;
overflow-x: auto;
white-space: pre;
-webkit-overflow-scrolling: touch;
}
.code-block pre {
margin: 0;
@@ -150,6 +158,8 @@
font-size: 13px;
line-height: 1.7;
color: #a8c8e8;
min-width: fit-content;
width: max-content;
white-space: pre;
}
@@ -166,6 +176,9 @@
border-radius: 8px;
padding: 16px 18px;
position: relative;
text-align: left;
cursor: pointer;
color: inherit;
}
.hw-card--recommended { border-color: rgba(0, 196, 255, 0.28); }
.hw-card__badge {
@@ -289,14 +302,11 @@
.health-kv span {
min-width: 0;
}
.health-kv strong,
.health-kv span {
overflow-wrap: anywhere;
}
.health-kv strong {
overflow-wrap: anywhere;
color: var(--text-primary);
font-family: var(--font-mono);
text-align: left;
@@ -381,8 +391,12 @@
.oss-banner__icon { font-size: 24px; flex-shrink: 0; margin-top: 2px; }
.oss-banner strong { color: var(--text-primary); display: block; margin-bottom: 6px; font-size: 15px; }
.oss-banner p { font-size: 14px; color: var(--text-secondary); margin: 0; line-height: 1.6; }
.oss-banner a:not(.site-btn) { color: var(--accent); text-decoration: none; }
.oss-banner a:not(.site-btn):hover { text-decoration: underline; }
.oss-banner a:not(.site-btn) {
color: var(--accent);
text-decoration: underline;
text-underline-offset: 0.16em;
}
.oss-banner a:not(.site-btn):hover { text-decoration-thickness: 2px; }
/* ── Packet type cards ──────────────────────────────────────────────────── */
.packet-grid {
+29 -7
View File
@@ -4,26 +4,44 @@
.topology-page__header p { max-width: 760px; color: var(--text-secondary); }
.topology-page__eyebrow { color: var(--accent) !important; font: 600 11px/1 var(--font-mono); letter-spacing: 0.13em; text-transform: uppercase; }
.topology-page__toggle { display: flex; align-items: center; gap: 8px; white-space: nowrap; color: var(--text-secondary); font-size: 12px; cursor: pointer; }
.topology-page__region-select {
appearance: none;
-webkit-appearance: none;
min-width: 148px;
padding: 8px 34px 8px 10px;
border: 1px solid rgba(107, 174, 210, 0.58);
border-radius: var(--radius);
background-color: #0d1828;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 14 14' fill='none' stroke='%23d6e8ff' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m3 5 4 4 4-4'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 10px center;
background-size: 14px;
color: #e8f4ff;
color-scheme: dark;
font: 12px var(--font-body);
cursor: pointer;
}
.topology-page__region-select:hover { border-color: var(--accent); background-color: #11243a; }
.topology-page__region-select:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.topology-page__region-select option { background: #0d1828; color: #e8f4ff; }
.topology-page__stats { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; margin-bottom: 12px; }
.topology-page__stats--six { grid-template-columns: repeat(3, 1fr); }
.topology-page__stats > div { display: flex; flex-direction: column; padding: 14px 16px; background: var(--bg-panel); border: 1px solid var(--border); border-radius: var(--radius-lg); }
.topology-page__stats strong { color: var(--text-primary); font: 600 22px/1.2 var(--font-mono); }
.topology-page__stats span { margin-top: 4px; color: var(--text-muted); font-size: 11px; }
.topology-page__workspace { display: grid; grid-template-columns: minmax(0, 1fr) 250px; gap: 12px; }
.topology-page__graph,
.topology-page__hubs { background: #07101d; border: 1px solid var(--border); border-radius: var(--radius-lg); }
.topology-page__graph { position: relative; min-height: 520px; overflow: hidden; }
.topology-page__graph { background: #07101d; border: 1px solid var(--border); border-radius: var(--radius-lg); position: relative; min-height: 520px; overflow: hidden; }
.topology-page__graph svg { display: block; width: 100%; height: 100%; min-height: 520px; }
.topology-page__link { stroke: #247390; opacity: 0.28; transition: opacity var(--transition), stroke var(--transition); }
.topology-page__link { stroke: #70d7ef; opacity: 0.62; transition: opacity var(--transition), stroke var(--transition); }
.topology-page__link--active { stroke: #22d3ee; opacity: 0.9; }
.topology-page__node { fill: #7dd3fc; stroke: #06101c; stroke-width: 1.5; cursor: pointer; transition: fill var(--transition), stroke var(--transition); }
.topology-page__node { fill: #a9eaff; stroke: #d9f7ff; stroke-width: 1.8; filter: drop-shadow(0 0 3px rgba(91, 211, 245, 0.42)); cursor: pointer; transition: fill var(--transition), stroke var(--transition); }
.topology-page__node:hover,
.topology-page__node:focus,
.topology-page__node--active { fill: #f8fafc; stroke: #22d3ee; outline: none; }
.topology-page__node--bridge { stroke: #f59e0b; stroke-width: 2.5; }
.topology-page__node--isolated { fill: transparent; stroke: #94a3b8; stroke-dasharray: 2 1; }
.topology-page__node--isolated { fill: transparent; stroke: #d7e8f7; stroke-width: 2.2; stroke-dasharray: 2 1; filter: drop-shadow(0 0 4px rgba(215, 232, 247, 0.45)); }
.topology-page__legend { position: absolute; left: 12px; bottom: 10px; padding: 5px 8px; border-radius: var(--radius); background: rgba(2, 8, 16, 0.82); color: var(--text-muted); font: 10px/1.2 var(--font-mono); }
.topology-page__hubs { padding: 18px; }
.topology-page__hubs { background: #07101d; border: 1px solid var(--border); border-radius: var(--radius-lg); padding: 18px; }
.topology-page__hubs h2 { margin: 0 0 12px; font-size: 15px; }
.topology-page__hubs ol { margin: 0; padding: 0; list-style: none; counter-reset: topology-hub; }
.topology-page__hubs li { counter-increment: topology-hub; }
@@ -81,6 +99,10 @@
.status-page__checks small { color: var(--text-muted); }
.status-page__dot { width: 8px; height: 8px; border-radius: 50%; background: var(--text-muted); }
.status-page__dot--ok { background: var(--online); box-shadow: 0 0 8px rgba(0, 230, 118, 0.45); }
.status-page__dot--warning,
.status-page__dot--stale,
.status-page__dot--unknown { background: var(--amber); }
.status-page__dot--critical,
.status-page__dot--failed,
.status-page__dot--disabled { background: var(--danger); }
.status-page__privacy { margin-top: 16px; color: var(--text-muted); font-size: 10px; }
+11 -3
View File
@@ -1,5 +1,5 @@
import React, { useEffect, useRef, useState } from 'react';
import { Link, NavLink, Outlet } from 'react-router-dom';
import { Link, NavLink, Outlet } from 'react-router';
type SiteLayoutProps = {
brandName: string;
@@ -182,7 +182,15 @@ export const SiteLayout: React.FC<SiteLayoutProps> = ({
id="site-navigation"
className={`site-nav__links${menuOpen ? ' site-nav__links--open' : ''}`}
>
{showLiveMap && <a href={appUrl} className="site-nav__link site-nav__link--map">Live Map </a>}
{showLiveMap && (
<a
href={appUrl}
className="site-nav__link site-nav__link--map site-nav__link--external"
aria-label="Live Map (external app)"
>
Live Map <span className="site-nav__external-icon" aria-hidden="true"></span>
</a>
)}
{navItems.filter((item) => item.enabled).map((item) => (
<NavLink
key={item.to}
@@ -243,7 +251,7 @@ export const SiteLayout: React.FC<SiteLayoutProps> = ({
<strong>Cookies, sadly.</strong>
<p>We only use them for the boring useful bits, like keeping logins alive and remembering site choices. No secret biscuit syndicate.</p>
</div>
<button className="cookie-banner__button" onClick={acceptCookies}>Accept</button>
<button type="button" className="cookie-banner__button" onClick={acceptCookies}>Accept</button>
</div>
)}
</div>
+214 -57
View File
@@ -50,22 +50,17 @@
max-width: 760px;
font-size: 16px;
line-height: 1.7;
color: var(--text-secondary);
color: #b4c6d8;
}
.site-home__actions {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.site-home__panel,
.site-home__card,
.site-home__join,
.site-home__support {
.site-home__panel {
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: 8px;
}
.site-home__panel {
padding: 20px;
display: grid;
gap: 16px;
@@ -82,7 +77,7 @@
.site-home__join p,
.site-home__support p,
.site-section__head p {
color: var(--text-secondary);
color: #b4c6d8;
line-height: 1.65;
margin: 0;
}
@@ -101,7 +96,7 @@
}
.site-home__meta-row:first-child { border-top: 0; }
.site-home__meta-row span {
color: var(--text-secondary);
color: #b4c6d8;
}
.site-home__meta-row strong {
color: var(--text-primary);
@@ -173,13 +168,29 @@
margin-bottom: 20px;
max-width: 760px;
}
.site-section__head > a {
color: var(--accent);
width: fit-content;
}
.site-section__head > a:hover { text-decoration-thickness: 2px; }
.site-home__cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 16px;
}
.site-card,
.site-home__card {
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: 8px;
padding: 20px;
display: grid;
gap: 12px;
transition: border-color var(--transition), background var(--transition);
}
.site-card {
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: 8px;
@@ -197,13 +208,18 @@
color: var(--text-primary);
margin: 0;
}
.site-card__body,
.site-home__card p {
.site-card__body {
font-size: 14px;
line-height: 1.65;
color: var(--text-secondary);
margin: 0;
}
.site-home__card p {
font-size: 14px;
line-height: 1.65;
color: #b4c6d8;
margin: 0;
}
.site-card__link,
.site-home__card a:not(.site-btn) {
font-size: 13px;
@@ -228,7 +244,7 @@
border-radius: 8px;
}
.site-home__spec-row span {
color: var(--text-secondary);
color: #b4c6d8;
font-size: 13px;
}
.site-home__spec-row strong {
@@ -237,6 +253,9 @@
font-size: 18px;
}
.site-home__join {
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: 8px;
display: flex;
justify-content: space-between;
align-items: center;
@@ -252,23 +271,6 @@
gap: 12px;
flex-wrap: wrap;
}
.site-home__support {
display: flex;
justify-content: space-between;
align-items: center;
gap: 24px;
flex-wrap: wrap;
padding: 20px;
}
.site-home__support > div:first-child {
max-width: 760px;
}
.site-home__support-actions {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.dev-monitor__grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -352,7 +354,6 @@
border-collapse: collapse;
}
.dev-monitor__table th,
.dev-monitor__table td {
text-align: left;
padding: 10px 12px;
@@ -362,6 +363,11 @@
}
.dev-monitor__table th {
text-align: left;
padding: 10px 12px;
border-top: 1px solid var(--border-soft);
vertical-align: top;
font-size: 14px;
color: var(--text-secondary);
font-weight: 600;
font-size: 12px;
@@ -628,7 +634,6 @@
border-collapse: collapse;
}
.dev-status-table th,
.dev-status-table td {
padding: 10px 12px;
border-top: 1px solid var(--border-soft);
@@ -638,6 +643,11 @@
}
.dev-status-table th {
padding: 10px 12px;
border-top: 1px solid var(--border-soft);
text-align: left;
vertical-align: top;
font-size: 14px;
color: var(--text-secondary);
font-size: 12px;
text-transform: uppercase;
@@ -671,8 +681,7 @@
min-width: 0;
}
.site-home__join,
.site-home__support {
.site-home__join {
align-items: flex-start;
}
}
@@ -690,20 +699,17 @@
.site-home__panel,
.site-home__card,
.site-home__join,
.site-home__support,
.site-card {
padding: 16px;
}
.site-home__actions,
.site-home__join-actions,
.site-home__support-actions {
.site-home__join-actions {
width: 100%;
}
.site-home__actions .site-btn,
.site-home__join-actions .site-btn,
.site-home__support-actions .site-btn {
.site-home__join-actions .site-btn {
flex: 1 1 100%;
}
}
@@ -724,30 +730,181 @@
}
.observer-registration button,
.observer-registration p { grid-column: 1 / -1; }
.hw-card { text-align: left; cursor: pointer; color: inherit; }
@media (max-width: 640px) {
.observer-registration { grid-template-columns: 1fr; }
}
.site-btn--donate {
border-color: var(--color-gold);
background: var(--color-gold);
color: #111827;
font-weight: 800;
}
.site-home-health { border-block: 1px solid var(--border); background: var(--bg-panel); }
.site-home-health__inner { display: flex; align-items: center; gap: 18px; padding-block: 13px; }
.site-home-health__inner > div { display: flex; align-items: center; gap: 8px; }
.site-home-health__inner > span { color: var(--text-secondary); }
.site-home-health__inner > a { margin-left: auto; color: var(--accent); }
.site-home-health__dot { width: 11px; height: 11px; border-radius: 50%; background: var(--color-amber); }
.site-home-health--healthy .site-home-health__dot { background: var(--color-green); }
.site-home-health--poor .site-home-health__dot { background: var(--color-red); }
.site-home-feed { display: grid; gap: 8px; }
.site-home-feed article { display: grid; grid-template-columns: 110px 1fr auto; gap: 12px; padding: 10px; border: 1px solid var(--border); border-radius: var(--radius-md); }
.site-home-feed time { color: var(--text-muted); }
.site-home-feed time { color: #b4c6d8; }
@media (max-width: 640px) {
.site-home-health__inner { align-items: flex-start; flex-direction: column; gap: 5px; }
.site-home-health__inner > a { margin-left: 0; }
.site-home-feed article { grid-template-columns: 1fr auto; }
.site-home-feed article span { grid-column: 1 / -1; grid-row: 2; }
}
/* ── Landing page contrast and spacing ─────────────────────────────────── */
.site-home__actions .site-btn--ghost {
background: rgba(13, 21, 32, 0.45);
border-color: rgba(138, 166, 196, 0.68);
}
.site-home__actions .site-btn--ghost:hover {
background: var(--bg-hover);
border-color: rgba(0, 196, 255, 0.72);
}
.site-home-feed > p {
color: #b4c6d8;
}
.site-stats-section .site-stat__label,
.site-stats-section .site-stat__suffix {
color: #b4c6d8;
}
.site-home-feed-section {
padding-bottom: 56px;
}
/* ── Data pages ────────────────────────────────────────────────────────── */
/* These selectors are scoped to the data-page markers so shared site chrome
and the landing-page sections remain owned by their respective styles. */
.repeater-page .repeater-search-box {
width: 100%;
max-width: none;
margin-bottom: 20px;
}
.repeater-page .repeater-details-card__empty {
min-height: 0;
padding: 32px 24px;
}
.repeater-page .repeater-details-card__empty-icon {
width: 36px;
height: 36px;
margin-bottom: 10px;
opacity: 0.58;
}
.repeater-page .repeater-details-card__empty h3 {
margin-bottom: 6px;
font-size: 16px;
}
.repeater-page .repeater-details-card__empty p {
max-width: 48ch;
margin: 0 auto;
line-height: 1.45;
}
.site-layout:has(.repeater-page) .site-footer,
.site-layout:has(.companion-page) .site-footer {
color: var(--text-secondary);
}
.site-layout:has(.repeater-page) .site-footer a,
.site-layout:has(.companion-page) .site-footer a { color: var(--text-secondary); }
.site-layout:has(.repeater-page) .site-footer__sep,
.site-layout:has(.companion-page) .site-footer__sep {
color: var(--accent);
opacity: 0.82;
}
.companion-page .companion-leaderboard__legend {
display: flex;
justify-content: space-between;
gap: 12px;
margin-bottom: 10px;
color: #b8c9dc;
font: 11px var(--font-mono);
letter-spacing: 0.04em;
}
.companion-page .companion-leaderboard {
position: relative;
max-height: min(680px, calc(100vh - 280px));
padding: 2px 6px 36px 0;
overflow-y: auto;
scrollbar-color: #52728f #101b29;
}
.companion-page .companion-leaderboard::after {
position: absolute;
right: 6px;
bottom: 0;
left: 0;
height: 38px;
background: linear-gradient(to bottom, rgba(13, 21, 32, 0), var(--bg-panel));
content: '';
pointer-events: none;
}
.companion-page .companion-row {
grid-template-columns: 48px minmax(0, 1fr) max-content;
min-width: 0;
border-color: rgba(137, 180, 214, 0.32);
}
.companion-page .companion-row__rank,
.companion-page .companion-row__last,
.companion-page .companion-updated { color: #b8c9dc; }
.companion-page .companion-row__bar-track {
height: 8px;
background: rgba(137, 180, 214, 0.22);
border: 1px solid rgba(137, 180, 214, 0.25);
border-radius: 999px;
}
.companion-page .companion-row__bar {
background: linear-gradient(90deg, #28bfe7, #8be8ff);
border-radius: 999px;
box-shadow: 0 0 8px rgba(92, 219, 255, 0.34);
}
@media (max-width: 560px) {
.companion-page .companion-leaderboard__legend { flex-direction: column; gap: 4px; }
.companion-page .companion-row {
grid-template-columns: 34px minmax(0, 1fr) max-content;
gap: 8px;
padding: 12px 10px;
}
.companion-page .companion-row__last { display: block; font-size: 10px; }
.companion-page .companion-leaderboard { max-height: min(560px, calc(100vh - 260px)); }
}
.install-page .install-page__heading {
display: flex;
align-items: center;
gap: 12px;
}
.install-page .prose-step {
display: inline-grid;
min-width: 32px;
width: 32px;
height: 32px;
margin-right: 0;
place-items: center;
flex: 0 0 32px;
border: 1px solid rgba(92, 219, 255, 0.6);
border-radius: 50%;
background: rgba(0, 196, 255, 0.12);
color: #bcefff;
font-size: 13px;
}
.install-page .hw-card {
transition: border-color var(--transition), background var(--transition), box-shadow var(--transition);
}
.install-page .hw-card--recommended {
border-color: rgba(92, 219, 255, 0.82);
box-shadow: 0 0 0 1px rgba(92, 219, 255, 0.12), 0 8px 22px rgba(0, 196, 255, 0.1);
}
.install-page .hw-card--selected {
background: #102235;
border-color: var(--accent);
}
.install-page .hw-card__badge {
margin-bottom: 9px;
padding: 3px 8px;
border: 1px solid rgba(92, 219, 255, 0.72);
border-radius: 999px;
background: rgba(0, 196, 255, 0.16);
color: #bcefff;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
}
.install-page .hw-card__detail {
color: #b8c9dc;
font-size: 13px;
line-height: 1.5;
}
@media (max-width: 560px) {
.install-page .install-page__heading { align-items: flex-start; gap: 10px; }
.install-page .prose-step { min-width: 28px; width: 28px; height: 28px; flex-basis: 28px; }
}
+27 -3
View File
@@ -13,6 +13,19 @@
padding: 0 24px;
}
.site-layout .site-footer {
color: #b4c6d8;
}
.site-layout .site-footer a {
color: #b4c6d8;
}
.site-layout .site-footer a:hover {
color: var(--text-primary);
}
.site-layout .site-footer__sep {
color: #66829e;
}
/* ── Navigation ────────────────────────────────────────────────────────── */
.site-nav {
display: flex;
@@ -50,7 +63,7 @@
}
.site-nav__link {
padding: 8px 10px;
color: var(--text-secondary);
color: #b4c6d8;
text-decoration: none;
font-size: 13px;
border-radius: 6px;
@@ -65,6 +78,18 @@
color: var(--accent);
font-weight: 500;
}
.site-nav__link--external {
color: var(--accent);
font-weight: 600;
}
.site-nav__link--external:hover {
color: #8be8ff;
background: rgba(0, 196, 255, 0.12);
}
.site-nav__external-icon {
margin-left: 3px;
font-size: 0.95em;
}
.site-nav__app-btn {
padding: 8px 12px;
background: transparent;
@@ -189,8 +214,7 @@
}
}
/* Mobile controls wrapper (filter grid + search) — hidden on desktop */
.mobile-controls { display: none; }
/* Mobile controls wrapper (filter grid + search) */
.mobile-search { display: none; }
.site-nav__badge {
display: inline-grid;
+49 -18
View File
@@ -71,6 +71,7 @@
font-weight: 600;
color: var(--text-primary);
}
.sm-status__text { color: #f0f6ff; }
.sm-status__dot {
width: 10px;
height: 10px;
@@ -91,9 +92,9 @@
display: flex;
gap: 8px;
align-items: center;
color: var(--text-muted);
color: #b8c9dc;
font-family: var(--font-mono);
font-size: 11px;
font-size: 12px;
letter-spacing: 0.04em;
}
@@ -113,16 +114,40 @@
transition: color var(--transition);
}
.sm-toggle:hover { color: var(--text-primary); }
.sm-toggle input { accent-color: var(--accent); width: 14px; height: 14px; cursor: pointer; }
.sm-toggle input {
appearance: none;
-webkit-appearance: none;
display: grid;
width: 16px;
height: 16px;
margin: 0;
place-content: center;
border: 1px solid #7f9bb8;
border-radius: 4px;
background: #0d1520;
cursor: pointer;
}
.sm-toggle input::after {
content: '';
width: 7px;
height: 4px;
border-left: 2px solid #06101c;
border-bottom: 2px solid #06101c;
transform: rotate(-45deg) scale(0);
transition: transform 120ms ease-in-out;
}
.sm-toggle input:checked { background: var(--accent); border-color: var(--accent); }
.sm-toggle input:checked::after { transform: rotate(-45deg) scale(1); }
.sm-toggle input:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
/* ── Map ────────────────────────────────────────────────────────────────── */
.sm-mapwrap { margin: 0 0 28px; }
.sm-maphint {
font-family: var(--font-mono);
font-size: 10px;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--text-muted);
color: #b8c9dc;
margin-bottom: 8px;
}
.sm-map {
@@ -132,6 +157,15 @@
border: 1px solid var(--border);
background: var(--bg-base);
}
.sm-page .maplibregl-ctrl-group {
background: rgba(9, 17, 26, 0.94);
border: 1px solid rgba(137, 180, 214, 0.5);
border-radius: 6px;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.4);
}
.sm-page .maplibregl-ctrl-group button + button { border-top-color: rgba(137, 180, 214, 0.35); }
.sm-page .maplibregl-ctrl-group button:not(:disabled):hover { background-color: rgba(92, 219, 255, 0.14); }
.sm-page .maplibregl-ctrl-group button .maplibregl-ctrl-icon { filter: invert(1); opacity: 0.9; }
/* ── Sections ───────────────────────────────────────────────────────────── */
.sm-section { margin: 28px 0; }
@@ -393,20 +427,23 @@
font-size: 12px;
margin-top: 8px;
}
.sm-timeline th, .sm-timeline td {
.sm-timeline th {
text-align: left;
padding: 7px 10px;
border-bottom: 1px solid var(--border);
white-space: nowrap;
}
.sm-timeline th {
color: var(--text-secondary);
font-weight: 400;
text-transform: uppercase;
font-size: 9px;
letter-spacing: 0.1em;
}
.sm-timeline td { color: var(--text-primary); }
.sm-timeline td {
text-align: left;
padding: 7px 10px;
border-bottom: 1px solid var(--border);
white-space: nowrap; color: var(--text-primary);
}
/* ── Footer ─────────────────────────────────────────────────────────────── */
.sm-footer {
@@ -448,13 +485,7 @@
@media (max-width: 340px) {
.sm-grid { grid-template-columns: 1fr; }
}
.sm-virtual-list {
max-height: min(760px, 75vh);
overflow: auto;
overscroll-behavior: contain;
scrollbar-gutter: stable;
}
.sm-virtual-list .sm-card {
contain: layout paint;
.sm-incident-list {
display: grid;
gap: 12px;
}
+55 -23
View File
@@ -1,8 +1,12 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { useWebSocket } from '../../hooks/useWebSocket.js';
import type { WSMessage } from '../../hooks/useWebSocket.js';
import { LoadingIndicator } from '../../components/LoadingIndicator.js';
import { ObserverRegistrationForm } from '../../components/ObserverRegistrationForm.js';
import { useRuntimeFeatures } from '../../config/runtimeFeatures.js';
import { getCurrentSite } from '../../config/site.js';
import { useVisibilityPoll } from '../../hooks/useVisibilityPoll.js';
import { fetchJson, withScopeParams } from '../../utils/api.js';
interface CompanionEntry {
sender: string;
@@ -28,9 +32,23 @@ function timeAgo(iso: string): string {
return `${Math.floor(secs / 86400)}d ago`;
}
const WS_SCOPE = { network: 'ukmesh' };
function isCompanionEntries(value: unknown): value is CompanionEntry[] {
return Array.isArray(value) && value.every((entry) => (
typeof entry === 'object'
&& entry !== null
&& typeof (entry as Record<string, unknown>)['sender'] === 'string'
&& typeof (entry as Record<string, unknown>)['message_count'] === 'number'
&& typeof (entry as Record<string, unknown>)['last_message_at'] === 'string'
));
}
export const UKCompanionPage: React.FC = () => {
const site = getCurrentSite();
const network = site.networkFilter ?? site.network;
const observer = site.observerId;
const { privacyGeneration } = useRuntimeFeatures();
const requestScope = useMemo(() => ({ network, observer }), [network, observer]);
const scopeKey = `${network}:${observer ?? 'all'}:${privacyGeneration}`;
const [entries, setEntries] = useState<CompanionEntry[]>([]);
const [loading, setLoading] = useState(true);
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
@@ -38,25 +56,33 @@ export const UKCompanionPage: React.FC = () => {
// Track which packet hashes we've already counted to avoid double-counting
const seenHashes = useRef(new Set<string>());
const fetchData = useCallback(() => {
fetch('/api/companion-activity?network=ukmesh')
.then(r => r.json())
.then((data: CompanionEntry[]) => {
setEntries(Array.isArray(data) ? data : []);
setLastUpdated(new Date());
setLoading(false);
// Reset live counter and seen hashes on each full resync
setLiveCount(0);
seenHashes.current.clear();
})
.catch(() => setLoading(false));
}, []);
useEffect(() => {
fetchData();
const interval = setInterval(fetchData, 60_000);
return () => clearInterval(interval);
}, [fetchData]);
setEntries([]);
setLoading(true);
setLastUpdated(null);
setLiveCount(0);
seenHashes.current.clear();
}, [scopeKey]);
useVisibilityPoll(async (signal) => {
const data = await fetchJson<CompanionEntry[]>(
withScopeParams('/api/companion-activity', requestScope),
{ cache: 'no-store', signal },
{ timeoutMs: 15_000, maxBytes: 2 * 1024 * 1024, validate: isCompanionEntries },
);
if (signal.aborted) return;
setEntries(data.slice(0, 2_000));
setLastUpdated(new Date());
setLoading(false);
// A full resync is authoritative for both the live delta and dedupe window.
setLiveCount(0);
seenHashes.current.clear();
}, {
scopeKey: `companion-activity:${scopeKey}`,
intervalMs: 60_000,
timeoutMs: 15_000,
onError: () => setLoading(false),
});
const handleMessage = useCallback((msg: WSMessage) => {
if (msg.type !== 'packet') return;
@@ -85,12 +111,12 @@ export const UKCompanionPage: React.FC = () => {
} else {
next = [...prev, { sender, message_count: 1, last_message_at: now }];
}
return next.sort((a, b) => b.message_count - a.message_count);
return next.sort((a, b) => b.message_count - a.message_count).slice(0, 2_000);
});
setLiveCount(n => n + 1);
}, []);
useWebSocket(handleMessage, WS_SCOPE);
useWebSocket(handleMessage, requestScope);
const topCount = entries[0]?.message_count ?? 1;
@@ -107,13 +133,18 @@ export const UKCompanionPage: React.FC = () => {
</div>
</section>
<section className="site-section site-section--dark">
<section className="site-section site-section--dark companion-page">
<div className="site-content">
{loading ? (
<LoadingIndicator label="Loading companion activity..." variant="block" />
) : entries.length === 0 ? (
<p style={{ color: 'var(--text-muted)', textAlign: 'center', padding: '48px 0' }}>No data available.</p>
) : (
<>
<div className="companion-leaderboard__legend" aria-label="Activity bars are relative to number one">
<span>Activity scale</span>
<span>100% = #1 - {topCount.toLocaleString()} msgs</span>
</div>
<div className="companion-leaderboard">
{entries.map((entry, i) => {
const barPct = Math.max(4, Math.round((entry.message_count / topCount) * 100));
@@ -134,6 +165,7 @@ export const UKCompanionPage: React.FC = () => {
);
})}
</div>
</>
)}
{lastUpdated && (
<p className="companion-updated">
+7 -53
View File
@@ -1,33 +1,19 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import React, { useEffect, useState } from 'react';
import { Link } from 'react-router';
import { LiveStatsSection } from '../../components/LiveStatsSection.js';
import { getCurrentSite } from '../../config/site.js';
const meshcoreDonationUrl = 'https://givealittle.co.nz/cause/help-us-save-meshcore';
const meshcoreSupportPostUrl = 'https://blog.meshcore.io/2026/07/04/help-us-save-meshcore';
export const UKHomePage: React.FC = () => {
const site = getCurrentSite();
const [regions, setRegions] = useState<Array<{ iata: string; health?: { score?: number; status?: 'healthy' | 'watch' | 'poor' }; lastPacketAt?: string | null }>>([]);
const [recent, setRecent] = useState<Array<{ packet_hash: string; time: string; summary?: string | null; packet_type?: number | null }>>([]);
useEffect(() => {
const controller = new AbortController();
void Promise.allSettled([
fetch('/api/stats/charts?network=ukmesh', { signal: controller.signal }).then((response) => response.json()),
fetch('/api/packets/recent?network=ukmesh&limit=5', { signal: controller.signal }).then((response) => response.json()),
]).then(([stats, packets]) => {
if (stats.status === 'fulfilled') setRegions(Array.isArray(stats.value?.observerRegions) ? stats.value.observerRegions : []);
if (packets.status === 'fulfilled') setRecent(Array.isArray(packets.value) ? packets.value : []);
});
void fetch('/api/packets/recent?network=ukmesh&limit=5', { signal: controller.signal })
.then((response) => response.json())
.then((packets) => setRecent(Array.isArray(packets) ? packets : []))
.catch(() => {});
return () => controller.abort();
}, []);
const regionalHealth = useMemo(() => {
if (regions.length === 0) return { status: 'watch', label: 'Health data starting', score: 0 };
const score = Math.round(regions.reduce((sum, region) => sum + Number(region.health?.score ?? 0), 0) / regions.length);
return score >= 75 ? { status: 'healthy', label: 'Regions healthy', score }
: score >= 45 ? { status: 'watch', label: 'Regions need attention', score }
: { status: 'poor', label: 'Regional disruption', score };
}, [regions]);
return (
<>
@@ -40,7 +26,6 @@ export const UKHomePage: React.FC = () => {
supporting documentation behind the live map.
</p>
<div className="site-home__actions">
<a href={meshcoreDonationUrl} target="_blank" rel="noopener noreferrer" className="site-btn site-btn--donate">Donate to MeshCore</a>
<a href={site.appUrl} className="site-btn site-btn--primary">Open live map</a>
<Link to="/install" className="site-btn site-btn--ghost">Install MeshCore</Link>
<Link to="/stats" className="site-btn site-btn--ghost">Network stats</Link>
@@ -71,17 +56,9 @@ export const UKHomePage: React.FC = () => {
</div>
</section>
<section className={`site-home-health site-home-health--${regionalHealth.status}`}>
<div className="site-content site-home-health__inner">
<div><span className="site-home-health__dot" /><strong>{regionalHealth.label}</strong></div>
<span>{regionalHealth.score}% aggregate health · {regions.length} reporting regions</span>
<Link to="/stats">View regional detail </Link>
</div>
</section>
<LiveStatsSection />
<section className="site-section site-section--dark">
<section className="site-section site-section--dark site-home-feed-section">
<div className="site-content">
<div className="site-section__head"><h2>Recent feed</h2><Link to="/feed">Open live feed </Link></div>
<div className="site-home-feed">
@@ -97,29 +74,6 @@ export const UKHomePage: React.FC = () => {
</div>
</section>
<section className="site-section">
<div className="site-content">
<div className="site-home__support">
<div>
<h2>Support the creators of MeshCore</h2>
<p>
The MeshCore team is crowdfunding legal costs to protect the project name and keep the core
firmware free, open-source, and community driven. Donations go through Givealittle, and the
MeshCore blog explains the background.
</p>
</div>
<div className="site-home__support-actions">
<a href={meshcoreDonationUrl} target="_blank" rel="noopener noreferrer" className="site-btn site-btn--primary">
Donate on Givealittle
</a>
<a href={meshcoreSupportPostUrl} target="_blank" rel="noopener noreferrer" className="site-btn site-btn--ghost">
Read the blog post
</a>
</div>
</div>
</div>
</section>
<section className="site-section">
<div className="site-content">
<div className="site-section__head">
+10 -10
View File
@@ -14,10 +14,10 @@ export const UKInstallPage: React.FC = () => {
return (
<>
<div className="site-content site-prose">
<div className="site-content site-prose install-page">
<section className="prose-section">
<h2>
<h2 className="install-page__heading">
<span className="prose-step">1</span>
What you need
</h2>
@@ -32,7 +32,7 @@ export const UKInstallPage: React.FC = () => {
{HARDWARE.map((entry) => <button
type="button"
key={entry.id}
className={`hw-card${entry.id === hardwareId ? ' hw-card--recommended' : ''}`}
className={`hw-card${entry.id === hardwareId ? ' hw-card--selected' : ''}${entry.id === 'v4' ? ' hw-card--recommended' : ''}`}
aria-pressed={entry.id === hardwareId}
onClick={() => setHardwareId(entry.id)}
>
@@ -71,7 +71,7 @@ export const UKInstallPage: React.FC = () => {
</section>
<section className="prose-section">
<h2>
<h2 className="install-page__heading">
<span className="prose-step">2</span>
Flash the firmware
</h2>
@@ -104,7 +104,7 @@ export const UKInstallPage: React.FC = () => {
</section>
<section className="prose-section">
<h2>
<h2 className="install-page__heading">
<span className="prose-step">3</span>
Configure your node
</h2>
@@ -141,7 +141,7 @@ export const UKInstallPage: React.FC = () => {
</section>
<section className="prose-section">
<h2>
<h2 className="install-page__heading">
<span className="prose-step">4</span>
Get on the network
</h2>
@@ -168,7 +168,7 @@ export const UKInstallPage: React.FC = () => {
</section>
<section className="prose-section">
<h2>
<h2 className="install-page__heading">
<span className="prose-step">5</span>
Add an MQTT observer
</h2>
@@ -179,13 +179,13 @@ export const UKInstallPage: React.FC = () => {
<div className="prose-note">
<strong>Access is by request.</strong> Message <strong>ibengr</strong> on Discord to get MQTT credentials before setting this up.
</div>
<div className="code-block">
<div className="code-block" tabIndex={0} aria-label="Observer configuration example">
<pre>{'curl -fsSL https://raw.githubusercontent.com/Cisien/meshcoretomqtt/main/install.sh | bash'}</pre>
</div>
<p>
During setup, enable packet logging, choose the correct IATA code for your location, and add one extra broker with:
</p>
<div className="code-block">
<div className="code-block" tabIndex={0} aria-label="Observer service command example">
<pre>{`Server hostname/IP: mqtt.ukmesh.com
Port [1883]: 443
Use WebSockets transport? [y/N]: y
@@ -198,7 +198,7 @@ Password: <your password>`}</pre>
<p className="prose-note">
Topic format:
</p>
<div className="code-block">
<div className="code-block" tabIndex={0} aria-label="Observer verification command example">
<pre>{'meshcore/<IATA>/{PUBLIC_KEY}/packets'}</pre>
</div>
<div className="prose-note">
@@ -1,5 +1,10 @@
import React, { useState, useEffect, useMemo, useRef } from 'react';
import { LoadingIndicator } from '../../components/LoadingIndicator.js';
import { getCurrentSite } from '../../config/site.js';
import { useRuntimeFeatures } from '../../config/runtimeFeatures.js';
import { fetchJson, withScopeParams } from '../../utils/api.js';
import { ScopedCache } from '../../utils/scopedCache.js';
import { Combobox, type ComboboxOption } from '../../components/ui/Combobox.js';
interface MeshNode {
node_id: string;
@@ -42,32 +47,107 @@ interface AdvertPacket {
}
type NodeDetailBundle = { links: NodeLink[]; history: PacketHistory[]; adverts: AdvertPacket[] };
type PublicMapPage = {
nodes: MeshNode[];
page: {
snapshot: string;
nextCursor: string | null;
complete: boolean;
returned: number;
rowLimit: number;
};
};
const NODE_DETAIL_TTL_MS = 5 * 60_000;
const nodeDetailCache = new Map<string, { expiresAt: number; value: NodeDetailBundle }>();
const MAP_PAGE_LIMIT = 2000;
const MAP_MAX_PAGES = 100;
const nodeDetailCache = new ScopedCache<NodeDetailBundle>({
name: 'repeater-detail',
ttlMs: NODE_DETAIL_TTL_MS,
maxEntries: 128,
maxBytes: 12 * 1024 * 1024,
maxInflight: 4,
});
async function fetchJsonWithTimeout<T>(url: string, timeoutMs = 8_000): Promise<T> {
const response = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json() as Promise<T>;
async function loadNodeDetails(
node: MeshNode,
network: string,
observer: string | undefined,
scopeKey: string,
signal: AbortSignal,
): Promise<NodeDetailBundle> {
const cacheKey = node.node_id.toUpperCase();
const publicKey = node.public_key ?? node.node_id;
return nodeDetailCache.getOrLoad(scopeKey, cacheKey, async () => {
const requestScope = { network, observer };
const [links, history, adverts] = await Promise.all([
fetchJson<NodeLink[]>(
withScopeParams(`/api/nodes/${encodeURIComponent(node.node_id)}/links`, requestScope),
{ signal, cache: 'no-store' },
{ timeoutMs: 8_000, maxBytes: 2 * 1024 * 1024 },
),
fetchJson<PacketHistory[]>(
withScopeParams(`/api/nodes/${encodeURIComponent(node.node_id)}/history?hours=24`, requestScope),
{ signal, cache: 'no-store' },
{ timeoutMs: 8_000, maxBytes: 4 * 1024 * 1024 },
),
fetchJson<AdvertPacket[]>(
withScopeParams(`/api/nodes/${encodeURIComponent(publicKey)}/adverts?hours=168`, requestScope),
{ signal, cache: 'no-store' },
{ timeoutMs: 8_000, maxBytes: 2 * 1024 * 1024 },
),
]);
return {
links: Array.isArray(links) ? links : [],
history: Array.isArray(history) ? history : [],
adverts: Array.isArray(adverts) ? adverts : [],
};
});
}
async function loadNodeDetails(node: MeshNode): Promise<NodeDetailBundle> {
const cacheKey = node.node_id.toUpperCase();
const cached = nodeDetailCache.get(cacheKey);
if (cached && cached.expiresAt > Date.now()) return cached.value;
const publicKey = node.public_key ?? node.node_id;
const [links, history, adverts] = await Promise.all([
fetchJsonWithTimeout<NodeLink[]>(`/api/nodes/${node.node_id}/links`),
fetchJsonWithTimeout<PacketHistory[]>(`/api/nodes/${node.node_id}/history?hours=24`),
fetchJsonWithTimeout<AdvertPacket[]>(`/api/nodes/${publicKey}/adverts?hours=168`),
]);
const value = {
links: Array.isArray(links) ? links : [],
history: Array.isArray(history) ? history : [],
adverts: Array.isArray(adverts) ? adverts : [],
};
nodeDetailCache.set(cacheKey, { expiresAt: Date.now() + NODE_DETAIL_TTL_MS, value });
return value;
async function loadCompleteNodeSnapshot(
signal: AbortSignal,
network: string,
observer: string | undefined,
): Promise<MeshNode[]> {
const nodes = new Map<string, MeshNode>();
let snapshot: string | null = null;
let cursor: string | null = null;
const seenCursors = new Set<string>();
for (let pageNumber = 0; pageNumber < MAP_MAX_PAGES; pageNumber += 1) {
const params = new URLSearchParams({
network,
fields: 'node_id,name,lat,lon,iata,role,last_seen,is_online,hardware_model,advert_count,elevation_m',
limit: String(MAP_PAGE_LIMIT),
});
if (observer) params.set('observer', observer);
if (snapshot) params.set('snapshot', snapshot);
if (cursor) params.set('cursor', cursor);
const payload = await fetchJson<PublicMapPage>(
`/api/nodes/map?${params}`,
{ signal, cache: 'no-store' },
{ timeoutMs: 20_000, maxBytes: 12 * 1024 * 1024 },
);
if (!payload || !Array.isArray(payload.nodes) || !payload.page) {
throw new Error('Repeater index returned an invalid page');
}
if (snapshot && payload.page.snapshot !== snapshot) {
throw new Error('Repeater index snapshot changed while paging');
}
snapshot = payload.page.snapshot;
for (const node of payload.nodes) {
if (node && typeof node.node_id === 'string') {
nodes.set(node.node_id, { ...node, public_key: node.node_id });
}
}
if (payload.page.complete) return [...nodes.values()];
if (!payload.page.nextCursor || seenCursors.has(payload.page.nextCursor)) {
throw new Error('Repeater index did not provide a forward cursor');
}
seenCursors.add(payload.page.nextCursor);
cursor = payload.page.nextCursor;
}
throw new Error('Repeater index exceeded its page budget');
}
function timeAgo(iso: string): string {
@@ -168,47 +248,54 @@ function formatTimeUntil(date: Date): string {
}
export const UKRepeaterSearchPage: React.FC = () => {
const site = getCurrentSite();
const runtimeFeatures = useRuntimeFeatures();
const network = site.networkFilter ?? site.network;
const observer = site.observerId;
const detailScopeKey = `${network}|${observer ?? 'all'}|privacy-${runtimeFeatures.privacyGeneration}`;
const [searchQuery, setSearchQuery] = useState('');
const [showResults, setShowResults] = useState(false);
const [nodes, setNodes] = useState<MeshNode[]>([]);
const [loadingNodes, setLoadingNodes] = useState(true);
const [nodesError, setNodesError] = useState<string | null>(null);
const [selectedNode, setSelectedNode] = useState<MeshNode | null>(null);
const [links, setLinks] = useState<NodeLink[]>([]);
const [history, setHistory] = useState<PacketHistory[]>([]);
const [adverts, setAdverts] = useState<AdvertPacket[]>([]);
const [loadingDetails, setLoadingDetails] = useState(false);
const [copiedKey, setCopiedKey] = useState(false);
const searchRef = useRef<HTMLDivElement>(null);
const selectionSequenceRef = useRef(0);
const selectionControllerRef = useRef<AbortController | null>(null);
// Load nodes on mount
useEffect(() => {
let cancelled = false;
fetch('/api/nodes/map?network=ukmesh&fields=node_id,name,lat,lon,iata,role,last_seen,is_online,hardware_model')
.then(r => r.json())
.then(data => {
if (!cancelled) setNodes(Array.isArray(data) ? data.map((node: MeshNode) => ({ ...node, public_key: node.node_id })) : []);
const controller = new AbortController();
setLoadingNodes(true);
setNodes([]);
setSelectedNode(null);
selectionControllerRef.current?.abort();
loadCompleteNodeSnapshot(controller.signal, network, observer)
.then(loadedNodes => {
if (!controller.signal.aborted) {
setNodes(loadedNodes);
setNodesError(null);
}
})
.catch(() => {
if (!cancelled) setNodes([]);
.catch((error: unknown) => {
if (!controller.signal.aborted) {
setNodes([]);
setNodesError(error instanceof Error ? error.message : 'Could not load repeater index');
}
})
.finally(() => {
if (!cancelled) setLoadingNodes(false);
if (!controller.signal.aborted) setLoadingNodes(false);
});
return () => {
cancelled = true;
controller.abort();
};
}, []);
}, [network, observer, runtimeFeatures.privacyGeneration]);
// Click outside to close search dropdown
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (searchRef.current && !searchRef.current.contains(event.target as Node)) {
setShowResults(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
useEffect(() => () => selectionControllerRef.current?.abort(), []);
const searchResults = useMemo(() => {
if (!searchQuery.trim()) return [];
@@ -226,6 +313,19 @@ export const UKRepeaterSearchPage: React.FC = () => {
})
.slice(0, 10);
}, [searchQuery, nodes]);
const searchOptions = useMemo<ComboboxOption[]>(() => searchResults.map((node) => ({
id: node.node_id,
label: node.name ?? node.node_id,
content: (
<>
<span className="repeater-search-box__result-name">{node.name || 'Unknown'}</span>
<span className="repeater-search-box__result-meta">
{node.iata ? `${node.iata} · ` : ''}
{node.public_key?.slice(0, 16)}... · {node.is_online ? 'Online' : 'Offline'}
</span>
</>
),
})), [searchResults]);
// Calculate predicted next advert based on advert packets
const prediction = useMemo(() => {
@@ -234,6 +334,11 @@ export const UKRepeaterSearchPage: React.FC = () => {
}, [adverts]);
const selectNode = async (node: MeshNode) => {
const sequence = selectionSequenceRef.current + 1;
selectionSequenceRef.current = sequence;
selectionControllerRef.current?.abort();
const controller = new AbortController();
selectionControllerRef.current = controller;
setSelectedNode(node);
setSearchQuery(node.name || node.public_key?.slice(0, 16) || '');
setShowResults(false);
@@ -244,14 +349,20 @@ export const UKRepeaterSearchPage: React.FC = () => {
setCopiedKey(false);
try {
const details = await loadNodeDetails(node);
const details = await loadNodeDetails(node, network, observer, detailScopeKey, controller.signal);
if (controller.signal.aborted || sequence !== selectionSequenceRef.current) return;
setLinks(details.links);
setHistory(details.history);
setAdverts(details.adverts);
} catch {
// Ignore errors
} finally {
setLoadingDetails(false);
if (selectionControllerRef.current === controller) {
selectionControllerRef.current = null;
}
if (!controller.signal.aborted && sequence === selectionSequenceRef.current) {
setLoadingDetails(false);
}
}
};
@@ -266,55 +377,57 @@ export const UKRepeaterSearchPage: React.FC = () => {
return (
<>
<section className="site-section">
<section className="site-section repeater-page">
<div className="site-content">
<div className="repeater-search-box" ref={searchRef}>
<input
type="text"
<Combobox
label="Search repeaters"
value={searchQuery}
onChange={(e) => { setSearchQuery(e.target.value); setShowResults(true); }}
onFocus={() => setShowResults(true)}
onValueChange={(value) => {
setSearchQuery(value);
setShowResults(true);
}}
onSelectionChange={(id) => {
const node = searchResults.find((entry) => entry.node_id === id);
if (node) void selectNode(node);
}}
options={searchOptions}
placeholder="Search by repeater name, IATA code, or public key..."
className="repeater-search-box__input"
className="repeater-search-box"
inputClassName="repeater-search-box__input"
popoverClassName="repeater-search-box__results"
optionClassName="repeater-search-box__result"
isOpen={showResults}
onOpenChange={setShowResults}
autoFocus
/>
{showResults && (
<div className="repeater-search-box__results">
{loadingNodes ? (
emptyContent={loadingNodes ? (
<div className="repeater-search-box__no-results">
<LoadingIndicator label="Loading repeaters..." variant="inline" />
</div>
) : nodesError ? (
<div className="repeater-search-box__no-results" role="alert">
{nodesError}
</div>
) : searchQuery && searchResults.length === 0 ? (
<div className="repeater-search-box__no-results">
No repeaters found matching "{searchQuery}"
</div>
) : (
searchResults.map(node => (
<button
key={node.node_id}
className="repeater-search-box__result"
onClick={() => selectNode(node)}
>
<span className="repeater-search-box__result-name">{node.name || 'Unknown'}</span>
<span className="repeater-search-box__result-meta">
{node.iata ? `${node.iata} · ` : ''}{node.public_key?.slice(0, 16)}... · {node.is_online ? 'Online' : 'Offline'}
</span>
</button>
))
)}
{searchResults.length > 0 && (
) : 'Type to search repeaters'}
footer={searchResults.length > 0 ? (
<div className="repeater-search-box__count">
{searchResults.length} result{searchResults.length !== 1 ? 's' : ''}
</div>
)}
</div>
)}
</div>
) : null}
/>
{!selectedNode ? (
<div className="repeater-details-card">
{loadingNodes ? (
<LoadingIndicator label="Loading repeater index..." variant="block" />
) : nodesError ? (
<div className="repeater-details-card__empty" role="alert">
<h3>Repeater index unavailable</h3>
<p>{nodesError}</p>
</div>
) : (
<div className="repeater-details-card__empty">
<svg className="repeater-details-card__empty-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
@@ -378,7 +491,8 @@ export const UKRepeaterSearchPage: React.FC = () => {
<div className="repeater-details-card__field">
<span className="repeater-details-card__label">Position</span>
<span className="repeater-details-card__value">
{selectedNode.lat && selectedNode.lon
{selectedNode.lat !== undefined && selectedNode.lat !== null
&& selectedNode.lon !== undefined && selectedNode.lon !== null
? `${selectedNode.lat.toFixed(5)}, ${selectedNode.lon.toFixed(5)}`
: 'Unknown'}
</span>
+227 -378
View File
@@ -90,7 +90,9 @@ html, body, #root {
.app-shell {
display: grid;
grid-template-rows: 48px 1fr;
grid-template-columns: 1fr;
grid-template-columns: minmax(0, 1fr);
width: 100%;
min-width: 0;
height: 100%;
overflow: hidden;
position: relative;
@@ -102,6 +104,9 @@ html, body, #root {
align-items: center;
gap: 24px;
padding: 0 20px;
width: 100%;
min-width: 0;
overflow: hidden;
background: var(--bg-panel);
border-bottom: 1px solid var(--border);
z-index: 1000;
@@ -261,7 +266,8 @@ button .loading-indicator--inline,
@media (prefers-reduced-motion: reduce) {
.loading-indicator__mark {
animation: loading-indicator-pulse 1.4s ease-in-out infinite;
animation: none;
border-top-color: var(--border-bright);
}
}
@@ -310,18 +316,14 @@ button .loading-indicator--inline,
border-color: var(--accent);
background: var(--accent-dim);
}
.topbar__network,
.topbar__tool-btn {
height: 28px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg-panel-alt);
color: var(--text-secondary);
font: 10px var(--font-mono);
font: 10px var(--font-mono); padding: 0 8px; cursor: pointer;
}
.topbar__network { max-width: 110px; padding: 0 7px; }
.topbar__tool-btn { padding: 0 8px; cursor: pointer; }
.topbar__tool-btn:hover,
.topbar__tool-btn--active { border-color: var(--accent); color: var(--accent); }
.topbar__shortcut-btn { width: 28px; padding: 0; font-size: 14px; }
@@ -422,139 +424,79 @@ button .loading-indicator--inline,
opacity: 0.85;
}
.stats-page__path-link {
appearance: none;
border: 0;
background: none;
padding: 0;
margin: 0;
color: var(--accent);
cursor: pointer;
font: inherit;
font-weight: 700;
text-align: left;
display: block;
max-width: 100%;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
/* ── Cookie notice: keep consent in document flow, below the page chrome ── */
.site-layout:has(.cookie-banner) .site-main {
padding-bottom: 40px;
}
.stats-page__path-link:hover {
text-decoration: underline;
}
.stats-page__path-modal {
background: var(--bg-panel);
border: 1px solid var(--border-bright);
border-radius: 12px;
width: min(960px, 100%);
max-height: min(88vh, 900px);
padding: 24px;
.site-layout .cookie-banner {
position: static;
right: auto;
bottom: auto;
z-index: auto;
width: 100%;
max-width: none;
display: flex;
flex-direction: column;
gap: 16px;
overflow-y: auto;
overscroll-behavior: contain;
box-sizing: border-box;
}
.stats-page__path-modal-header {
display: flex;
align-items: flex-start;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.stats-page__path-modal-title {
margin: 0;
font-family: var(--font-mono);
font-size: 16px;
color: var(--accent);
text-transform: uppercase;
letter-spacing: 0.08em;
padding: 10px clamp(16px, 4vw, 48px);
border: 0;
border-top: 1px solid var(--border);
border-bottom: 1px solid var(--border);
border-radius: 0;
background: var(--bg-panel);
box-shadow: none;
backdrop-filter: none;
}
.stats-page__path-modal-sub {
margin: 6px 0 0;
color: var(--text-muted);
font-size: 13px;
overflow-wrap: anywhere;
}
.stats-page__path-modal-close {
width: auto;
min-width: 110px;
}
.stats-page__path-modal-map {
height: 420px;
border: 1px solid var(--border);
border-radius: 10px;
overflow: hidden;
}
.stats-page__path-modal-list {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 10px;
}
.stats-page__path-modal-node {
.site-layout .cookie-banner__body {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: 8px;
background: rgba(11, 23, 37, 0.55);
flex: 1 1 auto;
min-width: 0;
align-items: baseline;
flex-wrap: wrap;
gap: 2px 10px;
}
.stats-page__path-modal-node span {
display: inline-flex;
width: 22px;
height: 22px;
align-items: center;
justify-content: center;
border-radius: 999px;
background: rgba(0, 196, 255, 0.12);
color: var(--accent);
font-family: var(--font-mono);
font-size: 11px;
flex: 0 0 22px;
}
.stats-page__path-modal-node strong {
.site-layout .cookie-banner__body strong {
color: var(--text-primary);
font-size: 13px;
line-height: 1.35;
}
.health-kv strong {
min-width: 0;
overflow-wrap: anywhere;
}
.stats-page__path-node-label {
background: transparent;
border: 0;
box-shadow: none;
color: #ffffff;
font-family: var(--font-mono);
font-size: 11px;
font-weight: 700;
.site-layout .cookie-banner__body p {
margin: 0;
padding: 0;
color: #b4c6d8;
font-size: 12px;
line-height: 1.45;
}
.stats-page__path-node-label::before {
display: none;
.site-layout .cookie-banner__button {
flex-shrink: 0;
min-width: 82px;
min-height: 32px;
padding: 7px 12px;
border: 1px solid rgba(0, 196, 255, 0.55);
border-radius: var(--radius);
background: var(--accent-dim);
color: var(--text-primary);
font-family: var(--font-mono);
font-size: 12px;
cursor: pointer;
transition: background var(--transition), border-color var(--transition);
}
.site-layout .cookie-banner__button:hover {
background: rgba(0, 196, 255, 0.22);
border-color: var(--accent-glow);
}
/* Mobile controls must not create an extra app-shell grid row on desktop. */
.mobile-controls {
display: none;
@media (max-width: 1200px) {
.topbar {
gap: 12px;
padding-inline: 12px;
}
.topbar__stats {
display: none;
}
}
/* ══════════════════════════════════════════════════════════════════════════ */
@@ -563,52 +505,19 @@ button .loading-indicator--inline,
@media (max-width: 640px) {
/* ── Analytics app topbar ────────────────────────────────────────────── */
.topbar { gap: 10px; padding: 0 12px; }
.topbar { gap: 6px; padding: 0 8px; }
.topbar__stats { display: none; }
.topbar__divider,
.topbar__tool-btn:not(.topbar__shortcut-btn) { display: none; }
.topbar__network { margin-left: auto; max-width: 92px; }
.topbar__logo { gap: 4px; }
.topbar__shortcut-btn { margin-left: auto; }
/* ── App shell: add rows for mobile controls + map ──────────────────── */
.app-shell { grid-template-rows: 48px auto 1fr; }
/* ── Desktop filter panel: hide on mobile (replaced by mobile-controls) */
.filter-panel { display: none; }
/* ── Mobile controls: 2x2 filter grid + search, always in flow ───────── */
.mobile-controls {
display: flex;
flex-direction: column;
background: var(--bg-panel);
border-bottom: 1px solid var(--border);
}
.mobile-controls > .map-modes {
padding: 8px 12px;
border-bottom: 1px solid var(--border);
}
.mobile-controls .map-modes__buttons {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.node-drawer {
position: fixed;
inset: auto 0 0;
width: 100%;
max-height: 62vh;
border-right: 0;
border-bottom: 0;
border-left: 0;
border-radius: 14px 14px 0 0;
}
.timeline-control {
position: absolute;
right: 16px;
bottom: 204px;
left: 16px;
width: auto;
transform: none;
}
.timeline-control__meta { flex-wrap: wrap; gap: 5px 12px; }
.planner-comparison { top: auto; right: 8px; bottom: 250px; left: 8px; width: auto; max-height: 42vh; overflow: auto; }
.mobile-filter-grid {
display: grid;
grid-template-columns: 1fr 1fr;
@@ -672,10 +581,8 @@ button .loading-indicator--inline,
}
/* ── In-map node search: hidden on mobile (replaced by mobile-search) ── */
.map-area .node-search { display: none; }
/* ── Packet feed: full width at bottom, max 4 items ─────────────────── */
.packet-feed { width: calc(100% - 32px); max-height: 180px; }
/* ── Website nav: keep bar fixed height, dropdown pops below it ─────── */
.site-nav {
@@ -715,15 +622,20 @@ button .loading-indicator--inline,
font-size: 14px;
}
.cookie-banner {
left: 16px;
right: 16px;
bottom: 16px;
max-width: none;
.site-layout:has(.cookie-banner) .site-main {
padding-bottom: 24px;
}
.site-layout .cookie-banner {
flex-direction: column;
align-items: stretch;
gap: 10px;
padding: 12px 16px;
}
.cookie-banner__button { width: 100%; }
.site-layout .cookie-banner__body {
display: grid;
gap: 4px;
}
.site-layout .cookie-banner__button { width: 100%; }
/* ── Website content / sections ──────────────────────────────────────── */
.site-content { padding: 0 16px; }
@@ -745,8 +657,7 @@ button .loading-indicator--inline,
.site-page-hero--stats {
margin-bottom: 12px;
}
.site-home__join,
.site-home__support { align-items: flex-start; }
.site-home__join { align-items: flex-start; }
.dev-monitor__grid { grid-template-columns: 1fr; }
.dev-monitor__row { flex-direction: column; align-items: flex-start; }
.dev-monitor__row strong { text-align: left; }
@@ -759,31 +670,12 @@ button .loading-indicator--inline,
.site-prose { padding: 24px 16px 56px; }
.site-prose--wide { max-width: 100%; }
.prose-section--muted { padding: 20px; }
.owner-summary-grid { grid-template-columns: 1fr; }
.owner-dashboard-grid { grid-template-columns: 1fr; }
.owner-panel--map,
.owner-panel--packets { grid-row: auto; grid-column: auto; }
.owner-alerts,
.owner-list { grid-template-columns: 1fr; }
.owner-list__row { grid-template-columns: 1fr; }
.owner-list__metrics { justify-content: flex-start; }
.owner-telemetry-strip { grid-template-columns: 1fr; }
.owner-roadmap { grid-template-columns: 1fr; }
.owner-head { flex-direction: column; align-items: flex-start; }
.owner-map { height: 260px; }
}
@media (max-width: 1180px) {
.dev-telemetry__charts {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.owner-summary-grid.site-stats-grid--6 {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.owner-dashboard-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 860px) {
@@ -791,112 +683,25 @@ button .loading-indicator--inline,
grid-template-columns: 1fr;
}
.owner-summary-grid.site-stats-grid--6 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
/* ── Feed: mobile layout ─────────────────────────────────────── */
.uk-feed-layout {
grid-template-columns: 1fr;
grid-template-rows: auto;
height: auto;
}
/* Channels sidebar → horizontal filter pills */
.uk-feed-channels {
border-right: none;
border-bottom: 1px solid var(--border);
display: flex;
flex-direction: row;
flex-wrap: wrap;
align-items: center;
padding: 6px 8px;
gap: 4px;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.uk-feed-channels__header { display: none; }
.uk-feed-channels__divider { display: none; }
.uk-feed-channel-item {
width: auto;
padding: 6px 12px;
border-radius: 999px;
border: 1px solid var(--border);
font-size: 13px;
flex-shrink: 0;
}
.uk-feed-channel-toggle {
font-size: 13px;
padding: 6px 8px;
}
/* Packet list — full height, no cap */
.uk-feed-chat {
border-right: none;
max-height: none;
}
/* Packet rows — bigger touch targets, simplified meta */
.uk-feed-packet-row {
min-height: 56px;
padding: 10px 12px;
}
.uk-feed-packet-row__meta {
grid-template-columns: auto auto auto 1fr;
gap: 6px;
}
/* Hide hash on mobile — too long to be useful */
.uk-feed-packet-row__hash {
display: none;
}
/* Observer spans to the last grid cell and wraps naturally */
.uk-feed-packet-row__observer {
font-size: 0.8rem;
}
/* Inline map — shown on mobile below selected row */
.uk-feed-inline-map {
display: block;
height: 240px;
border-bottom: 1px solid var(--border);
background: var(--bg-secondary);
}
/* Hide the desktop right column entirely on mobile */
.uk-feed-right {
display: none;
}
/* Mobile stats bar — connection status + type tags, shown between channels and chat */
.uk-feed-mobile-stats {
display: block;
padding: 8px 12px;
border-bottom: 1px solid var(--border);
background: var(--bg-panel);
}
.uk-feed-mobile-stats .uk-feed-stats__row {
margin-bottom: 6px;
}
/* Mobile detail strip — shown below inline map for selected packet */
.uk-feed-mobile-detail {
display: block;
padding: 10px 12px;
background: var(--bg-panel-alt);
border-bottom: 1px solid var(--border);
}
.owner-dashboard-grid {
grid-template-columns: 1fr;
grid-auto-rows: minmax(280px, auto);
}
}
/* Repeater Search Page */
@@ -923,10 +728,7 @@ button .loading-indicator--inline,
}
.repeater-search-box__results {
position: absolute;
top: 100%;
left: 0;
right: 0;
width: var(--trigger-width);
background: var(--bg-panel);
border: 1px solid var(--border);
border-top: none;
@@ -937,11 +739,10 @@ button .loading-indicator--inline,
z-index: 100;
}
.stats-page__pie-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(140px, 1fr);
align-items: center;
gap: 16px;
.repeater-search-box__results .ui-combobox__listbox {
max-height: min(250px, 38dvh);
overflow-y: auto;
overscroll-behavior: contain;
}
@media (max-width: 640px) {
@@ -953,32 +754,6 @@ button .loading-indicator--inline,
max-height: calc(100dvh - 20px);
padding: 20px;
}
.stats-page__path-modal {
max-height: calc(100dvh - 20px);
padding: 14px;
gap: 12px;
}
.stats-page__path-modal-header {
flex-direction: column;
align-items: stretch;
gap: 10px;
}
.stats-page__path-modal-close {
width: 100%;
min-width: 0;
}
.stats-page__path-modal-map {
height: min(46dvh, 320px);
}
.stats-page__path-modal-list,
.stats-page__pie-layout {
grid-template-columns: minmax(0, 1fr);
}
}
.repeater-search-box__result {
@@ -997,7 +772,8 @@ button .loading-indicator--inline,
border-bottom: none;
}
.repeater-search-box__result:hover {
.repeater-search-box__result:hover,
.repeater-search-box__result[data-focused] {
background: var(--bg-tertiary);
}
@@ -1069,14 +845,6 @@ button .loading-indicator--inline,
border-bottom: none;
}
.repeater-details-card__section h3 {
margin: 0 0 4px;
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
letter-spacing: -0.01em;
}
.repeater-details-card__desc {
margin: 0 0 16px;
font-size: 13px;
@@ -1183,7 +951,6 @@ button .loading-indicator--inline,
min-width: 500px;
}
.repeater-details-card__table th,
.repeater-details-card__table td {
padding: 10px 12px;
text-align: left;
@@ -1192,6 +959,10 @@ button .loading-indicator--inline,
}
.repeater-details-card__table th {
padding: 10px 12px;
text-align: left;
border-bottom: 1px solid var(--border);
white-space: nowrap;
font-size: 11px;
font-weight: 600;
color: var(--text-secondary);
@@ -1313,6 +1084,11 @@ button .loading-indicator--inline,
}
.repeater-details-card__section h3 {
margin: 0 0 4px;
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
letter-spacing: -0.01em;
display: flex;
align-items: center;
}
@@ -1367,63 +1143,6 @@ button .loading-indicator--inline,
}
/* ── Map tool buttons (top-left cluster) ──────────────────────────────────── */
.map-tools {
position: absolute;
top: 10px;
left: 10px;
z-index: 10;
display: flex;
flex-direction: column;
gap: 4px;
}
.map-tools__btn {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
font-size: 12px;
font-family: var(--font-mono);
letter-spacing: 0.04em;
color: var(--text-primary);
background: rgba(8, 13, 20, 0.8);
border: 1px solid var(--border);
border-radius: var(--radius);
cursor: pointer;
transition: background var(--transition), border-color var(--transition), color var(--transition);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
white-space: nowrap;
line-height: 1;
}
.map-tools__btn:hover {
background: var(--bg-hover);
border-color: var(--border-bright);
}
.map-tools__btn--active {
background: var(--accent);
border-color: var(--accent);
color: #080d14;
font-weight: 600;
}
.map-tools__btn--active:hover {
background: #33cfff;
border-color: #33cfff;
color: #080d14;
}
.map-tools__btn svg {
flex-shrink: 0;
}
@media (max-width: 640px), (pointer: coarse) {
.map-tools {
display: none !important;
}
}
/* Packet type labels */
.repeater-details-card__packet-type {
@@ -2295,3 +2014,133 @@ button .loading-indicator--inline,
.regions-rel__bc-node { padding: 4px 8px; }
.regions-spec__format-row { flex-direction: column; gap: 4px; }
}
/* Explicit, deferred service-worker activation prompt. */
.service-worker-update {
position: fixed;
z-index: 100000;
right: 16px;
bottom: 16px;
display: flex;
align-items: center;
gap: 10px;
max-width: min(620px, calc(100vw - 32px));
padding: 12px;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--bg-panel);
color: var(--text-primary);
box-shadow: 0 12px 36px rgba(0, 0, 0, 0.35);
}
.service-worker-update > div {
display: grid;
gap: 2px;
margin-right: 6px;
}
.service-worker-update span {
color: var(--text-secondary);
font-size: 0.85rem;
}
.service-worker-update button {
min-width: 44px;
min-height: 44px;
}
@media (max-width: 640px) {
.service-worker-update {
left: 12px;
right: 12px;
bottom: 12px;
align-items: stretch;
flex-wrap: wrap;
}
.service-worker-update > div {
flex-basis: 100%;
}
}
.ui-visually-hidden {
position: absolute !important;
width: 1px !important;
height: 1px !important;
padding: 0 !important;
margin: -1px !important;
overflow: hidden !important;
clip: rect(0, 0, 0, 0) !important;
white-space: nowrap !important;
border: 0 !important;
}
.ui-dialog-overlay {
position: fixed;
z-index: 99999;
inset: 0;
display: grid;
place-items: center;
padding: 16px;
background: rgba(0, 0, 0, 0.72);
}
.ui-dialog-modal {
width: min(760px, 100%);
max-height: calc(100dvh - 32px);
}
.ui-dialog {
max-height: calc(100dvh - 32px);
overflow: auto;
border-radius: 12px;
background: var(--bg-panel);
color: var(--text-primary);
}
.ui-combobox__input-wrap {
display: flex;
align-items: stretch;
}
.ui-combobox__popover {
min-width: var(--trigger-width);
max-height: min(420px, 60vh);
overflow: auto;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg-panel);
color: var(--text-primary);
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.35);
}
.ui-combobox__listbox {
outline: none;
}
.ui-combobox__empty {
padding: 12px;
color: var(--text-secondary);
}
:where(a, button, input, select, textarea, [tabindex]):focus-visible,
[data-focus-visible] {
outline: 3px solid var(--accent);
outline-offset: 2px;
}
@media (pointer: coarse) {
:where(button, input, select, textarea, [role="button"], [role="tab"], [role="option"]) {
min-height: 44px;
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
scroll-behavior: auto !important;
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
transition-delay: 0ms !important;
}
}
+273 -52
View File
@@ -2,6 +2,8 @@
.map-layer {
position: relative;
overflow: hidden;
width: 100%;
min-width: 0;
min-height: 0;
}
@@ -22,6 +24,8 @@
.map-area {
position: relative;
overflow: hidden;
width: 100%;
min-width: 0;
height: 100%;
}
@@ -48,6 +52,7 @@
}
.node-search__controls { display: flex; }
.node-search__combobox { flex: 1 1 auto; min-width: 0; }
.node-search__controls .node-search__input { border-radius: 6px 0 0 6px; }
.node-search__nearby {
flex: 0 0 34px;
@@ -79,10 +84,7 @@
.node-search__input:focus { border-color: var(--accent); }
.node-search__results {
position: absolute;
top: calc(100% + 4px);
left: 0;
width: 100%;
width: var(--trigger-width);
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: 6px;
@@ -109,7 +111,8 @@
justify-content: space-between;
align-items: center;
}
.node-search__result:hover { background: var(--bg-hover); color: var(--accent); }
.node-search__result:hover,
.node-search__result[data-focused] { background: var(--bg-hover); color: var(--accent); }
.node-search__result:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
.node-search__result-name {
overflow: hidden;
@@ -286,8 +289,7 @@
gap: 5px;
}
.map-modes__button,
.map-modes__share {
.map-modes__button {
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg-active);
@@ -311,6 +313,14 @@
}
.map-modes__share {
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg-active);
color: var(--text-secondary);
font: 600 10px/1.2 var(--font-mono);
padding: 7px 6px;
cursor: pointer;
transition: color var(--transition), border-color var(--transition), background var(--transition);
width: 100%;
background: transparent;
}
@@ -325,7 +335,7 @@
transition: background var(--transition);
user-select: none;
width: 100%;
border: 0;
border: 1px solid transparent;
background: transparent;
font: inherit;
text-align: left;
@@ -472,7 +482,19 @@
.node-dock__close:hover { color: var(--text-primary); border-color: var(--accent); }
.node-dock__actions { display: flex; gap: 6px; padding: 10px 14px 0; }
.node-dock__watch,
.node-dock__watch {
flex: 1;
padding: 7px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg-active);
font: 10px var(--font-mono);
letter-spacing: 0.06em;
cursor: pointer;
transition: color var(--transition), border-color var(--transition), background var(--transition); color: var(--amber);
}
.node-dock__watch:hover { border-color: var(--amber); }
.node-dock__watch--on { background: var(--amber-dim); border-color: var(--amber); }
.node-dock__copy {
flex: 1;
padding: 7px;
@@ -482,12 +504,8 @@
font: 10px var(--font-mono);
letter-spacing: 0.06em;
cursor: pointer;
transition: color var(--transition), border-color var(--transition), background var(--transition);
transition: color var(--transition), border-color var(--transition), background var(--transition); color: var(--text-secondary);
}
.node-dock__watch { color: var(--amber); }
.node-dock__watch:hover { border-color: var(--amber); }
.node-dock__watch--on { background: var(--amber-dim); border-color: var(--amber); }
.node-dock__copy { color: var(--text-secondary); }
.node-dock__copy:hover { color: var(--accent); border-color: var(--accent); }
.node-dock__metrics {
@@ -531,9 +549,9 @@
max-width: 560px;
margin-inline: auto;
transform: none;
border: 1px solid var(--border);
border: 1px solid rgba(251, 191, 36, 0.55);
border-radius: var(--radius-lg);
background: rgba(8, 16, 27, 0.94);
background: rgba(8, 16, 27, 0.97);
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.45);
backdrop-filter: blur(10px);
}
@@ -552,14 +570,21 @@
align-items: center;
justify-content: space-between;
width: 100%;
min-height: 36px;
padding: 8px 12px;
border: 0;
background: transparent;
color: var(--text-secondary);
color: var(--text-primary);
font: 10px var(--font-mono);
cursor: pointer;
}
.timeline-control__toggle strong { color: var(--amber); }
.timeline-control__toggle strong {
padding: 3px 8px;
border: 1px solid rgba(251, 191, 36, 0.42);
border-radius: 999px;
background: rgba(251, 191, 36, 0.1);
color: #ffd166;
}
.timeline-control__body { padding: 4px 12px 10px; border-top: 1px solid var(--border); }
.timeline-control__body input { width: 100%; accent-color: var(--amber); }
.timeline-control__meta { display: flex; gap: 14px; margin-top: 3px; color: var(--text-secondary); font: 9px var(--font-mono); }
@@ -574,17 +599,46 @@
.stats-page__observer-watch button:hover { color: var(--amber); border-color: var(--amber); }
.watchlist-panel { margin-top: 8px; border-top: 1px solid var(--border); padding-top: 7px; }
.watchlist-panel summary { display: flex; justify-content: space-between; color: var(--text-secondary); font: 10px var(--font-mono); cursor: pointer; }
.watchlist-panel summary span { color: var(--amber); }
.watchlist-panel summary {
display: flex;
justify-content: flex-start;
gap: 8px;
color: var(--text-secondary);
font: 10px var(--font-mono);
cursor: pointer;
}
.watchlist-panel summary span {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
padding: 0 5px;
border: 1px solid rgba(255, 179, 0, 0.38);
border-radius: 999px;
background: rgba(255, 179, 0, 0.1);
color: #ffd166;
font-weight: 700;
line-height: 1;
}
.watchlist-panel p { margin: 7px 0 0; color: var(--text-muted); font-size: 11px; }
.watchlist-panel ul { max-height: 170px; margin: 7px 0 0; padding: 0; overflow: auto; list-style: none; }
.watchlist-panel li { display: flex; align-items: center; justify-content: space-between; gap: 6px; padding: 5px 2px; border-bottom: 1px solid var(--border); }
.watchlist-panel li > span { min-width: 0; overflow: hidden; color: var(--text-secondary); font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
.watchlist-panel li > a { min-width: 0; overflow: hidden; color: var(--text-secondary); font-size: 11px; text-decoration: none; text-overflow: ellipsis; white-space: nowrap; }
.watchlist-panel li > a:hover, .watchlist-panel li > a:focus-visible { color: var(--amber); text-decoration: underline; }
.watchlist-panel li small { display: block; color: var(--text-muted); font: 7px var(--font-mono); text-transform: uppercase; }
.watchlist-panel li button { border: 0; background: transparent; color: var(--text-muted); cursor: pointer; }
.watchlist-panel li button:hover { color: var(--danger); }
.packet-item__watch { flex: 0 0 auto; border: 0; background: transparent; color: var(--amber); cursor: pointer; }
.packet-item__watch {
width: 20px;
min-width: 20px;
padding: 0;
border: 0;
background: transparent;
color: var(--amber);
cursor: pointer;
}
.planner-comparison {
position: absolute;
@@ -839,8 +893,7 @@
z-index: 5;
}
.leaflet-popup-content-wrapper,
.maplibregl-popup-content {
.leaflet-popup-content-wrapper {
background: var(--bg-panel) !important;
border: 1px solid var(--border-bright) !important;
border-radius: var(--radius-lg) !important;
@@ -849,8 +902,7 @@
padding: 0 !important;
}
.leaflet-popup-content,
.maplibregl-popup-content {
.leaflet-popup-content {
margin: 0 !important;
}
@@ -860,6 +912,13 @@
}
.maplibregl-popup-content {
background: var(--bg-panel) !important;
border: 1px solid var(--border-bright) !important;
border-radius: var(--radius-lg) !important;
box-shadow: 0 8px 32px rgba(0,0,0,0.7) !important;
color: var(--text-primary) !important;
padding: 0 !important;
margin: 0 !important;
min-width: 200px;
}
@@ -1079,28 +1138,42 @@
bottom: 16px;
left: 16px;
z-index: 800;
width: 420px;
width: min(420px, calc(100vw - 32px));
max-height: 280px;
overflow-y: auto;
overflow-x: hidden;
pointer-events: auto;
display: flex;
flex-direction: column;
padding: 8px 4px 2px 0;
border: 1px solid rgba(214, 232, 255, 0.28);
border-radius: var(--radius-lg);
background: rgba(8, 16, 27, 0.97);
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.42);
scrollbar-width: thin;
scrollbar-color: rgba(255,255,255,0.15) transparent;
scrollbar-color: rgba(214, 232, 255, 0.52) rgba(8, 16, 27, 0.7);
scroll-padding-top: 8px;
}
.packet-feed::-webkit-scrollbar { width: 8px; }
.packet-feed::-webkit-scrollbar-track {
background: rgba(8, 16, 27, 0.7);
border-radius: 4px;
}
.packet-feed::-webkit-scrollbar-thumb {
background: rgba(214, 232, 255, 0.52);
border: 2px solid rgba(8, 16, 27, 0.7);
border-radius: 4px;
}
.packet-feed::-webkit-scrollbar { width: 4px; }
.packet-feed::-webkit-scrollbar-track { background: transparent; }
.packet-feed::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.15); border-radius: 2px; }
.packet-item {
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 7px 11px;
display: flex;
align-items: flex-start;
gap: 9px;
padding: 7px 9px;
display: grid;
grid-template-columns: 34px 28px 36px 36px minmax(0, 1fr) 30px 62px 20px 8px;
align-items: center;
column-gap: 6px;
font-family: var(--font-mono);
font-size: 11px;
line-height: 1.35;
@@ -1108,6 +1181,12 @@
margin-bottom: 3px;
will-change: transform, opacity;
}
.packet-item > * {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.packet-item--fading {
opacity: 0;
@@ -1132,10 +1211,13 @@
}
.packet-item__pin {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 7px;
color: var(--accent);
opacity: 0.7;
flex-shrink: 0;
animation: pin-pulse 2s ease-in-out infinite;
}
@@ -1159,26 +1241,29 @@
}
.packet-item__type {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
font-size: 11px;
color: var(--accent);
min-width: 34px;
flex-shrink: 0;
}
.packet-item__path-bytes {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
font-size: 10px;
color: #f87171;
min-width: 24px;
flex-shrink: 0;
font-weight: 700;
letter-spacing: 0.04em;
}
.packet-item__iata {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
font-size: 10px;
color: var(--text-primary);
min-width: 30px;
flex-shrink: 0;
}
.packet-item__iata--all {
@@ -1187,20 +1272,23 @@
}
.packet-item__advert-badge {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
font-size: 11px;
color: var(--amber);
white-space: nowrap;
flex-shrink: 0;
}
.packet-item__summary {
color: var(--text-secondary);
font-size: 11px;
flex: 1;
min-width: 0;
white-space: normal;
overflow-wrap: break-word;
word-break: break-word;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
overflow-wrap: normal;
word-break: normal;
}
.packet-item__summary--empty {
@@ -1208,10 +1296,12 @@
}
.packet-item__counts {
min-width: 0;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 3px;
flex-shrink: 0;
overflow: visible;
}
.count {
@@ -1235,6 +1325,9 @@
}
.packet-item__hops {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
font-size: 11px;
color: var(--amber);
}
@@ -1314,7 +1407,6 @@
grid-template-rows: 1fr;
}
.map-annotation { top: 98px; max-width: calc(100vw - 30px); }
.filter-panel { display: none; }
.filter-launcher { display: none; }
.node-drawer,
.node-dock {
@@ -1363,7 +1455,6 @@
.app-shell[data-node-open="true"] .node-legend {
display: none;
}
.packet-feed { width: calc(100% - 32px); max-height: 180px; }
}
@@ -1485,7 +1576,7 @@
overflow-x: auto;
margin: 0 0 8px;
}
.node-popup__tabs button {
.node-popup__tabs [role='tab'] {
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 4px 6px;
@@ -1494,10 +1585,140 @@
font: 10px var(--font-mono);
cursor: pointer;
}
.node-popup__tabs button[aria-selected='true'] {
.node-popup__tabs [role='tab'][aria-selected='true'] {
border-color: var(--accent);
background: var(--accent-dim);
color: var(--text-primary);
}
.node-popup__link-spark { width: 100%; margin-top: 4px; }
.node-popup__muted { color: var(--text-muted); font-size: 11px; }
.mobile-controls {
display: none;
}
@media (max-width: 640px) {
.filter-panel { display: none; }
.mobile-controls {
display: flex;
flex-direction: column;
width: 100%;
min-width: 0;
max-height: min(40dvh, 270px);
overflow-y: auto;
overscroll-behavior: contain;
background: var(--bg-panel);
border-bottom: 1px solid var(--border);
}
.mobile-controls > .map-modes {
padding: 8px 12px;
border-bottom: 1px solid var(--border);
}
.mobile-controls .map-modes__buttons {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.map-area .node-search { display: none; }
.packet-feed { width: calc(100% - 32px); max-height: 180px; }
}
.map-tools {
position: absolute;
top: 10px;
left: 10px;
z-index: 10;
display: flex;
flex-direction: column;
gap: 4px;
}
.map-tools__btn {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
font-size: 12px;
font-family: var(--font-mono);
letter-spacing: 0.04em;
color: var(--text-primary);
background: rgba(8, 13, 20, 0.8);
border: 1px solid var(--border);
border-radius: var(--radius);
cursor: pointer;
transition: background var(--transition), border-color var(--transition), color var(--transition);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
white-space: nowrap;
line-height: 1;
}
.map-tools__btn:hover {
background: var(--bg-hover);
border-color: var(--border-bright);
}
.map-tools__btn--active {
background: var(--accent);
border-color: var(--accent);
color: #080d14;
font-weight: 600;
}
.map-tools__btn--active:hover {
background: #33cfff;
border-color: #33cfff;
color: #080d14;
}
.map-tools__btn svg {
flex-shrink: 0;
}
@media (max-width: 640px), (pointer: coarse) {
.map-tools {
display: none !important;
}
}
/* Agent B app chrome */
.app-shell .topbar__tool-btn,
.app-shell .topbar__info-btn {
border-color: rgba(214, 232, 255, 0.48);
background: rgba(17, 28, 43, 0.94);
color: var(--text-primary);
box-shadow: 0 0 0 1px rgba(8, 16, 27, 0.35);
}
.app-shell .topbar__tool-btn:hover,
.app-shell .topbar__tool-btn:focus-visible,
.app-shell .topbar__info-btn:hover,
.app-shell .topbar__info-btn:focus-visible {
border-color: var(--accent);
color: var(--accent);
background: var(--accent-dim);
}
.app-shell .topbar__tool-btn--active {
border-color: var(--accent);
background: rgba(0, 196, 255, 0.18);
color: var(--accent);
box-shadow: inset 0 0 0 1px rgba(0, 196, 255, 0.2), 0 0 10px rgba(0, 196, 255, 0.12);
}
.app-shell .topbar__info-btn[aria-label='Data disclaimer'] {
color: var(--text-primary);
font-weight: 700;
}
.filter-row[aria-pressed='true'] {
border-color: rgba(0, 196, 255, 0.28);
background: rgba(0, 196, 255, 0.08);
}
.timeline-control--open .timeline-control__toggle {
background: rgba(251, 191, 36, 0.08);
}
.timeline-control__name {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--text-primary);
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.timeline-control__name > span {
color: var(--amber);
font-size: 14px;
}
File diff suppressed because it is too large Load Diff
+8 -8
View File
@@ -105,12 +105,12 @@
<button class="control-button ghost-button hidden" id="install-app-button" type="button">
Install App
</button>
<a class="control-button ghost-button hidden" href="#" id="external-link" rel="noopener noreferrer" target="_blank">External Link</a>
<a class="control-button external-link hidden" href="#" id="external-link" rel="noopener noreferrer" target="_blank" aria-label="Open external link in a new tab">External Link</a>
</div>
</div>
<div class="dock-status-stack">
<span class="pill status-pill" id="mqtt-pill">MQTT offline</span>
<span class="pill status-pill" id="mqtt-pill" role="status" aria-live="polite">MQTT offline</span>
<div class="dock-note">
<span class="meta-label">Observer Window</span>
<strong id="network-window">Awaiting bootstrap</strong>
@@ -120,7 +120,7 @@
</article>
<section class="glance-grid telemetry-strip" id="health-at-a-glance">
<article class="glance-card telemetry-strip__item">
<article class="glance-card telemetry-strip__item glance-card--empty" id="signal-quality-card">
<div class="glance-card__head">
<span class="panel-label">Transport</span>
</div>
@@ -129,7 +129,7 @@
<p class="small-note" id="observer-density-label">Awaiting observer directory.</p>
</article>
<article class="glance-card telemetry-strip__item">
<article class="glance-card telemetry-strip__item glance-card--empty" id="receipt-spread-card">
<div class="glance-card__head">
<span class="panel-label">Node Count</span>
</div>
@@ -142,18 +142,18 @@
<div class="glance-card__head">
<span class="panel-label">Signal Quality</span>
</div>
<strong class="glance-card__value" id="signal-quality">--</strong>
<strong class="glance-card__value" id="signal-quality">No data yet</strong>
<div class="sparkline" id="signal-sparkline" aria-hidden="true"></div>
<p class="small-note" id="signal-quality-label">Awaiting telemetry.</p>
<p class="small-note" id="signal-quality-label">Waiting for a health check receipt.</p>
</article>
<article class="glance-card telemetry-strip__item">
<div class="glance-card__head">
<span class="panel-label">Receipt Spread</span>
</div>
<strong class="glance-card__value" id="latency-score">--</strong>
<strong class="glance-card__value" id="latency-score">No data yet</strong>
<div class="sparkline" id="latency-sparkline" aria-hidden="true"></div>
<p class="small-note" id="latency-label">Awaiting receipt spread.</p>
<p class="small-note" id="latency-label">Waiting for observer reports.</p>
</article>
</section>
</section>
+8 -8
View File
@@ -105,7 +105,7 @@
<button class="control-button ghost-button hidden" id="install-app-button" type="button">
Install App
</button>
<a class="control-button ghost-button hidden" href="#" id="external-link" rel="noopener noreferrer" target="_blank">External Link</a>
<a class="control-button external-link hidden" href="#" id="external-link" rel="noopener noreferrer" target="_blank" aria-label="Open external link in a new tab">External Link</a>
<a class="control-button primary-button" href="/app" id="start-own-check-link">Run Your Own Check</a>
<button class="control-button primary-button hidden" id="new-session-button" type="button">
New Code
@@ -114,7 +114,7 @@
</div>
<div class="dock-status-stack">
<span class="pill status-pill" id="mqtt-pill">Shared Link</span>
<span class="pill status-pill" id="mqtt-pill" role="status" aria-live="polite">Shared Link</span>
<div class="dock-note">
<span class="meta-label">Share Mode</span>
<strong id="network-window">Read-only diagnostics</strong>
@@ -124,7 +124,7 @@
</article>
<section class="glance-grid telemetry-strip" id="health-at-a-glance">
<article class="glance-card telemetry-strip__item">
<article class="glance-card telemetry-strip__item glance-card--empty" id="signal-quality-card">
<div class="glance-card__head">
<span class="panel-label">Transport</span>
</div>
@@ -133,7 +133,7 @@
<p class="small-note" id="observer-density-label">Awaiting observer directory.</p>
</article>
<article class="glance-card telemetry-strip__item">
<article class="glance-card telemetry-strip__item glance-card--empty" id="receipt-spread-card">
<div class="glance-card__head">
<span class="panel-label">Mapped Nodes</span>
</div>
@@ -146,18 +146,18 @@
<div class="glance-card__head">
<span class="panel-label">Signal Quality</span>
</div>
<strong class="glance-card__value" id="signal-quality">--</strong>
<strong class="glance-card__value" id="signal-quality">No data yet</strong>
<div class="sparkline" id="signal-sparkline" aria-hidden="true"></div>
<p class="small-note" id="signal-quality-label">Awaiting telemetry.</p>
<p class="small-note" id="signal-quality-label">Waiting for a health check receipt.</p>
</article>
<article class="glance-card telemetry-strip__item">
<div class="glance-card__head">
<span class="panel-label">Receipt Spread</span>
</div>
<strong class="glance-card__value" id="latency-score">--</strong>
<strong class="glance-card__value" id="latency-score">No data yet</strong>
<div class="sparkline" id="latency-sparkline" aria-hidden="true"></div>
<p class="small-note" id="latency-label">Awaiting receipt spread.</p>
<p class="small-note" id="latency-label">Waiting for observer reports.</p>
</article>
</section>
</section>
File diff suppressed because it is too large Load Diff