Compare commits

..

8 commits

104 changed files with 7751 additions and 1955 deletions

2
.dockerignore Normal file
View file

@ -0,0 +1,2 @@
*
!target/x86_64-unknown-linux-musl/release/swactor

7
.gitignore vendored
View file

@ -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
View file

@ -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]]

View file

@ -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"

View file

@ -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"]

View file

View file

@ -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"] }

View file

@ -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"]

View file

@ -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(),
job_name: j.definition.name.clone(),
status: j.status.clone(),
output_line_count: j.output_lines.len(),
.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: 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,

View file

@ -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.

View file

@ -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"

View file

@ -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));

View file

@ -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 ─────────────────────────────────────────────────────────

View file

@ -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 ─────────────────────────────────────────────────────────

View file

@ -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);
document.getElementById('addrBreadcrumb').textContent = shortAddr(hex);
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;

View file

@ -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();

View file

@ -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");

View file

@ -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()

View file

@ -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 ─────────────────────────────────────────────────────────

View file

@ -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(),
}));
}

View file

@ -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>';
});

View 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>;
}

View 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()">&times;</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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
function escAttr(s) {
if (!s) return '';
return s.replace(/\\/g,'\\\\').replace(/'/g,"\\'").replace(/"/g,'&quot;');
}
// 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>
"##;

View file

@ -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()">&times;</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>

View file

@ -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,

View file

@ -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 ci: Arc<Mutex<Option<Arc<dyn ci_collector::CiStatsProvider>>>> =
let datastore_factory: Arc<Mutex<Option<Arc<dyn datastore_collector::DatastoreFactory>>>> =
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 ci: Arc<Mutex<Option<Arc<dyn ci_collector::CiStatsProvider>>>> =
Arc::new(Mutex::new(None));
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")]
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");

View 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(&params);
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(&params);
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())
}

View file

@ -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(),

View file

@ -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 {

View file

@ -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 = format!("{}", a.address);
let addr_short = if a.poisoned {
let s = if addr.len() > 16 {
format!("!{}...", &addr[..14])
let addr_short = if let Some(ref name) = a.name {
if a.poisoned {
format!("!{}", name)
} else {
format!("!{}", addr)
};
s
} else if addr.len() > 18 {
format!("{}...", &addr[..16])
name.clone()
}
} else {
addr
let addr = format!("{}", a.address);
if a.poisoned {
if addr.len() > 16 {
format!("!{}...", &addr[..14])
} else {
format!("!{}", addr)
}
} 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 = format!("{}", a.address);
let addr_short = if a.poisoned {
let s = if addr.len() > 16 {
format!("!{}...", &addr[..14])
let addr_short = if let Some(ref name) = a.name {
if a.poisoned {
format!("!{}", name)
} else {
format!("!{}", addr)
};
s
} else if addr.len() > 18 {
format!("{}...", &addr[..16])
name.clone()
}
} else {
addr
let addr = format!("{}", a.address);
if a.poisoned {
if addr.len() > 16 {
format!("!{}...", &addr[..14])
} else {
format!("!{}", addr)
}
} 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,32 +581,40 @@ 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![
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![
Span::styled(" Messages: ", Style::default().fg(Color::DarkGray)),
Span::styled(
format_num(actor.messages_processed),
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
),
Span::styled(" Mailbox: ", Style::default().fg(Color::DarkGray)),
Span::styled(
format!("{}", actor.mailbox_depth),
Style::default().fg(Color::Blue).add_modifier(Modifier::BOLD),
),
Span::styled(" Last Msg: ", Style::default().fg(Color::DarkGray)),
Span::styled(msg_type, Style::default().fg(Color::Green)),
]),
];
]));
}
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)),
]));
info_lines.push(Line::from(vec![
Span::styled(" Messages: ", Style::default().fg(Color::DarkGray)),
Span::styled(
format_num(actor.messages_processed),
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
),
Span::styled(" Mailbox: ", Style::default().fg(Color::DarkGray)),
Span::styled(
format!("{}", actor.mailbox_depth),
Style::default().fg(Color::Blue).add_modifier(Modifier::BOLD),
),
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)),
])
})

View file

@ -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,

View file

@ -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",

View file

@ -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};

View file

@ -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"

View file

@ -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('/');

View file

@ -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);
}

View 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)
}

View file

@ -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};

View file

@ -460,3 +460,4 @@ pub enum GatewayMsg {
/// Periodic nonce garbage collection tick.
NonceGcTick,
}

View file

@ -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()

View file

@ -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,

View file

@ -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(

View file

@ -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};

View file

@ -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"] }

View file

@ -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]) {

View 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)
}

View file

@ -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,38 +252,93 @@ 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);
Ok(())
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.
@ -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.

View file

@ -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;

View file

@ -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.

View 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,
});
}
}

View 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)
}
}

View file

@ -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()),
}
}

View file

@ -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),
}
}
}

View 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]
}
}

View file

@ -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;

View file

@ -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();
}

View file

@ -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"

View file

@ -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>;
}

View file

@ -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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
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>
"##;

View file

@ -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(&params);
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(&params);
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);
}

View file

@ -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 {

View file

@ -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()

View 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"

View 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");
}

View 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()))
}
}

View 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);
}
}
}

File diff suppressed because it is too large Load diff

View 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])
}

View 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(())
}

View file

@ -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"

View file

@ -1,5 +1,5 @@
[package]
name = "swactor-crypto-wasm"
name = "wasm-crypto"
version = "0.1.0"
edition = "2021"

View 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"

View file

@ -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}

View file

@ -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}

View 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
```

View file

@ -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"

View file

@ -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)>,

View file

@ -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