Compare commits

..

12 Commits

Author SHA1 Message Date
l5y b4dd72e7eb matrix: listen for synapse on port 41448 (#607)
* matrix: listen for synapse on port 41448

* matrix: address review comments

* matrix: address review comments

* matrix: cover missing unit test vectors

* matrix: cover missing unit test vectors
2026-01-08 15:51:31 +01:00
l5y f5f2e977a1 web: collapse federation map ledgend (#604)
* web: collapse federation map ledgend

* web: cover missing unit test vectors
2026-01-06 17:31:20 +01:00
l5y e9a0dc0d59 web: fix stale node queries (#603) 2026-01-06 16:13:04 +01:00
l5y d75c395514 matrix: move short name to display name (#602)
* matrix: move short name to display name

* matrix: run fmt
2026-01-05 23:24:27 +01:00
l5y b08f951780 ci: update ruby to 4 (#601)
* ci: update ruby to 4

* ci: update dispatch triggers
2026-01-05 23:23:56 +01:00
l5y 955431ac18 web: display traces of last 28 days if available (#599)
* web: display traces of last 28 days if available

* web: address review comments

* web: fix tests

* web: fix tests
2026-01-05 21:22:16 +01:00
l5y 7f40abf92a web: establish menu structure (#597)
* web: establish menu structure

* web: cover missing unit test vectors

* web: fix tests
2026-01-05 21:18:51 +01:00
l5y c157fd481b matrix: fixed the text-message checkpoint regression (#595)
* matrix: fixed the text-message checkpoint regression

* matrix: improve formatting

* matrix: fix tests
2026-01-05 18:20:25 +01:00
l5y a6fc7145bc matrix: cache seen messages by rx_time not id (#594)
* matrix: cache seen messages by rx_time not id

* matrix: fix review comments

* matrix: fix review comments

* matrix: cover missing unit test vectors

* matrix: fix tests
2026-01-05 17:34:54 +01:00
l5y ca05cbb2c5 web: hide the default '0' tab when not active (#593) 2026-01-05 16:26:56 +01:00
l5y 5c79572c4d matrix: fix empty bridge state json (#592)
* matrix: fix empty bridge state json

* matrix: fix tests
2026-01-05 16:11:24 +01:00
l5y 6fd8e5ad12 web: allow certain charts to overflow upper bounds (#585)
* web: allow certain charts to overflow upper bounds

* web: cover missing unit test vectors
2025-12-31 15:15:18 +01:00
30 changed files with 2402 additions and 382 deletions
+1
View File
@@ -20,6 +20,7 @@ on:
pull_request:
branches: [ "main" ]
paths:
- '.github/**'
- 'web/**'
- 'tests/**'
+1
View File
@@ -20,6 +20,7 @@ on:
pull_request:
branches: [ "main" ]
paths:
- '.github/**'
- 'app/**'
- 'tests/**'
+1
View File
@@ -20,6 +20,7 @@ on:
pull_request:
branches: [ "main" ]
paths:
- '.github/**'
- 'data/**'
- 'tests/**'
+2 -1
View File
@@ -20,6 +20,7 @@ on:
pull_request:
branches: [ "main" ]
paths:
- '.github/**'
- 'web/**'
- 'tests/**'
@@ -34,7 +35,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
ruby-version: ['3.3', '3.4']
ruby-version: ['3.4', '4.0']
steps:
- uses: actions/checkout@v5
+2
View File
@@ -140,6 +140,8 @@ services:
- potatomesh-network
depends_on:
- web-bridge
ports:
- "41448:41448"
profiles:
- bridge
+214 -142
View File
@@ -27,24 +27,84 @@ dependencies = [
"serde_json",
]
[[package]]
name = "async-trait"
version = "0.1.89"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "atomic-waker"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
[[package]]
name = "axum"
version = "0.7.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f"
dependencies = [
"async-trait",
"axum-core",
"bytes",
"futures-util",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-util",
"itoa",
"matchit",
"memchr",
"mime",
"percent-encoding",
"pin-project-lite",
"rustversion",
"serde",
"serde_json",
"serde_path_to_error",
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tower",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "axum-core"
version = "0.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199"
dependencies = [
"async-trait",
"bytes",
"futures-util",
"http",
"http-body",
"http-body-util",
"mime",
"pin-project-lite",
"rustversion",
"sync_wrapper",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "base64"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "bitflags"
version = "1.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]]
name = "bitflags"
version = "2.10.0"
@@ -53,9 +113,9 @@ checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3"
[[package]]
name = "bumpalo"
version = "3.19.0"
version = "3.19.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43"
checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510"
[[package]]
name = "bytes"
@@ -65,9 +125,9 @@ checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3"
[[package]]
name = "cc"
version = "1.2.47"
version = "1.2.51"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd405d82c84ff7f35739f175f67d8b9fb7687a0e84ccdc78bd3568839827cf07"
checksum = "7a0aeaff4ff1a90589618835a598e545176939b97874f7abc7851caa0618f203"
dependencies = [
"find-msvc-tools",
"shlex",
@@ -91,7 +151,7 @@ version = "3.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fde0e0ec90c9dfb3b4b1a0891a7dcd0e2bffde2f7efed5fe7c9bb00e5bfb915e"
dependencies = [
"windows-sys 0.52.0",
"windows-sys 0.59.0",
]
[[package]]
@@ -154,9 +214,9 @@ checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
[[package]]
name = "find-msvc-tools"
version = "0.1.5"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844"
checksum = "645cbb3a84e60b7531617d5ae4e57f7e27308f6445f5abf653209ea76dec8dff"
[[package]]
name = "fnv"
@@ -188,21 +248,6 @@ dependencies = [
"percent-encoding",
]
[[package]]
name = "futures"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876"
dependencies = [
"futures-channel",
"futures-core",
"futures-executor",
"futures-io",
"futures-sink",
"futures-task",
"futures-util",
]
[[package]]
name = "futures-channel"
version = "0.3.31"
@@ -210,7 +255,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10"
dependencies = [
"futures-core",
"futures-sink",
]
[[package]]
@@ -230,12 +274,6 @@ dependencies = [
"futures-util",
]
[[package]]
name = "futures-io"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6"
[[package]]
name = "futures-sink"
version = "0.3.31"
@@ -254,12 +292,8 @@ version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
dependencies = [
"futures-channel",
"futures-core",
"futures-io",
"futures-sink",
"futures-task",
"memchr",
"pin-project-lite",
"pin-utils",
"slab",
@@ -294,9 +328,9 @@ dependencies = [
[[package]]
name = "h2"
version = "0.4.12"
version = "0.4.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3c0b69cfcb4e1b9f1bf2f53f95f766e4661169728ec61cd3fe5a0166f2d1386"
checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54"
dependencies = [
"atomic-waker",
"bytes",
@@ -420,9 +454,9 @@ dependencies = [
[[package]]
name = "hyper-util"
version = "0.1.18"
version = "0.1.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52e9a2a24dc5c6821e71a7030e1e14b7b632acac55c40e9d2e082c621261bb56"
checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f"
dependencies = [
"base64",
"bytes",
@@ -492,9 +526,9 @@ checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a"
[[package]]
name = "icu_properties"
version = "2.1.1"
version = "2.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e93fcd3157766c0c8da2f8cff6ce651a31f0810eaa1c51ec363ef790bbb5fb99"
checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec"
dependencies = [
"icu_collections",
"icu_locale_core",
@@ -506,9 +540,9 @@ dependencies = [
[[package]]
name = "icu_properties_data"
version = "2.1.1"
version = "2.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02845b3647bb045f1100ecd6480ff52f34c35f82d9880e029d329c21d1054899"
checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af"
[[package]]
name = "icu_provider"
@@ -548,9 +582,9 @@ dependencies = [
[[package]]
name = "indexmap"
version = "2.12.1"
version = "2.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ad4bb2b565bca0645f4d68c5c9af97fba094e9791da685bf83cb5f3ce74acf2"
checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017"
dependencies = [
"equivalent",
"hashbrown",
@@ -564,9 +598,9 @@ checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130"
[[package]]
name = "iri-string"
version = "0.7.9"
version = "0.7.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4f867b9d1d896b67beb18518eda36fdb77a32ea590de864f1325b294a6d14397"
checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a"
dependencies = [
"memchr",
"serde",
@@ -574,15 +608,15 @@ dependencies = [
[[package]]
name = "itoa"
version = "1.0.15"
version = "1.0.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c"
checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
[[package]]
name = "js-sys"
version = "0.3.82"
version = "0.3.83"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b011eec8cc36da2aab2d5cff675ec18454fad408585853910a202391cf9f8e65"
checksum = "464a3709c7f55f1f721e5389aa6ea4e3bc6aba669353300af094b29ffbdde1d8"
dependencies = [
"once_cell",
"wasm-bindgen",
@@ -596,9 +630,9 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]]
name = "libc"
version = "0.2.177"
version = "0.2.179"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976"
checksum = "c5a2d376baa530d1238d133232d15e239abad80d05838b4b59354e5268af431f"
[[package]]
name = "linux-raw-sys"
@@ -623,9 +657,9 @@ dependencies = [
[[package]]
name = "log"
version = "0.4.28"
version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]]
name = "lru-slab"
@@ -642,6 +676,12 @@ dependencies = [
"regex-automata",
]
[[package]]
name = "matchit"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94"
[[package]]
name = "memchr"
version = "2.7.6"
@@ -656,9 +696,9 @@ checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "mio"
version = "1.1.0"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69d83b0086dc8ecf3ce9ae2874b2d1290252e2a30720bea58a5c6639b0092873"
checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc"
dependencies = [
"libc",
"wasi",
@@ -667,20 +707,21 @@ dependencies = [
[[package]]
name = "mockito"
version = "1.7.0"
version = "1.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7760e0e418d9b7e5777c0374009ca4c93861b9066f18cb334a20ce50ab63aa48"
checksum = "7e0603425789b4a70fcc4ac4f5a46a566c116ee3e2a6b768dc623f7719c611de"
dependencies = [
"assert-json-diff",
"bytes",
"colored",
"futures-util",
"futures-core",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-util",
"log",
"pin-project-lite",
"rand",
"regex",
"serde_json",
@@ -727,7 +768,7 @@ version = "0.10.75"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328"
dependencies = [
"bitflags 2.10.0",
"bitflags",
"cfg-if",
"foreign-types",
"libc",
@@ -817,6 +858,7 @@ name = "potatomesh-matrix-bridge"
version = "0.5.9"
dependencies = [
"anyhow",
"axum",
"mockito",
"reqwest",
"serde",
@@ -825,6 +867,7 @@ dependencies = [
"tempfile",
"tokio",
"toml",
"tower",
"tracing",
"tracing-subscriber",
"urlencoding",
@@ -850,9 +893,9 @@ dependencies = [
[[package]]
name = "proc-macro2"
version = "1.0.103"
version = "1.0.105"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8"
checksum = "535d180e0ecab6268a3e718bb9fd44db66bbbc256257165fc699dadf70d16fe7"
dependencies = [
"unicode-ident",
]
@@ -914,9 +957,9 @@ dependencies = [
[[package]]
name = "quote"
version = "1.0.42"
version = "1.0.43"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f"
checksum = "dc74d9a594b72ae6656596548f56f667211f8a97b3d4c3d467150794690dc40a"
dependencies = [
"proc-macro2",
]
@@ -962,7 +1005,7 @@ version = "0.5.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
dependencies = [
"bitflags 2.10.0",
"bitflags",
]
[[package]]
@@ -996,9 +1039,9 @@ checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58"
[[package]]
name = "reqwest"
version = "0.12.24"
version = "0.12.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d0946410b9f7b082a427e4ef5c8ff541a88b357bc6c637c40db3a68ac70a36f"
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [
"base64",
"bytes",
@@ -1060,11 +1103,11 @@ checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
[[package]]
name = "rustix"
version = "1.1.2"
version = "1.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e"
checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34"
dependencies = [
"bitflags 2.10.0",
"bitflags",
"errno",
"libc",
"linux-raw-sys",
@@ -1073,9 +1116,9 @@ dependencies = [
[[package]]
name = "rustls"
version = "0.23.35"
version = "0.23.36"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "533f54bc6a7d4f647e46ad909549eda97bf5afc1585190ef692b4286b198bd8f"
checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b"
dependencies = [
"once_cell",
"ring",
@@ -1087,9 +1130,9 @@ dependencies = [
[[package]]
name = "rustls-pki-types"
version = "1.13.0"
version = "1.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94182ad936a0c91c324cd46c6511b9510ed16af436d7b5bab34beab0afd55f7a"
checksum = "21e6f2ab2928ca4291b86736a8bd920a277a399bba1589409d72154ff87c1282"
dependencies = [
"web-time",
"zeroize",
@@ -1114,9 +1157,9 @@ checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "ryu"
version = "1.0.20"
version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f"
checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984"
[[package]]
name = "scc"
@@ -1154,7 +1197,7 @@ version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
dependencies = [
"bitflags 2.10.0",
"bitflags",
"core-foundation",
"core-foundation-sys",
"libc",
@@ -1203,22 +1246,33 @@ dependencies = [
[[package]]
name = "serde_json"
version = "1.0.145"
version = "1.0.149"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c"
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
dependencies = [
"itoa",
"memchr",
"ryu",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "serde_path_to_error"
version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
dependencies = [
"itoa",
"serde",
"serde_core",
]
[[package]]
name = "serde_spanned"
version = "1.0.3"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e24345aa0fe688594e73770a5f6d1b216508b4f93484c0026d521acd30134392"
checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776"
dependencies = [
"serde_core",
]
@@ -1237,11 +1291,12 @@ dependencies = [
[[package]]
name = "serial_test"
version = "3.2.0"
version = "3.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b258109f244e1d6891bf1053a55d63a5cd4f8f4c30cf9a1280989f80e7a1fa9"
checksum = "0d0b343e184fc3b7bb44dff0705fffcf4b3756ba6aff420dddd8b24ca145e555"
dependencies = [
"futures",
"futures-executor",
"futures-util",
"log",
"once_cell",
"parking_lot",
@@ -1251,9 +1306,9 @@ dependencies = [
[[package]]
name = "serial_test_derive"
version = "3.2.0"
version = "3.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d69265a08751de7844521fd15003ae0a888e035773ba05695c5c759a6f89eef"
checksum = "6f50427f258fb77356e4cd4aa0e87e2bd2c66dbcee41dc405282cae2bfc26c83"
dependencies = [
"proc-macro2",
"quote",
@@ -1317,9 +1372,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "syn"
version = "2.0.111"
version = "2.0.114"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87"
checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a"
dependencies = [
"proc-macro2",
"quote",
@@ -1348,20 +1403,20 @@ dependencies = [
[[package]]
name = "system-configuration"
version = "0.5.1"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7"
checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b"
dependencies = [
"bitflags 1.3.2",
"bitflags",
"core-foundation",
"system-configuration-sys",
]
[[package]]
name = "system-configuration-sys"
version = "0.5.0"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9"
checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
dependencies = [
"core-foundation-sys",
"libc",
@@ -1369,9 +1424,9 @@ dependencies = [
[[package]]
name = "tempfile"
version = "3.23.0"
version = "3.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16"
checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c"
dependencies = [
"fastrand",
"getrandom 0.3.4",
@@ -1436,9 +1491,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "tokio"
version = "1.48.0"
version = "1.49.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408"
checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86"
dependencies = [
"bytes",
"libc",
@@ -1483,9 +1538,9 @@ dependencies = [
[[package]]
name = "tokio-util"
version = "0.7.17"
version = "0.7.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2efa149fe76073d6e8fd97ef4f4eca7b67f599660115591483572e406e165594"
checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"
dependencies = [
"bytes",
"futures-core",
@@ -1496,9 +1551,9 @@ dependencies = [
[[package]]
name = "toml"
version = "0.9.8"
version = "0.9.10+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0dc8b1fb61449e27716ec0e1bdf0f6b8f3e8f6b05391e8497b8b6d7804ea6d8"
checksum = "0825052159284a1a8b4d6c0c86cbc801f2da5afd2b225fa548c72f2e74002f48"
dependencies = [
"indexmap",
"serde_core",
@@ -1511,27 +1566,27 @@ dependencies = [
[[package]]
name = "toml_datetime"
version = "0.7.3"
version = "0.7.5+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2cdb639ebbc97961c51720f858597f7f24c4fc295327923af55b74c3c724533"
checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347"
dependencies = [
"serde_core",
]
[[package]]
name = "toml_parser"
version = "1.0.4"
version = "1.0.6+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0cbe268d35bdb4bb5a56a2de88d0ad0eb70af5384a99d648cd4b3d04039800e"
checksum = "a3198b4b0a8e11f09dd03e133c0280504d0801269e9afa46362ffde1cbeebf44"
dependencies = [
"winnow",
]
[[package]]
name = "toml_writer"
version = "1.0.4"
version = "1.0.6+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df8b2b54733674ad286d16267dcfc7a71ed5c776e4ac7aa3c3e2561f7c637bf2"
checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607"
[[package]]
name = "tower"
@@ -1546,15 +1601,16 @@ dependencies = [
"tokio",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "tower-http"
version = "0.6.7"
version = "0.6.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9cf146f99d442e8e68e585f5d798ccd3cad9a7835b917e09728880a862706456"
checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8"
dependencies = [
"bitflags 2.10.0",
"bitflags",
"bytes",
"futures-util",
"http",
@@ -1580,10 +1636,11 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
[[package]]
name = "tracing"
version = "0.1.41"
version = "0.1.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [
"log",
"pin-project-lite",
"tracing-attributes",
"tracing-core",
@@ -1602,9 +1659,9 @@ dependencies = [
[[package]]
name = "tracing-core"
version = "0.1.35"
version = "0.1.36"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a04e24fab5c89c6a36eb8558c9656f30d81de51dfa4d3b45f26b21d61fa0a6c"
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
dependencies = [
"once_cell",
"valuable",
@@ -1623,9 +1680,9 @@ dependencies = [
[[package]]
name = "tracing-subscriber"
version = "0.3.20"
version = "0.3.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5"
checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e"
dependencies = [
"matchers",
"nu-ansi-term",
@@ -1659,9 +1716,9 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "url"
version = "2.5.7"
version = "2.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b"
checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
dependencies = [
"form_urlencoded",
"idna",
@@ -1719,9 +1776,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen"
version = "0.2.105"
version = "0.2.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da95793dfc411fbbd93f5be7715b0578ec61fe87cb1a42b12eb625caa5c5ea60"
checksum = "0d759f433fa64a2d763d1340820e46e111a7a5ab75f993d1852d70b03dbb80fd"
dependencies = [
"cfg-if",
"once_cell",
@@ -1732,9 +1789,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-futures"
version = "0.4.55"
version = "0.4.56"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "551f88106c6d5e7ccc7cd9a16f312dd3b5d36ea8b4954304657d5dfba115d4a0"
checksum = "836d9622d604feee9e5de25ac10e3ea5f2d65b41eac0d9ce72eb5deae707ce7c"
dependencies = [
"cfg-if",
"js-sys",
@@ -1745,9 +1802,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.105"
version = "0.2.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04264334509e04a7bf8690f2384ef5265f05143a4bff3889ab7a3269adab59c2"
checksum = "48cb0d2638f8baedbc542ed444afc0644a29166f1595371af4fecf8ce1e7eeb3"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
@@ -1755,9 +1812,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.105"
version = "0.2.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "420bc339d9f322e562942d52e115d57e950d12d88983a14c79b86859ee6c7ebc"
checksum = "cefb59d5cd5f92d9dcf80e4683949f15ca4b511f4ac0a6e14d4e1ac60c6ecd40"
dependencies = [
"bumpalo",
"proc-macro2",
@@ -1768,18 +1825,18 @@ dependencies = [
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.105"
version = "0.2.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76f218a38c84bcb33c25ec7059b07847d465ce0e0a76b995e134a45adcb6af76"
checksum = "cbc538057e648b67f72a982e708d485b2efa771e1ac05fec311f9f63e5800db4"
dependencies = [
"unicode-ident",
]
[[package]]
name = "web-sys"
version = "0.3.82"
version = "0.3.83"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a1f95c0d03a47f4ae1f7a64643a6bb97465d9b740f0fa8f90ea33915c99a9a1"
checksum = "9b32828d774c412041098d182a8b38b16ea816958e07cf40eec2bc080ae137ac"
dependencies = [
"js-sys",
"wasm-bindgen",
@@ -1797,9 +1854,9 @@ dependencies = [
[[package]]
name = "webpki-roots"
version = "1.0.4"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2878ef029c47c6e8cf779119f20fcf52bde7ad42a731b2a304bc221df17571e"
checksum = "12bed680863276c63889429bfd6cab3b99943659923822de1c8a39c49e4d722c"
dependencies = [
"rustls-pki-types",
]
@@ -1848,6 +1905,15 @@ dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-sys"
version = "0.59.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-sys"
version = "0.60.2"
@@ -2038,18 +2104,18 @@ dependencies = [
[[package]]
name = "zerocopy"
version = "0.8.30"
version = "0.8.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ea879c944afe8a2b25fef16bb4ba234f47c694565e97383b36f3a878219065c"
checksum = "668f5168d10b9ee831de31933dc111a459c97ec93225beb307aed970d1372dfd"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.30"
version = "0.8.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf955aa904d6040f70dc8e9384444cb1030aed272ba3cb09bbc4ab9e7c1f34f5"
checksum = "2c7962b26b0a8685668b671ee4b54d007a67d4eaf05fda79ac0ecf41e32270f1"
dependencies = [
"proc-macro2",
"quote",
@@ -2115,3 +2181,9 @@ dependencies = [
"quote",
"syn",
]
[[package]]
name = "zmij"
version = "1.0.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2fc5a66a20078bf1251bde995aa2fdcc4b800c70b5d92dd2c62abc5c60f679f8"
+2
View File
@@ -27,8 +27,10 @@ anyhow = "1"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] }
urlencoding = "2"
axum = { version = "0.7", features = ["json"] }
[dev-dependencies]
tempfile = "3"
mockito = "1"
serial_test = "3"
tower = "0.5"
+2 -1
View File
@@ -9,6 +9,8 @@ poll_interval_secs = 60
homeserver = "https://matrix.dod.ngo"
# Appservice access token (from your registration.yaml)
as_token = "INVALID_TOKEN_NOT_WORKING"
# Homeserver token used to authenticate Synapse callbacks
hs_token = "INVALID_TOKEN_NOT_WORKING"
# Server name (domain) part of Matrix user IDs
server_name = "dod.ngo"
# Room ID to send into (must be joined by the appservice / puppets)
@@ -17,4 +19,3 @@ room_id = "!sXabOBXbVObAlZQEUs:c-base.org" # "#potato-bridge:c-base.org"
[state]
# Where to persist last seen message id (optional but recommended)
state_file = "bridge_state.json"
+2
View File
@@ -37,6 +37,8 @@ COPY --from=builder /app/target/release/potatomesh-matrix-bridge /usr/local/bin/
COPY matrix/Config.toml /app/Config.example.toml
COPY matrix/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
EXPOSE 41448
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
+6 -2
View File
@@ -134,7 +134,7 @@ A minimal example sketch (you **must** adjust URLs, secrets, namespaces):
```yaml
id: potatomesh-bridge
url: "http://your-bridge-host:8080" # not used by this bridge if it only calls out
url: "http://your-bridge-host:41448"
as_token: "YOUR_APPSERVICE_AS_TOKEN"
hs_token: "SECRET_HS_TOKEN"
sender_localpart: "potatomesh-bridge"
@@ -145,10 +145,12 @@ namespaces:
regex: "@potato_[0-9a-f]{8}:example.org"
```
For this bridge, only the `as_token` and `namespaces.users` actually matter. The bridge does not accept inbound events; it only uses the `as_token` to call the homeserver.
This bridge listens for Synapse appservice callbacks on port `41448` so it can log inbound transaction payloads. It still only forwards messages one way (PotatoMesh → Matrix), so inbound Matrix events are acknowledged but not bridged. The `as_token` and `namespaces.users` entries remain required for outbound calls, and the `url` should point at the listener.
In Synapses `homeserver.yaml`, add the registration file under `app_service_config_files`, restart, and invite a puppet user to your target room (or use room ID directly).
The bridge validates inbound appservice callbacks by comparing the `access_token` query param to `hs_token` in `Config.toml`, so keep those values in sync.
---
## Build
@@ -182,6 +184,7 @@ Provide your config at `/app/Config.toml` and persist the bridge state file by m
```bash
docker run --rm \
-p 41448:41448 \
-v bridge_state:/app \
-v "$(pwd)/matrix/Config.toml:/app/Config.toml:ro" \
potatomesh-matrix-bridge
@@ -191,6 +194,7 @@ If you prefer to isolate the state file from the config, mount it directly inste
```bash
docker run --rm \
-p 41448:41448 \
-v bridge_state:/app \
-v "$(pwd)/matrix/Config.toml:/app/Config.toml:ro" \
potatomesh-matrix-bridge
+5
View File
@@ -25,6 +25,7 @@ pub struct PotatomeshConfig {
pub struct MatrixConfig {
pub homeserver: String,
pub as_token: String,
pub hs_token: String,
pub server_name: String,
pub room_id: String,
}
@@ -73,6 +74,7 @@ mod tests {
[matrix]
homeserver = "https://matrix.example.org"
as_token = "AS_TOKEN"
hs_token = "HS_TOKEN"
server_name = "example.org"
room_id = "!roomid:example.org"
@@ -86,6 +88,7 @@ mod tests {
assert_eq!(cfg.matrix.homeserver, "https://matrix.example.org");
assert_eq!(cfg.matrix.as_token, "AS_TOKEN");
assert_eq!(cfg.matrix.hs_token, "HS_TOKEN");
assert_eq!(cfg.matrix.server_name, "example.org");
assert_eq!(cfg.matrix.room_id, "!roomid:example.org");
@@ -108,6 +111,7 @@ mod tests {
[matrix]
homeserver = "https://matrix.example.org"
as_token = "AS_TOKEN"
hs_token = "HS_TOKEN"
server_name = "example.org"
room_id = "!roomid:example.org"
@@ -140,6 +144,7 @@ mod tests {
[matrix]
homeserver = "https://matrix.example.org"
as_token = "AS_TOKEN"
hs_token = "HS_TOKEN"
server_name = "example.org"
room_id = "!roomid:example.org"
+396 -116
View File
@@ -14,21 +14,35 @@
mod config;
mod matrix;
mod matrix_server;
mod potatomesh;
use std::{fs, path::Path};
use std::{fs, net::SocketAddr, path::Path};
use anyhow::Result;
use tokio::time::{sleep, Duration};
use tokio::time::Duration;
use tracing::{error, info};
#[cfg(not(test))]
use crate::config::Config;
use crate::matrix::MatrixAppserviceClient;
use crate::potatomesh::{FetchParams, PotatoClient, PotatoMessage};
use crate::matrix_server::run_synapse_listener;
use crate::potatomesh::{FetchParams, PotatoClient, PotatoMessage, PotatoNode};
#[cfg(not(test))]
use tokio::time::sleep;
#[derive(Debug, serde::Serialize, serde::Deserialize, Default)]
pub struct BridgeState {
/// Highest message id processed by the bridge.
last_message_id: Option<u64>,
/// Highest rx_time observed; used to build incremental fetch queries.
#[serde(default)]
last_rx_time: Option<u64>,
/// Message ids seen at the current last_rx_time for de-duplication.
#[serde(default)]
last_rx_time_ids: Vec<u64>,
/// Legacy checkpoint timestamp used before last_rx_time was added.
#[serde(default, skip_serializing)]
last_checked_at: Option<u64>,
}
@@ -38,7 +52,15 @@ impl BridgeState {
return Ok(Self::default());
}
let data = fs::read_to_string(path)?;
let s: Self = serde_json::from_str(&data)?;
// Treat empty/whitespace-only files as a fresh state.
if data.trim().is_empty() {
return Ok(Self::default());
}
let mut s: Self = serde_json::from_str(&data)?;
if s.last_rx_time.is_none() {
s.last_rx_time = s.last_checked_at;
}
s.last_checked_at = None;
Ok(s)
}
@@ -49,17 +71,32 @@ impl BridgeState {
}
fn should_forward(&self, msg: &PotatoMessage) -> bool {
match self.last_message_id {
None => true,
Some(last) => msg.id > last,
match self.last_rx_time {
None => match self.last_message_id {
None => true,
Some(last_id) => msg.id > last_id,
},
Some(last_ts) => {
if msg.rx_time > last_ts {
true
} else if msg.rx_time < last_ts {
false
} else {
!self.last_rx_time_ids.contains(&msg.id)
}
}
}
}
fn update_with(&mut self, msg: &PotatoMessage) {
self.last_message_id = Some(match self.last_message_id {
None => msg.id,
Some(last) => last.max(msg.id),
});
self.last_message_id = Some(msg.id);
if self.last_rx_time.is_none() || Some(msg.rx_time) > self.last_rx_time {
self.last_rx_time = Some(msg.rx_time);
self.last_rx_time_ids = vec![msg.id];
} else if Some(msg.rx_time) == self.last_rx_time && !self.last_rx_time_ids.contains(&msg.id)
{
self.last_rx_time_ids.push(msg.id);
}
}
}
@@ -69,7 +106,7 @@ fn build_fetch_params(state: &BridgeState) -> FetchParams {
limit: None,
since: None,
}
} else if let Some(ts) = state.last_checked_at {
} else if let Some(ts) = state.last_rx_time {
FetchParams {
limit: None,
since: Some(ts),
@@ -82,17 +119,16 @@ fn build_fetch_params(state: &BridgeState) -> FetchParams {
}
}
fn update_checkpoint(state: &mut BridgeState, delivered_all: bool, now_secs: u64) -> bool {
if !delivered_all {
return false;
/// Persist the bridge state and log any write errors.
fn persist_state(state: &BridgeState, state_path: &str) {
if let Err(e) = state.save(state_path) {
error!("Error saving state: {:?}", e);
}
}
if state.last_message_id.is_some() {
state.last_checked_at = Some(now_secs);
true
} else {
false
}
/// Emit an info log for the latest bridge state snapshot.
fn log_state_update(state: &BridgeState) {
info!("Updated state: {:?}", state);
}
async fn poll_once(
@@ -100,16 +136,13 @@ async fn poll_once(
matrix: &MatrixAppserviceClient,
state: &mut BridgeState,
state_path: &str,
now_secs: u64,
) {
let params = build_fetch_params(state);
match potato.fetch_messages(params).await {
Ok(mut msgs) => {
// sort by id ascending so we process in order
msgs.sort_by_key(|m| m.id);
let mut delivered_all = true;
// sort by rx_time so we process by actual receipt time
msgs.sort_by_key(|m| m.rx_time);
for msg in &msgs {
if !state.should_forward(msg) {
@@ -120,27 +153,19 @@ async fn poll_once(
if let Some(port) = &msg.portnum {
if port != "TEXT_MESSAGE_APP" {
state.update_with(msg);
log_state_update(state);
persist_state(state, state_path);
continue;
}
}
if let Err(e) = handle_message(potato, matrix, state, msg).await {
error!("Error handling message {}: {:?}", msg.id, e);
delivered_all = false;
continue;
}
// persist after each processed message
if let Err(e) = state.save(state_path) {
error!("Error saving state: {:?}", e);
}
}
// Only advance checkpoint after successful delivery and a known last_message_id.
if update_checkpoint(state, delivered_all, now_secs) {
if let Err(e) = state.save(state_path) {
error!("Error saving state: {:?}", e);
}
persist_state(state, state_path);
}
}
Err(e) => {
@@ -149,6 +174,15 @@ async fn poll_once(
}
}
fn spawn_synapse_listener(addr: SocketAddr, token: String) -> tokio::task::JoinHandle<()> {
tokio::spawn(async move {
if let Err(e) = run_synapse_listener(addr, token).await {
error!("Synapse listener failed: {:?}", e);
}
})
}
#[cfg(not(test))]
#[tokio::main]
async fn main() -> Result<()> {
// Logging: RUST_LOG=info,bridge=debug,reqwest=warn ...
@@ -169,6 +203,10 @@ async fn main() -> Result<()> {
let matrix = MatrixAppserviceClient::new(http.clone(), cfg.matrix.clone());
matrix.health_check().await?;
let synapse_addr = SocketAddr::from(([0, 0, 0, 0], 41448));
let synapse_token = cfg.matrix.hs_token.clone();
let _synapse_handle = spawn_synapse_listener(synapse_addr, synapse_token);
let state_path = &cfg.state.state_file;
let mut state = BridgeState::load(state_path)?;
info!("Loaded state: {:?}", state);
@@ -176,12 +214,7 @@ async fn main() -> Result<()> {
let poll_interval = Duration::from_secs(cfg.potatomesh.poll_interval_secs);
loop {
let now_secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
poll_once(&potato, &matrix, &mut state, state_path, now_secs).await;
poll_once(&potato, &matrix, &mut state, state_path).await;
sleep(poll_interval).await;
}
@@ -199,38 +232,79 @@ async fn handle_message(
// Ensure puppet exists & has display name
matrix.ensure_user_registered(&localpart).await?;
matrix.set_display_name(&user_id, &node.long_name).await?;
matrix.ensure_user_joined_room(&user_id).await?;
let display_name = display_name_for_node(&node);
matrix.set_display_name(&user_id, &display_name).await?;
// Format the bridged message
let short = node
.short_name
.clone()
.unwrap_or_else(|| node.long_name.clone());
let body = format!(
"[{short}] {text}\n({from_id}{to_id}, {rssi}, {snr}, {chan}/{preset})",
short = short,
text = msg.text,
from_id = msg.from_id,
to_id = msg.to_id,
rssi = msg
.rssi
.map(|v| format!("RSSI {v} dB"))
.unwrap_or_else(|| "RSSI n/a".to_string()),
snr = msg
.snr
.map(|v| format!("SNR {v} dB"))
.unwrap_or_else(|| "SNR n/a".to_string()),
chan = msg.channel_name,
preset = msg.modem_preset,
let preset_short = modem_preset_short(&msg.modem_preset);
let prefix = format!(
"[{freq}][{preset_short}][{channel}]",
freq = msg.lora_freq,
preset_short = preset_short,
channel = msg.channel_name,
);
let (body, formatted_body) = format_message_bodies(&prefix, &msg.text);
matrix.send_text_message_as(&user_id, &body).await?;
matrix
.send_formatted_message_as(&user_id, &body, &formatted_body)
.await?;
info!("Bridged message: {:?}", msg);
state.update_with(msg);
log_state_update(state);
Ok(())
}
/// Build a compact modem preset label like "LF" for "LongFast".
fn modem_preset_short(preset: &str) -> String {
let letters: String = preset
.chars()
.filter(|ch| ch.is_ascii_uppercase())
.collect();
if letters.is_empty() {
preset.chars().take(2).collect()
} else {
letters
}
}
/// Build plain text + HTML message bodies with inline-code metadata.
fn format_message_bodies(prefix: &str, text: &str) -> (String, String) {
let body = format!("`{}` {}", prefix, text);
let formatted_body = format!("<code>{}</code> {}", escape_html(prefix), escape_html(text));
(body, formatted_body)
}
/// Build the Matrix display name from a node's long/short names.
fn display_name_for_node(node: &PotatoNode) -> String {
match node
.short_name
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
{
Some(short) if short != node.long_name => format!("{} ({})", node.long_name, short),
_ => node.long_name.clone(),
}
}
/// Minimal HTML escaping for Matrix formatted_body payloads.
fn escape_html(input: &str) -> String {
let mut escaped = String::with_capacity(input.len());
for ch in input.chars() {
match ch {
'&' => escaped.push_str("&amp;"),
'<' => escaped.push_str("&lt;"),
'>' => escaped.push_str("&gt;"),
'"' => escaped.push_str("&quot;"),
'\'' => escaped.push_str("&#39;"),
_ => escaped.push(ch),
}
}
escaped
}
#[cfg(test)]
mod tests {
use super::*;
@@ -259,6 +333,54 @@ mod tests {
}
}
fn sample_node(short_name: Option<&str>, long_name: &str) -> PotatoNode {
PotatoNode {
node_id: "!abcd1234".to_string(),
short_name: short_name.map(str::to_string),
long_name: long_name.to_string(),
role: None,
hw_model: None,
last_heard: None,
first_heard: None,
latitude: None,
longitude: None,
altitude: None,
}
}
#[test]
fn modem_preset_short_handles_camelcase() {
assert_eq!(modem_preset_short("LongFast"), "LF");
assert_eq!(modem_preset_short("MediumFast"), "MF");
}
#[test]
fn format_message_bodies_escape_html() {
let (body, formatted) = format_message_bodies("[868][LF]", "Hello <&>");
assert_eq!(body, "`[868][LF]` Hello <&>");
assert_eq!(formatted, "<code>[868][LF]</code> Hello &lt;&amp;&gt;");
}
#[test]
fn escape_html_escapes_quotes() {
assert_eq!(escape_html("a\"b'c"), "a&quot;b&#39;c");
}
#[test]
fn display_name_for_node_includes_short_when_present() {
let node = sample_node(Some("TN"), "Test Node");
assert_eq!(display_name_for_node(&node), "Test Node (TN)");
}
#[test]
fn display_name_for_node_ignores_empty_or_duplicate_short() {
let empty_short = sample_node(Some(""), "Test Node");
assert_eq!(display_name_for_node(&empty_short), "Test Node");
let duplicate_short = sample_node(Some("Test Node"), "Test Node");
assert_eq!(display_name_for_node(&duplicate_short), "Test Node");
}
#[test]
fn bridge_state_initially_forwards_all() {
let state = BridgeState::default();
@@ -268,39 +390,72 @@ mod tests {
}
#[test]
fn bridge_state_tracks_highest_id_and_skips_older() {
fn bridge_state_tracks_latest_rx_time_and_skips_older() {
let mut state = BridgeState::default();
let m1 = sample_msg(10);
let m2 = sample_msg(20);
let m3 = sample_msg(15);
let m1 = PotatoMessage { rx_time: 10, ..m1 };
let m2 = PotatoMessage { rx_time: 20, ..m2 };
let m3 = PotatoMessage { rx_time: 15, ..m3 };
// First message, should forward
assert!(state.should_forward(&m1));
state.update_with(&m1);
assert_eq!(state.last_message_id, Some(10));
assert_eq!(state.last_rx_time, Some(10));
// Second message, higher id, should forward
assert!(state.should_forward(&m2));
state.update_with(&m2);
assert_eq!(state.last_message_id, Some(20));
assert_eq!(state.last_rx_time, Some(20));
// Third message, lower than last, should NOT forward
assert!(!state.should_forward(&m3));
// state remains unchanged
assert_eq!(state.last_message_id, Some(20));
assert_eq!(state.last_rx_time, Some(20));
}
#[test]
fn bridge_state_update_is_monotonic() {
let mut state = BridgeState {
last_message_id: Some(50),
fn bridge_state_uses_legacy_id_filter_when_rx_time_missing() {
let state = BridgeState {
last_message_id: Some(10),
last_rx_time: None,
last_rx_time_ids: vec![],
last_checked_at: None,
};
let m = sample_msg(40);
let older = sample_msg(9);
let newer = sample_msg(11);
state.update_with(&m); // id is lower than current
// last_message_id must stay at 50
assert_eq!(state.last_message_id, Some(50));
assert!(!state.should_forward(&older));
assert!(state.should_forward(&newer));
}
#[test]
fn bridge_state_dedupes_same_timestamp() {
let mut state = BridgeState::default();
let m1 = PotatoMessage {
rx_time: 100,
..sample_msg(10)
};
let m2 = PotatoMessage {
rx_time: 100,
..sample_msg(9)
};
let dup = PotatoMessage {
rx_time: 100,
..sample_msg(10)
};
assert!(state.should_forward(&m1));
state.update_with(&m1);
assert!(state.should_forward(&m2));
state.update_with(&m2);
assert!(!state.should_forward(&dup));
assert_eq!(state.last_rx_time, Some(100));
assert_eq!(state.last_rx_time_ids, vec![10, 9]);
}
#[test]
@@ -311,13 +466,17 @@ mod tests {
let state = BridgeState {
last_message_id: Some(12345),
last_checked_at: Some(99),
last_rx_time: Some(99),
last_rx_time_ids: vec![123],
last_checked_at: Some(77),
};
state.save(path_str).unwrap();
let loaded_state = BridgeState::load(path_str).unwrap();
assert_eq!(loaded_state.last_message_id, Some(12345));
assert_eq!(loaded_state.last_checked_at, Some(99));
assert_eq!(loaded_state.last_rx_time, Some(99));
assert_eq!(loaded_state.last_rx_time_ids, vec![123]);
assert_eq!(loaded_state.last_checked_at, None);
}
#[test]
@@ -328,50 +487,50 @@ mod tests {
let state = BridgeState::load(path_str).unwrap();
assert_eq!(state.last_message_id, None);
assert_eq!(state.last_rx_time, None);
assert!(state.last_rx_time_ids.is_empty());
}
#[test]
fn bridge_state_load_empty_file() {
let tmp_dir = tempfile::tempdir().unwrap();
let file_path = tmp_dir.path().join("empty.json");
let path_str = file_path.to_str().unwrap();
fs::write(path_str, "").unwrap();
let state = BridgeState::load(path_str).unwrap();
assert_eq!(state.last_message_id, None);
assert_eq!(state.last_rx_time, None);
assert!(state.last_rx_time_ids.is_empty());
assert_eq!(state.last_checked_at, None);
}
#[test]
fn update_checkpoint_requires_last_message_id() {
let mut state = BridgeState {
last_message_id: None,
last_checked_at: Some(10),
};
fn bridge_state_migrates_legacy_checkpoint() {
let tmp_dir = tempfile::tempdir().unwrap();
let file_path = tmp_dir.path().join("legacy_state.json");
let path_str = file_path.to_str().unwrap();
let saved = update_checkpoint(&mut state, true, 123);
assert!(!saved);
assert_eq!(state.last_checked_at, Some(10));
}
fs::write(
path_str,
r#"{"last_message_id":42,"last_checked_at":1710000000}"#,
)
.unwrap();
#[test]
fn update_checkpoint_skips_when_not_delivered() {
let mut state = BridgeState {
last_message_id: Some(5),
last_checked_at: Some(10),
};
let saved = update_checkpoint(&mut state, false, 123);
assert!(!saved);
assert_eq!(state.last_checked_at, Some(10));
}
#[test]
fn update_checkpoint_sets_when_safe() {
let mut state = BridgeState {
last_message_id: Some(5),
last_checked_at: None,
};
let saved = update_checkpoint(&mut state, true, 123);
assert!(saved);
assert_eq!(state.last_checked_at, Some(123));
let state = BridgeState::load(path_str).unwrap();
assert_eq!(state.last_message_id, Some(42));
assert_eq!(state.last_rx_time, Some(1_710_000_000));
assert!(state.last_rx_time_ids.is_empty());
}
#[test]
fn fetch_params_respects_missing_last_message_id() {
let state = BridgeState {
last_message_id: None,
last_checked_at: Some(123),
last_rx_time: Some(123),
last_rx_time_ids: vec![],
last_checked_at: None,
};
let params = build_fetch_params(&state);
@@ -383,7 +542,9 @@ mod tests {
fn fetch_params_uses_since_when_safe() {
let state = BridgeState {
last_message_id: Some(1),
last_checked_at: Some(123),
last_rx_time: Some(123),
last_rx_time_ids: vec![],
last_checked_at: None,
};
let params = build_fetch_params(&state);
@@ -395,6 +556,8 @@ mod tests {
fn fetch_params_defaults_to_small_window() {
let state = BridgeState {
last_message_id: Some(1),
last_rx_time: None,
last_rx_time_ids: vec![],
last_checked_at: None,
};
@@ -403,8 +566,59 @@ mod tests {
assert_eq!(params.since, None);
}
#[test]
fn log_state_update_emits_info() {
let state = BridgeState::default();
log_state_update(&state);
}
#[test]
fn persist_state_writes_file() {
let tmp_dir = tempfile::tempdir().unwrap();
let file_path = tmp_dir.path().join("state.json");
let path_str = file_path.to_str().unwrap();
let state = BridgeState {
last_message_id: Some(42),
last_rx_time: Some(123),
last_rx_time_ids: vec![42],
last_checked_at: None,
};
persist_state(&state, path_str);
let loaded = BridgeState::load(path_str).unwrap();
assert_eq!(loaded.last_message_id, Some(42));
}
#[test]
fn persist_state_logs_on_error() {
let tmp_dir = tempfile::tempdir().unwrap();
let dir_path = tmp_dir.path().to_str().unwrap();
let state = BridgeState::default();
// Writing to a directory path should trigger the error branch.
persist_state(&state, dir_path);
}
#[tokio::test]
async fn poll_once_persists_checkpoint_without_messages() {
async fn spawn_synapse_listener_starts_task() {
let addr = SocketAddr::from(([127, 0, 0, 1], 0));
let handle = spawn_synapse_listener(addr, "HS_TOKEN".to_string());
tokio::time::sleep(Duration::from_millis(10)).await;
handle.abort();
}
#[tokio::test]
async fn spawn_synapse_listener_logs_error_on_bind_failure() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let handle = spawn_synapse_listener(addr, "HS_TOKEN".to_string());
let _ = handle.await;
}
#[tokio::test]
async fn poll_once_leaves_state_unchanged_without_messages() {
let tmp_dir = tempfile::tempdir().unwrap();
let state_path = tmp_dir.path().join("state.json");
let state_str = state_path.to_str().unwrap();
@@ -426,6 +640,7 @@ mod tests {
let matrix_cfg = MatrixConfig {
homeserver: server.url(),
as_token: "AS_TOKEN".to_string(),
hs_token: "HS_TOKEN".to_string(),
server_name: "example.org".to_string(),
room_id: "!roomid:example.org".to_string(),
};
@@ -435,18 +650,63 @@ mod tests {
let mut state = BridgeState {
last_message_id: Some(1),
last_rx_time: Some(100),
last_rx_time_ids: vec![1],
last_checked_at: None,
};
poll_once(&potato, &matrix, &mut state, state_str, 123).await;
poll_once(&potato, &matrix, &mut state, state_str).await;
mock_msgs.assert();
// Should have advanced checkpoint and saved it.
assert_eq!(state.last_checked_at, Some(123));
// No new data means state remains unchanged and is not persisted.
assert_eq!(state.last_rx_time, Some(100));
assert_eq!(state.last_rx_time_ids, vec![1]);
assert!(!state_path.exists());
}
#[tokio::test]
async fn poll_once_persists_state_for_non_text_messages() {
let tmp_dir = tempfile::tempdir().unwrap();
let state_path = tmp_dir.path().join("state.json");
let state_str = state_path.to_str().unwrap();
let mut server = mockito::Server::new_async().await;
let mock_msgs = server
.mock("GET", "/api/messages")
.match_query(mockito::Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"[{"id":1,"rx_time":100,"rx_iso":"2025-11-27T00:00:00Z","from_id":"!abcd1234","to_id":"^all","channel":1,"portnum":"POSITION_APP","text":"","rssi":-100,"hop_limit":1,"lora_freq":868,"modem_preset":"MediumFast","channel_name":"TEST","snr":0.0,"node_id":"!abcd1234"}]"#,
)
.create();
let http_client = reqwest::Client::new();
let potatomesh_cfg = PotatomeshConfig {
base_url: server.url(),
poll_interval_secs: 1,
};
let matrix_cfg = MatrixConfig {
homeserver: server.url(),
as_token: "AS_TOKEN".to_string(),
hs_token: "HS_TOKEN".to_string(),
server_name: "example.org".to_string(),
room_id: "!roomid:example.org".to_string(),
};
let potato = PotatoClient::new(http_client.clone(), potatomesh_cfg);
let matrix = MatrixAppserviceClient::new(http_client, matrix_cfg);
let mut state = BridgeState::default();
poll_once(&potato, &matrix, &mut state, state_str).await;
mock_msgs.assert();
assert!(state_path.exists());
let loaded = BridgeState::load(state_str).unwrap();
assert_eq!(loaded.last_checked_at, Some(123));
assert_eq!(loaded.last_message_id, Some(1));
assert_eq!(loaded.last_rx_time, Some(100));
assert_eq!(loaded.last_rx_time_ids, vec![1]);
}
#[tokio::test]
@@ -460,6 +720,7 @@ mod tests {
let matrix_cfg = MatrixConfig {
homeserver: server.url(),
as_token: "AS_TOKEN".to_string(),
hs_token: "HS_TOKEN".to_string(),
server_name: "example.org".to_string(),
room_id: "!roomid:example.org".to_string(),
};
@@ -467,6 +728,8 @@ mod tests {
let node_id = "abcd1234";
let user_id = format!("@potato_{}:{}", node_id, matrix_cfg.server_name);
let encoded_user = urlencoding::encode(&user_id);
let room_id = matrix_cfg.room_id.clone();
let encoded_room = urlencoding::encode(&room_id);
let mock_get_node = server
.mock("GET", "/api/nodes/abcd1234")
@@ -481,19 +744,29 @@ mod tests {
.with_status(200)
.create();
let mock_join = server
.mock(
"POST",
format!("/_matrix/client/v3/rooms/{}/join", encoded_room).as_str(),
)
.match_query(format!("user_id={}&access_token=AS_TOKEN", encoded_user).as_str())
.with_status(200)
.create();
let mock_display_name = server
.mock(
"PUT",
format!("/_matrix/client/v3/profile/{}/displayname", encoded_user).as_str(),
)
.match_query(format!("user_id={}&access_token=AS_TOKEN", encoded_user).as_str())
.match_body(mockito::Matcher::PartialJson(serde_json::json!({
"displayname": "Test Node (TN)"
})))
.with_status(200)
.create();
let http_client = reqwest::Client::new();
let matrix_client = MatrixAppserviceClient::new(http_client.clone(), matrix_cfg);
let room_id = &matrix_client.cfg.room_id;
let encoded_room = urlencoding::encode(room_id);
let txn_id = matrix_client
.txn_counter
.load(std::sync::atomic::Ordering::SeqCst);
@@ -508,6 +781,12 @@ mod tests {
.as_str(),
)
.match_query(format!("user_id={}&access_token=AS_TOKEN", encoded_user).as_str())
.match_body(mockito::Matcher::PartialJson(serde_json::json!({
"msgtype": "m.text",
"body": "`[868][MF][TEST]` Ping",
"format": "org.matrix.custom.html",
"formatted_body": "<code>[868][MF][TEST]</code> Ping",
})))
.with_status(200)
.create();
@@ -520,6 +799,7 @@ mod tests {
assert!(result.is_ok());
mock_get_node.assert();
mock_register.assert();
mock_join.assert();
mock_display_name.assert();
mock_send.assert();
+90 -25
View File
@@ -134,12 +134,50 @@ impl MatrixAppserviceClient {
}
}
/// Send a plain text message into the configured room as puppet user_id.
pub async fn send_text_message_as(&self, user_id: &str, body_text: &str) -> anyhow::Result<()> {
/// Ensure the puppet user is joined to the configured room.
pub async fn ensure_user_joined_room(&self, user_id: &str) -> anyhow::Result<()> {
#[derive(Serialize)]
struct JoinReq {}
let encoded_room = urlencoding::encode(&self.cfg.room_id);
let encoded_user = urlencoding::encode(user_id);
let url = format!(
"{}/_matrix/client/v3/rooms/{}/join?user_id={}&{}",
self.cfg.homeserver,
encoded_room,
encoded_user,
self.auth_query()
);
let resp = self.http.post(&url).json(&JoinReq {}).send().await?;
if resp.status().is_success() {
Ok(())
} else {
let status = resp.status();
let body_snip = resp.text().await.unwrap_or_default();
Err(anyhow::anyhow!(
"Matrix join failed for {} in {} with status {} ({})",
user_id,
self.cfg.room_id,
status,
body_snip
))
}
}
/// Send a text message with HTML formatting into the configured room as puppet user_id.
pub async fn send_formatted_message_as(
&self,
user_id: &str,
body_text: &str,
formatted_body: &str,
) -> anyhow::Result<()> {
#[derive(Serialize)]
struct MsgContent<'a> {
msgtype: &'a str,
body: &'a str,
format: &'a str,
formatted_body: &'a str,
}
let txn_id = self.txn_counter.fetch_add(1, Ordering::SeqCst);
@@ -158,24 +196,23 @@ impl MatrixAppserviceClient {
let content = MsgContent {
msgtype: "m.text",
body: body_text,
format: "org.matrix.custom.html",
formatted_body,
};
let resp = self.http.put(&url).json(&content).send().await?;
if !resp.status().is_success() {
let status = resp.status();
// optional: pull a short body snippet for debugging
let body_snip = resp.text().await.unwrap_or_default();
// Log for observability
tracing::warn!(
"Failed to send message as {}: status {}, body: {}",
"Failed to send formatted message as {}: status {}, body: {}",
user_id,
status,
body_snip
);
// Propagate an error so callers know this message was NOT delivered
return Err(anyhow::anyhow!(
"Matrix send failed for {} with status {}",
user_id,
@@ -195,6 +232,7 @@ mod tests {
MatrixConfig {
homeserver: "https://matrix.example.org".to_string(),
as_token: "AS_TOKEN".to_string(),
hs_token: "HS_TOKEN".to_string(),
server_name: "example.org".to_string(),
room_id: "!roomid:example.org".to_string(),
}
@@ -358,40 +396,59 @@ mod tests {
}
#[tokio::test]
async fn test_send_text_message_as_success() {
async fn test_ensure_user_joined_room_success() {
let mut server = mockito::Server::new_async().await;
let user_id = "@test:example.org";
let room_id = "!roomid:example.org";
let encoded_user = urlencoding::encode(user_id);
let encoded_room = urlencoding::encode(room_id);
let client = {
let mut cfg = dummy_cfg();
cfg.homeserver = server.url();
cfg.room_id = room_id.to_string();
MatrixAppserviceClient::new(reqwest::Client::new(), cfg)
};
let txn_id = client.txn_counter.load(Ordering::SeqCst);
let query = format!("user_id={}&access_token=AS_TOKEN", encoded_user);
let path = format!(
"/_matrix/client/v3/rooms/{}/send/m.room.message/{}",
encoded_room, txn_id
);
let path = format!("/_matrix/client/v3/rooms/{}/join", encoded_room);
let mock = server
.mock("PUT", path.as_str())
.mock("POST", path.as_str())
.match_query(query.as_str())
.with_status(200)
.create();
let result = client.send_text_message_as(user_id, "hello").await;
let mut cfg = dummy_cfg();
cfg.homeserver = server.url();
cfg.room_id = room_id.to_string();
let client = MatrixAppserviceClient::new(reqwest::Client::new(), cfg);
let result = client.ensure_user_joined_room(user_id).await;
mock.assert();
assert!(result.is_ok());
}
#[tokio::test]
async fn test_send_text_message_as_fail() {
async fn test_ensure_user_joined_room_fail() {
let mut server = mockito::Server::new_async().await;
let user_id = "@test:example.org";
let room_id = "!roomid:example.org";
let encoded_user = urlencoding::encode(user_id);
let encoded_room = urlencoding::encode(room_id);
let query = format!("user_id={}&access_token=AS_TOKEN", encoded_user);
let path = format!("/_matrix/client/v3/rooms/{}/join", encoded_room);
let mock = server
.mock("POST", path.as_str())
.match_query(query.as_str())
.with_status(403)
.create();
let mut cfg = dummy_cfg();
cfg.homeserver = server.url();
cfg.room_id = room_id.to_string();
let client = MatrixAppserviceClient::new(reqwest::Client::new(), cfg);
let result = client.ensure_user_joined_room(user_id).await;
mock.assert();
assert!(result.is_err());
}
#[tokio::test]
async fn test_send_formatted_message_as_success() {
let mut server = mockito::Server::new_async().await;
let user_id = "@test:example.org";
let room_id = "!roomid:example.org";
@@ -414,12 +471,20 @@ mod tests {
let mock = server
.mock("PUT", path.as_str())
.match_query(query.as_str())
.with_status(500)
.match_body(mockito::Matcher::PartialJson(serde_json::json!({
"msgtype": "m.text",
"body": "`[meta]` hello",
"format": "org.matrix.custom.html",
"formatted_body": "<code>[meta]</code> hello",
})))
.with_status(200)
.create();
let result = client.send_text_message_as(user_id, "hello").await;
let result = client
.send_formatted_message_as(user_id, "`[meta]` hello", "<code>[meta]</code> hello")
.await;
mock.assert();
assert!(result.is_err());
assert!(result.is_ok());
}
}
+195
View File
@@ -0,0 +1,195 @@
// Copyright © 2025-26 l5yth & contributors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use axum::{
extract::{Path, Query, State},
http::StatusCode,
response::IntoResponse,
routing::put,
Json, Router,
};
use serde_json::Value;
use std::net::SocketAddr;
use tracing::info;
#[derive(Clone)]
struct SynapseState {
hs_token: String,
}
#[derive(serde::Deserialize)]
struct AuthQuery {
access_token: Option<String>,
}
/// Captures inbound Synapse transaction payloads for logging.
#[derive(Debug)]
struct SynapseResponse {
txn_id: String,
payload: Value,
}
/// Build the router that handles Synapse appservice transactions.
fn build_router(state: SynapseState) -> Router {
Router::new()
.route(
"/_matrix/appservice/v1/transactions/:txn_id",
put(handle_transaction),
)
.with_state(state)
}
/// Handle inbound transaction callbacks from Synapse.
async fn handle_transaction(
Path(txn_id): Path<String>,
State(state): State<SynapseState>,
Query(auth): Query<AuthQuery>,
Json(payload): Json<Value>,
) -> impl IntoResponse {
let token_matches = auth
.access_token
.as_ref()
.is_some_and(|token| token == &state.hs_token);
if !token_matches {
return (StatusCode::UNAUTHORIZED, Json(serde_json::json!({})));
}
let response = SynapseResponse { txn_id, payload };
info!(
"Status response: SynapseResponse {{ txn_id: {}, payload: {:?} }}",
response.txn_id, response.payload
);
(StatusCode::OK, Json(serde_json::json!({})))
}
/// Listen for Synapse callbacks on the configured address.
pub async fn run_synapse_listener(addr: SocketAddr, hs_token: String) -> anyhow::Result<()> {
let app = build_router(SynapseState { hs_token });
let listener = tokio::net::TcpListener::bind(addr).await?;
info!("Synapse listener bound on {}", addr);
axum::serve(listener, app).await?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use axum::body::Body;
use axum::http::Request;
use tokio::time::{sleep, Duration};
use tower::ServiceExt;
#[tokio::test]
async fn transactions_endpoint_accepts_payloads() {
let app = build_router(SynapseState {
hs_token: "HS_TOKEN".to_string(),
});
let payload = serde_json::json!({
"events": [],
"txn_id": "123"
});
let response = app
.oneshot(
Request::builder()
.method("PUT")
.uri("/_matrix/appservice/v1/transactions/123?access_token=HS_TOKEN")
.header("content-type", "application/json")
.body(Body::from(payload.to_string()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
assert_eq!(body.as_ref(), b"{}");
}
#[tokio::test]
async fn transactions_endpoint_rejects_missing_token() {
let app = build_router(SynapseState {
hs_token: "HS_TOKEN".to_string(),
});
let payload = serde_json::json!({
"events": [],
"txn_id": "123"
});
let response = app
.oneshot(
Request::builder()
.method("PUT")
.uri("/_matrix/appservice/v1/transactions/123")
.header("content-type", "application/json")
.body(Body::from(payload.to_string()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
assert_eq!(body.as_ref(), b"{}");
}
#[tokio::test]
async fn transactions_endpoint_rejects_wrong_token() {
let app = build_router(SynapseState {
hs_token: "HS_TOKEN".to_string(),
});
let payload = serde_json::json!({
"events": [],
"txn_id": "123"
});
let response = app
.oneshot(
Request::builder()
.method("PUT")
.uri("/_matrix/appservice/v1/transactions/123?access_token=NOPE")
.header("content-type", "application/json")
.body(Body::from(payload.to_string()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
assert_eq!(body.as_ref(), b"{}");
}
#[tokio::test]
async fn run_synapse_listener_starts_and_can_abort() {
let addr = SocketAddr::from(([127, 0, 0, 1], 0));
let handle =
tokio::spawn(async move { run_synapse_listener(addr, "HS_TOKEN".to_string()).await });
sleep(Duration::from_millis(10)).await;
handle.abort();
}
#[tokio::test]
async fn run_synapse_listener_returns_error_on_bind_failure() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let result = run_synapse_listener(addr, "HS_TOKEN".to_string()).await;
assert!(result.is_err());
}
}
+13 -9
View File
@@ -213,7 +213,7 @@ module PotatoMesh
#
# @param limit [Integer] maximum number of rows to return.
# @param node_ref [String, Integer, nil] optional node reference to narrow results.
# @param since [Integer] unix timestamp threshold applied in addition to the rolling window.
# @param since [Integer] unix timestamp threshold applied in addition to the rolling window for collections.
# @return [Array<Hash>] compacted node rows suitable for API responses.
def query_nodes(limit, node_ref: nil, since: 0)
limit = coerce_query_limit(limit)
@@ -221,7 +221,8 @@ module PotatoMesh
db.results_as_hash = true
now = Time.now.to_i
min_last_heard = now - PotatoMesh::Config.week_seconds
since_threshold = normalize_since_threshold(since, floor: min_last_heard)
since_floor = node_ref ? 0 : min_last_heard
since_threshold = normalize_since_threshold(since, floor: since_floor)
params = []
where_clauses = []
@@ -283,7 +284,7 @@ module PotatoMesh
# Fetch ingestor heartbeats with optional freshness filtering.
#
# @param limit [Integer] maximum number of ingestors to return.
# @param since [Integer] unix timestamp threshold applied in addition to the rolling window.
# @param since [Integer] unix timestamp threshold applied in addition to the rolling window for collections.
# @return [Array<Hash>] compacted ingestor rows suitable for API responses.
def query_ingestors(limit, since: 0)
limit = coerce_query_limit(limit)
@@ -422,7 +423,8 @@ module PotatoMesh
where_clauses = []
now = Time.now.to_i
min_rx_time = now - PotatoMesh::Config.week_seconds
since_threshold = normalize_since_threshold(since, floor: min_rx_time)
since_floor = node_ref ? 0 : min_rx_time
since_threshold = normalize_since_threshold(since, floor: since_floor)
where_clauses << "COALESCE(rx_time, position_time, 0) >= ?"
params << since_threshold
@@ -470,7 +472,7 @@ module PotatoMesh
#
# @param limit [Integer] maximum number of rows to return.
# @param node_ref [String, Integer, nil] optional node reference to scope results.
# @param since [Integer] unix timestamp threshold applied in addition to the rolling window.
# @param since [Integer] unix timestamp threshold applied in addition to the rolling window for collections.
# @return [Array<Hash>] compacted neighbor rows suitable for API responses.
def query_neighbors(limit, node_ref: nil, since: 0)
limit = coerce_query_limit(limit)
@@ -480,7 +482,8 @@ module PotatoMesh
where_clauses = []
now = Time.now.to_i
min_rx_time = now - PotatoMesh::Config.week_seconds
since_threshold = normalize_since_threshold(since, floor: min_rx_time)
since_floor = node_ref ? 0 : min_rx_time
since_threshold = normalize_since_threshold(since, floor: since_floor)
where_clauses << "COALESCE(rx_time, 0) >= ?"
params << since_threshold
@@ -517,7 +520,7 @@ module PotatoMesh
#
# @param limit [Integer] maximum number of rows to return.
# @param node_ref [String, Integer, nil] optional node reference to scope results.
# @param since [Integer] unix timestamp threshold applied in addition to the rolling window.
# @param since [Integer] unix timestamp threshold applied in addition to the rolling window for collections.
# @return [Array<Hash>] compacted telemetry rows suitable for API responses.
def query_telemetry(limit, node_ref: nil, since: 0)
limit = coerce_query_limit(limit)
@@ -527,7 +530,8 @@ module PotatoMesh
where_clauses = []
now = Time.now.to_i
min_rx_time = now - PotatoMesh::Config.week_seconds
since_threshold = normalize_since_threshold(since, floor: min_rx_time)
since_floor = node_ref ? 0 : min_rx_time
since_threshold = normalize_since_threshold(since, floor: since_floor)
where_clauses << "COALESCE(rx_time, telemetry_time, 0) >= ?"
params << since_threshold
@@ -734,7 +738,7 @@ module PotatoMesh
params = []
where_clauses = []
now = Time.now.to_i
min_rx_time = now - PotatoMesh::Config.week_seconds
min_rx_time = now - PotatoMesh::Config.trace_neighbor_window_seconds
since_threshold = normalize_since_threshold(since, floor: min_rx_time)
where_clauses << "COALESCE(rx_time, 0) >= ?"
params << since_threshold
+7
View File
@@ -158,6 +158,13 @@ module PotatoMesh
7 * 24 * 60 * 60
end
# Rolling retention window in seconds for trace and neighbor API queries.
#
# @return [Integer] seconds in twenty-eight days.
def trace_neighbor_window_seconds
28 * 24 * 60 * 60
end
# Default upper bound for accepted JSON payload sizes.
#
# @return [Integer] byte ceiling for HTTP request bodies.
@@ -113,11 +113,9 @@ test('buildChatTabModel returns sorted nodes and channel buckets', () => {
assert.deepEqual(secondaryChannel.entries.map(entry => entry.message.id), ['iso-ts', 'recent-alt']);
});
test('buildChatTabModel always includes channel zero bucket', () => {
test('buildChatTabModel skips channel buckets when there are no messages', () => {
const model = buildChatTabModel({ nodes: [], messages: [], nowSeconds: NOW, windowSeconds: WINDOW });
assert.equal(model.channels.length, 1);
assert.equal(model.channels[0].index, 0);
assert.equal(model.channels[0].entries.length, 0);
assert.equal(model.channels.length, 0);
});
test('buildChatTabModel falls back to numeric label when no metadata provided', () => {
@@ -27,6 +27,9 @@ test('federation map centers on configured coordinates and follows theme filters
const mapEl = createElement('div', 'map');
registerElement('map', mapEl);
const mapPanel = createElement('div', 'mapPanel');
mapPanel.dataset.legendCollapsed = 'true';
registerElement('mapPanel', mapPanel);
const statusEl = createElement('div', 'status');
registerElement('status', statusEl);
const tableEl = createElement('table', 'instances');
@@ -408,15 +411,192 @@ test('federation table sorting, contact rendering, and legend creation', async (
assert.deepEqual(mapSetViewCalls[0], [[0, 0], 3]);
assert.equal(mapFitBoundsCalls[0][0].length, 3);
assert.equal(legendContainers.length, 1);
const legend = legendContainers[0];
assert.ok(legend.className.includes('legend'));
assert.equal(legendContainers.length, 2);
const legend = legendContainers.find(container => container.className.includes('legend--instances'));
assert.ok(legend);
assert.ok(legend.className.includes('legend-hidden'));
const legendHeader = legend.children.find(child => child.className === 'legend-header');
const legendTitle = legendHeader && Array.isArray(legendHeader.children)
? legendHeader.children.find(child => child.className === 'legend-title')
: null;
assert.ok(legendTitle);
assert.equal(legendTitle.textContent, 'Active nodes');
const legendToggle = legendContainers.find(container => container.className.includes('legend-toggle'));
assert.ok(legendToggle);
} finally {
cleanup();
}
});
test('federation legend toggle respects media query changes', async () => {
const env = createDomEnvironment({ includeBody: true, bodyHasDarkClass: false });
const { document, createElement, registerElement, cleanup } = env;
const mapEl = createElement('div', 'map');
registerElement('map', mapEl);
const mapPanel = createElement('div', 'mapPanel');
mapPanel.setAttribute('data-legend-collapsed', 'false');
registerElement('mapPanel', mapPanel);
const statusEl = createElement('div', 'status');
registerElement('status', statusEl);
const tableEl = createElement('table', 'instances');
const tbodyEl = createElement('tbody');
registerElement('instances', tableEl);
tableEl.appendChild(tbodyEl);
const configPayload = {
mapCenter: { lat: 0, lon: 0 },
mapZoom: 3,
tileFilters: { light: 'none', dark: 'invert(1)' }
};
const configEl = createElement('div');
configEl.setAttribute('data-app-config', JSON.stringify(configPayload));
document.querySelector = selector => {
if (selector === '[data-app-config]') return configEl;
if (selector === '#instances tbody') return tbodyEl;
return null;
};
let mediaQueryHandler = null;
window.matchMedia = () => ({
matches: false,
addListener(handler) {
mediaQueryHandler = handler;
}
});
const legendContainers = [];
const legendButtons = [];
const DomUtil = {
create(tag, className, parent) {
const classSet = new Set(className ? className.split(/\s+/).filter(Boolean) : []);
const el = {
tagName: tag,
className,
classList: {
toggle(name, force) {
const shouldAdd = typeof force === 'boolean' ? force : !classSet.has(name);
if (shouldAdd) {
classSet.add(name);
} else {
classSet.delete(name);
}
el.className = Array.from(classSet).join(' ');
}
},
children: [],
style: {},
textContent: '',
attributes: new Map(),
setAttribute(name, value) {
this.attributes.set(name, String(value));
},
appendChild(child) {
this.children.push(child);
return child;
},
addEventListener(event, handler) {
if (event === 'click') {
this._clickHandler = handler;
}
},
querySelector() {
return null;
}
};
if (parent && parent.appendChild) parent.appendChild(el);
if (className && className.includes('legend-toggle-button')) {
legendButtons.push(el);
}
return el;
}
};
const controlStub = () => {
const ctrl = {
onAdd: null,
container: null,
addTo(map) {
this.container = this.onAdd ? this.onAdd(map) : null;
legendContainers.push(this.container);
return this;
},
getContainer() {
return this.container;
}
};
return ctrl;
};
const markersLayer = {
addLayer() {
return null;
},
addTo() {
return this;
}
};
const leafletStub = {
map() {
return {
setView() {},
on() {},
fitBounds() {}
};
},
tileLayer() {
return {
addTo() {
return this;
},
getContainer() {
return null;
},
on() {}
};
},
layerGroup() {
return markersLayer;
},
circleMarker() {
return {
bindPopup() {
return this;
}
};
},
control: controlStub,
DomUtil,
DomEvent: {
disableClickPropagation() {},
disableScrollPropagation() {}
}
};
const fetchImpl = async () => ({
ok: true,
json: async () => []
});
try {
await initializeFederationPage({ config: configPayload, fetchImpl, leaflet: leafletStub });
const legend = legendContainers.find(container => container.className.includes('legend--instances'));
assert.ok(legend);
assert.ok(!legend.className.includes('legend-hidden'));
assert.equal(legendButtons.length, 1);
legendButtons[0]._clickHandler?.({ preventDefault() {}, stopPropagation() {} });
assert.ok(legend.className.includes('legend-hidden'));
if (mediaQueryHandler) {
mediaQueryHandler({ matches: false });
assert.ok(!legend.className.includes('legend-hidden'));
}
} finally {
cleanup();
}
@@ -0,0 +1,455 @@
/*
* Copyright © 2025-26 l5yth & contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import { __test__, initializeMobileMenu } from '../mobile-menu.js';
const { createMobileMenuController, resolveFocusableElements } = __test__;
function createClassList() {
const values = new Set();
return {
add(...names) {
names.forEach(name => values.add(name));
},
remove(...names) {
names.forEach(name => values.delete(name));
},
contains(name) {
return values.has(name);
}
};
}
function createElement(tagName = 'div', initialId = '') {
const listeners = new Map();
const attributes = new Map();
if (initialId) {
attributes.set('id', String(initialId));
}
return {
tagName: tagName.toUpperCase(),
attributes,
classList: createClassList(),
dataset: {},
hidden: false,
parentNode: null,
nextSibling: null,
setAttribute(name, value) {
attributes.set(name, String(value));
},
getAttribute(name) {
return attributes.has(name) ? attributes.get(name) : null;
},
addEventListener(event, handler) {
listeners.set(event, handler);
},
dispatchEvent(event) {
const key = typeof event === 'string' ? event : event?.type;
const handler = listeners.get(key);
if (handler) {
handler(event);
}
},
appendChild(node) {
this.lastAppended = node;
return node;
},
insertBefore(node, nextSibling) {
this.lastInserted = { node, nextSibling };
return node;
},
focus() {
globalThis.document.activeElement = this;
},
querySelector() {
return null;
},
querySelectorAll() {
return [];
}
};
}
function createDomStub() {
const originalDocument = globalThis.document;
const registry = new Map();
const documentStub = {
body: createElement('body'),
activeElement: null,
querySelectorAll() {
return [];
},
getElementById(id) {
return registry.get(id) || null;
}
};
globalThis.document = documentStub;
return {
documentStub,
registry,
cleanup() {
globalThis.document = originalDocument;
}
};
}
function createWindowStub(matches = true) {
const listeners = new Map();
const mediaListeners = new Map();
return {
matchMedia() {
return {
matches,
addEventListener(event, handler) {
mediaListeners.set(event, handler);
}
};
},
addEventListener(event, handler) {
listeners.set(event, handler);
},
dispatchEvent(event) {
const key = typeof event === 'string' ? event : event?.type;
const handler = listeners.get(key);
if (handler) {
handler(event);
}
},
dispatchMediaChange() {
const handler = mediaListeners.get('change');
if (handler) {
handler();
}
}
};
}
function createWindowStubWithListener(matches = true) {
const listeners = new Map();
let mediaHandler = null;
return {
matchMedia() {
return {
matches,
addListener(handler) {
mediaHandler = handler;
}
};
},
addEventListener(event, handler) {
listeners.set(event, handler);
},
dispatchMediaChange() {
if (mediaHandler) {
mediaHandler();
}
}
};
}
test('mobile menu toggles open state and aria-expanded', () => {
const { documentStub, registry, cleanup } = createDomStub();
const windowStub = createWindowStub(true);
const menuToggle = createElement('button');
const menu = createElement('div');
const menuPanel = createElement('div');
const closeButton = createElement('button');
const navLink = createElement('a');
menu.hidden = true;
menuPanel.classList.add('mobile-menu__panel');
menu.querySelector = selector => {
if (selector === '.mobile-menu__panel') return menuPanel;
return null;
};
menu.querySelectorAll = selector => {
if (selector === '[data-mobile-menu-close]') return [closeButton];
if (selector === 'a') return [navLink];
return [];
};
menuPanel.querySelectorAll = () => [closeButton, navLink];
registry.set('mobileMenuToggle', menuToggle);
registry.set('mobileMenu', menu);
try {
const controller = createMobileMenuController({
documentObject: documentStub,
windowObject: windowStub
});
controller.initialize();
windowStub.dispatchMediaChange();
menuToggle.dispatchEvent({ type: 'click', preventDefault() {} });
assert.equal(menu.hidden, false);
assert.equal(menuToggle.getAttribute('aria-expanded'), 'true');
assert.equal(documentStub.body.classList.contains('menu-open'), true);
navLink.dispatchEvent({ type: 'click' });
assert.equal(menu.hidden, true);
closeButton.dispatchEvent({ type: 'click' });
assert.equal(menu.hidden, true);
assert.equal(menuToggle.getAttribute('aria-expanded'), 'false');
} finally {
cleanup();
}
});
test('mobile menu closes on escape and route changes', () => {
const { documentStub, registry, cleanup } = createDomStub();
const windowStub = createWindowStub(true);
const menuToggle = createElement('button');
const menu = createElement('div');
const menuPanel = createElement('div');
const closeButton = createElement('button');
menu.hidden = true;
menuPanel.classList.add('mobile-menu__panel');
menu.querySelector = selector => {
if (selector === '.mobile-menu__panel') return menuPanel;
return null;
};
menu.querySelectorAll = selector => {
if (selector === '[data-mobile-menu-close]') return [closeButton];
return [];
};
menuPanel.querySelectorAll = () => [closeButton];
registry.set('mobileMenuToggle', menuToggle);
registry.set('mobileMenu', menu);
try {
const controller = createMobileMenuController({
documentObject: documentStub,
windowObject: windowStub
});
controller.initialize();
menuPanel.dispatchEvent({ type: 'keydown', key: 'Escape', preventDefault() {} });
assert.equal(menu.hidden, true);
menuToggle.dispatchEvent({ type: 'click', preventDefault() {} });
assert.equal(menu.hidden, false);
menuPanel.dispatchEvent({ type: 'keydown', key: 'ArrowDown' });
assert.equal(menu.hidden, false);
menuPanel.dispatchEvent({ type: 'keydown', key: 'Escape', preventDefault() {} });
assert.equal(menu.hidden, true);
menuToggle.dispatchEvent({ type: 'click', preventDefault() {} });
windowStub.dispatchEvent({ type: 'hashchange' });
assert.equal(menu.hidden, true);
menuToggle.dispatchEvent({ type: 'click', preventDefault() {} });
windowStub.dispatchEvent({ type: 'popstate' });
assert.equal(menu.hidden, true);
} finally {
cleanup();
}
});
test('mobile menu traps focus within the panel', () => {
const { documentStub, registry, cleanup } = createDomStub();
const windowStub = createWindowStub(true);
const menuToggle = createElement('button');
const menu = createElement('div');
const menuPanel = createElement('div');
const firstLink = createElement('a');
const lastButton = createElement('button');
menuPanel.classList.add('mobile-menu__panel');
menuPanel.querySelectorAll = () => [firstLink, lastButton];
menu.querySelector = selector => {
if (selector === '.mobile-menu__panel') return menuPanel;
return null;
};
menu.querySelectorAll = () => [];
registry.set('mobileMenuToggle', menuToggle);
registry.set('mobileMenu', menu);
try {
const controller = createMobileMenuController({
documentObject: documentStub,
windowObject: windowStub
});
controller.initialize();
menuToggle.dispatchEvent({ type: 'click', preventDefault() {} });
documentStub.activeElement = lastButton;
menuPanel.dispatchEvent({ type: 'keydown', key: 'Tab', preventDefault() {}, shiftKey: false });
assert.equal(documentStub.activeElement, firstLink);
documentStub.activeElement = firstLink;
menuPanel.dispatchEvent({ type: 'keydown', key: 'Tab', preventDefault() {}, shiftKey: true });
assert.equal(documentStub.activeElement, lastButton);
} finally {
cleanup();
}
});
test('resolveFocusableElements filters out aria-hidden nodes', () => {
const hiddenButton = createElement('button');
hiddenButton.getAttribute = name => (name === 'aria-hidden' ? 'true' : null);
const openLink = createElement('a');
const bareNode = { tagName: 'DIV' };
const container = {
querySelectorAll() {
return [hiddenButton, bareNode, openLink];
}
};
const focusables = resolveFocusableElements(container);
assert.equal(focusables.length, 1);
assert.equal(focusables[0], openLink);
});
test('resolveFocusableElements handles empty containers', () => {
assert.deepEqual(resolveFocusableElements(null), []);
assert.deepEqual(resolveFocusableElements({}), []);
});
test('mobile menu focuses the panel when no focusables exist', () => {
const { documentStub, registry, cleanup } = createDomStub();
const windowStub = createWindowStub(true);
const menuToggle = createElement('button');
const menu = createElement('div');
const menuPanel = createElement('div');
const lastActive = createElement('button');
menuPanel.classList.add('mobile-menu__panel');
menuPanel.querySelectorAll = () => [];
menu.querySelector = selector => {
if (selector === '.mobile-menu__panel') return menuPanel;
return null;
};
menu.querySelectorAll = () => [];
registry.set('mobileMenuToggle', menuToggle);
registry.set('mobileMenu', menu);
documentStub.activeElement = lastActive;
try {
const controller = createMobileMenuController({
documentObject: documentStub,
windowObject: windowStub
});
controller.initialize();
menuToggle.dispatchEvent({ type: 'click', preventDefault() {} });
assert.equal(documentStub.activeElement, menuPanel);
menuToggle.dispatchEvent({ type: 'click', preventDefault() {} });
assert.equal(documentStub.activeElement, lastActive);
} finally {
cleanup();
}
});
test('mobile menu registers legacy media query listeners', () => {
const { documentStub, registry, cleanup } = createDomStub();
const windowStub = createWindowStubWithListener(true);
const menuToggle = createElement('button');
const menu = createElement('div');
const menuPanel = createElement('div');
menuPanel.classList.add('mobile-menu__panel');
menu.querySelector = selector => {
if (selector === '.mobile-menu__panel') return menuPanel;
return null;
};
menu.querySelectorAll = () => [];
registry.set('mobileMenuToggle', menuToggle);
registry.set('mobileMenu', menu);
try {
const controller = createMobileMenuController({
documentObject: documentStub,
windowObject: windowStub
});
controller.initialize();
windowStub.dispatchMediaChange();
assert.equal(menuToggle.getAttribute('aria-expanded'), 'false');
} finally {
cleanup();
}
});
test('mobile menu safely no-ops without required nodes', () => {
const { documentStub, cleanup } = createDomStub();
const windowStub = createWindowStub(true);
try {
const controller = createMobileMenuController({
documentObject: documentStub,
windowObject: windowStub
});
controller.initialize();
controller.openMenu();
controller.closeMenu();
controller.syncLayout();
assert.equal(documentStub.body.classList.contains('menu-open'), false);
} finally {
cleanup();
}
});
test('initializeMobileMenu returns a controller', () => {
const { documentStub, registry, cleanup } = createDomStub();
const windowStub = createWindowStub(true);
const menuToggle = createElement('button');
const menu = createElement('div');
const menuPanel = createElement('div');
menuPanel.classList.add('mobile-menu__panel');
menu.querySelector = selector => {
if (selector === '.mobile-menu__panel') return menuPanel;
return null;
};
menu.querySelectorAll = () => [];
registry.set('mobileMenuToggle', menuToggle);
registry.set('mobileMenu', menu);
try {
const controller = initializeMobileMenu({
documentObject: documentStub,
windowObject: windowStub
});
assert.equal(typeof controller.openMenu, 'function');
} finally {
cleanup();
}
});
@@ -405,6 +405,77 @@ test('renderTelemetryCharts renders condensed scatter charts when telemetry exis
assert.equal(html.includes('node-detail__chart-point'), true);
});
test('renderTelemetryCharts expands upper bounds when overflow metrics exceed defaults', () => {
const nowMs = Date.UTC(2025, 0, 8, 12, 0, 0);
const nowSeconds = Math.floor(nowMs / 1000);
const node = {
rawSources: {
telemetry: {
snapshots: [
{
rx_time: nowSeconds - 120,
device_metrics: {
battery_level: 90,
voltage: 7.2,
current: 3.6,
channel_utilization: 45,
air_util_tx: 18,
},
environment_metrics: {
temperature: 45,
relative_humidity: 48,
barometric_pressure: 1250,
gas_resistance: 1200,
iaq: 650,
},
},
],
},
},
};
const html = renderTelemetryCharts(node, { nowMs });
assert.match(html, />7\.2<\/text>/);
assert.match(html, />3\.6<\/text>/);
assert.match(html, />45<\/text>/);
assert.match(html, />650<\/text>/);
assert.match(html, />1100<\/text>/);
});
test('renderTelemetryCharts keeps default bounds when metrics stay within limits', () => {
const nowMs = Date.UTC(2025, 0, 8, 12, 0, 0);
const nowSeconds = Math.floor(nowMs / 1000);
const node = {
rawSources: {
telemetry: {
snapshots: [
{
rx_time: nowSeconds - 180,
device_metrics: {
battery_level: 70,
voltage: 4.5,
current: 1.5,
channel_utilization: 35,
air_util_tx: 15,
},
environment_metrics: {
temperature: 25,
relative_humidity: 50,
barometric_pressure: 1015,
gas_resistance: 1500,
iaq: 200,
},
},
],
},
},
};
const html = renderTelemetryCharts(node, { nowMs });
assert.match(html, />6\.0<\/text>/);
assert.match(html, />3\.0<\/text>/);
assert.match(html, />40<\/text>/);
assert.match(html, />500<\/text>/);
});
test('renderNodeDetailHtml composes the table, neighbors, and messages', () => {
const html = renderNodeDetailHtml(
{
@@ -875,13 +946,19 @@ test('initializeNodeDetailPage reports an error when refresh fails', async () =>
throw new Error('boom');
};
const renderShortHtml = short => `<span>${short}</span>`;
const result = await initializeNodeDetailPage({
document: documentStub,
refreshImpl,
renderShortHtml,
});
assert.equal(result, false);
assert.equal(element.innerHTML.includes('Failed to load'), true);
const originalError = console.error;
console.error = () => {};
try {
const result = await initializeNodeDetailPage({
document: documentStub,
refreshImpl,
renderShortHtml,
});
assert.equal(result, false);
assert.equal(element.innerHTML.includes('Failed to load'), true);
} finally {
console.error = originalError;
}
});
test('initializeNodeDetailPage handles missing reference payloads', async () => {
+2 -21
View File
@@ -65,7 +65,8 @@ function resolveSnapshotList(entry) {
* Build a data model describing the content for chat tabs.
*
* Entries outside the recent activity window, encrypted messages, and
* channels above {@link MAX_CHANNEL_INDEX} are filtered out.
* channels above {@link MAX_CHANNEL_INDEX} are filtered out. Channel
* buckets are only created when messages are present for that channel.
*
* @param {{
* nodes?: Array<Object>,
@@ -287,26 +288,6 @@ export function buildChatTabModel({
logEntries.sort((a, b) => a.ts - b.ts);
let hasPrimaryBucket = false;
for (const bucket of channelBuckets.values()) {
if (bucket.index === 0) {
hasPrimaryBucket = true;
break;
}
}
if (!hasPrimaryBucket) {
const bucketKey = '0';
channelBuckets.set(bucketKey, {
key: bucketKey,
id: buildChannelTabId(bucketKey),
index: 0,
label: '0',
entries: [],
labelPriority: CHANNEL_LABEL_PRIORITY.INDEX,
isPrimaryFallback: true
});
}
const channels = Array.from(channelBuckets.values()).sort((a, b) => {
if (a.index !== b.index) {
return a.index - b.index;
+115
View File
@@ -15,6 +15,7 @@
*/
import { readAppConfig } from './config.js';
import { resolveLegendVisibility } from './map-legend-visibility.js';
import { mergeConfig } from './settings.js';
import { roleColors } from './role-helpers.js';
@@ -204,6 +205,31 @@ function hasNumberValue(value) {
return toFiniteNumber(value) != null;
}
/**
* Toggle the legend hidden class on a container element.
*
* @param {HTMLElement|{ classList?: { toggle?: Function }, className?: string }} container Legend container.
* @param {boolean} hidden Whether the legend should be hidden.
* @returns {void}
*/
function toggleLegendHiddenClass(container, hidden) {
if (!container) return;
if (container.classList && typeof container.classList.toggle === 'function') {
container.classList.toggle('legend-hidden', hidden);
return;
}
if (typeof container.className === 'string') {
const classes = container.className.split(/\s+/).filter(Boolean);
const hasHidden = classes.includes('legend-hidden');
if (hidden && !hasHidden) {
classes.push('legend-hidden');
} else if (!hidden && hasHidden) {
classes.splice(classes.indexOf('legend-hidden'), 1);
}
container.className = classes.join(' ');
}
}
const TILE_LAYER_URL = 'https://{s}.tile.openstreetmap.fr/hot/{z}/{x}/{y}.png';
/**
@@ -223,6 +249,7 @@ export async function initializeFederationPage(options = {}) {
const fetchImpl = options.fetchImpl || fetch;
const leaflet = options.leaflet || (typeof window !== 'undefined' ? window.L : null);
const mapContainer = document.getElementById('map');
const mapPanel = document.getElementById('mapPanel');
const tableEl = document.getElementById('instances');
const tableBody = document.querySelector('#instances tbody');
const statusEl = document.getElementById('status');
@@ -239,6 +266,13 @@ export async function initializeFederationPage(options = {}) {
let map = null;
let markersLayer = null;
let tileLayer = null;
let legendContainer = null;
let legendToggleButton = null;
let legendVisible = true;
const legendCollapsedValue = mapPanel ? mapPanel.getAttribute('data-legend-collapsed') : null;
const legendDefaultCollapsed = legendCollapsedValue == null
? true
: legendCollapsedValue.trim() !== 'false';
const tableSorters = {
name: { getValue: inst => inst.name ?? '', compare: compareString, hasValue: hasStringValue, defaultDirection: 'asc' },
domain: { getValue: inst => inst.domain ?? '', compare: compareString, hasValue: hasStringValue, defaultDirection: 'asc' },
@@ -357,6 +391,37 @@ export async function initializeFederationPage(options = {}) {
syncSortIndicators();
};
/**
* Update the pressed state of the legend visibility toggle button.
*
* @returns {void}
*/
const updateLegendToggleState = () => {
if (!legendToggleButton) return;
const baseLabel = legendVisible ? 'Hide map legend' : 'Show map legend';
const baseText = legendVisible ? 'Hide legend' : 'Show legend';
legendToggleButton.setAttribute('aria-pressed', legendVisible ? 'true' : 'false');
legendToggleButton.setAttribute('aria-label', baseLabel);
legendToggleButton.textContent = baseText;
};
/**
* Show or hide the map legend component.
*
* @param {boolean} visible Whether the legend should be displayed.
* @returns {void}
*/
const setLegendVisibility = visible => {
legendVisible = Boolean(visible);
if (legendContainer) {
toggleLegendHiddenClass(legendContainer, !legendVisible);
if (typeof legendContainer.setAttribute === 'function') {
legendContainer.setAttribute('aria-hidden', legendVisible ? 'false' : 'true');
}
}
updateLegendToggleState();
};
/**
* Wire up click and keyboard handlers for sortable headers.
*
@@ -483,6 +548,15 @@ export async function initializeFederationPage(options = {}) {
const canRenderLegend =
typeof leaflet.control === 'function' && leaflet.DomUtil && typeof leaflet.DomUtil.create === 'function';
if (canRenderLegend) {
const legendMediaQuery = typeof window !== 'undefined' && window.matchMedia
? window.matchMedia('(max-width: 1024px)')
: null;
const initialLegendVisible = resolveLegendVisibility({
defaultCollapsed: legendDefaultCollapsed,
mediaQueryMatches: legendMediaQuery ? legendMediaQuery.matches : false
});
legendVisible = initialLegendVisible;
const legendStops = NODE_COUNT_COLOR_STOPS.map((stop, index) => {
const lower = index === 0 ? 0 : NODE_COUNT_COLOR_STOPS[index - 1].limit;
const upper = stop.limit - 1;
@@ -495,7 +569,11 @@ export async function initializeFederationPage(options = {}) {
const legend = leaflet.control({ position: 'bottomright' });
legend.onAdd = function onAdd() {
const container = leaflet.DomUtil.create('div', 'legend legend--instances');
container.id = 'federationLegend';
container.setAttribute('aria-label', 'Active nodes legend');
container.setAttribute('role', 'region');
container.setAttribute('aria-hidden', initialLegendVisible ? 'false' : 'true');
toggleLegendHiddenClass(container, !initialLegendVisible);
const header = leaflet.DomUtil.create('div', 'legend-header', container);
const title = leaflet.DomUtil.create('span', 'legend-title', header);
title.textContent = 'Active nodes';
@@ -508,9 +586,46 @@ export async function initializeFederationPage(options = {}) {
const label = leaflet.DomUtil.create('span', 'legend-label', item);
label.textContent = stop.label;
});
legendContainer = container;
return container;
};
legend.addTo(map);
const legendToggleControl = leaflet.control({ position: 'bottomright' });
legendToggleControl.onAdd = function onAdd() {
const container = leaflet.DomUtil.create('div', 'leaflet-control legend-toggle');
const button = leaflet.DomUtil.create('button', 'legend-toggle-button', container);
button.type = 'button';
button.setAttribute('aria-controls', 'federationLegend');
button.addEventListener?.('click', event => {
event.preventDefault();
event.stopPropagation();
setLegendVisibility(!legendVisible);
});
legendToggleButton = button;
updateLegendToggleState();
if (leaflet.DomEvent && typeof leaflet.DomEvent.disableClickPropagation === 'function') {
leaflet.DomEvent.disableClickPropagation(container);
}
if (leaflet.DomEvent && typeof leaflet.DomEvent.disableScrollPropagation === 'function') {
leaflet.DomEvent.disableScrollPropagation(container);
}
return container;
};
legendToggleControl.addTo(map);
setLegendVisibility(initialLegendVisible);
if (legendMediaQuery) {
const changeHandler = event => {
if (legendDefaultCollapsed) return;
setLegendVisibility(!event.matches);
};
if (typeof legendMediaQuery.addEventListener === 'function') {
legendMediaQuery.addEventListener('change', changeHandler);
} else if (typeof legendMediaQuery.addListener === 'function') {
legendMediaQuery.addListener(changeHandler);
}
}
}
for (const instance of instances) {
+4 -1
View File
@@ -44,6 +44,7 @@ import {
formatChatPresetTag
} from './chat-format.js';
import { initializeInstanceSelector } from './instance-selector.js';
import { initializeMobileMenu } from './mobile-menu.js';
import { MESSAGE_LIMIT, normaliseMessageLimit } from './message-limit.js';
import { CHAT_LOG_ENTRY_TYPES, buildChatTabModel, MAX_CHANNEL_INDEX } from './chat-log-tabs.js';
import { renderChatTabs } from './chat-tabs.js';
@@ -119,6 +120,8 @@ export function initializeApp(config) {
const isChatView = bodyClassList ? bodyClassList.contains('view-chat') : false;
const isMapView = bodyClassList ? bodyClassList.contains('view-map') : false;
const mapZoomOverride = Number.isFinite(config.mapZoom) ? Number(config.mapZoom) : null;
initializeMobileMenu({ documentObject: document, windowObject: window });
/**
* Column sorter configuration for the node table.
*
@@ -192,7 +195,7 @@ export function initializeApp(config) {
});
const NODE_LIMIT = 1000;
const TRACE_LIMIT = 200;
const TRACE_MAX_AGE_SECONDS = 7 * 24 * 60 * 60;
const TRACE_MAX_AGE_SECONDS = 28 * 24 * 60 * 60;
const SNAPSHOT_LIMIT = SNAPSHOT_WINDOW;
const CHAT_LIMIT = MESSAGE_LIMIT;
const CHAT_RECENT_WINDOW_SECONDS = 7 * 24 * 60 * 60;
+271
View File
@@ -0,0 +1,271 @@
/*
* Copyright © 2025-26 l5yth & contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
const MOBILE_MENU_MEDIA_QUERY = '(max-width: 900px)';
const FOCUSABLE_SELECTOR = [
'a[href]',
'button:not([disabled])',
'input:not([disabled])',
'select:not([disabled])',
'textarea:not([disabled])',
'[tabindex]:not([tabindex="-1"])'
].join(', ');
/**
* Collect the elements that can receive focus within a container.
*
* @param {?Element} container DOM node hosting focusable descendants.
* @returns {Array<Element>} Ordered list of focusable elements.
*/
function resolveFocusableElements(container) {
if (!container || typeof container.querySelectorAll !== 'function') {
return [];
}
const candidates = Array.from(container.querySelectorAll(FOCUSABLE_SELECTOR));
return candidates.filter(candidate => {
if (!candidate || typeof candidate.getAttribute !== 'function') {
return false;
}
return candidate.getAttribute('aria-hidden') !== 'true';
});
}
/**
* Build a menu controller for handling toggle state, focus trapping, and
* responsive layout swapping.
*
* @param {{
* documentObject?: Document,
* windowObject?: Window
* }} [options]
* @returns {{
* initialize: () => void,
* openMenu: () => void,
* closeMenu: () => void,
* syncLayout: () => void
* }}
*/
function createMobileMenuController(options = {}) {
const documentObject = options.documentObject || document;
const windowObject = options.windowObject || window;
const menuToggle = documentObject.getElementById('mobileMenuToggle');
const menu = documentObject.getElementById('mobileMenu');
const menuPanel = menu ? menu.querySelector('.mobile-menu__panel') : null;
const closeTriggers = menu ? Array.from(menu.querySelectorAll('[data-mobile-menu-close]')) : [];
const menuLinks = menu ? Array.from(menu.querySelectorAll('a')) : [];
const body = documentObject.body;
const mediaQuery = windowObject.matchMedia
? windowObject.matchMedia(MOBILE_MENU_MEDIA_QUERY)
: null;
let isOpen = false;
let lastActive = null;
/**
* Toggle the ``aria-expanded`` state on the menu trigger.
*
* @param {boolean} expanded Whether the menu is open.
* @returns {void}
*/
function setExpandedState(expanded) {
if (!menuToggle || typeof menuToggle.setAttribute !== 'function') {
return;
}
menuToggle.setAttribute('aria-expanded', expanded ? 'true' : 'false');
}
/**
* Synchronize the meta row placement based on the active media query.
*
* @returns {void}
*/
function syncLayout() {
return;
}
/**
* Open the slide-in menu and trap focus within the panel.
*
* @returns {void}
*/
function openMenu() {
if (!menu || !menuToggle || !menuPanel) {
return;
}
syncLayout();
menu.hidden = false;
menu.classList.add('is-open');
if (body && body.classList) {
body.classList.add('menu-open');
}
setExpandedState(true);
isOpen = true;
lastActive = documentObject.activeElement || null;
const focusables = resolveFocusableElements(menuPanel);
const focusTarget = focusables[0] || menuPanel;
if (focusTarget && typeof focusTarget.focus === 'function') {
focusTarget.focus();
}
}
/**
* Close the menu and restore focus to the trigger.
*
* @returns {void}
*/
function closeMenu() {
if (!menu || !menuToggle) {
return;
}
menu.classList.remove('is-open');
menu.hidden = true;
if (body && body.classList) {
body.classList.remove('menu-open');
}
setExpandedState(false);
isOpen = false;
if (lastActive && typeof lastActive.focus === 'function') {
lastActive.focus();
}
}
/**
* Toggle open or closed based on the trigger interaction.
*
* @param {Event} event Click event originating from the trigger.
* @returns {void}
*/
function handleToggleClick(event) {
if (event && typeof event.preventDefault === 'function') {
event.preventDefault();
}
if (isOpen) {
closeMenu();
} else {
openMenu();
}
}
/**
* Trap tab focus within the menu panel while open.
*
* @param {KeyboardEvent} event Keydown event from the panel.
* @returns {void}
*/
function handleKeydown(event) {
if (!isOpen || !event) {
return;
}
if (event.key === 'Escape') {
event.preventDefault();
closeMenu();
return;
}
if (event.key !== 'Tab') {
return;
}
const focusables = resolveFocusableElements(menuPanel);
if (!focusables.length) {
return;
}
const first = focusables[0];
const last = focusables[focusables.length - 1];
const active = documentObject.activeElement;
if (event.shiftKey && active === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && active === last) {
event.preventDefault();
first.focus();
}
}
/**
* Close the menu when navigation state changes.
*
* @returns {void}
*/
function handleRouteChange() {
if (isOpen) {
closeMenu();
}
}
/**
* Attach event listeners and sync initial layout.
*
* @returns {void}
*/
function initialize() {
if (!menuToggle || !menu) {
return;
}
menuToggle.addEventListener('click', handleToggleClick);
closeTriggers.forEach(trigger => {
trigger.addEventListener('click', closeMenu);
});
menuLinks.forEach(link => {
link.addEventListener('click', closeMenu);
});
if (menuPanel && typeof menuPanel.addEventListener === 'function') {
menuPanel.addEventListener('keydown', handleKeydown);
}
if (mediaQuery) {
if (typeof mediaQuery.addEventListener === 'function') {
mediaQuery.addEventListener('change', syncLayout);
} else if (typeof mediaQuery.addListener === 'function') {
mediaQuery.addListener(syncLayout);
}
}
if (windowObject && typeof windowObject.addEventListener === 'function') {
windowObject.addEventListener('hashchange', handleRouteChange);
windowObject.addEventListener('popstate', handleRouteChange);
}
syncLayout();
setExpandedState(false);
}
return {
initialize,
openMenu,
closeMenu,
syncLayout,
};
}
/**
* Initialize the mobile menu using the live DOM environment.
*
* @param {{
* documentObject?: Document,
* windowObject?: Window
* }} [options]
* @returns {{
* initialize: () => void,
* openMenu: () => void,
* closeMenu: () => void,
* syncLayout: () => void
* }}
*/
export function initializeMobileMenu(options = {}) {
const controller = createMobileMenuController(options);
controller.initialize();
return controller;
}
export const __test__ = {
createMobileMenuController,
resolveFocusableElements,
};
+51 -7
View File
@@ -68,6 +68,7 @@ const TELEMETRY_CHART_SPECS = Object.freeze([
max: 6,
ticks: 3,
color: '#9ebcda',
allowUpperOverflow: true,
},
{
id: 'current',
@@ -77,6 +78,7 @@ const TELEMETRY_CHART_SPECS = Object.freeze([
max: 3,
ticks: 3,
color: '#3182bd',
allowUpperOverflow: true,
},
],
series: [
@@ -156,6 +158,7 @@ const TELEMETRY_CHART_SPECS = Object.freeze([
max: 40,
ticks: 4,
color: '#fc8d59',
allowUpperOverflow: true,
},
{
id: 'humidity',
@@ -220,6 +223,7 @@ const TELEMETRY_CHART_SPECS = Object.freeze([
max: 500,
ticks: 5,
color: '#636363',
allowUpperOverflow: true,
},
],
series: [
@@ -1004,6 +1008,31 @@ function buildSeriesPoints(entries, fields, domainStart, domainEnd) {
return points;
}
/**
* Resolve the effective axis maximum when upper overflow is allowed.
*
* @param {Object} axis Axis descriptor.
* @param {Array<{axisId: string, points: Array<{timestamp: number, value: number}>}>} seriesEntries Series entries.
* @returns {number} Effective axis max.
*/
function resolveAxisMax(axis, seriesEntries) {
if (!axis || axis.allowUpperOverflow !== true) {
return axis?.max;
}
let observedMax = null;
for (const entry of seriesEntries) {
if (!entry || entry.axisId !== axis.id || !Array.isArray(entry.points)) continue;
for (const point of entry.points) {
if (!point || !Number.isFinite(point.value)) continue;
observedMax = observedMax == null ? point.value : Math.max(observedMax, point.value);
}
}
if (observedMax != null && Number.isFinite(axis.max) && observedMax > axis.max) {
return observedMax;
}
return axis.max;
}
/**
* Render a telemetry series as circles plus an optional translucent guide line.
*
@@ -1133,33 +1162,48 @@ function renderTelemetryChart(spec, entries, nowMs, chartOptions = {}) {
const domainEnd = nowMs;
const domainStart = nowMs - windowMs;
const dims = createChartDimensions(spec);
const axisMap = new Map(spec.axes.map(axis => [axis.id, axis]));
const seriesEntries = spec.series
.map(series => {
const axis = axisMap.get(series.axis);
if (!axis) return null;
const points = buildSeriesPoints(entries, series.fields, domainStart, domainEnd);
if (points.length === 0) return null;
return { config: series, axis, points };
return { config: series, axisId: series.axis, points };
})
.filter(entry => entry != null);
if (seriesEntries.length === 0) {
return '';
}
const axesMarkup = spec.axes.map(axis => renderYAxis(axis, dims)).join('');
const adjustedAxes = spec.axes.map(axis => {
const resolvedMax = resolveAxisMax(axis, seriesEntries);
if (resolvedMax != null && resolvedMax !== axis.max) {
return { ...axis, max: resolvedMax };
}
return axis;
});
const axisMap = new Map(adjustedAxes.map(axis => [axis.id, axis]));
const plottedSeries = seriesEntries
.map(series => {
const axis = axisMap.get(series.axisId);
if (!axis) return null;
return { config: series.config, axis, points: series.points };
})
.filter(entry => entry != null);
if (plottedSeries.length === 0) {
return '';
}
const axesMarkup = adjustedAxes.map(axis => renderYAxis(axis, dims)).join('');
const tickBuilder = typeof chartOptions.xAxisTickBuilder === 'function' ? chartOptions.xAxisTickBuilder : buildMidnightTicks;
const tickFormatter = typeof chartOptions.xAxisTickFormatter === 'function' ? chartOptions.xAxisTickFormatter : formatCompactDate;
const ticks = tickBuilder(nowMs, windowMs);
const xAxisMarkup = renderXAxis(dims, domainStart, domainEnd, ticks, { labelFormatter: tickFormatter });
const seriesMarkup = seriesEntries
const seriesMarkup = plottedSeries
.map(series =>
renderTelemetrySeries(series.config, series.points, series.axis, dims, domainStart, domainEnd, {
lineReducer: chartOptions.lineReducer,
}),
)
.join('');
const legendItems = seriesEntries
const legendItems = plottedSeries
.map(series => {
const legendLabel = stringOrNull(series.config.legend) ?? series.config.label;
return `
+215 -11
View File
@@ -215,25 +215,214 @@ h1 {
.site-header {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 16px;
min-height: 56px;
padding: 4px 0;
margin-bottom: 8px;
}
.site-header__left,
.site-header__right {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 8px;
}
.site-header__left {
flex: 1 1 auto;
min-width: 0;
}
.site-header__right {
flex: 0 0 auto;
margin-left: auto;
}
.site-title {
display: inline-flex;
align-items: center;
gap: 12px;
min-width: 0;
}
.site-title-text {
min-width: 0;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.site-title img {
width: 52px;
height: 52px;
width: 36px;
height: 36px;
display: block;
border-radius: 12px;
}
.site-nav {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.site-nav__link {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border-radius: 999px;
color: var(--fg);
text-decoration: none;
border: 1px solid transparent;
font-size: 14px;
}
.site-nav__link:hover {
background: var(--card);
}
.site-nav__link.is-active {
border-color: var(--accent);
color: var(--accent);
background: transparent;
font-weight: 600;
}
.site-nav__link:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.menu-toggle {
display: none;
}
.menu-toggle:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.mobile-menu {
position: fixed;
inset: 0;
z-index: 1200;
display: flex;
justify-content: flex-end;
pointer-events: none;
}
.mobile-menu[hidden] {
display: none;
}
.mobile-menu__backdrop {
flex: 1 1 auto;
background: rgba(0, 0, 0, 0.4);
opacity: 0;
transition: opacity 200ms ease;
}
.mobile-menu__panel {
width: min(320px, 86vw);
background: var(--bg2);
color: var(--fg);
padding: 16px;
display: flex;
flex-direction: column;
gap: 16px;
height: 100%;
overflow-y: auto;
transform: translateX(100%);
transition: transform 220ms ease;
box-shadow: -12px 0 32px rgba(0, 0, 0, 0.3);
}
.mobile-menu__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.mobile-menu__title {
margin: 0;
font-size: 16px;
}
.mobile-menu__close:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.mobile-nav {
display: flex;
flex-direction: column;
gap: 8px;
}
.mobile-nav__link {
display: inline-flex;
align-items: center;
padding: 8px 10px;
border-radius: 10px;
color: var(--fg);
text-decoration: none;
border: 1px solid transparent;
}
.mobile-nav__link.is-active {
border-color: var(--accent);
color: var(--accent);
font-weight: 600;
}
.mobile-nav__link:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.mobile-menu.is-open {
pointer-events: auto;
}
.mobile-menu.is-open .mobile-menu__backdrop {
opacity: 1;
}
.mobile-menu.is-open .mobile-menu__panel {
transform: translateX(0);
}
.menu-open {
overflow: hidden;
}
.section-link {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
border-radius: 999px;
border: 1px solid var(--line);
color: var(--fg);
text-decoration: none;
font-size: 14px;
}
.section-link:hover {
border-color: var(--accent);
color: var(--accent);
}
.section-link:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.meta {
color: #555;
margin-bottom: 12px;
@@ -282,11 +471,29 @@ h1 {
@media (max-width: 900px) {
.site-header {
flex-direction: column;
align-items: flex-start;
margin-bottom: 4px;
}
.site-header__left {
flex-wrap: nowrap;
}
.site-header__left--federation {
flex-wrap: wrap;
}
.site-nav {
display: none;
}
.menu-toggle {
display: inline-flex;
}
.instance-selector {
flex: 0 1 auto;
}
.instance-selector,
.instance-select {
width: 100%;
}
@@ -296,6 +503,7 @@ h1 {
}
}
.pill {
display: inline-block;
padding: 2px 8px;
@@ -1694,10 +1902,6 @@ input[type="radio"] {
gap: 12px;
}
.controls--full-screen {
grid-template-columns: minmax(0, 1fr) auto;
}
.controls .filter-input {
width: 100%;
}
+15 -14
View File
@@ -4136,7 +4136,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
end
end
it "excludes nodes whose last activity is older than a week" do
it "excludes nodes whose last activity is older than a week from collection queries" do
clear_database
allow(Time).to receive(:now).and_return(reference_time)
now = reference_time.to_i
@@ -4162,7 +4162,9 @@ RSpec.describe "Potato Mesh Sinatra app" do
expect(ids).not_to include("!stale-node")
get "/api/nodes/!stale-node"
expect(last_response.status).to eq(404)
expect(last_response).to be_ok
payload = JSON.parse(last_response.body)
expect(payload["node_id"]).to eq("!stale-node")
get "/api/nodes/!fresh-node"
expect(last_response).to be_ok
@@ -4532,7 +4534,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
expect(entry["payload_b64"]).to eq("AQI=")
end
it "excludes position entries older than seven days" do
it "excludes position entries older than seven days from collection queries" do
clear_database
allow(Time).to receive(:now).and_return(reference_time)
now = reference_time.to_i
@@ -4561,7 +4563,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
expect(last_response).to be_ok
filtered = JSON.parse(last_response.body)
expect(filtered.map { |row| row["id"] }).to eq([2])
expect(filtered.map { |row| row["id"] }).to eq([2, 1])
end
it "filters positions using the since parameter for both global and node queries" do
@@ -4646,11 +4648,11 @@ RSpec.describe "Potato Mesh Sinatra app" do
end
describe "GET /api/neighbors" do
it "excludes neighbor records older than seven days" do
it "excludes neighbor records older than twenty-eight days from collection queries" do
clear_database
allow(Time).to receive(:now).and_return(reference_time)
now = reference_time.to_i
stale_rx = now - (PotatoMesh::Config.week_seconds + 45)
stale_rx = now - (PotatoMesh::Config.trace_neighbor_window_seconds + 45)
fresh_rx = now - 10
with_db do |db|
@@ -4688,9 +4690,8 @@ RSpec.describe "Potato Mesh Sinatra app" do
expect(last_response).to be_ok
filtered = JSON.parse(last_response.body)
expect(filtered.length).to eq(1)
expect(filtered.first["neighbor_id"]).to eq("!neighbor-new")
expect(filtered.first["rx_time"]).to eq(fresh_rx)
expect(filtered.length).to eq(2)
expect(filtered.map { |row| row["neighbor_id"] }).to eq(["!neighbor-new", "!neighbor-old"])
end
it "honours the since parameter for neighbor queries" do
@@ -4834,7 +4835,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
expect_same_value(second_entry["soil_temperature"], telemetry_metric(second_latest, "soil_temperature"))
end
it "excludes telemetry entries older than seven days" do
it "excludes telemetry entries older than seven days from collection queries" do
clear_database
allow(Time).to receive(:now).and_return(reference_time)
now = reference_time.to_i
@@ -4863,7 +4864,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
expect(last_response).to be_ok
filtered = JSON.parse(last_response.body)
expect(filtered.map { |row| row["id"] }).to eq([2])
expect(filtered.map { |row| row["id"] }).to eq([2, 1])
end
it "filters telemetry rows using the since parameter for both global and node-scoped queries" do
@@ -5236,11 +5237,11 @@ RSpec.describe "Potato Mesh Sinatra app" do
expect(JSON.parse(last_response.body)).to eq([])
end
it "excludes traces older than one week" do
it "excludes traces older than twenty-eight days" do
clear_database
now = Time.now.to_i
recent_rx = now - (PotatoMesh::Config.week_seconds / 2)
stale_rx = now - (PotatoMesh::Config.week_seconds + 60)
recent_rx = now - (PotatoMesh::Config.trace_neighbor_window_seconds / 2)
stale_rx = now - (PotatoMesh::Config.trace_neighbor_window_seconds + 60)
payload = [
{ "id" => 50_001, "src" => 1, "dest" => 2, "rx_time" => recent_rx, "metrics" => {} },
{ "id" => 50_002, "src" => 3, "dest" => 4, "rx_time" => stale_rx, "metrics" => {} },
+1 -1
View File
@@ -16,7 +16,7 @@
<section class="federation-page federation-page--full-width">
<div class="federation-page__content">
<div class="federation-page__map-row">
<%= erb :"shared/_map_panel", locals: { full_screen: true } %>
<%= erb :"shared/_map_panel", locals: { full_screen: true, legend_collapsed: true } %>
</div>
<%= erb :"shared/_instances_table" %>
</div>
+64 -16
View File
@@ -75,16 +75,19 @@
main_classes = ["page-main"]
main_classes << "page-main--dashboard" if view_mode == :dashboard
main_classes << "page-main--full-screen" if full_screen_view
show_header = !full_screen_view
show_header = true
show_meta_info = true
show_auto_refresh_controls = view_mode != :federation
show_auto_fit_toggle = %i[dashboard map].include?(view_mode)
map_zoom_override = defined?(map_zoom) ? map_zoom : nil
show_info_button = !full_screen_view
show_info_button = true
show_footer = !full_screen_view
show_filter_input = !%i[node_detail charts federation].include?(view_mode)
show_auto_refresh_toggle = show_auto_refresh_controls
show_refresh_actions = show_auto_refresh_controls || view_mode == :federation
nodes_nav_href = "/nodes"
nodes_nav_active = %i[nodes node_detail].include?(view_mode)
federation_nav_enabled = !private_mode && federation_enabled
controls_classes = ["controls"]
controls_classes << "controls--full-screen" if full_screen_view
refresh_row_classes = ["refresh-row"]
@@ -101,24 +104,69 @@
<div class="<%= shell_classes.join(" ") %>">
<% if show_header %>
<header class="site-header">
<h1 class="site-title">
<img src="/potatomesh-logo.svg" alt="" aria-hidden="true" />
<span class="site-title-text"><%= site_name %></span>
</h1>
<% if !private_mode && federation_enabled %>
<div class="header-federation">
<div class="instance-selector">
<label class="visually-hidden" for="instanceSelect">Select a region</label>
<select id="instanceSelect" class="instance-select" aria-label="Select instance region">
<option value=""><%= Rack::Utils.escape_html("Select region ...") %></option>
</select>
<div class="site-header__left<%= federation_nav_enabled ? " site-header__left--federation" : "" %>">
<h1 class="site-title">
<img src="/potatomesh-logo.svg" alt="" aria-hidden="true" />
<span class="site-title-text"><%= site_name %></span>
</h1>
<% if federation_nav_enabled %>
<div class="header-federation">
<div class="instance-selector">
<label class="visually-hidden" for="instanceSelect">Select a region</label>
<select id="instanceSelect" class="instance-select" aria-label="Select instance region">
<option value=""><%= Rack::Utils.escape_html("Select region ...") %></option>
</select>
</div>
</div>
</div>
<% end %>
<% end %>
</div>
<div class="site-header__right">
<nav class="site-nav" aria-label="Primary">
<a href="/" class="site-nav__link<%= view_mode == :dashboard ? " is-active" : "" %>"<%= view_mode == :dashboard ? ' aria-current="page"' : "" %>>Dashboard</a>
<a href="/map" class="site-nav__link<%= view_mode == :map ? " is-active" : "" %>"<%= view_mode == :map ? ' aria-current="page"' : "" %>>Map</a>
<a href="/chat" class="site-nav__link<%= view_mode == :chat ? " is-active" : "" %>"<%= view_mode == :chat ? ' aria-current="page"' : "" %>>Chat</a>
<a href="<%= nodes_nav_href %>" class="site-nav__link<%= nodes_nav_active ? " is-active" : "" %>"<%= nodes_nav_active ? ' aria-current="page"' : "" %>>Nodes</a>
<a href="/charts" class="site-nav__link<%= view_mode == :charts ? " is-active" : "" %>"<%= view_mode == :charts ? ' aria-current="page"' : "" %>>Charts</a>
<% if federation_nav_enabled %>
<a href="/federation" class="site-nav__link<%= view_mode == :federation ? " is-active" : "" %>"<%= view_mode == :federation ? ' aria-current="page"' : "" %>>Federation</a>
<% end %>
</nav>
<button
id="mobileMenuToggle"
class="icon-button menu-toggle"
type="button"
aria-label="Open navigation menu"
aria-expanded="false"
aria-controls="mobileMenu"
>
<span aria-hidden="true">☰</span>
</button>
</div>
</header>
<div id="mobileMenu" class="mobile-menu" hidden>
<div class="mobile-menu__backdrop" data-mobile-menu-close></div>
<div class="mobile-menu__panel" role="dialog" aria-modal="true" aria-labelledby="mobileMenuTitle" tabindex="-1">
<div class="mobile-menu__header">
<h2 id="mobileMenuTitle" class="mobile-menu__title">Menu</h2>
<button class="icon-button mobile-menu__close" type="button" data-mobile-menu-close aria-label="Close navigation menu">
<span aria-hidden="true">×</span>
</button>
</div>
<nav class="mobile-nav" aria-label="Mobile">
<a href="/" class="mobile-nav__link<%= view_mode == :dashboard ? " is-active" : "" %>"<%= view_mode == :dashboard ? ' aria-current="page"' : "" %>>Dashboard</a>
<a href="/map" class="mobile-nav__link<%= view_mode == :map ? " is-active" : "" %>"<%= view_mode == :map ? ' aria-current="page"' : "" %>>Map</a>
<a href="/chat" class="mobile-nav__link<%= view_mode == :chat ? " is-active" : "" %>"<%= view_mode == :chat ? ' aria-current="page"' : "" %>>Chat</a>
<a href="<%= nodes_nav_href %>" class="mobile-nav__link<%= nodes_nav_active ? " is-active" : "" %>"<%= nodes_nav_active ? ' aria-current="page"' : "" %>>Nodes</a>
<a href="/charts" class="mobile-nav__link<%= view_mode == :charts ? " is-active" : "" %>"<%= view_mode == :charts ? ' aria-current="page"' : "" %>>Charts</a>
<% if federation_nav_enabled %>
<a href="/federation" class="mobile-nav__link<%= view_mode == :federation ? " is-active" : "" %>"<%= view_mode == :federation ? ' aria-current="page"' : "" %>>Federation</a>
<% end %>
</nav>
</div>
</div>
<% end %>
<div class="row meta">
<div id="metaRow" class="row meta">
<% if show_meta_info %>
<div class="meta-info">
<div class="<%= refresh_row_classes.join(" ") %>">
+1 -1
View File
@@ -13,7 +13,7 @@
See the License for the specific language governing permissions and
limitations under the License.
-->
<div class="nodes-table-wrapper">
<div id="nodes-table" class="nodes-table-wrapper">
<table id="nodes">
<thead>
<tr>