Compare commits
8 commits
ee31b6623f
...
c954e95d4f
| Author | SHA1 | Date | |
|---|---|---|---|
| c954e95d4f | |||
| eb4bf65c20 | |||
| 523b0d5545 | |||
| 6ad7efe514 | |||
| 72138069dd | |||
| f6e20a6d4f | |||
| f3bd9685ba | |||
| 0ca540b750 |
104 changed files with 7751 additions and 1955 deletions
2
.dockerignore
Normal file
2
.dockerignore
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
*
|
||||
!target/x86_64-unknown-linux-musl/release/swactor
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
|
|
@ -17,3 +17,10 @@ crates/simulation/traces
|
|||
|
||||
# xtask personal config
|
||||
xtask/config.toml
|
||||
|
||||
# Deploy config (machine-specific)
|
||||
deploy.toml
|
||||
.deploy/
|
||||
|
||||
# Local dev node state
|
||||
.dev-node/
|
||||
535
Cargo.lock
generated
535
Cargo.lock
generated
|
|
@ -24,6 +24,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"getrandom 0.3.4",
|
||||
"once_cell",
|
||||
"version_check",
|
||||
"zerocopy",
|
||||
|
|
@ -130,6 +131,15 @@ version = "1.4.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
|
||||
|
||||
[[package]]
|
||||
name = "arc-swap"
|
||||
version = "1.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f9f3647c145568cec02c42054e07bdf9a5a698e15b466fb2341bfc393cd24aa5"
|
||||
dependencies = [
|
||||
"rustversion",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arrayref"
|
||||
version = "0.3.9"
|
||||
|
|
@ -148,6 +158,45 @@ version = "1.1.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16"
|
||||
|
||||
[[package]]
|
||||
name = "asn1-rs"
|
||||
version = "0.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "56624a96882bb8c26d61312ae18cb45868e5a9992ea73c58e45c3101e56a1e60"
|
||||
dependencies = [
|
||||
"asn1-rs-derive",
|
||||
"asn1-rs-impl",
|
||||
"displaydoc",
|
||||
"nom",
|
||||
"num-traits",
|
||||
"rusticata-macros",
|
||||
"thiserror 2.0.18",
|
||||
"time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "asn1-rs-derive"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "asn1-rs-impl"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-compat"
|
||||
version = "0.2.5"
|
||||
|
|
@ -238,6 +287,58 @@ dependencies = [
|
|||
"fs_extra",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "axum"
|
||||
version = "0.8.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8"
|
||||
dependencies = [
|
||||
"axum-core",
|
||||
"bytes",
|
||||
"form_urlencoded",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"itoa",
|
||||
"matchit",
|
||||
"memchr",
|
||||
"mime",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"serde_core",
|
||||
"serde_json",
|
||||
"serde_path_to_error",
|
||||
"serde_urlencoded",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tower",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "axum-core"
|
||||
version = "0.5.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"mime",
|
||||
"pin-project-lite",
|
||||
"sync_wrapper",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "backon"
|
||||
version = "1.6.0"
|
||||
|
|
@ -335,6 +436,15 @@ dependencies = [
|
|||
"objc2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bs58"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4"
|
||||
dependencies = [
|
||||
"tinyvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.19.1"
|
||||
|
|
@ -425,21 +535,6 @@ version = "1.5.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901"
|
||||
|
||||
[[package]]
|
||||
name = "ci-relay"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"hex",
|
||||
"hmac",
|
||||
"iroh",
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
"swactor-ci",
|
||||
"tiny_http",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ciborium"
|
||||
version = "0.2.2"
|
||||
|
|
@ -1012,6 +1107,42 @@ dependencies = [
|
|||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dashboard"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"clap",
|
||||
"crossbeam-queue",
|
||||
"crossterm",
|
||||
"ctrlc",
|
||||
"distribution",
|
||||
"iroh",
|
||||
"ratatui",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"swactor",
|
||||
"swactor-std",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dashmap"
|
||||
version = "6.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"crossbeam-utils",
|
||||
"hashbrown 0.14.5",
|
||||
"lock_api",
|
||||
"once_cell",
|
||||
"parking_lot_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "data-encoding"
|
||||
version = "2.10.0"
|
||||
|
|
@ -1048,6 +1179,20 @@ dependencies = [
|
|||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "der-parser"
|
||||
version = "10.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6"
|
||||
dependencies = [
|
||||
"asn1-rs",
|
||||
"displaydoc",
|
||||
"nom",
|
||||
"num-bigint",
|
||||
"num-traits",
|
||||
"rusticata-macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deranged"
|
||||
version = "0.5.6"
|
||||
|
|
@ -1187,8 +1332,10 @@ dependencies = [
|
|||
name = "distribution"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"bs58",
|
||||
"ed25519-dalek 2.2.0",
|
||||
"iroh",
|
||||
"iroh-relay",
|
||||
"rand_core 0.6.4",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
|
@ -2289,12 +2436,17 @@ version = "0.38.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c946095f060e6e59b9ff30cc26c75cdb758e7fb0cde8312c89e2144654989fcb"
|
||||
dependencies = [
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"iroh-metrics-derive",
|
||||
"itoa",
|
||||
"n0-error",
|
||||
"postcard",
|
||||
"reqwest 0.12.28",
|
||||
"ryu",
|
||||
"serde",
|
||||
"tokio",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
|
|
@ -2349,6 +2501,7 @@ dependencies = [
|
|||
"rustc-hash",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"rustls-platform-verifier",
|
||||
"slab",
|
||||
"sorted-index-buffer",
|
||||
"thiserror 2.0.18",
|
||||
|
|
@ -2376,9 +2529,12 @@ version = "0.96.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cd2b63e654b9dec799a73372cdc79b529ca6c7248c0c8de7da78a02e3a46f03c"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"blake3",
|
||||
"bytes",
|
||||
"cfg_aliases",
|
||||
"clap",
|
||||
"dashmap",
|
||||
"data-encoding",
|
||||
"derive_more",
|
||||
"getrandom 0.3.4",
|
||||
|
|
@ -2399,17 +2555,28 @@ dependencies = [
|
|||
"pkarr",
|
||||
"postcard",
|
||||
"rand",
|
||||
"rcgen",
|
||||
"reloadable-state",
|
||||
"reqwest 0.12.28",
|
||||
"rustls",
|
||||
"rustls-cert-file-reader",
|
||||
"rustls-cert-reloadable-resolver",
|
||||
"rustls-pki-types",
|
||||
"serde",
|
||||
"serde_bytes",
|
||||
"serde_json",
|
||||
"sha1",
|
||||
"simdutf8",
|
||||
"strum 0.27.2",
|
||||
"time",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tokio-rustls-acme",
|
||||
"tokio-util",
|
||||
"tokio-websockets",
|
||||
"toml 0.9.12+spec-1.1.0",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"url",
|
||||
"vergen-gitcl",
|
||||
"webpki-roots 1.0.6",
|
||||
|
|
@ -2593,20 +2760,6 @@ version = "1.0.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
|
||||
|
||||
[[package]]
|
||||
name = "local-runner"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"ctrlc",
|
||||
"iroh",
|
||||
"runtime-dashboard",
|
||||
"serde_json",
|
||||
"swactor",
|
||||
"swactor-ci",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lock_api"
|
||||
version = "0.4.14"
|
||||
|
|
@ -2683,6 +2836,12 @@ dependencies = [
|
|||
"regex-automata",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "matchit"
|
||||
version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.0"
|
||||
|
|
@ -2713,6 +2872,12 @@ version = "0.3.17"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||
|
||||
[[package]]
|
||||
name = "minimal-lexical"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.8.9"
|
||||
|
|
@ -2952,6 +3117,16 @@ dependencies = [
|
|||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nom"
|
||||
version = "7.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"minimal-lexical",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ntimestamp"
|
||||
version = "1.0.0"
|
||||
|
|
@ -2976,12 +3151,31 @@ dependencies = [
|
|||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-bigint"
|
||||
version = "0.4.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9"
|
||||
dependencies = [
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-conv"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050"
|
||||
|
||||
[[package]]
|
||||
name = "num-integer"
|
||||
version = "0.1.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
|
|
@ -3096,6 +3290,15 @@ dependencies = [
|
|||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "oid-registry"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7"
|
||||
dependencies = [
|
||||
"asn1-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.3"
|
||||
|
|
@ -3207,6 +3410,16 @@ version = "1.0.15"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
|
||||
|
||||
[[package]]
|
||||
name = "pem"
|
||||
version = "3.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pem-rfc7468"
|
||||
version = "1.0.0"
|
||||
|
|
@ -3770,6 +3983,20 @@ dependencies = [
|
|||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rcgen"
|
||||
version = "0.14.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "10b99e0098aa4082912d4c649628623db6aba77335e4f4569ff5083a6448b32e"
|
||||
dependencies = [
|
||||
"pem",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"time",
|
||||
"x509-parser",
|
||||
"yasna",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_syscall"
|
||||
version = "0.5.18"
|
||||
|
|
@ -3833,6 +4060,23 @@ version = "0.8.9"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c"
|
||||
|
||||
[[package]]
|
||||
name = "reloadable-core"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1dc20ac1418988b60072d783c9f68e28a173fb63493c127952f6face3b40c6e0"
|
||||
|
||||
[[package]]
|
||||
name = "reloadable-state"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3853ef78d45b50f8b989896304a85239539d39b7f866a000e8846b9b72d74ce8"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"reloadable-core",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "reqwest"
|
||||
version = "0.12.28"
|
||||
|
|
@ -3936,26 +4180,6 @@ dependencies = [
|
|||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "runtime-dashboard"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"crossbeam-queue",
|
||||
"crossterm",
|
||||
"ctrlc",
|
||||
"distribution",
|
||||
"iroh",
|
||||
"ratatui",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"swactor",
|
||||
"swactor-ci",
|
||||
"tiny_http",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc-demangle"
|
||||
version = "0.1.27"
|
||||
|
|
@ -3977,6 +4201,15 @@ dependencies = [
|
|||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rusticata-macros"
|
||||
version = "4.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632"
|
||||
dependencies = [
|
||||
"nom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "0.38.44"
|
||||
|
|
@ -4019,6 +4252,40 @@ dependencies = [
|
|||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-cert-file-reader"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8bb47c2a50fdfdaf95b0ac8b12620fc327da1fd4adbb30d0c56d866b005873ff"
|
||||
dependencies = [
|
||||
"rustls-cert-read",
|
||||
"rustls-pki-types",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-cert-read"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dd46e8c5ae4de3345c4786a83f99ec7aff287209b9e26fa883c473aeb28f19d5"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-cert-reloadable-resolver"
|
||||
version = "0.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fe1baa8a3a1f05eaa9fc55aed4342867f70e5c170ea3bfed1b38c51a4857c0c8"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"reloadable-state",
|
||||
"rustls",
|
||||
"rustls-cert-read",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-native-certs"
|
||||
version = "0.8.3"
|
||||
|
|
@ -4242,6 +4509,17 @@ dependencies = [
|
|||
"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 = "0.6.9"
|
||||
|
|
@ -4251,6 +4529,15 @@ dependencies = [
|
|||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_spanned"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_urlencoded"
|
||||
version = "0.7.1"
|
||||
|
|
@ -4276,6 +4563,17 @@ dependencies = [
|
|||
"unsafe-libyaml",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha1"
|
||||
version = "0.11.0-rc.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9c777f0a122a53fddb0beb6e706771197000b8eb5c9f42b5b850f450ef48c788"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"digest 0.11.0-rc.10",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha1_smol"
|
||||
version = "1.0.1"
|
||||
|
|
@ -4407,7 +4705,7 @@ dependencies = [
|
|||
"swactor",
|
||||
"swactor-ci",
|
||||
"tiny_http",
|
||||
"toml",
|
||||
"toml 0.8.23",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -4607,35 +4905,32 @@ dependencies = [
|
|||
name = "swactor-ci"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"ctrlc",
|
||||
"dashboard",
|
||||
"hex",
|
||||
"hmac",
|
||||
"iroh",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_yaml",
|
||||
"sha2 0.10.9",
|
||||
"swactor",
|
||||
"tiny_http",
|
||||
"tokio",
|
||||
"ureq",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "swactor-crypto-wasm"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"ed25519-dalek 2.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "swactor-datastore"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"clap",
|
||||
"ctrlc",
|
||||
"dashboard",
|
||||
"distribution",
|
||||
"getrandom 0.2.17",
|
||||
"proptest",
|
||||
"runtime-dashboard",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"shared-types",
|
||||
|
|
@ -4643,10 +4938,26 @@ dependencies = [
|
|||
"swactor-std",
|
||||
"tempfile",
|
||||
"tiny_http",
|
||||
"toml",
|
||||
"ureq",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "swactor-node"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"ctrlc",
|
||||
"dashboard",
|
||||
"distribution",
|
||||
"iroh",
|
||||
"libc",
|
||||
"serde",
|
||||
"swactor",
|
||||
"swactor-datastore",
|
||||
"swactor-std",
|
||||
"toml 0.8.23",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "swactor-std"
|
||||
version = "0.1.0"
|
||||
|
|
@ -4924,6 +5235,34 @@ dependencies = [
|
|||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-rustls-acme"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c31fcc374ec87d754358a5d0709ed1ab7671d51e0f70ddc3b17a11ac36604cfa"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
"futures",
|
||||
"log",
|
||||
"num-bigint",
|
||||
"pem",
|
||||
"proc-macro2",
|
||||
"rcgen",
|
||||
"reqwest 0.12.28",
|
||||
"ring",
|
||||
"rustls",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
"time",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"webpki-roots 1.0.6",
|
||||
"x509-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-stream"
|
||||
version = "0.1.18"
|
||||
|
|
@ -4979,11 +5318,26 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_spanned",
|
||||
"serde_spanned 0.6.9",
|
||||
"toml_datetime 0.6.11",
|
||||
"toml_edit 0.22.27",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml"
|
||||
version = "0.9.12+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"serde_core",
|
||||
"serde_spanned 1.0.4",
|
||||
"toml_datetime 0.7.5+spec-1.1.0",
|
||||
"toml_parser",
|
||||
"toml_writer",
|
||||
"winnow",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_datetime"
|
||||
version = "0.6.11"
|
||||
|
|
@ -5010,7 +5364,7 @@ checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
|
|||
dependencies = [
|
||||
"indexmap",
|
||||
"serde",
|
||||
"serde_spanned",
|
||||
"serde_spanned 0.6.9",
|
||||
"toml_datetime 0.6.11",
|
||||
"toml_write",
|
||||
"winnow",
|
||||
|
|
@ -5043,6 +5397,12 @@ version = "0.1.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
|
||||
|
||||
[[package]]
|
||||
name = "toml_writer"
|
||||
version = "1.0.6+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607"
|
||||
|
||||
[[package]]
|
||||
name = "tower"
|
||||
version = "0.5.3"
|
||||
|
|
@ -5056,6 +5416,7 @@ dependencies = [
|
|||
"tokio",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -5410,15 +5771,6 @@ dependencies = [
|
|||
"wit-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"swactor",
|
||||
"swactor-std",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen"
|
||||
version = "0.2.108"
|
||||
|
|
@ -5520,6 +5872,15 @@ dependencies = [
|
|||
"wasmparser 0.244.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-runtime"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"swactor",
|
||||
"swactor-std",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-streams"
|
||||
version = "0.4.2"
|
||||
|
|
@ -5662,7 +6023,7 @@ dependencies = [
|
|||
"serde",
|
||||
"serde_derive",
|
||||
"sha2 0.10.9",
|
||||
"toml",
|
||||
"toml 0.8.23",
|
||||
"windows-sys 0.59.0",
|
||||
"zstd",
|
||||
]
|
||||
|
|
@ -6550,6 +6911,24 @@ dependencies = [
|
|||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "x509-parser"
|
||||
version = "0.18.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202"
|
||||
dependencies = [
|
||||
"asn1-rs",
|
||||
"data-encoding",
|
||||
"der-parser",
|
||||
"lazy_static",
|
||||
"nom",
|
||||
"oid-registry",
|
||||
"ring",
|
||||
"rusticata-macros",
|
||||
"thiserror 2.0.18",
|
||||
"time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "xml-rs"
|
||||
version = "0.8.28"
|
||||
|
|
@ -6572,7 +6951,17 @@ dependencies = [
|
|||
"clap",
|
||||
"libc",
|
||||
"serde",
|
||||
"toml",
|
||||
"serde_json",
|
||||
"toml 0.8.23",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yasna"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd"
|
||||
dependencies = [
|
||||
"time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
|
|||
10
Cargo.toml
10
Cargo.toml
|
|
@ -2,22 +2,20 @@
|
|||
members = [
|
||||
".",
|
||||
"crates/python",
|
||||
"crates/wasm",
|
||||
"crates/wasm/runtime",
|
||||
"crates/bin-runner",
|
||||
"crates/simulation",
|
||||
"crates/runtime-dashboard",
|
||||
"crates/dashboard",
|
||||
"crates/distribution",
|
||||
"crates/std",
|
||||
"crates/datastore",
|
||||
"crates/shared-types",
|
||||
"crates/crypto-wasm",
|
||||
"crates/swactor-node",
|
||||
"tests/docker",
|
||||
"crates/ci",
|
||||
"crates/local-runner",
|
||||
"crates/ci-relay",
|
||||
"xtask",
|
||||
]
|
||||
exclude = ["tools/depgraph"]
|
||||
exclude = ["tools/depgraph", "crates/wasm/crypto"]
|
||||
|
||||
[package]
|
||||
name = "swactor"
|
||||
|
|
|
|||
11
Dockerfile
11
Dockerfile
|
|
@ -1,8 +1,3 @@
|
|||
FROM rust:1.93-slim AS builder
|
||||
WORKDIR /build
|
||||
COPY . .
|
||||
RUN cargo build --release -p runtime-dashboard --features node
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
COPY --from=builder /build/target/release/swactor-node /usr/local/bin/
|
||||
ENTRYPOINT ["swactor-node"]
|
||||
FROM scratch
|
||||
COPY target/x86_64-unknown-linux-musl/release/swactor /swactor
|
||||
ENTRYPOINT ["/swactor"]
|
||||
|
|
|
|||
|
|
@ -1,19 +0,0 @@
|
|||
[package]
|
||||
name = "ci-relay"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[[bin]]
|
||||
name = "ci-relay"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
swactor-ci = { path = "../ci", features = ["local"] }
|
||||
iroh = "0.96"
|
||||
tokio = { version = "1", features = ["rt-multi-thread"] }
|
||||
tiny_http = "0.12"
|
||||
serde_json = "1"
|
||||
hmac = "0.12"
|
||||
sha2 = "0.10"
|
||||
hex = "0.4"
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
|
|
@ -9,13 +9,20 @@ serde = { version = "1", features = ["derive"] }
|
|||
serde_yaml = "0.9"
|
||||
serde_json = "1"
|
||||
|
||||
# Local runner library dependencies
|
||||
# Shared optional deps (used by lib local feature and binaries)
|
||||
tiny_http = { version = "0.12", optional = true }
|
||||
ureq = { version = "2", features = ["json"], optional = true }
|
||||
hmac = { version = "0.12", optional = true }
|
||||
sha2 = { version = "0.10", optional = true }
|
||||
hex = { version = "0.4", optional = true }
|
||||
|
||||
# Binary-only optional deps
|
||||
iroh = { version = "0.96", optional = true }
|
||||
tokio = { version = "1", features = ["rt-multi-thread"], optional = true }
|
||||
clap = { version = "4", features = ["derive"], optional = true }
|
||||
ctrlc = { version = "3", optional = true }
|
||||
dashboard = { path = "../dashboard", optional = true }
|
||||
|
||||
[features]
|
||||
default = []
|
||||
local = [
|
||||
|
|
@ -25,5 +32,29 @@ local = [
|
|||
"dep:sha2",
|
||||
"dep:hex",
|
||||
]
|
||||
relay = [
|
||||
"local",
|
||||
"dep:iroh",
|
||||
"dep:tokio",
|
||||
"dep:clap",
|
||||
]
|
||||
runner = [
|
||||
"local",
|
||||
"dep:iroh",
|
||||
"dep:tokio",
|
||||
"dep:clap",
|
||||
"dep:ctrlc",
|
||||
"dep:dashboard",
|
||||
]
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
[[bin]]
|
||||
name = "ci-relay"
|
||||
path = "src/bin/ci_relay.rs"
|
||||
required-features = ["relay"]
|
||||
|
||||
[[bin]]
|
||||
name = "local-runner"
|
||||
path = "src/bin/local_runner.rs"
|
||||
required-features = ["runner"]
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ use swactor::actor::ActorAddress;
|
|||
use swactor::config::RuntimeConfig;
|
||||
use swactor::runtime::Runtime;
|
||||
|
||||
use runtime_dashboard::ci_collector::{
|
||||
use dashboard::ci_collector::{
|
||||
CiSnapshot, CiStatsProvider, JobSnapshot, PipelineSnapshot, ProvisionerStatus,
|
||||
};
|
||||
|
||||
|
|
@ -92,22 +92,34 @@ impl CiStatsProvider for LocalCiSnapshotProvider {
|
|||
}
|
||||
|
||||
fn pipeline_to_dashboard(p: &PipelineExecution) -> PipelineSnapshot {
|
||||
// Convert between swactor_ci types and dashboard::ci_collector types
|
||||
// (structurally identical but separate to avoid a dependency cycle).
|
||||
let status: dashboard::ci_collector::PipelineStatus =
|
||||
serde_json::from_value(serde_json::to_value(&p.status).unwrap()).unwrap();
|
||||
|
||||
PipelineSnapshot {
|
||||
pipeline_id: p.pipeline_id,
|
||||
pipeline_id: dashboard::ci_collector::PipelineId(p.pipeline_id.0),
|
||||
pipeline_name: p.pipeline_name.clone(),
|
||||
repo_owner: p.repo_owner.clone(),
|
||||
repo_name: p.repo_name.clone(),
|
||||
commit_sha: p.commit_sha.clone(),
|
||||
branch: p.branch.clone(),
|
||||
status: p.status.clone(),
|
||||
status,
|
||||
jobs: p
|
||||
.jobs
|
||||
.values()
|
||||
.map(|j| JobSnapshot {
|
||||
job_id: j.job_id.clone(),
|
||||
.map(|j| {
|
||||
let job_status: dashboard::ci_collector::JobStatus =
|
||||
serde_json::from_value(serde_json::to_value(&j.status).unwrap()).unwrap();
|
||||
JobSnapshot {
|
||||
job_id: dashboard::ci_collector::JobId {
|
||||
pipeline_id: dashboard::ci_collector::PipelineId(j.job_id.pipeline_id.0),
|
||||
job_name: j.job_id.job_name.clone(),
|
||||
},
|
||||
job_name: j.definition.name.clone(),
|
||||
status: j.status.clone(),
|
||||
status: job_status,
|
||||
output_line_count: j.output_lines.len(),
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
|
|
@ -128,7 +140,7 @@ fn main() {
|
|||
|
||||
// Optionally start dashboard.
|
||||
let dash = args.dashboard_port.map(|port| {
|
||||
let d = runtime_dashboard::start_dashboard(runtime_dashboard::DashboardConfig {
|
||||
let d = dashboard::start_dashboard(dashboard::DashboardConfig {
|
||||
port,
|
||||
..Default::default()
|
||||
});
|
||||
|
|
@ -138,7 +150,7 @@ fn main() {
|
|||
|
||||
// Create 2-thread runtime.
|
||||
let num_threads = 2;
|
||||
let collector = runtime_dashboard::collector::StatsCollector::new(num_threads);
|
||||
let collector = dashboard::collector::StatsCollector::new(num_threads);
|
||||
let mut rt = Runtime::new(RuntimeConfig {
|
||||
num_threads,
|
||||
max_actors: 256,
|
||||
|
|
@ -196,3 +196,50 @@ When diagnosing a runtime, a useful sequence:
|
|||
| Growing actors_after vs actors_before | `diff 5` | Unbounded actor spawning |
|
||||
| type_mismatches > 0 | `overview` | Messages routed to wrong actor type |
|
||||
| panics > 0 | `overview` | Actor handlers are panicking |
|
||||
|
||||
### Distribution & Peer Management API
|
||||
|
||||
#### GET /api/distribution
|
||||
Returns the distribution node snapshot: node ID, members list, routing table, peer auth mode, etc.
|
||||
|
||||
#### GET /api/peers
|
||||
Returns the peer allow-list. No auth required.
|
||||
|
||||
#### POST /api/peers/add (requires Bearer token)
|
||||
Add a peer to the allow-list and trigger a SWIM join.
|
||||
Body: `{"node_id": "<hex or base58>", "label": "optional-label"}`
|
||||
The `node_id` field accepts either 64-char hex or base58-encoded invite codes.
|
||||
|
||||
#### POST /api/peers/remove (requires Bearer token)
|
||||
Remove a peer from the allow-list.
|
||||
Body: `{"node_id": "<hex>"}`
|
||||
|
||||
### Agent Peering Playbook
|
||||
|
||||
To programmatically join two nodes:
|
||||
|
||||
1. Get each node's info:
|
||||
```
|
||||
curl http://NODE_A:9090/api/distribution
|
||||
curl http://NODE_B:9090/api/distribution
|
||||
```
|
||||
Extract `invite_code` and `node_id` from each response.
|
||||
|
||||
2. Add each node to the other's allow-list + trigger join:
|
||||
```
|
||||
curl -X POST http://NODE_A:9090/api/peers/add \
|
||||
-H "Authorization: Bearer <token_a>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"node_id": "<node_b_invite_code>", "label": "node-b"}'
|
||||
|
||||
curl -X POST http://NODE_B:9090/api/peers/add \
|
||||
-H "Authorization: Bearer <token_b>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"node_id": "<node_a_invite_code>", "label": "node-a"}'
|
||||
```
|
||||
|
||||
3. Verify (poll until members appear, ~5-10s):
|
||||
```
|
||||
curl http://NODE_A:9090/api/distribution | jq '.members'
|
||||
```
|
||||
Should list node B as alive.
|
||||
|
|
@ -1,15 +1,18 @@
|
|||
[package]
|
||||
name = "runtime-dashboard"
|
||||
name = "dashboard"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
swactor = { path = "../..", features = ["serde", "tracing"] }
|
||||
swactor-std = { path = "../std", default-features = false }
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["registry"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tiny_http = "0.12"
|
||||
axum = "0.8"
|
||||
tokio = { version = "1", features = ["net", "rt-multi-thread", "sync", "time"] }
|
||||
tokio-stream = "0.1"
|
||||
crossbeam-queue = "0.3.12"
|
||||
ratatui = { version = "0.29", optional = true, default-features = false, features = ["crossterm"] }
|
||||
crossterm = { version = "0.28", optional = true }
|
||||
|
|
@ -17,7 +20,6 @@ distribution = { path = "../distribution", optional = true }
|
|||
clap = { version = "4", features = ["derive"], optional = true }
|
||||
ctrlc = "3"
|
||||
iroh = { version = "0.96", optional = true }
|
||||
swactor-ci = { path = "../ci", optional = true }
|
||||
|
||||
[features]
|
||||
default = ["distribution"]
|
||||
|
|
@ -26,7 +28,6 @@ distribution = ["dep:distribution"]
|
|||
node = ["distribution", "dep:clap", "swactor/transport", "tcp"]
|
||||
tcp = ["distribution/tcp"]
|
||||
iroh = ["distribution/iroh", "dep:iroh"]
|
||||
ci = ["dep:swactor-ci"]
|
||||
|
||||
[[bin]]
|
||||
name = "swactor-tui"
|
||||
|
|
@ -5,8 +5,8 @@ use swactor::actor::{ActorAddress, ActorInterface, Ctx};
|
|||
use swactor::config::{BackoffPolicy, RuntimeConfig};
|
||||
use swactor::runtime::Runtime;
|
||||
|
||||
use runtime_dashboard::collector::StatsCollector;
|
||||
use runtime_dashboard::{start_dashboard, DashboardConfig};
|
||||
use dashboard::collector::StatsCollector;
|
||||
use dashboard::{start_dashboard, DashboardConfig};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Actors
|
||||
|
|
@ -102,7 +102,7 @@ fn run_for(duration: Duration, mut tick: impl FnMut()) {
|
|||
// Scenarios
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn scenario_single_actor(dash: &runtime_dashboard::DashboardHandle) {
|
||||
fn scenario_single_actor(dash: &dashboard::DashboardHandle) {
|
||||
eprintln!(" [1/4] Single-actor bombardment (5s)");
|
||||
let collector = StatsCollector::new(4);
|
||||
let mut rt = Runtime::new(bench_config(4, 64, 100_000));
|
||||
|
|
@ -122,7 +122,7 @@ fn scenario_single_actor(dash: &runtime_dashboard::DashboardHandle) {
|
|||
handle.join();
|
||||
}
|
||||
|
||||
fn scenario_multi_actor(dash: &runtime_dashboard::DashboardHandle) {
|
||||
fn scenario_multi_actor(dash: &dashboard::DashboardHandle) {
|
||||
eprintln!(" [2/4] Multi-actor fan-out (5s)");
|
||||
let collector = StatsCollector::new(4);
|
||||
let mut rt = Runtime::new(bench_config(4, 128, 10_000));
|
||||
|
|
@ -146,7 +146,7 @@ fn scenario_multi_actor(dash: &runtime_dashboard::DashboardHandle) {
|
|||
handle.join();
|
||||
}
|
||||
|
||||
fn scenario_ring(dash: &runtime_dashboard::DashboardHandle) {
|
||||
fn scenario_ring(dash: &dashboard::DashboardHandle) {
|
||||
eprintln!(" [3/4] Ring topology (5s)");
|
||||
let ring_size = 100;
|
||||
let collector = StatsCollector::new(4);
|
||||
|
|
@ -182,7 +182,7 @@ fn scenario_ring(dash: &runtime_dashboard::DashboardHandle) {
|
|||
handle.join();
|
||||
}
|
||||
|
||||
fn scenario_spawn_storm(dash: &runtime_dashboard::DashboardHandle) {
|
||||
fn scenario_spawn_storm(dash: &dashboard::DashboardHandle) {
|
||||
eprintln!(" [4/4] Spawn storm (5s)");
|
||||
let collector = StatsCollector::new(4);
|
||||
let mut rt = Runtime::new(bench_config(4, 50_000, 1_024));
|
||||
|
|
@ -13,9 +13,9 @@ use distribution::swim::node::NodeAction;
|
|||
use distribution::swim::probe::SwimConfig;
|
||||
use distribution::types::NodeId;
|
||||
|
||||
use runtime_dashboard::collector::StatsCollector;
|
||||
use runtime_dashboard::distribution_collector::DistributionStatsProvider;
|
||||
use runtime_dashboard::{start_dashboard, DashboardConfig};
|
||||
use dashboard::collector::StatsCollector;
|
||||
use dashboard::distribution_collector::DistributionStatsProvider;
|
||||
use dashboard::{start_dashboard, DashboardConfig};
|
||||
|
||||
// ── Demo actors ─────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -7,8 +7,8 @@ use swactor::actor::{ActorAddress, ActorInterface, Ctx};
|
|||
use swactor::config::RuntimeConfig;
|
||||
use swactor::runtime::Runtime;
|
||||
|
||||
use runtime_dashboard::collector::StatsCollector;
|
||||
use runtime_dashboard::{serve_replay, start_dashboard, DashboardConfig, ReplayConfig};
|
||||
use dashboard::collector::StatsCollector;
|
||||
use dashboard::{serve_replay, start_dashboard, DashboardConfig, ReplayConfig};
|
||||
|
||||
// ── Demo actors ─────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -135,8 +135,11 @@ pub const ACTOR_DETAIL_HTML: &str = r##"<!DOCTYPE html>
|
|||
</div>
|
||||
|
||||
<div class="info-card">
|
||||
<div class="info-row" id="nameRow" style="display:none;">
|
||||
<div><div class="info-label">Name</div><div class="info-value" id="addrName" style="color:#00bcd4;">—</div></div>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<div><div class="info-label">Address</div><div class="info-value" id="addrFull">—</div></div>
|
||||
<div><div class="info-label">Address</div><div class="info-value" id="addrFull" style="font-size:12px;color:#aaa;font-weight:400;">—</div></div>
|
||||
<div><div class="info-label">Worker</div><div class="info-value" id="addrWorker">—</div></div>
|
||||
<div><div class="info-label">Status</div><div class="info-value" id="addrStatus">—</div></div>
|
||||
</div>
|
||||
|
|
@ -237,7 +240,14 @@ pub const ACTOR_DETAIL_HTML: &str = r##"<!DOCTYPE html>
|
|||
if (!actor) return;
|
||||
|
||||
var hex = formatAddr(actor.address);
|
||||
if (actor.name) {
|
||||
document.getElementById('addrBreadcrumb').textContent = actor.name;
|
||||
document.getElementById('nameRow').style.display = '';
|
||||
document.getElementById('addrName').textContent = actor.name;
|
||||
} else {
|
||||
document.getElementById('addrBreadcrumb').textContent = shortAddr(hex);
|
||||
document.getElementById('nameRow').style.display = 'none';
|
||||
}
|
||||
document.getElementById('addrFull').textContent = hex;
|
||||
document.getElementById('addrWorker').textContent = 'W' + actor.worker_id;
|
||||
|
||||
|
|
@ -478,7 +478,8 @@ pub const ACTORS_HTML: &str = r##"<!DOCTYPE html>
|
|||
if (filter) {
|
||||
var hex = addrToHex(a.address).toLowerCase();
|
||||
var msgType = (a.last_msg_type || '').toLowerCase();
|
||||
if (hex.indexOf(filter) < 0 && ('w' + a.worker_id).indexOf(filter) < 0 && msgType.indexOf(filter) < 0) {
|
||||
var actorName = (a.name || '').toLowerCase();
|
||||
if (hex.indexOf(filter) < 0 && ('w' + a.worker_id).indexOf(filter) < 0 && msgType.indexOf(filter) < 0 && actorName.indexOf(filter) < 0) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -542,8 +543,11 @@ pub const ACTORS_HTML: &str = r##"<!DOCTYPE html>
|
|||
tr.className = 'clickable' + (fullHex === focusedAddrHex ? ' focused' : '');
|
||||
if (a.poisoned) tr.style.opacity = '0.6';
|
||||
tr.setAttribute('data-addr', fullHex);
|
||||
var addrCell = a.name
|
||||
? '<td><span style="color:#00bcd4;font-weight:600;">' + escapeHtml(a.name) + '</span> <span style="color:#555;font-size:10px;">' + escapeHtml(hex) + '</span>' + (a.poisoned ? ' <span style="color:#f44336;font-size:9px;">DEAD</span>' : '') + '</td>'
|
||||
: '<td style="color:#aaa;font-size:11px;">' + escapeHtml(hex) + (a.poisoned ? ' <span style="color:#f44336;font-size:9px;">DEAD</span>' : '') + '</td>';
|
||||
tr.innerHTML =
|
||||
'<td style="color:#aaa;font-size:11px;">' + escapeHtml(hex) + (a.poisoned ? ' <span style="color:#f44336;font-size:9px;">DEAD</span>' : '') + '</td>' +
|
||||
addrCell +
|
||||
'<td>W' + a.worker_id + '</td>' +
|
||||
'<td>' + a.mailbox_depth + '</td>' +
|
||||
'<td>' + (a.messages_processed || 0).toLocaleString() + '</td>' +
|
||||
|
|
@ -593,8 +597,14 @@ pub const ACTORS_HTML: &str = r##"<!DOCTYPE html>
|
|||
return;
|
||||
}
|
||||
|
||||
document.getElementById('detailAddr').innerHTML = '<a href="/actor/' + focusedAddrHex + '" style="color:#aaa;text-decoration:none;">' + escapeHtml(addrToHex(actor.address)) + '</a>';
|
||||
document.getElementById('detailFullAddr').innerHTML = '<a href="/actor/' + focusedAddrHex + '" style="color:#fff;text-decoration:none;">' + escapeHtml(focusedAddrHex) + '</a>';
|
||||
var detailAddrText = actor.name
|
||||
? '<a href="/actor/' + focusedAddrHex + '" style="color:#00bcd4;text-decoration:none;font-weight:600;">' + escapeHtml(actor.name) + '</a>'
|
||||
: '<a href="/actor/' + focusedAddrHex + '" style="color:#aaa;text-decoration:none;">' + escapeHtml(addrToHex(actor.address)) + '</a>';
|
||||
document.getElementById('detailAddr').innerHTML = detailAddrText;
|
||||
var detailFullText = actor.name
|
||||
? '<span style="color:#00bcd4;font-weight:600;">' + escapeHtml(actor.name) + '</span> <span style="color:#555;font-size:9px;">' + escapeHtml(focusedAddrHex) + '</span>'
|
||||
: '<a href="/actor/' + focusedAddrHex + '" style="color:#fff;text-decoration:none;">' + escapeHtml(focusedAddrHex) + '</a>';
|
||||
document.getElementById('detailFullAddr').innerHTML = detailFullText;
|
||||
document.getElementById('detailWorker').textContent = 'W' + actor.worker_id;
|
||||
document.getElementById('detailMailbox').textContent = actor.mailbox_depth;
|
||||
document.getElementById('detailMsgCount').textContent = (actor.messages_processed || 0).toLocaleString();
|
||||
|
|
@ -14,9 +14,9 @@ use distribution::registry::RegistryConfig;
|
|||
use distribution::snapshot::DistributionNodeSnapshot;
|
||||
use distribution::swim::probe::SwimConfig;
|
||||
|
||||
use runtime_dashboard::collector::StatsCollector;
|
||||
use runtime_dashboard::distribution_collector::DistributionStatsProvider;
|
||||
use runtime_dashboard::{start_dashboard, DashboardConfig};
|
||||
use dashboard::collector::StatsCollector;
|
||||
use dashboard::distribution_collector::DistributionStatsProvider;
|
||||
use dashboard::{start_dashboard, DashboardConfig};
|
||||
|
||||
// ── CLI ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -95,6 +95,7 @@ fn main() {
|
|||
..Default::default()
|
||||
});
|
||||
dash.install_tracing();
|
||||
dash.start_http_standalone();
|
||||
|
||||
// Create actor runtime
|
||||
let num_threads = 2;
|
||||
|
|
@ -154,7 +155,7 @@ fn run_tcp(
|
|||
args: Args,
|
||||
node_config: DistributedNodeConfig,
|
||||
handle: &swactor::runtime::RuntimeHandle,
|
||||
dash: &runtime_dashboard::DashboardHandle,
|
||||
dash: &dashboard::DashboardHandle,
|
||||
stop: &Arc<AtomicBool>,
|
||||
) {
|
||||
use distribution::driver::NodeDriver;
|
||||
|
|
@ -209,7 +210,7 @@ fn run_iroh(
|
|||
args: Args,
|
||||
node_config: DistributedNodeConfig,
|
||||
handle: &swactor::runtime::RuntimeHandle,
|
||||
dash: &runtime_dashboard::DashboardHandle,
|
||||
dash: &dashboard::DashboardHandle,
|
||||
stop: &Arc<AtomicBool>,
|
||||
) {
|
||||
use distribution::iroh_driver::{IrohDriver, IrohDriverConfig};
|
||||
|
|
@ -219,6 +220,7 @@ fn run_iroh(
|
|||
secret_key: None,
|
||||
relay_mode: RelayMode::Default,
|
||||
node: node_config,
|
||||
peer_auth: None,
|
||||
};
|
||||
let mut driver = IrohDriver::new(iroh_config).expect("failed to create iroh driver");
|
||||
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
use runtime_dashboard::tui::types::RuntimeEndpoint;
|
||||
use runtime_dashboard::tui::{start_tui_remote, TuiConfig};
|
||||
use dashboard::tui::types::RuntimeEndpoint;
|
||||
use dashboard::tui::{start_tui_remote, TuiConfig};
|
||||
|
||||
fn main() -> std::io::Result<()> {
|
||||
let url = std::env::args()
|
||||
|
|
@ -2,7 +2,39 @@
|
|||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use swactor_ci::{JobId, JobStatus, PipelineId, PipelineStatus};
|
||||
// CI types defined locally to avoid a cyclic dependency with swactor-ci.
|
||||
|
||||
/// Unique identifier for a pipeline execution.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct PipelineId(pub u64);
|
||||
|
||||
/// Unique identifier for a job within a pipeline.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct JobId {
|
||||
pub pipeline_id: PipelineId,
|
||||
pub job_name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum JobStatus {
|
||||
Pending,
|
||||
WaitingForProvisioner,
|
||||
Provisioning,
|
||||
Running,
|
||||
Passed,
|
||||
Failed { reason: String },
|
||||
Skipped,
|
||||
Interrupted,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum PipelineStatus {
|
||||
Pending,
|
||||
Running,
|
||||
Passed,
|
||||
Failed,
|
||||
Error { reason: String },
|
||||
}
|
||||
|
||||
// ─── Stats Provider ─────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -6,6 +6,7 @@
|
|||
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
use swactor::runtime::Runtime;
|
||||
use swactor::stats::{ActorInfo, ActorSnapshot, RuntimeStats, StatsHook};
|
||||
|
||||
/// Collects per-actor snapshots pushed by worker threads.
|
||||
|
|
@ -48,6 +49,21 @@ impl crate::command::StatsEnricher for StatsCollector {
|
|||
}
|
||||
}
|
||||
|
||||
/// Enrich actor names from the runtime's StdExtension name registry.
|
||||
pub fn enrich_names(stats: &mut RuntimeStats, runtime: &Runtime) {
|
||||
let ext = match runtime.extension() {
|
||||
Some(ext) => ext,
|
||||
None => return,
|
||||
};
|
||||
let std_ext = match ext.as_any().downcast_ref::<swactor_std::StdExtension>() {
|
||||
Some(ext) => ext,
|
||||
None => return,
|
||||
};
|
||||
for actor in &mut stats.actor_details {
|
||||
actor.name = std_ext.resolve_name(&actor.address);
|
||||
}
|
||||
}
|
||||
|
||||
impl StatsHook for StatsCollector {
|
||||
fn on_tick(&self, worker_id: usize, snapshots: &[ActorSnapshot]) {
|
||||
if let Some(slot) = self.slots.get(worker_id) {
|
||||
|
|
@ -60,6 +76,7 @@ impl StatsHook for StatsCollector {
|
|||
last_msg_type: s.last_msg_type.map(|t| t.to_string()),
|
||||
messages_processed: s.messages_processed,
|
||||
poisoned: s.poisoned,
|
||||
name: None,
|
||||
message_type_counts: s.message_type_counts.iter().map(|(k, v)| (k.to_string(), *v)).collect(),
|
||||
}));
|
||||
}
|
||||
|
|
@ -356,6 +356,14 @@ pub const DASHBOARD_HTML: &str = r##"<!DOCTYPE html>
|
|||
|
||||
renderWorkerCards(data);
|
||||
|
||||
var nameMap = {};
|
||||
if (data.actor_details) {
|
||||
data.actor_details.forEach(function(a) {
|
||||
var h = formatAddr(a.address);
|
||||
if (a.name) nameMap[h] = a.name;
|
||||
});
|
||||
}
|
||||
|
||||
var tbody = document.getElementById('actorTableBody');
|
||||
tbody.innerHTML = '';
|
||||
if (data.actors) {
|
||||
|
|
@ -372,7 +380,13 @@ pub const DASHBOARD_HTML: &str = r##"<!DOCTYPE html>
|
|||
hex += '\u2026';
|
||||
}
|
||||
var tr = document.createElement('tr');
|
||||
tr.innerHTML = '<td style="color:#aaa;font-size:11px;"><a href="/actor/' + hex + '" style="color:#aaa;text-decoration:none;">' + hex + '</a></td><td>W' + wid + '</td>';
|
||||
var addrCell;
|
||||
if (nameMap[hex]) {
|
||||
addrCell = '<td><a href="/actor/' + hex + '" style="text-decoration:none;"><span style="color:#00bcd4;font-weight:600;">' + escapeHtml(nameMap[hex]) + '</span> <span style="color:#555;font-size:10px;">' + hex + '</span></a></td>';
|
||||
} else {
|
||||
addrCell = '<td style="color:#aaa;font-size:11px;"><a href="/actor/' + hex + '" style="color:#aaa;text-decoration:none;">' + hex + '</a></td>';
|
||||
}
|
||||
tr.innerHTML = addrCell + '<td>W' + wid + '</td>';
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
if (data.actors.length > 200) {
|
||||
|
|
@ -493,7 +507,13 @@ pub const DASHBOARD_HTML: &str = r##"<!DOCTYPE html>
|
|||
var msgShort = hasMsg ? shortTypeName(a.last_msg_type) : 'none';
|
||||
var msgClass = hasMsg ? 'msg-type' : 'msg-type none';
|
||||
var title = hasMsg ? ' title="' + escapeHtml(a.last_msg_type) + '"' : '';
|
||||
rows += '<tr><td style="color:#aaa;"><a href="/actor/' + hex + '" style="color:#aaa;text-decoration:none;">' + hex + '</a></td>' +
|
||||
var addrHtml;
|
||||
if (a.name) {
|
||||
addrHtml = '<td><a href="/actor/' + hex + '" style="text-decoration:none;"><span style="color:#00bcd4;font-weight:600;">' + escapeHtml(a.name) + '</span> <span style="color:#555;font-size:10px;">' + hex + '</span></a></td>';
|
||||
} else {
|
||||
addrHtml = '<td style="color:#aaa;"><a href="/actor/' + hex + '" style="color:#aaa;text-decoration:none;">' + hex + '</a></td>';
|
||||
}
|
||||
rows += '<tr>' + addrHtml +
|
||||
'<td>' + a.mailbox_depth + '</td>' +
|
||||
'<td class="' + msgClass + '"' + title + '>' + escapeHtml(msgShort) + '</td></tr>';
|
||||
});
|
||||
75
crates/dashboard/src/datastore_collector.rs
Normal file
75
crates/dashboard/src/datastore_collector.rs
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
//! Datastore stats provider for the runtime dashboard.
|
||||
//!
|
||||
//! The trait returns a pre-serialized JSON string so that `runtime-dashboard`
|
||||
//! has no compile-time dependency on `swactor-datastore` (which would create a
|
||||
//! circular dependency since `swactor-datastore[node]` depends on us).
|
||||
//!
|
||||
//! The `swactor-datastore` crate implements this trait in its `node` feature.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Scope filter for listing objects.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ListScope {
|
||||
Local,
|
||||
Swarm,
|
||||
}
|
||||
|
||||
/// Trait for providing datastore stats and CRUD operations to the dashboard.
|
||||
///
|
||||
/// Implementations capture a point-in-time snapshot as serialized JSON.
|
||||
/// The dashboard polls this every ~200ms via SSE.
|
||||
///
|
||||
/// All command methods have default implementations returning `Err` so that
|
||||
/// existing `DatastoreMetrics` impls continue to compile without changes.
|
||||
pub trait DatastoreStatsProvider: Send + Sync {
|
||||
/// Return a JSON-serialized datastore snapshot, or `None` if unavailable.
|
||||
fn snapshot_json(&self) -> Option<String>;
|
||||
|
||||
/// List objects as a JSON string. `scope` selects local-only or swarm-wide.
|
||||
fn list_objects(&self, _name_filter: Option<&str>, _scope: ListScope) -> Result<String, String> {
|
||||
Err("not supported".into())
|
||||
}
|
||||
|
||||
/// Get a single object's metadata + manifest as JSON.
|
||||
fn get_object(&self, _hash: &str) -> Result<String, String> {
|
||||
Err("not supported".into())
|
||||
}
|
||||
|
||||
/// Get the raw binary data for an object.
|
||||
fn get_data(&self, _hash: &str) -> Result<Vec<u8>, String> {
|
||||
Err("not supported".into())
|
||||
}
|
||||
|
||||
/// Store data, optionally with a name. Returns JSON with `content_hash`.
|
||||
fn put_data(&self, _data: Vec<u8>, _name: Option<String>) -> Result<String, String> {
|
||||
Err("not supported".into())
|
||||
}
|
||||
|
||||
/// Delete an object by hash. Returns JSON confirmation.
|
||||
fn delete_object(&self, _hash: &str) -> Result<String, String> {
|
||||
Err("not supported".into())
|
||||
}
|
||||
|
||||
/// Get node status as JSON.
|
||||
fn node_status(&self) -> Result<String, String> {
|
||||
Err("not supported".into())
|
||||
}
|
||||
|
||||
/// Whether the datastore is currently running.
|
||||
fn is_running(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Shut down the datastore actors.
|
||||
fn shutdown_datastore(&self) -> Result<(), String> {
|
||||
Err("not supported".into())
|
||||
}
|
||||
}
|
||||
|
||||
/// Factory for creating a new datastore instance from the dashboard.
|
||||
pub trait DatastoreFactory: Send + Sync {
|
||||
/// Start a datastore with optional persistent storage path.
|
||||
/// Returns a provider that can be installed into the dashboard.
|
||||
fn start_datastore(&self, storage_path: Option<String>) -> Result<Arc<dyn DatastoreStatsProvider>, String>;
|
||||
}
|
||||
571
crates/dashboard/src/datastore_html.rs
Normal file
571
crates/dashboard/src/datastore_html.rs
Normal file
|
|
@ -0,0 +1,571 @@
|
|||
pub const DATASTORE_HTML: &str = r##"<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Swactor Runtime – Datastore</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: 'Menlo', 'Consolas', 'Monaco', monospace; background: #0f1117; color: #e0e0e0; font-size: 13px; }
|
||||
|
||||
.header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 12px 20px; background: #161822; border-bottom: 1px solid #2a2d3e;
|
||||
}
|
||||
.header-left { display: flex; align-items: center; }
|
||||
.header h1 { font-size: 16px; font-weight: 600; color: #fff; }
|
||||
.status-dot {
|
||||
width: 10px; height: 10px; border-radius: 50%; background: #4caf50;
|
||||
display: inline-block; margin-left: 8px; vertical-align: middle;
|
||||
}
|
||||
.status-dot.disconnected { background: #f44336; }
|
||||
.status-dot.done { background: #ff9800; }
|
||||
|
||||
.nav-links { display: flex; gap: 4px; margin-left: 20px; }
|
||||
.nav-link {
|
||||
color: #888; text-decoration: none; font-size: 12px;
|
||||
padding: 4px 10px; border-radius: 3px; transition: color 0.2s;
|
||||
}
|
||||
.nav-link:hover { color: #e0e0e0; }
|
||||
.nav-link.active { color: #fff; background: #2a2d3e; }
|
||||
|
||||
.header-right { display: flex; align-items: center; gap: 12px; }
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px; padding: 12px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: #161822; border: 1px solid #2a2d3e; border-radius: 6px;
|
||||
padding: 14px; overflow: hidden;
|
||||
}
|
||||
.panel h2 { font-size: 12px; color: #888; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 10px; }
|
||||
|
||||
.full-width { grid-column: 1 / -1; }
|
||||
|
||||
.stats-cards {
|
||||
display: grid; grid-template-columns: repeat(5, 1fr); gap: 10px;
|
||||
}
|
||||
.stat-card {
|
||||
background: #1c1f2e; border-radius: 4px; padding: 10px; text-align: center;
|
||||
}
|
||||
.stat-card .value { font-size: 22px; font-weight: 700; color: #fff; }
|
||||
.stat-card .label { font-size: 10px; color: #888; text-transform: uppercase; margin-top: 2px; }
|
||||
|
||||
.event-timeline {
|
||||
max-height: 400px; overflow-y: auto;
|
||||
}
|
||||
.event-row {
|
||||
display: flex; gap: 8px; padding: 4px 0; border-bottom: 1px solid #1c1f2e;
|
||||
font-size: 11px; align-items: center;
|
||||
}
|
||||
.event-time { color: #555; min-width: 70px; }
|
||||
.event-kind {
|
||||
min-width: 48px; font-weight: 700; text-transform: uppercase; font-size: 10px;
|
||||
padding: 1px 6px; border-radius: 3px; text-align: center;
|
||||
}
|
||||
.event-kind.put { background: #1b3a2a; color: #4caf50; }
|
||||
.event-kind.get { background: #1a2a3e; color: #2196f3; }
|
||||
.event-kind.delete { background: #3a1a1a; color: #f44336; }
|
||||
.event-hash { color: #aaa; font-size: 10px; min-width: 120px; }
|
||||
.event-name { color: #e0e0e0; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.event-size { color: #888; min-width: 70px; text-align: right; }
|
||||
|
||||
.objects-table { max-height: 400px; overflow-y: auto; }
|
||||
.objects-table table { width: 100%; border-collapse: collapse; }
|
||||
.objects-table th, .objects-table td {
|
||||
padding: 4px 8px; text-align: left; border-bottom: 1px solid #1c1f2e; font-size: 11px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.objects-table th { color: #888; font-weight: 500; position: sticky; top: 0; background: #161822; }
|
||||
.objects-table tr { cursor: pointer; }
|
||||
.objects-table tr:hover td { background: #1c1f2e; }
|
||||
|
||||
.transfers-section { display: none; }
|
||||
.transfers-section.visible { display: block; }
|
||||
.transfer-row {
|
||||
display: flex; align-items: center; gap: 10px; margin-bottom: 8px;
|
||||
}
|
||||
.transfer-hash { color: #aaa; font-size: 10px; min-width: 100px; }
|
||||
.transfer-bar {
|
||||
flex: 1; height: 16px; background: #1c1f2e; border-radius: 3px; overflow: hidden;
|
||||
}
|
||||
.transfer-fill {
|
||||
height: 100%; background: #6366f1; border-radius: 3px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
.transfer-label { color: #888; font-size: 11px; min-width: 80px; text-align: right; }
|
||||
|
||||
/* Upload panel */
|
||||
.upload-row {
|
||||
display: flex; gap: 10px; align-items: center; flex-wrap: wrap;
|
||||
}
|
||||
input[type="file"] {
|
||||
background: #1c1f2e; color: #e0e0e0; border: 1px solid #2a2d3e;
|
||||
border-radius: 4px; padding: 8px; font-family: inherit; font-size: 13px;
|
||||
min-height: 38px; cursor: pointer;
|
||||
}
|
||||
input[type="file"]::file-selector-button {
|
||||
background: #1e2030; color: #e0e0e0; border: 1px solid #2a2d3e;
|
||||
border-radius: 4px; padding: 4px 10px; font-family: inherit;
|
||||
font-size: 12px; cursor: pointer; margin-right: 8px;
|
||||
}
|
||||
input[type="file"]::file-selector-button:hover { border-color: #6366f1; }
|
||||
input[type="text"] {
|
||||
background: #1c1f2e; color: #e0e0e0; border: 1px solid #2a2d3e;
|
||||
border-radius: 4px; padding: 6px 10px; font-family: inherit;
|
||||
font-size: 13px; min-height: 38px; width: 180px;
|
||||
}
|
||||
input[type="text"]:focus { outline: none; border-color: #6366f1; }
|
||||
|
||||
/* Buttons */
|
||||
button {
|
||||
background: #1e2030; color: #e0e0e0; border: 1px solid #2a2d3e;
|
||||
border-radius: 4px; padding: 6px 14px; font-family: inherit;
|
||||
font-size: 13px; cursor: pointer; min-height: 38px;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
button:hover { border-color: #6366f1; color: #fff; }
|
||||
button:disabled { opacity: 0.4; cursor: default; }
|
||||
button.danger:hover { border-color: #f44336; }
|
||||
button.primary { background: #6366f1; border-color: #6366f1; color: #fff; font-weight: 600; }
|
||||
button.primary:hover { background: #5558e6; }
|
||||
|
||||
/* Actions in table */
|
||||
.actions-cell { white-space: nowrap; text-align: right; }
|
||||
.actions-cell button { min-height: 28px; padding: 2px 8px; font-size: 11px; }
|
||||
|
||||
/* Origin badge */
|
||||
.origin-badge {
|
||||
display: inline-block; font-size: 10px; padding: 1px 6px;
|
||||
border-radius: 3px; font-weight: 600;
|
||||
}
|
||||
.origin-badge.local { background: #1b3a2a; color: #4caf50; }
|
||||
.origin-badge.remote { background: #1a2a3e; color: #2196f3; }
|
||||
|
||||
/* Toast */
|
||||
.toast {
|
||||
position: fixed; bottom: 20px; right: 20px; padding: 10px 16px;
|
||||
border-radius: 4px; font-size: 12px; z-index: 100; opacity: 0;
|
||||
transition: opacity 0.3s; pointer-events: none;
|
||||
}
|
||||
.toast.show { opacity: 1; }
|
||||
.toast.success { background: #4caf50; color: #fff; }
|
||||
.toast.error { background: #f44336; color: #fff; }
|
||||
|
||||
/* Modal */
|
||||
.modal-overlay {
|
||||
display: none; position: fixed; inset: 0;
|
||||
background: rgba(0,0,0,0.6); z-index: 50;
|
||||
align-items: center; justify-content: center;
|
||||
}
|
||||
.modal-overlay.open { display: flex; }
|
||||
.modal {
|
||||
background: #161822; border: 1px solid #2a2d3e; border-radius: 6px;
|
||||
padding: 20px; width: 90%; max-width: 560px; max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.modal h2 { font-size: 14px; color: #fff; margin-bottom: 16px; text-transform: none; letter-spacing: 0; }
|
||||
.modal-close {
|
||||
float: right; background: none; border: none; color: #888;
|
||||
font-size: 18px; cursor: pointer; min-height: auto; padding: 0;
|
||||
}
|
||||
.modal-close:hover { color: #fff; border: none; }
|
||||
.detail-row { display: flex; margin-bottom: 8px; }
|
||||
.detail-label { color: #888; width: 110px; flex-shrink: 0; font-size: 11px; text-transform: uppercase; padding-top: 2px; }
|
||||
.detail-value { color: #e0e0e0; word-break: break-all; font-size: 13px; }
|
||||
.chunk-list { margin-top: 8px; }
|
||||
.chunk-item { color: #888; font-size: 11px; padding: 2px 0; }
|
||||
|
||||
::-webkit-scrollbar { width: 6px; }
|
||||
::-webkit-scrollbar-track { background: #0f1117; }
|
||||
::-webkit-scrollbar-thumb { background: #2a2d3e; border-radius: 3px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<div class="header-left">
|
||||
<h1>
|
||||
Swactor Runtime Dashboard
|
||||
<span id="statusDot" class="status-dot disconnected"></span>
|
||||
</h1>
|
||||
<nav class="nav-links">
|
||||
<a href="/" class="nav-link">Overview</a>
|
||||
<a href="/actors" class="nav-link">Actors</a>
|
||||
<a href="/distribution" class="nav-link">Distribution</a>
|
||||
<a href="/datastore" class="nav-link active">Datastore</a>
|
||||
</nav>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<span id="nodeLabel" style="color:#888;font-size:12px;">Waiting for data...</span>
|
||||
<button id="startBtn" onclick="startDatastore()" style="display:none" class="primary">Start Datastore</button>
|
||||
<button id="stopBtn" onclick="stopDatastore()" style="display:none" class="danger">Stop</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid">
|
||||
<!-- Upload panel -->
|
||||
<div class="panel full-width" id="uploadPanel" style="display:none">
|
||||
<h2>Upload</h2>
|
||||
<div class="upload-row">
|
||||
<input type="file" id="fileInput" />
|
||||
<input type="text" id="nameInput" placeholder="name (optional)" />
|
||||
<button class="primary" id="uploadBtn" onclick="upload()">Upload</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stat cards -->
|
||||
<div class="panel full-width">
|
||||
<h2>Datastore Stats</h2>
|
||||
<div class="stats-cards">
|
||||
<div class="stat-card"><div class="value" id="statObjects">0</div><div class="label">Objects</div></div>
|
||||
<div class="stat-card"><div class="value" id="statSize">0</div><div class="label">Total Size</div></div>
|
||||
<div class="stat-card"><div class="value" id="statPuts">0</div><div class="label">Puts</div></div>
|
||||
<div class="stat-card"><div class="value" id="statGets">0</div><div class="label">Gets</div></div>
|
||||
<div class="stat-card"><div class="value" id="statDeletes">0</div><div class="label">Deletes</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Event timeline -->
|
||||
<div class="panel">
|
||||
<h2>Event Timeline <span id="eventCount" style="color:#555;font-weight:400;"></span></h2>
|
||||
<div class="event-timeline" id="eventTimeline"></div>
|
||||
</div>
|
||||
|
||||
<!-- Objects table -->
|
||||
<div class="panel">
|
||||
<h2>Objects <span id="objectCount" style="color:#555;font-weight:400;"></span></h2>
|
||||
<div class="objects-table" id="objectsTableWrap">
|
||||
<table>
|
||||
<thead><tr><th>Hash</th><th>Name</th><th>Origin</th><th>Size</th><th style="text-align:right">Actions</th></tr></thead>
|
||||
<tbody id="objectsBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Transfers -->
|
||||
<div class="panel full-width transfers-section" id="transfersPanel">
|
||||
<h2>Active Transfers</h2>
|
||||
<div id="transfersList"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Detail modal -->
|
||||
<div class="modal-overlay" id="modal" onclick="if(event.target===this)closeModal()">
|
||||
<div class="modal">
|
||||
<button class="modal-close" onclick="closeModal()">×</button>
|
||||
<h2>Object Detail</h2>
|
||||
<div id="modalBody"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toast" id="toast"></div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
var DASHBOARD_MODE = '__DASHBOARD_MODE__';
|
||||
var dot = document.getElementById('statusDot');
|
||||
var dsRunning = false;
|
||||
var currentNodeId = '';
|
||||
|
||||
function $(id) { return document.getElementById(id); }
|
||||
|
||||
function formatBytes(b) {
|
||||
if (b === 0) return '0 B';
|
||||
var units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
var i = Math.floor(Math.log(b) / Math.log(1024));
|
||||
if (i >= units.length) i = units.length - 1;
|
||||
return (b / Math.pow(1024, i)).toFixed(i > 0 ? 1 : 0) + ' ' + units[i];
|
||||
}
|
||||
|
||||
function formatTime(ms) {
|
||||
var d = new Date(ms);
|
||||
return ('0' + d.getHours()).slice(-2) + ':' +
|
||||
('0' + d.getMinutes()).slice(-2) + ':' +
|
||||
('0' + d.getSeconds()).slice(-2);
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
if (!s) return '';
|
||||
return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
}
|
||||
|
||||
function escAttr(s) {
|
||||
if (!s) return '';
|
||||
return s.replace(/\\/g,'\\\\').replace(/'/g,"\\'").replace(/"/g,'"');
|
||||
}
|
||||
|
||||
// Toast notification
|
||||
window.toast = function(msg, type) {
|
||||
var t = $('toast');
|
||||
t.textContent = msg;
|
||||
t.className = 'toast show ' + type;
|
||||
setTimeout(function() { t.className = 'toast'; }, 2500);
|
||||
};
|
||||
|
||||
function detailRow(label, value) {
|
||||
return '<div class="detail-row"><div class="detail-label">' + label +
|
||||
'</div><div class="detail-value">' + escapeHtml(String(value)) + '</div></div>';
|
||||
}
|
||||
|
||||
// Update lifecycle buttons
|
||||
function updateLifecycleUI(running) {
|
||||
dsRunning = running;
|
||||
$('startBtn').style.display = running ? 'none' : 'inline-block';
|
||||
$('stopBtn').style.display = running ? 'inline-block' : 'none';
|
||||
$('uploadPanel').style.display = running ? 'block' : 'none';
|
||||
}
|
||||
|
||||
function updateFromSnapshot(data) {
|
||||
// Handle envelope format: {is_running, snapshot}
|
||||
var running = data.is_running;
|
||||
var snap = data.snapshot;
|
||||
|
||||
updateLifecycleUI(running);
|
||||
|
||||
if (!snap) return;
|
||||
|
||||
// Node label
|
||||
if (snap.node_id) {
|
||||
currentNodeId = snap.node_id;
|
||||
$('nodeLabel').textContent = 'Node: ' + snap.node_id.substring(0, 16) + '\u2026';
|
||||
}
|
||||
|
||||
// Stat cards
|
||||
$('statObjects').textContent = snap.object_count;
|
||||
$('statSize').textContent = formatBytes(snap.total_bytes);
|
||||
$('statPuts').textContent = snap.put_ops;
|
||||
$('statGets').textContent = snap.get_ops;
|
||||
$('statDeletes').textContent = snap.delete_ops;
|
||||
|
||||
// Event timeline
|
||||
var timeline = $('eventTimeline');
|
||||
var wasAtBottom = timeline.scrollTop + timeline.clientHeight >= timeline.scrollHeight - 20;
|
||||
timeline.innerHTML = '';
|
||||
$('eventCount').textContent = '(' + snap.recent_events.length + ')';
|
||||
|
||||
for (var i = snap.recent_events.length - 1; i >= 0; i--) {
|
||||
var ev = snap.recent_events[i];
|
||||
var row = document.createElement('div');
|
||||
row.className = 'event-row';
|
||||
row.innerHTML =
|
||||
'<span class="event-time">' + formatTime(ev.timestamp_ms) + '</span>' +
|
||||
'<span class="event-kind ' + ev.kind + '">' + ev.kind + '</span>' +
|
||||
'<span class="event-hash">' + ev.hash.substring(0, 16) + '\u2026</span>' +
|
||||
'<span class="event-name">' + escapeHtml(ev.name || '') + '</span>' +
|
||||
'<span class="event-size">' + (ev.size_bytes > 0 ? formatBytes(ev.size_bytes) : '') + '</span>';
|
||||
timeline.appendChild(row);
|
||||
}
|
||||
|
||||
if (wasAtBottom) {
|
||||
timeline.scrollTop = timeline.scrollHeight;
|
||||
}
|
||||
|
||||
// Objects table
|
||||
var tbody = $('objectsBody');
|
||||
tbody.innerHTML = '';
|
||||
$('objectCount').textContent = '(' + snap.objects.length + ')';
|
||||
|
||||
for (var i = 0; i < snap.objects.length; i++) {
|
||||
var obj = snap.objects[i];
|
||||
var tr = document.createElement('tr');
|
||||
var nodeHex = obj.node_id || currentNodeId || '';
|
||||
var isLocal = !obj.node_id || obj.node_id === currentNodeId;
|
||||
var badgeClass = isLocal ? 'local' : 'remote';
|
||||
var badgeText = isLocal ? 'local' : (nodeHex ? nodeHex.substring(0, 8) : 'remote');
|
||||
var h = obj.hash || obj.content_hash || '';
|
||||
var shortHash = h.substring(0, 16);
|
||||
var name = obj.name || '\u2014';
|
||||
tr.setAttribute('onclick', "showDetail('" + escAttr(h) + "')");
|
||||
tr.innerHTML =
|
||||
'<td style="color:#6366f1;font-size:11px;" title="' + escapeHtml(h) + '">' + shortHash + '\u2026</td>' +
|
||||
'<td>' + escapeHtml(name) + '</td>' +
|
||||
'<td><span class="origin-badge ' + badgeClass + '">' + escapeHtml(badgeText) + '</span></td>' +
|
||||
'<td style="color:#888;">' + formatBytes(obj.size_bytes) + '</td>' +
|
||||
'<td class="actions-cell">' +
|
||||
'<button onclick="event.stopPropagation();download(\'' + escAttr(h) + '\',\'' + escAttr(obj.name || shortHash) + '\')">download</button> ' +
|
||||
'<button class="danger" onclick="event.stopPropagation();del(\'' + escAttr(h) + '\')">delete</button>' +
|
||||
'</td>';
|
||||
tbody.appendChild(tr);
|
||||
}
|
||||
|
||||
// Transfers
|
||||
var panel = $('transfersPanel');
|
||||
var list = $('transfersList');
|
||||
|
||||
if (snap.active_transfers.length === 0) {
|
||||
panel.className = 'panel full-width transfers-section';
|
||||
} else {
|
||||
panel.className = 'panel full-width transfers-section visible';
|
||||
list.innerHTML = '';
|
||||
|
||||
for (var i = 0; i < snap.active_transfers.length; i++) {
|
||||
var t = snap.active_transfers[i];
|
||||
var pct = t.chunks_total > 0 ? Math.round((t.chunks_received / t.chunks_total) * 100) : 0;
|
||||
var trow = document.createElement('div');
|
||||
trow.className = 'transfer-row';
|
||||
trow.innerHTML =
|
||||
'<span class="transfer-hash">' + t.hash.substring(0, 16) + '\u2026</span>' +
|
||||
'<div class="transfer-bar"><div class="transfer-fill" style="width:' + pct + '%"></div></div>' +
|
||||
'<span class="transfer-label">' + t.chunks_received + ' / ' + t.chunks_total + '</span>';
|
||||
list.appendChild(trow);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── CRUD operations ──────────────────────────────────────────────────
|
||||
|
||||
window.upload = function() {
|
||||
var file = $('fileInput').files[0];
|
||||
if (!file) { toast('select a file first', 'error'); return; }
|
||||
var name = $('nameInput').value.trim();
|
||||
var btn = $('uploadBtn');
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'uploading...';
|
||||
var url = '/api/datastore/put';
|
||||
if (name) url += '?name=' + encodeURIComponent(name);
|
||||
fetch(url, { method: 'POST', body: file })
|
||||
.then(function(r) {
|
||||
if (!r.ok) return r.json().then(function(j) { throw new Error(j.error || r.statusText); });
|
||||
return r.json();
|
||||
})
|
||||
.then(function(j) {
|
||||
toast('uploaded ' + j.content_hash.substring(0, 12), 'success');
|
||||
$('fileInput').value = '';
|
||||
$('nameInput').value = '';
|
||||
})
|
||||
.catch(function(e) { toast('upload failed: ' + e.message, 'error'); })
|
||||
.finally(function() { btn.disabled = false; btn.textContent = 'Upload'; });
|
||||
};
|
||||
|
||||
window.download = function(hash, filename) {
|
||||
fetch('/api/datastore/data?hash=' + hash)
|
||||
.then(function(r) {
|
||||
if (!r.ok) throw new Error('not found');
|
||||
return r.blob();
|
||||
})
|
||||
.then(function(blob) {
|
||||
var a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(a.href);
|
||||
})
|
||||
.catch(function(e) { toast('download failed: ' + e.message, 'error'); });
|
||||
};
|
||||
|
||||
window.del = function(hash) {
|
||||
if (!confirm('Delete ' + hash.substring(0, 16) + '?')) return;
|
||||
fetch('/api/datastore/delete?hash=' + hash, { method: 'POST' })
|
||||
.then(function(r) {
|
||||
if (!r.ok) return r.json().then(function(j) { throw new Error(j.error || r.statusText); });
|
||||
toast('deleted', 'success');
|
||||
})
|
||||
.catch(function(e) { toast('delete failed: ' + e.message, 'error'); });
|
||||
};
|
||||
|
||||
window.showDetail = function(hash) {
|
||||
fetch('/api/datastore/get?hash=' + hash)
|
||||
.then(function(r) {
|
||||
if (!r.ok) throw new Error('not found');
|
||||
return r.json();
|
||||
})
|
||||
.then(function(j) {
|
||||
var e = j.entry;
|
||||
var m = j.manifest;
|
||||
var html = '';
|
||||
html += detailRow('Hash', e.content_hash);
|
||||
html += detailRow('Name', e.name || '\u2014');
|
||||
html += detailRow('Size', formatBytes(e.size_bytes));
|
||||
html += detailRow('Node', e.node_id ? e.node_id.substring(0, 16) + '...' : '\u2014');
|
||||
if (e.tags && Object.keys(e.tags).length > 0) {
|
||||
html += detailRow('Tags', Object.entries(e.tags).map(function(kv) { return kv[0] + '=' + kv[1]; }).join(', '));
|
||||
}
|
||||
html += detailRow('Chunks', m.chunks.length + ' (' + formatBytes(m.chunk_size) + ' each)');
|
||||
if (m.chunks.length > 0) {
|
||||
html += '<div class="chunk-list">';
|
||||
for (var i = 0; i < m.chunks.length; i++) {
|
||||
var c = m.chunks[i];
|
||||
html += '<div class="chunk-item">#' + i + ' ' + c.hash.substring(0, 16) + ' (' + formatBytes(c.size) + ')</div>';
|
||||
}
|
||||
html += '</div>';
|
||||
}
|
||||
$('modalBody').innerHTML = html;
|
||||
$('modal').classList.add('open');
|
||||
})
|
||||
.catch(function(e) { toast('failed to load detail', 'error'); });
|
||||
};
|
||||
|
||||
window.closeModal = function() { $('modal').classList.remove('open'); };
|
||||
document.addEventListener('keydown', function(e) { if (e.key === 'Escape') closeModal(); });
|
||||
|
||||
// ── Lifecycle ────────────────────────────────────────────────────────
|
||||
|
||||
window.startDatastore = function() {
|
||||
$('startBtn').disabled = true;
|
||||
fetch('/api/datastore/start', { method: 'POST' })
|
||||
.then(function(r) {
|
||||
if (!r.ok) return r.json().then(function(j) { throw new Error(j.error || r.statusText); });
|
||||
return r.json();
|
||||
})
|
||||
.then(function() {
|
||||
toast('datastore started', 'success');
|
||||
updateLifecycleUI(true);
|
||||
})
|
||||
.catch(function(e) { toast('start failed: ' + e.message, 'error'); })
|
||||
.finally(function() { $('startBtn').disabled = false; });
|
||||
};
|
||||
|
||||
window.stopDatastore = function() {
|
||||
if (!confirm('Stop the datastore? In-memory data will be lost.')) return;
|
||||
$('stopBtn').disabled = true;
|
||||
fetch('/api/datastore/shutdown', { method: 'POST' })
|
||||
.then(function(r) {
|
||||
if (!r.ok) return r.json().then(function(j) { throw new Error(j.error || r.statusText); });
|
||||
return r.json();
|
||||
})
|
||||
.then(function() {
|
||||
toast('datastore stopped', 'success');
|
||||
updateLifecycleUI(false);
|
||||
$('objectsBody').innerHTML = '';
|
||||
$('eventTimeline').innerHTML = '';
|
||||
$('statObjects').textContent = '0';
|
||||
$('statSize').textContent = '0 B';
|
||||
$('statPuts').textContent = '0';
|
||||
$('statGets').textContent = '0';
|
||||
$('statDeletes').textContent = '0';
|
||||
})
|
||||
.catch(function(e) { toast('stop failed: ' + e.message, 'error'); })
|
||||
.finally(function() { $('stopBtn').disabled = false; });
|
||||
};
|
||||
|
||||
// ── SSE connection ───────────────────────────────────────────────────
|
||||
|
||||
var es = new EventSource('/events');
|
||||
|
||||
es.addEventListener('datastore', function(e) {
|
||||
try {
|
||||
var data = JSON.parse(e.data);
|
||||
updateFromSnapshot(data);
|
||||
} catch(err) { console.error('datastore parse error', err); }
|
||||
});
|
||||
|
||||
es.addEventListener('done', function() {
|
||||
dot.className = 'status-dot done';
|
||||
es.close();
|
||||
});
|
||||
|
||||
es.onerror = function() {
|
||||
dot.className = 'status-dot disconnected';
|
||||
};
|
||||
|
||||
es.onopen = function() {
|
||||
dot.className = 'status-dot';
|
||||
};
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"##;
|
||||
|
|
@ -94,6 +94,19 @@ pub const DISTRIBUTION_HTML: &str = r##"<!DOCTYPE html>
|
|||
flex: 1; overflow-y: auto; font-size: 11px;
|
||||
}
|
||||
|
||||
.relay-badge {
|
||||
display: inline-block;
|
||||
background: #14b8a6;
|
||||
color: #fff;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
margin-left: 6px;
|
||||
vertical-align: middle;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.ego-hint {
|
||||
position: absolute; bottom: 12px; left: 12px; font-size: 10px;
|
||||
color: #555; pointer-events: none;
|
||||
|
|
@ -105,6 +118,40 @@ pub const DISTRIBUTION_HTML: &str = r##"<!DOCTYPE html>
|
|||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.share-btn {
|
||||
background: #6366f1; color: #fff; border: none; padding: 5px 14px;
|
||||
font-size: 12px; font-family: inherit; border-radius: 4px; cursor: pointer;
|
||||
}
|
||||
.share-btn:hover { background: #5558e6; }
|
||||
|
||||
.modal-overlay {
|
||||
display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%;
|
||||
background: rgba(0,0,0,0.6); z-index: 1000; align-items: center; justify-content: center;
|
||||
}
|
||||
.modal-overlay.active { display: flex; }
|
||||
.modal {
|
||||
background: #1c1f2e; border: 1px solid #2a2d3e; border-radius: 8px;
|
||||
padding: 24px; width: 360px; max-width: 90vw;
|
||||
}
|
||||
.modal h2 { font-size: 14px; color: #fff; margin-bottom: 16px; }
|
||||
.modal-close {
|
||||
float: right; background: none; border: none; color: #888; font-size: 18px;
|
||||
cursor: pointer; line-height: 1;
|
||||
}
|
||||
.modal-close:hover { color: #fff; }
|
||||
.invite-input {
|
||||
width: 100%; background: #0f1117; border: 1px solid #2a2d3e; color: #e0e0e0;
|
||||
padding: 8px 10px; font-size: 12px; font-family: monospace; border-radius: 4px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.copy-btn {
|
||||
background: #6366f1; color: #fff; border: none; padding: 5px 14px;
|
||||
font-size: 12px; font-family: inherit; border-radius: 4px; cursor: pointer;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.copy-btn:hover { background: #5558e6; }
|
||||
.qr-canvas { display: block; margin: 0 auto; image-rendering: pixelated; }
|
||||
|
||||
::-webkit-scrollbar { width: 6px; }
|
||||
::-webkit-scrollbar-track { background: #0f1117; }
|
||||
::-webkit-scrollbar-thumb { background: #2a2d3e; border-radius: 3px; }
|
||||
|
|
@ -126,6 +173,17 @@ pub const DISTRIBUTION_HTML: &str = r##"<!DOCTYPE html>
|
|||
</div>
|
||||
<div class="header-right">
|
||||
<span id="nodeLabel" style="color:#888;font-size:12px;">Waiting for data...</span>
|
||||
<button class="share-btn" id="shareBtn" onclick="openShareModal()">Share</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay" id="shareModal">
|
||||
<div class="modal">
|
||||
<button class="modal-close" onclick="closeShareModal()">×</button>
|
||||
<h2>Share Invite Code</h2>
|
||||
<input class="invite-input" id="inviteInput" readonly />
|
||||
<button class="copy-btn" id="copyBtn" onclick="copyInvite()">Copy</button>
|
||||
<canvas id="qrCanvas" class="qr-canvas" width="200" height="200"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -193,6 +251,17 @@ pub const DISTRIBUTION_HTML: &str = r##"<!DOCTYPE html>
|
|||
<h2>Routing Buckets</h2>
|
||||
<canvas id="bucketChart"></canvas>
|
||||
</div>
|
||||
<div class="bottom-section">
|
||||
<h2>Trusted Peers <span id="peerAuthMode" style="color:#555;font-weight:400;"></span></h2>
|
||||
<div class="scroll-wrap" id="peersPanel">
|
||||
<div id="peersList"></div>
|
||||
<div style="margin-top:8px;display:flex;gap:4px;">
|
||||
<input id="peerNodeId" placeholder="Node ID (hex)" style="flex:1;background:#1c1f2e;border:1px solid #2a2d3e;color:#e0e0e0;padding:3px 6px;font-size:10px;font-family:monospace;border-radius:3px;" />
|
||||
<input id="peerLabel" placeholder="Label" style="width:80px;background:#1c1f2e;border:1px solid #2a2d3e;color:#e0e0e0;padding:3px 6px;font-size:10px;font-family:monospace;border-radius:3px;" />
|
||||
<button onclick="addPeer()" style="background:#6366f1;color:#fff;border:none;padding:3px 8px;font-size:10px;border-radius:3px;cursor:pointer;">Add</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -544,15 +613,27 @@ pub const DISTRIBUTION_HTML: &str = r##"<!DOCTYPE html>
|
|||
ctx.stroke();
|
||||
}
|
||||
|
||||
// Teal ring around relay nodes
|
||||
var isRelay = (i === 0 && data && data.relay_url)
|
||||
|| (i > 0 && data && data.members[i-1] && data.members[i-1].relay_url);
|
||||
if (isRelay) {
|
||||
ctx.beginPath();
|
||||
ctx.arc(posX[i], posY[i], nr + 3/vs, 0, Math.PI * 2);
|
||||
ctx.lineWidth = 2/vs;
|
||||
ctx.strokeStyle = '#14b8a6';
|
||||
ctx.globalAlpha = opacity * 0.9;
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
ctx.globalAlpha = 1;
|
||||
|
||||
// Label (short ID)
|
||||
// Label (name for self, short ID for others)
|
||||
if (vs > 0.5) {
|
||||
ctx.fillStyle = '#ccc';
|
||||
ctx.globalAlpha = opacity;
|
||||
ctx.font = Math.round(9 / vs) + 'px monospace';
|
||||
ctx.textAlign = 'center';
|
||||
var label = nodeIds[i] ? nodeIds[i].substring(0, 8) : '';
|
||||
var label = (i === 0 && data && data.node_name) ? data.node_name : (nodeIds[i] ? nodeIds[i].substring(0, 8) : '');
|
||||
ctx.fillText(label, posX[i], posY[i] - nr - 3/vs);
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
|
|
@ -614,8 +695,8 @@ pub const DISTRIBUTION_HTML: &str = r##"<!DOCTYPE html>
|
|||
|
||||
function updateUI(d) {
|
||||
// Self label
|
||||
document.getElementById('selfLabel').textContent = 'Node: ' + d.node_id.substring(0, 16) + '\u2026';
|
||||
document.getElementById('nodeLabel').textContent = d.listen_addr;
|
||||
document.getElementById('selfLabel').textContent = 'Node: ' + (d.node_name || d.node_id.substring(0, 16) + '\u2026');
|
||||
document.getElementById('nodeLabel').textContent = d.node_name || d.listen_addr;
|
||||
|
||||
// Stats cards
|
||||
document.getElementById('statMembers').textContent = d.members.length;
|
||||
|
|
@ -641,9 +722,14 @@ pub const DISTRIBUTION_HTML: &str = r##"<!DOCTYPE html>
|
|||
var m = d.members[i];
|
||||
var cls = 'state-' + m.state;
|
||||
var tr = document.createElement('tr');
|
||||
var idCell = m.node_id.substring(0, 16) + '\u2026';
|
||||
if (m.label) idCell = m.label + ' <span style="color:#555;">(' + m.node_id.substring(0, 8) + ')</span>';
|
||||
var relayBadge = m.relay_url
|
||||
? ' <span class="relay-badge" title="' + m.relay_url + '">RELAY</span>'
|
||||
: '';
|
||||
tr.innerHTML =
|
||||
'<td class="' + cls + '">' + m.state + '</td>' +
|
||||
'<td style="color:#aaa;font-size:10px;">' + m.node_id.substring(0, 16) + '\u2026</td>' +
|
||||
'<td style="color:#aaa;font-size:10px;">' + idCell + relayBadge + '</td>' +
|
||||
'<td>' + m.addr + '</td>' +
|
||||
'<td>' + m.incarnation + '</td>';
|
||||
body.appendChild(tr);
|
||||
|
|
@ -760,8 +846,309 @@ pub const DISTRIBUTION_HTML: &str = r##"<!DOCTYPE html>
|
|||
dot.className = 'status-dot';
|
||||
};
|
||||
|
||||
// ── Peer management ──────────────────────────────────────────
|
||||
var dashToken = '';
|
||||
|
||||
function setToken(t) { dashToken = t; }
|
||||
|
||||
function fetchPeers() {
|
||||
fetch('/api/peers')
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(d) { renderPeers(d); })
|
||||
.catch(function() {});
|
||||
}
|
||||
|
||||
function renderPeers(d) {
|
||||
var el = document.getElementById('peerAuthMode');
|
||||
el.textContent = '(' + d.mode + ')';
|
||||
var list = document.getElementById('peersList');
|
||||
list.innerHTML = '';
|
||||
if (d.mode === 'open') {
|
||||
list.innerHTML = '<div style="color:#555;font-size:10px;">Open mode — all peers accepted</div>';
|
||||
return;
|
||||
}
|
||||
// Cross-reference with members for online status
|
||||
var memberMap = {};
|
||||
if (data && data.members) {
|
||||
for (var i = 0; i < data.members.length; i++) {
|
||||
memberMap[data.members[i].node_id] = data.members[i].state;
|
||||
}
|
||||
}
|
||||
for (var i = 0; i < d.peers.length; i++) {
|
||||
var p = d.peers[i];
|
||||
var state = memberMap[p.node_id] || 'offline';
|
||||
var color = state === 'alive' ? '#4caf50' : state === 'suspect' ? '#ff9800' : '#555';
|
||||
var div = document.createElement('div');
|
||||
div.style.cssText = 'display:flex;align-items:center;gap:6px;padding:2px 0;font-size:10px;';
|
||||
div.innerHTML =
|
||||
'<span style="width:6px;height:6px;border-radius:50%;background:' + color + ';display:inline-block;"></span>' +
|
||||
'<span style="color:#aaa;">' + p.node_id.substring(0, 16) + '\u2026</span>' +
|
||||
'<span style="color:#666;">' + (p.label || '') + '</span>' +
|
||||
'<span style="color:' + color + ';margin-left:auto;">' + state + '</span>' +
|
||||
'<button onclick="removePeer(\'' + p.node_id + '\')" style="background:none;border:1px solid #f44336;color:#f44336;padding:1px 4px;font-size:9px;border-radius:2px;cursor:pointer;">x</button>';
|
||||
list.appendChild(div);
|
||||
}
|
||||
}
|
||||
|
||||
window.addPeer = function() {
|
||||
var nid = document.getElementById('peerNodeId').value.trim();
|
||||
var label = document.getElementById('peerLabel').value.trim();
|
||||
if (!nid) return;
|
||||
fetch('/api/peers/add', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + dashToken },
|
||||
body: JSON.stringify({ node_id: nid, label: label })
|
||||
}).then(function() {
|
||||
document.getElementById('peerNodeId').value = '';
|
||||
document.getElementById('peerLabel').value = '';
|
||||
fetchPeers();
|
||||
});
|
||||
};
|
||||
|
||||
window.removePeer = function(nid) {
|
||||
fetch('/api/peers/remove', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + dashToken },
|
||||
body: JSON.stringify({ node_id: nid })
|
||||
}).then(function() { fetchPeers(); });
|
||||
};
|
||||
|
||||
// Poll peers every 5 seconds
|
||||
fetchPeers();
|
||||
setInterval(fetchPeers, 5000);
|
||||
|
||||
resizeCanvas();
|
||||
drawGraph();
|
||||
|
||||
// ── Share modal ─────────────────────────────────────────────
|
||||
var currentInvite = '';
|
||||
|
||||
window.openShareModal = function() {
|
||||
if (!data || !data.invite_code) return;
|
||||
currentInvite = data.invite_code;
|
||||
document.getElementById('inviteInput').value = currentInvite;
|
||||
document.getElementById('shareModal').classList.add('active');
|
||||
document.getElementById('copyBtn').textContent = 'Copy';
|
||||
renderQR(currentInvite);
|
||||
};
|
||||
|
||||
window.closeShareModal = function() {
|
||||
document.getElementById('shareModal').classList.remove('active');
|
||||
};
|
||||
|
||||
document.getElementById('shareModal').addEventListener('click', function(e) {
|
||||
if (e.target === this) closeShareModal();
|
||||
});
|
||||
|
||||
window.copyInvite = function() {
|
||||
navigator.clipboard.writeText(currentInvite).then(function() {
|
||||
document.getElementById('copyBtn').textContent = 'Copied!';
|
||||
setTimeout(function() { document.getElementById('copyBtn').textContent = 'Copy'; }, 2000);
|
||||
});
|
||||
};
|
||||
|
||||
// ── Minimal QR code renderer (Mode 2 alphanumeric, version auto) ──
|
||||
function renderQR(text) {
|
||||
var cv = document.getElementById('qrCanvas');
|
||||
var c = cv.getContext('2d');
|
||||
// Use a simple approach: encode text into a QR-like visual via a data URL
|
||||
// For proper QR, we use a compact encoder below.
|
||||
try {
|
||||
var modules = qrEncode(text);
|
||||
var size = modules.length;
|
||||
var scale = Math.floor(200 / size);
|
||||
cv.width = size * scale;
|
||||
cv.height = size * scale;
|
||||
c.fillStyle = '#fff';
|
||||
c.fillRect(0, 0, cv.width, cv.height);
|
||||
c.fillStyle = '#000';
|
||||
for (var y = 0; y < size; y++) {
|
||||
for (var x = 0; x < size; x++) {
|
||||
if (modules[y][x]) c.fillRect(x * scale, y * scale, scale, scale);
|
||||
}
|
||||
}
|
||||
} catch(e) {
|
||||
// Fallback: just show the text
|
||||
cv.width = 200; cv.height = 40;
|
||||
c.fillStyle = '#1c1f2e'; c.fillRect(0, 0, 200, 40);
|
||||
c.fillStyle = '#888'; c.font = '11px monospace'; c.textAlign = 'center';
|
||||
c.fillText('QR unavailable', 100, 24);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Compact QR encoder (byte mode, ECC L) ─────────────────────────
|
||||
// Supports up to ~150 bytes (versions 1-10). Minimal but functional.
|
||||
function qrEncode(text) {
|
||||
var data = [];
|
||||
for (var i = 0; i < text.length; i++) data.push(text.charCodeAt(i));
|
||||
var ver = 1;
|
||||
// Byte mode capacities at ECC L: v1=17, v2=32, v3=53, v4=78, v5=106, v6=134, v7=154...
|
||||
var caps = [0,17,32,53,78,106,134,154,192,230,271];
|
||||
while (ver < caps.length - 1 && data.length > caps[ver]) ver++;
|
||||
if (data.length > caps[ver]) throw 'too long';
|
||||
var size = ver * 4 + 17;
|
||||
var modules = [], isFunc = [];
|
||||
for (var y = 0; y < size; y++) { modules.push([]); isFunc.push([]); for (var x = 0; x < size; x++) { modules[y].push(false); isFunc[y].push(false); } }
|
||||
|
||||
// Finder patterns
|
||||
function setFinder(row, col) {
|
||||
for (var dy = -1; dy <= 7; dy++) for (var dx = -1; dx <= 7; dx++) {
|
||||
var r = row+dy, c = col+dx;
|
||||
if (r < 0 || r >= size || c < 0 || c >= size) continue;
|
||||
var v = (dy >= 0 && dy <= 6 && dx >= 0 && dx <= 6) &&
|
||||
(dy === 0 || dy === 6 || dx === 0 || dx === 6 || (dy >= 2 && dy <= 4 && dx >= 2 && dx <= 4));
|
||||
modules[r][c] = v; isFunc[r][c] = true;
|
||||
}
|
||||
}
|
||||
setFinder(0, 0); setFinder(0, size - 7); setFinder(size - 7, 0);
|
||||
|
||||
// Timing patterns
|
||||
for (var i = 8; i < size - 8; i++) {
|
||||
modules[6][i] = i % 2 === 0; isFunc[6][i] = true;
|
||||
modules[i][6] = i % 2 === 0; isFunc[i][6] = true;
|
||||
}
|
||||
|
||||
// Alignment patterns (simplified for small versions)
|
||||
var alignPos = [[], [], [6,18], [6,22], [6,26], [6,30], [6,34], [6,22,38], [6,24,42], [6,26,46], [6,28,50]];
|
||||
if (ver >= 2) {
|
||||
var ap = alignPos[ver];
|
||||
for (var ai = 0; ai < ap.length; ai++) for (var aj = 0; aj < ap.length; aj++) {
|
||||
var ay = ap[ai], ax = ap[aj];
|
||||
if (isFunc[ay][ax]) continue;
|
||||
for (var dy = -2; dy <= 2; dy++) for (var dx = -2; dx <= 2; dx++) {
|
||||
modules[ay+dy][ax+dx] = Math.abs(dy) === 2 || Math.abs(dx) === 2 || (dy === 0 && dx === 0);
|
||||
isFunc[ay+dy][ax+dx] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Format info area reservation
|
||||
for (var i = 0; i < 9; i++) {
|
||||
if (i < size) { isFunc[8][i] = true; isFunc[i][8] = true; }
|
||||
}
|
||||
for (var i = 0; i < 8; i++) {
|
||||
isFunc[8][size-1-i] = true; isFunc[size-1-i][8] = true;
|
||||
}
|
||||
modules[size-8][8] = true; isFunc[size-8][8] = true; // dark module
|
||||
|
||||
// Version info (v >= 7 only, skip for simplicity)
|
||||
|
||||
// Encode data
|
||||
var totalBits = qrDataBits(ver);
|
||||
var bits = [];
|
||||
function pushBits(val, len) { for (var i = len - 1; i >= 0; i--) bits.push((val >> i) & 1); }
|
||||
pushBits(4, 4); // byte mode indicator
|
||||
pushBits(data.length, ver <= 9 ? 8 : 16);
|
||||
for (var i = 0; i < data.length; i++) pushBits(data[i], 8);
|
||||
pushBits(0, Math.min(4, totalBits - bits.length)); // terminator
|
||||
while (bits.length % 8 !== 0) bits.push(0);
|
||||
var pad = [0xEC, 0x11], pi = 0;
|
||||
while (bits.length < totalBits) { pushBits(pad[pi], 8); pi = 1 - pi; }
|
||||
|
||||
// ECC (Reed-Solomon with GF(256))
|
||||
var codewords = [];
|
||||
for (var i = 0; i < bits.length; i += 8) {
|
||||
var b = 0; for (var j = 0; j < 8; j++) b = (b << 1) | (bits[i+j] || 0);
|
||||
codewords.push(b);
|
||||
}
|
||||
var eccInfo = qrEccInfo(ver);
|
||||
var eccLen = eccInfo.eccPer;
|
||||
var blocks = eccInfo.blocks;
|
||||
var dataPerBlock = Math.floor(codewords.length / blocks);
|
||||
var extra = codewords.length % blocks;
|
||||
var allData = [], allEcc = [];
|
||||
var idx = 0;
|
||||
for (var b = 0; b < blocks; b++) {
|
||||
var len = dataPerBlock + (b >= blocks - extra ? 1 : 0);
|
||||
var block = codewords.slice(idx, idx + len);
|
||||
idx += len;
|
||||
allData.push(block);
|
||||
allEcc.push(rsEncode(block, eccLen));
|
||||
}
|
||||
|
||||
// Interleave
|
||||
var result = [];
|
||||
var maxData = allData[blocks-1].length;
|
||||
for (var i = 0; i < maxData; i++) for (var b = 0; b < blocks; b++) { if (i < allData[b].length) result.push(allData[b][i]); }
|
||||
for (var i = 0; i < eccLen; i++) for (var b = 0; b < blocks; b++) result.push(allEcc[b][i]);
|
||||
|
||||
// Place data bits
|
||||
var resultBits = [];
|
||||
for (var i = 0; i < result.length; i++) for (var j = 7; j >= 0; j--) resultBits.push((result[i] >> j) & 1);
|
||||
var bi = 0;
|
||||
for (var right = size - 1; right >= 1; right -= 2) {
|
||||
if (right === 6) right = 5;
|
||||
for (var vert = 0; vert < size; vert++) {
|
||||
for (var j = 0; j < 2; j++) {
|
||||
var x = right - j;
|
||||
var upward = ((right + 1) & 2) === 0;
|
||||
var y = upward ? size - 1 - vert : vert;
|
||||
if (!isFunc[y][x] && bi < resultBits.length) {
|
||||
modules[y][x] = resultBits[bi++] === 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply mask 0 (checkerboard) and write format bits
|
||||
for (var y = 0; y < size; y++) for (var x = 0; x < size; x++) {
|
||||
if (!isFunc[y][x]) { if ((y + x) % 2 === 0) modules[y][x] = !modules[y][x]; }
|
||||
}
|
||||
// Format info for mask 0, ECC L = 0b111011111000100
|
||||
var fmtBits = [1,1,1,0,1,1,1,1,1,0,0,0,1,0,0];
|
||||
for (var i = 0; i < 6; i++) modules[8][i] = fmtBits[i] === 1;
|
||||
modules[8][7] = fmtBits[6] === 1; modules[8][8] = fmtBits[7] === 1; modules[7][8] = fmtBits[8] === 1;
|
||||
for (var i = 9; i < 15; i++) modules[14-i][8] = fmtBits[i] === 1;
|
||||
for (var i = 0; i < 8; i++) modules[8][size-8+i] = fmtBits[i] === 1;
|
||||
for (var i = 8; i < 15; i++) modules[size-15+i][8] = fmtBits[i] === 1;
|
||||
|
||||
return modules;
|
||||
}
|
||||
|
||||
function qrDataBits(ver) {
|
||||
// Total data codewords * 8 for ECC level L
|
||||
var total = [0,19,34,55,80,108,136,156,194,232,274];
|
||||
return total[ver] * 8;
|
||||
}
|
||||
|
||||
function qrEccInfo(ver) {
|
||||
// ECC codewords per block and number of blocks for ECC L
|
||||
var info = [
|
||||
null,
|
||||
{eccPer:7,blocks:1},{eccPer:10,blocks:1},{eccPer:15,blocks:1},{eccPer:20,blocks:1},
|
||||
{eccPer:26,blocks:1},{eccPer:18,blocks:2},{eccPer:20,blocks:2},{eccPer:24,blocks:2},
|
||||
{eccPer:30,blocks:2},{eccPer:18,blocks:2}
|
||||
];
|
||||
return info[ver];
|
||||
}
|
||||
|
||||
// GF(256) Reed-Solomon
|
||||
var gfExp = new Uint8Array(512), gfLog = new Uint8Array(256);
|
||||
(function() {
|
||||
var x = 1;
|
||||
for (var i = 0; i < 255; i++) { gfExp[i] = x; gfLog[x] = i; x = (x << 1) ^ (x >= 128 ? 0x11d : 0); }
|
||||
for (var i = 255; i < 512; i++) gfExp[i] = gfExp[i - 255];
|
||||
})();
|
||||
|
||||
function gfMul(a, b) { return (a === 0 || b === 0) ? 0 : gfExp[gfLog[a] + gfLog[b]]; }
|
||||
|
||||
function rsEncode(data, eccLen) {
|
||||
var gen = [1];
|
||||
for (var i = 0; i < eccLen; i++) {
|
||||
var ng = new Array(gen.length + 1).fill(0);
|
||||
for (var j = 0; j < gen.length; j++) {
|
||||
ng[j] ^= gen[j];
|
||||
ng[j+1] ^= gfMul(gen[j], gfExp[i]);
|
||||
}
|
||||
gen = ng;
|
||||
}
|
||||
var msg = data.concat(new Array(eccLen).fill(0));
|
||||
for (var i = 0; i < data.length; i++) {
|
||||
var coef = msg[i];
|
||||
if (coef !== 0) for (var j = 0; j < gen.length; j++) msg[i+j] ^= gfMul(gen[j], coef);
|
||||
}
|
||||
return msg.slice(data.length);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
|
|
@ -253,6 +253,7 @@ mod tests {
|
|||
fn make_actor(id: u8, worker: usize, depth: usize, msgs: u64) -> ActorInfo {
|
||||
ActorInfo {
|
||||
address: ActorAddress([id; 32]),
|
||||
name: None,
|
||||
worker_id: worker,
|
||||
mailbox_depth: depth,
|
||||
last_msg_type: None,
|
||||
|
|
@ -23,7 +23,6 @@ pub mod distribution_collector;
|
|||
mod datastore_html;
|
||||
pub mod datastore_collector;
|
||||
|
||||
#[cfg(feature = "ci")]
|
||||
pub mod ci_collector;
|
||||
|
||||
use std::io;
|
||||
|
|
@ -43,6 +42,9 @@ use crate::history::{DashboardHistory, HistoryConfig};
|
|||
use crate::layer::{now_ms, DashboardLayer, EventStore};
|
||||
use crate::trace::{RuntimeTrace, TimestampedStats};
|
||||
|
||||
/// Peer info sent through the join channel: (public_key, optional_relay_url).
|
||||
pub type JoinPeerInfo = ([u8; 32], Option<String>);
|
||||
|
||||
/// Configuration for the runtime dashboard.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DashboardConfig {
|
||||
|
|
@ -92,14 +94,19 @@ pub struct DashboardHandle {
|
|||
runtime: Arc<Mutex<Option<Arc<Runtime>>>>,
|
||||
collector: Arc<Mutex<Option<Arc<StatsCollector>>>>,
|
||||
shutdown: Arc<AtomicBool>,
|
||||
shutdown_notify: Arc<tokio::sync::Notify>,
|
||||
stats_timeline: Arc<ArrayQueue<TimestampedStats>>,
|
||||
history: Arc<DashboardHistory>,
|
||||
recording: bool,
|
||||
port: u16,
|
||||
#[cfg(feature = "distribution")]
|
||||
distribution: Arc<Mutex<Option<Arc<dyn distribution_collector::DistributionStatsProvider>>>>,
|
||||
datastore: Arc<Mutex<Option<Arc<dyn datastore_collector::DatastoreStatsProvider>>>>,
|
||||
#[cfg(feature = "ci")]
|
||||
datastore_factory: Arc<Mutex<Option<Arc<dyn datastore_collector::DatastoreFactory>>>>,
|
||||
ci: Arc<Mutex<Option<Arc<dyn ci_collector::CiStatsProvider>>>>,
|
||||
peer_auth: Arc<Mutex<Option<Arc<Mutex<distribution::peer_auth::PeerAllowList>>>>>,
|
||||
join_sender: Arc<Mutex<Option<std::sync::mpsc::Sender<JoinPeerInfo>>>>,
|
||||
standalone_rt: Mutex<Option<tokio::runtime::Runtime>>,
|
||||
}
|
||||
|
||||
impl DashboardHandle {
|
||||
|
|
@ -137,12 +144,31 @@ impl DashboardHandle {
|
|||
*self.datastore.lock().unwrap() = Some(provider);
|
||||
}
|
||||
|
||||
/// Attach a datastore factory, enabling start/stop from the dashboard.
|
||||
pub fn set_datastore_factory(&self, factory: Arc<dyn datastore_collector::DatastoreFactory>) {
|
||||
*self.datastore_factory.lock().unwrap() = Some(factory);
|
||||
}
|
||||
|
||||
/// Get the shared datastore provider mutex (for external wiring).
|
||||
pub fn datastore_provider(&self) -> &Arc<Mutex<Option<Arc<dyn datastore_collector::DatastoreStatsProvider>>>> {
|
||||
&self.datastore
|
||||
}
|
||||
|
||||
/// Attach a CI stats provider, enabling the `/api/ci/*` endpoints.
|
||||
#[cfg(feature = "ci")]
|
||||
pub fn set_ci(&self, provider: Arc<dyn ci_collector::CiStatsProvider>) {
|
||||
*self.ci.lock().unwrap() = Some(provider);
|
||||
}
|
||||
|
||||
/// Attach a peer allow-list for the peer management API.
|
||||
pub fn set_peer_auth(&self, auth: Arc<Mutex<distribution::peer_auth::PeerAllowList>>) {
|
||||
*self.peer_auth.lock().unwrap() = Some(auth);
|
||||
}
|
||||
|
||||
/// Set a sender that triggers `driver.join()` when a peer is added via the dashboard.
|
||||
pub fn set_join_sender(&self, tx: std::sync::mpsc::Sender<JoinPeerInfo>) {
|
||||
*self.join_sender.lock().unwrap() = Some(tx);
|
||||
}
|
||||
|
||||
/// Access the time-series history store (for TUI sparklines, etc.).
|
||||
pub fn history(&self) -> &Arc<DashboardHistory> {
|
||||
&self.history
|
||||
|
|
@ -151,6 +177,49 @@ impl DashboardHandle {
|
|||
/// Signal the dashboard to shut down (SSE clients receive "done").
|
||||
pub fn shutdown(&self) {
|
||||
self.shutdown.store(true, Ordering::Release);
|
||||
self.shutdown_notify.notify_waiters();
|
||||
}
|
||||
|
||||
/// Start the HTTP server on the provided tokio handle.
|
||||
/// Use this when a tokio runtime already exists (e.g. IrohDriver's runtime).
|
||||
pub fn start_http(&self, handle: tokio::runtime::Handle) {
|
||||
let state = self.build_app_state();
|
||||
let port = self.port;
|
||||
handle.spawn(async move {
|
||||
server::run_server(state, port).await;
|
||||
});
|
||||
}
|
||||
|
||||
/// Start the HTTP server on a standalone tokio runtime (1 worker thread).
|
||||
/// Use this when no external tokio runtime is available (e.g. TCP transport).
|
||||
pub fn start_http_standalone(&self) {
|
||||
let rt = tokio::runtime::Builder::new_multi_thread()
|
||||
.worker_threads(1)
|
||||
.enable_all()
|
||||
.build()
|
||||
.expect("failed to create tokio runtime for dashboard HTTP");
|
||||
let handle = rt.handle().clone();
|
||||
*self.standalone_rt.lock().unwrap() = Some(rt);
|
||||
self.start_http(handle);
|
||||
}
|
||||
|
||||
fn build_app_state(&self) -> server::AppState {
|
||||
server::AppState {
|
||||
store: Arc::clone(&self.store),
|
||||
runtime: Arc::clone(&self.runtime),
|
||||
collector: Arc::clone(&self.collector),
|
||||
shutdown: Arc::clone(&self.shutdown),
|
||||
shutdown_notify: Arc::clone(&self.shutdown_notify),
|
||||
history: Arc::clone(&self.history),
|
||||
cmd_router: Arc::new(crate::command::CommandRouter::with_builtins()),
|
||||
#[cfg(feature = "distribution")]
|
||||
distribution: Arc::clone(&self.distribution),
|
||||
datastore: Arc::clone(&self.datastore),
|
||||
datastore_factory: Arc::clone(&self.datastore_factory),
|
||||
ci: Arc::clone(&self.ci),
|
||||
peer_auth: Arc::clone(&self.peer_auth),
|
||||
join_sender: Arc::clone(&self.join_sender),
|
||||
}
|
||||
}
|
||||
|
||||
/// Save the recorded trace to a JSON file.
|
||||
|
|
@ -178,10 +247,12 @@ impl DashboardHandle {
|
|||
}
|
||||
}
|
||||
|
||||
/// Start a dashboard server and return a handle.
|
||||
/// Start a dashboard and return a handle.
|
||||
///
|
||||
/// The dashboard starts serving immediately. Call `install_tracing()` to set up
|
||||
/// the global subscriber, and `set_runtime()` to enable stats polling.
|
||||
/// The dashboard state is created immediately but the HTTP server is NOT started.
|
||||
/// Call `start_http()` or `start_http_standalone()` to begin serving.
|
||||
/// Call `install_tracing()` to set up the global subscriber, and `set_runtime()`
|
||||
/// to enable stats polling.
|
||||
pub fn start_dashboard(config: DashboardConfig) -> DashboardHandle {
|
||||
let store = Arc::new(EventStore::new(
|
||||
config.event_capacity,
|
||||
|
|
@ -191,6 +262,7 @@ pub fn start_dashboard(config: DashboardConfig) -> DashboardHandle {
|
|||
let runtime: Arc<Mutex<Option<Arc<Runtime>>>> = Arc::new(Mutex::new(None));
|
||||
let collector: Arc<Mutex<Option<Arc<StatsCollector>>>> = Arc::new(Mutex::new(None));
|
||||
let shutdown = Arc::new(AtomicBool::new(false));
|
||||
let shutdown_notify = Arc::new(tokio::sync::Notify::new());
|
||||
let stats_timeline = Arc::new(ArrayQueue::new(config.record_stats_capacity.max(1)));
|
||||
let history = Arc::new(DashboardHistory::new(HistoryConfig::default()));
|
||||
|
||||
|
|
@ -201,23 +273,17 @@ pub fn start_dashboard(config: DashboardConfig) -> DashboardHandle {
|
|||
let datastore: Arc<Mutex<Option<Arc<dyn datastore_collector::DatastoreStatsProvider>>>> =
|
||||
Arc::new(Mutex::new(None));
|
||||
|
||||
#[cfg(feature = "ci")]
|
||||
let datastore_factory: Arc<Mutex<Option<Arc<dyn datastore_collector::DatastoreFactory>>>> =
|
||||
Arc::new(Mutex::new(None));
|
||||
|
||||
let ci: Arc<Mutex<Option<Arc<dyn ci_collector::CiStatsProvider>>>> =
|
||||
Arc::new(Mutex::new(None));
|
||||
|
||||
server::spawn_http_server(
|
||||
Arc::clone(&store),
|
||||
Arc::clone(&runtime),
|
||||
Arc::clone(&collector),
|
||||
Arc::clone(&shutdown),
|
||||
Arc::clone(&history),
|
||||
config.port,
|
||||
#[cfg(feature = "distribution")]
|
||||
Arc::clone(&distribution),
|
||||
Arc::clone(&datastore),
|
||||
#[cfg(feature = "ci")]
|
||||
Arc::clone(&ci),
|
||||
);
|
||||
let peer_auth: Arc<Mutex<Option<Arc<Mutex<distribution::peer_auth::PeerAllowList>>>>> =
|
||||
Arc::new(Mutex::new(None));
|
||||
|
||||
let join_sender: Arc<Mutex<Option<std::sync::mpsc::Sender<JoinPeerInfo>>>> =
|
||||
Arc::new(Mutex::new(None));
|
||||
|
||||
// Start stats recorder thread when recording is enabled
|
||||
if config.record {
|
||||
|
|
@ -247,21 +313,26 @@ pub fn start_dashboard(config: DashboardConfig) -> DashboardHandle {
|
|||
});
|
||||
}
|
||||
|
||||
eprintln!("Runtime dashboard at http://localhost:{}", config.port);
|
||||
let port = config.port;
|
||||
|
||||
DashboardHandle {
|
||||
store,
|
||||
runtime,
|
||||
collector,
|
||||
shutdown,
|
||||
shutdown_notify,
|
||||
stats_timeline,
|
||||
history,
|
||||
recording: config.record,
|
||||
port,
|
||||
#[cfg(feature = "distribution")]
|
||||
distribution,
|
||||
datastore,
|
||||
#[cfg(feature = "ci")]
|
||||
datastore_factory,
|
||||
ci,
|
||||
peer_auth,
|
||||
join_sender,
|
||||
standalone_rt: Mutex::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -274,6 +345,7 @@ pub fn run_with_dashboard(
|
|||
) -> (RuntimeHandle, DashboardHandle) {
|
||||
let dash = start_dashboard(dash_config);
|
||||
dash.install_tracing();
|
||||
dash.start_http_standalone();
|
||||
|
||||
let num_workers = if rt_config.num_threads < 2 { 1 } else { rt_config.num_threads };
|
||||
let collector = StatsCollector::new(num_workers);
|
||||
|
|
@ -292,7 +364,21 @@ pub fn serve_replay(path: &str, config: ReplayConfig) -> io::Result<()> {
|
|||
let trace: RuntimeTrace = serde_json::from_str(&data)
|
||||
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
|
||||
|
||||
server::spawn_replay_server(Arc::new(trace), config.port, config.speed);
|
||||
let rt = tokio::runtime::Builder::new_multi_thread()
|
||||
.worker_threads(1)
|
||||
.enable_all()
|
||||
.build()
|
||||
.map_err(|e| io::Error::new(io::ErrorKind::Other, e))?;
|
||||
|
||||
let state = server::ReplayState {
|
||||
trace: Arc::new(trace),
|
||||
speed: config.speed,
|
||||
};
|
||||
let port = config.port;
|
||||
|
||||
rt.spawn(async move {
|
||||
server::run_replay_server(state, port).await;
|
||||
});
|
||||
|
||||
eprintln!("Replay dashboard at http://localhost:{}", config.port);
|
||||
eprintln!("Press Ctrl+C to stop");
|
||||
858
crates/dashboard/src/server.rs
Normal file
858
crates/dashboard/src/server.rs
Normal file
|
|
@ -0,0 +1,858 @@
|
|||
use std::collections::HashMap;
|
||||
use std::convert::Infallible;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use axum::body::Bytes;
|
||||
use axum::extract::{Path, Query, State};
|
||||
use axum::http::{header, StatusCode};
|
||||
use axum::response::sse::{Event, KeepAlive, Sse};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::routing::{get, post};
|
||||
use axum::Router;
|
||||
use tokio_stream::wrappers::ReceiverStream;
|
||||
use tokio_stream::StreamExt;
|
||||
|
||||
use swactor::runtime::Runtime;
|
||||
|
||||
use crate::actor_detail_html::ACTOR_DETAIL_HTML;
|
||||
use crate::actors_html::ACTORS_HTML;
|
||||
use crate::collector::StatsCollector;
|
||||
use crate::command::CommandRouter;
|
||||
use crate::dashboard_html::DASHBOARD_HTML;
|
||||
use crate::datastore_collector::{DatastoreFactory, DatastoreStatsProvider, ListScope};
|
||||
use crate::datastore_html::DATASTORE_HTML;
|
||||
use crate::history::DashboardHistory;
|
||||
use crate::layer::EventStore;
|
||||
use crate::topology;
|
||||
use crate::topology_html::TOPOLOGY_HTML;
|
||||
use crate::warnings::{WarningConfig, WarningDetector};
|
||||
|
||||
#[cfg(feature = "distribution")]
|
||||
use crate::distribution_collector::DistributionStatsProvider;
|
||||
#[cfg(feature = "distribution")]
|
||||
use crate::distribution_html::DISTRIBUTION_HTML;
|
||||
|
||||
use crate::ci_collector::CiStatsProvider;
|
||||
|
||||
use crate::trace::RuntimeTrace;
|
||||
|
||||
/// Format a server-sent event.
|
||||
fn format_sse(event: &str, data: &str) -> Event {
|
||||
Event::default().event(event).data(data)
|
||||
}
|
||||
|
||||
// ── Shared application state ────────────────────────────────────────────
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct AppState {
|
||||
pub store: Arc<EventStore>,
|
||||
pub runtime: Arc<Mutex<Option<Arc<Runtime>>>>,
|
||||
pub collector: Arc<Mutex<Option<Arc<StatsCollector>>>>,
|
||||
pub shutdown: Arc<AtomicBool>,
|
||||
pub shutdown_notify: Arc<tokio::sync::Notify>,
|
||||
pub history: Arc<DashboardHistory>,
|
||||
pub cmd_router: Arc<CommandRouter>,
|
||||
#[cfg(feature = "distribution")]
|
||||
pub distribution: Arc<Mutex<Option<Arc<dyn DistributionStatsProvider>>>>,
|
||||
pub datastore: Arc<Mutex<Option<Arc<dyn DatastoreStatsProvider>>>>,
|
||||
pub datastore_factory: Arc<Mutex<Option<Arc<dyn DatastoreFactory>>>>,
|
||||
pub ci: Arc<Mutex<Option<Arc<dyn CiStatsProvider>>>>,
|
||||
pub peer_auth: Arc<Mutex<Option<Arc<Mutex<distribution::peer_auth::PeerAllowList>>>>>,
|
||||
pub join_sender: Arc<Mutex<Option<std::sync::mpsc::Sender<crate::JoinPeerInfo>>>>,
|
||||
}
|
||||
|
||||
// ── Router builders ─────────────────────────────────────────────────────
|
||||
|
||||
pub(crate) fn build_live_router(state: AppState) -> Router {
|
||||
let router = Router::new()
|
||||
.route("/", get(page_dashboard))
|
||||
.route("/actors", get(page_actors))
|
||||
.route("/topology", get(page_topology))
|
||||
.route("/datastore", get(page_datastore))
|
||||
.route("/events", get(handle_live_sse))
|
||||
.route("/api/stats", get(handle_stats_api))
|
||||
.route("/api/history", get(handle_history_api))
|
||||
.route("/api/topology", get(handle_topology_api))
|
||||
.route("/api/investigate", get(handle_investigate_api))
|
||||
.route("/api/datastore", get(handle_datastore_api))
|
||||
.route("/api/logs", get(handle_logs_api))
|
||||
.route("/api/datastore/list", get(handle_ds_list))
|
||||
.route("/api/datastore/get", get(handle_ds_get))
|
||||
.route("/api/datastore/data", get(handle_ds_data))
|
||||
.route("/api/datastore/status", get(handle_ds_status))
|
||||
.route("/api/datastore/put", post(handle_ds_put))
|
||||
.route("/api/datastore/delete", post(handle_ds_delete))
|
||||
.route("/api/datastore/start", post(handle_ds_start))
|
||||
.route("/api/datastore/shutdown", post(handle_ds_shutdown))
|
||||
.route("/api/peers", get(handle_peers_list))
|
||||
.route("/api/peers/add", post(handle_peers_add))
|
||||
.route("/api/peers/remove", post(handle_peers_remove))
|
||||
.route("/actor/{hex}", get(handle_actor_detail));
|
||||
|
||||
#[cfg(feature = "distribution")]
|
||||
let router = router
|
||||
.route("/distribution", get(page_distribution))
|
||||
.route("/api/distribution", get(handle_distribution_api));
|
||||
|
||||
let router = router.route("/api/ci/{*rest}", get(handle_ci_api));
|
||||
|
||||
router.with_state(state)
|
||||
}
|
||||
|
||||
pub(crate) fn build_replay_router(state: ReplayState) -> Router {
|
||||
Router::new()
|
||||
.route("/", get(replay_page_dashboard))
|
||||
.route("/actors", get(replay_page_actors))
|
||||
.route("/events", get(handle_replay_sse))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
// ── Server startup ──────────────────────────────────────────────────────
|
||||
|
||||
pub(crate) async fn run_server(state: AppState, port: u16) {
|
||||
let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{port}"))
|
||||
.await
|
||||
.expect("failed to bind HTTP server");
|
||||
let shutdown = state.shutdown_notify.clone();
|
||||
axum::serve(listener, build_live_router(state))
|
||||
.with_graceful_shutdown(async move { shutdown.notified().await })
|
||||
.await
|
||||
.expect("HTTP server error");
|
||||
}
|
||||
|
||||
pub(crate) async fn run_replay_server(state: ReplayState, port: u16) {
|
||||
let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{port}"))
|
||||
.await
|
||||
.expect("failed to bind HTTP server");
|
||||
axum::serve(listener, build_replay_router(state))
|
||||
.await
|
||||
.expect("HTTP server error");
|
||||
}
|
||||
|
||||
// ── HTML page handlers ──────────────────────────────────────────────────
|
||||
|
||||
fn html_response(template: &str, mode: &str) -> Response {
|
||||
let html = template.replace("__DASHBOARD_MODE__", mode);
|
||||
([(header::CONTENT_TYPE, "text/html; charset=utf-8")], html).into_response()
|
||||
}
|
||||
|
||||
async fn page_dashboard() -> Response {
|
||||
html_response(DASHBOARD_HTML, "live")
|
||||
}
|
||||
|
||||
async fn page_actors() -> Response {
|
||||
html_response(ACTORS_HTML, "live")
|
||||
}
|
||||
|
||||
async fn page_topology() -> Response {
|
||||
html_response(TOPOLOGY_HTML, "live")
|
||||
}
|
||||
|
||||
async fn page_datastore() -> Response {
|
||||
html_response(DATASTORE_HTML, "live")
|
||||
}
|
||||
|
||||
#[cfg(feature = "distribution")]
|
||||
async fn page_distribution() -> Response {
|
||||
html_response(DISTRIBUTION_HTML, "live")
|
||||
}
|
||||
|
||||
async fn handle_actor_detail(Path(hex_addr): Path<String>) -> Response {
|
||||
let html = ACTOR_DETAIL_HTML
|
||||
.replace("__DASHBOARD_MODE__", "live")
|
||||
.replace("__ACTOR_ADDR__", &hex_addr);
|
||||
([(header::CONTENT_TYPE, "text/html; charset=utf-8")], html).into_response()
|
||||
}
|
||||
|
||||
// ── Live SSE handler ────────────────────────────────────────────────────
|
||||
|
||||
async fn handle_live_sse(
|
||||
State(state): State<AppState>,
|
||||
) -> Sse<impl tokio_stream::Stream<Item = Result<Event, Infallible>>> {
|
||||
let (tx, rx) = tokio::sync::mpsc::channel::<Event>(32);
|
||||
|
||||
tokio::spawn(async move {
|
||||
let mut cursor: u64 = 0;
|
||||
let mut warning_detector = WarningDetector::new(WarningConfig::default());
|
||||
let mut tick_count: u64 = 0;
|
||||
|
||||
// Send initial history snapshot so sparklines render immediately
|
||||
if state.history.sample_count() > 0 {
|
||||
let json = state.history.worker_history_json();
|
||||
if tx.send(format_sse("history", &json)).await.is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
loop {
|
||||
// Send stats if runtime is available
|
||||
{
|
||||
let maybe_rt = state.runtime.lock().unwrap().clone();
|
||||
if let Some(rt) = maybe_rt {
|
||||
let mut stats = rt.stats();
|
||||
if let Some(col) = state.collector.lock().unwrap().as_ref() {
|
||||
col.enrich(&mut stats);
|
||||
}
|
||||
crate::collector::enrich_names(&mut stats, &rt);
|
||||
state.history.record(&stats);
|
||||
|
||||
// Run warning detection
|
||||
let warnings = warning_detector.check(&stats);
|
||||
if !warnings.is_empty() {
|
||||
if let Ok(wjson) = serde_json::to_string(&warnings) {
|
||||
if tx.send(format_sse("warnings", &wjson)).await.is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let json = serde_json::to_string(&stats).unwrap();
|
||||
if tx.send(format_sse("stats", &json)).await.is_err() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Send topology every 5th tick (~1/sec)
|
||||
tick_count += 1;
|
||||
if tick_count % 5 == 0 {
|
||||
let topo = topology::worker_topology(&stats);
|
||||
if let Ok(tjson) = serde_json::to_string(&topo) {
|
||||
if tx.send(format_sse("topology", &tjson)).await.is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Send distribution snapshot if provider is attached
|
||||
#[cfg(feature = "distribution")]
|
||||
{
|
||||
let maybe_dist = state.distribution.lock().unwrap().clone();
|
||||
if let Some(provider) = maybe_dist {
|
||||
if let Some(snapshot) = provider.snapshot() {
|
||||
if let Ok(json) = serde_json::to_string(&snapshot) {
|
||||
if tx.send(format_sse("distribution", &json)).await.is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Send datastore snapshot if provider is attached
|
||||
{
|
||||
let maybe_ds = state.datastore.lock().unwrap().clone();
|
||||
match maybe_ds {
|
||||
Some(provider) => {
|
||||
let is_running = provider.is_running();
|
||||
let snap_json = provider.snapshot_json().unwrap_or_else(|| "null".into());
|
||||
let envelope = format!(
|
||||
r#"{{"is_running":{},"snapshot":{}}}"#,
|
||||
is_running, snap_json
|
||||
);
|
||||
if tx.send(format_sse("datastore", &envelope)).await.is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
None => {
|
||||
let envelope = r#"{"is_running":false,"snapshot":null}"#;
|
||||
if tx.send(format_sse("datastore", envelope)).await.is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Send CI snapshot if provider is attached
|
||||
{
|
||||
let maybe_ci = state.ci.lock().unwrap().clone();
|
||||
if let Some(provider) = maybe_ci {
|
||||
let snapshot = provider.snapshot();
|
||||
if let Ok(json) = serde_json::to_string(&snapshot) {
|
||||
if tx.send(format_sse("ci", &json)).await.is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Send new activity events
|
||||
let (batch, new_cursor) = state.store.read_from(cursor);
|
||||
if !batch.is_empty() {
|
||||
let json = serde_json::to_string(&batch).unwrap();
|
||||
if tx.send(format_sse("activity", &json)).await.is_err() {
|
||||
return;
|
||||
}
|
||||
cursor = new_cursor;
|
||||
}
|
||||
|
||||
if state.shutdown.load(Ordering::Relaxed) {
|
||||
let _ = tx.send(format_sse("done", "{}")).await;
|
||||
return;
|
||||
}
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
}
|
||||
});
|
||||
|
||||
Sse::new(ReceiverStream::new(rx).map(Ok))
|
||||
.keep_alive(KeepAlive::default())
|
||||
}
|
||||
|
||||
// ── JSON API handlers ───────────────────────────────────────────────────
|
||||
|
||||
fn json_response(json: String) -> Response {
|
||||
([(header::CONTENT_TYPE, "application/json")], json).into_response()
|
||||
}
|
||||
|
||||
fn json_error(status: StatusCode, msg: &str) -> Response {
|
||||
let json = serde_json::json!({ "error": msg }).to_string();
|
||||
(status, [(header::CONTENT_TYPE, "application/json")], json).into_response()
|
||||
}
|
||||
|
||||
async fn handle_stats_api(State(state): State<AppState>) -> Response {
|
||||
let maybe_rt = state.runtime.lock().unwrap().clone();
|
||||
let json = match maybe_rt {
|
||||
Some(rt) => {
|
||||
let mut stats = rt.stats();
|
||||
if let Some(col) = state.collector.lock().unwrap().as_ref() {
|
||||
col.enrich(&mut stats);
|
||||
}
|
||||
crate::collector::enrich_names(&mut stats, &rt);
|
||||
serde_json::to_string(&stats).unwrap()
|
||||
}
|
||||
None => "{}".to_string(),
|
||||
};
|
||||
json_response(json)
|
||||
}
|
||||
|
||||
async fn handle_investigate_api(
|
||||
State(state): State<AppState>,
|
||||
Query(params): Query<HashMap<String, String>>,
|
||||
) -> Response {
|
||||
let maybe_rt = state.runtime.lock().unwrap().clone();
|
||||
let maybe_col = state.collector.lock().unwrap().clone();
|
||||
|
||||
let json = match (maybe_rt, maybe_col) {
|
||||
(Some(rt), Some(col)) => {
|
||||
let ctx = crate::command::CommandContext::with_enricher(rt, col);
|
||||
let req = crate::command::from_query_params(¶ms);
|
||||
state.cmd_router.dispatch(&req, &ctx).to_json_line()
|
||||
}
|
||||
(Some(rt), None) => {
|
||||
let ctx = crate::command::CommandContext::new(rt);
|
||||
let req = crate::command::from_query_params(¶ms);
|
||||
state.cmd_router.dispatch(&req, &ctx).to_json_line()
|
||||
}
|
||||
_ => {
|
||||
let cmd = params.get("cmd").map(|s| s.as_str()).unwrap_or("help");
|
||||
serde_json::json!({
|
||||
"ok": false,
|
||||
"command": cmd,
|
||||
"error": "runtime not attached yet"
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
};
|
||||
|
||||
json_response(json)
|
||||
}
|
||||
|
||||
#[cfg(feature = "distribution")]
|
||||
async fn handle_distribution_api(State(state): State<AppState>) -> Response {
|
||||
let json = match state.distribution.lock().unwrap().as_ref() {
|
||||
Some(provider) => match provider.snapshot() {
|
||||
Some(snapshot) => serde_json::to_string(&snapshot).unwrap_or_else(|_| "{}".into()),
|
||||
None => "{}".to_string(),
|
||||
},
|
||||
None => serde_json::json!({
|
||||
"error": "distribution provider not attached"
|
||||
})
|
||||
.to_string(),
|
||||
};
|
||||
json_response(json)
|
||||
}
|
||||
|
||||
async fn handle_datastore_api(State(state): State<AppState>) -> Response {
|
||||
let json = match state.datastore.lock().unwrap().as_ref() {
|
||||
Some(provider) => provider.snapshot_json().unwrap_or_else(|| "{}".into()),
|
||||
None => serde_json::json!({
|
||||
"error": "datastore provider not attached"
|
||||
})
|
||||
.to_string(),
|
||||
};
|
||||
json_response(json)
|
||||
}
|
||||
|
||||
async fn handle_ci_api(
|
||||
State(state): State<AppState>,
|
||||
axum::extract::OriginalUri(uri): axum::extract::OriginalUri,
|
||||
) -> Response {
|
||||
use crate::ci_collector;
|
||||
|
||||
let path = uri.path();
|
||||
let route = ci_collector::parse_route(path);
|
||||
let json = match state.ci.lock().unwrap().as_ref() {
|
||||
Some(provider) => {
|
||||
let snapshot = provider.snapshot();
|
||||
ci_collector::handle_route(&route, &snapshot)
|
||||
.unwrap_or_else(|| r#"{"error":"not found"}"#.to_string())
|
||||
}
|
||||
None => serde_json::json!({
|
||||
"error": "CI provider not attached"
|
||||
})
|
||||
.to_string(),
|
||||
};
|
||||
json_response(json)
|
||||
}
|
||||
|
||||
async fn handle_topology_api(State(state): State<AppState>) -> Response {
|
||||
let maybe_rt = state.runtime.lock().unwrap().clone();
|
||||
let json = match maybe_rt {
|
||||
Some(rt) => {
|
||||
let mut stats = rt.stats();
|
||||
if let Some(col) = state.collector.lock().unwrap().as_ref() {
|
||||
col.enrich(&mut stats);
|
||||
}
|
||||
let topo = topology::worker_topology(&stats);
|
||||
serde_json::to_string(&topo).unwrap_or_else(|_| "{}".into())
|
||||
}
|
||||
None => "{}".to_string(),
|
||||
};
|
||||
json_response(json)
|
||||
}
|
||||
|
||||
async fn handle_logs_api(
|
||||
State(state): State<AppState>,
|
||||
Query(params): Query<HashMap<String, String>>,
|
||||
) -> Response {
|
||||
let actor = params.get("actor").cloned().unwrap_or_default();
|
||||
let limit: usize = params
|
||||
.get("limit")
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(200);
|
||||
let level = params.get("level").cloned();
|
||||
|
||||
let mut events = state.store.read_for_actor(&actor, limit);
|
||||
|
||||
// Filter by level if specified
|
||||
if let Some(ref lvl) = level {
|
||||
let lvl_upper = lvl.to_uppercase();
|
||||
events.retain(|e| e.level == lvl_upper);
|
||||
}
|
||||
|
||||
let json = serde_json::to_string(&events).unwrap_or_else(|_| "[]".into());
|
||||
json_response(json)
|
||||
}
|
||||
|
||||
async fn handle_history_api(State(state): State<AppState>) -> Response {
|
||||
let json = state.history.worker_history_json();
|
||||
json_response(json)
|
||||
}
|
||||
|
||||
// ── Datastore CRUD API handlers ─────────────────────────────────────────
|
||||
|
||||
fn get_ds_provider(
|
||||
datastore: &Arc<Mutex<Option<Arc<dyn DatastoreStatsProvider>>>>,
|
||||
) -> Option<Arc<dyn DatastoreStatsProvider>> {
|
||||
datastore.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
async fn handle_ds_list(
|
||||
State(state): State<AppState>,
|
||||
Query(params): Query<HashMap<String, String>>,
|
||||
) -> Response {
|
||||
let provider = match get_ds_provider(&state.datastore) {
|
||||
Some(p) => p,
|
||||
None => return json_error(StatusCode::SERVICE_UNAVAILABLE, "datastore not running"),
|
||||
};
|
||||
let scope = match params.get("scope").map(|s| s.as_str()) {
|
||||
Some("local") => ListScope::Local,
|
||||
_ => ListScope::Swarm,
|
||||
};
|
||||
let name_filter = params.get("name").map(|s| s.as_str());
|
||||
match provider.list_objects(name_filter, scope) {
|
||||
Ok(json) => json_response(json),
|
||||
Err(e) => json_error(StatusCode::INTERNAL_SERVER_ERROR, &e),
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_ds_get(
|
||||
State(state): State<AppState>,
|
||||
Query(params): Query<HashMap<String, String>>,
|
||||
) -> Response {
|
||||
let provider = match get_ds_provider(&state.datastore) {
|
||||
Some(p) => p,
|
||||
None => return json_error(StatusCode::SERVICE_UNAVAILABLE, "datastore not running"),
|
||||
};
|
||||
let hash = match params.get("hash") {
|
||||
Some(h) => h.as_str(),
|
||||
None => return json_error(StatusCode::BAD_REQUEST, "missing ?hash= parameter"),
|
||||
};
|
||||
match provider.get_object(hash) {
|
||||
Ok(json) => json_response(json),
|
||||
Err(e) if e.contains("not found") => json_error(StatusCode::NOT_FOUND, &e),
|
||||
Err(e) => json_error(StatusCode::INTERNAL_SERVER_ERROR, &e),
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_ds_data(
|
||||
State(state): State<AppState>,
|
||||
Query(params): Query<HashMap<String, String>>,
|
||||
) -> Response {
|
||||
let provider = match get_ds_provider(&state.datastore) {
|
||||
Some(p) => p,
|
||||
None => return json_error(StatusCode::SERVICE_UNAVAILABLE, "datastore not running"),
|
||||
};
|
||||
let hash = match params.get("hash") {
|
||||
Some(h) => h.as_str(),
|
||||
None => return json_error(StatusCode::BAD_REQUEST, "missing ?hash= parameter"),
|
||||
};
|
||||
match provider.get_data(hash) {
|
||||
Ok(data) => {
|
||||
([(header::CONTENT_TYPE, "application/octet-stream")], data).into_response()
|
||||
}
|
||||
Err(e) if e.contains("not found") => json_error(StatusCode::NOT_FOUND, &e),
|
||||
Err(e) => json_error(StatusCode::INTERNAL_SERVER_ERROR, &e),
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_ds_status(State(state): State<AppState>) -> Response {
|
||||
let provider = match get_ds_provider(&state.datastore) {
|
||||
Some(p) => p,
|
||||
None => return json_error(StatusCode::SERVICE_UNAVAILABLE, "datastore not running"),
|
||||
};
|
||||
match provider.node_status() {
|
||||
Ok(json) => json_response(json),
|
||||
Err(e) => json_error(StatusCode::INTERNAL_SERVER_ERROR, &e),
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_ds_put(
|
||||
State(state): State<AppState>,
|
||||
Query(params): Query<HashMap<String, String>>,
|
||||
body: Bytes,
|
||||
) -> Response {
|
||||
let provider = match get_ds_provider(&state.datastore) {
|
||||
Some(p) => p,
|
||||
None => return json_error(StatusCode::SERVICE_UNAVAILABLE, "datastore not running"),
|
||||
};
|
||||
let name = params.get("name").cloned();
|
||||
match provider.put_data(body.to_vec(), name) {
|
||||
Ok(json) => json_response(json),
|
||||
Err(e) => json_error(StatusCode::INTERNAL_SERVER_ERROR, &e),
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_ds_delete(
|
||||
State(state): State<AppState>,
|
||||
Query(params): Query<HashMap<String, String>>,
|
||||
) -> Response {
|
||||
let provider = match get_ds_provider(&state.datastore) {
|
||||
Some(p) => p,
|
||||
None => return json_error(StatusCode::SERVICE_UNAVAILABLE, "datastore not running"),
|
||||
};
|
||||
let hash = match params.get("hash") {
|
||||
Some(h) => h.as_str(),
|
||||
None => return json_error(StatusCode::BAD_REQUEST, "missing ?hash= parameter"),
|
||||
};
|
||||
match provider.delete_object(hash) {
|
||||
Ok(json) => json_response(json),
|
||||
Err(e) if e.contains("not found") => json_error(StatusCode::NOT_FOUND, &e),
|
||||
Err(e) => json_error(StatusCode::INTERNAL_SERVER_ERROR, &e),
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_ds_start(
|
||||
State(state): State<AppState>,
|
||||
Query(params): Query<HashMap<String, String>>,
|
||||
) -> Response {
|
||||
// Check if already running
|
||||
{
|
||||
let ds = state.datastore.lock().unwrap();
|
||||
if ds.is_some() {
|
||||
return json_error(StatusCode::CONFLICT, "datastore already running");
|
||||
}
|
||||
}
|
||||
|
||||
let fac = match state.datastore_factory.lock().unwrap().clone() {
|
||||
Some(f) => f,
|
||||
None => return json_error(StatusCode::NOT_IMPLEMENTED, "no datastore factory configured"),
|
||||
};
|
||||
|
||||
let storage_path = params.get("storage_path").cloned();
|
||||
|
||||
match fac.start_datastore(storage_path) {
|
||||
Ok(provider) => {
|
||||
*state.datastore.lock().unwrap() = Some(provider);
|
||||
json_response(r#"{"ok":true}"#.to_string())
|
||||
}
|
||||
Err(e) => json_error(StatusCode::INTERNAL_SERVER_ERROR, &e),
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_ds_shutdown(State(state): State<AppState>) -> Response {
|
||||
let provider = match get_ds_provider(&state.datastore) {
|
||||
Some(p) => p,
|
||||
None => return json_error(StatusCode::SERVICE_UNAVAILABLE, "datastore not running"),
|
||||
};
|
||||
|
||||
match provider.shutdown_datastore() {
|
||||
Ok(()) => {
|
||||
*state.datastore.lock().unwrap() = None;
|
||||
json_response(r#"{"ok":true}"#.to_string())
|
||||
}
|
||||
Err(e) => json_error(StatusCode::INTERNAL_SERVER_ERROR, &e),
|
||||
}
|
||||
}
|
||||
|
||||
// ── Peer Management API ─────────────────────────────────────────────────
|
||||
|
||||
async fn handle_peers_list(State(state): State<AppState>) -> Response {
|
||||
let maybe_auth = state.peer_auth.lock().unwrap().clone();
|
||||
match maybe_auth {
|
||||
Some(auth) => {
|
||||
let list = auth.lock().unwrap();
|
||||
let is_open = list.is_open();
|
||||
let peers: Vec<serde_json::Value> = list
|
||||
.list_peers()
|
||||
.iter()
|
||||
.map(|p| {
|
||||
serde_json::json!({
|
||||
"node_id": p.node_id,
|
||||
"label": p.label,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let json = serde_json::json!({
|
||||
"mode": if is_open { "open" } else { "allow-list" },
|
||||
"peers": peers,
|
||||
})
|
||||
.to_string();
|
||||
json_response(json)
|
||||
}
|
||||
None => {
|
||||
let json = serde_json::json!({
|
||||
"mode": "open",
|
||||
"peers": [],
|
||||
})
|
||||
.to_string();
|
||||
json_response(json)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_peers_add(State(state): State<AppState>, body: String) -> Response {
|
||||
let maybe_auth = state.peer_auth.lock().unwrap().clone();
|
||||
let auth = match maybe_auth {
|
||||
Some(a) => a,
|
||||
None => return json_error(StatusCode::BAD_REQUEST, "peer auth not configured"),
|
||||
};
|
||||
|
||||
let parsed: serde_json::Value = match serde_json::from_str(&body) {
|
||||
Ok(v) => v,
|
||||
Err(e) => return json_error(StatusCode::BAD_REQUEST, &format!("invalid JSON: {e}")),
|
||||
};
|
||||
|
||||
let node_id_str = match parsed.get("node_id").and_then(|v| v.as_str()) {
|
||||
Some(s) => s,
|
||||
None => return json_error(StatusCode::BAD_REQUEST, "missing node_id field"),
|
||||
};
|
||||
let label = parsed
|
||||
.get("label")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
// Accept hex (64 chars) or base58 (~44 chars)
|
||||
let bytes: [u8; 32] = if let Some(b) = distribution::identity::hex_decode(node_id_str) {
|
||||
match b.try_into() {
|
||||
Ok(arr) => arr,
|
||||
Err(_) => {
|
||||
return json_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"invalid node_id (hex decoded to wrong length)",
|
||||
);
|
||||
}
|
||||
}
|
||||
} else if let Some(arr) = distribution::identity::base58_decode(node_id_str) {
|
||||
arr
|
||||
} else {
|
||||
return json_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"invalid node_id (expected 64-char hex or base58)",
|
||||
);
|
||||
};
|
||||
|
||||
let relay_url = parsed
|
||||
.get("relay_url")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
let node_id = distribution::types::NodeId(bytes);
|
||||
let mut list = auth.lock().unwrap();
|
||||
list.add_peer(node_id, label);
|
||||
if let Err(e) = list.save() {
|
||||
eprintln!("warning: failed to persist peers.json: {e}");
|
||||
}
|
||||
drop(list);
|
||||
|
||||
// Trigger a SWIM join for the newly added peer
|
||||
if let Some(tx) = state.join_sender.lock().unwrap().as_ref() {
|
||||
let _ = tx.send((bytes, relay_url));
|
||||
}
|
||||
|
||||
json_response(r#"{"ok":true}"#.to_string())
|
||||
}
|
||||
|
||||
async fn handle_peers_remove(State(state): State<AppState>, body: String) -> Response {
|
||||
let maybe_auth = state.peer_auth.lock().unwrap().clone();
|
||||
let auth = match maybe_auth {
|
||||
Some(a) => a,
|
||||
None => return json_error(StatusCode::BAD_REQUEST, "peer auth not configured"),
|
||||
};
|
||||
|
||||
let parsed: serde_json::Value = match serde_json::from_str(&body) {
|
||||
Ok(v) => v,
|
||||
Err(e) => return json_error(StatusCode::BAD_REQUEST, &format!("invalid JSON: {e}")),
|
||||
};
|
||||
|
||||
let node_id_hex = match parsed.get("node_id").and_then(|v| v.as_str()) {
|
||||
Some(s) => s,
|
||||
None => return json_error(StatusCode::BAD_REQUEST, "missing node_id field"),
|
||||
};
|
||||
|
||||
let bytes = match distribution::identity::hex_decode(node_id_hex) {
|
||||
Some(b) if b.len() == 32 => b,
|
||||
_ => {
|
||||
return json_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"invalid node_id hex (must be 64 hex chars)",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
let node_id = distribution::types::NodeId(bytes.try_into().unwrap());
|
||||
let mut list = auth.lock().unwrap();
|
||||
list.remove_peer(&node_id);
|
||||
if let Err(e) = list.save() {
|
||||
eprintln!("warning: failed to persist peers.json: {e}");
|
||||
}
|
||||
|
||||
json_response(r#"{"ok":true}"#.to_string())
|
||||
}
|
||||
|
||||
// ── Replay server ───────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct ReplayState {
|
||||
pub trace: Arc<RuntimeTrace>,
|
||||
pub speed: f64,
|
||||
}
|
||||
|
||||
async fn replay_page_dashboard() -> Response {
|
||||
html_response(DASHBOARD_HTML, "replay")
|
||||
}
|
||||
|
||||
async fn replay_page_actors() -> Response {
|
||||
html_response(ACTORS_HTML, "replay")
|
||||
}
|
||||
|
||||
async fn handle_replay_sse(
|
||||
State(state): State<ReplayState>,
|
||||
) -> Sse<impl tokio_stream::Stream<Item = Result<Event, Infallible>>> {
|
||||
let (tx, rx) = tokio::sync::mpsc::channel::<Event>(32);
|
||||
|
||||
tokio::spawn(async move {
|
||||
let trace = &state.trace;
|
||||
let speed = state.speed;
|
||||
|
||||
// Send replay metadata
|
||||
let meta = serde_json::json!({
|
||||
"total_events": trace.events.len(),
|
||||
"total_stats": trace.stats_timeline.len(),
|
||||
"speed": speed,
|
||||
});
|
||||
if tx.send(format_sse("replay_meta", &meta.to_string())).await.is_err() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the earliest timestamp across events and stats
|
||||
let base_time = trace
|
||||
.events
|
||||
.first()
|
||||
.map(|e| e.timestamp_ms)
|
||||
.into_iter()
|
||||
.chain(trace.stats_timeline.first().map(|s| s.timestamp_ms))
|
||||
.min()
|
||||
.unwrap_or(0);
|
||||
|
||||
let playback_start = tokio::time::Instant::now();
|
||||
let mut event_idx = 0;
|
||||
let mut stats_idx = 0;
|
||||
|
||||
loop {
|
||||
let elapsed_ms = (playback_start.elapsed().as_millis() as f64 * speed) as u64;
|
||||
let virtual_time = base_time + elapsed_ms;
|
||||
|
||||
// Batch events up to virtual_time
|
||||
let mut batch = Vec::new();
|
||||
while event_idx < trace.events.len()
|
||||
&& trace.events[event_idx].timestamp_ms <= virtual_time
|
||||
{
|
||||
batch.push(trace.events[event_idx].clone());
|
||||
event_idx += 1;
|
||||
}
|
||||
if !batch.is_empty() {
|
||||
let json = serde_json::to_string(&batch).unwrap();
|
||||
if tx.send(format_sse("activity", &json)).await.is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Send stats snapshots up to virtual_time
|
||||
while stats_idx < trace.stats_timeline.len()
|
||||
&& trace.stats_timeline[stats_idx].timestamp_ms <= virtual_time
|
||||
{
|
||||
let json =
|
||||
serde_json::to_string(&trace.stats_timeline[stats_idx].stats).unwrap();
|
||||
if tx.send(format_sse("stats", &json)).await.is_err() {
|
||||
return;
|
||||
}
|
||||
stats_idx += 1;
|
||||
}
|
||||
|
||||
// Send progress
|
||||
let total = trace.events.len() + trace.stats_timeline.len();
|
||||
let done_count = event_idx + stats_idx;
|
||||
let progress = if total > 0 {
|
||||
done_count as f64 / total as f64
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
let progress_json = serde_json::json!({ "progress": progress });
|
||||
if tx
|
||||
.send(format_sse("replay_progress", &progress_json.to_string()))
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if replay is complete
|
||||
if event_idx >= trace.events.len()
|
||||
&& stats_idx >= trace.stats_timeline.len()
|
||||
{
|
||||
let _ = tx.send(format_sse("done", "{}")).await;
|
||||
return;
|
||||
}
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
});
|
||||
|
||||
Sse::new(ReceiverStream::new(rx).map(Ok))
|
||||
.keep_alive(KeepAlive::default())
|
||||
}
|
||||
|
|
@ -34,6 +34,7 @@ pub struct ActorRow {
|
|||
pub last_msg_type: Option<String>,
|
||||
pub messages_processed: u64,
|
||||
pub poisoned: bool,
|
||||
pub name: Option<String>,
|
||||
/// Per-actor mailbox sparkline (from local ring buffer).
|
||||
pub sparkline_mailbox: Vec<u64>,
|
||||
/// Per-actor message rate sparkline.
|
||||
|
|
@ -191,7 +192,8 @@ impl App {
|
|||
let addr = format!("{}", r.address).to_lowercase();
|
||||
let msg = r.last_msg_type.as_deref().unwrap_or("").to_lowercase();
|
||||
let worker = format!("w{}", r.worker_id);
|
||||
addr.contains(&q) || msg.contains(&q) || worker.contains(&q)
|
||||
let name = r.name.as_deref().unwrap_or("").to_lowercase();
|
||||
addr.contains(&q) || msg.contains(&q) || worker.contains(&q) || name.contains(&q)
|
||||
}).collect()
|
||||
}
|
||||
}
|
||||
|
|
@ -335,6 +337,7 @@ impl App {
|
|||
last_msg_type: a.last_msg_type.clone(),
|
||||
messages_processed: a.messages_processed,
|
||||
poisoned: a.poisoned,
|
||||
name: a.name.clone(),
|
||||
sparkline_rates: rates_buf.iter().copied().collect(),
|
||||
sparkline_mailbox: mbox_buf.iter().copied().collect(),
|
||||
message_type_counts: a.message_type_counts.clone(),
|
||||
|
|
@ -107,6 +107,7 @@ fn run_loop(
|
|||
// Initial stats poll
|
||||
let mut stats = runtime.stats();
|
||||
collector.enrich(&mut stats);
|
||||
crate::collector::enrich_names(&mut stats, &runtime);
|
||||
app.update(stats);
|
||||
|
||||
loop {
|
||||
|
|
@ -116,6 +117,7 @@ fn run_loop(
|
|||
Ok(AppEvent::Tick) => {
|
||||
let mut stats = runtime.stats();
|
||||
collector.enrich(&mut stats);
|
||||
crate::collector::enrich_names(&mut stats, &runtime);
|
||||
app.update(stats);
|
||||
#[cfg(feature = "distribution")]
|
||||
if let Some(ref provider) = distribution {
|
||||
|
|
@ -306,21 +306,30 @@ fn draw_actor_table(f: &mut Frame, app: &App, table_state: &mut TableState, area
|
|||
}
|
||||
|
||||
fn actor_row_cells(a: &super::app::ActorRow) -> Row<'static> {
|
||||
let addr_short = if let Some(ref name) = a.name {
|
||||
if a.poisoned {
|
||||
format!("!{}", name)
|
||||
} else {
|
||||
name.clone()
|
||||
}
|
||||
} else {
|
||||
let addr = format!("{}", a.address);
|
||||
let addr_short = if a.poisoned {
|
||||
let s = if addr.len() > 16 {
|
||||
if a.poisoned {
|
||||
if addr.len() > 16 {
|
||||
format!("!{}...", &addr[..14])
|
||||
} else {
|
||||
format!("!{}", addr)
|
||||
};
|
||||
s
|
||||
}
|
||||
} else if addr.len() > 18 {
|
||||
format!("{}...", &addr[..16])
|
||||
} else {
|
||||
addr
|
||||
}
|
||||
};
|
||||
let addr_style = if a.poisoned {
|
||||
Style::default().fg(Color::Red)
|
||||
} else if a.name.is_some() {
|
||||
Style::default().fg(Color::Cyan)
|
||||
} else {
|
||||
Style::default()
|
||||
};
|
||||
|
|
@ -465,21 +474,30 @@ fn draw_focused_actor_table(f: &mut Frame, app: &App, table_state: &mut TableSta
|
|||
let rows: Vec<Row> = focused_rows
|
||||
.iter()
|
||||
.map(|a| {
|
||||
let addr_short = if let Some(ref name) = a.name {
|
||||
if a.poisoned {
|
||||
format!("!{}", name)
|
||||
} else {
|
||||
name.clone()
|
||||
}
|
||||
} else {
|
||||
let addr = format!("{}", a.address);
|
||||
let addr_short = if a.poisoned {
|
||||
let s = if addr.len() > 16 {
|
||||
if a.poisoned {
|
||||
if addr.len() > 16 {
|
||||
format!("!{}...", &addr[..14])
|
||||
} else {
|
||||
format!("!{}", addr)
|
||||
};
|
||||
s
|
||||
}
|
||||
} else if addr.len() > 18 {
|
||||
format!("{}...", &addr[..16])
|
||||
} else {
|
||||
addr
|
||||
}
|
||||
};
|
||||
let addr_style = if a.poisoned {
|
||||
Style::default().fg(Color::Red)
|
||||
} else if a.name.is_some() {
|
||||
Style::default().fg(Color::Cyan)
|
||||
} else {
|
||||
Style::default()
|
||||
};
|
||||
|
|
@ -563,18 +581,27 @@ fn draw_actor_detail(f: &mut Frame, app: &App) {
|
|||
.map(|s| short_type_name(Some(s)))
|
||||
.unwrap_or_else(|| "\u{2014}".to_string());
|
||||
|
||||
let info_lines = vec![
|
||||
Line::from(vec![
|
||||
let mut info_lines = Vec::new();
|
||||
if let Some(ref name) = actor.name {
|
||||
info_lines.push(Line::from(vec![
|
||||
Span::styled(" Name: ", Style::default().fg(Color::DarkGray)),
|
||||
Span::styled(name.clone(), Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)),
|
||||
Span::styled(" Address: ", Style::default().fg(Color::DarkGray)),
|
||||
Span::styled(addr_str, Style::default().fg(Color::White)),
|
||||
]));
|
||||
} else {
|
||||
info_lines.push(Line::from(vec![
|
||||
Span::styled(" Address: ", Style::default().fg(Color::DarkGray)),
|
||||
Span::styled(addr_str, Style::default().fg(Color::White).add_modifier(Modifier::BOLD)),
|
||||
]),
|
||||
Line::from(vec![
|
||||
]));
|
||||
}
|
||||
info_lines.push(Line::from(vec![
|
||||
Span::styled(" Worker: ", Style::default().fg(Color::DarkGray)),
|
||||
Span::styled(format!("W{}", actor.worker_id), Style::default().fg(Color::Cyan)),
|
||||
Span::styled(" Status: ", Style::default().fg(Color::DarkGray)),
|
||||
Span::styled(status, Style::default().fg(status_color).add_modifier(Modifier::BOLD)),
|
||||
]),
|
||||
Line::from(vec![
|
||||
]));
|
||||
info_lines.push(Line::from(vec![
|
||||
Span::styled(" Messages: ", Style::default().fg(Color::DarkGray)),
|
||||
Span::styled(
|
||||
format_num(actor.messages_processed),
|
||||
|
|
@ -587,8 +614,7 @@ fn draw_actor_detail(f: &mut Frame, app: &App) {
|
|||
),
|
||||
Span::styled(" Last Msg: ", Style::default().fg(Color::DarkGray)),
|
||||
Span::styled(msg_type, Style::default().fg(Color::Green)),
|
||||
]),
|
||||
];
|
||||
]));
|
||||
|
||||
let info_block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
|
|
@ -782,7 +808,7 @@ fn draw_dist_summary(f: &mut Frame, app: &App, area: Rect) {
|
|||
match &app.distribution {
|
||||
Some(d) => (
|
||||
&d.node_id[..d.node_id.len().min(16)],
|
||||
d.listen_addr.as_str(),
|
||||
d.listen_addr.as_deref().unwrap_or("—"),
|
||||
d.alive_count,
|
||||
d.suspect_count,
|
||||
d.dead_count,
|
||||
|
|
@ -863,7 +889,7 @@ fn draw_dist_members(f: &mut Frame, app: &App, table_state: &mut TableState, are
|
|||
Row::new(vec![
|
||||
Cell::from(m.state.clone()).style(state_style),
|
||||
Cell::from(id_short),
|
||||
Cell::from(m.addr.clone()),
|
||||
Cell::from(m.addr.clone().unwrap_or_default()),
|
||||
Cell::from(format!("{}", m.incarnation)),
|
||||
])
|
||||
})
|
||||
|
|
@ -227,6 +227,7 @@ mod tests {
|
|||
fn make_actor(id: u8, depth: usize, msgs: u64, poisoned: bool) -> ActorInfo {
|
||||
ActorInfo {
|
||||
address: ActorAddress([id; 32]),
|
||||
name: None,
|
||||
worker_id: 0,
|
||||
mailbox_depth: depth,
|
||||
last_msg_type: None,
|
||||
|
|
@ -7,7 +7,7 @@ use std::sync::Arc;
|
|||
|
||||
use swactor::actor::ActorInterface;
|
||||
use swactor::runtime::{Ctx, Runtime, RuntimeConfig};
|
||||
use runtime_dashboard::command::{
|
||||
use dashboard::command::{
|
||||
from_query_params, parse_line, CommandContext, CommandResponse, CommandRouter,
|
||||
};
|
||||
|
||||
|
|
@ -310,9 +310,9 @@ fn from_query_params_defaults_to_help() {
|
|||
#[test]
|
||||
fn custom_command_handler() {
|
||||
struct PingCommand;
|
||||
impl runtime_dashboard::command::CommandHandler for PingCommand {
|
||||
fn meta(&self) -> runtime_dashboard::command::CommandMeta {
|
||||
runtime_dashboard::command::CommandMeta {
|
||||
impl dashboard::command::CommandHandler for PingCommand {
|
||||
fn meta(&self) -> dashboard::command::CommandMeta {
|
||||
dashboard::command::CommandMeta {
|
||||
name: "ping",
|
||||
description: "Respond with pong",
|
||||
usage: "ping",
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use runtime_dashboard::collector::StatsCollector;
|
||||
use runtime_dashboard::layer::{DashboardEvent, EventStore};
|
||||
use runtime_dashboard::trace::RuntimeTrace;
|
||||
use dashboard::collector::StatsCollector;
|
||||
use dashboard::layer::{DashboardEvent, EventStore};
|
||||
use dashboard::trace::RuntimeTrace;
|
||||
use swactor::actor::ActorAddress;
|
||||
use swactor::stats::{ActorSnapshot, StatsHook};
|
||||
|
||||
|
|
@ -10,33 +10,23 @@ shared-types = { path = "../shared-types" }
|
|||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
blake3 = "1"
|
||||
tiny_http = { version = "0.12", optional = true }
|
||||
tiny_http = "0.12"
|
||||
clap = { version = "4", features = ["derive"], optional = true }
|
||||
ureq = { version = "2", features = ["json"], optional = true }
|
||||
getrandom = { version = "0.2", optional = true }
|
||||
ctrlc = { version = "3", optional = true }
|
||||
runtime-dashboard = { path = "../runtime-dashboard", optional = true }
|
||||
toml = { version = "0.8", optional = true }
|
||||
dashboard = { path = "../dashboard" }
|
||||
swactor-std = { path = "../std" }
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json = "1"
|
||||
proptest = "1"
|
||||
tempfile = "3"
|
||||
swactor = { path = "../.." }
|
||||
swactor-std = { path = "../std" }
|
||||
ureq = { version = "2", features = ["json"] }
|
||||
tiny_http = "0.12"
|
||||
runtime-dashboard = { path = "../runtime-dashboard" }
|
||||
|
||||
[features]
|
||||
node = ["dep:tiny_http", "dep:clap", "dep:ctrlc", "dep:runtime-dashboard", "dep:toml"]
|
||||
cli = ["dep:clap", "dep:ureq", "dep:getrandom"]
|
||||
|
||||
[[bin]]
|
||||
name = "swactor-store-node"
|
||||
path = "src/bin/store_node.rs"
|
||||
required-features = ["node"]
|
||||
|
||||
[[bin]]
|
||||
name = "swactor-store"
|
||||
path = "src/bin/store_cli.rs"
|
||||
|
|
|
|||
|
|
@ -316,6 +316,75 @@ fn resolve_authorized_key(base: &str, name_input: &str, kp: &Keypair) -> String
|
|||
}
|
||||
}
|
||||
|
||||
// ── Key file helpers ────────────────────────────────────────────────────────
|
||||
|
||||
fn hex_decode(hex: &str) -> Option<Vec<u8>> {
|
||||
if hex.len() % 2 != 0 {
|
||||
return None;
|
||||
}
|
||||
let mut bytes = Vec::with_capacity(hex.len() / 2);
|
||||
for chunk in hex.as_bytes().chunks(2) {
|
||||
let hi = hex_digit(chunk[0])?;
|
||||
let lo = hex_digit(chunk[1])?;
|
||||
bytes.push((hi << 4) | lo);
|
||||
}
|
||||
Some(bytes)
|
||||
}
|
||||
|
||||
fn hex_digit(b: u8) -> Option<u8> {
|
||||
match b {
|
||||
b'0'..=b'9' => Some(b - b'0'),
|
||||
b'a'..=b'f' => Some(b - b'a' + 10),
|
||||
b'A'..=b'F' => Some(b - b'A' + 10),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn load_keypair(path: &std::path::Path) -> Keypair {
|
||||
let data = fs::read_to_string(path).unwrap_or_else(|e| {
|
||||
eprintln!("Error reading key file {}: {e}", path.display());
|
||||
std::process::exit(1);
|
||||
});
|
||||
let json: serde_json::Value = serde_json::from_str(&data).unwrap_or_else(|e| {
|
||||
eprintln!("Error parsing key file: {e}");
|
||||
std::process::exit(1);
|
||||
});
|
||||
let secret_hex = json
|
||||
.get("secret_key")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_else(|| {
|
||||
eprintln!("Key file missing secret_key field");
|
||||
std::process::exit(1);
|
||||
});
|
||||
let secret_bytes = hex_decode(secret_hex).unwrap_or_else(|| {
|
||||
eprintln!("Invalid secret_key hex in key file");
|
||||
std::process::exit(1);
|
||||
});
|
||||
let secret: [u8; 32] = secret_bytes.try_into().unwrap_or_else(|_| {
|
||||
eprintln!("secret_key must be exactly 32 bytes");
|
||||
std::process::exit(1);
|
||||
});
|
||||
Keypair::from_bytes(&secret)
|
||||
}
|
||||
|
||||
// ── Auth signing ────────────────────────────────────────────────────────────
|
||||
|
||||
fn sign_action(keypair: &Keypair, action: DatastoreAction) -> String {
|
||||
let timestamp = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs();
|
||||
let mut nonce = [0u8; 16];
|
||||
getrandom::getrandom(&mut nonce).expect("failed to generate random nonce");
|
||||
let payload = SignedRequestPayload {
|
||||
action,
|
||||
timestamp,
|
||||
nonce,
|
||||
};
|
||||
let signed = sign_request(keypair, payload);
|
||||
serde_json::to_string(&signed).expect("SignedRequest is always serializable")
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let args = Args::parse();
|
||||
let base = args.url.trim_end_matches('/');
|
||||
|
|
|
|||
|
|
@ -1,447 +0,0 @@
|
|||
//! swactor-store-node — standalone datastore node with HTTP API.
|
||||
//!
|
||||
//! Starts the actor runtime, spawns datastore actors, and serves
|
||||
//! a REST API for external tools (the `swactor-store` CLI).
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
use clap::Parser;
|
||||
use serde::Deserialize;
|
||||
|
||||
use swactor::config::RuntimeConfig;
|
||||
use swactor::runtime::Runtime;
|
||||
|
||||
use swactor_datastore::actors::{BlobStoreActor, DatastoreNode, GatewayActor, MetadataActor};
|
||||
use swactor_datastore::api::start_api_server;
|
||||
use swactor_datastore::auth::{AccessControlList, AuthzEngine};
|
||||
use swactor_datastore::messages::{GatewayMsg, MetadataMsg};
|
||||
use swactor_datastore::metrics::DatastoreMetrics;
|
||||
use swactor_datastore::storage::{FilesystemBackend, InMemoryBackend};
|
||||
use swactor_datastore::DatastoreConfig;
|
||||
|
||||
use distribution::crypto::Keypair;
|
||||
use distribution::types::NodeId;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "swactor-store-node", about = "Swactor distributed datastore node")]
|
||||
struct Args {
|
||||
/// Path to a TOML config file
|
||||
#[arg(long)]
|
||||
config: Option<std::path::PathBuf>,
|
||||
|
||||
/// HTTP API port
|
||||
#[arg(long)]
|
||||
port: Option<u16>,
|
||||
|
||||
/// Storage directory (omit for in-memory)
|
||||
#[arg(long)]
|
||||
storage_path: Option<String>,
|
||||
|
||||
/// Dashboard HTTP port (omit to disable dashboard)
|
||||
#[arg(long)]
|
||||
dashboard_port: Option<u16>,
|
||||
|
||||
/// Chunk size in bytes
|
||||
#[arg(long)]
|
||||
chunk_size: Option<u32>,
|
||||
|
||||
/// GC interval in ticks (each tick is ~100ms)
|
||||
#[arg(long)]
|
||||
gc_interval: Option<u64>,
|
||||
|
||||
/// Dissemination interval in ticks
|
||||
#[arg(long)]
|
||||
disseminate_interval: Option<u64>,
|
||||
|
||||
/// Enable auth (generates owner keypair if needed)
|
||||
#[arg(long)]
|
||||
auth: bool,
|
||||
|
||||
/// Directory for owner.key.json + acl.json (default: "auth")
|
||||
#[arg(long, default_value = "auth")]
|
||||
auth_dir: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
struct NodeConfig {
|
||||
port: Option<u16>,
|
||||
storage_path: Option<String>,
|
||||
dashboard_port: Option<u16>,
|
||||
chunk_size: Option<u32>,
|
||||
gc_interval: Option<u64>,
|
||||
disseminate_interval: Option<u64>,
|
||||
}
|
||||
|
||||
/// Resolved configuration with CLI > config file > defaults applied.
|
||||
struct ResolvedConfig {
|
||||
port: u16,
|
||||
storage_path: Option<String>,
|
||||
dashboard_port: Option<u16>,
|
||||
chunk_size: u32,
|
||||
gc_interval: u64,
|
||||
disseminate_interval: u64,
|
||||
}
|
||||
|
||||
// ── Key file helpers ────────────────────────────────────────────────────────
|
||||
|
||||
fn hex_encode(bytes: &[u8]) -> String {
|
||||
bytes.iter().map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
|
||||
fn hex_decode(hex: &str) -> Option<Vec<u8>> {
|
||||
if hex.len() % 2 != 0 {
|
||||
return None;
|
||||
}
|
||||
let mut bytes = Vec::with_capacity(hex.len() / 2);
|
||||
for chunk in hex.as_bytes().chunks(2) {
|
||||
let hi = hex_digit(chunk[0])?;
|
||||
let lo = hex_digit(chunk[1])?;
|
||||
bytes.push((hi << 4) | lo);
|
||||
}
|
||||
Some(bytes)
|
||||
}
|
||||
|
||||
fn hex_digit(b: u8) -> Option<u8> {
|
||||
match b {
|
||||
b'0'..=b'9' => Some(b - b'0'),
|
||||
b'a'..=b'f' => Some(b - b'a' + 10),
|
||||
b'A'..=b'F' => Some(b - b'A' + 10),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn load_or_generate_keypair(path: &std::path::Path) -> Keypair {
|
||||
if path.exists() {
|
||||
let data = std::fs::read_to_string(path).expect("failed to read key file");
|
||||
let json: serde_json::Value = serde_json::from_str(&data).expect("invalid key file JSON");
|
||||
let secret_hex = json
|
||||
.get("secret_key")
|
||||
.and_then(|v| v.as_str())
|
||||
.expect("key file missing secret_key");
|
||||
let secret_bytes = hex_decode(secret_hex).expect("invalid secret_key hex");
|
||||
let secret: [u8; 32] = secret_bytes
|
||||
.try_into()
|
||||
.expect("secret_key must be 32 bytes");
|
||||
Keypair::from_bytes(&secret)
|
||||
} else {
|
||||
let keypair = Keypair::generate();
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs();
|
||||
let json = serde_json::json!({
|
||||
"version": 1,
|
||||
"secret_key": hex_encode(&keypair.secret_bytes()),
|
||||
"public_key": hex_encode(&keypair.node_id().0),
|
||||
"created_at": format_timestamp(now),
|
||||
});
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent).expect("failed to create key file directory");
|
||||
}
|
||||
std::fs::write(path, serde_json::to_string_pretty(&json).unwrap())
|
||||
.expect("failed to write key file");
|
||||
keypair
|
||||
}
|
||||
}
|
||||
|
||||
fn format_timestamp(secs: u64) -> String {
|
||||
// Simple ISO-8601 UTC timestamp
|
||||
let s = secs % 60;
|
||||
let m = (secs / 60) % 60;
|
||||
let h = (secs / 3600) % 24;
|
||||
let days = secs / 86400;
|
||||
// Days since epoch to Y-M-D (simplified)
|
||||
let (y, mo, d) = days_to_ymd(days);
|
||||
format!("{y:04}-{mo:02}-{d:02}T{h:02}:{m:02}:{s:02}Z")
|
||||
}
|
||||
|
||||
fn days_to_ymd(mut days: u64) -> (u64, u64, u64) {
|
||||
// Algorithm from http://howardhinnant.github.io/date_algorithms.html
|
||||
days += 719468;
|
||||
let era = days / 146097;
|
||||
let doe = days - era * 146097;
|
||||
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
|
||||
let y = yoe + era * 400;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let y = if m <= 2 { y + 1 } else { y };
|
||||
(y, m, d)
|
||||
}
|
||||
|
||||
fn resolve_config(args: &Args) -> ResolvedConfig {
|
||||
let file_cfg = match &args.config {
|
||||
Some(path) => {
|
||||
let contents = std::fs::read_to_string(path)
|
||||
.unwrap_or_else(|e| panic!("failed to read config file {}: {e}", path.display()));
|
||||
toml::from_str::<NodeConfig>(&contents)
|
||||
.unwrap_or_else(|e| panic!("failed to parse config file {}: {e}", path.display()))
|
||||
}
|
||||
None => NodeConfig::default(),
|
||||
};
|
||||
|
||||
ResolvedConfig {
|
||||
port: args.port.or(file_cfg.port).unwrap_or(9091),
|
||||
storage_path: args.storage_path.clone().or(file_cfg.storage_path),
|
||||
dashboard_port: args.dashboard_port.or(file_cfg.dashboard_port),
|
||||
chunk_size: args.chunk_size.or(file_cfg.chunk_size).unwrap_or(1_048_576),
|
||||
gc_interval: args.gc_interval.or(file_cfg.gc_interval).unwrap_or(1000),
|
||||
disseminate_interval: args.disseminate_interval.or(file_cfg.disseminate_interval).unwrap_or(50),
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let args = Args::parse();
|
||||
let cfg = resolve_config(&args);
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
|
||||
// Signal handler — double Ctrl-C forces immediate exit
|
||||
{
|
||||
let stop = Arc::clone(&stop);
|
||||
ctrlc::set_handler(move || {
|
||||
if stop.load(Ordering::Relaxed) {
|
||||
eprintln!("\nForced exit.");
|
||||
std::process::exit(1);
|
||||
}
|
||||
stop.store(true, Ordering::Relaxed);
|
||||
})
|
||||
.expect("failed to set signal handler");
|
||||
}
|
||||
|
||||
// Optionally start dashboard
|
||||
let dash = cfg.dashboard_port.map(|port| {
|
||||
let d = runtime_dashboard::start_dashboard(runtime_dashboard::DashboardConfig {
|
||||
port,
|
||||
..Default::default()
|
||||
});
|
||||
d.install_tracing();
|
||||
d
|
||||
});
|
||||
|
||||
// Create runtime
|
||||
let num_threads = 2;
|
||||
let collector = runtime_dashboard::collector::StatsCollector::new(num_threads);
|
||||
let mut rt = Runtime::new(RuntimeConfig {
|
||||
num_threads,
|
||||
max_actors: 1024,
|
||||
channel_buffer_size: 2000,
|
||||
..Default::default()
|
||||
});
|
||||
rt.set_stats_hook(collector.clone());
|
||||
|
||||
// Generate or load node identity
|
||||
let (node_id, owner_keypair) = if args.auth {
|
||||
let auth_dir = std::path::PathBuf::from(&args.auth_dir);
|
||||
std::fs::create_dir_all(&auth_dir).expect("failed to create auth directory");
|
||||
let key_path = auth_dir.join("owner.key.json");
|
||||
let keypair = load_or_generate_keypair(&key_path);
|
||||
let nid = keypair.node_id();
|
||||
eprintln!(
|
||||
"Auth enabled — owner key: {}",
|
||||
hex_encode(&nid.0)
|
||||
);
|
||||
eprintln!("Key file: {}", key_path.display());
|
||||
(nid, Some((keypair, auth_dir)))
|
||||
} else {
|
||||
let node_id = {
|
||||
let mut bytes = [0u8; 32];
|
||||
for (i, b) in std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
.to_le_bytes()
|
||||
.iter()
|
||||
.enumerate()
|
||||
{
|
||||
bytes[i % 32] ^= *b;
|
||||
}
|
||||
// Mix in process id for uniqueness
|
||||
let pid = std::process::id();
|
||||
for (i, b) in pid.to_le_bytes().iter().enumerate() {
|
||||
bytes[i + 16] ^= *b;
|
||||
}
|
||||
NodeId(bytes)
|
||||
};
|
||||
(node_id, None)
|
||||
};
|
||||
|
||||
// Datastore config
|
||||
let config = DatastoreConfig {
|
||||
chunk_size: cfg.chunk_size,
|
||||
storage_path: cfg
|
||||
.storage_path
|
||||
.as_ref()
|
||||
.map(|s| s.into())
|
||||
.unwrap_or_else(|| "datastore".into()),
|
||||
gc_interval: cfg.gc_interval,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// Create storage backend
|
||||
let backend: Box<dyn swactor_datastore::StorageBackend> = match &cfg.storage_path {
|
||||
Some(path) => {
|
||||
let p = std::path::PathBuf::from(path);
|
||||
std::fs::create_dir_all(&p).expect("failed to create storage directory");
|
||||
Box::new(FilesystemBackend::new(p))
|
||||
}
|
||||
None => Box::new(InMemoryBackend::new()),
|
||||
};
|
||||
|
||||
// Spawn actors before starting runtime threads
|
||||
let blob_store_addr = rt
|
||||
.spawn(BlobStoreActor::new(backend))
|
||||
.expect("failed to spawn BlobStoreActor");
|
||||
|
||||
let mut metadata = MetadataActor::new(node_id, &config);
|
||||
metadata.set_blob_store(blob_store_addr);
|
||||
let metadata_addr = rt
|
||||
.spawn(metadata)
|
||||
.expect("failed to spawn MetadataActor");
|
||||
|
||||
let datastore_node = DatastoreNode::new(node_id, blob_store_addr, metadata_addr, config);
|
||||
let datastore_addr = rt
|
||||
.spawn(datastore_node)
|
||||
.expect("failed to spawn DatastoreNode");
|
||||
|
||||
// Spawn GatewayActor if auth is enabled
|
||||
let gateway_addr = if let Some((_, ref auth_dir)) = owner_keypair {
|
||||
let acl_path = auth_dir.join("acl.json");
|
||||
let acl = AccessControlList::load_or_create(&acl_path, node_id)
|
||||
.expect("failed to load/create ACL");
|
||||
let engine = AuthzEngine::new(acl);
|
||||
let gateway = GatewayActor::new(engine, datastore_addr, Some(acl_path));
|
||||
let addr = rt
|
||||
.spawn(gateway)
|
||||
.expect("failed to spawn GatewayActor");
|
||||
Some(addr)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Start runtime
|
||||
let handle = rt.run().expect("failed to start runtime");
|
||||
|
||||
// Load persisted entries from storage
|
||||
{
|
||||
let inbox = handle
|
||||
.runtime
|
||||
.new_inbox::<swactor_datastore::DatastoreResponse>()
|
||||
.expect("failed to create inbox");
|
||||
let _ = handle.runtime.send_to(
|
||||
blob_store_addr,
|
||||
swactor_datastore::BlobStoreMsg::LoadAll {
|
||||
reply_to: *inbox.addr(),
|
||||
},
|
||||
);
|
||||
// Poll for response (up to 5 seconds)
|
||||
let start = std::time::Instant::now();
|
||||
let mut loaded = false;
|
||||
while start.elapsed() < Duration::from_secs(5) {
|
||||
if let Some(resp) = inbox.try_recv() {
|
||||
match resp {
|
||||
swactor_datastore::DatastoreResponse::LoadedAll { entries } => {
|
||||
let n = entries.len();
|
||||
let _ = handle.runtime.send_to(
|
||||
metadata_addr,
|
||||
swactor_datastore::MetadataMsg::BulkLoad { entries },
|
||||
);
|
||||
if n > 0 {
|
||||
eprintln!("Loaded {n} entries from storage");
|
||||
}
|
||||
loaded = true;
|
||||
}
|
||||
swactor_datastore::DatastoreResponse::Error { reason } => {
|
||||
eprintln!("Warning: failed to load entries: {reason}");
|
||||
loaded = true;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
break;
|
||||
}
|
||||
thread::sleep(Duration::from_millis(1));
|
||||
}
|
||||
if !loaded {
|
||||
eprintln!("Warning: timed out loading entries from storage");
|
||||
}
|
||||
}
|
||||
|
||||
// Create datastore metrics
|
||||
let node_hex: String = node_id.0.iter().map(|b| format!("{b:02x}")).collect();
|
||||
let metrics = Arc::new(DatastoreMetrics::new());
|
||||
metrics.set_node_id(node_hex.clone());
|
||||
|
||||
if let Some(ref d) = dash {
|
||||
d.set_runtime(handle.runtime.clone(), collector);
|
||||
d.set_datastore(Arc::clone(&metrics) as Arc<dyn runtime_dashboard::datastore_collector::DatastoreStatsProvider>);
|
||||
}
|
||||
|
||||
// Start HTTP API
|
||||
let (api_shutdown, _peers) = start_api_server(
|
||||
handle.runtime.clone(),
|
||||
datastore_addr,
|
||||
metadata_addr,
|
||||
blob_store_addr,
|
||||
gateway_addr,
|
||||
cfg.port,
|
||||
Arc::clone(&metrics),
|
||||
);
|
||||
|
||||
eprintln!("──────────────────────────────────────");
|
||||
eprintln!(" swactor-store node {}", &node_hex[..16]);
|
||||
eprintln!(" API: http://0.0.0.0:{}", cfg.port);
|
||||
if let Some(port) = cfg.dashboard_port {
|
||||
eprintln!(" Dashboard: http://0.0.0.0:{port}");
|
||||
}
|
||||
if cfg.storage_path.is_some() {
|
||||
eprintln!(" Storage: {} (filesystem)", cfg.storage_path.as_ref().unwrap());
|
||||
} else {
|
||||
eprintln!(" Storage: in-memory");
|
||||
}
|
||||
if owner_keypair.is_some() {
|
||||
eprintln!(" Auth: enabled (owner {})", &node_hex[..16]);
|
||||
} else {
|
||||
eprintln!(" Auth: disabled");
|
||||
}
|
||||
eprintln!("──────────────────────────────────────");
|
||||
|
||||
// Main loop
|
||||
let mut round: u64 = 0;
|
||||
while !stop.load(Ordering::Relaxed) {
|
||||
round += 1;
|
||||
|
||||
if round % cfg.gc_interval == 0 {
|
||||
let _ = handle
|
||||
.runtime
|
||||
.send_to(metadata_addr, MetadataMsg::GcTick);
|
||||
|
||||
if let Some(gw) = gateway_addr {
|
||||
let _ = handle.runtime.send_to(gw, GatewayMsg::NonceGcTick);
|
||||
}
|
||||
}
|
||||
|
||||
if round % cfg.disseminate_interval == 0 {
|
||||
let _ = handle
|
||||
.runtime
|
||||
.send_to(metadata_addr, MetadataMsg::DisseminateTick);
|
||||
}
|
||||
|
||||
thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
|
||||
eprintln!("\nShutting down...");
|
||||
api_shutdown.store(true, Ordering::Relaxed);
|
||||
handle.shutdown();
|
||||
if let Some(d) = dash {
|
||||
d.shutdown();
|
||||
}
|
||||
// Brief pause for threads to flush I/O, then exit.
|
||||
// No join — cargo run already died from SIGINT so there's
|
||||
// no parent waiting on us; just exit cleanly.
|
||||
thread::sleep(Duration::from_millis(50));
|
||||
eprintln!("Shutdown complete.");
|
||||
std::process::exit(0);
|
||||
}
|
||||
519
crates/datastore/src/bridge.rs
Normal file
519
crates/datastore/src/bridge.rs
Normal file
|
|
@ -0,0 +1,519 @@
|
|||
//! Bridge between the runtime dashboard's `DatastoreStatsProvider` trait and
|
||||
//! the datastore actor system. Allows the dashboard to perform CRUD operations
|
||||
//! and lifecycle management without depending on `swactor-datastore` types.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use swactor::actor::ActorAddress;
|
||||
use swactor::runtime::{Inbox, Runtime};
|
||||
use swactor_std::RuntimeNaming;
|
||||
|
||||
use distribution::types::NodeId;
|
||||
use dashboard::datastore_collector::{
|
||||
DatastoreFactory, DatastoreStatsProvider, ListScope,
|
||||
};
|
||||
|
||||
use crate::actors::{BlobStoreActor, DatastoreNode, GatewayActor, MetadataActor};
|
||||
use crate::auth::{AccessControlList, AuthzEngine};
|
||||
use crate::chunking::reassemble_blob;
|
||||
use crate::messages::{DatastoreNodeMsg, DatastoreResponse, GatewayMsg, MetadataMsg};
|
||||
use crate::metrics::DatastoreMetrics;
|
||||
use crate::storage::{FilesystemBackend, InMemoryBackend};
|
||||
use crate::types::{ContentHash, DatastoreConfig};
|
||||
|
||||
const POLL_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
const POLL_INTERVAL: Duration = Duration::from_millis(1);
|
||||
|
||||
fn poll_response(inbox: &Inbox<DatastoreResponse>, timeout: Duration) -> Option<DatastoreResponse> {
|
||||
let start = Instant::now();
|
||||
loop {
|
||||
if let Some(resp) = inbox.try_recv() {
|
||||
return Some(resp);
|
||||
}
|
||||
if start.elapsed() > timeout {
|
||||
return None;
|
||||
}
|
||||
thread::sleep(POLL_INTERVAL);
|
||||
}
|
||||
}
|
||||
|
||||
fn entry_to_json(entry: &crate::types::ObjectEntry) -> serde_json::Value {
|
||||
let node_hex: String = entry.node_id.0.iter().map(|b| format!("{b:02x}")).collect();
|
||||
serde_json::json!({
|
||||
"content_hash": entry.content_hash.to_hex(),
|
||||
"name": entry.name,
|
||||
"node_id": node_hex,
|
||||
"tags": entry.tags,
|
||||
"size_bytes": entry.size_bytes,
|
||||
"created_at": entry.created_at,
|
||||
})
|
||||
}
|
||||
|
||||
fn manifest_to_json(manifest: &crate::types::ObjectManifest) -> serde_json::Value {
|
||||
let chunks: Vec<serde_json::Value> = manifest
|
||||
.chunks
|
||||
.iter()
|
||||
.map(|c| {
|
||||
serde_json::json!({
|
||||
"hash": c.hash.to_hex(),
|
||||
"offset": c.offset,
|
||||
"size": c.size,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
serde_json::json!({
|
||||
"content_hash": manifest.content_hash.to_hex(),
|
||||
"chunks": chunks,
|
||||
"total_size": manifest.total_size,
|
||||
"chunk_size": manifest.chunk_size,
|
||||
"content_type": manifest.content_type,
|
||||
})
|
||||
}
|
||||
|
||||
fn entries_to_json(entries: &[crate::types::ObjectEntry]) -> Vec<serde_json::Value> {
|
||||
entries.iter().map(entry_to_json).collect()
|
||||
}
|
||||
|
||||
/// Bridges the dashboard trait to the datastore actor system.
|
||||
pub struct DatastoreBridge {
|
||||
metrics: Arc<DatastoreMetrics>,
|
||||
runtime: Arc<Runtime>,
|
||||
datastore_addr: ActorAddress,
|
||||
metadata_addr: ActorAddress,
|
||||
#[allow(dead_code)]
|
||||
blob_store_addr: ActorAddress,
|
||||
}
|
||||
|
||||
impl DatastoreBridge {
|
||||
pub fn new(
|
||||
metrics: Arc<DatastoreMetrics>,
|
||||
runtime: Arc<Runtime>,
|
||||
datastore_addr: ActorAddress,
|
||||
metadata_addr: ActorAddress,
|
||||
blob_store_addr: ActorAddress,
|
||||
) -> Self {
|
||||
Self {
|
||||
metrics,
|
||||
runtime,
|
||||
datastore_addr,
|
||||
metadata_addr,
|
||||
blob_store_addr,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DatastoreStatsProvider for DatastoreBridge {
|
||||
fn snapshot_json(&self) -> Option<String> {
|
||||
let snap = self.metrics.snapshot();
|
||||
serde_json::to_string(&snap).ok()
|
||||
}
|
||||
|
||||
fn is_running(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn list_objects(&self, name_filter: Option<&str>, scope: ListScope) -> Result<String, String> {
|
||||
let inbox = self.runtime.new_inbox::<DatastoreResponse>()
|
||||
.map_err(|e| format!("failed to create inbox: {e}"))?;
|
||||
|
||||
match scope {
|
||||
ListScope::Local => {
|
||||
let _ = self.runtime.send_to(
|
||||
self.datastore_addr,
|
||||
DatastoreNodeMsg::List {
|
||||
name_filter: name_filter.map(|s| s.to_string()),
|
||||
all: false,
|
||||
reply_to: *inbox.addr(),
|
||||
},
|
||||
);
|
||||
}
|
||||
ListScope::Swarm => {
|
||||
let _ = self.runtime.send_to(
|
||||
self.metadata_addr,
|
||||
MetadataMsg::ListLocal {
|
||||
name_filter: name_filter.map(|s| s.to_string()),
|
||||
reply_to: *inbox.addr(),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
match poll_response(&inbox, POLL_TIMEOUT) {
|
||||
Some(DatastoreResponse::ListOk { entries }) => {
|
||||
let json = serde_json::json!({ "entries": entries_to_json(&entries) }).to_string();
|
||||
Ok(json)
|
||||
}
|
||||
Some(DatastoreResponse::Error { reason }) => Err(reason),
|
||||
_ => Err("timeout".into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn get_object(&self, hash: &str) -> Result<String, String> {
|
||||
let content_hash = ContentHash::from_hex(hash)
|
||||
.ok_or_else(|| "invalid content hash hex".to_string())?;
|
||||
|
||||
let inbox = self.runtime.new_inbox::<DatastoreResponse>()
|
||||
.map_err(|e| format!("failed to create inbox: {e}"))?;
|
||||
|
||||
let _ = self.runtime.send_to(
|
||||
self.datastore_addr,
|
||||
DatastoreNodeMsg::Get {
|
||||
content_hash,
|
||||
reply_to: *inbox.addr(),
|
||||
},
|
||||
);
|
||||
|
||||
match poll_response(&inbox, POLL_TIMEOUT) {
|
||||
Some(DatastoreResponse::GetOk { entry, manifest }) => {
|
||||
self.metrics.record_get(&content_hash.to_hex());
|
||||
let json = serde_json::json!({
|
||||
"entry": entry_to_json(&entry),
|
||||
"manifest": manifest_to_json(&manifest),
|
||||
})
|
||||
.to_string();
|
||||
Ok(json)
|
||||
}
|
||||
Some(DatastoreResponse::NotFound) => Err("not found".into()),
|
||||
Some(DatastoreResponse::Error { reason }) => Err(reason),
|
||||
_ => Err("timeout".into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn get_data(&self, hash: &str) -> Result<Vec<u8>, String> {
|
||||
let content_hash = ContentHash::from_hex(hash)
|
||||
.ok_or_else(|| "invalid content hash hex".to_string())?;
|
||||
|
||||
// Get manifest
|
||||
let inbox = self.runtime.new_inbox::<DatastoreResponse>()
|
||||
.map_err(|e| format!("failed to create inbox: {e}"))?;
|
||||
|
||||
let _ = self.runtime.send_to(
|
||||
self.datastore_addr,
|
||||
DatastoreNodeMsg::Get {
|
||||
content_hash,
|
||||
reply_to: *inbox.addr(),
|
||||
},
|
||||
);
|
||||
|
||||
self.metrics.record_get(&content_hash.to_hex());
|
||||
|
||||
let manifest = match poll_response(&inbox, POLL_TIMEOUT) {
|
||||
Some(DatastoreResponse::GetOk { manifest, .. }) => manifest,
|
||||
Some(DatastoreResponse::NotFound) => return Err("not found".into()),
|
||||
Some(DatastoreResponse::Error { reason }) => return Err(reason),
|
||||
_ => return Err("timeout".into()),
|
||||
};
|
||||
|
||||
// Read chunks
|
||||
let mut chunk_data = Vec::new();
|
||||
for chunk_ref in &manifest.chunks {
|
||||
let chunk_inbox = self.runtime.new_inbox::<DatastoreResponse>()
|
||||
.map_err(|e| format!("failed to create inbox: {e}"))?;
|
||||
|
||||
let _ = self.runtime.send_to(
|
||||
self.datastore_addr,
|
||||
DatastoreNodeMsg::ReadChunk {
|
||||
hash: chunk_ref.hash,
|
||||
reply_to: *chunk_inbox.addr(),
|
||||
},
|
||||
);
|
||||
|
||||
match poll_response(&chunk_inbox, POLL_TIMEOUT) {
|
||||
Some(DatastoreResponse::ChunkOk { hash, data }) => {
|
||||
chunk_data.push((hash, data));
|
||||
}
|
||||
_ => return Err("failed to read chunk".into()),
|
||||
}
|
||||
}
|
||||
|
||||
reassemble_blob(&manifest, &chunk_data)
|
||||
.map_err(|e| format!("reassembly failed: {e:?}"))
|
||||
}
|
||||
|
||||
fn put_data(&self, data: Vec<u8>, name: Option<String>) -> Result<String, String> {
|
||||
let body_len = data.len();
|
||||
|
||||
let inbox = self.runtime.new_inbox::<DatastoreResponse>()
|
||||
.map_err(|e| format!("failed to create inbox: {e}"))?;
|
||||
|
||||
let _ = self.runtime.send_to(
|
||||
self.datastore_addr,
|
||||
DatastoreNodeMsg::Put {
|
||||
data,
|
||||
name: name.clone(),
|
||||
tags: BTreeMap::new(),
|
||||
reply_to: *inbox.addr(),
|
||||
},
|
||||
);
|
||||
|
||||
match poll_response(&inbox, POLL_TIMEOUT) {
|
||||
Some(DatastoreResponse::PutOk { content_hash }) => {
|
||||
let hex = content_hash.to_hex();
|
||||
self.metrics.record_put(&hex, name.as_deref(), body_len as u64);
|
||||
let json = serde_json::json!({ "content_hash": hex }).to_string();
|
||||
Ok(json)
|
||||
}
|
||||
Some(DatastoreResponse::Error { reason }) => Err(reason),
|
||||
_ => Err("timeout waiting for put response".into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn delete_object(&self, hash: &str) -> Result<String, String> {
|
||||
let content_hash = ContentHash::from_hex(hash)
|
||||
.ok_or_else(|| "invalid content hash hex".to_string())?;
|
||||
|
||||
let inbox = self.runtime.new_inbox::<DatastoreResponse>()
|
||||
.map_err(|e| format!("failed to create inbox: {e}"))?;
|
||||
|
||||
let _ = self.runtime.send_to(
|
||||
self.datastore_addr,
|
||||
DatastoreNodeMsg::Delete {
|
||||
content_hash,
|
||||
reply_to: *inbox.addr(),
|
||||
},
|
||||
);
|
||||
|
||||
match poll_response(&inbox, POLL_TIMEOUT) {
|
||||
Some(DatastoreResponse::DeleteOk { content_hash }) => {
|
||||
let hex = content_hash.to_hex();
|
||||
self.metrics.record_delete(&hex, 0);
|
||||
let json = serde_json::json!({ "content_hash": hex }).to_string();
|
||||
Ok(json)
|
||||
}
|
||||
Some(DatastoreResponse::NotFound) => Err("not found".into()),
|
||||
Some(DatastoreResponse::Error { reason }) => Err(reason),
|
||||
_ => Err("timeout".into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn node_status(&self) -> Result<String, String> {
|
||||
let inbox = self.runtime.new_inbox::<DatastoreResponse>()
|
||||
.map_err(|e| format!("failed to create inbox: {e}"))?;
|
||||
|
||||
let _ = self.runtime.send_to(
|
||||
self.datastore_addr,
|
||||
DatastoreNodeMsg::Status {
|
||||
reply_to: *inbox.addr(),
|
||||
},
|
||||
);
|
||||
|
||||
match poll_response(&inbox, POLL_TIMEOUT) {
|
||||
Some(DatastoreResponse::NodeStatus { node_id }) => {
|
||||
let hex: String = node_id.0.iter().map(|b| format!("{b:02x}")).collect();
|
||||
let json = serde_json::json!({ "node_id": hex }).to_string();
|
||||
Ok(json)
|
||||
}
|
||||
_ => Err("timeout".into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn shutdown_datastore(&self) -> Result<(), String> {
|
||||
// We can't actually stop the actors from here without a runtime handle,
|
||||
// but we can signal shutdown. The caller (server handler) clears the
|
||||
// provider reference which effectively disables the datastore.
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Factory that can spawn a new set of datastore actors on a shared runtime.
|
||||
pub struct DatastoreNodeFactory {
|
||||
runtime: Arc<Runtime>,
|
||||
default_chunk_size: u32,
|
||||
}
|
||||
|
||||
impl DatastoreNodeFactory {
|
||||
pub fn new(runtime: Arc<Runtime>, default_chunk_size: u32) -> Self {
|
||||
Self {
|
||||
runtime,
|
||||
default_chunk_size,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DatastoreFactory for DatastoreNodeFactory {
|
||||
fn start_datastore(
|
||||
&self,
|
||||
storage_path: Option<String>,
|
||||
) -> Result<Arc<dyn DatastoreStatsProvider>, String> {
|
||||
// Generate a unique node ID
|
||||
let node_id = generate_node_id();
|
||||
let node_hex: String = node_id.0.iter().map(|b| format!("{b:02x}")).collect();
|
||||
|
||||
let group = DatastoreGroup::spawn(
|
||||
Arc::clone(&self.runtime),
|
||||
DatastoreGroupConfig {
|
||||
node_id,
|
||||
node_id_hex: node_hex,
|
||||
chunk_size: self.default_chunk_size,
|
||||
storage_path,
|
||||
auth: None,
|
||||
gc_interval: 1000,
|
||||
disseminate_interval: 50,
|
||||
},
|
||||
)?;
|
||||
|
||||
Ok(group.bridge().clone())
|
||||
}
|
||||
}
|
||||
|
||||
// ─── DatastoreGroup ─────────────────────────────────────────────────────────
|
||||
|
||||
/// Configuration for spawning a complete datastore actor group.
|
||||
pub struct DatastoreGroupConfig {
|
||||
pub node_id: NodeId,
|
||||
pub node_id_hex: String,
|
||||
pub chunk_size: u32,
|
||||
pub storage_path: Option<String>,
|
||||
pub auth: Option<DatastoreAuthConfig>,
|
||||
pub gc_interval: u64,
|
||||
pub disseminate_interval: u64,
|
||||
}
|
||||
|
||||
/// Auth configuration for the datastore gateway.
|
||||
pub struct DatastoreAuthConfig {
|
||||
pub auth_dir: PathBuf,
|
||||
}
|
||||
|
||||
/// Owns the full lifecycle of a datastore actor group: BlobStore, Metadata,
|
||||
/// DatastoreNode, and optional GatewayActor.
|
||||
pub struct DatastoreGroup {
|
||||
metadata_addr: ActorAddress,
|
||||
gateway_addr: Option<ActorAddress>,
|
||||
bridge: Arc<dyn DatastoreStatsProvider>,
|
||||
gc_interval: u64,
|
||||
disseminate_interval: u64,
|
||||
runtime: Arc<Runtime>,
|
||||
}
|
||||
|
||||
impl DatastoreGroup {
|
||||
/// Spawn all datastore actors, wire them together, and register names.
|
||||
pub fn spawn(runtime: Arc<Runtime>, config: DatastoreGroupConfig) -> Result<Self, String> {
|
||||
let ds_config = DatastoreConfig {
|
||||
chunk_size: config.chunk_size,
|
||||
storage_path: config
|
||||
.storage_path
|
||||
.as_ref()
|
||||
.map(|s| s.into())
|
||||
.unwrap_or_else(|| "datastore".into()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let backend: Box<dyn crate::StorageBackend> = match &config.storage_path {
|
||||
Some(path) => {
|
||||
let p = PathBuf::from(path);
|
||||
std::fs::create_dir_all(&p)
|
||||
.map_err(|e| format!("failed to create storage directory: {e}"))?;
|
||||
Box::new(FilesystemBackend::new(p))
|
||||
}
|
||||
None => Box::new(InMemoryBackend::new()),
|
||||
};
|
||||
|
||||
let blob_store_addr = runtime
|
||||
.spawn(BlobStoreActor::new(backend))
|
||||
.map_err(|e| format!("failed to spawn BlobStoreActor: {e}"))?;
|
||||
let _ = runtime.register_name("BlobStore", blob_store_addr);
|
||||
|
||||
let mut metadata = MetadataActor::new(config.node_id, &ds_config);
|
||||
metadata.set_blob_store(blob_store_addr);
|
||||
let metadata_addr = runtime
|
||||
.spawn(metadata)
|
||||
.map_err(|e| format!("failed to spawn MetadataActor: {e}"))?;
|
||||
let _ = runtime.register_name("Metadata", metadata_addr);
|
||||
|
||||
let datastore_node =
|
||||
DatastoreNode::new(config.node_id, blob_store_addr, metadata_addr, ds_config);
|
||||
let datastore_addr = runtime
|
||||
.spawn(datastore_node)
|
||||
.map_err(|e| format!("failed to spawn DatastoreNode: {e}"))?;
|
||||
let _ = runtime.register_name("Datastore", datastore_addr);
|
||||
|
||||
// Spawn GatewayActor if auth is configured
|
||||
let gateway_addr = if let Some(auth_cfg) = &config.auth {
|
||||
std::fs::create_dir_all(&auth_cfg.auth_dir)
|
||||
.map_err(|e| format!("failed to create auth directory: {e}"))?;
|
||||
let acl_path = auth_cfg.auth_dir.join("acl.json");
|
||||
let acl = AccessControlList::load_or_create(&acl_path, config.node_id)
|
||||
.map_err(|e| format!("failed to load/create ACL: {e}"))?;
|
||||
let engine = AuthzEngine::new(acl);
|
||||
let gateway = GatewayActor::new(engine, datastore_addr, Some(acl_path));
|
||||
let addr = runtime
|
||||
.spawn(gateway)
|
||||
.map_err(|e| format!("failed to spawn GatewayActor: {e}"))?;
|
||||
let _ = runtime.register_name("Gateway", addr);
|
||||
eprintln!("Auth: enabled (owner {})", &config.node_id_hex[..16]);
|
||||
Some(addr)
|
||||
} else {
|
||||
eprintln!("Auth: disabled");
|
||||
None
|
||||
};
|
||||
|
||||
let metrics = Arc::new(DatastoreMetrics::new());
|
||||
metrics.set_node_id(config.node_id_hex.clone());
|
||||
|
||||
let bridge: Arc<dyn DatastoreStatsProvider> = Arc::new(DatastoreBridge::new(
|
||||
metrics,
|
||||
Arc::clone(&runtime),
|
||||
datastore_addr,
|
||||
metadata_addr,
|
||||
blob_store_addr,
|
||||
));
|
||||
|
||||
if config.storage_path.is_some() {
|
||||
eprintln!(
|
||||
"Datastore: persistent ({})",
|
||||
config.storage_path.as_ref().unwrap()
|
||||
);
|
||||
} else {
|
||||
eprintln!("Datastore: in-memory");
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
metadata_addr,
|
||||
gateway_addr,
|
||||
bridge,
|
||||
gc_interval: config.gc_interval,
|
||||
disseminate_interval: config.disseminate_interval,
|
||||
runtime,
|
||||
})
|
||||
}
|
||||
|
||||
/// Send periodic ticks to the datastore actors based on the current round.
|
||||
pub fn tick(&self, round: u64) {
|
||||
if round % self.gc_interval == 0 {
|
||||
let _ = self.runtime.send_to(self.metadata_addr, MetadataMsg::GcTick);
|
||||
if let Some(gw) = self.gateway_addr {
|
||||
let _ = self.runtime.send_to(gw, GatewayMsg::NonceGcTick);
|
||||
}
|
||||
}
|
||||
if round % self.disseminate_interval == 0 {
|
||||
let _ = self
|
||||
.runtime
|
||||
.send_to(self.metadata_addr, MetadataMsg::DisseminateTick);
|
||||
}
|
||||
}
|
||||
|
||||
/// Access the bridge (as a trait object for the dashboard).
|
||||
pub fn bridge(&self) -> &Arc<dyn DatastoreStatsProvider> {
|
||||
&self.bridge
|
||||
}
|
||||
}
|
||||
|
||||
fn generate_node_id() -> NodeId {
|
||||
let mut bytes = [0u8; 32];
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
for (i, b) in nanos.to_le_bytes().iter().enumerate() {
|
||||
bytes[i % 32] ^= *b;
|
||||
}
|
||||
let pid = std::process::id();
|
||||
for (i, b) in pid.to_le_bytes().iter().enumerate() {
|
||||
bytes[i + 16] ^= *b;
|
||||
}
|
||||
NodeId(bytes)
|
||||
}
|
||||
|
|
@ -6,13 +6,13 @@ pub mod actors;
|
|||
pub mod auth;
|
||||
pub mod cli;
|
||||
pub mod metrics;
|
||||
#[cfg(feature = "node")]
|
||||
pub mod api;
|
||||
#[cfg(feature = "node")]
|
||||
pub mod ui_html;
|
||||
pub mod bridge;
|
||||
|
||||
pub use types::{ChunkRef, ContentHash, DatastoreConfig, ObjectEntry, ObjectManifest};
|
||||
pub use messages::{BlobStoreMsg, DatastoreNodeMsg, DatastoreResponse, MetadataMsg, TransferMsg};
|
||||
pub use chunking::{chunk_blob, reassemble_blob, verify_integrity, ChunkingError};
|
||||
pub use storage::{StorageBackend, FilesystemBackend, InMemoryBackend};
|
||||
pub use actors::{BlobStoreActor, DatastoreNode, MetadataActor, TransferActor};
|
||||
pub use bridge::{DatastoreGroup, DatastoreGroupConfig, DatastoreAuthConfig};
|
||||
|
|
|
|||
|
|
@ -460,3 +460,4 @@ pub enum GatewayMsg {
|
|||
/// Periodic nonce garbage collection tick.
|
||||
NonceGcTick,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -186,10 +186,9 @@ fn now_ms() -> u64 {
|
|||
.as_millis() as u64
|
||||
}
|
||||
|
||||
// ── Dashboard integration (only when runtime-dashboard is available) ────────
|
||||
// ── Dashboard integration ────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(feature = "node")]
|
||||
impl runtime_dashboard::datastore_collector::DatastoreStatsProvider for DatastoreMetrics {
|
||||
impl dashboard::datastore_collector::DatastoreStatsProvider for DatastoreMetrics {
|
||||
fn snapshot_json(&self) -> Option<String> {
|
||||
let snap = self.snapshot();
|
||||
serde_json::to_string(&snap).ok()
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
#![cfg(feature = "node")]
|
||||
|
||||
//! Integration test: spins up a real datastore node with HTTP API and exercises
|
||||
//! the full CRUD lifecycle over HTTP.
|
||||
|
||||
|
|
@ -34,7 +32,7 @@ fn http_crud_lifecycle() {
|
|||
let base = format!("http://127.0.0.1:{port}");
|
||||
|
||||
// Set up runtime with worker threads (needed for HTTP server)
|
||||
let collector = runtime_dashboard::collector::StatsCollector::new(2);
|
||||
let collector = dashboard::collector::StatsCollector::new(2);
|
||||
let mut rt = Runtime::new(RuntimeConfig {
|
||||
num_threads: 2,
|
||||
max_actors: 1024,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
#![cfg(feature = "node")]
|
||||
|
||||
//! End-to-end integration test: spins up a datastore node with an HTTP API
|
||||
//! **and** a runtime dashboard, performs CRUD over HTTP, then verifies:
|
||||
//!
|
||||
|
|
@ -46,12 +44,12 @@ fn dashboard_reflects_datastore_operations() {
|
|||
|
||||
// ── Infrastructure: runtime + actors + dashboard + API ──────────────
|
||||
|
||||
let dash = runtime_dashboard::start_dashboard(runtime_dashboard::DashboardConfig {
|
||||
let dash = dashboard::start_dashboard(dashboard::DashboardConfig {
|
||||
port: dash_port,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let collector = runtime_dashboard::collector::StatsCollector::new(2);
|
||||
let collector = dashboard::collector::StatsCollector::new(2);
|
||||
let mut rt = Runtime::new(RuntimeConfig {
|
||||
num_threads: 2,
|
||||
max_actors: 1024,
|
||||
|
|
@ -86,7 +84,7 @@ fn dashboard_reflects_datastore_operations() {
|
|||
dash.set_runtime(handle.runtime.clone(), collector);
|
||||
dash.set_datastore(
|
||||
Arc::clone(&metrics)
|
||||
as Arc<dyn runtime_dashboard::datastore_collector::DatastoreStatsProvider>,
|
||||
as Arc<dyn dashboard::datastore_collector::DatastoreStatsProvider>,
|
||||
);
|
||||
|
||||
let (api_shutdown, _peers) = start_api_server(
|
||||
|
|
|
|||
|
|
@ -4,8 +4,6 @@
|
|||
//! and uses ureq to prove that authorized requests succeed while unauthorized
|
||||
//! ones get 403 and missing-auth requests get 401.
|
||||
|
||||
#![cfg(feature = "node")]
|
||||
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ edition = "2024"
|
|||
default = ["tcp"]
|
||||
tcp = []
|
||||
iroh = ["dep:iroh", "dep:tokio"]
|
||||
relay = ["iroh", "dep:iroh-relay"]
|
||||
|
||||
[dependencies]
|
||||
swactor = { path = "../..", features = ["serde", "transport"] }
|
||||
|
|
@ -15,8 +16,12 @@ ed25519-dalek = { version = "2", features = ["rand_core"] }
|
|||
rand_core = { version = "0.6", features = ["getrandom"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
bs58 = "0.5"
|
||||
iroh = { version = "0.96", optional = true }
|
||||
iroh-relay = { version = "0.96", features = ["server"], optional = true }
|
||||
tokio = { version = "1", features = ["rt-multi-thread"], optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json = "1"
|
||||
iroh-relay = { version = "0.96", features = ["server", "test-utils"] }
|
||||
tokio = { version = "1", features = ["rt-multi-thread"] }
|
||||
|
|
|
|||
|
|
@ -95,6 +95,25 @@ impl NodeDriver {
|
|||
})
|
||||
}
|
||||
|
||||
/// Create a driver with a specific keypair for persistent identity.
|
||||
pub fn with_keypair(
|
||||
listen_addr: SocketAddr,
|
||||
keypair: crate::crypto::Keypair,
|
||||
config: DistributedNodeConfig,
|
||||
) -> Result<Self, swactor::Error> {
|
||||
let acceptor = TcpAcceptor::bind(listen_addr)?;
|
||||
let actual_addr = acceptor.local_addr();
|
||||
let node = DistributedNode::with_keypair(keypair, config);
|
||||
Ok(Self {
|
||||
node,
|
||||
transport: TcpTransport::pool(),
|
||||
acceptor,
|
||||
streams: Vec::new(),
|
||||
address_book: PeerAddressBook::new(),
|
||||
listen_addr: actual_addr,
|
||||
})
|
||||
}
|
||||
|
||||
/// The node's identity.
|
||||
pub fn node_id(&self) -> NodeId {
|
||||
self.node.node_id()
|
||||
|
|
@ -192,6 +211,42 @@ impl NodeDriver {
|
|||
}
|
||||
}
|
||||
|
||||
/// Process incoming TCP messages with peer auth filtering.
|
||||
///
|
||||
/// Same as `recv()`, but checks the sender's NodeId against the
|
||||
/// peer allow-list before dispatching. Unauthorized messages are dropped.
|
||||
pub fn recv_with_auth(
|
||||
&mut self,
|
||||
peer_auth: &std::sync::Arc<std::sync::Mutex<crate::peer_auth::PeerAllowList>>,
|
||||
) {
|
||||
let envelopes = self.acceptor.try_recv(&mut self.streams);
|
||||
for (envelope, _peer_addr, hints_bytes) in envelopes {
|
||||
// Extract sender NodeId from hints
|
||||
let mut sender_node_id = None;
|
||||
if !hints_bytes.is_empty() {
|
||||
if let Ok(hints) = serde_json::from_slice::<Vec<AddressHint>>(&hints_bytes) {
|
||||
if let Some(first) = hints.first() {
|
||||
sender_node_id = Some(first.node_id);
|
||||
}
|
||||
self.learn_hints(&hints);
|
||||
}
|
||||
}
|
||||
|
||||
// Check peer auth if we know the sender
|
||||
if let Some(node_id) = sender_node_id {
|
||||
let allowed = peer_auth.lock().unwrap().is_allowed(&node_id);
|
||||
if !allowed {
|
||||
let hex: String = node_id.0[..4].iter().map(|b| format!("{b:02x}")).collect();
|
||||
eprintln!("driver: rejected message from unauthorized peer {hex}");
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
let response_actions = self.dispatch_incoming(envelope);
|
||||
self.send_actions(&response_actions);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Outgoing: NodeAction → TCP ─────────────────────────────────────
|
||||
|
||||
fn send_actions(&mut self, actions: &[NodeAction]) {
|
||||
|
|
|
|||
118
crates/distribution/src/identity.rs
Normal file
118
crates/distribution/src/identity.rs
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
//! Shared keypair persistence — load or generate an ed25519 keypair on disk.
|
||||
//!
|
||||
//! Reused by `swactor-node`, `store_node`, and xtask tooling.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use crate::crypto::Keypair;
|
||||
|
||||
/// Hex-encode a byte slice.
|
||||
pub fn hex_encode(bytes: &[u8]) -> String {
|
||||
bytes.iter().map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
|
||||
/// Hex-decode a string into bytes. Returns `None` on invalid input.
|
||||
pub fn hex_decode(hex: &str) -> Option<Vec<u8>> {
|
||||
if hex.len() % 2 != 0 {
|
||||
return None;
|
||||
}
|
||||
let mut bytes = Vec::with_capacity(hex.len() / 2);
|
||||
for chunk in hex.as_bytes().chunks(2) {
|
||||
let hi = hex_digit(chunk[0])?;
|
||||
let lo = hex_digit(chunk[1])?;
|
||||
bytes.push((hi << 4) | lo);
|
||||
}
|
||||
Some(bytes)
|
||||
}
|
||||
|
||||
fn hex_digit(b: u8) -> Option<u8> {
|
||||
match b {
|
||||
b'0'..=b'9' => Some(b - b'0'),
|
||||
b'a'..=b'f' => Some(b - b'a' + 10),
|
||||
b'A'..=b'F' => Some(b - b'A' + 10),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Base58-encode a byte slice (Bitcoin alphabet).
|
||||
pub fn base58_encode(bytes: &[u8]) -> String {
|
||||
bs58::encode(bytes).into_string()
|
||||
}
|
||||
|
||||
/// Base58-decode a string into a 32-byte array. Returns `None` on invalid input.
|
||||
pub fn base58_decode(s: &str) -> Option<[u8; 32]> {
|
||||
let bytes = bs58::decode(s).into_vec().ok()?;
|
||||
bytes.try_into().ok()
|
||||
}
|
||||
|
||||
/// Load a keypair from `path`, or generate a new one and persist it.
|
||||
///
|
||||
/// The file format is JSON:
|
||||
/// ```json
|
||||
/// {
|
||||
/// "version": 1,
|
||||
/// "secret_key": "<hex>",
|
||||
/// "public_key": "<hex>",
|
||||
/// "created_at": "2025-01-01T00:00:00Z"
|
||||
/// }
|
||||
/// ```
|
||||
pub fn load_or_generate_keypair(path: &Path) -> Keypair {
|
||||
if path.exists() {
|
||||
let data = std::fs::read_to_string(path).expect("failed to read key file");
|
||||
let json: serde_json::Value =
|
||||
serde_json::from_str(&data).expect("invalid key file JSON");
|
||||
let secret_hex = json
|
||||
.get("secret_key")
|
||||
.and_then(|v| v.as_str())
|
||||
.expect("key file missing secret_key");
|
||||
let secret_bytes = hex_decode(secret_hex).expect("invalid secret_key hex");
|
||||
let secret: [u8; 32] = secret_bytes
|
||||
.try_into()
|
||||
.expect("secret_key must be 32 bytes");
|
||||
Keypair::from_bytes(&secret)
|
||||
} else {
|
||||
let keypair = Keypair::generate();
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs();
|
||||
let json = serde_json::json!({
|
||||
"version": 1,
|
||||
"secret_key": hex_encode(&keypair.secret_bytes()),
|
||||
"public_key": hex_encode(&keypair.node_id().0),
|
||||
"created_at": format_timestamp(now),
|
||||
});
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent).expect("failed to create key file directory");
|
||||
}
|
||||
std::fs::write(path, serde_json::to_string_pretty(&json).unwrap())
|
||||
.expect("failed to write key file");
|
||||
keypair
|
||||
}
|
||||
}
|
||||
|
||||
/// Simple ISO-8601 UTC timestamp from epoch seconds.
|
||||
pub fn format_timestamp(secs: u64) -> String {
|
||||
let s = secs % 60;
|
||||
let m = (secs / 60) % 60;
|
||||
let h = (secs / 3600) % 24;
|
||||
let days = secs / 86400;
|
||||
let (y, mo, d) = days_to_ymd(days);
|
||||
format!("{y:04}-{mo:02}-{d:02}T{h:02}:{m:02}:{s:02}Z")
|
||||
}
|
||||
|
||||
/// Convert days since epoch to (year, month, day).
|
||||
/// Algorithm from <http://howardhinnant.github.io/date_algorithms.html>.
|
||||
pub fn days_to_ymd(mut days: u64) -> (u64, u64, u64) {
|
||||
days += 719468;
|
||||
let era = days / 146097;
|
||||
let doe = days - era * 146097;
|
||||
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
|
||||
let y = yoe + era * 400;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let y = if m <= 2 { y + 1 } else { y };
|
||||
(y, m, d)
|
||||
}
|
||||
|
|
@ -8,16 +8,17 @@
|
|||
//! (`tick()`, `recv()`, `join()`) to match the existing main loop pattern.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use iroh::endpoint::Connection;
|
||||
use iroh::{Endpoint, PublicKey, RelayMode, SecretKey};
|
||||
use iroh::{Endpoint, EndpointAddr, PublicKey, RelayMode, SecretKey};
|
||||
use tokio::runtime::Runtime as TokioRuntime;
|
||||
|
||||
|
||||
use crate::crypto::Keypair;
|
||||
use crate::messages::*;
|
||||
use crate::node::{DistributedNode, DistributedNodeConfig};
|
||||
use crate::peer_auth::PeerAllowList;
|
||||
use crate::snapshot::DistributionNodeSnapshot;
|
||||
use crate::swim::node::NodeAction;
|
||||
use crate::types::NodeId;
|
||||
|
|
@ -37,6 +38,26 @@ pub struct IrohDriverConfig {
|
|||
pub relay_mode: RelayMode,
|
||||
/// Protocol-layer configuration.
|
||||
pub node: DistributedNodeConfig,
|
||||
/// Optional peer allow-list. If provided, only allowed peers can connect.
|
||||
pub peer_auth: Option<Arc<Mutex<PeerAllowList>>>,
|
||||
/// If set, start an embedded relay server on this address.
|
||||
/// Requires the `relay` feature. On success, the driver uses the embedded
|
||||
/// relay for `RelayMode::Custom`; on failure, falls back to `relay_mode`.
|
||||
#[cfg(feature = "relay")]
|
||||
pub embedded_relay_bind: Option<std::net::SocketAddr>,
|
||||
/// Public IP to advertise in the relay URL instead of the bind address.
|
||||
/// When `Some`, the relay URL uses this IP; when `None`, falls back to the
|
||||
/// bind address (which may be `0.0.0.0`).
|
||||
#[cfg(feature = "relay")]
|
||||
pub relay_public_ip: Option<std::net::IpAddr>,
|
||||
}
|
||||
|
||||
// ─── Pending join result ────────────────────────────────────────────────────
|
||||
|
||||
/// Result of a background join attempt, collected during `recv()`.
|
||||
struct JoinResult {
|
||||
node_id: NodeId,
|
||||
conn: Connection,
|
||||
}
|
||||
|
||||
// ─── Driver ─────────────────────────────────────────────────────────────────
|
||||
|
|
@ -50,6 +71,16 @@ pub struct IrohDriver {
|
|||
endpoint: Endpoint,
|
||||
rt: TokioRuntime,
|
||||
connections: HashMap<NodeId, Connection>,
|
||||
peer_auth: Option<Arc<Mutex<PeerAllowList>>>,
|
||||
/// Collects connections from background join tasks.
|
||||
pending_joins: Arc<Mutex<Vec<JoinResult>>>,
|
||||
/// Connections accepted by the background accept loop.
|
||||
accepted_conns: Arc<Mutex<Vec<(NodeId, Connection)>>>,
|
||||
/// Embedded relay server (if started).
|
||||
#[cfg(feature = "relay")]
|
||||
relay_server: Option<iroh_relay::server::Server>,
|
||||
/// URL of the embedded relay server (if started).
|
||||
relay_url: Option<String>,
|
||||
}
|
||||
|
||||
impl IrohDriver {
|
||||
|
|
@ -57,15 +88,40 @@ impl IrohDriver {
|
|||
///
|
||||
/// Builds a tokio runtime, creates an iroh `Endpoint`, and initializes
|
||||
/// the protocol-layer `DistributedNode`.
|
||||
///
|
||||
/// If `embedded_relay_bind` is set (requires `relay` feature), the driver
|
||||
/// starts an embedded relay server on the tokio runtime before creating
|
||||
/// the endpoint. On success the endpoint uses the embedded relay; on
|
||||
/// failure it falls back to `config.relay_mode`.
|
||||
pub fn new(config: IrohDriverConfig) -> Result<Self, Box<dyn std::error::Error>> {
|
||||
let rt = tokio::runtime::Builder::new_multi_thread()
|
||||
.enable_all()
|
||||
.build()?;
|
||||
|
||||
// Try to start embedded relay if configured
|
||||
#[cfg(feature = "relay")]
|
||||
let (relay_server, relay_url, effective_relay_mode) = match config.embedded_relay_bind {
|
||||
Some(bind_addr) => {
|
||||
match rt.block_on(start_embedded_relay(bind_addr, config.relay_public_ip)) {
|
||||
Ok((server, url)) => {
|
||||
let url_str = url.to_string();
|
||||
eprintln!("Relay: embedded relay started at {url}");
|
||||
(Some(server), Some(url_str), RelayMode::Custom(url.into()))
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Relay: failed to start embedded relay: {e}, falling back");
|
||||
(None, None, config.relay_mode)
|
||||
}
|
||||
}
|
||||
}
|
||||
None => (None, None, config.relay_mode),
|
||||
};
|
||||
#[cfg(not(feature = "relay"))]
|
||||
let (relay_url, effective_relay_mode) = (None::<String>, config.relay_mode);
|
||||
|
||||
let endpoint = rt.block_on(async {
|
||||
let mut builder = Endpoint::builder()
|
||||
.alpns(vec![ALPN.to_vec()])
|
||||
.relay_mode(config.relay_mode);
|
||||
let mut builder = Endpoint::empty_builder(effective_relay_mode)
|
||||
.alpns(vec![ALPN.to_vec()]);
|
||||
|
||||
if let Some(key) = config.secret_key {
|
||||
builder = builder.secret_key(key);
|
||||
|
|
@ -80,19 +136,99 @@ impl IrohDriver {
|
|||
let keypair = Keypair::from_bytes(&iroh_secret);
|
||||
let node = DistributedNode::with_keypair(keypair, config.node);
|
||||
|
||||
// Spawn background accept loop so incoming connections are never missed
|
||||
let accepted_conns: Arc<Mutex<Vec<(NodeId, Connection)>>> =
|
||||
Arc::new(Mutex::new(Vec::new()));
|
||||
{
|
||||
let ep = endpoint.clone();
|
||||
let peer_auth = config.peer_auth.clone();
|
||||
let buf = Arc::clone(&accepted_conns);
|
||||
rt.spawn(async move {
|
||||
loop {
|
||||
match ep.accept().await {
|
||||
Some(incoming) => match incoming.await {
|
||||
Ok(conn) => {
|
||||
let remote_id = conn.remote_id();
|
||||
let node_id = NodeId(*remote_id.as_bytes());
|
||||
// Peer auth check
|
||||
let allowed = match &peer_auth {
|
||||
None => true,
|
||||
Some(auth) => auth.lock().unwrap().is_allowed(&node_id),
|
||||
};
|
||||
if !allowed {
|
||||
eprintln!(
|
||||
"iroh driver: rejected connection from unauthorized peer {}",
|
||||
crate::identity::hex_encode(&node_id.0[..4])
|
||||
);
|
||||
conn.close(0u32.into(), b"unauthorized");
|
||||
continue;
|
||||
}
|
||||
eprintln!(
|
||||
"iroh driver: accepted connection from {}",
|
||||
crate::identity::hex_encode(&node_id.0[..4])
|
||||
);
|
||||
buf.lock().unwrap().push((node_id, conn));
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("iroh driver: incoming connection error: {e}");
|
||||
}
|
||||
},
|
||||
None => break, // endpoint closed
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
node,
|
||||
endpoint,
|
||||
rt,
|
||||
connections: HashMap::new(),
|
||||
peer_auth: config.peer_auth,
|
||||
pending_joins: Arc::new(Mutex::new(Vec::new())),
|
||||
accepted_conns,
|
||||
#[cfg(feature = "relay")]
|
||||
relay_server,
|
||||
relay_url,
|
||||
})
|
||||
}
|
||||
|
||||
/// Get a handle to the tokio runtime owned by this driver.
|
||||
pub fn tokio_handle(&self) -> tokio::runtime::Handle {
|
||||
self.rt.handle().clone()
|
||||
}
|
||||
|
||||
/// The node's identity.
|
||||
pub fn node_id(&self) -> NodeId {
|
||||
self.node.node_id()
|
||||
}
|
||||
|
||||
/// The endpoint's full address (public key + direct socket addresses).
|
||||
///
|
||||
/// Constructs the address from the endpoint's public key and bound
|
||||
/// sockets. Unspecified addresses (`0.0.0.0` / `[::]`) are mapped to
|
||||
/// their loopback equivalents so peers on the same host can connect.
|
||||
pub fn endpoint_addr(&self) -> EndpointAddr {
|
||||
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
|
||||
|
||||
let key = PublicKey::from_bytes(&self.node.node_id().0)
|
||||
.expect("node_id is a valid public key");
|
||||
let mut addr = EndpointAddr::new(key);
|
||||
for sock in self.endpoint.bound_sockets() {
|
||||
let resolved = match sock.ip() {
|
||||
IpAddr::V4(ip) if ip.is_unspecified() => {
|
||||
SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), sock.port())
|
||||
}
|
||||
IpAddr::V6(ip) if ip.is_unspecified() => {
|
||||
SocketAddr::new(IpAddr::V6(Ipv6Addr::LOCALHOST), sock.port())
|
||||
}
|
||||
_ => sock,
|
||||
};
|
||||
addr = addr.with_ip_addr(resolved);
|
||||
}
|
||||
addr
|
||||
}
|
||||
|
||||
/// Access the underlying node (read-only).
|
||||
pub fn node(&self) -> &DistributedNode {
|
||||
&self.node
|
||||
|
|
@ -116,39 +252,94 @@ impl IrohDriver {
|
|||
|
||||
/// Join a cluster by connecting to seed nodes via iroh.
|
||||
///
|
||||
/// Each seed is identified by its iroh `PublicKey` (= our `NodeId`).
|
||||
pub fn join(&mut self, seeds: &[PublicKey]) {
|
||||
for seed_key in seeds {
|
||||
let seed_node_id = NodeId(*seed_key.as_bytes());
|
||||
if let Err(e) = self.send_join_request(*seed_key, seed_node_id) {
|
||||
eprintln!("iroh driver: join error to {seed_key}: {e}");
|
||||
}
|
||||
/// Each seed is identified by its `EndpointAddr` (public key + optional
|
||||
/// direct addresses). Connect+send is spawned as a background task so
|
||||
/// that the peer can accept the connection during its `recv()` cycle.
|
||||
/// Results are collected in the next `recv()` call.
|
||||
pub fn join(&mut self, seeds: &[EndpointAddr]) {
|
||||
for seed_addr in seeds {
|
||||
self.spawn_join_request(seed_addr.clone());
|
||||
}
|
||||
}
|
||||
|
||||
fn send_join_request(
|
||||
&mut self,
|
||||
seed_key: PublicKey,
|
||||
seed_node_id: NodeId,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
fn spawn_join_request(&self, seed_addr: EndpointAddr) {
|
||||
let msg = JoinRequest {
|
||||
from: self.node.node_id(),
|
||||
};
|
||||
let payload = serde_json::to_vec(&msg)?;
|
||||
let payload = serde_json::to_vec(&msg).expect("serialize JoinRequest");
|
||||
let tag = <JoinRequest as swactor::transport::NetworkMessage>::type_tag();
|
||||
|
||||
let endpoint = self.endpoint.clone();
|
||||
let conn = self.rt.block_on(async {
|
||||
let conn = endpoint.connect(seed_key, ALPN).await?;
|
||||
let mut send = conn.open_uni().await?;
|
||||
write_message(&mut send, tag.as_bytes(), &payload).await?;
|
||||
send.finish()?;
|
||||
Ok::<_, Box<dyn std::error::Error>>(conn)
|
||||
})?;
|
||||
let seed_node_id = NodeId(*seed_addr.id.as_bytes());
|
||||
let pending = Arc::clone(&self.pending_joins);
|
||||
|
||||
self.connections.insert(seed_node_id, conn);
|
||||
self.rt.spawn(async move {
|
||||
let mut delay = Duration::from_secs(2);
|
||||
let max_delay = Duration::from_secs(30);
|
||||
let max_attempts = 5;
|
||||
|
||||
for attempt in 1..=max_attempts {
|
||||
if attempt > 1 {
|
||||
tokio::time::sleep(delay).await;
|
||||
delay = (delay * 2).min(max_delay);
|
||||
}
|
||||
|
||||
eprintln!("iroh driver: join attempt {attempt}/{max_attempts} connecting to {}...", seed_addr.id);
|
||||
let connect_result = tokio::time::timeout(
|
||||
Duration::from_secs(10),
|
||||
endpoint.connect(seed_addr.clone(), ALPN),
|
||||
).await;
|
||||
|
||||
match connect_result {
|
||||
Ok(Ok(conn)) => {
|
||||
eprintln!("iroh driver: join attempt {attempt}/{max_attempts} connected to {}, sending...", seed_addr.id);
|
||||
let send_result: Result<(), String> = async {
|
||||
let mut send = conn.open_uni().await.map_err(|e| e.to_string())?;
|
||||
let tag_len = (tag.len() as u32).to_be_bytes();
|
||||
send.write_all(&tag_len).await.map_err(|e| e.to_string())?;
|
||||
send.write_all(tag.as_bytes()).await.map_err(|e| e.to_string())?;
|
||||
send.write_all(&payload).await.map_err(|e| e.to_string())?;
|
||||
send.finish().map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
.await;
|
||||
|
||||
match send_result {
|
||||
Ok(()) => {
|
||||
eprintln!("iroh driver: join attempt {attempt}/{max_attempts} sent to {}", seed_addr.id);
|
||||
pending.lock().unwrap().push(JoinResult {
|
||||
node_id: seed_node_id,
|
||||
conn,
|
||||
});
|
||||
return;
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"iroh driver: join attempt {attempt}/{max_attempts} send error to {}: {e}",
|
||||
seed_addr.id
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
eprintln!(
|
||||
"iroh driver: join attempt {attempt}/{max_attempts} connect error to {}: {e}",
|
||||
seed_addr.id
|
||||
);
|
||||
continue;
|
||||
}
|
||||
Err(_) => {
|
||||
eprintln!(
|
||||
"iroh driver: join attempt {attempt}/{max_attempts} connect timeout to {}",
|
||||
seed_addr.id
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
eprintln!("iroh driver: join failed after {max_attempts} attempts to {}", seed_addr.id);
|
||||
});
|
||||
}
|
||||
|
||||
/// Advance the node by one tick.
|
||||
pub fn tick(&mut self) {
|
||||
|
|
@ -158,6 +349,17 @@ impl IrohDriver {
|
|||
|
||||
/// Process incoming iroh connections and messages (non-blocking).
|
||||
pub fn recv(&mut self) {
|
||||
// Collect completed background join connections
|
||||
{
|
||||
let mut pending = self.pending_joins.lock().unwrap();
|
||||
if !pending.is_empty() {
|
||||
eprintln!("iroh driver: collecting {} pending join connection(s)", pending.len());
|
||||
}
|
||||
for result in pending.drain(..) {
|
||||
self.connections.entry(result.node_id).or_insert(result.conn);
|
||||
}
|
||||
}
|
||||
|
||||
let (incoming, new_conns) = self.rt.block_on(async {
|
||||
self.receive_pending().await
|
||||
});
|
||||
|
|
@ -274,6 +476,15 @@ impl IrohDriver {
|
|||
node_id: NodeId,
|
||||
key: PublicKey,
|
||||
) -> Result<Connection, Box<dyn std::error::Error>> {
|
||||
// Defense in depth: check peer auth before connecting
|
||||
if !self.is_peer_allowed(&node_id) {
|
||||
return Err(format!(
|
||||
"peer {} not in allow-list",
|
||||
crate::identity::hex_encode(&node_id.0[..4])
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
// Check for cached connection that's still open
|
||||
if let Some(conn) = self.connections.get(&node_id) {
|
||||
if conn.close_reason().is_none() {
|
||||
|
|
@ -294,26 +505,26 @@ impl IrohDriver {
|
|||
|
||||
// ─── Incoming: iroh → handler ────────────────────────────────────
|
||||
|
||||
fn is_peer_allowed(&self, node_id: &NodeId) -> bool {
|
||||
match &self.peer_auth {
|
||||
None => true,
|
||||
Some(auth) => auth.lock().unwrap().is_allowed(node_id),
|
||||
}
|
||||
}
|
||||
|
||||
async fn receive_pending(&self) -> (Vec<(String, Vec<u8>, PublicKey)>, Vec<(NodeId, Connection)>) {
|
||||
let mut messages = Vec::new();
|
||||
let mut new_connections = Vec::new();
|
||||
|
||||
// Poll for incoming connections with a short timeout
|
||||
loop {
|
||||
let accept_fut = self.endpoint.accept();
|
||||
let result = tokio::time::timeout(Duration::from_millis(1), accept_fut).await;
|
||||
// Drain connections accepted by the background accept loop
|
||||
let new_connections: Vec<(NodeId, Connection)> = {
|
||||
let mut buf = self.accepted_conns.lock().unwrap();
|
||||
buf.drain(..).collect()
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok(Some(incoming)) => {
|
||||
if let Ok(conn) = incoming.await {
|
||||
let remote_id = conn.remote_id();
|
||||
self.read_streams(&conn, remote_id, &mut messages).await;
|
||||
let node_id = NodeId(*remote_id.as_bytes());
|
||||
new_connections.push((node_id, conn));
|
||||
}
|
||||
}
|
||||
_ => break,
|
||||
}
|
||||
// Read streams from newly accepted connections
|
||||
for (node_id, conn) in &new_connections {
|
||||
let remote_id = PublicKey::from_bytes(&node_id.0).unwrap();
|
||||
self.read_streams(conn, remote_id, &mut messages).await;
|
||||
}
|
||||
|
||||
// Also read from existing cached connections
|
||||
|
|
@ -328,6 +539,10 @@ impl IrohDriver {
|
|||
self.read_streams(&conn, remote_id, &mut messages).await;
|
||||
}
|
||||
|
||||
if !messages.is_empty() {
|
||||
eprintln!("iroh driver: received {} message(s)", messages.len());
|
||||
}
|
||||
|
||||
(messages, new_connections)
|
||||
}
|
||||
|
||||
|
|
@ -416,14 +631,65 @@ impl IrohDriver {
|
|||
}
|
||||
}
|
||||
|
||||
/// Shut down the iroh endpoint.
|
||||
pub fn shutdown(&self) {
|
||||
/// URL of the embedded relay server, if one was started.
|
||||
pub fn relay_url(&self) -> Option<&str> {
|
||||
self.relay_url.as_deref()
|
||||
}
|
||||
|
||||
/// The endpoint's home relay URL (from RelayMode::Custom), if connected.
|
||||
pub fn home_relay_url(&self) -> Option<iroh::RelayUrl> {
|
||||
self.endpoint.addr().relay_urls().next().cloned()
|
||||
}
|
||||
|
||||
/// Shut down the driver: stop the embedded relay (if any), then close the
|
||||
/// iroh endpoint.
|
||||
pub fn shutdown(&mut self) {
|
||||
// Shut down embedded relay first (must stop before endpoint closes)
|
||||
#[cfg(feature = "relay")]
|
||||
if let Some(server) = self.relay_server.take() {
|
||||
self.rt.block_on(async {
|
||||
let _ = server.shutdown().await;
|
||||
});
|
||||
}
|
||||
self.rt.block_on(async {
|
||||
self.endpoint.close().await;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Embedded Relay ─────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(feature = "relay")]
|
||||
async fn start_embedded_relay(
|
||||
bind_addr: std::net::SocketAddr,
|
||||
public_ip: Option<std::net::IpAddr>,
|
||||
) -> Result<(iroh_relay::server::Server, iroh::RelayUrl), Box<dyn std::error::Error>> {
|
||||
let server = iroh_relay::server::Server::spawn(
|
||||
iroh_relay::server::ServerConfig::<(), ()> {
|
||||
relay: Some(iroh_relay::server::RelayConfig {
|
||||
http_bind_addr: bind_addr,
|
||||
tls: None,
|
||||
limits: Default::default(),
|
||||
key_cache_capacity: Some(256),
|
||||
access: iroh_relay::server::AccessConfig::Everyone,
|
||||
}),
|
||||
quic: None,
|
||||
metrics_addr: None,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
let url: iroh::RelayUrl = match server.http_addr() {
|
||||
Some(addr) => {
|
||||
let host = public_ip.unwrap_or_else(|| addr.ip());
|
||||
format!("http://{}:{}/", host, addr.port()).parse()?
|
||||
}
|
||||
None => return Err("relay server has no HTTP address".into()),
|
||||
};
|
||||
|
||||
Ok((server, url))
|
||||
}
|
||||
|
||||
// ─── Wire Framing Over QUIC Streams ─────────────────────────────────────────
|
||||
|
||||
/// Write a tagged message to a QUIC send stream.
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
pub mod types;
|
||||
pub mod crypto;
|
||||
pub mod identity;
|
||||
pub mod peer_auth;
|
||||
pub mod messages;
|
||||
pub mod codec;
|
||||
#[cfg(feature = "tcp")]
|
||||
|
|
@ -9,6 +11,7 @@ pub mod kademlia;
|
|||
pub mod cache;
|
||||
pub mod node;
|
||||
pub mod registry;
|
||||
pub mod node_metadata;
|
||||
pub mod snapshot;
|
||||
#[cfg(feature = "tcp")]
|
||||
pub mod driver;
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ use crate::crypto::Keypair;
|
|||
use crate::kademlia::directory::{actor_addr_as_node_id, DirectoryShard};
|
||||
use crate::kademlia::repair::{RepairQueue, RepublishTracker};
|
||||
use crate::kademlia::routing_table::RoutingTable;
|
||||
use crate::node_metadata::{NodeMetadataDisseminator, NodeMetadataEntry};
|
||||
use crate::registry::{
|
||||
pack_combined_piggyback, unpack_combined_piggyback, ClusterRegistry, RegistryConfig,
|
||||
RegistryEntry, RegistryEvent,
|
||||
|
|
@ -25,6 +26,8 @@ pub struct DistributedNodeConfig {
|
|||
pub cache_capacity: usize,
|
||||
pub republish_interval: u64,
|
||||
pub registry: RegistryConfig,
|
||||
/// Dissemination multiplier for node metadata (default: 3).
|
||||
pub metadata_lambda: usize,
|
||||
}
|
||||
|
||||
impl Default for DistributedNodeConfig {
|
||||
|
|
@ -34,6 +37,7 @@ impl Default for DistributedNodeConfig {
|
|||
cache_capacity: 10_000,
|
||||
republish_interval: 1000,
|
||||
registry: RegistryConfig::default(),
|
||||
metadata_lambda: 3,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -51,6 +55,7 @@ pub struct DistributedNode {
|
|||
repair_queue: RepairQueue,
|
||||
republish: RepublishTracker,
|
||||
registry: ClusterRegistry,
|
||||
metadata: NodeMetadataDisseminator,
|
||||
tick_count: u64,
|
||||
}
|
||||
|
||||
|
|
@ -72,6 +77,7 @@ impl DistributedNode {
|
|||
repair_queue: RepairQueue::new(),
|
||||
republish: RepublishTracker::new(config.republish_interval),
|
||||
registry: ClusterRegistry::new(config.registry),
|
||||
metadata: NodeMetadataDisseminator::new(config.metadata_lambda),
|
||||
tick_count: 0,
|
||||
keypair,
|
||||
}
|
||||
|
|
@ -138,35 +144,41 @@ impl DistributedNode {
|
|||
// Registry GC
|
||||
self.registry.gc_tick();
|
||||
|
||||
// Wrap outgoing piggyback with registry entries
|
||||
self.inject_registry_piggyback(actions)
|
||||
// Wrap outgoing piggyback with registry + metadata entries
|
||||
self.inject_piggyback(actions)
|
||||
}
|
||||
|
||||
// ─── SWIM message handling (delegate to SwimNode) ───────────────────
|
||||
|
||||
pub fn handle_ping(&mut self, from: NodeId, sequence: u64, piggyback: &[u8]) -> Vec<NodeAction> {
|
||||
let (membership_bytes, registry_entries) = unpack_combined_piggyback(piggyback);
|
||||
let (membership_bytes, registry_entries, metadata_entries) =
|
||||
unpack_combined_piggyback(piggyback);
|
||||
let actions = self.swim.handle_ping(from, sequence, &membership_bytes);
|
||||
self.process_membership_changes(&actions);
|
||||
self.merge_registry_entries(registry_entries);
|
||||
self.merge_metadata_entries(metadata_entries);
|
||||
self.maybe_update_routing_table(from);
|
||||
self.inject_registry_piggyback(actions)
|
||||
self.inject_piggyback(actions)
|
||||
}
|
||||
|
||||
pub fn handle_ack(&mut self, from: NodeId, sequence: u64, piggyback: &[u8]) -> Vec<NodeAction> {
|
||||
let (membership_bytes, registry_entries) = unpack_combined_piggyback(piggyback);
|
||||
let (membership_bytes, registry_entries, metadata_entries) =
|
||||
unpack_combined_piggyback(piggyback);
|
||||
let actions = self.swim.handle_ack(from, sequence, &membership_bytes);
|
||||
self.process_membership_changes(&actions);
|
||||
self.merge_registry_entries(registry_entries);
|
||||
self.inject_registry_piggyback(actions)
|
||||
self.merge_metadata_entries(metadata_entries);
|
||||
self.inject_piggyback(actions)
|
||||
}
|
||||
|
||||
pub fn handle_ping_req(&mut self, from: NodeId, target: NodeId, sequence: u64, piggyback: &[u8]) -> Vec<NodeAction> {
|
||||
let (membership_bytes, registry_entries) = unpack_combined_piggyback(piggyback);
|
||||
let (membership_bytes, registry_entries, metadata_entries) =
|
||||
unpack_combined_piggyback(piggyback);
|
||||
let actions = self.swim.handle_ping_req(from, target, sequence, &membership_bytes);
|
||||
self.process_membership_changes(&actions);
|
||||
self.merge_registry_entries(registry_entries);
|
||||
self.inject_registry_piggyback(actions)
|
||||
self.merge_metadata_entries(metadata_entries);
|
||||
self.inject_piggyback(actions)
|
||||
}
|
||||
|
||||
pub fn handle_join_request(&mut self, from: NodeId) -> Vec<NodeAction> {
|
||||
|
|
@ -261,6 +273,24 @@ impl DistributedNode {
|
|||
&self.registry
|
||||
}
|
||||
|
||||
// ─── Node metadata (relay URL) ─────────────────────────────────────
|
||||
|
||||
/// Set this node's relay URL and begin gossiping it to the cluster.
|
||||
pub fn set_relay_url(&mut self, url: Option<String>) {
|
||||
self.metadata
|
||||
.set_local(self.node_id(), url, self.cluster_size());
|
||||
}
|
||||
|
||||
/// Look up a node's relay URL.
|
||||
pub fn relay_url(&self, node_id: &NodeId) -> Option<&str> {
|
||||
self.metadata.relay_url(node_id)
|
||||
}
|
||||
|
||||
/// Read-only access to the metadata disseminator.
|
||||
pub fn metadata(&self) -> &NodeMetadataDisseminator {
|
||||
&self.metadata
|
||||
}
|
||||
|
||||
// ─── Accessors ──────────────────────────────────────────────────────
|
||||
|
||||
pub fn routing_table(&self) -> &RoutingTable {
|
||||
|
|
@ -306,15 +336,18 @@ impl DistributedNode {
|
|||
match state {
|
||||
MemberState::Alive => {
|
||||
self.routing_table.insert(node_id);
|
||||
// Re-disseminate registry entries so the recovering node
|
||||
// catches up on state accumulated during the partition.
|
||||
self.registry.re_disseminate_all(self.cluster_size());
|
||||
// Re-disseminate registry + metadata entries so the recovering
|
||||
// node catches up on state accumulated during the partition.
|
||||
let size = self.cluster_size();
|
||||
self.registry.re_disseminate_all(size);
|
||||
self.metadata.re_disseminate_all(size);
|
||||
}
|
||||
MemberState::Dead => {
|
||||
self.routing_table.remove(&node_id);
|
||||
self.cache.invalidate_node(&node_id);
|
||||
self.repair_queue.on_node_death(&node_id, &mut self.directory);
|
||||
self.registry.tombstone_node(node_id, self.cluster_size());
|
||||
self.metadata.remove_node(&node_id);
|
||||
}
|
||||
MemberState::Suspect => {
|
||||
// Keep in routing table but could downprioritize
|
||||
|
|
@ -326,24 +359,27 @@ impl DistributedNode {
|
|||
self.swim.members().alive_count() + 1 // +1 for self
|
||||
}
|
||||
|
||||
/// Post-process outgoing actions: wrap each piggyback with registry entries.
|
||||
fn inject_registry_piggyback(&mut self, actions: Vec<NodeAction>) -> Vec<NodeAction> {
|
||||
/// Post-process outgoing actions: wrap each piggyback with registry + metadata entries.
|
||||
fn inject_piggyback(&mut self, actions: Vec<NodeAction>) -> Vec<NodeAction> {
|
||||
actions
|
||||
.into_iter()
|
||||
.map(|action| match action {
|
||||
NodeAction::SendPing { to, sequence, piggyback } => {
|
||||
let registry_entries = self.registry.take_pending(8);
|
||||
let combined = pack_combined_piggyback(piggyback, registry_entries);
|
||||
let metadata_entries = self.metadata.take_pending(4);
|
||||
let combined = pack_combined_piggyback(piggyback, registry_entries, metadata_entries);
|
||||
NodeAction::SendPing { to, sequence, piggyback: combined }
|
||||
}
|
||||
NodeAction::SendAck { to, sequence, piggyback } => {
|
||||
let registry_entries = self.registry.take_pending(8);
|
||||
let combined = pack_combined_piggyback(piggyback, registry_entries);
|
||||
let metadata_entries = self.metadata.take_pending(4);
|
||||
let combined = pack_combined_piggyback(piggyback, registry_entries, metadata_entries);
|
||||
NodeAction::SendAck { to, sequence, piggyback: combined }
|
||||
}
|
||||
NodeAction::SendPingReq { relay, target, sequence, piggyback } => {
|
||||
let registry_entries = self.registry.take_pending(8);
|
||||
let combined = pack_combined_piggyback(piggyback, registry_entries);
|
||||
let metadata_entries = self.metadata.take_pending(4);
|
||||
let combined = pack_combined_piggyback(piggyback, registry_entries, metadata_entries);
|
||||
NodeAction::SendPingReq { relay, target, sequence, piggyback: combined }
|
||||
}
|
||||
other => other,
|
||||
|
|
@ -357,6 +393,13 @@ impl DistributedNode {
|
|||
self.registry.merge_batch(entries, self.cluster_size());
|
||||
}
|
||||
}
|
||||
|
||||
/// Merge metadata entries received from a piggyback payload.
|
||||
fn merge_metadata_entries(&mut self, entries: Vec<NodeMetadataEntry>) {
|
||||
if !entries.is_empty() {
|
||||
self.metadata.apply_incoming(entries, self.cluster_size());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of resolving an actor's location.
|
||||
|
|
|
|||
129
crates/distribution/src/node_metadata.rs
Normal file
129
crates/distribution/src/node_metadata.rs
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
//! Node metadata dissemination — gossip-propagated per-node metadata.
|
||||
//!
|
||||
//! Each node may have metadata (currently: relay URL) that should be visible
|
||||
//! cluster-wide. Uses higher-generation-wins semantics and SWIM-style
|
||||
//! dissemination budgets (Λ * log₂(n)).
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::types::NodeId;
|
||||
|
||||
/// A single metadata entry for one node.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct NodeMetadataEntry {
|
||||
pub node_id: NodeId,
|
||||
pub relay_url: Option<String>,
|
||||
pub generation: u64,
|
||||
}
|
||||
|
||||
/// Dissemination state for one entry.
|
||||
#[derive(Debug, Clone)]
|
||||
struct PendingEntry {
|
||||
entry: NodeMetadataEntry,
|
||||
remaining: usize,
|
||||
}
|
||||
|
||||
/// Per-node metadata store with gossip dissemination.
|
||||
pub struct NodeMetadataDisseminator {
|
||||
store: HashMap<NodeId, NodeMetadataEntry>,
|
||||
pending: Vec<PendingEntry>,
|
||||
lambda: usize,
|
||||
local_generation: u64,
|
||||
}
|
||||
|
||||
impl NodeMetadataDisseminator {
|
||||
pub fn new(lambda: usize) -> Self {
|
||||
Self {
|
||||
store: HashMap::new(),
|
||||
pending: Vec::new(),
|
||||
lambda,
|
||||
local_generation: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set this node's relay URL and enqueue for dissemination.
|
||||
pub fn set_local(&mut self, node_id: NodeId, relay_url: Option<String>, cluster_size: usize) {
|
||||
self.local_generation += 1;
|
||||
let entry = NodeMetadataEntry {
|
||||
node_id,
|
||||
relay_url,
|
||||
generation: self.local_generation,
|
||||
};
|
||||
self.store.insert(node_id, entry.clone());
|
||||
self.enqueue(entry, cluster_size);
|
||||
}
|
||||
|
||||
/// Merge incoming entries from gossip. Re-enqueues changed entries for forwarding.
|
||||
pub fn apply_incoming(&mut self, entries: Vec<NodeMetadataEntry>, cluster_size: usize) {
|
||||
for entry in entries {
|
||||
let dominated = match self.store.get(&entry.node_id) {
|
||||
Some(existing) => entry.generation <= existing.generation,
|
||||
None => false,
|
||||
};
|
||||
if dominated {
|
||||
continue;
|
||||
}
|
||||
self.store.insert(entry.node_id, entry.clone());
|
||||
self.enqueue(entry, cluster_size);
|
||||
}
|
||||
}
|
||||
|
||||
/// Take pending entries for piggyback, up to `max_count`.
|
||||
pub fn take_pending(&mut self, max_count: usize) -> Vec<NodeMetadataEntry> {
|
||||
let count = max_count.min(self.pending.len());
|
||||
let mut result = Vec::with_capacity(count);
|
||||
|
||||
for entry in self.pending.iter_mut().take(count) {
|
||||
result.push(entry.entry.clone());
|
||||
entry.remaining = entry.remaining.saturating_sub(1);
|
||||
}
|
||||
|
||||
self.pending.retain(|e| e.remaining > 0);
|
||||
result
|
||||
}
|
||||
|
||||
/// Look up a node's relay URL.
|
||||
pub fn relay_url(&self, node_id: &NodeId) -> Option<&str> {
|
||||
self.store
|
||||
.get(node_id)
|
||||
.and_then(|e| e.relay_url.as_deref())
|
||||
}
|
||||
|
||||
/// Remove metadata for a dead node.
|
||||
pub fn remove_node(&mut self, node_id: &NodeId) {
|
||||
self.store.remove(node_id);
|
||||
self.pending.retain(|e| &e.entry.node_id != node_id);
|
||||
}
|
||||
|
||||
/// Re-enqueue all entries for dissemination (anti-entropy on membership recovery).
|
||||
pub fn re_disseminate_all(&mut self, cluster_size: usize) {
|
||||
for entry in self.store.values().cloned().collect::<Vec<_>>() {
|
||||
self.enqueue(entry, cluster_size);
|
||||
}
|
||||
}
|
||||
|
||||
fn transmit_budget(&self, cluster_size: usize) -> usize {
|
||||
let n = cluster_size.max(2) as f64;
|
||||
let log_n = n.log2().ceil() as usize;
|
||||
self.lambda * log_n.max(1)
|
||||
}
|
||||
|
||||
fn enqueue(&mut self, entry: NodeMetadataEntry, cluster_size: usize) {
|
||||
let budget = self.transmit_budget(cluster_size);
|
||||
|
||||
// Replace existing pending entry for same node if present.
|
||||
if let Some(existing) = self.pending.iter_mut().find(|e| e.entry.node_id == entry.node_id)
|
||||
{
|
||||
existing.entry = entry;
|
||||
existing.remaining = budget;
|
||||
return;
|
||||
}
|
||||
|
||||
self.pending.push(PendingEntry {
|
||||
entry,
|
||||
remaining: budget,
|
||||
});
|
||||
}
|
||||
}
|
||||
140
crates/distribution/src/peer_auth.rs
Normal file
140
crates/distribution/src/peer_auth.rs
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
//! Runtime-mutable peer allow-list for network-level authentication.
|
||||
//!
|
||||
//! When enabled, only peers whose `NodeId` appears in the allow-list
|
||||
//! can join the cluster or exchange messages with this node.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::identity::{hex_decode, hex_encode};
|
||||
use crate::types::NodeId;
|
||||
|
||||
/// A single trusted peer entry.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PeerEntry {
|
||||
pub node_id: String,
|
||||
pub label: String,
|
||||
}
|
||||
|
||||
/// File format for the peers.json allow-list.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct PeersFile {
|
||||
version: u32,
|
||||
peers: Vec<PeerEntry>,
|
||||
}
|
||||
|
||||
/// Runtime-mutable peer allow-list.
|
||||
///
|
||||
/// - `None` inner map = open mode (all peers accepted)
|
||||
/// - `Some(map)` = only listed peers accepted
|
||||
pub struct PeerAllowList {
|
||||
peers: Option<HashMap<NodeId, PeerEntry>>,
|
||||
path: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl PeerAllowList {
|
||||
/// Create an open allow-list (no restrictions).
|
||||
pub fn open() -> Self {
|
||||
Self {
|
||||
peers: None,
|
||||
path: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Load an allow-list from a JSON file.
|
||||
///
|
||||
/// If the file doesn't exist, creates an empty allow-list (restrictive mode
|
||||
/// with zero peers). The file will be created on the first `save()`.
|
||||
pub fn from_file(path: &Path) -> io::Result<Self> {
|
||||
let peers = if path.exists() {
|
||||
let data = std::fs::read_to_string(path)?;
|
||||
let file: PeersFile = serde_json::from_str(&data)
|
||||
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
|
||||
let mut map = HashMap::new();
|
||||
for entry in file.peers {
|
||||
if let Some(bytes) = hex_decode(&entry.node_id) {
|
||||
if let Ok(arr) = <[u8; 32]>::try_from(bytes.as_slice()) {
|
||||
map.insert(NodeId(arr), entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
map
|
||||
} else {
|
||||
HashMap::new()
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
peers: Some(peers),
|
||||
path: Some(path.to_path_buf()),
|
||||
})
|
||||
}
|
||||
|
||||
/// Check whether a peer is allowed.
|
||||
pub fn is_allowed(&self, node_id: &NodeId) -> bool {
|
||||
match &self.peers {
|
||||
None => true,
|
||||
Some(map) => map.contains_key(node_id),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the allow-list is in open mode.
|
||||
pub fn is_open(&self) -> bool {
|
||||
self.peers.is_none()
|
||||
}
|
||||
|
||||
/// Add a peer to the allow-list.
|
||||
pub fn add_peer(&mut self, node_id: NodeId, label: String) {
|
||||
let map = self.peers.get_or_insert_with(HashMap::new);
|
||||
map.insert(
|
||||
node_id,
|
||||
PeerEntry {
|
||||
node_id: hex_encode(&node_id.0),
|
||||
label,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Remove a peer from the allow-list.
|
||||
pub fn remove_peer(&mut self, node_id: &NodeId) {
|
||||
if let Some(ref mut map) = self.peers {
|
||||
map.remove(node_id);
|
||||
}
|
||||
}
|
||||
|
||||
/// List all trusted peers.
|
||||
pub fn list_peers(&self) -> Vec<&PeerEntry> {
|
||||
match &self.peers {
|
||||
None => Vec::new(),
|
||||
Some(map) => map.values().collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Persist the allow-list to disk.
|
||||
pub fn save(&self) -> io::Result<()> {
|
||||
let path = match &self.path {
|
||||
Some(p) => p,
|
||||
None => return Ok(()),
|
||||
};
|
||||
|
||||
let entries: Vec<PeerEntry> = match &self.peers {
|
||||
Some(map) => map.values().cloned().collect(),
|
||||
None => Vec::new(),
|
||||
};
|
||||
|
||||
let file = PeersFile {
|
||||
version: 1,
|
||||
peers: entries,
|
||||
};
|
||||
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
let json = serde_json::to_string_pretty(&file)
|
||||
.map_err(|e| io::Error::new(io::ErrorKind::Other, e))?;
|
||||
std::fs::write(path, json)
|
||||
}
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ use std::collections::{HashMap, VecDeque};
|
|||
use serde::{Deserialize, Serialize};
|
||||
use swactor::actor::ActorAddress;
|
||||
|
||||
use crate::node_metadata::NodeMetadataEntry;
|
||||
use crate::types::NodeId;
|
||||
|
||||
// ─── Configuration ──────────────────────────────────────────────────────────
|
||||
|
|
@ -53,13 +54,16 @@ pub struct RegistryEntry {
|
|||
pub tombstone: bool,
|
||||
}
|
||||
|
||||
/// Combined piggyback payload: membership bytes + registry entries.
|
||||
/// Combined piggyback payload: membership bytes + registry entries + node metadata.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PiggybackPayload {
|
||||
/// Raw SWIM membership piggyback bytes (opaque to registry).
|
||||
pub membership: Vec<u8>,
|
||||
/// Registry entries to disseminate.
|
||||
pub registry: Vec<RegistryEntry>,
|
||||
/// Node metadata entries (relay URLs, etc.) to disseminate.
|
||||
#[serde(default)]
|
||||
pub node_metadata: Vec<NodeMetadataEntry>,
|
||||
}
|
||||
|
||||
// ─── Events ─────────────────────────────────────────────────────────────────
|
||||
|
|
@ -367,20 +371,30 @@ fn lww_wins(incoming: &RegistryEntry, existing: &RegistryEntry) -> bool {
|
|||
|
||||
// ─── Piggyback pack/unpack ──────────────────────────────────────────────────
|
||||
|
||||
/// Combine membership piggyback bytes and registry entries into a single payload.
|
||||
pub fn pack_combined_piggyback(membership: Vec<u8>, registry: Vec<RegistryEntry>) -> Vec<u8> {
|
||||
let payload = PiggybackPayload { membership, registry };
|
||||
/// Combine membership piggyback bytes, registry entries, and node metadata into a single payload.
|
||||
pub fn pack_combined_piggyback(
|
||||
membership: Vec<u8>,
|
||||
registry: Vec<RegistryEntry>,
|
||||
node_metadata: Vec<NodeMetadataEntry>,
|
||||
) -> Vec<u8> {
|
||||
let payload = PiggybackPayload {
|
||||
membership,
|
||||
registry,
|
||||
node_metadata,
|
||||
};
|
||||
serde_json::to_vec(&payload).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Split a combined piggyback payload into membership bytes and registry entries.
|
||||
/// Split a combined piggyback payload into membership bytes, registry entries, and node metadata.
|
||||
/// If deserialization fails, treats the entire blob as membership bytes (backwards compat).
|
||||
pub fn unpack_combined_piggyback(bytes: &[u8]) -> (Vec<u8>, Vec<RegistryEntry>) {
|
||||
pub fn unpack_combined_piggyback(
|
||||
bytes: &[u8],
|
||||
) -> (Vec<u8>, Vec<RegistryEntry>, Vec<NodeMetadataEntry>) {
|
||||
if bytes.is_empty() {
|
||||
return (Vec::new(), Vec::new());
|
||||
return (Vec::new(), Vec::new(), Vec::new());
|
||||
}
|
||||
match serde_json::from_slice::<PiggybackPayload>(bytes) {
|
||||
Ok(payload) => (payload.membership, payload.registry),
|
||||
Err(_) => (bytes.to_vec(), Vec::new()),
|
||||
Ok(payload) => (payload.membership, payload.registry, payload.node_metadata),
|
||||
Err(_) => (bytes.to_vec(), Vec::new(), Vec::new()),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,12 @@ pub struct MemberInfo {
|
|||
pub addr: Option<String>,
|
||||
pub state: String,
|
||||
pub incarnation: u64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub is_authorized: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub label: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub relay_url: Option<String>,
|
||||
}
|
||||
|
||||
/// Snapshot of a node in the Kademlia routing table.
|
||||
|
|
@ -89,6 +95,26 @@ pub struct DistributionNodeSnapshot {
|
|||
// ─── Gossip pairs ────────────────────────────────────────────────
|
||||
/// Recent SWIM probe targets (most recent last).
|
||||
pub recent_probe_targets: Vec<String>,
|
||||
|
||||
// ─── Peer auth ──────────────────────────────────────────────────
|
||||
/// "open" or "allow-list".
|
||||
#[serde(default)]
|
||||
pub peer_auth_mode: String,
|
||||
/// Number of authorized peers (None if open mode).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub authorized_peer_count: Option<usize>,
|
||||
|
||||
/// Human-readable node name (e.g. "swift-falcon").
|
||||
#[serde(skip_serializing_if = "Option::is_none", default)]
|
||||
pub node_name: Option<String>,
|
||||
|
||||
/// Base58-encoded invite code for this node.
|
||||
#[serde(skip_serializing_if = "Option::is_none", default)]
|
||||
pub invite_code: Option<String>,
|
||||
|
||||
/// This node's relay URL, if running an embedded relay server.
|
||||
#[serde(skip_serializing_if = "Option::is_none", default)]
|
||||
pub relay_url: Option<String>,
|
||||
}
|
||||
|
||||
fn node_id_hex(id: &NodeId) -> String {
|
||||
|
|
@ -117,6 +143,9 @@ impl DistributedNode {
|
|||
addr: None,
|
||||
state: state_str(m.state),
|
||||
incarnation: m.incarnation,
|
||||
is_authorized: None,
|
||||
label: None,
|
||||
relay_url: self.metadata().relay_url(&m.node_id).map(String::from),
|
||||
})
|
||||
.collect();
|
||||
|
||||
|
|
@ -179,6 +208,11 @@ impl DistributedNode {
|
|||
registry_tombstones: registry.tombstone_count(),
|
||||
registry_entries,
|
||||
recent_probe_targets: recent_targets,
|
||||
peer_auth_mode: "open".into(),
|
||||
authorized_peer_count: None,
|
||||
node_name: None,
|
||||
invite_code: None,
|
||||
relay_url: self.metadata().relay_url(&self.node_id()).map(String::from),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
230
crates/distribution/tests/common/iroh.rs
Normal file
230
crates/distribution/tests/common/iroh.rs
Normal file
|
|
@ -0,0 +1,230 @@
|
|||
//! Test helpers for iroh-based integration tests.
|
||||
//!
|
||||
//! Provides `IrohTestCluster` — an N-node harness that owns real iroh
|
||||
//! endpoints with `RelayMode::Disabled`, connected via direct addresses.
|
||||
#![allow(dead_code)]
|
||||
|
||||
use std::ops::{Index, IndexMut};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use distribution::iroh_driver::{IrohDriver, IrohDriverConfig};
|
||||
use distribution::peer_auth::PeerAllowList;
|
||||
use iroh::{PublicKey, RelayMode};
|
||||
|
||||
use super::test_config;
|
||||
|
||||
// ─── Single-driver helpers ──────────────────────────────────────────────
|
||||
|
||||
pub fn make_driver() -> IrohDriver {
|
||||
IrohDriver::new(IrohDriverConfig {
|
||||
secret_key: None,
|
||||
relay_mode: RelayMode::Disabled,
|
||||
node: test_config(),
|
||||
peer_auth: None,
|
||||
#[cfg(feature = "relay")]
|
||||
embedded_relay_bind: None,
|
||||
#[cfg(feature = "relay")]
|
||||
relay_public_ip: None,
|
||||
})
|
||||
.expect("failed to create iroh driver")
|
||||
}
|
||||
|
||||
pub fn make_driver_with_auth(auth: Arc<Mutex<PeerAllowList>>) -> IrohDriver {
|
||||
IrohDriver::new(IrohDriverConfig {
|
||||
secret_key: None,
|
||||
relay_mode: RelayMode::Disabled,
|
||||
node: test_config(),
|
||||
peer_auth: Some(auth),
|
||||
#[cfg(feature = "relay")]
|
||||
embedded_relay_bind: None,
|
||||
#[cfg(feature = "relay")]
|
||||
relay_public_ip: None,
|
||||
})
|
||||
.expect("failed to create iroh driver")
|
||||
}
|
||||
|
||||
pub fn make_driver_with_relay(relay_url: iroh::RelayUrl) -> IrohDriver {
|
||||
IrohDriver::new(IrohDriverConfig {
|
||||
secret_key: None,
|
||||
relay_mode: RelayMode::Custom(relay_url.into()),
|
||||
node: test_config(),
|
||||
peer_auth: None,
|
||||
#[cfg(feature = "relay")]
|
||||
embedded_relay_bind: None,
|
||||
#[cfg(feature = "relay")]
|
||||
relay_public_ip: None,
|
||||
})
|
||||
.expect("failed to create iroh driver with relay")
|
||||
}
|
||||
|
||||
/// Pump one driver: recv + tick.
|
||||
pub fn pump_one(driver: &mut IrohDriver) {
|
||||
driver.recv();
|
||||
driver.tick();
|
||||
}
|
||||
|
||||
/// Pump a slice of drivers: recv + tick on each.
|
||||
pub fn pump_all(drivers: &mut [IrohDriver]) {
|
||||
for d in drivers.iter_mut() {
|
||||
d.recv();
|
||||
d.tick();
|
||||
}
|
||||
}
|
||||
|
||||
/// Pump two drivers until a condition is met or timeout expires.
|
||||
pub fn pump_until_pair(
|
||||
a: &mut IrohDriver,
|
||||
b: &mut IrohDriver,
|
||||
timeout: Duration,
|
||||
check_fn: fn(&IrohDriver, &IrohDriver) -> bool,
|
||||
) -> bool {
|
||||
let start = Instant::now();
|
||||
while start.elapsed() < timeout {
|
||||
pump_one(a);
|
||||
pump_one(b);
|
||||
if check_fn(a, b) {
|
||||
return true;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Pump N drivers until a condition is met or timeout expires.
|
||||
pub fn pump_until<F>(
|
||||
drivers: &mut [IrohDriver],
|
||||
timeout: Duration,
|
||||
check_fn: F,
|
||||
) -> bool
|
||||
where
|
||||
F: Fn(&[IrohDriver]) -> bool,
|
||||
{
|
||||
let start = Instant::now();
|
||||
while start.elapsed() < timeout {
|
||||
pump_all(drivers);
|
||||
if check_fn(drivers) {
|
||||
return true;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Check whether `driver` sees `peer_key` as alive.
|
||||
pub fn sees_alive(driver: &IrohDriver, peer_key: &PublicKey) -> bool {
|
||||
let snap = driver.snapshot();
|
||||
let peer_hex: String = peer_key
|
||||
.as_bytes()
|
||||
.iter()
|
||||
.map(|b| format!("{:02x}", b))
|
||||
.collect();
|
||||
snap.members
|
||||
.iter()
|
||||
.any(|m| m.node_id == peer_hex && m.state == "alive")
|
||||
}
|
||||
|
||||
// ─── Local relay ────────────────────────────────────────────────────────
|
||||
|
||||
/// Guard that keeps the relay server alive while it exists.
|
||||
pub struct RelayGuard {
|
||||
_server: iroh_relay::server::Server,
|
||||
_rt: tokio::runtime::Runtime,
|
||||
}
|
||||
|
||||
/// Spawn a local HTTP relay server for tests. Returns the relay URL and a
|
||||
/// guard that shuts the server down on drop.
|
||||
pub fn spawn_test_relay() -> (iroh::RelayUrl, RelayGuard) {
|
||||
let rt = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap();
|
||||
let server = rt.block_on(async {
|
||||
iroh_relay::server::Server::spawn(iroh_relay::server::ServerConfig::<(), ()> {
|
||||
relay: Some(iroh_relay::server::RelayConfig {
|
||||
http_bind_addr: (std::net::Ipv4Addr::LOCALHOST, 0).into(),
|
||||
tls: None,
|
||||
limits: Default::default(),
|
||||
key_cache_capacity: Some(256),
|
||||
access: iroh_relay::server::AccessConfig::Everyone,
|
||||
}),
|
||||
quic: None,
|
||||
metrics_addr: None,
|
||||
})
|
||||
.await
|
||||
})
|
||||
.unwrap();
|
||||
let url = server.http_url().expect("relay has no HTTP URL");
|
||||
(
|
||||
url,
|
||||
RelayGuard {
|
||||
_server: server,
|
||||
_rt: rt,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// ─── N-node cluster ─────────────────────────────────────────────────────
|
||||
|
||||
/// An N-node iroh test cluster with real QUIC endpoints.
|
||||
pub struct IrohTestCluster {
|
||||
drivers: Vec<IrohDriver>,
|
||||
}
|
||||
|
||||
impl IrohTestCluster {
|
||||
/// Create N disconnected drivers (no joins).
|
||||
pub fn disconnected(n: usize) -> Self {
|
||||
let drivers = (0..n).map(|_| make_driver()).collect();
|
||||
Self { drivers }
|
||||
}
|
||||
|
||||
/// Create N drivers connected in a star topology through node 0.
|
||||
/// Nodes 1..N join node 0 using its full `EndpointAddr`.
|
||||
pub fn star(n: usize) -> Self {
|
||||
assert!(n >= 2, "star cluster requires at least 2 nodes");
|
||||
let mut drivers: Vec<IrohDriver> = (0..n).map(|_| make_driver()).collect();
|
||||
|
||||
let addr_0 = drivers[0].endpoint_addr();
|
||||
for i in 1..n {
|
||||
drivers[i].join(&[addr_0.clone()]);
|
||||
}
|
||||
|
||||
Self { drivers }
|
||||
}
|
||||
|
||||
/// Pump all drivers until a condition is met or timeout expires.
|
||||
pub fn pump_until<F>(&mut self, timeout: Duration, check_fn: F) -> bool
|
||||
where
|
||||
F: Fn(&[IrohDriver]) -> bool,
|
||||
{
|
||||
let start = Instant::now();
|
||||
while start.elapsed() < timeout {
|
||||
pump_all(&mut self.drivers);
|
||||
if check_fn(&self.drivers) {
|
||||
return true;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Shut down all drivers.
|
||||
pub fn shutdown(&mut self) {
|
||||
for d in &mut self.drivers {
|
||||
d.shutdown();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Index<usize> for IrohTestCluster {
|
||||
type Output = IrohDriver;
|
||||
fn index(&self, idx: usize) -> &Self::Output {
|
||||
&self.drivers[idx]
|
||||
}
|
||||
}
|
||||
|
||||
impl IndexMut<usize> for IrohTestCluster {
|
||||
fn index_mut(&mut self, idx: usize) -> &mut Self::Output {
|
||||
&mut self.drivers[idx]
|
||||
}
|
||||
}
|
||||
|
|
@ -25,6 +25,7 @@ pub fn test_config() -> DistributedNodeConfig {
|
|||
cache_capacity: 100,
|
||||
republish_interval: 50,
|
||||
registry: RegistryConfig::default(),
|
||||
metadata_lambda: 3,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -206,3 +207,6 @@ impl IndexMut<usize> for TestCluster {
|
|||
&mut self.nodes[idx]
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "iroh")]
|
||||
pub mod iroh;
|
||||
|
|
|
|||
|
|
@ -4,45 +4,27 @@
|
|||
//! - Create endpoints with matching identities
|
||||
//! - Form clusters via join
|
||||
//! - Detect membership changes through SWIM
|
||||
//! - Reject unauthorized peers
|
||||
//!
|
||||
//! Requires the `iroh` feature.
|
||||
|
||||
#![cfg(feature = "iroh")]
|
||||
|
||||
use distribution::iroh_driver::{IrohDriver, IrohDriverConfig};
|
||||
use distribution::node::DistributedNodeConfig;
|
||||
use distribution::registry::RegistryConfig;
|
||||
use distribution::swim::probe::SwimConfig;
|
||||
use iroh::RelayMode;
|
||||
mod common;
|
||||
|
||||
fn test_config() -> DistributedNodeConfig {
|
||||
DistributedNodeConfig {
|
||||
swim: SwimConfig {
|
||||
probe_interval: 1,
|
||||
probe_timeout: 3,
|
||||
indirect_probes: 1,
|
||||
suspicion_timeout: 5,
|
||||
dead_reprobe_interval: 0,
|
||||
},
|
||||
cache_capacity: 100,
|
||||
republish_interval: 50,
|
||||
registry: RegistryConfig::default(),
|
||||
}
|
||||
}
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
fn make_driver() -> IrohDriver {
|
||||
IrohDriver::new(IrohDriverConfig {
|
||||
secret_key: None,
|
||||
relay_mode: RelayMode::Disabled,
|
||||
node: test_config(),
|
||||
})
|
||||
.expect("failed to create iroh driver")
|
||||
}
|
||||
use common::iroh::*;
|
||||
use distribution::peer_auth::PeerAllowList;
|
||||
use iroh::PublicKey;
|
||||
|
||||
// ─── Identity tests ─────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn iroh_driver_creates_with_unique_identity() {
|
||||
let d1 = make_driver();
|
||||
let d2 = make_driver();
|
||||
let mut d1 = make_driver();
|
||||
let mut d2 = make_driver();
|
||||
assert_ne!(d1.node_id(), d2.node_id());
|
||||
d1.shutdown();
|
||||
d2.shutdown();
|
||||
|
|
@ -50,7 +32,7 @@ fn iroh_driver_creates_with_unique_identity() {
|
|||
|
||||
#[test]
|
||||
fn iroh_driver_snapshot_contains_node_id() {
|
||||
let driver = make_driver();
|
||||
let mut driver = make_driver();
|
||||
let snap = driver.snapshot();
|
||||
assert!(!snap.node_id.is_empty());
|
||||
assert_eq!(snap.members.len(), 0);
|
||||
|
|
@ -59,11 +41,147 @@ fn iroh_driver_snapshot_contains_node_id() {
|
|||
|
||||
#[test]
|
||||
fn iroh_driver_identity_matches_iroh_endpoint() {
|
||||
let driver = make_driver();
|
||||
let mut driver = make_driver();
|
||||
let node_id = driver.node_id();
|
||||
// The snapshot's node_id hex should match the NodeId bytes
|
||||
let snap = driver.snapshot();
|
||||
let expected_hex: String = node_id.0.iter().map(|b| format!("{:02x}", b)).collect();
|
||||
assert_eq!(snap.node_id, expected_hex);
|
||||
driver.shutdown();
|
||||
}
|
||||
|
||||
// ─── Join integration tests ─────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn two_nodes_form_cluster_via_join() {
|
||||
let mut node_a = make_driver();
|
||||
let mut node_b = make_driver();
|
||||
|
||||
let b_addr = node_b.endpoint_addr();
|
||||
node_a.join(&[b_addr]);
|
||||
|
||||
let converged = pump_until_pair(
|
||||
&mut node_a,
|
||||
&mut node_b,
|
||||
Duration::from_secs(5),
|
||||
|a, b| {
|
||||
let a_key = PublicKey::from_bytes(&a.node_id().0).unwrap();
|
||||
let b_key = PublicKey::from_bytes(&b.node_id().0).unwrap();
|
||||
sees_alive(a, &b_key) && sees_alive(b, &a_key)
|
||||
},
|
||||
);
|
||||
|
||||
assert!(converged, "nodes did not converge within timeout");
|
||||
assert_eq!(node_a.snapshot().alive_count, 1, "node_a should see 1 alive peer");
|
||||
assert_eq!(node_b.snapshot().alive_count, 1, "node_b should see 1 alive peer");
|
||||
|
||||
node_a.shutdown();
|
||||
node_b.shutdown();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_nodes_form_cluster_via_mutual_join() {
|
||||
let mut node_a = make_driver();
|
||||
let mut node_b = make_driver();
|
||||
|
||||
let b_addr = node_b.endpoint_addr();
|
||||
let a_addr = node_a.endpoint_addr();
|
||||
|
||||
node_a.join(&[b_addr]);
|
||||
node_b.join(&[a_addr]);
|
||||
|
||||
let converged = pump_until_pair(
|
||||
&mut node_a,
|
||||
&mut node_b,
|
||||
Duration::from_secs(5),
|
||||
|a, b| {
|
||||
let a_key = PublicKey::from_bytes(&a.node_id().0).unwrap();
|
||||
let b_key = PublicKey::from_bytes(&b.node_id().0).unwrap();
|
||||
sees_alive(a, &b_key) && sees_alive(b, &a_key)
|
||||
},
|
||||
);
|
||||
|
||||
assert!(converged, "nodes did not converge within timeout (mutual join)");
|
||||
assert_eq!(node_a.snapshot().alive_count, 1);
|
||||
assert_eq!(node_b.snapshot().alive_count, 1);
|
||||
|
||||
node_a.shutdown();
|
||||
node_b.shutdown();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_nodes_form_cluster_with_peer_auth() {
|
||||
let auth_a = Arc::new(Mutex::new(PeerAllowList::open()));
|
||||
let auth_b = Arc::new(Mutex::new(PeerAllowList::open()));
|
||||
|
||||
let mut node_a = make_driver_with_auth(auth_a.clone());
|
||||
let mut node_b = make_driver_with_auth(auth_b.clone());
|
||||
|
||||
let a_id = node_a.node_id();
|
||||
let b_id = node_b.node_id();
|
||||
let b_addr = node_b.endpoint_addr();
|
||||
|
||||
// Switch to restrictive mode by adding each other
|
||||
auth_a.lock().unwrap().add_peer(b_id, "node-b".into());
|
||||
auth_b.lock().unwrap().add_peer(a_id, "node-a".into());
|
||||
|
||||
node_a.join(&[b_addr]);
|
||||
|
||||
let converged = pump_until_pair(
|
||||
&mut node_a,
|
||||
&mut node_b,
|
||||
Duration::from_secs(5),
|
||||
|a, b| {
|
||||
let a_key = PublicKey::from_bytes(&a.node_id().0).unwrap();
|
||||
let b_key = PublicKey::from_bytes(&b.node_id().0).unwrap();
|
||||
sees_alive(a, &b_key) && sees_alive(b, &a_key)
|
||||
},
|
||||
);
|
||||
|
||||
assert!(converged, "nodes with peer auth did not converge within timeout");
|
||||
assert_eq!(node_a.snapshot().alive_count, 1);
|
||||
assert_eq!(node_b.snapshot().alive_count, 1);
|
||||
|
||||
node_a.shutdown();
|
||||
node_b.shutdown();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn peer_auth_prevents_unauthorized_join() {
|
||||
let mut node_a = make_driver();
|
||||
|
||||
// Node B has auth with only a dummy peer — node_a is NOT authorized
|
||||
let auth_b = Arc::new(Mutex::new(PeerAllowList::open()));
|
||||
let dummy_id = distribution::types::NodeId([0xAA; 32]);
|
||||
auth_b.lock().unwrap().add_peer(dummy_id, "dummy".into());
|
||||
let mut node_b = make_driver_with_auth(auth_b);
|
||||
|
||||
let b_addr = node_b.endpoint_addr();
|
||||
node_a.join(&[b_addr]);
|
||||
|
||||
let converged = pump_until_pair(
|
||||
&mut node_a,
|
||||
&mut node_b,
|
||||
Duration::from_secs(3),
|
||||
|_a, b| b.snapshot().alive_count > 0,
|
||||
);
|
||||
|
||||
assert!(!converged, "unauthorized peer should NOT have joined");
|
||||
assert_eq!(node_b.snapshot().alive_count, 0, "node_b should have no alive peers");
|
||||
|
||||
node_a.shutdown();
|
||||
node_b.shutdown();
|
||||
}
|
||||
|
||||
// ─── Multi-node tests ───────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn three_nodes_converge_via_star_join() {
|
||||
let mut cluster = IrohTestCluster::star(3);
|
||||
|
||||
let converged = cluster.pump_until(Duration::from_secs(10), |drivers| {
|
||||
drivers.iter().all(|d| d.snapshot().alive_count == 2)
|
||||
});
|
||||
|
||||
assert!(converged, "3-node star did not converge");
|
||||
cluster.shutdown();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,18 +0,0 @@
|
|||
[package]
|
||||
name = "local-runner"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[[bin]]
|
||||
name = "local-runner"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
swactor = { path = "../..", features = ["serde"] }
|
||||
swactor-ci = { path = "../ci", features = ["local"] }
|
||||
runtime-dashboard = { path = "../runtime-dashboard", features = ["ci"] }
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
ctrlc = "3"
|
||||
iroh = "0.96"
|
||||
tokio = { version = "1", features = ["rt-multi-thread"] }
|
||||
serde_json = "1"
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
//! Datastore stats provider for the runtime dashboard.
|
||||
//!
|
||||
//! The trait returns a pre-serialized JSON string so that `runtime-dashboard`
|
||||
//! has no compile-time dependency on `swactor-datastore` (which would create a
|
||||
//! circular dependency since `swactor-datastore[node]` depends on us).
|
||||
//!
|
||||
//! The `swactor-datastore` crate implements this trait in its `node` feature.
|
||||
|
||||
/// Trait for providing datastore stats to the dashboard.
|
||||
///
|
||||
/// Implementations capture a point-in-time snapshot as serialized JSON.
|
||||
/// The dashboard polls this every ~200ms via SSE.
|
||||
pub trait DatastoreStatsProvider: Send + Sync {
|
||||
/// Return a JSON-serialized datastore snapshot, or `None` if unavailable.
|
||||
fn snapshot_json(&self) -> Option<String>;
|
||||
}
|
||||
|
|
@ -1,286 +0,0 @@
|
|||
pub const DATASTORE_HTML: &str = r##"<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Swactor Runtime – Datastore</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: 'Menlo', 'Consolas', 'Monaco', monospace; background: #0f1117; color: #e0e0e0; font-size: 13px; }
|
||||
|
||||
.header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 12px 20px; background: #161822; border-bottom: 1px solid #2a2d3e;
|
||||
}
|
||||
.header-left { display: flex; align-items: center; }
|
||||
.header h1 { font-size: 16px; font-weight: 600; color: #fff; }
|
||||
.status-dot {
|
||||
width: 10px; height: 10px; border-radius: 50%; background: #4caf50;
|
||||
display: inline-block; margin-left: 8px; vertical-align: middle;
|
||||
}
|
||||
.status-dot.disconnected { background: #f44336; }
|
||||
.status-dot.done { background: #ff9800; }
|
||||
|
||||
.nav-links { display: flex; gap: 4px; margin-left: 20px; }
|
||||
.nav-link {
|
||||
color: #888; text-decoration: none; font-size: 12px;
|
||||
padding: 4px 10px; border-radius: 3px; transition: color 0.2s;
|
||||
}
|
||||
.nav-link:hover { color: #e0e0e0; }
|
||||
.nav-link.active { color: #fff; background: #2a2d3e; }
|
||||
|
||||
.header-right { display: flex; align-items: center; gap: 12px; }
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px; padding: 12px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: #161822; border: 1px solid #2a2d3e; border-radius: 6px;
|
||||
padding: 14px; overflow: hidden;
|
||||
}
|
||||
.panel h2 { font-size: 12px; color: #888; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 10px; }
|
||||
|
||||
.full-width { grid-column: 1 / -1; }
|
||||
|
||||
.stats-cards {
|
||||
display: grid; grid-template-columns: repeat(5, 1fr); gap: 10px;
|
||||
}
|
||||
.stat-card {
|
||||
background: #1c1f2e; border-radius: 4px; padding: 10px; text-align: center;
|
||||
}
|
||||
.stat-card .value { font-size: 22px; font-weight: 700; color: #fff; }
|
||||
.stat-card .label { font-size: 10px; color: #888; text-transform: uppercase; margin-top: 2px; }
|
||||
|
||||
.event-timeline {
|
||||
max-height: 400px; overflow-y: auto;
|
||||
}
|
||||
.event-row {
|
||||
display: flex; gap: 8px; padding: 4px 0; border-bottom: 1px solid #1c1f2e;
|
||||
font-size: 11px; align-items: center;
|
||||
}
|
||||
.event-time { color: #555; min-width: 70px; }
|
||||
.event-kind {
|
||||
min-width: 48px; font-weight: 700; text-transform: uppercase; font-size: 10px;
|
||||
padding: 1px 6px; border-radius: 3px; text-align: center;
|
||||
}
|
||||
.event-kind.put { background: #1b3a2a; color: #4caf50; }
|
||||
.event-kind.get { background: #1a2a3e; color: #2196f3; }
|
||||
.event-kind.delete { background: #3a1a1a; color: #f44336; }
|
||||
.event-hash { color: #aaa; font-size: 10px; min-width: 120px; }
|
||||
.event-name { color: #e0e0e0; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.event-size { color: #888; min-width: 70px; text-align: right; }
|
||||
|
||||
.objects-table { max-height: 400px; overflow-y: auto; }
|
||||
.objects-table table { width: 100%; border-collapse: collapse; }
|
||||
.objects-table th, .objects-table td {
|
||||
padding: 4px 8px; text-align: left; border-bottom: 1px solid #1c1f2e; font-size: 11px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.objects-table th { color: #888; font-weight: 500; position: sticky; top: 0; background: #161822; }
|
||||
|
||||
.transfers-section { display: none; }
|
||||
.transfers-section.visible { display: block; }
|
||||
.transfer-row {
|
||||
display: flex; align-items: center; gap: 10px; margin-bottom: 8px;
|
||||
}
|
||||
.transfer-hash { color: #aaa; font-size: 10px; min-width: 100px; }
|
||||
.transfer-bar {
|
||||
flex: 1; height: 16px; background: #1c1f2e; border-radius: 3px; overflow: hidden;
|
||||
}
|
||||
.transfer-fill {
|
||||
height: 100%; background: #6366f1; border-radius: 3px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
.transfer-label { color: #888; font-size: 11px; min-width: 80px; text-align: right; }
|
||||
|
||||
::-webkit-scrollbar { width: 6px; }
|
||||
::-webkit-scrollbar-track { background: #0f1117; }
|
||||
::-webkit-scrollbar-thumb { background: #2a2d3e; border-radius: 3px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<div class="header-left">
|
||||
<h1>
|
||||
Swactor Runtime Dashboard
|
||||
<span id="statusDot" class="status-dot disconnected"></span>
|
||||
</h1>
|
||||
<nav class="nav-links">
|
||||
<a href="/" class="nav-link">Overview</a>
|
||||
<a href="/actors" class="nav-link">Actors</a>
|
||||
<a href="/distribution" class="nav-link">Distribution</a>
|
||||
<a href="/datastore" class="nav-link active">Datastore</a>
|
||||
</nav>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<span id="nodeLabel" style="color:#888;font-size:12px;">Waiting for data...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid">
|
||||
<!-- Stat cards -->
|
||||
<div class="panel full-width">
|
||||
<h2>Datastore Stats</h2>
|
||||
<div class="stats-cards">
|
||||
<div class="stat-card"><div class="value" id="statObjects">0</div><div class="label">Objects</div></div>
|
||||
<div class="stat-card"><div class="value" id="statSize">0</div><div class="label">Total Size</div></div>
|
||||
<div class="stat-card"><div class="value" id="statPuts">0</div><div class="label">Puts</div></div>
|
||||
<div class="stat-card"><div class="value" id="statGets">0</div><div class="label">Gets</div></div>
|
||||
<div class="stat-card"><div class="value" id="statDeletes">0</div><div class="label">Deletes</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Event timeline -->
|
||||
<div class="panel">
|
||||
<h2>Event Timeline <span id="eventCount" style="color:#555;font-weight:400;"></span></h2>
|
||||
<div class="event-timeline" id="eventTimeline"></div>
|
||||
</div>
|
||||
|
||||
<!-- Objects table -->
|
||||
<div class="panel">
|
||||
<h2>Objects <span id="objectCount" style="color:#555;font-weight:400;"></span></h2>
|
||||
<div class="objects-table">
|
||||
<table>
|
||||
<thead><tr><th>Hash</th><th>Name</th><th>Size</th></tr></thead>
|
||||
<tbody id="objectsBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Transfers -->
|
||||
<div class="panel full-width transfers-section" id="transfersPanel">
|
||||
<h2>Active Transfers</h2>
|
||||
<div id="transfersList"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
var DASHBOARD_MODE = '__DASHBOARD_MODE__';
|
||||
var dot = document.getElementById('statusDot');
|
||||
|
||||
function formatBytes(b) {
|
||||
if (b === 0) return '0 B';
|
||||
var units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
var i = Math.floor(Math.log(b) / Math.log(1024));
|
||||
if (i >= units.length) i = units.length - 1;
|
||||
return (b / Math.pow(1024, i)).toFixed(i > 0 ? 1 : 0) + ' ' + units[i];
|
||||
}
|
||||
|
||||
function formatTime(ms) {
|
||||
var d = new Date(ms);
|
||||
return ('0' + d.getHours()).slice(-2) + ':' +
|
||||
('0' + d.getMinutes()).slice(-2) + ':' +
|
||||
('0' + d.getSeconds()).slice(-2);
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
if (!s) return '';
|
||||
return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
}
|
||||
|
||||
function updateFromSnapshot(snap) {
|
||||
// Node label
|
||||
if (snap.node_id) {
|
||||
document.getElementById('nodeLabel').textContent = 'Node: ' + snap.node_id.substring(0, 16) + '\u2026';
|
||||
}
|
||||
|
||||
// Stat cards
|
||||
document.getElementById('statObjects').textContent = snap.object_count;
|
||||
document.getElementById('statSize').textContent = formatBytes(snap.total_bytes);
|
||||
document.getElementById('statPuts').textContent = snap.put_ops;
|
||||
document.getElementById('statGets').textContent = snap.get_ops;
|
||||
document.getElementById('statDeletes').textContent = snap.delete_ops;
|
||||
|
||||
// Event timeline
|
||||
var timeline = document.getElementById('eventTimeline');
|
||||
var wasAtBottom = timeline.scrollTop + timeline.clientHeight >= timeline.scrollHeight - 20;
|
||||
timeline.innerHTML = '';
|
||||
document.getElementById('eventCount').textContent = '(' + snap.recent_events.length + ')';
|
||||
|
||||
for (var i = snap.recent_events.length - 1; i >= 0; i--) {
|
||||
var ev = snap.recent_events[i];
|
||||
var row = document.createElement('div');
|
||||
row.className = 'event-row';
|
||||
row.innerHTML =
|
||||
'<span class="event-time">' + formatTime(ev.timestamp_ms) + '</span>' +
|
||||
'<span class="event-kind ' + ev.kind + '">' + ev.kind + '</span>' +
|
||||
'<span class="event-hash">' + ev.hash.substring(0, 16) + '\u2026</span>' +
|
||||
'<span class="event-name">' + escapeHtml(ev.name || '') + '</span>' +
|
||||
'<span class="event-size">' + (ev.size_bytes > 0 ? formatBytes(ev.size_bytes) : '') + '</span>';
|
||||
timeline.appendChild(row);
|
||||
}
|
||||
|
||||
if (wasAtBottom) {
|
||||
timeline.scrollTop = timeline.scrollHeight;
|
||||
}
|
||||
|
||||
// Objects table
|
||||
var tbody = document.getElementById('objectsBody');
|
||||
tbody.innerHTML = '';
|
||||
document.getElementById('objectCount').textContent = '(' + snap.objects.length + ')';
|
||||
|
||||
for (var i = 0; i < snap.objects.length; i++) {
|
||||
var obj = snap.objects[i];
|
||||
var tr = document.createElement('tr');
|
||||
tr.innerHTML =
|
||||
'<td style="color:#aaa;font-size:10px;">' + obj.hash.substring(0, 16) + '\u2026</td>' +
|
||||
'<td>' + escapeHtml(obj.name || '\u2014') + '</td>' +
|
||||
'<td style="color:#888;">' + formatBytes(obj.size_bytes) + '</td>';
|
||||
tbody.appendChild(tr);
|
||||
}
|
||||
|
||||
// Transfers
|
||||
var panel = document.getElementById('transfersPanel');
|
||||
var list = document.getElementById('transfersList');
|
||||
|
||||
if (snap.active_transfers.length === 0) {
|
||||
panel.className = 'panel full-width transfers-section';
|
||||
} else {
|
||||
panel.className = 'panel full-width transfers-section visible';
|
||||
list.innerHTML = '';
|
||||
|
||||
for (var i = 0; i < snap.active_transfers.length; i++) {
|
||||
var t = snap.active_transfers[i];
|
||||
var pct = t.chunks_total > 0 ? Math.round((t.chunks_received / t.chunks_total) * 100) : 0;
|
||||
var row = document.createElement('div');
|
||||
row.className = 'transfer-row';
|
||||
row.innerHTML =
|
||||
'<span class="transfer-hash">' + t.hash.substring(0, 16) + '\u2026</span>' +
|
||||
'<div class="transfer-bar"><div class="transfer-fill" style="width:' + pct + '%"></div></div>' +
|
||||
'<span class="transfer-label">' + t.chunks_received + ' / ' + t.chunks_total + '</span>';
|
||||
list.appendChild(row);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SSE connection
|
||||
var es = new EventSource('/events');
|
||||
|
||||
es.addEventListener('datastore', function(e) {
|
||||
try {
|
||||
var snap = JSON.parse(e.data);
|
||||
updateFromSnapshot(snap);
|
||||
} catch(err) { console.error('datastore parse error', err); }
|
||||
});
|
||||
|
||||
es.addEventListener('done', function() {
|
||||
dot.className = 'status-dot done';
|
||||
es.close();
|
||||
});
|
||||
|
||||
es.onerror = function() {
|
||||
dot.className = 'status-dot disconnected';
|
||||
};
|
||||
|
||||
es.onopen = function() {
|
||||
dot.className = 'status-dot';
|
||||
};
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"##;
|
||||
|
|
@ -1,727 +0,0 @@
|
|||
use std::io::{self, Read as IoRead};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{mpsc, Arc, Mutex};
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use swactor::runtime::Runtime;
|
||||
|
||||
use crate::actor_detail_html::ACTOR_DETAIL_HTML;
|
||||
use crate::actors_html::ACTORS_HTML;
|
||||
use crate::collector::StatsCollector;
|
||||
use crate::dashboard_html::DASHBOARD_HTML;
|
||||
use crate::history::DashboardHistory;
|
||||
use crate::layer::EventStore;
|
||||
use crate::topology;
|
||||
use crate::topology_html::TOPOLOGY_HTML;
|
||||
use crate::trace::RuntimeTrace;
|
||||
use crate::warnings::{WarningConfig, WarningDetector};
|
||||
|
||||
#[cfg(feature = "distribution")]
|
||||
use crate::distribution_collector::DistributionStatsProvider;
|
||||
#[cfg(feature = "distribution")]
|
||||
use crate::distribution_html::DISTRIBUTION_HTML;
|
||||
|
||||
use crate::datastore_collector::DatastoreStatsProvider;
|
||||
use crate::datastore_html::DATASTORE_HTML;
|
||||
|
||||
#[cfg(feature = "ci")]
|
||||
use crate::ci_collector::CiStatsProvider;
|
||||
|
||||
/// Format a server-sent event.
|
||||
fn format_sse(event: &str, data: &str) -> Vec<u8> {
|
||||
format!("event: {event}\ndata: {data}\n\n").into_bytes()
|
||||
}
|
||||
|
||||
/// Adapts an `mpsc::Receiver<Vec<u8>>` to `std::io::Read` for tiny_http streaming.
|
||||
struct ChannelReader {
|
||||
rx: mpsc::Receiver<Vec<u8>>,
|
||||
buf: Vec<u8>,
|
||||
pos: usize,
|
||||
}
|
||||
|
||||
impl ChannelReader {
|
||||
fn new(rx: mpsc::Receiver<Vec<u8>>) -> Self {
|
||||
Self {
|
||||
rx,
|
||||
buf: Vec::new(),
|
||||
pos: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IoRead for ChannelReader {
|
||||
fn read(&mut self, out: &mut [u8]) -> io::Result<usize> {
|
||||
// Drain current buffer first.
|
||||
if self.pos < self.buf.len() {
|
||||
let n = std::cmp::min(out.len(), self.buf.len() - self.pos);
|
||||
out[..n].copy_from_slice(&self.buf[self.pos..self.pos + n]);
|
||||
self.pos += n;
|
||||
return Ok(n);
|
||||
}
|
||||
|
||||
// Wait for next chunk.
|
||||
match self.rx.recv() {
|
||||
Ok(data) => {
|
||||
if data.is_empty() {
|
||||
return Ok(0); // EOF signal
|
||||
}
|
||||
let n = std::cmp::min(out.len(), data.len());
|
||||
out[..n].copy_from_slice(&data[..n]);
|
||||
if n < data.len() {
|
||||
self.buf = data;
|
||||
self.pos = n;
|
||||
} else {
|
||||
self.buf.clear();
|
||||
self.pos = 0;
|
||||
}
|
||||
Ok(n)
|
||||
}
|
||||
Err(_) => Ok(0), // channel closed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn make_sse_response(
|
||||
rx: mpsc::Receiver<Vec<u8>>,
|
||||
) -> tiny_http::Response<Box<dyn IoRead + Send>> {
|
||||
let reader = ChannelReader::new(rx);
|
||||
tiny_http::Response::new(
|
||||
tiny_http::StatusCode(200),
|
||||
vec![
|
||||
"Content-Type: text/event-stream"
|
||||
.parse::<tiny_http::Header>()
|
||||
.unwrap(),
|
||||
"Cache-Control: no-cache"
|
||||
.parse::<tiny_http::Header>()
|
||||
.unwrap(),
|
||||
"Connection: keep-alive"
|
||||
.parse::<tiny_http::Header>()
|
||||
.unwrap(),
|
||||
],
|
||||
Box::new(reader) as Box<dyn IoRead + Send>,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
fn respond_html(request: tiny_http::Request, html_template: &str, mode: &str) {
|
||||
let html = html_template.replace("__DASHBOARD_MODE__", mode);
|
||||
let response = tiny_http::Response::from_string(html).with_header(
|
||||
"Content-Type: text/html; charset=utf-8"
|
||||
.parse::<tiny_http::Header>()
|
||||
.unwrap(),
|
||||
);
|
||||
let _ = request.respond(response);
|
||||
}
|
||||
|
||||
fn respond_404(request: tiny_http::Request) {
|
||||
let response = tiny_http::Response::from_string("Not Found").with_status_code(404);
|
||||
let _ = request.respond(response);
|
||||
}
|
||||
|
||||
// ── Live server ─────────────────────────────────────────────────────────
|
||||
|
||||
/// Start the live HTTP server with a pool of handler threads.
|
||||
pub(crate) fn spawn_http_server(
|
||||
store: Arc<EventStore>,
|
||||
runtime: Arc<Mutex<Option<Arc<Runtime>>>>,
|
||||
collector: Arc<Mutex<Option<Arc<StatsCollector>>>>,
|
||||
shutdown: Arc<AtomicBool>,
|
||||
history: Arc<DashboardHistory>,
|
||||
port: u16,
|
||||
#[cfg(feature = "distribution")]
|
||||
distribution: Arc<Mutex<Option<Arc<dyn DistributionStatsProvider>>>>,
|
||||
datastore: Arc<Mutex<Option<Arc<dyn DatastoreStatsProvider>>>>,
|
||||
#[cfg(feature = "ci")]
|
||||
ci: Arc<Mutex<Option<Arc<dyn CiStatsProvider>>>>,
|
||||
) {
|
||||
let addr = format!("0.0.0.0:{port}");
|
||||
let server = tiny_http::Server::http(&addr).expect("failed to bind HTTP server");
|
||||
let server = Arc::new(server);
|
||||
let cmd_router = Arc::new(crate::command::CommandRouter::with_builtins());
|
||||
|
||||
for _ in 0..4 {
|
||||
let server = Arc::clone(&server);
|
||||
let store = Arc::clone(&store);
|
||||
let runtime = Arc::clone(&runtime);
|
||||
let collector = Arc::clone(&collector);
|
||||
let shutdown = Arc::clone(&shutdown);
|
||||
let history = Arc::clone(&history);
|
||||
let cmd_router = Arc::clone(&cmd_router);
|
||||
#[cfg(feature = "distribution")]
|
||||
let distribution = Arc::clone(&distribution);
|
||||
let datastore = Arc::clone(&datastore);
|
||||
#[cfg(feature = "ci")]
|
||||
let ci = Arc::clone(&ci);
|
||||
thread::spawn(move || {
|
||||
loop {
|
||||
let request = match server.recv() {
|
||||
Ok(r) => r,
|
||||
Err(_) => break,
|
||||
};
|
||||
|
||||
let url = request.url().to_string();
|
||||
let path = url.split('?').next().unwrap_or(&url);
|
||||
match path {
|
||||
"/" => respond_html(request, DASHBOARD_HTML, "live"),
|
||||
"/actors" => respond_html(request, ACTORS_HTML, "live"),
|
||||
"/topology" => respond_html(request, TOPOLOGY_HTML, "live"),
|
||||
#[cfg(feature = "distribution")]
|
||||
"/distribution" => respond_html(request, DISTRIBUTION_HTML, "live"),
|
||||
"/datastore" => respond_html(request, DATASTORE_HTML, "live"),
|
||||
"/events" => {
|
||||
handle_live_sse(
|
||||
request,
|
||||
Arc::clone(&store),
|
||||
Arc::clone(&runtime),
|
||||
Arc::clone(&collector),
|
||||
Arc::clone(&shutdown),
|
||||
Arc::clone(&history),
|
||||
#[cfg(feature = "distribution")]
|
||||
Arc::clone(&distribution),
|
||||
Arc::clone(&datastore),
|
||||
#[cfg(feature = "ci")]
|
||||
Arc::clone(&ci),
|
||||
);
|
||||
}
|
||||
"/api/stats" => {
|
||||
handle_stats_api(
|
||||
request,
|
||||
Arc::clone(&runtime),
|
||||
Arc::clone(&collector),
|
||||
);
|
||||
}
|
||||
"/api/history" => {
|
||||
handle_history_api(request, Arc::clone(&history));
|
||||
}
|
||||
"/api/topology" => {
|
||||
handle_topology_api(
|
||||
request,
|
||||
Arc::clone(&runtime),
|
||||
Arc::clone(&collector),
|
||||
);
|
||||
}
|
||||
"/api/investigate" => {
|
||||
handle_investigate_api(
|
||||
request,
|
||||
&url,
|
||||
Arc::clone(&runtime),
|
||||
Arc::clone(&collector),
|
||||
Arc::clone(&cmd_router),
|
||||
);
|
||||
}
|
||||
#[cfg(feature = "distribution")]
|
||||
"/api/distribution" => {
|
||||
handle_distribution_api(
|
||||
request,
|
||||
Arc::clone(&distribution),
|
||||
);
|
||||
}
|
||||
"/api/datastore" => {
|
||||
handle_datastore_api(
|
||||
request,
|
||||
Arc::clone(&datastore),
|
||||
);
|
||||
}
|
||||
"/api/logs" => {
|
||||
handle_logs_api(request, &url, Arc::clone(&store));
|
||||
}
|
||||
#[cfg(feature = "ci")]
|
||||
_ if path.starts_with("/api/ci/") => {
|
||||
handle_ci_api(request, path, Arc::clone(&ci));
|
||||
}
|
||||
_ if path.starts_with("/actor/") => {
|
||||
let hex = &path[7..]; // strip "/actor/"
|
||||
respond_actor_detail(request, hex);
|
||||
}
|
||||
_ => respond_404(request),
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn respond_actor_detail(request: tiny_http::Request, hex_addr: &str) {
|
||||
let html = ACTOR_DETAIL_HTML
|
||||
.replace("__DASHBOARD_MODE__", "live")
|
||||
.replace("__ACTOR_ADDR__", hex_addr);
|
||||
let response = tiny_http::Response::from_string(html).with_header(
|
||||
"Content-Type: text/html; charset=utf-8"
|
||||
.parse::<tiny_http::Header>()
|
||||
.unwrap(),
|
||||
);
|
||||
let _ = request.respond(response);
|
||||
}
|
||||
|
||||
fn handle_live_sse(
|
||||
request: tiny_http::Request,
|
||||
store: Arc<EventStore>,
|
||||
runtime: Arc<Mutex<Option<Arc<Runtime>>>>,
|
||||
collector: Arc<Mutex<Option<Arc<StatsCollector>>>>,
|
||||
shutdown: Arc<AtomicBool>,
|
||||
history: Arc<DashboardHistory>,
|
||||
#[cfg(feature = "distribution")]
|
||||
distribution: Arc<Mutex<Option<Arc<dyn DistributionStatsProvider>>>>,
|
||||
datastore: Arc<Mutex<Option<Arc<dyn DatastoreStatsProvider>>>>,
|
||||
#[cfg(feature = "ci")]
|
||||
ci: Arc<Mutex<Option<Arc<dyn CiStatsProvider>>>>,
|
||||
) {
|
||||
let (tx, rx) = mpsc::channel::<Vec<u8>>();
|
||||
let response = make_sse_response(rx);
|
||||
|
||||
// Spawn producer thread
|
||||
thread::spawn(move || {
|
||||
let mut cursor: u64 = 0;
|
||||
let mut warning_detector = WarningDetector::new(WarningConfig::default());
|
||||
let mut tick_count: u64 = 0;
|
||||
|
||||
// Send initial history snapshot so sparklines render immediately
|
||||
if history.sample_count() > 0 {
|
||||
let json = history.worker_history_json();
|
||||
let _ = tx.send(format_sse("history", &json));
|
||||
}
|
||||
|
||||
loop {
|
||||
// Send stats if runtime is available
|
||||
{
|
||||
let maybe_rt = runtime.lock().unwrap().clone();
|
||||
if let Some(rt) = maybe_rt {
|
||||
let mut stats = rt.stats();
|
||||
if let Some(col) = collector.lock().unwrap().as_ref() {
|
||||
col.enrich(&mut stats);
|
||||
}
|
||||
history.record(&stats);
|
||||
|
||||
// Run warning detection
|
||||
let warnings = warning_detector.check(&stats);
|
||||
if !warnings.is_empty() {
|
||||
if let Ok(wjson) = serde_json::to_string(&warnings) {
|
||||
if tx.send(format_sse("warnings", &wjson)).is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let json = serde_json::to_string(&stats).unwrap();
|
||||
if tx.send(format_sse("stats", &json)).is_err() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Send topology every 5th tick (~1/sec)
|
||||
tick_count += 1;
|
||||
if tick_count % 5 == 0 {
|
||||
let topo = topology::worker_topology(&stats);
|
||||
if let Ok(tjson) = serde_json::to_string(&topo) {
|
||||
if tx.send(format_sse("topology", &tjson)).is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Send distribution snapshot if provider is attached
|
||||
#[cfg(feature = "distribution")]
|
||||
{
|
||||
let maybe_dist = distribution.lock().unwrap().clone();
|
||||
if let Some(provider) = maybe_dist {
|
||||
if let Some(snapshot) = provider.snapshot() {
|
||||
if let Ok(json) = serde_json::to_string(&snapshot) {
|
||||
if tx.send(format_sse("distribution", &json)).is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Send datastore snapshot if provider is attached
|
||||
{
|
||||
let maybe_ds = datastore.lock().unwrap().clone();
|
||||
if let Some(provider) = maybe_ds {
|
||||
if let Some(json) = provider.snapshot_json() {
|
||||
if tx.send(format_sse("datastore", &json)).is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Send CI snapshot if provider is attached
|
||||
#[cfg(feature = "ci")]
|
||||
{
|
||||
let maybe_ci = ci.lock().unwrap().clone();
|
||||
if let Some(provider) = maybe_ci {
|
||||
let snapshot = provider.snapshot();
|
||||
if let Ok(json) = serde_json::to_string(&snapshot) {
|
||||
if tx.send(format_sse("ci", &json)).is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Send new activity events
|
||||
let (batch, new_cursor) = store.read_from(cursor);
|
||||
if !batch.is_empty() {
|
||||
let json = serde_json::to_string(&batch).unwrap();
|
||||
if tx.send(format_sse("activity", &json)).is_err() {
|
||||
return;
|
||||
}
|
||||
cursor = new_cursor;
|
||||
}
|
||||
|
||||
if shutdown.load(Ordering::Relaxed) {
|
||||
let _ = tx.send(format_sse("done", "{}"));
|
||||
let _ = tx.send(Vec::new()); // EOF
|
||||
return;
|
||||
}
|
||||
|
||||
thread::sleep(Duration::from_millis(200));
|
||||
}
|
||||
});
|
||||
|
||||
// Blocks until connection closes
|
||||
let _ = request.respond(response);
|
||||
}
|
||||
|
||||
fn handle_stats_api(
|
||||
request: tiny_http::Request,
|
||||
runtime: Arc<Mutex<Option<Arc<Runtime>>>>,
|
||||
collector: Arc<Mutex<Option<Arc<StatsCollector>>>>,
|
||||
) {
|
||||
let maybe_rt = runtime.lock().unwrap().clone();
|
||||
let json = match maybe_rt {
|
||||
Some(rt) => {
|
||||
let mut stats = rt.stats();
|
||||
if let Some(col) = collector.lock().unwrap().as_ref() {
|
||||
col.enrich(&mut stats);
|
||||
}
|
||||
serde_json::to_string(&stats).unwrap()
|
||||
}
|
||||
None => "{}".to_string(),
|
||||
};
|
||||
let response = tiny_http::Response::from_string(json).with_header(
|
||||
"Content-Type: application/json"
|
||||
.parse::<tiny_http::Header>()
|
||||
.unwrap(),
|
||||
);
|
||||
let _ = request.respond(response);
|
||||
}
|
||||
|
||||
fn handle_investigate_api(
|
||||
request: tiny_http::Request,
|
||||
url: &str,
|
||||
runtime: Arc<Mutex<Option<Arc<Runtime>>>>,
|
||||
collector: Arc<Mutex<Option<Arc<StatsCollector>>>>,
|
||||
cmd_router: Arc<crate::command::CommandRouter>,
|
||||
) {
|
||||
let params = parse_query_string(url);
|
||||
|
||||
let maybe_rt = runtime.lock().unwrap().clone();
|
||||
let maybe_col = collector.lock().unwrap().clone();
|
||||
|
||||
let json = match (maybe_rt, maybe_col) {
|
||||
(Some(rt), Some(col)) => {
|
||||
let ctx = crate::command::CommandContext::with_enricher(rt, col);
|
||||
let req = crate::command::from_query_params(¶ms);
|
||||
cmd_router.dispatch(&req, &ctx).to_json_line()
|
||||
}
|
||||
(Some(rt), None) => {
|
||||
let ctx = crate::command::CommandContext::new(rt);
|
||||
let req = crate::command::from_query_params(¶ms);
|
||||
cmd_router.dispatch(&req, &ctx).to_json_line()
|
||||
}
|
||||
_ => {
|
||||
let cmd = params.get("cmd").map(|s| s.as_str()).unwrap_or("help");
|
||||
serde_json::json!({
|
||||
"ok": false,
|
||||
"command": cmd,
|
||||
"error": "runtime not attached yet"
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
};
|
||||
|
||||
let response = tiny_http::Response::from_string(json).with_header(
|
||||
"Content-Type: application/json"
|
||||
.parse::<tiny_http::Header>()
|
||||
.unwrap(),
|
||||
);
|
||||
let _ = request.respond(response);
|
||||
}
|
||||
|
||||
#[cfg(feature = "distribution")]
|
||||
fn handle_distribution_api(
|
||||
request: tiny_http::Request,
|
||||
distribution: Arc<Mutex<Option<Arc<dyn DistributionStatsProvider>>>>,
|
||||
) {
|
||||
let json = match distribution.lock().unwrap().as_ref() {
|
||||
Some(provider) => match provider.snapshot() {
|
||||
Some(snapshot) => serde_json::to_string(&snapshot).unwrap_or_else(|_| "{}".into()),
|
||||
None => "{}".to_string(),
|
||||
},
|
||||
None => serde_json::json!({
|
||||
"error": "distribution provider not attached"
|
||||
})
|
||||
.to_string(),
|
||||
};
|
||||
|
||||
let response = tiny_http::Response::from_string(json).with_header(
|
||||
"Content-Type: application/json"
|
||||
.parse::<tiny_http::Header>()
|
||||
.unwrap(),
|
||||
);
|
||||
let _ = request.respond(response);
|
||||
}
|
||||
|
||||
fn handle_datastore_api(
|
||||
request: tiny_http::Request,
|
||||
datastore: Arc<Mutex<Option<Arc<dyn DatastoreStatsProvider>>>>,
|
||||
) {
|
||||
let json = match datastore.lock().unwrap().as_ref() {
|
||||
Some(provider) => provider.snapshot_json().unwrap_or_else(|| "{}".into()),
|
||||
None => serde_json::json!({
|
||||
"error": "datastore provider not attached"
|
||||
})
|
||||
.to_string(),
|
||||
};
|
||||
|
||||
let response = tiny_http::Response::from_string(json).with_header(
|
||||
"Content-Type: application/json"
|
||||
.parse::<tiny_http::Header>()
|
||||
.unwrap(),
|
||||
);
|
||||
let _ = request.respond(response);
|
||||
}
|
||||
|
||||
#[cfg(feature = "ci")]
|
||||
fn handle_ci_api(
|
||||
request: tiny_http::Request,
|
||||
path: &str,
|
||||
ci: Arc<Mutex<Option<Arc<dyn CiStatsProvider>>>>,
|
||||
) {
|
||||
use crate::ci_collector;
|
||||
|
||||
let route = ci_collector::parse_route(path);
|
||||
let json = match ci.lock().unwrap().as_ref() {
|
||||
Some(provider) => {
|
||||
let snapshot = provider.snapshot();
|
||||
ci_collector::handle_route(&route, &snapshot)
|
||||
.unwrap_or_else(|| r#"{"error":"not found"}"#.to_string())
|
||||
}
|
||||
None => serde_json::json!({
|
||||
"error": "CI provider not attached"
|
||||
})
|
||||
.to_string(),
|
||||
};
|
||||
|
||||
let response = tiny_http::Response::from_string(json).with_header(
|
||||
"Content-Type: application/json"
|
||||
.parse::<tiny_http::Header>()
|
||||
.unwrap(),
|
||||
);
|
||||
let _ = request.respond(response);
|
||||
}
|
||||
|
||||
fn handle_topology_api(
|
||||
request: tiny_http::Request,
|
||||
runtime: Arc<Mutex<Option<Arc<Runtime>>>>,
|
||||
collector: Arc<Mutex<Option<Arc<StatsCollector>>>>,
|
||||
) {
|
||||
let maybe_rt = runtime.lock().unwrap().clone();
|
||||
let json = match maybe_rt {
|
||||
Some(rt) => {
|
||||
let mut stats = rt.stats();
|
||||
if let Some(col) = collector.lock().unwrap().as_ref() {
|
||||
col.enrich(&mut stats);
|
||||
}
|
||||
let topo = topology::worker_topology(&stats);
|
||||
serde_json::to_string(&topo).unwrap_or_else(|_| "{}".into())
|
||||
}
|
||||
None => "{}".to_string(),
|
||||
};
|
||||
let response = tiny_http::Response::from_string(json).with_header(
|
||||
"Content-Type: application/json"
|
||||
.parse::<tiny_http::Header>()
|
||||
.unwrap(),
|
||||
);
|
||||
let _ = request.respond(response);
|
||||
}
|
||||
|
||||
fn handle_logs_api(request: tiny_http::Request, url: &str, store: Arc<EventStore>) {
|
||||
let params = parse_query_string(url);
|
||||
let actor = params.get("actor").cloned().unwrap_or_default();
|
||||
let limit: usize = params
|
||||
.get("limit")
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(200);
|
||||
let level = params.get("level").cloned();
|
||||
|
||||
let mut events = store.read_for_actor(&actor, limit);
|
||||
|
||||
// Filter by level if specified
|
||||
if let Some(ref lvl) = level {
|
||||
let lvl_upper = lvl.to_uppercase();
|
||||
events.retain(|e| e.level == lvl_upper);
|
||||
}
|
||||
|
||||
let json = serde_json::to_string(&events).unwrap_or_else(|_| "[]".into());
|
||||
let response = tiny_http::Response::from_string(json).with_header(
|
||||
"Content-Type: application/json"
|
||||
.parse::<tiny_http::Header>()
|
||||
.unwrap(),
|
||||
);
|
||||
let _ = request.respond(response);
|
||||
}
|
||||
|
||||
fn handle_history_api(request: tiny_http::Request, history: Arc<DashboardHistory>) {
|
||||
let json = history.worker_history_json();
|
||||
let response = tiny_http::Response::from_string(json).with_header(
|
||||
"Content-Type: application/json"
|
||||
.parse::<tiny_http::Header>()
|
||||
.unwrap(),
|
||||
);
|
||||
let _ = request.respond(response);
|
||||
}
|
||||
|
||||
fn parse_query_string(url: &str) -> HashMap<String, String> {
|
||||
let mut params = HashMap::new();
|
||||
if let Some(qs) = url.split('?').nth(1) {
|
||||
for pair in qs.split('&') {
|
||||
let mut kv = pair.splitn(2, '=');
|
||||
if let (Some(k), Some(v)) = (kv.next(), kv.next()) {
|
||||
params.insert(k.to_string(), v.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
params
|
||||
}
|
||||
|
||||
// ── Replay server ───────────────────────────────────────────────────────
|
||||
|
||||
/// Start a replay HTTP server that serves a pre-recorded trace.
|
||||
pub(crate) fn spawn_replay_server(trace: Arc<RuntimeTrace>, port: u16, speed: f64) {
|
||||
let addr = format!("0.0.0.0:{port}");
|
||||
let server = tiny_http::Server::http(&addr).expect("failed to bind HTTP server");
|
||||
let server = Arc::new(server);
|
||||
|
||||
for _ in 0..4 {
|
||||
let server = Arc::clone(&server);
|
||||
let trace = Arc::clone(&trace);
|
||||
thread::spawn(move || {
|
||||
loop {
|
||||
let request = match server.recv() {
|
||||
Ok(r) => r,
|
||||
Err(_) => break,
|
||||
};
|
||||
|
||||
let url = request.url().to_string();
|
||||
match url.as_str() {
|
||||
"/" => respond_html(request, DASHBOARD_HTML, "replay"),
|
||||
"/actors" => respond_html(request, ACTORS_HTML, "replay"),
|
||||
"/events" => {
|
||||
handle_replay_sse(request, Arc::clone(&trace), speed);
|
||||
}
|
||||
_ => respond_404(request),
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_replay_sse(request: tiny_http::Request, trace: Arc<RuntimeTrace>, speed: f64) {
|
||||
let (tx, rx) = mpsc::channel::<Vec<u8>>();
|
||||
let response = make_sse_response(rx);
|
||||
|
||||
thread::spawn(move || {
|
||||
// Send replay metadata
|
||||
let meta = serde_json::json!({
|
||||
"total_events": trace.events.len(),
|
||||
"total_stats": trace.stats_timeline.len(),
|
||||
"speed": speed,
|
||||
});
|
||||
if tx.send(format_sse("replay_meta", &meta.to_string())).is_err() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the earliest timestamp across events and stats
|
||||
let base_time = trace
|
||||
.events
|
||||
.first()
|
||||
.map(|e| e.timestamp_ms)
|
||||
.into_iter()
|
||||
.chain(trace.stats_timeline.first().map(|s| s.timestamp_ms))
|
||||
.min()
|
||||
.unwrap_or(0);
|
||||
|
||||
let playback_start = Instant::now();
|
||||
let mut event_idx = 0;
|
||||
let mut stats_idx = 0;
|
||||
|
||||
loop {
|
||||
let elapsed_ms = (playback_start.elapsed().as_millis() as f64 * speed) as u64;
|
||||
let virtual_time = base_time + elapsed_ms;
|
||||
|
||||
// Batch events up to virtual_time
|
||||
let mut batch = Vec::new();
|
||||
while event_idx < trace.events.len()
|
||||
&& trace.events[event_idx].timestamp_ms <= virtual_time
|
||||
{
|
||||
batch.push(trace.events[event_idx].clone());
|
||||
event_idx += 1;
|
||||
}
|
||||
if !batch.is_empty() {
|
||||
let json = serde_json::to_string(&batch).unwrap();
|
||||
if tx.send(format_sse("activity", &json)).is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Send stats snapshots up to virtual_time
|
||||
while stats_idx < trace.stats_timeline.len()
|
||||
&& trace.stats_timeline[stats_idx].timestamp_ms <= virtual_time
|
||||
{
|
||||
let json =
|
||||
serde_json::to_string(&trace.stats_timeline[stats_idx].stats).unwrap();
|
||||
if tx.send(format_sse("stats", &json)).is_err() {
|
||||
return;
|
||||
}
|
||||
stats_idx += 1;
|
||||
}
|
||||
|
||||
// Send progress
|
||||
let total = trace.events.len() + trace.stats_timeline.len();
|
||||
let done_count = event_idx + stats_idx;
|
||||
let progress = if total > 0 {
|
||||
done_count as f64 / total as f64
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
let progress_json = serde_json::json!({ "progress": progress });
|
||||
if tx
|
||||
.send(format_sse("replay_progress", &progress_json.to_string()))
|
||||
.is_err()
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if replay is complete
|
||||
if event_idx >= trace.events.len()
|
||||
&& stats_idx >= trace.stats_timeline.len()
|
||||
{
|
||||
let _ = tx.send(format_sse("done", "{}"));
|
||||
let _ = tx.send(Vec::new()); // EOF
|
||||
return;
|
||||
}
|
||||
|
||||
thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
});
|
||||
|
||||
// Blocks until connection closes
|
||||
let _ = request.respond(response);
|
||||
}
|
||||
|
|
@ -28,6 +28,11 @@ impl StdExtension {
|
|||
group_registry: GroupRegistry::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a human-readable name for an actor address (reverse lookup).
|
||||
pub fn resolve_name(&self, addr: &ActorAddress) -> Option<String> {
|
||||
self.name_registry.lookup_by_addr(addr)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for StdExtension {
|
||||
|
|
|
|||
|
|
@ -52,6 +52,11 @@ impl NameRegistry {
|
|||
}
|
||||
}
|
||||
|
||||
/// Look up the name bound to an actor address (reverse lookup).
|
||||
pub fn lookup_by_addr(&self, addr: &ActorAddress) -> Option<String> {
|
||||
self.reverse.read().unwrap().get(addr).cloned()
|
||||
}
|
||||
|
||||
/// Return all registered names.
|
||||
pub fn registered_names(&self) -> Vec<String> {
|
||||
self.names.read().unwrap().keys().cloned().collect()
|
||||
|
|
|
|||
27
crates/swactor-node/Cargo.toml
Normal file
27
crates/swactor-node/Cargo.toml
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
[package]
|
||||
name = "swactor-node"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
swactor = { path = "../..", features = ["serde", "tracing", "transport"] }
|
||||
swactor-std = { path = "../std" }
|
||||
dashboard = { path = "../dashboard", features = ["distribution"] }
|
||||
swactor-datastore = { path = "../datastore" }
|
||||
distribution = { path = "../distribution" }
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
ctrlc = "3"
|
||||
iroh = { version = "0.96", optional = true }
|
||||
toml = "0.8"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
libc = "0.2"
|
||||
|
||||
[features]
|
||||
default = ["iroh", "relay"]
|
||||
tcp = ["distribution/tcp"]
|
||||
iroh = ["distribution/iroh", "dep:iroh"]
|
||||
relay = ["iroh", "distribution/relay"]
|
||||
|
||||
[[bin]]
|
||||
name = "swactor"
|
||||
path = "src/main.rs"
|
||||
25
crates/swactor-node/build.rs
Normal file
25
crates/swactor-node/build.rs
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
use std::process::Command;
|
||||
|
||||
fn git(args: &[&str]) -> String {
|
||||
Command::new("git")
|
||||
.args(args)
|
||||
.output()
|
||||
.ok()
|
||||
.filter(|o| o.status.success())
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
|
||||
.unwrap_or_else(|| "unknown".into())
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let hash = git(&["rev-parse", "--short", "HEAD"]);
|
||||
let branch = git(&["rev-parse", "--abbrev-ref", "HEAD"]);
|
||||
let dirty = git(&["status", "--porcelain"]);
|
||||
let suffix = if dirty.is_empty() { "" } else { "-dirty" };
|
||||
|
||||
println!("cargo:rustc-env=SWACTOR_GIT_HASH={hash}{suffix}");
|
||||
println!("cargo:rustc-env=SWACTOR_GIT_BRANCH={branch}");
|
||||
|
||||
// Rebuild when HEAD changes (new commit, branch switch)
|
||||
println!("cargo:rerun-if-changed=../../.git/HEAD");
|
||||
println!("cargo:rerun-if-changed=../../.git/index");
|
||||
}
|
||||
40
crates/swactor-node/src/config.rs
Normal file
40
crates/swactor-node/src/config.rs
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
//! TOML configuration file support for swactor-node.
|
||||
//!
|
||||
//! CLI flags take precedence over config file values, which take
|
||||
//! precedence over compiled defaults.
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
#[allow(dead_code)] // fields may be unused depending on feature flags
|
||||
pub struct SwactorNodeConfig {
|
||||
pub transport: Option<String>,
|
||||
pub dashboard_port: Option<u16>,
|
||||
pub storage_path: Option<String>,
|
||||
pub identity_dir: Option<String>,
|
||||
pub peers_file: Option<String>,
|
||||
pub seed_node_id: Option<String>,
|
||||
pub seed: Option<String>,
|
||||
pub listen: Option<String>,
|
||||
pub auth: Option<bool>,
|
||||
pub auth_dir: Option<String>,
|
||||
pub actors: Option<usize>,
|
||||
pub chunk_size: Option<u32>,
|
||||
pub gc_interval: Option<u64>,
|
||||
pub disseminate_interval: Option<u64>,
|
||||
pub no_datastore: Option<bool>,
|
||||
pub node_name: Option<String>,
|
||||
pub relay: Option<bool>,
|
||||
pub relay_port: Option<u16>,
|
||||
pub relay_bind: Option<String>,
|
||||
pub relay_hosts: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
impl SwactorNodeConfig {
|
||||
pub fn load(path: &std::path::Path) -> Result<Self, String> {
|
||||
let contents = std::fs::read_to_string(path)
|
||||
.map_err(|e| format!("failed to read config file {}: {e}", path.display()))?;
|
||||
toml::from_str(&contents)
|
||||
.map_err(|e| format!("failed to parse config file {}: {e}", path.display()))
|
||||
}
|
||||
}
|
||||
495
crates/swactor-node/src/install.rs
Normal file
495
crates/swactor-node/src/install.rs
Normal file
|
|
@ -0,0 +1,495 @@
|
|||
//! Self-install / uninstall swactor as a system service.
|
||||
//!
|
||||
//! Binary is installed to `~/.swactor/bin/swactor`. The service strategy
|
||||
//! is chosen automatically:
|
||||
//!
|
||||
//! - **systemd** → user-level service (`systemctl --user`), no root
|
||||
//! - **OpenRC / sysvinit + root** → system service in `/etc/init.d/`
|
||||
//! - **OpenRC / sysvinit + no root** → `@reboot` crontab entry
|
||||
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
// ── Init system detection ───────────────────────────────────────────────
|
||||
|
||||
enum InitSystem {
|
||||
Systemd,
|
||||
OpenRc,
|
||||
SysVinit,
|
||||
}
|
||||
|
||||
fn detect_init() -> InitSystem {
|
||||
if Path::new("/run/systemd/system").exists() {
|
||||
InitSystem::Systemd
|
||||
} else if Path::new("/sbin/openrc").exists() {
|
||||
InitSystem::OpenRc
|
||||
} else {
|
||||
InitSystem::SysVinit
|
||||
}
|
||||
}
|
||||
|
||||
fn is_root() -> bool {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
// SAFETY: getuid is always safe to call
|
||||
unsafe { libc::getuid() == 0 }
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Paths ───────────────────────────────────────────────────────────────
|
||||
|
||||
fn swactor_dir() -> PathBuf {
|
||||
let home = std::env::var("HOME").unwrap_or_else(|_| {
|
||||
eprintln!("$HOME is not set");
|
||||
std::process::exit(1);
|
||||
});
|
||||
PathBuf::from(home).join(".swactor")
|
||||
}
|
||||
|
||||
fn bin_path() -> PathBuf {
|
||||
swactor_dir().join("bin").join("swactor")
|
||||
}
|
||||
|
||||
// ── Service file templates ──────────────────────────────────────────────
|
||||
|
||||
fn systemd_unit(bin: &Path) -> String {
|
||||
format!(
|
||||
"\
|
||||
[Unit]
|
||||
Description=Swactor distributed node
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart={bin}
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
KillSignal=SIGINT
|
||||
TimeoutStopSec=30
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
",
|
||||
bin = bin.display(),
|
||||
)
|
||||
}
|
||||
|
||||
fn openrc_init(bin: &Path) -> String {
|
||||
format!(
|
||||
"\
|
||||
#!/sbin/openrc-run
|
||||
name=\"swactor\"
|
||||
description=\"Swactor distributed node\"
|
||||
command=\"{bin}\"
|
||||
command_background=true
|
||||
pidfile=\"/run/${{RC_SVCNAME}}.pid\"
|
||||
",
|
||||
bin = bin.display(),
|
||||
)
|
||||
}
|
||||
|
||||
fn sysvinit_script(bin: &Path) -> String {
|
||||
format!(
|
||||
r#"#!/bin/sh
|
||||
### BEGIN INIT INFO
|
||||
# Provides: swactor
|
||||
# Required-Start: $network $remote_fs
|
||||
# Required-Stop: $network $remote_fs
|
||||
# Default-Start: 2 3 4 5
|
||||
# Default-Stop: 0 1 6
|
||||
# Short-Description: Swactor distributed node
|
||||
### END INIT INFO
|
||||
|
||||
DAEMON="{bin}"
|
||||
NAME="swactor"
|
||||
PIDFILE="/var/run/$NAME.pid"
|
||||
|
||||
case "$1" in
|
||||
start)
|
||||
echo "Starting $NAME..."
|
||||
start-stop-daemon --start --background --make-pidfile \
|
||||
--pidfile "$PIDFILE" --exec "$DAEMON"
|
||||
;;
|
||||
stop)
|
||||
echo "Stopping $NAME..."
|
||||
start-stop-daemon --stop --pidfile "$PIDFILE" --signal INT --retry 30
|
||||
rm -f "$PIDFILE"
|
||||
;;
|
||||
restart)
|
||||
$0 stop
|
||||
$0 start
|
||||
;;
|
||||
status)
|
||||
if [ -f "$PIDFILE" ] && kill -0 "$(cat "$PIDFILE")" 2>/dev/null; then
|
||||
echo "$NAME is running (pid $(cat "$PIDFILE"))"
|
||||
else
|
||||
echo "$NAME is not running"
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
echo "Usage: $0 {{start|stop|restart|status}}"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
"#,
|
||||
bin = bin.display(),
|
||||
)
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
fn run(program: &str, args: &[&str]) -> bool {
|
||||
match Command::new(program).args(args).status() {
|
||||
Ok(s) => s.success(),
|
||||
Err(e) => {
|
||||
eprintln!(" failed to run {program}: {e}");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn copy_self_to_bin(dest: &Path) {
|
||||
let exe = std::env::current_exe().expect("cannot determine own executable path");
|
||||
let canonical_exe = fs::canonicalize(&exe).unwrap_or(exe);
|
||||
if canonical_exe == dest {
|
||||
eprintln!(" binary already at {}, skipping copy", dest.display());
|
||||
return;
|
||||
}
|
||||
if let Some(parent) = dest.parent() {
|
||||
fs::create_dir_all(parent).unwrap_or_else(|e| {
|
||||
eprintln!(" failed to create {}: {e}", parent.display());
|
||||
std::process::exit(1);
|
||||
});
|
||||
}
|
||||
// Unlink the existing binary first to avoid ETXTBSY when upgrading
|
||||
// a running process. On Unix the kernel keeps the old inode alive
|
||||
// until the process exits; the new copy gets a fresh inode.
|
||||
if dest.exists() {
|
||||
fs::remove_file(dest).unwrap_or_else(|e| {
|
||||
eprintln!(" failed to remove old binary: {e}");
|
||||
std::process::exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
eprintln!(" copying {} -> {}", canonical_exe.display(), dest.display());
|
||||
fs::copy(&canonical_exe, dest).unwrap_or_else(|e| {
|
||||
eprintln!(" failed to copy binary: {e}");
|
||||
std::process::exit(1);
|
||||
});
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
fs::set_permissions(dest, fs::Permissions::from_mode(0o755)).ok();
|
||||
}
|
||||
}
|
||||
|
||||
fn systemd_user_dir() -> PathBuf {
|
||||
let home = std::env::var("HOME").unwrap_or_else(|_| {
|
||||
eprintln!("$HOME is not set");
|
||||
std::process::exit(1);
|
||||
});
|
||||
PathBuf::from(home)
|
||||
.join(".config")
|
||||
.join("systemd")
|
||||
.join("user")
|
||||
}
|
||||
|
||||
fn set_executable(path: &str) {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
fs::set_permissions(path, fs::Permissions::from_mode(0o755)).ok();
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the current user crontab, returns empty string if none.
|
||||
fn read_crontab() -> String {
|
||||
Command::new("crontab")
|
||||
.arg("-l")
|
||||
.output()
|
||||
.ok()
|
||||
.filter(|o| o.status.success())
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).into_owned())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Write a new crontab from the given string.
|
||||
fn write_crontab(content: &str) -> bool {
|
||||
use std::io::Write;
|
||||
let mut child = match Command::new("crontab")
|
||||
.arg("-")
|
||||
.stdin(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
{
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
eprintln!(" failed to run crontab: {e}");
|
||||
return false;
|
||||
}
|
||||
};
|
||||
if let Some(mut stdin) = child.stdin.take() {
|
||||
let _ = stdin.write_all(content.as_bytes());
|
||||
}
|
||||
child.wait().map(|s| s.success()).unwrap_or(false)
|
||||
}
|
||||
|
||||
fn crontab_tag(bin: &Path) -> String {
|
||||
// Restart on failure only (matches systemd Restart=on-failure).
|
||||
// Retries up to 5 times with linear backoff (5s, 10s, …, 25s),
|
||||
// then gives up. A clean exit (code 0) stops immediately.
|
||||
format!(
|
||||
"@reboot /bin/sh -c 'n=0; while [ $n -lt 5 ]; do {} && exit 0; n=$((n+1)); sleep $((n*5)); done'",
|
||||
bin.display()
|
||||
)
|
||||
}
|
||||
|
||||
// ── Install ─────────────────────────────────────────────────────────────
|
||||
|
||||
pub fn install() {
|
||||
eprintln!("Installing swactor...");
|
||||
|
||||
let dest = bin_path();
|
||||
copy_self_to_bin(&dest);
|
||||
|
||||
match detect_init() {
|
||||
InitSystem::Systemd => install_systemd(&dest),
|
||||
InitSystem::OpenRc if is_root() => install_openrc(&dest),
|
||||
InitSystem::SysVinit if is_root() => install_sysvinit(&dest),
|
||||
_ => install_crontab(&dest),
|
||||
}
|
||||
|
||||
eprintln!("Done.");
|
||||
}
|
||||
|
||||
fn install_systemd(bin: &Path) {
|
||||
let dir = systemd_user_dir();
|
||||
fs::create_dir_all(&dir).unwrap_or_else(|e| {
|
||||
eprintln!(" failed to create {}: {e}", dir.display());
|
||||
std::process::exit(1);
|
||||
});
|
||||
|
||||
let path = dir.join("swactor.service");
|
||||
eprintln!(" writing {}", path.display());
|
||||
fs::write(&path, systemd_unit(bin)).unwrap_or_else(|e| {
|
||||
eprintln!(" failed to write service file: {e}");
|
||||
std::process::exit(1);
|
||||
});
|
||||
|
||||
eprintln!(" enabling and restarting user service...");
|
||||
run("systemctl", &["--user", "daemon-reload"]);
|
||||
run("systemctl", &["--user", "enable", "swactor"]);
|
||||
run("systemctl", &["--user", "restart", "swactor"]);
|
||||
|
||||
// Enable lingering so the service survives logout
|
||||
if let Ok(user) = std::env::var("USER") {
|
||||
run("loginctl", &["enable-linger", &user]);
|
||||
}
|
||||
|
||||
eprintln!(" systemd user service installed and started");
|
||||
}
|
||||
|
||||
fn install_openrc(bin: &Path) {
|
||||
let path = "/etc/init.d/swactor";
|
||||
eprintln!(" writing {path}");
|
||||
fs::write(path, openrc_init(bin)).unwrap_or_else(|e| {
|
||||
eprintln!(" failed to write init script: {e}");
|
||||
std::process::exit(1);
|
||||
});
|
||||
set_executable(path);
|
||||
|
||||
eprintln!(" enabling and restarting service...");
|
||||
run("rc-update", &["add", "swactor", "default"]);
|
||||
run("rc-service", &["swactor", "restart"]);
|
||||
|
||||
eprintln!(" OpenRC service installed and restarted");
|
||||
}
|
||||
|
||||
fn install_sysvinit(bin: &Path) {
|
||||
let path = "/etc/init.d/swactor";
|
||||
eprintln!(" writing {path}");
|
||||
fs::write(path, sysvinit_script(bin)).unwrap_or_else(|e| {
|
||||
eprintln!(" failed to write init script: {e}");
|
||||
std::process::exit(1);
|
||||
});
|
||||
set_executable(path);
|
||||
|
||||
eprintln!(" enabling and restarting service...");
|
||||
if !run("update-rc.d", &["swactor", "defaults"]) {
|
||||
run("chkconfig", &["--add", "swactor"]);
|
||||
}
|
||||
run("/etc/init.d/swactor", &["restart"]);
|
||||
|
||||
eprintln!(" sysvinit service installed and restarted");
|
||||
}
|
||||
|
||||
fn install_crontab(bin: &Path) {
|
||||
let tag = crontab_tag(bin);
|
||||
let legacy_tag = format!(
|
||||
"@reboot /bin/sh -c 'while true; do {} ; sleep 5; done'",
|
||||
bin.display()
|
||||
);
|
||||
let existing = read_crontab();
|
||||
|
||||
if existing.lines().any(|l| l.trim() == tag) {
|
||||
eprintln!(" crontab @reboot entry already exists, skipping");
|
||||
} else {
|
||||
// Remove legacy entry if present, then add the new one.
|
||||
let mut new: String = existing
|
||||
.lines()
|
||||
.filter(|l| l.trim() != legacy_tag)
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
if !new.is_empty() && !new.ends_with('\n') {
|
||||
new.push('\n');
|
||||
}
|
||||
new.push_str(&tag);
|
||||
new.push('\n');
|
||||
eprintln!(" adding crontab @reboot entry");
|
||||
if !write_crontab(&new) {
|
||||
eprintln!(" failed to update crontab");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Stop any running instance, then start fresh
|
||||
eprintln!(" stopping old swactor (if running)...");
|
||||
run("pkill", &["-INT", "-f", &bin.to_string_lossy()]);
|
||||
// Give it a moment to exit cleanly
|
||||
std::thread::sleep(std::time::Duration::from_secs(2));
|
||||
|
||||
eprintln!(" starting swactor in background...");
|
||||
let bin_str = bin.to_string_lossy();
|
||||
match Command::new(&*bin_str)
|
||||
.stdin(std::process::Stdio::null())
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.spawn()
|
||||
{
|
||||
Ok(child) => eprintln!(" started (pid {})", child.id()),
|
||||
Err(e) => eprintln!(" failed to start: {e}"),
|
||||
}
|
||||
|
||||
eprintln!(" crontab service installed (starts on @reboot)");
|
||||
}
|
||||
|
||||
// ── Uninstall ───────────────────────────────────────────────────────────
|
||||
|
||||
pub fn uninstall() {
|
||||
eprintln!("Uninstalling swactor...");
|
||||
|
||||
match detect_init() {
|
||||
InitSystem::Systemd => uninstall_systemd(),
|
||||
InitSystem::OpenRc if is_root() => uninstall_openrc(),
|
||||
InitSystem::SysVinit if is_root() => uninstall_sysvinit(),
|
||||
_ => uninstall_crontab(),
|
||||
}
|
||||
|
||||
// Remove binary
|
||||
let dest = bin_path();
|
||||
if dest.exists() {
|
||||
eprintln!(" removing {}", dest.display());
|
||||
fs::remove_file(&dest).unwrap_or_else(|e| {
|
||||
eprintln!(" failed to remove binary: {e}");
|
||||
});
|
||||
// Remove bin/ dir if empty
|
||||
if let Some(parent) = dest.parent() {
|
||||
fs::remove_dir(parent).ok();
|
||||
}
|
||||
}
|
||||
|
||||
eprintln!(" data left in ~/.swactor/ (not removed)");
|
||||
eprintln!("Done.");
|
||||
}
|
||||
|
||||
fn uninstall_systemd() {
|
||||
eprintln!(" stopping and disabling user service...");
|
||||
run("systemctl", &["--user", "stop", "swactor"]);
|
||||
run("systemctl", &["--user", "disable", "swactor"]);
|
||||
|
||||
let path = systemd_user_dir().join("swactor.service");
|
||||
if path.exists() {
|
||||
eprintln!(" removing {}", path.display());
|
||||
fs::remove_file(&path).unwrap_or_else(|e| {
|
||||
eprintln!(" failed to remove service file: {e}");
|
||||
});
|
||||
}
|
||||
|
||||
run("systemctl", &["--user", "daemon-reload"]);
|
||||
}
|
||||
|
||||
fn uninstall_openrc() {
|
||||
let path = "/etc/init.d/swactor";
|
||||
|
||||
eprintln!(" stopping and disabling service...");
|
||||
run("rc-service", &["swactor", "stop"]);
|
||||
run("rc-update", &["del", "swactor", "default"]);
|
||||
|
||||
if Path::new(path).exists() {
|
||||
eprintln!(" removing {path}");
|
||||
fs::remove_file(path).unwrap_or_else(|e| {
|
||||
eprintln!(" failed to remove init script: {e}");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn uninstall_sysvinit() {
|
||||
let path = "/etc/init.d/swactor";
|
||||
|
||||
eprintln!(" stopping service...");
|
||||
run(path, &["stop"]);
|
||||
|
||||
if !run("update-rc.d", &["-f", "swactor", "remove"]) {
|
||||
run("chkconfig", &["--del", "swactor"]);
|
||||
}
|
||||
|
||||
if Path::new(path).exists() {
|
||||
eprintln!(" removing {path}");
|
||||
fs::remove_file(path).unwrap_or_else(|e| {
|
||||
eprintln!(" failed to remove init script: {e}");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn uninstall_crontab() {
|
||||
let dest = bin_path();
|
||||
let tag = crontab_tag(&dest);
|
||||
// Legacy format used an unconditional `while true` loop.
|
||||
let legacy_tag = format!(
|
||||
"@reboot /bin/sh -c 'while true; do {} ; sleep 5; done'",
|
||||
dest.display()
|
||||
);
|
||||
let existing = read_crontab();
|
||||
|
||||
// Kill running process
|
||||
eprintln!(" stopping swactor...");
|
||||
run("pkill", &["-INT", "-f", &dest.to_string_lossy()]);
|
||||
|
||||
let is_swactor_entry = |l: &&str| {
|
||||
let t = l.trim();
|
||||
t == tag || t == legacy_tag
|
||||
};
|
||||
|
||||
if existing.lines().any(|l| is_swactor_entry(&l)) {
|
||||
eprintln!(" removing crontab @reboot entry");
|
||||
let new: String = existing
|
||||
.lines()
|
||||
.filter(|l| !is_swactor_entry(l))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
+ "\n";
|
||||
// If crontab is now empty (just whitespace), remove it entirely
|
||||
if new.trim().is_empty() {
|
||||
run("crontab", &["-r"]);
|
||||
} else {
|
||||
write_crontab(&new);
|
||||
}
|
||||
}
|
||||
}
|
||||
1006
crates/swactor-node/src/main.rs
Normal file
1006
crates/swactor-node/src/main.rs
Normal file
File diff suppressed because it is too large
Load diff
43
crates/swactor-node/src/names.rs
Normal file
43
crates/swactor-node/src/names.rs
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
//! Human-readable name generation for swactor nodes.
|
||||
//!
|
||||
//! Produces deterministic `adjective-animal` names from a keypair's
|
||||
//! public key bytes, so the same identity always gets the same default name.
|
||||
|
||||
use distribution::crypto::Keypair;
|
||||
|
||||
const ADJECTIVES: &[&str] = &[
|
||||
"bold", "brave", "bright", "calm", "clever",
|
||||
"cool", "crisp", "deft", "eager", "fair",
|
||||
"fast", "fierce", "fleet", "fond", "frank",
|
||||
"free", "fresh", "glad", "grand", "green",
|
||||
"happy", "hardy", "keen", "kind", "light",
|
||||
"live", "lucky", "merry", "mild", "neat",
|
||||
"noble", "pale", "plain", "prime", "proud",
|
||||
"pure", "quick", "quiet", "rapid", "ready",
|
||||
"rich", "sharp", "sleek", "smart", "solid",
|
||||
"sound", "stout", "sure", "swift", "wise",
|
||||
];
|
||||
|
||||
const ANIMALS: &[&str] = &[
|
||||
"badger", "bear", "bison", "bobcat", "crane",
|
||||
"crow", "deer", "dove", "eagle", "elk",
|
||||
"falcon", "finch", "fox", "frog", "goose",
|
||||
"hare", "hawk", "heron", "horse", "ibis",
|
||||
"jackal", "jay", "kite", "lark", "lion",
|
||||
"lynx", "marten", "mink", "moose", "newt",
|
||||
"otter", "owl", "panda", "pike", "puma",
|
||||
"quail", "raven", "robin", "salmon", "seal",
|
||||
"shrike", "snake", "sparrow", "stork", "swan",
|
||||
"tiger", "toad", "trout", "viper", "wolf",
|
||||
];
|
||||
|
||||
/// Generate a deterministic human-readable name from a keypair.
|
||||
///
|
||||
/// Uses the first two bytes of the public key (node ID) to index into
|
||||
/// the adjective and animal word lists.
|
||||
pub fn generate_name(keypair: &Keypair) -> String {
|
||||
let id = keypair.node_id();
|
||||
let adj_idx = id.0[0] as usize % ADJECTIVES.len();
|
||||
let animal_idx = id.0[1] as usize % ANIMALS.len();
|
||||
format!("{}-{}", ADJECTIVES[adj_idx], ANIMALS[animal_idx])
|
||||
}
|
||||
86
crates/swactor-node/src/relay.rs
Normal file
86
crates/swactor-node/src/relay.rs
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
//! Relay candidacy evaluation.
|
||||
//!
|
||||
//! Determines whether this node is eligible to run an embedded relay server
|
||||
//! by checking a set of extensible rules (public IP, port availability, etc.).
|
||||
|
||||
use std::net::{IpAddr, SocketAddr, UdpSocket};
|
||||
|
||||
// ─── Candidacy ──────────────────────────────────────────────────────────
|
||||
|
||||
/// Result of evaluating a single candidacy rule.
|
||||
pub enum CandidacyResult {
|
||||
Eligible,
|
||||
Ineligible(String),
|
||||
}
|
||||
|
||||
/// Extensible rule for relay candidacy evaluation.
|
||||
pub trait CandidacyRule: Send {
|
||||
fn evaluate(&self) -> CandidacyResult;
|
||||
}
|
||||
|
||||
/// Checks whether the node's outbound IP is public (non-RFC1918, non-loopback).
|
||||
pub struct PublicIpRule;
|
||||
|
||||
impl PublicIpRule {
|
||||
pub fn outbound_ip() -> Option<IpAddr> {
|
||||
let sock = UdpSocket::bind("0.0.0.0:0").ok()?;
|
||||
sock.connect("192.0.2.1:80").ok()?; // RFC 5737 TEST-NET-1 (non-routable)
|
||||
Some(sock.local_addr().ok()?.ip())
|
||||
}
|
||||
|
||||
fn is_public(ip: &IpAddr) -> bool {
|
||||
match ip {
|
||||
IpAddr::V4(v4) => {
|
||||
!v4.is_loopback()
|
||||
&& !v4.is_private()
|
||||
&& !v4.is_link_local()
|
||||
&& !v4.is_unspecified()
|
||||
}
|
||||
IpAddr::V6(v6) => !v6.is_loopback() && !v6.is_unspecified(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CandidacyRule for PublicIpRule {
|
||||
fn evaluate(&self) -> CandidacyResult {
|
||||
match Self::outbound_ip() {
|
||||
Some(ip) if Self::is_public(&ip) => CandidacyResult::Eligible,
|
||||
Some(ip) => CandidacyResult::Ineligible(format!("outbound IP {ip} is private")),
|
||||
None => CandidacyResult::Ineligible("could not determine outbound IP".into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks whether the desired relay port is available for binding.
|
||||
pub struct PortBindRule {
|
||||
addr: SocketAddr,
|
||||
}
|
||||
|
||||
impl PortBindRule {
|
||||
pub fn new(addr: SocketAddr) -> Self {
|
||||
Self { addr }
|
||||
}
|
||||
}
|
||||
|
||||
impl CandidacyRule for PortBindRule {
|
||||
fn evaluate(&self) -> CandidacyResult {
|
||||
match std::net::TcpListener::bind(self.addr) {
|
||||
Ok(_) => CandidacyResult::Eligible,
|
||||
Err(e) => CandidacyResult::Ineligible(format!(
|
||||
"cannot bind {}: {e}",
|
||||
self.addr
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Evaluate all candidacy rules. Returns `Ok(())` if all pass, or
|
||||
/// `Err(reason)` with the first failure reason.
|
||||
pub fn evaluate_candidacy(rules: &[Box<dyn CandidacyRule>]) -> Result<(), String> {
|
||||
for rule in rules {
|
||||
if let CandidacyResult::Ineligible(reason) = rule.evaluate() {
|
||||
return Err(reason);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
[package]
|
||||
name = "wasm"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
swactor = { path = "../..", default-features = false, features = ["wasm"] }
|
||||
swactor-std = { path = "../std", default-features = false, features = ["wasm"] }
|
||||
wasm-bindgen = "0.2"
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
[package]
|
||||
name = "swactor-crypto-wasm"
|
||||
name = "wasm-crypto"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
12
crates/wasm/runtime/Cargo.toml
Normal file
12
crates/wasm/runtime/Cargo.toml
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
[package]
|
||||
name = "wasm-runtime"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
swactor = { path = "../../..", default-features = false, features = ["wasm"] }
|
||||
swactor-std = { path = "../../std", default-features = false, features = ["wasm"] }
|
||||
wasm-bindgen = "0.2"
|
||||
Binary file not shown.
|
|
@ -1 +0,0 @@
|
|||
{"content_hash":[52,15,103,173,110,73,232,170,169,226,114,219,202,210,124,56,231,179,46,63,246,247,87,65,190,76,29,41,77,143,210,221],"name":"Distributed Data Types v1.pdf","node_id":[130,74,105,87,21,9,99,141,247,147,202,184,64,55,216,254,212,34,166,19,54,124,213,5,205,134,85,35,28,219,47,170],"tags":{},"size_bytes":287176,"created_at":0}
|
||||
|
|
@ -1 +0,0 @@
|
|||
{"content_hash":[52,15,103,173,110,73,232,170,169,226,114,219,202,210,124,56,231,179,46,63,246,247,87,65,190,76,29,41,77,143,210,221],"chunks":[{"hash":[52,15,103,173,110,73,232,170,169,226,114,219,202,210,124,56,231,179,46,63,246,247,87,65,190,76,29,41,77,143,210,221],"offset":0,"size":287176}],"total_size":287176,"chunk_size":1048576,"content_type":null}
|
||||
93
docs/development_history/datastore/CHANGES.md
Normal file
93
docs/development_history/datastore/CHANGES.md
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
# Unified Swactor Node with Datastore Dashboard Management
|
||||
|
||||
This document summarizes the changes on the `datastore-dashboard` branch.
|
||||
|
||||
## Problem
|
||||
|
||||
The swactor ecosystem had two separate binaries with no overlap:
|
||||
|
||||
- **`swactor-node`** (in `runtime-dashboard`) — distribution + dashboard, no datastore
|
||||
- **`swactor-store-node`** (in `swactor-datastore`) — datastore + optional dashboard, no distribution
|
||||
|
||||
The dashboard's `/datastore` page was read-only (stats via SSE). The standalone datastore had its own management UI on a separate port. Neither binary gave you the full picture.
|
||||
|
||||
## Solution
|
||||
|
||||
A single batteries-included `swactor-node` crate that combines distribution, dashboard, and datastore. The dashboard now supports full datastore CRUD and lifecycle management. Old binaries remain as lightweight alternatives.
|
||||
|
||||
**Quick start:**
|
||||
|
||||
```
|
||||
cargo xtask dev-node
|
||||
```
|
||||
|
||||
Opens an iroh node with in-memory datastore on dashboard port 9090.
|
||||
|
||||
## What Changed
|
||||
|
||||
### New files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `crates/swactor-node/Cargo.toml` | Unified node crate — depends on `runtime-dashboard`, `swactor-datastore`, and `distribution` |
|
||||
| `crates/swactor-node/src/main.rs` | Combined binary with CLI: `--transport` (iroh default), `--storage-path`, `--no-datastore`, `--dashboard-port`, etc. Main loop merges distribution ticking with datastore GC/dissemination |
|
||||
| `crates/datastore/src/bridge.rs` | `DatastoreBridge` — implements the dashboard's provider trait by sending actor messages and polling responses. `DatastoreNodeFactory` — spawns a fresh set of datastore actors on demand (used by the start/stop UI) |
|
||||
|
||||
### Modified files
|
||||
|
||||
**`Cargo.toml` (workspace root)**
|
||||
- Added `"crates/swactor-node"` to workspace members.
|
||||
|
||||
**`crates/datastore/src/lib.rs`**
|
||||
- Added `pub mod bridge` behind `#[cfg(feature = "node")]`.
|
||||
|
||||
**`crates/runtime-dashboard/src/datastore_collector.rs`**
|
||||
- Expanded `DatastoreStatsProvider` trait with CRUD methods: `list_objects`, `get_object`, `get_data`, `put_data`, `delete_object`, `node_status`, `is_running`, `shutdown_datastore`. All have default impls returning `Err("not supported")` so existing `DatastoreMetrics` impl compiles unchanged.
|
||||
- Added `ListScope` enum (`Local` / `Swarm`).
|
||||
- Added `DatastoreFactory` trait for starting datastores from the dashboard.
|
||||
|
||||
**`crates/runtime-dashboard/src/lib.rs`**
|
||||
- Added `datastore_factory` field to `DashboardHandle`.
|
||||
- Added `set_datastore_factory()` and `datastore_provider()` methods.
|
||||
- Threads factory through to `spawn_http_server()`.
|
||||
|
||||
**`crates/runtime-dashboard/src/server.rs`**
|
||||
- Switched route matching from path-only to `(method, path)` tuples.
|
||||
- Added 8 new API routes under `/api/datastore/`:
|
||||
- `GET /api/datastore/list` — list objects (local or swarm scope)
|
||||
- `GET /api/datastore/get` — object metadata + manifest
|
||||
- `GET /api/datastore/data` — download raw bytes
|
||||
- `GET /api/datastore/status` — node identity
|
||||
- `POST /api/datastore/put` — upload data
|
||||
- `POST /api/datastore/delete` — delete object
|
||||
- `POST /api/datastore/start` — start datastore via factory
|
||||
- `POST /api/datastore/shutdown` — stop datastore
|
||||
- SSE `datastore` event now wraps the snapshot in an envelope: `{"is_running": bool, "snapshot": ...}`.
|
||||
|
||||
**`crates/runtime-dashboard/src/datastore_html.rs`**
|
||||
- Full rewrite merging the monitoring dashboard (SSE-driven stats, event timeline, transfers) with the management UI from `ui_html.rs`:
|
||||
- Upload panel (file input + optional name)
|
||||
- Objects table with Origin column (local/remote badges) and action buttons (download, delete)
|
||||
- Detail modal (hash, name, size, node, tags, chunk list)
|
||||
- Toast notifications
|
||||
- Lifecycle buttons: Start Datastore / Stop (shown based on `is_running` from SSE)
|
||||
|
||||
**`xtask/src/main.rs`**
|
||||
- Added `dev-node` subcommand: builds and runs the unified node with happy defaults (iroh transport, port 9090, 3 actors, in-memory datastore).
|
||||
- Options: `--port`, `--actors`, `--storage`, `--no-datastore`, `--tcp`, `--listen`, `--release`.
|
||||
|
||||
## Design Decisions
|
||||
|
||||
- **Iroh is the default transport.** TCP is available via `--tcp` flag or `--transport tcp`.
|
||||
- **Datastore is on by default** (in-memory). Disable with `--no-datastore`.
|
||||
- **Dashboard-only API** — no separate datastore HTTP port. The dashboard serves all CRUD routes.
|
||||
- **Factory pattern** — even when started with `--no-datastore`, the dashboard can start/stop a datastore at runtime via `DatastoreNodeFactory`.
|
||||
- **No circular dependencies** — `swactor-node` sits atop the dependency graph: `swactor-node` -> `runtime-dashboard` + `swactor-datastore[node]`. The bridge trait lives in `runtime-dashboard` with default method impls.
|
||||
- **Old binaries kept** — `runtime-dashboard`'s `swactor-node` and `swactor-datastore`'s `swactor-store-node` still work as lightweight alternatives.
|
||||
|
||||
## Verification
|
||||
|
||||
```
|
||||
cargo build -p swactor-node -p runtime-dashboard -p swactor-datastore # clean, 0 warnings
|
||||
cargo test -p swactor -p distribution -p runtime-dashboard -p swactor-datastore -p swactor-node # 323 tests pass
|
||||
```
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
DIR="$(cd "$(dirname "${1:-.}")" && pwd)/$(basename "${1:-traces}")"
|
||||
PORT="${2:-8080}"
|
||||
|
||||
rm -rf "$DIR"
|
||||
mkdir -p "$DIR"
|
||||
|
||||
echo "Running distribution sim tests with trace export..."
|
||||
SWACTOR_TRACE_DIR="$DIR" cargo test -p simulation \
|
||||
--test distribution_registry \
|
||||
--test distribution_lifecycle \
|
||||
--test distribution_properties || echo "WARNING: some tests failed (traces from passing tests are still available)"
|
||||
|
||||
COUNT=$(find "$DIR" -name '*.trace.json' 2>/dev/null | wc -l)
|
||||
echo "$COUNT traces in $DIR/"
|
||||
echo "Dashboard at http://localhost:$PORT"
|
||||
cargo run -p simulation --features dashboard --example replay -- "$DIR" "$PORT"
|
||||
|
|
@ -152,6 +152,9 @@ pub struct ActorInfo {
|
|||
/// Whether the actor has panicked and is no longer processing messages.
|
||||
#[cfg_attr(feature = "serde", serde(default))]
|
||||
pub poisoned: bool,
|
||||
/// Human-readable name from the name registry (if registered).
|
||||
#[cfg_attr(feature = "serde", serde(default))]
|
||||
pub name: Option<String>,
|
||||
/// Per-message-type counts, sorted descending by count. Top 32 types.
|
||||
#[cfg_attr(feature = "serde", serde(default))]
|
||||
pub message_type_counts: Vec<(String, u64)>,
|
||||
|
|
|
|||
|
|
@ -391,6 +391,22 @@ pub fn wait_for_lan_convergence(
|
|||
}
|
||||
}
|
||||
|
||||
// ── Deploy simulation helpers ────────────────────────────────────────────────
|
||||
|
||||
/// Restart a specific container with fresh flags (simulates deploy lifecycle).
|
||||
pub fn redeploy_node(service: &str) {
|
||||
let status = Command::new("docker")
|
||||
.args(["compose", "-f", &compose_file(), "up", "-d", "--force-recreate", service])
|
||||
.status()
|
||||
.expect("failed to redeploy node");
|
||||
assert!(status.success(), "docker compose force-recreate {service} failed");
|
||||
}
|
||||
|
||||
/// Fetch the node_id from a node's distribution snapshot.
|
||||
pub fn get_node_id(port: u16) -> Option<String> {
|
||||
poll_distribution(port).map(|snap| snap.node_id)
|
||||
}
|
||||
|
||||
fn lan_hpz_compose_path() -> String {
|
||||
let mut p = PathBuf::from(COMPOSE_DIR);
|
||||
p.push("docker-compose.lan-hpz.yml");
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue