mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-06-10 13:41:42 +00:00
Compare commits
233 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 93b2f4b6bb | |||
| ff76b1bf71 | |||
| 2de53d19a3 | |||
| 1e88d00ee9 | |||
| e26c961138 | |||
| 68d5c3ae82 | |||
| cc37f9f689 | |||
| 0386eba374 | |||
| 884e60d2b5 | |||
| 7e2b5f2878 | |||
| 03e1d135d6 | |||
| 784f44d213 | |||
| d964c27964 | |||
| fe997fefb2 | |||
| df60aa1d9f | |||
| 92afdd6dce | |||
| 4364f34b85 | |||
| b5b0cfcb60 | |||
| 7c40e24a35 | |||
| ad45a774d7 | |||
| 981664528e | |||
| 52f131e2dc | |||
| 29432d4fe0 | |||
| b3e55ae8d5 | |||
| 889a785058 | |||
| 0b72120cce | |||
| df9b8d96a0 | |||
| bab1b1d6e6 | |||
| c5d7d5762c | |||
| 2627bd053b | |||
| 4e5e141182 | |||
| ca9ba018fa | |||
| 430c6c43eb | |||
| 4309f6f98f | |||
| bc45338a5a | |||
| 7106e1921e | |||
| bf1f425116 | |||
| bc5e5719c2 | |||
| 2f8750baaa | |||
| 0e7a6511a3 | |||
| 3abffde0ed | |||
| 6d5c731d2e | |||
| 1ca8497ca2 | |||
| e4365d2c14 | |||
| 0474807c2e | |||
| 3ee89d75d7 | |||
| c50563992e | |||
| b2d654bf61 | |||
| d24246395d | |||
| 65c1d9ba9e | |||
| fbbdcf220e | |||
| 26ebfa0e09 | |||
| 7bd55b8f7a | |||
| 5d9681eff5 | |||
| d00ba91b1a | |||
| 3b924d0807 | |||
| 8e49c91fb6 | |||
| b3d2620d39 | |||
| 8fd5ce12f7 | |||
| bf99d1ddc1 | |||
| 7abe2dd56b | |||
| 58282c91d8 | |||
| 17d00c8366 | |||
| 6c54b7040f | |||
| 7395ae8aef | |||
| 270deda39e | |||
| 31c04d4674 | |||
| b5a1642024 | |||
| 8987dd4163 | |||
| fac967825c | |||
| b279dfce87 | |||
| 732c8843ea | |||
| 88d4380ce4 | |||
| ee6e4e917d | |||
| e4b703b6a5 | |||
| 54e3b8242b | |||
| 7a8ac4a698 | |||
| d6ba19efe0 | |||
| e87a370143 | |||
| 4ca6548d75 | |||
| ddf14d1954 | |||
| b01466237f | |||
| 678e247cef | |||
| ad8811a553 | |||
| d2c3276425 | |||
| 657fa3435a | |||
| 604c3552c7 | |||
| a7ef34aa77 | |||
| 6b83ccc21a | |||
| c0c13435e1 | |||
| 58656e11ae | |||
| a3f85778d3 | |||
| 074e3d6bed | |||
| a7e750ad71 | |||
| fba56c75cd | |||
| d7746e17db | |||
| a7a692b0e2 | |||
| 664bb97e0c | |||
| 94f004909c | |||
| 94530ad6eb | |||
| 76658dcc44 | |||
| 5b4349a93b | |||
| 08dcc864f0 | |||
| 3deb3188d4 | |||
| 777f77a451 | |||
| d01f41483b | |||
| 8cf2347131 | |||
| 00d351f053 | |||
| 32cb0e9664 | |||
| 9535f367a5 | |||
| f0c69d5fe7 | |||
| 48717aaccb | |||
| 13ae0dd6aa | |||
| ec7ff4c597 | |||
| 5d8d857cfb | |||
| 8d702bdfd9 | |||
| 77d1925f30 | |||
| 306ac37ea0 | |||
| 50a1b1c6e8 | |||
| 0c52cf663a | |||
| be1b014269 | |||
| c796d48442 | |||
| 0986caaa44 | |||
| 89410d58b4 | |||
| f72b1bd2ca | |||
| 037a54d9c2 | |||
| b6395afbc6 | |||
| f799bc106c | |||
| 5a962f8d0b | |||
| 0aa67b2d61 | |||
| 52b6dd82ac | |||
| 060e0d5aa1 | |||
| 0aa70ca9c6 | |||
| 217d23b7bd | |||
| a544283661 | |||
| 45085b9a59 | |||
| 9b0a4ee054 | |||
| 080f2c6609 | |||
| 3095668347 | |||
| 51c5ed9345 | |||
| 1bfbbd6bb2 | |||
| b3b81a57ba | |||
| ae77d58ec5 | |||
| 46424909cf | |||
| 7b50be14fc | |||
| a665e065bf | |||
| c32cc06de4 | |||
| 3711cc6fed | |||
| 7e492a71a0 | |||
| d88cf28a80 | |||
| ee8b3efd27 | |||
| 1c50539e59 | |||
| 3f8799f975 | |||
| 55f34bbd7a | |||
| 902f9c4976 | |||
| 5552744867 | |||
| a7fc3cd6ed | |||
| ffffc83dbf | |||
| 4c0e66ffc0 | |||
| 8688b48121 | |||
| 7f5cc96bd9 | |||
| 86d503cd14 | |||
| eabf0d3ee7 | |||
| e98b83a937 | |||
| ce7bfe87ef | |||
| 7f459c1c13 | |||
| f0a7ed758f | |||
| aa63a478a7 | |||
| f15d2efe81 | |||
| 9a2270168f | |||
| 95d7916530 | |||
| c70f4b1c3d | |||
| ff0ee50354 | |||
| 101c11b4b3 | |||
| 0b35c7eef3 | |||
| 9d3dd8df0a | |||
| dc6c79cff8 | |||
| 2ea84e2237 | |||
| ec98a43d68 | |||
| 791c8ae1bc | |||
| bfebf200b7 | |||
| 88bc5d9d3b | |||
| 7742fbe7b1 | |||
| a6224e2325 | |||
| 9f92b1331c | |||
| d7dd2dca1e | |||
| 7f9bad452f | |||
| 0f7cce3a5f | |||
| c0c5b66ca9 | |||
| 954148ae8e | |||
| 988f64a27d | |||
| b81256976c | |||
| ddc353aab7 | |||
| c7ab5f3eb9 | |||
| fa52c0887e | |||
| 73d9f06f9a | |||
| ea849d226a | |||
| cf74d6cfa4 | |||
| 7906524340 | |||
| 91d90d48fb | |||
| 78da393737 | |||
| 83feae228a | |||
| a279ab736c | |||
| 3bb9dc16ef | |||
| 2e08305b1d | |||
| 40aa02b438 | |||
| e545f315ca | |||
| f798b59c4d | |||
| 0e305d880d | |||
| e7debe7b13 | |||
| 1b7dc34e74 | |||
| 933ef4e6ef | |||
| bbd185a826 | |||
| e4c6246257 | |||
| 30a20c388e | |||
| 3170cbdea5 | |||
| de3424533c | |||
| 0d131808d4 | |||
| bfb652c1e8 | |||
| c1423ee5dd | |||
| f4a1db023d | |||
| c5c2b8c483 | |||
| 01f6a4707a | |||
| de583f9df4 | |||
| 534227ab89 | |||
| adcca3a8fc | |||
| 67ea45aa31 | |||
| 8e86ba57ed | |||
| c266921805 | |||
| eeddf46bc9 | |||
| 0f7c03ccaf | |||
| adcf29dd6b | |||
| 92df28a569 |
@@ -1 +1 @@
|
||||
{"schemaVersion":1,"label":"e2e tests","message":"659 passed","color":"brightgreen"}
|
||||
{"schemaVersion":1,"label":"e2e tests","message":"725 passed","color":"brightgreen"}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"schemaVersion":1,"label":"frontend coverage","message":"38.88%","color":"red"}
|
||||
{"schemaVersion":1,"label":"frontend coverage","message":"36.05%","color":"red"}
|
||||
|
||||
+286
@@ -0,0 +1,286 @@
|
||||
{
|
||||
"parserOptions": {
|
||||
"ecmaVersion": 2022,
|
||||
"sourceType": "script"
|
||||
},
|
||||
"env": {
|
||||
"browser": true,
|
||||
"es2022": true
|
||||
},
|
||||
"globals": {
|
||||
"AreaFilter": "readonly",
|
||||
"CACHE_INVALIDATE_MS": "readonly",
|
||||
"CLIENT_CONFIG": "readonly",
|
||||
"CLIENT_TTL": "readonly",
|
||||
"ChannelColorPicker": "readonly",
|
||||
"ChannelColors": "readonly",
|
||||
"ChannelDecrypt": "readonly",
|
||||
"ChannelQR": "readonly",
|
||||
"Chart": "readonly",
|
||||
"DIST_THRESHOLDS": "readonly",
|
||||
"DragManager": "readonly",
|
||||
"EXTERNAL_URLS": "readonly",
|
||||
"FAV_KEY": "readonly",
|
||||
"FilterUX": "readonly",
|
||||
"GestureHints": "readonly",
|
||||
"HEALTH_THRESHOLDS": "readonly",
|
||||
"HashColor": "readonly",
|
||||
"HopDisplay": "readonly",
|
||||
"HopResolver": "readonly",
|
||||
"IATA_CITIES": "readonly",
|
||||
"IATA_COORDS_GEO": "readonly",
|
||||
"L": "readonly",
|
||||
"LIMITS": "readonly",
|
||||
"Logo": "readonly",
|
||||
"MAX_HOP_DIST": "readonly",
|
||||
"MeshAudio": "readonly",
|
||||
"MeshConfigReady": "readonly",
|
||||
"PAYLOAD_COLORS": "readonly",
|
||||
"PAYLOAD_TYPES": "readonly",
|
||||
"PERF_SLOW_MS": "readonly",
|
||||
"PROPAGATION_BUFFER_MS": "readonly",
|
||||
"PULL_THRESHOLD_PX": "readonly",
|
||||
"PacketFilter": "readonly",
|
||||
"PathInspector": "readonly",
|
||||
"PrefixReserved": "readonly",
|
||||
"QRCode": "readonly",
|
||||
"ROLE_COLORS": "readonly",
|
||||
"ROLE_EMOJI": "readonly",
|
||||
"ROLE_LABELS": "readonly",
|
||||
"ROLE_SHAPES": "readonly",
|
||||
"ROLE_SORT": "readonly",
|
||||
"ROLE_STYLE": "readonly",
|
||||
"ROUTE_TYPES": "readonly",
|
||||
"RegionFilter": "readonly",
|
||||
"SITE_CONFIG": "readonly",
|
||||
"SKEW_SEVERITY_COLORS": "readonly",
|
||||
"SKEW_SEVERITY_LABELS": "readonly",
|
||||
"SKEW_SEVERITY_ORDER": "readonly",
|
||||
"SNR_THRESHOLDS": "readonly",
|
||||
"SlideOver": "readonly",
|
||||
"TILE_DARK": "readonly",
|
||||
"TILE_LIGHT": "readonly",
|
||||
"MC_TILE_PROVIDERS": "readonly",
|
||||
"MC_setDarkTileProvider": "readonly",
|
||||
"MC_getDarkTileProvider": "readonly",
|
||||
"MC_setServerDefaultTileProvider": "readonly",
|
||||
"MC_applyTileFilter": "readonly",
|
||||
"MC_DARK_TILE_DEFAULT": "readonly",
|
||||
"TYPE_COLORS": "readonly",
|
||||
"TableResponsive": "readonly",
|
||||
"TableSort": "readonly",
|
||||
"TouchGestures": "readonly",
|
||||
"TracesHelpers": "readonly",
|
||||
"URLState": "readonly",
|
||||
"WS_RECONNECT_MS": "readonly",
|
||||
"_SITE_CONFIG_ORIGINAL_HOME": "readonly",
|
||||
"__PERF_LOG_RENDER": "readonly",
|
||||
"__bottomNavInitDone": "readonly",
|
||||
"__corescopeLogo": "readonly",
|
||||
"__dirname": "readonly",
|
||||
"__filename": "readonly",
|
||||
"__gestureHints1065Init": "readonly",
|
||||
"__liveMQLBindCount": "readonly",
|
||||
"__meshcoreMapInternals": "readonly",
|
||||
"__navDrawer": "readonly",
|
||||
"__navDrawerPointerBindCount": "readonly",
|
||||
"__pathOverflowWired": "readonly",
|
||||
"__scrollLock": "readonly",
|
||||
"__touchGestures1062InitCount": "readonly",
|
||||
"_analyticsChannelTbodyHtml": "readonly",
|
||||
"_analyticsChannelTheadHtml": "readonly",
|
||||
"_analyticsDecorateChannels": "readonly",
|
||||
"_analyticsHashStatCardsHtml": "readonly",
|
||||
"_analyticsLoadChannelSort": "readonly",
|
||||
"_analyticsRenderCollisionsFromServer": "readonly",
|
||||
"_analyticsRenderMultiByteAdopters": "readonly",
|
||||
"_analyticsRenderMultiByteCapability": "readonly",
|
||||
"_analyticsRfNFColumnChart": "readonly",
|
||||
"_analyticsSaveChannelSort": "readonly",
|
||||
"_analyticsSortChannels": "readonly",
|
||||
"_apiCache": "readonly",
|
||||
"_apiPerf": "readonly",
|
||||
"_channelsBeginMessageRequestForTest": "readonly",
|
||||
"_channelsGetStateForTest": "readonly",
|
||||
"_channelsHandleWSBatchForTest": "readonly",
|
||||
"_channelsIsStaleMessageRequestForTest": "readonly",
|
||||
"_channelsLoadChannelsForTest": "readonly",
|
||||
"_channelsProcessWSBatchForTest": "readonly",
|
||||
"_channelsReconcileSelectionForTest": "readonly",
|
||||
"_channelsRefreshMessagesForTest": "readonly",
|
||||
"_channelsSelectChannelForTest": "readonly",
|
||||
"_channelsSetObserverRegionsForTest": "readonly",
|
||||
"_channelsSetStateForTest": "readonly",
|
||||
"_channelsShouldProcessWSMessageForRegion": "readonly",
|
||||
"_customizerV2": "readonly",
|
||||
"_ensurePullIndicator": "readonly",
|
||||
"_inflight": "readonly",
|
||||
"_isTouchDevice": "readonly",
|
||||
"_liveAddFeedItem": "readonly",
|
||||
"_liveBufferPacket": "readonly",
|
||||
"_liveBuildClickablePathPopupHtml": "readonly",
|
||||
"_liveBuildObserverIataMap": "readonly",
|
||||
"_liveClickablePaths": "readonly",
|
||||
"_liveDbPacketToLive": "readonly",
|
||||
"_liveExpandToBufferEntries": "readonly",
|
||||
"_liveExpandToBufferEntriesAsync": "readonly",
|
||||
"_liveFormatLiveTimestampHtml": "readonly",
|
||||
"_liveGetFavoritePubkeys": "readonly",
|
||||
"_liveGetNodeFilterKeys": "readonly",
|
||||
"_liveGetObserverIataMap": "readonly",
|
||||
"_liveIsNodeFavorited": "readonly",
|
||||
"_liveNodeActivity": "readonly",
|
||||
"_liveNodeData": "readonly",
|
||||
"_liveNodeMarkers": "readonly",
|
||||
"_livePacketInvolvesFavorite": "readonly",
|
||||
"_livePacketInvolvesFilterNode": "readonly",
|
||||
"_livePacketMatchesRegion": "readonly",
|
||||
"_livePruneClickablePaths": "readonly",
|
||||
"_livePruneStaleNodes": "readonly",
|
||||
"_liveRebuildFeedList": "readonly",
|
||||
"_liveResolveHopPositions": "readonly",
|
||||
"_liveSEG_MAP": "readonly",
|
||||
"_liveSetMarkerColor": "readonly",
|
||||
"_liveSetMarkerSize": "readonly",
|
||||
"_liveSetNodeFilter": "readonly",
|
||||
"_liveSetObserverIataMap": "readonly",
|
||||
"_liveSpeedLabel": "readonly",
|
||||
"_liveVCR": "readonly",
|
||||
"_liveVcrPause": "readonly",
|
||||
"_liveVcrResumeLive": "readonly",
|
||||
"_liveVcrSetMode": "readonly",
|
||||
"_liveVcrSpeedCycle": "readonly",
|
||||
"_live_packetTimestamp": "readonly",
|
||||
"_mapGetNeighborPubkeys": "readonly",
|
||||
"_mapSelectRefNode": "readonly",
|
||||
"_meshAudioVoices": "readonly",
|
||||
"_meshcoreHeatLayer": "readonly",
|
||||
"_meshcoreLiveHeatLayer": "readonly",
|
||||
"_nodesGetAllNodes": "readonly",
|
||||
"_nodesGetSortState": "readonly",
|
||||
"_nodesGetStatusInfo": "readonly",
|
||||
"_nodesGetStatusTooltip": "readonly",
|
||||
"_nodesIsAdvertMessage": "readonly",
|
||||
"_nodesMatchesSearch": "readonly",
|
||||
"_nodesRenderNodeTimestampHtml": "readonly",
|
||||
"_nodesRenderNodeTimestampText": "readonly",
|
||||
"_nodesSetAllNodes": "readonly",
|
||||
"_nodesSetSortState": "readonly",
|
||||
"_nodesSortArrow": "readonly",
|
||||
"_nodesSortNodes": "readonly",
|
||||
"_nodesSyncClaimedToFavorites": "readonly",
|
||||
"_nodesToggleSort": "readonly",
|
||||
"_packetsTestAPI": "readonly",
|
||||
"_panelCorner": "readonly",
|
||||
"_pendingPathInspectorRoute": "readonly",
|
||||
"_perfWriteSourcesPrev": "readonly",
|
||||
"_pullIndicator": "readonly",
|
||||
"_pullToast": "readonly",
|
||||
"_pullToastTimer": "readonly",
|
||||
"_reducedMotionMQL": "readonly",
|
||||
"_showPullToast": "readonly",
|
||||
"_themeRefreshTimer": "readonly",
|
||||
"_vcrFormatTime": "readonly",
|
||||
"addEventListener": "readonly",
|
||||
"api": "readonly",
|
||||
"apiPerf": "readonly",
|
||||
"bindFavStars": "readonly",
|
||||
"buildHexLegend": "readonly",
|
||||
"buildNodesQuery": "readonly",
|
||||
"buildPacketsQuery": "readonly",
|
||||
"clearParsedCache": "readonly",
|
||||
"closeMoreMenu": "readonly",
|
||||
"closeNav": "readonly",
|
||||
"comparePacketSets": "readonly",
|
||||
"computeBreakdownRanges": "readonly",
|
||||
"computeOverlapStats": "readonly",
|
||||
"connectWS": "readonly",
|
||||
"copyToClipboard": "readonly",
|
||||
"createColoredHexDump": "readonly",
|
||||
"currentPage": "readonly",
|
||||
"currentSkewValue": "readonly",
|
||||
"debounce": "readonly",
|
||||
"debouncedOnWS": "readonly",
|
||||
"destroy": "readonly",
|
||||
"devicePixelRatio": "readonly",
|
||||
"dispatchEvent": "readonly",
|
||||
"drawPacketRoute": "readonly",
|
||||
"escapeHtml": "readonly",
|
||||
"exports": "readonly",
|
||||
"favStar": "readonly",
|
||||
"filterPacketsByRoute": "readonly",
|
||||
"formatAbsoluteTimestamp": "readonly",
|
||||
"formatChartAxisLabel": "readonly",
|
||||
"formatDistance": "readonly",
|
||||
"formatDistanceRound": "readonly",
|
||||
"formatDrift": "readonly",
|
||||
"formatEngineBadge": "readonly",
|
||||
"formatHex": "readonly",
|
||||
"formatIsoLike": "readonly",
|
||||
"formatSkew": "readonly",
|
||||
"formatTimestamp": "readonly",
|
||||
"formatTimestampCustom": "readonly",
|
||||
"formatTimestampWithTooltip": "readonly",
|
||||
"formatVersionBadge": "readonly",
|
||||
"getDistanceUnit": "readonly",
|
||||
"getFavorites": "readonly",
|
||||
"getHashParams": "readonly",
|
||||
"getHealthThresholds": "readonly",
|
||||
"getNodeStatus": "readonly",
|
||||
"getParsedDecoded": "readonly",
|
||||
"getParsedPath": "readonly",
|
||||
"getPathLenOffset": "readonly",
|
||||
"getResolvedPath": "readonly",
|
||||
"getTileUrl": "readonly",
|
||||
"getTimestampCustomFormat": "readonly",
|
||||
"getTimestampFormatPreset": "readonly",
|
||||
"getTimestampMode": "readonly",
|
||||
"getTimestampTimezone": "readonly",
|
||||
"global": "readonly",
|
||||
"initGeoFilterOverlay": "readonly",
|
||||
"initTabBar": "readonly",
|
||||
"invalidateApiCache": "readonly",
|
||||
"isFavorite": "readonly",
|
||||
"isTransportRoute": "readonly",
|
||||
"makeColumnsResizable": "readonly",
|
||||
"makeRoleMarkerSVG": "readonly",
|
||||
"miniMarkdown": "readonly",
|
||||
"module": "readonly",
|
||||
"navigate": "readonly",
|
||||
"observerSkewSeverity": "readonly",
|
||||
"offWS": "readonly",
|
||||
"onWS": "readonly",
|
||||
"pad2": "readonly",
|
||||
"pad3": "readonly",
|
||||
"pages": "readonly",
|
||||
"payloadTypeColor": "readonly",
|
||||
"payloadTypeName": "readonly",
|
||||
"process": "readonly",
|
||||
"pullReconnect": "readonly",
|
||||
"qrcode": "readonly",
|
||||
"registerPage": "readonly",
|
||||
"renderSkewBadge": "readonly",
|
||||
"renderSkewSparkline": "readonly",
|
||||
"require": "readonly",
|
||||
"routeLayer": "readonly",
|
||||
"routeTypeName": "readonly",
|
||||
"setupPullToReconnect": "readonly",
|
||||
"syncBadgeColors": "readonly",
|
||||
"timeAgo": "readonly",
|
||||
"toggleFavorite": "readonly",
|
||||
"transportBadge": "readonly",
|
||||
"truncate": "readonly",
|
||||
"ws": "readonly",
|
||||
"wsListeners": "readonly"
|
||||
},
|
||||
"rules": {
|
||||
"no-undef": "error",
|
||||
"no-unused-vars": [
|
||||
"warn",
|
||||
{
|
||||
"argsIgnorePattern": "^_",
|
||||
"varsIgnorePattern": "^_"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,7 @@ permissions:
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
@@ -99,12 +99,38 @@ jobs:
|
||||
node test-channel-qr-wiring.js
|
||||
node test-channel-modal-ux.js
|
||||
node test-channel-issue-1087.js
|
||||
node test-issue-1409-no-encrypted-flood.js
|
||||
node test-channel-issue-1101.js
|
||||
node test-observer-iata-1188.js
|
||||
node test-pull-to-reconnect-1091.js
|
||||
node test-channel-fluid-layout.js
|
||||
node test-issue-1279-p2-code-filter.js
|
||||
node test-area-filter.js
|
||||
node test-issue-1293-marker-shapes.js
|
||||
node test-issue-1356-map-a11y.js
|
||||
node test-issue-1360-pill-letter-count.js
|
||||
node test-issue-1364-pill-no-clamp.js
|
||||
node test-issue-1375-scope-stats-fetch.js
|
||||
node test-issue-1361-cb-presets.js
|
||||
node test-issue-1407-cb-preset-propagation.js
|
||||
node test-issue-1412-customizer-no-override.js
|
||||
node test-issue-1418-raw-hex-extraction.js
|
||||
node test-issue-1418-edge-weights.js
|
||||
node test-issue-1418-cb-preset-ramp.js
|
||||
node test-issue-1418-spider-fan.js
|
||||
node test-issue-1418-deeplink-hops-channels.js
|
||||
node test-issue-1418-polish-review.js
|
||||
node test-issue-1420-tile-providers.js
|
||||
node test-issue-1438-marker-css-vars.js
|
||||
node test-live.js
|
||||
|
||||
- name: 🧹 Frontend lint (eslint no-undef) — issue #1342
|
||||
run: |
|
||||
set -e
|
||||
# Use eslint@8 (legacy .eslintrc.json). Don't migrate to flat-config / eslint@9.
|
||||
# --no-save: avoid touching package.json / no committed node_modules.
|
||||
npm install --no-save --no-audit --no-fund eslint@8
|
||||
npx eslint public/*.js
|
||||
|
||||
- name: Verify proto syntax
|
||||
run: |
|
||||
@@ -250,6 +276,9 @@ jobs:
|
||||
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-nav-fluid-1055-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-nav-priority-1102-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-nav-priority-1311-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-nav-priority-1391-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-1413-nav-overlap-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-1400-nav-vertical-clip.js 2>&1 | tee -a e2e-output.txt
|
||||
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-nav-more-floor-1139-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-bottom-nav-1061-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-gestures-1062-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
@@ -282,7 +311,9 @@ jobs:
|
||||
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-1206-vcr-overlap-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-1244-live-vcr-row-hints-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
BASE_URL=http://localhost:13581 node test-issue-1224-channels-mobile-ux-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
BASE_URL=http://localhost:13581 node test-issue-1367-channels-chat-app-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
BASE_URL=http://localhost:13581 node test-issue-1236-map-mobile-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
BASE_URL=http://localhost:13581 node test-issue-1329-map-controls-accordion-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
BASE_URL=http://localhost:13581 node test-issue-1273-qr-overlay-height-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
BASE_URL=http://localhost:13581 node test-issue-1281-location-row-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
BASE_URL=http://localhost:13581 node test-issue-1279-legend-p2-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
@@ -301,6 +332,7 @@ jobs:
|
||||
BASE_URL=http://localhost:13581 node test-customize-export-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-drag-manager-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-1306-collisions-terminology-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-1374-route-map-a11y-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
|
||||
- name: Collect frontend coverage (parallel)
|
||||
if: success() && github.event_name == 'push'
|
||||
|
||||
@@ -381,6 +381,7 @@ Existing patterns: `#/nodes/{pubkey}?section=node-neighbors`, `#/analytics?tab=c
|
||||
|
||||
## What NOT to Do
|
||||
- **Don't check in private information** — no names, API keys, tokens, passwords, IP addresses, personal data, or any identifying information. This is a PUBLIC repo.
|
||||
- **Don't introduce new `map[string]interface{}` in API response builders, handler returns, or internal data structures that cross domain boundaries.** Use a named Go struct with explicit JSON tags. CoreScope already carries 694 occurrences (see #1383); the count must monotonically decrease. If your change adds even one new occurrence in a touched file, the PR is wrong-shaped — fix the design, don't paper over with `interface{}`. Exempt: third-party library boundaries that genuinely return `interface{}`, and ad-hoc test fixture assertions.
|
||||
- Don't add npm dependencies without asking
|
||||
- Don't create a build step
|
||||
- Don't add framework abstractions (React, Vue, etc.)
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### 📝 Documentation Corrections
|
||||
- **PR #1324 historical record correction** (#1387) — the merged PR #1324 body referenced four tests that do NOT exist in master: `TestMultibyteCapPersistRoundTrip`, `TestMultibyteCapPersistSkipsUnknown`, `TestMaybePersistCoalesces`, and a `TryLock` coalescing test. The actual tests that landed are `TestRunMultibyteCapPersist_AppliesSnapshot` and `TestRunMultibyteCapPersist_NoSnapshot_NoOp`. See issue #1386 for the corrective test additions (round-trip, unknown-key skip, coalescing).
|
||||
|
||||
## [3.7.2] — 2026-05-06
|
||||
|
||||
Hotfix release branched from `v3.7.1`. Cherry-picks PR #1121 only — no other changes.
|
||||
|
||||
+2
-2
@@ -22,7 +22,7 @@ COPY internal/dbconfig/ ../../internal/dbconfig/
|
||||
COPY internal/dbschema/ ../../internal/dbschema/
|
||||
COPY internal/prunequeue/ ../../internal/prunequeue/
|
||||
COPY internal/perfio/ ../../internal/perfio/
|
||||
COPY internal/prunequeue/ ../../internal/prunequeue/
|
||||
COPY internal/mbcapqueue/ ../../internal/mbcapqueue/
|
||||
RUN go mod download
|
||||
COPY cmd/server/ ./
|
||||
RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \
|
||||
@@ -38,7 +38,7 @@ COPY internal/dbconfig/ ../../internal/dbconfig/
|
||||
COPY internal/dbschema/ ../../internal/dbschema/
|
||||
COPY internal/prunequeue/ ../../internal/prunequeue/
|
||||
COPY internal/perfio/ ../../internal/perfio/
|
||||
COPY internal/prunequeue/ ../../internal/prunequeue/
|
||||
COPY internal/mbcapqueue/ ../../internal/mbcapqueue/
|
||||
RUN go mod download
|
||||
COPY cmd/ingestor/ ./
|
||||
RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ingestor
|
||||
+10
-1
@@ -286,15 +286,24 @@ func LoadConfig(path string) (*Config, error) {
|
||||
}
|
||||
|
||||
// ResolvedSources returns the final list of MQTT sources to connect to.
|
||||
//
|
||||
// Scheme mapping:
|
||||
//
|
||||
// mqtt:// → tcp:// (paho plain TCP)
|
||||
// mqtts:// → ssl:// (paho TLS over TCP)
|
||||
// ws:// (paho WebSocket — passed through, no mapping needed)
|
||||
// wss:// (paho WebSocket TLS — passed through, no mapping needed)
|
||||
func (c *Config) ResolvedSources() []MQTTSource {
|
||||
for i := range c.MQTTSources {
|
||||
// paho uses tcp:// and ssl:// not mqtt:// and mqtts://
|
||||
// paho uses tcp:// and ssl:// for plain MQTT; ws:// and wss:// are accepted natively.
|
||||
b := c.MQTTSources[i].Broker
|
||||
if strings.HasPrefix(b, "mqtt://") {
|
||||
c.MQTTSources[i].Broker = "tcp://" + b[7:]
|
||||
} else if strings.HasPrefix(b, "mqtts://") {
|
||||
c.MQTTSources[i].Broker = "ssl://" + b[8:]
|
||||
}
|
||||
// ws:// and wss:// pass through unchanged — paho handles WebSocket
|
||||
// connections natively via gorilla/websocket.
|
||||
}
|
||||
return c.MQTTSources
|
||||
}
|
||||
|
||||
@@ -394,3 +394,93 @@ func TestMQTTSourceRegionField(t *testing.T) {
|
||||
t.Fatalf("expected region PDX, got %q", cfg.MQTTSources[0].Region)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolvedSourcesSchemeMapping verifies that mqtt:// and mqtts:// are translated
|
||||
// to the paho-native tcp:// and ssl:// schemes, while ws:// and wss:// pass through
|
||||
// unchanged (paho handles WebSocket connections natively).
|
||||
func TestResolvedSourcesSchemeMapping(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"mqtt://host:1883", "tcp://host:1883"},
|
||||
{"mqtts://host:8883", "ssl://host:8883"},
|
||||
{"tcp://host:1883", "tcp://host:1883"},
|
||||
{"ssl://host:8883", "ssl://host:8883"},
|
||||
{"ws://host:9001", "ws://host:9001"},
|
||||
{"wss://host:9001", "wss://host:9001"},
|
||||
{"ws://host:9001/mqtt", "ws://host:9001/mqtt"},
|
||||
{"wss://host:9001/mqtt", "wss://host:9001/mqtt"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
cfg := &Config{
|
||||
MQTTSources: []MQTTSource{
|
||||
{Name: "test", Broker: tt.input, Topics: []string{"meshcore/#"}},
|
||||
},
|
||||
}
|
||||
sources := cfg.ResolvedSources()
|
||||
if got := sources[0].Broker; got != tt.want {
|
||||
t.Errorf("ResolvedSources(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadConfigWSSource verifies that a WebSocket MQTT source round-trips through
|
||||
// LoadConfig correctly — username/password preserved, scheme unchanged.
|
||||
func TestLoadConfigWSSource(t *testing.T) {
|
||||
t.Setenv("DB_PATH", "")
|
||||
t.Setenv("MQTT_BROKER", "")
|
||||
|
||||
dir := t.TempDir()
|
||||
cfgPath := filepath.Join(dir, "config.json")
|
||||
os.WriteFile(cfgPath, []byte(`{
|
||||
"dbPath": "test.db",
|
||||
"mqttSources": [
|
||||
{
|
||||
"name": "local-tcp",
|
||||
"broker": "mqtt://localhost:1883",
|
||||
"topics": ["meshcore/#"]
|
||||
},
|
||||
{
|
||||
"name": "wsmqtt-ws",
|
||||
"broker": "wss://wsmqtt.example.com/mqtt",
|
||||
"username": "corescope",
|
||||
"password": "s3cr3t",
|
||||
"topics": ["meshcore/#"]
|
||||
}
|
||||
]
|
||||
}`), 0o644)
|
||||
|
||||
cfg, err := LoadConfig(cfgPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(cfg.MQTTSources) != 2 {
|
||||
t.Fatalf("mqttSources len=%d, want 2", len(cfg.MQTTSources))
|
||||
}
|
||||
|
||||
tcp := cfg.MQTTSources[0]
|
||||
if tcp.Name != "local-tcp" {
|
||||
t.Errorf("name=%s, want local-tcp", tcp.Name)
|
||||
}
|
||||
|
||||
ws := cfg.MQTTSources[1]
|
||||
if ws.Name != "wsmqtt-ws" {
|
||||
t.Errorf("name=%s, want wsmqtt-ws", ws.Name)
|
||||
}
|
||||
if ws.Broker != "wss://wsmqtt.example.com/mqtt" {
|
||||
t.Errorf("broker=%s, want wss://wsmqtt.example.com/mqtt", ws.Broker)
|
||||
}
|
||||
if ws.Username != "corescope" {
|
||||
t.Errorf("username=%s, want corescope", ws.Username)
|
||||
}
|
||||
if ws.Password != "s3cr3t" {
|
||||
t.Errorf("password=%s, want s3cr3t", ws.Password)
|
||||
}
|
||||
|
||||
sources := cfg.ResolvedSources()
|
||||
if sources[1].Broker != "wss://wsmqtt.example.com/mqtt" {
|
||||
t.Errorf("ResolvedSources wss broker=%s, want unchanged", sources[1].Broker)
|
||||
}
|
||||
}
|
||||
|
||||
+49
-10
@@ -556,6 +556,26 @@ func applySchema(db *sql.DB) error {
|
||||
// this column as hasDefaultScope; keeping a single canonical Apply
|
||||
// path closes the startup race that #1321 documented.
|
||||
|
||||
// Migration: normalize known channel_hash values for existing rows.
|
||||
// Before this PR, config key "public" was stored as channel_hash="public".
|
||||
// After this PR, new rows use channel_hash="Public". Without backfill,
|
||||
// channel grouping queries split into two buckets across the upgrade boundary.
|
||||
row = db.QueryRow("SELECT 1 FROM _migrations WHERE name = 'channel_hash_casing_v1'")
|
||||
if row.Scan(&migDone) != nil {
|
||||
log.Println("[migration] Normalizing known channel_hash values...")
|
||||
res, err := db.Exec(`UPDATE transmissions SET channel_hash = 'Public' WHERE channel_hash = 'public' AND payload_type = 5`)
|
||||
if err != nil {
|
||||
log.Printf("[migration] ERROR: failed to normalize channel_hash: %v", err)
|
||||
return fmt.Errorf("migration channel_hash_casing_v1 UPDATE failed: %w", err)
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
log.Printf("[migration] Normalized %d channel_hash rows from 'public' to 'Public'", n)
|
||||
if _, err := db.Exec(`INSERT OR IGNORE INTO _migrations (name) VALUES ('channel_hash_casing_v1')`); err != nil {
|
||||
log.Printf("[migration] WARNING: failed to record migration: %v", err)
|
||||
}
|
||||
log.Println("[migration] channel_hash casing normalization complete")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -728,9 +748,11 @@ func (s *Store) InsertTransmission(data *PacketData) (bool, error) {
|
||||
err := s.stmtGetObserverRowid.QueryRow(data.ObserverID).Scan(&rowid)
|
||||
if err == nil {
|
||||
observerIdx = &rowid
|
||||
// Update observer last_seen and last_packet_at on every packet to prevent
|
||||
// low-traffic observers from appearing offline (#463)
|
||||
_, _ = s.stmtUpdateObserverLastSeen.Exec(ingestNow, rxTime, ingestNow, rxTime, rowid)
|
||||
// observer.last_seen and last_packet_at answer "when did the analyzer
|
||||
// last hear from this observer" — both are ingest-time questions.
|
||||
// Per-packet rxTime is stored separately on observations/transmissions
|
||||
// using envelope time (see InsertTransmission above). See #1465.
|
||||
_, _ = s.stmtUpdateObserverLastSeen.Exec(ingestNow, ingestNow, ingestNow, ingestNow, rowid)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1023,14 +1045,20 @@ func (s *Store) RunIncrementalVacuum(pages int) {
|
||||
}
|
||||
}
|
||||
|
||||
// Checkpoint forces a WAL checkpoint to release the WAL lock file,
|
||||
// preventing lock contention with a new process starting up.
|
||||
func (s *Store) Checkpoint() {
|
||||
if _, err := s.db.Exec("PRAGMA wal_checkpoint(TRUNCATE)"); err != nil {
|
||||
// Checkpoint runs a WAL checkpoint (TRUNCATE mode).
|
||||
// Returns the number of WAL frames checkpointed (0 if WAL was already empty).
|
||||
// TRUNCATE resets the WAL file to zero bytes when all frames are checkpointed;
|
||||
// if active readers hold frames, it checkpoints what it can and leaves the rest.
|
||||
func (s *Store) Checkpoint() int {
|
||||
var busy, walFrames, checkpointed int
|
||||
if err := s.db.QueryRow("PRAGMA wal_checkpoint(TRUNCATE)").Scan(&busy, &walFrames, &checkpointed); err != nil {
|
||||
log.Printf("[db] WAL checkpoint error: %v", err)
|
||||
} else {
|
||||
log.Println("[db] WAL checkpoint complete")
|
||||
return 0
|
||||
}
|
||||
if walFrames > 0 {
|
||||
log.Printf("[db] WAL checkpoint: %d/%d frames checkpointed (blocked=%v)", checkpointed, walFrames, busy != 0)
|
||||
}
|
||||
return checkpointed
|
||||
}
|
||||
|
||||
// BackfillPathJSONAsync launches the path_json backfill in a background goroutine.
|
||||
@@ -1360,6 +1388,17 @@ type MQTTPacketMessage struct {
|
||||
// path_json is derived directly from raw_hex header bytes (not decoded.Path.Hops)
|
||||
// to guarantee the stored path always matches the raw bytes. This matters for
|
||||
// TRACE packets where decoded.Path.Hops is overwritten with payload hops (#886).
|
||||
//
|
||||
// Timestamp is server ingest time (time.Now()), NOT msg.Timestamp (#1370):
|
||||
// PR #1233 (commit 498fbc03) routed the envelope timestamp into
|
||||
// PacketData.Timestamp on the premise that uploader-stamped envelope time
|
||||
// was trustworthy. Issue #1370 disproved that premise — observers with
|
||||
// broken client clocks (staging Voodoo3 tx 304114: 4/5 obs stamped 18:42
|
||||
// while genuine receive was 01:42) poisoned transmissions.first_seen /
|
||||
// observations.timestamp and dragged the /api/channels lastActivity 7h
|
||||
// into the past. Packet ordering is owned by the server clock; client
|
||||
// clocks are untrusted. msg.Timestamp still flows into observer.last_seen
|
||||
// via UpsertObserverAt — that's #1233's MAX/MIN guarded path and is fine.
|
||||
func BuildPacketData(msg *MQTTPacketMessage, decoded *DecodedPacket, observerID, region string, regionKeys map[string][]byte) *PacketData {
|
||||
pathJSON := "[]"
|
||||
// For TRACE packets, path_json must be the payload-decoded route hops
|
||||
@@ -1377,7 +1416,7 @@ func BuildPacketData(msg *MQTTPacketMessage, decoded *DecodedPacket, observerID,
|
||||
|
||||
pd := &PacketData{
|
||||
RawHex: msg.Raw,
|
||||
Timestamp: msg.Timestamp,
|
||||
Timestamp: time.Now().UTC().Format(time.RFC3339), // #1370 (counters #1233)
|
||||
ObserverID: observerID,
|
||||
ObserverName: msg.Origin,
|
||||
SNR: msg.SNR,
|
||||
|
||||
+82
-9
@@ -554,18 +554,26 @@ func TestInsertTransmissionUpdatesObserverLastSeen(t *testing.T) {
|
||||
PathJSON: "[]",
|
||||
DecodedJSON: `{"type":"TXT_MSG"}`,
|
||||
}
|
||||
before := time.Now().Unix()
|
||||
if _, err := s.InsertTransmission(data); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
after := time.Now().Unix()
|
||||
|
||||
// Verify last_seen was updated
|
||||
// Verify last_seen was updated to INGEST time, not envelope time (#1465).
|
||||
var lastSeenAfter string
|
||||
s.db.QueryRow("SELECT last_seen FROM observers WHERE id = ?", "obs1").Scan(&lastSeenAfter)
|
||||
if lastSeenAfter == oldTime {
|
||||
t.Error("observer last_seen was NOT updated after packet insertion — low-traffic observers will appear offline")
|
||||
}
|
||||
if lastSeenAfter != "2026-03-25T01:00:00Z" {
|
||||
t.Errorf("expected last_seen=2026-03-25T01:00:00Z, got %s", lastSeenAfter)
|
||||
ls, err := time.Parse(time.RFC3339, lastSeenAfter)
|
||||
if err != nil {
|
||||
t.Fatalf("last_seen %q not RFC3339: %v", lastSeenAfter, err)
|
||||
}
|
||||
if ls.Unix() < before-5 || ls.Unix() > after+5 {
|
||||
t.Errorf("expected last_seen ≈ server now (in [%d, %d]), got %s (epoch %d). "+
|
||||
"observer.last_seen must use ingest time, not envelope time (#1465).",
|
||||
before, after, lastSeenAfter, ls.Unix())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -598,18 +606,26 @@ func TestLastPacketAtUpdatedOnPacketOnly(t *testing.T) {
|
||||
PathJSON: "[]",
|
||||
DecodedJSON: `{"type":"TXT_MSG"}`,
|
||||
}
|
||||
before := time.Now().Unix()
|
||||
if _, err := s.InsertTransmission(data); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
after := time.Now().Unix()
|
||||
|
||||
s.db.QueryRow("SELECT last_packet_at FROM observers WHERE id = ?", "obs1").Scan(&lastPacketAt)
|
||||
if !lastPacketAt.Valid {
|
||||
t.Fatal("expected last_packet_at to be non-NULL after InsertTransmission")
|
||||
}
|
||||
// InsertTransmission uses `now = data.Timestamp || time.Now()`, so last_packet_at
|
||||
// should match the packet's Timestamp when provided (same source-of-truth as last_seen).
|
||||
if lastPacketAt.String != "2026-04-24T12:00:00Z" {
|
||||
t.Errorf("expected last_packet_at=2026-04-24T12:00:00Z, got %s", lastPacketAt.String)
|
||||
// last_packet_at, like last_seen, is "when did the analyzer last receive a
|
||||
// packet from this observer" — an ingest-time question, independent of the
|
||||
// envelope timestamp. See #1465.
|
||||
lp, err := time.Parse(time.RFC3339, lastPacketAt.String)
|
||||
if err != nil {
|
||||
t.Fatalf("last_packet_at %q not RFC3339: %v", lastPacketAt.String, err)
|
||||
}
|
||||
if lp.Unix() < before-5 || lp.Unix() > after+5 {
|
||||
t.Errorf("expected last_packet_at ≈ server now (in [%d, %d]), got %s (epoch %d)",
|
||||
before, after, lastPacketAt.String, lp.Unix())
|
||||
}
|
||||
|
||||
// UpsertObserver again (status path) — last_packet_at should NOT change
|
||||
@@ -866,8 +882,12 @@ func TestBuildPacketData(t *testing.T) {
|
||||
if pkt.PayloadType != decoded.Header.PayloadType {
|
||||
t.Errorf("payloadType mismatch")
|
||||
}
|
||||
if pkt.Timestamp != "2026-05-16T10:00:00Z" {
|
||||
t.Errorf("timestamp=%s, want 2026-05-16T10:00:00Z", pkt.Timestamp)
|
||||
if pkt.Timestamp == "" {
|
||||
t.Errorf("timestamp must be populated (server ingest time, #1370 reverts #1233)")
|
||||
}
|
||||
if pkt.Timestamp == "2026-05-16T10:00:00Z" {
|
||||
t.Errorf("timestamp=%s; must NOT be the envelope value (#1370 reverts #1233's "+
|
||||
"premise that envelope timestamp is trustworthy — buggy client clocks poison ordering)", pkt.Timestamp)
|
||||
}
|
||||
if pkt.DecodedJSON == "" || pkt.DecodedJSON == "{}" {
|
||||
t.Error("decodedJSON should be populated")
|
||||
@@ -2844,3 +2864,56 @@ func TestBackfillPathJSONAsync_BracketRowsTerminate(t *testing.T) {
|
||||
t.Errorf("expected %d rows with path_json='[]', got %d", seedCount, bracketCount)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSchemaMultibyteSupColumns verifies that the multibyte_sup_v1 migration adds
|
||||
// the expected columns and is idempotent across multiple OpenStore calls.
|
||||
func TestSchemaMultibyteSupColumns(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dbPath := filepath.Join(dir, "test.db")
|
||||
|
||||
store, err := OpenStore(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("OpenStore: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
for _, table := range []string{"nodes", "inactive_nodes"} {
|
||||
rows, err := store.db.Query("PRAGMA table_info(" + table + ")")
|
||||
if err != nil {
|
||||
t.Fatalf("PRAGMA table_info(%s): %v", table, err)
|
||||
}
|
||||
var foundSup, foundEvid bool
|
||||
for rows.Next() {
|
||||
var cid int
|
||||
var name, colType string
|
||||
var notNull, pk int
|
||||
var dflt interface{}
|
||||
if rows.Scan(&cid, &name, &colType, ¬Null, &dflt, &pk) == nil {
|
||||
if name == "multibyte_sup" {
|
||||
foundSup = true
|
||||
}
|
||||
if name == "multibyte_evidence" {
|
||||
foundEvid = true
|
||||
}
|
||||
}
|
||||
}
|
||||
rows.Close()
|
||||
if !foundSup {
|
||||
t.Errorf("table %s: multibyte_sup column missing", table)
|
||||
}
|
||||
if !foundEvid {
|
||||
t.Errorf("table %s: multibyte_evidence column missing", table)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify migration is present. As of #1324 follow-up the migration
|
||||
// lives in internal/dbschema (column-probe + idempotent ALTER), not
|
||||
// in the legacy _migrations marker table — so we just re-assert the
|
||||
// columns exist and the second OpenStore is a no-op.
|
||||
store.Close()
|
||||
store2, err := OpenStore(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("OpenStore (second open): %v", err)
|
||||
}
|
||||
store2.Close()
|
||||
}
|
||||
|
||||
+17
-1
@@ -493,6 +493,22 @@ func decryptChannelMessage(ciphertextHex, macHex, channelKeyHex string) (*channe
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// knownChannelCasing maps known channel keys to their canonical display names.
|
||||
// Only well-known channels are normalized — custom/user channels are left as-is.
|
||||
var knownChannelCasing = map[string]string{
|
||||
"public": "Public",
|
||||
}
|
||||
|
||||
// normalizeChannelName fixes casing for well-known channel names.
|
||||
// Only normalizes names that appear in knownChannelCasing (e.g. "public" → "Public").
|
||||
// Custom channel names are left untouched since we can't know the intended casing.
|
||||
func normalizeChannelName(name string) string {
|
||||
if corrected, ok := knownChannelCasing[strings.ToLower(name)]; ok {
|
||||
return corrected
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
func decodeGrpTxt(buf []byte, channelKeys map[string]string) Payload {
|
||||
if len(buf) < 3 {
|
||||
return Payload{Type: "GRP_TXT", Error: "too short", RawHex: hex.EncodeToString(buf)}
|
||||
@@ -517,7 +533,7 @@ func decodeGrpTxt(buf []byte, channelKeys map[string]string) Payload {
|
||||
}
|
||||
return Payload{
|
||||
Type: "CHAN",
|
||||
Channel: name,
|
||||
Channel: normalizeChannelName(name),
|
||||
ChannelHash: channelHash,
|
||||
ChannelHashHex: channelHashHex,
|
||||
DecryptionStatus: "decrypted",
|
||||
|
||||
@@ -47,3 +47,7 @@ require (
|
||||
require github.com/meshcore-analyzer/prunequeue v0.0.0
|
||||
|
||||
replace github.com/meshcore-analyzer/prunequeue => ../../internal/prunequeue
|
||||
|
||||
require github.com/meshcore-analyzer/mbcapqueue v0.0.0
|
||||
|
||||
replace github.com/meshcore-analyzer/mbcapqueue => ../../internal/mbcapqueue
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
package main
|
||||
|
||||
// Regression test for issue #1370 — counters PR #1233 (commit 498fbc03).
|
||||
//
|
||||
// PR #1233 made the ingestor use the MQTT envelope's "timestamp" field as
|
||||
// transmissions.first_seen / observations.timestamp, on the premise that
|
||||
// uploaders stamp it at radio receive and the value is trustworthy.
|
||||
//
|
||||
// That premise FAILS for observers whose own clock is wrong. Staging
|
||||
// Voodoo3 tx 304114 in channel #test had 5 observations:
|
||||
// - 4 from Voodoo3 stamped "18:42" — Voodoo3's broken client clock,
|
||||
// - 1 from another observer stamped "01:42" — the actual receive time.
|
||||
// Voodoo3 ingested first, so first_seen locked at "18:42" and the
|
||||
// /api/channels row showed the channel as last-active 7h+ in the past.
|
||||
//
|
||||
// Fix: revert the storage path — packet/observation timestamps are
|
||||
// server ingest time (time.Now() at the ingestor). Envelope timestamp
|
||||
// stays usable for observer.last_seen (PR #1233's MAX/MIN guard there
|
||||
// is fine and unrelated to the channel-ordering bug).
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Raw packet path: envelope reports timestamp 7h in the past
|
||||
// (simulating Voodoo3's broken client clock). After ingest,
|
||||
// transmissions.first_seen and observations.timestamp must reflect
|
||||
// SERVER wall clock, not the bogus envelope value.
|
||||
func TestHandleMessage_PacketTimestamp_IgnoresStaleEnvelope_1370(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
source := MQTTSource{Name: "test"}
|
||||
|
||||
stale := time.Now().UTC().Add(-7 * time.Hour).Format(time.RFC3339)
|
||||
before := time.Now().Unix()
|
||||
|
||||
rawHex := "0A00D69FD7A5A7475DB07337749AE61FA53A4788E976"
|
||||
payload := []byte(`{"raw":"` + rawHex + `","SNR":5.5,"RSSI":-100.0,"origin":"voodoo3","timestamp":"` + stale + `"}`)
|
||||
msg := &mockMessage{topic: "meshcore/SJC/voodoo3/packets", payload: payload}
|
||||
|
||||
handleMessage(store, "test", source, msg, nil, nil, &Config{})
|
||||
after := time.Now().Unix()
|
||||
|
||||
// ─── transmissions.first_seen ───────────────────────────────────────
|
||||
var firstSeen string
|
||||
if err := store.db.QueryRow(`SELECT first_seen FROM transmissions LIMIT 1`).Scan(&firstSeen); err != nil {
|
||||
t.Fatalf("scan first_seen: %v", err)
|
||||
}
|
||||
fsParsed, err := time.Parse(time.RFC3339, firstSeen)
|
||||
if err != nil {
|
||||
t.Fatalf("first_seen %q not RFC3339: %v", firstSeen, err)
|
||||
}
|
||||
if fsParsed.Unix() < before-5 || fsParsed.Unix() > after+5 {
|
||||
t.Errorf("transmissions.first_seen = %q (epoch %d); want in [%d, %d] (server wall clock). "+
|
||||
"Envelope reported stale %q (7h ago) — PR #1233's premise that envelope timestamp is trustworthy is FALSE for buggy-clock observers. Issue #1370.",
|
||||
firstSeen, fsParsed.Unix(), before, after, stale)
|
||||
}
|
||||
|
||||
// ─── observations.timestamp (epoch) ─────────────────────────────────
|
||||
var obsTs int64
|
||||
if err := store.db.QueryRow(`SELECT timestamp FROM observations LIMIT 1`).Scan(&obsTs); err != nil {
|
||||
t.Fatalf("scan observations.timestamp: %v", err)
|
||||
}
|
||||
if obsTs < before-5 || obsTs > after+5 {
|
||||
t.Errorf("observations.timestamp = %d; want in [%d, %d] (server wall clock). Envelope stale = %q. Issue #1370.",
|
||||
obsTs, before, after, stale)
|
||||
}
|
||||
}
|
||||
|
||||
// Channel-message (BLE companion) path: envelope timestamp stale → stored
|
||||
// transmissions.first_seen must still be server wall clock.
|
||||
func TestHandleMessage_ChannelPath_PacketTimestamp_IgnoresStaleEnvelope_1370(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
source := MQTTSource{Name: "test"}
|
||||
|
||||
stale := time.Now().UTC().Add(-7 * time.Hour).Format(time.RFC3339)
|
||||
before := time.Now().Unix()
|
||||
|
||||
payload := []byte(`{"text":"Voodoo3: tst hmdpt","channel_idx":3,"SNR":5.0,"RSSI":-95,"timestamp":"` + stale + `","sender_timestamp":` + strconv.FormatInt(time.Now().Unix(), 10) + `}`)
|
||||
msg := &mockMessage{topic: "meshcore/message/channel/3", payload: payload}
|
||||
|
||||
handleMessage(store, "test", source, msg, nil, nil, &Config{})
|
||||
after := time.Now().Unix()
|
||||
|
||||
var firstSeen string
|
||||
if err := store.db.QueryRow(`SELECT first_seen FROM transmissions LIMIT 1`).Scan(&firstSeen); err != nil {
|
||||
t.Fatalf("scan first_seen: %v", err)
|
||||
}
|
||||
fsParsed, err := time.Parse(time.RFC3339, firstSeen)
|
||||
if err != nil {
|
||||
t.Fatalf("first_seen %q not RFC3339: %v", firstSeen, err)
|
||||
}
|
||||
if fsParsed.Unix() < before-5 || fsParsed.Unix() > after+5 {
|
||||
t.Errorf("channel-path transmissions.first_seen = %q (epoch %d); want in [%d, %d] (server wall clock). Envelope stale = %q. Issue #1370.",
|
||||
firstSeen, fsParsed.Unix(), before, after, stale)
|
||||
}
|
||||
}
|
||||
|
||||
// DM (BLE companion direct-message) path: same revert applies.
|
||||
func TestHandleMessage_DMPath_PacketTimestamp_IgnoresStaleEnvelope_1370(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
source := MQTTSource{Name: "test"}
|
||||
|
||||
stale := time.Now().UTC().Add(-7 * time.Hour).Format(time.RFC3339)
|
||||
before := time.Now().Unix()
|
||||
|
||||
payload := []byte(`{"text":"Voodoo3: hello","SNR":5.0,"RSSI":-95,"timestamp":"` + stale + `"}`)
|
||||
msg := &mockMessage{topic: "meshcore/message/direct/voodoo3", payload: payload}
|
||||
|
||||
handleMessage(store, "test", source, msg, nil, nil, &Config{})
|
||||
after := time.Now().Unix()
|
||||
|
||||
var firstSeen string
|
||||
if err := store.db.QueryRow(`SELECT first_seen FROM transmissions LIMIT 1`).Scan(&firstSeen); err != nil {
|
||||
t.Fatalf("scan first_seen: %v", err)
|
||||
}
|
||||
fsParsed, err := time.Parse(time.RFC3339, firstSeen)
|
||||
if err != nil {
|
||||
t.Fatalf("first_seen %q not RFC3339: %v", firstSeen, err)
|
||||
}
|
||||
if fsParsed.Unix() < before-5 || fsParsed.Unix() > after+5 {
|
||||
t.Errorf("DM-path transmissions.first_seen = %q (epoch %d); want in [%d, %d] (server wall clock). Envelope stale = %q. Issue #1370.",
|
||||
firstSeen, fsParsed.Unix(), before, after, stale)
|
||||
}
|
||||
}
|
||||
+124
-23
@@ -150,6 +150,21 @@ func main() {
|
||||
log.Printf("[prune] auto-prune enabled: packets older than %d days will be removed daily", packetDays)
|
||||
}
|
||||
|
||||
// Hourly WAL checkpoint to prevent unbounded WAL growth.
|
||||
// TRUNCATE resets the WAL file to zero bytes when all frames are flushed;
|
||||
// if the server's read connection holds frames, remaining pages stay in the
|
||||
// WAL until the next tick. Staggered 30s after startup to avoid competing
|
||||
// with the initial burst of ingest writes.
|
||||
walCheckpointTicker := time.NewTicker(1 * time.Hour)
|
||||
go func() {
|
||||
time.Sleep(30 * time.Second)
|
||||
store.Checkpoint()
|
||||
for range walCheckpointTicker.C {
|
||||
store.Checkpoint()
|
||||
}
|
||||
}()
|
||||
log.Printf("[db] WAL checkpoint scheduled every 1h")
|
||||
|
||||
// Daily neighbor_edges retention (#1287 — moved from cmd/server).
|
||||
{
|
||||
nDays := cfg.NeighborEdgesDaysOrDefault()
|
||||
@@ -197,6 +212,25 @@ func main() {
|
||||
// endpoint (#1120). Best-effort; never fatal.
|
||||
StartStatsFileWriter(store, time.Second)
|
||||
|
||||
// Multi-byte capability persister (#1324 follow-up): the server's
|
||||
// analytics cycle publishes a snapshot file via internal/mbcapqueue
|
||||
// (it cannot UPDATE itself, mode=ro since #1289). The ingestor
|
||||
// applies the snapshot here every 5 minutes — derived/cached
|
||||
// columns, ingestor owns the write.
|
||||
multibytePersistTicker := time.NewTicker(5 * time.Minute)
|
||||
go func() {
|
||||
time.Sleep(2 * time.Minute) // stagger after analytics warmup
|
||||
if _, err := store.RunMultibyteCapPersist(); err != nil {
|
||||
log.Printf("[multibyte-persist] error: %v", err)
|
||||
}
|
||||
for range multibytePersistTicker.C {
|
||||
if _, err := store.RunMultibyteCapPersist(); err != nil {
|
||||
log.Printf("[multibyte-persist] error: %v", err)
|
||||
}
|
||||
}
|
||||
}()
|
||||
log.Printf("[multibyte-persist] enabled (interval=5m)")
|
||||
|
||||
// Neighbor-edges builder (#1287 — Option 4): ingestor owns
|
||||
// neighbor_edges writes. Runs every 60s. Server reads the snapshot
|
||||
// via cmd/server/neighbor_recomputer.go on the same cadence.
|
||||
@@ -276,6 +310,18 @@ func main() {
|
||||
// Registration BEFORE Connect so the attempt counter is available
|
||||
// to OnConnectAttempt on the very first dial.
|
||||
liveness.IsConnectedFn = client.IsConnected
|
||||
// #1335: wire force-reconnect so the watchdog can drop a
|
||||
// half-open TCP socket and re-dial when paho.IsConnected==true
|
||||
// but no messages have flowed past the stall threshold. Throttled
|
||||
// per source by the watchdog itself (forceReconnectThrottle).
|
||||
// Disconnect(250) gives in-flight publishes 250ms to drain;
|
||||
// Connect() returns immediately and paho's reconnect machinery
|
||||
// takes over from there. Captured-by-value `client` is the same
|
||||
// pointer used everywhere else for this source.
|
||||
liveness.ForceReconnectFn = func() {
|
||||
client.Disconnect(250)
|
||||
client.Connect()
|
||||
}
|
||||
// PR #1216 r2 item 3: tag collisions used to log.Fatalf, which
|
||||
// killed the entire ingestor over one config typo and recreated
|
||||
// the #1212 total-ingest-stop class this PR exists to prevent.
|
||||
@@ -342,6 +388,7 @@ func main() {
|
||||
}
|
||||
statsTicker.Stop()
|
||||
pruneQueueTicker.Stop()
|
||||
walCheckpointTicker.Stop()
|
||||
stopWatchdog()
|
||||
store.LogStats() // final stats on shutdown
|
||||
for _, c := range clients {
|
||||
@@ -371,7 +418,16 @@ func buildMQTTOpts(source MQTTSource) *mqtt.ClientOptions {
|
||||
SetOrderMatters(true).
|
||||
SetMaxReconnectInterval(30 * time.Second).
|
||||
SetConnectTimeout(10 * time.Second).
|
||||
SetWriteTimeout(10 * time.Second)
|
||||
SetWriteTimeout(10 * time.Second).
|
||||
// #1335: TCP-level keepalive surfaces a half-open socket within
|
||||
// ~30-60s instead of waiting for the application-level watchdog
|
||||
// (5m) to notice no messages. paho's MQTT PINGREQ uses this
|
||||
// interval too — if the broker's PINGRESP doesn't arrive,
|
||||
// ConnectionLost fires and auto-reconnect kicks in. Was unset
|
||||
// (paho default 30s actually — making this explicit so it can't
|
||||
// drift, and so operators reading the code know it's intentional
|
||||
// per the #1335 RCA).
|
||||
SetKeepAlive(30 * time.Second)
|
||||
|
||||
opts.SetConnectionAttemptHandler(func(broker *url.URL, tlsCfg *tls.Config) *tls.Config {
|
||||
// Look up the per-source liveness state (registered in main) so we
|
||||
@@ -396,7 +452,9 @@ func buildMQTTOpts(source MQTTSource) *mqtt.ClientOptions {
|
||||
}
|
||||
if source.RejectUnauthorized != nil && !*source.RejectUnauthorized {
|
||||
opts.SetTLSConfig(&tls.Config{InsecureSkipVerify: true})
|
||||
} else if strings.HasPrefix(source.Broker, "ssl://") {
|
||||
} else if strings.HasPrefix(source.Broker, "ssl://") || strings.HasPrefix(source.Broker, "wss://") {
|
||||
// TLS with system CA pool — valid for ssl:// MQTT brokers and
|
||||
// wss:// WebSocket brokers behind a publicly-trusted certificate.
|
||||
opts.SetTLSConfig(&tls.Config{})
|
||||
}
|
||||
return opts
|
||||
@@ -447,7 +505,11 @@ func handleMessage(store *Store, tag string, source MQTTSource, m mqtt.Message,
|
||||
name, _ := msg["origin"].(string)
|
||||
iata := parts[1]
|
||||
meta := extractObserverMeta(msg)
|
||||
if err := store.UpsertObserverAt(observerID, name, iata, meta, resolveRxTime(msg, tag)); err != nil {
|
||||
// observer.last_seen is "when did the analyzer last hear from this
|
||||
// observer" — fundamentally an ingest-time question. Passing "" makes
|
||||
// UpsertObserverAt use time.Now(), independent of the envelope timestamp
|
||||
// (which can be stale/skewed even when well-formed). See #1465.
|
||||
if err := store.UpsertObserverAt(observerID, name, iata, meta, ""); err != nil {
|
||||
log.Printf("MQTT [%s] observer status error: %v", tag, err)
|
||||
}
|
||||
// Insert metrics sample from status message
|
||||
@@ -669,7 +731,10 @@ func handleMessage(store *Store, tag string, source MQTTSource, m mqtt.Message,
|
||||
if mqttMsg.Region != "" {
|
||||
effectiveRegion = mqttMsg.Region
|
||||
}
|
||||
if err := store.UpsertObserverAt(observerID, origin, effectiveRegion, nil, mqttMsg.Timestamp); err != nil {
|
||||
// Same as the status-path call above: observer.last_seen is ingest
|
||||
// time, not envelope time. Per-packet rxTime (stored in observations
|
||||
// via InsertTransmission) still uses envelope time. See #1465.
|
||||
if err := store.UpsertObserverAt(observerID, origin, effectiveRegion, nil, ""); err != nil {
|
||||
log.Printf("MQTT [%s] observer upsert error: %v", tag, err)
|
||||
}
|
||||
}
|
||||
@@ -714,7 +779,6 @@ func handleMessage(store *Store, tag string, source MQTTSource, m mqtt.Message,
|
||||
decodedJSON, _ := json.Marshal(channelMsg)
|
||||
|
||||
ingestNow := time.Now().UTC().Format(time.RFC3339)
|
||||
rxTime := resolveRxTime(msg, tag)
|
||||
hashInput := fmt.Sprintf("ch:%s:%s:%s", channelIdx, text, ingestNow)
|
||||
h := sha256.Sum256([]byte(hashInput))
|
||||
hash := hex.EncodeToString(h[:])[:16]
|
||||
@@ -755,7 +819,7 @@ func handleMessage(store *Store, tag string, source MQTTSource, m mqtt.Message,
|
||||
}
|
||||
|
||||
pktData := &PacketData{
|
||||
Timestamp: rxTime,
|
||||
Timestamp: ingestNow, // #1370 (counters #1233): server ingest time, not envelope rxTime
|
||||
ObserverID: "companion",
|
||||
ObserverName: "L1 Pro (BLE)",
|
||||
SNR: snr,
|
||||
@@ -808,7 +872,6 @@ func handleMessage(store *Store, tag string, source MQTTSource, m mqtt.Message,
|
||||
decodedJSON, _ := json.Marshal(dm)
|
||||
|
||||
ingestNow := time.Now().UTC().Format(time.RFC3339)
|
||||
rxTime := resolveRxTime(msg, tag)
|
||||
hashInput := fmt.Sprintf("dm:%s:%s", text, ingestNow)
|
||||
h := sha256.Sum256([]byte(hashInput))
|
||||
hash := hex.EncodeToString(h[:])[:16]
|
||||
@@ -849,7 +912,7 @@ func handleMessage(store *Store, tag string, source MQTTSource, m mqtt.Message,
|
||||
}
|
||||
|
||||
pktData := &PacketData{
|
||||
Timestamp: rxTime,
|
||||
Timestamp: ingestNow, // #1370 (counters #1233): server ingest time, not envelope rxTime
|
||||
ObserverID: "companion",
|
||||
ObserverName: "L1 Pro (BLE)",
|
||||
SNR: snr,
|
||||
@@ -1048,7 +1111,7 @@ func resolveRxTime(msg map[string]interface{}, tag string) string {
|
||||
if raw == "" {
|
||||
return now.Format(time.RFC3339)
|
||||
}
|
||||
t, err := parseEnvelopeTime(raw)
|
||||
t, naive, err := parseEnvelopeTime(raw)
|
||||
if err != nil {
|
||||
log.Printf("MQTT [%s] unparseable timestamp %q, using ingest time", tag, raw)
|
||||
return now.Format(time.RFC3339)
|
||||
@@ -1067,13 +1130,30 @@ func resolveRxTime(msg map[string]interface{}, tag string) string {
|
||||
log.Printf("MQTT [%s] stale timestamp %q (>30d old), using ingest time", tag, raw)
|
||||
return now.Format(time.RFC3339)
|
||||
}
|
||||
// Soft clamp: naive local-clock timestamps from UTC+N observers are parsed
|
||||
// as-if UTC, making them appear N hours in the future. A UTC+2 observer's
|
||||
// live packet looks 2h ahead, but it is NOT a buffered packet — the whole
|
||||
// point of using rxTime is to preserve the past timestamp for packets that
|
||||
// were buffered offline. If rxTime is ahead of now, the packet is live and
|
||||
// ingest time is the correct value. This also prevents storing future
|
||||
// timestamps that would show ⚠️ in the UI for every packet from UTC+N nodes.
|
||||
// Symmetric naive-timestamp clamp (issue #1463). Naive (zone-less) ISO
|
||||
// values from observers in non-UTC zones are parsed as-if UTC, leaving a
|
||||
// residual offset equal to the observer's UTC offset:
|
||||
// - UTC+N observer → value appears N hours in the future
|
||||
// - UTC-N observer → value appears N hours in the past
|
||||
// The past case was silently stored verbatim, poisoning last_seen and
|
||||
// rendering UTC-N observers perpetually "Stale" in the UI. Collapse any
|
||||
// naive value more than 15 min off server-now to now() — well-behaved
|
||||
// observers (Z-suffixed or explicit offset) are untouched regardless of
|
||||
// skew so legitimate buffered uploads remain accurate.
|
||||
const naiveTolerance = 15 * time.Minute
|
||||
if naive {
|
||||
delta := t.Sub(now)
|
||||
if delta < 0 {
|
||||
delta = -delta
|
||||
}
|
||||
if delta > naiveTolerance {
|
||||
log.Printf("MQTT [%s] naive timestamp %q off by %s, using ingest time", tag, raw, delta.Round(time.Second))
|
||||
return now.Format(time.RFC3339)
|
||||
}
|
||||
}
|
||||
// Legacy soft clamp for zone-aware near-future values: any value ahead of
|
||||
// now is from a slightly skewed observer clock — collapse to now so we
|
||||
// don't render ⚠️ in the UI for live packets from those nodes.
|
||||
if t.After(now) {
|
||||
return now.Format(time.RFC3339)
|
||||
}
|
||||
@@ -1083,19 +1163,22 @@ func resolveRxTime(msg map[string]interface{}, tag string) string {
|
||||
// parseEnvelopeTime parses the MQTT envelope timestamp. Two on-wire forms
|
||||
// occur: zone-aware ISO8601 (RFC3339), and a naive local-clock ISO string
|
||||
// with no zone (python datetime.isoformat()). Zone-aware layouts are tried
|
||||
// first; naive layouts are assumed UTC, leaving a bounded residual offset
|
||||
// equal to the observer's UTC offset for naive-timestamp uploaders.
|
||||
func parseEnvelopeTime(s string) (time.Time, error) {
|
||||
// first; naive layouts are assumed UTC but the caller is informed via the
|
||||
// returned `naive` flag so it can apply a symmetric clamp (see issue #1463).
|
||||
func parseEnvelopeTime(s string) (time.Time, bool, error) {
|
||||
// Zone-aware first — RFC3339 demands Z or ±HH:MM.
|
||||
if t, err := time.Parse(time.RFC3339, s); err == nil {
|
||||
return t, false, nil
|
||||
}
|
||||
for _, layout := range []string{
|
||||
time.RFC3339, // 2026-05-16T10:00:00Z / +02:00
|
||||
"2006-01-02T15:04:05.999999", // python isoformat w/ microseconds
|
||||
"2006-01-02T15:04:05", // naive ISO
|
||||
} {
|
||||
if t, err := time.Parse(layout, s); err == nil {
|
||||
return t, nil
|
||||
return t, true, nil
|
||||
}
|
||||
}
|
||||
return time.Time{}, fmt.Errorf("unrecognized timestamp layout: %q", s)
|
||||
return time.Time{}, false, fmt.Errorf("unrecognized timestamp layout: %q", s)
|
||||
}
|
||||
|
||||
// deriveHashtagChannelKey derives an AES-128 key from a channel name.
|
||||
@@ -1157,7 +1240,25 @@ func loadChannelKeys(cfg *Config, configPath string) map[string]string {
|
||||
|
||||
// 3. Explicit config keys (highest priority — overrides rainbow + derived)
|
||||
for k, v := range cfg.ChannelKeys {
|
||||
keys[k] = v
|
||||
normalized := normalizeChannelName(k)
|
||||
if normalized != k {
|
||||
log.Printf("[channels] Normalizing known channel key %q → %q for display", k, normalized)
|
||||
}
|
||||
// Detect config collision: if both "public" and "Public" are present,
|
||||
// the normalized key collides. Resolve deterministically: prefer the
|
||||
// canonical (already-normalized) form over the lowercase variant.
|
||||
if _, dupe := keys[normalized]; dupe {
|
||||
// If the incoming key IS the canonical form, it wins (overwrite).
|
||||
// If the incoming key is a non-canonical form (e.g., "public"), keep existing.
|
||||
if k == normalized {
|
||||
log.Printf("[channels] Resolving duplicate %q: canonical form wins over non-canonical", normalized)
|
||||
keys[normalized] = v
|
||||
} else {
|
||||
log.Printf("[channels] WARNING: duplicate channel key %q — config has %q normalizing to %q, keeping canonical value", normalized, k, normalized)
|
||||
}
|
||||
} else {
|
||||
keys[normalized] = v
|
||||
}
|
||||
}
|
||||
|
||||
return keys
|
||||
|
||||
@@ -14,6 +14,10 @@ import (
|
||||
// shift, infrequent enough not to spam ops chat.
|
||||
const livenessHeartbeatInterval = time.Hour
|
||||
|
||||
// forceReconnectThrottle is the minimum interval between forced
|
||||
// reconnects on the SAME source. See processLivenessTransition.
|
||||
const forceReconnectThrottle = 60 * time.Second
|
||||
|
||||
// LivenessKind enumerates the watchdog verdicts for a source. Edge-triggered
|
||||
// transitions use this to decide whether to emit (and what severity).
|
||||
type LivenessKind int
|
||||
@@ -63,6 +67,22 @@ type SourceLivenessState struct {
|
||||
StartedAt int64 // atomic; unix seconds when the source was registered / last reconnected (transient-stall tracking)
|
||||
LastAlertUnix int64 // atomic; unix seconds of last emit (WARN or heartbeat); 0 means quiet
|
||||
IsConnectedFn func() bool
|
||||
// ForceReconnectFn (#1335) is called by the watchdog when a source
|
||||
// transitions INTO LivenessStalled. It must force the paho client
|
||||
// to drop its current TCP socket and re-establish (typically
|
||||
// client.Disconnect(250) followed by client.Connect()). Half-open
|
||||
// TCP sockets (Azure NAT idle timeout) report IsConnected==true so
|
||||
// paho's own auto-reconnect never fires; this is the recovery path.
|
||||
// May be nil (tests, or sources registered before wiring); the
|
||||
// watchdog must treat that as a safe no-op. Invocations are
|
||||
// throttled at forceReconnectThrottle per source so a
|
||||
// stall→reconnect→re-stall loop self-recovers without hammering
|
||||
// the broker.
|
||||
ForceReconnectFn func()
|
||||
// LastForceReconnectUnix is the unix-seconds timestamp of the most
|
||||
// recent forced reconnect for this source; the watchdog reads it
|
||||
// to enforce forceReconnectThrottle. atomic.
|
||||
LastForceReconnectUnix int64
|
||||
// AttemptCount is incremented on every TCP/TLS connection attempt. Used
|
||||
// by ConnectionAttemptHandler to log attempt # independent of paho's
|
||||
// internal reconnect-loop state. atomic.
|
||||
@@ -272,12 +292,30 @@ func processLivenessTransition(s *SourceLivenessState, kind LivenessKind, msg st
|
||||
// First detection — fire WARN edge.
|
||||
emit(msg)
|
||||
atomic.StoreInt64(&s.LastAlertUnix, now.Unix())
|
||||
// #1335: ONLY LivenessStalled (paho reports connected but no
|
||||
// messages past threshold — classic half-open TCP) gets
|
||||
// force-reconnected. LivenessNeverReceived is almost always
|
||||
// an ACL deny / wrong channel hash — a new TCP socket won't
|
||||
// fix it and would just churn the broker. The distinct
|
||||
// "NEVER received" alarm is the right operator signal for
|
||||
// that class.
|
||||
if kind == LivenessStalled {
|
||||
maybeForceReconnect(s, now, emit)
|
||||
}
|
||||
return
|
||||
}
|
||||
// Already alerted; only re-emit on heartbeat interval to avoid log flood.
|
||||
if now.Sub(time.Unix(lastAlert, 0)) >= livenessHeartbeatInterval {
|
||||
emit(fmt.Sprintf("MQTT [%s] WATCHDOG heartbeat: still stalled — %s", s.Tag, msg))
|
||||
atomic.StoreInt64(&s.LastAlertUnix, now.Unix())
|
||||
// Heartbeat re-emit on a still-Stalled source: try another
|
||||
// force-reconnect IF the throttle window has elapsed. Under
|
||||
// a persistent broker issue this caps at one attempt per
|
||||
// heartbeat (1h) — orders of magnitude under any rate
|
||||
// limit and well within "don't hammer the broker".
|
||||
if kind == LivenessStalled {
|
||||
maybeForceReconnect(s, now, emit)
|
||||
}
|
||||
}
|
||||
case LivenessOK:
|
||||
if lastAlert != 0 {
|
||||
@@ -294,3 +332,31 @@ func processLivenessTransition(s *SourceLivenessState, kind LivenessKind, msg st
|
||||
}
|
||||
}
|
||||
|
||||
// maybeForceReconnect invokes ForceReconnectFn IFF (a) one is wired and
|
||||
// (b) the throttle window (forceReconnectThrottle) has elapsed since
|
||||
// the most recent forced reconnect for this source. Logs WATCHDOG
|
||||
// telemetry before/after so operators can correlate the reconnect with
|
||||
// downstream paho ConnectionAttempt/OnConnect lines.
|
||||
func maybeForceReconnect(s *SourceLivenessState, now time.Time, emit func(...any)) {
|
||||
if s.ForceReconnectFn == nil {
|
||||
return
|
||||
}
|
||||
lastForce := atomic.LoadInt64(&s.LastForceReconnectUnix)
|
||||
if lastForce != 0 && now.Sub(time.Unix(lastForce, 0)) < forceReconnectThrottle {
|
||||
emit(fmt.Sprintf("MQTT [%s] WATCHDOG suppressing forced reconnect (last attempt %s ago, throttle %s)",
|
||||
s.Tag, now.Sub(time.Unix(lastForce, 0)).Round(time.Second), forceReconnectThrottle))
|
||||
return
|
||||
}
|
||||
atomic.StoreInt64(&s.LastForceReconnectUnix, now.Unix())
|
||||
emit(fmt.Sprintf("MQTT [%s] WATCHDOG forcing reconnect (half-open TCP suspected — paho.IsConnected==true but no messages)", s.Tag))
|
||||
// Run in a goroutine: ForceReconnectFn typically calls
|
||||
// client.Disconnect(250) which blocks up to 250ms, then
|
||||
// client.Connect() which can block on the connect timeout. The
|
||||
// watchdog goroutine must not stall a per-tick scan over a single
|
||||
// slow source.
|
||||
go func() {
|
||||
s.ForceReconnectFn()
|
||||
emit(fmt.Sprintf("MQTT [%s] WATCHDOG reconnect attempt issued", s.Tag))
|
||||
}()
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Issue #1335 — staging's lincomatic source stalls: paho reports
|
||||
// IsConnected==true but no messages arrive for 1h+. The PR #1216
|
||||
// watchdog DETECTS this (LivenessStalled) but only LOGS — it never
|
||||
// forces paho to drop the half-open TCP socket and reconnect, so the
|
||||
// source stays silently broken until container restart.
|
||||
//
|
||||
// Fix: on transition INTO LivenessStalled, invoke a per-source
|
||||
// ForceReconnectFn (wired in main.go to client.Disconnect(250) +
|
||||
// client.Connect()). Throttled by forceReconnectThrottle so a
|
||||
// stall→reconnect→re-stall loop self-recovers without hammering the
|
||||
// broker.
|
||||
|
||||
// RED on master: ForceReconnectFn is never invoked because the
|
||||
// transition engine does not call it. After the fix, the WARN edge on
|
||||
// LivenessStalled MUST fire force-reconnect exactly once.
|
||||
func TestMQTTStallWatchdog_ForceReconnectOnStallEdge(t *testing.T) {
|
||||
defer snapshotAndResetRegistry(t)()
|
||||
|
||||
now := time.Now()
|
||||
var reconnectCount atomic.Int32
|
||||
s := &SourceLivenessState{
|
||||
Tag: "stalled-half-open",
|
||||
Broker: "tcp://halfopen.example:1883",
|
||||
IsConnectedFn: func() bool { return true },
|
||||
ForceReconnectFn: func() { reconnectCount.Add(1) },
|
||||
}
|
||||
atomic.StoreInt64(&s.LastMessageUnix, now.Add(-10*time.Minute).Unix())
|
||||
atomic.StoreInt64(&s.StartedAt, now.Add(-20*time.Minute).Unix())
|
||||
if err := registerLivenessState(s); err != nil {
|
||||
t.Fatalf("setup: %v", err)
|
||||
}
|
||||
|
||||
var mu sync.Mutex
|
||||
var emits []string
|
||||
emit := func(args ...any) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if len(args) > 0 {
|
||||
if str, ok := args[0].(string); ok {
|
||||
emits = append(emits, str)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
processLivenessTransition(s, LivenessStalled, "10m silent", now, emit)
|
||||
|
||||
// ForceReconnectFn runs in a goroutine (the production code can't
|
||||
// block the watchdog tick on a slow Disconnect+Connect). Wait
|
||||
// briefly for it to land before asserting.
|
||||
waitForReconnect(t, &reconnectCount, 1, 2*time.Second)
|
||||
|
||||
if got := reconnectCount.Load(); got != 1 {
|
||||
t.Fatalf("LivenessStalled transition MUST force-reconnect exactly once; got %d invocations (emits=%v)", got, emits)
|
||||
}
|
||||
}
|
||||
|
||||
// Throttle: a second LivenessStalled transition within the throttle
|
||||
// window MUST NOT fire a second reconnect (no broker hammering).
|
||||
func TestMQTTStallWatchdog_ForceReconnectThrottled(t *testing.T) {
|
||||
defer snapshotAndResetRegistry(t)()
|
||||
|
||||
now := time.Now()
|
||||
var reconnectCount atomic.Int32
|
||||
s := &SourceLivenessState{
|
||||
Tag: "throttled",
|
||||
Broker: "tcp://x:1883",
|
||||
IsConnectedFn: func() bool { return true },
|
||||
ForceReconnectFn: func() { reconnectCount.Add(1) },
|
||||
}
|
||||
if err := registerLivenessState(s); err != nil {
|
||||
t.Fatalf("setup: %v", err)
|
||||
}
|
||||
|
||||
emit := func(args ...any) {}
|
||||
|
||||
// First stall edge → fires.
|
||||
processLivenessTransition(s, LivenessStalled, "stall 1", now, emit)
|
||||
waitForReconnect(t, &reconnectCount, 1, 2*time.Second)
|
||||
// Simulate paho reconnect cycle: MarkReconnected clears the alert
|
||||
// cooldown, then the source goes stalled again 5s later.
|
||||
s.MarkReconnected(now.Add(5 * time.Second))
|
||||
processLivenessTransition(s, LivenessStalled, "stall 2", now.Add(10*time.Second), emit)
|
||||
// Give a stray goroutine a chance to land (it shouldn't, due to throttle).
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
if got := reconnectCount.Load(); got != 1 {
|
||||
t.Fatalf("force-reconnect MUST be throttled within %s; got %d invocations", forceReconnectThrottle, got)
|
||||
}
|
||||
|
||||
// After the throttle window, a fresh stall edge MAY fire again.
|
||||
s.MarkReconnected(now.Add(30 * time.Second))
|
||||
processLivenessTransition(s, LivenessStalled, "stall 3", now.Add(forceReconnectThrottle+30*time.Second), emit)
|
||||
waitForReconnect(t, &reconnectCount, 2, 2*time.Second)
|
||||
if got := reconnectCount.Load(); got != 2 {
|
||||
t.Fatalf("after throttle window, force-reconnect must re-arm; got %d invocations", got)
|
||||
}
|
||||
}
|
||||
|
||||
// NeverReceived (cold-start ACL-deny / never-flowed) MUST NOT
|
||||
// force-reconnect. A SUBSCRIBE ACL deny is not fixed by a new TCP
|
||||
// socket; reconnecting just churns the broker. Operators get the
|
||||
// distinct "NEVER received" alarm so they can address the ACL.
|
||||
func TestMQTTStallWatchdog_NoForceReconnectOnNeverReceived(t *testing.T) {
|
||||
defer snapshotAndResetRegistry(t)()
|
||||
|
||||
now := time.Now()
|
||||
var reconnectCount atomic.Int32
|
||||
s := &SourceLivenessState{
|
||||
Tag: "acl-denied",
|
||||
Broker: "tcp://x:1883",
|
||||
IsConnectedFn: func() bool { return true },
|
||||
ForceReconnectFn: func() { reconnectCount.Add(1) },
|
||||
}
|
||||
if err := registerLivenessState(s); err != nil {
|
||||
t.Fatalf("setup: %v", err)
|
||||
}
|
||||
|
||||
emit := func(args ...any) {}
|
||||
processLivenessTransition(s, LivenessNeverReceived, "no msgs ever", now, emit)
|
||||
// Settle any (incorrect) goroutine before counting.
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
if got := reconnectCount.Load(); got != 0 {
|
||||
t.Fatalf("LivenessNeverReceived must NOT force-reconnect (likely ACL deny — TCP churn won't help); got %d invocations", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Safety: a source with no ForceReconnectFn wired (e.g. tests, or a
|
||||
// source registered before the wiring was added) MUST NOT panic when
|
||||
// LivenessStalled fires.
|
||||
func TestMQTTStallWatchdog_NilForceReconnectFnIsSafe(t *testing.T) {
|
||||
defer snapshotAndResetRegistry(t)()
|
||||
|
||||
now := time.Now()
|
||||
s := &SourceLivenessState{
|
||||
Tag: "no-reconnect-fn",
|
||||
Broker: "tcp://x:1883",
|
||||
IsConnectedFn: func() bool { return true },
|
||||
// ForceReconnectFn deliberately nil.
|
||||
}
|
||||
if err := registerLivenessState(s); err != nil {
|
||||
t.Fatalf("setup: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("nil ForceReconnectFn must be a safe no-op; panicked: %v", r)
|
||||
}
|
||||
}()
|
||||
processLivenessTransition(s, LivenessStalled, "stalled", now, func(args ...any) {})
|
||||
}
|
||||
|
||||
// waitForReconnect polls reconnectCount until it reaches `want` or the
|
||||
// deadline elapses. ForceReconnectFn runs in a goroutine in production
|
||||
// (Disconnect+Connect can block on broker IO), so tests can't read the
|
||||
// counter synchronously.
|
||||
func waitForReconnect(t *testing.T, count *atomic.Int32, want int32, timeout time.Duration) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(timeout)
|
||||
for time.Now().Before(deadline) {
|
||||
if count.Load() >= want {
|
||||
return
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"github.com/meshcore-analyzer/mbcapqueue"
|
||||
)
|
||||
|
||||
// MultibyteCapPersistStats holds counts for /api/healthz exposure / logging.
|
||||
type MultibyteCapPersistStats struct {
|
||||
ReadEntries int // entries read from snapshot
|
||||
UpdatedActive int64 // rows updated in nodes
|
||||
UpdatedInactive int64 // rows updated in inactive_nodes
|
||||
Skipped int // entries skipped (status=="unknown")
|
||||
}
|
||||
|
||||
// RunMultibyteCapPersist consumes the latest multi-byte capability snapshot
|
||||
// written by the server (internal/mbcapqueue) and persists it to nodes /
|
||||
// inactive_nodes. Owned by the ingestor per #1287: the server is read-only
|
||||
// since #1289 and cannot UPDATE these columns itself.
|
||||
//
|
||||
// INVARIANT (canonical owner): multibyte_sup / multibyte_evidence are
|
||||
// derived/cached columns. The server COMPUTES the value during its
|
||||
// analytics cycle (from observed packets) and writes a snapshot file;
|
||||
// this function is the ONLY runtime path that mutates those columns
|
||||
// (the schema itself is added by internal/dbschema). The server MUST
|
||||
// NOT execute any UPDATE on nodes.multibyte_* — see
|
||||
// cmd/server/readonly_invariant_test.go for the enforcement.
|
||||
//
|
||||
// Data-destruction guard: entries with Status=="unknown" (sup==0) are
|
||||
// NEVER persisted — we never overwrite a previously confirmed/suspected
|
||||
// DB value with a snapshot blank. Same guarantee the original
|
||||
// server-side helper enforced before relocation.
|
||||
//
|
||||
// Safe to call from a ticker; no-op when no snapshot has been written
|
||||
// (cold start), when the snapshot is empty, when the snapshot is
|
||||
// malformed (#1386), or when running against a legacy DB that
|
||||
// pre-dates the multibyte_sup migration (#1386).
|
||||
func (s *Store) RunMultibyteCapPersist() (MultibyteCapPersistStats, error) {
|
||||
var stats MultibyteCapPersistStats
|
||||
snap, err := mbcapqueue.ReadSnapshot(s.path)
|
||||
if err != nil {
|
||||
// os.ErrNotExist is the steady state until the server's first
|
||||
// analytics cycle completes — silent no-op. A malformed file
|
||||
// is operator-actionable: log it (but still no-op, no error
|
||||
// surfaced to the ticker — a corrupt snapshot must not stop
|
||||
// the maintenance loop).
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return stats, nil
|
||||
}
|
||||
// All other ReadSnapshot errors today are wrap-arounds of
|
||||
// io / unmarshal failures — both classify as "malformed
|
||||
// snapshot on disk" from this loop's perspective.
|
||||
var jsonErr *json.SyntaxError
|
||||
if errors.As(err, &jsonErr) || isMalformedSnapshotErr(err) {
|
||||
log.Printf("[multibyte-persist] malformed snapshot on disk (no-op): %v", err)
|
||||
return stats, nil
|
||||
}
|
||||
log.Printf("[multibyte-persist] read snapshot: %v (no-op)", err)
|
||||
return stats, nil
|
||||
}
|
||||
stats.ReadEntries = len(snap.Entries)
|
||||
if len(snap.Entries) == 0 {
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// Defensive schema check: a legacy DB that pre-dates the
|
||||
// multibyte_sup migration would fail at tx.Prepare with a SQL
|
||||
// error. Detect early and skip cleanly so the ticker keeps
|
||||
// running on heterogeneous deployments.
|
||||
if !s.hasMultibyteSupColumns() {
|
||||
log.Printf("[multibyte-persist] schema missing: nodes.multibyte_sup not present on this DB (legacy schema) — skipping %d entries", stats.ReadEntries)
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return stats, err
|
||||
}
|
||||
defer tx.Rollback() //nolint:errcheck
|
||||
// Combined dispatch: each pubkey lives in exactly one of nodes /
|
||||
// inactive_nodes. The pre-#1386 implementation issued one UPDATE
|
||||
// against each table per entry — 50% guaranteed-empty. We now
|
||||
// look up the table once, then issue the matching UPDATE.
|
||||
stmtN, err := tx.Prepare(`UPDATE nodes SET multibyte_sup=?, multibyte_evidence=? WHERE public_key=?`)
|
||||
if err != nil {
|
||||
return stats, err
|
||||
}
|
||||
defer stmtN.Close()
|
||||
stmtI, err := tx.Prepare(`UPDATE inactive_nodes SET multibyte_sup=?, multibyte_evidence=? WHERE public_key=?`)
|
||||
if err != nil {
|
||||
return stats, err
|
||||
}
|
||||
defer stmtI.Close()
|
||||
// Membership probe: one indexed PK lookup. Cheap; avoids the
|
||||
// guaranteed-miss second UPDATE.
|
||||
stmtProbe, err := tx.Prepare(`SELECT 1 FROM nodes WHERE public_key=? LIMIT 1`)
|
||||
if err != nil {
|
||||
return stats, err
|
||||
}
|
||||
defer stmtProbe.Close()
|
||||
|
||||
for _, e := range snap.Entries {
|
||||
sup := multibyteStatusToInt(e.Status)
|
||||
if sup == 0 {
|
||||
stats.Skipped++
|
||||
continue
|
||||
}
|
||||
// Probe once. If hit, UPDATE nodes; else UPDATE inactive_nodes.
|
||||
var hit int
|
||||
if err := stmtProbe.QueryRow(e.PublicKey).Scan(&hit); err == nil {
|
||||
if r, err := stmtN.Exec(sup, e.Evidence, e.PublicKey); err == nil {
|
||||
if n, _ := r.RowsAffected(); n > 0 {
|
||||
stats.UpdatedActive += n
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if r, err := stmtI.Exec(sup, e.Evidence, e.PublicKey); err == nil {
|
||||
if n, _ := r.RowsAffected(); n > 0 {
|
||||
stats.UpdatedInactive += n
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return stats, err
|
||||
}
|
||||
if stats.UpdatedActive+stats.UpdatedInactive > 0 {
|
||||
log.Printf("[multibyte-persist] applied snapshot: %d entries (%d skipped); updated %d active + %d inactive nodes",
|
||||
stats.ReadEntries, stats.Skipped, stats.UpdatedActive, stats.UpdatedInactive)
|
||||
}
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// isMalformedSnapshotErr returns true if err looks like a JSON parse /
|
||||
// IO-truncation failure surfaced by mbcapqueue.ReadSnapshot. The
|
||||
// queue wraps errors with %w but mbcapqueue currently formats with
|
||||
// %w only for "read:"/"unmarshal:" prefixes — we substring-match
|
||||
// those so the operator-actionable log message is unambiguous.
|
||||
func isMalformedSnapshotErr(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
msg := err.Error()
|
||||
for _, frag := range []string{"unmarshal", "invalid character", "unexpected end of JSON"} {
|
||||
if containsCI(msg, frag) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func containsCI(s, sub string) bool {
|
||||
if len(sub) == 0 {
|
||||
return true
|
||||
}
|
||||
// case-insensitive Contains without importing strings (already
|
||||
// imported in db.go, but keeping helper local to avoid widening
|
||||
// this file's imports).
|
||||
for i := 0; i+len(sub) <= len(s); i++ {
|
||||
match := true
|
||||
for j := 0; j < len(sub); j++ {
|
||||
a, b := s[i+j], sub[j]
|
||||
if a >= 'A' && a <= 'Z' {
|
||||
a += 32
|
||||
}
|
||||
if b >= 'A' && b <= 'Z' {
|
||||
b += 32
|
||||
}
|
||||
if a != b {
|
||||
match = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if match {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// hasMultibyteSupColumns probes whether the active DB carries the
|
||||
// multibyte_sup column on the `nodes` table. Used to short-circuit
|
||||
// RunMultibyteCapPersist on legacy DBs that pre-date the
|
||||
// internal/dbschema migration (#1386).
|
||||
func (s *Store) hasMultibyteSupColumns() bool {
|
||||
rows, err := s.db.Query(`PRAGMA table_info(nodes)`)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var cid int
|
||||
var name, ctype string
|
||||
var notnull, pk int
|
||||
var dflt interface{}
|
||||
if err := rows.Scan(&cid, &name, &ctype, ¬null, &dflt, &pk); err != nil {
|
||||
return false
|
||||
}
|
||||
if name == "multibyte_sup" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// multibyteStatusToInt mirrors the mapping the server used before relocation.
|
||||
// 0 = unknown (never persisted), 1 = suspected, 2 = confirmed.
|
||||
func multibyteStatusToInt(status string) int {
|
||||
switch status {
|
||||
case "confirmed":
|
||||
return 2
|
||||
case "suspected":
|
||||
return 1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"database/sql"
|
||||
"log"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// captureLogs redirects the standard logger to a buffer for the
|
||||
// duration of the test and returns the buffer. Restores the previous
|
||||
// writer when the test ends.
|
||||
func captureLogs(t *testing.T) *bytes.Buffer {
|
||||
t.Helper()
|
||||
buf := &bytes.Buffer{}
|
||||
prevWriter := log.Writer()
|
||||
prevFlags := log.Flags()
|
||||
log.SetOutput(buf)
|
||||
t.Cleanup(func() {
|
||||
log.SetOutput(prevWriter)
|
||||
log.SetFlags(prevFlags)
|
||||
})
|
||||
return buf
|
||||
}
|
||||
|
||||
// logContains reports whether the captured log buffer contains substr
|
||||
// (case-insensitive).
|
||||
func logContains(buf *bytes.Buffer, substr string) bool {
|
||||
return strings.Contains(strings.ToLower(buf.String()), strings.ToLower(substr))
|
||||
}
|
||||
|
||||
// columnExists reports whether the named column exists on the table.
|
||||
func columnExists(t *testing.T, db *sql.DB, table, col string) bool {
|
||||
t.Helper()
|
||||
rows, err := db.Query("PRAGMA table_info(" + table + ")")
|
||||
if err != nil {
|
||||
t.Fatalf("PRAGMA table_info(%s): %v", table, err)
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var cid int
|
||||
var name, ctype string
|
||||
var notnull, pk int
|
||||
var dfltValue sql.NullString
|
||||
if err := rows.Scan(&cid, &name, &ctype, ¬null, &dfltValue, &pk); err != nil {
|
||||
t.Fatalf("scan PRAGMA: %v", err)
|
||||
}
|
||||
if name == col {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/meshcore-analyzer/mbcapqueue"
|
||||
)
|
||||
|
||||
// TestRunMultibyteCapPersist_AppliesSnapshot enforces the architectural
|
||||
// invariant from #1289 + #1322 + #1324 follow-up: the multi-byte
|
||||
// capability columns (multibyte_sup / multibyte_evidence) on
|
||||
// nodes / inactive_nodes MUST be written by the ingestor, NEVER by the
|
||||
// read-only server. The server publishes a snapshot file via
|
||||
// internal/mbcapqueue; the ingestor's maintenance loop applies it here.
|
||||
//
|
||||
// Pre-relocation (PR #1324 as-shipped), the server held a write handle
|
||||
// and executed UPDATE … nodes SET multibyte_sup directly — which is
|
||||
// impossible after #1289 made the server's *sql.DB read-only. This test
|
||||
// asserts the relocated path: snapshot in → UPDATEs out, from the
|
||||
// ingestor side.
|
||||
func TestRunMultibyteCapPersist_AppliesSnapshot(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dbPath := filepath.Join(dir, "test.db")
|
||||
store, err := OpenStore(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("OpenStore: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
// Seed two nodes: one active, one inactive.
|
||||
if _, err := store.db.Exec(`INSERT INTO nodes (public_key, name, role, last_seen, multibyte_sup, multibyte_evidence)
|
||||
VALUES ('aa11', 'Alpha', 'repeater', '2026-01-01T00:00:00Z', 0, NULL)`); err != nil {
|
||||
t.Fatalf("seed nodes: %v", err)
|
||||
}
|
||||
if _, err := store.db.Exec(`INSERT INTO inactive_nodes (public_key, name, role, last_seen, multibyte_sup, multibyte_evidence)
|
||||
VALUES ('bb22', 'Bravo', 'repeater', '2025-01-01T00:00:00Z', 0, NULL)`); err != nil {
|
||||
t.Fatalf("seed inactive_nodes: %v", err)
|
||||
}
|
||||
// Seed a third node already confirmed, then send "unknown" for it —
|
||||
// the data-destruction guard must keep its DB value.
|
||||
if _, err := store.db.Exec(`INSERT INTO nodes (public_key, name, role, last_seen, multibyte_sup, multibyte_evidence)
|
||||
VALUES ('cc33', 'Charlie', 'repeater', '2026-01-01T00:00:00Z', 2, 'advert')`); err != nil {
|
||||
t.Fatalf("seed cc33: %v", err)
|
||||
}
|
||||
|
||||
snap := mbcapqueue.Snapshot{Entries: []mbcapqueue.Entry{
|
||||
{PublicKey: "aa11", Status: "confirmed", Evidence: "advert"},
|
||||
{PublicKey: "bb22", Status: "suspected", Evidence: "path"},
|
||||
{PublicKey: "cc33", Status: "unknown"}, // must NOT overwrite
|
||||
}}
|
||||
if err := mbcapqueue.WriteSnapshot(dbPath, snap); err != nil {
|
||||
t.Fatalf("WriteSnapshot: %v", err)
|
||||
}
|
||||
// Sanity: snapshot file landed where we expect.
|
||||
if _, err := os.Stat(filepath.Join(filepath.Dir(dbPath), mbcapqueue.QueueDirName, mbcapqueue.SnapshotFileName)); err != nil {
|
||||
t.Fatalf("snapshot not on disk: %v", err)
|
||||
}
|
||||
|
||||
stats, err := store.RunMultibyteCapPersist()
|
||||
if err != nil {
|
||||
t.Fatalf("RunMultibyteCapPersist: %v", err)
|
||||
}
|
||||
if stats.ReadEntries != 3 {
|
||||
t.Errorf("ReadEntries = %d, want 3", stats.ReadEntries)
|
||||
}
|
||||
if stats.Skipped != 1 {
|
||||
t.Errorf("Skipped = %d, want 1 (the unknown entry)", stats.Skipped)
|
||||
}
|
||||
if stats.UpdatedActive == 0 {
|
||||
t.Errorf("UpdatedActive = 0; expected aa11 to be updated in nodes")
|
||||
}
|
||||
if stats.UpdatedInactive == 0 {
|
||||
t.Errorf("UpdatedInactive = 0; expected bb22 to be updated in inactive_nodes")
|
||||
}
|
||||
|
||||
// Verify DB state.
|
||||
var sup int
|
||||
var evid string
|
||||
if err := store.db.QueryRow(`SELECT multibyte_sup, COALESCE(multibyte_evidence,'') FROM nodes WHERE public_key='aa11'`).Scan(&sup, &evid); err != nil {
|
||||
t.Fatalf("read aa11: %v", err)
|
||||
}
|
||||
if sup != 2 || evid != "advert" {
|
||||
t.Errorf("aa11 after persist: sup=%d evid=%q, want sup=2 evid=advert", sup, evid)
|
||||
}
|
||||
if err := store.db.QueryRow(`SELECT multibyte_sup, COALESCE(multibyte_evidence,'') FROM inactive_nodes WHERE public_key='bb22'`).Scan(&sup, &evid); err != nil {
|
||||
t.Fatalf("read bb22: %v", err)
|
||||
}
|
||||
if sup != 1 || evid != "path" {
|
||||
t.Errorf("bb22 after persist: sup=%d evid=%q, want sup=1 evid=path", sup, evid)
|
||||
}
|
||||
// Data-destruction guard: cc33 must still be confirmed=2/'advert'.
|
||||
if err := store.db.QueryRow(`SELECT multibyte_sup, COALESCE(multibyte_evidence,'') FROM nodes WHERE public_key='cc33'`).Scan(&sup, &evid); err != nil {
|
||||
t.Fatalf("read cc33: %v", err)
|
||||
}
|
||||
if sup != 2 || evid != "advert" {
|
||||
t.Errorf("cc33 was overwritten by unknown entry: sup=%d evid=%q, want sup=2 evid=advert", sup, evid)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunMultibyteCapPersist_NoSnapshot_NoOp verifies that the persist
|
||||
// step is a clean no-op when the server hasn't written a snapshot yet
|
||||
// (cold start; the analytics cycle takes ~15s after server boot).
|
||||
func TestRunMultibyteCapPersist_NoSnapshot_NoOp(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dbPath := filepath.Join(dir, "test.db")
|
||||
store, err := OpenStore(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("OpenStore: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
stats, err := store.RunMultibyteCapPersist()
|
||||
if err != nil {
|
||||
t.Fatalf("RunMultibyteCapPersist (no snapshot): %v", err)
|
||||
}
|
||||
if stats.ReadEntries != 0 || stats.UpdatedActive != 0 || stats.UpdatedInactive != 0 {
|
||||
t.Errorf("expected zero-valued stats on cold start, got %+v", stats)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunMultibyteCapPersist_RoundTrip exercises the full end-to-end
|
||||
// contract claimed by PR #1324: the server writes a snapshot, the
|
||||
// ingestor persists it, and after a simulated restart (close + reopen
|
||||
// the store) the DB still carries the persisted state.
|
||||
//
|
||||
// The audit (#1386) flagged this as the #1 missing test: the two halves
|
||||
// (persist / read-back) were each tested in isolation, but no single
|
||||
// test proved the persist path produces a database state the loader
|
||||
// can later consume — so a column-rename or snapshot-version drift
|
||||
// would slip past.
|
||||
func TestRunMultibyteCapPersist_RoundTrip(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dbPath := filepath.Join(dir, "test.db")
|
||||
|
||||
// --- Phase 1: open store, seed, persist snapshot ---
|
||||
store, err := OpenStore(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("OpenStore: %v", err)
|
||||
}
|
||||
if _, err := store.db.Exec(`INSERT INTO nodes (public_key, name, role, last_seen, multibyte_sup, multibyte_evidence)
|
||||
VALUES ('dd44', 'Delta', 'repeater', '2026-01-01T00:00:00Z', 0, NULL)`); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
if _, err := store.db.Exec(`INSERT INTO inactive_nodes (public_key, name, role, last_seen, multibyte_sup, multibyte_evidence)
|
||||
VALUES ('ee55', 'Echo', 'companion', '2025-12-01T00:00:00Z', 0, NULL)`); err != nil {
|
||||
t.Fatalf("seed inactive: %v", err)
|
||||
}
|
||||
snap := mbcapqueue.Snapshot{Entries: []mbcapqueue.Entry{
|
||||
{PublicKey: "dd44", Status: "confirmed", Evidence: "advert"},
|
||||
{PublicKey: "ee55", Status: "suspected", Evidence: "path"},
|
||||
}}
|
||||
if err := mbcapqueue.WriteSnapshot(dbPath, snap); err != nil {
|
||||
t.Fatalf("WriteSnapshot: %v", err)
|
||||
}
|
||||
if _, err := store.RunMultibyteCapPersist(); err != nil {
|
||||
t.Fatalf("RunMultibyteCapPersist: %v", err)
|
||||
}
|
||||
// Capture original state for round-trip comparison.
|
||||
var origActiveSup, origInactiveSup int
|
||||
var origActiveEvid, origInactiveEvid string
|
||||
if err := store.db.QueryRow(`SELECT multibyte_sup, COALESCE(multibyte_evidence,'') FROM nodes WHERE public_key='dd44'`).Scan(&origActiveSup, &origActiveEvid); err != nil {
|
||||
t.Fatalf("read dd44 (phase1): %v", err)
|
||||
}
|
||||
if err := store.db.QueryRow(`SELECT multibyte_sup, COALESCE(multibyte_evidence,'') FROM inactive_nodes WHERE public_key='ee55'`).Scan(&origInactiveSup, &origInactiveEvid); err != nil {
|
||||
t.Fatalf("read ee55 (phase1): %v", err)
|
||||
}
|
||||
// Simulate restart: drop the in-memory Store entirely.
|
||||
if err := store.Close(); err != nil {
|
||||
t.Fatalf("Close: %v", err)
|
||||
}
|
||||
|
||||
// --- Phase 2: fresh Store, verify persisted state survived ---
|
||||
store2, err := OpenStore(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("OpenStore (reopen): %v", err)
|
||||
}
|
||||
defer store2.Close()
|
||||
var sup int
|
||||
var evid string
|
||||
if err := store2.db.QueryRow(`SELECT multibyte_sup, COALESCE(multibyte_evidence,'') FROM nodes WHERE public_key='dd44'`).Scan(&sup, &evid); err != nil {
|
||||
t.Fatalf("read dd44 after reopen: %v", err)
|
||||
}
|
||||
if sup != origActiveSup || evid != origActiveEvid {
|
||||
t.Errorf("dd44 after restart: sup=%d evid=%q, want sup=%d evid=%q", sup, evid, origActiveSup, origActiveEvid)
|
||||
}
|
||||
if sup != 2 || evid != "advert" {
|
||||
t.Errorf("dd44 after restart: sup=%d evid=%q, want sup=2 evid=advert", sup, evid)
|
||||
}
|
||||
if err := store2.db.QueryRow(`SELECT multibyte_sup, COALESCE(multibyte_evidence,'') FROM inactive_nodes WHERE public_key='ee55'`).Scan(&sup, &evid); err != nil {
|
||||
t.Fatalf("read ee55 after reopen: %v", err)
|
||||
}
|
||||
if sup != origInactiveSup || evid != origInactiveEvid {
|
||||
t.Errorf("ee55 after restart: sup=%d evid=%q, want sup=%d evid=%q", sup, evid, origInactiveSup, origInactiveEvid)
|
||||
}
|
||||
if sup != 1 || evid != "path" {
|
||||
t.Errorf("ee55 after restart: sup=%d evid=%q, want sup=1 evid=path", sup, evid)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunMultibyteCapPersist_MalformedSnapshot verifies the persist
|
||||
// path is safe against a corrupted/truncated snapshot file: it must
|
||||
// return without error (no-op), MUST NOT crash, AND MUST log a warning
|
||||
// distinguishing the malformed case from the steady-state "no
|
||||
// snapshot yet" cold-start case.
|
||||
//
|
||||
// Audit (#1386, kent-beck) flagged: "Snapshot file malformed /
|
||||
// truncated / wrong-version — RunMultibyteCapPersist error vs.
|
||||
// silent-skip behavior is unspecified by any test."
|
||||
func TestRunMultibyteCapPersist_MalformedSnapshot(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dbPath := filepath.Join(dir, "test.db")
|
||||
store, err := OpenStore(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("OpenStore: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
// Write malformed JSON directly to the snapshot path.
|
||||
if err := mbcapqueue.EnsureDir(dbPath); err != nil {
|
||||
t.Fatalf("EnsureDir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(mbcapqueue.SnapshotPath(dbPath), []byte("not-json{{{garbage"), 0o644); err != nil {
|
||||
t.Fatalf("write malformed: %v", err)
|
||||
}
|
||||
|
||||
// Capture log output to assert the warning is emitted.
|
||||
logBuf := captureLogs(t)
|
||||
|
||||
// Must not panic.
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("RunMultibyteCapPersist panicked on malformed snapshot: %v", r)
|
||||
}
|
||||
}()
|
||||
stats, err := store.RunMultibyteCapPersist()
|
||||
if err != nil {
|
||||
t.Errorf("RunMultibyteCapPersist on malformed snapshot returned error %v; expected silent no-op", err)
|
||||
}
|
||||
if stats.ReadEntries != 0 || stats.UpdatedActive != 0 || stats.UpdatedInactive != 0 {
|
||||
t.Errorf("expected zero-valued stats on malformed snapshot, got %+v", stats)
|
||||
}
|
||||
if !logContains(logBuf, "malformed") && !logContains(logBuf, "invalid") && !logContains(logBuf, "corrupt") {
|
||||
t.Errorf("expected log to mention malformed/invalid/corrupt snapshot; got: %s", logBuf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunMultibyteCapPersist_MissingSchemaColumns verifies the persist
|
||||
// path is a clean no-op on a legacy DB that doesn't yet have the
|
||||
// multibyte_sup / multibyte_evidence columns. Currently the persist
|
||||
// would fail at tx.Prepare with a SQL error; the audit requires it
|
||||
// skip cleanly instead.
|
||||
//
|
||||
// We simulate a legacy DB by DROPping the columns post-migration
|
||||
// (SQLite ≥ 3.35 supports ALTER TABLE DROP COLUMN).
|
||||
func TestRunMultibyteCapPersist_MissingSchemaColumns(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dbPath := filepath.Join(dir, "test.db")
|
||||
store, err := OpenStore(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("OpenStore: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
// Drop the multibyte columns from both tables to simulate a legacy DB.
|
||||
for _, stmt := range []string{
|
||||
`ALTER TABLE nodes DROP COLUMN multibyte_sup`,
|
||||
`ALTER TABLE nodes DROP COLUMN multibyte_evidence`,
|
||||
`ALTER TABLE inactive_nodes DROP COLUMN multibyte_sup`,
|
||||
`ALTER TABLE inactive_nodes DROP COLUMN multibyte_evidence`,
|
||||
} {
|
||||
if _, err := store.db.Exec(stmt); err != nil {
|
||||
t.Fatalf("simulate legacy DB (%q): %v", stmt, err)
|
||||
}
|
||||
}
|
||||
// Confirm columns are gone.
|
||||
if columnExists(t, store.db, "nodes", "multibyte_sup") {
|
||||
t.Fatalf("setup failed: nodes.multibyte_sup still present after DROP")
|
||||
}
|
||||
|
||||
snap := mbcapqueue.Snapshot{Entries: []mbcapqueue.Entry{
|
||||
{PublicKey: "ff66", Status: "confirmed", Evidence: "advert"},
|
||||
}}
|
||||
if err := mbcapqueue.WriteSnapshot(dbPath, snap); err != nil {
|
||||
t.Fatalf("WriteSnapshot: %v", err)
|
||||
}
|
||||
|
||||
logBuf := captureLogs(t)
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("RunMultibyteCapPersist panicked on legacy DB: %v", r)
|
||||
}
|
||||
}()
|
||||
stats, err := store.RunMultibyteCapPersist()
|
||||
if err != nil {
|
||||
t.Errorf("RunMultibyteCapPersist on legacy DB returned error %v; expected clean skip", err)
|
||||
}
|
||||
if stats.UpdatedActive != 0 || stats.UpdatedInactive != 0 {
|
||||
t.Errorf("expected zero writes on legacy DB, got %+v", stats)
|
||||
}
|
||||
// Must explicitly detect + log the skip — otherwise the "clean skip"
|
||||
// is silent UPDATE-affected-zero accident, not defensive code.
|
||||
if !logContains(logBuf, "legacy") && !logContains(logBuf, "schema") && !logContains(logBuf, "multibyte_sup") {
|
||||
t.Errorf("expected explicit log on missing schema columns; got: %s", logBuf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunMultibyteCapPersist_PreservesConfirmedOnUnknown is the
|
||||
// data-destruction guard the PR claims to enforce: a snapshot Entry
|
||||
// with status="unknown" must NEVER overwrite an existing "confirmed"
|
||||
// (or "suspected") DB row. The audit's mutation test: revert the
|
||||
// `if sup == 0 { continue }` guard in multibyte_persist.go — this
|
||||
// test must fail.
|
||||
func TestRunMultibyteCapPersist_PreservesConfirmedOnUnknown(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dbPath := filepath.Join(dir, "test.db")
|
||||
store, err := OpenStore(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("OpenStore: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
// Seed a confirmed active node and a suspected inactive node.
|
||||
if _, err := store.db.Exec(`INSERT INTO nodes (public_key, name, role, last_seen, multibyte_sup, multibyte_evidence)
|
||||
VALUES ('gg77', 'Golf', 'repeater', '2026-01-01T00:00:00Z', 2, 'advert')`); err != nil {
|
||||
t.Fatalf("seed gg77: %v", err)
|
||||
}
|
||||
if _, err := store.db.Exec(`INSERT INTO inactive_nodes (public_key, name, role, last_seen, multibyte_sup, multibyte_evidence)
|
||||
VALUES ('hh88', 'Hotel', 'companion', '2025-12-01T00:00:00Z', 1, 'path')`); err != nil {
|
||||
t.Fatalf("seed hh88: %v", err)
|
||||
}
|
||||
|
||||
// Snapshot has only "unknown" entries for both — must skip both.
|
||||
snap := mbcapqueue.Snapshot{Entries: []mbcapqueue.Entry{
|
||||
{PublicKey: "gg77", Status: "unknown"},
|
||||
{PublicKey: "hh88", Status: "unknown"},
|
||||
}}
|
||||
if err := mbcapqueue.WriteSnapshot(dbPath, snap); err != nil {
|
||||
t.Fatalf("WriteSnapshot: %v", err)
|
||||
}
|
||||
|
||||
stats, err := store.RunMultibyteCapPersist()
|
||||
if err != nil {
|
||||
t.Fatalf("RunMultibyteCapPersist: %v", err)
|
||||
}
|
||||
if stats.Skipped != 2 {
|
||||
t.Errorf("Skipped = %d, want 2 (both unknown entries)", stats.Skipped)
|
||||
}
|
||||
if stats.UpdatedActive != 0 || stats.UpdatedInactive != 0 {
|
||||
t.Errorf("expected zero updates, got %+v", stats)
|
||||
}
|
||||
|
||||
// Verify the existing values were NOT clobbered.
|
||||
var sup int
|
||||
var evid string
|
||||
if err := store.db.QueryRow(`SELECT multibyte_sup, COALESCE(multibyte_evidence,'') FROM nodes WHERE public_key='gg77'`).Scan(&sup, &evid); err != nil {
|
||||
t.Fatalf("read gg77: %v", err)
|
||||
}
|
||||
if sup != 2 || evid != "advert" {
|
||||
t.Errorf("gg77 was clobbered by unknown snapshot: sup=%d evid=%q, want sup=2 evid=advert", sup, evid)
|
||||
}
|
||||
if err := store.db.QueryRow(`SELECT multibyte_sup, COALESCE(multibyte_evidence,'') FROM inactive_nodes WHERE public_key='hh88'`).Scan(&sup, &evid); err != nil {
|
||||
t.Fatalf("read hh88: %v", err)
|
||||
}
|
||||
if sup != 1 || evid != "path" {
|
||||
t.Errorf("hh88 was clobbered by unknown snapshot: sup=%d evid=%q, want sup=1 evid=path", sup, evid)
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,20 @@ import (
|
||||
// pulse here is sufficient to keep the snapshot fresh.
|
||||
const NeighborEdgesBuilderInterval = 60 * time.Second
|
||||
|
||||
// neighborBuilderMaxBatch caps how many observation rows a single
|
||||
// delta tick may process (#1339). With max_open_conns=1, an unbounded
|
||||
// scan on a multi-million-row table holds the SQLite write lock for
|
||||
// minutes and starves MQTT ingest. The cap keeps each tick bounded;
|
||||
// if a backlog accumulates, successive ticks drain it 50k rows at a
|
||||
// time without ever blocking ingest for long.
|
||||
const neighborBuilderMaxBatch = 50000
|
||||
|
||||
// neighborBuilderSlowTickThreshold is the per-tick wallclock budget
|
||||
// for the builder. Exceeding it is logged loudly so operators can
|
||||
// catch a regression of #1339 quickly. The full instrumentation
|
||||
// framework is tracked in #1340.
|
||||
const neighborBuilderSlowTickThreshold = 5 * time.Second
|
||||
|
||||
// payloadADVERT mirrors the constant in cmd/server/decoder.go.
|
||||
// Duplicated rather than imported so the ingestor binary stays
|
||||
// independent of the server package.
|
||||
@@ -42,13 +56,25 @@ func (s *Store) StartNeighborEdgesBuilder(interval time.Duration) func() {
|
||||
stop := make(chan struct{})
|
||||
done := make(chan struct{})
|
||||
|
||||
// Synchronous warm-up: a single pass so the first server load
|
||||
// after process start sees a populated table.
|
||||
if n, err := s.buildAndPersistNeighborEdges(); err != nil {
|
||||
log.Printf("[neighbor-build] initial build error: %v", err)
|
||||
} else {
|
||||
log.Printf("[neighbor-build] initial build: %d edges upserted", n)
|
||||
// Synchronous warm-up: on a fresh DB this is a full scan; on a DB
|
||||
// with persisted neighbor_edges (most restarts), the watermark
|
||||
// short-circuits it into a delta scan. Loop until the per-tick
|
||||
// batch cap stops triggering so we drain any backlog before
|
||||
// returning — first server load needs a fully-populated table.
|
||||
wuStart := time.Now()
|
||||
var wuTotal int
|
||||
for {
|
||||
n, err := s.buildAndPersistNeighborEdges()
|
||||
if err != nil {
|
||||
log.Printf("[neighbor-build] initial build error: %v", err)
|
||||
break
|
||||
}
|
||||
wuTotal += n
|
||||
if n < neighborBuilderMaxBatch {
|
||||
break
|
||||
}
|
||||
}
|
||||
log.Printf("[neighbor-build] initial build: %d edges upserted in %s", wuTotal, time.Since(wuStart))
|
||||
|
||||
var stopOnce sync.Once
|
||||
go func() {
|
||||
@@ -58,10 +84,16 @@ func (s *Store) StartNeighborEdgesBuilder(interval time.Duration) func() {
|
||||
for {
|
||||
select {
|
||||
case <-t.C:
|
||||
if n, err := s.buildAndPersistNeighborEdges(); err != nil {
|
||||
log.Printf("[neighbor-build] tick error: %v", err)
|
||||
start := time.Now()
|
||||
n, err := s.buildAndPersistNeighborEdges()
|
||||
dur := time.Since(start)
|
||||
if err != nil {
|
||||
log.Printf("[neighbor-build] tick error after %s: %v", dur, err)
|
||||
} else if n > 0 {
|
||||
log.Printf("[neighbor-build] %d edges upserted", n)
|
||||
log.Printf("[neighbor-build] tick: %d edges in %s (delta from watermark)", n, dur)
|
||||
}
|
||||
if dur > neighborBuilderSlowTickThreshold {
|
||||
log.Printf("[neighbor-build] SLOW tick: %s — possible regression of #1339", dur)
|
||||
}
|
||||
case <-stop:
|
||||
return
|
||||
@@ -83,6 +115,21 @@ func (s *Store) StartNeighborEdgesBuilder(interval time.Duration) func() {
|
||||
// observer↔last-hop on all packet types) and upserts them into
|
||||
// neighbor_edges. Returns count of attempted upserts.
|
||||
//
|
||||
// Watermark / delta semantics (#1339): the builder derives a watermark
|
||||
// from MAX(neighbor_edges.last_seen). On an empty edges table (fresh
|
||||
// DB), watermark is 0 and the builder does a full warm-up scan. On
|
||||
// every subsequent call, the SELECT is restricted to observations
|
||||
// whose timestamp is strictly greater than the watermark, bounded by
|
||||
// neighborBuilderMaxBatch. neighbor_edges itself is the persistence —
|
||||
// no metadata table or in-memory state is required, and restarts
|
||||
// resume cleanly from whatever the table reflects.
|
||||
//
|
||||
// Trade-off (documented for #1340 follow-up): an anomalously-old
|
||||
// observation that arrives AFTER its timestamp has already been
|
||||
// crossed by the watermark will be skipped. Acceptable for an
|
||||
// approximate neighbor graph; a periodic full-rebuild can be added
|
||||
// later if needed.
|
||||
//
|
||||
// Resolution of hop-prefix → full pubkey is done via a one-shot
|
||||
// SELECT of (lowered) pubkey prefixes from nodes. Prefixes with
|
||||
// multiple candidates are skipped (matches the conservative
|
||||
@@ -93,6 +140,21 @@ func (s *Store) buildAndPersistNeighborEdges() (int, error) {
|
||||
return 0, fmt.Errorf("build prefix index: %w", err)
|
||||
}
|
||||
|
||||
// Derive the watermark from the existing edges table. RFC3339
|
||||
// → epoch seconds so it can be compared against observations.timestamp
|
||||
// (stored as INTEGER unix epoch). On an empty edges table both the
|
||||
// query and the parse return zero → full warm-up scan.
|
||||
var watermarkRFC sql.NullString
|
||||
if err := s.db.QueryRow(`SELECT MAX(last_seen) FROM neighbor_edges`).Scan(&watermarkRFC); err != nil {
|
||||
return 0, fmt.Errorf("read watermark: %w", err)
|
||||
}
|
||||
var watermarkEpoch int64
|
||||
if watermarkRFC.Valid && watermarkRFC.String != "" {
|
||||
if t, parseErr := time.Parse(time.RFC3339, watermarkRFC.String); parseErr == nil {
|
||||
watermarkEpoch = t.Unix()
|
||||
}
|
||||
}
|
||||
|
||||
rows, err := s.db.Query(`SELECT
|
||||
t.payload_type,
|
||||
t.decoded_json,
|
||||
@@ -102,7 +164,10 @@ func (s *Store) buildAndPersistNeighborEdges() (int, error) {
|
||||
o.timestamp
|
||||
FROM observations o
|
||||
JOIN transmissions t ON t.id = o.transmission_id
|
||||
LEFT JOIN observers obs ON obs.rowid = o.observer_idx`)
|
||||
LEFT JOIN observers obs ON obs.rowid = o.observer_idx
|
||||
WHERE o.timestamp > ?
|
||||
ORDER BY o.timestamp
|
||||
LIMIT ?`, watermarkEpoch, neighborBuilderMaxBatch)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("scan observations: %w", err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestNeighborEdgesBuilderDeltaScan enforces issue #1339:
|
||||
// after the initial (warm-up) full build, subsequent ticks of
|
||||
// buildAndPersistNeighborEdges MUST scan only observations newer
|
||||
// than the most recent edge already persisted. The watermark is
|
||||
// derived from MAX(neighbor_edges.last_seen) — neighbor_edges itself
|
||||
// is the persistence, no separate metadata table.
|
||||
//
|
||||
// RED expectations:
|
||||
// 1. After warm-up that produces edges, a second build with NO new
|
||||
// observations is a fast no-op (<1s) and writes nothing.
|
||||
// 2. After inserting K observations with timestamps strictly newer
|
||||
// than the prior MAX(last_seen), the next build upserts exactly
|
||||
// K edges in <1s.
|
||||
// 3. Initial build (empty neighbor_edges) still does a full scan
|
||||
// (warm-up preserved).
|
||||
func TestNeighborEdgesBuilderDeltaScan(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("synthetic 100k-row benchmark; skipped in -short")
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
dbPath := filepath.Join(dir, "delta.db")
|
||||
store, err := OpenStore(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("OpenStore: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
if _, err := store.db.Exec(
|
||||
`INSERT INTO nodes (public_key, name) VALUES (?, ?), (?, ?)`,
|
||||
"aaaaaaaaaa", "from-node",
|
||||
"bbbbbbbbbb", "first-hop",
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := store.db.Exec(
|
||||
`INSERT INTO observers (id, name) VALUES (?, ?)`,
|
||||
"obs-1", "observer-1",
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var obsRowid int64
|
||||
if err := store.db.QueryRow(`SELECT rowid FROM observers WHERE id = ?`, "obs-1").Scan(&obsRowid); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Baseline timestamps: a contiguous block ending at baselineMaxTs.
|
||||
const baseline = 100_000
|
||||
const baselineStartTs int64 = 1735689600 // 2025-01-01 UTC
|
||||
baselineMaxTs := baselineStartTs + int64(baseline) - 1
|
||||
|
||||
tx, err := store.db.Begin()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
txStmt, err := tx.Prepare(`INSERT INTO transmissions
|
||||
(raw_hex, hash, first_seen, route_type, payload_type, payload_version, decoded_json, from_pubkey)
|
||||
VALUES ('', ?, ?, 0, ?, 0, '{}', 'aaaaaaaaaa')`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
obsStmt, err := tx.Prepare(`INSERT INTO observations
|
||||
(transmission_id, observer_idx, path_json, timestamp) VALUES (?, ?, '["bb"]', ?)`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := 0; i < baseline; i++ {
|
||||
res, err := txStmt.Exec(fmt.Sprintf("h%d", i), baselineStartTs+int64(i), payloadADVERT)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
txID, _ := res.LastInsertId()
|
||||
if _, err := obsStmt.Exec(txID, obsRowid, baselineStartTs+int64(i)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Initial warm-up: drain to completion (StartNeighborEdgesBuilder
|
||||
// does the same — call directly so the test doesn't depend on the
|
||||
// goroutine harness). Full scan allowed because neighbor_edges
|
||||
// starts empty.
|
||||
for {
|
||||
n, err := store.buildAndPersistNeighborEdges()
|
||||
if err != nil {
|
||||
t.Fatalf("warm-up build: %v", err)
|
||||
}
|
||||
if n == 0 || n < 50000 {
|
||||
break
|
||||
}
|
||||
}
|
||||
var edgesAfterWarmup int
|
||||
if err := store.db.QueryRow(`SELECT COUNT(*) FROM neighbor_edges`).Scan(&edgesAfterWarmup); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if edgesAfterWarmup == 0 {
|
||||
t.Fatal("warm-up produced 0 edges; can't establish a watermark")
|
||||
}
|
||||
// Sanity: MAX(last_seen) should reflect the baseline tail timestamp.
|
||||
var maxLastSeen string
|
||||
if err := store.db.QueryRow(`SELECT MAX(last_seen) FROM neighbor_edges`).Scan(&maxLastSeen); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantMax := time.Unix(baselineMaxTs, 0).UTC().Format(time.RFC3339)
|
||||
if maxLastSeen != wantMax {
|
||||
t.Fatalf("MAX(last_seen) after warm-up: want %s, got %s", wantMax, maxLastSeen)
|
||||
}
|
||||
|
||||
// Tick #2: NO new observations. Expect no-op + fast.
|
||||
noopStart := time.Now()
|
||||
n2, err := store.buildAndPersistNeighborEdges()
|
||||
if err != nil {
|
||||
t.Fatalf("noop build: %v", err)
|
||||
}
|
||||
noopDur := time.Since(noopStart)
|
||||
if n2 != 0 {
|
||||
t.Fatalf("expected 0 edges on empty-delta tick; got %d (#1339)", n2)
|
||||
}
|
||||
if noopDur > time.Second {
|
||||
t.Fatalf("empty-delta build took %v; expected <1s — builder is "+
|
||||
"still doing a full table scan. (#1339)", noopDur)
|
||||
}
|
||||
|
||||
// Tick #3: insert K observations with timestamps strictly newer
|
||||
// than baselineMaxTs.
|
||||
const delta = 100
|
||||
deltaStartTs := baselineMaxTs + 1
|
||||
tx2, err := store.db.Begin()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
txStmt2, err := tx2.Prepare(`INSERT INTO transmissions
|
||||
(raw_hex, hash, first_seen, route_type, payload_type, payload_version, decoded_json, from_pubkey)
|
||||
VALUES ('', ?, ?, 0, ?, 0, '{}', 'aaaaaaaaaa')`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
obsStmt2, err := tx2.Prepare(`INSERT INTO observations
|
||||
(transmission_id, observer_idx, path_json, timestamp) VALUES (?, ?, '["bb"]', ?)`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := 0; i < delta; i++ {
|
||||
res, err := txStmt2.Exec(fmt.Sprintf("d%d", i), deltaStartTs+int64(i), payloadADVERT)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
txID, _ := res.LastInsertId()
|
||||
if _, err := obsStmt2.Exec(txID, obsRowid, deltaStartTs+int64(i)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := tx2.Commit(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
deltaStart := time.Now()
|
||||
n3, err := store.buildAndPersistNeighborEdges()
|
||||
if err != nil {
|
||||
t.Fatalf("delta build: %v", err)
|
||||
}
|
||||
deltaDur := time.Since(deltaStart)
|
||||
// Each ADVERT observation with a non-empty path produces 2 edge
|
||||
// candidates (from↔hop[0] and observer↔hop[-1]). The watermark
|
||||
// must clamp the scan to the delta rows ONLY — anything more
|
||||
// proves the WHERE clause was bypassed.
|
||||
if n3 != delta*2 {
|
||||
t.Fatalf("expected %d edges upserted (delta only, 2 per advert obs); got %d. "+
|
||||
"Builder must only scan observations with timestamp > MAX(neighbor_edges.last_seen). (#1339)",
|
||||
delta*2, n3)
|
||||
}
|
||||
if deltaDur > 500*time.Millisecond {
|
||||
t.Fatalf("delta build of %d rows took %v; expected <500ms. (#1339)", delta, deltaDur)
|
||||
}
|
||||
|
||||
// Sanity: MAX(last_seen) advanced.
|
||||
var maxLastSeen2 string
|
||||
if err := store.db.QueryRow(`SELECT MAX(last_seen) FROM neighbor_edges`).Scan(&maxLastSeen2); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if maxLastSeen2 <= maxLastSeen {
|
||||
t.Fatalf("MAX(last_seen) did not advance: was %s, now %s", maxLastSeen, maxLastSeen2)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNormalizeChannelName(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
// Known channel: "public" should be normalized to "Public"
|
||||
{"public", "Public"},
|
||||
{"Public", "Public"},
|
||||
{"PUBLIC", "Public"},
|
||||
// Hashtag channels should be left untouched
|
||||
{"#LongFast", "#LongFast"},
|
||||
{"#wardrive", "#wardrive"},
|
||||
// Custom/unknown channels should be left untouched
|
||||
{"myChannel", "myChannel"},
|
||||
{"testchannel", "testchannel"},
|
||||
// Empty string
|
||||
{"", ""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
got := normalizeChannelName(tt.input)
|
||||
if got != tt.expected {
|
||||
t.Errorf("normalizeChannelName(%q) = %q, want %q", tt.input, got, tt.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadChannelKeys_NormalizesKnownDisplayNames(t *testing.T) {
|
||||
// Verify that known channel keys with wrong casing get normalized
|
||||
cfg := &Config{
|
||||
ChannelKeys: map[string]string{
|
||||
"public": "8b3387e9c5cdea6ac9e5edbaa115cd72",
|
||||
},
|
||||
}
|
||||
|
||||
keys := loadChannelKeys(cfg, "/dev/null")
|
||||
|
||||
// Should have "Public" (normalized) not "public" (raw)
|
||||
if _, ok := keys["public"]; ok {
|
||||
t.Error("Expected 'public' to be normalized to 'Public'")
|
||||
}
|
||||
if _, ok := keys["Public"]; !ok {
|
||||
t.Error("Expected 'Public' key to exist in loaded channel keys")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadChannelKeys_LeavesCustomNamesUntouched(t *testing.T) {
|
||||
// Verify that custom channel names are NOT normalized
|
||||
cfg := &Config{
|
||||
ChannelKeys: map[string]string{
|
||||
"myCustomChannel": "deadbeef12345678",
|
||||
},
|
||||
}
|
||||
|
||||
keys := loadChannelKeys(cfg, "/dev/null")
|
||||
|
||||
// Should keep "myCustomChannel" as-is
|
||||
if _, ok := keys["myCustomChannel"]; !ok {
|
||||
t.Error("Expected 'myCustomChannel' to be left untouched")
|
||||
}
|
||||
// Should NOT have "MyCustomChannel"
|
||||
if _, ok := keys["MyCustomChannel"]; ok {
|
||||
t.Error("Custom channel names should NOT be auto-capitalized")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadChannelKeys_DuplicateCasingLogsWarning(t *testing.T) {
|
||||
// Verify that config with both "public" and "Public" resolves deterministically:
|
||||
// the canonical (already-normalized) form should win.
|
||||
cfg := &Config{
|
||||
ChannelKeys: map[string]string{
|
||||
"public": "8b3387e9c5cdea6ac9e5edbaa115cd72",
|
||||
"Public": "differentkey1234567",
|
||||
},
|
||||
}
|
||||
|
||||
keys := loadChannelKeys(cfg, "/dev/null")
|
||||
|
||||
// After normalization, only one key should exist: "Public"
|
||||
// The canonical form ("Public") should win over the lowercase form ("public")
|
||||
if _, ok := keys["public"]; ok {
|
||||
t.Error("Expected 'public' to be normalized away")
|
||||
}
|
||||
if _, ok := keys["Public"]; !ok {
|
||||
t.Error("Expected 'Public' key to exist")
|
||||
}
|
||||
// Assert the canonical form's value won, not just any value
|
||||
if keys["Public"] != "differentkey1234567" {
|
||||
t.Errorf("Expected canonical 'Public' value to win, got %q", keys["Public"])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package main
|
||||
|
||||
// Regression tests for issue #1465 — observer.last_seen MUST always reflect
|
||||
// ingest time (server wall clock), never the MQTT envelope timestamp. Observers
|
||||
// with broken clocks (wrong TZ, RTC drift, replayed retained messages) must
|
||||
// NOT be able to drag the analyzer's "last heard from" field into the past
|
||||
// or future.
|
||||
//
|
||||
// Per-packet rxTime semantics (envelope time with naive-clamp from #1464)
|
||||
// are out of scope here — those continue to use envelope time. This file
|
||||
// asserts only the observer.last_seen path.
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Status path: envelope timestamp is a well-formed RFC3339 value 3h in the
|
||||
// past. observer.last_seen must be server wall clock, NOT the envelope value.
|
||||
func TestStatusMessage_ObserverLastSeen_AlwaysIngestTime_PastEnvelope_1465(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
source := MQTTSource{Name: "test"}
|
||||
|
||||
stale := time.Now().UTC().Add(-3 * time.Hour).Format(time.RFC3339)
|
||||
before := time.Now().Unix()
|
||||
|
||||
payload := []byte(`{"status":"online","origin":"obs-past","timestamp":"` + stale + `"}`)
|
||||
msg := &mockMessage{topic: "meshcore/SJC/obs-past/status", payload: payload}
|
||||
|
||||
handleMessage(store, "test", source, msg, nil, nil, &Config{})
|
||||
after := time.Now().Unix()
|
||||
|
||||
var lastSeen string
|
||||
if err := store.db.QueryRow(`SELECT last_seen FROM observers WHERE id = ?`, "obs-past").Scan(&lastSeen); err != nil {
|
||||
t.Fatalf("scan last_seen: %v", err)
|
||||
}
|
||||
ls, err := time.Parse(time.RFC3339, lastSeen)
|
||||
if err != nil {
|
||||
t.Fatalf("last_seen %q not RFC3339: %v", lastSeen, err)
|
||||
}
|
||||
if ls.Unix() < before-5 || ls.Unix() > after+5 {
|
||||
t.Errorf("observer.last_seen = %q (epoch %d); want in [%d, %d] (server wall clock). "+
|
||||
"Envelope reported well-formed stale %q (3h ago) — must NOT drag last_seen into the past. Issue #1465.",
|
||||
lastSeen, ls.Unix(), before, after, stale)
|
||||
}
|
||||
}
|
||||
|
||||
// Status path: envelope timestamp 5 min in the future. observer.last_seen
|
||||
// must still be server wall clock.
|
||||
func TestStatusMessage_ObserverLastSeen_AlwaysIngestTime_FutureEnvelope_1465(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
source := MQTTSource{Name: "test"}
|
||||
|
||||
future := time.Now().UTC().Add(5 * time.Minute).Format(time.RFC3339)
|
||||
before := time.Now().Unix()
|
||||
|
||||
payload := []byte(`{"status":"online","origin":"obs-future","timestamp":"` + future + `"}`)
|
||||
msg := &mockMessage{topic: "meshcore/SJC/obs-future/status", payload: payload}
|
||||
|
||||
handleMessage(store, "test", source, msg, nil, nil, &Config{})
|
||||
after := time.Now().Unix()
|
||||
|
||||
var lastSeen string
|
||||
if err := store.db.QueryRow(`SELECT last_seen FROM observers WHERE id = ?`, "obs-future").Scan(&lastSeen); err != nil {
|
||||
t.Fatalf("scan last_seen: %v", err)
|
||||
}
|
||||
ls, err := time.Parse(time.RFC3339, lastSeen)
|
||||
if err != nil {
|
||||
t.Fatalf("last_seen %q not RFC3339: %v", lastSeen, err)
|
||||
}
|
||||
if ls.Unix() < before-5 || ls.Unix() > after+5 {
|
||||
t.Errorf("observer.last_seen = %q (epoch %d); want in [%d, %d] (server wall clock). "+
|
||||
"Envelope reported well-formed future %q (5 min ahead) — must NOT drag last_seen into the future. Issue #1465.",
|
||||
lastSeen, ls.Unix(), before, after, future)
|
||||
}
|
||||
}
|
||||
|
||||
// Packet path: a transmission whose envelope timestamp is 3h in the past
|
||||
// MUST still bump observer.last_seen to server wall clock — observer is
|
||||
// clearly alive (we just ingested a packet from it), regardless of what
|
||||
// its clock claims.
|
||||
func TestPacketMessage_ObserverLastSeen_AlwaysIngestTime_PastEnvelope_1465(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
source := MQTTSource{Name: "test"}
|
||||
|
||||
stale := time.Now().UTC().Add(-3 * time.Hour).Format(time.RFC3339)
|
||||
before := time.Now().Unix()
|
||||
|
||||
rawHex := "0A00D69FD7A5A7475DB07337749AE61FA53A4788E976"
|
||||
payload := []byte(`{"raw":"` + rawHex + `","SNR":5.5,"RSSI":-100.0,"origin":"obs-pkt","timestamp":"` + stale + `"}`)
|
||||
msg := &mockMessage{topic: "meshcore/SJC/obs-pkt/packets", payload: payload}
|
||||
|
||||
handleMessage(store, "test", source, msg, nil, nil, &Config{})
|
||||
after := time.Now().Unix()
|
||||
|
||||
var lastSeen string
|
||||
if err := store.db.QueryRow(`SELECT last_seen FROM observers WHERE id = ?`, "obs-pkt").Scan(&lastSeen); err != nil {
|
||||
t.Fatalf("scan last_seen: %v", err)
|
||||
}
|
||||
ls, err := time.Parse(time.RFC3339, lastSeen)
|
||||
if err != nil {
|
||||
t.Fatalf("last_seen %q not RFC3339: %v", lastSeen, err)
|
||||
}
|
||||
if ls.Unix() < before-5 || ls.Unix() > after+5 {
|
||||
t.Errorf("packet-path observer.last_seen = %q (epoch %d); want in [%d, %d] (server wall clock). "+
|
||||
"Envelope stale = %q. Observer just delivered a packet; last_seen must be NOW. Issue #1465.",
|
||||
lastSeen, ls.Unix(), before, after, stale)
|
||||
}
|
||||
}
|
||||
+86
-10
@@ -7,23 +7,27 @@ import (
|
||||
|
||||
func TestParseEnvelopeTime(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
ok bool
|
||||
name string
|
||||
in string
|
||||
ok bool
|
||||
wantNaive bool
|
||||
}{
|
||||
{"rfc3339 utc", "2026-05-16T10:00:00Z", true},
|
||||
{"rfc3339 offset", "2026-05-16T12:00:00+02:00", true},
|
||||
{"naive iso", "2026-05-16T10:00:00", true},
|
||||
{"naive iso micros", "2026-05-16T10:00:00.123456", true},
|
||||
{"garbage", "not-a-time", false},
|
||||
{"empty", "", false},
|
||||
{"rfc3339 utc", "2026-05-16T10:00:00Z", true, false},
|
||||
{"rfc3339 offset", "2026-05-16T12:00:00+02:00", true, false},
|
||||
{"naive iso", "2026-05-16T10:00:00", true, true},
|
||||
{"naive iso micros", "2026-05-16T10:00:00.123456", true, true},
|
||||
{"garbage", "not-a-time", false, false},
|
||||
{"empty", "", false, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
_, err := parseEnvelopeTime(c.in)
|
||||
_, naive, err := parseEnvelopeTime(c.in)
|
||||
if (err == nil) != c.ok {
|
||||
t.Fatalf("parseEnvelopeTime(%q): want ok=%v, got err=%v", c.in, c.ok, err)
|
||||
}
|
||||
if err == nil && naive != c.wantNaive {
|
||||
t.Fatalf("parseEnvelopeTime(%q): want naive=%v, got %v", c.in, c.wantNaive, naive)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -78,3 +82,75 @@ func TestResolveRxTime(t *testing.T) {
|
||||
t.Errorf("recent timestamp <30d: got %q want %q", got, recent)
|
||||
}
|
||||
}
|
||||
|
||||
// Regression: issue #1463 — naive (zone-less) ISO timestamps from observers
|
||||
// in negative-UTC-offset zones (e.g. California PDT, UTC−7) were interpreted
|
||||
// as UTC, producing rxTime values 7h in the past that poisoned `last_seen`
|
||||
// and rendered the observer perpetually "Stale" in the UI. The symmetric
|
||||
// clamp now collapses any naive timestamp more than 15 min off server-now to
|
||||
// `now()`, while zone-aware timestamps (RFC3339 with Z or offset) are still
|
||||
// honored verbatim regardless of skew (those are well-behaved observers).
|
||||
func TestResolveRxTimeNaiveTimestampClamp(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
|
||||
mustParse := func(s string) time.Time {
|
||||
t.Helper()
|
||||
parsed, err := time.Parse(time.RFC3339, s)
|
||||
if err != nil {
|
||||
t.Fatalf("result %q is not RFC3339: %v", s, err)
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
nearNow := func(s string) bool {
|
||||
d := mustParse(s).Sub(now)
|
||||
if d < 0 {
|
||||
d = -d
|
||||
}
|
||||
return d <= time.Minute
|
||||
}
|
||||
|
||||
// California observer (UTC-7) emitting a naive local-clock timestamp:
|
||||
// must NOT be stored verbatim 7h in the past — clamp to ~now.
|
||||
naivePast := now.Add(-7 * time.Hour).Format("2006-01-02T15:04:05")
|
||||
if got := resolveRxTime(map[string]interface{}{"timestamp": naivePast}, "test"); !nearNow(got) {
|
||||
t.Errorf("naive past timestamp (UTC-7 observer): got %q, expected ~now (clamped)", got)
|
||||
}
|
||||
|
||||
// Naive future just minutes ahead (UTC+N observer, existing soft-clamp
|
||||
// behavior): still clamped to now.
|
||||
naiveFuture := now.Add(5 * time.Minute).Format("2006-01-02T15:04:05")
|
||||
if got := resolveRxTime(map[string]interface{}{"timestamp": naiveFuture}, "test"); !nearNow(got) {
|
||||
t.Errorf("naive future timestamp: got %q, expected ~now (clamped)", got)
|
||||
}
|
||||
|
||||
// Naive microsecond layout (python isoformat without tz) — same clamp.
|
||||
naivePastMicros := now.Add(-7 * time.Hour).Format("2006-01-02T15:04:05.000000")
|
||||
if got := resolveRxTime(map[string]interface{}{"timestamp": naivePastMicros}, "test"); !nearNow(got) {
|
||||
t.Errorf("naive past timestamp w/ micros: got %q, expected ~now (clamped)", got)
|
||||
}
|
||||
|
||||
// Well-behaved observer: Z-suffixed past timestamp passes through verbatim
|
||||
// even if it's hours old (legitimate buffered uploads must be preserved).
|
||||
zPast := now.Add(-7 * time.Hour).Format(time.RFC3339)
|
||||
if got := resolveRxTime(map[string]interface{}{"timestamp": zPast}, "test"); got != zPast {
|
||||
t.Errorf("Z-suffixed past timestamp must pass through: got %q want %q", got, zPast)
|
||||
}
|
||||
|
||||
// Well-behaved observer with explicit offset (UTC-7) — canonicalize to UTC
|
||||
// but preserve the moment in time. Must equal the same moment in UTC.
|
||||
offsetLoc := time.FixedZone("PDT", -7*3600)
|
||||
offsetMoment := now.Add(-7 * time.Hour).In(offsetLoc)
|
||||
offsetStr := offsetMoment.Format(time.RFC3339)
|
||||
wantUTC := offsetMoment.UTC().Format(time.RFC3339)
|
||||
if got := resolveRxTime(map[string]interface{}{"timestamp": offsetStr}, "test"); got != wantUTC {
|
||||
t.Errorf("offset-suffixed timestamp: got %q want %q", got, wantUTC)
|
||||
}
|
||||
|
||||
// Naive timestamp within tolerance window (2 min in past, observer that
|
||||
// happens to be in UTC) — within tolerance, passes through verbatim.
|
||||
naiveCloseStr := now.Add(-2 * time.Minute).Format("2006-01-02T15:04:05")
|
||||
naiveCloseWant := now.Add(-2 * time.Minute).Format(time.RFC3339)
|
||||
if got := resolveRxTime(map[string]interface{}{"timestamp": naiveCloseStr}, "test"); got != naiveCloseWant {
|
||||
t.Errorf("naive timestamp within tolerance: got %q, expected %q (verbatim)", got, naiveCloseWant)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
package main
|
||||
|
||||
// Regression tests for issue #1366: Channel view shows stale timestamps
|
||||
// because GetChannelMessages emits tx.FirstSeen (first-observation time)
|
||||
// when the operator-visible expectation is the latest observation time
|
||||
// (tx.LatestSeen). For repeated heartbeat-style messages whose tx.Hash is
|
||||
// stable, FirstSeen stays pinned to the very first observation while the
|
||||
// real-world transmission keeps repeating, producing a multi-hour gap
|
||||
// between the channel view and the operator's live MeshCore client.
|
||||
//
|
||||
// Server-side UTC clocks are trusted; client-reported sender_timestamp
|
||||
// is NOT (firmware lacks reliable wall-clock on many builds). Therefore
|
||||
// the fix uses tx.LatestSeen (== max observation timestamp), NOT
|
||||
// sender_timestamp. sender_timestamp remains exposed in the response
|
||||
// for debug surfaces but MUST NOT be the rendered field.
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestChannelMessages_TimestampUsesLatestSeen: a CHAN tx with multiple
|
||||
// observations spanning hours must render with the LATEST observation
|
||||
// timestamp, not the first-seen ingest time.
|
||||
func TestChannelMessages_TimestampUsesLatestSeen(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
|
||||
now := time.Now().UTC()
|
||||
firstSeen := now.Add(-7 * time.Hour).Format(time.RFC3339)
|
||||
firstSeenEpoch := now.Add(-7 * time.Hour).Unix()
|
||||
laterEpoch := now.Add(-5 * time.Minute).Unix()
|
||||
_ = laterEpoch
|
||||
|
||||
db.conn.Exec(`INSERT INTO observers (id, name, iata, last_seen, first_seen, packet_count)
|
||||
VALUES ('obsA', 'ObsA', 'SJC', ?, '2026-01-01T00:00:00Z', 10)`, firstSeen)
|
||||
db.conn.Exec(`INSERT INTO observers (id, name, iata, last_seen, first_seen, packet_count)
|
||||
VALUES ('obsB', 'ObsB', 'LAX', ?, '2026-01-01T00:00:00Z', 10)`, firstSeen)
|
||||
|
||||
// One transmission with two observations: T0 (7h ago) and T1 (5m ago).
|
||||
db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json, channel_hash)
|
||||
VALUES ('AA01', 'hash_repeated_msg', ?, 1, 5,
|
||||
'{"type":"CHAN","channel":"#test","text":"Heartbeat: ping","sender":"Heartbeat","sender_timestamp":` +
|
||||
strconv.FormatInt(firstSeenEpoch, 10) + `}',
|
||||
'#test')`, firstSeen)
|
||||
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
|
||||
VALUES (1, 1, 10.0, -90, '["aa"]', ?)`, firstSeenEpoch)
|
||||
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
|
||||
VALUES (1, 2, 11.0, -88, '["bb"]', ?)`, laterEpoch)
|
||||
|
||||
store := NewPacketStore(db, nil)
|
||||
store.Load()
|
||||
|
||||
msgs, total := store.GetChannelMessages("#test", 10, 0)
|
||||
if total != 1 {
|
||||
t.Fatalf("want 1 msg, got %d (msgs=%+v)", total, msgs)
|
||||
}
|
||||
got, _ := msgs[0]["timestamp"].(string)
|
||||
gotParsed, err := time.Parse(time.RFC3339, got)
|
||||
if err != nil {
|
||||
// Try the milli-second precision form that SQLite strftime emits.
|
||||
gotParsed, err = time.Parse("2006-01-02T15:04:05.000Z", got)
|
||||
if err != nil {
|
||||
gotParsed, err = time.Parse("2006-01-02T15:04:05.000Z07:00", got)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("timestamp not parseable: %q (%v)", got, err)
|
||||
}
|
||||
// LatestSeen should equal the laterEpoch observation (±1s).
|
||||
if delta := gotParsed.Unix() - laterEpoch; delta < -1 || delta > 1 {
|
||||
t.Errorf("timestamp: want ~%s (LatestSeen, observation at T-5m), got %q (Δ=%ds — likely FirstSeen, issue #1366)",
|
||||
time.Unix(laterEpoch, 0).UTC().Format(time.RFC3339), got, delta)
|
||||
}
|
||||
|
||||
// first_seen MUST also be exposed separately so the UI/debug can see
|
||||
// when the analyzer first heard the packet (older than `timestamp`).
|
||||
fs, _ := msgs[0]["first_seen"].(string)
|
||||
if fs == "" {
|
||||
t.Errorf("first_seen field must be exposed alongside timestamp; got empty")
|
||||
}
|
||||
if fs == got {
|
||||
t.Errorf("first_seen should differ from latest-seen timestamp (both = %q)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestChannelMessages_TimestampNotSenderTimestamp: a CHAN tx whose
|
||||
// decoded sender_timestamp is wildly off (e.g. client with bad RTC)
|
||||
// must NOT cause the rendered timestamp to drift. Rendered timestamp
|
||||
// must remain server UTC (LatestSeen/FirstSeen), regardless of what
|
||||
// the client claimed.
|
||||
func TestChannelMessages_TimestampNotSenderTimestamp(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
|
||||
now := time.Now().UTC()
|
||||
firstSeen := now.Add(-10 * time.Minute).Format(time.RFC3339)
|
||||
firstSeenEpoch := now.Add(-10 * time.Minute).Unix()
|
||||
|
||||
// Client claims it sent the message in year 2000 (bad RTC).
|
||||
badSenderTs := int64(946684800) // 2000-01-01 UTC
|
||||
|
||||
db.conn.Exec(`INSERT INTO observers (id, name, iata, last_seen, first_seen, packet_count)
|
||||
VALUES ('obsX', 'ObsX', 'SJC', ?, '2026-01-01T00:00:00Z', 1)`, firstSeen)
|
||||
db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json, channel_hash)
|
||||
VALUES ('BB01', 'hash_bad_clock', ?, 1, 5,
|
||||
'{"type":"CHAN","channel":"#bad","text":"Alice: ping","sender":"Alice","sender_timestamp":` +
|
||||
strconv.FormatInt(badSenderTs, 10) + `}',
|
||||
'#bad')`, firstSeen)
|
||||
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
|
||||
VALUES (1, 1, 10.0, -90, '["aa"]', ?)`, firstSeenEpoch)
|
||||
|
||||
store := NewPacketStore(db, nil)
|
||||
store.Load()
|
||||
|
||||
msgs, total := store.GetChannelMessages("#bad", 10, 0)
|
||||
if total != 1 {
|
||||
t.Fatalf("want 1 msg, got %d", total)
|
||||
}
|
||||
got, _ := msgs[0]["timestamp"].(string)
|
||||
// MUST be the server-side observation time, parseable as RFC3339, and
|
||||
// within ~1h of now — NOT the year-2000 client value.
|
||||
parsed, err := time.Parse(time.RFC3339, got)
|
||||
if err != nil {
|
||||
t.Fatalf("timestamp not RFC3339: %q (%v)", got, err)
|
||||
}
|
||||
if parsed.Year() < now.Year() {
|
||||
t.Errorf("rendered timestamp %q took on the client's bad sender_timestamp (year %d) instead of server UTC",
|
||||
got, parsed.Year())
|
||||
}
|
||||
}
|
||||
|
||||
// TestChannelMessages_TimestampIsUTCZ: rendered timestamp MUST end with
|
||||
// 'Z' (or +00:00) so the browser does NOT interpret it as a local-zone
|
||||
// string and shift by the operator's TZ offset.
|
||||
func TestChannelMessages_TimestampIsUTCZ(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
|
||||
now := time.Now().UTC()
|
||||
fs := now.Add(-30 * time.Minute).Format(time.RFC3339)
|
||||
ep := now.Add(-30 * time.Minute).Unix()
|
||||
|
||||
db.conn.Exec(`INSERT INTO observers (id, name, iata, last_seen, first_seen, packet_count)
|
||||
VALUES ('obsZ', 'ObsZ', 'SJC', ?, '2026-01-01T00:00:00Z', 1)`, fs)
|
||||
db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json, channel_hash)
|
||||
VALUES ('ZZ01', 'hash_zone_check', ?, 1, 5,
|
||||
'{"type":"CHAN","channel":"#zone","text":"Carol: ping","sender":"Carol"}',
|
||||
'#zone')`, fs)
|
||||
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
|
||||
VALUES (1, 1, 11.0, -89, '["zz"]', ?)`, ep)
|
||||
|
||||
store := NewPacketStore(db, nil)
|
||||
store.Load()
|
||||
|
||||
msgs, _ := store.GetChannelMessages("#zone", 10, 0)
|
||||
if len(msgs) != 1 {
|
||||
t.Fatalf("want 1 msg, got %d", len(msgs))
|
||||
}
|
||||
ts, _ := msgs[0]["timestamp"].(string)
|
||||
if ts == "" {
|
||||
t.Fatal("empty timestamp")
|
||||
}
|
||||
n := len(ts)
|
||||
if !(ts[n-1] == 'Z' || (n >= 6 && ts[n-6:] == "+00:00")) {
|
||||
t.Errorf("timestamp not UTC-suffixed (Z/+00:00): %q", ts)
|
||||
}
|
||||
}
|
||||
|
||||
// TestChannelMessages_OrderedByLatestSeen: adversarial follow-up to #1366
|
||||
// (PR #1368). The earlier fix only adjusted the rendered `timestamp`
|
||||
// field; page SELECTION and SORT ORDER on both the in-memory and DB
|
||||
// paths still used FirstSeen. This test pins the contract:
|
||||
//
|
||||
// - tx-A: FirstSeen 24h ago, LatestSeen NOW (via a fresh observation).
|
||||
// - tx-B: FirstSeen 1h ago, LatestSeen 1h ago (single observation).
|
||||
//
|
||||
// Both paths MUST:
|
||||
// 1. Return BOTH transmissions in a small (limit=10) page — tx-A must
|
||||
// not be excluded because its FirstSeen is old.
|
||||
// 2. Return tx-A AFTER tx-B (newest-LatestSeen-LAST), matching the
|
||||
// tail-of-msgOrder convention used by the rest of the API and
|
||||
// the frontend's scrollToBottom().
|
||||
func TestChannelMessages_OrderedByLatestSeen_InMemory(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
|
||||
now := time.Now().UTC()
|
||||
tOld := now.Add(-24 * time.Hour)
|
||||
tMid := now.Add(-1 * time.Hour)
|
||||
tNewest := now.Add(-30 * time.Minute)
|
||||
tFresh := now.Add(-1 * time.Minute)
|
||||
|
||||
tOldStr := tOld.Format(time.RFC3339)
|
||||
tMidStr := tMid.Format(time.RFC3339)
|
||||
tNewestStr := tNewest.Format(time.RFC3339)
|
||||
|
||||
db.conn.Exec(`INSERT INTO observers (id, name, iata, last_seen, first_seen, packet_count)
|
||||
VALUES ('obsO', 'ObsO', 'SJC', ?, '2026-01-01T00:00:00Z', 10)`, tOldStr)
|
||||
|
||||
// tx-A: FirstSeen 24h ago, LatestSeen NOW (T-1m). Old insertion order.
|
||||
db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json, channel_hash)
|
||||
VALUES ('AAAA', 'order_hash_a', ?, 1, 5,
|
||||
'{"type":"CHAN","channel":"#ord","text":"Alpha: hb","sender":"Alpha"}', '#ord')`, tOldStr)
|
||||
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
|
||||
VALUES (1, 1, 10.0, -90, '["aa"]', ?)`, tOld.Unix())
|
||||
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
|
||||
VALUES (1, 1, 11.0, -88, '["aa"]', ?)`, tFresh.Unix())
|
||||
|
||||
// tx-B: FirstSeen 1h ago, LatestSeen 1h ago. OLDEST.
|
||||
db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json, channel_hash)
|
||||
VALUES ('BBBB', 'order_hash_b', ?, 1, 5,
|
||||
'{"type":"CHAN","channel":"#ord","text":"Bravo: msg","sender":"Bravo"}', '#ord')`, tMidStr)
|
||||
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
|
||||
VALUES (2, 1, 9.0, -91, '["bb"]', ?)`, tMid.Unix())
|
||||
|
||||
// tx-C: FirstSeen 30m ago, LatestSeen 30m ago. Middle.
|
||||
db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json, channel_hash)
|
||||
VALUES ('CCCC', 'order_hash_c', ?, 1, 5,
|
||||
'{"type":"CHAN","channel":"#ord","text":"Charlie: msg","sender":"Charlie"}', '#ord')`, tNewestStr)
|
||||
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
|
||||
VALUES (3, 1, 9.0, -91, '["cc"]', ?)`, tNewest.Unix())
|
||||
|
||||
store := NewPacketStore(db, nil)
|
||||
store.Load()
|
||||
|
||||
// Full-page: ordering check (fix #1 gates this — without sort,
|
||||
// msgOrder is insertion order and Alpha lands FIRST, not LAST).
|
||||
msgsAll, totalAll := store.GetChannelMessages("#ord", 10, 0)
|
||||
if totalAll != 3 {
|
||||
t.Fatalf("in-memory: want total=3, got %d", totalAll)
|
||||
}
|
||||
if len(msgsAll) != 3 {
|
||||
t.Fatalf("in-memory: want 3 msgs, got %d", len(msgsAll))
|
||||
}
|
||||
wantOrder := []string{"Bravo", "Charlie", "Alpha"}
|
||||
for i, want := range wantOrder {
|
||||
got, _ := msgsAll[i]["sender"].(string)
|
||||
if got != want {
|
||||
t.Errorf("in-memory: msg[%d] want sender=%q, got %q (LatestSeen ASC, fix #1)", i, want, got)
|
||||
}
|
||||
}
|
||||
|
||||
// Small page (limit=2): tx-A (Alpha) MUST be included because its
|
||||
// LatestSeen is freshest, even though FirstSeen is oldest. Without
|
||||
// fix #1, the in-memory path takes msgOrder[total-2:] which would
|
||||
// drop Alpha (it sits at msgOrder[0] by insertion order).
|
||||
msgsPage, _ := store.GetChannelMessages("#ord", 2, 0)
|
||||
if len(msgsPage) != 2 {
|
||||
t.Fatalf("in-memory: want 2 msgs at limit=2, got %d", len(msgsPage))
|
||||
}
|
||||
hasAlpha := false
|
||||
for _, m := range msgsPage {
|
||||
if s, _ := m["sender"].(string); s == "Alpha" {
|
||||
hasAlpha = true
|
||||
}
|
||||
}
|
||||
if !hasAlpha {
|
||||
t.Errorf("in-memory: tx-A (Alpha) excluded from limit=2 page — FirstSeen-based tail selection bug (fix #1 reverted?)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelMessages_OrderedByLatestSeen_DB(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
|
||||
now := time.Now().UTC()
|
||||
tOld := now.Add(-24 * time.Hour)
|
||||
tMid := now.Add(-1 * time.Hour)
|
||||
tNewest := now.Add(-30 * time.Minute)
|
||||
tFresh := now.Add(-1 * time.Minute)
|
||||
|
||||
tOldStr := tOld.Format(time.RFC3339)
|
||||
tMidStr := tMid.Format(time.RFC3339)
|
||||
tNewestStr := tNewest.Format(time.RFC3339)
|
||||
|
||||
db.conn.Exec(`INSERT INTO observers (id, name, iata, last_seen, first_seen, packet_count)
|
||||
VALUES ('obsD', 'ObsD', 'SJC', ?, '2026-01-01T00:00:00Z', 10)`, tOldStr)
|
||||
|
||||
// tx-A: FirstSeen 24h ago, observations at T-24h and T-1m (LatestSeen
|
||||
// = T-1m, the FRESHEST). Despite the freshest LatestSeen, a
|
||||
// FirstSeen-DESC selection would push it OFF a small page.
|
||||
db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json, channel_hash)
|
||||
VALUES ('AADB', 'order_db_hash_a', ?, 1, 5,
|
||||
'{"type":"CHAN","channel":"#ordb","text":"Alpha: hb","sender":"Alpha"}', '#ordb')`, tOldStr)
|
||||
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
|
||||
VALUES (1, 1, 10.0, -90, '["aa"]', ?)`, tOld.Unix())
|
||||
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
|
||||
VALUES (1, 1, 11.0, -88, '["aa"]', ?)`, tFresh.Unix())
|
||||
|
||||
// tx-B: FirstSeen 1h ago, LatestSeen 1h ago. OLDEST LatestSeen.
|
||||
db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json, channel_hash)
|
||||
VALUES ('BBDB', 'order_db_hash_b', ?, 1, 5,
|
||||
'{"type":"CHAN","channel":"#ordb","text":"Bravo: msg","sender":"Bravo"}', '#ordb')`, tMidStr)
|
||||
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
|
||||
VALUES (2, 1, 9.0, -91, '["bb"]', ?)`, tMid.Unix())
|
||||
|
||||
// tx-C: FirstSeen 30m ago, LatestSeen 30m ago. Middle LatestSeen.
|
||||
// With FirstSeen-DESC selection + limit=2, page = [tx-C, tx-B] and
|
||||
// tx-A is EXCLUDED — that's the selection bug fix #2 gates.
|
||||
db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json, channel_hash)
|
||||
VALUES ('CCDB', 'order_db_hash_c', ?, 1, 5,
|
||||
'{"type":"CHAN","channel":"#ordb","text":"Charlie: msg","sender":"Charlie"}', '#ordb')`, tNewestStr)
|
||||
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
|
||||
VALUES (3, 1, 9.0, -91, '["cc"]', ?)`, tNewest.Unix())
|
||||
|
||||
msgs, total, err := db.GetChannelMessages("#ordb", 2, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if total != 3 {
|
||||
t.Fatalf("DB: want total=3, got %d", total)
|
||||
}
|
||||
if len(msgs) != 2 {
|
||||
t.Fatalf("DB: want 2 msgs in page (limit=2), got %d", len(msgs))
|
||||
}
|
||||
// Selection (fix #2): the page MUST include tx-A (Alpha) because its
|
||||
// LatestSeen is the newest — even though its FirstSeen is the OLDEST.
|
||||
// With limit=2 + LatestSeen-DESC selection, page = [Alpha, Charlie].
|
||||
// Returned ASC by LatestSeen (newest LAST, fix #3) = [Charlie, Alpha].
|
||||
sender0, _ := msgs[0]["sender"].(string)
|
||||
sender1, _ := msgs[1]["sender"].(string)
|
||||
if sender0 != "Charlie" || sender1 != "Alpha" {
|
||||
t.Errorf("DB: want order [Charlie, Alpha] (page selected by LatestSeen DESC, returned ASC, fix #2+#3), got [%q, %q]",
|
||||
sender0, sender1)
|
||||
}
|
||||
hasAlpha := false
|
||||
for _, m := range msgs {
|
||||
if s, _ := m["sender"].(string); s == "Alpha" {
|
||||
hasAlpha = true
|
||||
}
|
||||
}
|
||||
if !hasAlpha {
|
||||
t.Errorf("DB: tx-A (Alpha) excluded from page — FirstSeen-based selection bug (fix #2 reverted?)")
|
||||
}
|
||||
|
||||
// Also exercise large-page case (limit > total): ordering-only check.
|
||||
msgsAll, totalAll, err := db.GetChannelMessages("#ordb", 10, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if totalAll != 3 || len(msgsAll) != 3 {
|
||||
t.Fatalf("DB: want all 3 msgs at limit=10, got total=%d len=%d", totalAll, len(msgsAll))
|
||||
}
|
||||
// Expected ASC by LatestSeen: Bravo (T-1h), Charlie (T-30m), Alpha (T-1m).
|
||||
wantOrder := []string{"Bravo", "Charlie", "Alpha"}
|
||||
for i, want := range wantOrder {
|
||||
got, _ := msgsAll[i]["sender"].(string)
|
||||
if got != want {
|
||||
t.Errorf("DB: msg[%d] want sender=%q, got %q (full order: must be LatestSeen ASC, fix #3)", i, want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Issue #1373: /api/channels emits a ghost "unknown" bucket for encrypted GRP_TXT
|
||||
// packets whose decoded JSON sets channel="" (server has no PSK to decrypt).
|
||||
// Fix A (cosmetic): drop the "unknown" bucket from the response so users only
|
||||
// see real channels. Encrypted-no-key packets are still observable via the
|
||||
// encrypted-channels analytics, just not as a fake "unknown" channel.
|
||||
//
|
||||
// This test seeds 5 GRP_TXT with Channel="" (encrypted-no-key) + 3 with
|
||||
// Channel="#real" and asserts GetChannels returns exactly one entry, #real —
|
||||
// no "unknown" bucket.
|
||||
|
||||
func TestGetChannels_NoUnknownBucket_1373(t *testing.T) {
|
||||
packets := []*StoreTx{
|
||||
makeGrpTx(129, "", "", ""),
|
||||
makeGrpTx(129, "", "", ""),
|
||||
makeGrpTx(129, "", "", ""),
|
||||
makeGrpTx(129, "", "", ""),
|
||||
makeGrpTx(129, "", "", ""),
|
||||
makeGrpTx(72, "#real", "hello", "alice"),
|
||||
makeGrpTx(72, "#real", "world", "bob"),
|
||||
makeGrpTx(72, "#real", "third", "carol"),
|
||||
}
|
||||
store := newChannelTestStore(packets)
|
||||
|
||||
channels := store.GetChannels("")
|
||||
|
||||
var gotNames []string
|
||||
for _, ch := range channels {
|
||||
name, _ := ch["name"].(string)
|
||||
gotNames = append(gotNames, name)
|
||||
if name == "unknown" {
|
||||
t.Errorf("GetChannels emitted ghost 'unknown' bucket (issue #1373): %+v", ch)
|
||||
}
|
||||
}
|
||||
if len(channels) != 1 {
|
||||
t.Fatalf("expected exactly 1 channel (#real), got %d: %v", len(channels), gotNames)
|
||||
}
|
||||
if name, _ := channels[0]["name"].(string); name != "#real" {
|
||||
t.Errorf("expected channel name '#real', got %q", name)
|
||||
}
|
||||
if mc, _ := channels[0]["messageCount"].(int); mc != 3 {
|
||||
t.Errorf("expected messageCount=3 for #real, got %v", channels[0]["messageCount"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetChannels_DB_NoUnknownBucket_1373 mirrors the in-memory test against
|
||||
// the DB-backed GetChannels path in cmd/server/db.go. It seeds GRP_TXT rows
|
||||
// with channel_hash NULL (encrypted, no PSK known to ingestor) + rows with
|
||||
// channel_hash="#real" and asserts the response contains only #real.
|
||||
//
|
||||
// Note: the DB path already filters NULL channel_hash via the SELECT (`channel_hash IS NOT NULL`),
|
||||
// AND nullStr("")==empty triggers `continue` in the loop. This test pins that
|
||||
// contract so a future refactor can't reintroduce an "unknown" bucket on the
|
||||
// DB side either.
|
||||
func TestGetChannels_DB_NoUnknownBucket_1373(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
|
||||
// Seed 5 encrypted GRP_TXT rows with channel_hash NULL (server had no PSK).
|
||||
for i := 0; i < 5; i++ {
|
||||
_, err := db.conn.Exec(`INSERT INTO transmissions
|
||||
(raw_hex, hash, first_seen, route_type, payload_type, decoded_json, channel_hash)
|
||||
VALUES (?, ?, '2026-05-25T12:00:00Z', 1, 5,
|
||||
'{"type":"CHAN","channel":"","text":"","sender":""}', NULL)`,
|
||||
"AA", sqlHashFor(i))
|
||||
if err != nil {
|
||||
t.Fatalf("seed encrypted row %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Seed 3 decrypted GRP_TXT rows with channel_hash="#real".
|
||||
for i := 0; i < 3; i++ {
|
||||
_, err := db.conn.Exec(`INSERT INTO transmissions
|
||||
(raw_hex, hash, first_seen, route_type, payload_type, decoded_json, channel_hash)
|
||||
VALUES (?, ?, '2026-05-25T12:00:00Z', 1, 5,
|
||||
'{"type":"CHAN","channel":"#real","text":"Alice: hi","sender":"Alice"}', '#real')`,
|
||||
"BB", sqlHashFor(100+i))
|
||||
if err != nil {
|
||||
t.Fatalf("seed real row %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
channels, err := db.GetChannels()
|
||||
if err != nil {
|
||||
t.Fatalf("GetChannels: %v", err)
|
||||
}
|
||||
|
||||
var gotNames []string
|
||||
for _, ch := range channels {
|
||||
name, _ := ch["name"].(string)
|
||||
gotNames = append(gotNames, name)
|
||||
if name == "unknown" {
|
||||
t.Errorf("DB GetChannels emitted ghost 'unknown' bucket (issue #1373): %+v", ch)
|
||||
}
|
||||
if name == "" {
|
||||
t.Errorf("DB GetChannels emitted empty-name channel bucket (issue #1373): %+v", ch)
|
||||
}
|
||||
}
|
||||
if len(channels) != 1 {
|
||||
t.Fatalf("expected exactly 1 channel (#real), got %d: %v", len(channels), gotNames)
|
||||
}
|
||||
if name, _ := channels[0]["name"].(string); name != "#real" {
|
||||
t.Errorf("expected channel name '#real', got %q", name)
|
||||
}
|
||||
}
|
||||
|
||||
// sqlHashFor returns a unique 16-char hex string per index for the
|
||||
// `hash` UNIQUE column in transmissions.
|
||||
func sqlHashFor(i int) string {
|
||||
return fmt.Sprintf("%016x", uint64(0x1373_0000_0000_0000)+uint64(i))
|
||||
}
|
||||
|
||||
// silence unused-import warning when the file is reduced.
|
||||
var _ = sql.ErrNoRows
|
||||
@@ -92,6 +92,13 @@ type Config struct {
|
||||
|
||||
DebugAffinity bool `json:"debugAffinity,omitempty"`
|
||||
|
||||
// MapDarkTileProvider selects the default dark-mode basemap provider for
|
||||
// new visitors. The client may override per-browser via the customizer
|
||||
// (persisted to localStorage). Allowed values: "carto-dark" (default),
|
||||
// "esri-darkgray-labels", "voyager-inverted", "positron-inverted". See
|
||||
// public/map-tile-providers.js for the registry. #1420.
|
||||
MapDarkTileProvider string `json:"mapDarkTileProvider,omitempty"`
|
||||
|
||||
// ObserverBlacklist is a list of observer public keys to exclude from API
|
||||
// responses (defense in depth — ingestor drops at ingest, server filters
|
||||
// any that slipped through from a prior unblocked window).
|
||||
|
||||
+93
-34
@@ -27,8 +27,9 @@ type DB struct {
|
||||
isV3 bool // v3 schema: observer_idx in observations (vs observer_id in v2)
|
||||
hasResolvedPath bool // observations table has resolved_path column
|
||||
hasObsRawHex bool // observations table has raw_hex column (#881)
|
||||
hasScopeName bool // transmissions.scope_name column exists (#899)
|
||||
hasDefaultScope bool // nodes.default_scope column exists (#899)
|
||||
hasScopeName bool // transmissions.scope_name column exists (#899)
|
||||
hasDefaultScope bool // nodes.default_scope column exists (#899)
|
||||
hasMultibyteSupCols bool // nodes/inactive_nodes have multibyte_sup/multibyte_evidence (#903)
|
||||
|
||||
// Channel list cache (60s TTL) — avoids repeated GROUP BY scans (#762)
|
||||
channelsCacheMu sync.Mutex
|
||||
@@ -121,8 +122,11 @@ func (db *DB) detectSchema() {
|
||||
var notNull, pk int
|
||||
var dflt sql.NullString
|
||||
if nodeRows.Scan(&cid, &colName, &colType, ¬Null, &dflt, &pk) == nil {
|
||||
if colName == "default_scope" {
|
||||
switch colName {
|
||||
case "default_scope":
|
||||
db.hasDefaultScope = true
|
||||
case "multibyte_sup":
|
||||
db.hasMultibyteSupCols = true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -493,8 +497,14 @@ func (db *DB) QueryPackets(q PacketQuery) (*PacketResult, error) {
|
||||
db.conn.QueryRow(countSQL, args...).Scan(&total)
|
||||
}
|
||||
|
||||
// #1345: order by ingest id, NOT first_seen. PR #1233 made first_seen=rxTime,
|
||||
// so buffered-then-uploaded observer packets with hours-old rxTime were
|
||||
// sorting to the top/middle and hiding fresh ingest. Ordering by id keeps
|
||||
// "latest activity" semantically equal to "what we ingested last" — which
|
||||
// is what the packets page is showing. The `since=` filter still uses
|
||||
// first_seen / observation timestamp, preserving "received-by-radio since X."
|
||||
selectCols, observerJoin := db.transmissionBaseSQL()
|
||||
querySQL := fmt.Sprintf("SELECT %s FROM transmissions t %s %s ORDER BY t.first_seen %s LIMIT ? OFFSET ?",
|
||||
querySQL := fmt.Sprintf("SELECT %s FROM transmissions t %s %s ORDER BY t.id %s LIMIT ? OFFSET ?",
|
||||
selectCols, observerJoin, w, q.Order)
|
||||
|
||||
qArgs := make([]interface{}, len(args))
|
||||
@@ -1013,7 +1023,10 @@ func (db *DB) GetRecentTransmissionsForNode(pubkey string, limit int) ([]map[str
|
||||
|
||||
selectCols, observerJoin := db.transmissionBaseSQL()
|
||||
|
||||
querySQL := fmt.Sprintf("SELECT %s FROM transmissions t %s WHERE t.from_pubkey = ? ORDER BY t.first_seen DESC LIMIT ?",
|
||||
// #1345: order by ingest id, not first_seen (=rxTime). Buffered observer
|
||||
// uploads with old rxTime would otherwise displace fresh activity from
|
||||
// the "recent transmissions for node" list.
|
||||
querySQL := fmt.Sprintf("SELECT %s FROM transmissions t %s WHERE t.from_pubkey = ? ORDER BY t.id DESC LIMIT ?",
|
||||
selectCols, observerJoin)
|
||||
args := []interface{}{pubkey, limit}
|
||||
|
||||
@@ -1633,27 +1646,38 @@ func (db *DB) GetChannelMessages(channelHash string, limit, offset int, region .
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
// 2) Page of transmission IDs — newest LIMIT msgs minus OFFSET, returned
|
||||
// in ASC order to match prior API contract (tail of message log).
|
||||
pageSQL := `SELECT t.id FROM (
|
||||
SELECT id FROM transmissions
|
||||
WHERE channel_hash = ? AND payload_type = 5
|
||||
ORDER BY first_seen DESC
|
||||
LIMIT ? OFFSET ?
|
||||
) t`
|
||||
// When a region filter is in play, we must filter on the inner subquery
|
||||
// against the transmissions table — re-use the same EXISTS form but
|
||||
// wrap so we still get DESC-then-ASC pagination.
|
||||
// 2) Page of transmission IDs — newest LIMIT msgs minus OFFSET.
|
||||
// Issue #1366 follow-up (fix #2): select page by latest observation
|
||||
// timestamp (LatestSeen) DESC, NOT by t.first_seen DESC — otherwise
|
||||
// a heartbeat tx whose FirstSeen is 24h old but whose latest
|
||||
// observation is fresh gets pushed off page 1.
|
||||
//
|
||||
// PR #1368 perf fix: use a correlated subquery for MAX(timestamp) per
|
||||
// transmission. With the composite index idx_observations_tx_ts
|
||||
// (transmission_id, timestamp) sqlite resolves MAX as an index-only
|
||||
// rightmost-leaf lookup — total O(N_tx · log N_obs). The previously-
|
||||
// used grouped derived table (`GROUP BY transmission_id` over the
|
||||
// whole observations table) scanned all observation rows (O(N_obs))
|
||||
// and blew the 1.5s perf budget on 1500 tx × 50 obs under -race.
|
||||
// LEFT JOIN + GROUP BY t.id was even slower because GROUP BY forced
|
||||
// a temp B-tree on the full transmissions×observations join.
|
||||
//
|
||||
// The returned page is in newest-LatestSeen-FIRST (DESC) order.
|
||||
// The Go side re-orders the emitted rows ASC below (fix #3) so the
|
||||
// contract matches the in-memory path's tail-of-msgOrder convention.
|
||||
pageSQL := `SELECT t.id,
|
||||
COALESCE((SELECT MAX(timestamp) FROM observations WHERE transmission_id = t.id), 0) AS latest_obs_epoch
|
||||
FROM transmissions t
|
||||
WHERE t.channel_hash = ? AND t.payload_type = 5
|
||||
ORDER BY latest_obs_epoch DESC, t.id DESC
|
||||
LIMIT ? OFFSET ?`
|
||||
if len(regionCodes) > 0 {
|
||||
pageSQL = `SELECT id FROM (
|
||||
SELECT t.id, t.first_seen FROM transmissions t
|
||||
WHERE t.channel_hash = ? AND t.payload_type = 5` + regionFilter + `
|
||||
ORDER BY t.first_seen DESC
|
||||
LIMIT ? OFFSET ?
|
||||
) sub
|
||||
ORDER BY first_seen ASC`
|
||||
} else {
|
||||
pageSQL += ` ORDER BY (SELECT first_seen FROM transmissions WHERE id = t.id) ASC`
|
||||
pageSQL = `SELECT t.id,
|
||||
COALESCE((SELECT MAX(timestamp) FROM observations WHERE transmission_id = t.id), 0) AS latest_obs_epoch
|
||||
FROM transmissions t
|
||||
WHERE t.channel_hash = ? AND t.payload_type = 5` + regionFilter + `
|
||||
ORDER BY latest_obs_epoch DESC, t.id DESC
|
||||
LIMIT ? OFFSET ?`
|
||||
}
|
||||
pageArgs := []interface{}{channelHash}
|
||||
pageArgs = append(pageArgs, regionArgs...)
|
||||
@@ -1666,7 +1690,8 @@ func (db *DB) GetChannelMessages(channelHash string, limit, offset int, region .
|
||||
pageIDs := make([]int, 0, limit)
|
||||
for idRows.Next() {
|
||||
var id int
|
||||
if err := idRows.Scan(&id); err == nil {
|
||||
var le sql.NullInt64
|
||||
if err := idRows.Scan(&id, &le); err == nil {
|
||||
pageIDs = append(pageIDs, id)
|
||||
}
|
||||
}
|
||||
@@ -1688,7 +1713,7 @@ func (db *DB) GetChannelMessages(channelHash string, limit, offset int, region .
|
||||
var obsSQL string
|
||||
if db.isV3 {
|
||||
obsSQL = `SELECT o.id, t.id, t.hash, t.decoded_json, t.first_seen,
|
||||
obs.id, obs.name, o.snr, o.path_json
|
||||
obs.id, obs.name, o.snr, o.path_json, o.timestamp
|
||||
FROM observations o
|
||||
JOIN transmissions t ON t.id = o.transmission_id
|
||||
LEFT JOIN observers obs ON obs.rowid = o.observer_idx
|
||||
@@ -1696,7 +1721,7 @@ func (db *DB) GetChannelMessages(channelHash string, limit, offset int, region .
|
||||
ORDER BY o.id ASC`
|
||||
} else {
|
||||
obsSQL = `SELECT o.id, t.id, t.hash, t.decoded_json, t.first_seen,
|
||||
o.observer_id, o.observer_name, o.snr, o.path_json
|
||||
o.observer_id, o.observer_name, o.snr, o.path_json, o.timestamp
|
||||
FROM observations o
|
||||
JOIN transmissions t ON t.id = o.transmission_id
|
||||
WHERE t.id IN (` + strings.Join(idPlaceholders, ",") + `)
|
||||
@@ -1710,8 +1735,9 @@ func (db *DB) GetChannelMessages(channelHash string, limit, offset int, region .
|
||||
defer rows.Close()
|
||||
|
||||
type msg struct {
|
||||
Data map[string]interface{}
|
||||
Repeats int
|
||||
Data map[string]interface{}
|
||||
Repeats int
|
||||
LatestEpoch int64 // max observation timestamp (unix seconds) — issue #1366
|
||||
}
|
||||
msgMap := make(map[int]*msg, len(pageIDs))
|
||||
|
||||
@@ -1719,12 +1745,16 @@ func (db *DB) GetChannelMessages(channelHash string, limit, offset int, region .
|
||||
var pktID, txID int
|
||||
var pktHash, dj, fs, obsID, obsName, pathJSON sql.NullString
|
||||
var snr sql.NullFloat64
|
||||
rows.Scan(&pktID, &txID, &pktHash, &dj, &fs, &obsID, &obsName, &snr, &pathJSON)
|
||||
var obsTs sql.NullInt64
|
||||
rows.Scan(&pktID, &txID, &pktHash, &dj, &fs, &obsID, &obsName, &snr, &pathJSON, &obsTs)
|
||||
if !dj.Valid {
|
||||
continue
|
||||
}
|
||||
if existing, ok := msgMap[txID]; ok {
|
||||
existing.Repeats++
|
||||
if obsTs.Valid && obsTs.Int64 > existing.LatestEpoch {
|
||||
existing.LatestEpoch = obsTs.Int64
|
||||
}
|
||||
continue
|
||||
}
|
||||
var decoded map[string]interface{}
|
||||
@@ -1759,6 +1789,7 @@ func (db *DB) GetChannelMessages(channelHash string, limit, offset int, region .
|
||||
"sender": displaySender,
|
||||
"text": displayText,
|
||||
"timestamp": nullStr(fs),
|
||||
"first_seen": nullStr(fs),
|
||||
"sender_timestamp": senderTs,
|
||||
"packetId": pktID,
|
||||
"packetHash": nullStr(pktHash),
|
||||
@@ -1769,6 +1800,9 @@ func (db *DB) GetChannelMessages(channelHash string, limit, offset int, region .
|
||||
},
|
||||
Repeats: 1,
|
||||
}
|
||||
if obsTs.Valid {
|
||||
m.LatestEpoch = obsTs.Int64
|
||||
}
|
||||
if obsName.Valid {
|
||||
m.Data["observers"] = []string{obsName.String}
|
||||
} else if obsID.Valid {
|
||||
@@ -1777,7 +1811,16 @@ func (db *DB) GetChannelMessages(channelHash string, limit, offset int, region .
|
||||
msgMap[txID] = m
|
||||
}
|
||||
|
||||
messages := make([]map[string]interface{}, 0, len(pageIDs))
|
||||
// Issue #1366 follow-up: emit batch sorted by LatestSeen ascending
|
||||
// (newest LAST) — matches the in-memory path's tail-of-msgOrder
|
||||
// convention and the frontend's scrollToBottom() behavior. pageIDs
|
||||
// order is not LatestSeen-ordered for in-page rows after fix #2.
|
||||
type emitted struct {
|
||||
latestEpoch int64
|
||||
txID int
|
||||
data map[string]interface{}
|
||||
}
|
||||
rowsOut := make([]emitted, 0, len(pageIDs))
|
||||
for _, id := range pageIDs {
|
||||
m, ok := msgMap[id]
|
||||
if !ok {
|
||||
@@ -1787,7 +1830,22 @@ func (db *DB) GetChannelMessages(channelHash string, limit, offset int, region .
|
||||
continue
|
||||
}
|
||||
m.Data["repeats"] = m.Repeats
|
||||
messages = append(messages, m.Data)
|
||||
// Issue #1366: emit LatestSeen (max obs timestamp) as the rendered
|
||||
// `timestamp` field. `first_seen` stays alongside for debug.
|
||||
if m.LatestEpoch > 0 {
|
||||
m.Data["timestamp"] = time.Unix(m.LatestEpoch, 0).UTC().Format(time.RFC3339)
|
||||
}
|
||||
rowsOut = append(rowsOut, emitted{latestEpoch: m.LatestEpoch, txID: id, data: m.Data})
|
||||
}
|
||||
sort.SliceStable(rowsOut, func(i, j int) bool {
|
||||
if rowsOut[i].latestEpoch != rowsOut[j].latestEpoch {
|
||||
return rowsOut[i].latestEpoch < rowsOut[j].latestEpoch
|
||||
}
|
||||
return rowsOut[i].txID < rowsOut[j].txID
|
||||
})
|
||||
messages := make([]map[string]interface{}, 0, len(rowsOut))
|
||||
for _, e := range rowsOut {
|
||||
messages = append(messages, e.data)
|
||||
}
|
||||
|
||||
return messages, total, nil
|
||||
@@ -1968,7 +2026,8 @@ func (db *DB) QueryMultiNodePackets(pubkeys []string, limit, offset int, order,
|
||||
db.conn.QueryRow(fmt.Sprintf("SELECT COUNT(*) FROM transmissions t %s", w), args...).Scan(&total)
|
||||
|
||||
selectCols, observerJoin := db.transmissionBaseSQL()
|
||||
querySQL := fmt.Sprintf("SELECT %s FROM transmissions t %s %s ORDER BY t.first_seen %s LIMIT ? OFFSET ?",
|
||||
// #1345: order by ingest id (see QueryPackets comment above).
|
||||
querySQL := fmt.Sprintf("SELECT %s FROM transmissions t %s %s ORDER BY t.id %s LIMIT ? OFFSET ?",
|
||||
selectCols, observerJoin, w, order)
|
||||
|
||||
qArgs := make([]interface{}, len(args))
|
||||
|
||||
@@ -120,6 +120,16 @@ func setupTestDB(t *testing.T) *DB {
|
||||
WHERE id = NEW.id;
|
||||
END;
|
||||
CREATE INDEX IF NOT EXISTS idx_transmissions_from_pubkey ON transmissions(from_pubkey);
|
||||
|
||||
-- Mirror prod indexes from internal/dbschema/dbschema.go so query plans
|
||||
-- in tests match prod. idx_observations_transmission_id is required by
|
||||
-- GetChannelMessages's grouped MAX(timestamp) per tx aggregate
|
||||
-- (issue #1366 / PR #1368): without it the perf test on 1500 tx × 50 obs
|
||||
-- blows the 1.5s budget under -race.
|
||||
CREATE INDEX IF NOT EXISTS idx_observations_transmission_id ON observations(transmission_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_observations_timestamp ON observations(timestamp);
|
||||
CREATE INDEX IF NOT EXISTS idx_observations_tx_ts ON observations(transmission_id, timestamp);
|
||||
CREATE INDEX IF NOT EXISTS idx_transmissions_channel_hash ON transmissions(channel_hash);
|
||||
`
|
||||
if _, err := conn.Exec(schema); err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -45,3 +45,7 @@ require (
|
||||
require github.com/meshcore-analyzer/prunequeue v0.0.0
|
||||
|
||||
replace github.com/meshcore-analyzer/prunequeue => ../../internal/prunequeue
|
||||
|
||||
require github.com/meshcore-analyzer/mbcapqueue v0.0.0
|
||||
|
||||
replace github.com/meshcore-analyzer/mbcapqueue => ../../internal/mbcapqueue
|
||||
|
||||
@@ -126,6 +126,7 @@ func main() {
|
||||
default:
|
||||
log.Printf("[memlimit] no soft memory limit set (GOMEMLIMIT unset, packetStore.maxMemoryMB=0); recommend setting one to avoid container OOM-kill")
|
||||
}
|
||||
warnIfMemlimitUnderprovisioned(limit)
|
||||
}
|
||||
|
||||
// Resolve DB path
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
"runtime/debug"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// cgroupUnlimitedThreshold is the sentinel above which a cgroup memory value
|
||||
// means "no limit". cgroup v1 encodes unlimited as math.MaxInt64 (page-aligned
|
||||
// near 1<<63); 1<<62 is a safe upper bound that excludes all real limits while
|
||||
// staying well below the unlimited sentinel.
|
||||
const cgroupUnlimitedThreshold = int64(1 << 62)
|
||||
|
||||
// applyMemoryLimit configures Go's soft memory limit (GOMEMLIMIT).
|
||||
//
|
||||
// Behavior:
|
||||
@@ -30,3 +40,74 @@ func applyMemoryLimit(maxMemoryMB int, envSet bool) (int64, string) {
|
||||
debug.SetMemoryLimit(limit)
|
||||
return limit, "derived"
|
||||
}
|
||||
|
||||
// readCgroupMemoryMBFn is the package-level hook used by
|
||||
// warnIfMemlimitUnderprovisioned. Tests override it to inject deterministic
|
||||
// cgroup values without needing a Linux kernel with cgroup mounts.
|
||||
var readCgroupMemoryMBFn = readCgroupMemoryMB
|
||||
|
||||
// readCgroupMemoryMB returns the container's memory limit from cgroup, in MiB.
|
||||
// Returns 0 when unavailable (non-Linux, unlimited, or read error).
|
||||
func readCgroupMemoryMB() int64 {
|
||||
// cgroup v2: single file, value in bytes or literal "max"
|
||||
if b, err := os.ReadFile("/sys/fs/cgroup/memory.max"); err == nil {
|
||||
s := strings.TrimSpace(string(b))
|
||||
if s != "max" {
|
||||
if v, err := strconv.ParseInt(s, 10, 64); err == nil && v > 0 {
|
||||
return v / (1024 * 1024)
|
||||
}
|
||||
}
|
||||
}
|
||||
// cgroup v1: values near math.MaxInt64 represent "unlimited"
|
||||
if b, err := os.ReadFile("/sys/fs/cgroup/memory/memory.limit_in_bytes"); err == nil {
|
||||
if v, err := strconv.ParseInt(strings.TrimSpace(string(b)), 10, 64); err == nil {
|
||||
if v > 0 && v < cgroupUnlimitedThreshold {
|
||||
return v / (1024 * 1024)
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// memlimitUnderprovisioned reports whether effectiveMB is less than half of
|
||||
// cgroupMB. Extracted for unit testing the comparison boundary.
|
||||
func memlimitUnderprovisioned(effectiveMB, cgroupMB int64) bool {
|
||||
return effectiveMB > 0 && cgroupMB > 0 && effectiveMB*2 < cgroupMB
|
||||
}
|
||||
|
||||
// warnIfMemlimitUnderprovisioned logs a warning when GOMEMLIMIT is below 50%
|
||||
// of the container cgroup memory limit, which causes the Go GC to thrash.
|
||||
// In one reported incident (#1264) 82% of CPU was GC with a 1536 MiB limit
|
||||
// on a 7.7 GB container — all endpoints 3-100x slower until maxMemoryMB was
|
||||
// bumped and the process restarted.
|
||||
//
|
||||
// limitBytes is the value returned by applyMemoryLimit:
|
||||
// - source="derived": the limit we set ourselves (> 0)
|
||||
// - source="env": 0 — we did not touch the runtime; read it back below
|
||||
// - source="none": 0 — no limit set at all; runtime default is math.MaxInt64,
|
||||
// which the >= cgroupUnlimitedThreshold guard below catches and skips
|
||||
func warnIfMemlimitUnderprovisioned(limitBytes int64) {
|
||||
cgroupMB := readCgroupMemoryMBFn()
|
||||
if cgroupMB <= 0 {
|
||||
return
|
||||
}
|
||||
effective := limitBytes
|
||||
if effective <= 0 {
|
||||
// Either GOMEMLIMIT was set via env (source="env") or no limit was
|
||||
// configured (source="none"). Read the runtime's current value:
|
||||
// - env case: returns whatever the operator set
|
||||
// - none case: returns math.MaxInt64, caught by the guard below
|
||||
// debug.SetMemoryLimit(-1) leaves the limit unchanged and returns it.
|
||||
effective = debug.SetMemoryLimit(-1)
|
||||
}
|
||||
if effective <= 0 || effective >= cgroupUnlimitedThreshold {
|
||||
return
|
||||
}
|
||||
effectiveMB := effective / (1024 * 1024)
|
||||
if memlimitUnderprovisioned(effectiveMB, cgroupMB) {
|
||||
log.Printf("[memlimit] WARN: GOMEMLIMIT=%d MiB is <50%% of container limit %d MiB — "+
|
||||
"GC may thrash under load; consider bumping packetStore.maxMemoryMB "+
|
||||
"(suggested: ~%d MiB, roughly 2/3 of container limit)",
|
||||
effectiveMB, cgroupMB, cgroupMB*2/3)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"log"
|
||||
"runtime/debug"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -52,3 +55,109 @@ func TestApplyMemoryLimit_None(t *testing.T) {
|
||||
t.Fatalf("expected limit=0, got %d", limit)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemlimitUnderprovisioned(t *testing.T) {
|
||||
cases := []struct {
|
||||
effective, cgroup int64
|
||||
want bool
|
||||
}{
|
||||
{512, 1536, true}, // 512*2=1024 < 1536 → underprovisioned
|
||||
{768, 1536, false}, // 768*2=1536 == 1536 → not under (boundary)
|
||||
{1024, 1536, false},
|
||||
{0, 1536, false}, // no effective limit → skip
|
||||
{512, 0, false}, // no cgroup info → skip
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := memlimitUnderprovisioned(c.effective, c.cgroup)
|
||||
if got != c.want {
|
||||
t.Errorf("memlimitUnderprovisioned(%d, %d) = %v, want %v", c.effective, c.cgroup, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// captureLog redirects the default logger to a buffer for the duration of f,
|
||||
// then restores the previous writer. Returns captured output.
|
||||
func captureLog(f func()) string {
|
||||
var buf bytes.Buffer
|
||||
prev := log.Writer()
|
||||
log.SetOutput(&buf)
|
||||
defer log.SetOutput(prev)
|
||||
f()
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
// TestWarnIfMemlimitUnderprovisioned_EmitsWarning verifies the warning IS
|
||||
// logged when the injected cgroup reader reports a container limit more than
|
||||
// 2x larger than the effective GOMEMLIMIT.
|
||||
func TestWarnIfMemlimitUnderprovisioned_EmitsWarning(t *testing.T) {
|
||||
defer debug.SetMemoryLimit(-1)
|
||||
// Effective: 512 MiB; container: 2048 MiB → 512*2=1024 < 2048 → warn
|
||||
debug.SetMemoryLimit(int64(512) * 1024 * 1024)
|
||||
|
||||
orig := readCgroupMemoryMBFn
|
||||
readCgroupMemoryMBFn = func() int64 { return 2048 }
|
||||
defer func() { readCgroupMemoryMBFn = orig }()
|
||||
|
||||
out := captureLog(func() {
|
||||
warnIfMemlimitUnderprovisioned(int64(512) * 1024 * 1024)
|
||||
})
|
||||
if !strings.Contains(out, "[memlimit] WARN") {
|
||||
t.Errorf("expected warning log, got: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWarnIfMemlimitUnderprovisioned_NoWarnWhenAdequate verifies no warning
|
||||
// when GOMEMLIMIT is >= 50% of the container limit.
|
||||
func TestWarnIfMemlimitUnderprovisioned_NoWarnWhenAdequate(t *testing.T) {
|
||||
defer debug.SetMemoryLimit(-1)
|
||||
// Effective: 1024 MiB; container: 1536 MiB → 1024*2=2048 >= 1536 → no warn
|
||||
debug.SetMemoryLimit(int64(1024) * 1024 * 1024)
|
||||
|
||||
orig := readCgroupMemoryMBFn
|
||||
readCgroupMemoryMBFn = func() int64 { return 1536 }
|
||||
defer func() { readCgroupMemoryMBFn = orig }()
|
||||
|
||||
out := captureLog(func() {
|
||||
warnIfMemlimitUnderprovisioned(int64(1024) * 1024 * 1024)
|
||||
})
|
||||
if strings.Contains(out, "[memlimit] WARN") {
|
||||
t.Errorf("unexpected warning when limit is adequate: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWarnIfMemlimitUnderprovisioned_NoCgroupNoLog verifies early exit when
|
||||
// no cgroup info is available (non-Linux / non-container).
|
||||
func TestWarnIfMemlimitUnderprovisioned_NoCgroupNoLog(t *testing.T) {
|
||||
defer debug.SetMemoryLimit(-1)
|
||||
debug.SetMemoryLimit(int64(512) * 1024 * 1024)
|
||||
|
||||
orig := readCgroupMemoryMBFn
|
||||
readCgroupMemoryMBFn = func() int64 { return 0 }
|
||||
defer func() { readCgroupMemoryMBFn = orig }()
|
||||
|
||||
out := captureLog(func() {
|
||||
warnIfMemlimitUnderprovisioned(int64(512) * 1024 * 1024)
|
||||
})
|
||||
if strings.Contains(out, "[memlimit] WARN") {
|
||||
t.Errorf("unexpected warning when cgroup unavailable: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWarnIfMemlimitUnderprovisioned_NoneSource verifies that when no limit
|
||||
// was configured (source="none", limitBytes=0), the function reads back
|
||||
// math.MaxInt64 from the runtime and skips the warning.
|
||||
func TestWarnIfMemlimitUnderprovisioned_NoneSource(t *testing.T) {
|
||||
defer debug.SetMemoryLimit(-1)
|
||||
debug.SetMemoryLimit(int64(1<<63 - 1)) // math.MaxInt64 = "no limit"
|
||||
|
||||
orig := readCgroupMemoryMBFn
|
||||
readCgroupMemoryMBFn = func() int64 { return 2048 }
|
||||
defer func() { readCgroupMemoryMBFn = orig }()
|
||||
|
||||
out := captureLog(func() {
|
||||
warnIfMemlimitUnderprovisioned(0) // source="none" passes limit=0
|
||||
})
|
||||
if strings.Contains(out, "[memlimit] WARN") {
|
||||
t.Errorf("unexpected warning when no limit configured: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -433,3 +433,98 @@ func TestMultiByteCapability_AdopterEvidenceTakesPrecedence(t *testing.T) {
|
||||
t.Errorf("with adopter data: expected advert evidence, got %s", capByName["RepAdopter"].Evidence)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Persistence layer tests (#903, relocated #1324 follow-up) ---
|
||||
//
|
||||
// The actual DB persistence now lives in cmd/ingestor (see
|
||||
// cmd/ingestor/multibyte_persist_test.go). What the server is responsible
|
||||
// for is publishing the snapshot file that the ingestor consumes. The
|
||||
// data-destruction guard ("never overwrite confirmed with unknown") is
|
||||
// enforced by the ingestor, not the server — the snapshot can legitimately
|
||||
// carry "unknown" entries; the ingestor filters them.
|
||||
|
||||
// setupPersistTestDB creates an in-memory DB with multibyte_sup/multibyte_evidence columns.
|
||||
func setupPersistTestDB(t *testing.T) *DB {
|
||||
t.Helper()
|
||||
conn, err := sql.Open("sqlite", ":memory:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conn.SetMaxOpenConns(1)
|
||||
conn.Exec(`CREATE TABLE nodes (
|
||||
public_key TEXT PRIMARY KEY, name TEXT, role TEXT,
|
||||
lat REAL, lon REAL, last_seen TEXT, first_seen TEXT,
|
||||
advert_count INTEGER DEFAULT 0, battery_mv INTEGER, temperature_c REAL,
|
||||
foreign_advert INTEGER DEFAULT 0, default_scope TEXT,
|
||||
multibyte_sup INTEGER NOT NULL DEFAULT 0, multibyte_evidence TEXT
|
||||
)`)
|
||||
conn.Exec(`CREATE TABLE inactive_nodes (
|
||||
public_key TEXT PRIMARY KEY, name TEXT, role TEXT,
|
||||
lat REAL, lon REAL, last_seen TEXT, first_seen TEXT,
|
||||
advert_count INTEGER DEFAULT 0, battery_mv INTEGER, temperature_c REAL,
|
||||
foreign_advert INTEGER DEFAULT 0, default_scope TEXT,
|
||||
multibyte_sup INTEGER NOT NULL DEFAULT 0, multibyte_evidence TEXT
|
||||
)`)
|
||||
return &DB{conn: conn, hasMultibyteSupCols: true}
|
||||
}
|
||||
|
||||
// TestMultibyteCapGetMultibyteCapForO1 verifies that GetMultibyteCapFor returns
|
||||
// the correct entry via the O(1) mbCapIndex map.
|
||||
func TestMultibyteCapGetMultibyteCapForO1(t *testing.T) {
|
||||
db := setupPersistTestDB(t)
|
||||
store := NewPacketStore(db, nil)
|
||||
|
||||
// Directly populate the index as the analytics cycle would.
|
||||
store.cacheMu.Lock()
|
||||
store.mbCapIndex = map[string]MultiByteCapEntry{
|
||||
"aabbccdd11223344": {PublicKey: "aabbccdd11223344", Status: "confirmed", Evidence: "advert"},
|
||||
"eeff001122334455": {PublicKey: "eeff001122334455", Status: "suspected", Evidence: "path"},
|
||||
}
|
||||
store.cacheMu.Unlock()
|
||||
|
||||
e, ok := store.GetMultibyteCapFor("aabbccdd11223344")
|
||||
if !ok || e == nil {
|
||||
t.Fatal("expected entry for known pubkey, got none")
|
||||
}
|
||||
if e.Status != "confirmed" {
|
||||
t.Errorf("status = %q, want confirmed", e.Status)
|
||||
}
|
||||
|
||||
_, ok = store.GetMultibyteCapFor("0000000000000000")
|
||||
if ok {
|
||||
t.Error("expected no entry for unknown pubkey")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMultibyteCapLoadFromDB verifies that loadMultibyteCapFromDB skips nodes
|
||||
// with multibyte_sup == 0 and only loads confirmed/suspected entries.
|
||||
func TestMultibyteCapLoadFromDB(t *testing.T) {
|
||||
db := setupPersistTestDB(t)
|
||||
db.conn.Exec(`INSERT INTO nodes (public_key, name, role, last_seen, multibyte_sup, multibyte_evidence)
|
||||
VALUES ('aa11', 'A', 'repeater', '2026-01-01T00:00:00Z', 2, 'advert')`)
|
||||
db.conn.Exec(`INSERT INTO nodes (public_key, name, role, last_seen, multibyte_sup, multibyte_evidence)
|
||||
VALUES ('bb22', 'B', 'repeater', '2026-01-01T00:00:00Z', 1, 'path')`)
|
||||
db.conn.Exec(`INSERT INTO nodes (public_key, name, role, last_seen, multibyte_sup)
|
||||
VALUES ('cc33', 'C', 'repeater', '2026-01-01T00:00:00Z', 0)`) // unknown — must be skipped
|
||||
|
||||
store := NewPacketStore(db, nil)
|
||||
store.loadMultibyteCapFromDB()
|
||||
|
||||
store.cacheMu.Lock()
|
||||
snap := store.mbCapSnapshot
|
||||
idx := store.mbCapIndex
|
||||
store.cacheMu.Unlock()
|
||||
|
||||
if len(snap) != 2 {
|
||||
t.Fatalf("expected 2 entries (confirmed+suspected), got %d", len(snap))
|
||||
}
|
||||
if e, ok := idx["aa11"]; !ok || e.Status != "confirmed" {
|
||||
t.Errorf("aa11: expected confirmed, got %+v", e)
|
||||
}
|
||||
if e, ok := idx["bb22"]; !ok || e.Status != "suspected" {
|
||||
t.Errorf("bb22: expected suspected, got %+v", e)
|
||||
}
|
||||
if _, ok := idx["cc33"]; ok {
|
||||
t.Error("cc33 with sup=0 should not be in the index")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestQueryPacketsOrdersByIngestID is the regression test for issue #1345.
|
||||
//
|
||||
// PR #1233 changed `first_seen` to be the observer's receive time (rxTime),
|
||||
// not the moment the server ingested the row. When an observer buffers
|
||||
// offline and uploads hours later, its packets land with old first_seen
|
||||
// values. The /api/packets handler previously ordered by
|
||||
// `first_seen DESC`, so buffered uploads with old rxTime appeared at the
|
||||
// bottom while older-ingested packets with newer rxTime took the top —
|
||||
// users on the packets page saw "no recent activity" even though MQTT
|
||||
// ingest was active.
|
||||
//
|
||||
// Fix: default ordering for /api/packets is `t.id DESC` (ingest order).
|
||||
// This test inserts two rows where row order by id and order by
|
||||
// first_seen DISAGREE, then asserts the result is ordered by id DESC.
|
||||
func TestQueryPacketsOrdersByIngestID(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
|
||||
now := time.Now().UTC()
|
||||
// Row A: ingested FIRST (lower id), rxTime "newer" (fresher first_seen)
|
||||
freshFirstSeen := now.Add(-1 * time.Hour).Format(time.RFC3339)
|
||||
// Row B: ingested SECOND (higher id), rxTime "older" — simulating a
|
||||
// buffered observer upload that arrived after row A but contains a
|
||||
// packet the radio received hours earlier.
|
||||
bufferedFirstSeen := now.Add(-6 * time.Hour).Format(time.RFC3339)
|
||||
|
||||
if _, err := db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, payload_type)
|
||||
VALUES ('AA', 'hashfresh00000001', ?, 4)`, freshFirstSeen); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, payload_type)
|
||||
VALUES ('BB', 'hashbuffered00002', ?, 4)`, bufferedFirstSeen); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
result, err := db.QueryPackets(PacketQuery{Limit: 50, Order: "DESC"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(result.Packets) != 2 {
|
||||
t.Fatalf("expected 2 packets, got %d", len(result.Packets))
|
||||
}
|
||||
// With first_seen DESC (the bug), the order would be [fresh, buffered]
|
||||
// because the fresh row has the newer rxTime. With the fix (id DESC),
|
||||
// order is [buffered, fresh] because the buffered row was ingested
|
||||
// second and has the higher id.
|
||||
first, _ := result.Packets[0]["hash"].(string)
|
||||
second, _ := result.Packets[1]["hash"].(string)
|
||||
if first != "hashbuffered00002" || second != "hashfresh00000001" {
|
||||
t.Errorf("expected order [buffered, fresh] by ingest id DESC, got [%s, %s]",
|
||||
first, second)
|
||||
}
|
||||
}
|
||||
|
||||
// TestQueryPacketsSinceFilterUsesFirstSeen documents the chosen semantic for
|
||||
// the `since=` query param: it still filters by `first_seen` (radio receive
|
||||
// time), NOT by ingest time. Rationale: callers using `since=` expect
|
||||
// "packets the network received since X" — buffered uploads of older
|
||||
// packets should still be EXCLUDED from a `since=15min` view even if
|
||||
// they were ingested in the last 15 minutes. Display order is by ingest
|
||||
// id (issue #1345 fix); filter semantic is unchanged.
|
||||
func TestQueryPacketsSinceFilterUsesFirstSeen(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
|
||||
now := time.Now().UTC()
|
||||
recent := now.Add(-30 * time.Minute).Format(time.RFC3339)
|
||||
old := now.Add(-6 * time.Hour).Format(time.RFC3339)
|
||||
sinceCutoff := now.Add(-1 * time.Hour).Format(time.RFC3339)
|
||||
recentEpoch := now.Add(-30 * time.Minute).Unix()
|
||||
oldEpoch := now.Add(-6 * time.Hour).Unix()
|
||||
|
||||
if _, err := db.conn.Exec(`INSERT INTO observers (id, name, last_seen, first_seen, packet_count)
|
||||
VALUES ('obs1', 'Obs1', ?, ?, 1)`, recent, recent); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, payload_type)
|
||||
VALUES ('AA', 'recentrx00000001', ?, 4)`, recent); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Buffered upload — ingested SECOND, but rxTime is 6h ago.
|
||||
if _, err := db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, payload_type)
|
||||
VALUES ('BB', 'oldrxbuffered001', ?, 4)`, old); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
|
||||
VALUES (1, 1, 10, -90, '[]', ?)`, recentEpoch); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
|
||||
VALUES (2, 1, 10, -90, '[]', ?)`, oldEpoch); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
result, err := db.QueryPackets(PacketQuery{Limit: 50, Order: "DESC", Since: sinceCutoff})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(result.Packets) != 1 {
|
||||
t.Fatalf("since= should filter by first_seen (rxTime); expected 1 packet, got %d",
|
||||
len(result.Packets))
|
||||
}
|
||||
h, _ := result.Packets[0]["hash"].(string)
|
||||
if h != "recentrx00000001" {
|
||||
t.Errorf("expected the rxTime-recent packet, got %s", h)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
// TestHandleNodePaths_HopName_CanonicalPathShowsTarget_1144 is the regression
|
||||
// test for issue #1144.
|
||||
//
|
||||
// Bug: the biased hop resolver picked a GPS-having sibling over the actual target
|
||||
// node when the target had no GPS coordinates, causing the wrong name in hop slots.
|
||||
//
|
||||
// Fix: the canonical-path branch (Option A) uses lookupNode(resolvedPK) with the
|
||||
// full pubkey stored in resolved_path, bypassing the biased resolver entirely.
|
||||
// This test verifies that when two nodes share a short prefix ("37"), the hop
|
||||
// display uses the stored resolved_path pubkey and shows the correct target name.
|
||||
func TestHandleNodePaths_HopName_CanonicalPathShowsTarget_1144(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
recent := time.Now().Add(-1 * time.Hour).Format(time.RFC3339)
|
||||
recentEpoch := time.Now().Add(-1 * time.Hour).Unix()
|
||||
|
||||
targetPK := "37cf0832aaaabbbb" // no GPS
|
||||
siblingPK := "37bb000011112222" // has GPS — biased resolver picks this without fix
|
||||
|
||||
mustExec(t, db, `INSERT INTO nodes (public_key, name, role, lat, lon, last_seen, first_seen, advert_count)
|
||||
VALUES (?, 'CJS SF Mission', 'repeater', 0, 0, ?, '2026-01-01', 1)`, targetPK, recent)
|
||||
mustExec(t, db, `INSERT INTO nodes (public_key, name, role, lat, lon, last_seen, first_seen, advert_count)
|
||||
VALUES (?, 'Templeton Hills', 'repeater', 35.5, -120.7, ?, '2026-01-01', 1)`, siblingPK, recent)
|
||||
|
||||
// TX: resolved_path = [targetPK] → canonical path (Option A) → lookupNode(targetPK)
|
||||
mustExec(t, db, `INSERT INTO transmissions (id, raw_hex, hash, first_seen) VALUES (1, 'AA', 'hash1144', ?)`, recent)
|
||||
mustExec(t, db, `INSERT INTO observations (transmission_id, observer_idx, path_json, timestamp, resolved_path)
|
||||
VALUES (1, NULL, '["37"]', ?, ?)`, recentEpoch, `["`+targetPK+`"]`)
|
||||
|
||||
cfg := &Config{Port: 3000}
|
||||
hub := NewHub()
|
||||
srv := NewServer(db, cfg, hub)
|
||||
store := NewPacketStore(db, nil)
|
||||
if err := store.Load(); err != nil {
|
||||
t.Fatalf("store.Load: %v", err)
|
||||
}
|
||||
srv.store = store
|
||||
router := mux.NewRouter()
|
||||
srv.RegisterRoutes(router)
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/nodes/"+targetPK+"/paths", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("GET /paths: code=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp NodePathsResponse
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if len(resp.Paths) != 1 {
|
||||
t.Fatalf("expected 1 path, got %d", len(resp.Paths))
|
||||
}
|
||||
if len(resp.Paths[0].Hops) != 1 {
|
||||
t.Fatalf("expected 1 hop, got %d", len(resp.Paths[0].Hops))
|
||||
}
|
||||
hop := resp.Paths[0].Hops[0]
|
||||
// The "37" prefix resolves to TWO candidates; the canonical path must use
|
||||
// the stored resolved_path pubkey (targetPK) and display the target's name,
|
||||
// NOT the GPS-having sibling.
|
||||
if hop.Name != "CJS SF Mission" {
|
||||
if hop.Name == "Templeton Hills" {
|
||||
t.Errorf("hop name = %q (sibling mis-resolution #1144): canonical path must show target name %q", hop.Name, "CJS SF Mission")
|
||||
} else {
|
||||
t.Errorf("hop name = %q, want %q", hop.Name, "CJS SF Mission")
|
||||
}
|
||||
}
|
||||
if hop.Pubkey != targetPK {
|
||||
t.Errorf("hop pubkey = %q, want %q", hop.Pubkey, targetPK)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
// TestHandleNodePaths_SortByRecency_1145 is the regression test for issue #1145.
|
||||
//
|
||||
// Prior to the fix, paths were returned in map-iteration order (non-deterministic).
|
||||
// After the fix, paths are sorted by LastSeen descending (newest first), with
|
||||
// Count as a tiebreaker (higher first).
|
||||
//
|
||||
// Setup: target node "aa..." is reached via three distinct paths.
|
||||
//
|
||||
// Path A (via relay "11..."): 3 transmissions, last seen 2026-01-03 (oldest)
|
||||
// Path B (via relay "22..."): 1 transmission, last seen 2026-05-01 (newest)
|
||||
// Path C (direct — "aa..." only): 2 transmissions, last seen 2026-03-02 (middle)
|
||||
//
|
||||
// Expected sort: B (newest) → C (middle) → A (oldest)
|
||||
// Also covers: when LastSeen is equal, Count descending is the tiebreaker.
|
||||
func TestHandleNodePaths_SortByRecency_1145(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
|
||||
targetPK := "aabbccdd11111111"
|
||||
relay1PK := "1111111100000000"
|
||||
relay2PK := "2222222200000000"
|
||||
|
||||
epoch := func(ts string) int64 {
|
||||
v, _ := time.Parse(time.RFC3339, ts)
|
||||
return v.Unix()
|
||||
}
|
||||
|
||||
// Only the target node needs to be in the nodes table.
|
||||
// Relay pubkeys appear only in resolved_path; they don't need a nodes row.
|
||||
mustExec(t, db, `INSERT INTO nodes (public_key, name, role, lat, lon, last_seen, first_seen, advert_count)
|
||||
VALUES (?, 'Target', 'repeater', 0, 0, '2026-05-01T00:00:00Z', '2026-01-01T00:00:00Z', 1)`, targetPK)
|
||||
|
||||
// -- Path A (via relay1): 3 txs, last seen 2026-01-03 → group sig "relay1PK→targetPK" --
|
||||
for txID, ts := range map[int]string{
|
||||
1: "2026-01-01T00:00:00Z",
|
||||
2: "2026-01-02T00:00:00Z",
|
||||
3: "2026-01-03T00:00:00Z",
|
||||
} {
|
||||
mustExec(t, db, `INSERT INTO transmissions (id, raw_hex, hash, first_seen) VALUES (?, 'AA', ?, ?)`,
|
||||
txID, "hashA"+string(rune('0'+txID)), ts)
|
||||
mustExec(t, db, `INSERT INTO observations (transmission_id, observer_idx, path_json, timestamp, resolved_path)
|
||||
VALUES (?, NULL, '["11", "aa"]', ?, ?)`,
|
||||
txID, epoch(ts), `["`+relay1PK+`", "`+targetPK+`"]`)
|
||||
}
|
||||
|
||||
// -- Path B (via relay2): 1 tx, last seen 2026-05-01 → group sig "relay2PK→targetPK" --
|
||||
mustExec(t, db, `INSERT INTO transmissions (id, raw_hex, hash, first_seen) VALUES (4, 'BB', 'hashB1', '2026-05-01T00:00:00Z')`)
|
||||
mustExec(t, db, `INSERT INTO observations (transmission_id, observer_idx, path_json, timestamp, resolved_path)
|
||||
VALUES (4, NULL, '["22", "aa"]', ?, ?)`,
|
||||
epoch("2026-05-01T00:00:00Z"), `["`+relay2PK+`", "`+targetPK+`"]`)
|
||||
|
||||
// -- Path C (direct — target is sole hop): 2 txs, last seen 2026-03-02 --
|
||||
for txID, ts := range map[int]string{
|
||||
5: "2026-03-01T00:00:00Z",
|
||||
6: "2026-03-02T00:00:00Z",
|
||||
} {
|
||||
mustExec(t, db, `INSERT INTO transmissions (id, raw_hex, hash, first_seen) VALUES (?, 'CC', ?, ?)`,
|
||||
txID, "hashC"+string(rune('0'+txID)), ts)
|
||||
mustExec(t, db, `INSERT INTO observations (transmission_id, observer_idx, path_json, timestamp, resolved_path)
|
||||
VALUES (?, NULL, '["aa"]', ?, ?)`,
|
||||
txID, epoch(ts), `["`+targetPK+`"]`)
|
||||
}
|
||||
|
||||
// Wire up server + store
|
||||
cfg := &Config{Port: 3000}
|
||||
hub := NewHub()
|
||||
srv := NewServer(db, cfg, hub)
|
||||
store := NewPacketStore(db, nil)
|
||||
if err := store.Load(); err != nil {
|
||||
t.Fatalf("store.Load: %v", err)
|
||||
}
|
||||
srv.store = store
|
||||
router := mux.NewRouter()
|
||||
srv.RegisterRoutes(router)
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/nodes/"+targetPK+"/paths", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("GET /paths: code=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp NodePathsResponse
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
|
||||
if len(resp.Paths) != 3 {
|
||||
t.Fatalf("expected 3 distinct paths, got %d: %+v", len(resp.Paths), resp.Paths)
|
||||
}
|
||||
if resp.TotalTransmissions != 6 {
|
||||
t.Errorf("expected TotalTransmissions=6, got %d", resp.TotalTransmissions)
|
||||
}
|
||||
|
||||
// Sort order: B (newest, 2026-05-01) → C (middle, 2026-03-02) → A (oldest, 2026-01-03)
|
||||
wantCounts := []int{1, 2, 3}
|
||||
for i, want := range wantCounts {
|
||||
got := resp.Paths[i].Count
|
||||
if got != want {
|
||||
t.Errorf("Paths[%d].Count = %d, want %d (sort order wrong — paths must be newest-first)", i, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleNodePaths_SortCountTiebreaker_1145 verifies that when two paths
|
||||
// have identical LastSeen, the one with higher Count appears first.
|
||||
func TestHandleNodePaths_SortCountTiebreaker_1145(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
|
||||
targetPK := "ccddeeFF11111111"
|
||||
relay1PK := "aaaa111100000000"
|
||||
relay2PK := "bbbb222200000000"
|
||||
sameTS := "2026-04-15T12:00:00Z"
|
||||
epoch := func(ts string) int64 {
|
||||
v, _ := time.Parse(time.RFC3339, ts)
|
||||
return v.Unix()
|
||||
}
|
||||
|
||||
mustExec(t, db, `INSERT INTO nodes (public_key, name, role, lat, lon, last_seen, first_seen, advert_count)
|
||||
VALUES (?, 'Tgt', 'repeater', 0, 0, ?, '2026-01-01T00:00:00Z', 1)`, targetPK, sameTS)
|
||||
|
||||
// Path X: 3 txs, all at sameTS → higher count
|
||||
for txID, ts := range map[int]string{
|
||||
10: "2026-04-15T11:00:00Z",
|
||||
11: "2026-04-15T11:30:00Z",
|
||||
12: sameTS,
|
||||
} {
|
||||
mustExec(t, db, `INSERT INTO transmissions (id, raw_hex, hash, first_seen) VALUES (?, 'XX', ?, ?)`,
|
||||
txID, "hashX"+string(rune('0'+txID)), ts)
|
||||
mustExec(t, db, `INSERT INTO observations (transmission_id, observer_idx, path_json, timestamp, resolved_path)
|
||||
VALUES (?, NULL, '["aa", "cc"]', ?, ?)`,
|
||||
txID, epoch(ts), `["`+relay1PK+`", "`+targetPK+`"]`)
|
||||
}
|
||||
|
||||
// Path Y: 1 tx, at sameTS → lower count
|
||||
mustExec(t, db, `INSERT INTO transmissions (id, raw_hex, hash, first_seen) VALUES (20, 'YY', 'hashY1', ?)`, sameTS)
|
||||
mustExec(t, db, `INSERT INTO observations (transmission_id, observer_idx, path_json, timestamp, resolved_path)
|
||||
VALUES (20, NULL, '["bb", "cc"]', ?, ?)`,
|
||||
epoch(sameTS), `["`+relay2PK+`", "`+targetPK+`"]`)
|
||||
|
||||
cfg := &Config{Port: 3000}
|
||||
hub := NewHub()
|
||||
srv := NewServer(db, cfg, hub)
|
||||
store := NewPacketStore(db, nil)
|
||||
if err := store.Load(); err != nil {
|
||||
t.Fatalf("store.Load: %v", err)
|
||||
}
|
||||
srv.store = store
|
||||
router := mux.NewRouter()
|
||||
srv.RegisterRoutes(router)
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/nodes/"+targetPK+"/paths", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("GET /paths: code=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp NodePathsResponse
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if len(resp.Paths) != 2 {
|
||||
t.Fatalf("expected 2 paths, got %d", len(resp.Paths))
|
||||
}
|
||||
// Path X (count=3) must sort before Path Y (count=1) when LastSeen is equal.
|
||||
if resp.Paths[0].Count != 3 {
|
||||
t.Errorf("Paths[0].Count = %d, want 3 (higher-count path must sort first when LastSeen equal)", resp.Paths[0].Count)
|
||||
}
|
||||
if resp.Paths[1].Count != 1 {
|
||||
t.Errorf("Paths[1].Count = %d, want 1", resp.Paths[1].Count)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
// collisionScenario captures the shared fixture state used by every #1352
|
||||
// sub-test: 3 nodes sharing the 2-char "c0" prefix, plus a wired-up
|
||||
// server + router ready to serve /api/nodes/{pk}/paths.
|
||||
type collisionScenario struct {
|
||||
srv *Server
|
||||
db *DB
|
||||
router *mux.Router
|
||||
|
||||
nodeAPK string
|
||||
nodeBPK string
|
||||
nodeCPK string
|
||||
|
||||
recent string
|
||||
recentEpoch int64
|
||||
}
|
||||
|
||||
// mustExec runs db.conn.Exec and fails the test on error. Used so INSERT
|
||||
// failures (schema drift, NOT NULL violations) surface as test failures
|
||||
// rather than silently producing an empty database that lets later
|
||||
// assertions pass vacuously (#1352 round-1 adv #2).
|
||||
func mustExec(t *testing.T, db *DB, query string, args ...any) {
|
||||
t.Helper()
|
||||
if _, err := db.conn.Exec(query, args...); err != nil {
|
||||
t.Fatalf("Exec failed: %v\n query: %s\n args: %v", err, query, args)
|
||||
}
|
||||
}
|
||||
|
||||
// setupCollisionScenario wires up the shared #1352 fixture: 3 "c0"-prefix
|
||||
// nodes with configurable GPS, a Server + PacketStore + router. Caller
|
||||
// inserts transmissions/observations and queries via s.query.
|
||||
func setupCollisionScenario(t *testing.T, withGPS bool) *collisionScenario {
|
||||
t.Helper()
|
||||
db := setupTestDB(t)
|
||||
recent := time.Now().Add(-1 * time.Hour).Format(time.RFC3339)
|
||||
recentEpoch := time.Now().Add(-1 * time.Hour).Unix()
|
||||
|
||||
sc := &collisionScenario{
|
||||
db: db,
|
||||
nodeAPK: "c0dedad42222aaaa",
|
||||
nodeBPK: "c0ffeec733333333",
|
||||
nodeCPK: "c0efb77f44444444",
|
||||
recent: recent,
|
||||
recentEpoch: recentEpoch,
|
||||
}
|
||||
|
||||
// GPS placement: when withGPS=true, ALL three siblings have distinct
|
||||
// GPS points (worst-case for the biased resolver, see fallback test).
|
||||
// When withGPS=false, only B has GPS (canonical-branch test).
|
||||
aLat, aLon := 0.0, 0.0
|
||||
bLat, bLon := 37.79, -122.41
|
||||
cLat, cLon := 0.0, 0.0
|
||||
if withGPS {
|
||||
aLat, aLon = 37.78, -122.40
|
||||
cLat, cLon = 37.50, -122.00
|
||||
}
|
||||
|
||||
mustExec(t, db, `INSERT INTO nodes (public_key, name, role, lat, lon, last_seen, first_seen, advert_count)
|
||||
VALUES (?, 'NodeA', 'repeater', ?, ?, ?, '2026-01-01', 1)`, sc.nodeAPK, aLat, aLon, recent)
|
||||
mustExec(t, db, `INSERT INTO nodes (public_key, name, role, lat, lon, last_seen, first_seen, advert_count)
|
||||
VALUES (?, 'NodeB', 'repeater', ?, ?, ?, '2026-01-01', 1)`, sc.nodeBPK, bLat, bLon, recent)
|
||||
mustExec(t, db, `INSERT INTO nodes (public_key, name, role, lat, lon, last_seen, first_seen, advert_count)
|
||||
VALUES (?, 'NodeC', 'repeater', ?, ?, ?, '2026-01-01', 1)`, sc.nodeCPK, cLat, cLon, recent)
|
||||
|
||||
cfg := &Config{Port: 3000}
|
||||
hub := NewHub()
|
||||
srv := NewServer(db, cfg, hub)
|
||||
sc.srv = srv
|
||||
|
||||
// store is wired after observations are inserted, by reloadStore().
|
||||
return sc
|
||||
}
|
||||
|
||||
// reloadStore (re)builds the PacketStore from the current DB state. Must
|
||||
// be called AFTER all transmissions/observations are inserted, otherwise
|
||||
// the store snapshot is empty and queries return nothing.
|
||||
func (sc *collisionScenario) reloadStore(t *testing.T) {
|
||||
t.Helper()
|
||||
store := NewPacketStore(sc.db, nil)
|
||||
if err := store.Load(); err != nil {
|
||||
t.Fatalf("store.Load: %v", err)
|
||||
}
|
||||
sc.srv.store = store
|
||||
router := mux.NewRouter()
|
||||
sc.srv.RegisterRoutes(router)
|
||||
sc.router = router
|
||||
}
|
||||
|
||||
// query issues GET /api/nodes/{pk}/paths and returns the decoded response.
|
||||
func (sc *collisionScenario) query(t *testing.T, pk string) NodePathsResponse {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest("GET", "/api/nodes/"+pk+"/paths", nil)
|
||||
w := httptest.NewRecorder()
|
||||
sc.router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("GET /paths for %s: code=%d body=%s", pk, w.Code, w.Body.String())
|
||||
}
|
||||
var resp NodePathsResponse
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
// TestHandleNodePaths_PrefixCollision_1352 reproduces issue #1352.
|
||||
//
|
||||
// Setup: 3 nodes share 2-char prefix "c0":
|
||||
//
|
||||
// A = c0dedad4... (no GPS)
|
||||
// B = c0ffeec7... (HAS GPS @ SF) — canonical relay per resolved_path
|
||||
// C = c0efb77f... (no GPS)
|
||||
//
|
||||
// A packet observed with raw path ["c0"] has a CANONICAL resolved_path
|
||||
// that names B (c0ffeec7…) — produced by the hop-disambiguator using
|
||||
// observer context. The query for paths-through-X must use the canonical
|
||||
// resolved_path to decide membership, NOT a naive prefix lookup.
|
||||
//
|
||||
// Only B is in the canonical resolved_path; only paths-through-B
|
||||
// must include the tx. paths-through-A and paths-through-C must exclude it.
|
||||
func TestHandleNodePaths_PrefixCollision_1352(t *testing.T) {
|
||||
sc := setupCollisionScenario(t, false /* only B has GPS */)
|
||||
mustExec(t, sc.db, `INSERT INTO transmissions (id, raw_hex, hash, first_seen) VALUES (42, 'DEAD', 'hash_1352', ?)`, sc.recent)
|
||||
mustExec(t, sc.db, `INSERT INTO observations (transmission_id, observer_idx, path_json, timestamp, resolved_path)
|
||||
VALUES (42, NULL, '["c0"]', ?, ?)`, sc.recentEpoch, `["`+sc.nodeBPK+`"]`)
|
||||
sc.reloadStore(t)
|
||||
|
||||
respA := sc.query(t, sc.nodeAPK)
|
||||
respB := sc.query(t, sc.nodeBPK)
|
||||
respC := sc.query(t, sc.nodeCPK)
|
||||
|
||||
// A and C are NOT in the canonical resolved_path → must be excluded.
|
||||
if respA.TotalTransmissions != 0 {
|
||||
t.Errorf("nodeA (c0dedad…) paths-through: canonical resolved_path names B, not A — "+
|
||||
"expected 0 transmissions, got %d (wrong-node attribution #1352)",
|
||||
respA.TotalTransmissions)
|
||||
}
|
||||
if respC.TotalTransmissions != 0 {
|
||||
t.Errorf("nodeC (c0efb77…) paths-through: canonical resolved_path names B, not C — "+
|
||||
"expected 0 transmissions, got %d (wrong-node attribution #1352)",
|
||||
respC.TotalTransmissions)
|
||||
}
|
||||
// B IS named by the canonical resolved_path → must be included.
|
||||
if respB.TotalTransmissions != 1 {
|
||||
t.Errorf("nodeB (c0ffeec…) paths-through: B is canonical relay — "+
|
||||
"expected 1 transmission, got %d", respB.TotalTransmissions)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleNodePaths_PrefixCollision_1352_FallbackBranch covers the
|
||||
// worse case: obs has NO persisted resolved_path. The OLD fallback branch
|
||||
// invoked pm.resolveWithContext(hop, []string{lowerPK}, graph) — anchoring
|
||||
// the resolver on the queried node. Tier-2 (geo_proximity) then picked
|
||||
// the GPS candidate closest to the centroid of context (== the target
|
||||
// itself when the target has GPS), causing every paths-through-X query
|
||||
// that shared the prefix to return the tx with X attribution.
|
||||
//
|
||||
// Fix: with multiple "c0" candidates and no SQL/index pre-confirmation,
|
||||
// the colliders must sum to AT MOST 1 (ideally 0). Old buggy code:
|
||||
// all three = 3. Fixed: ≤1, and we tighten further to ≤1 explicitly.
|
||||
func TestHandleNodePaths_PrefixCollision_1352_FallbackBranch(t *testing.T) {
|
||||
sc := setupCollisionScenario(t, true /* all three have GPS */)
|
||||
mustExec(t, sc.db, `INSERT INTO transmissions (id, raw_hex, hash, first_seen) VALUES (43, 'BEEF', 'hash_1352_fb', ?)`, sc.recent)
|
||||
mustExec(t, sc.db, `INSERT INTO observations (transmission_id, observer_idx, path_json, timestamp, resolved_path)
|
||||
VALUES (43, NULL, '["c0"]', ?, NULL)`, sc.recentEpoch)
|
||||
sc.reloadStore(t)
|
||||
|
||||
a := sc.query(t, sc.nodeAPK).TotalTransmissions
|
||||
b := sc.query(t, sc.nodeBPK).TotalTransmissions
|
||||
c := sc.query(t, sc.nodeCPK).TotalTransmissions
|
||||
|
||||
sum := a + b + c
|
||||
// Old buggy code: a==1 && b==1 && c==1 → sum==3 (wrong-node attribution
|
||||
// on all). Fixed: sum ∈ {0, 1}. Asserting sum ≤ 1 catches the degenerate
|
||||
// "all zero" implementation as legitimate (it IS legitimate — ambiguous
|
||||
// hops with no SQL confirmation must be excluded) while still rejecting
|
||||
// the bug. The positive case (sum==1 when unambiguous) is covered by
|
||||
// the canonical sub-test above and by FallbackUniquePrefix below.
|
||||
if sum > 1 {
|
||||
t.Errorf("ambiguous-prefix tx with NULL resolved_path attributed to %d nodes total (A=%d B=%d C=%d); "+
|
||||
"expected sum ≤ 1 — paths-through must not return the same tx for multiple sibling prefix collisions (#1352)",
|
||||
sum, a, b, c)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleNodePaths_FallbackUniquePrefix_1352 is the POSITIVE companion
|
||||
// to FallbackBranch: a hop prefix that has EXACTLY ONE candidate node MUST
|
||||
// attribute the tx when that hop resolves to the queried target.
|
||||
//
|
||||
// Without this test, the "all zero" degenerate implementation passes the
|
||||
// ≤1 fallback assertion vacuously. This locks in that the
|
||||
// `len(pm.m[lowerHop]) <= 1` guard does NOT over-reject unique prefixes.
|
||||
//
|
||||
// Setup: only ONE node has the prefix "ab". NULL resolved_path so we take
|
||||
// the fallback branch. paths-through-target MUST include exactly 1 tx.
|
||||
func TestHandleNodePaths_FallbackUniquePrefix_1352(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
recent := time.Now().Add(-1 * time.Hour).Format(time.RFC3339)
|
||||
recentEpoch := time.Now().Add(-1 * time.Hour).Unix()
|
||||
pk := "abcdef0123456789"
|
||||
|
||||
mustExec(t, db, `INSERT INTO nodes (public_key, name, role, lat, lon, last_seen, first_seen, advert_count)
|
||||
VALUES (?, 'UniqueNode', 'repeater', 37.78, -122.4, ?, '2026-01-01', 1)`, pk, recent)
|
||||
mustExec(t, db, `INSERT INTO transmissions (id, raw_hex, hash, first_seen) VALUES (44, 'CAFE', 'hash_1352_unique', ?)`, recent)
|
||||
mustExec(t, db, `INSERT INTO observations (transmission_id, observer_idx, path_json, timestamp, resolved_path)
|
||||
VALUES (44, NULL, '["ab"]', ?, NULL)`, recentEpoch)
|
||||
|
||||
cfg := &Config{Port: 3000}
|
||||
hub := NewHub()
|
||||
srv := NewServer(db, cfg, hub)
|
||||
store := NewPacketStore(db, nil)
|
||||
if err := store.Load(); err != nil {
|
||||
t.Fatalf("store.Load: %v", err)
|
||||
}
|
||||
srv.store = store
|
||||
router := mux.NewRouter()
|
||||
srv.RegisterRoutes(router)
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/nodes/"+pk+"/paths", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("GET /paths: code=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp NodePathsResponse
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if resp.TotalTransmissions != 1 {
|
||||
t.Errorf("unique-prefix hop with NULL resolved_path: target attribution "+
|
||||
"MUST be exactly 1, got %d — `len(pm.m[lowerHop]) <= 1` guard is "+
|
||||
"over-rejecting unambiguous prefixes (#1352)", resp.TotalTransmissions)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleNodePaths_FallbackPreconfirmed_1352 exercises the
|
||||
// pre-confirmation path: when a tx is in confirmedByFullKey OR
|
||||
// confirmedBySQL for the queried target, attribution MUST survive
|
||||
// regardless of any sibling-prefix ambiguity.
|
||||
//
|
||||
// Mutation note (pushback recorded in PR body): in the current
|
||||
// code shape, containsTarget is initialized to
|
||||
// `confirmedByFullKey[tx.ID] || confirmedBySQL[tx.ID]` BEFORE the
|
||||
// per-hop loop runs, and the loop only ever flips false→true. So
|
||||
// removing the `preconfirmed ||` clause alone does not break this
|
||||
// test — the preconfirmed tx is already attributed via the
|
||||
// initialization. The `preconfirmed` snapshot is kept as a
|
||||
// structural invariant (see routes.go comment): it documents the
|
||||
// contract that the SQL/index signal must NEVER be silently
|
||||
// overridden by a biased-resolver false-negative in a future edit
|
||||
// that flips containsTarget back to false inside the loop. This
|
||||
// test guards the BEHAVIOR ("preconfirmed survives ambiguous
|
||||
// prefix") even if it can't currently mutation-detect every
|
||||
// formulation of the structural guard.
|
||||
func TestHandleNodePaths_FallbackPreconfirmed_1352(t *testing.T) {
|
||||
sc := setupCollisionScenario(t, true /* all three have GPS so resolver bias is maximal */)
|
||||
|
||||
// tx 50: best obs has NULL resolved_path (fallback branch). A SECOND
|
||||
// obs persists resolved_path = [B] which populates the byPathHop index
|
||||
// for B's full pubkey AND lets confirmedBySQL hit via INSTR.
|
||||
mustExec(t, sc.db, `INSERT INTO transmissions (id, raw_hex, hash, first_seen) VALUES (50, 'F00D', 'hash_1352_pre', ?)`, sc.recent)
|
||||
mustExec(t, sc.db, `INSERT INTO observations (transmission_id, observer_idx, path_json, timestamp, resolved_path)
|
||||
VALUES (50, NULL, '["c0"]', ?, NULL)`, sc.recentEpoch)
|
||||
// Second observation (different observer) — same tx, persisted resolved_path = [B].
|
||||
// This populates byPathHop[B] during Load(), so confirmedByFullKey is true
|
||||
// when paths-through-B is queried.
|
||||
mustExec(t, sc.db, `INSERT INTO observations (transmission_id, observer_idx, path_json, timestamp, resolved_path)
|
||||
VALUES (50, 1, '["c0"]', ?, ?)`, sc.recentEpoch+1, `["`+sc.nodeBPK+`"]`)
|
||||
sc.reloadStore(t)
|
||||
|
||||
respA := sc.query(t, sc.nodeAPK)
|
||||
respB := sc.query(t, sc.nodeBPK)
|
||||
respC := sc.query(t, sc.nodeCPK)
|
||||
|
||||
// B is preconfirmed by SQL/index → tx survives the collision guard.
|
||||
if respB.TotalTransmissions != 1 {
|
||||
t.Errorf("nodeB preconfirmed via byPathHop/SQL: tx MUST attribute despite "+
|
||||
"multi-candidate `c0` prefix — got %d, expected 1. The SQL/index "+
|
||||
"pre-confirmation path is the documented contract for #1352. "+
|
||||
"If this fails, either the byPathHop full-pubkey index is not being "+
|
||||
"populated from persisted resolved_path, or containsTarget is being "+
|
||||
"reset inside the per-hop loop.", respB.TotalTransmissions)
|
||||
}
|
||||
// A and C are NOT preconfirmed and the prefix IS ambiguous → excluded.
|
||||
if respA.TotalTransmissions != 0 {
|
||||
t.Errorf("nodeA not preconfirmed, prefix ambiguous: expected 0, got %d", respA.TotalTransmissions)
|
||||
}
|
||||
if respC.TotalTransmissions != 0 {
|
||||
t.Errorf("nodeC not preconfirmed, prefix ambiguous: expected 0, got %d", respC.TotalTransmissions)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleNodePaths_FallbackUnresolvableHop_1352 documents the
|
||||
// behavior of the unresolvable-hop arm under multi-candidate prefix:
|
||||
// when resolveHop returns nil (prefix not indexed by pm) AND the hop
|
||||
// IS a prefix of the queried target, attribution must NOT happen
|
||||
// without SQL/index pre-confirmation.
|
||||
//
|
||||
// Implementation reality (pushback recorded in PR body): the
|
||||
// unresolvable arm is only reached when pm.m[lowerHop] is empty —
|
||||
// resolveWithContext returns non-nil whenever len(candidates) >= 1.
|
||||
// So in practice the arm's `len(pm.m[lowerHop]) <= 1` guard is
|
||||
// always-true and structurally cannot be mutation-detected by a
|
||||
// multi-candidate setup. This test instead asserts the BEHAVIOR
|
||||
// (no attribution under an ambiguous + unresolvable scenario)
|
||||
// and serves as a regression seat-belt for future edits to
|
||||
// resolveWithContext that might start returning nil for len>=1.
|
||||
func TestHandleNodePaths_FallbackUnresolvableHop_1352(t *testing.T) {
|
||||
sc := setupCollisionScenario(t, false /* only B has GPS */)
|
||||
|
||||
mustExec(t, sc.db, `INSERT INTO transmissions (id, raw_hex, hash, first_seen) VALUES (60, 'FEED', 'hash_1352_unres', ?)`, sc.recent)
|
||||
mustExec(t, sc.db, `INSERT INTO observations (transmission_id, observer_idx, path_json, timestamp, resolved_path)
|
||||
VALUES (60, NULL, '["c0"]', ?, NULL)`, sc.recentEpoch)
|
||||
sc.reloadStore(t)
|
||||
|
||||
// Query A (no GPS): biased resolver in the fallback branch picks B via
|
||||
// tier-3 GPS preference; B's pubkey != A's lowerPK so the resolvable
|
||||
// arm's pubkey-match condition fails. Either way: NOT attributed to A.
|
||||
respA := sc.query(t, sc.nodeAPK)
|
||||
if respA.TotalTransmissions != 0 {
|
||||
t.Errorf("nodeA (no GPS) with multi-candidate `c0` prefix + NULL resolved_path: "+
|
||||
"expected 0 attribution, got %d (#1352)", respA.TotalTransmissions)
|
||||
}
|
||||
respC := sc.query(t, sc.nodeCPK)
|
||||
if respC.TotalTransmissions != 0 {
|
||||
t.Errorf("nodeC (no GPS) with multi-candidate `c0` prefix + NULL resolved_path: "+
|
||||
"expected 0 attribution, got %d (#1352)", respC.TotalTransmissions)
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,14 @@ func TestServerSourceHasNoCachedRWCalls(t *testing.T) {
|
||||
regexp.MustCompile(`\bcachedRW\s*\(`),
|
||||
regexp.MustCompile(`mode=rw`),
|
||||
regexp.MustCompile(`sql\.Open\([^)]*\?[^)]*_journal_mode=WAL[^)]*\)`),
|
||||
// #1324 follow-up: PR #903's persistMultibyteCapability moved
|
||||
// to cmd/ingestor — the server may NEVER UPDATE these columns
|
||||
// (it opens mode=ro since #1289). Server publishes a snapshot
|
||||
// file via internal/mbcapqueue; the ingestor applies it.
|
||||
regexp.MustCompile(`UPDATE\s+nodes\s+SET\s+multibyte_`),
|
||||
regexp.MustCompile(`UPDATE\s+inactive_nodes\s+SET\s+multibyte_`),
|
||||
regexp.MustCompile(`\bpersistMultibyteCapability\s*\(`),
|
||||
regexp.MustCompile(`\bmaybePersistMultibyteCapability\s*\(`),
|
||||
}
|
||||
violations := []string{}
|
||||
for _, e := range entries {
|
||||
@@ -78,6 +86,12 @@ func TestServerDBHasNoWriteMethods(t *testing.T) {
|
||||
// ingestor's *Store. The server's HTTP handler now enqueues a
|
||||
// marker file (see internal/prunequeue); it does not write.
|
||||
"DeleteNodesByPubkeys",
|
||||
// #1324 follow-up: PR #903 originally added these to *PacketStore
|
||||
// (not *DB), and they UPDATEd nodes/inactive_nodes from a
|
||||
// mode=ro handle. After relocation, the methods live in the
|
||||
// ingestor's *Store (cmd/ingestor/multibyte_persist.go). Server
|
||||
// must expose neither on *DB nor on *PacketStore — see the
|
||||
// dedicated test below for *PacketStore.
|
||||
}
|
||||
typ := reflect.TypeOf((*DB)(nil))
|
||||
for _, name := range forbidden {
|
||||
@@ -130,3 +144,23 @@ func bootstrapMinimalDB(path string) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// TestPacketStoreHasNoMultibytePersistMethods enforces the #1324 follow-up:
|
||||
// PR #903 wired persistMultibyteCapability + maybePersistMultibyteCapability
|
||||
// onto *PacketStore in cmd/server. Both executed UPDATEs on
|
||||
// nodes/inactive_nodes from a mode=ro DB handle — impossible since #1289.
|
||||
// After relocation the persistence lives in cmd/ingestor/*Store; the
|
||||
// server only publishes a snapshot via internal/mbcapqueue. This test
|
||||
// fails if a future change re-introduces these methods on *PacketStore.
|
||||
func TestPacketStoreHasNoMultibytePersistMethods(t *testing.T) {
|
||||
forbidden := []string{
|
||||
"persistMultibyteCapability",
|
||||
"maybePersistMultibyteCapability",
|
||||
}
|
||||
typ := reflect.TypeOf((*PacketStore)(nil))
|
||||
for _, name := range forbidden {
|
||||
if _, ok := typ.MethodByName(name); ok {
|
||||
t.Errorf("server *PacketStore exposes forbidden write method %q — must be relocated to ingestor (#1324)", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,13 +5,6 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// repeaterEnrichTTL bounds how stale the per-page bulk enrichment caches
|
||||
// for handleNodes may be. Same 15s budget as GetNodeHashSizeInfo — the
|
||||
// numbers feed an at-a-glance status column, not an alerting path, so
|
||||
// up-to-15s freshness is fine and keeps the request path O(page) instead
|
||||
// of O(page × byPathHop[pk] × parsed timestamps).
|
||||
const repeaterEnrichTTL = 15 * time.Second
|
||||
|
||||
// GetRepeaterRelayInfoMap returns a cached pubkey → RepeaterRelayInfo
|
||||
// map covering EVERY pubkey that currently appears as a path hop in any
|
||||
// non-advert StoreTx. This is the bulk equivalent of calling
|
||||
@@ -29,28 +22,34 @@ const repeaterEnrichTTL = 15 * time.Second
|
||||
// The cached map is keyed by lowercase pubkey/hop key (same shape as
|
||||
// byPathHop). Lookups should use strings.ToLower(pk).
|
||||
//
|
||||
// The cache is invalidated by TTL only — never by ingest. With a 15s
|
||||
// budget that's acceptable for a status column; if a fresher signal is
|
||||
// ever needed for a non-status caller, expose a non-cached path.
|
||||
// The cache is refreshed by the background recomputer (every 5 min by
|
||||
// default). This function never rebuilds inline on a populated cache —
|
||||
// serving a slightly stale snapshot is always preferable to a 700ms
|
||||
// on-request rebuild. The only time an inline compute happens is when
|
||||
// the cache is nil (i.e. before the recomputer's synchronous prewarm
|
||||
// completes, which can occur in tests without a running recomputer).
|
||||
func (s *PacketStore) GetRepeaterRelayInfoMap(windowHours float64) map[string]RepeaterRelayInfo {
|
||||
s.repeaterEnrichMu.Lock()
|
||||
if s.repeaterRelayCache != nil &&
|
||||
time.Since(s.repeaterRelayAt) < repeaterEnrichTTL &&
|
||||
s.repeaterRelayCacheWin == windowHours {
|
||||
cached := s.repeaterRelayCache
|
||||
s.repeaterEnrichMu.Unlock()
|
||||
cached := s.repeaterRelayCache
|
||||
s.repeaterEnrichMu.Unlock()
|
||||
if cached != nil {
|
||||
return cached
|
||||
}
|
||||
s.repeaterEnrichMu.Unlock()
|
||||
|
||||
// Cache is nil — recomputer hasn't prewarmed yet (edge case: tests
|
||||
// without a running recomputer, or a request racing the initial
|
||||
// synchronous prewarm). Build once inline; the recomputer takes over.
|
||||
result := s.computeRepeaterRelayInfoMap(windowHours)
|
||||
|
||||
s.repeaterEnrichMu.Lock()
|
||||
s.repeaterRelayCache = result
|
||||
s.repeaterRelayCacheWin = windowHours
|
||||
s.repeaterRelayAt = time.Now()
|
||||
if s.repeaterRelayCache == nil {
|
||||
s.repeaterRelayCache = result
|
||||
s.repeaterRelayCacheWin = windowHours
|
||||
s.repeaterRelayAt = time.Now()
|
||||
}
|
||||
cached = s.repeaterRelayCache
|
||||
s.repeaterEnrichMu.Unlock()
|
||||
return result
|
||||
return cached
|
||||
}
|
||||
|
||||
// computeRepeaterRelayInfoMap walks byPathHop once under a single RLock,
|
||||
@@ -176,23 +175,25 @@ func (s *PacketStore) computeRepeaterRelayInfoMap(windowHours float64) map[strin
|
||||
// GetRepeaterUsefulnessScoreMap returns a cached pubkey → 0..1 score
|
||||
// for every pubkey appearing in byPathHop. Bulk equivalent of
|
||||
// GetRepeaterUsefulnessScore. See GetRepeaterRelayInfoMap for the
|
||||
// motivation (#1257).
|
||||
// motivation (#1257) and the no-inline-rebuild rationale (#1272).
|
||||
func (s *PacketStore) GetRepeaterUsefulnessScoreMap() map[string]float64 {
|
||||
s.repeaterEnrichMu.Lock()
|
||||
if s.repeaterUsefulCache != nil && time.Since(s.repeaterUsefulAt) < repeaterEnrichTTL {
|
||||
cached := s.repeaterUsefulCache
|
||||
s.repeaterEnrichMu.Unlock()
|
||||
cached := s.repeaterUsefulCache
|
||||
s.repeaterEnrichMu.Unlock()
|
||||
if cached != nil {
|
||||
return cached
|
||||
}
|
||||
s.repeaterEnrichMu.Unlock()
|
||||
|
||||
result := s.computeRepeaterUsefulnessScoreMap()
|
||||
|
||||
s.repeaterEnrichMu.Lock()
|
||||
s.repeaterUsefulCache = result
|
||||
s.repeaterUsefulAt = time.Now()
|
||||
if s.repeaterUsefulCache == nil {
|
||||
s.repeaterUsefulCache = result
|
||||
s.repeaterUsefulAt = time.Now()
|
||||
}
|
||||
cached = s.repeaterUsefulCache
|
||||
s.repeaterEnrichMu.Unlock()
|
||||
return result
|
||||
return cached
|
||||
}
|
||||
|
||||
func (s *PacketStore) computeRepeaterUsefulnessScoreMap() map[string]float64 {
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestGetRepeaterRelayInfoMap_ServesStaleOnTTLExpiry is a regression guard
|
||||
// for issue #1272.
|
||||
//
|
||||
// Background: GetRepeaterRelayInfoMap used to rebuild the cache inline
|
||||
// whenever the TTL expired, causing ~700ms latency spikes on /api/nodes.
|
||||
// The recomputer (StartRepeaterEnrichmentRecomputer) runs every 5 min and
|
||||
// already keeps the cache warm; there is no reason to rebuild on-request.
|
||||
//
|
||||
// This test verifies that a populated cache is ALWAYS returned as-is,
|
||||
// even when its timestamp is ancient (simulating TTL expiry under the old
|
||||
// code). The stale sentinel value proves no inline recompute occurred.
|
||||
func TestGetRepeaterRelayInfoMap_ServesStaleOnTTLExpiry(t *testing.T) {
|
||||
store := &PacketStore{
|
||||
byPathHop: make(map[string][]*StoreTx),
|
||||
}
|
||||
|
||||
// Pre-populate the cache with a sentinel entry that would NOT be
|
||||
// produced by computeRepeaterRelayInfoMap on the empty byPathHop.
|
||||
stale := map[string]RepeaterRelayInfo{
|
||||
"sentinel": {RelayCount24h: 9999},
|
||||
}
|
||||
store.repeaterRelayAt = time.Now().Add(-24 * time.Hour) // well past any TTL
|
||||
store.repeaterRelayCache = stale
|
||||
store.repeaterRelayCacheWin = 24
|
||||
|
||||
got := store.GetRepeaterRelayInfoMap(24)
|
||||
|
||||
if got["sentinel"].RelayCount24h != 9999 {
|
||||
t.Fatalf("stale cache not served: sentinel missing or overwritten (RelayCount24h=%d)", got["sentinel"].RelayCount24h)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetRepeaterUsefulnessScoreMap_ServesStaleOnTTLExpiry mirrors
|
||||
// TestGetRepeaterRelayInfoMap_ServesStaleOnTTLExpiry for the usefulness
|
||||
// score map (same fix, same root cause).
|
||||
func TestGetRepeaterUsefulnessScoreMap_ServesStaleOnTTLExpiry(t *testing.T) {
|
||||
store := &PacketStore{
|
||||
byPathHop: make(map[string][]*StoreTx),
|
||||
byPayloadType: make(map[int][]*StoreTx),
|
||||
}
|
||||
|
||||
stale := map[string]float64{"sentinel": 0.42}
|
||||
store.repeaterUsefulAt = time.Now().Add(-24 * time.Hour)
|
||||
store.repeaterUsefulCache = stale
|
||||
|
||||
got := store.GetRepeaterUsefulnessScoreMap()
|
||||
|
||||
if got["sentinel"] != 0.42 {
|
||||
t.Fatalf("stale cache not served: sentinel missing or overwritten (score=%v)", got["sentinel"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetRepeaterRelayInfoMap_BuildsWhenNil verifies that when the cache
|
||||
// is nil (before the recomputer's first prewarm), GetRepeaterRelayInfoMap
|
||||
// computes inline and caches the result for subsequent callers.
|
||||
func TestGetRepeaterRelayInfoMap_BuildsWhenNil(t *testing.T) {
|
||||
pt2 := 2
|
||||
now := time.Now().UTC()
|
||||
tx := &StoreTx{
|
||||
ID: 1,
|
||||
Hash: "abc",
|
||||
FirstSeen: now.Add(-10 * time.Minute).Format(time.RFC3339Nano),
|
||||
PayloadType: &pt2,
|
||||
}
|
||||
store := &PacketStore{
|
||||
byPathHop: map[string][]*StoreTx{"aabbcc": {tx}},
|
||||
byPayloadType: map[int][]*StoreTx{pt2: {tx}},
|
||||
mu: sync.RWMutex{},
|
||||
}
|
||||
|
||||
got := store.GetRepeaterRelayInfoMap(24)
|
||||
if _, ok := got["aabbcc"]; !ok {
|
||||
t.Fatal("inline compute did not produce entry for seeded hop key")
|
||||
}
|
||||
|
||||
// Second call must return the cached result, not a fresh recompute.
|
||||
got2 := store.GetRepeaterRelayInfoMap(24)
|
||||
if got2["aabbcc"].RelayCount24h != got["aabbcc"].RelayCount24h {
|
||||
t.Fatal("second call returned different map — cache not installed")
|
||||
}
|
||||
}
|
||||
@@ -7,9 +7,9 @@ import (
|
||||
|
||||
// repeaterEnrichmentRecomputerInterval is the default tick interval
|
||||
// for the steady-state recompute of the repeater enrichment bulk
|
||||
// caches. The on-request 15s-TTL fallback in repeater_enrich_bulk.go
|
||||
// is kept as a safety net — the recomputer just makes sure the cache
|
||||
// is populated before any request arrives.
|
||||
// caches. The on-request TTL fallback in repeater_enrich_bulk.go is
|
||||
// kept as a safety net — the recomputer just makes sure the cache is
|
||||
// populated before any request arrives.
|
||||
//
|
||||
// 5min mirrors the analytics_recomputer default from #1240 and is
|
||||
// plenty fresh for an at-a-glance status column.
|
||||
@@ -88,9 +88,9 @@ func (s *PacketStore) StartRepeaterEnrichmentRecomputer(windowHours float64, int
|
||||
// background goroutine (the previous snapshot remains valid).
|
||||
func recomputeRepeaterEnrichmentSafe(s *PacketStore, windowHours float64) {
|
||||
defer func() { _ = recover() }()
|
||||
// Bypass the 15s-TTL gate by forcing a fresh recompute and
|
||||
// installing the result. The public Get* helpers would return the
|
||||
// existing cache when within TTL; we want to refresh proactively.
|
||||
// Write directly to the cache fields under mutex rather than going
|
||||
// through the public Get* helpers — those return the existing
|
||||
// non-nil cache immediately, so calling them here would be a no-op.
|
||||
relay := s.computeRepeaterRelayInfoMap(windowHours)
|
||||
useful := s.computeRepeaterUsefulnessScoreMap()
|
||||
now := time.Now()
|
||||
|
||||
+92
-19
@@ -346,6 +346,7 @@ func (s *Server) handleConfigClient(w http.ResponseWriter, r *http.Request) {
|
||||
PropagationBufferMs: float64(s.cfg.PropagationBufferMs()),
|
||||
Timestamps: s.cfg.GetTimestampConfig(),
|
||||
DebugAffinity: s.cfg.DebugAffinity,
|
||||
MapDarkTileProvider: s.cfg.MapDarkTileProvider,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1186,7 +1187,6 @@ func (s *Server) handleNodes(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
if s.store != nil {
|
||||
hashInfo := s.store.GetNodeHashSizeInfo()
|
||||
mbCap := s.store.GetMultiByteCapMap()
|
||||
relayWindow := s.cfg.GetHealthThresholds().RelayActiveHours
|
||||
// #1257: bulk-compute relay info + usefulness scores ONCE per
|
||||
// request (cached 15s) instead of calling the per-node helpers
|
||||
@@ -1213,7 +1213,8 @@ func (s *Server) handleNodes(w http.ResponseWriter, r *http.Request) {
|
||||
for _, node := range nodes {
|
||||
if pk, ok := node["public_key"].(string); ok {
|
||||
EnrichNodeWithHashSize(node, hashInfo[pk])
|
||||
EnrichNodeWithMultiByte(node, mbCap[pk])
|
||||
mbEntry, _ := s.store.GetMultibyteCapFor(pk)
|
||||
EnrichNodeWithMultiByte(node, mbEntry)
|
||||
if role, _ := node["role"].(string); role == "repeater" || role == "room" {
|
||||
info, _ := lookupRelayInfo(relayMap, pk)
|
||||
info.WindowHours = relayWindow
|
||||
@@ -1223,7 +1224,15 @@ func (s *Server) handleNodes(w http.ResponseWriter, r *http.Request) {
|
||||
node["relay_active"] = info.RelayActive
|
||||
node["relay_count_1h"] = info.RelayCount1h
|
||||
node["relay_count_24h"] = info.RelayCount24h
|
||||
node["usefulness_score"] = lookupUsefulnessScore(usefulMap, pk)
|
||||
// usefulness_score retained for API compat; new
|
||||
// consumers should read traffic_share_score
|
||||
// (issue #1456). When the #672 composite ships
|
||||
// usefulness_score will become the composite
|
||||
// and traffic_share_score will keep the
|
||||
// per-axis value.
|
||||
us := lookupUsefulnessScore(usefulMap, pk)
|
||||
node["usefulness_score"] = us
|
||||
node["traffic_share_score"] = us
|
||||
node["bridge_score"] = lookupUsefulnessScore(bridgeMap, pk)
|
||||
}
|
||||
}
|
||||
@@ -1358,8 +1367,8 @@ func (s *Server) handleNodeDetail(w http.ResponseWriter, r *http.Request) {
|
||||
if s.store != nil {
|
||||
hashInfo := s.store.GetNodeHashSizeInfo()
|
||||
EnrichNodeWithHashSize(node, hashInfo[pubkey])
|
||||
mbCap := s.store.GetMultiByteCapMap()
|
||||
EnrichNodeWithMultiByte(node, mbCap[pubkey])
|
||||
mbEntry, _ := s.store.GetMultibyteCapFor(pubkey)
|
||||
EnrichNodeWithMultiByte(node, mbEntry)
|
||||
if role, _ := node["role"].(string); role == "repeater" || role == "room" {
|
||||
ht := s.cfg.GetHealthThresholds()
|
||||
info := s.store.GetRepeaterRelayInfo(pubkey, ht.RelayActiveHours)
|
||||
@@ -1370,7 +1379,11 @@ func (s *Server) handleNodeDetail(w http.ResponseWriter, r *http.Request) {
|
||||
node["relay_window_hours"] = info.WindowHours
|
||||
node["relay_count_1h"] = info.RelayCount1h
|
||||
node["relay_count_24h"] = info.RelayCount24h
|
||||
node["usefulness_score"] = s.store.GetRepeaterUsefulnessScore(pubkey)
|
||||
// usefulness_score retained for API compat; new
|
||||
// consumers should read traffic_share_score (#1456).
|
||||
us := s.store.GetRepeaterUsefulnessScore(pubkey)
|
||||
node["usefulness_score"] = us
|
||||
node["traffic_share_score"] = us
|
||||
node["bridge_score"] = s.store.GetBridgeScore(pubkey)
|
||||
}
|
||||
}
|
||||
@@ -1665,10 +1678,59 @@ func (s *Server) handleNodePaths(w http.ResponseWriter, r *http.Request) {
|
||||
// async backfill incomplete). Use biased re-resolve and the
|
||||
// legacy containsTarget heuristics (preserves #1197 behavior
|
||||
// and the #929 prefix-collision exclusion test).
|
||||
//
|
||||
// #1352: When a hop prefix has MULTIPLE candidates (sibling
|
||||
// prefix collisions), the biased resolver — anchored on the
|
||||
// queried target via hopContext=[lowerPK] — will preferentially
|
||||
// resolve to the target via tier-2 geo / tier-3 GPS. This
|
||||
// causes the SAME tx to be attributed to every prefix sibling
|
||||
// when each is queried in turn. To prevent wrong-node
|
||||
// attribution, we ONLY accept a resolver match as evidence of
|
||||
// target membership when:
|
||||
// (a) the tx was already pre-confirmed via
|
||||
// confirmedByFullKey (resolved_path index hit) or
|
||||
// confirmedBySQL (verified pubkey in resolved_path), OR
|
||||
// (b) the hop's prefix candidate set is UNIQUE — no
|
||||
// collision, so the resolver had no choice to bias.
|
||||
// Multi-candidate hops with no SQL/index confirmation are
|
||||
// treated as ambiguous and excluded from paths-through.
|
||||
containsTarget = confirmedByFullKey[tx.ID] || confirmedBySQL[tx.ID]
|
||||
// preconfirmed: SNAPSHOT of containsTarget BEFORE the per-hop
|
||||
// loop runs. Captures only the SQL/full-key index pre-confirmation
|
||||
// signal (independent of biased-resolver output). MUST NOT be
|
||||
// reassigned inside the loop — doing so would let a biased-
|
||||
// resolver match in hop[i] silently authorize a later ambiguous
|
||||
// hop[j], re-opening the #1352 wrong-node attribution path.
|
||||
//
|
||||
// Note: today the loop only ever transitions containsTarget
|
||||
// false → true, so the snapshot is functionally redundant for
|
||||
// the preconfirmed==true case (containsTarget is already true).
|
||||
// We keep the snapshot + the `preconfirmed ||` clauses below
|
||||
// as a structural invariant: future edits that flip
|
||||
// containsTarget back to false inside the loop (e.g. an
|
||||
// "exclude if last hop doesn't match" tweak) would otherwise
|
||||
// silently lose the SQL/index confirmation. The snapshot is
|
||||
// the documented contract.
|
||||
preconfirmed := containsTarget
|
||||
for i, hop := range hops {
|
||||
resolved := resolveHop(hop)
|
||||
entry := PathHopResp{Prefix: hop, Name: hop}
|
||||
lowerHop := strings.ToLower(hop)
|
||||
// #1352 guard helper. We treat as "unique/safe" when the
|
||||
// hop's prefix candidate set has EXACTLY ONE member: no
|
||||
// sibling collision, so the biased resolver had no choice
|
||||
// to bias. len(pm.m[lowerHop]) == 0 is also accepted as
|
||||
// safe-by-default in the resolvable arm because the
|
||||
// resolver returned a non-nil candidate from somewhere
|
||||
// (e.g. a full-pubkey hop longer than maxPrefixLen, or a
|
||||
// hop indexed under a different prefix length); there's
|
||||
// no collision to resolve away. In the unresolvable arm
|
||||
// below, len==0 is the ONLY reachable case (resolveHop
|
||||
// returns nil iff pm.m[lowerHop] is empty — see
|
||||
// resolveWithContext priority chain), so the guard there
|
||||
// is intentionally permissive on len==0 and the
|
||||
// `preconfirmed ||` clause is the meaningful gate.
|
||||
uniquePrefix := len(pm.m[lowerHop]) <= 1
|
||||
if resolved != nil {
|
||||
entry.Name = resolved.Name
|
||||
entry.Pubkey = resolved.PublicKey
|
||||
@@ -1678,13 +1740,24 @@ func (s *Server) handleNodePaths(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
sigParts[i] = resolved.PublicKey
|
||||
if strings.ToLower(resolved.PublicKey) == lowerPK {
|
||||
containsTarget = true
|
||||
// #1352: only attribute when unambiguous OR
|
||||
// already pre-confirmed via SQL/full-key index.
|
||||
if preconfirmed || uniquePrefix {
|
||||
containsTarget = true
|
||||
}
|
||||
}
|
||||
} else {
|
||||
sigParts[i] = hop
|
||||
// Unresolvable hop: keep conservative if prefix could be the target.
|
||||
if strings.HasPrefix(lowerPK, strings.ToLower(hop)) {
|
||||
containsTarget = true
|
||||
// Unresolvable hop: keep conservative if prefix could
|
||||
// be the target AND there's no sibling collision.
|
||||
// If multiple candidates share this prefix, attribution
|
||||
// is ambiguous — don't claim membership without SQL
|
||||
// confirmation (#1352). See comment on uniquePrefix
|
||||
// above re: why len==0 is treated as safe here.
|
||||
if strings.HasPrefix(lowerPK, lowerHop) {
|
||||
if preconfirmed || uniquePrefix {
|
||||
containsTarget = true
|
||||
}
|
||||
}
|
||||
}
|
||||
resolvedHops[i] = entry
|
||||
@@ -1729,15 +1802,15 @@ func (s *Server) handleNodePaths(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
sort.Slice(paths, func(i, j int) bool {
|
||||
if paths[i].Count == paths[j].Count {
|
||||
li := ""
|
||||
lj := ""
|
||||
if paths[i].LastSeen != nil {
|
||||
li = fmt.Sprintf("%v", paths[i].LastSeen)
|
||||
}
|
||||
if paths[j].LastSeen != nil {
|
||||
lj = fmt.Sprintf("%v", paths[j].LastSeen)
|
||||
}
|
||||
li := ""
|
||||
lj := ""
|
||||
if paths[i].LastSeen != nil {
|
||||
li = fmt.Sprintf("%v", paths[i].LastSeen)
|
||||
}
|
||||
if paths[j].LastSeen != nil {
|
||||
lj = fmt.Sprintf("%v", paths[j].LastSeen)
|
||||
}
|
||||
if li != lj {
|
||||
return li > lj
|
||||
}
|
||||
return paths[i].Count > paths[j].Count
|
||||
|
||||
+166
-36
@@ -15,6 +15,8 @@ import (
|
||||
"sync/atomic"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/meshcore-analyzer/mbcapqueue"
|
||||
)
|
||||
|
||||
// payloadTypeNames maps payload_type int → human-readable name (firmware-standard).
|
||||
@@ -144,7 +146,7 @@ type PacketStore struct {
|
||||
insertCount int64
|
||||
queryCount int64
|
||||
// Response caches (separate mutex to avoid contention with store RWMutex)
|
||||
cacheMu sync.Mutex
|
||||
cacheMu sync.RWMutex
|
||||
rfCache map[string]*cachedResult // region → cached RF result
|
||||
topoCache map[string]*cachedResult // region → cached topology result
|
||||
hashCache map[string]*cachedResult // region → cached hash-sizes result
|
||||
@@ -229,9 +231,13 @@ type PacketStore struct {
|
||||
relayStatsCacheWindow float64
|
||||
relayStatsCacheSig string
|
||||
|
||||
// Cached multi-byte capability map (pubkey → entry), recomputed every 15s.
|
||||
multiByteCapCache map[string]*MultiByteCapEntry
|
||||
multiByteCapAt time.Time
|
||||
// Snapshot from the last analytics cycle + O(1) index, both under cacheMu.
|
||||
// Populated by analytics + pre-populated from DB on Load (read-only path).
|
||||
// Persistence to the DB is owned by the ingestor (#1289/#1324): the
|
||||
// analytics cycle publishes a snapshot file via internal/mbcapqueue
|
||||
// and the ingestor's RunMultibyteCapPersist applies it.
|
||||
mbCapSnapshot []MultiByteCapEntry
|
||||
mbCapIndex map[string]MultiByteCapEntry
|
||||
|
||||
// Cached per-pubkey relay info + usefulness score maps (#1257). These
|
||||
// fold the previously per-node GetRepeaterRelayInfo /
|
||||
@@ -813,6 +819,7 @@ func (s *PacketStore) Load() error {
|
||||
log.Printf("[store] Loaded %d transmissions (%d observations) in %v (tracked ~%.0fMB, heap ~%.0fMB)",
|
||||
len(s.packets), s.totalObs, elapsed, s.trackedMemoryMB(), s.estimatedMemoryMB())
|
||||
}
|
||||
s.loadMultibyteCapFromDB()
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1373,6 +1380,20 @@ func (s *PacketStore) QueryPackets(q PacketQuery) *PacketResult {
|
||||
results := s.filterPackets(q)
|
||||
total := len(results)
|
||||
|
||||
// #1345: order by ingest id, not insertion-into-s.packets order. After
|
||||
// Load() (which orders by first_seen ASC) the slice is mostly id-ordered
|
||||
// EXCEPT where rxTime ≠ ingest time — exactly the buffered-observer-upload
|
||||
// case that hides fresh activity. Sort by ID DESC so "page 0" is always
|
||||
// the most-recently-ingested transmissions, matching the DB-path fix.
|
||||
// Cost: O(n log n) on the filtered set per query; acceptable for the
|
||||
// typical filter-then-paginate flow (filterPackets already O(n)).
|
||||
sortedByID := make([]*StoreTx, len(results))
|
||||
copy(sortedByID, results)
|
||||
sort.Slice(sortedByID, func(i, j int) bool {
|
||||
return sortedByID[i].ID < sortedByID[j].ID
|
||||
})
|
||||
results = sortedByID
|
||||
|
||||
// results is oldest-first (ASC). For DESC (default) read backwards from the tail;
|
||||
// for ASC read forwards. Both are O(page_size) — no sort copy needed.
|
||||
start := q.Offset
|
||||
@@ -1956,9 +1977,9 @@ func (s *PacketStore) QueryMultiNodePackets(pubkeys []string, limit, offset int,
|
||||
filtered = append(filtered, tx)
|
||||
}
|
||||
}
|
||||
// Sort oldest-first to match pagination expectations (same as s.packets order).
|
||||
// #1345: sort by ingest id, not first_seen (=rxTime).
|
||||
sort.Slice(filtered, func(i, j int) bool {
|
||||
return filtered[i].FirstSeen < filtered[j].FirstSeen
|
||||
return filtered[i].ID < filtered[j].ID
|
||||
})
|
||||
|
||||
total := len(filtered)
|
||||
@@ -4508,7 +4529,13 @@ func (s *PacketStore) GetChannels(region string) []map[string]interface{} {
|
||||
}
|
||||
channelName := decoded.Channel
|
||||
if channelName == "" {
|
||||
channelName = "unknown"
|
||||
// Issue #1373: encrypted-no-key packets decode with channel="".
|
||||
// Previously we bucketed them under a literal "unknown" channel
|
||||
// which then leaked into /api/channels as a ghost entry next to
|
||||
// real channels (especially visible after the operator added a
|
||||
// PSK client-side). Skip them — they belong in encrypted-channels
|
||||
// analytics, not the user-facing channel list.
|
||||
continue
|
||||
}
|
||||
ch := channelMap[channelName]
|
||||
if ch == nil {
|
||||
@@ -4791,6 +4818,19 @@ func (s *PacketStore) GetChannelMessages(channelHash string, limit, offset int,
|
||||
|
||||
senderTs := decoded.SenderTimestamp
|
||||
|
||||
// Issue #1366: emit tx.LatestSeen (max observation timestamp,
|
||||
// server UTC) as the rendered timestamp — NOT tx.FirstSeen,
|
||||
// which stays pinned at the first-ever observation of a hash
|
||||
// and lags reality for heartbeat-style retransmissions. Fall
|
||||
// back to FirstSeen only when LatestSeen is empty (no obs).
|
||||
// sender_timestamp from the decoded payload is NOT used as the
|
||||
// rendered field: client RTCs are unreliable. It remains in
|
||||
// the response for debug surfaces.
|
||||
displayTs := tx.LatestSeen
|
||||
if displayTs == "" {
|
||||
displayTs = tx.FirstSeen
|
||||
}
|
||||
|
||||
observers := []string{}
|
||||
obsName := tx.ObserverName
|
||||
if obsName == "" {
|
||||
@@ -4804,7 +4844,8 @@ func (s *PacketStore) GetChannelMessages(channelHash string, limit, offset int,
|
||||
Data: map[string]interface{}{
|
||||
"sender": displaySender,
|
||||
"text": displayText,
|
||||
"timestamp": strOrNil(tx.FirstSeen),
|
||||
"timestamp": strOrNil(displayTs),
|
||||
"first_seen": strOrNil(tx.FirstSeen),
|
||||
"sender_timestamp": senderTs,
|
||||
"packetId": tx.ID,
|
||||
"packetHash": strOrNil(tx.Hash),
|
||||
@@ -4821,6 +4862,18 @@ func (s *PacketStore) GetChannelMessages(channelHash string, limit, offset int,
|
||||
}
|
||||
}
|
||||
|
||||
// Issue #1366 follow-up: msgOrder is in tx insertion order
|
||||
// (≈ FirstSeen ascending). Re-sort by the rendered timestamp field
|
||||
// (= LatestSeen, set above) ascending, so the page tail = newest
|
||||
// LatestSeen. Without this, a long-running heartbeat with old
|
||||
// FirstSeen but fresh LatestSeen ends up at the head of msgOrder
|
||||
// and gets sliced off by the tail selection below.
|
||||
sort.SliceStable(msgOrder, func(i, j int) bool {
|
||||
ti, _ := msgMap[msgOrder[i]].Data["timestamp"].(string)
|
||||
tj, _ := msgMap[msgOrder[j]].Data["timestamp"].(string)
|
||||
return ti < tj
|
||||
})
|
||||
|
||||
total := len(msgOrder)
|
||||
// Return latest messages (tail)
|
||||
start := total - limit - offset
|
||||
@@ -7136,7 +7189,27 @@ func (s *PacketStore) computeAnalyticsHashSizesWithCapability(region, area strin
|
||||
}
|
||||
}
|
||||
}
|
||||
result["multiByteCapability"] = s.computeMultiByteCapability(globalAdopterHS)
|
||||
mbEntries := s.computeMultiByteCapability(globalAdopterHS)
|
||||
result["multiByteCapability"] = mbEntries
|
||||
|
||||
// Build the O(1) lookup index OUTSIDE the cache lock — at Cascadia
|
||||
// scale this is a ~2400-entry allocation + hash + insert per cycle.
|
||||
// Holding cacheMu while doing it blocks every API reader for the
|
||||
// duration. Swap the pointers in under a short write-lock.
|
||||
mbIdx := make(map[string]MultiByteCapEntry, len(mbEntries))
|
||||
for _, e := range mbEntries {
|
||||
mbIdx[e.PublicKey] = e
|
||||
}
|
||||
s.cacheMu.Lock()
|
||||
s.mbCapSnapshot = mbEntries
|
||||
s.mbCapIndex = mbIdx
|
||||
s.cacheMu.Unlock()
|
||||
// Publish snapshot to the on-disk handoff so the ingestor can
|
||||
// persist it (#1289/#1324: server is read-only; persistence is the
|
||||
// ingestor's job). Best-effort — a write failure here does not
|
||||
// affect serving (the in-memory index above is the read path).
|
||||
s.publishMultibyteCapSnapshot(mbEntries)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -7936,39 +8009,96 @@ func EnrichNodeWithMultiByte(node map[string]interface{}, entry *MultiByteCapEnt
|
||||
node["multi_byte_max_hash_size"] = entry.MaxHashSize
|
||||
}
|
||||
|
||||
// GetMultiByteCapMap returns a cached pubkey → MultiByteCapEntry map.
|
||||
// Reuses the same 15s TTL cache pattern as hash size info.
|
||||
func (s *PacketStore) GetMultiByteCapMap() map[string]*MultiByteCapEntry {
|
||||
s.hashSizeInfoMu.Lock()
|
||||
if s.multiByteCapCache != nil && time.Since(s.multiByteCapAt) < 15*time.Second {
|
||||
cached := s.multiByteCapCache
|
||||
s.hashSizeInfoMu.Unlock()
|
||||
return cached
|
||||
// GetMultibyteCapFor returns the capability entry for a single pubkey via an O(1) map
|
||||
// lookup into the snapshot rebuilt by each analytics cycle (and pre-populated from
|
||||
// the DB on cold start). Returns false when the pubkey has no known capability.
|
||||
func (s *PacketStore) GetMultibyteCapFor(pk string) (*MultiByteCapEntry, bool) {
|
||||
s.cacheMu.RLock()
|
||||
e, ok := s.mbCapIndex[pk]
|
||||
s.cacheMu.RUnlock()
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
s.hashSizeInfoMu.Unlock()
|
||||
return &e, true
|
||||
}
|
||||
|
||||
// Get adopter hash sizes from analytics for cross-referencing
|
||||
analyticsData := s.GetAnalyticsHashSizes("", "")
|
||||
adopterSizes := make(map[string]int)
|
||||
if nodes, ok := analyticsData["nodes"].(map[string]map[string]interface{}); ok {
|
||||
for pk, data := range nodes {
|
||||
if hs, ok := data["hashSize"].(int); ok {
|
||||
adopterSizes[pk] = hs
|
||||
}
|
||||
// loadMultibyteCapFromDB pre-populates mbCapSnapshot and mbCapIndex from the nodes
|
||||
// table so cold starts serve the last-known capability without waiting for the first
|
||||
// analytics cycle (~15s).
|
||||
func (s *PacketStore) loadMultibyteCapFromDB() {
|
||||
if !s.db.hasMultibyteSupCols {
|
||||
return
|
||||
}
|
||||
rows, err := s.db.conn.Query(
|
||||
`SELECT public_key, COALESCE(name,''), COALESCE(role,''), COALESCE(last_seen,''), multibyte_sup, COALESCE(multibyte_evidence,'')
|
||||
FROM nodes WHERE multibyte_sup > 0`)
|
||||
if err != nil {
|
||||
log.Printf("[multibyte] loadFromDB: %v", err)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var entries []MultiByteCapEntry
|
||||
for rows.Next() {
|
||||
var pk, name, role, lastSeen, evidence string
|
||||
var sup int
|
||||
if err := rows.Scan(&pk, &name, &role, &lastSeen, &sup, &evidence); err != nil {
|
||||
continue
|
||||
}
|
||||
status := "unknown"
|
||||
switch sup {
|
||||
case 2:
|
||||
status = "confirmed"
|
||||
case 1:
|
||||
status = "suspected"
|
||||
}
|
||||
entries = append(entries, MultiByteCapEntry{
|
||||
PublicKey: pk,
|
||||
Name: name,
|
||||
Role: role,
|
||||
Status: status,
|
||||
Evidence: evidence,
|
||||
LastSeen: lastSeen,
|
||||
})
|
||||
}
|
||||
|
||||
caps := s.computeMultiByteCapability(adopterSizes)
|
||||
result := make(map[string]*MultiByteCapEntry, len(caps))
|
||||
for i := range caps {
|
||||
result[caps[i].PublicKey] = &caps[i]
|
||||
if len(entries) == 0 {
|
||||
return
|
||||
}
|
||||
idx := make(map[string]MultiByteCapEntry, len(entries))
|
||||
for _, e := range entries {
|
||||
idx[e.PublicKey] = e
|
||||
}
|
||||
s.cacheMu.Lock()
|
||||
s.mbCapSnapshot = entries
|
||||
s.mbCapIndex = idx
|
||||
s.cacheMu.Unlock()
|
||||
log.Printf("[multibyte] loaded %d capability entries from DB", len(entries))
|
||||
}
|
||||
|
||||
s.hashSizeInfoMu.Lock()
|
||||
s.multiByteCapCache = result
|
||||
s.multiByteCapAt = time.Now()
|
||||
s.hashSizeInfoMu.Unlock()
|
||||
return result
|
||||
// publishMultibyteCapSnapshot writes the analytics-cycle output to the
|
||||
// on-disk handoff (internal/mbcapqueue). The ingestor's
|
||||
// RunMultibyteCapPersist consumes the file and writes confirmed /
|
||||
// suspected entries to the DB.
|
||||
//
|
||||
// INVARIANT (#1289/#1324): the server is the read path and opens
|
||||
// SQLite mode=ro. It MUST NOT execute any UPDATE on
|
||||
// nodes.multibyte_* — see readonly_invariant_test.go. This helper is
|
||||
// the only side-effect path for capability data leaving the server.
|
||||
func (s *PacketStore) publishMultibyteCapSnapshot(entries []MultiByteCapEntry) {
|
||||
if s.db == nil || s.db.path == "" {
|
||||
return
|
||||
}
|
||||
out := make([]mbcapqueue.Entry, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
out = append(out, mbcapqueue.Entry{
|
||||
PublicKey: e.PublicKey,
|
||||
Status: e.Status,
|
||||
Evidence: e.Evidence,
|
||||
})
|
||||
}
|
||||
if err := mbcapqueue.WriteSnapshot(s.db.path, mbcapqueue.Snapshot{Entries: out}); err != nil {
|
||||
log.Printf("[multibyte] publish snapshot: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Multi-Byte Capability Inference ---
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
// TestTrafficShareScore_HandleNodesSurface pins issue #1456: the
|
||||
// /api/nodes response carries a new `traffic_share_score` field
|
||||
// alongside the legacy `usefulness_score`, with the same numeric
|
||||
// value. The legacy field is kept for API backwards-compat (existing
|
||||
// consumers + stale frontends); the new field is the canonical name
|
||||
// for the Traffic-axis score.
|
||||
func TestTrafficShareScore_HandleNodesSurface(t *testing.T) {
|
||||
db := setupCapabilityTestDB(t)
|
||||
defer db.conn.Close()
|
||||
if _, err := db.conn.Exec(`ALTER TABLE nodes ADD COLUMN foreign_advert INTEGER DEFAULT 0`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
pk := "aaaa000000000000000000000000000000000000000000000000000000000000"
|
||||
recent := time.Now().UTC().Format("2006-01-02T15:04:05.000Z")
|
||||
if _, err := db.conn.Exec(`INSERT INTO nodes
|
||||
(public_key, name, role, lat, lon, last_seen, first_seen, advert_count)
|
||||
VALUES (?, 'rpt', 'repeater', 37.5, -122.0, ?, ?, 10)`,
|
||||
pk, recent, recent); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
store := NewPacketStore(db, nil)
|
||||
cfg := &Config{Port: 3000}
|
||||
hub := NewHub()
|
||||
srv := NewServer(db, cfg, hub)
|
||||
srv.store = store
|
||||
|
||||
router := mux.NewRouter()
|
||||
srv.RegisterRoutes(router)
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/nodes?limit=10", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
if rr.Code != 200 {
|
||||
t.Fatalf("/api/nodes status: want 200, got %d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Nodes []map[string]interface{} `json:"nodes"`
|
||||
}
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode: %v body=%s", err, rr.Body.String())
|
||||
}
|
||||
var got map[string]interface{}
|
||||
for _, n := range resp.Nodes {
|
||||
if k, _ := n["public_key"].(string); k == pk {
|
||||
got = n
|
||||
break
|
||||
}
|
||||
}
|
||||
if got == nil {
|
||||
t.Fatalf("repeater node missing from /api/nodes response")
|
||||
}
|
||||
useful, hasU := got["usefulness_score"]
|
||||
share, hasS := got["traffic_share_score"]
|
||||
if !hasU {
|
||||
t.Errorf("usefulness_score absent (must remain for API compat)")
|
||||
}
|
||||
if !hasS {
|
||||
t.Errorf("traffic_share_score absent (new field per #1456)")
|
||||
}
|
||||
if hasU && hasS {
|
||||
uf, _ := useful.(float64)
|
||||
sf, _ := share.(float64)
|
||||
if uf != sf {
|
||||
t.Errorf("traffic_share_score (%v) must equal usefulness_score (%v)", sf, uf)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestTrafficShareScore_NodeDetail pins the same dual-field shape on
|
||||
// the per-node detail endpoint /api/nodes/{pubkey}.
|
||||
func TestTrafficShareScore_NodeDetail(t *testing.T) {
|
||||
db := setupCapabilityTestDB(t)
|
||||
defer db.conn.Close()
|
||||
if _, err := db.conn.Exec(`ALTER TABLE nodes ADD COLUMN foreign_advert INTEGER DEFAULT 0`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
pk := "bbbb000000000000000000000000000000000000000000000000000000000000"
|
||||
recent := time.Now().UTC().Format("2006-01-02T15:04:05.000Z")
|
||||
if _, err := db.conn.Exec(`INSERT INTO nodes
|
||||
(public_key, name, role, lat, lon, last_seen, first_seen, advert_count)
|
||||
VALUES (?, 'rpt', 'repeater', 37.5, -122.0, ?, ?, 10)`,
|
||||
pk, recent, recent); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
store := NewPacketStore(db, nil)
|
||||
cfg := &Config{Port: 3000}
|
||||
hub := NewHub()
|
||||
srv := NewServer(db, cfg, hub)
|
||||
srv.store = store
|
||||
|
||||
router := mux.NewRouter()
|
||||
srv.RegisterRoutes(router)
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/nodes/"+pk, nil)
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
if rr.Code != 200 {
|
||||
t.Fatalf("/api/nodes/{pk} status: want 200, got %d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Node map[string]interface{} `json:"node"`
|
||||
}
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode: %v body=%s", err, rr.Body.String())
|
||||
}
|
||||
if resp.Node == nil {
|
||||
t.Fatalf("node missing in response: %s", rr.Body.String())
|
||||
}
|
||||
if _, ok := resp.Node["usefulness_score"]; !ok {
|
||||
t.Errorf("usefulness_score absent on node detail (must remain for API compat)")
|
||||
}
|
||||
if _, ok := resp.Node["traffic_share_score"]; !ok {
|
||||
t.Errorf("traffic_share_score absent on node detail (new field per #1456)")
|
||||
}
|
||||
uf, _ := resp.Node["usefulness_score"].(float64)
|
||||
sf, _ := resp.Node["traffic_share_score"].(float64)
|
||||
if uf != sf {
|
||||
t.Errorf("traffic_share_score (%v) must equal usefulness_score (%v)", sf, uf)
|
||||
}
|
||||
}
|
||||
@@ -988,6 +988,9 @@ type ClientConfigResponse struct {
|
||||
PropagationBufferMs float64 `json:"propagationBufferMs"`
|
||||
Timestamps TimestampConfig `json:"timestamps"`
|
||||
DebugAffinity bool `json:"debugAffinity,omitempty"`
|
||||
// #1420 — server default for dark-tile provider picker. Client uses this
|
||||
// as the fallback when no localStorage override is set.
|
||||
MapDarkTileProvider string `json:"mapDarkTileProvider,omitempty"`
|
||||
}
|
||||
|
||||
// ─── IATA Coords ───────────────────────────────────────────────────────────────
|
||||
|
||||
+13
-2
@@ -47,6 +47,8 @@
|
||||
"observer": "#8b5cf6",
|
||||
"_comment": "Marker/badge colors per node role. Used on map, nodes list, and live feed."
|
||||
},
|
||||
"mapDarkTileProvider": "carto-dark",
|
||||
"_comment_mapDarkTileProvider": "Default dark-mode basemap provider. Allowed: 'carto-dark' (Carto dark_all — default), 'esri-darkgray-labels' (Esri Dark Gray Canvas + reference labels), 'voyager-inverted' (Carto Voyager with CSS invert filter), 'positron-inverted' (Carto Positron with CSS invert filter). Light mode is unaffected. Users can override per-browser via the in-app customizer (persisted to localStorage). #1420.",
|
||||
"home": {
|
||||
"heroTitle": "CoreScope",
|
||||
"heroSubtitle": "Find your nodes to start monitoring them.",
|
||||
@@ -135,6 +137,16 @@
|
||||
],
|
||||
"region": "SJC",
|
||||
"connectTimeoutSec": 45
|
||||
},
|
||||
{
|
||||
"_comment": "WebSocket MQTT broker (e.g. meshcore-mqtt-broker). Use ws:// for plain WebSocket or wss:// for TLS. Username/password supported.",
|
||||
"name": "wsmqtt",
|
||||
"broker": "wss://wsmqtt.example.com/mqtt",
|
||||
"username": "corescope",
|
||||
"password": "your-password",
|
||||
"topics": [
|
||||
"meshcore/#"
|
||||
]
|
||||
}
|
||||
],
|
||||
"channelKeys": {
|
||||
@@ -262,7 +274,7 @@
|
||||
"criticalMv": 3000,
|
||||
"_comment": "Voltage cutoffs (millivolts) for the per-node battery trend chart on /node-analytics. Latest sample below lowMv shows the node as ⚠️ Low; below criticalMv shows 🪫 Critical. Both default to 3300 / 3000 if omitted. Source data: observer_metrics.battery_mv populated from observer status messages; only nodes that are themselves observers (matching pubkey ↔ observer id) yield a series. Issue #663."
|
||||
},
|
||||
"_comment_mqttSources": "Each source connects to an MQTT broker. topics: what to subscribe to. iataFilter: only ingest packets from these regions (optional). region: default IATA region for this source — used when packet/topic doesn't specify one (optional, priority: payload > topic > this field).",
|
||||
"_comment_mqttSources": "Each source connects to an MQTT broker. Supported schemes: mqtt:// (plain TCP), mqtts:// (TLS), ws:// (WebSocket), wss:// (WebSocket TLS). topics: what to subscribe to. iataFilter: only ingest packets from these regions (optional). region: default IATA region for this source — used when packet/topic doesn't specify one (optional, priority: payload > topic > this field).",
|
||||
"compression": {
|
||||
"gzip": false,
|
||||
"websocket": false,
|
||||
@@ -279,7 +291,6 @@
|
||||
]
|
||||
},
|
||||
"_comment_compression": "Opt-in HTTP gzip middleware + WebSocket permessage-deflate. Both default to false — enable ONLY when your upstream reverse proxy is NOT already compressing. gzip: enables the gzipMiddleware wrapper around the HTTP handler. websocket: sets gorilla websocket Upgrader.EnableCompression. level: gzip compression level 1..9 (1=BestSpeed, 9=BestCompression, default 6). minSizeBytes: advisory minimum response size below which compression would not pay off. contentTypes: MIME allow-list — only responses with these Content-Type values are compressed. Already-compressed types (image/*, video/*, audio/*, application/zip, application/x-gzip, application/pdf, application/octet-stream) are always skipped, as are responses whose handler already set Content-Encoding. Omit contentTypes to use the built-in default allow-list.",
|
||||
"_comment_mqttSources": "Each source connects to an MQTT broker. topics: what to subscribe to. iataFilter: only ingest packets from these regions (optional).",
|
||||
"_comment_channelKeys": "Hex keys for decrypting channel messages. Key name = channel display name. public channel key is well-known.",
|
||||
"_comment_hashChannels": "Channel names whose keys are derived via SHA256. Key = SHA256(name)[:16]. Listed here so the ingestor can auto-derive keys.",
|
||||
"hashRegions": [
|
||||
|
||||
@@ -76,6 +76,9 @@ func Apply(rw *sql.DB, logf Logger) error {
|
||||
if err := ensureObservationsRawHexColumn(rw, logf); err != nil {
|
||||
return fmt.Errorf("ensure observations.raw_hex: %w", err)
|
||||
}
|
||||
if err := ensureMultibyteCapColumns(rw, logf); err != nil {
|
||||
return fmt.Errorf("ensure multibyte_cap columns: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -120,6 +123,13 @@ func AssertReady(ro *sql.DB) error {
|
||||
mustCol("nodes", "default_scope")
|
||||
mustCol("inactive_nodes", "default_scope")
|
||||
mustCol("observations", "raw_hex")
|
||||
// Multi-byte capability cache (#1324 follow-up; PR #903 surface).
|
||||
// Owned by ingestor — server reads these for O(1) /api/nodes
|
||||
// enrichment, ingestor's RunMultibyteCapPersist is the only writer.
|
||||
mustCol("nodes", "multibyte_sup")
|
||||
mustCol("nodes", "multibyte_evidence")
|
||||
mustCol("inactive_nodes", "multibyte_sup")
|
||||
mustCol("inactive_nodes", "multibyte_evidence")
|
||||
|
||||
if len(missing) > 0 {
|
||||
return fmt.Errorf("schema not migrated by ingestor; restart ingestor first. missing: %s",
|
||||
@@ -161,6 +171,10 @@ func ensureServerIndexes(rw *sql.DB) error {
|
||||
`CREATE INDEX IF NOT EXISTS idx_transmissions_payload_type ON transmissions(payload_type)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_observations_timestamp ON observations(timestamp)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_observations_transmission_id ON observations(transmission_id)`,
|
||||
// Composite covers GetChannelMessages' grouped MAX(timestamp) per
|
||||
// transmission_id (issue #1366 / PR #1368). With this index sqlite can
|
||||
// satisfy the aggregate index-only without touching the heap.
|
||||
`CREATE INDEX IF NOT EXISTS idx_observations_tx_ts ON observations(transmission_id, timestamp)`,
|
||||
}
|
||||
for _, s := range stmts {
|
||||
if _, err := rw.Exec(s); err != nil {
|
||||
@@ -434,3 +448,38 @@ func SoftDeleteBlacklistedObservers(rw *sql.DB, blacklist []string) (int64, erro
|
||||
n, _ := res.RowsAffected()
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// ensureMultibyteCapColumns adds the multi-byte capability cache columns
|
||||
// to nodes / inactive_nodes (PR #903, canonical owner per #1324
|
||||
// follow-up). These columns are populated by the ingestor's
|
||||
// RunMultibyteCapPersist from snapshot files written by the server's
|
||||
// analytics cycle; the server is read-only since #1289 and MUST NOT
|
||||
// write here. The schema itself lives here in dbschema (the writer
|
||||
// owns migrations, the read-only server merely AssertReady's them).
|
||||
func ensureMultibyteCapColumns(rw *sql.DB, logf Logger) error {
|
||||
for _, table := range []string{"nodes", "inactive_nodes"} {
|
||||
hasSup, err := TableHasColumn(rw, table, "multibyte_sup")
|
||||
if err != nil {
|
||||
return fmt.Errorf("inspect %s.multibyte_sup: %w", table, err)
|
||||
}
|
||||
if !hasSup {
|
||||
if _, err := rw.Exec(fmt.Sprintf(
|
||||
"ALTER TABLE %s ADD COLUMN multibyte_sup INTEGER NOT NULL DEFAULT 0", table)); err != nil {
|
||||
return fmt.Errorf("add %s.multibyte_sup: %w", table, err)
|
||||
}
|
||||
logf("[dbschema] added multibyte_sup column to %s", table)
|
||||
}
|
||||
hasEvid, err := TableHasColumn(rw, table, "multibyte_evidence")
|
||||
if err != nil {
|
||||
return fmt.Errorf("inspect %s.multibyte_evidence: %w", table, err)
|
||||
}
|
||||
if !hasEvid {
|
||||
if _, err := rw.Exec(fmt.Sprintf(
|
||||
"ALTER TABLE %s ADD COLUMN multibyte_evidence TEXT", table)); err != nil {
|
||||
return fmt.Errorf("add %s.multibyte_evidence: %w", table, err)
|
||||
}
|
||||
logf("[dbschema] added multibyte_evidence column to %s", table)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
module github.com/meshcore-analyzer/mbcapqueue
|
||||
|
||||
go 1.22
|
||||
@@ -0,0 +1,118 @@
|
||||
// Package mbcapqueue defines the on-disk handoff used by the read-only
|
||||
// server (cmd/server) to publish multi-byte capability snapshots that
|
||||
// the writer-owning ingestor (cmd/ingestor) persists to the nodes /
|
||||
// inactive_nodes tables.
|
||||
//
|
||||
// Rationale: PR #903 originally added a server-side persistMultibyteCapability
|
||||
// that executed UPDATEs on nodes/inactive_nodes — a hard violation of the
|
||||
// read-only-server invariant established in #1283/#1287/#1289 (the server
|
||||
// opens SQLite with mode=ro). The capability computation is heavy and lives
|
||||
// in the server's analytics cycle; rather than duplicate it in the ingestor,
|
||||
// the server writes a snapshot file under <dataDir>/mbcap-snapshot/ and the
|
||||
// ingestor's maintenance loop picks it up and writes to the DB.
|
||||
//
|
||||
// Pattern mirrors internal/prunequeue (#669/#738).
|
||||
//
|
||||
// Layout (under <dir(dbPath)>/mbcap-snapshot/):
|
||||
//
|
||||
// snapshot.json — atomic-replaced by the server each analytics cycle
|
||||
// snapshot.json.tmp — transient (rename target)
|
||||
//
|
||||
// The file is rewritten in full each cycle (idempotent overwrite). The
|
||||
// ingestor reads the file at most once per persist tick; if absent, the
|
||||
// tick is a no-op.
|
||||
package mbcapqueue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
// QueueDirName is the subdirectory (under the SQLite data dir) holding
|
||||
// the snapshot file.
|
||||
const QueueDirName = "mbcap-snapshot"
|
||||
|
||||
// SnapshotFileName is the canonical snapshot file written by the server.
|
||||
const SnapshotFileName = "snapshot.json"
|
||||
|
||||
// Entry is one node's multi-byte capability as derived by the server's
|
||||
// analytics cycle. Status is the human label ("confirmed", "suspected",
|
||||
// "unknown"); the ingestor maps it to the DB sup integer.
|
||||
//
|
||||
// Entries with Status=="unknown" are NEVER persisted (the writer must
|
||||
// not overwrite a previously confirmed/suspected DB value with a
|
||||
// snapshot blank — same data-destruction guard the server enforced).
|
||||
type Entry struct {
|
||||
PublicKey string `json:"public_key"`
|
||||
Status string `json:"status"`
|
||||
Evidence string `json:"evidence,omitempty"`
|
||||
}
|
||||
|
||||
// Snapshot is the full payload the server writes.
|
||||
type Snapshot struct {
|
||||
WrittenAt time.Time `json:"writtenAt"`
|
||||
Entries []Entry `json:"entries"`
|
||||
}
|
||||
|
||||
// QueueDir returns the absolute path of the snapshot directory, given
|
||||
// the SQLite database path the ingestor and server share.
|
||||
func QueueDir(dbPath string) string {
|
||||
return filepath.Join(filepath.Dir(dbPath), QueueDirName)
|
||||
}
|
||||
|
||||
// EnsureDir creates the snapshot directory if missing.
|
||||
func EnsureDir(dbPath string) error {
|
||||
return os.MkdirAll(QueueDir(dbPath), 0o755)
|
||||
}
|
||||
|
||||
// SnapshotPath returns the absolute path of snapshot.json under dbPath.
|
||||
func SnapshotPath(dbPath string) string {
|
||||
return filepath.Join(QueueDir(dbPath), SnapshotFileName)
|
||||
}
|
||||
|
||||
// WriteSnapshot atomically replaces snapshot.json with the given payload.
|
||||
// Uses tmp-then-rename so a reader never sees a torn file.
|
||||
func WriteSnapshot(dbPath string, snap Snapshot) error {
|
||||
if err := EnsureDir(dbPath); err != nil {
|
||||
return fmt.Errorf("ensure dir: %w", err)
|
||||
}
|
||||
if snap.WrittenAt.IsZero() {
|
||||
snap.WrittenAt = time.Now().UTC()
|
||||
}
|
||||
b, err := json.Marshal(snap)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal: %w", err)
|
||||
}
|
||||
final := SnapshotPath(dbPath)
|
||||
tmp := final + ".tmp"
|
||||
if err := os.WriteFile(tmp, b, 0o644); err != nil {
|
||||
return fmt.Errorf("write tmp: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmp, final); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return fmt.Errorf("rename: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReadSnapshot loads the current snapshot.json. Returns os.ErrNotExist
|
||||
// when no snapshot has been written yet — callers should treat that as
|
||||
// "nothing to persist" rather than an error.
|
||||
func ReadSnapshot(dbPath string) (Snapshot, error) {
|
||||
var snap Snapshot
|
||||
b, err := os.ReadFile(SnapshotPath(dbPath))
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return snap, os.ErrNotExist
|
||||
}
|
||||
return snap, fmt.Errorf("read: %w", err)
|
||||
}
|
||||
if err := json.Unmarshal(b, &snap); err != nil {
|
||||
return snap, fmt.Errorf("unmarshal: %w", err)
|
||||
}
|
||||
return snap, nil
|
||||
}
|
||||
+58
-10
@@ -1615,6 +1615,14 @@
|
||||
html += hashMatrixLegendHtml(legendLabels);
|
||||
el.innerHTML = html;
|
||||
initMatrixTooltip(el);
|
||||
// #1473 — Grey out cells whose first byte the MeshCore firmware keygen
|
||||
// routine avoids (pub_key[0] in {0x00, 0xFF}). This is a keygen
|
||||
// CONVENTION, not a protocol-level rejection — see firmware
|
||||
// examples/simple_repeater/main.cpp:83 (HEAD 8ede7641). Must run BEFORE
|
||||
// we wire click handlers so .hash-active is stripped first.
|
||||
if (typeof PrefixReserved !== 'undefined' && PrefixReserved && typeof PrefixReserved.markReservedCells === 'function') {
|
||||
PrefixReserved.markReservedCells(el);
|
||||
}
|
||||
el.querySelectorAll('.hash-active').forEach(td => {
|
||||
td.addEventListener('click', () => {
|
||||
clickHandlerFn(td);
|
||||
@@ -2992,6 +3000,12 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _analyticsData =
|
||||
<div class="analytics-card" id="ptGenerator">
|
||||
<h3 style="margin-top:0">Generate Available Prefix</h3>
|
||||
<p class="text-muted" style="margin-top:0;font-size:0.9em">Find a prefix with zero current collisions.</p>
|
||||
<p class="text-muted" style="margin:4px 0 10px;font-size:0.82em">
|
||||
<span aria-hidden="true">🚫</span>
|
||||
<strong>0x00 and 0xFF excluded</strong> as a first byte — the MeshCore firmware keygen routine re-rolls identities whose <code>pub_key[0]</code> is <code>00</code> or <code>FF</code>, so by convention you should not see those prefixes on real nodes (see
|
||||
<a href="https://github.com/meshcore-dev/MeshCore/blob/8ede7641/examples/simple_repeater/main.cpp#L83"
|
||||
target="_blank" rel="noopener noreferrer" style="color:var(--accent)">simple_repeater/main.cpp:83</a>).
|
||||
</p>
|
||||
<div style="display:flex;gap:16px;align-items:center;flex-wrap:wrap;margin-bottom:12px">
|
||||
<label style="display:flex;align-items:center;gap:6px;cursor:pointer">
|
||||
<input type="radio" name="ptGenSize" value="1" ${initGenerate === '1' ? 'checked' : ''}> 1-byte
|
||||
@@ -3052,6 +3066,19 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _analyticsData =
|
||||
: [{ b: input.length / 2, prefix: input }];
|
||||
|
||||
let html = '';
|
||||
// #1473 — Warn when the user pastes a prefix or full pubkey whose
|
||||
// first byte is one the MeshCore firmware keygen routine avoids
|
||||
// (pub_key[0] in {0x00, 0xFF}). Firmware keygen CONVENTION, not a
|
||||
// protocol-level rejection — see simple_repeater/main.cpp:83.
|
||||
if (typeof PrefixReserved !== 'undefined' && PrefixReserved &&
|
||||
PrefixReserved.isReservedPrefix(input)) {
|
||||
html += `<div role="alert" style="margin-bottom:10px;padding:10px 14px;border:1px solid var(--status-yellow);border-radius:6px;background:var(--bg-secondary,var(--bg))">
|
||||
<strong style="color:var(--status-yellow)">⚠️ Firmware avoids this first byte</strong>
|
||||
<div class="text-muted" style="font-size:0.85em;margin-top:4px">
|
||||
<code class="mono">${input.slice(0,2)}</code> as the first byte of a node pubkey is avoided by the MeshCore firmware keygen convention (the standard repeater re-rolls identities whose <code class="mono">pub_key[0]</code> is <code class="mono">00</code> or <code class="mono">FF</code>). You generally shouldn't see this on real nodes.
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
if (isFullKey) {
|
||||
const inNetwork = nodes.some(n => n.public_key.toUpperCase() === input);
|
||||
html += `<p class="text-muted" style="font-size:0.85em;margin:0 0 10px">Derived prefixes: <code class="mono">${input.slice(0,2)}</code> / <code class="mono">${input.slice(0,4)}</code> / <code class="mono">${input.slice(0,6)}</code>${!inNetwork ? ' — <em>this node is not yet in the network</em>' : ''}</p>`;
|
||||
@@ -3085,34 +3112,55 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _analyticsData =
|
||||
const b = sizeInput ? parseInt(sizeInput.value) : 2;
|
||||
const hexLen = b * 2;
|
||||
const totalSpace = spaceSizes[b];
|
||||
const available = totalSpace - idx[b].size;
|
||||
// #1473 — Reserved prefixes (first byte 0x00 / 0xFF) are dropped from
|
||||
// the candidate pool because the MeshCore firmware keygen routine
|
||||
// re-rolls identities whose pub_key[0] is 0x00 or 0xFF — a keygen
|
||||
// CONVENTION (not a protocol rejection). See firmware
|
||||
// examples/simple_repeater/main.cpp:83 (HEAD 8ede7641).
|
||||
// Available = space - used - reserved.
|
||||
const reservedTotal = (typeof PrefixReserved !== 'undefined' && PrefixReserved)
|
||||
? PrefixReserved.reservedCount(b)
|
||||
: 0;
|
||||
// Count reserved prefixes that are ALREADY used so we don't subtract them twice.
|
||||
let reservedUsed = 0;
|
||||
if (typeof PrefixReserved !== 'undefined' && PrefixReserved) {
|
||||
for (const p of idx[b].keys()) {
|
||||
if (PrefixReserved.isReservedPrefix(p)) reservedUsed++;
|
||||
}
|
||||
}
|
||||
const available = totalSpace - idx[b].size - (reservedTotal - reservedUsed);
|
||||
|
||||
if (available === 0) {
|
||||
if (available <= 0) {
|
||||
const next = b < 3 ? (b + 1) + '-byte' : 'a different size';
|
||||
genResultEl.innerHTML = `<p style="color:var(--status-red);margin:0">No collision-free ${b}-byte prefixes available. Try ${next}.</p>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const isReserved = (p) =>
|
||||
(typeof PrefixReserved !== 'undefined' && PrefixReserved)
|
||||
? PrefixReserved.isReservedPrefix(p)
|
||||
: false;
|
||||
|
||||
let prefix;
|
||||
if (b === 1) {
|
||||
// Enumerate all 256 options
|
||||
// Enumerate all 256 options, skipping used + reserved.
|
||||
const free = [];
|
||||
for (let i = 0; i < totalSpace; i++) {
|
||||
const p = i.toString(16).toUpperCase().padStart(hexLen, '0');
|
||||
if (!idx[b].has(p)) free.push(p);
|
||||
if (!idx[b].has(p) && !isReserved(p)) free.push(p);
|
||||
}
|
||||
prefix = free[Math.floor(Math.random() * free.length)];
|
||||
} else {
|
||||
// Random sampling — with 2K used / 65K space, hit rate >96%
|
||||
// Random sampling — with 2K used / 65K space, hit rate >96%.
|
||||
let attempts = 0;
|
||||
do {
|
||||
prefix = Math.floor(Math.random() * totalSpace).toString(16).toUpperCase().padStart(hexLen, '0');
|
||||
} while (idx[b].has(prefix) && ++attempts < 500);
|
||||
// Fallback to enumeration if sampling kept hitting used prefixes
|
||||
if (idx[b].has(prefix)) {
|
||||
} while ((idx[b].has(prefix) || isReserved(prefix)) && ++attempts < 500);
|
||||
// Fallback to enumeration if sampling kept hitting used/reserved prefixes.
|
||||
if (idx[b].has(prefix) || isReserved(prefix)) {
|
||||
for (let i = 0; i < totalSpace; i++) {
|
||||
const p = i.toString(16).toUpperCase().padStart(hexLen, '0');
|
||||
if (!idx[b].has(p)) { prefix = p; break; }
|
||||
if (!idx[b].has(p) && !isReserved(p)) { prefix = p; break; }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3986,7 +4034,7 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _analyticsData =
|
||||
if (loadingEl) loadingEl.style.display = '';
|
||||
try {
|
||||
// Fix 4: use api() instead of raw fetch()
|
||||
var data = await api('/api/scope-stats?window=' + encodeURIComponent(w), { ttl: 30000 });
|
||||
var data = await api('/scope-stats?window=' + encodeURIComponent(w), { ttl: 30000 });
|
||||
if (loadingEl) loadingEl.style.display = 'none';
|
||||
if (data.error) {
|
||||
var cardsEl2 = document.getElementById('scopes-cards');
|
||||
|
||||
+91
-9
@@ -1000,10 +1000,11 @@ window.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
// --- Dark Mode ---
|
||||
const darkToggle = document.getElementById('darkModeToggle');
|
||||
const darkCheckbox = document.getElementById('darkModeCheckbox');
|
||||
const savedTheme = localStorage.getItem('meshcore-theme');
|
||||
function applyTheme(theme) {
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
darkToggle.textContent = theme === 'dark' ? '🌙' : '☀️';
|
||||
if (darkCheckbox) darkCheckbox.checked = theme === 'dark';
|
||||
localStorage.setItem('meshcore-theme', theme);
|
||||
// Re-apply user theme CSS vars for the correct mode (light/dark)
|
||||
reapplyUserThemeVars(theme === 'dark');
|
||||
@@ -1051,9 +1052,45 @@ window.addEventListener('DOMContentLoaded', () => {
|
||||
} else {
|
||||
applyTheme('light');
|
||||
}
|
||||
darkToggle.addEventListener('click', () => {
|
||||
const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
|
||||
applyTheme(isDark ? 'light' : 'dark');
|
||||
if (darkCheckbox) {
|
||||
darkCheckbox.addEventListener('change', () => {
|
||||
applyTheme(darkCheckbox.checked ? 'dark' : 'light');
|
||||
});
|
||||
} else {
|
||||
// Fallback for button-style toggle (upstream compatibility)
|
||||
darkToggle.addEventListener('click', () => {
|
||||
const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
|
||||
applyTheme(isDark ? 'light' : 'dark');
|
||||
});
|
||||
}
|
||||
// PR #893 follow-up: cross-tab sync — when another tab toggles theme,
|
||||
// mirror it here without re-persisting (avoid loop). Matches the pattern
|
||||
// used by the cb-presets storage listener below.
|
||||
window.addEventListener('storage', function (ev) {
|
||||
if (!ev || ev.key !== 'meshcore-theme' || !ev.newValue) return;
|
||||
if (ev.newValue !== 'dark' && ev.newValue !== 'light') return;
|
||||
document.documentElement.setAttribute('data-theme', ev.newValue);
|
||||
if (darkCheckbox) darkCheckbox.checked = ev.newValue === 'dark';
|
||||
try { reapplyUserThemeVars(ev.newValue === 'dark'); } catch (_) {}
|
||||
});
|
||||
|
||||
// --- #1361 Colorblind preset bootstrap & cross-tab sync ---
|
||||
// cb-presets.js auto-inits on module load, but body may not have existed
|
||||
// yet (script loads in <head>); re-apply now that DOMContentLoaded fired
|
||||
// so body[data-cb-preset] is set before first paint of map/cluster bubbles.
|
||||
try {
|
||||
if (window.MeshCorePresets && typeof window.MeshCorePresets.initFromStorage === 'function') {
|
||||
window.MeshCorePresets.initFromStorage();
|
||||
}
|
||||
} catch (e) { console.error('[cb-preset] init failed:', e); }
|
||||
// Cross-tab sync: storage event listener is also registered inside
|
||||
// cb-presets.js, but we wire a redundant one here so any future refactor
|
||||
// of the module still leaves the cross-tab guarantee intact.
|
||||
window.addEventListener('storage', function (ev) {
|
||||
if (!ev || ev.key !== 'meshcore-cb-preset') return;
|
||||
if (window.MeshCorePresets && ev.newValue) {
|
||||
window.MeshCorePresets.applyPreset(ev.newValue, { skipPersist: true });
|
||||
}
|
||||
});
|
||||
|
||||
// --- Hamburger Menu ---
|
||||
@@ -1096,9 +1133,23 @@ window.addEventListener('DOMContentLoaded', () => {
|
||||
// only signal — if you ever need finer ordering, switch to a numeric
|
||||
// attribute (e.g. data-overflow-order="3") rather than re-shuffling
|
||||
// index in HTML.
|
||||
const overflowQueue = allLinks.filter(a => a.dataset.priority !== 'high')
|
||||
.reverse() // right-to-left
|
||||
.concat(allLinks.filter(a => a.dataset.priority === 'high').reverse());
|
||||
// #1391: ALSO exclude the currently-active link from the queue.
|
||||
// The active pill has wider rendered width (background + padding),
|
||||
// and acceptance for #1391 requires "Active-route pill MUST always
|
||||
// be visible inline (never overflowed to More) at any viewport
|
||||
// ≥768px." The queue is rebuilt on hashchange (applyNavPriority
|
||||
// is wired to hashchange below), so the exclusion tracks the
|
||||
// current route automatically.
|
||||
function buildOverflowQueue() {
|
||||
var isPinned = function(a) {
|
||||
return a.dataset.priority === 'high' || a.classList.contains('active');
|
||||
};
|
||||
return allLinks.filter(a => !isPinned(a))
|
||||
.reverse() // right-to-left
|
||||
.concat(allLinks.filter(a => a.dataset.priority === 'high' && !a.classList.contains('active')).reverse());
|
||||
}
|
||||
var overflowQueue = buildOverflowQueue();
|
||||
|
||||
|
||||
function rebuildMoreMenu() {
|
||||
navMoreMenu.innerHTML = '';
|
||||
@@ -1157,7 +1208,14 @@ window.addEventListener('DOMContentLoaded', () => {
|
||||
// owns the decision (and at 2560px nothing overflows).
|
||||
if (window.innerWidth <= 1100) {
|
||||
allLinks.forEach(a => {
|
||||
if (a.dataset.priority !== 'high') a.classList.add('is-overflow');
|
||||
// #1391: never overflow the active-route pill, even in the
|
||||
// narrow-desktop CSS branch — acceptance requires it stay
|
||||
// inline at any viewport ≥768px. Without this guard, a
|
||||
// non-high-priority active route (e.g. /#/perf) would be
|
||||
// shoved into More alongside the rest.
|
||||
if (a.dataset.priority !== 'high' && !a.classList.contains('active')) {
|
||||
a.classList.add('is-overflow');
|
||||
}
|
||||
});
|
||||
rebuildMoreMenu();
|
||||
return;
|
||||
@@ -1214,6 +1272,11 @@ window.addEventListener('DOMContentLoaded', () => {
|
||||
return needed <= window.innerWidth;
|
||||
}
|
||||
let i = 0;
|
||||
// #1391: rebuild queue here so it reflects the CURRENT active
|
||||
// link (hashchange wakes applyNavPriority, but the queue was
|
||||
// captured at init-time; we need to re-evaluate which link is
|
||||
// active on every run). Cheap — just filters allLinks twice.
|
||||
overflowQueue = buildOverflowQueue();
|
||||
// #1311 floor: protect data-priority="high" links from being
|
||||
// dropped by the greedy fit loop. The bug was that on a non-high
|
||||
// active route (e.g. /#/perf, /#/audio-lab) at ~1101-1200px, the
|
||||
@@ -1226,8 +1289,13 @@ window.addEventListener('DOMContentLoaded', () => {
|
||||
// still doesn't fit at that point, that's a layout issue (e.g.
|
||||
// shrink the active pill, drop nav-stats earlier) — never the
|
||||
// measurer's call to delete primary navigation.
|
||||
//
|
||||
// #1391: also break on .active — buildOverflowQueue already
|
||||
// excludes the active link from the queue, but the break is a
|
||||
// defensive belt for any future code that re-enqueues it.
|
||||
while (!fits() && i < overflowQueue.length) {
|
||||
if (overflowQueue[i].dataset.priority === 'high') break;
|
||||
if (overflowQueue[i].classList.contains('active')) break;
|
||||
overflowQueue[i].classList.add('is-overflow');
|
||||
i++;
|
||||
}
|
||||
@@ -1246,7 +1314,7 @@ window.addEventListener('DOMContentLoaded', () => {
|
||||
// it just to satisfy the >=2 More-menu floor. A degenerate
|
||||
// 1-item dropdown is a smaller UX paper-cut than nuking a
|
||||
// primary nav link.
|
||||
if (i < overflowQueue.length && overflowQueue[i].dataset.priority !== 'high') {
|
||||
if (i < overflowQueue.length && overflowQueue[i].dataset.priority !== 'high' && !overflowQueue[i].classList.contains('active')) {
|
||||
overflowQueue[i].classList.add('is-overflow');
|
||||
i++;
|
||||
} else {
|
||||
@@ -1280,9 +1348,19 @@ window.addEventListener('DOMContentLoaded', () => {
|
||||
requestAnimationFrame(applyNavPriority);
|
||||
});
|
||||
|
||||
// #1406: position the fixed dropdown relative to the More button on each open.
|
||||
// Required because .nav-more-menu is position:fixed (so it escapes
|
||||
// .nav-more-wrap's layout box and doesn't inflate the parent flex line).
|
||||
function positionMoreMenu() {
|
||||
var wr = navMoreWrap.getBoundingClientRect();
|
||||
navMoreMenu.style.top = (wr.bottom + 4) + 'px';
|
||||
navMoreMenu.style.right = (window.innerWidth - wr.right) + 'px';
|
||||
navMoreMenu.style.left = 'auto';
|
||||
}
|
||||
navMoreBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const opening = !navMoreMenu.classList.contains('open');
|
||||
if (opening) positionMoreMenu();
|
||||
navMoreMenu.classList.toggle('open');
|
||||
navMoreBtn.setAttribute('aria-expanded', String(opening));
|
||||
if (opening) {
|
||||
@@ -1290,6 +1368,10 @@ window.addEventListener('DOMContentLoaded', () => {
|
||||
if (firstLink) firstLink.focus();
|
||||
}
|
||||
});
|
||||
// Re-position on window resize while open.
|
||||
window.addEventListener('resize', () => {
|
||||
if (navMoreMenu.classList.contains('open')) positionMoreMenu();
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', (e) => {
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
/* cb-presets.js — Colorblind preset registry & runtime switcher (#1361).
|
||||
*
|
||||
* MVP scope:
|
||||
* - 5 presets: default (Wong 2011), deut (IBM 5-class), prot (IBM 5-class
|
||||
* with high-luminance amber anchor), trit (Tol muted, blue/yellow-safe),
|
||||
* achromat (pure luminance ramp).
|
||||
* - applyPreset(id) sets body[data-cb-preset], writes --mc-role-* and
|
||||
* --mc-mb-* CSS vars on documentElement, persists to localStorage.
|
||||
* - initFromStorage() re-applies on reload.
|
||||
* - storage event listener syncs across tabs.
|
||||
* - WCAG 2.2 SC 1.4.3 / 1.4.11 contrast helper for validation.
|
||||
*
|
||||
* Stretch (Brettel/Vienot SVG simulation overlay, "Reset to default Wong"
|
||||
* button) is intentionally NOT implemented here — separate follow-up.
|
||||
*
|
||||
* Palette sources cited in PR body.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var STORAGE_KEY = 'meshcore-cb-preset';
|
||||
var DATA_ATTR = 'data-cb-preset';
|
||||
|
||||
// ── Palettes ────────────────────────────────────────────────────────────
|
||||
// Each preset declares colors for the 5 roles + the 3 multi-byte status
|
||||
// colors. role keys mirror --mc-role-{repeater|companion|room|sensor|observer}.
|
||||
// mb keys mirror --mc-mb-{confirmed|suspected|unknown}.
|
||||
var PRESETS = [
|
||||
{
|
||||
id: 'default',
|
||||
label: 'Default (Wong 2011)',
|
||||
description: 'Wong\'s 8-class colorblind-safe palette — the project default.',
|
||||
roleColors: {
|
||||
repeater: '#D55E00', // vermillion
|
||||
companion: '#56B4E9', // sky blue
|
||||
room: '#009E73', // bluish-green
|
||||
sensor: '#F0E442', // yellow
|
||||
observer: '#CC79A7' // reddish-purple
|
||||
},
|
||||
// #1407 — per-role text colors paired with each bg for WCAG 1.4.3 AA
|
||||
// (≥4.5:1). Wong defaults all pass with dark text; explicit so the
|
||||
// CSS-var pipeline is uniform across presets.
|
||||
roleText: {
|
||||
repeater: '#1a1a1a', companion: '#1a1a1a', room: '#1a1a1a',
|
||||
sensor: '#1a1a1a', observer: '#1a1a1a'
|
||||
},
|
||||
mb: {
|
||||
confirmed: '#56F0A0',
|
||||
suspected: '#FFD966',
|
||||
unknown: '#FF8888'
|
||||
}
|
||||
,
|
||||
routeRamp: ['#440154', '#3b528b', '#21918c', '#5ec962', '#fde725']
|
||||
},
|
||||
{
|
||||
id: 'deut',
|
||||
label: 'Deuteranopia-tuned',
|
||||
description: 'IBM 5-class palette — anchors shifted away from red/green collision.',
|
||||
// IBM Design Language colorblind-safe: blue / purple / magenta / orange / amber.
|
||||
roleColors: {
|
||||
repeater: '#FE6100', // orange (high-luminance anchor for repeater)
|
||||
companion: '#648FFF', // blue
|
||||
room: '#785EF0', // purple
|
||||
sensor: '#FFB000', // amber
|
||||
observer: '#DC267F' // magenta
|
||||
},
|
||||
// #1407 — IBM 5-class: room (#785EF0) and observer (#DC267F) fail AA
|
||||
// with #1a1a1a (3.86 / 3.83). Flip to white where needed.
|
||||
roleText: {
|
||||
repeater: '#1a1a1a', companion: '#1a1a1a', room: '#ffffff',
|
||||
sensor: '#1a1a1a', observer: '#ffffff'
|
||||
},
|
||||
mb: {
|
||||
confirmed: '#648FFF',
|
||||
suspected: '#FFB000',
|
||||
unknown: '#DC267F'
|
||||
}
|
||||
,
|
||||
routeRamp: ['#0d0887', '#7e03a8', '#cc4778', '#f89540', '#f0f921']
|
||||
},
|
||||
{
|
||||
id: 'prot',
|
||||
label: 'Protanopia-tuned',
|
||||
description: 'IBM 5-class with amber-shifted repeater anchor (protan-safe luminance).',
|
||||
roleColors: {
|
||||
repeater: '#FFB000', // amber — higher luminance than orange for protans
|
||||
companion: '#648FFF',
|
||||
room: '#785EF0',
|
||||
sensor: '#FE6100',
|
||||
observer: '#DC267F'
|
||||
},
|
||||
// Same as deut for room/observer.
|
||||
roleText: {
|
||||
repeater: '#1a1a1a', companion: '#1a1a1a', room: '#ffffff',
|
||||
sensor: '#1a1a1a', observer: '#ffffff'
|
||||
},
|
||||
mb: {
|
||||
confirmed: '#648FFF',
|
||||
suspected: '#FFB000',
|
||||
unknown: '#DC267F'
|
||||
}
|
||||
,
|
||||
routeRamp: ['#0d0887', '#7e03a8', '#cc4778', '#f89540', '#f0f921']
|
||||
},
|
||||
{
|
||||
id: 'trit',
|
||||
label: 'Tritanopia-tuned',
|
||||
description: 'Tol muted palette — avoids blue/yellow confusion zone.',
|
||||
// Paul Tol muted (B/Y-safe): red / teal / green / purple / sand.
|
||||
roleColors: {
|
||||
repeater: '#CC6677', // rose
|
||||
companion: '#117733', // green
|
||||
room: '#882255', // wine
|
||||
sensor: '#DDCC77', // sand (replaces pure yellow)
|
||||
observer: '#AA4499' // purple
|
||||
},
|
||||
// #1407 — Tol muted has 3 darker anchors that fail with dark text:
|
||||
// companion #117733 vs #1a1a1a = 3.71:1 → use white text
|
||||
// room #882255 vs #1a1a1a = 2.41:1 → use white text
|
||||
// observer #AA4499 vs #1a1a1a = 4.00:1 → use white text
|
||||
// The 2 lighter anchors (rose, sand) keep dark text.
|
||||
roleText: {
|
||||
repeater: '#1a1a1a', // #CC6677 vs #1a1a1a = 5.73:1 ✓
|
||||
companion: '#ffffff', // #117733 vs #fff = 5.66:1 ✓
|
||||
room: '#ffffff', // #882255 vs #fff = 8.71:1 ✓
|
||||
sensor: '#1a1a1a', // #DDCC77 vs #1a1a1a = 12.98:1 ✓
|
||||
observer: '#ffffff' // #AA4499 vs #fff = 5.25:1 ✓
|
||||
},
|
||||
mb: {
|
||||
confirmed: '#117733',
|
||||
suspected: '#DDCC77',
|
||||
unknown: '#CC6677'
|
||||
}
|
||||
,
|
||||
routeRamp: ['#440154', '#3b528b', '#21918c', '#5ec962', '#fde725']
|
||||
},
|
||||
{
|
||||
id: 'achromat',
|
||||
label: 'Achromatopsia (monochrome)',
|
||||
description: 'Pure luminance ramp — relies on shape/letter/glyph carriers from #1356/#1357.',
|
||||
roleColors: {
|
||||
repeater: '#333333', // L=20%
|
||||
companion: '#595959', // L=35%
|
||||
room: '#808080', // L=50%
|
||||
sensor: '#b3b3b3', // L=70%
|
||||
observer: '#e6e6e6' // L=90%
|
||||
},
|
||||
// #1407 — original bug: pill text locked to #1a1a1a → 3 of 5 fail AA.
|
||||
// Fix: white text on the 2 darkest grays, dark text on the 2 lightest,
|
||||
// pure black for L=50 mid-gray (neither #1a1a1a nor #fff clears 4.5
|
||||
// there — black yields 5.32:1).
|
||||
// repeater #333 vs #fff = 12.63:1 ✓
|
||||
// companion #595959 vs #fff = 7.00:1 ✓
|
||||
// room #808080 vs #000 = 5.32:1 ✓ (vs #1a1a1a = 4.41 ✗ / #fff = 3.95 ✗)
|
||||
// sensor #b3b3b3 vs #1a1a1a = 8.30:1 ✓
|
||||
// observer #e6e6e6 vs #1a1a1a = 13.94:1 ✓
|
||||
roleText: {
|
||||
repeater: '#ffffff',
|
||||
companion: '#ffffff',
|
||||
room: '#000000',
|
||||
sensor: '#1a1a1a',
|
||||
observer: '#1a1a1a'
|
||||
},
|
||||
mb: {
|
||||
confirmed: '#b3b3b3',
|
||||
suspected: '#808080',
|
||||
unknown: '#595959'
|
||||
}
|
||||
,
|
||||
routeRamp: ['#222222', '#555555', '#888888', '#bbbbbb', '#eeeeee']
|
||||
}
|
||||
];
|
||||
|
||||
// ── WCAG helpers ────────────────────────────────────────────────────────
|
||||
function _hexToRgb(hex) {
|
||||
if (!hex || hex[0] !== '#' || hex.length !== 7) return null;
|
||||
return {
|
||||
r: parseInt(hex.slice(1, 3), 16),
|
||||
g: parseInt(hex.slice(3, 5), 16),
|
||||
b: parseInt(hex.slice(5, 7), 16)
|
||||
};
|
||||
}
|
||||
function _channelLin(c) {
|
||||
var s = c / 255;
|
||||
return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
|
||||
}
|
||||
function relativeLuminance(hex) {
|
||||
var rgb = _hexToRgb(hex);
|
||||
if (!rgb) return 0;
|
||||
return 0.2126 * _channelLin(rgb.r) + 0.7152 * _channelLin(rgb.g) + 0.0722 * _channelLin(rgb.b);
|
||||
}
|
||||
function contrast(fg, bg) {
|
||||
var L1 = relativeLuminance(fg);
|
||||
var L2 = relativeLuminance(bg);
|
||||
var hi = Math.max(L1, L2);
|
||||
var lo = Math.min(L1, L2);
|
||||
return (hi + 0.05) / (lo + 0.05);
|
||||
}
|
||||
// Canonical map tile backgrounds for validation (Carto Positron / Dark Matter)
|
||||
var TILE_LIGHT = '#f2efe9';
|
||||
var TILE_DARK = '#1a1a1a';
|
||||
|
||||
/**
|
||||
* Validate a preset against WCAG 2.2 SC 1.4.11 (3:1 for non-text UI).
|
||||
* Returns an array of { role, color, vsLight, vsDark, passLight, passDark }.
|
||||
*/
|
||||
function validatePreset(presetId) {
|
||||
var p = PRESETS.filter(function (x) { return x.id === presetId; })[0];
|
||||
if (!p) return [];
|
||||
var out = [];
|
||||
Object.keys(p.roleColors).forEach(function (role) {
|
||||
var c = p.roleColors[role];
|
||||
var vL = contrast(c, TILE_LIGHT);
|
||||
var vD = contrast(c, TILE_DARK);
|
||||
out.push({
|
||||
role: role,
|
||||
color: c,
|
||||
vsLight: vL,
|
||||
vsDark: vD,
|
||||
passLight: vL >= 3.0,
|
||||
passDark: vD >= 3.0
|
||||
});
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── Runtime application ────────────────────────────────────────────────
|
||||
function _byId(id) {
|
||||
for (var i = 0; i < PRESETS.length; i++) if (PRESETS[i].id === id) return PRESETS[i];
|
||||
return null;
|
||||
}
|
||||
|
||||
function applyPreset(id, opts) {
|
||||
opts = opts || {};
|
||||
var p = _byId(id);
|
||||
if (!p) return false;
|
||||
if (typeof document !== 'undefined' && document.body) {
|
||||
document.body.setAttribute(DATA_ATTR, p.id);
|
||||
}
|
||||
if (typeof document !== 'undefined' && document.documentElement) {
|
||||
var style = document.documentElement.style;
|
||||
Object.keys(p.roleColors).forEach(function (role) {
|
||||
style.setProperty('--mc-role-' + role, p.roleColors[role]);
|
||||
});
|
||||
// #1407 — per-role text-color CSS vars so .mc-pill / badges can pick
|
||||
// a foreground that meets WCAG 1.4.3 AA against the role bg.
|
||||
var rt = p.roleText || {};
|
||||
['repeater', 'companion', 'room', 'sensor', 'observer'].forEach(function (role) {
|
||||
style.setProperty('--mc-role-' + role + '-text', rt[role] || '#1a1a1a');
|
||||
});
|
||||
Object.keys(p.mb).forEach(function (k) {
|
||||
style.setProperty('--mc-mb-' + k, p.mb[k]);
|
||||
});
|
||||
// #1418 — route-view sequence ramp (5 stops). route-view.js reads
|
||||
// --mc-rt-ramp-0..4 instead of hardcoded viridis/magma so a CB preset
|
||||
// changes the route edge colors live. Achromat uses a luminance ramp.
|
||||
var rr = p.routeRamp || ['#440154','#3b528b','#21918c','#5ec962','#fde725'];
|
||||
for (var ri = 0; ri < 5; ri++) {
|
||||
style.setProperty('--mc-rt-ramp-' + ri, rr[ri] || rr[rr.length - 1]);
|
||||
}
|
||||
// #1407 — ROLE_COLORS / ROLE_STYLE are now live getters in roles.js
|
||||
// that read --mc-role-* directly, so no explicit sync is needed. The
|
||||
// pre-#1407 code path kept them in sync as a workaround for the static
|
||||
// literal bug; with the getter it's a no-op and removed.
|
||||
}
|
||||
if (!opts.skipPersist) {
|
||||
try { if (typeof localStorage !== 'undefined') localStorage.setItem(STORAGE_KEY, p.id); } catch (e) {}
|
||||
}
|
||||
if (typeof window !== 'undefined' && typeof window.dispatchEvent === 'function' && typeof window.CustomEvent === 'function') {
|
||||
try { window.dispatchEvent(new window.CustomEvent('cb-preset-changed', { detail: { id: p.id } })); } catch (e) {}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function currentPreset() {
|
||||
try {
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
var v = localStorage.getItem(STORAGE_KEY);
|
||||
if (v && _byId(v)) return v;
|
||||
}
|
||||
} catch (e) {}
|
||||
// #1446 — return null when no preset is stored. Previously this returned
|
||||
// 'default' unconditionally, which forced body[data-cb-preset="default"]
|
||||
// on every cold boot and trapped --mc-role-* in the Wong palette via the
|
||||
// matching style.css rule. The CB preset is now an end-user opt-in:
|
||||
// absent attribute = "no preset", role colors flow from server config.
|
||||
return null;
|
||||
}
|
||||
|
||||
function clearPreset() {
|
||||
try { if (typeof localStorage !== 'undefined') localStorage.removeItem(STORAGE_KEY); } catch (e) {}
|
||||
if (typeof document !== 'undefined' && document.body && document.body.removeAttribute) {
|
||||
document.body.removeAttribute(DATA_ATTR);
|
||||
}
|
||||
// Strip preset-written CSS vars from documentElement so the cascade
|
||||
// re-falls through :root defaults (or server config, which the
|
||||
// customizer pipeline re-applies via the cb-preset-changed listener).
|
||||
if (typeof document !== 'undefined' && document.documentElement && document.documentElement.style) {
|
||||
var style = document.documentElement.style;
|
||||
['repeater', 'companion', 'room', 'sensor', 'observer'].forEach(function (role) {
|
||||
style.removeProperty('--mc-role-' + role);
|
||||
style.removeProperty('--mc-role-' + role + '-text');
|
||||
});
|
||||
['confirmed', 'suspected', 'unknown'].forEach(function (k) {
|
||||
style.removeProperty('--mc-mb-' + k);
|
||||
});
|
||||
for (var ri = 0; ri < 5; ri++) style.removeProperty('--mc-rt-ramp-' + ri);
|
||||
}
|
||||
if (typeof window !== 'undefined' && typeof window.dispatchEvent === 'function' && typeof window.CustomEvent === 'function') {
|
||||
try { window.dispatchEvent(new window.CustomEvent('cb-preset-changed', { detail: { id: null } })); } catch (e) {}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function initFromStorage() {
|
||||
var id = currentPreset();
|
||||
// #1446 — only apply when a preset is actually stored. No stored preset
|
||||
// means "no preset active" (the new default), not "fall back to Wong".
|
||||
if (id) applyPreset(id, { skipPersist: true });
|
||||
}
|
||||
|
||||
// Cross-tab sync via storage event.
|
||||
function _onStorage(ev) {
|
||||
if (!ev || ev.key !== STORAGE_KEY) return;
|
||||
var id = ev.newValue;
|
||||
if (!id || !_byId(id)) return;
|
||||
applyPreset(id, { skipPersist: true });
|
||||
}
|
||||
if (typeof window !== 'undefined' && typeof window.addEventListener === 'function') {
|
||||
window.addEventListener('storage', _onStorage);
|
||||
}
|
||||
|
||||
// Auto-init on module load (so reload re-applies the saved preset before
|
||||
// first paint, modulo script ordering — cb-presets.js loads before app.js).
|
||||
try { initFromStorage(); } catch (e) {}
|
||||
|
||||
// Export
|
||||
var api = {
|
||||
list: PRESETS,
|
||||
applyPreset: applyPreset,
|
||||
clearPreset: clearPreset,
|
||||
currentPreset: currentPreset,
|
||||
initFromStorage: initFromStorage,
|
||||
validatePreset: validatePreset,
|
||||
wcag: {
|
||||
relativeLuminance: relativeLuminance,
|
||||
contrast: contrast,
|
||||
TILE_LIGHT: TILE_LIGHT,
|
||||
TILE_DARK: TILE_DARK
|
||||
},
|
||||
STORAGE_KEY: STORAGE_KEY
|
||||
};
|
||||
if (typeof window !== 'undefined') window.MeshCorePresets = api;
|
||||
if (typeof module !== 'undefined') module.exports = api;
|
||||
})();
|
||||
+137
-18
@@ -676,8 +676,6 @@
|
||||
<div id="chRegionFilter" class="region-filter-container ch-header-region"></div>
|
||||
<button type="button" id="chAddChannelBtn" class="ch-add-channel-btn"
|
||||
aria-label="Add channel" title="Add a channel — generate, paste a key, or monitor a hashtag">+ Add</button>
|
||||
<a href="#/analytics" class="ch-analytics-link"
|
||||
title="Open the Analytics page to see channel activity stats" aria-label="Channel Analytics">📊</a>
|
||||
</div>
|
||||
<div id="chAddStatus" class="ch-add-status" style="display:none"></div>
|
||||
<div class="ch-channel-list" id="chList" role="listbox" aria-label="Channels">
|
||||
@@ -767,6 +765,8 @@
|
||||
</div>
|
||||
<div class="ch-main" role="region" aria-label="Channel messages">
|
||||
<div class="ch-main-header" id="chHeader">
|
||||
<button type="button" class="ch-back" data-action="ch-back"
|
||||
aria-label="Back to channel list" title="Back">‹</button>
|
||||
<span class="ch-header-text">Select a channel</span>
|
||||
</div>
|
||||
<div class="ch-messages" id="chMessages">
|
||||
@@ -779,10 +779,11 @@
|
||||
|
||||
RegionFilter.init(document.getElementById('chRegionFilter'));
|
||||
|
||||
// #1034 PR1: encrypted-channels visibility now driven by sectioned sidebar.
|
||||
// Always include encrypted channels in the API call; the renderer groups them.
|
||||
var showEncrypted = true;
|
||||
try { localStorage.setItem('channels-show-encrypted', 'true'); } catch (e) { /* quota */ }
|
||||
// #1409: Do NOT force-enable encrypted-channel visibility on init. The
|
||||
// operator-facing toggle (read at the includeEncrypted gate in
|
||||
// loadChannels) drives whether the API returns the 246+ encrypted
|
||||
// placeholders. Default is OFF (hidden); a future user-facing toggle
|
||||
// writes the flag explicitly.
|
||||
|
||||
regionChangeHandler = RegionFilter.onChange(function () {
|
||||
loadChannels(true).then(async function () {
|
||||
@@ -1078,6 +1079,13 @@
|
||||
if (_pendingNode && _pendingNode.length < 200) await showNodeDetail(_pendingNode);
|
||||
});
|
||||
|
||||
// #1454 — customizer flips the "show encrypted channels" toggle, which
|
||||
// writes localStorage and fires this event. Re-fetch the list live so
|
||||
// the operator sees the change without a page reload.
|
||||
window.addEventListener('mc-channels-show-encrypted-changed', function () {
|
||||
loadChannels(true);
|
||||
});
|
||||
|
||||
// #89: Sidebar resize handle
|
||||
(function () {
|
||||
var sidebar = app.querySelector('.ch-sidebar');
|
||||
@@ -1104,6 +1112,18 @@
|
||||
if (!btn) return;
|
||||
var action = btn.dataset.action;
|
||||
if (action === 'ch-close-node') closeNodeDetail();
|
||||
if (action === 'ch-back') {
|
||||
// Mobile slide-back: return to the channel list view.
|
||||
selectedHash = null;
|
||||
messages = [];
|
||||
history.replaceState(null, '', '#/channels');
|
||||
document.querySelector('.ch-layout')?.classList.remove('ch-detail-open');
|
||||
var headerT = document.querySelector('#chHeader .ch-header-text');
|
||||
if (headerT) headerT.textContent = 'Select a channel';
|
||||
var msgEl = document.getElementById('chMessages');
|
||||
if (msgEl) msgEl.innerHTML = '<div class="ch-empty">Choose a channel from the sidebar to view messages</div>';
|
||||
renderChannelList();
|
||||
}
|
||||
});
|
||||
|
||||
// Event delegation for channel selection (touch-friendly)
|
||||
@@ -1214,7 +1234,7 @@
|
||||
if (ch) ChannelColorPicker.show(ch, e.clientX, e.clientY);
|
||||
return;
|
||||
}
|
||||
const item = e.target.closest('.ch-item[data-hash]');
|
||||
const item = e.target.closest('.ch-item[data-hash], .ch-row[data-hash]');
|
||||
if (item) selectChannel(item.dataset.hash);
|
||||
});
|
||||
|
||||
@@ -1312,7 +1332,13 @@
|
||||
var payload = m.data?.decoded?.payload;
|
||||
if (!payload) continue;
|
||||
|
||||
var channelName = payload.channel || 'unknown';
|
||||
// #1468: drop CHAN messages with no decoded channel name instead of
|
||||
// synthesizing a literal "unknown" row that renders as a fake channel
|
||||
// in the sidebar. Server-side (#1373/#1377) already filters these from
|
||||
// /api/channels; the live WebSocket router was the remaining offender.
|
||||
if (!payload.channel) continue;
|
||||
|
||||
var channelName = payload.channel;
|
||||
// For live-decrypted user-added (PSK) channels, decryptLivePSKBatch
|
||||
// also stamps payload.channelKey ("user:<name>") so we route the
|
||||
// message to the correct sidebar row and to the open chat view.
|
||||
@@ -1502,14 +1528,27 @@
|
||||
window._channelsHandleWSBatchForTest = handleWSBatch;
|
||||
window._channelsProcessWSBatchForTest = processWSBatch;
|
||||
|
||||
// #1367: Re-render the channel list when the viewport crosses the
|
||||
// mobile/desktop boundary so the layout swaps between flat .ch-row
|
||||
// and sectioned .ch-item without a navigation.
|
||||
var _chMobileMQ = null;
|
||||
try { _chMobileMQ = window.matchMedia('(max-width: 767px)'); } catch (e) { /* noop */ }
|
||||
if (_chMobileMQ && typeof _chMobileMQ.addEventListener === 'function') {
|
||||
_chMobileMQ.addEventListener('change', function () { renderChannelList(); });
|
||||
}
|
||||
|
||||
// Tick relative timestamps every 1s — iterates channels array, updates DOM text only
|
||||
timeAgoTimer = setInterval(function () {
|
||||
var now = Date.now();
|
||||
for (var i = 0; i < channels.length; i++) {
|
||||
var ch = channels[i];
|
||||
if (!ch.lastActivityMs) continue;
|
||||
var text = formatSecondsAgo(Math.floor((now - ch.lastActivityMs) / 1000));
|
||||
var el = document.querySelector('.ch-item-time[data-channel-hash="' + ch.hash + '"]');
|
||||
if (el) el.textContent = formatSecondsAgo(Math.floor((now - ch.lastActivityMs) / 1000));
|
||||
if (el) el.textContent = text;
|
||||
// #1367: mobile rows live in a flat list; update those too.
|
||||
var rowEl = document.querySelector('.ch-row[data-hash="' + ch.hash + '"] .ch-row-time');
|
||||
if (rowEl) rowEl.textContent = text;
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
@@ -1653,12 +1692,73 @@
|
||||
</button>`;
|
||||
}
|
||||
|
||||
// #1367: mobile chat-app row renderer. Full-width 80px rows with a
|
||||
// hash-colored avatar, bold name, ellipsized last-message preview,
|
||||
// and right-aligned relative timestamp. No inline action chips.
|
||||
function isMobileChannels() {
|
||||
try { return window.matchMedia('(max-width: 767px)').matches; } catch (e) { return false; }
|
||||
}
|
||||
|
||||
function avatarTextForChannel(ch) {
|
||||
const name = ch && ch.name ? String(ch.name) : '';
|
||||
if (name.charAt(0) === '#') return name.slice(0, 3); // "#wa"
|
||||
if (ch && ch.encrypted && !ch.userAdded) return '🔒';
|
||||
if (ch && ch.userAdded) return '🔑';
|
||||
// Fallback: 2-char uppercase abbreviation.
|
||||
return name.replace(/[^A-Za-z0-9]/g, '').slice(0, 2).toUpperCase() ||
|
||||
String(ch && ch.hash || '?').slice(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
function renderChannelRowMobile(ch) {
|
||||
const isEncrypted = ch.encrypted === true;
|
||||
const isUserAdded = ch.userAdded === true;
|
||||
const encryptedFallback = isEncrypted ? 'Unknown' : '';
|
||||
const name = channelDisplayName(ch, encryptedFallback);
|
||||
const color = (isEncrypted && !isUserAdded)
|
||||
? 'var(--text-muted, #6b7280)'
|
||||
: getChannelColor(ch.hash);
|
||||
const time = ch.lastActivityMs
|
||||
? formatSecondsAgo(Math.floor((Date.now() - ch.lastActivityMs) / 1000))
|
||||
: '';
|
||||
let preview = '';
|
||||
if (ch.lastSender && ch.lastMessage) {
|
||||
preview = ch.lastSender + ': ' + ch.lastMessage;
|
||||
} else if (isEncrypted && !isUserAdded) {
|
||||
preview = '0x' + formatHashHex(ch.hash);
|
||||
} else if (typeof ch.messageCount === 'number' && ch.messageCount > 0) {
|
||||
preview = ch.messageCount + ' messages';
|
||||
}
|
||||
const abbr = avatarTextForChannel(ch);
|
||||
const sel = selectedHash === ch.hash ? ' selected' : '';
|
||||
return '<button type="button" class="ch-row' + sel + '" data-hash="' + escapeHtml(ch.hash) +
|
||||
'" role="option" aria-selected="' + (selectedHash === ch.hash ? 'true' : 'false') +
|
||||
'" aria-label="' + escapeHtml(name) + '">' +
|
||||
'<div class="ch-avatar ch-row-avatar" style="background:' + color +
|
||||
'" aria-hidden="true">' + escapeHtml(abbr) + '</div>' +
|
||||
'<div class="ch-row-body">' +
|
||||
'<div class="ch-row-line1">' +
|
||||
'<span class="ch-row-name">' + escapeHtml(name) + '</span>' +
|
||||
'<span class="ch-row-time">' + escapeHtml(time) + '</span>' +
|
||||
'</div>' +
|
||||
'<div class="ch-row-preview">' + escapeHtml(preview) + '</div>' +
|
||||
'</div>' +
|
||||
'</button>';
|
||||
}
|
||||
|
||||
// #1034 PR1: sectioned sidebar — My Channels / Network / Encrypted (N).
|
||||
function renderChannelList() {
|
||||
const el = document.getElementById('chList');
|
||||
if (!el) return;
|
||||
if (channels.length === 0) { el.innerHTML = '<div class="ch-empty">No channels found</div>'; return; }
|
||||
|
||||
// #1367: mobile gets a flat chat-app list (no sections, no inline actions).
|
||||
if (isMobileChannels()) {
|
||||
const sortByActivity = (a, b) => (b.lastActivityMs || 0) - (a.lastActivityMs || 0);
|
||||
const sorted = channels.slice().sort(sortByActivity);
|
||||
el.innerHTML = sorted.map(renderChannelRowMobile).join('');
|
||||
return;
|
||||
}
|
||||
|
||||
const sortByActivity = (a, b) => (b.lastActivityMs || 0) - (a.lastActivityMs || 0);
|
||||
const sortByCount = (a, b) => (b.messageCount || 0) - (a.messageCount || 0);
|
||||
|
||||
@@ -1717,6 +1817,9 @@
|
||||
var __selCh = channels.find(function (c) { return c.hash === hash; });
|
||||
if (__selCh && __selCh.unread) { __selCh.unread = 0; }
|
||||
history.replaceState(null, '', `#/channels/${encodeURIComponent(hash)}`);
|
||||
// #1367: mobile slide-in — flip the layout into detail mode so CSS
|
||||
// can swap the visible pane. Desktop is a no-op (rule matches mobile).
|
||||
document.querySelector('.ch-layout')?.classList.add('ch-detail-open');
|
||||
renderChannelList();
|
||||
const ch = channels.find(c => c.hash === hash);
|
||||
// #1041: never show raw "psk:<hex>" prefixes in the header — use the
|
||||
@@ -1896,11 +1999,24 @@
|
||||
const senderColor = getSenderColor(sender);
|
||||
const senderLetter = sender.replace(/[^\w]/g, '').charAt(0).toUpperCase() || '?';
|
||||
|
||||
let displayText;
|
||||
displayText = highlightMentions(msg.text || '');
|
||||
let rawBody = msg.text || '';
|
||||
// Detect a leading @TARGET reply prefix and split it out so we can
|
||||
// style it in the sender color (#1367 detail-view spec).
|
||||
let replyTarget = '';
|
||||
const replyMatch = rawBody.match(/^@([A-Za-z0-9_\-]{1,32})\s+/);
|
||||
if (replyMatch) {
|
||||
replyTarget = replyMatch[1];
|
||||
rawBody = rawBody.slice(replyMatch[0].length);
|
||||
}
|
||||
let displayText = highlightMentions(rawBody);
|
||||
if (replyTarget) {
|
||||
displayText = '<span class="ch-reply-target" style="color:' + senderColor + '">@' +
|
||||
escapeHtml(replyTarget) + '</span> ' + displayText;
|
||||
}
|
||||
|
||||
const time = msg.timestamp ? new Date(msg.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : '';
|
||||
const date = msg.timestamp ? new Date(msg.timestamp).toLocaleDateString() : '';
|
||||
const tsDate = msg.timestamp ? new Date(msg.timestamp) : null;
|
||||
const time = tsDate ? tsDate.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : '';
|
||||
const date = tsDate ? tsDate.toLocaleDateString() : '';
|
||||
|
||||
const meta = [];
|
||||
meta.push(date + ' ' + time);
|
||||
@@ -1910,12 +2026,15 @@
|
||||
if (msg.snr !== null && msg.snr !== undefined) meta.push(`SNR ${msg.snr}`);
|
||||
|
||||
const safeId = btoa(encodeURIComponent(sender));
|
||||
return `<div class="ch-msg">
|
||||
// #1367: emit BOTH the new chat-app class names (.ch-message /
|
||||
// .ch-message-bubble / .ch-message-meta) and the legacy .ch-msg*
|
||||
// names so existing tests/themes don't regress.
|
||||
return `<div class="ch-msg ch-message">
|
||||
<div class="ch-avatar ch-tappable" style="background:${senderColor}" tabindex="0" role="button" data-node="${safeId}">${senderLetter}</div>
|
||||
<div class="ch-msg-content">
|
||||
<div class="ch-msg-sender ch-sender-link ch-tappable" style="color:${senderColor}" tabindex="0" role="button" data-node="${safeId}">${escapeHtml(sender)}</div>
|
||||
<div class="ch-msg-bubble">${displayText}</div>
|
||||
<div class="ch-msg-meta">${meta.join(' · ')}${msg.packetHash ? ` · <a href="#/packets/${msg.packetHash}" class="ch-analyze-link">View packet →</a>` : ''}</div>
|
||||
<div class="ch-msg-content ch-message-content">
|
||||
<div class="ch-msg-sender ch-message-sender ch-sender-link ch-tappable" style="color:${senderColor}" tabindex="0" role="button" data-node="${safeId}">${escapeHtml(sender)}</div>
|
||||
<div class="ch-msg-bubble ch-message-bubble">${displayText}</div>
|
||||
<div class="ch-msg-meta ch-message-meta">${meta.join(' · ')}${msg.packetHash ? ` · <a href="#/packets/${msg.packetHash}" class="ch-analyze-link">View packet →</a>` : ''}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
+212
-9
@@ -74,8 +74,10 @@
|
||||
img.className = 'brand-logo';
|
||||
img.setAttribute('src', url);
|
||||
img.setAttribute('alt', alt || node.getAttribute('aria-label') || 'Brand');
|
||||
img.setAttribute('width', '125');
|
||||
img.setAttribute('height', '36');
|
||||
// #1450 — DO NOT set width/height attrs. CSS img.brand-logo handles
|
||||
// sizing (height:36px, width:auto, max-width cap) so the operator's
|
||||
// natural image aspect ratio is preserved instead of being squished
|
||||
// into the default SVG's 125x36 pill box.
|
||||
node.parentNode.replaceChild(img, node);
|
||||
} else {
|
||||
if (node.tagName.toLowerCase() !== 'img') {
|
||||
@@ -546,13 +548,63 @@
|
||||
if (themeSection.background) root.setProperty('--content-bg', themeSection.contentBg || themeSection.background);
|
||||
if (themeSection.surface1) root.setProperty('--card-bg', themeSection.cardBg || themeSection.surface1);
|
||||
|
||||
// Node colors → CSS vars + global objects
|
||||
// Node colors → --node-X CSS var only (legacy compat).
|
||||
// #1412: do NOT push server-config nodeColors into window.ROLE_COLORS —
|
||||
// that defeats cb-presets propagation by trapping the legacy palette in
|
||||
// the _roleOverrides map (where the live getter prefers it over the
|
||||
// --mc-role-X CSS vars that presets actually write). User-chosen
|
||||
// overrides still flow through setRoleColorOverride() in customize.js.
|
||||
var nc = effectiveConfig.nodeColors;
|
||||
if (nc) {
|
||||
// #1438 final: scope --mc-role-{role} writes to USER overrides only,
|
||||
// UNLESS no CB preset is active (#1446). When a preset is active the
|
||||
// server-config palette must stay out of --mc-role-* so the preset
|
||||
// wins (preserves #1412). When NO preset is active, the cascade is:
|
||||
// user override > server config > built-in :root default.
|
||||
// → server config gets to write --mc-role-{role} in that case.
|
||||
var userNc = (userOverrides && userOverrides.nodeColors) || {};
|
||||
var presetActive = false;
|
||||
try {
|
||||
var presetAttr = document.body && document.body.getAttribute && document.body.getAttribute('data-cb-preset');
|
||||
presetActive = !!(presetAttr && presetAttr !== 'none');
|
||||
} catch (e) {}
|
||||
for (var role in nc) {
|
||||
root.setProperty('--node-' + role, nc[role]);
|
||||
if (window.ROLE_COLORS && role in window.ROLE_COLORS) window.ROLE_COLORS[role] = nc[role];
|
||||
if (window.ROLE_STYLE && window.ROLE_STYLE[role]) window.ROLE_STYLE[role].color = nc[role];
|
||||
if (Object.prototype.hasOwnProperty.call(userNc, role)) {
|
||||
// Operator picked this color → drive --mc-role-{role} so marker
|
||||
// SVGs (fill="var(--mc-role-X)") and other CSS-var consumers
|
||||
// pick it up on every page load. Without this the user pick
|
||||
// sits in localStorage but --mc-role-{role} falls back to the
|
||||
// active preset on reload, reverting marker fills.
|
||||
root.setProperty('--mc-role-' + role, nc[role]);
|
||||
// #1446 — also write to body.style with !important so the user
|
||||
// pick beats the body[data-cb-preset="X"] selector cascade when
|
||||
// a CB preset is active. Without this, the root-level write is
|
||||
// shadowed by the preset's body-scoped CSS rule (root cause of
|
||||
// #1444). When no preset is active, the body write is harmless
|
||||
// and still wins inheritance.
|
||||
if (presetActive && document.body && document.body.style) {
|
||||
document.body.style.setProperty('--mc-role-' + role, nc[role], 'important');
|
||||
}
|
||||
} else if (!presetActive) {
|
||||
// #1446 — no preset is active; server config is the legitimate
|
||||
// source of role colors. Write --mc-role-{role} so marker SVGs
|
||||
// honor operator's config.json without forcing visitors to pick
|
||||
// a CB preset to "unlock" their server palette.
|
||||
root.setProperty('--mc-role-' + role, nc[role]);
|
||||
} else if (presetActive && document.body && document.body.style) {
|
||||
// Preset active AND this role has no user override:
|
||||
// ensure any prior body inline !important is removed so the
|
||||
// preset value (from body[data-cb-preset=X] CSS rule) takes over.
|
||||
// Also remove the root-level --mc-role-{role} that a PREVIOUS
|
||||
// setOverride call left behind (#1446 followup): without this,
|
||||
// :root.style.--mc-role-{role} stays stuck at the old user-pick
|
||||
// value even though body's cascaded preset rule now wins for
|
||||
// descendant elements. The visible UI is correct but introspection
|
||||
// (getComputedStyle on documentElement) reports stale color.
|
||||
document.body.style.removeProperty('--mc-role-' + role);
|
||||
root.removeProperty('--mc-role-' + role);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1123,6 +1175,57 @@
|
||||
'</div>';
|
||||
}
|
||||
|
||||
// ── #1361 Colorblind preset selector ──
|
||||
// MVP scope: radio selector + 1-line description + WCAG warning badge.
|
||||
// Stretch (live Brettel/Vienot simulation overlay, "Reset to default Wong"
|
||||
// button) intentionally deferred to a follow-up issue.
|
||||
function _renderColorblindPresetSelector() {
|
||||
var MCP = (typeof window !== 'undefined') && window.MeshCorePresets;
|
||||
if (!MCP || !Array.isArray(MCP.list)) return '';
|
||||
// #1446 — currentPreset() now returns null when no preset is stored.
|
||||
var current = MCP.currentPreset ? MCP.currentPreset() : null;
|
||||
var clearOpt = _renderCbPresetClearOption(current);
|
||||
var options = MCP.list.map(function (p) {
|
||||
var checked = p.id === current ? ' checked' : '';
|
||||
return '<label class="cust-cb-preset-row" style="display:flex;gap:8px;align-items:flex-start;margin:6px 0;cursor:pointer">' +
|
||||
'<input type="radio" name="cv2-cb-preset" data-cv2-cb-preset value="' + escAttr(p.id) + '"' + checked + ' style="margin-top:3px">' +
|
||||
'<div style="flex:1">' +
|
||||
'<div style="font-weight:600">' + esc(p.label) + '</div>' +
|
||||
'<div class="cust-hint" style="font-size:12px;color:var(--text-muted)">' + esc(p.description) + '</div>' +
|
||||
_renderCbPresetWarning(p.id) +
|
||||
'</div>' +
|
||||
'</label>';
|
||||
}).join('');
|
||||
return '<p class="cust-section-title">Optional: Colorblind-Safe Preset</p>' +
|
||||
'<p class="cust-hint" style="margin-bottom:8px">A CB preset is an end-user opt-in that swaps the role/status palette for color-vision variants. ' +
|
||||
'Leave unset to use the operator\'s configured colors (or pick from above). ' +
|
||||
'Achromatopsia uses a luminance-only ramp and relies on the shape/letter/glyph carriers from #1356/#1357.</p>' +
|
||||
'<div class="cust-cb-presets" data-cv2-cb-preset-group>' + clearOpt + options + '</div>' +
|
||||
'<hr style="border:none;border-top:1px solid var(--border);margin:16px 0">';
|
||||
}
|
||||
|
||||
function _renderCbPresetClearOption(current) {
|
||||
var checked = !current ? ' checked' : '';
|
||||
return '<label class="cust-cb-preset-row" style="display:flex;gap:8px;align-items:flex-start;margin:6px 0;cursor:pointer">' +
|
||||
'<input type="radio" name="cv2-cb-preset" data-cv2-cb-preset value="" data-cv2-cb-preset-none' + checked + ' style="margin-top:3px">' +
|
||||
'<div style="flex:1">' +
|
||||
'<div style="font-weight:600">No preset (use operator / custom colors)</div>' +
|
||||
'<div class="cust-hint" style="font-size:12px;color:var(--text-muted)">Default — server-configured colors apply, then any per-role overrides above.</div>' +
|
||||
'</div>' +
|
||||
'</label>';
|
||||
}
|
||||
|
||||
function _renderCbPresetWarning(id) {
|
||||
var MCP = window.MeshCorePresets;
|
||||
if (!MCP || typeof MCP.validatePreset !== 'function') return '';
|
||||
var rep = MCP.validatePreset(id);
|
||||
var dark = document.documentElement.getAttribute('data-theme') === 'dark';
|
||||
var failing = rep.filter(function (r) { return dark ? !r.passDark : !r.passLight; });
|
||||
if (!failing.length) return '';
|
||||
var names = failing.map(function (r) { return r.role; }).join(', ');
|
||||
return '<div class="cust-cb-warn" style="margin-top:4px;font-size:11px;color:var(--status-yellow);background:rgba(255,200,0,0.08);padding:4px 6px;border-radius:4px">⚠ WCAG 1.4.11: ' + esc(names) + ' below 3:1 vs ' + (dark ? 'dark' : 'light') + ' tiles</div>';
|
||||
}
|
||||
|
||||
function _renderNodes() {
|
||||
var eff = _getEffective();
|
||||
var server = _getServer();
|
||||
@@ -1160,8 +1263,11 @@
|
||||
var liveHeatPct = Math.round(liveHeatOpacity * 100);
|
||||
|
||||
return '<div class="cust-panel' + (_activeTab === 'nodes' ? ' active' : '') + '" data-panel="nodes">' +
|
||||
'<p class="cust-section-title">Node Role Colors</p>' + rows +
|
||||
'<p class="cust-section-title">Node Role Colors</p>' +
|
||||
'<p class="cust-hint" style="margin-bottom:8px">These are the canonical role colors used across the app. They inherit from your server config (or built-in defaults), and can be optionally remapped by a colorblind-safe preset below.</p>' +
|
||||
rows +
|
||||
'<hr style="border:none;border-top:1px solid var(--border);margin:16px 0">' +
|
||||
_renderColorblindPresetSelector() +
|
||||
'<p class="cust-section-title">Packet Type Colors</p>' + typeRows +
|
||||
'<hr style="border:none;border-top:1px solid var(--border);margin:16px 0">' +
|
||||
'<p class="cust-section-title">Heatmap Opacity</p>' +
|
||||
@@ -1216,9 +1322,50 @@
|
||||
'<p class="cust-section-title" style="font-size:14px;margin:16px 0 8px">Gesture Hints</p>' +
|
||||
'<p style="font-size:12px;color:var(--text-muted);margin-bottom:8px">Re-show first-visit gesture discoverability hints (swipe rows, swipe tabs, edge-swipe drawer, pull-to-refresh).</p>' +
|
||||
'<button type="button" class="cust-dl-btn" data-cv2-reset-hints data-reset-gesture-hints>↺ Reset gesture hints</button>' +
|
||||
_renderChannelsShowEncryptedToggle() +
|
||||
_renderDarkTileProviderSelector() +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
// ── #1454 Show-encrypted-channels toggle ──
|
||||
// Writes localStorage["channels-show-encrypted"]. Default OFF: key is
|
||||
// removed (not set to "false") so the read-gate in channels.js cleanly
|
||||
// returns false. Fires `mc-channels-show-encrypted-changed`; channels.js
|
||||
// re-fetches the list live without a page reload.
|
||||
function _renderChannelsShowEncryptedToggle() {
|
||||
var on = false;
|
||||
try { on = localStorage.getItem('channels-show-encrypted') === 'true'; } catch (_e) {}
|
||||
return '<p class="cust-section-title" style="font-size:14px;margin:16px 0 8px">Channels</p>' +
|
||||
'<p class="cust-hint" style="font-size:12px;color:var(--text-muted);margin-bottom:8px">Encrypted channels appear as "Encrypted (0xAB)" with no name. Operators usually leave this off.</p>' +
|
||||
'<div class="cust-field" style="display:flex;align-items:center;gap:8px">' +
|
||||
'<input type="checkbox" id="cv2-channels-show-encrypted" data-cv2-channels-show-encrypted' +
|
||||
(on ? ' checked' : '') +
|
||||
' style="width:16px;height:16px;cursor:pointer">' +
|
||||
'<label for="cv2-channels-show-encrypted" style="cursor:pointer;margin:0">Show encrypted channels</label>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
// ── #1420 Dark-tile provider selector ──
|
||||
// Persists per-browser via MC_setDarkTileProvider; map.js / live.js
|
||||
// listen for `mc-tile-provider-changed` and swap tiles live.
|
||||
function _renderDarkTileProviderSelector() {
|
||||
var reg = (typeof window !== 'undefined') && window.MC_TILE_PROVIDERS;
|
||||
if (!reg) return '';
|
||||
var active = (typeof window.MC_getDarkTileProvider === 'function') ? window.MC_getDarkTileProvider() : 'carto-dark';
|
||||
var ids = ['carto-dark', 'esri-darkgray-labels', 'voyager-inverted', 'positron-inverted'];
|
||||
var options = ids.filter(function (id) { return reg[id]; }).map(function (id) {
|
||||
var label = reg[id].label || id;
|
||||
var sel = id === active ? ' selected' : '';
|
||||
return '<option value="' + escAttr(id) + '"' + sel + '>' + esc(label) + '</option>';
|
||||
}).join('');
|
||||
return '<p class="cust-section-title" style="font-size:14px;margin:16px 0 8px">Dark Map Tiles</p>' +
|
||||
'<p class="cust-hint" style="font-size:12px;color:var(--text-muted);margin-bottom:8px">Choose the dark-mode basemap. Light mode is unaffected. Inverted variants apply a CSS filter for higher contrast.</p>' +
|
||||
'<div class="cust-field"><label for="cv2-dark-tile-provider">Provider</label>' +
|
||||
'<select id="cv2-dark-tile-provider" data-cv2-dark-tile-provider style="width:100%;padding:6px 8px;border:1px solid var(--border);border-radius:6px;background:var(--input-bg);color:var(--text)">' +
|
||||
options +
|
||||
'</select></div>';
|
||||
}
|
||||
|
||||
function _renderHome() {
|
||||
var eff = _getEffective();
|
||||
var h = eff.home || {};
|
||||
@@ -1756,6 +1903,50 @@
|
||||
// GeoFilter tab init
|
||||
if (_activeTab === 'geofilter') _initGeoFilterTab(container);
|
||||
|
||||
// #1361 Colorblind preset radio — switches preset via MeshCorePresets.applyPreset
|
||||
// #1446 — empty-value radio = "no preset" → clearPreset(), then re-run
|
||||
// the customizer pipeline so server-config colors take over.
|
||||
container.querySelectorAll('[data-cv2-cb-preset]').forEach(function (radio) {
|
||||
radio.addEventListener('change', function () {
|
||||
if (!radio.checked) return;
|
||||
var id = radio.value;
|
||||
var MCP = window.MeshCorePresets;
|
||||
if (!MCP) return;
|
||||
if (!id) {
|
||||
if (typeof MCP.clearPreset === 'function') MCP.clearPreset();
|
||||
_runPipeline();
|
||||
} else if (typeof MCP.applyPreset === 'function') {
|
||||
MCP.applyPreset(id);
|
||||
}
|
||||
_refreshPanel();
|
||||
});
|
||||
});
|
||||
|
||||
// #1420 Dark-tile provider dropdown — persists + fires mc-tile-provider-changed
|
||||
container.querySelectorAll('[data-cv2-dark-tile-provider]').forEach(function (sel) {
|
||||
sel.addEventListener('change', function () {
|
||||
var id = sel.value;
|
||||
if (typeof window.MC_setDarkTileProvider === 'function') {
|
||||
window.MC_setDarkTileProvider(id);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// #1454 Show-encrypted-channels checkbox — persists + fires
|
||||
// mc-channels-show-encrypted-changed; channels.js re-fetches live.
|
||||
container.querySelectorAll('[data-cv2-channels-show-encrypted]').forEach(function (cb) {
|
||||
cb.addEventListener('change', function () {
|
||||
var on = !!cb.checked;
|
||||
try {
|
||||
if (on) localStorage.setItem('channels-show-encrypted', 'true');
|
||||
else localStorage.removeItem('channels-show-encrypted');
|
||||
} catch (_e) { /* private mode etc. */ }
|
||||
window.dispatchEvent(new CustomEvent('mc-channels-show-encrypted-changed', {
|
||||
detail: { value: on }
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
// Preset buttons
|
||||
container.querySelectorAll('.cust-preset-btn').forEach(function (btn) {
|
||||
btn.addEventListener('click', function () {
|
||||
@@ -2068,6 +2259,18 @@
|
||||
// 1. Migration check
|
||||
migrateOldKeys();
|
||||
|
||||
// #1446 — when a CB preset is cleared (or applied), re-run the customizer
|
||||
// pipeline so server-config nodeColors take over the --mc-role-{role}
|
||||
// CSS vars (the gating logic in applyCSS checks the body[data-cb-preset]
|
||||
// attribute to decide whether to write them).
|
||||
try {
|
||||
if (typeof window !== 'undefined' && typeof window.addEventListener === 'function') {
|
||||
window.addEventListener('cb-preset-changed', function () {
|
||||
if (_initDone) _runPipeline();
|
||||
});
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
// 2. Read overrides and apply CSS immediately (before DOMContentLoaded)
|
||||
// Server defaults will be set later when /api/config/theme completes.
|
||||
// For now, apply whatever overrides exist on top of current SITE_CONFIG.
|
||||
@@ -2093,11 +2296,11 @@
|
||||
if (ovTheme.accentHover) root.setProperty('--logo-accent-hi', ovTheme.accentHover);
|
||||
if (themeSection.background) root.setProperty('--content-bg', themeSection.contentBg || themeSection.background);
|
||||
if (themeSection.surface1) root.setProperty('--card-bg', themeSection.cardBg || themeSection.surface1);
|
||||
// Apply node/type colors from overrides early
|
||||
// Apply node colors from overrides early — --node-X CSS var only.
|
||||
// #1412: do NOT write to window.ROLE_COLORS / ROLE_STYLE here.
|
||||
if (earlyOverrides.nodeColors) {
|
||||
for (var role in earlyOverrides.nodeColors) {
|
||||
if (window.ROLE_COLORS && role in window.ROLE_COLORS) window.ROLE_COLORS[role] = earlyOverrides.nodeColors[role];
|
||||
if (window.ROLE_STYLE && window.ROLE_STYLE[role]) window.ROLE_STYLE[role].color = earlyOverrides.nodeColors[role];
|
||||
root.setProperty('--node-' + role, earlyOverrides.nodeColors[role]);
|
||||
}
|
||||
}
|
||||
if (earlyOverrides.typeColors && window.TYPE_COLORS) {
|
||||
|
||||
+11
-3
@@ -1145,8 +1145,13 @@
|
||||
inp.addEventListener('input', function () {
|
||||
var key = inp.dataset.node;
|
||||
state.nodeColors[key] = inp.value;
|
||||
// Sync to global role colors used by map/packets/etc
|
||||
if (window.ROLE_COLORS) window.ROLE_COLORS[key] = inp.value;
|
||||
// #1412: route per-key user picks through setRoleColorOverride so
|
||||
// the explicit override map is the only place mutation happens.
|
||||
// (Direct subscript assignment would also work via the roles.js
|
||||
// proxy, but the explicit API is the documented contract.)
|
||||
if (typeof window.setRoleColorOverride === 'function') {
|
||||
window.setRoleColorOverride(key, inp.value);
|
||||
}
|
||||
if (window.ROLE_STYLE && window.ROLE_STYLE[key]) window.ROLE_STYLE[key].color = inp.value;
|
||||
// Trigger re-render of current page
|
||||
window.dispatchEvent(new CustomEvent('theme-changed')); autoSave();
|
||||
@@ -1162,7 +1167,10 @@
|
||||
btn.addEventListener('click', function () {
|
||||
var key = btn.dataset.resetNode;
|
||||
state.nodeColors[key] = DEFAULTS.nodeColors[key];
|
||||
if (window.ROLE_COLORS) window.ROLE_COLORS[key] = DEFAULTS.nodeColors[key];
|
||||
// #1412: clearing the override lets cb-preset CSS var win again.
|
||||
if (typeof window.setRoleColorOverride === 'function') {
|
||||
window.setRoleColorOverride(key, DEFAULTS.nodeColors[key]);
|
||||
}
|
||||
if (window.ROLE_STYLE && window.ROLE_STYLE[key]) window.ROLE_STYLE[key].color = DEFAULTS.nodeColors[key];
|
||||
render(container);
|
||||
});
|
||||
|
||||
@@ -31,11 +31,24 @@
|
||||
var h = location.hash || '';
|
||||
return /^#\/live(\/|$|\?)/.test(h);
|
||||
}
|
||||
// #1065 follow-up: hints must only appear on touch-capable viewports.
|
||||
// Mouse-only desktops (e.g. analyzer.00id.net opened in Chrome on a
|
||||
// workstation) were getting "swipe a row left" tips that make no sense.
|
||||
// Three independent probes — any positive answer counts.
|
||||
function hasTouchCapability() {
|
||||
try {
|
||||
if ('ontouchstart' in window) return true;
|
||||
if (navigator.maxTouchPoints && navigator.maxTouchPoints > 0) return true;
|
||||
if (window.matchMedia && window.matchMedia('(pointer: coarse)').matches) return true;
|
||||
} catch (_e) {}
|
||||
return false;
|
||||
}
|
||||
var HINTS = {
|
||||
'row-swipe': {
|
||||
key: NS + 'row-swipe',
|
||||
text: 'Tip: swipe a row left for quick actions.',
|
||||
relevant: function () {
|
||||
if (!hasTouchCapability()) return false;
|
||||
if (onLiveRoute()) return false; // #1244
|
||||
var h = location.hash || '';
|
||||
return /^#\/(packets|nodes)/.test(h);
|
||||
@@ -46,6 +59,7 @@
|
||||
key: NS + 'tab-swipe',
|
||||
text: 'Tip: swipe left or right to switch tabs.',
|
||||
relevant: function () {
|
||||
if (!hasTouchCapability()) return false;
|
||||
if (onLiveRoute()) return false; // #1244
|
||||
return !!document.querySelector('[data-bottom-nav]');
|
||||
},
|
||||
@@ -55,7 +69,10 @@
|
||||
key: NS + 'edge-drawer',
|
||||
text: 'Tip: swipe in from the left edge to open navigation.',
|
||||
relevant: function () {
|
||||
if (!hasTouchCapability()) return false;
|
||||
if (onLiveRoute()) return false; // #1244
|
||||
// nav-drawer.js: NARROW_MAX=768; edge-swipe drawer is the WIDE
|
||||
// (>768) layout's nav UI. Below 768, the bottom-nav owns navigation.
|
||||
return window.innerWidth > 768 && !!document.querySelector('.nav-drawer, [data-nav-drawer]');
|
||||
},
|
||||
position: 'top-left',
|
||||
@@ -64,6 +81,7 @@
|
||||
key: NS + 'pull-refresh',
|
||||
text: 'Tip: pull down to refresh the connection.',
|
||||
relevant: function () {
|
||||
if (!hasTouchCapability()) return false;
|
||||
if (onLiveRoute()) return false; // #1244
|
||||
return !!document.querySelector('.pull-to-reconnect');
|
||||
},
|
||||
|
||||
+28
-1
@@ -22,7 +22,21 @@
|
||||
<meta name="twitter:title" content="CoreScope">
|
||||
<meta name="twitter:description" content="Real-time MeshCore LoRa mesh network analyzer — live packet visualization, node tracking, channel decryption, and route analysis.">
|
||||
<meta name="twitter:image" content="https://raw.githubusercontent.com/Kpa-clawbot/corescope/master/public/og-image.png">
|
||||
<!-- PR #893 follow-up: apply persisted theme before stylesheet/paint to prevent
|
||||
FOUC (light flash for users who chose dark). Matches keys used by app.js. -->
|
||||
<script>
|
||||
(function () {
|
||||
try {
|
||||
var saved = localStorage.getItem('meshcore-theme');
|
||||
var t = saved === 'dark' || saved === 'light'
|
||||
? saved
|
||||
: (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
|
||||
document.documentElement.setAttribute('data-theme', t);
|
||||
} catch (_) { /* localStorage may be blocked; CSS handles default */ }
|
||||
})();
|
||||
</script>
|
||||
<link rel="stylesheet" href="style.css?v=__BUST__">
|
||||
<link rel="stylesheet" href="route-view.css?v=__BUST__">
|
||||
<link rel="stylesheet" href="home.css?v=__BUST__">
|
||||
<link rel="stylesheet" href="live.css?v=__BUST__">
|
||||
<link rel="stylesheet" href="bottom-nav.css?v=__BUST__">
|
||||
@@ -85,7 +99,14 @@
|
||||
</div>
|
||||
<button class="nav-btn" id="searchToggle" title="Search (Ctrl+K)">🔍</button>
|
||||
<button class="nav-btn" id="customizeToggle" title="Customize theme & branding">🎨</button>
|
||||
<button class="nav-btn" id="darkModeToggle" title="Toggle dark mode">☀️</button>
|
||||
<label class="theme-toggle" id="darkModeToggle" title="Toggle dark mode">
|
||||
<input type="checkbox" id="darkModeCheckbox" role="switch" aria-label="Toggle dark mode">
|
||||
<span class="theme-toggle-track" aria-hidden="true">
|
||||
<span class="theme-toggle-thumb"></span>
|
||||
<span class="theme-toggle-icon theme-toggle-sun">☀️</span>
|
||||
<span class="theme-toggle-icon theme-toggle-moon">🌙</span>
|
||||
</span>
|
||||
</label>
|
||||
<button class="nav-btn hamburger" id="hamburger" title="Menu" aria-label="Toggle navigation menu">☰</button>
|
||||
</div>
|
||||
</nav>
|
||||
@@ -102,6 +123,8 @@
|
||||
|
||||
<script src="vendor/qrcode.js"></script>
|
||||
<script src="roles.js?v=__BUST__"></script>
|
||||
<script src="map-tile-providers.js?v=__BUST__"></script>
|
||||
<script src="cb-presets.js?v=__BUST__"></script>
|
||||
<script src="customize-v2.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="region-filter.js?v=__BUST__"></script>
|
||||
<script src="area-filter.js?v=__BUST__"></script>
|
||||
@@ -118,6 +141,7 @@
|
||||
<script src="packet-filter.js?v=__BUST__"></script>
|
||||
<script src="filter-ux.js?v=__BUST__"></script>
|
||||
<script src="hash-color.js?v=__BUST__"></script>
|
||||
<script src="prefix-reserved.js?v=__BUST__"></script>
|
||||
<script src="packet-helpers.js?v=__BUST__"></script>
|
||||
<script src="vendor/aes-ecb.js?v=__BUST__"></script>
|
||||
<script src="vendor/sha256-hmac.js?v=__BUST__"></script>
|
||||
@@ -129,6 +153,8 @@
|
||||
<script src="packets.js?v=__BUST__"></script>
|
||||
<script src="geo-filter-overlay.js?v=__BUST__"></script>
|
||||
<script src="map.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="route-render.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="route-view.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="channels.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="table-sort.js?v=__BUST__"></script>
|
||||
<script src="nodes.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
@@ -145,5 +171,6 @@
|
||||
<script src="compare.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="node-analytics.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="perf.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="mobile-page-actions.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -351,6 +351,18 @@
|
||||
box-shadow: 0 0 4px currentColor;
|
||||
}
|
||||
|
||||
/* #1293 — SVG shape-aware legend swatch (replaces the flat colour dot).
|
||||
* Inline-block wrapper keeps SVG aligned with adjacent text labels. */
|
||||
.live-shape-swatch {
|
||||
display: inline-block;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
margin-right: 6px;
|
||||
vertical-align: middle;
|
||||
line-height: 0;
|
||||
}
|
||||
.live-shape-swatch svg { display: block; }
|
||||
|
||||
/* #1274: marker-style swatches — mirror the live map circleMarker ring
|
||||
* convention (bright white ring = repeater, faded ring = other roles).
|
||||
* Background uses --role-repeater / --text-muted via CSS variables so
|
||||
|
||||
+163
-38
@@ -1171,15 +1171,59 @@
|
||||
|
||||
const isDark = document.documentElement.getAttribute('data-theme') === 'dark' ||
|
||||
(document.documentElement.getAttribute('data-theme') !== 'light' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
let tileLayer = L.tileLayer(isDark ? TILE_DARK : TILE_LIGHT, { maxZoom: 19 }).addTo(map);
|
||||
// #1420 — multi-provider dark-tile picker. Light mode unchanged.
|
||||
let _liveDarkRefLayer = null;
|
||||
function _liveResolveTile(dark) {
|
||||
if (!dark) return { url: TILE_LIGHT, attribution: '© OpenStreetMap © CartoDB', refUrl: null };
|
||||
const reg = window.MC_TILE_PROVIDERS || {};
|
||||
const id = (typeof window.MC_getDarkTileProvider === 'function') ? window.MC_getDarkTileProvider() : 'carto-dark';
|
||||
const p = reg[id] || reg['carto-dark'] || {};
|
||||
return {
|
||||
url: p.url || p.baseUrl || TILE_DARK,
|
||||
attribution: p.attribution || '© OpenStreetMap © CartoDB',
|
||||
refUrl: p.refUrl || null
|
||||
};
|
||||
}
|
||||
function _liveSyncDarkTiles(dark) {
|
||||
const r = _liveResolveTile(dark);
|
||||
tileLayer.setUrl(r.url);
|
||||
if (tileLayer.options) tileLayer.options.attribution = r.attribution;
|
||||
if (dark && r.refUrl) {
|
||||
if (!_liveDarkRefLayer) {
|
||||
_liveDarkRefLayer = L.tileLayer(r.refUrl, { maxZoom: 19, attribution: r.attribution }).addTo(map);
|
||||
} else {
|
||||
_liveDarkRefLayer.setUrl(r.refUrl);
|
||||
}
|
||||
} else if (_liveDarkRefLayer) {
|
||||
map.removeLayer(_liveDarkRefLayer);
|
||||
_liveDarkRefLayer = null;
|
||||
}
|
||||
if (typeof window.MC_applyTileFilter === 'function') window.MC_applyTileFilter();
|
||||
// #1420 parity with map.js — refresh visible attribution credit after provider swap.
|
||||
if (map.attributionControl) {
|
||||
try { map.attributionControl._update && map.attributionControl._update(); } catch (_) {}
|
||||
}
|
||||
}
|
||||
const _liveInitTile = _liveResolveTile(isDark);
|
||||
let tileLayer = L.tileLayer(_liveInitTile.url, { maxZoom: 19, attribution: _liveInitTile.attribution }).addTo(map);
|
||||
if (isDark && _liveInitTile.refUrl) {
|
||||
_liveDarkRefLayer = L.tileLayer(_liveInitTile.refUrl, { maxZoom: 19, attribution: _liveInitTile.attribution }).addTo(map);
|
||||
}
|
||||
if (typeof window.MC_applyTileFilter === 'function') window.MC_applyTileFilter();
|
||||
|
||||
// Swap tiles when theme changes
|
||||
const _themeObs = new MutationObserver(function () {
|
||||
const dark = document.documentElement.getAttribute('data-theme') === 'dark' ||
|
||||
(document.documentElement.getAttribute('data-theme') !== 'light' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
tileLayer.setUrl(dark ? TILE_DARK : TILE_LIGHT);
|
||||
_liveSyncDarkTiles(dark);
|
||||
});
|
||||
_themeObs.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
|
||||
// #1420 — re-render on customizer change.
|
||||
window.addEventListener('mc-tile-provider-changed', function () {
|
||||
const dark = document.documentElement.getAttribute('data-theme') === 'dark' ||
|
||||
(document.documentElement.getAttribute('data-theme') !== 'light' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
_liveSyncDarkTiles(dark);
|
||||
});
|
||||
L.control.zoom({ position: 'topright' }).addTo(map);
|
||||
|
||||
nodesLayer = L.layerGroup().addTo(map);
|
||||
@@ -1712,7 +1756,13 @@
|
||||
if (roleLegendList) {
|
||||
for (const role of (window.ROLE_SORT || ['repeater', 'companion', 'room', 'sensor', 'observer'])) {
|
||||
const li = document.createElement('li');
|
||||
li.innerHTML = `<span class="live-dot" style="background:${ROLE_COLORS[role] || '#6b7280'}" aria-hidden="true"></span> ${(ROLE_LABELS[role] || role).replace(/s$/, '')}`;
|
||||
// #1293 — SVG swatch shows SHAPE + colour so colourblind ops can
|
||||
// distinguish roles without relying on hue alone (WCAG 1.4.1).
|
||||
const color = ROLE_COLORS[role] || '#6b7280';
|
||||
const swatch = window.makeRoleMarkerSVG
|
||||
? window.makeRoleMarkerSVG(role, color, 14)
|
||||
: `<span class="live-dot" style="background:${color}" aria-hidden="true"></span>`;
|
||||
li.innerHTML = `<span class="live-shape-swatch" aria-hidden="true">${swatch}</span> ${(ROLE_LABELS[role] || role).replace(/s$/, '')}`;
|
||||
roleLegendList.appendChild(li);
|
||||
}
|
||||
}
|
||||
@@ -2355,52 +2405,123 @@
|
||||
function addNodeMarker(n) {
|
||||
if (nodeMarkers[n.public_key]) return nodeMarkers[n.public_key];
|
||||
const color = ROLE_COLORS[n.role] || ROLE_COLORS.unknown;
|
||||
// #1438: SVG fill expression — use the live CSS var so existing
|
||||
// markers recolor when cb-preset switches or the operator overrides.
|
||||
// `color` (hex from ROLE_COLORS) is still tracked as `_baseColor`
|
||||
// for matrix mode / pulse animations that need an explicit value.
|
||||
const fillExpr = 'var(--mc-role-' + (n.role || 'companion') + ')';
|
||||
const isRepeater = n.role === 'repeater';
|
||||
const zoom = map ? map.getZoom() : 11;
|
||||
const zoomScale = Math.max(0.4, (zoom - 8) / 6);
|
||||
const size = Math.round((isRepeater ? 6 : 4) * zoomScale);
|
||||
// Shape-aware sizing: keep prior visual weight (~6/4 base) but
|
||||
// route through divIcon so colourblind ops get distinct silhouettes
|
||||
// (#1293). Size is the SVG box; circleMarker radius ~= size/3.
|
||||
const sizePx = Math.max(10, Math.round((isRepeater ? 18 : 14) * zoomScale));
|
||||
|
||||
const glow = L.circleMarker([n.lat, n.lon], {
|
||||
radius: size + 4, fillColor: color, fillOpacity: 0.12, stroke: false, interactive: false
|
||||
}).addTo(nodesLayer);
|
||||
const svgHtml = (window.makeRoleMarkerSVG
|
||||
? window.makeRoleMarkerSVG(n.role, null, sizePx)
|
||||
: '<svg width="' + sizePx + '" height="' + sizePx + '" viewBox="0 0 ' + sizePx + ' ' + sizePx +
|
||||
'"><circle cx="' + (sizePx/2) + '" cy="' + (sizePx/2) + '" r="' + (sizePx/2 - 2) +
|
||||
'" fill="' + fillExpr + '" stroke="#fff" stroke-width="1"/></svg>');
|
||||
|
||||
const marker = L.circleMarker([n.lat, n.lon], {
|
||||
radius: size, fillColor: color, fillOpacity: 0.85,
|
||||
color: '#fff', weight: isRepeater ? 1.5 : 0.5, opacity: isRepeater ? 0.6 : 0.3
|
||||
const icon = L.divIcon({
|
||||
html: svgHtml,
|
||||
className: 'live-node-marker live-node-' + (n.role || 'unknown'),
|
||||
iconSize: [sizePx, sizePx],
|
||||
iconAnchor: [sizePx / 2, sizePx / 2],
|
||||
popupAnchor: [0, -sizePx / 2]
|
||||
});
|
||||
const marker = L.marker([n.lat, n.lon], { icon: icon, interactive: true }).addTo(nodesLayer);
|
||||
|
||||
// Highlight ring (#1293): a separate stroke-only circleMarker layered
|
||||
// BENEATH the shape. Hidden by default; pulseNodeMarker grows/fades
|
||||
// its radius + opacity — never fills, so same-hue concentric stacking
|
||||
// (issue's "blue-on-blue") is impossible.
|
||||
const ringPos = [n.lat, n.lon];
|
||||
const ring = L.circleMarker(ringPos, {
|
||||
radius: sizePx / 2 + 4,
|
||||
fillOpacity: 0,
|
||||
fill: false,
|
||||
color: color,
|
||||
weight: 0,
|
||||
opacity: 0,
|
||||
interactive: false
|
||||
}).addTo(nodesLayer);
|
||||
|
||||
marker.bindTooltip(n.name || n.public_key.slice(0, 8), {
|
||||
permanent: false, direction: 'top', offset: [0, -10], className: 'live-tooltip'
|
||||
permanent: false, direction: 'top', offset: [0, -sizePx / 2], className: 'live-tooltip'
|
||||
});
|
||||
|
||||
marker.on('click', () => showNodeDetail(n.public_key));
|
||||
|
||||
marker._glowMarker = glow;
|
||||
marker._highlightRing = ring;
|
||||
marker._baseColor = color;
|
||||
marker._baseSize = size;
|
||||
marker._baseSize = sizePx;
|
||||
marker._role = n.role || 'unknown';
|
||||
nodeMarkers[n.public_key] = marker;
|
||||
|
||||
// Apply matrix tint if active
|
||||
// Apply matrix tint if active — re-render the SVG with matrix colour
|
||||
if (matrixMode) {
|
||||
marker._matrixPrevColor = color;
|
||||
marker._baseColor = '#008a22';
|
||||
marker.setStyle({ fillColor: '#008a22', color: '#008a22', fillOpacity: 0.5, opacity: 0.5 });
|
||||
glow.setStyle({ fillColor: '#008a22', fillOpacity: 0.15 });
|
||||
const mxHtml = window.makeRoleMarkerSVG
|
||||
? window.makeRoleMarkerSVG(marker._role, '#008a22', sizePx)
|
||||
: svgHtml;
|
||||
const el = marker.getElement();
|
||||
if (el) el.innerHTML = mxHtml;
|
||||
}
|
||||
|
||||
return marker;
|
||||
}
|
||||
|
||||
// #1293 — divIcon helpers. The live-map node marker is now an
|
||||
// L.marker (divIcon SVG), not an L.circleMarker, so setStyle /
|
||||
// setRadius are no-ops. These helpers update the DOM element
|
||||
// directly so existing call-sites (rescale, stale-dim, matrix mode,
|
||||
// highlight pulse) keep working without same-colour fill stacking.
|
||||
function _liveMarkerEl(marker) {
|
||||
if (!marker || typeof marker.getElement !== 'function') return null;
|
||||
return marker.getElement();
|
||||
}
|
||||
function _liveSetMarkerOpacity(marker, opacity) {
|
||||
var el = _liveMarkerEl(marker);
|
||||
if (el) el.style.opacity = String(opacity);
|
||||
}
|
||||
function _liveSetMarkerSize(marker, sizePx) {
|
||||
var el = _liveMarkerEl(marker);
|
||||
if (!el) return;
|
||||
var svg = el.querySelector('svg');
|
||||
if (svg) {
|
||||
svg.setAttribute('width', sizePx);
|
||||
svg.setAttribute('height', sizePx);
|
||||
}
|
||||
marker._baseSize = sizePx;
|
||||
if (marker._highlightRing && typeof marker._highlightRing.setRadius === 'function') {
|
||||
marker._highlightRing.setRadius(sizePx / 2 + 4);
|
||||
}
|
||||
}
|
||||
function _liveSetMarkerColor(marker, color) {
|
||||
var el = _liveMarkerEl(marker);
|
||||
if (!el) return;
|
||||
if (window.makeRoleMarkerSVG) {
|
||||
el.innerHTML = window.makeRoleMarkerSVG(marker._role || 'unknown', color, marker._baseSize || 14);
|
||||
} else {
|
||||
// Fallback: tweak fill on first shape
|
||||
var shape = el.querySelector('svg > *');
|
||||
if (shape) shape.setAttribute('fill', color);
|
||||
}
|
||||
}
|
||||
window._liveSetMarkerSize = _liveSetMarkerSize;
|
||||
window._liveSetMarkerColor = _liveSetMarkerColor;
|
||||
|
||||
function rescaleMarkers() {
|
||||
const zoom = map.getZoom();
|
||||
const zoomScale = Math.max(0.4, (zoom - 8) / 6);
|
||||
for (const [key, marker] of Object.entries(nodeMarkers)) {
|
||||
const n = nodeData[key];
|
||||
const isRepeater = n && n.role === 'repeater';
|
||||
const size = Math.round((isRepeater ? 6 : 4) * zoomScale);
|
||||
marker.setRadius(size);
|
||||
marker._baseSize = size;
|
||||
if (marker._glowMarker) marker._glowMarker.setRadius(size + 4);
|
||||
const sizePx = Math.max(10, Math.round((isRepeater ? 18 : 14) * zoomScale));
|
||||
_liveSetMarkerSize(marker, sizePx);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2422,15 +2543,14 @@
|
||||
// API-loaded nodes: dim instead of removing (consistent with static map)
|
||||
if (marker && !marker._staleDimmed) {
|
||||
marker._staleDimmed = true;
|
||||
marker.setStyle({ fillOpacity: 0.25, opacity: 0.15 });
|
||||
if (marker._glowMarker) marker._glowMarker.setStyle({ fillOpacity: 0.04 });
|
||||
_liveSetMarkerOpacity(marker, 0.35);
|
||||
}
|
||||
} else {
|
||||
// WS-only nodes: remove to prevent unbounded memory growth
|
||||
if (marker) {
|
||||
if (nodesLayer) {
|
||||
try { nodesLayer.removeLayer(marker); } catch (e) {}
|
||||
if (marker._glowMarker) try { nodesLayer.removeLayer(marker._glowMarker); } catch (e) {}
|
||||
if (marker._highlightRing) try { nodesLayer.removeLayer(marker._highlightRing); } catch (e) {}
|
||||
}
|
||||
}
|
||||
delete nodeMarkers[key];
|
||||
@@ -2441,9 +2561,7 @@
|
||||
} else if (marker && marker._staleDimmed) {
|
||||
// Node became active again — restore full opacity
|
||||
marker._staleDimmed = false;
|
||||
var isRepeater = n.role === 'repeater';
|
||||
marker.setStyle({ fillOpacity: 0.85, opacity: isRepeater ? 0.6 : 0.3 });
|
||||
if (marker._glowMarker) marker._glowMarker.setStyle({ fillOpacity: 0.12 });
|
||||
_liveSetMarkerOpacity(marker, 1);
|
||||
}
|
||||
}
|
||||
if (pruned) {
|
||||
@@ -2948,17 +3066,26 @@
|
||||
requestAnimationFrame(animatePulse);
|
||||
|
||||
const baseColor = marker._baseColor || '#6b7280';
|
||||
const baseSize = marker._baseSize || 6;
|
||||
marker.setStyle({ fillColor: '#fff', fillOpacity: 1, radius: baseSize + 2, color: color, weight: 2 });
|
||||
const baseSize = marker._baseSize || 14;
|
||||
|
||||
if (marker._glowMarker) {
|
||||
marker._glowMarker.setStyle({ fillColor: color, fillOpacity: 0.2, radius: baseSize + 6 });
|
||||
setTimeout(() => marker._glowMarker.setStyle({ fillColor: baseColor, fillOpacity: 0.08, radius: baseSize + 3 }), 500);
|
||||
// #1293 — highlight via OUTLINE ring (no same-colour concentric
|
||||
// fill). Use the marker's pre-allocated _highlightRing; grow + fade
|
||||
// it. Marker shape/colour is left untouched so colourblind silhouette
|
||||
// stays distinguishable during the pulse.
|
||||
const ringHl = marker._highlightRing;
|
||||
if (ringHl && typeof ringHl.setStyle === 'function') {
|
||||
try {
|
||||
ringHl.setStyle({ color: color, weight: 3, opacity: 0.95, fillOpacity: 0, fill: false });
|
||||
ringHl.setRadius(baseSize / 2 + 4);
|
||||
setTimeout(() => {
|
||||
try { ringHl.setStyle({ opacity: 0.4, weight: 2 }); ringHl.setRadius(baseSize / 2 + 8); } catch (e) {}
|
||||
}, 200);
|
||||
setTimeout(() => {
|
||||
try { ringHl.setStyle({ opacity: 0, weight: 0 }); } catch (e) {}
|
||||
}, 700);
|
||||
} catch (e) { /* circleMarker absent — ignore */ }
|
||||
}
|
||||
|
||||
setTimeout(() => marker.setStyle({ fillColor: color, fillOpacity: 0.95, radius: baseSize + 1, weight: 1.5 }), 150);
|
||||
setTimeout(() => marker.setStyle({ fillColor: baseColor, fillOpacity: 0.85, radius: baseSize, color: '#fff', weight: marker._baseSize > 6 ? 1.5 : 0.5 }), 700);
|
||||
|
||||
nodeActivity[key] = (nodeActivity[key] || 0) + 1;
|
||||
}
|
||||
|
||||
@@ -3112,8 +3239,7 @@
|
||||
for (const [key, marker] of Object.entries(nodeMarkers)) {
|
||||
marker._matrixPrevColor = marker._baseColor;
|
||||
marker._baseColor = '#008a22';
|
||||
marker.setStyle({ fillColor: '#008a22', color: '#008a22', fillOpacity: 0.5, opacity: 0.5 });
|
||||
if (marker._glowMarker) marker._glowMarker.setStyle({ fillColor: '#008a22', fillOpacity: 0.15 });
|
||||
_liveSetMarkerColor(marker, '#008a22');
|
||||
}
|
||||
} else {
|
||||
container.classList.remove('matrix-theme');
|
||||
@@ -3134,8 +3260,7 @@
|
||||
for (const [key, marker] of Object.entries(nodeMarkers)) {
|
||||
if (marker._matrixPrevColor) {
|
||||
marker._baseColor = marker._matrixPrevColor;
|
||||
marker.setStyle({ fillColor: marker._matrixPrevColor, color: '#fff', fillOpacity: 0.85, opacity: 1 });
|
||||
if (marker._glowMarker) marker._glowMarker.setStyle({ fillColor: marker._matrixPrevColor });
|
||||
_liveSetMarkerColor(marker, marker._matrixPrevColor);
|
||||
delete marker._matrixPrevColor;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
/* map-tile-providers.js — Dark-tile provider registry & runtime switcher (#1420).
|
||||
*
|
||||
* Scope:
|
||||
* - 4 providers: carto-dark (default), esri-darkgray-labels (base+ref),
|
||||
* voyager-inverted, positron-inverted (CSS-filter variants).
|
||||
* - MC_setDarkTileProvider(id) persists per-browser to localStorage and
|
||||
* dispatches `mc-tile-provider-changed` so map.js / live.js can swap.
|
||||
* - MC_getDarkTileProvider() resolves localStorage → server default →
|
||||
* 'carto-dark'.
|
||||
* - MC_applyTileFilter() applies/clears the CSS filter on
|
||||
* `.leaflet-tile-pane` based on current theme + selected provider.
|
||||
*
|
||||
* No new deps — URL-only providers. Light mode is unchanged.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var STORAGE_KEY = 'mc-dark-tile-provider';
|
||||
var DEFAULT_ID = 'carto-dark';
|
||||
var EVENT_NAME = 'mc-tile-provider-changed';
|
||||
var INVERT_CSS = 'invert(1) hue-rotate(180deg) brightness(0.9) contrast(1.05)';
|
||||
|
||||
// Per-browser server-injected default. roles.js writes this from
|
||||
// /api/config/client (cfg.mapDarkTileProvider) before any consumer reads.
|
||||
var _serverDefault = null;
|
||||
|
||||
// ── Registry ────────────────────────────────────────────────────────────
|
||||
var REGISTRY = {
|
||||
'carto-dark': {
|
||||
label: 'Carto Dark (default)',
|
||||
url: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png',
|
||||
attribution: '© OpenStreetMap © CartoDB',
|
||||
invertFilter: null
|
||||
},
|
||||
'esri-darkgray-labels': {
|
||||
label: 'Esri Dark Gray + Labels',
|
||||
// Two-layer provider: base + reference (labels) overlay.
|
||||
baseUrl: 'https://server.arcgisonline.com/ArcGIS/rest/services/Canvas/World_Dark_Gray_Base/MapServer/tile/{z}/{y}/{x}',
|
||||
refUrl: 'https://server.arcgisonline.com/ArcGIS/rest/services/Canvas/World_Dark_Gray_Reference/MapServer/tile/{z}/{y}/{x}',
|
||||
attribution: 'Tiles © Esri — Esri, DeLorme, NAVTEQ',
|
||||
invertFilter: null
|
||||
},
|
||||
'voyager-inverted': {
|
||||
label: 'Carto Voyager (inverted)',
|
||||
url: 'https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png',
|
||||
attribution: '© OpenStreetMap © CartoDB',
|
||||
invertFilter: INVERT_CSS
|
||||
},
|
||||
'positron-inverted': {
|
||||
label: 'Carto Positron (inverted)',
|
||||
url: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png',
|
||||
attribution: '© OpenStreetMap © CartoDB',
|
||||
invertFilter: INVERT_CSS
|
||||
}
|
||||
};
|
||||
|
||||
function _hasId(id) {
|
||||
return typeof id === 'string' && Object.prototype.hasOwnProperty.call(REGISTRY, id);
|
||||
}
|
||||
|
||||
function _isDark() {
|
||||
try {
|
||||
var attr = document.documentElement.getAttribute('data-theme');
|
||||
if (attr === 'dark') return true;
|
||||
if (attr === 'light') return false;
|
||||
return !!(window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
} catch (_) { return false; }
|
||||
}
|
||||
|
||||
function getActiveId() {
|
||||
try {
|
||||
var stored = window.localStorage && window.localStorage.getItem(STORAGE_KEY);
|
||||
if (_hasId(stored)) return stored;
|
||||
} catch (_) { /* localStorage may be disabled */ }
|
||||
if (_hasId(_serverDefault)) return _serverDefault;
|
||||
return DEFAULT_ID;
|
||||
}
|
||||
|
||||
function setActive(id) {
|
||||
if (!_hasId(id)) return false;
|
||||
try {
|
||||
if (window.localStorage) window.localStorage.setItem(STORAGE_KEY, id);
|
||||
} catch (_) { /* swallow quota / disabled */ }
|
||||
var detail = { id: id, provider: REGISTRY[id] };
|
||||
try {
|
||||
var ev = (typeof CustomEvent === 'function')
|
||||
? new CustomEvent(EVENT_NAME, { detail: detail })
|
||||
: { type: EVENT_NAME, detail: detail };
|
||||
window.dispatchEvent(ev);
|
||||
} catch (_) { /* dispatch optional */ }
|
||||
// Re-apply filter immediately so callers without a listener still see it.
|
||||
applyTileFilter();
|
||||
return true;
|
||||
}
|
||||
|
||||
function setServerDefault(id) {
|
||||
if (_hasId(id)) _serverDefault = id;
|
||||
}
|
||||
|
||||
function applyTileFilter() {
|
||||
var pane;
|
||||
try { pane = document.querySelector('.leaflet-tile-pane'); } catch (_) { pane = null; }
|
||||
if (!pane || !pane.style) return;
|
||||
if (!_isDark()) { pane.style.filter = ''; return; }
|
||||
var id = getActiveId();
|
||||
var p = REGISTRY[id];
|
||||
pane.style.filter = (p && p.invertFilter) ? p.invertFilter : '';
|
||||
}
|
||||
|
||||
// ── Public surface ──────────────────────────────────────────────────────
|
||||
window.MC_TILE_PROVIDERS = REGISTRY;
|
||||
window.MC_DARK_TILE_DEFAULT = DEFAULT_ID;
|
||||
window.MC_setDarkTileProvider = setActive;
|
||||
window.MC_getDarkTileProvider = getActiveId;
|
||||
window.MC_setServerDefaultTileProvider = setServerDefault;
|
||||
window.MC_applyTileFilter = applyTileFilter;
|
||||
|
||||
// ── Cross-tab sync ──────────────────────────────────────────────────────
|
||||
// If another tab in the same browser changes the provider, mirror the
|
||||
// dispatch + filter-apply here so live map.js / live.js swap tiles too.
|
||||
try {
|
||||
window.addEventListener('storage', function (e) {
|
||||
if (!e || e.key !== STORAGE_KEY) return;
|
||||
if (!_hasId(e.newValue)) return;
|
||||
var detail = { id: e.newValue, provider: REGISTRY[e.newValue], crossTab: true };
|
||||
try {
|
||||
var ev = (typeof CustomEvent === 'function')
|
||||
? new CustomEvent(EVENT_NAME, { detail: detail })
|
||||
: { type: EVENT_NAME, detail: detail };
|
||||
window.dispatchEvent(ev);
|
||||
} catch (_) { /* dispatch optional */ }
|
||||
applyTileFilter();
|
||||
});
|
||||
} catch (_) { /* addEventListener may not exist in some envs */ }
|
||||
})();
|
||||
+763
-103
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,194 @@
|
||||
/* #1461 mobile page-actions: mirror per-page header buttons (pause, filter
|
||||
* toggle) into the top nav's .nav-right area on mobile so the page-header
|
||||
* row can be hidden entirely. On desktop this script is a no-op. */
|
||||
(function () {
|
||||
'use strict';
|
||||
const MOBILE_BP = 600;
|
||||
const SLOT_ID = 'navPageActions';
|
||||
|
||||
function isMobile() { return window.innerWidth <= MOBILE_BP; }
|
||||
|
||||
function ensureSlot() {
|
||||
let slot = document.getElementById(SLOT_ID);
|
||||
if (slot) return slot;
|
||||
// On mobile, .nav-right is display:none — use .nav-left so the slot is
|
||||
// visible. Append after the brand link.
|
||||
const navLeft = document.querySelector('.nav-left');
|
||||
if (!navLeft) return null;
|
||||
slot = document.createElement('div');
|
||||
slot.id = SLOT_ID;
|
||||
// Layout-only styles inline; visual tokens (border/colors) come from
|
||||
// the mobile @media block in style.css so the customizer can theme us.
|
||||
slot.style.cssText = 'display:inline-flex;gap:4px;align-items:center;margin-left:8px;';
|
||||
navLeft.appendChild(slot);
|
||||
return slot;
|
||||
}
|
||||
|
||||
function clearSlot() {
|
||||
const slot = document.getElementById(SLOT_ID);
|
||||
if (slot) slot.innerHTML = '';
|
||||
}
|
||||
|
||||
function makeBtn(label, title, onClick) {
|
||||
const b = document.createElement('button');
|
||||
b.className = 'nav-btn';
|
||||
b.type = 'button';
|
||||
b.title = title;
|
||||
b.textContent = label;
|
||||
b.addEventListener('click', onClick);
|
||||
return b;
|
||||
}
|
||||
|
||||
function syncForRoute() {
|
||||
// #1461 #6: close mobile detail sheet on route change away from packets
|
||||
try {
|
||||
const sheet = document.getElementById('mobileDetailSheet');
|
||||
if (sheet && !/^#\/packets/.test(location.hash || '')) {
|
||||
sheet.classList.remove('open');
|
||||
}
|
||||
} catch (_e) {}
|
||||
|
||||
if (!isMobile()) { clearSlot(); return; }
|
||||
const hash = location.hash || '';
|
||||
const slot = ensureSlot();
|
||||
if (!slot) return;
|
||||
slot.innerHTML = '';
|
||||
|
||||
if (/^#\/packets(\/|$|\?)/.test(hash)) {
|
||||
// Mirror pause button (icon only — small)
|
||||
const pause = makeBtn('⏸', 'Pause live updates', function () {
|
||||
const real = document.getElementById('pktPauseBtn');
|
||||
if (real) real.click();
|
||||
});
|
||||
pause.classList.add('mpa-btn-icon');
|
||||
slot.appendChild(pause);
|
||||
// Mirror filter toggle as a labeled "Filters ▾" pill (matches inline style)
|
||||
const filt = makeBtn('Filters ▾', 'Toggle filters', function () {
|
||||
const real = document.querySelector('.filter-bar .filter-toggle-btn, #filterToggleBtn');
|
||||
if (real) real.click();
|
||||
});
|
||||
filt.className = 'nav-btn filter-toggle-btn-mirror mpa-btn-pill';
|
||||
slot.appendChild(filt);
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('hashchange', syncForRoute);
|
||||
window.addEventListener('resize', syncForRoute);
|
||||
|
||||
/* #1461 #7: on mobile, packets-list group-header expand is a UX dead-end
|
||||
* (we hid the chevron so there's no way to collapse). Intercept those
|
||||
* clicks and force them to the single-select code path instead — the
|
||||
* detail pane has all the obs info anyway. */
|
||||
document.addEventListener('click', function (e) {
|
||||
if (!isMobile()) return;
|
||||
const row = e.target.closest && e.target.closest('#pktTable tr[data-action="toggle-select"]');
|
||||
if (!row) return;
|
||||
// Convert to a select-hash event by re-dispatching synthetically — simpler
|
||||
// to mutate the attribute briefly so the existing delegated handler
|
||||
// routes it correctly.
|
||||
row.setAttribute('data-action', 'select-hash');
|
||||
}, true);
|
||||
|
||||
/* #1461 #8: traffic_share_score / bridge_score tooltips use title= which
|
||||
* doesn't fire on touch. Show a click-to-toast popover on mobile when
|
||||
* operator taps a TD whose title mentions traffic/bridge/centrality. */
|
||||
document.addEventListener('click', function (e) {
|
||||
if (!isMobile()) return;
|
||||
const el = e.target.closest('[title]');
|
||||
if (!el) return;
|
||||
const text = el.getAttribute('title');
|
||||
if (!text) return;
|
||||
// Limit to score / metric explanations to avoid spamming on every titled element
|
||||
if (!/traffic share|bridge|centrality|score|usefulness/i.test(text + ' ' + el.textContent)) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
showToast(text);
|
||||
}, true);
|
||||
function showToast(msg) {
|
||||
let t = document.getElementById('mcMobileToast');
|
||||
if (!t) {
|
||||
t = document.createElement('div');
|
||||
t.id = 'mcMobileToast';
|
||||
t.className = 'mpa-toast';
|
||||
document.body.appendChild(t);
|
||||
}
|
||||
t.textContent = msg;
|
||||
t.style.opacity = '1';
|
||||
clearTimeout(t._timer);
|
||||
t._timer = setTimeout(() => { t.style.opacity = '0'; }, 4000);
|
||||
}
|
||||
|
||||
/* #1467: mirror missing top-nav controls (Favorites, Search, Customize)
|
||||
* into the bottom-nav More sheet. bottom-nav.js only wired Dark mode;
|
||||
* the others have no mobile surface today. Insert above the existing
|
||||
* dark-mode separator so the new items group with the other route items. */
|
||||
function addMissingMoreSheetItems(retryCount) {
|
||||
retryCount = retryCount || 0;
|
||||
const sheet = document.querySelector('[data-bottom-nav-sheet]');
|
||||
if (!sheet) {
|
||||
// Bounded retry — bottom-nav.js builds the sheet asynchronously, but
|
||||
// give up after ~5s so we don't poll forever on pages that don't have
|
||||
// bottom-nav (e.g. embedded views, headless tests).
|
||||
if (retryCount < 10) setTimeout(() => addMissingMoreSheetItems(retryCount + 1), 500);
|
||||
return;
|
||||
}
|
||||
if (sheet.querySelector('[data-mpa-mirror]')) return; // already injected
|
||||
|
||||
const mirrors = [
|
||||
{ id: 'favToggle', icon: '⭐', label: 'Favorites' },
|
||||
{ id: 'searchToggle', icon: '🔍', label: 'Search' },
|
||||
{ id: 'customizeToggle', icon: '🎨', label: 'Customize' },
|
||||
];
|
||||
|
||||
const sep = sheet.querySelector('.bottom-nav-sheet-sep');
|
||||
mirrors.forEach((m) => {
|
||||
const real = document.getElementById(m.id);
|
||||
if (!real) return;
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'bottom-nav-sheet-item';
|
||||
btn.setAttribute('role', 'menuitem');
|
||||
btn.setAttribute('data-mpa-mirror', m.id);
|
||||
|
||||
const ic = document.createElement('span');
|
||||
ic.className = 'bottom-nav-sheet-icon';
|
||||
ic.setAttribute('aria-hidden', 'true');
|
||||
ic.textContent = m.icon;
|
||||
|
||||
const lb = document.createElement('span');
|
||||
lb.className = 'bottom-nav-sheet-label';
|
||||
lb.textContent = m.label;
|
||||
|
||||
btn.appendChild(ic);
|
||||
btn.appendChild(lb);
|
||||
btn.addEventListener('click', function () {
|
||||
real.click();
|
||||
// close the sheet after delegating
|
||||
try { sheet.classList.remove('open'); } catch (_e) {}
|
||||
});
|
||||
|
||||
if (sep) sheet.insertBefore(btn, sep);
|
||||
else sheet.appendChild(btn);
|
||||
});
|
||||
}
|
||||
// Also re-run when sheet is opened (bottom-nav rebuilds it on open)
|
||||
document.addEventListener('click', function (e) {
|
||||
const target = e.target.closest && e.target.closest('[data-bottom-nav-more]');
|
||||
if (target) setTimeout(addMissingMoreSheetItems, 50);
|
||||
}, true);
|
||||
|
||||
// Run after page-header is rendered (packets.js builds it async); retry briefly
|
||||
let tries = 0;
|
||||
function init() {
|
||||
syncForRoute();
|
||||
addMissingMoreSheetItems();
|
||||
if (tries++ < 20 && /^#\/packets/.test(location.hash) && !document.getElementById('pktPauseBtn')) {
|
||||
setTimeout(init, 250);
|
||||
}
|
||||
}
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
})();
|
||||
+40
-10
@@ -74,6 +74,29 @@
|
||||
let wsHandler = null;
|
||||
let detailMap = null;
|
||||
|
||||
// #1461 followup: node-detail inset map tile layer that honors the
|
||||
// customizer dark-tile-provider pick (#1420/#1430). Falls back to
|
||||
// window.getTileUrl() output if the registry isn't loaded. Also applies
|
||||
// the provider's invert CSS filter to the tile pane when needed.
|
||||
function _applyTilesToNodeMap(map) {
|
||||
if (!map) return;
|
||||
var tileUrl = (window.getTileUrl && window.getTileUrl()) || 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png';
|
||||
var provider = window.getActiveTileProvider && window.getActiveTileProvider();
|
||||
var attribution = (provider && provider.attribution) || '© OpenStreetMap contributors';
|
||||
var layer = L.tileLayer(tileUrl, { maxZoom: 18, attribution: attribution }).addTo(map);
|
||||
// Esri 2-layer provider: add the labels reference overlay too
|
||||
if (provider && provider.refUrl) {
|
||||
try { L.tileLayer(provider.refUrl, { maxZoom: 18 }).addTo(map); } catch (_e) {}
|
||||
}
|
||||
// Apply invert CSS filter to the tile pane if the provider needs it
|
||||
try {
|
||||
var pane = map.getPane && map.getPane('tilePane');
|
||||
if (pane) pane.style.filter = (provider && provider.invertFilter) ? provider.invertFilter : '';
|
||||
} catch (_e) {}
|
||||
return layer;
|
||||
}
|
||||
|
||||
|
||||
// ROLE_COLORS loaded from shared roles.js
|
||||
const TABS = [
|
||||
{ key: 'all', label: 'All' },
|
||||
@@ -547,8 +570,14 @@
|
||||
<tr><td>Status</td><td><span title="${si.statusTooltip}">${statusLabel}</span> <span style="font-size:11px;color:var(--text-muted);margin-left:4px">${statusExplanation}</span></td></tr>
|
||||
<tr><td>Last Heard</td><td>${renderNodeTimestampHtml(lastHeard || n.last_seen)}</td></tr>
|
||||
${(n.role === 'repeater' || n.role === 'room') ? `<tr><td title="Last time this repeater appeared as a relay hop in a non-advert packet observed by the network. Distinct from 'Last Heard' (which counts the repeater's own adverts). See issue #662.">Last Relayed</td><td>${n.last_relayed ? renderNodeTimestampHtml(n.last_relayed) + ' ' + (n.relay_active ? '<span style="color:var(--status-green);font-size:11px">🟢 actively relaying</span>' : '<span style="color:var(--status-yellow);font-size:11px">🟡 alive (idle)</span>') : '<span style="color:var(--text-muted)">never observed as relay hop</span> <span style="color:var(--status-yellow);font-size:11px">🟡 alive (idle)</span>'}${(n.relay_count_1h != null || n.relay_count_24h != null) ? ` <span style="color:var(--text-muted);font-size:11px;margin-left:4px">(${n.relay_count_1h || 0} relays/hr, ${n.relay_count_24h || 0} relays/24h)</span>` : ''}</td></tr>` : ''}
|
||||
${(n.role === 'repeater' || n.role === 'room') && n.usefulness_score != null ? (() => {
|
||||
const s = Number(n.usefulness_score) || 0;
|
||||
${(n.role === 'repeater' || n.role === 'room') && (n.traffic_share_score != null || n.usefulness_score != null) ? (() => {
|
||||
// #1456: prefer the new traffic_share_score field; fall back
|
||||
// to legacy usefulness_score for graceful degradation
|
||||
// against stale servers. The visible label is now "Traffic
|
||||
// share" (the old "Usefulness" implied a composite that
|
||||
// doesn't exist yet — see #672).
|
||||
const raw = (n.traffic_share_score != null) ? n.traffic_share_score : n.usefulness_score;
|
||||
const s = Number(raw) || 0;
|
||||
const pct = (s * 100).toFixed(1);
|
||||
// Visual indicator: width % bar with green→yellow→red color by score.
|
||||
// Per issue #672 classification table: 0.8+ Critical, 0.6+ Valuable,
|
||||
@@ -560,15 +589,15 @@
|
||||
else if (s >= 0.1) { label = 'Marginal'; color = 'var(--status-orange, #e67e22)'; }
|
||||
else { label = 'Redundant'; color = 'var(--status-red, #e74c3c)'; }
|
||||
const barWidth = Math.max(2, Math.round(s * 100));
|
||||
return `<tr id="row-usefulness-score" data-usefulness-score="${s.toFixed(4)}"><td title="Fraction of non-advert traffic in the network observed by CoreScope that this repeater carries as a relay hop (Traffic axis of issue #672). Range 0–1; higher = forwards more of the mesh's actual traffic.">Usefulness</td><td><span style="display:inline-block;vertical-align:middle;width:80px;height:8px;background:var(--bg-secondary,#333);border-radius:4px;overflow:hidden;margin-right:6px"><span style="display:block;width:${barWidth}%;height:100%;background:${color}"></span></span><span style="color:${color};font-weight:600">${pct}%</span> <span style="color:var(--text-muted);font-size:11px;margin-left:4px">${label}</span></td></tr>`;
|
||||
const tooltip = "Fraction of all non-advert mesh traffic in the analyzer's memory that transited through this repeater as a relay hop. High = lots of packets pass through; low = quieter (may still be structurally important — see Bridge score). One of 4 planned scoring axes (#672); others pending.";
|
||||
return `<tr id="row-usefulness-score" data-usefulness-score="${s.toFixed(4)}" data-traffic-share-score="${s.toFixed(4)}"><td title="${tooltip}">Traffic share <span style="color:var(--text-muted);cursor:help" aria-label="help">ⓘ</span></td><td><span style="display:inline-block;vertical-align:middle;width:80px;height:8px;background:var(--bg-secondary,#333);border-radius:4px;overflow:hidden;margin-right:6px"><span style="display:block;width:${barWidth}%;height:100%;background:${color}"></span></span><span style="color:${color};font-weight:600">${pct}%</span> <span style="color:var(--text-muted);font-size:11px;margin-left:4px">${label}</span></td></tr>`;
|
||||
})() : ''}
|
||||
${(n.role === 'repeater' || n.role === 'room') && n.bridge_score != null ? (() => {
|
||||
// Bridge axis (issue #672 axis 2 of 4): normalized betweenness
|
||||
// centrality from the neighbor-edges graph. Distinct from the
|
||||
// Traffic-based Usefulness score above — bridge measures
|
||||
// STRUCTURAL importance (how many shortest paths between
|
||||
// other node pairs go through this one) regardless of
|
||||
// current traffic.
|
||||
// Traffic-share score above — bridge measures STRUCTURAL
|
||||
// importance (how many shortest paths between other node
|
||||
// pairs go through this one) regardless of current traffic.
|
||||
const b = Number(n.bridge_score) || 0;
|
||||
const bpct = (b * 100).toFixed(1);
|
||||
let blabel, bcolor;
|
||||
@@ -578,7 +607,8 @@
|
||||
else if (b > 0) { blabel = 'Marginal'; bcolor = 'var(--status-orange, #e67e22)'; }
|
||||
else { blabel = 'No bridge role'; bcolor = 'var(--text-muted)'; }
|
||||
const bbarWidth = Math.max(2, Math.round(b * 100));
|
||||
return `<tr id="row-bridge-score" data-bridge-score="${b.toFixed(4)}"><td title="Structural importance of this repeater as a path between other nodes — normalized betweenness centrality on the neighbor-edges graph (Bridge axis of issue #672, axis 2 of 4). Higher = more pairs of nodes route shortest paths through this one. Independent of current traffic.">Bridge</td><td><span style="display:inline-block;vertical-align:middle;width:80px;height:8px;background:var(--bg-secondary,#333);border-radius:4px;overflow:hidden;margin-right:6px"><span style="display:block;width:${bbarWidth}%;height:100%;background:${bcolor}"></span></span><span style="color:${bcolor};font-weight:600">${bpct}%</span> <span style="color:var(--text-muted);font-size:11px;margin-left:4px">${blabel}</span></td></tr>`;
|
||||
const btooltip = "Normalized betweenness centrality (0..1). How often this node sits on the shortest path between other pairs of nodes in the affinity graph. 1.0 = the most structurally critical node on the mesh. High Bridge + low Traffic share = a quiet but irreplaceable chokepoint.";
|
||||
return `<tr id="row-bridge-score" data-bridge-score="${b.toFixed(4)}"><td title="${btooltip}">Bridge score <span style="color:var(--text-muted);cursor:help" aria-label="help">ⓘ</span></td><td><span style="display:inline-block;vertical-align:middle;width:80px;height:8px;background:var(--bg-secondary,#333);border-radius:4px;overflow:hidden;margin-right:6px"><span style="display:block;width:${bbarWidth}%;height:100%;background:${bcolor}"></span></span><span style="color:${bcolor};font-weight:600">${bpct}%</span> <span style="color:var(--text-muted);font-size:11px;margin-left:4px">${blabel}</span></td></tr>`;
|
||||
})() : ''}
|
||||
<tr><td>First Seen</td><td>${renderNodeTimestampHtml(n.first_seen)}</td></tr>
|
||||
<tr><td>Total Packets</td><td>${stats.totalTransmissions || stats.totalPackets || n.advert_count || 0}${stats.totalObservations && stats.totalObservations !== (stats.totalTransmissions || stats.totalPackets) ? ' <span class="text-muted" style="font-size:0.85em">(seen ' + stats.totalObservations + '×)</span>' : ''}</td></tr>
|
||||
@@ -669,7 +699,7 @@
|
||||
try {
|
||||
if (detailMap) { detailMap.remove(); detailMap = null; }
|
||||
detailMap = L.map('nodeFullMap', { zoomControl: true, attributionControl: false }).setView([n.lat, n.lon], 13);
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 18 }).addTo(detailMap);
|
||||
_applyTilesToNodeMap(detailMap);
|
||||
L.marker([n.lat, n.lon]).addTo(detailMap).bindPopup(n.name || n.public_key.slice(0, 12));
|
||||
setTimeout(() => detailMap.invalidateSize(), 100);
|
||||
} catch {}
|
||||
@@ -1517,7 +1547,7 @@
|
||||
try {
|
||||
if (detailMap) { detailMap.remove(); detailMap = null; }
|
||||
detailMap = L.map('nodeMap', { zoomControl: false, attributionControl: false }).setView([n.lat, n.lon], 13);
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 18 }).addTo(detailMap);
|
||||
_applyTilesToNodeMap(detailMap);
|
||||
L.marker([n.lat, n.lon]).addTo(detailMap).bindPopup(n.name || n.public_key.slice(0, 12));
|
||||
setTimeout(() => detailMap.invalidateSize(), 100);
|
||||
} catch {}
|
||||
|
||||
+69
-18
@@ -1331,7 +1331,7 @@
|
||||
<button class="btn-icon" data-action="pkt-byop" title="Bring Your Own Packet" aria-label="Bring Your Own Packet - paste raw packet hex for analysis" aria-haspopup="dialog">📦 BYOP</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="filter-group" style="flex:1;margin-bottom:8px;position:relative">
|
||||
<div class="filter-group pkt-filter-expr" style="flex:1;margin-bottom:8px;position:relative">
|
||||
<input type="text" id="packetFilterInput" class="packet-filter-input"
|
||||
placeholder='Filter: type == Advert && snr > 5 · payload.name contains "Gilroy"'
|
||||
aria-label="Packet filter expression"
|
||||
@@ -1406,9 +1406,9 @@
|
||||
</div>
|
||||
<div class="table-fluid-wrap"><table class="data-table" id="pktTable">
|
||||
<thead><tr>
|
||||
<th scope="col" data-priority="1"></th><th scope="col" class="col-region" data-sort-key="region" data-priority="3">Region</th><th scope="col" class="col-time" data-sort-key="time" data-type="date" data-priority="1">Time</th><th scope="col" class="col-hash" data-sort-key="hash" data-priority="1">Hash</th><th scope="col" class="col-size" data-sort-key="size" data-type="numeric" data-priority="4">Size</th>
|
||||
<th scope="col" class="col-expand" data-priority="1"></th><th scope="col" class="col-region" data-sort-key="region" data-priority="3">Region</th><th scope="col" class="col-time" data-sort-key="time" data-type="date" data-priority="1">Time</th><th scope="col" class="col-hash" data-sort-key="hash" data-priority="3">Hash</th><th scope="col" class="col-size" data-sort-key="size" data-type="numeric" data-priority="4">Size</th>
|
||||
<th scope="col" class="col-hashsize" data-sort-key="hb" data-type="numeric" data-priority="5">HB</th>
|
||||
<th scope="col" class="col-type" data-sort-key="type" data-priority="1">Type</th><th scope="col" class="col-observer" data-sort-key="observer" data-priority="1">Observer</th><th scope="col" class="col-path" data-sort-key="path" data-priority="2">Path</th><th scope="col" class="col-rpt" data-sort-key="rpt" data-type="numeric" data-priority="4">Rpt</th><th scope="col" class="col-details" data-priority="2">Details</th>
|
||||
<th scope="col" class="col-type" data-sort-key="type" data-priority="1">Type</th><th scope="col" class="col-observer" data-sort-key="observer" data-priority="3">Observer</th><th scope="col" class="col-path" data-sort-key="path" data-priority="5">Path</th><th scope="col" class="col-rpt" data-sort-key="rpt" data-type="numeric" data-priority="3">Rpt</th><th scope="col" class="col-details" data-priority="1">Details</th>
|
||||
</tr></thead>
|
||||
<tbody id="pktBody"></tbody>
|
||||
</table></div>
|
||||
@@ -2017,7 +2017,7 @@
|
||||
const _grpHashStripe = _hashStripeStyle(p.hash);
|
||||
const _grpStyle = _grpHashStripe + _grpChanStyle;
|
||||
let html = `<tr class="${isSingle ? '' : 'group-header'} ${isExpanded ? 'expanded' : ''}" data-hash="${p.hash}" data-action="${isSingle ? 'select-hash' : 'toggle-select'}" data-value="${p.hash}" data-entry-idx="${entryIdx}" tabindex="0" role="row"${_grpStyle ? ' style="' + _grpStyle + '"' : ''}>
|
||||
<td style="width:28px;text-align:center;cursor:pointer">${isSingle ? '' : (isExpanded ? '▼' : '▶')}</td>
|
||||
<td class="col-expand" style="text-align:center;cursor:pointer">${isSingle ? '' : (isExpanded ? '▼' : '▶')}</td>
|
||||
<td class="col-region">${groupRegion ? `<span class="badge-region">${groupRegion}</span>` : '—'}</td>
|
||||
<td class="col-time">${renderTimestampCell(p.latest)}</td>
|
||||
<td class="mono col-hash" data-filter-field="hash" data-filter-value="${escapeHtml(p.hash || '')}">${truncate(p.hash || '—', 8)}</td>
|
||||
@@ -2044,7 +2044,7 @@
|
||||
const childPathStr = renderPath(childPath, c.observer_id);
|
||||
const _childHashStripe = _hashStripeStyle(c.hash || p.hash);
|
||||
html += `<tr class="group-child" data-id="${c.id}" data-hash="${c.hash || ''}" data-action="select-observation" data-value="${c.id}" data-parent-hash="${p.hash}" data-entry-idx="${entryIdx}" tabindex="0" role="row"${_childHashStripe ? ' style="' + _childHashStripe + '"' : ''}>
|
||||
<td></td><td class="col-region">${childRegion ? `<span class="badge-region">${childRegion}</span>` : '—'}</td>
|
||||
<td class="col-expand"></td><td class="col-region">${childRegion ? `<span class="badge-region">${childRegion}</span>` : '—'}</td>
|
||||
<td class="col-time">${renderTimestampCell(c.timestamp)}</td>
|
||||
<td class="mono col-hash" data-filter-field="hash" data-filter-value="${escapeHtml(c.hash || '')}">${truncate(c.hash || '', 8)}</td>
|
||||
<td class="col-size" data-filter-field="size" data-filter-value="${size || ''}">${size}B</td>
|
||||
@@ -2076,7 +2076,7 @@
|
||||
const _flatHashStripe = _hashStripeStyle(p.hash);
|
||||
const _flatStyle = _flatHashStripe + _chanStyle;
|
||||
return `<tr data-id="${p.id}" data-hash="${p.hash || ''}" data-action="select-hash" data-value="${p.hash || p.id}" data-entry-idx="${entryIdx}" tabindex="0" role="row" class="${selectedId === p.id ? 'selected' : ''}"${_flatStyle ? ' style="' + _flatStyle + '"' : ''}>
|
||||
<td></td><td class="col-region">${region ? `<span class="badge-region">${region}</span>` : '—'}</td>
|
||||
<td class="col-expand"></td><td class="col-region">${region ? `<span class="badge-region">${region}</span>` : '—'}</td>
|
||||
<td class="col-time">${renderTimestampCell(p.timestamp)}</td>
|
||||
<td class="mono col-hash" data-filter-field="hash" data-filter-value="${escapeHtml(p.hash || '')}">${truncate(p.hash || String(p.id), 8)}</td>
|
||||
<td class="col-size" data-filter-field="size" data-filter-value="${size || ''}">${size}B</td>
|
||||
@@ -2920,22 +2920,37 @@
|
||||
rawCustomRow = `<dt>Raw Custom</dt><dd class="raw-custom-detail">Length: <code>${escapeHtml(rl)}</code> · First byte tag: <code>${escapeHtml(ft)}</code></dd>`;
|
||||
}
|
||||
|
||||
// #1458 P0-A — semantic identity header (type badge + decoded summary +
|
||||
// src→dst). Replaces the prior byte-count title that buried packet
|
||||
// identity behind a byte counter (#1458 P0-A).
|
||||
const semanticSummary = getDetailPreview(decoded);
|
||||
const srcLabel = decoded.sender || decoded.name || (decoded.srcHash ? decoded.srcHash.slice(0,8) : null) || (decoded.pubKey ? decoded.pubKey.slice(0,8) + '…' : null);
|
||||
const dstLabel = decoded.recipient || (decoded.destHash ? decoded.destHash.slice(0,8) : null);
|
||||
const srcDstHtml = (srcLabel || dstLabel)
|
||||
? `<div class="detail-srcdst">${escapeHtml(srcLabel || '?')} <span class="arrow">→</span> ${escapeHtml(dstLabel || (decoded.channel ? '#' + decoded.channel : '?'))}</div>`
|
||||
: '';
|
||||
|
||||
panel.innerHTML = `
|
||||
${anomalyBanner}
|
||||
<div class="detail-title">${hasRawHex ? `Packet Byte Breakdown (${size} bytes)` : typeName + ' Packet'}</div>
|
||||
<div class="detail-title">
|
||||
<span class="badge badge-${payloadTypeColor(pkt.payload_type)}">${typeName}</span>
|
||||
${semanticSummary ? `<span class="detail-summary">${semanticSummary}</span>` : ''}
|
||||
${displayHopCount > 0 ? `<span class="badge badge-info">${displayHopCount} hop${displayHopCount !== 1 ? 's' : ''}</span>` : ''}
|
||||
</div>
|
||||
${srcDstHtml}
|
||||
<div class="detail-hash">${pkt.hash || 'Packet #' + pkt.id}${obsIndicator}</div>
|
||||
${messageHtml}
|
||||
<dl class="detail-meta">
|
||||
<dt>Payload Type</dt><dd><span class="badge badge-${payloadTypeColor(pkt.payload_type)}">${typeName}</span></dd>
|
||||
<dt>Path</dt><dd>${displayHopCount > 0 ? `<span class="badge badge-info">${displayHopCount} hop${displayHopCount !== 1 ? 's' : ''}</span> ` + renderPath(pathHops, effectivePkt.observer_id) : '— (direct)'}</dd>
|
||||
<dt>Timestamp</dt><dd>${renderTimestampCell(effectivePkt.timestamp)}</dd>
|
||||
<dt>Observer</dt><dd>${obsNameOnly(effectivePkt.observer_id)}${obsIataBadge(effectivePkt)}</dd>
|
||||
${locationHtml ? `<dt>Location</dt><dd>${locationHtml}</dd>` : ''}
|
||||
<dt>SNR / RSSI</dt><dd>${snr != null ? snr + ' dB' : '—'} / ${rssi != null ? rssi + ' dBm' : '—'}</dd>
|
||||
<dt>Route Type</dt><dd>${routeTypeName(pkt.route_type)}</dd>
|
||||
${pkt.scope_name != null ? `<dt>Scope</dt><dd>${pkt.scope_name !== '' ? escapeHtml(pkt.scope_name) : '<span style="color:var(--text-muted)">unknown scope</span>'}</dd>` : ''}
|
||||
<dt>Payload Type</dt><dd><span class="badge badge-${payloadTypeColor(pkt.payload_type)}">${typeName}</span></dd>
|
||||
${hashSize ? `<dt>Hash Size</dt><dd>${hashSize} byte${hashSize !== 1 ? 's' : ''}</dd>` : ''}
|
||||
<dt>Timestamp</dt><dd>${renderTimestampCell(effectivePkt.timestamp)}</dd>
|
||||
<dt>Propagation</dt><dd>${propagationHtml}</dd>
|
||||
<dt>Path</dt><dd>${displayHopCount > 0 ? `<span class="badge badge-info">${displayHopCount} hop${displayHopCount !== 1 ? 's' : ''}</span> ` + renderPath(pathHops, effectivePkt.observer_id) : '— (direct)'}</dd>
|
||||
${transportCodesRow}
|
||||
${rawCustomRow}
|
||||
${effectivePkt.direction ? `<dt>Direction</dt><dd>${escapeHtml(effectivePkt.direction)}</dd>` : ''}
|
||||
@@ -2947,10 +2962,13 @@
|
||||
<button class="replay-live-btn" title="Replay this packet on the live map">▶ Replay</button>
|
||||
</div>
|
||||
|
||||
${hasRawHex ? `<div class="hex-legend">${buildHexLegend(ranges)}</div>
|
||||
<div class="hex-dump">${createColoredHexDump(effectivePkt.raw_hex || pkt.raw_hex, ranges)}</div>` : ''}
|
||||
${(hasRawHex || Object.keys(decoded).length) ? `<details class="detail-technical"${(typeof window !== 'undefined' && window.innerWidth > 480) ? ' open' : ''}>
|
||||
<summary>Show raw bytes</summary>
|
||||
${hasRawHex ? `<div class="hex-legend">${buildHexLegend(ranges)}</div>
|
||||
<div class="hex-dump">${createColoredHexDump(effectivePkt.raw_hex || pkt.raw_hex, ranges)}</div>` : ''}
|
||||
|
||||
${hasRawHex ? buildFieldTable(effectivePkt.raw_hex ? effectivePkt : pkt, decoded, pathHops, ranges) : buildDecodedTable(decoded)}
|
||||
${hasRawHex ? buildFieldTable(effectivePkt.raw_hex ? effectivePkt : pkt, decoded, pathHops, ranges) : buildDecodedTable(decoded)}
|
||||
</details>` : ''}
|
||||
|
||||
${observations.length > 1 ? `
|
||||
<div class="detail-observations" style="margin-top:16px">
|
||||
@@ -3076,11 +3094,44 @@
|
||||
else if (decoded.srcHash) origin.pubkey = decoded.srcHash;
|
||||
if (decoded.adName || decoded.name) origin.name = decoded.adName || decoded.name;
|
||||
if (senderLat != null && senderLon != null) { origin.lat = senderLat; origin.lon = senderLon; }
|
||||
sessionStorage.setItem('map-route-hops', JSON.stringify({
|
||||
origin: origin,
|
||||
hops: resolvedKeys
|
||||
}));
|
||||
window.location.hash = '#/map?route=1';
|
||||
// #1418 Phase D: also include the recipient (destHash) so the route
|
||||
// displays as: sender → [intermediate hops] → recipient. Without
|
||||
// this the destination node is invisible — operator only sees the
|
||||
// last intermediate repeater.
|
||||
const destination = {};
|
||||
if (decoded.destHash) destination.pubkey = decoded.destHash;
|
||||
// #1418 Phase C: include ALL observations as alternate paths so the
|
||||
// route view can render union-of-edges with stroke-width weighting.
|
||||
// Each observation contributes its own path_json array.
|
||||
const allPaths = (observations || []).map(o => {
|
||||
let path = [];
|
||||
try { path = JSON.parse(o.path_json || '[]'); } catch (_) {}
|
||||
return { path: path, observer: o.observer_name, observer_id: o.observer_id, snr: o.snr, rssi: o.rssi };
|
||||
}).filter(p => p.path && p.path.length > 0);
|
||||
// #1418/#1419: navigate via deep-link URL only. The map page's
|
||||
// loadRouteFromDeepLink() re-fetches the packet from the API and
|
||||
// builds the full payload (incl. packetContext) consistently.
|
||||
// SessionStorage was unreliable — the deep-link path includes
|
||||
// packetContext but the sessionStorage payload didn't, leading
|
||||
// to missing chip + facts when entered from the packets page.
|
||||
const obsId = currentObs ? currentObs.id : (observations[0] && observations[0].id);
|
||||
const pkHash = pkt.hash || pkt.packet_hash;
|
||||
const obsPart = obsId ? '&obs=' + encodeURIComponent(obsId) : '';
|
||||
// Tufte audit fix: close ALL mobile packet panels so operator lands
|
||||
// on the route view, not behind a still-visible detail sheet.
|
||||
// Three different panels exist depending on viewport + flow:
|
||||
// - #pktRight (desktop split-pane)
|
||||
// - .slide-over-panel (mid-width SlideOver)
|
||||
// - #mobileDetailSheet (small-mobile bottom sheet)
|
||||
if (window.innerWidth <= 767) {
|
||||
try { closeDetailPanel(); } catch (_) {}
|
||||
try { if (window.SlideOver && window.SlideOver.close) window.SlideOver.close(); } catch (_) {}
|
||||
try {
|
||||
const sheet = document.getElementById('mobileDetailSheet');
|
||||
if (sheet) sheet.classList.remove('open');
|
||||
} catch (_) {}
|
||||
}
|
||||
window.location.hash = '#/map?packet=' + encodeURIComponent(pkHash) + obsPart;
|
||||
} catch {
|
||||
window.location.hash = '#/map';
|
||||
}
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
/* === CoreScope — prefix-reserved.js =====================================
|
||||
*
|
||||
* Issue #1473 — Flag prefixes that the MeshCore firmware keygen routine
|
||||
* avoids by convention.
|
||||
*
|
||||
* Scope (narrow, per meshcore-protocol-expert review):
|
||||
* - This is a FIRMWARE KEYGEN CONVENTION, not a protocol-level rule.
|
||||
* The standard repeater example re-rolls any new identity whose
|
||||
* public-key FIRST BYTE is 0x00 or 0xFF, so in practice you should
|
||||
* never see a node prefix of 00 or FF in the wild.
|
||||
* - We only check the FIRST byte. Other bytes 00/FF inside a pubkey are
|
||||
* perfectly normal (~96% of pubkeys contain a 00 or FF byte somewhere).
|
||||
* - There is NO protocol rejection of such pubkeys and NO routing-level
|
||||
* wildcard semantics tied to dest_hash == 0xFF.
|
||||
*
|
||||
* Firmware citation (HEAD 8ede7641, examples/simple_repeater/main.cpp:83):
|
||||
*
|
||||
* while (count < 10 && (the_mesh.self_id.pub_key[0] == 0x00
|
||||
* || the_mesh.self_id.pub_key[0] == 0xFF)) {
|
||||
* // reserved id hashes
|
||||
* the_mesh.self_id = radio_new_identity(); count++;
|
||||
* }
|
||||
*
|
||||
* https://github.com/meshcore-dev/MeshCore/blob/8ede7641/examples/simple_repeater/main.cpp#L83
|
||||
*
|
||||
* Surfaces that consume this helper:
|
||||
* - Prefix matrix (analytics.js → renderHashMatrixFromServer, 1-byte view):
|
||||
* grey 00 / FF cells and disable click, tooltip explains the convention.
|
||||
* - Prefix generator (analytics.js → renderPrefixTool.doGenerate):
|
||||
* never suggest a prefix whose first byte is 00 / FF; visible note.
|
||||
*
|
||||
* Reporter: @halo779 (community).
|
||||
* ========================================================================= */
|
||||
'use strict';
|
||||
|
||||
(function (root) {
|
||||
// First-byte reservations as uppercase 2-char hex strings.
|
||||
var RESERVED_FIRST_BYTES = ['00', 'FF'];
|
||||
var RESERVED_CLASS = 'prefix-reserved';
|
||||
var RESERVED_NOTE = '0x00 and 0xFF excluded — the MeshCore firmware keygen routine avoids these as the first byte of a node pubkey.';
|
||||
var RESERVED_TITLE =
|
||||
'0x00 and 0xFF as a first byte are avoided by the MeshCore firmware keygen convention (the standard repeater re-rolls identities whose pub_key[0] is 0x00 or 0xFF), so you should not pick them as a node prefix.';
|
||||
|
||||
function isReservedPrefix(prefix) {
|
||||
if (prefix == null) return false;
|
||||
var s = String(prefix);
|
||||
if (s.length < 2) return false;
|
||||
var head = s.slice(0, 2).toUpperCase();
|
||||
for (var i = 0; i < RESERVED_FIRST_BYTES.length; i++) {
|
||||
if (head === RESERVED_FIRST_BYTES[i]) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function filterReserved(prefixes) {
|
||||
var out = [];
|
||||
for (var i = 0; i < prefixes.length; i++) {
|
||||
if (!isReservedPrefix(prefixes[i])) out.push(prefixes[i]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// How many prefixes of `bytes` length the reservation removes from the
|
||||
// total space of 256^bytes. (For each reserved first byte the entire
|
||||
// 256^(bytes-1) tail is reserved.)
|
||||
function reservedCount(bytes) {
|
||||
var b = Number(bytes) || 1;
|
||||
if (b < 1) return 0;
|
||||
return RESERVED_FIRST_BYTES.length * Math.pow(256, b - 1);
|
||||
}
|
||||
|
||||
// Given a DOM root (or any object exposing querySelectorAll), find
|
||||
// hash-matrix cells whose data-hex first byte is reserved, mark them
|
||||
// .prefix-reserved + aria-disabled, strip .hash-active so the matrix's
|
||||
// click wiring skips them, and set a tooltip explaining why.
|
||||
// Returns the count of cells marked.
|
||||
function markReservedCells(root) {
|
||||
if (!root || typeof root.querySelectorAll !== 'function') return 0;
|
||||
var cells = root.querySelectorAll('[data-hex]');
|
||||
var n = 0;
|
||||
for (var i = 0; i < cells.length; i++) {
|
||||
var td = cells[i];
|
||||
var hex = (typeof td.getAttribute === 'function')
|
||||
? td.getAttribute('data-hex')
|
||||
: (td.dataset && td.dataset.hex);
|
||||
if (!isReservedPrefix(hex)) continue;
|
||||
if (td.classList && typeof td.classList.add === 'function') {
|
||||
td.classList.add(RESERVED_CLASS);
|
||||
td.classList.remove('hash-active');
|
||||
}
|
||||
if (typeof td.setAttribute === 'function') {
|
||||
td.setAttribute('aria-disabled', 'true');
|
||||
td.setAttribute('title', RESERVED_TITLE);
|
||||
}
|
||||
n++;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
var api = {
|
||||
RESERVED_FIRST_BYTES: RESERVED_FIRST_BYTES.slice(),
|
||||
RESERVED_CLASS: RESERVED_CLASS,
|
||||
RESERVED_NOTE: RESERVED_NOTE,
|
||||
RESERVED_TITLE: RESERVED_TITLE,
|
||||
isReservedPrefix: isReservedPrefix,
|
||||
filterReserved: filterReserved,
|
||||
reservedCount: reservedCount,
|
||||
markReservedCells: markReservedCells,
|
||||
};
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
||||
if (root) root.PrefixReserved = api;
|
||||
})(typeof window !== 'undefined' ? window : globalThis);
|
||||
+313
-16
@@ -9,10 +9,159 @@
|
||||
|
||||
(function () {
|
||||
// ─── Role definitions ───
|
||||
window.ROLE_COLORS = {
|
||||
repeater: '#dc2626', companion: '#2563eb', room: '#16a34a',
|
||||
sensor: '#d97706', observer: '#8b5cf6', unknown: '#6b7280'
|
||||
// #1407 — Wong palette defaults that match the unscoped --mc-role-* CSS
|
||||
// vars in :root of style.css. These are FALLBACKS only — the live getter
|
||||
// below reads --mc-role-* from documentElement on every access, so any
|
||||
// preset switch (cb-presets.js) is reflected immediately without per-page
|
||||
// listener wiring. The legacy April palette (#dc2626 etc.) was the bug.
|
||||
var WONG_ROLE_DEFAULTS = {
|
||||
repeater: '#D55E00',
|
||||
companion: '#56B4E9',
|
||||
room: '#009E73',
|
||||
sensor: '#F0E442',
|
||||
observer: '#CC79A7',
|
||||
unknown: '#6b7280'
|
||||
};
|
||||
var WONG_ROLE_TEXT_DEFAULTS = {
|
||||
repeater: '#1a1a1a', companion: '#1a1a1a', room: '#1a1a1a',
|
||||
sensor: '#1a1a1a', observer: '#1a1a1a', unknown: '#1a1a1a'
|
||||
};
|
||||
|
||||
function _readCssVar(name, fallback) {
|
||||
try {
|
||||
if (typeof document === 'undefined' || !document.documentElement) return fallback;
|
||||
var v = '';
|
||||
if (typeof getComputedStyle === 'function') {
|
||||
v = getComputedStyle(document.documentElement).getPropertyValue(name);
|
||||
}
|
||||
if (!v && document.documentElement.style && typeof document.documentElement.style.getPropertyValue === 'function') {
|
||||
v = document.documentElement.style.getPropertyValue(name);
|
||||
}
|
||||
v = (v || '').trim();
|
||||
return v || fallback;
|
||||
} catch (e) { return fallback; }
|
||||
}
|
||||
|
||||
// Server-config overrides go into this object; the getter prefers them
|
||||
// when present so backend-pushed role colors still win over CSS vars.
|
||||
var _roleOverrides = {};
|
||||
|
||||
function _liveRoleColors() {
|
||||
var base = {};
|
||||
var roles = ['repeater', 'companion', 'room', 'sensor', 'observer'];
|
||||
for (var i = 0; i < roles.length; i++) {
|
||||
var k = roles[i];
|
||||
base[k] = _roleOverrides[k] || _readCssVar('--mc-role-' + k, WONG_ROLE_DEFAULTS[k]);
|
||||
}
|
||||
base.unknown = _roleOverrides.unknown || WONG_ROLE_DEFAULTS.unknown;
|
||||
// Wrap in a Proxy so per-key assignment by legacy callers (customizer:
|
||||
// `window.ROLE_COLORS[key] = inp.value`) lands in _roleOverrides and
|
||||
// is visible on the NEXT read. Without this, the mutation would be
|
||||
// thrown away when the snapshot is GC'd. Falls back to a plain object
|
||||
// in environments without Proxy (none we ship to, but cheap).
|
||||
if (typeof Proxy === 'function') {
|
||||
return new Proxy(base, {
|
||||
set: function (t, prop, value) {
|
||||
_roleOverrides[prop] = value;
|
||||
t[prop] = value;
|
||||
return true;
|
||||
}
|
||||
});
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
Object.defineProperty(window, 'ROLE_COLORS', {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
get: function () { return _liveRoleColors(); },
|
||||
// Setter accepts per-key writes — older callers do
|
||||
// `ROLE_COLORS.repeater = '#xxx'`
|
||||
// which on a getter-only object would silently no-op in strict mode.
|
||||
// We treat any whole-object assignment as an override merge so the
|
||||
// legacy customizer code path still works.
|
||||
set: function (v) {
|
||||
if (v && typeof v === 'object') {
|
||||
for (var k in v) if (Object.prototype.hasOwnProperty.call(v, k)) _roleOverrides[k] = v[k];
|
||||
}
|
||||
}
|
||||
});
|
||||
// Per-key writes via Proxy not portable enough — expose helper for callers
|
||||
// that want to override at runtime (customizer "node colors" path).
|
||||
// #1438: snapshot of the cb-preset (or initial) CSS-var value per role
|
||||
// so that clearing an override restores the preset, not nothing.
|
||||
// We write the override to BOTH documentElement and body inline styles
|
||||
// because cb-presets ships stylesheet rules of the form
|
||||
// body[data-cb-preset="deut"] { --mc-role-X: #...; }
|
||||
// which beats inheritance from :root. Body inline beats both.
|
||||
var _presetCssSnapshot = {};
|
||||
function _styleTargets() {
|
||||
var t = [];
|
||||
try { if (document.documentElement && document.documentElement.style) t.push(document.documentElement.style); } catch (e) {}
|
||||
try { if (document.body && document.body.style) t.push(document.body.style); } catch (e) {}
|
||||
return t.filter(function (s) { return s && typeof s.setProperty === 'function'; });
|
||||
}
|
||||
window.setRoleColorOverride = function (role, hex) {
|
||||
if (!role) return;
|
||||
var targets = _styleTargets();
|
||||
var varName = '--mc-role-' + role;
|
||||
|
||||
if (hex == null || hex === '') {
|
||||
// Clear override → restore prior CSS var values captured at
|
||||
// first-override time, so CSS-var consumers see the preset color
|
||||
// again (matches JS getter behavior, preserves #1412 contract).
|
||||
delete _roleOverrides[role];
|
||||
if (Object.prototype.hasOwnProperty.call(_presetCssSnapshot, role)) {
|
||||
var snap = _presetCssSnapshot[role] || {};
|
||||
targets.forEach(function (s, i) {
|
||||
var prior = snap[i];
|
||||
if (prior && prior.length) s.setProperty(varName, prior);
|
||||
else s.removeProperty(varName);
|
||||
});
|
||||
delete _presetCssSnapshot[role];
|
||||
} else {
|
||||
targets.forEach(function (s) { s.removeProperty(varName); });
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Capture the current per-target CSS var values before overwriting,
|
||||
// but only on the first override for this role so repeated picks
|
||||
// don't lose the original preset value.
|
||||
if (!Object.prototype.hasOwnProperty.call(_presetCssSnapshot, role)) {
|
||||
_presetCssSnapshot[role] = targets.map(function (s) {
|
||||
return s.getPropertyValue ? (s.getPropertyValue(varName) || '').trim() : '';
|
||||
});
|
||||
}
|
||||
_roleOverrides[role] = hex;
|
||||
// #1438: drive the CSS var so CSS-var consumers (cluster pills,
|
||||
// route lines, all marker SVGs that use fill="var(--mc-role-X)")
|
||||
// pick up the operator's hex without a page reload. Writing to
|
||||
// body inline style is necessary because body[data-cb-preset="..."]
|
||||
// selectors beat :root inheritance.
|
||||
//
|
||||
// #1446: write with !important so the inline body declaration also
|
||||
// beats the body[data-cb-preset="X"] CSS rule on equal specificity.
|
||||
// Without !important, the cascade order picks the later-defined
|
||||
// stylesheet rule in some browser versions even though specificity
|
||||
// (1,0,1) matches the inline body style — operator pick visibly
|
||||
// loses to active preset (root cause of #1444).
|
||||
targets.forEach(function (s) {
|
||||
// documentElement gets the value without !important (used as the
|
||||
// canonical readout for the JS getter); body gets !important so it
|
||||
// wins the CSS cascade against body[data-cb-preset="X"].
|
||||
if (s === (document.body && document.body.style)) {
|
||||
s.setProperty(varName, hex, 'important');
|
||||
} else {
|
||||
s.setProperty(varName, hex);
|
||||
}
|
||||
});
|
||||
};
|
||||
// Back-compat: also export the writable override map so customize.js's
|
||||
// `window.ROLE_COLORS[key] = inp.value` style mutation works.
|
||||
// We intercept by replacing the getter target with a Proxy on access.
|
||||
Object.defineProperty(window, 'ROLE_COLORS_OVERRIDES', {
|
||||
value: _roleOverrides, writable: false, enumerable: false, configurable: false
|
||||
});
|
||||
|
||||
window.TYPE_COLORS = {
|
||||
ADVERT: '#22c55e', GRP_TXT: '#3b82f6', GRP_DATA: '#8b5cf6', TXT_MSG: '#f59e0b', ACK: '#6b7280',
|
||||
@@ -55,16 +204,128 @@
|
||||
sensor: 'Sensors', observer: 'Observers'
|
||||
};
|
||||
|
||||
window.ROLE_STYLE = {
|
||||
repeater: { color: '#dc2626', shape: 'diamond', radius: 10, weight: 2 },
|
||||
companion: { color: '#2563eb', shape: 'circle', radius: 8, weight: 2 },
|
||||
room: { color: '#16a34a', shape: 'square', radius: 9, weight: 2 },
|
||||
sensor: { color: '#d97706', shape: 'triangle', radius: 8, weight: 2 },
|
||||
observer: { color: '#8b5cf6', shape: 'star', radius: 11, weight: 2 }
|
||||
// #1293 — Marker shape per role (WCAG 1.4.1 — shape, not only colour).
|
||||
// Single source of truth; ROLE_STYLE.shape is derived from this map.
|
||||
window.ROLE_SHAPES = {
|
||||
repeater: 'circle',
|
||||
companion: 'square',
|
||||
room: 'hexagon',
|
||||
sensor: 'triangle',
|
||||
observer: 'diamond'
|
||||
};
|
||||
|
||||
// #1407 — ROLE_STYLE.color reads live (matches ROLE_COLORS getter).
|
||||
// The shape/radius/weight stay static. Stored overrides survive across
|
||||
// reads via the closure above.
|
||||
var _styleShapes = {
|
||||
repeater: { shape: 'circle', radius: 8, weight: 2 },
|
||||
companion: { shape: 'square', radius: 8, weight: 2 },
|
||||
room: { shape: 'hexagon', radius: 9, weight: 2 },
|
||||
sensor: { shape: 'triangle', radius: 8, weight: 2 },
|
||||
observer: { shape: 'diamond', radius: 9, weight: 2 }
|
||||
};
|
||||
function _buildRoleStyle() {
|
||||
var out = {};
|
||||
var live = _liveRoleColors();
|
||||
for (var role in _styleShapes) {
|
||||
var s = _styleShapes[role];
|
||||
out[role] = {
|
||||
color: _roleOverrides[role] || live[role],
|
||||
shape: s.shape,
|
||||
radius: s.radius,
|
||||
weight: s.weight
|
||||
};
|
||||
}
|
||||
return out;
|
||||
}
|
||||
Object.defineProperty(window, 'ROLE_STYLE', {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
get: function () { return _buildRoleStyle(); },
|
||||
set: function (v) {
|
||||
// Legacy whole-object assignment: copy color overrides only.
|
||||
if (v && typeof v === 'object') {
|
||||
for (var k in v) if (v[k] && v[k].color) _roleOverrides[k] = v[k].color;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Glyphs mirror the ROLE_SHAPES (used in tooltips, legends, lists).
|
||||
window.ROLE_EMOJI = {
|
||||
repeater: '◆', companion: '●', room: '■', sensor: '▲', observer: '★'
|
||||
repeater: '●', companion: '■', room: '⬢', sensor: '▲', observer: '◆'
|
||||
};
|
||||
|
||||
/**
|
||||
* #1293 — Shared SVG marker generator. Returns a self-contained
|
||||
* <svg>...</svg> string for the given role/colour/size, with white
|
||||
* stroke for contrast (works on both dark + light tiles). Used by:
|
||||
* - public/live.js → addNodeMarker (L.divIcon)
|
||||
* - public/live.js → role legend swatches
|
||||
* - public/map.js → makeMarkerIcon (legacy switch retained for
|
||||
* per-role overrides + observer star overlay)
|
||||
*
|
||||
* Reads ROLE_SHAPES for the role's geometry; falls back to circle.
|
||||
* Caller controls colour to allow theming overrides (matrix mode,
|
||||
* stale dim, etc.) without rebuilding the marker.
|
||||
*/
|
||||
window.makeRoleMarkerSVG = function (role, color, size) {
|
||||
var shape = (window.ROLE_SHAPES && window.ROLE_SHAPES[role]) || 'circle';
|
||||
size = size || 16;
|
||||
var c = size / 2;
|
||||
// #1438: default fill resolves through the live CSS var so existing
|
||||
// mounted SVG markers recolor when cb-preset switches or the
|
||||
// operator picks a per-role override via the customizer. Callers
|
||||
// that need a fixed tint (matrix mode, stale dim) keep passing
|
||||
// their explicit colour.
|
||||
var fill = color || ('var(--mc-role-' + (role || 'companion') + ')');
|
||||
var path;
|
||||
switch (shape) {
|
||||
case 'square':
|
||||
path = '<rect x="3" y="3" width="' + (size - 6) + '" height="' + (size - 6) +
|
||||
'" fill="' + fill + '" stroke="#fff" stroke-width="1"/>';
|
||||
break;
|
||||
case 'triangle':
|
||||
path = '<polygon points="' + c + ',2 ' + (size - 2) + ',' + (size - 2) +
|
||||
' 2,' + (size - 2) + '" fill="' + fill + '" stroke="#fff" stroke-width="1"/>';
|
||||
break;
|
||||
case 'diamond':
|
||||
path = '<polygon points="' + c + ',2 ' + (size - 2) + ',' + c + ' ' +
|
||||
c + ',' + (size - 2) + ' 2,' + c +
|
||||
'" fill="' + fill + '" stroke="#fff" stroke-width="1"/>';
|
||||
break;
|
||||
case 'hexagon': {
|
||||
// Pointy-top hexagon centred at (c,c), inscribed radius ≈ c-1.5
|
||||
var r = c - 1.5;
|
||||
var pts = '';
|
||||
for (var i = 0; i < 6; i++) {
|
||||
var a = (i * 60 - 90) * Math.PI / 180;
|
||||
pts += (c + r * Math.cos(a)).toFixed(2) + ',' +
|
||||
(c + r * Math.sin(a)).toFixed(2) + ' ';
|
||||
}
|
||||
path = '<polygon points="' + pts.trim() + '" fill="' + fill +
|
||||
'" stroke="#fff" stroke-width="1"/>';
|
||||
break;
|
||||
}
|
||||
case 'star': {
|
||||
var cx = c, cy = c, outer = c - 1, inner = outer * 0.4;
|
||||
var spts = '';
|
||||
for (var j = 0; j < 5; j++) {
|
||||
var aO = (j * 72 - 90) * Math.PI / 180;
|
||||
var aI = ((j * 72) + 36 - 90) * Math.PI / 180;
|
||||
spts += (cx + outer * Math.cos(aO)) + ',' + (cy + outer * Math.sin(aO)) + ' ';
|
||||
spts += (cx + inner * Math.cos(aI)) + ',' + (cy + inner * Math.sin(aI)) + ' ';
|
||||
}
|
||||
path = '<polygon points="' + spts.trim() + '" fill="' + fill +
|
||||
'" stroke="#fff" stroke-width="1"/>';
|
||||
break;
|
||||
}
|
||||
default: // circle
|
||||
path = '<circle cx="' + c + '" cy="' + c + '" r="' + (c - 2) +
|
||||
'" fill="' + fill + '" stroke="#fff" stroke-width="1"/>';
|
||||
}
|
||||
return '<svg width="' + size + '" height="' + size +
|
||||
'" viewBox="0 0 ' + size + ' ' + size +
|
||||
'" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">' + path + '</svg>';
|
||||
};
|
||||
|
||||
window.ROLE_SORT = ['repeater', 'companion', 'room', 'sensor', 'observer'];
|
||||
@@ -102,7 +363,35 @@
|
||||
var isDark = document.documentElement.getAttribute('data-theme') === 'dark' ||
|
||||
(document.documentElement.getAttribute('data-theme') !== 'light' &&
|
||||
window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
return isDark ? TILE_DARK : TILE_LIGHT;
|
||||
if (!isDark) return TILE_LIGHT;
|
||||
// #1461 followup: honor customizer's dark-tile-provider pick (#1420 / #1430)
|
||||
// when the registry is loaded. Falls back to TILE_DARK if absent.
|
||||
try {
|
||||
if (window.MC_getDarkTileProvider && window.MC_TILE_PROVIDERS) {
|
||||
var id = window.MC_getDarkTileProvider();
|
||||
var p = window.MC_TILE_PROVIDERS[id];
|
||||
if (p && (p.url || p.baseUrl)) {
|
||||
return p.url || p.baseUrl;
|
||||
}
|
||||
}
|
||||
} catch (_e) {}
|
||||
return TILE_DARK;
|
||||
};
|
||||
/* Helper: get the full provider object (for callers that also need the
|
||||
* invertFilter or refUrl/attribution). Returns null when no customizer
|
||||
* provider applies (light mode, or registry not loaded). */
|
||||
window.getActiveTileProvider = function () {
|
||||
var isDark = document.documentElement.getAttribute('data-theme') === 'dark' ||
|
||||
(document.documentElement.getAttribute('data-theme') !== 'light' &&
|
||||
window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
if (!isDark) return null;
|
||||
try {
|
||||
if (window.MC_getDarkTileProvider && window.MC_TILE_PROVIDERS) {
|
||||
var id = window.MC_getDarkTileProvider();
|
||||
return window.MC_TILE_PROVIDERS[id] || null;
|
||||
}
|
||||
} catch (_e) {}
|
||||
return null;
|
||||
};
|
||||
|
||||
// ─── SNR thresholds ───
|
||||
@@ -145,10 +434,16 @@
|
||||
// ─── Fetch server overrides ───
|
||||
window.MeshConfigReady = fetch('/api/config/client').then(function (r) { return r.json(); }).then(function (cfg) {
|
||||
if (cfg.roles) {
|
||||
if (cfg.roles.colors) Object.assign(ROLE_COLORS, cfg.roles.colors);
|
||||
if (cfg.roles.colors) {
|
||||
// #1407 — ROLE_COLORS is now a live getter; merge into the override map.
|
||||
for (var rk in cfg.roles.colors) _roleOverrides[rk] = cfg.roles.colors[rk];
|
||||
}
|
||||
if (cfg.roles.labels) Object.assign(ROLE_LABELS, cfg.roles.labels);
|
||||
if (cfg.roles.style) {
|
||||
for (var k in cfg.roles.style) ROLE_STYLE[k] = Object.assign(ROLE_STYLE[k] || {}, cfg.roles.style[k]);
|
||||
// Same: merge color overrides only; shape/radius/weight come from _styleShapes.
|
||||
for (var sk in cfg.roles.style) {
|
||||
if (cfg.roles.style[sk] && cfg.roles.style[sk].color) _roleOverrides[sk] = cfg.roles.style[sk].color;
|
||||
}
|
||||
}
|
||||
if (cfg.roles.emoji) Object.assign(ROLE_EMOJI, cfg.roles.emoji);
|
||||
if (cfg.roles.sort) window.ROLE_SORT = cfg.roles.sort;
|
||||
@@ -158,6 +453,10 @@
|
||||
if (cfg.tiles.dark) window.TILE_DARK = cfg.tiles.dark;
|
||||
if (cfg.tiles.light) window.TILE_LIGHT = cfg.tiles.light;
|
||||
}
|
||||
// #1420 — server default for dark-tile provider picker.
|
||||
if (typeof cfg.mapDarkTileProvider === 'string' && typeof window.MC_setServerDefaultTileProvider === 'function') {
|
||||
window.MC_setServerDefaultTileProvider(cfg.mapDarkTileProvider);
|
||||
}
|
||||
if (cfg.snrThresholds) Object.assign(SNR_THRESHOLDS, cfg.snrThresholds);
|
||||
if (cfg.distThresholds) Object.assign(DIST_THRESHOLDS, cfg.distThresholds);
|
||||
if (cfg.maxHopDist != null) window.MAX_HOP_DIST = cfg.maxHopDist;
|
||||
@@ -168,9 +467,7 @@
|
||||
if (cfg.externalUrls) Object.assign(EXTERNAL_URLS, cfg.externalUrls);
|
||||
if (cfg.propagationBufferMs != null) window.PROPAGATION_BUFFER_MS = cfg.propagationBufferMs;
|
||||
// Sync ROLE_STYLE colors with ROLE_COLORS
|
||||
for (var role in ROLE_STYLE) {
|
||||
if (ROLE_COLORS[role]) ROLE_STYLE[role].color = ROLE_COLORS[role];
|
||||
}
|
||||
// #1407 — both are now live getters; no manual sync needed. Kept as no-op for clarity.
|
||||
}).catch(function () { /* use defaults */ });
|
||||
|
||||
// ─── Built-in IATA airport code → city name mapping ───
|
||||
|
||||
@@ -0,0 +1,452 @@
|
||||
/**
|
||||
* #1374 — Packet-route map renderer.
|
||||
*
|
||||
* Pure-ish renderer for a resolved packet route on top of a Leaflet map.
|
||||
* Caller resolves hops (server- or client-side) and passes the positions
|
||||
* array as [origin, hop1, hop2, …, destination]. This module owns:
|
||||
*
|
||||
* - role-aware shape markers (reuses window.makeRoleMarkerSVG)
|
||||
* - origin / destination visual + semantic distinction
|
||||
* - sequence-number badges beside each marker (not in label text)
|
||||
* - directional <marker-end> arrows on edges
|
||||
* - per-hop color gradient (bright → fading)
|
||||
* - per-marker role="img" + aria-label "Hop N of M, <name>, <role>"
|
||||
* - per-edge aria-label "Hop N → N+1, ~Xkm"
|
||||
* - reuses window.deconflictLabels (registered by map.js)
|
||||
* - collapsible legend panel
|
||||
* - "Route observed at <timestamp>" toolbar context label
|
||||
* - partial-route: ch-unresolved class + "X of N hops resolved" badge
|
||||
*
|
||||
* Animations gate on `prefers-reduced-motion`; high-contrast / forced-colors
|
||||
* mode is handled by CSS.
|
||||
*
|
||||
* See test-issue-1374-route-map-a11y-e2e.js for the contract.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
// Wong palette: per-hop sequence gradient, bright → fading.
|
||||
// Used purely as a redundant carrier alongside the sequence-number badge,
|
||||
// so colorblind / forced-colors users still read the order from the badge.
|
||||
function seqColor(idx, total) {
|
||||
if (total <= 1) return '#56F0A0';
|
||||
// HSL: 152° (green) full-bright at idx=0 → 18° (orange) at last hop.
|
||||
var t = idx / Math.max(1, total - 1);
|
||||
var hue = 152 - 134 * t;
|
||||
var sat = 70;
|
||||
var light = 50 + 8 * t;
|
||||
return 'hsl(' + hue.toFixed(0) + ',' + sat + '%,' + light + '%)';
|
||||
}
|
||||
|
||||
function haversineKm(a, b) {
|
||||
if (a.lat == null || b.lat == null) return null;
|
||||
var R = 6371;
|
||||
var dLat = (b.lat - a.lat) * Math.PI / 180;
|
||||
var dLon = (b.lon - a.lon) * Math.PI / 180;
|
||||
var la1 = a.lat * Math.PI / 180, la2 = b.lat * Math.PI / 180;
|
||||
var h = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
||||
Math.cos(la1) * Math.cos(la2) *
|
||||
Math.sin(dLon / 2) * Math.sin(dLon / 2);
|
||||
return Math.round(R * 2 * Math.atan2(Math.sqrt(h), Math.sqrt(1 - h)));
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
|
||||
return ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the role-aware marker SVG for a hop. Origin and destination get a
|
||||
* larger outline + a glyph (▶ / ⚑) layered on the standard role shape so
|
||||
* the role information remains visible.
|
||||
*/
|
||||
function buildHopSVG(p, opts) {
|
||||
var size = opts.size || 22;
|
||||
var role = p.role || 'companion';
|
||||
var color = opts.color;
|
||||
var inner = (window.makeRoleMarkerSVG &&
|
||||
window.makeRoleMarkerSVG(role, color, size)) ||
|
||||
'<svg width="' + size + '" height="' + size + '"><circle cx="' + (size / 2) +
|
||||
'" cy="' + (size / 2) + '" r="' + (size / 2 - 2) + '" fill="' + color +
|
||||
'" stroke="#fff" stroke-width="1"/></svg>';
|
||||
// Outer ring for origin/destination
|
||||
var outerSize = (opts.isOrigin || opts.isDest) ? size + 10 : size + 4;
|
||||
var pad = (outerSize - size) / 2;
|
||||
var ringStroke = opts.isOrigin ? '#06b6d4' : opts.isDest ? '#ef4444' : '#666';
|
||||
var ringWidth = (opts.isOrigin || opts.isDest) ? 2.4 : 1.2;
|
||||
var ringDash = opts.unresolved ? '4 3' : 'none';
|
||||
var ringFill = opts.unresolved ? 'rgba(150,150,150,0.15)' : 'none';
|
||||
|
||||
var glyph = '';
|
||||
if (opts.isOrigin) {
|
||||
glyph = '<text x="' + (outerSize / 2) + '" y="' + (outerSize / 2 + 4) +
|
||||
'" text-anchor="middle" font-size="11" font-weight="700" fill="#0f172a" aria-hidden="true">\u25B6</text>';
|
||||
} else if (opts.isDest) {
|
||||
glyph = '<text x="' + (outerSize / 2) + '" y="' + (outerSize / 2 + 4) +
|
||||
'" text-anchor="middle" font-size="12" font-weight="700" fill="#0f172a" aria-hidden="true">\u2691</text>';
|
||||
}
|
||||
|
||||
// Strip outer <svg> from inner SVG, re-wrap with outer ring + glyph
|
||||
var innerBody = inner.replace(/^<svg[^>]*>/, '').replace(/<\/svg>$/, '');
|
||||
var svg = '<svg width="' + outerSize + '" height="' + outerSize +
|
||||
'" viewBox="0 0 ' + outerSize + ' ' + outerSize +
|
||||
'" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">' +
|
||||
'<circle cx="' + (outerSize / 2) + '" cy="' + (outerSize / 2) +
|
||||
'" r="' + (outerSize / 2 - ringWidth / 2) +
|
||||
'" fill="' + ringFill + '" stroke="' + ringStroke +
|
||||
'" stroke-width="' + ringWidth + '" stroke-dasharray="' + ringDash + '"/>' +
|
||||
'<g transform="translate(' + pad + ',' + pad + ')">' + innerBody + '</g>' +
|
||||
glyph +
|
||||
'</svg>';
|
||||
return { svg: svg, size: outerSize };
|
||||
}
|
||||
|
||||
function buildBadge(idx, total, opts) {
|
||||
var txt;
|
||||
if (opts.isOrigin) txt = '\u25B6'; // ▶
|
||||
else if (opts.isDest) txt = '\u2691'; // ⚑
|
||||
else txt = String(idx); // intermediate hop number
|
||||
return '<span class="mc-route-seq-badge" aria-hidden="true">' + txt + '</span>';
|
||||
}
|
||||
|
||||
function buildPopupHtml(p, hopNum, total) {
|
||||
var pubkeyShort = p.pubkey ? String(p.pubkey).slice(0, 12) : '—';
|
||||
var roleLine = escapeHtml(p.role || 'unknown');
|
||||
var lastSeen = p.last_seen
|
||||
? new Date(p.last_seen).toLocaleString()
|
||||
: (p.last_heard ? new Date(p.last_heard).toLocaleString() : '—');
|
||||
var obsCount = p.observation_count != null ? p.observation_count : '—';
|
||||
var coords = (p.lat != null && p.lon != null)
|
||||
? (p.lat.toFixed(4) + ', ' + p.lon.toFixed(4))
|
||||
: '—';
|
||||
var deepLink = p.pubkey
|
||||
? '<div style="margin-top:6px"><a class="mc-route-popup-link" href="#/map?node=' +
|
||||
encodeURIComponent(p.pubkey) + '">Show on main map \u2192</a></div>'
|
||||
: '';
|
||||
return '<div class="mc-route-popup">' +
|
||||
'<div class="mc-route-popup-title">Hop ' + hopNum + ' of ' + total +
|
||||
': ' + escapeHtml(p.name || pubkeyShort) + '</div>' +
|
||||
'<div class="mc-route-popup-row"><span>Role</span><b>' + roleLine + '</b></div>' +
|
||||
'<div class="mc-route-popup-row"><span>Pubkey</span><code>' +
|
||||
escapeHtml(pubkeyShort) + '\u2026</code></div>' +
|
||||
'<div class="mc-route-popup-row"><span>Last seen</span>' + escapeHtml(lastSeen) + '</div>' +
|
||||
'<div class="mc-route-popup-row"><span>Observations</span>' + escapeHtml(String(obsCount)) + '</div>' +
|
||||
'<div class="mc-route-popup-row"><span>Coords</span>' + escapeHtml(coords) + '</div>' +
|
||||
deepLink +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function ariaLabelFor(p, idx, total) {
|
||||
var name = p.name || (p.pubkey ? String(p.pubkey).slice(0, 8) : 'unknown');
|
||||
var role = p.role || 'unknown';
|
||||
var base = 'Hop ' + (idx + 1) + ' of ' + total + ', ' + name + ', ' + role;
|
||||
if (p.isOrigin) base += ', originator';
|
||||
if (p.isDest) base += ', destination';
|
||||
if (p.resolved === false) base += ', unresolved';
|
||||
return base;
|
||||
}
|
||||
|
||||
function ensureArrowDefs(mapRef) {
|
||||
// Inject a single SVG <defs> into Leaflet's overlay pane.
|
||||
var pane = mapRef.getPane && mapRef.getPane('overlayPane');
|
||||
if (!pane) return;
|
||||
if (document.getElementById('mc-route-arrow-defs')) return;
|
||||
var ns = 'http://www.w3.org/2000/svg';
|
||||
var svgNS = document.createElementNS(ns, 'svg');
|
||||
svgNS.setAttribute('id', 'mc-route-arrow-defs');
|
||||
svgNS.setAttribute('width', '0');
|
||||
svgNS.setAttribute('height', '0');
|
||||
svgNS.setAttribute('style', 'position:absolute;width:0;height:0;overflow:hidden;');
|
||||
svgNS.setAttribute('aria-hidden', 'true');
|
||||
var defs = document.createElementNS(ns, 'defs');
|
||||
var marker = document.createElementNS(ns, 'marker');
|
||||
marker.setAttribute('id', 'mc-route-arrow');
|
||||
marker.setAttribute('viewBox', '0 0 10 10');
|
||||
marker.setAttribute('refX', '8');
|
||||
marker.setAttribute('refY', '5');
|
||||
marker.setAttribute('markerWidth', '6');
|
||||
marker.setAttribute('markerHeight', '6');
|
||||
marker.setAttribute('orient', 'auto-start-reverse');
|
||||
var poly = document.createElementNS(ns, 'path');
|
||||
poly.setAttribute('d', 'M0,0 L10,5 L0,10 z');
|
||||
poly.setAttribute('fill', 'currentColor');
|
||||
marker.appendChild(poly);
|
||||
defs.appendChild(marker);
|
||||
svgNS.appendChild(defs);
|
||||
document.body.appendChild(svgNS);
|
||||
}
|
||||
|
||||
function buildLegend(container, resolvedCount, totalCount) {
|
||||
// Remove any prior legend
|
||||
var prior = container.querySelector('.mc-route-legend');
|
||||
if (prior) prior.remove();
|
||||
|
||||
var roles = ['repeater', 'companion', 'room', 'sensor', 'observer'];
|
||||
var roleEntries = roles.map(function (r) {
|
||||
var color = (window.ROLE_COLORS && window.ROLE_COLORS[r]) || '#888';
|
||||
var svg = window.makeRoleMarkerSVG ? window.makeRoleMarkerSVG(r, color, 14) : '';
|
||||
return '<li class="mc-route-legend-entry mc-route-legend-role">' +
|
||||
'<span class="mc-route-legend-swatch">' + svg + '</span>' +
|
||||
'<span>' + r + '</span></li>';
|
||||
}).join('');
|
||||
|
||||
var html =
|
||||
'<div class="mc-route-legend" role="region" aria-label="Route legend">' +
|
||||
'<button type="button" class="mc-route-legend-toggle" aria-expanded="true" aria-controls="mc-route-legend-body">' +
|
||||
'Legend' +
|
||||
'</button>' +
|
||||
'<div id="mc-route-legend-body" class="mc-route-legend-body">' +
|
||||
(resolvedCount < totalCount
|
||||
? '<div class="mc-route-resolved-badge" role="status">' +
|
||||
resolvedCount + ' of ' + totalCount + ' hops resolved</div>'
|
||||
: '<div class="mc-route-resolved-badge" role="status">' +
|
||||
totalCount + ' of ' + totalCount + ' hops resolved</div>') +
|
||||
'<ul class="mc-route-legend-list">' +
|
||||
'<li class="mc-route-legend-entry"><span class="mc-route-legend-glyph" aria-hidden="true">\u25B6</span><span>origin (originator)</span></li>' +
|
||||
'<li class="mc-route-legend-entry"><span class="mc-route-legend-glyph" aria-hidden="true">\u2691</span><span>destination</span></li>' +
|
||||
'<li class="mc-route-legend-entry"><span class="mc-route-legend-gradient" aria-hidden="true"></span><span>hop-order color (bright \u2192 fading)</span></li>' +
|
||||
'</ul>' +
|
||||
'<div class="mc-route-legend-section">role shapes</div>' +
|
||||
'<ul class="mc-route-legend-list">' + roleEntries + '</ul>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
|
||||
var wrap = document.createElement('div');
|
||||
wrap.innerHTML = html;
|
||||
var node = wrap.firstChild;
|
||||
container.appendChild(node);
|
||||
|
||||
var btn = node.querySelector('.mc-route-legend-toggle');
|
||||
var body = node.querySelector('.mc-route-legend-body');
|
||||
btn.addEventListener('click', function () {
|
||||
var open = btn.getAttribute('aria-expanded') === 'true';
|
||||
btn.setAttribute('aria-expanded', String(!open));
|
||||
body.style.display = open ? 'none' : '';
|
||||
});
|
||||
}
|
||||
|
||||
function buildContextLabel(container, timestamp) {
|
||||
var prior = container.querySelector('.mc-route-context-label');
|
||||
if (prior) prior.remove();
|
||||
var ts = timestamp ? new Date(timestamp).toLocaleString() : 'unknown time';
|
||||
var el = document.createElement('div');
|
||||
el.className = 'mc-route-context-label';
|
||||
el.setAttribute('role', 'status');
|
||||
el.textContent = 'Route observed at ' + ts;
|
||||
container.appendChild(el);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the route. Caller passes the Leaflet map, a clean layer group,
|
||||
* and the ordered positions array.
|
||||
*
|
||||
* @param {L.Map} mapRef
|
||||
* @param {L.LayerGroup} layer
|
||||
* @param {Array<{lat,lon,name,role,pubkey,isOrigin?,isDest?,resolved?,
|
||||
* last_seen?,last_heard?,observation_count?}>} positions
|
||||
* @param {{timestamp?:string|number}} [opts]
|
||||
*/
|
||||
function render(mapRef, layer, positions, opts) {
|
||||
opts = opts || {};
|
||||
if (!mapRef || !layer || !Array.isArray(positions) || positions.length === 0) return;
|
||||
|
||||
layer.clearLayers();
|
||||
ensureArrowDefs(mapRef);
|
||||
|
||||
// Mark origin / destination explicitly. If caller didn't set isDest, the
|
||||
// last resolved hop becomes the destination.
|
||||
var total = positions.length;
|
||||
var resolvedCount = positions.filter(function (p) { return p.resolved !== false; }).length;
|
||||
positions.forEach(function (p, i) {
|
||||
if (i === 0 && !('isOrigin' in p)) p.isOrigin = true;
|
||||
if (i === total - 1 && !('isDest' in p)) p.isDest = true;
|
||||
});
|
||||
|
||||
// Partial-route placement: unresolved hops with no lat/lon are
|
||||
// interpolated between the nearest resolved neighbors so they render as
|
||||
// dashed-gray placeholders on the route line.
|
||||
for (var pi = 0; pi < positions.length; pi++) {
|
||||
var cur = positions[pi];
|
||||
if (cur.lat != null && cur.lon != null) continue;
|
||||
var before = null, after = null;
|
||||
for (var k = pi - 1; k >= 0; k--) {
|
||||
if (positions[k].lat != null && positions[k].lon != null) { before = positions[k]; break; }
|
||||
}
|
||||
for (var k2 = pi + 1; k2 < positions.length; k2++) {
|
||||
if (positions[k2].lat != null && positions[k2].lon != null) { after = positions[k2]; break; }
|
||||
}
|
||||
if (before && after) {
|
||||
cur.lat = (before.lat + after.lat) / 2;
|
||||
cur.lon = (before.lon + after.lon) / 2;
|
||||
} else if (before) {
|
||||
cur.lat = before.lat; cur.lon = before.lon;
|
||||
} else if (after) {
|
||||
cur.lat = after.lat; cur.lon = after.lon;
|
||||
}
|
||||
}
|
||||
|
||||
var reduceMotion = window.matchMedia &&
|
||||
window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
// ── Edges ───────────────────────────────────────────────────────
|
||||
for (var i = 0; i < total - 1; i++) {
|
||||
var a = positions[i], b = positions[i + 1];
|
||||
if (a.lat == null || a.lon == null || b.lat == null || b.lon == null) continue;
|
||||
var color = seqColor(i, total - 1);
|
||||
var dist = haversineKm(a, b);
|
||||
var ariaLabel = 'Hop ' + (i + 1) + ' \u2192 ' + (i + 2) +
|
||||
(dist != null ? ', ~' + dist + 'km' : '');
|
||||
var poly = L.polyline([[a.lat, a.lon], [b.lat, b.lon]], {
|
||||
color: color,
|
||||
weight: 3.5,
|
||||
opacity: 0.92,
|
||||
dashArray: (a.resolved === false || b.resolved === false) ? '6 4' : null,
|
||||
className: 'mc-route-edge'
|
||||
}).addTo(layer);
|
||||
|
||||
// Patch the rendered <path> element to add aria-label + marker-end.
|
||||
// Leaflet builds it on the next animation frame, so defer.
|
||||
(function (polyRef, lbl, col) {
|
||||
setTimeout(function () {
|
||||
var el = polyRef.getElement && polyRef.getElement();
|
||||
if (!el) return;
|
||||
el.setAttribute('aria-label', lbl);
|
||||
el.setAttribute('role', 'img');
|
||||
el.classList.add('mc-route-edge');
|
||||
el.setAttribute('marker-end', 'url(#mc-route-arrow)');
|
||||
el.style.color = col; // arrow inherits via currentColor
|
||||
if (reduceMotion) el.style.transition = 'none';
|
||||
}, 0);
|
||||
})(poly, ariaLabel, color);
|
||||
}
|
||||
|
||||
// ── Markers + labels ────────────────────────────────────────────
|
||||
var labelItems = [];
|
||||
positions.forEach(function (p, i) {
|
||||
if (p.lat == null || p.lon == null) return;
|
||||
var unresolved = (p.resolved === false);
|
||||
var color = unresolved ? '#9ca3af' : ((window.ROLE_COLORS && window.ROLE_COLORS[p.role]) || '#3b82f6');
|
||||
var size = (p.isOrigin || p.isDest) ? 24 : 18;
|
||||
var built = buildHopSVG(p, { color: color, size: size, isOrigin: p.isOrigin, isDest: p.isDest, unresolved: unresolved });
|
||||
var badge = buildBadge(i + 1, total, { isOrigin: p.isOrigin, isDest: p.isDest });
|
||||
var classNames = 'mc-route-marker' + (unresolved ? ' ch-unresolved' : '') +
|
||||
(p.isOrigin ? ' mc-route-origin' : '') + (p.isDest ? ' mc-route-dest' : '');
|
||||
var aria = ariaLabelFor(p, i, total);
|
||||
var html =
|
||||
'<div class="' + classNames + '" role="img" aria-label="' + escapeHtml(aria) +
|
||||
'" tabindex="0" data-hop-index="' + i + '">' +
|
||||
built.svg +
|
||||
badge +
|
||||
'</div>';
|
||||
var icon = L.divIcon({
|
||||
html: html,
|
||||
className: 'mc-route-marker-icon',
|
||||
iconSize: [built.size + 14, built.size + 14],
|
||||
iconAnchor: [(built.size + 14) / 2, (built.size + 14) / 2]
|
||||
});
|
||||
var marker = L.marker([p.lat, p.lon], { icon: icon, keyboard: true }).addTo(layer);
|
||||
marker.bindPopup(buildPopupHtml(p, i + 1, total), { className: 'mc-route-popup-wrap' });
|
||||
|
||||
labelItems.push({
|
||||
latLng: L.latLng(p.lat, p.lon),
|
||||
isLabel: true,
|
||||
text: p.name || (p.pubkey ? String(p.pubkey).slice(0, 8) : 'hop')
|
||||
});
|
||||
});
|
||||
|
||||
// Deconflict label boxes — reuses map.js' shared algorithm.
|
||||
if (typeof window.deconflictLabels === 'function') {
|
||||
window.deconflictLabels(labelItems, mapRef);
|
||||
}
|
||||
labelItems.forEach(function (m) {
|
||||
var pos = m.adjustedLatLng || m.latLng;
|
||||
var labelHtml = '<div class="mc-route-label">' + escapeHtml(m.text) + '</div>';
|
||||
var icon = L.divIcon({
|
||||
html: labelHtml,
|
||||
className: 'mc-route-label-icon',
|
||||
iconSize: null,
|
||||
iconAnchor: [0, -16]
|
||||
});
|
||||
var lblMarker = L.marker(pos, { icon: icon, interactive: false }).addTo(layer);
|
||||
m._lblMarker = lblMarker;
|
||||
if (m.offset && m.offset > 2) {
|
||||
L.polyline([m.latLng, pos], {
|
||||
weight: 1, color: '#475569', opacity: 0.5, dashArray: '3 3'
|
||||
}).addTo(layer);
|
||||
}
|
||||
});
|
||||
|
||||
// Second-pass overlap resolution: shared `deconflictLabels` uses a fixed
|
||||
// 38×24 collision box, but our role-aware labels are often wider. After
|
||||
// Leaflet paints, measure the real DOM rects and nudge any overlapping
|
||||
// labels vertically using an L.DomUtil offset (no relayout).
|
||||
//
|
||||
// We run the nudge once immediately AND again after `fitBounds`
|
||||
// completes its async pan (`moveend`), because fitBounds re-projects
|
||||
// the labels and can re-introduce overlap that the first nudge missed.
|
||||
function nudgeOverlappingLabels() {
|
||||
var containerEl = mapRef.getContainer ? mapRef.getContainer() : document.body;
|
||||
var labelEls = Array.from(containerEl.querySelectorAll('.mc-route-label'));
|
||||
// Reset prior nudges so we recompute from scratch (otherwise stacked
|
||||
// nudges from successive passes drift labels off-screen).
|
||||
for (var li = 0; li < labelEls.length; li++) {
|
||||
var parent = labelEls[li].parentElement;
|
||||
if (parent && parent.dataset && parent.dataset.mcRouteDy) {
|
||||
parent.style.marginTop = '';
|
||||
delete parent.dataset.mcRouteDy;
|
||||
}
|
||||
}
|
||||
var rects = labelEls.map(function (el) { return el.getBoundingClientRect(); });
|
||||
var maxIter = 8;
|
||||
for (var iter = 0; iter < maxIter; iter++) {
|
||||
var moved = false;
|
||||
for (var i = 0; i < labelEls.length; i++) {
|
||||
for (var j = i + 1; j < labelEls.length; j++) {
|
||||
var a = rects[i], b = rects[j];
|
||||
if (a.x < b.x + b.width && a.x + a.width > b.x &&
|
||||
a.y < b.y + b.height && a.y + a.height > b.y) {
|
||||
// Push the later label downward by the overlap height + 6px.
|
||||
var dy = (a.y + a.height) - b.y + 6;
|
||||
var p2 = labelEls[j].parentElement;
|
||||
if (p2 && p2.style) {
|
||||
var prev = p2.dataset.mcRouteDy ? Number(p2.dataset.mcRouteDy) : 0;
|
||||
var next = prev + dy;
|
||||
p2.dataset.mcRouteDy = String(next);
|
||||
p2.style.marginTop = next + 'px';
|
||||
}
|
||||
rects[j] = labelEls[j].getBoundingClientRect();
|
||||
moved = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!moved) break;
|
||||
}
|
||||
}
|
||||
setTimeout(nudgeOverlappingLabels, 30);
|
||||
mapRef.once('moveend', function () { setTimeout(nudgeOverlappingLabels, 30); });
|
||||
|
||||
// Fit map to route
|
||||
var coords = positions.filter(function (p) { return p.lat != null && p.lon != null; })
|
||||
.map(function (p) { return [p.lat, p.lon]; });
|
||||
if (coords.length >= 2) {
|
||||
mapRef.fitBounds(L.latLngBounds(coords).pad(0.3));
|
||||
} else if (coords.length === 1) {
|
||||
mapRef.setView(coords[0], 13);
|
||||
}
|
||||
|
||||
// ── Overlay UI: legend + context label ──────────────────────────
|
||||
var container = mapRef.getContainer ? mapRef.getContainer() : document.getElementById('leaflet-map');
|
||||
if (container) {
|
||||
buildLegend(container, resolvedCount, total);
|
||||
buildContextLabel(container, opts.timestamp);
|
||||
}
|
||||
}
|
||||
|
||||
window.MeshRoute = {
|
||||
render: render,
|
||||
_seqColor: seqColor,
|
||||
_haversineKm: haversineKm,
|
||||
_ariaLabelFor: ariaLabelFor
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,727 @@
|
||||
/* route-view.css — minimal route view layout + styling. */
|
||||
|
||||
body.mc-route-active #leaflet-map { left: 320px !important; width: calc(100% - 320px) !important; }
|
||||
|
||||
/* Auto-collapse Map Controls panel when route view opens. The toggle button
|
||||
(.map-controls-toggle) stays visible — clicking it expands the panel.
|
||||
Map controls JS uses the `.collapsed` class for its own toggle state. */
|
||||
body.mc-route-active .map-controls.collapsed { display: none !important; }
|
||||
body.mc-route-active #pktRight,
|
||||
body.mc-route-active .slide-over-panel,
|
||||
body.mc-route-active .slide-over-backdrop,
|
||||
body.mc-route-active .mobile-detail-sheet { display: none !important; }
|
||||
|
||||
/* Hide regular node clusters and topology markers during route view so the
|
||||
route polyline + its own markers aren't lost in a 600-node mesh. The route
|
||||
layer's own markers use .mc-rt-marker-icon and are NOT hidden. */
|
||||
body.mc-route-active .leaflet-marker-pane .meshcore-marker { display: none !important; }
|
||||
body.mc-route-active .leaflet-marker-pane .meshcore-label-marker { display: none !important; }
|
||||
body.mc-route-active .leaflet-marker-pane .marker-cluster { display: none !important; }
|
||||
/* CoreScope custom cluster bubble wrappers (not the leaflet.markercluster ones) */
|
||||
body.mc-route-active .leaflet-marker-pane .mc-cluster-wrap { display: none !important; }
|
||||
/* Hide overlay-pane SVG paths that aren't part of the route. The route's
|
||||
own polylines have class="mc-rt-edge". */
|
||||
body.mc-route-active .leaflet-overlay-pane svg path:not(.mc-rt-edge) { display: none !important; }
|
||||
|
||||
.mc-rt-sidebar {
|
||||
position: fixed;
|
||||
top: 52px; /* below top-nav */
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: 320px;
|
||||
background: var(--surface, #1a1a1a);
|
||||
border-right: 1px solid var(--border, #333);
|
||||
color: var(--text, #e7e7e7);
|
||||
font: 13px/1.4 system-ui, -apple-system, sans-serif;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
z-index: 500;
|
||||
box-shadow: 2px 0 8px rgba(0,0,0,0.4);
|
||||
}
|
||||
|
||||
.mc-rt-header {
|
||||
padding: 12px 14px 10px;
|
||||
border-bottom: 1px solid var(--border, #333);
|
||||
position: relative;
|
||||
}
|
||||
.mc-rt-title-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding-right: 36px; /* leave room for close button */
|
||||
}
|
||||
.mc-rt-back-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
color: var(--accent, #06b6d4);
|
||||
text-decoration: none;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.mc-rt-back-link:hover {
|
||||
background: var(--bg-hover, rgba(120,160,255,0.12));
|
||||
text-decoration: underline;
|
||||
}
|
||||
.mc-rt-back-link:focus {
|
||||
outline: 2px solid var(--accent, #06b6d4);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.mc-rt-title { font-size: 14px; font-weight: 700; letter-spacing: 0.5px; text-transform: uppercase; color: var(--text-muted, #94a3b8); }
|
||||
.mc-rt-meta { font-size: 11px; color: var(--text-muted, #94a3b8); margin-top: 2px; }
|
||||
.mc-rt-multipath-chip {
|
||||
margin-top: 4px;
|
||||
padding: 4px 8px;
|
||||
background: var(--surface-2, #232323);
|
||||
border: 1px solid var(--border, #333);
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
color: var(--text, #cbd5e1);
|
||||
font-family: ui-monospace, Menlo, monospace;
|
||||
}
|
||||
.mc-rt-multipath-chip b { color: var(--text, #fff); }
|
||||
.mc-rt-multipath-key {
|
||||
margin-top: 3px;
|
||||
font-size: 10px;
|
||||
color: var(--text-muted, #94a3b8);
|
||||
font-style: italic;
|
||||
font-family: system-ui, sans-serif;
|
||||
}
|
||||
|
||||
/* Multi-path picker — Click a path to isolate it on the map. */
|
||||
.mc-rt-paths {
|
||||
margin-top: 6px;
|
||||
background: var(--surface-2, #232323);
|
||||
border: 1px solid var(--border, #333);
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
max-height: 180px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.mc-rt-paths[open] { overflow: auto; max-height: 180px; }
|
||||
.mc-rt-paths-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 4px 8px;
|
||||
cursor: pointer;
|
||||
font-size: 10px;
|
||||
color: var(--text-muted, #94a3b8);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
list-style: none;
|
||||
font-weight: 600;
|
||||
}
|
||||
.mc-rt-paths-header::-webkit-details-marker { display: none; }
|
||||
.mc-rt-paths-header::before {
|
||||
content: '▾';
|
||||
margin-right: 4px;
|
||||
font-size: 9px;
|
||||
transition: transform 120ms;
|
||||
}
|
||||
.mc-rt-paths:not([open]) .mc-rt-paths-header::before { transform: rotate(-90deg); }
|
||||
.mc-rt-path-clear {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border, #444);
|
||||
color: var(--text-muted, #94a3b8);
|
||||
font-size: 9px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.mc-rt-path-clear:hover {
|
||||
background: var(--bg-hover, rgba(120,160,255,0.12));
|
||||
color: var(--text, #fff);
|
||||
}
|
||||
.mc-rt-path-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border-top: 1px solid var(--border, #333);
|
||||
}
|
||||
.mc-rt-path-row {
|
||||
display: grid;
|
||||
grid-template-columns: 36px 1fr auto;
|
||||
column-gap: 6px;
|
||||
align-items: center;
|
||||
padding: 4px 8px;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.04);
|
||||
font-family: ui-monospace, Menlo, monospace;
|
||||
}
|
||||
.mc-rt-path-row:hover {
|
||||
background: var(--bg-hover, rgba(120,160,255,0.08));
|
||||
}
|
||||
.mc-rt-path-row.mc-rt-path-active {
|
||||
background: var(--bg-hover, rgba(120,160,255,0.18));
|
||||
border-left: 3px solid var(--accent, #06b6d4);
|
||||
padding-left: 5px;
|
||||
}
|
||||
.mc-rt-path-row:focus {
|
||||
outline: 2px solid var(--accent, #06b6d4);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
.mc-rt-path-count {
|
||||
font-weight: 600;
|
||||
font-size: 10px;
|
||||
color: var(--text-muted, #94a3b8);
|
||||
text-align: right;
|
||||
}
|
||||
.mc-rt-path-hops {
|
||||
font-size: 10px;
|
||||
color: var(--text, #cbd5e1);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.mc-rt-path-obs {
|
||||
font-size: 9px;
|
||||
color: var(--text-muted, #94a3b8);
|
||||
font-family: system-ui, sans-serif;
|
||||
max-width: 80px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.mc-rt-spark-wrap {
|
||||
margin: 8px 0 4px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.mc-rt-spark-title {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted, #94a3b8);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.mc-rt-spark-title b { color: var(--text, #e7e7e7); font-weight: 600; }
|
||||
.mc-rt-spark { display: block; cursor: pointer; }
|
||||
.mc-rt-spark-dot { cursor: pointer; }
|
||||
.mc-rt-spark-dot:hover { r: 3; }
|
||||
.mc-rt-spark-tooltip {
|
||||
position: absolute;
|
||||
background: var(--surface-2, #2a2a2a);
|
||||
border: 1px solid var(--border, #444);
|
||||
border-radius: 4px;
|
||||
padding: 4px 8px;
|
||||
font: 11px ui-monospace, Menlo, monospace;
|
||||
color: var(--text, #e7e7e7);
|
||||
pointer-events: none;
|
||||
z-index: 9999;
|
||||
white-space: nowrap;
|
||||
box-shadow: 0 2px 6px rgba(0,0,0,0.4);
|
||||
}
|
||||
.mc-rt-close {
|
||||
position: absolute; top: 10px; right: 10px;
|
||||
background: transparent; border: 1px solid var(--border, #333);
|
||||
color: var(--text, #e7e7e7); border-radius: 4px;
|
||||
width: 26px; height: 26px; cursor: pointer; font-size: 14px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.mc-rt-close:hover { background: var(--bg-hover, rgba(255,255,255,0.06)); }
|
||||
|
||||
.mc-rt-list { list-style: none; padding: 0; margin: 0; overflow-y: auto; flex: 1; }
|
||||
.mc-rt-pinned { padding: 2px 0; }
|
||||
.mc-rt-pinned-top { border-bottom: 1px solid var(--border, #333); }
|
||||
.mc-rt-pinned-bottom { border-top: 1px solid var(--border, #333); }
|
||||
.mc-rt-pinned .mc-rt-row { background: var(--surface-2, #232323); font-weight: 600; }
|
||||
.mc-rt-pinned .mc-rt-row::before {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
font-size: 9px; letter-spacing: 1px;
|
||||
color: var(--text-muted, #94a3b8);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.mc-rt-pinned-top .mc-rt-row::before { content: 'SRC'; padding-right: 4px; }
|
||||
.mc-rt-pinned-bottom .mc-rt-row::before { content: 'DST'; padding-right: 4px; }
|
||||
|
||||
.mc-rt-row {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: 4px 22px 18px 1fr auto;
|
||||
grid-template-rows: auto 4px;
|
||||
column-gap: 6px;
|
||||
align-items: center;
|
||||
padding: 4px 12px 4px 0;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.02);
|
||||
}
|
||||
.mc-rt-row:hover,
|
||||
.mc-rt-row:focus,
|
||||
.mc-rt-row.mc-rt-row-active {
|
||||
background: var(--bg-hover, rgba(120,160,255,0.08));
|
||||
outline: none;
|
||||
}
|
||||
.mc-rt-stripe {
|
||||
grid-column: 1; grid-row: 1 / -1;
|
||||
width: 4px; height: 100%;
|
||||
background: var(--mc-rt-row-color, transparent);
|
||||
}
|
||||
.mc-rt-seq {
|
||||
grid-column: 2; grid-row: 1;
|
||||
font-family: ui-monospace, Menlo, monospace;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted, #94a3b8);
|
||||
text-align: right;
|
||||
}
|
||||
.mc-rt-glyph {
|
||||
grid-column: 3; grid-row: 1;
|
||||
text-align: center; font-size: 12px;
|
||||
}
|
||||
.mc-rt-name {
|
||||
grid-column: 4; grid-row: 1;
|
||||
font-size: 12px;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.mc-rt-obs-chip {
|
||||
display: inline-block;
|
||||
font-size: 9px; padding: 0 4px;
|
||||
background: var(--surface-2, #2a2a2a);
|
||||
border: 1px solid var(--border, #444);
|
||||
border-radius: 8px;
|
||||
margin-left: 4px;
|
||||
color: var(--text-muted, #94a3b8);
|
||||
font-family: ui-monospace, Menlo, monospace;
|
||||
}
|
||||
.mc-rt-status-chip {
|
||||
display: inline-block;
|
||||
font-size: 9px; padding: 0 4px;
|
||||
border-radius: 8px;
|
||||
margin-left: 4px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.mc-rt-status-nogps {
|
||||
background: rgba(245, 158, 11, 0.18);
|
||||
color: #fbbf24;
|
||||
border: 1px solid rgba(245, 158, 11, 0.4);
|
||||
}
|
||||
.mc-rt-status-unknown {
|
||||
background: rgba(148, 163, 184, 0.18);
|
||||
color: #94a3b8;
|
||||
border: 1px solid rgba(148, 163, 184, 0.4);
|
||||
}
|
||||
.mc-rt-status-payload {
|
||||
background: rgba(6, 182, 212, 0.15);
|
||||
color: #67e8f9;
|
||||
border: 1px solid rgba(6, 182, 212, 0.35);
|
||||
font-style: italic;
|
||||
}
|
||||
.mc-rt-distlabel {
|
||||
grid-column: 5; grid-row: 1;
|
||||
font-family: ui-monospace, Menlo, monospace;
|
||||
font-size: 10px;
|
||||
color: var(--text-muted, #94a3b8);
|
||||
}
|
||||
.mc-rt-distbar-wrap {
|
||||
grid-column: 2 / -1; grid-row: 2;
|
||||
height: 3px;
|
||||
background: transparent;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.mc-rt-distbar {
|
||||
height: 100%;
|
||||
border-radius: 1.5px;
|
||||
}
|
||||
.mc-rt-unresolved .mc-rt-name { color: var(--text-muted, #94a3b8); font-style: italic; }
|
||||
|
||||
/* Drill-in expanding panel (hop detail) */
|
||||
.mc-rt-row.mc-rt-row-expanded {
|
||||
background: var(--bg-hover, rgba(120,160,255,0.12));
|
||||
}
|
||||
.mc-rt-detail-panel {
|
||||
grid-column: 1 / -1;
|
||||
grid-row: 3;
|
||||
padding: 8px 10px 10px;
|
||||
background: var(--surface-2, #1d1d1d);
|
||||
border-top: 1px solid var(--border, #333);
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
color: var(--text, #e7e7e7);
|
||||
margin-top: 2px;
|
||||
}
|
||||
.mc-rt-row { grid-template-rows: auto 4px auto; }
|
||||
.mc-rt-detail-loading,
|
||||
.mc-rt-detail-na { color: var(--text-muted, #94a3b8); font-style: italic; font-size: 10px; }
|
||||
.mc-rt-detail-row1 { display: flex; flex-wrap: wrap; align-items: baseline; gap: 6px; margin-bottom: 4px; }
|
||||
.mc-rt-detail-name { font-weight: 700; color: var(--text, #fff); font-size: 12px; }
|
||||
.mc-rt-detail-warn {
|
||||
background: rgba(245, 158, 11, 0.18);
|
||||
color: #fbbf24;
|
||||
border: 1px solid rgba(245, 158, 11, 0.4);
|
||||
padding: 0 4px;
|
||||
border-radius: 3px;
|
||||
font-size: 9px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.mc-rt-detail-meta {
|
||||
font-family: ui-monospace, Menlo, monospace;
|
||||
font-size: 10px;
|
||||
color: var(--text-muted, #94a3b8);
|
||||
}
|
||||
.mc-rt-detail-label {
|
||||
display: inline-block;
|
||||
width: 50px;
|
||||
font-size: 9px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--text-muted, #94a3b8);
|
||||
}
|
||||
.mc-rt-detail-snr,
|
||||
.mc-rt-detail-relay,
|
||||
.mc-rt-detail-also { margin: 2px 0; display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
|
||||
.mc-rt-detail-spark { vertical-align: middle; color: var(--text, #cbd5e1); }
|
||||
.mc-rt-detail-spark-meta { font-size: 9px; color: var(--text-muted, #94a3b8); font-family: ui-monospace, Menlo, monospace; }
|
||||
.mc-rt-detail-link { color: var(--accent, #06b6d4); text-decoration: none; }
|
||||
.mc-rt-detail-link:hover { text-decoration: underline; }
|
||||
.mc-rt-detail-link:focus {
|
||||
outline: 2px solid var(--accent, #06b6d4);
|
||||
outline-offset: 2px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.mc-rt-detail-action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 8px;
|
||||
background: var(--surface-2, #232323);
|
||||
border: 1px solid var(--border, #333);
|
||||
border-radius: 4px;
|
||||
font-weight: 600;
|
||||
font-size: 11px;
|
||||
transition: background 120ms, border-color 120ms;
|
||||
}
|
||||
.mc-rt-detail-action:hover {
|
||||
background: var(--bg-hover, rgba(120,160,255,0.12));
|
||||
border-color: var(--accent, #06b6d4);
|
||||
text-decoration: none;
|
||||
}
|
||||
.mc-rt-route-badge {
|
||||
background: var(--surface, #1a1a1a);
|
||||
border: 1px solid var(--border, #444);
|
||||
border-radius: 8px;
|
||||
padding: 1px 5px;
|
||||
font-family: ui-monospace, Menlo, monospace;
|
||||
font-size: 9px;
|
||||
color: var(--text-muted, #94a3b8);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Marker styles — no chips, just shape. */
|
||||
.mc-rt-marker-icon { background: transparent !important; border: none !important; }
|
||||
.mc-rt-marker { position: relative; line-height: 0; cursor: pointer; transition: transform 120ms ease-out; }
|
||||
.mc-rt-marker:hover,
|
||||
.mc-rt-marker.mc-rt-hover { transform: scale(1.5); z-index: 1000 !important; }
|
||||
.mc-rt-marker:focus { outline: 2px solid #06b6d4; outline-offset: 2px; border-radius: 50%; }
|
||||
|
||||
/* packet-context block (type chip + 3-5 facts). Above multi-path. */
|
||||
.mc-rt-ctx {
|
||||
margin: 6px 0 4px;
|
||||
padding: 6px 8px;
|
||||
background: var(--surface-2, #1f1f1f);
|
||||
border-left: 3px solid var(--accent, #06b6d4);
|
||||
border-radius: 0 4px 4px 0;
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.mc-rt-ctx-chip {
|
||||
display: inline-block;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--text-muted, #94a3b8);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.mc-rt-ctx-glyph {
|
||||
font-size: 12px;
|
||||
margin-right: 2px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.mc-rt-ctx-facts { display: flex; flex-direction: column; gap: 2px; }
|
||||
.mc-rt-ctx-line { color: var(--text, #cbd5e1); }
|
||||
.mc-rt-ctx-line b { color: var(--text, #fff); font-weight: 600; }
|
||||
.mc-rt-ctx-arrow { color: var(--text-muted, #94a3b8); margin: 0 4px; }
|
||||
.mc-rt-ctx-meta { color: var(--text-muted, #94a3b8); font-size: 10px; }
|
||||
.mc-rt-ctx-mono { font-family: ui-monospace, Menlo, monospace; font-size: 10px; color: var(--text-muted, #94a3b8); }
|
||||
.mc-rt-ctx-quote {
|
||||
font-style: italic;
|
||||
color: var(--text, #fff);
|
||||
padding-left: 6px;
|
||||
border-left: 2px solid var(--border, #444);
|
||||
font-family: ui-monospace, Menlo, monospace;
|
||||
font-size: 10px;
|
||||
}
|
||||
1,2,3… on the map without scrubbing the sidebar. Origin (square) + dest
|
||||
(triangle) are shape-differentiated and don't need numeric labels. */
|
||||
.mc-rt-marker-seq {
|
||||
position: absolute;
|
||||
top: 1px;
|
||||
left: 14px; /* to the right of the marker dot, no overlap */
|
||||
background: var(--surface, #1a1a1a);
|
||||
color: var(--text, #fff);
|
||||
border: 1px solid var(--border, #666);
|
||||
border-radius: 5px;
|
||||
min-width: 13px;
|
||||
height: 11px;
|
||||
padding: 0 2px;
|
||||
font: 700 8px/11px ui-monospace, Menlo, monospace;
|
||||
text-align: center;
|
||||
pointer-events: none;
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,0.5);
|
||||
z-index: 2;
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* Hover/focus on a marker pops its seq label so it's never hidden by
|
||||
neighbors at high density. */
|
||||
.mc-rt-marker:hover .mc-rt-marker-seq,
|
||||
.mc-rt-marker:focus .mc-rt-marker-seq,
|
||||
.mc-rt-marker.mc-rt-hover .mc-rt-marker-seq {
|
||||
z-index: 1000;
|
||||
transform: scale(1.4);
|
||||
transform-origin: left center;
|
||||
}
|
||||
|
||||
/* Mobile: map dominates (75vh), sidebar is a compact bottom strip (25vh)
|
||||
showing just packet type + hop count + distance + summary. Operator's job:
|
||||
see the route on the map first, scroll the strip for hop list. */
|
||||
/* Mobile bottom-sheet handle (visible only on mobile) — hidden by default */
|
||||
.mc-rt-mobile-handle { display: none; }
|
||||
.mc-rt-collapsed-label { display: none; }
|
||||
|
||||
/* Desktop resize handle on the right edge of the sidebar */
|
||||
.mc-rt-resize-handle {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 6px;
|
||||
height: 100%;
|
||||
cursor: ew-resize;
|
||||
z-index: 10;
|
||||
background: transparent;
|
||||
transition: background 120ms;
|
||||
}
|
||||
.mc-rt-resize-handle:hover,
|
||||
.mc-rt-resize-handle:focus {
|
||||
background: var(--accent, #06b6d4);
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
/* Desktop collapse button — sits on the RIGHT edge of the sidebar,
|
||||
vertically centered. Standard Material/Drive-style affordance: chevron
|
||||
pointing into the panel = collapse, out of the panel = expand. */
|
||||
.mc-rt-collapse-btn {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: -14px;
|
||||
transform: translateY(-50%);
|
||||
background: var(--surface-2, #232323);
|
||||
border: 1px solid var(--border, #333);
|
||||
color: var(--text-muted, #94a3b8);
|
||||
font-size: 12px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
z-index: 12;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
box-shadow: 1px 0 3px rgba(0,0,0,0.4);
|
||||
}
|
||||
.mc-rt-collapse-btn:hover {
|
||||
background: var(--bg-hover, rgba(120,160,255,0.12));
|
||||
color: var(--text, #fff);
|
||||
border-color: var(--accent, #06b6d4);
|
||||
}
|
||||
|
||||
/* Vertical "ROUTE" label shown only when collapsed */
|
||||
.mc-rt-collapsed-label {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%) rotate(-90deg);
|
||||
transform-origin: center;
|
||||
white-space: nowrap;
|
||||
font-size: 11px;
|
||||
letter-spacing: 2px;
|
||||
font-weight: 700;
|
||||
color: var(--text-muted, #94a3b8);
|
||||
text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Collapsed state on desktop */
|
||||
.mc-rt-sidebar.mc-rt-collapsed {
|
||||
width: 36px !important;
|
||||
min-width: 36px;
|
||||
}
|
||||
.mc-rt-sidebar.mc-rt-collapsed .mc-rt-header,
|
||||
.mc-rt-sidebar.mc-rt-collapsed .mc-rt-list,
|
||||
.mc-rt-sidebar.mc-rt-collapsed .mc-rt-pinned,
|
||||
.mc-rt-sidebar.mc-rt-collapsed .mc-rt-resize-handle { display: none; }
|
||||
.mc-rt-sidebar.mc-rt-collapsed .mc-rt-collapsed-label { display: block; }
|
||||
.mc-rt-sidebar.mc-rt-collapsed .mc-rt-collapse-btn {
|
||||
top: 50%;
|
||||
right: -14px;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
/* When sidebar is collapsed, expand map to fill */
|
||||
body.mc-route-active:has(.mc-rt-sidebar.mc-rt-collapsed) #leaflet-map {
|
||||
left: 36px !important;
|
||||
width: calc(100% - 36px) !important;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
/* Mobile: hide desktop collapse + resize affordances (mobile uses bottom-sheet) */
|
||||
body.mc-route-active .mc-rt-collapse-btn,
|
||||
body.mc-route-active .mc-rt-resize-handle,
|
||||
body.mc-route-active .mc-rt-collapsed-label { display: none !important; }
|
||||
body.mc-route-active #leaflet-map {
|
||||
position: fixed !important;
|
||||
top: 52px !important;
|
||||
left: 0 !important;
|
||||
right: 0 !important;
|
||||
width: 100% !important;
|
||||
/* iOS Safari/Edge: use dvh (dynamic viewport) so URL bar collapse
|
||||
doesn't leave a stale layout. Fall back to vh for browsers that
|
||||
don't support dvh yet. */
|
||||
bottom: calc(116px + env(safe-area-inset-bottom, 0px)) !important;
|
||||
height: auto !important;
|
||||
z-index: 100 !important;
|
||||
}
|
||||
body.mc-route-active.mc-rt-mobile-sheet-expanded #leaflet-map {
|
||||
bottom: calc(75vh + 56px + env(safe-area-inset-bottom, 0px)) !important;
|
||||
}
|
||||
body.mc-route-active .map-controls-toggle {
|
||||
position: fixed !important;
|
||||
top: 60px !important;
|
||||
right: 8px !important;
|
||||
z-index: 1100 !important;
|
||||
/* Force overlay — no normal-flow row consumption */
|
||||
margin: 0 !important;
|
||||
width: 36px !important;
|
||||
height: 36px !important;
|
||||
}
|
||||
body.mc-route-active .map-controls {
|
||||
position: fixed !important;
|
||||
top: 100px !important;
|
||||
right: 8px !important;
|
||||
left: 8px !important;
|
||||
width: auto !important;
|
||||
max-height: 60vh !important;
|
||||
z-index: 1090 !important;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.mc-rt-sidebar {
|
||||
position: fixed;
|
||||
top: auto !important;
|
||||
left: 0 !important;
|
||||
right: 0;
|
||||
/* Sit ABOVE the bottom-nav (56px) + iOS safe-area inset */
|
||||
bottom: calc(56px + env(safe-area-inset-bottom, 0px));
|
||||
width: 100%;
|
||||
height: 60px;
|
||||
max-height: 60px;
|
||||
transition: height 240ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
border-right: none;
|
||||
border-top: 1px solid var(--border, #333);
|
||||
box-shadow: 0 -2px 12px rgba(0, 0, 0, 0.5);
|
||||
overflow: hidden;
|
||||
z-index: 1190; /* below bottom-nav (1200) but above content */
|
||||
}
|
||||
.mc-rt-sidebar.mc-rt-mobile-expanded {
|
||||
height: 75vh;
|
||||
max-height: 75vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
/* Bigger touch target — full sheet header tappable, large chevron */
|
||||
.mc-rt-mobile-handle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 6px 14px 8px;
|
||||
cursor: pointer;
|
||||
background: var(--surface, #1a1a1a);
|
||||
user-select: none;
|
||||
height: 60px;
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
/* Prevent the page from scrolling when swiping on this area */
|
||||
touch-action: none;
|
||||
}
|
||||
.mc-rt-mobile-grip {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 60px;
|
||||
height: 6px;
|
||||
background: var(--text-muted, #94a3b8);
|
||||
border-radius: 3px;
|
||||
opacity: 0.6;
|
||||
/* Large tap target around the grip */
|
||||
cursor: grab;
|
||||
}
|
||||
.mc-rt-mobile-grip::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -12px;
|
||||
left: -20px;
|
||||
right: -20px;
|
||||
bottom: -12px;
|
||||
}
|
||||
.mc-rt-mobile-chevron {
|
||||
font-size: 22px;
|
||||
color: var(--text-muted, #94a3b8);
|
||||
margin-top: 8px;
|
||||
padding: 4px 8px;
|
||||
transition: transform 240ms;
|
||||
/* Make the chevron itself a generous tap target */
|
||||
min-width: 32px;
|
||||
text-align: center;
|
||||
}
|
||||
.mc-rt-mobile-summary {
|
||||
flex: 1;
|
||||
font-size: 11px;
|
||||
color: var(--text, #cbd5e1);
|
||||
font-family: ui-monospace, Menlo, monospace;
|
||||
margin-top: 10px;
|
||||
line-height: 1.3;
|
||||
max-height: 36px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.mc-rt-mobile-hex {
|
||||
color: var(--text-muted, #94a3b8);
|
||||
font-size: 9px;
|
||||
display: block;
|
||||
margin-top: 1px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.mc-rt-sidebar.mc-rt-mobile-expanded .mc-rt-mobile-chevron {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
/* Hide full content when collapsed; show when expanded */
|
||||
.mc-rt-sidebar .mc-rt-header,
|
||||
.mc-rt-sidebar .mc-rt-list,
|
||||
.mc-rt-sidebar .mc-rt-pinned { display: none; }
|
||||
.mc-rt-sidebar.mc-rt-mobile-expanded .mc-rt-header { display: block; padding: 8px 12px 6px; }
|
||||
.mc-rt-sidebar.mc-rt-mobile-expanded .mc-rt-list { display: block; }
|
||||
.mc-rt-sidebar.mc-rt-mobile-expanded .mc-rt-pinned { display: block; }
|
||||
.mc-rt-sidebar.mc-rt-mobile-expanded .mc-rt-ctx { margin: 4px 0 2px; padding: 4px 6px; font-size: 11px; }
|
||||
.mc-rt-sidebar.mc-rt-mobile-expanded .mc-rt-row { padding: 3px 10px 3px 0; font-size: 11px; }
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+886
-33
File diff suppressed because it is too large
Load Diff
@@ -157,11 +157,11 @@
|
||||
o.setAttribute('role', 'group');
|
||||
o.setAttribute('aria-label', 'Row actions');
|
||||
var hash = row.getAttribute('data-hash') || row.getAttribute('data-id') || '';
|
||||
var hashAttr = ' data-hash="' + String(hash).replace(/"/g, '"') + '"';
|
||||
o.innerHTML =
|
||||
'<button type="button" class="row-action-btn" data-row-action="trace">Trace</button>' +
|
||||
'<button type="button" class="row-action-btn" data-row-action="filter">Filter</button>' +
|
||||
'<button type="button" class="row-action-btn" data-row-action="copy" data-hash="' +
|
||||
String(hash).replace(/"/g, '"') + '">Copy hash</button>';
|
||||
'<button type="button" class="row-action-btn" data-row-action="trace"' + hashAttr + '>Trace</button>' +
|
||||
'<button type="button" class="row-action-btn" data-row-action="filter"' + hashAttr + '>Filter</button>' +
|
||||
'<button type="button" class="row-action-btn" data-row-action="copy"' + hashAttr + '>Copy hash</button>';
|
||||
document.body.appendChild(o);
|
||||
rowOverlay = o;
|
||||
return o;
|
||||
|
||||
+24
@@ -12,6 +12,7 @@ echo "── Unit Tests ──"
|
||||
node test-packet-filter.js
|
||||
node test-packet-filter-ux.js
|
||||
node test-aging.js
|
||||
node test-issue-1065-gesture-hints-gates.js
|
||||
node test-frontend-helpers.js
|
||||
node test-url-state.js
|
||||
node test-perf-go-runtime.js
|
||||
@@ -23,10 +24,33 @@ node test-channel-decrypt-insecure-context.js
|
||||
node test-channel-qr.js
|
||||
node test-channel-qr-wiring.js
|
||||
node test-channel-issue-1087.js
|
||||
node test-issue-1409-no-encrypted-flood.js
|
||||
node test-analytics-channels-integration.js
|
||||
node test-observers-headings.js
|
||||
node test-marker-outline-weight.js
|
||||
node test-traces.js
|
||||
|
||||
# #1418 — route-view v2 (Tufte) coverage
|
||||
node test-issue-1418-raw-hex-extraction.js
|
||||
node test-issue-1418-edge-weights.js
|
||||
node test-issue-1418-cb-preset-ramp.js
|
||||
node test-issue-1418-spider-fan.js
|
||||
node test-issue-1418-deeplink-hops-channels.js
|
||||
node test-issue-1418-polish-review.js
|
||||
node test-issue-1420-tile-providers.js
|
||||
node test-issue-1438-marker-css-vars.js
|
||||
node test-issue-1438-customizer-mcrole.js
|
||||
node test-issue-1446-cb-preset-cascade.js
|
||||
node test-issue-1450-logo-aspect.js
|
||||
node test-issue-1454-channels-toggle.js
|
||||
node test-issue-1456-score-labels.js
|
||||
|
||||
# #1461 mobile UX overhaul + #1470 node-detail tile helper (#1468 covered by E2E)
|
||||
node test-issue-1461-mobile-page-actions.js
|
||||
node test-issue-1470-node-tile-helper.js
|
||||
node test-issue-1473-reserved-prefixes.js
|
||||
node test-issue-1473-prefix-generator.js
|
||||
|
||||
echo ""
|
||||
echo "═══════════════════════════════════════"
|
||||
echo " All tests passed"
|
||||
|
||||
@@ -162,27 +162,54 @@ function assert(c, m) { if (!c) throw new Error(m || 'assertion failed'); }
|
||||
});
|
||||
|
||||
await step('outside click closes popover', async () => {
|
||||
// De-flake history: #1317 (62a81776) tried `mouse.click(700,500)` + a
|
||||
// `rect.width > 0` "listener installed" proxy. That proxy is FALSE — it
|
||||
// only proves the popover is visible, not that showPopover's
|
||||
// `setTimeout(0)` document-level click listener has actually run. Under
|
||||
// CI load the macrotask can be deferred past Playwright's polling
|
||||
// resolution, so the synthetic click fires BEFORE the listener exists,
|
||||
// is dropped, and the popover never hides → 8s default-timeout failure
|
||||
// (see run 26574358472 / d24246395 master push).
|
||||
//
|
||||
// Real fix: (1) install a one-shot probe of our own via
|
||||
// `requestAnimationFrame + setTimeout(0)` and `await` it from
|
||||
// node-side, guaranteeing showPopover's setTimeout(0) drained;
|
||||
// (2) retry the click in a small loop, since even with the probe
|
||||
// there's no synchronous handle on Playwright's internal event-loop
|
||||
// ordering. Each click is cheap (~ms); the popover hides on the first
|
||||
// one that reaches the installed listener.
|
||||
await page.evaluate(() =>
|
||||
window.ChannelColorPicker.show('#outsidechan', 100, 100));
|
||||
await page.waitForSelector('.cc-picker-popover');
|
||||
// Wait for the deferred (setTimeout 0) document-level click listener
|
||||
// to be installed before dispatching the outside click. Otherwise the
|
||||
// click races the listener registration and the popover stays open.
|
||||
await page.waitForFunction(() => {
|
||||
const el = document.querySelector('.cc-picker-popover');
|
||||
const rect = el && el.getBoundingClientRect();
|
||||
return rect && rect.width > 0 && rect.height > 0;
|
||||
}, { timeout: 5000 });
|
||||
// Real mouse click at a viewport coordinate that is clearly outside
|
||||
// the popover (popover anchored at 100,100; click at 700,500).
|
||||
// page.mouse.click dispatches PointerEvent + MouseEvent with real
|
||||
// coords, more representative than HTMLElement.click() and reliably
|
||||
// reaches the document-level capture-phase listener.
|
||||
await page.mouse.click(700, 500);
|
||||
await page.waitForFunction(() => {
|
||||
const el = document.querySelector('.cc-picker-popover');
|
||||
return el && el.style.display === 'none';
|
||||
}, { timeout: 15000 });
|
||||
await page.waitForSelector('.cc-picker-popover', { state: 'visible', timeout: 5000 });
|
||||
// Drain pending macrotasks (showPopover's setTimeout(0) installs the
|
||||
// outside-click listener). Wait two animation frames + a setTimeout(0)
|
||||
// so the same scheduler tier the listener uses has definitely run.
|
||||
await page.evaluate(() => new Promise((r) => {
|
||||
requestAnimationFrame(() => requestAnimationFrame(() =>
|
||||
setTimeout(r, 0)));
|
||||
}));
|
||||
// Click outside in a retry loop — if the very first synthetic click
|
||||
// still races the listener install, subsequent clicks land cleanly.
|
||||
// Popover anchored at (100,100); click at (700,500) is unambiguously
|
||||
// outside its bounding rect (popover is ~300×80).
|
||||
const closed = await (async () => {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await page.mouse.click(700, 500);
|
||||
try {
|
||||
await page.waitForFunction(() => {
|
||||
const el = document.querySelector('.cc-picker-popover');
|
||||
return el && el.style.display === 'none';
|
||||
}, { timeout: 1000 });
|
||||
return true;
|
||||
} catch (_) {
|
||||
// Re-check listener install by waiting another rAF and retrying.
|
||||
await page.evaluate(() => new Promise((r) =>
|
||||
requestAnimationFrame(() => setTimeout(r, 0))));
|
||||
}
|
||||
}
|
||||
return false;
|
||||
})();
|
||||
assert(closed, 'popover did not close after 10 outside-click attempts');
|
||||
});
|
||||
|
||||
// Cleanup
|
||||
|
||||
+121
-8
@@ -188,17 +188,30 @@ async function run() {
|
||||
await page.goto(BASE, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('nav, .navbar, .nav, [class*="nav"]');
|
||||
const themeBefore = await page.$eval('html', el => el.getAttribute('data-theme'));
|
||||
// Find toggle button
|
||||
const allButtons = await page.$$('button');
|
||||
|
||||
// The toggle may be a <label#darkModeToggle> wrapping a checkbox (new toggle-switch
|
||||
// design) or a <button#darkModeToggle> (legacy button design). Try the checkbox path
|
||||
// first, then fall back to the old button scan.
|
||||
let toggled = false;
|
||||
for (const b of allButtons) {
|
||||
const text = await b.textContent();
|
||||
if (text.includes('\u2600') || text.includes('\ud83c\udf19') || text.includes('\ud83c\udf11') || text.includes('\ud83c\udf15')) {
|
||||
await b.click();
|
||||
toggled = true;
|
||||
break;
|
||||
|
||||
// New toggle-switch: click the label or directly set the checkbox
|
||||
const toggleLabel = await page.$('#darkModeToggle');
|
||||
if (toggleLabel) {
|
||||
await toggleLabel.click();
|
||||
toggled = true;
|
||||
} else {
|
||||
// Legacy fallback: scan buttons for sun/moon emoji
|
||||
const allButtons = await page.$$('button');
|
||||
for (const b of allButtons) {
|
||||
const text = await b.textContent();
|
||||
if (text.includes('\u2600') || text.includes('\ud83c\udf19') || text.includes('\ud83c\udf11') || text.includes('\ud83c\udf15')) {
|
||||
await b.click();
|
||||
toggled = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert(toggled, 'Could not find dark mode toggle button');
|
||||
await page.waitForFunction(
|
||||
(before) => document.documentElement.getAttribute('data-theme') !== before,
|
||||
@@ -206,6 +219,23 @@ async function run() {
|
||||
);
|
||||
const themeAfter = await page.$eval('html', el => el.getAttribute('data-theme'));
|
||||
assert(themeBefore !== themeAfter, `Theme didn't change: before=${themeBefore}, after=${themeAfter}`);
|
||||
|
||||
// PR #893 follow-up: tighten — if the new toggle-switch is present, verify
|
||||
// (a) the checkbox is present and behaves as role="switch", and
|
||||
// (b) the chosen theme persists across a full reload (localStorage path).
|
||||
const checkbox = await page.$('#darkModeCheckbox');
|
||||
if (checkbox) {
|
||||
const role = await checkbox.evaluate(el => el.getAttribute('role'));
|
||||
assert(role === 'switch', `Expected role="switch" on #darkModeCheckbox, got "${role}"`);
|
||||
const checkedNow = await checkbox.evaluate(el => el.checked);
|
||||
assert(checkedNow === (themeAfter === 'dark'),
|
||||
`Checkbox state out of sync: checked=${checkedNow}, theme=${themeAfter}`);
|
||||
await page.reload({ waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('#darkModeToggle');
|
||||
const themePersisted = await page.$eval('html', el => el.getAttribute('data-theme'));
|
||||
assert(themePersisted === themeAfter,
|
||||
`Theme did not persist across reload: was=${themeAfter}, after-reload=${themePersisted}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Test: Stats bar shows version/commit badge
|
||||
@@ -2041,6 +2071,89 @@ async function run() {
|
||||
|
||||
// ─── End mobile filter tests ──────────────────────────────────────────────
|
||||
|
||||
// ─── #1468 — drop client-side "unknown" channel synthesis ────────────────
|
||||
|
||||
await test('#1468: live WS CHAN message with no payload.channel is dropped (no "unknown" bucket)', async () => {
|
||||
await page.setViewportSize({ width: 1280, height: 720 });
|
||||
await page.goto(`${BASE}/#/channels`, { waitUntil: 'domcontentloaded' });
|
||||
// Wait for the channels init() to mount and expose the test hook.
|
||||
await page.waitForFunction(() => typeof window._channelsProcessWSBatchForTest === 'function', { timeout: 10000 });
|
||||
|
||||
// Snapshot starting state so we can compare deltas.
|
||||
const before = await page.evaluate(() => {
|
||||
const s = window._channelsGetStateForTest();
|
||||
return { count: s.channels.length, names: s.channels.map(c => c.name || c.channel || '') };
|
||||
});
|
||||
|
||||
// Feed a CHAN-like message with NO payload.channel field (but valid hash).
|
||||
await page.evaluate(() => {
|
||||
window._channelsProcessWSBatchForTest([
|
||||
{
|
||||
type: 'packet',
|
||||
data: {
|
||||
hash: 'test1468drophash' + Date.now(),
|
||||
decoded: {
|
||||
header: { payloadTypeName: 'GRP_TXT' },
|
||||
payload: { /* no `channel` */ text: 'orphan: hello' },
|
||||
},
|
||||
},
|
||||
},
|
||||
], null);
|
||||
});
|
||||
|
||||
const after = await page.evaluate(() => {
|
||||
const s = window._channelsGetStateForTest();
|
||||
return { count: s.channels.length, names: s.channels.map(c => c.name || c.channel || '') };
|
||||
});
|
||||
|
||||
// No "unknown" channel materialized.
|
||||
assert(!after.names.includes('unknown'),
|
||||
'channels list does not contain a synthesized "unknown" entry — got ' + JSON.stringify(after.names));
|
||||
// And the channel-count delta is 0 — the orphan message was dropped, not bucketed.
|
||||
assert(after.count === before.count,
|
||||
`channel count unchanged after orphan WS msg — before=${before.count}, after=${after.count}`);
|
||||
});
|
||||
|
||||
await test('#1468 control: same WS message WITH payload.channel is still routed', async () => {
|
||||
await page.setViewportSize({ width: 1280, height: 720 });
|
||||
await page.goto(`${BASE}/#/channels`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => typeof window._channelsProcessWSBatchForTest === 'function', { timeout: 10000 });
|
||||
|
||||
const sentinel = '__test_chan_1468_' + Date.now();
|
||||
const before = await page.evaluate((name) => {
|
||||
const s = window._channelsGetStateForTest();
|
||||
return { hasSentinel: s.channels.some(c => (c.name || c.channel) === name) };
|
||||
}, sentinel);
|
||||
assert(!before.hasSentinel, 'pre: sentinel channel does not pre-exist');
|
||||
|
||||
await page.evaluate((name) => {
|
||||
window._channelsProcessWSBatchForTest([
|
||||
{
|
||||
type: 'packet',
|
||||
data: {
|
||||
hash: 'test1468hash' + Date.now(),
|
||||
decoded: {
|
||||
header: { payloadTypeName: 'GRP_TXT' },
|
||||
payload: { channel: name, text: 'alice: hi', sender: 'alice' },
|
||||
},
|
||||
},
|
||||
},
|
||||
], null);
|
||||
}, sentinel);
|
||||
|
||||
const after = await page.evaluate((name) => {
|
||||
const s = window._channelsGetStateForTest();
|
||||
return {
|
||||
hasSentinel: s.channels.some(c => (c.name || c.channel) === name),
|
||||
names: s.channels.map(c => c.name || c.channel || ''),
|
||||
};
|
||||
}, sentinel);
|
||||
assert(after.hasSentinel,
|
||||
'control: channel WITH payload.channel IS routed into the registry — got ' + JSON.stringify(after.names));
|
||||
});
|
||||
|
||||
// ─── End #1468 tests ──────────────────────────────────────────────────────
|
||||
|
||||
// Extract frontend coverage if instrumented server is running
|
||||
try {
|
||||
const coverage = await page.evaluate(() => window.__coverage__);
|
||||
|
||||
@@ -208,8 +208,11 @@ async function main() {
|
||||
|
||||
await ctx.close();
|
||||
|
||||
// ── (e) at 1024x800, edge-swipe hint visible on first visit ──
|
||||
const ctx2 = await browser.newContext({ viewport: { width: 1024, height: 800 } });
|
||||
// ── (e) at 1024x800 with touch, edge-swipe hint visible on first visit ──
|
||||
// #1065 follow-up: edge-swipe is a touch gesture; the hint must only
|
||||
// appear when the viewport reports touch capability. Test context must
|
||||
// pass hasTouch:true (real edge-swipe-on-tablet/touch-laptop scenario).
|
||||
const ctx2 = await browser.newContext({ viewport: { width: 1024, height: 800 }, hasTouch: true });
|
||||
const page2 = await ctx2.newPage();
|
||||
await page2.goto(`${BASE}/#/packets`, { waitUntil: 'domcontentloaded' });
|
||||
await page2.evaluate((keys) => Object.values(keys).forEach((k) => localStorage.removeItem(k)), KEYS);
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
#!/usr/bin/env node
|
||||
/* Issue #1065 follow-up — gesture hints must:
|
||||
* 1. Define a hasTouchCapability() helper that probes ontouchstart,
|
||||
* maxTouchPoints, and (pointer: coarse).
|
||||
* 2. Gate every HINTS[*].relevant() body on hasTouchCapability() at the
|
||||
* very top (no hint should fire on mouse-only viewports).
|
||||
* 3. Ship a .gesture-hint parent CSS rule that includes
|
||||
* `width: fit-content` AND `max-width: 360px` so the pill shrinks to
|
||||
* its content instead of stretching full-bleed and being pushed
|
||||
* off-screen by translateX(-50%) on narrow viewports.
|
||||
*
|
||||
* Pure source-file assertions — no browser required.
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const JS_PATH = path.join(__dirname, 'public', 'gesture-hints.js');
|
||||
const CSS_PATH = path.join(__dirname, 'public', 'style.css');
|
||||
|
||||
let failures = 0, passes = 0;
|
||||
const fail = (m) => { failures++; console.error(' FAIL: ' + m); };
|
||||
const pass = (m) => { passes++; console.log(' PASS: ' + m); };
|
||||
|
||||
const js = fs.readFileSync(JS_PATH, 'utf8');
|
||||
const css = fs.readFileSync(CSS_PATH, 'utf8');
|
||||
|
||||
// (1) helper exists and probes the three signals
|
||||
if (/function\s+hasTouchCapability\s*\(/.test(js)) pass('hasTouchCapability() defined');
|
||||
else fail('hasTouchCapability() not defined in gesture-hints.js');
|
||||
|
||||
if (/ontouchstart/.test(js)) pass('hasTouchCapability probes ontouchstart');
|
||||
else fail('hasTouchCapability missing ontouchstart probe');
|
||||
|
||||
if (/maxTouchPoints/.test(js)) pass('hasTouchCapability probes maxTouchPoints');
|
||||
else fail('hasTouchCapability missing maxTouchPoints probe');
|
||||
|
||||
if (/pointer:\s*coarse/.test(js)) pass('hasTouchCapability probes (pointer: coarse)');
|
||||
else fail('hasTouchCapability missing (pointer: coarse) probe');
|
||||
|
||||
// (2) every relevant() body must start with the touch gate
|
||||
// Find each `relevant: function () { ... }` block and check.
|
||||
const relevantRe = /relevant:\s*function\s*\(\s*\)\s*\{([\s\S]*?)\n\s{6}\}/g;
|
||||
let m, count = 0, gated = 0;
|
||||
while ((m = relevantRe.exec(js)) !== null) {
|
||||
count++;
|
||||
const body = m[1];
|
||||
// First non-comment statement must be hasTouchCapability gate
|
||||
if (/^\s*if\s*\(\s*!\s*hasTouchCapability\s*\(\s*\)\s*\)\s*return\s+false\s*;/m.test(body)) {
|
||||
gated++;
|
||||
}
|
||||
}
|
||||
if (count >= 4) pass(`found ${count} relevant() predicates`);
|
||||
else fail(`expected ≥4 relevant() predicates, found ${count}`);
|
||||
if (gated === count && count > 0) pass(`all ${gated}/${count} relevant() bodies start with !hasTouchCapability() return false`);
|
||||
else fail(`only ${gated}/${count} relevant() bodies gate on hasTouchCapability()`);
|
||||
|
||||
// (3) .gesture-hint parent rule has width: fit-content + max-width: 360px
|
||||
// Locate the rule block starting `.gesture-hint {` (NOT .gesture-hint-...).
|
||||
const ruleRe = /\n\.gesture-hint\s*\{([\s\S]*?)\}/;
|
||||
const ruleMatch = ruleRe.exec(css);
|
||||
if (!ruleMatch) {
|
||||
fail('.gesture-hint parent CSS rule not found in style.css');
|
||||
} else {
|
||||
pass('.gesture-hint parent CSS rule present');
|
||||
const body = ruleMatch[1];
|
||||
if (/\bwidth:\s*fit-content\b/.test(body)) pass('.gesture-hint declares width: fit-content');
|
||||
else fail('.gesture-hint missing width: fit-content (pill must shrink to content)');
|
||||
if (/\bmax-width:\s*360px\b/.test(body)) pass('.gesture-hint declares max-width: 360px');
|
||||
else fail('.gesture-hint missing max-width: 360px');
|
||||
}
|
||||
|
||||
// (4) defensive: no em-dash or stray "*/" inside .gesture-hint rule body
|
||||
if (ruleMatch) {
|
||||
const body = ruleMatch[1];
|
||||
if (/[\u2014\u2013]/.test(body)) fail('em-dash / en-dash inside .gesture-hint rule body (CSS-parse-fragile)');
|
||||
else pass('no em-dash inside .gesture-hint rule body');
|
||||
}
|
||||
|
||||
console.log(`\ntest-issue-1065-gesture-hints-gates.js: ${passes} passed, ${failures} failed`);
|
||||
process.exit(failures > 0 ? 1 : 0);
|
||||
@@ -36,7 +36,11 @@ async function run() {
|
||||
await page.waitForSelector('#chList', { timeout: 10000 });
|
||||
await page.waitForFunction(() => {
|
||||
const l = document.getElementById('chList');
|
||||
return l && l.querySelectorAll('.ch-item').length > 0;
|
||||
// #1367: mobile now renders flat .ch-row entries; older .ch-item
|
||||
// markup still ships on desktop. Accept either so this regression
|
||||
// test keeps gating the header/empty-state/name-width invariants
|
||||
// (which apply to both layouts) without pinning the row markup.
|
||||
return l && l.querySelectorAll('.ch-item, .ch-row').length > 0;
|
||||
}, { timeout: 15000 });
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
@@ -66,7 +70,11 @@ async function run() {
|
||||
|
||||
await step('first channel row name has computed-width >150px', async () => {
|
||||
const nameW = await page.evaluate(() => {
|
||||
const name = document.querySelector('#chList .ch-item .ch-item-name');
|
||||
// #1367: chat-app mobile row uses .ch-row + .ch-row-name. Fall back to
|
||||
// the legacy .ch-item .ch-item-name so this test still works on the
|
||||
// desktop layout / any regression that re-renders the old markup.
|
||||
const name = document.querySelector('#chList .ch-row .ch-row-name')
|
||||
|| document.querySelector('#chList .ch-item .ch-item-name');
|
||||
if (!name) return null;
|
||||
return Math.round(name.getBoundingClientRect().width);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* #1293 — Marker shape variation per role + colorblind-safe palette.
|
||||
*
|
||||
* Acceptance:
|
||||
* - ROLE_SHAPES map exposed by roles.js, with repeater=circle,
|
||||
* companion=square, room=hexagon, sensor=triangle, observer=diamond.
|
||||
* - ROLE_STYLE.shape values match ROLE_SHAPES (single source of truth).
|
||||
* - A shared helper `window.makeRoleMarkerSVG(role, color, size)` exists
|
||||
* and can produce a hexagon path for the room role (covers the
|
||||
* previously-missing shape in map.js's switch).
|
||||
* - public/live.js uses `L.divIcon` (shape-aware) for node markers,
|
||||
* NOT the legacy `L.circleMarker` in `addNodeMarker`.
|
||||
* - public/live.js legend renders SVG marker swatches (not flat dots) so
|
||||
* colorblind users can distinguish shape, not only colour.
|
||||
* - public/map.js switch handles `case 'hexagon'`.
|
||||
* - Selected/highlighted state uses an outline RING (no same-colour
|
||||
* filled overlay) — i.e. the highlight path sets fillOpacity:0
|
||||
* (or 'transparent') and uses a stroke-based ring helper.
|
||||
*
|
||||
* Pure-string assertions; no DOM/browser required so this can land
|
||||
* in the JS-unit-tests step of the CI workflow (fast red).
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
function assert(cond, msg) {
|
||||
if (cond) { passed++; console.log(' ✓ ' + msg); }
|
||||
else { failed++; console.error(' ✗ ' + msg); }
|
||||
}
|
||||
|
||||
const rolesSrc = fs.readFileSync(path.join(__dirname, 'public', 'roles.js'), 'utf8');
|
||||
const liveSrc = fs.readFileSync(path.join(__dirname, 'public', 'live.js'), 'utf8');
|
||||
const mapSrc = fs.readFileSync(path.join(__dirname, 'public', 'map.js'), 'utf8');
|
||||
|
||||
console.log('\n=== #1293: ROLE_SHAPES single source of truth ===');
|
||||
|
||||
// ROLE_SHAPES map declared on window
|
||||
assert(/window\.ROLE_SHAPES\s*=\s*\{/.test(rolesSrc),
|
||||
'roles.js declares window.ROLE_SHAPES map');
|
||||
|
||||
// Required role → shape pairings (line-order independent)
|
||||
const shapeBlockMatch = rolesSrc.match(/window\.ROLE_SHAPES\s*=\s*\{([\s\S]*?)\};/);
|
||||
const shapeBlock = shapeBlockMatch ? shapeBlockMatch[1] : '';
|
||||
const expectedShapes = {
|
||||
repeater: 'circle',
|
||||
companion: 'square',
|
||||
room: 'hexagon',
|
||||
sensor: 'triangle',
|
||||
observer: 'diamond',
|
||||
};
|
||||
for (const role of Object.keys(expectedShapes)) {
|
||||
const re = new RegExp(role + '\\s*:\\s*[\'\"]' + expectedShapes[role] + '[\'\"]');
|
||||
assert(re.test(shapeBlock), `ROLE_SHAPES.${role} === '${expectedShapes[role]}'`);
|
||||
}
|
||||
|
||||
// ROLE_STYLE shape values match the new map.
|
||||
// #1407 refactored ROLE_STYLE into a live getter (over Object.defineProperty)
|
||||
// whose shape data lives in a _styleShapes literal — parse that instead.
|
||||
const styleBlockMatch =
|
||||
rolesSrc.match(/window\.ROLE_STYLE\s*=\s*\{([\s\S]*?)\};/) ||
|
||||
rolesSrc.match(/_styleShapes\s*=\s*\{([\s\S]*?)\};/);
|
||||
const styleBlock = styleBlockMatch ? styleBlockMatch[1] : '';
|
||||
for (const role of Object.keys(expectedShapes)) {
|
||||
// crude per-line check
|
||||
const lineRe = new RegExp(role + '\\s*:[^}]*shape:\\s*[\'\"]' + expectedShapes[role] + '[\'\"]');
|
||||
assert(lineRe.test(styleBlock),
|
||||
`ROLE_STYLE.${role}.shape === '${expectedShapes[role]}' (matches ROLE_SHAPES)`);
|
||||
}
|
||||
|
||||
console.log('\n=== #1293: shared SVG helper covers hexagon ===');
|
||||
|
||||
assert(/window\.makeRoleMarkerSVG\s*=\s*function/.test(rolesSrc),
|
||||
'roles.js exposes window.makeRoleMarkerSVG(role, color, size)');
|
||||
|
||||
// Helper string must include a hexagon branch (matches map.js switch)
|
||||
const helperMatch = rolesSrc.match(/window\.makeRoleMarkerSVG[\s\S]*?\n\s*\};/);
|
||||
const helperBlock = helperMatch ? helperMatch[0] : '';
|
||||
assert(/case\s+['\"]hexagon['\"]/.test(helperBlock),
|
||||
'helper handles case "hexagon" (room role)');
|
||||
assert(/case\s+['\"]square['\"]/.test(helperBlock),
|
||||
'helper handles case "square"');
|
||||
assert(/case\s+['\"]triangle['\"]/.test(helperBlock),
|
||||
'helper handles case "triangle"');
|
||||
assert(/case\s+['\"]diamond['\"]/.test(helperBlock),
|
||||
'helper handles case "diamond"');
|
||||
|
||||
console.log('\n=== #1293: map.js switch handles hexagon ===');
|
||||
|
||||
assert(/case\s+['\"]hexagon['\"]/.test(mapSrc),
|
||||
'map.js makeMarkerIcon switch has a "hexagon" branch');
|
||||
|
||||
console.log('\n=== #1293: live.js node markers use shape-aware divIcons ===');
|
||||
|
||||
// Carve out addNodeMarker body (best-effort) and assert it uses divIcon.
|
||||
const addNodeIdx = liveSrc.indexOf('function addNodeMarker');
|
||||
assert(addNodeIdx > 0, 'live.js addNodeMarker function present');
|
||||
const addNodeBody = liveSrc.slice(addNodeIdx, addNodeIdx + 2500);
|
||||
assert(/L\.divIcon|window\.makeRoleMarkerSVG|makeRoleMarkerSVG\s*\(/.test(addNodeBody),
|
||||
'addNodeMarker uses L.divIcon / makeRoleMarkerSVG (not legacy circleMarker)');
|
||||
assert(!/L\.circleMarker\(\s*\[\s*n\.lat/.test(addNodeBody),
|
||||
'addNodeMarker no longer creates L.circleMarker for the node itself');
|
||||
|
||||
console.log('\n=== #1293: live.js legend renders shape swatches ===');
|
||||
|
||||
// The role legend block (id="roleLegendList") must inject SVG, not a
|
||||
// flat live-dot span only.
|
||||
const legendIdx = liveSrc.indexOf("getElementById('roleLegendList')");
|
||||
assert(legendIdx > 0, 'live.js renders roleLegendList');
|
||||
const legendBody = liveSrc.slice(legendIdx, legendIdx + 1500);
|
||||
assert(/<svg|makeRoleMarkerSVG/.test(legendBody),
|
||||
'roleLegendList swatches include SVG shape (not bare colour dot)');
|
||||
|
||||
console.log('\n=== #1293: selected/highlight uses outline ring (no same-colour fill overlay) ===');
|
||||
|
||||
// New behaviour: marker highlight pulse must NOT recolor marker fill to
|
||||
// the same packet colour stacked over a same-coloured base. The fix
|
||||
// uses a stroke ring (fillOpacity 0 / 'transparent') for the overlay.
|
||||
assert(/highlightNodeRing|RingHighlight|highlightRing/.test(liveSrc) ||
|
||||
/fillOpacity:\s*0[,\s}]/.test(liveSrc.slice(liveSrc.indexOf('animatePulse') || 0,
|
||||
(liveSrc.indexOf('animatePulse') || 0) + 1500)),
|
||||
'highlight path uses a transparent-fill ring (no same-colour concentric fill)');
|
||||
|
||||
console.log('\n=== Summary ===');
|
||||
console.log(` Passed: ${passed}`);
|
||||
console.log(` Failed: ${failed}`);
|
||||
if (failed > 0) { console.error('\n#1293 FAIL'); process.exit(1); }
|
||||
console.log('\n#1293 PASS');
|
||||
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* E2E (#1329): Map controls panel on mobile must NOT be capped at 200px
|
||||
* with internal scroll. Use accordion sections — one expanded at a time —
|
||||
* so the visible content always fits without scrolling.
|
||||
*
|
||||
* Mobile (375x812):
|
||||
* - Open Map controls.
|
||||
* - Panel must have accordion sections (legend acts as toggle, with
|
||||
* aria-expanded attribute).
|
||||
* - Default state: at most one section expanded.
|
||||
* - Panel contents must NOT require internal scroll
|
||||
* (scrollHeight <= clientHeight + 1).
|
||||
* - Clicking a different section's legend collapses the previously-open
|
||||
* section (single-open behavior).
|
||||
*
|
||||
* Desktop (1280x800):
|
||||
* - Existing layout unchanged: all sections visible by default,
|
||||
* panel position:absolute, modest width.
|
||||
*
|
||||
* Run: BASE_URL=http://localhost:13581 node test-issue-1329-map-controls-accordion-e2e.js
|
||||
*/
|
||||
'use strict';
|
||||
const { chromium } = require('playwright');
|
||||
|
||||
const BASE = process.env.BASE_URL || 'http://localhost:13581';
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
async function step(name, fn) {
|
||||
try { await fn(); passed++; console.log(' \u2713 ' + name); }
|
||||
catch (e) { failed++; console.error(' \u2717 ' + name + ': ' + e.message); }
|
||||
}
|
||||
function assert(c, m) { if (!c) throw new Error(m || 'assertion failed'); }
|
||||
|
||||
async function run() {
|
||||
const launchOpts = { args: ['--no-sandbox'] };
|
||||
if (process.env.CHROMIUM_PATH) launchOpts.executablePath = process.env.CHROMIUM_PATH;
|
||||
const browser = await chromium.launch(launchOpts);
|
||||
|
||||
// === Mobile: 375x812 ===
|
||||
const ctx = await browser.newContext({ viewport: { width: 375, height: 812 } });
|
||||
const page = await ctx.newPage();
|
||||
|
||||
await page.goto(BASE + '/#/map', { waitUntil: 'load', timeout: 60000 });
|
||||
await page.waitForSelector('#leaflet-map', { timeout: 10000 });
|
||||
await page.waitForSelector('#mapControls', { state: 'attached', timeout: 10000 });
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Ensure controls panel is expanded (default is collapsed on mobile).
|
||||
await page.evaluate(() => {
|
||||
const panel = document.getElementById('mapControls');
|
||||
const btn = document.getElementById('mapControlsToggle');
|
||||
if (panel && panel.classList.contains('collapsed')) btn && btn.click();
|
||||
});
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
await step('mobile: at least one accordion section present with aria-expanded', async () => {
|
||||
const data = await page.evaluate(() => {
|
||||
const panel = document.getElementById('mapControls');
|
||||
// Accordion section markers: legend (or button) carrying aria-expanded
|
||||
// inside a .mc-section.mc-accordion (or equivalent) descendant.
|
||||
const toggles = panel.querySelectorAll('.mc-section [aria-expanded], .mc-accordion-toggle[aria-expanded]');
|
||||
const sections = panel.querySelectorAll('.mc-section');
|
||||
return {
|
||||
toggles: toggles.length,
|
||||
sections: sections.length,
|
||||
expandedCount: Array.from(toggles).filter(t => t.getAttribute('aria-expanded') === 'true').length,
|
||||
};
|
||||
});
|
||||
assert(data.toggles >= 1,
|
||||
'expected ≥1 accordion toggle (aria-expanded), got ' + data.toggles +
|
||||
' (sections=' + data.sections + ')');
|
||||
});
|
||||
|
||||
await step('mobile: at most one section expanded by default', async () => {
|
||||
const data = await page.evaluate(() => {
|
||||
const panel = document.getElementById('mapControls');
|
||||
const toggles = panel.querySelectorAll('.mc-section [aria-expanded], .mc-accordion-toggle[aria-expanded]');
|
||||
return {
|
||||
expandedCount: Array.from(toggles).filter(t => t.getAttribute('aria-expanded') === 'true').length,
|
||||
total: toggles.length,
|
||||
};
|
||||
});
|
||||
assert(data.expandedCount <= 1,
|
||||
'expected ≤1 section expanded by default, got ' + data.expandedCount + '/' + data.total);
|
||||
});
|
||||
|
||||
await step('mobile: panel content does NOT require internal scroll', async () => {
|
||||
const data = await page.evaluate(() => {
|
||||
const panel = document.getElementById('mapControls');
|
||||
return {
|
||||
scrollH: panel.scrollHeight,
|
||||
clientH: panel.clientHeight,
|
||||
overflowY: getComputedStyle(panel).overflowY,
|
||||
};
|
||||
});
|
||||
// The accordion sections should keep content within viewport — when only
|
||||
// one section is expanded, panel must not need to scroll internally.
|
||||
assert(data.scrollH <= data.clientH + 1,
|
||||
'panel must not require internal scroll (scrollH=' + data.scrollH +
|
||||
' clientH=' + data.clientH + ')');
|
||||
});
|
||||
|
||||
await step('mobile: clicking a 2nd toggle collapses the first (single-open)', async () => {
|
||||
const result = await page.evaluate(() => {
|
||||
const panel = document.getElementById('mapControls');
|
||||
const toggles = Array.from(panel.querySelectorAll('.mc-section [aria-expanded], .mc-accordion-toggle[aria-expanded]'));
|
||||
if (toggles.length < 2) return { skip: true, n: toggles.length };
|
||||
// Find one currently closed and one open; if all closed, open first then click second.
|
||||
let openIdx = toggles.findIndex(t => t.getAttribute('aria-expanded') === 'true');
|
||||
if (openIdx === -1) {
|
||||
toggles[0].click();
|
||||
openIdx = 0;
|
||||
}
|
||||
const otherIdx = openIdx === 0 ? 1 : 0;
|
||||
toggles[otherIdx].click();
|
||||
return {
|
||||
skip: false,
|
||||
firstNow: toggles[openIdx].getAttribute('aria-expanded'),
|
||||
otherNow: toggles[otherIdx].getAttribute('aria-expanded'),
|
||||
};
|
||||
});
|
||||
if (result.skip) {
|
||||
throw new Error('need at least 2 accordion toggles to test single-open (got ' + result.n + ')');
|
||||
}
|
||||
assert(result.otherNow === 'true',
|
||||
'second toggle should be open after click, got ' + result.otherNow);
|
||||
assert(result.firstNow === 'false',
|
||||
'first toggle should auto-close (single-open), got ' + result.firstNow);
|
||||
});
|
||||
|
||||
await ctx.close();
|
||||
|
||||
// === Desktop: 1280x800 ===
|
||||
const ctx2 = await browser.newContext({ viewport: { width: 1280, height: 800 } });
|
||||
const p2 = await ctx2.newPage();
|
||||
await p2.goto(BASE + '/#/map', { waitUntil: 'load', timeout: 60000 });
|
||||
await p2.waitForSelector('#mapControls', { state: 'attached', timeout: 10000 });
|
||||
await p2.waitForTimeout(300);
|
||||
|
||||
await step('desktop (1280px): panel position:absolute, all section contents visible', async () => {
|
||||
const data = await p2.evaluate(() => {
|
||||
const panel = document.getElementById('mapControls');
|
||||
const cs = getComputedStyle(panel);
|
||||
const rect = panel.getBoundingClientRect();
|
||||
// Check that section content (e.g., labels) is visible on desktop.
|
||||
const allInputs = panel.querySelectorAll('input[type=checkbox], select, button');
|
||||
let visible = 0;
|
||||
allInputs.forEach(el => {
|
||||
const r = el.getBoundingClientRect();
|
||||
if (r.width > 0 && r.height > 0) visible++;
|
||||
});
|
||||
return {
|
||||
position: cs.position,
|
||||
width: Math.round(rect.width),
|
||||
vw: window.innerWidth,
|
||||
visibleControls: visible,
|
||||
totalControls: allInputs.length,
|
||||
};
|
||||
});
|
||||
assert(data.position === 'absolute',
|
||||
'desktop panel must be position:absolute, got ' + data.position);
|
||||
assert(data.width < data.vw * 0.5,
|
||||
'desktop panel must be <50% viewport width, got ' + data.width + '/' + data.vw);
|
||||
// All (or nearly all) controls should be visible on desktop — accordion
|
||||
// collapse must NOT apply at desktop sizes.
|
||||
assert(data.visibleControls >= data.totalControls - 2,
|
||||
'desktop must show all controls (got ' + data.visibleControls + '/' + data.totalControls + ')');
|
||||
});
|
||||
|
||||
await browser.close();
|
||||
|
||||
console.log('\n' + passed + '/' + (passed + failed) + ' tests passed' +
|
||||
(failed ? ', ' + failed + ' failed' : ''));
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
run().catch(err => { console.error('Fatal:', err); process.exit(1); });
|
||||
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* #1356 — WCAG 2.2 AA accessibility for map cluster bubbles, role pills,
|
||||
* and multi-byte hash labels.
|
||||
*
|
||||
* Locked design = Tufte's structural framing (drop color as primary signal,
|
||||
* use shape / glyph / border-style as carriers) WITH the audit's "Minimal
|
||||
* patch to Tufte's proposal to reach AA" applied.
|
||||
*
|
||||
* Design sources:
|
||||
* - https://github.com/Kpa-clawbot/CoreScope/issues/1356#issuecomment-4535244400
|
||||
* - https://github.com/Kpa-clawbot/CoreScope/issues/1356#issuecomment-4535849354
|
||||
*
|
||||
* Pure-string assertions (mirrors test-issue-1293-marker-shapes.js pattern)
|
||||
* so this runs in the JS-unit-tests CI step without a browser.
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
function assert(cond, msg) {
|
||||
if (cond) { passed++; console.log(' ✓ ' + msg); }
|
||||
else { failed++; console.error(' ✗ ' + msg); }
|
||||
}
|
||||
|
||||
const mapSrc = fs.readFileSync(path.join(__dirname, 'public', 'map.js'), 'utf8');
|
||||
const cssSrc = fs.readFileSync(path.join(__dirname, 'public', 'style.css'), 'utf8');
|
||||
|
||||
console.log('\n=== #1356 V1: cluster bubble — neutral fill, border-style ramp, ARIA ===');
|
||||
|
||||
// V1.a — CSS must define a neutral cluster fill constant (not the bucket color).
|
||||
assert(/--mc-cluster-fill\s*:/.test(cssSrc),
|
||||
'style.css declares --mc-cluster-fill CSS variable');
|
||||
|
||||
// V1.b — Per-bucket background MUST NOT be the old --info/--warning/--accent system colors.
|
||||
// (Those system vars are reserved per AGENTS.md / issue scope.)
|
||||
const clusterBlock = cssSrc.match(/\.mc-cluster\.mc-sm[\s\S]{0,400}\.mc-cluster\.mc-lg[^}]*\}/);
|
||||
assert(clusterBlock && !/var\(--info|var\(--warning|var\(--accent/.test(clusterBlock[0]),
|
||||
'cluster sm/md/lg no longer use --info / --warning / --accent for fill');
|
||||
|
||||
// V1.c — Border-style ramp (solid → heavier → double) is the redundant carrier.
|
||||
assert(/\.mc-cluster\.mc-lg[^}]*double/.test(cssSrc),
|
||||
'cluster lg uses "double" border-style as a non-color carrier');
|
||||
|
||||
// V1.d — Audit override: border color must be #666 (NOT white) plus a dark halo via box-shadow.
|
||||
assert(/--mc-cluster-border\s*:\s*#666/i.test(cssSrc),
|
||||
'--mc-cluster-border is #666 (audit fix for SC 1.4.11 vs Carto-light)');
|
||||
assert(/\.mc-cluster[^{]*\{[\s\S]*?box-shadow[^;]*rgba\(0\s*,\s*0\s*,\s*0/i.test(cssSrc),
|
||||
'.mc-cluster has a dark halo box-shadow (audit fix for border visibility)');
|
||||
|
||||
// V1.e — ARIA on the cluster div (rendered in makeClusterIcon).
|
||||
assert(/role=["']img["']/.test(mapSrc) && /aria-label[^=]*=[^>]*nodes/.test(mapSrc),
|
||||
'makeClusterIcon emits role="img" + aria-label summarising count + role breakdown');
|
||||
assert(/' nodes — '/.test(mapSrc) || /\d+ nodes — /.test(mapSrc) ||
|
||||
/total\s*\+\s*' nodes — '/.test(mapSrc),
|
||||
'cluster aria-label matches /\\d+ nodes — / pattern (summary + breakdown)');
|
||||
|
||||
console.log('\n=== #1356 V2: role pills — letter primary, Wong palette, dark text ===');
|
||||
|
||||
// V2.a — A ROLE_LETTERS map is defined for the 5 roles.
|
||||
assert(/ROLE_LETTERS\s*=\s*\{[\s\S]*?repeater[\s\S]*?['"]R['"][\s\S]*?companion[\s\S]*?['"]C['"][\s\S]*?room[\s\S]*?['"]M['"][\s\S]*?sensor[\s\S]*?['"]S['"][\s\S]*?observer[\s\S]*?['"]O['"]/.test(mapSrc),
|
||||
'map.js defines ROLE_LETTERS with R/C/M/S/O for the five roles');
|
||||
|
||||
// V2.b — makeClusterIcon emits the letter (not just a count) inside the pill.
|
||||
const pillEmitRe = /<span class="mc-pill[^>]*>[^<]*' \+\s*ROLE_LETTERS\[/;
|
||||
assert(pillEmitRe.test(mapSrc) || /ROLE_LETTERS\[role\][\s\S]{0,200}mc-pill/.test(mapSrc) ||
|
||||
/mc-pill[\s\S]{0,200}ROLE_LETTERS\[role\]/.test(mapSrc),
|
||||
'pill HTML embeds ROLE_LETTERS[role] as the primary content');
|
||||
|
||||
// V2.c — Dark text on ALL five Wong-default pills (audit override of Tufte's
|
||||
// per-pill switch). #1407 generalized this to a per-role text-color CSS var
|
||||
// (--mc-role-X-text) so darker presets (achromat / trit) can pair white text
|
||||
// with darker bgs and still meet WCAG 1.4.3 AA. The Wong DEFAULT still uses
|
||||
// #1a1a1a — encoded as the fallback in `var(--mc-pill-text, #1a1a1a)` AND
|
||||
// on each `var(--mc-role-X-text, #1a1a1a)`, so any regression that drops the
|
||||
// per-role vars still renders dark text on Wong (no theming illusion).
|
||||
assert(/\.mc-pill\b[^{]*\{[^}]*color\s*:\s*var\(\s*--mc-(?:pill|role-[a-z]+)-text\s*,\s*#1a1a1a\s*\)/i.test(cssSrc),
|
||||
'.mc-pill CSS rule sets color: var(--mc-...-text, #1a1a1a) — #1407 generalized #1356\'s authoritative dark default');
|
||||
assert(/class="mc-pill[^"]*"[^>]*style="[^"]*color:(?:\s*#1a1a1a|'\s*\+\s*fg\b|\s*var\(--mc-role-[a-z]+-text)/i.test(mapSrc),
|
||||
'.mc-pill render-site emits inline color (#1a1a1a, "+ fg +", or var(--mc-role-X-text, #1a1a1a)) — defense-in-depth for divIcon (#1407)');
|
||||
|
||||
// V2.d — font-size ≥ 10px (audit bumped from 9px).
|
||||
const pillFontMatch = cssSrc.match(/\.mc-pill\b[^{]*\{[^}]*font[^;]*;/);
|
||||
assert(pillFontMatch && /1[0-9]px|0\.625rem|0\.6875rem|0\.75rem/.test(pillFontMatch[0]),
|
||||
'.mc-pill font-size is ≥ 10px (audit fix for SC 1.4.3 / 1.4.4)');
|
||||
|
||||
// V2.e — Wong palette declared as --mc-role-* constants.
|
||||
['repeater','companion','room','sensor','observer'].forEach(function(r){
|
||||
assert(new RegExp('--mc-role-' + r + '\\s*:').test(cssSrc),
|
||||
'--mc-role-' + r + ' CSS variable declared');
|
||||
});
|
||||
|
||||
// V2.f — per-pill aria-label "<N> <role>s".
|
||||
assert(/aria-label="'\s*\+\s*n\s*\+\s*' '\s*\+\s*role/.test(mapSrc) ||
|
||||
/aria-label=("|')[\s\S]{0,80}\+\s*n\s*\+[\s\S]{0,80}\+\s*role/.test(mapSrc),
|
||||
'pill HTML emits aria-label with count + role');
|
||||
|
||||
// V2.g — DO NOT touch --info / --warning / --accent (out of scope hard rule).
|
||||
const mcRoleBlock = cssSrc.match(/--mc-role-[\s\S]{0,1500}/);
|
||||
assert(mcRoleBlock && !/--info\s*:|--warning\s*:|--accent\s*:/.test(mcRoleBlock[0]),
|
||||
'role pill constants are --mc-* namespaced (do not redefine --info/--warning/--accent)');
|
||||
|
||||
console.log('\n=== #1356 V3: multi-byte hash labels — glyph + neutral fill + colored border-left ===');
|
||||
|
||||
// V3.a — MB_GLYPHS map for ✓ / ? / ✗.
|
||||
assert(/MB_GLYPHS\s*=\s*\{[\s\S]*?confirmed[\s\S]*?['"\\]u2713|MB_GLYPHS\s*=\s*\{[\s\S]*?confirmed[\s\S]*?['"]\u2713['"]/.test(mapSrc) ||
|
||||
/MB_GLYPHS\s*=\s*\{[\s\S]*?confirmed[\s\S]*?['"]✓['"]/.test(mapSrc),
|
||||
'map.js defines MB_GLYPHS with ✓ for confirmed');
|
||||
assert(/MB_GLYPHS[\s\S]*?suspected[\s\S]*?['"]\?['"]/.test(mapSrc),
|
||||
'MB_GLYPHS.suspected === "?"');
|
||||
assert(/MB_GLYPHS[\s\S]*?unknown[\s\S]*?['"\\]u2717|MB_GLYPHS[\s\S]*?unknown[\s\S]*?['"]✗['"]/.test(mapSrc),
|
||||
'MB_GLYPHS.unknown === ✗ (u2717)');
|
||||
|
||||
// V3.b — Neutral fill constant for multi-byte label.
|
||||
assert(/--mc-mb-fill\s*:/.test(cssSrc),
|
||||
'--mc-mb-fill CSS variable declared (neutral fill, not status color)');
|
||||
|
||||
// V3.c — High-luminance accent set (audit override of Tol "vibrant").
|
||||
// Confirmed #56F0A0 / suspected #FFD966 / unknown #FF8888.
|
||||
assert(/--mc-mb-confirmed\s*:\s*#56F0A0/i.test(cssSrc),
|
||||
'--mc-mb-confirmed is #56F0A0 (audit high-luminance set, not #117733)');
|
||||
assert(/--mc-mb-suspected\s*:\s*#FFD966/i.test(cssSrc),
|
||||
'--mc-mb-suspected is #FFD966');
|
||||
assert(/--mc-mb-unknown\s*:\s*#FF8888/i.test(cssSrc),
|
||||
'--mc-mb-unknown is #FF8888');
|
||||
|
||||
// V3.d — 3px colored left border in style.
|
||||
assert(/border-left\s*:\s*3px solid/.test(cssSrc),
|
||||
'.mc-mb-label has 3px solid border-left (colored accent stripe)');
|
||||
|
||||
// V3.e — makeRepeaterLabelIcon prepends MB_GLYPHS[status].
|
||||
assert(/MB_GLYPHS\[[^\]]+\][\s\S]{0,200}shortHash|shortHash[\s\S]{0,200}MB_GLYPHS\[/.test(mapSrc),
|
||||
'makeRepeaterLabelIcon prepends MB_GLYPHS glyph to the hash text');
|
||||
|
||||
// V3.f — aria-label "multi-byte <status>, hash <ID>".
|
||||
assert(/aria-label="'\s*\+\s*ariaStatus\s*\+\s*'"/.test(mapSrc) ||
|
||||
/'multi-byte '\s*\+\s*status\s*\+\s*', hash '\s*\+\s*shortHash/.test(mapSrc) ||
|
||||
/aria-label="multi-byte \$\{[^}]+\}, hash \$\{shortHash\}"/.test(mapSrc),
|
||||
'makeRepeaterLabelIcon emits aria-label "multi-byte <status>, hash <ID>"');
|
||||
|
||||
// V3.g — Glyph span must be aria-hidden so AT does not read "check mark 3 E".
|
||||
assert(/<span aria-hidden="true">[\s\S]{0,100}shortHash|<span aria-hidden="true">'\s*\+\s*(?:glyph|visible)/.test(mapSrc) ||
|
||||
/aria-hidden="true">'\s*\+\s*visible/.test(mapSrc),
|
||||
'visible glyph+hash span is aria-hidden="true" (AT reads aria-label only)');
|
||||
|
||||
// V3.h — repeater label MUST use the neutral fill via var(--mc-mb-fill); MUST
|
||||
// NOT paint background per-status (that would re-enable the pre-#1356
|
||||
// color-only signal). Affirmative check on the neutral-fill rule AND
|
||||
// negative check on the per-status bgColor pattern (round-1 adversarial #5:
|
||||
// the prior `!removal || affirmative` form short-circuited to a tautology).
|
||||
assert(/\.mc-mb-label\b[^{]*\{[^}]*background\s*:\s*var\(--mc-mb-fill\)/.test(cssSrc),
|
||||
'.mc-mb-label background uses var(--mc-mb-fill) — neutral fill, not status color');
|
||||
assert(!/bgColor\s*=\s*colorOverride\s*\|\|\s*s\.color/.test(mapSrc),
|
||||
'old per-status bgColor pattern is gone (no per-status background painting)');
|
||||
|
||||
console.log('\n=== #1356 Round-1 coverage adds: dual-marker star, null mbStatus, forced-colors ===');
|
||||
|
||||
// COV-1 — Observer-also-repeater dual marker: the ★ star glyph inside
|
||||
// makeRepeaterLabelIcon's obsIndicator branch MUST carry aria-hidden="true",
|
||||
// otherwise the AT announcement is polluted with "black star" / "star" on
|
||||
// top of the meaningful aria-label. Round-1 (Kent + adversarial) flagged.
|
||||
// Match the exact obsIndicator construction shape: `isAlsoObserver ? ' <span aria-hidden="true" ... ★`.
|
||||
assert(/isAlsoObserver[\s\S]{0,40}\?\s*['"][^'"]*<span\s+aria-hidden="true"[^>]*>[^<]*★/.test(mapSrc),
|
||||
'observer-also-repeater star span carries aria-hidden="true" (no AT pollution)');
|
||||
|
||||
// COV-2 — makeRepeaterLabelIcon with no multi_byte_status field must NOT emit
|
||||
// an aria-label containing "multi-byte undefined" (the obvious bug if the
|
||||
// null-fallback branch is dropped). Verify the source has the explicit
|
||||
// `mbStatus || null` + truthy-check structure that prevents this.
|
||||
assert(/var\s+status\s*=\s*mbStatus\s*\|\|\s*null\s*;/.test(mapSrc),
|
||||
'makeRepeaterLabelIcon normalises missing mbStatus to null (not "undefined")');
|
||||
assert(/ariaStatus\s*=\s*status\s*\?\s*\(\s*['"]multi-byte\s/.test(mapSrc),
|
||||
'ariaStatus uses ternary on truthy `status` — null falls through to "repeater hash <ID>" branch');
|
||||
// Negative regression: no template/concat that would ever produce "multi-byte undefined".
|
||||
assert(!/['"]multi-byte\s*['"]\s*\+\s*mbStatus(?![^,]*\?)/.test(mapSrc),
|
||||
'no unconditional concat of "multi-byte " + mbStatus (would emit "multi-byte undefined" on null)');
|
||||
|
||||
// COV-3 — @media (forced-colors: active) block MUST exist in style.css AND
|
||||
// MUST NOT contain `forced-color-adjust: none` anywhere within its body
|
||||
// (audit explicitly warned against `none`; degrades High Contrast Mode).
|
||||
const fcMatch = cssSrc.match(/@media\s*\(\s*forced-colors\s*:\s*active\s*\)\s*\{[\s\S]*?\n\}/);
|
||||
assert(fcMatch, '@media (forced-colors: active) block present in style.css');
|
||||
assert(fcMatch && !/forced-color-adjust\s*:\s*none/i.test(fcMatch[0]),
|
||||
'@media (forced-colors: active) block does NOT use forced-color-adjust: none (audit regression guard)');
|
||||
|
||||
|
||||
console.log('\n=== #1356 Hard rules: --info / --warning / --accent untouched ===');
|
||||
|
||||
// Sanity: ensure new --mc-* constants don't redefine the reserved system vars.
|
||||
// (--info and --warning are only used via var(..., fallback) — they may not be declared
|
||||
// at all; --accent IS declared.)
|
||||
const newConstantsBlock = (cssSrc.match(/\/\*[^*]*#1356[\s\S]*?\*\/[\s\S]*?(?=\/\*|$)/) || ['', ''])[0];
|
||||
assert(!/--info\s*:|--warning\s*:|--accent\s*:/.test(newConstantsBlock),
|
||||
'#1356 CSS block does not redefine --info / --warning / --accent');
|
||||
assert(/--accent\s*:/.test(cssSrc), '--accent CSS variable still defined');
|
||||
|
||||
console.log('\n=== Summary ===');
|
||||
console.log(` Passed: ${passed}`);
|
||||
console.log(` Failed: ${failed}`);
|
||||
if (failed > 0) { console.error('\n#1356 FAIL'); process.exit(1); }
|
||||
console.log('\n#1356 PASS');
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* #1360 — regression(map): #1357 cluster role pills lost the count number.
|
||||
*
|
||||
* Pill body must contain BOTH the role letter (WCAG carrier from #1356)
|
||||
* AND the per-role count (the data sighted operators need at a glance).
|
||||
*
|
||||
* Pure-string assertions over public/map.js (mirrors #1356 test pattern).
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
function assert(cond, msg) {
|
||||
if (cond) { passed++; console.log(' ✓ ' + msg); }
|
||||
else { failed++; console.error(' ✗ ' + msg); }
|
||||
}
|
||||
|
||||
const mapSrc = fs.readFileSync(path.join(__dirname, 'public', 'map.js'), 'utf8');
|
||||
|
||||
console.log('\n=== #1360: pill body emits letter + count (not letter alone) ===');
|
||||
|
||||
// A. Source must concatenate letter and n (the count) into the pill body.
|
||||
// Acceptable shapes: `letter + n`, `letter + String(n)`, `(letter + n)`.
|
||||
const concatRe = /letter\s*\+\s*(?:String\()?\s*n\b/;
|
||||
assert(concatRe.test(mapSrc),
|
||||
'map.js concatenates letter + n (or letter + String(n)) for pill body');
|
||||
|
||||
// B. The pill body must NOT be bare `letter` followed immediately by '</span>'.
|
||||
// i.e. reject `... + letter + '</span>'` with nothing in between.
|
||||
const bareLetterRe = /\+\s*letter\s*\+\s*['"]<\/span>/;
|
||||
assert(!bareLetterRe.test(mapSrc),
|
||||
'pill body is no longer just letter (no `+ letter + "</span>"` pattern)');
|
||||
|
||||
// C. Simulate makeClusterIcon by exercising __meshcoreMapInternals if loadable
|
||||
// in Node — fallback: pattern-check the rendered HTML template.
|
||||
// map.js is browser-oriented (Leaflet IIFE) so we string-test the template.
|
||||
// Build a synthetic expected pill body: a letter from R/C/M/S/O + digits.
|
||||
// The assertion below validates the rendered shape via regex over the
|
||||
// template's emitted output pattern.
|
||||
const pillTemplateRe = /<span class="mc-pill[\s\S]{0,400}letter\s*\+\s*(?:String\()?\s*n/;
|
||||
assert(pillTemplateRe.test(mapSrc),
|
||||
'pill HTML template body interpolates letter + n inside the span');
|
||||
|
||||
// D. Letter is still the first character of the pill body (preserves #1356
|
||||
// WCAG carrier ordering — assistive scanning sees the role letter first).
|
||||
// The concatenation must be `letter + n`, not `n + letter`.
|
||||
const reverseRe = /\bn\s*\+\s*letter\b/;
|
||||
assert(!reverseRe.test(mapSrc),
|
||||
'letter precedes count in concatenation (letter + n, not n + letter)');
|
||||
|
||||
// E. Acceptance criterion from the issue: pill body matches /^[RCMSO]\d+$/
|
||||
// for non-zero counts. Verify ROLE_LETTERS maps to the expected set.
|
||||
const roleLettersRe = /ROLE_LETTERS\s*=\s*\{([\s\S]*?)\}/;
|
||||
const rlMatch = mapSrc.match(roleLettersRe);
|
||||
assert(rlMatch, 'ROLE_LETTERS map is defined in map.js');
|
||||
if (rlMatch) {
|
||||
const letters = (rlMatch[1].match(/'[A-Z]'/g) || []).map(function (s) { return s[1]; });
|
||||
const expected = ['R', 'C', 'M', 'S', 'O'];
|
||||
const haveAll = expected.every(function (l) { return letters.indexOf(l) !== -1; });
|
||||
assert(haveAll,
|
||||
'ROLE_LETTERS includes R, C, M, S, O so pill body matches /^[RCMSO]\\d+$/');
|
||||
}
|
||||
|
||||
// === #1360 follow-up: 4+ digit count overflow guard ===
|
||||
console.log('\n=== #1360 follow-up: pill width bounded for 4+ digit counts ===');
|
||||
|
||||
// F. JS cap: makeClusterIcon must clamp counts > 999 to "999+" so pill body
|
||||
// becomes e.g. "R999+" instead of "R1234" / "R10000".
|
||||
const jsCapRe = /n\s*>\s*999[\s\S]{0,80}['"]999\+['"]/;
|
||||
assert(jsCapRe.test(mapSrc),
|
||||
'makeClusterIcon caps counts > 999 to "999+" (n > 999 → "999+")');
|
||||
|
||||
// G. CSS guard: .mc-pill rule must include max-width AND text-overflow:ellipsis
|
||||
// as defense-in-depth in case a render slips past the JS cap.
|
||||
const cssSrc = fs.readFileSync(path.join(__dirname, 'public', 'style.css'), 'utf8');
|
||||
const pillRuleRe = /\.mc-cluster\s+\.mc-pill\s*\{([\s\S]*?)\}/;
|
||||
const pillMatch = cssSrc.match(pillRuleRe);
|
||||
assert(pillMatch, '.mc-cluster .mc-pill rule found in style.css');
|
||||
if (pillMatch) {
|
||||
const body = pillMatch[1];
|
||||
// #1364: dropped `max-width` — it over-clamped multi-digit counts.
|
||||
// Graceful-degrade ellipsis assertion stays.
|
||||
assert(/text-overflow\s*:\s*ellipsis/.test(body),
|
||||
'.mc-pill declares text-overflow: ellipsis (graceful clip)');
|
||||
}
|
||||
|
||||
console.log('\n=== Summary ===');
|
||||
console.log(' Passed: ' + passed);
|
||||
console.log(' Failed: ' + failed);
|
||||
console.log('\n#1360 ' + (failed === 0 ? 'PASS' : 'FAIL'));
|
||||
process.exit(failed === 0 ? 0 : 1);
|
||||
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* #1361 — Theme customizer: first-class colorblind-mode presets.
|
||||
*
|
||||
* MVP scope (locked):
|
||||
* - 5 presets: default, deut, prot, trit, achromat
|
||||
* - Each preset overrides --mc-role-* CSS vars + --mc-mb-* status vars
|
||||
* - Achromatopsia uses pure luminance ramp (no hue)
|
||||
* - Persisted to localStorage("meshcore-cb-preset"), survives reload,
|
||||
* syncs across tabs via the `storage` event.
|
||||
* - Customizer UI exposes a radio/dropdown to switch preset.
|
||||
* - WCAG 1.4.3 / 1.4.11 validation helper exists and is correct on
|
||||
* known reference pairs.
|
||||
*
|
||||
* Pure-string + vm.createContext assertions (mirrors test-issue-1356 / 1360
|
||||
* pattern) so this runs in the JS-unit-tests CI step without a browser.
|
||||
*
|
||||
* Stretch goals (live simulation overlay, "Reset to default Wong" button)
|
||||
* are explicitly DEFERRED and intentionally NOT asserted here.
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const vm = require('vm');
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
function assert(cond, msg) {
|
||||
if (cond) { passed++; console.log(' ✓ ' + msg); }
|
||||
else { failed++; console.error(' ✗ ' + msg); }
|
||||
}
|
||||
|
||||
const presetsPath = path.join(__dirname, 'public', 'cb-presets.js');
|
||||
const styleSrc = fs.readFileSync(path.join(__dirname, 'public', 'style.css'), 'utf8');
|
||||
const customSrc = fs.readFileSync(path.join(__dirname, 'public', 'customize-v2.js'), 'utf8');
|
||||
const appSrc = fs.readFileSync(path.join(__dirname, 'public', 'app.js'), 'utf8');
|
||||
const indexSrc = fs.readFileSync(path.join(__dirname, 'public', 'index.html'), 'utf8');
|
||||
|
||||
console.log('\n=== #1361 A: cb-presets.js module exists and is loadable ===');
|
||||
assert(fs.existsSync(presetsPath), 'public/cb-presets.js exists');
|
||||
const presetsSrc = fs.existsSync(presetsPath) ? fs.readFileSync(presetsPath, 'utf8') : '';
|
||||
|
||||
// Build a minimal browser-ish sandbox so we can run the IIFE module.
|
||||
function makeSandbox() {
|
||||
const root = { style: { _vars: {}, setProperty(k, v) { this._vars[k] = v; }, getPropertyValue(k) { return this._vars[k]; }, removeProperty(k) { delete this._vars[k]; } } };
|
||||
const body = { _attrs: {}, setAttribute(k, v) { this._attrs[k] = v; }, getAttribute(k) { return this._attrs[k] || null; }, removeAttribute(k) { delete this._attrs[k]; }, dataset: {} };
|
||||
const listeners = {};
|
||||
const storage = {
|
||||
_data: {},
|
||||
getItem(k) { return Object.prototype.hasOwnProperty.call(this._data, k) ? this._data[k] : null; },
|
||||
setItem(k, v) { this._data[k] = String(v); },
|
||||
removeItem(k) { delete this._data[k]; },
|
||||
};
|
||||
const sandbox = {
|
||||
window: null,
|
||||
document: {
|
||||
documentElement: root,
|
||||
body: body,
|
||||
getElementById(id) { return null; },
|
||||
createElement() { return { setAttribute() {}, appendChild() {}, style: {} }; },
|
||||
},
|
||||
localStorage: storage,
|
||||
console: console,
|
||||
setTimeout: setTimeout,
|
||||
clearTimeout: clearTimeout,
|
||||
addEventListener(ev, cb) { (listeners[ev] = listeners[ev] || []).push(cb); },
|
||||
dispatchEvent(ev) { (listeners[ev.type] || []).forEach(function (cb) { cb(ev); }); return true; },
|
||||
CustomEvent: function (type, opts) { this.type = type; this.detail = opts && opts.detail; },
|
||||
Event: function (type) { this.type = type; },
|
||||
};
|
||||
sandbox.window = sandbox;
|
||||
sandbox.document.body = body;
|
||||
return { sandbox, root, body, storage, listeners };
|
||||
}
|
||||
|
||||
let envOK = false, env;
|
||||
try {
|
||||
env = makeSandbox();
|
||||
vm.createContext(env.sandbox);
|
||||
vm.runInContext(presetsSrc, env.sandbox);
|
||||
envOK = true;
|
||||
} catch (e) {
|
||||
console.error(' ! cb-presets.js failed to load in vm sandbox: ' + e.message);
|
||||
}
|
||||
|
||||
console.log('\n=== #1361 B: MeshCorePresets.list — 5 documented presets ===');
|
||||
const MCP = envOK && env.sandbox.window && env.sandbox.window.MeshCorePresets;
|
||||
assert(!!MCP, 'window.MeshCorePresets exists after script load');
|
||||
assert(MCP && Array.isArray(MCP.list), 'MeshCorePresets.list is an array');
|
||||
const expectedIds = ['default', 'deut', 'prot', 'trit', 'achromat'];
|
||||
if (MCP && Array.isArray(MCP.list)) {
|
||||
assert(MCP.list.length === 5, 'list contains exactly 5 presets (got ' + MCP.list.length + ')');
|
||||
const ids = MCP.list.map(function (p) { return p.id; });
|
||||
expectedIds.forEach(function (id) {
|
||||
assert(ids.indexOf(id) >= 0, 'list contains preset id="' + id + '"');
|
||||
});
|
||||
MCP.list.forEach(function (p) {
|
||||
assert(typeof p.label === 'string' && p.label.length > 0, 'preset "' + p.id + '" has non-empty label');
|
||||
assert(typeof p.description === 'string' && p.description.length > 0, 'preset "' + p.id + '" has 1-line description');
|
||||
assert(p.roleColors && typeof p.roleColors === 'object', 'preset "' + p.id + '" has roleColors map');
|
||||
['repeater', 'companion', 'room', 'sensor', 'observer'].forEach(function (role) {
|
||||
assert(typeof p.roleColors[role] === 'string' && /^#[0-9a-f]{6}$/i.test(p.roleColors[role]),
|
||||
'preset "' + p.id + '" has hex roleColors.' + role);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
console.log('\n=== #1361 C: applyPreset sets body[data-cb-preset] + CSS vars ===');
|
||||
assert(MCP && typeof MCP.applyPreset === 'function', 'applyPreset is a function');
|
||||
if (MCP && typeof MCP.applyPreset === 'function') {
|
||||
['default', 'deut', 'prot', 'trit', 'achromat'].forEach(function (id) {
|
||||
MCP.applyPreset(id);
|
||||
assert(env.body.getAttribute('data-cb-preset') === id,
|
||||
'applyPreset("' + id + '") sets body[data-cb-preset="' + id + '"]');
|
||||
// Verify the css var for repeater matches the preset's declared color
|
||||
const declared = MCP.list.find(function (p) { return p.id === id; }).roleColors.repeater;
|
||||
const got = env.root.style.getPropertyValue('--mc-role-repeater');
|
||||
assert(got && got.toLowerCase() === declared.toLowerCase(),
|
||||
'applyPreset("' + id + '") sets --mc-role-repeater=' + declared + ' (got ' + got + ')');
|
||||
});
|
||||
}
|
||||
|
||||
console.log('\n=== #1361 D: persistence — localStorage("meshcore-cb-preset") ===');
|
||||
if (MCP) {
|
||||
MCP.applyPreset('trit');
|
||||
assert(env.storage.getItem('meshcore-cb-preset') === 'trit',
|
||||
'applyPreset persists choice to localStorage key "meshcore-cb-preset"');
|
||||
}
|
||||
|
||||
console.log('\n=== #1361 E: re-init from localStorage re-applies preset ===');
|
||||
// Fresh sandbox with localStorage pre-populated
|
||||
{
|
||||
const env2 = makeSandbox();
|
||||
env2.storage.setItem('meshcore-cb-preset', 'achromat');
|
||||
vm.createContext(env2.sandbox);
|
||||
try {
|
||||
vm.runInContext(presetsSrc, env2.sandbox);
|
||||
const MCP2 = env2.sandbox.window.MeshCorePresets;
|
||||
// Module init OR explicit initFromStorage should re-apply
|
||||
if (MCP2 && typeof MCP2.initFromStorage === 'function') MCP2.initFromStorage();
|
||||
assert(env2.body.getAttribute('data-cb-preset') === 'achromat',
|
||||
're-init from localStorage re-applies "achromat" preset to body data-attr');
|
||||
} catch (e) {
|
||||
assert(false, 're-init sandbox load failed: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n=== #1361 F: cross-tab sync via storage event ===');
|
||||
if (MCP) {
|
||||
// Dispatch a synthetic storage event for our key
|
||||
const ev = new env.sandbox.Event('storage');
|
||||
ev.key = 'meshcore-cb-preset';
|
||||
ev.newValue = 'prot';
|
||||
env.sandbox.dispatchEvent(ev);
|
||||
assert(env.body.getAttribute('data-cb-preset') === 'prot',
|
||||
'storage event with newValue="prot" updates body[data-cb-preset="prot"]');
|
||||
}
|
||||
|
||||
console.log('\n=== #1361 G: style.css has preset blocks for non-default presets ===');
|
||||
['deut', 'prot', 'trit', 'achromat'].forEach(function (id) {
|
||||
const re = new RegExp('body\\[data-cb-preset=["\']' + id + '["\']\\][^{]*\\{[^}]*--mc-role-repeater', 'i');
|
||||
assert(re.test(styleSrc),
|
||||
'style.css has body[data-cb-preset="' + id + '"] block overriding --mc-role-repeater');
|
||||
});
|
||||
|
||||
console.log('\n=== #1361 H: customize-v2.js has Colorblind preset selector UI ===');
|
||||
assert(/data-cv2-cb-preset|cust-cb-preset|colorblind|Colorblind/i.test(customSrc),
|
||||
'customize-v2.js contains a Colorblind preset selector hook');
|
||||
assert(/MeshCorePresets|applyPreset|cb-preset/i.test(customSrc),
|
||||
'customize-v2.js wires the UI to MeshCorePresets.applyPreset');
|
||||
|
||||
console.log('\n=== #1361 I: index.html loads cb-presets.js BEFORE app.js ===');
|
||||
const cbIdx = indexSrc.indexOf('cb-presets.js');
|
||||
const appIdx = indexSrc.indexOf('app.js?');
|
||||
assert(cbIdx > 0, 'index.html includes <script src="cb-presets.js?...">');
|
||||
assert(cbIdx >= 0 && appIdx >= 0 && cbIdx < appIdx,
|
||||
'cb-presets.js script tag precedes app.js (so app.js can init the preset)');
|
||||
|
||||
console.log('\n=== #1361 J: app.js initializes preset on DOMContentLoaded ===');
|
||||
assert(/MeshCorePresets\s*[\.\&]/.test(appSrc) || /window\.MeshCorePresets/.test(appSrc),
|
||||
'app.js references window.MeshCorePresets (init wiring)');
|
||||
assert(/['"]storage['"]/.test(appSrc) && /meshcore-cb-preset/.test(appSrc),
|
||||
'app.js handles cross-tab storage event for meshcore-cb-preset');
|
||||
|
||||
console.log('\n=== #1361 K: WCAG luminance helper — correctness on reference pairs ===');
|
||||
assert(MCP && MCP.wcag && typeof MCP.wcag.contrast === 'function',
|
||||
'MeshCorePresets.wcag.contrast(fg, bg) is exposed');
|
||||
if (MCP && MCP.wcag && typeof MCP.wcag.contrast === 'function') {
|
||||
const c1 = MCP.wcag.contrast('#000000', '#ffffff');
|
||||
assert(Math.abs(c1 - 21) < 0.05, 'contrast(black, white) ≈ 21:1 (got ' + c1.toFixed(2) + ')');
|
||||
const c2 = MCP.wcag.contrast('#ffffff', '#ffffff');
|
||||
assert(Math.abs(c2 - 1) < 0.001, 'contrast(white, white) === 1:1 (got ' + c2.toFixed(3) + ')');
|
||||
// Mid-grey #777 vs white ~ 4.48
|
||||
const c3 = MCP.wcag.contrast('#777777', '#ffffff');
|
||||
assert(c3 > 4.4 && c3 < 4.7, 'contrast(#777, white) ≈ 4.48 (got ' + c3.toFixed(2) + ')');
|
||||
}
|
||||
|
||||
console.log('\n=== #1361 L: achromat preset is pure luminance (no chroma) ===');
|
||||
if (MCP) {
|
||||
const ach = MCP.list.find(function (p) { return p.id === 'achromat'; });
|
||||
if (ach) {
|
||||
Object.keys(ach.roleColors).forEach(function (role) {
|
||||
const hex = ach.roleColors[role];
|
||||
const r = parseInt(hex.slice(1, 3), 16);
|
||||
const g = parseInt(hex.slice(3, 5), 16);
|
||||
const b = parseInt(hex.slice(5, 7), 16);
|
||||
assert(r === g && g === b,
|
||||
'achromat preset roleColors.' + role + ' is grey (r==g==b, got ' + hex + ')');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n=== Summary ===');
|
||||
console.log(' passed: ' + passed);
|
||||
console.log(' failed: ' + failed);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* #1364 — regression(map): #1362 pill max-width:4ch over-clamps multi-digit
|
||||
* counts → `R…` instead of `R60`.
|
||||
*
|
||||
* The defense-in-depth `max-width: 4ch` added in #1362 ellipsizes pill
|
||||
* content because the 4ch box includes left/right padding (1px 3px),
|
||||
* leaving ~2.5ch for text — enough for `R6` but not `R60`.
|
||||
*
|
||||
* Fix (Option A from issue): drop `max-width` entirely. JS already caps
|
||||
* at "999+" so CSS guard was overcaution. Keep `overflow:hidden` +
|
||||
* `text-overflow:ellipsis` as graceful-degrade if JS ever fails.
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
function assert(cond, msg) {
|
||||
if (cond) { passed++; console.log(' ✓ ' + msg); }
|
||||
else { failed++; console.error(' ✗ ' + msg); }
|
||||
}
|
||||
|
||||
const cssSrc = fs.readFileSync(path.join(__dirname, 'public', 'style.css'), 'utf8');
|
||||
const pillRuleRe = /\.mc-cluster\s+\.mc-pill\s*\{([\s\S]*?)\}/;
|
||||
const pillMatch = cssSrc.match(pillRuleRe);
|
||||
|
||||
console.log('\n=== #1364: .mc-pill no longer clamps multi-digit counts ===');
|
||||
|
||||
assert(pillMatch, '.mc-cluster .mc-pill rule found in style.css');
|
||||
|
||||
if (pillMatch) {
|
||||
const body = pillMatch[1];
|
||||
|
||||
// Primary regression guard: NO max-width: 4ch (or any max-width that would
|
||||
// clamp `R999+`). Issue acceptance criterion: "assert .mc-pill CSS does
|
||||
// NOT contain max-width: 4ch".
|
||||
assert(!/max-width\s*:\s*4ch/.test(body),
|
||||
'.mc-pill does NOT declare `max-width: 4ch` (regression guard for #1364)');
|
||||
|
||||
// Graceful degradation: keep belt-only overflow guards in case JS cap
|
||||
// is bypassed by a hypothetical regression.
|
||||
assert(/overflow\s*:\s*hidden/.test(body),
|
||||
'.mc-pill keeps `overflow: hidden` as graceful-degrade');
|
||||
assert(/text-overflow\s*:\s*ellipsis/.test(body),
|
||||
'.mc-pill keeps `text-overflow: ellipsis` as graceful-degrade');
|
||||
}
|
||||
|
||||
console.log('\n=== Summary ===');
|
||||
console.log(' Passed: ' + passed);
|
||||
console.log(' Failed: ' + failed);
|
||||
console.log('\n#1364 ' + (failed === 0 ? 'PASS' : 'FAIL'));
|
||||
process.exit(failed === 0 ? 0 : 1);
|
||||
@@ -0,0 +1,249 @@
|
||||
/**
|
||||
* E2E (#1367): Channels page chat-app redesign — restore prod's row layout,
|
||||
* drop the analytics chip, and add a per-channel detail view.
|
||||
*
|
||||
* Design source: issue #1367 body + 4 design-lock comments
|
||||
* (Operator + Tufte): full-width chat-app rows with avatar / name /
|
||||
* preview / relative-time; no inline action chips on rows; tap a row
|
||||
* to slide into a full-screen messages view; back chevron + title.
|
||||
*
|
||||
* Run: BASE_URL=http://localhost:13581 node test-issue-1367-channels-chat-app-e2e.js
|
||||
*/
|
||||
'use strict';
|
||||
const { chromium } = require('playwright');
|
||||
|
||||
const BASE = process.env.BASE_URL || 'http://localhost:13581';
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
async function step(name, fn) {
|
||||
try { await fn(); passed++; console.log(' \u2713 ' + name); }
|
||||
catch (e) { failed++; console.error(' \u2717 ' + name + ': ' + e.message); }
|
||||
}
|
||||
function assert(c, m) { if (!c) throw new Error(m || 'assertion failed'); }
|
||||
|
||||
async function run() {
|
||||
const launchOpts = { args: ['--no-sandbox'] };
|
||||
if (process.env.CHROMIUM_PATH) launchOpts.executablePath = process.env.CHROMIUM_PATH;
|
||||
const browser = await chromium.launch(launchOpts);
|
||||
|
||||
// ----- Mobile (375x800) -----
|
||||
const ctx = await browser.newContext({ viewport: { width: 375, height: 800 } });
|
||||
const page = await ctx.newPage();
|
||||
|
||||
await page.goto(BASE + '/#/channels', { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('#chList', { timeout: 10000 });
|
||||
// New rows use .ch-row; wait for at least one to render.
|
||||
await page.waitForFunction(() => {
|
||||
const l = document.getElementById('chList');
|
||||
return l && l.querySelectorAll('.ch-row').length > 0;
|
||||
}, { timeout: 15000 });
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
await step('channel rows use .ch-row, are ~80px tall, full-width', async () => {
|
||||
const data = await page.evaluate(() => {
|
||||
const rows = document.querySelectorAll('#chList .ch-row');
|
||||
if (!rows.length) return null;
|
||||
const r = rows[0];
|
||||
const rect = r.getBoundingClientRect();
|
||||
const parentW = r.parentElement.getBoundingClientRect().width;
|
||||
return { h: Math.round(rect.height), w: Math.round(rect.width), parentW: Math.round(parentW), count: rows.length };
|
||||
});
|
||||
assert(data, 'no .ch-row elements found');
|
||||
assert(data.h >= 72 && data.h <= 88, '.ch-row height must be 72-88px, got ' + data.h);
|
||||
// Full-width within its list container (allow 4px slop for borders/padding).
|
||||
assert(data.w >= data.parentW - 8, '.ch-row width ' + data.w + ' must fill parent ' + data.parentW);
|
||||
});
|
||||
|
||||
await step('each row has .ch-avatar with hash-derived bg + 2-3 char text', async () => {
|
||||
const info = await page.evaluate(() => {
|
||||
const row = document.querySelector('#chList .ch-row');
|
||||
const av = row && row.querySelector('.ch-avatar');
|
||||
if (!av) return null;
|
||||
const bg = getComputedStyle(av).backgroundColor;
|
||||
return { text: (av.textContent || '').trim(), bg: bg };
|
||||
});
|
||||
assert(info, 'first row has no .ch-avatar');
|
||||
assert(info.text.length >= 1 && info.text.length <= 3, 'avatar text length must be 1-3, got "' + info.text + '"');
|
||||
// Background should be a real color, not transparent / none.
|
||||
assert(info.bg && info.bg !== 'rgba(0, 0, 0, 0)' && info.bg !== 'transparent',
|
||||
'avatar bg must be a real color, got ' + info.bg);
|
||||
});
|
||||
|
||||
await step('row body has bold name, preview text, right-aligned timestamp', async () => {
|
||||
const data = await page.evaluate(() => {
|
||||
const row = document.querySelector('#chList .ch-row');
|
||||
const name = row && row.querySelector('.ch-row-name');
|
||||
const prev = row && row.querySelector('.ch-row-preview');
|
||||
const time = row && row.querySelector('.ch-row-time');
|
||||
if (!name || !prev || !time) return { missing: { name: !name, prev: !prev, time: !time } };
|
||||
const rowRect = row.getBoundingClientRect();
|
||||
const timeRect = time.getBoundingClientRect();
|
||||
const nameRect = name.getBoundingClientRect();
|
||||
return {
|
||||
nameWeight: getComputedStyle(name).fontWeight,
|
||||
timeRight: rowRect.right - timeRect.right,
|
||||
// Timestamp must sit to the right of the name's right edge.
|
||||
timeAfterName: timeRect.left >= nameRect.right - 4,
|
||||
};
|
||||
});
|
||||
assert(!data.missing, 'missing sub-elements: ' + JSON.stringify(data.missing || {}));
|
||||
const w = parseInt(data.nameWeight, 10) || 0;
|
||||
assert(w >= 600 || data.nameWeight === 'bold', 'channel name must be bold, got ' + data.nameWeight);
|
||||
assert(data.timeRight <= 20, 'timestamp must be right-aligned, got ' + data.timeRight + 'px from row right');
|
||||
assert(data.timeAfterName, 'timestamp must be to the right of the name');
|
||||
});
|
||||
|
||||
await step('rows have NO inline share/remove action chips', async () => {
|
||||
const offenders = await page.evaluate(() => {
|
||||
const rows = document.querySelectorAll('#chList .ch-row');
|
||||
let bad = [];
|
||||
for (const r of rows) {
|
||||
if (r.querySelector('.ch-row-actions, .ch-share, .ch-remove, .ch-share-btn, .ch-remove-btn, [data-share-channel], [data-remove-channel]')) {
|
||||
bad.push(r.getAttribute('data-hash') || '?');
|
||||
}
|
||||
}
|
||||
return bad;
|
||||
});
|
||||
assert(offenders.length === 0,
|
||||
'inline action chips found on ' + offenders.length + ' rows: ' + offenders.slice(0, 3).join(','));
|
||||
});
|
||||
|
||||
await step('header has NO analytics / chart-emoji chip', async () => {
|
||||
const hits = await page.evaluate(() => {
|
||||
const sidebar = document.querySelector('.ch-sidebar');
|
||||
const header = sidebar && sidebar.querySelector('.ch-sidebar-header');
|
||||
if (!header) return { noHeader: true };
|
||||
const hasLink = !!header.querySelector('.ch-analytics-link, a[href*="analytics"]');
|
||||
const hasEmoji = (header.textContent || '').indexOf('\uD83D\uDCCA') !== -1;
|
||||
return { hasLink, hasEmoji };
|
||||
});
|
||||
assert(!hits.noHeader, 'channels sidebar header not found');
|
||||
assert(!hits.hasLink, 'analytics link must be removed from header');
|
||||
assert(!hits.hasEmoji, '📊 emoji must be removed from header');
|
||||
});
|
||||
|
||||
await step('tap a row → URL hash changes to channel detail route', async () => {
|
||||
// Prefer a row whose preview is non-empty (i.e., the channel has at
|
||||
// least one observed message), so the downstream detail-view test
|
||||
// can rely on .ch-message rendering. Fall back to the first row.
|
||||
const targetHash = await page.evaluate(() => {
|
||||
const rows = Array.from(document.querySelectorAll('#chList .ch-row[data-hash]'));
|
||||
const withPreview = rows.find(r => {
|
||||
const p = r.querySelector('.ch-row-preview');
|
||||
return p && (p.textContent || '').trim().length > 0
|
||||
&& !/^0x/.test((p.textContent || '').trim());
|
||||
});
|
||||
const r = withPreview || rows[0];
|
||||
return r ? r.getAttribute('data-hash') : null;
|
||||
});
|
||||
assert(targetHash, 'no .ch-row[data-hash] to click');
|
||||
await page.click('#chList .ch-row[data-hash="' + targetHash.replace(/"/g, '\\"') + '"]');
|
||||
await page.waitForFunction((h) => location.hash.indexOf(encodeURIComponent(h)) !== -1
|
||||
|| location.hash.indexOf(h) !== -1, targetHash, { timeout: 5000 });
|
||||
const hash = await page.evaluate(() => location.hash);
|
||||
assert(hash.indexOf('/channels/') !== -1, 'URL hash should include /channels/<hash>, got ' + hash);
|
||||
});
|
||||
|
||||
// ----- Detail view (mobile, after tap) -----
|
||||
await step('detail view header: back affordance + "<name> — <count> messages"', async () => {
|
||||
// The header already updates on selection; assert the back chevron and the title format.
|
||||
await page.waitForFunction(() => {
|
||||
const t = document.querySelector('#chHeader .ch-header-text');
|
||||
return t && /—\s*\d+\s*messages/i.test(t.textContent || '');
|
||||
}, { timeout: 8000 });
|
||||
const data = await page.evaluate(() => {
|
||||
const header = document.getElementById('chHeader');
|
||||
const back = header && header.querySelector('.ch-back, [data-action="ch-back"], [aria-label*="Back"]');
|
||||
const title = header && header.querySelector('.ch-header-text');
|
||||
return {
|
||||
hasBack: !!back,
|
||||
title: title ? (title.textContent || '').trim() : '',
|
||||
};
|
||||
});
|
||||
assert(data.hasBack, 'detail header must include a back button (.ch-back / data-action=ch-back)');
|
||||
assert(/—\s*\d+\s*messages/i.test(data.title), 'header title must be "<name> — <count> messages", got: ' + data.title);
|
||||
});
|
||||
|
||||
await step('detail view renders at least one .ch-message (avatar + bubble + footer)', async () => {
|
||||
// Wait up to 10s for messages to load. If the chosen channel renders
|
||||
// an empty-state, fall back to scanning the entire channel list for
|
||||
// the busiest one and re-opening it.
|
||||
let ok = await page.evaluate(async () => {
|
||||
function sleep(ms){return new Promise(r=>setTimeout(r,ms));}
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const m = document.querySelector('.ch-message');
|
||||
if (m) {
|
||||
const av = m.querySelector('.ch-avatar');
|
||||
const body = m.querySelector('.ch-message-bubble, .ch-msg-bubble');
|
||||
const foot = m.querySelector('.ch-message-meta, .ch-msg-meta');
|
||||
if (av && body && foot) return true;
|
||||
}
|
||||
await sleep(200);
|
||||
}
|
||||
return false;
|
||||
});
|
||||
if (!ok) {
|
||||
// Go back to the list and try the row with the highest visible
|
||||
// message count in its preview (e.g. "N messages").
|
||||
await page.evaluate(() => {
|
||||
const back = document.querySelector('.ch-back, [data-action="ch-back"]');
|
||||
if (back) back.click();
|
||||
else history.replaceState(null, '', '#/channels');
|
||||
});
|
||||
await page.waitForSelector('#chList .ch-row[data-hash]', { timeout: 5000 });
|
||||
const altHash = await page.evaluate(() => {
|
||||
const rows = Array.from(document.querySelectorAll('#chList .ch-row[data-hash]'));
|
||||
let best = null, bestN = -1;
|
||||
for (const r of rows) {
|
||||
const p = r.querySelector('.ch-row-preview');
|
||||
const t = (p ? p.textContent || '' : '').trim();
|
||||
const m = t.match(/(\d+)\s+messages/i);
|
||||
const n = m ? parseInt(m[1], 10) : (t && !/^0x/.test(t) ? 1 : 0);
|
||||
if (n > bestN) { bestN = n; best = r.getAttribute('data-hash'); }
|
||||
}
|
||||
return best;
|
||||
});
|
||||
if (altHash) {
|
||||
await page.click('#chList .ch-row[data-hash="' + altHash.replace(/"/g, '\\"') + '"]');
|
||||
ok = await page.evaluate(async () => {
|
||||
function sleep(ms){return new Promise(r=>setTimeout(r,ms));}
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const m = document.querySelector('.ch-message');
|
||||
if (m) {
|
||||
const av = m.querySelector('.ch-avatar');
|
||||
const body = m.querySelector('.ch-message-bubble, .ch-msg-bubble');
|
||||
const foot = m.querySelector('.ch-message-meta, .ch-msg-meta');
|
||||
if (av && body && foot) return true;
|
||||
}
|
||||
await sleep(200);
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
}
|
||||
assert(ok, '.ch-message with avatar+bubble+footer not rendered in detail view');
|
||||
});
|
||||
|
||||
await ctx.close();
|
||||
|
||||
// ----- Desktop (1024x800) -----
|
||||
const ctx2 = await browser.newContext({ viewport: { width: 1024, height: 800 } });
|
||||
const p2 = await ctx2.newPage();
|
||||
await p2.goto(BASE + '/#/channels', { waitUntil: 'domcontentloaded' });
|
||||
await p2.waitForSelector('.ch-layout', { timeout: 10000 });
|
||||
await p2.waitForTimeout(200);
|
||||
|
||||
await step('desktop (1024px): two-pane layout preserved', async () => {
|
||||
const dir = await p2.evaluate(() => {
|
||||
const l = document.querySelector('.ch-layout');
|
||||
return l ? getComputedStyle(l).flexDirection : null;
|
||||
});
|
||||
assert(dir === 'row', 'desktop ch-layout flex-direction must remain "row", got ' + dir);
|
||||
});
|
||||
|
||||
await browser.close();
|
||||
console.log('\n' + passed + '/' + (passed + failed) + ' tests passed' + (failed ? ', ' + failed + ' failed' : ''));
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
run().catch(err => { console.error('Fatal:', err); process.exit(1); });
|
||||
@@ -0,0 +1,239 @@
|
||||
/**
|
||||
* #1374 — Packet-route map view a11y + visual modernization.
|
||||
*
|
||||
* Asserts the rewritten `/#/map?route=N` renderer:
|
||||
* - role-aware shape markers (reuses makeRoleMarkerSVG)
|
||||
* - origin / destination semantically distinct from intermediate hops
|
||||
* - sequence-number badges (separate from label text)
|
||||
* - directional arrows on edges + per-edge aria-label
|
||||
* - per-marker role="img" + aria-label "Hop N of M, <name>, <role>"
|
||||
* - deconflictLabels reused — no overlapping label boxes
|
||||
* - collapsible legend panel renders
|
||||
* - partial-route handling: unresolved markers + "X of N hops resolved"
|
||||
*
|
||||
* Strategy: the production renderer is split into a pure
|
||||
* `window.MeshRoute.render(map, layer, positions, options)` that the test
|
||||
* drives directly with synthetic positions, so no DB is required. The
|
||||
* production `drawPacketRoute` resolves hops then calls the same function.
|
||||
*
|
||||
* Run: BASE_URL=http://localhost:13581 node test-issue-1374-route-map-a11y-e2e.js
|
||||
*/
|
||||
'use strict';
|
||||
const { chromium } = require('playwright');
|
||||
|
||||
const BASE = process.env.BASE_URL || 'http://localhost:13581';
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
async function step(name, fn) {
|
||||
try { await fn(); passed++; console.log(' \u2713 ' + name); }
|
||||
catch (e) { failed++; console.error(' \u2717 ' + name + ': ' + e.message); }
|
||||
}
|
||||
function assert(c, m) { if (!c) throw new Error(m || 'assertion failed'); }
|
||||
|
||||
// Synthetic 4-hop route in the Bay Area.
|
||||
const ROUTE_FIXTURE = {
|
||||
origin: { pubkey: 'aa00aa00aa00aa00', name: 'Originator Node', role: 'companion', lat: 37.78, lon: -122.42, isOrigin: true },
|
||||
hops: [
|
||||
{ pubkey: 'bb11bb11bb11bb11', name: 'Big Redwood Oakland', role: 'repeater', lat: 37.80, lon: -122.27, resolved: true },
|
||||
{ pubkey: 'cc22cc22cc22cc22', name: 'San Carlos Rptr', role: 'repeater', lat: 37.51, lon: -122.26, resolved: true },
|
||||
{ pubkey: 'dd33dd33dd33dd33', name: 'Room Server SJ', role: 'room', lat: 37.34, lon: -121.89, resolved: true },
|
||||
{ pubkey: 'ee44ee44ee44ee44', name: 'Destination Node', role: 'sensor', lat: 37.27, lon: -121.97, resolved: true, isDest: true },
|
||||
]
|
||||
};
|
||||
|
||||
const PARTIAL_FIXTURE = {
|
||||
origin: { pubkey: 'aa00aa00aa00aa00', name: 'Originator Node', role: 'companion', lat: 37.78, lon: -122.42, isOrigin: true },
|
||||
hops: [
|
||||
{ pubkey: 'bb11bb11bb11bb11', name: 'Big Redwood Oakland', role: 'repeater', lat: 37.80, lon: -122.27, resolved: true },
|
||||
{ pubkey: 'unresolved-xx', name: 'unresol', role: null, resolved: false },
|
||||
{ pubkey: 'dd33dd33dd33dd33', name: 'Destination Node', role: 'sensor', lat: 37.34, lon: -121.89, resolved: true, isDest: true },
|
||||
]
|
||||
};
|
||||
|
||||
async function renderRouteOnPage(page, fixture) {
|
||||
return await page.evaluate((fx) => {
|
||||
if (!window.MeshRoute || typeof window.MeshRoute.render !== 'function') {
|
||||
return { error: 'window.MeshRoute.render not present' };
|
||||
}
|
||||
// Build positions array: [origin, ...hops]
|
||||
const positions = [];
|
||||
if (fx.origin) positions.push(Object.assign({}, fx.origin));
|
||||
for (const h of fx.hops) positions.push(Object.assign({}, h));
|
||||
// Reset any existing route
|
||||
if (window.__mc_routeLayer && window.__mc_routeLayer.clearLayers) {
|
||||
window.__mc_routeLayer.clearLayers();
|
||||
}
|
||||
window.MeshRoute.render(window.__mc_map, window.__mc_routeLayer, positions, {
|
||||
timestamp: new Date('2025-01-01T12:00:00Z').toISOString()
|
||||
});
|
||||
return { ok: true, count: positions.length };
|
||||
}, fixture);
|
||||
}
|
||||
|
||||
async function runViewport(browser, width, height, label) {
|
||||
console.log('\n=== Viewport ' + label + ' (' + width + 'x' + height + ') ===');
|
||||
const ctx = await browser.newContext({ viewport: { width, height } });
|
||||
const page = await ctx.newPage();
|
||||
page.on('pageerror', e => console.error(' pageerror:', e.message));
|
||||
await page.goto(BASE + '/#/map', { waitUntil: 'commit', timeout: 30000 });
|
||||
await page.waitForSelector('#leaflet-map', { timeout: 10000 });
|
||||
// Wait for MeshRoute to register
|
||||
await page.waitForFunction(() => window.MeshRoute && window.__mc_map && window.__mc_routeLayer, { timeout: 10000 });
|
||||
await page.waitForTimeout(400);
|
||||
|
||||
const r1 = await renderRouteOnPage(page, ROUTE_FIXTURE);
|
||||
assertNoError(r1);
|
||||
await page.waitForTimeout(1800);
|
||||
|
||||
await step(label + ': every hop marker has role="img" and informative aria-label', async () => {
|
||||
const data = await page.evaluate(() => {
|
||||
const markers = Array.from(document.querySelectorAll('.mc-route-marker[role="img"]'));
|
||||
return markers.map(m => m.getAttribute('aria-label') || '');
|
||||
});
|
||||
assert(data.length === 5, 'expected 5 markers, got ' + data.length);
|
||||
const re = /Hop \d+ of \d+, [^,]+, (repeater|companion|room|sensor|observer)/;
|
||||
for (const lbl of data) {
|
||||
assert(re.test(lbl), 'aria-label "' + lbl + '" does not match Hop N of M pattern');
|
||||
}
|
||||
});
|
||||
|
||||
await step(label + ': origin aria-label contains "originator", destination contains "destination"', async () => {
|
||||
const data = await page.evaluate(() => {
|
||||
const markers = Array.from(document.querySelectorAll('.mc-route-marker[role="img"]'));
|
||||
return markers.map(m => m.getAttribute('aria-label') || '');
|
||||
});
|
||||
assert(/originator/i.test(data[0]), 'origin label missing "originator": ' + data[0]);
|
||||
assert(/destination/i.test(data[data.length - 1]), 'destination label missing "destination": ' + data[data.length - 1]);
|
||||
});
|
||||
|
||||
await step(label + ': sequence-number badge present beside each marker (not in label text)', async () => {
|
||||
const data = await page.evaluate(() => {
|
||||
const badges = Array.from(document.querySelectorAll('.mc-route-seq-badge'));
|
||||
return badges.map(b => b.textContent.trim());
|
||||
});
|
||||
assert(data.length >= 5, 'expected >=5 sequence badges, got ' + data.length);
|
||||
// Badges should be numeric or numbered glyphs.
|
||||
for (const b of data) {
|
||||
assert(/^[\d①②③④⑤⑥⑦⑧⑨⑩▶⚑]+$/.test(b), 'badge "' + b + '" not numeric/glyph');
|
||||
}
|
||||
});
|
||||
|
||||
await step(label + ': no two label boxes overlap (deconflict reused)', async () => {
|
||||
const rects = await page.evaluate(() => {
|
||||
const labels = Array.from(document.querySelectorAll('.mc-route-label'));
|
||||
return labels.map(l => {
|
||||
const r = l.getBoundingClientRect();
|
||||
return { x: r.x, y: r.y, w: r.width, h: r.height };
|
||||
});
|
||||
});
|
||||
assert(rects.length >= 2, 'expected at least 2 labels rendered, got ' + rects.length);
|
||||
for (let i = 0; i < rects.length; i++) {
|
||||
for (let j = i + 1; j < rects.length; j++) {
|
||||
const a = rects[i], b = rects[j];
|
||||
const overlap = a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y;
|
||||
assert(!overlap, 'labels ' + i + ' and ' + j + ' overlap');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await step(label + ': edges have aria-label "Hop N \u2192 N+1"', async () => {
|
||||
const data = await page.evaluate(() => {
|
||||
const edges = Array.from(document.querySelectorAll('path.mc-route-edge[aria-label]'));
|
||||
return edges.map(e => e.getAttribute('aria-label'));
|
||||
});
|
||||
assert(data.length >= 4, 'expected >=4 edge aria-labels, got ' + data.length);
|
||||
const re = /Hop \d+ \u2192 \d+/;
|
||||
for (const lbl of data) assert(re.test(lbl), 'edge label "' + lbl + '" missing arrow pattern');
|
||||
});
|
||||
|
||||
await step(label + ': edges carry directionality marker (marker-end arrow)', async () => {
|
||||
const data = await page.evaluate(() => {
|
||||
const edges = Array.from(document.querySelectorAll('path.mc-route-edge'));
|
||||
const arrowDefs = document.querySelectorAll('marker[id^="mc-route-arrow"]');
|
||||
return {
|
||||
edgeCount: edges.length,
|
||||
withArrow: edges.filter(e => /url\(#mc-route-arrow/.test(e.getAttribute('marker-end') || '')).length,
|
||||
defCount: arrowDefs.length
|
||||
};
|
||||
});
|
||||
assert(data.defCount >= 1, 'expected at least one <marker id="mc-route-arrow…"> def, got ' + data.defCount);
|
||||
assert(data.withArrow >= data.edgeCount, 'not all edges have marker-end arrow: ' +
|
||||
data.withArrow + '/' + data.edgeCount);
|
||||
});
|
||||
|
||||
await step(label + ': collapsible legend panel renders with role entries', async () => {
|
||||
const data = await page.evaluate(() => {
|
||||
const legend = document.querySelector('.mc-route-legend');
|
||||
if (!legend) return { found: false };
|
||||
const toggle = legend.querySelector('[aria-expanded]');
|
||||
const entries = legend.querySelectorAll('.mc-route-legend-entry, .mc-route-legend-role');
|
||||
const txt = legend.textContent.toLowerCase();
|
||||
return {
|
||||
found: true,
|
||||
hasToggle: !!toggle,
|
||||
entryCount: entries.length,
|
||||
hasRoleTerm: /repeater|companion|room|sensor/.test(txt),
|
||||
hasOriginTerm: /origin/.test(txt),
|
||||
hasDestTerm: /destin/.test(txt)
|
||||
};
|
||||
});
|
||||
assert(data.found, '.mc-route-legend not rendered');
|
||||
assert(data.hasToggle, 'legend toggle missing aria-expanded');
|
||||
assert(data.entryCount >= 3, 'expected >=3 legend entries, got ' + data.entryCount);
|
||||
assert(data.hasRoleTerm, 'legend missing role labels');
|
||||
assert(data.hasOriginTerm, 'legend missing origin/destination glyph entries');
|
||||
assert(data.hasDestTerm, 'legend missing destination glyph entry');
|
||||
});
|
||||
|
||||
await step(label + ': toolbar shows "Route observed at <timestamp>" context label', async () => {
|
||||
const data = await page.evaluate(() => {
|
||||
const el = document.querySelector('.mc-route-context-label');
|
||||
return el ? el.textContent : null;
|
||||
});
|
||||
assert(data && /Route observed at/i.test(data), 'missing "Route observed at" label, got: ' + data);
|
||||
});
|
||||
|
||||
// Partial route case
|
||||
const r2 = await page.evaluate(() => {
|
||||
if (window.__mc_routeLayer && window.__mc_routeLayer.clearLayers) window.__mc_routeLayer.clearLayers();
|
||||
});
|
||||
await renderRouteOnPage(page, PARTIAL_FIXTURE);
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
await step(label + ': partial-route — unresolved marker carries ch-unresolved class', async () => {
|
||||
const data = await page.evaluate(() => {
|
||||
return document.querySelectorAll('.mc-route-marker[class*="ch-unresolved"]').length;
|
||||
});
|
||||
assert(data >= 1, 'expected >=1 ch-unresolved marker, got ' + data);
|
||||
});
|
||||
|
||||
await step(label + ': partial-route — "X of N hops resolved" badge present', async () => {
|
||||
const data = await page.evaluate(() => {
|
||||
const el = document.querySelector('.mc-route-resolved-badge');
|
||||
return el ? el.textContent : null;
|
||||
});
|
||||
assert(data && /\d+ of \d+ hops resolved/i.test(data), 'missing resolved badge, got: ' + data);
|
||||
});
|
||||
|
||||
await ctx.close();
|
||||
}
|
||||
|
||||
function assertNoError(r) {
|
||||
if (r && r.error) throw new Error(r.error);
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const launchOpts = { args: ['--no-sandbox'] };
|
||||
if (process.env.CHROMIUM_PATH) launchOpts.executablePath = process.env.CHROMIUM_PATH;
|
||||
const browser = await chromium.launch(launchOpts);
|
||||
try {
|
||||
await runViewport(browser, 375, 800, 'mobile');
|
||||
await runViewport(browser, 1920, 1080, 'desktop');
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
console.log('\n' + passed + ' passed, ' + failed + ' failed');
|
||||
if (failed > 0) process.exit(1);
|
||||
}
|
||||
|
||||
run().catch(e => { console.error(e); process.exit(1); });
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* #1375 — regression(analytics): Scopes tab fetches `/api/api/scope-stats`
|
||||
* (duplicate prefix) → 404 → SPA HTML → JSON.parse error.
|
||||
*
|
||||
* The `api()` helper already prepends `/api`. Other callers in
|
||||
* public/analytics.js correctly pass `/scope-stats` style relative paths;
|
||||
* the Scopes loader was the lone offender passing `/api/scope-stats`,
|
||||
* producing the doubled prefix at runtime.
|
||||
*
|
||||
* Fix: drop the leading `/api` from the Scopes-tab call so the helper
|
||||
* builds `/api/scope-stats?window=…`.
|
||||
*
|
||||
* Originally landed on the PR #915 branch (commit 2fd22cee) but that
|
||||
* branch never merged, so the bug resurfaced in subsequent rebases.
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
function assert(cond, msg) {
|
||||
if (cond) { passed++; console.log(' ✓ ' + msg); }
|
||||
else { failed++; console.error(' ✗ ' + msg); }
|
||||
}
|
||||
|
||||
const src = fs.readFileSync(
|
||||
path.join(__dirname, 'public', 'analytics.js'), 'utf8');
|
||||
|
||||
console.log('\n=== #1375: Scopes tab scope-stats fetch path ===');
|
||||
|
||||
// Regression guard: the buggy doubled-prefix form must never reappear.
|
||||
const badRe = /api\(\s*['"]\/api\/scope-stats/g;
|
||||
const badMatches = src.match(badRe) || [];
|
||||
assert(badMatches.length === 0,
|
||||
"ZERO `api('/api/scope-stats'` occurrences in analytics.js " +
|
||||
'(regression guard for doubled /api prefix)');
|
||||
|
||||
// Positive: the corrected, helper-relative form is present exactly once.
|
||||
const goodRe = /api\(\s*['"]\/scope-stats/g;
|
||||
const goodMatches = src.match(goodRe) || [];
|
||||
assert(goodMatches.length === 1,
|
||||
"Exactly one `api('/scope-stats'` call exists (the fixed loader) — " +
|
||||
'found ' + goodMatches.length);
|
||||
|
||||
console.log('\n=== Summary ===');
|
||||
console.log(' Passed: ' + passed);
|
||||
console.log(' Failed: ' + failed);
|
||||
console.log('\n#1375 ' + (failed === 0 ? 'PASS' : 'FAIL'));
|
||||
process.exit(failed === 0 ? 0 : 1);
|
||||
@@ -0,0 +1,176 @@
|
||||
#!/usr/bin/env node
|
||||
/* Issue #1400 — root cause of recurring nav-vanishing class of bugs.
|
||||
*
|
||||
* Symptom: at desktop viewports (1024..1711), the `.nav-links` strip
|
||||
* rendered at NEGATIVE y (operator probe: y=-57, height=56), entirely
|
||||
* above the visible 0..52 band of `.top-nav` which has `overflow:hidden`.
|
||||
*
|
||||
* Root cause: PR #1060 (commit eaf14a61) added a global
|
||||
* .nav-link { min-height: 48px; display:inline-flex; align-items:center; }
|
||||
* The 48px link + padding inflated `.nav-links` to 56px tall inside a 52px
|
||||
* `.top-nav` with `overflow:hidden`. With `align-items: center`, Firefox
|
||||
* centers the over-tall flex item at a negative y → strip clipped above
|
||||
* viewport.
|
||||
*
|
||||
* Acceptance (from #1400):
|
||||
* - Desktop: `.nav-links` rect.y >= 0 AND every `.nav-links > a` is
|
||||
* vertically inside the visible top-nav band (y >= 0 AND y+height <= 60).
|
||||
* - Mobile (<768px): touch-target preserved — `.nav-link` min-height
|
||||
* computed style >= 48px (regression guard for #1060).
|
||||
*
|
||||
* Mutation guard: re-adding `min-height: 48px` to global `.nav-link`
|
||||
* must make this test fail with negative y at desktop widths.
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert');
|
||||
const { chromium } = require('playwright');
|
||||
|
||||
const BASE = process.env.BASE_URL || 'http://localhost:13581';
|
||||
|
||||
const DESKTOP_WIDTHS = [1024, 1366, 1711];
|
||||
const MOBILE_WIDTH = 480;
|
||||
const HEIGHT = 800;
|
||||
const TOPNAV_HEIGHT_MAX = 60; // 52px nominal + a few px slack
|
||||
|
||||
async function settleNav(page) {
|
||||
await page.waitForSelector('.top-nav .nav-links');
|
||||
await page.evaluate(() => document.fonts && document.fonts.ready ? document.fonts.ready : null);
|
||||
await page.waitForFunction(() => {
|
||||
const el = document.querySelector('.top-nav .nav-links');
|
||||
if (!el) return false;
|
||||
const r1 = el.getBoundingClientRect();
|
||||
return new Promise((resolve) => {
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => {
|
||||
const r2 = el.getBoundingClientRect();
|
||||
resolve(r1.top === r2.top && r1.height === r2.height);
|
||||
}));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
let browser;
|
||||
try {
|
||||
browser = await chromium.launch({
|
||||
headless: true,
|
||||
executablePath: process.env.CHROMIUM_PATH || undefined,
|
||||
args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'],
|
||||
});
|
||||
} catch (err) {
|
||||
if (process.env.CHROMIUM_REQUIRE === '1') {
|
||||
console.error(`test-issue-1400-nav-vertical-clip.js: FAIL — Chromium required but unavailable: ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`test-issue-1400-nav-vertical-clip.js: SKIP (Chromium unavailable: ${err.message.split('\n')[0]})`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
let failures = 0;
|
||||
let passes = 0;
|
||||
const ctx = await browser.newContext();
|
||||
const page = await ctx.newPage();
|
||||
page.setDefaultTimeout(15000);
|
||||
|
||||
// === Desktop: vertical clip guard ===
|
||||
for (const w of DESKTOP_WIDTHS) {
|
||||
await page.setViewportSize({ width: w, height: HEIGHT });
|
||||
await page.goto(`${BASE}/#/home`, { waitUntil: 'domcontentloaded' });
|
||||
await settleNav(page);
|
||||
|
||||
const probe = await page.evaluate(() => {
|
||||
const nav = document.querySelector('.top-nav');
|
||||
const links = document.querySelector('.nav-links');
|
||||
const anchors = Array.from(document.querySelectorAll('.nav-links > a'));
|
||||
const r = (el) => {
|
||||
if (!el) return null;
|
||||
const b = el.getBoundingClientRect();
|
||||
return { y: b.y, height: b.height, bottom: b.y + b.height };
|
||||
};
|
||||
return {
|
||||
nav: r(nav),
|
||||
links: r(links),
|
||||
anchors: anchors.map((a) => ({ href: a.getAttribute('href'), ...r(a) })),
|
||||
};
|
||||
});
|
||||
|
||||
const tag = `vw=${w}`;
|
||||
if (!probe.links) {
|
||||
console.error(`FAIL ${tag}: .nav-links not found`);
|
||||
failures++;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
assert.ok(
|
||||
probe.links.y >= 0,
|
||||
`${tag}: .nav-links y=${probe.links.y} must be >= 0 (issue #1400 root-cause regression: clipped above viewport)`,
|
||||
);
|
||||
assert.ok(
|
||||
probe.anchors.length > 0,
|
||||
`${tag}: expected >=1 .nav-links > a, got 0`,
|
||||
);
|
||||
for (const a of probe.anchors) {
|
||||
assert.ok(
|
||||
a.y >= 0,
|
||||
`${tag}: nav-link href=${a.href} y=${a.y} must be >= 0`,
|
||||
);
|
||||
assert.ok(
|
||||
a.bottom <= TOPNAV_HEIGHT_MAX,
|
||||
`${tag}: nav-link href=${a.href} bottom=${a.bottom} must be <= ${TOPNAV_HEIGHT_MAX} (overflowing 52px top-nav)`,
|
||||
);
|
||||
}
|
||||
console.log(`PASS ${tag}: .nav-links y=${probe.links.y.toFixed(1)} h=${probe.links.height.toFixed(1)}; ${probe.anchors.length} anchors all inside top-nav band`);
|
||||
passes++;
|
||||
} catch (err) {
|
||||
console.error(`FAIL ${tag}: ${err.message}`);
|
||||
console.error(` probe: ${JSON.stringify(probe)}`);
|
||||
failures++;
|
||||
}
|
||||
}
|
||||
|
||||
// === Mobile: touch-target preserved (#1060 regression guard) ===
|
||||
await page.setViewportSize({ width: MOBILE_WIDTH, height: HEIGHT });
|
||||
await page.goto(`${BASE}/#/home`, { waitUntil: 'domcontentloaded' });
|
||||
// open hamburger so .nav-link is rendered (display:none otherwise on mobile until .open)
|
||||
await page.evaluate(() => {
|
||||
const links = document.querySelector('.nav-links');
|
||||
if (links) links.classList.add('open');
|
||||
});
|
||||
await page.waitForTimeout(50);
|
||||
|
||||
const mobileProbe = await page.evaluate(() => {
|
||||
const anchors = Array.from(document.querySelectorAll('.nav-links > a'));
|
||||
return anchors.slice(0, 3).map((a) => {
|
||||
const cs = getComputedStyle(a);
|
||||
return { href: a.getAttribute('href'), minHeight: parseFloat(cs.minHeight) || 0 };
|
||||
});
|
||||
});
|
||||
|
||||
const tag = `vw=${MOBILE_WIDTH}`;
|
||||
try {
|
||||
assert.ok(mobileProbe.length > 0, `${tag}: expected mobile nav-links anchors, got 0`);
|
||||
for (const a of mobileProbe) {
|
||||
assert.ok(
|
||||
a.minHeight >= 48,
|
||||
`${tag}: nav-link href=${a.href} min-height=${a.minHeight} must be >= 48 (touch-target regression of #1060)`,
|
||||
);
|
||||
}
|
||||
console.log(`PASS ${tag}: mobile .nav-link min-height >= 48 (touch-target preserved per #1060)`);
|
||||
passes++;
|
||||
} catch (err) {
|
||||
console.error(`FAIL ${tag}: ${err.message}`);
|
||||
console.error(` probe: ${JSON.stringify(mobileProbe)}`);
|
||||
failures++;
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
|
||||
console.log(`\ntest-issue-1400-nav-vertical-clip.js: ${passes} passed, ${failures} failed`);
|
||||
if (failures > 0) process.exit(1);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('test-issue-1400-nav-vertical-clip.js: ERROR', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* #1407 — cb-preset propagation + WCAG AA for every preset/role.
|
||||
*
|
||||
* Two bugs:
|
||||
* 1. window.ROLE_COLORS is a STATIC literal that's never resynced when
|
||||
* MeshCorePresets.applyPreset() rewrites the --mc-role-* CSS vars.
|
||||
* The hardcoded values are the LEGACY April palette (#dc2626 et al),
|
||||
* not even the current Wong defaults from #1357.
|
||||
* 2. The achromat preset pairs dark text (#1a1a1a) with 3 dark grays
|
||||
* whose contrast falls below WCAG 1.4.3 AA (4.5:1): repeater 1.27,
|
||||
* companion 2.55, room 4.43.
|
||||
*
|
||||
* This test fails on master and passes after the fix lands.
|
||||
*
|
||||
* Pure node + vm.createContext — runs in the JS-unit-tests CI step
|
||||
* without a browser. Mirrors test-issue-1361-cb-presets.js sandbox shape.
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const vm = require('vm');
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
function assert(cond, msg) {
|
||||
if (cond) { passed++; console.log(' ✓ ' + msg); }
|
||||
else { failed++; console.error(' ✗ ' + msg); }
|
||||
}
|
||||
|
||||
const rolesSrc = fs.readFileSync(path.join(__dirname, 'public', 'roles.js'), 'utf8');
|
||||
const presetsSrc = fs.readFileSync(path.join(__dirname, 'public', 'cb-presets.js'), 'utf8');
|
||||
const styleSrc = fs.readFileSync(path.join(__dirname, 'public', 'style.css'), 'utf8');
|
||||
const mapSrc = fs.readFileSync(path.join(__dirname, 'public', 'map.js'), 'utf8');
|
||||
|
||||
// ─── WCAG helpers (independent of cb-presets, so we validate the impl) ───
|
||||
function hexToRgb(hex) {
|
||||
hex = String(hex || '').trim();
|
||||
if (hex[0] !== '#' || hex.length !== 7) return null;
|
||||
return {
|
||||
r: parseInt(hex.slice(1, 3), 16),
|
||||
g: parseInt(hex.slice(3, 5), 16),
|
||||
b: parseInt(hex.slice(5, 7), 16)
|
||||
};
|
||||
}
|
||||
function chanLin(c) { var s = c / 255; return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4); }
|
||||
function relLum(hex) { var rgb = hexToRgb(hex); if (!rgb) return 0; return 0.2126*chanLin(rgb.r)+0.7152*chanLin(rgb.g)+0.0722*chanLin(rgb.b); }
|
||||
function contrast(fg, bg) {
|
||||
var L1 = relLum(fg), L2 = relLum(bg);
|
||||
var hi = Math.max(L1, L2), lo = Math.min(L1, L2);
|
||||
return (hi + 0.05) / (lo + 0.05);
|
||||
}
|
||||
|
||||
// ─── Browser-ish sandbox (CSS var setProperty/getPropertyValue + listeners) ───
|
||||
function makeSandbox() {
|
||||
const root = {
|
||||
style: {
|
||||
_vars: {},
|
||||
setProperty(k, v) { this._vars[k] = String(v); },
|
||||
getPropertyValue(k) { return this._vars[k] || ''; },
|
||||
removeProperty(k) { delete this._vars[k]; }
|
||||
},
|
||||
getAttribute() { return null; },
|
||||
setAttribute() {}
|
||||
};
|
||||
const body = {
|
||||
_attrs: {},
|
||||
setAttribute(k, v) { this._attrs[k] = v; },
|
||||
getAttribute(k) { return this._attrs[k] || null; },
|
||||
removeAttribute(k) { delete this._attrs[k]; },
|
||||
dataset: {}
|
||||
};
|
||||
const listeners = {};
|
||||
const storage = {
|
||||
_data: {},
|
||||
getItem(k) { return Object.prototype.hasOwnProperty.call(this._data, k) ? this._data[k] : null; },
|
||||
setItem(k, v) { this._data[k] = String(v); },
|
||||
removeItem(k) { delete this._data[k]; }
|
||||
};
|
||||
const sandbox = {
|
||||
window: null,
|
||||
document: {
|
||||
documentElement: root,
|
||||
body: body,
|
||||
readyState: 'complete',
|
||||
getElementById() { return null; },
|
||||
createElement() {
|
||||
var el = { _children: [], style: {}, textContent: '', id: '',
|
||||
setAttribute() {}, appendChild(c) { this._children.push(c); } };
|
||||
return el;
|
||||
},
|
||||
head: { appendChild() {} },
|
||||
addEventListener() {},
|
||||
},
|
||||
localStorage: storage,
|
||||
console: console,
|
||||
setTimeout: setTimeout,
|
||||
clearTimeout: clearTimeout,
|
||||
addEventListener(ev, cb) { (listeners[ev] = listeners[ev] || []).push(cb); },
|
||||
dispatchEvent(ev) { (listeners[ev.type] || []).forEach(function (cb) { cb(ev); }); return true; },
|
||||
CustomEvent: function (type, opts) { this.type = type; this.detail = opts && opts.detail; },
|
||||
Event: function (type) { this.type = type; },
|
||||
fetch: function () { return { then: function () { return { then: function () { return { catch: function () {} }; }, catch: function () {} }; } }; },
|
||||
matchMedia: function () { return { matches: false }; },
|
||||
// getComputedStyle reads from the root.style._vars set by cb-presets
|
||||
getComputedStyle: function (el) {
|
||||
return {
|
||||
getPropertyValue: function (k) {
|
||||
return (root.style._vars[k] || '');
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
sandbox.window = sandbox;
|
||||
return { sandbox, root, body, storage, listeners };
|
||||
}
|
||||
|
||||
console.log('\n=== #1407 A: ROLE_COLORS is NOT the static legacy palette ===');
|
||||
let env;
|
||||
try {
|
||||
env = makeSandbox();
|
||||
vm.createContext(env.sandbox);
|
||||
vm.runInContext(rolesSrc, env.sandbox);
|
||||
vm.runInContext(presetsSrc, env.sandbox);
|
||||
} catch (e) {
|
||||
assert(false, 'sandbox load failed: ' + e.message);
|
||||
}
|
||||
|
||||
const RC = env && env.sandbox.window.ROLE_COLORS;
|
||||
assert(!!RC, 'window.ROLE_COLORS is defined');
|
||||
// MUTATION GUARD: ROLE_COLORS must be exposed via a getter that reads live
|
||||
// CSS vars — NOT a plain hardcoded data property. The bug is that it's a
|
||||
// static literal disconnected from --mc-role-* CSS vars.
|
||||
const RCDesc = env && Object.getOwnPropertyDescriptor(env.sandbox.window, 'ROLE_COLORS');
|
||||
assert(RCDesc && typeof RCDesc.get === 'function',
|
||||
'window.ROLE_COLORS must be a getter property (live read of --mc-role-* CSS vars), not a static literal');
|
||||
|
||||
// Direct CSS-var test: simulate what cb-presets.js does without going through
|
||||
// applyPreset's legacy ROLE_COLORS mutation path. Set the CSS var directly →
|
||||
// ROLE_COLORS getter must reflect it.
|
||||
env.root.style.setProperty('--mc-role-repeater', '#abcdef');
|
||||
const live = env.sandbox.window.ROLE_COLORS.repeater;
|
||||
assert(String(live).toLowerCase() === '#abcdef',
|
||||
'ROLE_COLORS.repeater reflects live --mc-role-repeater CSS var (got ' + live + ')');
|
||||
env.root.style.removeProperty('--mc-role-repeater');
|
||||
|
||||
console.log('\n=== #1407 B: ROLE_COLORS tracks --mc-role-* CSS vars live ===');
|
||||
const MCP = env && env.sandbox.window.MeshCorePresets;
|
||||
assert(!!MCP, 'MeshCorePresets exists');
|
||||
if (MCP) {
|
||||
// Apply default preset → CSS vars become Wong → ROLE_COLORS should report Wong.
|
||||
MCP.applyPreset('default');
|
||||
const def = String(env.sandbox.window.ROLE_COLORS.repeater).toLowerCase();
|
||||
assert(def === '#d55e00', 'after applyPreset("default") ROLE_COLORS.repeater === #D55E00 Wong (got ' + def + ')');
|
||||
|
||||
// Switch to deut → ROLE_COLORS.repeater should change to IBM orange #FE6100.
|
||||
MCP.applyPreset('deut');
|
||||
const deut = String(env.sandbox.window.ROLE_COLORS.repeater).toLowerCase();
|
||||
assert(deut === '#fe6100', 'after applyPreset("deut") ROLE_COLORS.repeater === #FE6100 IBM orange (got ' + deut + ')');
|
||||
|
||||
// Switch to achromat → should be dark gray #333333.
|
||||
MCP.applyPreset('achromat');
|
||||
const ach = String(env.sandbox.window.ROLE_COLORS.repeater).toLowerCase();
|
||||
assert(ach === '#333333', 'after applyPreset("achromat") ROLE_COLORS.repeater === #333333 (got ' + ach + ')');
|
||||
}
|
||||
|
||||
console.log('\n=== #1407 C: ROLE_STYLE.color also reads live ===');
|
||||
if (MCP) {
|
||||
MCP.applyPreset('trit');
|
||||
const rs = env.sandbox.window.ROLE_STYLE && env.sandbox.window.ROLE_STYLE.repeater;
|
||||
const c = rs && String(rs.color || '').toLowerCase();
|
||||
assert(c === '#cc6677', 'after applyPreset("trit") ROLE_STYLE.repeater.color === #CC6677 (got ' + c + ')');
|
||||
}
|
||||
|
||||
console.log('\n=== #1407 D: applyPreset writes --mc-role-X-text CSS vars ===');
|
||||
if (MCP) {
|
||||
['default', 'deut', 'prot', 'trit', 'achromat'].forEach(function (id) {
|
||||
MCP.applyPreset(id);
|
||||
['repeater', 'companion', 'room', 'sensor', 'observer'].forEach(function (role) {
|
||||
const v = env.root.style.getPropertyValue('--mc-role-' + role + '-text');
|
||||
assert(/^#[0-9a-f]{6}$/i.test(v), 'preset "' + id + '" sets --mc-role-' + role + '-text (got "' + v + '")');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
console.log('\n=== #1407 E: WCAG 1.4.3 AA — every (preset, role) pair ≥ 4.5:1 ===');
|
||||
if (MCP) {
|
||||
['default', 'deut', 'prot', 'trit', 'achromat'].forEach(function (id) {
|
||||
MCP.applyPreset(id);
|
||||
const preset = MCP.list.find(function (p) { return p.id === id; });
|
||||
['repeater', 'companion', 'room', 'sensor', 'observer'].forEach(function (role) {
|
||||
const bg = preset.roleColors[role];
|
||||
const text = env.root.style.getPropertyValue('--mc-role-' + role + '-text');
|
||||
const ratio = contrast(text, bg);
|
||||
assert(ratio >= 4.5,
|
||||
'WCAG 1.4.3 AA: preset "' + id + '" role "' + role + '" bg=' + bg +
|
||||
' text=' + text + ' contrast=' + ratio.toFixed(2) + ':1 (need ≥4.5)');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
console.log('\n=== #1407 F: pill text color is driven by CSS var, not hardcoded ===');
|
||||
// style.css `.mc-pill` rule must use var(--mc-role-*-text) — NOT hardcoded #1a1a1a.
|
||||
const pillRuleMatch = styleSrc.match(/\.mc-cluster\s+\.mc-pill\s*\{[^}]*\}/);
|
||||
assert(pillRuleMatch, '.mc-cluster .mc-pill rule found in style.css');
|
||||
if (pillRuleMatch) {
|
||||
const block = pillRuleMatch[0];
|
||||
assert(/var\(--mc-pill-text|var\(--mc-role-/.test(block),
|
||||
'.mc-cluster .mc-pill uses var(--mc-...-text) for color (got: ' + block.replace(/\s+/g,' ').slice(0,200) + ')');
|
||||
}
|
||||
// map.js inline style: must not hardcode color:#1a1a1a on the pill
|
||||
const inlineHardcoded = /color:\s*#1a1a1a/.test(mapSrc);
|
||||
assert(!inlineHardcoded, 'public/map.js does not hardcode color:#1a1a1a on .mc-pill inline style');
|
||||
|
||||
console.log('\n=== Summary ===');
|
||||
console.log(' passed: ' + passed);
|
||||
console.log(' failed: ' + failed);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -0,0 +1,49 @@
|
||||
/* Issue #1409 — channels.js must NOT unconditionally force-enable
|
||||
* 'channels-show-encrypted' in localStorage on every init.
|
||||
*
|
||||
* The bug: channels.js set localStorage.setItem('channels-show-encrypted', 'true')
|
||||
* unconditionally on init, which made it impossible for an operator to ever
|
||||
* hide the 246 encrypted-placeholder channels.
|
||||
*
|
||||
* Test strategy: source-grep. The file must not contain a
|
||||
* setItem('channels-show-encrypted', 'true') call anywhere — there is no
|
||||
* legitimate place to force this on; the only writer should be a future
|
||||
* user-toggle handler that writes BOTH 'true' and 'false' under a condition.
|
||||
*/
|
||||
'use strict';
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const assert = require('assert');
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
function test(name, fn) {
|
||||
try { fn(); passed++; console.log(' \u2705 ' + name); }
|
||||
catch (e) { failed++; console.log(' \u274c ' + name + ': ' + e.message); }
|
||||
}
|
||||
|
||||
const src = fs.readFileSync(path.join(__dirname, 'public/channels.js'), 'utf8');
|
||||
|
||||
console.log('Issue #1409 — no force-enable of channels-show-encrypted');
|
||||
|
||||
test('channels.js does NOT unconditionally setItem(channels-show-encrypted, true)', function () {
|
||||
// Match any whitespace/quote variant of:
|
||||
// localStorage.setItem('channels-show-encrypted', 'true')
|
||||
// or with double quotes. A user-toggle handler would set a VARIABLE,
|
||||
// not the literal string 'true', so this is a safe gate.
|
||||
var re = /localStorage\s*\.\s*setItem\s*\(\s*['"]channels-show-encrypted['"]\s*,\s*['"]true['"]\s*\)/;
|
||||
var m = src.match(re);
|
||||
assert.strictEqual(m, null,
|
||||
'Found forbidden literal force-set of channels-show-encrypted=true in public/channels.js. ' +
|
||||
'A user-toggle handler should pass a boolean variable, not the literal string "true".');
|
||||
});
|
||||
|
||||
test('channels.js still reads channels-show-encrypted (toggle gate preserved)', function () {
|
||||
// We are NOT removing the read path; the reader is still needed so a
|
||||
// future user toggle works. This sanity-check ensures the fix did not
|
||||
// also delete the reader.
|
||||
assert.ok(/getItem\(\s*['"]channels-show-encrypted['"]\s*\)/.test(src),
|
||||
'Expected getItem(channels-show-encrypted) to still be present');
|
||||
});
|
||||
|
||||
console.log('\n' + passed + ' passed, ' + failed + ' failed');
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
@@ -0,0 +1,230 @@
|
||||
/**
|
||||
* #1412 — customizer nodeColors must NOT auto-push server config into
|
||||
* ROLE_COLORS overrides, or it defeats CB-preset propagation.
|
||||
*
|
||||
* Bug (CDP-verified on staging): PR #1408 made window.ROLE_COLORS a live
|
||||
* getter that reads --mc-role-* CSS vars. cb-presets.applyPreset() writes
|
||||
* those vars, so consumers SHOULD see new colors. But customize-v2.js:553
|
||||
* runs early on every page load and pushes effectiveConfig.nodeColors
|
||||
* (server config, legacy April palette) into the override map, which the
|
||||
* getter prefers over CSS vars. Net effect: ROLE_COLORS is frozen on the
|
||||
* legacy palette forever; presets only update the CSS, not the JS.
|
||||
*
|
||||
* Fix: server-config nodeColors must only write --node-* CSS var (legacy
|
||||
* compat for anything still reading --node-*). It must NOT touch the
|
||||
* override map. User-chosen colors in the customizer continue to win via
|
||||
* setRoleColorOverride() (explicit, intentional override).
|
||||
*
|
||||
* Test strategy: extract the actual code block from customize-v2.js that
|
||||
* processes effectiveConfig.nodeColors, run it in a vm sandbox with a
|
||||
* legacy-palette config, apply preset "deut", assert ROLE_COLORS reflects
|
||||
* the preset (not the server config).
|
||||
*
|
||||
* Mutation guard: re-introducing the `window.ROLE_COLORS[role] = nc[role]`
|
||||
* write to customize-v2.js makes the first test fail.
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const vm = require('vm');
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
function assert(cond, msg) {
|
||||
if (cond) { passed++; console.log(' ✓ ' + msg); }
|
||||
else { failed++; console.error(' ✗ ' + msg); }
|
||||
}
|
||||
|
||||
const rolesSrc = fs.readFileSync(path.join(__dirname, 'public', 'roles.js'), 'utf8');
|
||||
const presetsSrc = fs.readFileSync(path.join(__dirname, 'public', 'cb-presets.js'), 'utf8');
|
||||
const cv2Src = fs.readFileSync(path.join(__dirname, 'public', 'customize-v2.js'), 'utf8');
|
||||
|
||||
// Browser-ish sandbox (CSS var setProperty/getPropertyValue).
|
||||
function makeSandbox() {
|
||||
const root = {
|
||||
style: {
|
||||
_vars: {},
|
||||
setProperty(k, v) { this._vars[k] = String(v); },
|
||||
getPropertyValue(k) { return this._vars[k] || ''; },
|
||||
removeProperty(k) { delete this._vars[k]; }
|
||||
},
|
||||
getAttribute() { return null; },
|
||||
setAttribute() {}
|
||||
};
|
||||
const body = {
|
||||
_attrs: {},
|
||||
setAttribute(k, v) { this._attrs[k] = v; },
|
||||
getAttribute(k) { return this._attrs[k] || null; },
|
||||
removeAttribute(k) { delete this._attrs[k]; },
|
||||
dataset: {}
|
||||
};
|
||||
const sandbox = {
|
||||
window: null,
|
||||
document: {
|
||||
documentElement: root,
|
||||
body: body,
|
||||
readyState: 'complete',
|
||||
getElementById() { return null; },
|
||||
createElement() { return { style: {}, setAttribute() {}, appendChild() {} }; },
|
||||
head: { appendChild() {} },
|
||||
addEventListener() {},
|
||||
},
|
||||
console: console,
|
||||
setTimeout: setTimeout,
|
||||
clearTimeout: clearTimeout,
|
||||
addEventListener() {},
|
||||
dispatchEvent() { return true; },
|
||||
fetch: function () { return { then: function () { return { then: function () { return { catch: function () {} }; }, catch: function () {} }; } }; },
|
||||
matchMedia: function () { return { matches: false }; },
|
||||
CustomEvent: function (type, opts) { this.type = type; this.detail = opts && opts.detail; },
|
||||
Event: function (type) { this.type = type; },
|
||||
getComputedStyle: function () {
|
||||
return { getPropertyValue: function (k) { return (root.style._vars[k] || ''); } };
|
||||
}
|
||||
};
|
||||
sandbox.window = sandbox;
|
||||
return { sandbox, root, body };
|
||||
}
|
||||
|
||||
// ─── Extract the two nodeColors-processing blocks from customize-v2.js. ───
|
||||
// We want to execute the REAL source so reverting the fix breaks the test.
|
||||
// Block 1: the effective-config apply path (≈ line 550).
|
||||
// Block 2: the early-overrides apply path (≈ line 2146).
|
||||
function extractBlock(src, anchor) {
|
||||
const idx = src.indexOf(anchor);
|
||||
if (idx === -1) throw new Error('anchor not found: ' + anchor);
|
||||
// Walk forward to the matching closing brace of the surrounding `if (nc) { ... }`.
|
||||
// Slice forward a generous window then balance braces from the first '{' after anchor.
|
||||
const start = src.indexOf('{', idx);
|
||||
if (start === -1) throw new Error('open brace not found after anchor');
|
||||
let depth = 0, end = -1;
|
||||
for (let i = start; i < src.length; i++) {
|
||||
if (src[i] === '{') depth++;
|
||||
else if (src[i] === '}') { depth--; if (depth === 0) { end = i; break; } }
|
||||
}
|
||||
if (end === -1) throw new Error('matching close brace not found');
|
||||
return src.slice(idx, end + 1);
|
||||
}
|
||||
|
||||
// Block A — main effective-config push: `var nc = effectiveConfig.nodeColors;`
|
||||
const blockA = extractBlock(cv2Src, 'var nc = effectiveConfig.nodeColors;');
|
||||
// Block B — early overrides: `if (earlyOverrides.nodeColors) {`
|
||||
const blockB = extractBlock(cv2Src, 'if (earlyOverrides.nodeColors) {');
|
||||
|
||||
console.log('\n=== #1412 A: server-config nodeColors does NOT clobber preset ROLE_COLORS ===');
|
||||
{
|
||||
const env = makeSandbox();
|
||||
vm.createContext(env.sandbox);
|
||||
vm.runInContext(rolesSrc, env.sandbox);
|
||||
vm.runInContext(presetsSrc, env.sandbox);
|
||||
|
||||
// Simulate user choosing the "deut" preset.
|
||||
env.sandbox.window.MeshCorePresets.applyPreset('deut');
|
||||
// CSS var should be IBM orange now.
|
||||
assert(env.root.style.getPropertyValue('--mc-role-repeater').toLowerCase() === '#fe6100',
|
||||
'precondition: --mc-role-repeater is #FE6100 after applyPreset("deut")');
|
||||
|
||||
// Now simulate customize-v2 picking up the server config (legacy palette).
|
||||
const setupBlockA =
|
||||
'var root = document.documentElement.style;\n' +
|
||||
'var userOverrides = undefined;\n' +
|
||||
'var effectiveConfig = { nodeColors: { repeater: "#dc2626", companion: "#2563eb", room: "#16a34a", sensor: "#d97706", observer: "#8b5cf6" } };\n' +
|
||||
blockA + '\n';
|
||||
vm.runInContext(setupBlockA, env.sandbox);
|
||||
|
||||
// The --node-* CSS vars should still be written for legacy consumers.
|
||||
assert(env.root.style.getPropertyValue('--node-repeater') === '#dc2626',
|
||||
'--node-repeater CSS var is still written (legacy compat preserved)');
|
||||
|
||||
// The KEY assertion: ROLE_COLORS must still reflect the preset, NOT the
|
||||
// server-config legacy palette.
|
||||
const got = String(env.sandbox.window.ROLE_COLORS.repeater).toLowerCase();
|
||||
assert(got === '#fe6100',
|
||||
'ROLE_COLORS.repeater === #FE6100 after server-config push (got ' + got + ')');
|
||||
|
||||
const gotCompanion = String(env.sandbox.window.ROLE_COLORS.companion).toLowerCase();
|
||||
assert(gotCompanion !== '#2563eb',
|
||||
'ROLE_COLORS.companion is NOT the server-config legacy #2563eb (got ' + gotCompanion + ')');
|
||||
}
|
||||
|
||||
console.log('\n=== #1412 B: early-overrides path also stays out of ROLE_COLORS override map ===');
|
||||
{
|
||||
const env = makeSandbox();
|
||||
vm.createContext(env.sandbox);
|
||||
vm.runInContext(rolesSrc, env.sandbox);
|
||||
vm.runInContext(presetsSrc, env.sandbox);
|
||||
env.sandbox.window.MeshCorePresets.applyPreset('deut');
|
||||
|
||||
const setupBlockB =
|
||||
'var root = document.documentElement.style;\n' +
|
||||
'var earlyOverrides = { nodeColors: { repeater: "#dc2626", companion: "#2563eb" } };\n' +
|
||||
blockB + '\n';
|
||||
// earlyOverrides path also writes --node-* and (per fix) only --node-*.
|
||||
// The extracted block may not write --node-* — that's fine; we only care
|
||||
// it does NOT push into the override map.
|
||||
try { vm.runInContext(setupBlockB, env.sandbox); }
|
||||
catch (e) { /* if the block touches APIs we didn't stub, ignore — the
|
||||
override-map assertion below is what matters */ }
|
||||
|
||||
const got = String(env.sandbox.window.ROLE_COLORS.repeater).toLowerCase();
|
||||
assert(got === '#fe6100',
|
||||
'ROLE_COLORS.repeater === #FE6100 after early-overrides push (got ' + got + ')');
|
||||
}
|
||||
|
||||
console.log('\n=== #1412 C: explicit setRoleColorOverride() still wins (user customizer pick) ===');
|
||||
{
|
||||
const env = makeSandbox();
|
||||
vm.createContext(env.sandbox);
|
||||
vm.runInContext(rolesSrc, env.sandbox);
|
||||
vm.runInContext(presetsSrc, env.sandbox);
|
||||
env.sandbox.window.MeshCorePresets.applyPreset('deut');
|
||||
|
||||
// User manually picks a node color in the customizer.
|
||||
env.sandbox.window.setRoleColorOverride('repeater', '#ff00ff');
|
||||
const got = String(env.sandbox.window.ROLE_COLORS.repeater).toLowerCase();
|
||||
assert(got === '#ff00ff',
|
||||
'after setRoleColorOverride("repeater","#ff00ff") ROLE_COLORS.repeater === #ff00ff (got ' + got + ')');
|
||||
|
||||
// Clearing the override lets the preset show through again.
|
||||
env.sandbox.window.setRoleColorOverride('repeater', '');
|
||||
const got2 = String(env.sandbox.window.ROLE_COLORS.repeater).toLowerCase();
|
||||
assert(got2 === '#fe6100',
|
||||
'after clearing override, ROLE_COLORS.repeater reverts to preset #FE6100 (got ' + got2 + ')');
|
||||
}
|
||||
|
||||
console.log('\n=== #1412 D: customize.js per-key node-color picker uses setRoleColorOverride ===');
|
||||
{
|
||||
// Static guard: the legacy customizer (customize.js) handlers for the node
|
||||
// color pickers must call setRoleColorOverride(key, value) — NOT mutate
|
||||
// ROLE_COLORS directly. The proxy-on-read trick in roles.js handles direct
|
||||
// assignment, but going through the explicit API keeps semantics obvious
|
||||
// and lets us delete the proxy layer later.
|
||||
const customizeSrc = fs.readFileSync(path.join(__dirname, 'public', 'customize.js'), 'utf8');
|
||||
|
||||
// Grep for the two affected handlers (data-node input handler + reset).
|
||||
// Locate the input[data-node] handler — slice forward through the inner forEach callback.
|
||||
const nodeInputStart = customizeSrc.indexOf("querySelectorAll('input[data-node]')");
|
||||
const nodeInputHandler = nodeInputStart >= 0 ? [customizeSrc.slice(nodeInputStart, nodeInputStart + 800)] : null;
|
||||
assert(nodeInputHandler, 'node color input handler block found in customize.js');
|
||||
if (nodeInputHandler) {
|
||||
assert(/setRoleColorOverride\s*\(/.test(nodeInputHandler[0]),
|
||||
'node color input handler calls setRoleColorOverride()');
|
||||
assert(!/window\.ROLE_COLORS\s*\[[^\]]+\]\s*=/.test(nodeInputHandler[0]),
|
||||
'node color input handler does NOT assign window.ROLE_COLORS[key] = … directly');
|
||||
}
|
||||
|
||||
const nodeResetStart = customizeSrc.indexOf("querySelectorAll('[data-reset-node]')");
|
||||
const nodeResetHandler = nodeResetStart >= 0 ? [customizeSrc.slice(nodeResetStart, nodeResetStart + 800)] : null;
|
||||
assert(nodeResetHandler, 'node color reset handler block found in customize.js');
|
||||
if (nodeResetHandler) {
|
||||
assert(/setRoleColorOverride\s*\(/.test(nodeResetHandler[0]),
|
||||
'node color reset handler calls setRoleColorOverride()');
|
||||
assert(!/window\.ROLE_COLORS\[/.test(nodeResetHandler[0]),
|
||||
'node color reset handler does NOT write window.ROLE_COLORS[key] directly');
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n=== Summary ===');
|
||||
console.log(' passed: ' + passed);
|
||||
console.log(' failed: ' + failed);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -0,0 +1,134 @@
|
||||
#!/usr/bin/env node
|
||||
/* Issue #1413 — More button overlaps nav-stats badge at vw~1200px.
|
||||
*
|
||||
* Symptom: at viewport ~1101..1599px on a non-mobile page (e.g.
|
||||
* /#/packets), the ".nav-more-btn" (in .nav-left) and ".nav-stats"
|
||||
* (in .nav-right) overlap horizontally. CDP-confirmed: at vw=1200,
|
||||
* .nav-more-btn rect (x=499..556) sat on top of .nav-stats (x=502..961),
|
||||
* a ~54px x-axis overlap. Visually the stats badge number rendered on
|
||||
* top of the "More" text and the chevron.
|
||||
*
|
||||
* Acceptance (from issue #1413):
|
||||
* - At vw=1101..1920 (sample step), .nav-more-btn.right + GAP <=
|
||||
* .nav-stats.left, where GAP >= 8px.
|
||||
* - At vw <= 1100, .nav-stats is display:none (no change).
|
||||
* - Nav doesn't horizontally scroll at any viewport.
|
||||
*
|
||||
* Root cause: .top-nav uses display:flex with justify-content:
|
||||
* space-between, but .nav-left had no flex-grow and .nav-links had no
|
||||
* flex-grow either, so .nav-left only consumed its content's intrinsic
|
||||
* width. .nav-right (flex-shrink:0) then sat at its natural position
|
||||
* computed from total content — and the JS Priority+ fits() check
|
||||
* succeeded based on intrinsic widths that under-reported the real
|
||||
* collision because .top-nav has overflow:hidden masking it.
|
||||
*
|
||||
* Fix (verified via CDP at vw 1101..1920): `.nav-links { flex: 1 1
|
||||
* auto; min-width: 0 }` + `.top-nav { column-gap: 16px }`. Reverting
|
||||
* either part of the fix reintroduces overlap at vw=1200.
|
||||
*
|
||||
* Mutation guard: revert the CSS fix → this test fails at vw=1200.
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert');
|
||||
const { chromium } = require('playwright');
|
||||
|
||||
const BASE = process.env.BASE_URL || 'http://localhost:13581';
|
||||
const WIDTHS = [1101, 1200, 1366, 1440, 1600, 1920];
|
||||
const HEIGHT = 800;
|
||||
const MIN_GAP_PX = 8;
|
||||
|
||||
async function main() {
|
||||
let browser;
|
||||
try {
|
||||
browser = await chromium.launch({
|
||||
headless: true,
|
||||
executablePath: process.env.CHROMIUM_PATH || undefined,
|
||||
args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'],
|
||||
});
|
||||
} catch (err) {
|
||||
if (process.env.CHROMIUM_REQUIRE === '1') {
|
||||
console.error(`test-issue-1413-nav-overlap-e2e.js: FAIL — Chromium required but unavailable: ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`test-issue-1413-nav-overlap-e2e.js: SKIP (Chromium unavailable: ${err.message.split('\n')[0]})`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
let failures = 0;
|
||||
let passes = 0;
|
||||
const ctx = await browser.newContext();
|
||||
const page = await ctx.newPage();
|
||||
page.setDefaultTimeout(15000);
|
||||
|
||||
for (const w of WIDTHS) {
|
||||
await page.setViewportSize({ width: w, height: HEIGHT });
|
||||
await page.goto(`${BASE}/#/packets`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('.top-nav .nav-links');
|
||||
await page.evaluate(() => document.fonts && document.fonts.ready ? document.fonts.ready : null);
|
||||
// Settle layout: two consecutive frames identical for nav-right.
|
||||
await page.waitForFunction(() => {
|
||||
const el = document.querySelector('.top-nav .nav-right');
|
||||
if (!el) return false;
|
||||
const r1 = el.getBoundingClientRect();
|
||||
return new Promise((resolve) => {
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => {
|
||||
const r2 = el.getBoundingClientRect();
|
||||
resolve(r1.right === r2.right && r1.left === r2.left);
|
||||
}));
|
||||
});
|
||||
}, null, { timeout: 5000 });
|
||||
await page.evaluate(() => new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r))));
|
||||
|
||||
const data = await page.evaluate(() => {
|
||||
const more = document.querySelector('.nav-more-btn');
|
||||
const stats = document.querySelector('.nav-stats');
|
||||
const moreVisible = more && getComputedStyle(more).display !== 'none' &&
|
||||
getComputedStyle(more.parentElement).display !== 'none' &&
|
||||
!more.parentElement.classList.contains('is-hidden');
|
||||
const statsVisible = stats && getComputedStyle(stats).display !== 'none';
|
||||
const mb = more ? more.getBoundingClientRect() : null;
|
||||
const sb = stats ? stats.getBoundingClientRect() : null;
|
||||
const topNav = document.querySelector('.top-nav');
|
||||
const tnScrollW = topNav ? topNav.scrollWidth : 0;
|
||||
const tnClientW = topNav ? topNav.clientWidth : 0;
|
||||
return {
|
||||
moreVisible, statsVisible,
|
||||
more: mb ? { x: mb.x, right: mb.right, w: mb.width } : null,
|
||||
stats: sb ? { x: sb.x, right: sb.right, w: sb.width } : null,
|
||||
tnScrollW, tnClientW,
|
||||
};
|
||||
});
|
||||
|
||||
let status = 'PASS';
|
||||
const reasons = [];
|
||||
|
||||
// Acceptance: if both visible, more.right + 8 <= stats.left.
|
||||
if (data.moreVisible && data.statsVisible && data.more && data.stats) {
|
||||
const gap = data.stats.x - data.more.right;
|
||||
if (gap < MIN_GAP_PX) {
|
||||
status = 'FAIL';
|
||||
reasons.push(`overlap: more.right=${data.more.right.toFixed(1)} stats.left=${data.stats.x.toFixed(1)} gap=${gap.toFixed(1)} (need >= ${MIN_GAP_PX})`);
|
||||
}
|
||||
}
|
||||
// No horizontal scroll in nav.
|
||||
if (data.tnScrollW > data.tnClientW + 1) {
|
||||
status = 'FAIL';
|
||||
reasons.push(`top-nav h-scroll: scrollW=${data.tnScrollW} clientW=${data.tnClientW}`);
|
||||
}
|
||||
|
||||
if (status === 'FAIL') {
|
||||
failures++;
|
||||
console.error(`vw=${w} #/packets ${status}: ${reasons.join('; ')}`);
|
||||
} else {
|
||||
passes++;
|
||||
console.log(`vw=${w} #/packets PASS (more.right=${data.more && data.more.right.toFixed(1)} stats.left=${data.stats && data.stats.x.toFixed(1)})`);
|
||||
}
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
console.log(`\ntest-issue-1413-nav-overlap-e2e.js: ${passes} pass, ${failures} fail`);
|
||||
process.exit(failures > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
main().catch((err) => { console.error('test-issue-1413-nav-overlap-e2e.js: ERROR', err); process.exit(1); });
|
||||
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* #1415 — Packets cross-viewport jank source-grep test.
|
||||
*
|
||||
* Asserts the four code-level invariants required by the layout fix:
|
||||
*
|
||||
* 1. Expand-chevron column is pinned narrow at every viewport via an
|
||||
* explicit `.col-expand` class on the first <th>/<td> AND a CSS rule
|
||||
* pinning its width to ~32px (max-width ≤ 36px).
|
||||
* 2. DETAILS column is capped — `.col-details` has a `max-width` ≤ 480px
|
||||
* so wide viewports stop wasting hundreds of px on the last column.
|
||||
* 3. Mobile chrome compaction — the `@media (max-width: 480px)` block
|
||||
* hides `.col-details` (so the table doesn't carry the dead column to
|
||||
* mobile) AND hides the BYOP button in `.page-header` (operator
|
||||
* request: reclaim 60+ px of pre-table chrome).
|
||||
* 4. Mobile-priority detail order — `renderDetail()` renders the Payload
|
||||
* Type as the FIRST `<dt>` of `.detail-meta` (operator's "lead with
|
||||
* packet type"), and wraps the byte-breakdown / hex-dump / field-table
|
||||
* into a `<details class="detail-technical">` element so the
|
||||
* technical fields collapse on mobile (collapsed by default, open on
|
||||
* desktop via the `open` attribute being conditionally set).
|
||||
*
|
||||
* Strategy: pure source-grep — no browser, no playwright. The grep is the
|
||||
* gate. If someone reverts any of the four fixes, the corresponding assert
|
||||
* fails. Cheap to run, deterministic, runs in CI without browser deps.
|
||||
*/
|
||||
'use strict';
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
function assert(cond, msg) {
|
||||
if (cond) { passed++; console.log(' \u2705 ' + msg); }
|
||||
else { failed++; console.error(' \u274c ' + msg); }
|
||||
}
|
||||
|
||||
const pktJs = fs.readFileSync(path.join(__dirname, 'public/packets.js'), 'utf8');
|
||||
const css = fs.readFileSync(path.join(__dirname, 'public/style.css'), 'utf8');
|
||||
|
||||
// ── 1. col-expand class + CSS pin ────────────────────────────────────────
|
||||
assert(
|
||||
/<th[^>]*class="col-expand"/.test(pktJs),
|
||||
'packets.js header has <th class="col-expand"> on the first column'
|
||||
);
|
||||
assert(
|
||||
/<td class="col-expand"/.test(pktJs),
|
||||
'packets.js row builders emit <td class="col-expand"> for the chevron cell'
|
||||
);
|
||||
|
||||
// CSS must pin width somewhere in the .col-expand selector.
|
||||
var colExpandBlocks = css.match(/\.col-expand\b[^{}]*\{[^}]*\}/g) || [];
|
||||
var pinned = colExpandBlocks.some(function (b) {
|
||||
return /max-width:\s*3[26]px/.test(b) && /min-width:\s*3[26]px/.test(b);
|
||||
});
|
||||
assert(pinned, 'style.css .col-expand pins min-width AND max-width to ~32px');
|
||||
|
||||
// ── 1b. Locked column-priority tiers (operator spec) ─────────────────────
|
||||
// Tier 1 (always — even on smallest mobile): expand, time, type, details
|
||||
// Tier 2 (tablet+): path
|
||||
// Tier 3 (desktop only): hash, observer, rpt
|
||||
// Region/Size/HB stay at the existing low-priority tiers (already 3-5).
|
||||
//
|
||||
// Mapping to priority values (see TableResponsive doc at top of packets.js):
|
||||
// priority 1 → always visible
|
||||
// priority 3 → hidden ≤ 1024 (desktop-only)
|
||||
// priority 5 → hidden ≤ 768 (tablet+ only)
|
||||
function colPriority(klass) {
|
||||
var re = new RegExp('<th[^>]*class="' + klass + '"[^>]*data-priority="(\\d+)"');
|
||||
var m = pktJs.match(re);
|
||||
return m ? parseInt(m[1], 10) : null;
|
||||
}
|
||||
assert(colPriority('col-expand') === 1, 'col-expand is tier-1 priority (always visible)');
|
||||
assert(colPriority('col-time') === 1, 'col-time is tier-1 priority (always visible)');
|
||||
assert(colPriority('col-type') === 1, 'col-type is tier-1 priority (always visible)');
|
||||
assert(colPriority('col-details') === 1, 'col-details is tier-1 priority (always visible)');
|
||||
assert(colPriority('col-path') === 5, 'col-path is tier-2 (hidden ≤768, tablet+ only)');
|
||||
assert(colPriority('col-hash') === 3, 'col-hash is tier-3 (desktop only, hidden ≤1024)');
|
||||
assert(colPriority('col-observer') === 3, 'col-observer is tier-3 (desktop only, hidden ≤1024)');
|
||||
assert(colPriority('col-rpt') === 3, 'col-rpt is tier-3 (desktop only, hidden ≤1024)');
|
||||
|
||||
// ── 2. DETAILS column capped ─────────────────────────────────────────────
|
||||
var colDetailsBlocks = css.match(/\.col-details\b[^{}]*\{[^}]*\}/g) || [];
|
||||
var capped = colDetailsBlocks.some(function (b) {
|
||||
var m = b.match(/max-width:\s*(\d+)px/);
|
||||
return m && parseInt(m[1], 10) <= 480 && parseInt(m[1], 10) >= 200;
|
||||
});
|
||||
assert(capped, 'style.css caps .col-details with max-width ≤ 480px');
|
||||
|
||||
// ── 3. Mobile compaction — DETAILS hidden + BYOP hidden under 480 ────────
|
||||
var mobileBlock = (function () {
|
||||
var idx = css.indexOf('@media (max-width: 480px)');
|
||||
if (idx < 0) return '';
|
||||
var depth = 0, start = -1, end = -1;
|
||||
for (var i = idx; i < css.length; i++) {
|
||||
var c = css[i];
|
||||
if (c === '{') { if (depth === 0) start = i; depth++; }
|
||||
else if (c === '}') { depth--; if (depth === 0) { end = i; break; } }
|
||||
}
|
||||
return start > 0 && end > 0 ? css.slice(start, end + 1) : '';
|
||||
})();
|
||||
assert(mobileBlock.length > 0, 'style.css has a @media (max-width: 480px) block');
|
||||
assert(
|
||||
/pkt-byop[^{}]*\{[^}]*display:\s*none/.test(mobileBlock),
|
||||
'mobile @media block hides the BYOP button (chrome compaction)'
|
||||
);
|
||||
// Note: per LOCKED spec, col-details is tier-1 and stays visible at mobile.
|
||||
// It is the col-path / col-hash / col-observer / col-rpt that drop on mobile,
|
||||
// already enforced via data-priority above (TableResponsive.apply).
|
||||
|
||||
// ── 4. renderDetail mobile-priority ordering ────────────────────────────
|
||||
var dlMatch = pktJs.match(/<dl class="detail-meta">([\s\S]*?)<\/dl>/);
|
||||
assert(!!dlMatch, 'renderDetail emits <dl class="detail-meta">');
|
||||
if (dlMatch) {
|
||||
var dlBody = dlMatch[1];
|
||||
var idxType = dlBody.indexOf('Payload Type');
|
||||
var idxObs = dlBody.indexOf('Observer');
|
||||
assert(idxType >= 0, '.detail-meta still includes Payload Type row');
|
||||
assert(idxObs >= 0, '.detail-meta still includes Observer row');
|
||||
assert(
|
||||
idxType >= 0 && idxObs >= 0 && idxType < idxObs,
|
||||
'.detail-meta lists Payload Type BEFORE Observer (mobile-priority order)'
|
||||
);
|
||||
}
|
||||
|
||||
// Wrap hex / breakdown / observations in a collapsible technical section.
|
||||
assert(
|
||||
/<details[^>]*class="detail-technical"/.test(pktJs),
|
||||
'renderDetail wraps technical fields in <details class="detail-technical">'
|
||||
);
|
||||
|
||||
// ── 5. #1458 P0-A — semantic-first detail title ─────────────────────────
|
||||
// Previously the title hard-coded "Packet Byte Breakdown (N bytes)" when
|
||||
// raw_hex was present. Must be replaced by a type-badge + summary header.
|
||||
assert(
|
||||
!/Packet Byte Breakdown/.test(pktJs),
|
||||
'renderDetail no longer leads with "Packet Byte Breakdown (N bytes)" title'
|
||||
);
|
||||
assert(
|
||||
/<div class="detail-title">[\s\S]{0,200}badge badge-\$\{payloadTypeColor/.test(pktJs),
|
||||
'detail-title leads with a type badge (semantic identity first)'
|
||||
);
|
||||
assert(
|
||||
/<div class="detail-srcdst">/.test(pktJs),
|
||||
'renderDetail emits a .detail-srcdst row (src → dst summary)'
|
||||
);
|
||||
|
||||
// ── 6. #1458 P0-B — raw-bytes disclosure copy ───────────────────────────
|
||||
assert(
|
||||
/<summary>Show raw bytes<\/summary>/.test(pktJs),
|
||||
'detail-technical disclosure summary reads "Show raw bytes" (per spec)'
|
||||
);
|
||||
|
||||
// ── 7. #1458 P0-C — mobile filter-zone collapse ─────────────────────────
|
||||
assert(
|
||||
/pkt-filter-expr/.test(pktJs),
|
||||
'always-on filter input wrapper carries class .pkt-filter-expr'
|
||||
);
|
||||
assert(
|
||||
/\.pkt-filter-expr[^{}]*\{[^}]*display:\s*none/.test(mobileBlock),
|
||||
'mobile @media (max-width: 480px) hides .pkt-filter-expr by default'
|
||||
);
|
||||
assert(
|
||||
/\.filter-bar\.filters-expanded[^{}]*\.pkt-filter-expr[^{}]*\{[^}]*display:/.test(mobileBlock) ||
|
||||
/:has\(\.filter-bar\.filters-expanded\)[^{}]*\.pkt-filter-expr[^{}]*\{[^}]*display:/.test(mobileBlock),
|
||||
'expanded filters reveal .pkt-filter-expr on mobile (Filters ▾ toggle)'
|
||||
);
|
||||
|
||||
// ── Summary ──────────────────────────────────────────────────────────────
|
||||
console.log('\n' + passed + ' passed, ' + failed + ' failed');
|
||||
process.exit(failed === 0 ? 0 : 1);
|
||||
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* #1418 — cb-presets.js writes --mc-rt-ramp-0..4 + fires cb-preset-changed.
|
||||
*
|
||||
* `route-view.js` reads CSS vars --mc-rt-ramp-0..4 to color the edge gradient
|
||||
* via getComputedStyle. When the user switches color-blind preset,
|
||||
* applyPreset() must:
|
||||
* 1. Write 5 ramp stops from preset.routeRamp (or fallback viridis).
|
||||
* 2. Fire a cb-preset-changed CustomEvent so route-view.js recolorRoute
|
||||
* can walk .mc-rt-edge / .mc-rt-row / .mc-rt-spark-dot live.
|
||||
*
|
||||
* Pattern mirrors test-issue-1407-cb-preset-propagation.js sandbox shape.
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const vm = require('vm');
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
function assert(cond, msg) {
|
||||
if (cond) { passed++; console.log(' ✓ ' + msg); }
|
||||
else { failed++; console.error(' ✗ ' + msg); }
|
||||
}
|
||||
|
||||
const rolesSrc = fs.readFileSync(path.join(__dirname, 'public', 'roles.js'), 'utf8');
|
||||
const presetsSrc = fs.readFileSync(path.join(__dirname, 'public', 'cb-presets.js'), 'utf8');
|
||||
const routeSrc = fs.readFileSync(path.join(__dirname, 'public', 'route-view.js'), 'utf8');
|
||||
|
||||
console.log('\n=== #1418 ramp A: route-view reads --mc-rt-ramp-* CSS vars ===');
|
||||
assert(/--mc-rt-ramp-/.test(routeSrc),
|
||||
'route-view.js references --mc-rt-ramp-* CSS vars');
|
||||
assert(/cb-preset-changed/.test(routeSrc),
|
||||
'route-view.js listens for cb-preset-changed event');
|
||||
// Selectors recolorRoute touches
|
||||
assert(/mc-rt-edge|mc-rt-spark-dot|mc-rt-row/.test(routeSrc),
|
||||
'recolorRoute touches mc-rt-edge / mc-rt-spark-dot / mc-rt-row classes');
|
||||
|
||||
console.log('\n=== #1418 ramp B: cb-presets writes ramp stops ===');
|
||||
function makeSandbox() {
|
||||
const root = {
|
||||
style: {
|
||||
_vars: {},
|
||||
setProperty(k, v) { this._vars[k] = String(v); },
|
||||
getPropertyValue(k) { return this._vars[k] || ''; },
|
||||
removeProperty(k) { delete this._vars[k]; }
|
||||
},
|
||||
getAttribute() { return null; },
|
||||
setAttribute() {}
|
||||
};
|
||||
const body = {
|
||||
_attrs: {},
|
||||
setAttribute(k, v) { this._attrs[k] = v; },
|
||||
getAttribute(k) { return this._attrs[k] || null; },
|
||||
removeAttribute(k) { delete this._attrs[k]; }
|
||||
};
|
||||
const listeners = {};
|
||||
const storage = {
|
||||
_data: {},
|
||||
getItem(k) { return Object.prototype.hasOwnProperty.call(this._data, k) ? this._data[k] : null; },
|
||||
setItem(k, v) { this._data[k] = String(v); },
|
||||
removeItem(k) { delete this._data[k]; }
|
||||
};
|
||||
const sandbox = {
|
||||
window: null,
|
||||
document: {
|
||||
documentElement: root, body: body, readyState: 'complete',
|
||||
getElementById() { return null; },
|
||||
createElement() {
|
||||
return { _children: [], style: {}, textContent: '', id: '',
|
||||
setAttribute() {}, appendChild(c) { this._children.push(c); } };
|
||||
},
|
||||
head: { appendChild() {} },
|
||||
addEventListener() {}
|
||||
},
|
||||
localStorage: storage,
|
||||
console: console,
|
||||
setTimeout: setTimeout,
|
||||
clearTimeout: clearTimeout,
|
||||
fetch: function () { return Promise.resolve({ ok: false }); },
|
||||
matchMedia: function () { return { matches: false, addEventListener() {}, addListener() {} }; },
|
||||
addEventListener(ev, cb) { (listeners[ev] = listeners[ev] || []).push(cb); },
|
||||
dispatchEvent(ev) { (listeners[ev.type] || []).forEach(function (cb) { cb(ev); }); return true; },
|
||||
CustomEvent: function (type, opts) { this.type = type; this.detail = opts && opts.detail; },
|
||||
Event: function (type) { this.type = type; },
|
||||
getComputedStyle: function () {
|
||||
return { getPropertyValue: function (k) { return root.style._vars[k] || ''; } };
|
||||
}
|
||||
};
|
||||
sandbox.window = sandbox;
|
||||
return { sandbox, root, body, storage, listeners };
|
||||
}
|
||||
|
||||
let env;
|
||||
try {
|
||||
env = makeSandbox();
|
||||
vm.createContext(env.sandbox);
|
||||
vm.runInContext(rolesSrc, env.sandbox);
|
||||
vm.runInContext(presetsSrc, env.sandbox);
|
||||
} catch (e) {
|
||||
assert(false, 'sandbox load failed: ' + e.message);
|
||||
}
|
||||
|
||||
const MCP = env && env.sandbox.window.MeshCorePresets;
|
||||
assert(!!MCP, 'MeshCorePresets exported');
|
||||
|
||||
if (MCP) {
|
||||
console.log('\n --- ramp-stop count for every preset ---');
|
||||
['default', 'deut', 'prot', 'trit', 'achromat'].forEach(function (id) {
|
||||
MCP.applyPreset(id);
|
||||
let stopsSet = 0;
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const v = env.root.style.getPropertyValue('--mc-rt-ramp-' + i);
|
||||
if (/^#[0-9a-f]{6}$/i.test(v)) stopsSet++;
|
||||
}
|
||||
assert(stopsSet === 5,
|
||||
'preset "' + id + '" sets all 5 ramp stops (--mc-rt-ramp-0..4) — got ' + stopsSet);
|
||||
});
|
||||
|
||||
console.log('\n --- preset routeRamp values land in CSS vars ---');
|
||||
MCP.applyPreset('default');
|
||||
const preset0 = MCP.list.find(p => p.id === 'default');
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const expected = preset0.routeRamp[i].toLowerCase();
|
||||
const actual = env.root.style.getPropertyValue('--mc-rt-ramp-' + i).toLowerCase();
|
||||
assert(actual === expected,
|
||||
'default --mc-rt-ramp-' + i + ' = ' + expected + ' (got ' + actual + ')');
|
||||
}
|
||||
|
||||
console.log('\n --- switching preset rewrites all 5 stops ---');
|
||||
MCP.applyPreset('deut');
|
||||
const deut = MCP.list.find(p => p.id === 'deut');
|
||||
let allRewritten = true;
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const actual = env.root.style.getPropertyValue('--mc-rt-ramp-' + i).toLowerCase();
|
||||
if (actual !== deut.routeRamp[i].toLowerCase()) allRewritten = false;
|
||||
}
|
||||
assert(allRewritten, 'switching to deut overwrites every ramp stop');
|
||||
|
||||
console.log('\n --- achromat ramp is luminance (B/W) ---');
|
||||
MCP.applyPreset('achromat');
|
||||
const achr = MCP.list.find(p => p.id === 'achromat');
|
||||
// Achromat ramp is the gray luminance ramp per cb-presets.js line 170.
|
||||
const stop0 = env.root.style.getPropertyValue('--mc-rt-ramp-0').toLowerCase();
|
||||
const stop4 = env.root.style.getPropertyValue('--mc-rt-ramp-4').toLowerCase();
|
||||
assert(stop0 === '#222222', 'achromat ramp[0] === #222222 (got ' + stop0 + ')');
|
||||
assert(stop4 === '#eeeeee', 'achromat ramp[4] === #eeeeee (got ' + stop4 + ')');
|
||||
}
|
||||
|
||||
console.log('\n=== #1418 ramp C: applyPreset fires cb-preset-changed event ===');
|
||||
if (MCP) {
|
||||
let fired = false, detailId = null;
|
||||
env.sandbox.addEventListener('cb-preset-changed', function (ev) {
|
||||
fired = true;
|
||||
detailId = ev.detail && ev.detail.id;
|
||||
});
|
||||
MCP.applyPreset('prot');
|
||||
assert(fired === true, 'cb-preset-changed event fired on applyPreset()');
|
||||
assert(detailId === 'prot', 'event detail.id === applied preset id (got ' + detailId + ')');
|
||||
}
|
||||
|
||||
console.log('\n=== Summary ===');
|
||||
console.log(' passed: ' + passed);
|
||||
console.log(' failed: ' + failed);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* #1418 — map.js loadRouteFromDeepLink:
|
||||
* - Hop resolution priority (server resolved_path > HopResolver > raw).
|
||||
* - GRP_TXT channel hash → name resolution (enc_ placeholder, SHA-256 byte
|
||||
* match for keyed channels, fallback to "channel 0x<HEX>").
|
||||
*
|
||||
* The deep-link loader is a giant async function; we don't run it end-to-end.
|
||||
* Instead we verify:
|
||||
* 1. Source invariants: priority order is unambiguous in code.
|
||||
* 2. Replica of the chosen-path resolution logic, exercised on fixtures.
|
||||
* 3. Replica of the channel-match predicate (the same `find` callback).
|
||||
* 4. Live SubtleCrypto comparison: SHA-256(name)[0] === target byte
|
||||
* reproduced via node's built-in crypto.
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
function assert(cond, msg) {
|
||||
if (cond) { passed++; console.log(' ✓ ' + msg); }
|
||||
else { failed++; console.error(' ✗ ' + msg); }
|
||||
}
|
||||
|
||||
const mapSrc = fs.readFileSync(path.join(__dirname, 'public', 'map.js'), 'utf8');
|
||||
|
||||
console.log('\n=== #1418 hop-priority A: source invariants (3-tier priority) ===');
|
||||
// Priority comment is documented; assert the structural keywords are in order.
|
||||
const priorityBlock = mapSrc.match(/Priority:[\s\S]{0,800}rawHops/);
|
||||
assert(!!priorityBlock,
|
||||
'priority block documented in map.js');
|
||||
if (priorityBlock) {
|
||||
const blk = priorityBlock[0];
|
||||
const iResolved = blk.indexOf('resolved_path');
|
||||
const iHopRes = blk.indexOf('HopResolver');
|
||||
const iRaw = blk.indexOf('raw');
|
||||
assert(iResolved >= 0 && iHopRes >= 0 && iRaw >= 0,
|
||||
'priority block mentions all three: resolved_path, HopResolver, raw');
|
||||
assert(iResolved < iHopRes && iHopRes < iRaw,
|
||||
'priority order in comment: resolved_path → HopResolver → raw');
|
||||
}
|
||||
// Structural code path: resolved_path branch checked first, then HopResolver,
|
||||
// then naked rawHops fallback.
|
||||
assert(/if\s*\(\s*Array\.isArray\(resolvedHops\)[^\)]*\)\s*\{[\s\S]{0,200}\}\s*else if\s*\(\s*window\.HopResolver/.test(mapSrc),
|
||||
'code structure: if (resolvedHops valid) else if (window.HopResolver) else (rawHops)');
|
||||
|
||||
console.log('\n=== #1418 hop-priority B: replica of chosen-path selection ===');
|
||||
|
||||
// Replicate the chooseChosenPath logic exactly. window.HopResolver shim
|
||||
// returns a per-pubkey dict; resolveResult[h] is consulted per raw hop.
|
||||
function chooseChosenPath(rawHops, resolvedHopsRaw, hopResolver) {
|
||||
let resolvedHops = null;
|
||||
try {
|
||||
if (resolvedHopsRaw) {
|
||||
resolvedHops = typeof resolvedHopsRaw === 'string' ? JSON.parse(resolvedHopsRaw) : resolvedHopsRaw;
|
||||
}
|
||||
} catch (_) {}
|
||||
if (Array.isArray(resolvedHops) && resolvedHops.length === rawHops.length) {
|
||||
return rawHops.map((h, i) => resolvedHops[i] || h);
|
||||
}
|
||||
if (hopResolver && typeof hopResolver.resolve === 'function' && rawHops.length) {
|
||||
try {
|
||||
const result = hopResolver.resolve(rawHops);
|
||||
return rawHops.map(h => {
|
||||
const r = result ? result[h] : null;
|
||||
return r && r.pubkey ? r.pubkey : h;
|
||||
});
|
||||
} catch (_) { return rawHops; }
|
||||
}
|
||||
return rawHops;
|
||||
}
|
||||
|
||||
const rawHops = ['AA', 'BB', 'CC'];
|
||||
|
||||
// Tier 1: server resolved_path takes priority over HopResolver
|
||||
const serverResolved = ['AAFULL1', 'BBFULL2', 'CCFULL3'];
|
||||
const naiveResolver = { resolve: () => ({ AA: { pubkey: 'WRONG_A' }, BB: { pubkey: 'WRONG_B' }, CC: { pubkey: 'WRONG_C' }}) };
|
||||
let chosen = chooseChosenPath(rawHops, serverResolved, naiveResolver);
|
||||
assert(JSON.stringify(chosen) === JSON.stringify(serverResolved),
|
||||
'server resolved_path wins over HopResolver (returns ' + JSON.stringify(chosen) + ')');
|
||||
|
||||
// Tier 1 with JSON string input (server returns it stringified sometimes)
|
||||
chosen = chooseChosenPath(rawHops, JSON.stringify(serverResolved), naiveResolver);
|
||||
assert(JSON.stringify(chosen) === JSON.stringify(serverResolved),
|
||||
'server resolved_path accepts JSON-string input (parses it)');
|
||||
|
||||
// Tier 2: no resolved_path → use HopResolver
|
||||
const smartResolver = { resolve: () => ({ AA: { pubkey: 'AAFULL_DIFF' }, BB: { pubkey: 'BBFULL_DIFF' }, CC: { pubkey: 'CCFULL_DIFF' }}) };
|
||||
chosen = chooseChosenPath(rawHops, null, smartResolver);
|
||||
assert(JSON.stringify(chosen) === JSON.stringify(['AAFULL_DIFF', 'BBFULL_DIFF', 'CCFULL_DIFF']),
|
||||
'no resolved_path → HopResolver result used (returns ' + JSON.stringify(chosen) + ')');
|
||||
|
||||
// HopResolver returns different from naive prefix → values change
|
||||
chosen = chooseChosenPath(['AB'], null, { resolve: () => ({ AB: { pubkey: 'ABcorrect123' } }) });
|
||||
assert(chosen[0] === 'ABcorrect123',
|
||||
'HopResolver overrides naive prefix when it returns a longer pubkey');
|
||||
|
||||
// HopResolver throws → fallback to raw
|
||||
chosen = chooseChosenPath(rawHops, null, { resolve: () => { throw new Error('boom'); } });
|
||||
assert(JSON.stringify(chosen) === JSON.stringify(rawHops),
|
||||
'HopResolver throw → fallback to rawHops');
|
||||
|
||||
// Tier 3: no resolved_path, no HopResolver → raw prefixes
|
||||
chosen = chooseChosenPath(rawHops, null, null);
|
||||
assert(JSON.stringify(chosen) === JSON.stringify(rawHops),
|
||||
'no resolved_path AND no HopResolver → raw prefixes returned as-is');
|
||||
|
||||
// Length mismatch: resolved_path is wrong length → falls through to HopResolver
|
||||
chosen = chooseChosenPath(rawHops, ['only_one'], smartResolver);
|
||||
assert(JSON.stringify(chosen) === JSON.stringify(['AAFULL_DIFF', 'BBFULL_DIFF', 'CCFULL_DIFF']),
|
||||
'resolved_path with mismatched length → falls through to HopResolver');
|
||||
|
||||
// Per-element falsy in resolved_path → falls back to raw for THAT index
|
||||
chosen = chooseChosenPath(rawHops, ['AAFULL1', null, 'CCFULL3'], null);
|
||||
assert(JSON.stringify(chosen) === JSON.stringify(['AAFULL1', 'BB', 'CCFULL3']),
|
||||
'per-index null in resolved_path → falls back to raw for that index only');
|
||||
|
||||
console.log('\n=== #1418 channel A: GRP_TXT match predicate (sync part) ===');
|
||||
|
||||
// Replica of the channel-find predicate from loadRouteFromDeepLink.
|
||||
function findChannelSync(chList, wantHex) {
|
||||
const wantUp = String(wantHex).toUpperCase();
|
||||
return chList.find(c => {
|
||||
const ch = String(c.hash || '').toUpperCase();
|
||||
const nm = String(c.name || '').toUpperCase();
|
||||
return ch.startsWith(wantUp) ||
|
||||
ch === 'ENC_' + wantUp ||
|
||||
nm.includes('0X' + wantUp);
|
||||
}) || null;
|
||||
}
|
||||
|
||||
const channels = [
|
||||
{ hash: 'public_full_hash_AB...', name: 'Public' },
|
||||
{ hash: 'enc_77', name: 'Encrypted (0x77)', encrypted: true },
|
||||
{ hash: 'unknown', name: 'channel 0xCD' }
|
||||
];
|
||||
|
||||
// hash starts with target hex
|
||||
let m = findChannelSync([{ hash: 'AB1234', name: 'Test' }], 'AB');
|
||||
assert(m && m.name === 'Test', 'finds channel where hash starts with target hex');
|
||||
|
||||
// enc_<HEX> placeholder
|
||||
m = findChannelSync(channels, '77');
|
||||
assert(m && m.name === 'Encrypted (0x77)',
|
||||
'matches enc_<HEX> placeholder ("enc_77") for encrypted channel');
|
||||
|
||||
// name contains "0x<HEX>"
|
||||
m = findChannelSync(channels, 'CD');
|
||||
assert(m && m.name === 'channel 0xCD',
|
||||
'matches name containing "0x<HEX>" placeholder');
|
||||
|
||||
// Case-insensitive
|
||||
m = findChannelSync([{ hash: 'enc_ff', name: 'lower' }], 'FF');
|
||||
assert(m && m.name === 'lower', 'case-insensitive match on enc_<HEX>');
|
||||
|
||||
// No match → null (caller falls back to "channel 0x<HEX>")
|
||||
m = findChannelSync(channels, 'XX');
|
||||
assert(m === null, 'no match → null (so caller renders "channel 0x<HEX>" fallback)');
|
||||
|
||||
console.log('\n=== #1418 channel B: SHA-256(name)[0] keyed-channel match ===');
|
||||
|
||||
// The async fallback (SubtleCrypto) computes SHA-256(name)[0] and checks
|
||||
// it against the target byte. Reproduce in node and verify the formula
|
||||
// matches the firmware/decoder convention (first byte of SHA-256).
|
||||
function sha256Byte0(name) {
|
||||
const buf = crypto.createHash('sha256').update(name, 'utf8').digest();
|
||||
return buf[0].toString(16).padStart(2, '0').toUpperCase();
|
||||
}
|
||||
|
||||
// Known channel name → its derived byte
|
||||
const wellKnown = ['Public', 'Test Channel', 'mesh-control', 'general'];
|
||||
wellKnown.forEach(name => {
|
||||
const byte = sha256Byte0(name);
|
||||
assert(/^[0-9A-F]{2}$/.test(byte),
|
||||
'SHA-256("' + name + '")[0] = 0x' + byte + ' (valid 2-hex)');
|
||||
});
|
||||
|
||||
// Construct a fixture where we deliberately want to match channel "Public"
|
||||
const target = sha256Byte0('Public');
|
||||
// Simulate the async match loop: walk the channel list, hash each name,
|
||||
// return the one whose first byte === target.
|
||||
function findChannelAsync(chList, wantHex) {
|
||||
const wantUp = String(wantHex).toUpperCase();
|
||||
for (const c of chList) {
|
||||
if (c.encrypted) continue;
|
||||
if (!c.name) continue;
|
||||
if (sha256Byte0(c.name) === wantUp) return c;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const result = findChannelAsync([
|
||||
{ name: 'Public' },
|
||||
{ name: 'Other' },
|
||||
{ name: 'Public', encrypted: true } // would match but encrypted → skipped
|
||||
], target);
|
||||
assert(result && result.name === 'Public' && !result.encrypted,
|
||||
'SHA-256 match: returns first non-encrypted channel whose name SHA-256[0] === target byte');
|
||||
|
||||
// Source invariants: the async block exists in map.js
|
||||
assert(/window\.crypto\.subtle/.test(mapSrc), 'map.js uses window.crypto.subtle for SHA-256 fallback');
|
||||
assert(/'SHA-256'/.test(mapSrc), 'map.js requests SHA-256 specifically');
|
||||
assert(/if\s*\(c\.encrypted\)\s*continue/.test(mapSrc),
|
||||
'async loop skips already-known encrypted/placeholder channels');
|
||||
assert(/byteHex\s*===\s*wantUp/.test(mapSrc),
|
||||
'async loop compares first-byte hex to target (byteHex === wantUp)');
|
||||
|
||||
console.log('\n=== #1418 channel C: fallback label format ===');
|
||||
// When no match found, caller renders "Encrypted (0x<HEX>)" for encrypted,
|
||||
// "channel 0x<HEX>" otherwise. Just guard the literal templates exist.
|
||||
assert(/Encrypted \(0x/.test(mapSrc),
|
||||
'encrypted-channel fallback label "Encrypted (0x..." present in map.js');
|
||||
|
||||
console.log('\n=== Summary ===');
|
||||
console.log(' passed: ' + passed);
|
||||
console.log(' failed: ' + failed);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* #1418 — route-view.js edgeWeight() scales + boundary fix.
|
||||
*
|
||||
* Edge-stroke-width logic (route-view.js `edgeWeight()`):
|
||||
* - Single-path mode → flat 5
|
||||
* - Multi-path interior edge → 3 + ratio*6 (range 3..9)
|
||||
* - Multi-path BOUNDARY edge (origin→hop1 or last-hop→dest) → proxy via
|
||||
* max adjacent edgeCount. Before the recent fix, boundary edges with no
|
||||
* matching prefix returned 1.5 (the floor for unknown interior edges),
|
||||
* visually shrinking origin/dest edges to hairlines.
|
||||
* - Union-of-edges view (in isolatePath/restoreAllPaths) → 2 + ratio*6
|
||||
* (range 2..8).
|
||||
*
|
||||
* Strategy: extract the edgeWeight() function from route-view.js with regex,
|
||||
* eval it into a sandbox seeded with `positions` + `edgeCounts` + `multiPath`
|
||||
* + `totalObservers`, and assert on returns. This exercises the SHIPPING
|
||||
* function — if route-view.js drifts, the test breaks.
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const vm = require('vm');
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
function assert(cond, msg) {
|
||||
if (cond) { passed++; console.log(' ✓ ' + msg); }
|
||||
else { failed++; console.error(' ✗ ' + msg); }
|
||||
}
|
||||
|
||||
const src = fs.readFileSync(path.join(__dirname, 'public', 'route-view.js'), 'utf8');
|
||||
|
||||
console.log('\n=== #1418 edgeWeight A: source invariants ===');
|
||||
assert(/function\s+edgeWeight\s*\(\s*idx\s*\)/.test(src),
|
||||
'edgeWeight(idx) function exists in route-view.js');
|
||||
assert(/if\s*\(!multiPath\)\s+return\s+5/.test(src),
|
||||
'single-path mode returns flat 5');
|
||||
// Boundary fix invariant: an isOriginEdge / isDestEdge code path exists
|
||||
// and computes a proxy from max adjacent count instead of returning 1.5.
|
||||
assert(/isOriginEdge\s*\|\|\s*isDestEdge/.test(src),
|
||||
'boundary-edge branch present (isOriginEdge || isDestEdge)');
|
||||
assert(/3\s*\+\s*bRatio\s*\*\s*6/.test(src),
|
||||
'boundary branch uses 3 + bRatio*6 scale (not 1.5)');
|
||||
assert(/3\s*\+\s*ratio\s*\*\s*6/.test(src),
|
||||
'interior multi-path uses 3 + ratio*6 (range 3..9)');
|
||||
assert(/2\s*\+\s*ratio\s*\*\s*6/.test(src),
|
||||
'union/isolate view uses 2 + ratio*6 (range 2..8)');
|
||||
|
||||
console.log('\n=== #1418 edgeWeight B: extract + exercise the real function ===');
|
||||
|
||||
// Extract the edgeWeight function body verbatim. The function is declared
|
||||
// inside the IIFE; we regex it out and run it in a sandbox with the closure
|
||||
// variables it expects (positions, edgeCounts, multiPath, totalObservers).
|
||||
const fnMatch = src.match(/function\s+edgeWeight\s*\(\s*idx\s*\)\s*\{[\s\S]*?\n {4}\}/);
|
||||
assert(!!fnMatch, 'edgeWeight() function body extracted from route-view.js');
|
||||
|
||||
function runEdgeWeight(positions, edgeCounts, totalObservers, multiPath, idx) {
|
||||
const ctx = { positions, edgeCounts, totalObservers, multiPath };
|
||||
vm.createContext(ctx);
|
||||
vm.runInContext(fnMatch[0] + '; result = edgeWeight(' + idx + ');', ctx);
|
||||
return ctx.result;
|
||||
}
|
||||
|
||||
// --- Single-path mode: always 5 ---
|
||||
const singlePos = [
|
||||
{ pubkey: 'AABB', isOrigin: true },
|
||||
{ pubkey: 'CCDD' },
|
||||
{ pubkey: 'EEFF', isDest: true }
|
||||
];
|
||||
assert(runEdgeWeight(singlePos, {}, 1, false, 0) === 5,
|
||||
'single-path mode: edgeWeight(0) === 5');
|
||||
assert(runEdgeWeight(singlePos, { 'AA→CC': 99 }, 50, false, 1) === 5,
|
||||
'single-path mode: edgeWeight(1) === 5 regardless of edgeCounts');
|
||||
|
||||
// --- Multi-path INTERIOR edge: 3 + ratio*6 ---
|
||||
const mPos = [
|
||||
{ pubkey: 'AABB', isOrigin: true }, // origin
|
||||
{ pubkey: 'CCDD' }, // hop 1 (interior start)
|
||||
{ pubkey: 'EEFF' }, // hop 2 (interior end)
|
||||
{ pubkey: 'GG00', isDest: true } // dest
|
||||
];
|
||||
// Edge 1: CC→EE. edgeCounts has CC→EE: 5 of 10 observers → ratio 0.5
|
||||
// expected = 3 + 0.5*6 = 6
|
||||
let w = runEdgeWeight(mPos, { 'CC→EE': 5 }, 10, true, 1);
|
||||
assert(Math.abs(w - 6) < 0.001,
|
||||
'multi-path interior: ratio 0.5 → weight 6 (got ' + w + ')');
|
||||
// Full coverage: ratio 1.0 → weight 9
|
||||
w = runEdgeWeight(mPos, { 'CC→EE': 10 }, 10, true, 1);
|
||||
assert(Math.abs(w - 9) < 0.001,
|
||||
'multi-path interior: ratio 1.0 → weight 9 (got ' + w + ')');
|
||||
// No matching count: falls through to 1.5 floor
|
||||
w = runEdgeWeight(mPos, { 'XX→YY': 5 }, 10, true, 1);
|
||||
assert(w === 1.5,
|
||||
'multi-path interior: no matching edge → 1.5 hairline floor (got ' + w + ')');
|
||||
|
||||
// --- BOUNDARY edge fix: origin→hop1 ---
|
||||
// idx=0: AA(isOrigin) → CC. edgeCounts has CC→EE: 8 of 10
|
||||
// Boundary proxy: look for edges where a==CC (the next-to-boundary node)
|
||||
// 8/10 → weight = 3 + 0.8*6 = 7.8
|
||||
w = runEdgeWeight(mPos, { 'CC→EE': 8 }, 10, true, 0);
|
||||
assert(Math.abs(w - 7.8) < 0.001,
|
||||
'boundary edge (origin→hop1): proxied by adjacent CC→EE count 8/10 → 7.8 (got ' + w + ')');
|
||||
|
||||
// --- BOUNDARY edge fix: last-hop→dest ---
|
||||
// idx=2: EE → GG(isDest). Look for edges where b==EE (the from-boundary node)
|
||||
// edgeCounts CC→EE: 7 of 10 → 3 + 0.7*6 = 7.2
|
||||
w = runEdgeWeight(mPos, { 'CC→EE': 7 }, 10, true, 2);
|
||||
assert(Math.abs(w - 7.2) < 0.001,
|
||||
'boundary edge (last-hop→dest): proxied by adjacent CC→EE count 7/10 → 7.2 (got ' + w + ')');
|
||||
|
||||
// --- REGRESSION GUARD: boundary edge with NO adjacent edgeCount must NOT
|
||||
// return 1.5 (the old bug). It returns 5 as the documented fallback. ---
|
||||
w = runEdgeWeight(mPos, { 'XX→YY': 5 }, 10, true, 0);
|
||||
assert(w === 5,
|
||||
'boundary edge with no adjacent edgeCount returns 5 (NOT the old 1.5 bug) — got ' + w);
|
||||
w = runEdgeWeight(mPos, { 'XX→YY': 5 }, 10, true, 2);
|
||||
assert(w === 5,
|
||||
'boundary edge (last-hop→dest) with no adjacent count → 5 (NOT 1.5) — got ' + w);
|
||||
|
||||
// --- Multiple matching adjacent edges: use MAX, not sum ---
|
||||
// idx=0: AA(origin)→CC. edgeCounts has CC→EE:3 and CC→FF:7. Max is 7 → 3+0.7*6=7.2
|
||||
w = runEdgeWeight(mPos, { 'CC→EE': 3, 'CC→FF': 7 }, 10, true, 0);
|
||||
assert(Math.abs(w - 7.2) < 0.001,
|
||||
'boundary edge: picks MAX adjacent count (max of 3,7 = 7 → 7.2) — got ' + w);
|
||||
|
||||
console.log('\n=== #1418 edgeWeight C: isolated-path union weight (2 + ratio*6) ===');
|
||||
// The 2+ratio*6 formula is in the isolatePath() block. Source-grep guarantees
|
||||
// its presence. Verify the literal expression is unique (not stripped).
|
||||
const occurrences2 = (src.match(/2\s*\+\s*ratio\s*\*\s*6/g) || []).length;
|
||||
assert(occurrences2 >= 1, 'isolatePath union weight formula (2 + ratio*6) present at least once');
|
||||
|
||||
console.log('\n=== Summary ===');
|
||||
console.log(' passed: ' + passed);
|
||||
console.log(' failed: ' + failed);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* #1418 / PR #1423 polish-review guards.
|
||||
*
|
||||
* Source-grep guards for the polish-review findings addressed on the
|
||||
* route-view feature branch. Each guard pins one finding so future edits
|
||||
* can't silently regress the fix.
|
||||
*
|
||||
* Findings covered (see PR #1423 review comments for full context):
|
||||
* - resize listener leak (carmack/munger)
|
||||
* - 5-staggered-timer fit storm (tufte/doshi)
|
||||
* - empty catch {} swallowing errors (torvalds)
|
||||
* - _detailCache unbounded (carmack) — LRU(50)
|
||||
* - recolorRoute walks document.querySelectorAll (torvalds) — scoped
|
||||
* - deep-link silent failure (doshi) — toast on empty paths
|
||||
* - innerHTML row re-wire factored (dijkstra) — wireRow helper
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
function assert(cond, msg) {
|
||||
if (cond) { passed++; console.log(' ✓ ' + msg); }
|
||||
else { failed++; console.error(' ✗ ' + msg); }
|
||||
}
|
||||
|
||||
const rvSrc = fs.readFileSync(path.join(__dirname, 'public', 'route-view.js'), 'utf8');
|
||||
const mapSrc = fs.readFileSync(path.join(__dirname, 'public', 'map.js'), 'utf8');
|
||||
|
||||
console.log('\n=== A. resize listener leak fix (carmack/munger) ===');
|
||||
// Single resize listener attached via window.__mc_routeResizeRefit stash,
|
||||
// torn down on next render() + on teardownIfNavigatedAway.
|
||||
assert(/window\.__mc_routeResizeRefit/.test(rvSrc),
|
||||
'resize handler stashed on window.__mc_routeResizeRefit for dedupe');
|
||||
assert(/removeEventListener\(['"]resize['"],\s*window\.__mc_routeResizeRefit\)/.test(rvSrc),
|
||||
'prior resize handler removed before attaching new one');
|
||||
// Old buggy pattern (anonymous resize listener with no removal) must be gone.
|
||||
const anonResize = rvSrc.match(/window\.addEventListener\(['"]resize['"]\s*,\s*function/g) || [];
|
||||
assert(anonResize.length === 0,
|
||||
'no anonymous window.resize listeners (all go via __mc_routeResizeRefit) — found ' + anonResize.length);
|
||||
|
||||
console.log('\n=== B. fit-storm collapse to rAF (tufte/doshi) ===');
|
||||
// The 5-staggered (0/300/800/1600/2800) and 3-staggered (0/200/600/1400)
|
||||
// timers MUST be gone. Single requestAnimationFrame is the replacement.
|
||||
const bigFitStorm = /setTimeout\(\s*refit\s*,\s*(?:300|800|1600|2800)\s*\)/.test(rvSrc);
|
||||
assert(!bigFitStorm, 'no setTimeout(refit, 300|800|1600|2800) staggered fit storm');
|
||||
const isoFitStorm = /setTimeout\(\s*doFit\s*,\s*(?:200|600|1400)\s*\)/.test(rvSrc);
|
||||
assert(!isoFitStorm, 'no setTimeout(doFit, 200|600|1400) staggered isolate-fit storm');
|
||||
const restoreFitStorm = /setTimeout\(\s*_restoreFit\s*,\s*(?:200|600|1400)\s*\)/.test(rvSrc);
|
||||
assert(!restoreFitStorm, 'no setTimeout(_restoreFit, 200|600|1400) staggered restore-fit storm');
|
||||
assert(/requestAnimationFrame\(\s*refit\s*\)/.test(rvSrc),
|
||||
'requestAnimationFrame(refit) is the new initial-settle path');
|
||||
assert(/requestAnimationFrame\(\s*doFit\s*\)/.test(rvSrc),
|
||||
'requestAnimationFrame(doFit) replaces isolate-path staggered timers');
|
||||
assert(/new ResizeObserver/.test(rvSrc),
|
||||
'ResizeObserver attached to map container for layout-settle re-fit');
|
||||
|
||||
console.log('\n=== C. ResizeObserver lifecycle (carmack) ===');
|
||||
assert(/window\.__mc_routeResizeObserver/.test(rvSrc),
|
||||
'ResizeObserver stashed on window.__mc_routeResizeObserver for dedupe');
|
||||
assert(/__mc_routeResizeObserver[^;]*\.disconnect\(\)/.test(rvSrc),
|
||||
'ResizeObserver disconnected on render() re-entry + teardown');
|
||||
|
||||
console.log('\n=== D. _detailCache LRU bound (carmack) ===');
|
||||
assert(/_detailCache\s*=\s*new\s+Map\(\)/.test(rvSrc),
|
||||
'_detailCache is a Map (LRU-capable) not a plain object');
|
||||
assert(/DETAIL_CACHE_MAX/.test(rvSrc),
|
||||
'DETAIL_CACHE_MAX constant defined (LRU bound)');
|
||||
assert(/_detailCache\.size\s*>=?\s*DETAIL_CACHE_MAX/.test(rvSrc),
|
||||
'LRU eviction guard checks _detailCache.size against DETAIL_CACHE_MAX');
|
||||
|
||||
console.log('\n=== E. catch {} silent swallow → console.warn (torvalds) ===');
|
||||
// Empty `catch (e) {}` (no body) count should be near zero. A handful may
|
||||
// remain where the catch is genuinely a "best-effort" no-op — but the
|
||||
// review flagged 20+ silent swallows; we should be down to ≤5 after the pass.
|
||||
// Empty `catch (e) {}` (no body) count for full-block catches (e). The
|
||||
// inline `} catch (_) {}` no-op removers are intentional (marker may
|
||||
// already be detached). The review flagged 20+ silent block swallows;
|
||||
// after the pass the remaining ones must be legitimately benign
|
||||
// (localStorage may be disabled, marker may have been removed in a race).
|
||||
const blockEmptyCatches = (rvSrc.match(/\}\s*catch\s*\(\s*e\s*\)\s*\{\s*\}/g) || []).length;
|
||||
assert(blockEmptyCatches <= 8,
|
||||
'block-style silent `} catch (e) {}` reduced to ≤8 (was 20+) — current: ' + blockEmptyCatches);
|
||||
assert(/console\.warn\(['"]\[route-view\]/.test(rvSrc),
|
||||
'at least one [route-view] console.warn breadcrumb present');
|
||||
|
||||
console.log('\n=== F. recolorRoute scoped to sidebar (torvalds) ===');
|
||||
// The walks must be scoped to the active sidebar root, not document-wide.
|
||||
// We allow document.querySelectorAll for `.mc-rt-sidebar` (the tear-down)
|
||||
// but NOT for `.mc-rt-edge` / `.mc-rt-row` / `.mc-rt-spark-dot`.
|
||||
const docEdges = /document\.querySelectorAll\(['"]\.mc-rt-edge['"]\)/.test(rvSrc);
|
||||
assert(!docEdges, 'recolorRoute no longer walks document.querySelectorAll(.mc-rt-edge)');
|
||||
const docRows = /document\.querySelectorAll\(['"]\.mc-rt-row['"]\)/.test(rvSrc);
|
||||
assert(!docRows, 'recolorRoute no longer walks document.querySelectorAll(.mc-rt-row)');
|
||||
|
||||
console.log('\n=== G. deep-link empty-paths toast (doshi) ===');
|
||||
// When allPaths.length === 0, surface a sidebar/console message instead of
|
||||
// silently bailing.
|
||||
assert(/allPaths\.length\s*===\s*0[\s\S]{0,400}(?:console\.warn|alert|toast|showToast|notif)/i.test(mapSrc),
|
||||
'deep-link empty-paths path emits a console.warn / toast (no silent return)');
|
||||
|
||||
console.log('\n=== H. wireRow row-wireup helper (dijkstra) ===');
|
||||
assert(/function\s+wireRow\s*\(\s*row\s*\)/.test(rvSrc),
|
||||
'wireRow(row) helper centralizes row event wiring');
|
||||
assert(/sidebar\._wireRow\s*=\s*wireRow/.test(rvSrc),
|
||||
'wireRow stashed on sidebar so restoreAllPaths can reuse');
|
||||
assert(/newRowEls\.forEach\(\s*sidebar\._wireRow/.test(rvSrc),
|
||||
'restoreAllPaths re-wires rows via sidebar._wireRow (not inline duplicate)');
|
||||
|
||||
console.log('\n=== Summary ===');
|
||||
console.log(' passed: ' + passed);
|
||||
console.log(' failed: ' + failed);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* #1418 — map.js loadRouteFromDeepLink raw_hex byte extraction.
|
||||
*
|
||||
* The deep-link loader peeks at chosen.raw_hex when decoded JSON is empty,
|
||||
* to extract src/destHash and (for GRP_TXT) channel_hash. Wire layout per
|
||||
* cmd/ingestor/decoder.go:
|
||||
* byte0=route+type, byte1=path_len, then path bytes, then ...
|
||||
*
|
||||
* TXT_MSG (type 2): destHash + srcHash bytes after path
|
||||
* RESPONSE (type 1): destHash + srcHash bytes after path
|
||||
* ANON_REQ (type 7): destHash ONLY (no srcHash byte — sender anonymous)
|
||||
* PATH (type 8): destHash + srcHash bytes after path
|
||||
* GRP_TXT (type 5): channel_hash byte after path
|
||||
*
|
||||
* This test asserts behavior by replicating the exact extraction logic
|
||||
* from public/map.js and exercising it on hand-built raw_hex fixtures
|
||||
* built to mirror real wire packets.
|
||||
*
|
||||
* Source invariants (string grep on map.js) also guarded so any code-move
|
||||
* that drops the extraction is caught.
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
function assert(cond, msg) {
|
||||
if (cond) { passed++; console.log(' ✓ ' + msg); }
|
||||
else { failed++; console.error(' ✗ ' + msg); }
|
||||
}
|
||||
|
||||
const mapSrc = fs.readFileSync(path.join(__dirname, 'public', 'map.js'), 'utf8');
|
||||
|
||||
console.log('\n=== #1418 raw_hex A: source invariants in map.js ===');
|
||||
assert(/TYPES_WITH_DST_SRC\s*=\s*\[\s*1\s*,\s*2\s*,\s*7\s*,\s*8\s*\]/.test(mapSrc),
|
||||
'TYPES_WITH_DST_SRC = [1, 2, 7, 8] (RESPONSE, TXT_MSG, ANON_REQ, PATH)');
|
||||
assert(/payload_type\s*!==\s*7/.test(mapSrc),
|
||||
'ANON_REQ (type 7) special-cased to skip srcHash extraction');
|
||||
assert(/payload_type\s*===\s*5/.test(mapSrc),
|
||||
'GRP_TXT (type 5) branch present for channel_hash extraction');
|
||||
assert(/PAYLOAD_TYPE_MAP\s*=\s*\{[^}]*0:\s*'REQ'[^}]*1:\s*'RESPONSE'[^}]*2:\s*'TXT_MSG'/m.test(mapSrc),
|
||||
'PAYLOAD_TYPE_MAP covers 0=REQ, 1=RESPONSE, 2=TXT_MSG');
|
||||
assert(/5:\s*'GRP_TXT'[^}]*7:\s*'ANON_REQ'[^}]*8:\s*'PATH'/m.test(mapSrc),
|
||||
'PAYLOAD_TYPE_MAP covers 5=GRP_TXT, 7=ANON_REQ, 8=PATH');
|
||||
|
||||
// Polish review (djb): pathLen MUST be bounded before slicing. A crafted
|
||||
// pathLen=200 byte would surface random body bytes as srcHash/destHash.
|
||||
// Cap at MeshCore wire max of 64 hops in BOTH the TXT-family branch and
|
||||
// the GRP_TXT channel-hash branch.
|
||||
assert((mapSrc.match(/pathLen[^>]*>\s*64/g) || []).length >= 2,
|
||||
'raw_hex pathLen capped at >64 in both TXT and GRP_TXT branches (#1423 review/djb)');
|
||||
assert(/Number\.isFinite\(pathLen\)/.test(mapSrc),
|
||||
'raw_hex pathLen guarded with Number.isFinite (rejects NaN from non-hex byte)');
|
||||
|
||||
console.log('\n=== #1418 raw_hex B: replica extractor reproduces map.js logic ===');
|
||||
|
||||
// Pure replica of the extractor inside loadRouteFromDeepLink. If map.js's
|
||||
// logic changes, this replica MUST be updated and the diff explained.
|
||||
function extractSrcDst(rawHex, payloadType) {
|
||||
const TYPES = [1, 2, 7, 8];
|
||||
if (TYPES.indexOf(payloadType) < 0) return { src: null, dst: null };
|
||||
try {
|
||||
const pathLen = parseInt(rawHex.slice(2, 4), 16);
|
||||
if (!Number.isFinite(pathLen) || pathLen < 0 || pathLen > 64) {
|
||||
return { src: null, dst: null };
|
||||
}
|
||||
const destOff = 4 + pathLen * 2;
|
||||
if (rawHex.length < destOff + 2) return { src: null, dst: null };
|
||||
const dst = rawHex.slice(destOff, destOff + 2).toUpperCase();
|
||||
let src = null;
|
||||
if (payloadType !== 7 && rawHex.length >= destOff + 4) {
|
||||
src = rawHex.slice(destOff + 2, destOff + 4).toUpperCase();
|
||||
}
|
||||
return { src, dst };
|
||||
} catch (_) { return { src: null, dst: null }; }
|
||||
}
|
||||
|
||||
function extractChannelHash(rawHex, payloadType) {
|
||||
if (payloadType !== 5) return null;
|
||||
try {
|
||||
const pathLen = parseInt(rawHex.slice(2, 4), 16);
|
||||
if (!Number.isFinite(pathLen) || pathLen < 0 || pathLen > 64) return null;
|
||||
const chOff = 4 + pathLen * 2;
|
||||
if (rawHex.length < chOff + 2) return null;
|
||||
return rawHex.slice(chOff, chOff + 2).toUpperCase();
|
||||
} catch (_) { return null; }
|
||||
}
|
||||
|
||||
// Build a hex string: route+type byte, path_len, path bytes, then payload.
|
||||
function build(routeType, pathBytes, payloadBytes) {
|
||||
const lenHex = pathBytes.length.toString(16).padStart(2, '0');
|
||||
return routeType + lenHex + pathBytes.join('') + payloadBytes.join('');
|
||||
}
|
||||
|
||||
// Fixture 1: TXT_MSG (type 2), 2 path hops AB,CD, destHash=42, srcHash=99
|
||||
const txt = build('02', ['AB', 'CD'], ['42', '99', 'FF', 'EE']);
|
||||
let r = extractSrcDst(txt, 2);
|
||||
assert(r.dst === '42' && r.src === '99',
|
||||
'TXT_MSG (type 2) extracts destHash=42, srcHash=99 after 2-hop path (got dst=' + r.dst + ', src=' + r.src + ')');
|
||||
|
||||
// Fixture 2: RESPONSE (type 1), 0-hop path
|
||||
const resp = build('01', [], ['7A', '3C']);
|
||||
r = extractSrcDst(resp, 1);
|
||||
assert(r.dst === '7A' && r.src === '3C',
|
||||
'RESPONSE (type 1) extracts destHash + srcHash on 0-hop path (got dst=' + r.dst + ', src=' + r.src + ')');
|
||||
|
||||
// Fixture 3: ANON_REQ (type 7) — destHash present, srcHash MUST be null
|
||||
const anon = build('07', ['11'], ['DD', 'BB', 'CC']);
|
||||
r = extractSrcDst(anon, 7);
|
||||
assert(r.dst === 'DD', 'ANON_REQ (type 7) extracts destHash=DD');
|
||||
assert(r.src === null, 'ANON_REQ (type 7) MUST NOT extract srcHash (anonymous sender) — got ' + r.src);
|
||||
|
||||
// Fixture 4: PATH (type 8) carries both hashes
|
||||
const pathPkt = build('08', ['AA', 'BB', 'CC'], ['11', '22']);
|
||||
r = extractSrcDst(pathPkt, 8);
|
||||
assert(r.dst === '11' && r.src === '22',
|
||||
'PATH (type 8) extracts destHash + srcHash after 3-hop path (got dst=' + r.dst + ', src=' + r.src + ')');
|
||||
|
||||
// Fixture 5: GRP_TXT (type 5) — channel_hash extraction, NOT src/dst
|
||||
const grp = build('05', ['77'], ['AB', 'XX']);
|
||||
const ch = extractChannelHash(grp, 5);
|
||||
assert(ch === 'AB', 'GRP_TXT (type 5) extracts channel_hash=AB after 1-hop path (got ' + ch + ')');
|
||||
r = extractSrcDst(grp, 5);
|
||||
assert(r.src === null && r.dst === null,
|
||||
'GRP_TXT (type 5) is NOT in TYPES_WITH_DST_SRC — extractor returns nulls');
|
||||
|
||||
// Fixture 6: non-extracting types (REQ=0, ACK=3, ADVERT=4, MULTIPART=10, …)
|
||||
[0, 3, 4, 6, 9, 10, 11, 12].forEach(function (pt) {
|
||||
r = extractSrcDst('00' + '00' + 'FFFF', pt);
|
||||
assert(r.src === null && r.dst === null,
|
||||
'payload_type=' + pt + ' (not in TYPES_WITH_DST_SRC) → no extraction');
|
||||
});
|
||||
|
||||
// Edge case: raw_hex too short (path length claims more bytes than present)
|
||||
r = extractSrcDst('02' + '04' + 'AB', 2); // claims 4-hop path, only 1 byte payload
|
||||
assert(r.src === null && r.dst === null, 'truncated raw_hex → null extraction (no crash)');
|
||||
|
||||
// Polish review (djb): malicious pathLen=200 (0xC8) MUST be rejected even
|
||||
// when the body is long enough to slice. Without the cap, the extractor
|
||||
// would surface random body bytes as src/destHash strings in the UI.
|
||||
const evil = '02' + 'C8' + 'AB'.repeat(500); // pathLen=200, plenty of body to slice
|
||||
r = extractSrcDst(evil, 2);
|
||||
assert(r.src === null && r.dst === null,
|
||||
'malicious pathLen=200 → rejected, no OOB-style byte surfacing');
|
||||
const evilCh = extractChannelHash('05' + 'C8' + 'AB'.repeat(500), 5);
|
||||
assert(evilCh === null, 'malicious pathLen=200 (GRP_TXT) → rejected');
|
||||
// Boundary: pathLen=64 (max) still works; 65 rejected.
|
||||
const okBig = '02' + '40' + 'AB'.repeat(64) + 'EE' + 'FF';
|
||||
r = extractSrcDst(okBig, 2);
|
||||
assert(r.dst === 'EE' && r.src === 'FF', 'pathLen=64 (max allowed) still extracts');
|
||||
const tooBig = '02' + '41' + 'AB'.repeat(65) + 'EE' + 'FF';
|
||||
r = extractSrcDst(tooBig, 2);
|
||||
assert(r.src === null && r.dst === null, 'pathLen=65 → rejected (above wire max of 64)');
|
||||
|
||||
console.log('\n=== #1418 raw_hex C: channel_hash NOT extracted for non-GRP_TXT ===');
|
||||
[0, 1, 2, 3, 4, 6, 7, 8, 9, 10, 11, 12].forEach(function (pt) {
|
||||
const v = extractChannelHash('05' + '00' + 'AB', pt);
|
||||
assert(v === null, 'payload_type=' + pt + ' returns null channel_hash');
|
||||
});
|
||||
|
||||
console.log('\n=== Summary ===');
|
||||
console.log(' passed: ' + passed);
|
||||
console.log(' failed: ' + failed);
|
||||
if (failed > 0) process.exit(1);
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user