mirror of
https://github.com/gadgethd/ukmesh.git
synced 2026-09-09 00:33:43 +00:00
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:
@@ -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>
|
||||
|
||||
@@ -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:
|
||||
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> © <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:
|
||||
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> © <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,
|
||||
},
|
||||
});
|
||||
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user