Compare commits
5 Commits
main
..
c399781369
| Author | SHA1 | Date | |
|---|---|---|---|
| c399781369 | |||
| d32895f5ca | |||
| b7fffd0d0f | |||
| b63cf8c9eb | |||
| 892fc96f71 |
@@ -4,4 +4,3 @@
|
|||||||
/.idea/
|
/.idea/
|
||||||
/components/*/wit/deps/
|
/components/*/wit/deps/
|
||||||
/components/*/wit/.deps.*
|
/components/*/wit/.deps.*
|
||||||
/docs/design/
|
|
||||||
|
|||||||
@@ -1,26 +0,0 @@
|
|||||||
{
|
|
||||||
"lsp": {
|
|
||||||
"tailwindcss-language-server": {
|
|
||||||
"settings": {
|
|
||||||
"classFunctions": [
|
|
||||||
"cva",
|
|
||||||
"cx",
|
|
||||||
"cn"
|
|
||||||
],
|
|
||||||
"experimental": {
|
|
||||||
"classRegex": [
|
|
||||||
"[cls|className]\\s\\:\\=\\s\"([^\"]*)"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"languages": {
|
|
||||||
"CSS": {
|
|
||||||
"language_servers": [
|
|
||||||
"tailwindcss-intellisense-css",
|
|
||||||
"!vscode-css-language-server"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Generated
+18
-135
@@ -673,7 +673,7 @@ version = "0.3.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1"
|
checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"thiserror 2.0.19",
|
"thiserror",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1042,27 +1042,6 @@ dependencies = [
|
|||||||
"crypto-common",
|
"crypto-common",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "directories-next"
|
|
||||||
version = "2.0.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "339ee130d97a610ea5a5872d2bbb130fdf68884ff09d3028b81bec8a1ac23bbc"
|
|
||||||
dependencies = [
|
|
||||||
"cfg-if",
|
|
||||||
"dirs-sys-next",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "dirs-sys-next"
|
|
||||||
version = "0.1.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d"
|
|
||||||
dependencies = [
|
|
||||||
"libc",
|
|
||||||
"redox_users",
|
|
||||||
"winapi",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "displaydoc"
|
name = "displaydoc"
|
||||||
version = "0.2.6"
|
version = "0.2.6"
|
||||||
@@ -1844,25 +1823,6 @@ dependencies = [
|
|||||||
"icu_properties",
|
"icu_properties",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "include_dir"
|
|
||||||
version = "0.7.4"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "923d117408f1e49d914f1a379a309cffe4f18c05cf4e3d12e613a15fc81bd0dd"
|
|
||||||
dependencies = [
|
|
||||||
"include_dir_macros",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "include_dir_macros"
|
|
||||||
version = "0.7.4"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "7cab85a7ed0bd5f0e76d93846e0147172bed2e2d3f859bcc33a8d9699cad1a75"
|
|
||||||
dependencies = [
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "index_vec"
|
name = "index_vec"
|
||||||
version = "0.1.4"
|
version = "0.1.4"
|
||||||
@@ -1977,7 +1937,7 @@ dependencies = [
|
|||||||
"jni-sys",
|
"jni-sys",
|
||||||
"log",
|
"log",
|
||||||
"simd_cesu8",
|
"simd_cesu8",
|
||||||
"thiserror 2.0.19",
|
"thiserror",
|
||||||
"walkdir",
|
"walkdir",
|
||||||
"windows-link",
|
"windows-link",
|
||||||
]
|
]
|
||||||
@@ -2035,13 +1995,6 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "kv-probe-component"
|
|
||||||
version = "0.1.0"
|
|
||||||
dependencies = [
|
|
||||||
"wit-bindgen 0.41.0",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lazy_static"
|
name = "lazy_static"
|
||||||
version = "1.5.0"
|
version = "1.5.0"
|
||||||
@@ -2097,15 +2050,6 @@ dependencies = [
|
|||||||
"cc",
|
"cc",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "libredox"
|
|
||||||
version = "0.1.18"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652"
|
|
||||||
dependencies = [
|
|
||||||
"libc",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "linkme"
|
name = "linkme"
|
||||||
version = "0.3.37"
|
version = "0.3.37"
|
||||||
@@ -2703,7 +2647,7 @@ dependencies = [
|
|||||||
"rustc-hash 2.1.3",
|
"rustc-hash 2.1.3",
|
||||||
"rustls",
|
"rustls",
|
||||||
"socket2",
|
"socket2",
|
||||||
"thiserror 2.0.19",
|
"thiserror",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
"web-time",
|
"web-time",
|
||||||
@@ -2726,7 +2670,7 @@ dependencies = [
|
|||||||
"rustls",
|
"rustls",
|
||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
"slab",
|
"slab",
|
||||||
"thiserror 2.0.19",
|
"thiserror",
|
||||||
"tinyvec",
|
"tinyvec",
|
||||||
"tracing",
|
"tracing",
|
||||||
"web-time",
|
"web-time",
|
||||||
@@ -2905,17 +2849,6 @@ dependencies = [
|
|||||||
"bitflags",
|
"bitflags",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "redox_users"
|
|
||||||
version = "0.4.6"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43"
|
|
||||||
dependencies = [
|
|
||||||
"getrandom 0.2.17",
|
|
||||||
"libredox",
|
|
||||||
"thiserror 1.0.69",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "regalloc2"
|
name = "regalloc2"
|
||||||
version = "0.13.5"
|
version = "0.13.5"
|
||||||
@@ -2999,13 +2932,6 @@ dependencies = [
|
|||||||
"web-sys",
|
"web-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "resident-probe-component"
|
|
||||||
version = "0.1.0"
|
|
||||||
dependencies = [
|
|
||||||
"wit-bindgen 0.41.0",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ring"
|
name = "ring"
|
||||||
version = "0.17.14"
|
version = "0.17.14"
|
||||||
@@ -3649,7 +3575,7 @@ dependencies = [
|
|||||||
"tantivy-stacker",
|
"tantivy-stacker",
|
||||||
"tantivy-tokenizer-api",
|
"tantivy-tokenizer-api",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"thiserror 2.0.19",
|
"thiserror",
|
||||||
"time",
|
"time",
|
||||||
"typetag",
|
"typetag",
|
||||||
"uuid",
|
"uuid",
|
||||||
@@ -3785,33 +3711,13 @@ dependencies = [
|
|||||||
"winapi-util",
|
"winapi-util",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "thiserror"
|
|
||||||
version = "1.0.69"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
|
|
||||||
dependencies = [
|
|
||||||
"thiserror-impl 1.0.69",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "thiserror"
|
name = "thiserror"
|
||||||
version = "2.0.19"
|
version = "2.0.19"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9"
|
checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"thiserror-impl 2.0.19",
|
"thiserror-impl",
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "thiserror-impl"
|
|
||||||
version = "1.0.69"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
|
|
||||||
dependencies = [
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"syn 2.0.119",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4171,7 +4077,7 @@ checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"crossbeam-channel",
|
"crossbeam-channel",
|
||||||
"symlink",
|
"symlink",
|
||||||
"thiserror 2.0.19",
|
"thiserror",
|
||||||
"time",
|
"time",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
]
|
]
|
||||||
@@ -4239,7 +4145,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "6b6c49aecd4abae3ffa88ab1e28b3de7a7497a9732be828e3a6f1855b6ea80fd"
|
checksum = "6b6c49aecd4abae3ffa88ab1e28b3de7a7497a9732be828e3a6f1855b6ea80fd"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"mimalloc",
|
"mimalloc",
|
||||||
"thiserror 2.0.19",
|
"thiserror",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
"turso_core",
|
"turso_core",
|
||||||
@@ -4306,7 +4212,7 @@ dependencies = [
|
|||||||
"strum_macros",
|
"strum_macros",
|
||||||
"tantivy",
|
"tantivy",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"thiserror 2.0.19",
|
"thiserror",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
"turso_ext",
|
"turso_ext",
|
||||||
@@ -4351,7 +4257,7 @@ dependencies = [
|
|||||||
"miette",
|
"miette",
|
||||||
"strum",
|
"strum",
|
||||||
"strum_macros",
|
"strum_macros",
|
||||||
"thiserror 2.0.19",
|
"thiserror",
|
||||||
"turso_macros",
|
"turso_macros",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -4399,7 +4305,7 @@ dependencies = [
|
|||||||
"roaring",
|
"roaring",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"thiserror 2.0.19",
|
"thiserror",
|
||||||
"tracing",
|
"tracing",
|
||||||
"turso_core",
|
"turso_core",
|
||||||
"turso_parser",
|
"turso_parser",
|
||||||
@@ -4728,13 +4634,11 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"axum",
|
"axum",
|
||||||
"base64 0.23.0",
|
"base64 0.23.0",
|
||||||
"include_dir",
|
|
||||||
"mime_guess",
|
|
||||||
"semver",
|
"semver",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"thiserror 2.0.19",
|
"thiserror",
|
||||||
"toasty",
|
"toasty",
|
||||||
"toasty-driver-turso",
|
"toasty-driver-turso",
|
||||||
"tokio",
|
"tokio",
|
||||||
@@ -4757,7 +4661,7 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2",
|
"sha2",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"thiserror 2.0.19",
|
"thiserror",
|
||||||
"toml",
|
"toml",
|
||||||
"wit-component 0.243.0",
|
"wit-component 0.243.0",
|
||||||
"wit-parser 0.243.0",
|
"wit-parser 0.243.0",
|
||||||
@@ -4769,7 +4673,7 @@ name = "wasmeld-runtime"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
"thiserror 2.0.19",
|
"thiserror",
|
||||||
"toml",
|
"toml",
|
||||||
"wasmeld-package",
|
"wasmeld-package",
|
||||||
"wasmtime",
|
"wasmtime",
|
||||||
@@ -4844,7 +4748,6 @@ dependencies = [
|
|||||||
"target-lexicon",
|
"target-lexicon",
|
||||||
"wasmparser 0.243.0",
|
"wasmparser 0.243.0",
|
||||||
"wasmtime-environ",
|
"wasmtime-environ",
|
||||||
"wasmtime-internal-cache",
|
|
||||||
"wasmtime-internal-component-macro",
|
"wasmtime-internal-component-macro",
|
||||||
"wasmtime-internal-component-util",
|
"wasmtime-internal-component-util",
|
||||||
"wasmtime-internal-cranelift",
|
"wasmtime-internal-cranelift",
|
||||||
@@ -4884,26 +4787,6 @@ dependencies = [
|
|||||||
"wasmtime-internal-component-util",
|
"wasmtime-internal-component-util",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "wasmtime-internal-cache"
|
|
||||||
version = "41.0.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "2a31a582632f47753e4a9a5412ab5edfbc05a6df9031336a2f2eff46a70a1cac"
|
|
||||||
dependencies = [
|
|
||||||
"base64 0.22.1",
|
|
||||||
"directories-next",
|
|
||||||
"log",
|
|
||||||
"postcard",
|
|
||||||
"rustix 1.1.4",
|
|
||||||
"serde",
|
|
||||||
"serde_derive",
|
|
||||||
"sha2",
|
|
||||||
"toml",
|
|
||||||
"wasmtime-environ",
|
|
||||||
"windows-sys 0.61.2",
|
|
||||||
"zstd",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasmtime-internal-component-macro"
|
name = "wasmtime-internal-component-macro"
|
||||||
version = "41.0.0"
|
version = "41.0.0"
|
||||||
@@ -4944,7 +4827,7 @@ dependencies = [
|
|||||||
"pulley-interpreter",
|
"pulley-interpreter",
|
||||||
"smallvec",
|
"smallvec",
|
||||||
"target-lexicon",
|
"target-lexicon",
|
||||||
"thiserror 2.0.19",
|
"thiserror",
|
||||||
"wasmparser 0.243.0",
|
"wasmparser 0.243.0",
|
||||||
"wasmtime-environ",
|
"wasmtime-environ",
|
||||||
"wasmtime-internal-math",
|
"wasmtime-internal-math",
|
||||||
@@ -5079,7 +4962,7 @@ dependencies = [
|
|||||||
"io-lifetimes",
|
"io-lifetimes",
|
||||||
"rustix 1.1.4",
|
"rustix 1.1.4",
|
||||||
"system-interface",
|
"system-interface",
|
||||||
"thiserror 2.0.19",
|
"thiserror",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
"url",
|
"url",
|
||||||
@@ -5186,7 +5069,7 @@ dependencies = [
|
|||||||
"regalloc2",
|
"regalloc2",
|
||||||
"smallvec",
|
"smallvec",
|
||||||
"target-lexicon",
|
"target-lexicon",
|
||||||
"thiserror 2.0.19",
|
"thiserror",
|
||||||
"wasmparser 0.243.0",
|
"wasmparser 0.243.0",
|
||||||
"wasmtime-environ",
|
"wasmtime-environ",
|
||||||
"wasmtime-internal-cranelift",
|
"wasmtime-internal-cranelift",
|
||||||
@@ -5650,7 +5533,7 @@ dependencies = [
|
|||||||
"flate2",
|
"flate2",
|
||||||
"indexmap",
|
"indexmap",
|
||||||
"memchr",
|
"memchr",
|
||||||
"thiserror 2.0.19",
|
"thiserror",
|
||||||
"zopfli",
|
"zopfli",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
+1
-5
@@ -8,8 +8,6 @@ members = [
|
|||||||
"components/fault",
|
"components/fault",
|
||||||
"components/spin",
|
"components/spin",
|
||||||
"components/clock-probe",
|
"components/clock-probe",
|
||||||
"components/kv-probe",
|
|
||||||
"components/resident-probe",
|
|
||||||
"components/wasi-clock-probe",
|
"components/wasi-clock-probe",
|
||||||
]
|
]
|
||||||
default-members = ["crates/wasmeld-runtime"]
|
default-members = ["crates/wasmeld-runtime"]
|
||||||
@@ -23,8 +21,6 @@ license = "Apache-2.0"
|
|||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
axum = { version = "0.8.9", features = ["multipart"] }
|
axum = { version = "0.8.9", features = ["multipart"] }
|
||||||
base64 = "0.23.0"
|
base64 = "0.23.0"
|
||||||
include_dir = "0.7.4"
|
|
||||||
mime_guess = "2.0.5"
|
|
||||||
reqwest = { version = "0.13.4", default-features = false, features = ["blocking", "json", "multipart", "rustls"] }
|
reqwest = { version = "0.13.4", default-features = false, features = ["blocking", "json", "multipart", "rustls"] }
|
||||||
serde = { version = "1.0.228", features = ["derive"] }
|
serde = { version = "1.0.228", features = ["derive"] }
|
||||||
serde_json = "1.0.149"
|
serde_json = "1.0.149"
|
||||||
@@ -38,7 +34,7 @@ toml = "0.9.8"
|
|||||||
tower-http = { version = "0.7.0", features = ["cors", "trace"] }
|
tower-http = { version = "0.7.0", features = ["cors", "trace"] }
|
||||||
tracing = "0.1.44"
|
tracing = "0.1.44"
|
||||||
tracing-subscriber = { version = "0.3.23", features = ["env-filter", "fmt"] }
|
tracing-subscriber = { version = "0.3.23", features = ["env-filter", "fmt"] }
|
||||||
wasmtime = { version = "=41.0.0", default-features = false, features = ["cache", "component-model", "cranelift", "runtime", "std"] }
|
wasmtime = { version = "=41.0.0", default-features = false, features = ["component-model", "cranelift", "runtime", "std"] }
|
||||||
wasmtime-wasi = { version = "=41.0.0", default-features = false, features = ["p2"] }
|
wasmtime-wasi = { version = "=41.0.0", default-features = false, features = ["p2"] }
|
||||||
wit-bindgen = "=0.41.0"
|
wit-bindgen = "=0.41.0"
|
||||||
wit-component = "=0.243.0"
|
wit-component = "=0.243.0"
|
||||||
|
|||||||
@@ -4,39 +4,14 @@ Wasmeld 是面向 WebAssembly Component 的服务运行与管理平台。内部
|
|||||||
Component,在受限 Wasmtime Sandbox 中以常驻 Actor 运行,并通过稳定 WIT 契约
|
Component,在受限 Wasmtime Sandbox 中以常驻 Actor 运行,并通过稳定 WIT 契约
|
||||||
暴露能力。
|
暴露能力。
|
||||||
|
|
||||||
Runtime 内置版本化 Host Capability Registry。注册 Component 时会直接读取其 WIT
|
|
||||||
imports,只链接实际请求且版本完全匹配的 Host 能力;未知能力会被拒绝。能力不需要在
|
|
||||||
`.wasmpkg` 中重复声明,管理面会展示每个服务版本解析出的完整接口标识。
|
|
||||||
|
|
||||||
当前 Host 能力:
|
|
||||||
|
|
||||||
- `wasmeld:clock/monotonic-clock@0.1.0`:Actor 内单调时钟
|
|
||||||
- `wasmeld:kv/store@0.1.0`:按服务隔离、跨 Revision 共享的持久化二进制 KV
|
|
||||||
|
|
||||||
常驻型 Component 可额外导出 `wasmeld:resident/actor@0.1.0`。Runtime 将定时器、
|
|
||||||
TCP/UDP/Unix、消息订阅和扩展事件放入同一个有界 Actor mailbox;Component 每次只处理
|
|
||||||
一个事件并返回 effect。系统 socket、timer task 和 broker consumer 始终由 Host 持有,
|
|
||||||
Component 只能引用当前 Revision 内不复用的资源 ID,不能直接取得文件描述符或绕过
|
|
||||||
端点策略。
|
|
||||||
|
|
||||||
```text
|
|
||||||
Host driver -> ResidentSession -> Actor mailbox -> Wasm Component
|
|
||||||
Host driver <- validated operation <- raw effect <-
|
|
||||||
```
|
|
||||||
|
|
||||||
`ResidentSession` 负责 deny-by-default 的网络策略、资源归属、数量限制、流的暂停/半关闭/
|
|
||||||
关闭状态以及 effect 批量原子校验。TCP、UDP、Unix listener 等异步驱动运行在 Actor
|
|
||||||
线程外;协议级驱动以及文件监听、串口、系统信号等其它来源通过独立版本的 WIT 能力演进,
|
|
||||||
不需要扩大基础 service world。
|
|
||||||
|
|
||||||
## 结构
|
## 结构
|
||||||
|
|
||||||
```text
|
```text
|
||||||
crates/wasmeld-runtime Component Runtime 与 Sandbox
|
crates/wasmeld-runtime Component Runtime 与 Sandbox
|
||||||
crates/wasmeld-console 管理 API 与持久化
|
crates/wasmeld-console 管理 API 与持久化
|
||||||
└── web/ Solid + Tailwind v4 管理面
|
|
||||||
crates/wasmeld-package wasmeld CLI、组件包与 WIT 依赖管理
|
crates/wasmeld-package wasmeld CLI、组件包与 WIT 依赖管理
|
||||||
components/ 示例 Component
|
components/ 示例 Component
|
||||||
|
console/ TanStack Start 管理面
|
||||||
wit/ Wasmeld WIT package 源码
|
wit/ Wasmeld WIT package 源码
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -48,63 +23,13 @@ wit/ Wasmeld WIT package 源码
|
|||||||
cargo +stable run -p wasmeld-console
|
cargo +stable run -p wasmeld-console
|
||||||
```
|
```
|
||||||
|
|
||||||
后端会启动本地管理 API `127.0.0.1:8080` 和公开数据面
|
管理面:
|
||||||
`0.0.0.0:8081`。注册 Component 后,通过管理 API 激活一个版本:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -X POST http://127.0.0.1:8080/api/v1/deployments/echo/activate \
|
cd console
|
||||||
-H 'Content-Type: application/json' \
|
|
||||||
-d '{"revision":"0.1.0"}'
|
|
||||||
```
|
|
||||||
|
|
||||||
客户端只访问 Gateway,并以原始二进制请求调用活动版本:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl http://127.0.0.1:8081/v1/services/echo/invoke \
|
|
||||||
-H 'Content-Type: application/octet-stream' \
|
|
||||||
--data-binary 'hello'
|
|
||||||
```
|
|
||||||
|
|
||||||
管理面开发服务器:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd crates/wasmeld-console/web
|
|
||||||
npm ci
|
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
打开 `http://127.0.0.1:3000`。开发期间 Rust 和 Web 独立增量构建;Release 构建会自动
|
|
||||||
构建 Web 并嵌入 `wasmeld-console`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cargo +stable build --release -p wasmeld-console
|
|
||||||
./target/release/wasmeld-console
|
|
||||||
```
|
|
||||||
|
|
||||||
此时管理面直接由 `http://127.0.0.1:8080` 提供,不需要部署单独的静态站点。Host +
|
|
||||||
iframe 页面生命周期、PWA 缓存和前端目录约定见
|
|
||||||
[`Console Web 文档`](crates/wasmeld-console/web/README.md)。
|
|
||||||
|
|
||||||
## 组件开发
|
|
||||||
|
|
||||||
Console 启动后,使用开发模式监听组件源码和本地 WIT `replace`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cargo run -p wasmeld-package --bin wasmeld -- \
|
|
||||||
dev components/echo/Cargo.toml
|
|
||||||
```
|
|
||||||
|
|
||||||
首次构建和每次源码变化都会自动完成 WIT 同步、Debug 编译、打包、注册和 Deployment
|
|
||||||
切换。默认使用 `echo-dev` 这类独立服务 ID,并根据 Component 内容生成
|
|
||||||
`0.1.0-dev.h<hash>` Revision,不会覆盖正式服务。新版本构建、校验或启动失败时,上一
|
|
||||||
版本继续运行;切换成功后旧开发 Revision 会被注销并释放。编译期间的新保存会继续
|
|
||||||
排队构建,Console 暂时不可用时则复用已构建制品重试部署。
|
|
||||||
|
|
||||||
管理面每 5 秒刷新一次,可以直接在调用面板预览新版本。只构建并部署一次可使用
|
|
||||||
`--once`;其它选项包括 `--id`、`--console`、`--release` 和 `--poll-ms`。管理 API
|
|
||||||
地址也可通过 `WASMELD_CONSOLE` 设置。默认允许本地 `replace` 更新 `wit.lock`;若开发
|
|
||||||
期间不允许 WIT 契约发生变化,再添加 `--locked`。
|
|
||||||
|
|
||||||
构建组件:
|
构建组件:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -112,26 +37,6 @@ cargo run -p wasmeld-package --bin wasmeld -- \
|
|||||||
pack components/echo/Cargo.toml --locked
|
pack components/echo/Cargo.toml --locked
|
||||||
```
|
```
|
||||||
|
|
||||||
KV 示例组件:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cargo run -p wasmeld-package --bin wasmeld -- \
|
|
||||||
pack components/kv-probe/Cargo.toml --locked
|
|
||||||
```
|
|
||||||
|
|
||||||
`kv-probe` 接受 `set:<key>:<value>`、`get:<key>` 和 `delete:<key>`,用于验证 Host KV
|
|
||||||
能力;它不是公开 Gateway 的业务协议。
|
|
||||||
|
|
||||||
常驻事件示例组件:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cargo run -p wasmeld-package --bin wasmeld -- \
|
|
||||||
pack components/resident-probe/Cargo.toml --locked
|
|
||||||
```
|
|
||||||
|
|
||||||
该组件同时实现基础 service world 与 resident actor export,用于验证 stream、datagram、
|
|
||||||
timer、message 和扩展 source 的事件/effect 往返。
|
|
||||||
|
|
||||||
发布 WIT Package:
|
发布 WIT Package:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -141,7 +46,5 @@ cargo run -p wasmeld-package --bin wasmeld -- \
|
|||||||
|
|
||||||
组件在 `wasmeld.toml` 中声明 Registry 和精确版本依赖,`wasmeld wit fetch` 会递归
|
组件在 `wasmeld.toml` 中声明 Registry 和精确版本依赖,`wasmeld wit fetch` 会递归
|
||||||
解析依赖、生成 `wit/deps`,并将完整解析结果记录在 `wit.lock`。本地开发可使用
|
解析依赖、生成 `wit/deps`,并将完整解析结果记录在 `wit.lock`。本地开发可使用
|
||||||
`[replace]` 临时覆盖 Registry 来源。
|
`[replace]` 临时覆盖 Registry 来源。详见
|
||||||
|
[`docs/design/wit-package-management.md`](docs/design/wit-package-management.md)。
|
||||||
完整命令、开发选项、组件包格式和 WIT 依赖说明见
|
|
||||||
[`wasmeld-package` CLI 文档](crates/wasmeld-package/README.md)。
|
|
||||||
|
|||||||
@@ -1,12 +1,4 @@
|
|||||||
//! Example service that imports the versioned Wasmeld monotonic-clock capability.
|
|
||||||
//!
|
|
||||||
//! Each response is eight little-endian timestamp bytes followed by the input.
|
|
||||||
//! The timestamp is Actor-local monotonic time, not a Unix timestamp.
|
|
||||||
|
|
||||||
mod bindings {
|
mod bindings {
|
||||||
// This macro generates Rust ABI adapters at compile time from the
|
|
||||||
// materialized local WIT graph. It does not make a remote protocol call or
|
|
||||||
// embed the WIT source files; the Component carries canonical ABI types.
|
|
||||||
wit_bindgen::generate!({
|
wit_bindgen::generate!({
|
||||||
path: "wit",
|
path: "wit",
|
||||||
world: "clock-probe-component",
|
world: "clock-probe-component",
|
||||||
|
|||||||
@@ -4,12 +4,12 @@ schema_version = 1
|
|||||||
name = "wasmeld:clock"
|
name = "wasmeld:clock"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "path+../../wit/clock"
|
source = "path+../../wit/clock"
|
||||||
sha256 = "43ece485a550eca94cf06cb711f0b42c9f4977d275e9a042c3ac84eeb1f941e1"
|
sha256 = "93079c69d1b9cb3afc28da6fe4a7e37f724d0a09e780c847f8524d3920a90960"
|
||||||
replaced = true
|
replaced = true
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasmeld:service"
|
name = "wasmeld:service"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "path+../../wit/service"
|
source = "path+../../wit/service"
|
||||||
sha256 = "1b2069606ccbf5202789667570bc824f702f34b147aa38e81c2ab677444ffa0e"
|
sha256 = "d5497307bbcd1e159f7707f385b488a6f2e26362d5256d5bc1080ac603a51305"
|
||||||
replaced = true
|
replaced = true
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
package component:clock-probe@0.1.0;
|
package component:clock-probe@0.1.0;
|
||||||
|
|
||||||
/// Base service exports plus the exact monotonic-clock Host capability used by
|
|
||||||
/// this Component. Other Wasmeld capabilities are not linked implicitly.
|
|
||||||
world clock-probe-component {
|
world clock-probe-component {
|
||||||
include wasmeld:service/service-component@0.1.0;
|
include wasmeld:service/service-component@0.1.0;
|
||||||
import wasmeld:clock/monotonic-clock@0.1.0;
|
import wasmeld:clock/monotonic-clock@0.1.0;
|
||||||
|
|||||||
@@ -1,14 +1,6 @@
|
|||||||
//! Minimal stateful Component used to demonstrate a warm Actor Store.
|
|
||||||
//!
|
|
||||||
//! Invoke returns an eight-byte little-endian counter. The value survives
|
|
||||||
//! calls to the same Actor but resets on `init`, restart, or process recovery.
|
|
||||||
|
|
||||||
use core::sync::atomic::{AtomicU64, Ordering};
|
use core::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
|
||||||
mod bindings {
|
mod bindings {
|
||||||
// Bindings are generated from `wit/world.wit` plus materialized
|
|
||||||
// `wit/deps`; WIT is a compile-time ABI contract, not downloaded at run
|
|
||||||
// time and not executed as code.
|
|
||||||
wit_bindgen::generate!({
|
wit_bindgen::generate!({
|
||||||
path: "wit",
|
path: "wit",
|
||||||
world: "counter-component",
|
world: "counter-component",
|
||||||
|
|||||||
@@ -4,5 +4,5 @@ schema_version = 1
|
|||||||
name = "wasmeld:service"
|
name = "wasmeld:service"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "path+../../wit/service"
|
source = "path+../../wit/service"
|
||||||
sha256 = "1b2069606ccbf5202789667570bc824f702f34b147aa38e81c2ab677444ffa0e"
|
sha256 = "d5497307bbcd1e159f7707f385b488a6f2e26362d5256d5bc1080ac603a51305"
|
||||||
replaced = true
|
replaced = true
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
package component:counter@0.1.0;
|
package component:counter@0.1.0;
|
||||||
|
|
||||||
/// Counter only needs the base service lifecycle and imports no Host capability.
|
|
||||||
world counter-component {
|
world counter-component {
|
||||||
include wasmeld:service/service-component@0.1.0;
|
include wasmeld:service/service-component@0.1.0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,4 @@
|
|||||||
//! Smallest request/response Component example: it returns input bytes unchanged.
|
|
||||||
|
|
||||||
mod bindings {
|
mod bindings {
|
||||||
// Generate the guest trait and export adapter from the selected WIT world.
|
|
||||||
// Dependency sync must populate `wit/deps` before this macro is compiled.
|
|
||||||
wit_bindgen::generate!({
|
wit_bindgen::generate!({
|
||||||
path: "wit",
|
path: "wit",
|
||||||
world: "echo-component",
|
world: "echo-component",
|
||||||
|
|||||||
@@ -4,5 +4,5 @@ schema_version = 1
|
|||||||
name = "wasmeld:service"
|
name = "wasmeld:service"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "path+../../wit/service"
|
source = "path+../../wit/service"
|
||||||
sha256 = "1b2069606ccbf5202789667570bc824f702f34b147aa38e81c2ab677444ffa0e"
|
sha256 = "d5497307bbcd1e159f7707f385b488a6f2e26362d5256d5bc1080ac603a51305"
|
||||||
replaced = true
|
replaced = true
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
package component:echo@0.1.0;
|
package component:echo@0.1.0;
|
||||||
|
|
||||||
/// Echo only needs the base service lifecycle and imports no Host capability.
|
|
||||||
world echo-component {
|
world echo-component {
|
||||||
include wasmeld:service/service-component@0.1.0;
|
include wasmeld:service/service-component@0.1.0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,4 @@
|
|||||||
//! Deliberately trapping Component used to verify Actor fault isolation.
|
|
||||||
//!
|
|
||||||
//! Sending the exact bytes `fault` triggers a panic. This crate is a test
|
|
||||||
//! fixture and must not be deployed as an application service.
|
|
||||||
|
|
||||||
mod bindings {
|
mod bindings {
|
||||||
// Compile the versioned WIT world into canonical ABI Rust bindings.
|
|
||||||
wit_bindgen::generate!({
|
wit_bindgen::generate!({
|
||||||
path: "wit",
|
path: "wit",
|
||||||
world: "fault-component",
|
world: "fault-component",
|
||||||
@@ -19,8 +13,6 @@ impl bindings::Guest for Fault {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn invoke(input: Vec<u8>) -> Result<Vec<u8>, bindings::ServiceError> {
|
fn invoke(input: Vec<u8>) -> Result<Vec<u8>, bindings::ServiceError> {
|
||||||
// A panic becomes a Wasm trap so Runtime tests can assert that one
|
|
||||||
// faulted Actor does not corrupt other service instances.
|
|
||||||
assert_ne!(input, b"fault", "intentional test fault");
|
assert_ne!(input, b"fault", "intentional test fault");
|
||||||
Ok(input)
|
Ok(input)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,5 +4,5 @@ schema_version = 1
|
|||||||
name = "wasmeld:service"
|
name = "wasmeld:service"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "path+../../wit/service"
|
source = "path+../../wit/service"
|
||||||
sha256 = "1b2069606ccbf5202789667570bc824f702f34b147aa38e81c2ab677444ffa0e"
|
sha256 = "d5497307bbcd1e159f7707f385b488a6f2e26362d5256d5bc1080ac603a51305"
|
||||||
replaced = true
|
replaced = true
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
package component:fault@0.1.0;
|
package component:fault@0.1.0;
|
||||||
|
|
||||||
/// Fault fixture exports only the base service lifecycle.
|
|
||||||
world fault-component {
|
world fault-component {
|
||||||
include wasmeld:service/service-component@0.1.0;
|
include wasmeld:service/service-component@0.1.0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "kv-probe-component"
|
|
||||||
version = "0.1.0"
|
|
||||||
edition.workspace = true
|
|
||||||
rust-version.workspace = true
|
|
||||||
license.workspace = true
|
|
||||||
|
|
||||||
[package.metadata.wasmeld]
|
|
||||||
id = "kv-probe"
|
|
||||||
world = "component:kv-probe/kv-probe-component@0.1.0"
|
|
||||||
|
|
||||||
[lib]
|
|
||||||
crate-type = ["cdylib"]
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
wit-bindgen.workspace = true
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
//! Example service that exercises persistent, service-scoped Host KV.
|
|
||||||
//!
|
|
||||||
//! Input uses the test protocol `get:<key>`, `set:<key>:<value>`, or
|
|
||||||
//! `delete:<key>`. Production Components should define an application-specific
|
|
||||||
//! request format instead of copying this colon-delimited probe protocol.
|
|
||||||
|
|
||||||
mod bindings {
|
|
||||||
// `generate_all` emits both the service export and imported KV module from
|
|
||||||
// the exact versions selected in `wit/world.wit`.
|
|
||||||
wit_bindgen::generate!({
|
|
||||||
path: "wit",
|
|
||||||
world: "kv-probe-component",
|
|
||||||
generate_all,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
use bindings::wasmeld::kv::store;
|
|
||||||
|
|
||||||
struct KvProbe;
|
|
||||||
|
|
||||||
impl bindings::Guest for KvProbe {
|
|
||||||
fn init(_config: Vec<u8>) -> Result<(), bindings::ServiceError> {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn invoke(input: Vec<u8>) -> Result<Vec<u8>, bindings::ServiceError> {
|
|
||||||
if let Some(key) = input.strip_prefix(b"get:") {
|
|
||||||
let key = parse_key(key)?;
|
|
||||||
return store::get(&key)
|
|
||||||
.map(|value| value.unwrap_or_default())
|
|
||||||
.map_err(kv_error);
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(command) = input.strip_prefix(b"set:") {
|
|
||||||
let separator = command
|
|
||||||
.iter()
|
|
||||||
.position(|byte| *byte == b':')
|
|
||||||
.ok_or_else(|| {
|
|
||||||
bindings::ServiceError::CallFailed(
|
|
||||||
"set command must be set:<key>:<value>".to_owned(),
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
let key = parse_key(&command[..separator])?;
|
|
||||||
let value = command[separator + 1..].to_vec();
|
|
||||||
store::set(&key, &value).map_err(kv_error)?;
|
|
||||||
return Ok(Vec::new());
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(key) = input.strip_prefix(b"delete:") {
|
|
||||||
let key = parse_key(key)?;
|
|
||||||
store::delete(&key).map_err(kv_error)?;
|
|
||||||
return Ok(Vec::new());
|
|
||||||
}
|
|
||||||
|
|
||||||
Err(bindings::ServiceError::CallFailed(
|
|
||||||
"expected get:<key>, set:<key>:<value>, or delete:<key>".to_owned(),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_key(bytes: &[u8]) -> Result<String, bindings::ServiceError> {
|
|
||||||
core::str::from_utf8(bytes)
|
|
||||||
.map(str::to_owned)
|
|
||||||
.map_err(|_| bindings::ServiceError::CallFailed("key must be valid UTF-8".to_owned()))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn kv_error(error: store::KvError) -> bindings::ServiceError {
|
|
||||||
bindings::ServiceError::CallFailed(format!("KV operation failed: {error:?}"))
|
|
||||||
}
|
|
||||||
|
|
||||||
bindings::export!(KvProbe with_types_in bindings);
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
schema_version = 1
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
"wasmeld:kv" = "0.1.0"
|
|
||||||
"wasmeld:service" = "0.1.0"
|
|
||||||
|
|
||||||
[replace."wasmeld:kv"]
|
|
||||||
path = "../../wit/kv"
|
|
||||||
|
|
||||||
[replace."wasmeld:service"]
|
|
||||||
path = "../../wit/service"
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
schema_version = 1
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "wasmeld:kv"
|
|
||||||
version = "0.1.0"
|
|
||||||
source = "path+../../wit/kv"
|
|
||||||
sha256 = "10dc81b1f04bd9c8f7b324a96e0c1d24d5af0c2dabfc723d003b8729cfd933d9"
|
|
||||||
replaced = true
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "wasmeld:service"
|
|
||||||
version = "0.1.0"
|
|
||||||
source = "path+../../wit/service"
|
|
||||||
sha256 = "1b2069606ccbf5202789667570bc824f702f34b147aa38e81c2ab677444ffa0e"
|
|
||||||
replaced = true
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
package component:kv-probe@0.1.0;
|
|
||||||
|
|
||||||
/// Base service exports plus the exact persistent KV Host capability used by
|
|
||||||
/// this Component.
|
|
||||||
world kv-probe-component {
|
|
||||||
include wasmeld:service/service-component@0.1.0;
|
|
||||||
import wasmeld:kv/store@0.1.0;
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "resident-probe-component"
|
|
||||||
version = "0.1.0"
|
|
||||||
edition.workspace = true
|
|
||||||
rust-version.workspace = true
|
|
||||||
license.workspace = true
|
|
||||||
|
|
||||||
[package.metadata.wasmeld]
|
|
||||||
id = "resident-probe"
|
|
||||||
world = "component:resident-probe/resident-probe-component@0.1.0"
|
|
||||||
|
|
||||||
[lib]
|
|
||||||
crate-type = ["cdylib"]
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
wit-bindgen.workspace = true
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
//! Resident event/effect fixture covering every current Host-driven I/O family.
|
|
||||||
//!
|
|
||||||
//! The callback echoes stream chunks and datagrams, rearms fired timers,
|
|
||||||
//! acknowledges messages, and reflects extension commands. It owns no socket,
|
|
||||||
//! timer, or subscription handles; those resources remain in the Host driver.
|
|
||||||
|
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
|
||||||
|
|
||||||
mod bindings {
|
|
||||||
// One generated module contains both the base service export and resident
|
|
||||||
// actor export because this component world composes both WIT packages.
|
|
||||||
wit_bindgen::generate!({
|
|
||||||
path: "wit",
|
|
||||||
world: "resident-probe-component",
|
|
||||||
generate_all,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
use bindings::exports::wasmeld::resident::actor::{
|
|
||||||
DatagramSend, Effect, Event, Guest as ResidentGuest, MessageAck, SourceCommand, StreamWrite,
|
|
||||||
TimerArm,
|
|
||||||
};
|
|
||||||
|
|
||||||
static EVENTS_HANDLED: AtomicU64 = AtomicU64::new(0);
|
|
||||||
|
|
||||||
struct ResidentProbe;
|
|
||||||
|
|
||||||
impl bindings::Guest for ResidentProbe {
|
|
||||||
fn init(_config: Vec<u8>) -> Result<(), bindings::ServiceError> {
|
|
||||||
EVENTS_HANDLED.store(0, Ordering::Relaxed);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn invoke(_input: Vec<u8>) -> Result<Vec<u8>, bindings::ServiceError> {
|
|
||||||
Ok(EVENTS_HANDLED
|
|
||||||
.load(Ordering::Relaxed)
|
|
||||||
.to_le_bytes()
|
|
||||||
.to_vec())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ResidentGuest for ResidentProbe {
|
|
||||||
fn handle_event(
|
|
||||||
input: Event,
|
|
||||||
) -> Result<Vec<Effect>, bindings::exports::wasmeld::resident::actor::ResidentError> {
|
|
||||||
// Event callbacks are serialized with normal invocations by the same
|
|
||||||
// Actor mailbox, so this in-memory count is deterministic per Actor.
|
|
||||||
EVENTS_HANDLED.fetch_add(1, Ordering::Relaxed);
|
|
||||||
Ok(match input {
|
|
||||||
Event::StreamData(chunk) => vec![Effect::WriteStream(StreamWrite {
|
|
||||||
stream_id: chunk.stream_id,
|
|
||||||
bytes: chunk.bytes,
|
|
||||||
})],
|
|
||||||
Event::Datagram(datagram) => vec![Effect::SendDatagram(DatagramSend {
|
|
||||||
endpoint_id: datagram.endpoint_id,
|
|
||||||
peer: datagram.peer,
|
|
||||||
bytes: datagram.bytes,
|
|
||||||
})],
|
|
||||||
Event::Timer(timer) => vec![Effect::ArmTimer(TimerArm {
|
|
||||||
timer_id: timer.timer_id,
|
|
||||||
delay_ms: 10,
|
|
||||||
interval_ms: None,
|
|
||||||
})],
|
|
||||||
Event::Message(message) => vec![Effect::AcknowledgeMessage(MessageAck {
|
|
||||||
subscription_id: message.subscription_id,
|
|
||||||
message_id: message.message_id,
|
|
||||||
})],
|
|
||||||
Event::Source(source) => vec![Effect::SourceCommand(SourceCommand {
|
|
||||||
source_id: source.source_id,
|
|
||||||
command: source.kind,
|
|
||||||
payload: source.payload,
|
|
||||||
})],
|
|
||||||
Event::StreamOpened(_)
|
|
||||||
| Event::StreamWritable(_)
|
|
||||||
| Event::StreamHalfClosed(_)
|
|
||||||
| Event::StreamClosed(_)
|
|
||||||
| Event::Shutdown => Vec::new(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
bindings::export!(ResidentProbe with_types_in bindings);
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
schema_version = 1
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
"wasmeld:resident" = "0.1.0"
|
|
||||||
"wasmeld:service" = "0.1.0"
|
|
||||||
|
|
||||||
[replace."wasmeld:resident"]
|
|
||||||
path = "../../wit/resident"
|
|
||||||
|
|
||||||
[replace."wasmeld:service"]
|
|
||||||
path = "../../wit/service"
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
schema_version = 1
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "wasmeld:resident"
|
|
||||||
version = "0.1.0"
|
|
||||||
source = "path+../../wit/resident"
|
|
||||||
sha256 = "a13728b0e6f000c03eb0f88e2e54bad1c6e5160ac3ca4b9e87619247c0043d1b"
|
|
||||||
replaced = true
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "wasmeld:service"
|
|
||||||
version = "0.1.0"
|
|
||||||
source = "path+../../wit/service"
|
|
||||||
sha256 = "1b2069606ccbf5202789667570bc824f702f34b147aa38e81c2ab677444ffa0e"
|
|
||||||
replaced = true
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
package component:resident-probe@0.1.0;
|
|
||||||
|
|
||||||
/// Composes ordinary request/response exports with the optional resident actor
|
|
||||||
/// export. Network and timer drivers remain Host-owned.
|
|
||||||
world resident-probe-component {
|
|
||||||
include wasmeld:service/service-component@0.1.0;
|
|
||||||
export wasmeld:resident/actor@0.1.0;
|
|
||||||
}
|
|
||||||
@@ -1,11 +1,4 @@
|
|||||||
//! CPU-bound fixture used to verify fuel and epoch deadline interruption.
|
|
||||||
//!
|
|
||||||
//! The input's first eight bytes select a little-endian iteration count.
|
|
||||||
//! Initializing with `spin` intentionally runs without a finite loop bound.
|
|
||||||
//! This crate is a Runtime test fixture, not an application service.
|
|
||||||
|
|
||||||
mod bindings {
|
mod bindings {
|
||||||
// Compile the base service WIT contract into guest and export adapters.
|
|
||||||
wit_bindgen::generate!({
|
wit_bindgen::generate!({
|
||||||
path: "wit",
|
path: "wit",
|
||||||
world: "spin-component",
|
world: "spin-component",
|
||||||
|
|||||||
@@ -4,5 +4,5 @@ schema_version = 1
|
|||||||
name = "wasmeld:service"
|
name = "wasmeld:service"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "path+../../wit/service"
|
source = "path+../../wit/service"
|
||||||
sha256 = "1b2069606ccbf5202789667570bc824f702f34b147aa38e81c2ab677444ffa0e"
|
sha256 = "d5497307bbcd1e159f7707f385b488a6f2e26362d5256d5bc1080ac603a51305"
|
||||||
replaced = true
|
replaced = true
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
package component:spin@0.1.0;
|
package component:spin@0.1.0;
|
||||||
|
|
||||||
/// Spin fixture exports only the base service lifecycle.
|
|
||||||
world spin-component {
|
world spin-component {
|
||||||
include wasmeld:service/service-component@0.1.0;
|
include wasmeld:service/service-component@0.1.0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,4 @@
|
|||||||
//! Fixture proving that ambient WASI clock imports are denied by the sandbox.
|
|
||||||
//!
|
|
||||||
//! Calling `std::time::Instant::now` causes this Component to import WASI
|
|
||||||
//! monotonic-clock directly. Wasmeld only links explicitly approved capability
|
|
||||||
//! packages, so registration or startup must reject this fixture.
|
|
||||||
|
|
||||||
mod bindings {
|
mod bindings {
|
||||||
// The declared service WIT contains no WASI clock capability; the direct
|
|
||||||
// standard-library use below deliberately creates an undeclared import.
|
|
||||||
wit_bindgen::generate!({
|
wit_bindgen::generate!({
|
||||||
path: "wit",
|
path: "wit",
|
||||||
world: "wasi-clock-probe-component",
|
world: "wasi-clock-probe-component",
|
||||||
|
|||||||
@@ -4,5 +4,5 @@ schema_version = 1
|
|||||||
name = "wasmeld:service"
|
name = "wasmeld:service"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "path+../../wit/service"
|
source = "path+../../wit/service"
|
||||||
sha256 = "1b2069606ccbf5202789667570bc824f702f34b147aa38e81c2ab677444ffa0e"
|
sha256 = "d5497307bbcd1e159f7707f385b488a6f2e26362d5256d5bc1080ac603a51305"
|
||||||
replaced = true
|
replaced = true
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
package component:wasi-clock-probe@0.1.0;
|
package component:wasi-clock-probe@0.1.0;
|
||||||
|
|
||||||
/// The declared world grants only the base service contract. Any direct WASI
|
|
||||||
/// clock import introduced by implementation code remains undeclared.
|
|
||||||
world wasi-clock-probe-component {
|
world wasi-clock-probe-component {
|
||||||
include wasmeld:service/service-component@0.1.0;
|
include wasmeld:service/service-component@0.1.0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
/node_modules/
|
/node_modules/
|
||||||
/dist/
|
/dist/
|
||||||
|
/.tanstack/
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
.env*
|
.env*
|
||||||
!.env.example
|
!.env.example
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"$schema": "./node_modules/oxfmt/configuration_schema.json",
|
||||||
|
"ignorePatterns": ["src/routeTree.gen.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||||
|
"plugins": ["typescript", "unicorn", "oxc", "react", "jsx-a11y", "node"],
|
||||||
|
"categories": {
|
||||||
|
"correctness": "error"
|
||||||
|
},
|
||||||
|
"rules": {},
|
||||||
|
"ignorePatterns": ["src/routeTree.gen.ts"],
|
||||||
|
"env": {
|
||||||
|
"builtin": true,
|
||||||
|
"browser": true,
|
||||||
|
"node": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# Wasmeld Console
|
||||||
|
|
||||||
|
WebAssembly Runtime 的管理控制台,使用 TanStack Start、TanStack Router、
|
||||||
|
React 和 srvx 构建,通过 Node.js 运行。页面只调用 `wasmeld-console` HTTP API,
|
||||||
|
不直接访问数据库或 Wasmeld Runtime。
|
||||||
|
|
||||||
|
运行概览可以启动、停止和重启整个 Runtime;服务版本与运行实例页面负责管理单个
|
||||||
|
Wasm Actor。停止 Runtime 不会断开管理 API。
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
默认访问 `http://localhost:3000`。
|
||||||
|
|
||||||
|
管理 API 默认使用 `http://127.0.0.1:8080`。可以在控制台的“运行设置”中修改,
|
||||||
|
或在构建时设置 `VITE_WASMELD_API_URL`。
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run format:check
|
||||||
|
npm run lint
|
||||||
|
npm test
|
||||||
|
```
|
||||||
|
|
||||||
|
生产构建完成后可以运行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
`npm test` 会生成 Node.js 生产构建,并验证 srvx 服务端渲染和静态资源。
|
||||||
+1155
-1281
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"name": "wasmeld-console",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite dev",
|
||||||
|
"build": "vite build && tsc --noEmit",
|
||||||
|
"start": "srvx serve --prod --entry dist/server/server.js --static ../client",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"test": "npm run build && node --test tests/rendered-html.test.mjs",
|
||||||
|
"lint": "oxlint . --deny-warnings",
|
||||||
|
"format": "oxfmt .",
|
||||||
|
"format:check": "oxfmt --check ."
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tanstack/react-router": "^1.170.18",
|
||||||
|
"@tanstack/react-start": "^1.168.32",
|
||||||
|
"lucide-react": "^1.27.0",
|
||||||
|
"react": "19.2.6",
|
||||||
|
"react-dom": "19.2.6",
|
||||||
|
"srvx": "^0.12.4"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "22.19.19",
|
||||||
|
"@types/react": "19.2.14",
|
||||||
|
"@types/react-dom": "19.2.3",
|
||||||
|
"@vitejs/plugin-react": "6.0.2",
|
||||||
|
"oxfmt": "^0.60.0",
|
||||||
|
"oxlint": "^1.75.0",
|
||||||
|
"typescript": "5.9.3",
|
||||||
|
"vite": "^8.1.5"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22.13.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Before Width: | Height: | Size: 1.0 MiB After Width: | Height: | Size: 1.0 MiB |
@@ -1,31 +1,14 @@
|
|||||||
/**
|
export const DEFAULT_API_BASE = import.meta.env.VITE_WASMELD_API_URL ?? "http://127.0.0.1:8080";
|
||||||
* Typed client for the Rust management API.
|
|
||||||
*
|
|
||||||
* Production uses a same-origin base so the embedded Console follows the
|
|
||||||
* address used to open it. Vite development keeps the existing local backend
|
|
||||||
* default and relies on Wasmeld's development CORS policy.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export const DEFAULT_API_BASE =
|
|
||||||
import.meta.env.VITE_WASMELD_API_URL ?? (import.meta.env.DEV ? "http://127.0.0.1:8080" : "");
|
|
||||||
|
|
||||||
const API_BASE_STORAGE_KEY = "wasmeld-api-base";
|
const API_BASE_STORAGE_KEY = "wasmeld-api-base";
|
||||||
|
|
||||||
export type BackendServiceStatus = "running" | "stopped" | "faulted";
|
export type BackendServiceStatus = "running" | "stopped" | "faulted";
|
||||||
|
|
||||||
export type BackendCapability = {
|
|
||||||
interface: string;
|
|
||||||
package: string;
|
|
||||||
name: string;
|
|
||||||
version: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type BackendService = {
|
export type BackendService = {
|
||||||
id: string;
|
id: string;
|
||||||
revision: string;
|
revision: string;
|
||||||
artifact: string;
|
artifact: string;
|
||||||
world: string;
|
world: string;
|
||||||
capabilities: BackendCapability[];
|
|
||||||
status: BackendServiceStatus;
|
status: BackendServiceStatus;
|
||||||
updated_at_ms: number;
|
updated_at_ms: number;
|
||||||
limits: {
|
limits: {
|
||||||
@@ -44,7 +27,7 @@ export type BackendService = {
|
|||||||
export type BackendEvent = {
|
export type BackendEvent = {
|
||||||
id: number;
|
id: number;
|
||||||
timestamp_ms: number;
|
timestamp_ms: number;
|
||||||
kind: "registered" | "unregistered" | "deployed" | "started" | "stopped" | "invoked" | "failed";
|
kind: "registered" | "started" | "stopped" | "invoked" | "failed";
|
||||||
service_id: string | null;
|
service_id: string | null;
|
||||||
revision: string | null;
|
revision: string | null;
|
||||||
message: string;
|
message: string;
|
||||||
@@ -58,13 +41,6 @@ export type BackendRuntime = {
|
|||||||
running_services: number;
|
running_services: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type BackendDeployment = {
|
|
||||||
service_id: string;
|
|
||||||
active_revision: string;
|
|
||||||
status: BackendServiceStatus;
|
|
||||||
updated_at_ms: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type BackendWitDependency = {
|
export type BackendWitDependency = {
|
||||||
name: string;
|
name: string;
|
||||||
version: string;
|
version: string;
|
||||||
@@ -78,12 +54,8 @@ export type BackendWitPackage = {
|
|||||||
dependencies: BackendWitDependency[];
|
dependencies: BackendWitDependency[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type BackendSnapshot = {
|
export type RegisterComponentInput = {
|
||||||
services: BackendService[];
|
file: File;
|
||||||
deployments: BackendDeployment[];
|
|
||||||
events: BackendEvent[];
|
|
||||||
runtime: BackendRuntime;
|
|
||||||
witPackages: BackendWitPackage[];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type InvokeResult = {
|
export type InvokeResult = {
|
||||||
@@ -97,16 +69,8 @@ export function getApiBase(): string {
|
|||||||
return window.localStorage.getItem(API_BASE_STORAGE_KEY) ?? DEFAULT_API_BASE;
|
return window.localStorage.getItem(API_BASE_STORAGE_KEY) ?? DEFAULT_API_BASE;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function displayApiBase(value: string): string {
|
|
||||||
return value || (typeof window === "undefined" ? "same origin" : window.location.origin);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function saveApiBase(value: string): string {
|
export function saveApiBase(value: string): string {
|
||||||
const normalized = value.trim().replace(/\/+$/, "");
|
const normalized = value.trim().replace(/\/+$/, "");
|
||||||
if (!normalized) {
|
|
||||||
window.localStorage.removeItem(API_BASE_STORAGE_KEY);
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
const parsed = new URL(normalized);
|
const parsed = new URL(normalized);
|
||||||
if (!["http:", "https:"].includes(parsed.protocol)) {
|
if (!["http:", "https:"].includes(parsed.protocol)) {
|
||||||
throw new Error("API 地址必须使用 http 或 https");
|
throw new Error("API 地址必须使用 http 或 https");
|
||||||
@@ -115,44 +79,34 @@ export function saveApiBase(value: string): string {
|
|||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchSnapshot(apiBase: string): Promise<BackendSnapshot> {
|
export async function fetchSnapshot(apiBase: string) {
|
||||||
const [services, deployments, events, runtime, witPackages] = await Promise.all([
|
const [services, events, runtime, witPackages] = await Promise.all([
|
||||||
request<{ services: BackendService[] }>(apiBase, "/api/v1/services"),
|
request<{ services: BackendService[] }>(apiBase, "/api/v1/services"),
|
||||||
request<{ deployments: BackendDeployment[] }>(apiBase, "/api/v1/deployments"),
|
|
||||||
request<{ events: BackendEvent[] }>(apiBase, "/api/v1/events"),
|
request<{ events: BackendEvent[] }>(apiBase, "/api/v1/events"),
|
||||||
request<BackendRuntime>(apiBase, "/api/v1/runtime"),
|
request<BackendRuntime>(apiBase, "/api/v1/runtime"),
|
||||||
request<{ packages: BackendWitPackage[] }>(apiBase, "/api/v1/wit/packages"),
|
request<{ packages: BackendWitPackage[] }>(apiBase, "/api/v1/wit/packages"),
|
||||||
]);
|
]);
|
||||||
return {
|
return {
|
||||||
services: services.services,
|
services: services.services,
|
||||||
deployments: deployments.deployments,
|
|
||||||
events: events.events,
|
events: events.events,
|
||||||
runtime,
|
runtime,
|
||||||
witPackages: witPackages.packages,
|
witPackages: witPackages.packages,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function activateDeployment(
|
export async function registerComponent(
|
||||||
apiBase: string,
|
apiBase: string,
|
||||||
service: Pick<BackendService, "id" | "revision">,
|
input: RegisterComponentInput,
|
||||||
): Promise<BackendDeployment> {
|
): Promise<BackendService> {
|
||||||
return request(apiBase, `/api/v1/deployments/${segment(service.id)}/activate`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "content-type": "application/json" },
|
|
||||||
body: JSON.stringify({ revision: service.revision }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function registerComponent(apiBase: string, file: File): Promise<BackendService> {
|
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
form.set("package", file, file.name);
|
form.set("package", input.file, input.file.name);
|
||||||
return request(apiBase, "/api/v1/services", {
|
return request(apiBase, "/api/v1/services", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: form,
|
body: form,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function publishWitPackage(apiBase: string, file: File): Promise<BackendWitPackage> {
|
export async function publishWitPackage(apiBase: string, file: File): Promise<BackendWitPackage> {
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
form.set("package", file, file.name);
|
form.set("package", file, file.name);
|
||||||
return request(apiBase, "/api/v1/wit/packages", {
|
return request(apiBase, "/api/v1/wit/packages", {
|
||||||
@@ -166,17 +120,17 @@ export function witPackageDownloadUrl(
|
|||||||
packageMetadata: Pick<BackendWitPackage, "name" | "version">,
|
packageMetadata: Pick<BackendWitPackage, "name" | "version">,
|
||||||
): string {
|
): string {
|
||||||
const [namespace, name] = packageMetadata.name.split(":");
|
const [namespace, name] = packageMetadata.name.split(":");
|
||||||
return `${apiBase}/api/v1/wit/packages/${segment(namespace)}/${segment(name)}/${segment(packageMetadata.version)}/content`;
|
return `${apiBase}/api/v1/wit/packages/${encodeURIComponent(namespace)}/${encodeURIComponent(name)}/${encodeURIComponent(packageMetadata.version)}/content`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function changeServiceState(
|
export async function changeServiceState(
|
||||||
apiBase: string,
|
apiBase: string,
|
||||||
service: Pick<BackendService, "id" | "revision">,
|
service: Pick<BackendService, "id" | "revision">,
|
||||||
action: "start" | "stop" | "restart",
|
action: "start" | "stop" | "restart",
|
||||||
): Promise<BackendService> {
|
): Promise<BackendService> {
|
||||||
return request(
|
return request(
|
||||||
apiBase,
|
apiBase,
|
||||||
`/api/v1/services/${segment(service.id)}/${segment(service.revision)}/${action}`,
|
`/api/v1/services/${encodeURIComponent(service.id)}/${encodeURIComponent(service.revision)}/${action}`,
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "content-type": "application/json" },
|
headers: { "content-type": "application/json" },
|
||||||
@@ -185,11 +139,13 @@ export function changeServiceState(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function changeRuntimeState(
|
export async function changeRuntimeState(
|
||||||
apiBase: string,
|
apiBase: string,
|
||||||
action: "start" | "stop" | "restart",
|
action: "start" | "stop" | "restart",
|
||||||
): Promise<BackendRuntime> {
|
): Promise<BackendRuntime> {
|
||||||
return request(apiBase, `/api/v1/runtime/${action}`, { method: "POST" });
|
return request(apiBase, `/api/v1/runtime/${action}`, {
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function invokeComponent(
|
export async function invokeComponent(
|
||||||
@@ -201,11 +157,15 @@ export async function invokeComponent(
|
|||||||
output_base64: string;
|
output_base64: string;
|
||||||
output_bytes: number;
|
output_bytes: number;
|
||||||
latency_ms: number;
|
latency_ms: number;
|
||||||
}>(apiBase, `/api/v1/services/${segment(service.id)}/${segment(service.revision)}/invoke`, {
|
}>(
|
||||||
method: "POST",
|
apiBase,
|
||||||
headers: { "content-type": "application/json" },
|
`/api/v1/services/${encodeURIComponent(service.id)}/${encodeURIComponent(service.revision)}/invoke`,
|
||||||
body: JSON.stringify({ input_base64: bytesToBase64(input) }),
|
{
|
||||||
});
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ input_base64: bytesToBase64(input) }),
|
||||||
|
},
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
output: base64ToBytes(response.output_base64),
|
output: base64ToBytes(response.output_base64),
|
||||||
outputBytes: response.output_bytes,
|
outputBytes: response.output_bytes,
|
||||||
@@ -224,7 +184,7 @@ async function request<T>(apiBase: string, path: string, init?: RequestInit): Pr
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
throw new Error(`无法连接 Wasmeld:${displayApiBase(apiBase)}`);
|
throw new Error(`无法连接 Wasmeld:${apiBase}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -236,10 +196,6 @@ async function request<T>(apiBase: string, path: string, init?: RequestInit): Pr
|
|||||||
return (await response.json()) as T;
|
return (await response.json()) as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
function segment(value: string): string {
|
|
||||||
return encodeURIComponent(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
function bytesToBase64(bytes: Uint8Array): string {
|
function bytesToBase64(bytes: Uint8Array): string {
|
||||||
let binary = "";
|
let binary = "";
|
||||||
for (const byte of bytes) binary += String.fromCharCode(byte);
|
for (const byte of bytes) binary += String.fromCharCode(byte);
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
/* oxlint-disable */
|
||||||
|
|
||||||
|
// @ts-nocheck
|
||||||
|
|
||||||
|
// noinspection JSUnusedGlobalSymbols
|
||||||
|
|
||||||
|
// This file was automatically generated by TanStack Router.
|
||||||
|
// You should NOT make any changes in this file as it will be overwritten.
|
||||||
|
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
||||||
|
|
||||||
|
import { Route as rootRouteImport } from './routes/__root'
|
||||||
|
import { Route as IndexRouteImport } from './routes/index'
|
||||||
|
|
||||||
|
const IndexRoute = IndexRouteImport.update({
|
||||||
|
id: '/',
|
||||||
|
path: '/',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
|
||||||
|
export interface FileRoutesByFullPath {
|
||||||
|
'/': typeof IndexRoute
|
||||||
|
}
|
||||||
|
export interface FileRoutesByTo {
|
||||||
|
'/': typeof IndexRoute
|
||||||
|
}
|
||||||
|
export interface FileRoutesById {
|
||||||
|
__root__: typeof rootRouteImport
|
||||||
|
'/': typeof IndexRoute
|
||||||
|
}
|
||||||
|
export interface FileRouteTypes {
|
||||||
|
fileRoutesByFullPath: FileRoutesByFullPath
|
||||||
|
fullPaths: '/'
|
||||||
|
fileRoutesByTo: FileRoutesByTo
|
||||||
|
to: '/'
|
||||||
|
id: '__root__' | '/'
|
||||||
|
fileRoutesById: FileRoutesById
|
||||||
|
}
|
||||||
|
export interface RootRouteChildren {
|
||||||
|
IndexRoute: typeof IndexRoute
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '@tanstack/react-router' {
|
||||||
|
interface FileRoutesByPath {
|
||||||
|
'/': {
|
||||||
|
id: '/'
|
||||||
|
path: '/'
|
||||||
|
fullPath: '/'
|
||||||
|
preLoaderRoute: typeof IndexRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rootRouteChildren: RootRouteChildren = {
|
||||||
|
IndexRoute: IndexRoute,
|
||||||
|
}
|
||||||
|
export const routeTree = rootRouteImport
|
||||||
|
._addFileChildren(rootRouteChildren)
|
||||||
|
._addFileTypes<FileRouteTypes>()
|
||||||
|
|
||||||
|
import type { getRouter } from './router.tsx'
|
||||||
|
import type { createStart } from '@tanstack/react-start'
|
||||||
|
declare module '@tanstack/react-start' {
|
||||||
|
interface Register {
|
||||||
|
ssr: true
|
||||||
|
router: Awaited<ReturnType<typeof getRouter>>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { createRouter } from "@tanstack/react-router";
|
||||||
|
import { routeTree } from "./routeTree.gen";
|
||||||
|
|
||||||
|
export function getRouter() {
|
||||||
|
return createRouter({
|
||||||
|
routeTree,
|
||||||
|
defaultPreload: "intent",
|
||||||
|
scrollRestoration: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module "@tanstack/react-router" {
|
||||||
|
interface Register {
|
||||||
|
router: ReturnType<typeof getRouter>;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
import { HeadContent, Outlet, Scripts, createRootRoute } from "@tanstack/react-router";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import appCss from "../styles/app.css?url";
|
||||||
|
|
||||||
|
export const Route = createRootRoute({
|
||||||
|
head: () => ({
|
||||||
|
meta: [
|
||||||
|
{ charSet: "utf-8" },
|
||||||
|
{
|
||||||
|
name: "viewport",
|
||||||
|
content: "width=device-width, initial-scale=1",
|
||||||
|
},
|
||||||
|
{ title: "Wasmeld Console" },
|
||||||
|
{
|
||||||
|
name: "description",
|
||||||
|
content: "WebAssembly Runtime 管理控制台",
|
||||||
|
},
|
||||||
|
{ property: "og:title", content: "Wasmeld Console" },
|
||||||
|
{
|
||||||
|
property: "og:description",
|
||||||
|
content: "WebAssembly Runtime 管理控制台",
|
||||||
|
},
|
||||||
|
{ property: "og:type", content: "website" },
|
||||||
|
{ property: "og:image", content: "/og.png" },
|
||||||
|
{ name: "twitter:card", content: "summary_large_image" },
|
||||||
|
{ name: "twitter:title", content: "Wasmeld Console" },
|
||||||
|
{
|
||||||
|
name: "twitter:description",
|
||||||
|
content: "WebAssembly Runtime 管理控制台",
|
||||||
|
},
|
||||||
|
{ name: "twitter:image", content: "/og.png" },
|
||||||
|
],
|
||||||
|
links: [{ rel: "stylesheet", href: appCss }],
|
||||||
|
}),
|
||||||
|
component: RootComponent,
|
||||||
|
});
|
||||||
|
|
||||||
|
function RootComponent() {
|
||||||
|
return (
|
||||||
|
<RootDocument>
|
||||||
|
<Outlet />
|
||||||
|
</RootDocument>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RootDocument({ children }: Readonly<{ children: ReactNode }>) {
|
||||||
|
return (
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<HeadContent />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{children}
|
||||||
|
<Scripts />
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,106 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { spawn } from "node:child_process";
|
||||||
|
import { access, readFile } from "node:fs/promises";
|
||||||
|
import { createServer } from "node:net";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import test, { after, before } from "node:test";
|
||||||
|
|
||||||
|
let serverProcess;
|
||||||
|
let serverOrigin;
|
||||||
|
|
||||||
|
async function reservePort() {
|
||||||
|
const server = createServer();
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
server.once("error", reject);
|
||||||
|
server.listen(0, "127.0.0.1", resolve);
|
||||||
|
});
|
||||||
|
const address = server.address();
|
||||||
|
assert(address && typeof address === "object");
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
server.close((error) => (error ? reject(error) : resolve()));
|
||||||
|
});
|
||||||
|
return address.port;
|
||||||
|
}
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
const port = await reservePort();
|
||||||
|
serverOrigin = `http://127.0.0.1:${port}`;
|
||||||
|
serverProcess = spawn(
|
||||||
|
process.execPath,
|
||||||
|
[
|
||||||
|
fileURLToPath(new URL("../node_modules/srvx/bin/srvx.mjs", import.meta.url)),
|
||||||
|
"serve",
|
||||||
|
"--prod",
|
||||||
|
"--entry",
|
||||||
|
"dist/server/server.js",
|
||||||
|
"--static",
|
||||||
|
"../client",
|
||||||
|
],
|
||||||
|
{
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
HOST: "127.0.0.1",
|
||||||
|
PORT: String(port),
|
||||||
|
},
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt < 50; attempt += 1) {
|
||||||
|
if (serverProcess.exitCode !== null) {
|
||||||
|
throw new Error(`Node server exited with code ${serverProcess.exitCode}`);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const response = await fetch(serverOrigin);
|
||||||
|
if (response.ok) return;
|
||||||
|
} catch {
|
||||||
|
// The server is still starting.
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
}
|
||||||
|
throw new Error("Node server did not become ready");
|
||||||
|
});
|
||||||
|
|
||||||
|
after(() => {
|
||||||
|
serverProcess?.kill("SIGTERM");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("server-renders the Wasm management console", async () => {
|
||||||
|
const response = await fetch(serverOrigin, {
|
||||||
|
headers: { accept: "text/html" },
|
||||||
|
});
|
||||||
|
assert.equal(response.status, 200);
|
||||||
|
assert.match(response.headers.get("content-type") ?? "", /^text\/html\b/i);
|
||||||
|
|
||||||
|
const html = await response.text();
|
||||||
|
assert.match(html, /<title>Wasmeld Console<\/title>/i);
|
||||||
|
assert.match(html, /运行概览/);
|
||||||
|
assert.match(html, /WIT 包/);
|
||||||
|
assert.match(html, /连接中/);
|
||||||
|
assert.match(html, /来自 Wasmeld/);
|
||||||
|
assert.match(html, /Component Model/);
|
||||||
|
assert.doesNotMatch(html, /本地演示|codex-preview|vinext|next\.js/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("uses TanStack Start routing and produces Node artifacts", async () => {
|
||||||
|
const [rootRoute, indexRoute, router, packageJson, viteConfig] = await Promise.all([
|
||||||
|
readFile(new URL("../src/routes/__root.tsx", import.meta.url), "utf8"),
|
||||||
|
readFile(new URL("../src/routes/index.tsx", import.meta.url), "utf8"),
|
||||||
|
readFile(new URL("../src/router.tsx", import.meta.url), "utf8"),
|
||||||
|
readFile(new URL("../package.json", import.meta.url), "utf8"),
|
||||||
|
readFile(new URL("../vite.config.ts", import.meta.url), "utf8"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.match(rootRoute, /createRootRoute/);
|
||||||
|
assert.match(rootRoute, /<HeadContent \/>/);
|
||||||
|
assert.match(rootRoute, /<Scripts \/>/);
|
||||||
|
assert.match(indexRoute, /createFileRoute\("\/"\)/);
|
||||||
|
assert.match(router, /createRouter/);
|
||||||
|
assert.match(packageJson, /"@tanstack\/react-start"/);
|
||||||
|
assert.match(packageJson, /"srvx"/);
|
||||||
|
assert.doesNotMatch(packageJson, /"next"|"vinext"|"drizzle-orm"/);
|
||||||
|
assert.match(viteConfig, /tanstackStart\(\{/);
|
||||||
|
|
||||||
|
await access(new URL("../dist/server/server.js", import.meta.url));
|
||||||
|
await access(new URL("../dist/client/og.png", import.meta.url));
|
||||||
|
});
|
||||||
@@ -1,17 +1,17 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ES2022",
|
"target": "ES2022",
|
||||||
"lib": ["DOM", "DOM.Iterable", "ESNext", "WebWorker"],
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"module": "ESNext",
|
"esModuleInterop": true,
|
||||||
"moduleResolution": "Bundler",
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"jsx": "preserve",
|
"jsx": "react-jsx",
|
||||||
"jsxImportSource": "solid-js",
|
|
||||||
"types": ["vite/client"]
|
"types": ["vite/client"]
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts", "src/**/*.tsx", "vite.config.ts"],
|
"include": ["src/**/*.ts", "src/**/*.tsx", "vite.config.ts"],
|
||||||
"exclude": ["dist", "node_modules"]
|
"exclude": ["node_modules"]
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { tanstackStart } from "@tanstack/react-start/plugin/vite";
|
||||||
|
import viteReact from "@vitejs/plugin-react";
|
||||||
|
import { defineConfig } from "vite";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
server: { port: 3000 },
|
||||||
|
plugins: [
|
||||||
|
tanstackStart({
|
||||||
|
router: {
|
||||||
|
routeTreeFileHeader: [
|
||||||
|
"/* oxlint-disable */",
|
||||||
|
"// @ts-nocheck",
|
||||||
|
"// noinspection JSUnusedGlobalSymbols",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
viteReact(),
|
||||||
|
],
|
||||||
|
});
|
||||||
@@ -8,8 +8,6 @@ license.workspace = true
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
axum.workspace = true
|
axum.workspace = true
|
||||||
base64.workspace = true
|
base64.workspace = true
|
||||||
include_dir.workspace = true
|
|
||||||
mime_guess.workspace = true
|
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
semver.workspace = true
|
semver.workspace = true
|
||||||
|
|||||||
@@ -6,29 +6,17 @@
|
|||||||
- 发布、查询和下载不可变的二进制 WIT Package
|
- 发布、查询和下载不可变的二进制 WIT Package
|
||||||
- 启动、停止和重启内嵌的 `wasmeld-runtime`
|
- 启动、停止和重启内嵌的 `wasmeld-runtime`
|
||||||
- 启动、停止和重启常驻 Actor
|
- 启动、停止和重启常驻 Actor
|
||||||
- 管理服务 ID 到活动版本的 Deployment
|
- 转发二进制调用
|
||||||
- 通过独立 Gateway 转发原始二进制调用
|
|
||||||
- 为导入 `wasmeld:kv/store@0.1.0` 的 Component 提供持久化 KV
|
|
||||||
- 暴露调用计数、状态和最近事件
|
- 暴露调用计数、状态和最近事件
|
||||||
- 在 Release 可执行文件中嵌入 Solid + Tailwind v4 管理面
|
|
||||||
|
|
||||||
Wasm 制品保存在本地文件系统。服务 manifest、调用计数和最近 256 条事件通过
|
Wasm 制品保存在本地文件系统。服务 manifest、调用计数和最近 256 条事件通过
|
||||||
[Toasty](https://github.com/tokio-rs/toasty) 写入本地 libSQL 数据库,默认路径是
|
[Toasty](https://github.com/tokio-rs/toasty) 写入本地 libSQL 数据库,默认路径是
|
||||||
`var/wasmeld/console.db`。Deployment 也存储在同一数据库中。
|
`var/wasmeld/console.db`。
|
||||||
Host KV 使用 `(service_id, key)` 复合主键存储在该数据库中:同一服务的 Revision
|
|
||||||
共享数据,不同服务互相隔离,Runtime 或 Console 重启不会清空 KV。
|
|
||||||
|
|
||||||
调用计数、错误数、延迟和调用事件通过容量受限的异步队列批量写入数据库,Gateway
|
|
||||||
响应不等待 libSQL。队列不可用或进程异常退出时,尚未写入的遥测可能丢失,但不会把
|
|
||||||
已经成功的 Component 调用改写成失败。注册、Deployment 和生命周期变更仍在响应前
|
|
||||||
完成持久化。
|
|
||||||
|
|
||||||
`wasmeld-console` 启动时会在同一进程内创建 `wasmeld-runtime`、加载 Wasmtime Engine,
|
`wasmeld-console` 启动时会在同一进程内创建 `wasmeld-runtime`、加载 Wasmtime Engine,
|
||||||
并重新注册已保存的 Component。Runtime 停止后 Console HTTP 和数据库仍然在线;
|
并重新注册已保存的 Component。Runtime 停止后 Console HTTP 和数据库仍然在线;
|
||||||
再次启动 Runtime 会重新注册 Component。未部署的版本恢复为 `stopped`;Deployment
|
再次启动 Runtime 会重新注册 Component,但 Actor 内存不做快照,服务运行状态统一
|
||||||
指向的版本会用空初始化配置创建新的 Actor。Actor 的 Store 和线性内存不做快照。
|
恢复为 `stopped`。
|
||||||
编译后的本地机器码默认缓存在 `var/wasmeld/component-cache`,相同 Component 在
|
|
||||||
Console 或 Runtime 重启后可由 Wasmtime 直接复用;缓存不包含 Actor 内存。
|
|
||||||
|
|
||||||
## 启动
|
## 启动
|
||||||
|
|
||||||
@@ -39,37 +27,11 @@ Console 或 Runtime 重启后可由 Wasmtime 直接复用;缓存不包含 Acto
|
|||||||
cargo +stable run -p wasmeld-console
|
cargo +stable run -p wasmeld-console
|
||||||
```
|
```
|
||||||
|
|
||||||
Debug 构建只启动 API,不构建或提供 Web 静态文件。前端开发服务器需要单独启动:
|
默认监听 `127.0.0.1:8080`。
|
||||||
|
|
||||||
```bash
|
|
||||||
cd crates/wasmeld-console/web
|
|
||||||
npm ci
|
|
||||||
npm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
Release 构建会自动使用 `package-lock.json` 构建并嵌入管理面:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cargo +stable build --release -p wasmeld-console
|
|
||||||
./target/release/wasmeld-console
|
|
||||||
```
|
|
||||||
|
|
||||||
发布构建要求 Node.js 22.13+ 和 npm。可用 `NPM` 指定 npm 可执行文件;设置
|
|
||||||
`WASMELD_BUILD_WEB=1` 可以在非 Release Cargo 构建中验证嵌入流程。前端结构和 PWA
|
|
||||||
边界见 [`web/README.md`](web/README.md)。
|
|
||||||
|
|
||||||
进程默认启动两个独立 Listener:
|
|
||||||
|
|
||||||
- 管理 API:`127.0.0.1:8080`
|
|
||||||
- Gateway:`0.0.0.0:8081`
|
|
||||||
|
|
||||||
管理 Router 不会挂载到 Gateway Listener,Gateway 也不暴露注册、Runtime 生命周期和
|
|
||||||
WIT Registry 路由。
|
|
||||||
|
|
||||||
可用环境变量:
|
可用环境变量:
|
||||||
|
|
||||||
- `WASMELD_ADDR`
|
- `WASMELD_ADDR`
|
||||||
- `WASMELD_GATEWAY_ADDR`
|
|
||||||
- `WASMELD_ARTIFACT_DIR`
|
- `WASMELD_ARTIFACT_DIR`
|
||||||
- `WASMELD_DATABASE_PATH`
|
- `WASMELD_DATABASE_PATH`
|
||||||
- `WASMELD_WIT_REGISTRY_DIR`
|
- `WASMELD_WIT_REGISTRY_DIR`
|
||||||
@@ -84,56 +46,20 @@ WIT Registry 路由。
|
|||||||
| `GET` | `/api/v1/runtime` | Runtime 状态 |
|
| `GET` | `/api/v1/runtime` | Runtime 状态 |
|
||||||
| `POST` | `/api/v1/runtime/start` | 启动 Runtime 并重新注册受管 Component |
|
| `POST` | `/api/v1/runtime/start` | 启动 Runtime 并重新注册受管 Component |
|
||||||
| `POST` | `/api/v1/runtime/stop` | 停止 Runtime 并释放所有 Actor |
|
| `POST` | `/api/v1/runtime/stop` | 停止 Runtime 并释放所有 Actor |
|
||||||
| `POST` | `/api/v1/runtime/restart` | 重建 Runtime,并重新创建活动 Deployment 的 Actor |
|
| `POST` | `/api/v1/runtime/restart` | 重建 Runtime,服务恢复为停止状态 |
|
||||||
| `GET` | `/api/v1/services` | 服务版本及精确 Host Capability 列表 |
|
| `GET` | `/api/v1/services` | 服务版本列表 |
|
||||||
| `POST` | `/api/v1/services` | multipart 上传单个 `package` 字段(`.wasmpkg`) |
|
| `POST` | `/api/v1/services` | multipart 上传单个 `package` 字段(`.wasmpkg`) |
|
||||||
| `DELETE` | `/api/v1/services/{id}/{revision}` | 注销非活动 Revision 并删除制品 |
|
|
||||||
| `POST` | `/api/v1/services/{id}/{revision}/start` | 启动 Actor |
|
| `POST` | `/api/v1/services/{id}/{revision}/start` | 启动 Actor |
|
||||||
| `POST` | `/api/v1/services/{id}/{revision}/stop` | 停止 Actor |
|
| `POST` | `/api/v1/services/{id}/{revision}/stop` | 停止 Actor |
|
||||||
| `POST` | `/api/v1/services/{id}/{revision}/restart` | 重启 Actor |
|
| `POST` | `/api/v1/services/{id}/{revision}/restart` | 重启 Actor |
|
||||||
| `POST` | `/api/v1/services/{id}/{revision}/invoke` | Base64 二进制调用 |
|
| `POST` | `/api/v1/services/{id}/{revision}/invoke` | Base64 二进制调用 |
|
||||||
| `GET` | `/api/v1/deployments` | 活动 Deployment 列表 |
|
|
||||||
| `GET` | `/api/v1/deployments/{id}` | 查询服务的活动版本 |
|
|
||||||
| `POST` | `/api/v1/deployments/{id}/activate` | 启动目标版本并原子切换活动版本 |
|
|
||||||
| `GET` | `/api/v1/events` | 最近 256 条持久化事件 |
|
| `GET` | `/api/v1/events` | 最近 256 条持久化事件 |
|
||||||
| `GET` | `/api/v1/wit/packages` | WIT 包版本列表 |
|
| `GET` | `/api/v1/wit/packages` | WIT 包版本列表 |
|
||||||
| `POST` | `/api/v1/wit/packages` | multipart 发布单个二进制 WIT `package` 字段 |
|
| `POST` | `/api/v1/wit/packages` | multipart 发布单个二进制 WIT `package` 字段 |
|
||||||
| `GET` | `/api/v1/wit/packages/{namespace}/{name}/{version}` | WIT 包元数据 |
|
| `GET` | `/api/v1/wit/packages/{namespace}/{name}/{version}` | WIT 包元数据 |
|
||||||
| `GET` | `/api/v1/wit/packages/{namespace}/{name}/{version}/content` | 下载 WIT 包 |
|
| `GET` | `/api/v1/wit/packages/{namespace}/{name}/{version}/content` | 下载 WIT 包 |
|
||||||
|
|
||||||
活动 Deployment 不能直接注销。开发工具会先完成新 Revision 的编译、注册和切换,
|
组件包的构建与格式说明见
|
||||||
再注销上一份开发 Revision,因此失败的构建不会影响当前可调用版本。
|
[`docs/design/wasmeld-component-package.md`](../../docs/design/wasmeld-component-package.md)。
|
||||||
|
WIT 依赖、Registry 和本地 replace 说明见
|
||||||
激活已注册版本:
|
[`docs/design/wit-package-management.md`](../../docs/design/wit-package-management.md)。
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X POST http://127.0.0.1:8080/api/v1/deployments/echo/activate \
|
|
||||||
-H 'Content-Type: application/json' \
|
|
||||||
-d '{"revision":"0.1.0"}'
|
|
||||||
```
|
|
||||||
|
|
||||||
Gateway 只提供 `GET /healthz` 和
|
|
||||||
`POST /v1/services/{id}/invoke`。调用请求与成功响应的
|
|
||||||
`Content-Type` 都是 `application/octet-stream`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl http://127.0.0.1:8081/v1/services/echo/invoke \
|
|
||||||
-H 'Content-Type: application/octet-stream' \
|
|
||||||
--data-binary 'hello'
|
|
||||||
```
|
|
||||||
|
|
||||||
成功响应通过 `X-Wasmeld-Revision` 返回实际路由的版本。Gateway 错误统一返回 JSON;
|
|
||||||
未部署为 `404`,Actor 不可用为 `503`,过载为 `429`,执行超时为 `504`。
|
|
||||||
|
|
||||||
服务响应的 `capabilities` 来自 Component 二进制中的 WIT imports,例如
|
|
||||||
`wasmeld:clock/monotonic-clock@0.1.0`。Console 不接受手工能力声明,Runtime 只会链接
|
|
||||||
注册表中存在且版本完全匹配的接口。
|
|
||||||
|
|
||||||
## Host KV
|
|
||||||
|
|
||||||
`wasmeld:kv/store@0.1.0` 提供 `get`、`set` 和 `delete`。Key 必须是非空 UTF-8,
|
|
||||||
最大 256 B;Value 最大 64 KiB。数据库命令通过有界专用工作线程执行,Host 等待时间
|
|
||||||
不会超过服务的 `deadline_ms`。v0.1.0 不提供 TTL、遍历、事务或 CAS。
|
|
||||||
|
|
||||||
组件包格式、开发循环、WIT 依赖、Registry 和本地 `replace` 说明见
|
|
||||||
[`wasmeld-package` CLI 文档](../wasmeld-package/README.md)。
|
|
||||||
|
|||||||
@@ -1,104 +0,0 @@
|
|||||||
//! Builds the Solid management UI for embedded Release binaries.
|
|
||||||
//!
|
|
||||||
//! Debug builds intentionally return before invoking npm: during development
|
|
||||||
//! Vite serves `web/` independently and proxies its API client to the Rust
|
|
||||||
//! listener. Release builds use the lockfile, write only into Cargo `OUT_DIR`,
|
|
||||||
//! and expose a cfg consumed by `src/web.rs`. The source tree therefore never
|
|
||||||
//! needs a generated `dist/` for `cargo build --release`.
|
|
||||||
//!
|
|
||||||
//! Set `WASMELD_BUILD_WEB=1` to exercise the embedded path in another Cargo
|
|
||||||
//! profile, or `NPM=/absolute/path/to/npm` when npm is not on `PATH`.
|
|
||||||
|
|
||||||
use std::{
|
|
||||||
env, fs,
|
|
||||||
path::PathBuf,
|
|
||||||
process::{Command, ExitStatus},
|
|
||||||
};
|
|
||||||
|
|
||||||
const WEB_INPUTS: &[&str] = &[
|
|
||||||
"web/src",
|
|
||||||
"web/public",
|
|
||||||
"web/index.html",
|
|
||||||
"web/page.html",
|
|
||||||
"web/package.json",
|
|
||||||
"web/package-lock.json",
|
|
||||||
"web/tsconfig.json",
|
|
||||||
"web/vite.config.ts",
|
|
||||||
];
|
|
||||||
|
|
||||||
fn main() {
|
|
||||||
println!("cargo:rustc-check-cfg=cfg(wasmeld_embedded_web)");
|
|
||||||
println!("cargo:rerun-if-env-changed=WASMELD_BUILD_WEB");
|
|
||||||
println!("cargo:rerun-if-env-changed=NPM");
|
|
||||||
for input in WEB_INPUTS {
|
|
||||||
println!("cargo:rerun-if-changed={input}");
|
|
||||||
}
|
|
||||||
|
|
||||||
let force = env::var("WASMELD_BUILD_WEB").is_ok_and(|value| value == "1");
|
|
||||||
if env::var("PROFILE").as_deref() != Ok("release") && !force {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let crate_dir = PathBuf::from(required_env("CARGO_MANIFEST_DIR"));
|
|
||||||
let web_dir = crate_dir.join("web");
|
|
||||||
let output_dir = PathBuf::from(required_env("OUT_DIR")).join("web");
|
|
||||||
let npm = env::var_os("NPM")
|
|
||||||
.map(PathBuf::from)
|
|
||||||
.unwrap_or_else(|| PathBuf::from(if cfg!(windows) { "npm.cmd" } else { "npm" }));
|
|
||||||
|
|
||||||
if output_dir.exists() {
|
|
||||||
fs::remove_dir_all(&output_dir).unwrap_or_else(|error| {
|
|
||||||
panic!(
|
|
||||||
"failed to clear embedded Web output {}: {error}",
|
|
||||||
output_dir.display()
|
|
||||||
)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// `npm ci` is deliberate here: a release must not silently rewrite the
|
|
||||||
// dependency graph represented by package-lock.json.
|
|
||||||
run(
|
|
||||||
Command::new(&npm).current_dir(&web_dir).args([
|
|
||||||
"ci",
|
|
||||||
"--ignore-scripts",
|
|
||||||
"--no-audit",
|
|
||||||
"--no-fund",
|
|
||||||
]),
|
|
||||||
"install locked Web dependencies",
|
|
||||||
);
|
|
||||||
run(
|
|
||||||
Command::new(&npm)
|
|
||||||
.current_dir(&web_dir)
|
|
||||||
.env("WASMELD_WEB_OUT_DIR", &output_dir)
|
|
||||||
.args(["run", "build"]),
|
|
||||||
"build embedded Web application",
|
|
||||||
);
|
|
||||||
|
|
||||||
for required in ["index.html", "page.html", "sw.js", "manifest.webmanifest"] {
|
|
||||||
let path = output_dir.join(required);
|
|
||||||
assert!(
|
|
||||||
path.is_file(),
|
|
||||||
"Web build did not produce required asset {}",
|
|
||||||
path.display()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
println!("cargo:rustc-cfg=wasmeld_embedded_web");
|
|
||||||
}
|
|
||||||
|
|
||||||
fn run(command: &mut Command, action: &str) {
|
|
||||||
let status = command
|
|
||||||
.status()
|
|
||||||
.unwrap_or_else(|error| panic!("failed to {action}: {error}"));
|
|
||||||
assert_success(status, action);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn assert_success(status: ExitStatus, action: &str) {
|
|
||||||
assert!(
|
|
||||||
status.success(),
|
|
||||||
"failed to {action}: process exited with {status}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn required_env(name: &str) -> String {
|
|
||||||
env::var(name).unwrap_or_else(|_| panic!("Cargo did not provide {name}"))
|
|
||||||
}
|
|
||||||
@@ -1,697 +0,0 @@
|
|||||||
//! Axum management and public gateway protocol adapters.
|
|
||||||
//!
|
|
||||||
//! The management router accepts JSON and multipart packages and exposes
|
|
||||||
//! lifecycle operations. The gateway router is intentionally smaller: it
|
|
||||||
//! accepts raw `application/octet-stream` input and returns raw bytes plus
|
|
||||||
//! `x-wasmeld-revision` and `x-wasmeld-latency-ms` response headers.
|
|
||||||
//!
|
|
||||||
//! Neither router installs authentication or authorization middleware. A
|
|
||||||
//! production deployment must place the appropriate identity, authorization,
|
|
||||||
//! TLS, rate-limit, and request-size policy in front of these routers. CORS is
|
|
||||||
//! a browser access policy and must not be treated as authentication.
|
|
||||||
|
|
||||||
use super::*;
|
|
||||||
use axum::{
|
|
||||||
Json, Router,
|
|
||||||
body::{Body, Bytes},
|
|
||||||
extract::{DefaultBodyLimit, Multipart, Path as AxumPath, State, rejection::BytesRejection},
|
|
||||||
http::{HeaderMap, HeaderValue, Method, StatusCode, header},
|
|
||||||
response::{IntoResponse, Response},
|
|
||||||
routing::{delete, get, post},
|
|
||||||
};
|
|
||||||
use serde_json::json;
|
|
||||||
use tower_http::{
|
|
||||||
cors::CorsLayer,
|
|
||||||
trace::{DefaultMakeSpan, DefaultOnResponse, TraceLayer},
|
|
||||||
};
|
|
||||||
use tracing::Level;
|
|
||||||
use wasmeld_package::wit_package::WitPackageError;
|
|
||||||
|
|
||||||
/// Builds the Axum management API around a shared [`Console`].
|
|
||||||
///
|
|
||||||
/// The returned router exposes Runtime control, component registration,
|
|
||||||
/// invocation, events, and WIT Registry endpoints.
|
|
||||||
///
|
|
||||||
/// `allowed_origins` only affects browser CORS responses. Passing an empty
|
|
||||||
/// vector does not make the API private; it merely omits allowed origins.
|
|
||||||
/// Multipart bodies receive one MiB of framing headroom above the larger
|
|
||||||
/// configured artifact limit.
|
|
||||||
pub fn app(console: Arc<Console>, allowed_origins: Vec<HeaderValue>) -> Router {
|
|
||||||
let max_body_bytes = console
|
|
||||||
.max_artifact_bytes()
|
|
||||||
.max(console.max_wit_package_bytes())
|
|
||||||
.saturating_add(1024 * 1024);
|
|
||||||
let mut cors = CorsLayer::new()
|
|
||||||
.allow_methods([Method::GET, Method::POST, Method::DELETE])
|
|
||||||
.allow_headers([header::CONTENT_TYPE]);
|
|
||||||
if !allowed_origins.is_empty() {
|
|
||||||
cors = cors.allow_origin(allowed_origins);
|
|
||||||
}
|
|
||||||
|
|
||||||
Router::new()
|
|
||||||
.route("/healthz", get(health))
|
|
||||||
.route("/api/v1/runtime", get(runtime_status))
|
|
||||||
.route("/api/v1/runtime/start", post(start_runtime))
|
|
||||||
.route("/api/v1/runtime/stop", post(stop_runtime))
|
|
||||||
.route("/api/v1/runtime/restart", post(restart_runtime))
|
|
||||||
.route("/api/v1/services", get(list_services).post(register))
|
|
||||||
.route(
|
|
||||||
"/api/v1/services/{id}/{revision}",
|
|
||||||
delete(unregister_service),
|
|
||||||
)
|
|
||||||
.route("/api/v1/deployments", get(list_deployments))
|
|
||||||
.route("/api/v1/deployments/{id}", get(get_deployment))
|
|
||||||
.route(
|
|
||||||
"/api/v1/deployments/{id}/activate",
|
|
||||||
post(activate_deployment),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/api/v1/wit/packages",
|
|
||||||
get(list_wit_packages).post(publish_wit_package),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/api/v1/wit/packages/{namespace}/{name}/{version}",
|
|
||||||
get(get_wit_package),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/api/v1/wit/packages/{namespace}/{name}/{version}/content",
|
|
||||||
get(download_wit_package),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/api/v1/services/{id}/{revision}/start",
|
|
||||||
post(start_service),
|
|
||||||
)
|
|
||||||
.route("/api/v1/services/{id}/{revision}/stop", post(stop_service))
|
|
||||||
.route(
|
|
||||||
"/api/v1/services/{id}/{revision}/restart",
|
|
||||||
post(restart_service),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/api/v1/services/{id}/{revision}/invoke",
|
|
||||||
post(invoke_service),
|
|
||||||
)
|
|
||||||
.route("/api/v1/events", get(list_events))
|
|
||||||
.layer(DefaultBodyLimit::max(max_body_bytes))
|
|
||||||
.layer(cors)
|
|
||||||
.layer(
|
|
||||||
TraceLayer::new_for_http()
|
|
||||||
.make_span_with(DefaultMakeSpan::new().level(Level::INFO))
|
|
||||||
.on_response(DefaultOnResponse::new().level(Level::INFO)),
|
|
||||||
)
|
|
||||||
.with_state(console)
|
|
||||||
.merge(web::router())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Builds the public data-plane API around the same resident Runtime.
|
|
||||||
///
|
|
||||||
/// This router intentionally contains no registration, lifecycle, Registry,
|
|
||||||
/// or deployment management routes. Callers must send
|
|
||||||
/// `Content-Type: application/octet-stream`; the stable service ID is resolved
|
|
||||||
/// to its active immutable revision at the start of each request.
|
|
||||||
///
|
|
||||||
/// # Wire example
|
|
||||||
///
|
|
||||||
/// ```text
|
|
||||||
/// POST /v1/services/image-resize/invoke
|
|
||||||
/// Content-Type: application/octet-stream
|
|
||||||
///
|
|
||||||
/// <component-specific bytes>
|
|
||||||
/// ```
|
|
||||||
pub fn gateway_app(console: Arc<Console>) -> Router {
|
|
||||||
let max_input_bytes = console.max_gateway_input_bytes();
|
|
||||||
Router::new()
|
|
||||||
.route("/healthz", get(health))
|
|
||||||
.route("/v1/services/{id}/invoke", post(gateway_invoke))
|
|
||||||
.layer(DefaultBodyLimit::max(max_input_bytes))
|
|
||||||
.layer(
|
|
||||||
TraceLayer::new_for_http()
|
|
||||||
.make_span_with(DefaultMakeSpan::new().level(Level::INFO))
|
|
||||||
.on_response(DefaultOnResponse::new().level(Level::INFO)),
|
|
||||||
)
|
|
||||||
.with_state(console)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn health() -> Json<serde_json::Value> {
|
|
||||||
Json(json!({ "status": "ok" }))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn gateway_invoke(
|
|
||||||
State(console): State<Arc<Console>>,
|
|
||||||
AxumPath(service_id): AxumPath<String>,
|
|
||||||
headers: HeaderMap,
|
|
||||||
body: Result<Bytes, BytesRejection>,
|
|
||||||
) -> Result<Response, GatewayError> {
|
|
||||||
validate_path_segment("id", &service_id)?;
|
|
||||||
let content_type = headers
|
|
||||||
.get(header::CONTENT_TYPE)
|
|
||||||
.and_then(|value| value.to_str().ok())
|
|
||||||
.and_then(|value| value.split(';').next())
|
|
||||||
.map(str::trim);
|
|
||||||
if !content_type.is_some_and(|value| value.eq_ignore_ascii_case("application/octet-stream")) {
|
|
||||||
return Err(GatewayError::UnsupportedMediaType);
|
|
||||||
}
|
|
||||||
let input = body.map_err(|_| GatewayError::PayloadTooLarge {
|
|
||||||
limit: console.max_gateway_input_bytes(),
|
|
||||||
})?;
|
|
||||||
// Deployment resolution and Actor enqueue happen in the same blocking
|
|
||||||
// closure. The returned revision header therefore identifies the exact
|
|
||||||
// revision that received this request, even if deployment changes later.
|
|
||||||
let invocation = run_blocking(console, move |console| {
|
|
||||||
console.gateway_invoke(&service_id, input.to_vec())
|
|
||||||
})
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let mut response_headers = HeaderMap::new();
|
|
||||||
response_headers.insert(
|
|
||||||
header::CONTENT_TYPE,
|
|
||||||
HeaderValue::from_static("application/octet-stream"),
|
|
||||||
);
|
|
||||||
response_headers.insert(
|
|
||||||
"x-wasmeld-revision",
|
|
||||||
HeaderValue::from_str(&invocation.revision).map_err(|_| {
|
|
||||||
ConsoleError::InvalidRequest("revision is not a valid header".to_owned())
|
|
||||||
})?,
|
|
||||||
);
|
|
||||||
response_headers.insert(
|
|
||||||
"x-wasmeld-latency-ms",
|
|
||||||
HeaderValue::from_str(&invocation.result.latency_ms.to_string()).map_err(|_| {
|
|
||||||
ConsoleError::InvalidRequest("latency is not a valid header".to_owned())
|
|
||||||
})?,
|
|
||||||
);
|
|
||||||
Ok((response_headers, invocation.result.output).into_response())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn runtime_status(
|
|
||||||
State(console): State<Arc<Console>>,
|
|
||||||
) -> Result<Json<RuntimeView>, ApiError> {
|
|
||||||
Ok(Json(console.runtime_view()?))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn start_runtime(State(console): State<Arc<Console>>) -> Result<Json<RuntimeView>, ApiError> {
|
|
||||||
Ok(Json(
|
|
||||||
run_blocking_and_persist(console, |console| console.start_runtime()).await?,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn stop_runtime(State(console): State<Arc<Console>>) -> Result<Json<RuntimeView>, ApiError> {
|
|
||||||
Ok(Json(
|
|
||||||
run_blocking_and_persist(console, |console| console.stop_runtime()).await?,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn restart_runtime(
|
|
||||||
State(console): State<Arc<Console>>,
|
|
||||||
) -> Result<Json<RuntimeView>, ApiError> {
|
|
||||||
Ok(Json(
|
|
||||||
run_blocking_and_persist(console, |console| console.restart_runtime()).await?,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn list_services(State(console): State<Arc<Console>>) -> Result<Json<ServiceList>, ApiError> {
|
|
||||||
Ok(Json(ServiceList {
|
|
||||||
services: console.services()?,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn list_deployments(
|
|
||||||
State(console): State<Arc<Console>>,
|
|
||||||
) -> Result<Json<DeploymentList>, ApiError> {
|
|
||||||
Ok(Json(DeploymentList {
|
|
||||||
deployments: console.deployments()?,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn get_deployment(
|
|
||||||
State(console): State<Arc<Console>>,
|
|
||||||
AxumPath(service_id): AxumPath<String>,
|
|
||||||
) -> Result<Json<DeploymentView>, ApiError> {
|
|
||||||
validate_path_segment("id", &service_id)?;
|
|
||||||
Ok(Json(console.deployment(&service_id)?))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn activate_deployment(
|
|
||||||
State(console): State<Arc<Console>>,
|
|
||||||
AxumPath(service_id): AxumPath<String>,
|
|
||||||
Json(request): Json<ActivateDeploymentRequest>,
|
|
||||||
) -> Result<Json<DeploymentView>, ApiError> {
|
|
||||||
validate_path_segment("id", &service_id)?;
|
|
||||||
validate_path_segment("revision", &request.revision)?;
|
|
||||||
let init_config = decode_optional_base64(request.init_config_base64.as_deref())?;
|
|
||||||
Ok(Json(
|
|
||||||
run_blocking_and_persist(console, move |console| {
|
|
||||||
console.activate_deployment(service_id, request.revision, init_config)
|
|
||||||
})
|
|
||||||
.await?,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn list_events(State(console): State<Arc<Console>>) -> Result<Json<EventList>, ApiError> {
|
|
||||||
Ok(Json(EventList {
|
|
||||||
events: console.events()?,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn list_wit_packages(
|
|
||||||
State(console): State<Arc<Console>>,
|
|
||||||
) -> Result<Json<WitPackageList>, ApiError> {
|
|
||||||
Ok(Json(WitPackageList {
|
|
||||||
packages: console.wit_registry.list()?,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn get_wit_package(
|
|
||||||
State(console): State<Arc<Console>>,
|
|
||||||
AxumPath((namespace, name, version)): AxumPath<(String, String, String)>,
|
|
||||||
) -> Result<Json<WitPackageMetadata>, ApiError> {
|
|
||||||
Ok(Json(
|
|
||||||
console
|
|
||||||
.wit_registry
|
|
||||||
.metadata(&format!("{namespace}:{name}"), &version)?,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn download_wit_package(
|
|
||||||
State(console): State<Arc<Console>>,
|
|
||||||
AxumPath((namespace, name, version)): AxumPath<(String, String, String)>,
|
|
||||||
) -> Result<Response, ApiError> {
|
|
||||||
let (metadata, bytes) = console
|
|
||||||
.wit_registry
|
|
||||||
.download(&format!("{namespace}:{name}"), &version)?;
|
|
||||||
Response::builder()
|
|
||||||
.status(StatusCode::OK)
|
|
||||||
.header(header::CONTENT_TYPE, "application/wasm")
|
|
||||||
.header(header::ETAG, format!("\"{}\"", metadata.sha256))
|
|
||||||
.body(Body::from(bytes))
|
|
||||||
.map_err(|error| {
|
|
||||||
ConsoleError::InvalidRequest(format!("failed to build package response: {error}"))
|
|
||||||
.into()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn publish_wit_package(
|
|
||||||
State(console): State<Arc<Console>>,
|
|
||||||
mut multipart: Multipart,
|
|
||||||
) -> Result<(StatusCode, Json<WitPackageMetadata>), ApiError> {
|
|
||||||
// Axum materializes this one field in memory. DefaultBodyLimit above is
|
|
||||||
// only a transport guard; WitRegistry repeats the exact artifact limit and
|
|
||||||
// validates the package identity before committing it to disk.
|
|
||||||
let mut package = None;
|
|
||||||
while let Some(field) = multipart
|
|
||||||
.next_field()
|
|
||||||
.await
|
|
||||||
.map_err(|error| ConsoleError::InvalidRequest(format!("invalid multipart body: {error}")))?
|
|
||||||
{
|
|
||||||
match field.name() {
|
|
||||||
Some("package") => {
|
|
||||||
if package.is_some() {
|
|
||||||
return Err(ConsoleError::InvalidRequest(
|
|
||||||
"package field must occur exactly once".to_owned(),
|
|
||||||
)
|
|
||||||
.into());
|
|
||||||
}
|
|
||||||
package = Some(
|
|
||||||
field
|
|
||||||
.bytes()
|
|
||||||
.await
|
|
||||||
.map_err(|error| {
|
|
||||||
ConsoleError::InvalidRequest(format!(
|
|
||||||
"invalid WIT package field: {error}"
|
|
||||||
))
|
|
||||||
})?
|
|
||||||
.to_vec(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Some(name) => {
|
|
||||||
return Err(ConsoleError::InvalidRequest(format!(
|
|
||||||
"unsupported multipart field {name}"
|
|
||||||
))
|
|
||||||
.into());
|
|
||||||
}
|
|
||||||
None => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let package = package
|
|
||||||
.ok_or_else(|| ConsoleError::InvalidRequest("package field is required".to_owned()))?;
|
|
||||||
if package.len() > console.max_wit_package_bytes() {
|
|
||||||
return Err(WitRegistryError::TooLarge {
|
|
||||||
limit: console.max_wit_package_bytes(),
|
|
||||||
}
|
|
||||||
.into());
|
|
||||||
}
|
|
||||||
let metadata = run_blocking(console, move |console| {
|
|
||||||
Ok(console.wit_registry.publish(&package)?)
|
|
||||||
})
|
|
||||||
.await?;
|
|
||||||
Ok((StatusCode::CREATED, Json(metadata)))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn register(
|
|
||||||
State(console): State<Arc<Console>>,
|
|
||||||
mut multipart: Multipart,
|
|
||||||
) -> Result<(StatusCode, Json<ServiceView>), ApiError> {
|
|
||||||
let mut package = None;
|
|
||||||
|
|
||||||
while let Some(field) = multipart
|
|
||||||
.next_field()
|
|
||||||
.await
|
|
||||||
.map_err(|error| ConsoleError::InvalidRequest(format!("invalid multipart body: {error}")))?
|
|
||||||
{
|
|
||||||
match field.name() {
|
|
||||||
Some("package") => {
|
|
||||||
if package.is_some() {
|
|
||||||
return Err(ConsoleError::InvalidRequest(
|
|
||||||
"package field must occur exactly once".to_owned(),
|
|
||||||
)
|
|
||||||
.into());
|
|
||||||
}
|
|
||||||
package = Some(
|
|
||||||
field
|
|
||||||
.bytes()
|
|
||||||
.await
|
|
||||||
.map_err(|error| {
|
|
||||||
ConsoleError::InvalidRequest(format!("invalid package field: {error}"))
|
|
||||||
})?
|
|
||||||
.to_vec(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Some(name) => {
|
|
||||||
return Err(ConsoleError::InvalidRequest(format!(
|
|
||||||
"unsupported multipart field {name}"
|
|
||||||
))
|
|
||||||
.into());
|
|
||||||
}
|
|
||||||
None => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let package = package
|
|
||||||
.ok_or_else(|| ConsoleError::InvalidRequest("package field is required".to_owned()))?;
|
|
||||||
let service =
|
|
||||||
run_blocking_and_persist(console, move |console| console.register_package(package)).await?;
|
|
||||||
Ok((StatusCode::CREATED, Json(service)))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn start_service(
|
|
||||||
State(console): State<Arc<Console>>,
|
|
||||||
AxumPath((id, revision)): AxumPath<(String, String)>,
|
|
||||||
request: Option<Json<StartRequest>>,
|
|
||||||
) -> Result<Json<ServiceView>, ApiError> {
|
|
||||||
let key = api_key(id, revision)?;
|
|
||||||
let init_config = decode_optional_base64(
|
|
||||||
request
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|Json(request)| request.init_config_base64.as_deref()),
|
|
||||||
)?;
|
|
||||||
Ok(Json(
|
|
||||||
run_blocking_and_persist(console, move |console| console.start(&key, init_config)).await?,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn stop_service(
|
|
||||||
State(console): State<Arc<Console>>,
|
|
||||||
AxumPath((id, revision)): AxumPath<(String, String)>,
|
|
||||||
) -> Result<Json<ServiceView>, ApiError> {
|
|
||||||
let key = api_key(id, revision)?;
|
|
||||||
Ok(Json(
|
|
||||||
run_blocking_and_persist(console, move |console| console.stop(&key)).await?,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn unregister_service(
|
|
||||||
State(console): State<Arc<Console>>,
|
|
||||||
AxumPath((id, revision)): AxumPath<(String, String)>,
|
|
||||||
) -> Result<Json<ServiceView>, ApiError> {
|
|
||||||
let key = api_key(id, revision)?;
|
|
||||||
Ok(Json(
|
|
||||||
run_blocking_and_persist(console, move |console| console.unregister(&key)).await?,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn restart_service(
|
|
||||||
State(console): State<Arc<Console>>,
|
|
||||||
AxumPath((id, revision)): AxumPath<(String, String)>,
|
|
||||||
request: Option<Json<StartRequest>>,
|
|
||||||
) -> Result<Json<ServiceView>, ApiError> {
|
|
||||||
let key = api_key(id, revision)?;
|
|
||||||
let init_config = decode_optional_base64(
|
|
||||||
request
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|Json(request)| request.init_config_base64.as_deref()),
|
|
||||||
)?;
|
|
||||||
Ok(Json(
|
|
||||||
run_blocking_and_persist(console, move |console| console.restart(&key, init_config))
|
|
||||||
.await?,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn invoke_service(
|
|
||||||
State(console): State<Arc<Console>>,
|
|
||||||
AxumPath((id, revision)): AxumPath<(String, String)>,
|
|
||||||
Json(request): Json<InvokeRequest>,
|
|
||||||
) -> Result<Json<InvokeResponse>, ApiError> {
|
|
||||||
let key = api_key(id, revision)?;
|
|
||||||
let input = BASE64.decode(request.input_base64).map_err(|error| {
|
|
||||||
ConsoleError::InvalidRequest(format!("input_base64 is invalid: {error}"))
|
|
||||||
})?;
|
|
||||||
let result = run_blocking(console, move |console| console.invoke(&key, input)).await?;
|
|
||||||
Ok(Json(InvokeResponse {
|
|
||||||
output_base64: BASE64.encode(&result.output),
|
|
||||||
output_bytes: result.output.len(),
|
|
||||||
latency_ms: result.latency_ms,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn run_blocking<T, F>(console: Arc<Console>, operation: F) -> Result<T, ConsoleError>
|
|
||||||
where
|
|
||||||
T: Send + 'static,
|
|
||||||
F: FnOnce(Arc<Console>) -> Result<T, ConsoleError> + Send + 'static,
|
|
||||||
{
|
|
||||||
// Wasmtime compilation and synchronous Actor calls must not occupy a Tokio
|
|
||||||
// async worker.
|
|
||||||
tokio::task::spawn_blocking(move || operation(console))
|
|
||||||
.await
|
|
||||||
.map_err(ConsoleError::from)
|
|
||||||
.and_then(|result| result)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn run_blocking_and_persist<T, F>(
|
|
||||||
console: Arc<Console>,
|
|
||||||
operation: F,
|
|
||||||
) -> Result<T, ConsoleError>
|
|
||||||
where
|
|
||||||
T: Send + 'static,
|
|
||||||
F: FnOnce(Arc<Console>) -> Result<T, ConsoleError> + Send + 'static,
|
|
||||||
{
|
|
||||||
// Control operations mutate durable state. Persist even on failure because
|
|
||||||
// an operation may have changed state before returning its error.
|
|
||||||
let operation_console = Arc::clone(&console);
|
|
||||||
let result = run_blocking(operation_console, operation).await;
|
|
||||||
// The flush marker orders previously accepted metric deltas before the
|
|
||||||
// full snapshot transaction. It means "attempted", not "durably written";
|
|
||||||
// the telemetry worker logs write failures independently.
|
|
||||||
console.flush_invocation_telemetry().await;
|
|
||||||
let persisted = console.persist_snapshot().await;
|
|
||||||
|
|
||||||
match result {
|
|
||||||
Ok(value) => {
|
|
||||||
persisted?;
|
|
||||||
Ok(value)
|
|
||||||
}
|
|
||||||
Err(error) => {
|
|
||||||
if let Err(persistence_error) = persisted {
|
|
||||||
tracing::error!(
|
|
||||||
error = %persistence_error,
|
|
||||||
"failed to persist console state after operation error"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Err(error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
enum GatewayError {
|
|
||||||
Console(ConsoleError),
|
|
||||||
UnsupportedMediaType,
|
|
||||||
PayloadTooLarge { limit: usize },
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<ConsoleError> for GatewayError {
|
|
||||||
fn from(error: ConsoleError) -> Self {
|
|
||||||
Self::Console(error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl IntoResponse for GatewayError {
|
|
||||||
fn into_response(self) -> Response {
|
|
||||||
let (status, code, message) = match self {
|
|
||||||
Self::UnsupportedMediaType => (
|
|
||||||
StatusCode::UNSUPPORTED_MEDIA_TYPE,
|
|
||||||
"unsupported_media_type",
|
|
||||||
"Content-Type must be application/octet-stream".to_owned(),
|
|
||||||
),
|
|
||||||
Self::PayloadTooLarge { limit } => (
|
|
||||||
StatusCode::PAYLOAD_TOO_LARGE,
|
|
||||||
"payload_too_large",
|
|
||||||
format!("request body exceeds the {limit}-byte limit"),
|
|
||||||
),
|
|
||||||
Self::Console(error) => {
|
|
||||||
let (status, code, message) = match &error {
|
|
||||||
ConsoleError::InvalidRequest(_) => (
|
|
||||||
StatusCode::BAD_REQUEST,
|
|
||||||
"invalid_request",
|
|
||||||
error.to_string(),
|
|
||||||
),
|
|
||||||
ConsoleError::DeploymentNotFound(_)
|
|
||||||
| ConsoleError::ServiceNotFound(_)
|
|
||||||
| ConsoleError::Runtime(RuntimeError::ServiceNotRegistered(_)) => (
|
|
||||||
StatusCode::NOT_FOUND,
|
|
||||||
"service_not_found",
|
|
||||||
"service is not deployed".to_owned(),
|
|
||||||
),
|
|
||||||
ConsoleError::RuntimeNotRunning
|
|
||||||
| ConsoleError::Runtime(RuntimeError::ActorUnavailable(_))
|
|
||||||
| ConsoleError::Runtime(RuntimeError::ActorStopped(_)) => (
|
|
||||||
StatusCode::SERVICE_UNAVAILABLE,
|
|
||||||
"service_unavailable",
|
|
||||||
"service is temporarily unavailable".to_owned(),
|
|
||||||
),
|
|
||||||
ConsoleError::Runtime(RuntimeError::ActorOverloaded(_)) => (
|
|
||||||
StatusCode::TOO_MANY_REQUESTS,
|
|
||||||
"service_overloaded",
|
|
||||||
"service is overloaded".to_owned(),
|
|
||||||
),
|
|
||||||
ConsoleError::Runtime(RuntimeError::InputTooLarge { .. }) => (
|
|
||||||
StatusCode::PAYLOAD_TOO_LARGE,
|
|
||||||
"payload_too_large",
|
|
||||||
error.to_string(),
|
|
||||||
),
|
|
||||||
ConsoleError::Runtime(RuntimeError::DeadlineExceeded { .. }) => (
|
|
||||||
StatusCode::GATEWAY_TIMEOUT,
|
|
||||||
"deadline_exceeded",
|
|
||||||
"service execution deadline exceeded".to_owned(),
|
|
||||||
),
|
|
||||||
ConsoleError::Runtime(
|
|
||||||
RuntimeError::OutputTooLarge { .. }
|
|
||||||
| RuntimeError::ActorFault { .. }
|
|
||||||
| RuntimeError::ComponentError { .. },
|
|
||||||
) => (StatusCode::BAD_GATEWAY, "service_error", error.to_string()),
|
|
||||||
ConsoleError::ArtifactTooLarge { .. }
|
|
||||||
| ConsoleError::ActiveDeployment(_)
|
|
||||||
| ConsoleError::Package(_)
|
|
||||||
| ConsoleError::WitRegistry(_)
|
|
||||||
| ConsoleError::Storage { .. }
|
|
||||||
| ConsoleError::ManifestSerialize(_)
|
|
||||||
| ConsoleError::Database(_)
|
|
||||||
| ConsoleError::KvBackendStart(_)
|
|
||||||
| ConsoleError::InvalidDatabaseData(_)
|
|
||||||
| ConsoleError::Runtime(_)
|
|
||||||
| ConsoleError::Join(_)
|
|
||||||
| ConsoleError::LockPoisoned => (
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
"internal_error",
|
|
||||||
"internal gateway error".to_owned(),
|
|
||||||
),
|
|
||||||
};
|
|
||||||
(status, code, message)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
(
|
|
||||||
status,
|
|
||||||
Json(json!({
|
|
||||||
"error": {
|
|
||||||
"code": code,
|
|
||||||
"message": message,
|
|
||||||
}
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
.into_response()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ApiError(ConsoleError);
|
|
||||||
|
|
||||||
impl From<ConsoleError> for ApiError {
|
|
||||||
fn from(error: ConsoleError) -> Self {
|
|
||||||
Self(error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<WitRegistryError> for ApiError {
|
|
||||||
fn from(error: WitRegistryError) -> Self {
|
|
||||||
Self(error.into())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl IntoResponse for ApiError {
|
|
||||||
fn into_response(self) -> Response {
|
|
||||||
let (status, code) = match &self.0 {
|
|
||||||
ConsoleError::InvalidRequest(_) => (StatusCode::BAD_REQUEST, "invalid_request"),
|
|
||||||
ConsoleError::ServiceNotFound(_) => (StatusCode::NOT_FOUND, "service_not_found"),
|
|
||||||
ConsoleError::DeploymentNotFound(_) => (StatusCode::NOT_FOUND, "deployment_not_found"),
|
|
||||||
ConsoleError::ActiveDeployment(_) => (StatusCode::CONFLICT, "active_deployment"),
|
|
||||||
ConsoleError::RuntimeNotRunning => (StatusCode::CONFLICT, "runtime_not_running"),
|
|
||||||
ConsoleError::ArtifactTooLarge { .. }
|
|
||||||
| ConsoleError::Package(PackageError::ComponentTooLarge { .. })
|
|
||||||
| ConsoleError::WitRegistry(WitRegistryError::TooLarge { .. })
|
|
||||||
| ConsoleError::Runtime(RuntimeError::InputTooLarge { .. })
|
|
||||||
| ConsoleError::Runtime(RuntimeError::OutputTooLarge { .. }) => {
|
|
||||||
(StatusCode::PAYLOAD_TOO_LARGE, "payload_too_large")
|
|
||||||
}
|
|
||||||
ConsoleError::Runtime(RuntimeError::ServiceNotRegistered(_)) => {
|
|
||||||
(StatusCode::NOT_FOUND, "service_not_registered")
|
|
||||||
}
|
|
||||||
ConsoleError::Runtime(RuntimeError::ServiceAlreadyRegistered(_))
|
|
||||||
| ConsoleError::Runtime(RuntimeError::ActorUnavailable(_))
|
|
||||||
| ConsoleError::Runtime(RuntimeError::ActorOverloaded(_)) => {
|
|
||||||
(StatusCode::CONFLICT, "runtime_conflict")
|
|
||||||
}
|
|
||||||
ConsoleError::WitRegistry(WitRegistryError::AlreadyExists(_)) => {
|
|
||||||
(StatusCode::CONFLICT, "wit_package_exists")
|
|
||||||
}
|
|
||||||
ConsoleError::WitRegistry(WitRegistryError::NotFound(_)) => {
|
|
||||||
(StatusCode::NOT_FOUND, "wit_package_not_found")
|
|
||||||
}
|
|
||||||
ConsoleError::Runtime(RuntimeError::DeadlineExceeded { .. }) => {
|
|
||||||
(StatusCode::REQUEST_TIMEOUT, "deadline_exceeded")
|
|
||||||
}
|
|
||||||
ConsoleError::Runtime(
|
|
||||||
RuntimeError::InvalidServiceKey
|
|
||||||
| RuntimeError::InvalidManifest(_)
|
|
||||||
| RuntimeError::ManifestParse(_)
|
|
||||||
| RuntimeError::ComponentCompilation(_)
|
|
||||||
| RuntimeError::UnsupportedImport(_)
|
|
||||||
| RuntimeError::ActorInitialization(_)
|
|
||||||
| RuntimeError::ComponentError { .. },
|
|
||||||
) => (StatusCode::UNPROCESSABLE_ENTITY, "invalid_component"),
|
|
||||||
ConsoleError::Package(_) => (StatusCode::UNPROCESSABLE_ENTITY, "invalid_package"),
|
|
||||||
ConsoleError::WitRegistry(WitRegistryError::InvalidPackage(
|
|
||||||
WitPackageError::Source { .. }
|
|
||||||
| WitPackageError::Encode(_)
|
|
||||||
| WitPackageError::Decode(_)
|
|
||||||
| WitPackageError::MissingVersion(_),
|
|
||||||
)) => (StatusCode::UNPROCESSABLE_ENTITY, "invalid_wit_package"),
|
|
||||||
ConsoleError::Storage { .. }
|
|
||||||
| ConsoleError::ManifestSerialize(_)
|
|
||||||
| ConsoleError::Database(_)
|
|
||||||
| ConsoleError::KvBackendStart(_)
|
|
||||||
| ConsoleError::InvalidDatabaseData(_)
|
|
||||||
| ConsoleError::Runtime(_)
|
|
||||||
| ConsoleError::WitRegistry(WitRegistryError::InvalidStorage(_))
|
|
||||||
| ConsoleError::WitRegistry(WitRegistryError::Storage { .. })
|
|
||||||
| ConsoleError::WitRegistry(WitRegistryError::LockPoisoned)
|
|
||||||
| ConsoleError::Join(_)
|
|
||||||
| ConsoleError::LockPoisoned => (StatusCode::INTERNAL_SERVER_ERROR, "internal_error"),
|
|
||||||
};
|
|
||||||
|
|
||||||
(
|
|
||||||
status,
|
|
||||||
Json(json!({
|
|
||||||
"error": {
|
|
||||||
"code": code,
|
|
||||||
"message": self.0.to_string(),
|
|
||||||
}
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
.into_response()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
//! Bounded, asynchronous persistence for data-plane invocation telemetry.
|
|
||||||
//!
|
|
||||||
//! Invocation responses must not wait for libSQL, so producers use
|
|
||||||
//! [`tokio::sync::mpsc::Sender::try_send`] and one task writes ordered batches.
|
|
||||||
//! When the queue is full or closed, the update is deliberately dropped and a
|
|
||||||
//! warning is emitted. These counters and events are operational telemetry;
|
|
||||||
//! they are not suitable as a billing ledger or audit log.
|
|
||||||
|
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use tokio::sync::{Mutex, mpsc, oneshot};
|
|
||||||
|
|
||||||
use crate::persistence::{InvocationUpdate, Persistence};
|
|
||||||
|
|
||||||
const QUEUE_CAPACITY: usize = 1024;
|
|
||||||
const MAX_BATCH_SIZE: usize = 64;
|
|
||||||
|
|
||||||
/// Non-blocking producer used by Wasmtime invocation threads.
|
|
||||||
///
|
|
||||||
/// All producers share one FIFO queue. Queue order is preserved for accepted
|
|
||||||
/// records, but a process crash can lose records that have not yet reached
|
|
||||||
/// libSQL.
|
|
||||||
pub(crate) struct InvocationPersistence {
|
|
||||||
sender: mpsc::Sender<Command>,
|
|
||||||
}
|
|
||||||
|
|
||||||
enum Command {
|
|
||||||
Record(InvocationUpdate),
|
|
||||||
Flush(oneshot::Sender<()>),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl InvocationPersistence {
|
|
||||||
/// Starts the single ordered writer on the current Tokio runtime.
|
|
||||||
///
|
|
||||||
/// `sync` is shared with full Console snapshot persistence. Taking it
|
|
||||||
/// around each batch prevents a snapshot transaction from racing a metric
|
|
||||||
/// delta transaction.
|
|
||||||
pub(crate) fn start(persistence: Persistence, sync: Arc<Mutex<()>>) -> Self {
|
|
||||||
let (sender, mut receiver) = mpsc::channel(QUEUE_CAPACITY);
|
|
||||||
tokio::spawn(async move {
|
|
||||||
while let Some(first) = receiver.recv().await {
|
|
||||||
let mut batch = Vec::with_capacity(MAX_BATCH_SIZE);
|
|
||||||
let mut flushes = Vec::new();
|
|
||||||
match first {
|
|
||||||
Command::Record(update) => batch.push(update),
|
|
||||||
Command::Flush(flush) => flushes.push(flush),
|
|
||||||
}
|
|
||||||
while batch.len() < MAX_BATCH_SIZE {
|
|
||||||
match receiver.try_recv() {
|
|
||||||
Ok(Command::Record(update)) => batch.push(update),
|
|
||||||
Ok(Command::Flush(flush)) => flushes.push(flush),
|
|
||||||
Err(_) => break,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !batch.is_empty() {
|
|
||||||
let _sync = sync.lock().await;
|
|
||||||
if let Err(error) = persistence.record_invocations(batch).await {
|
|
||||||
tracing::error!(%error, "failed to persist invocation telemetry");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Flush is an ordering barrier, not a durability receipt. A
|
|
||||||
// database error above is logged rather than returned, and a
|
|
||||||
// flush may wait for records dequeued after the marker when
|
|
||||||
// those records fit in the same batch.
|
|
||||||
for flush in flushes {
|
|
||||||
let _ = flush.send(());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
Self { sender }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Enqueues telemetry without extending API response latency.
|
|
||||||
///
|
|
||||||
/// Saturation drops this update instead of applying backpressure to the
|
|
||||||
/// Component invocation path.
|
|
||||||
pub(crate) fn record(&self, update: InvocationUpdate) {
|
|
||||||
if let Err(error) = self.sender.try_send(Command::Record(update)) {
|
|
||||||
tracing::warn!(%error, "invocation telemetry queue is unavailable; update dropped");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Waits until all telemetry queued before this call has been attempted.
|
|
||||||
///
|
|
||||||
/// This method is used before a control-plane snapshot so the snapshot
|
|
||||||
/// reads the newest in-memory counters after older delta writes. It does
|
|
||||||
/// not report persistence failures; those are visible through tracing.
|
|
||||||
pub(crate) async fn flush(&self) {
|
|
||||||
let (sender, receiver) = oneshot::channel();
|
|
||||||
if self.sender.send(Command::Flush(sender)).await.is_ok() {
|
|
||||||
let _ = receiver.await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,251 +0,0 @@
|
|||||||
//! Bounded bridge between synchronous Component Host calls and async Toasty.
|
|
||||||
//!
|
|
||||||
//! Wasmtime calls [`wasmeld_runtime::KvBackend`] synchronously on an Actor
|
|
||||||
//! thread, while Toasty and libSQL are asynchronous. This adapter moves every
|
|
||||||
//! database operation onto one dedicated current-thread Tokio runtime. The
|
|
||||||
//! bounded channel prevents a slow database from accumulating unbounded work
|
|
||||||
//! or blocking every Actor while waiting for queue capacity.
|
|
||||||
//!
|
|
||||||
//! # Timeout semantics
|
|
||||||
//!
|
|
||||||
//! A timeout only stops the caller from waiting. It does not remove a command
|
|
||||||
//! that is already queued or cancel a libSQL operation that has started.
|
|
||||||
//! Consequently, `set` and `delete` may commit after the Component observes
|
|
||||||
//! `KvBackendError::Timeout`. Components should make retries idempotent and
|
|
||||||
//! must not interpret a timeout as proof that no mutation occurred.
|
|
||||||
|
|
||||||
use std::{
|
|
||||||
fmt, io,
|
|
||||||
sync::{
|
|
||||||
Arc, Mutex,
|
|
||||||
mpsc::{self, Receiver, SyncSender, TrySendError},
|
|
||||||
},
|
|
||||||
thread,
|
|
||||||
time::Duration,
|
|
||||||
};
|
|
||||||
|
|
||||||
use wasmeld_runtime::{KvBackend, KvBackendError};
|
|
||||||
|
|
||||||
use crate::persistence::Persistence;
|
|
||||||
|
|
||||||
const COMMAND_CAPACITY: usize = 64;
|
|
||||||
// Keep Host calls responsive even when a Component requests a larger timeout.
|
|
||||||
// The Component-facing deadline is an upper bound, not permission to occupy an
|
|
||||||
// Actor thread indefinitely.
|
|
||||||
const MAX_RESPONSE_TIMEOUT: Duration = Duration::from_millis(400);
|
|
||||||
|
|
||||||
pub(crate) struct ToastyKvBackend {
|
|
||||||
inner: Arc<BackendInner>,
|
|
||||||
}
|
|
||||||
|
|
||||||
struct BackendInner {
|
|
||||||
sender: SyncSender<KvCommand>,
|
|
||||||
worker: Mutex<Option<thread::JoinHandle<()>>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
enum KvCommand {
|
|
||||||
Get {
|
|
||||||
service_id: String,
|
|
||||||
key: String,
|
|
||||||
response: SyncSender<Result<Option<Vec<u8>>, KvBackendError>>,
|
|
||||||
},
|
|
||||||
Set {
|
|
||||||
service_id: String,
|
|
||||||
key: String,
|
|
||||||
value: Vec<u8>,
|
|
||||||
response: SyncSender<Result<(), KvBackendError>>,
|
|
||||||
},
|
|
||||||
Delete {
|
|
||||||
service_id: String,
|
|
||||||
key: String,
|
|
||||||
response: SyncSender<Result<(), KvBackendError>>,
|
|
||||||
},
|
|
||||||
Shutdown,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ToastyKvBackend {
|
|
||||||
/// Starts the database worker and waits until its Tokio runtime is ready.
|
|
||||||
///
|
|
||||||
/// Waiting for the ready signal makes construction fail deterministically
|
|
||||||
/// instead of returning a backend whose first operation discovers that
|
|
||||||
/// the worker could not initialize.
|
|
||||||
pub(crate) fn start(persistence: Persistence) -> io::Result<Self> {
|
|
||||||
let (sender, receiver) = mpsc::sync_channel(COMMAND_CAPACITY);
|
|
||||||
let (ready_sender, ready_receiver) = mpsc::sync_channel(1);
|
|
||||||
let worker = thread::Builder::new()
|
|
||||||
.name("wasmeld-kv-backend".to_owned())
|
|
||||||
.spawn(move || run_worker(persistence, receiver, ready_sender))?;
|
|
||||||
|
|
||||||
match ready_receiver.recv() {
|
|
||||||
Ok(Ok(())) => Ok(Self {
|
|
||||||
inner: Arc::new(BackendInner {
|
|
||||||
sender,
|
|
||||||
worker: Mutex::new(Some(worker)),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
Ok(Err(error)) => {
|
|
||||||
let _ = worker.join();
|
|
||||||
Err(error)
|
|
||||||
}
|
|
||||||
Err(_) => {
|
|
||||||
let _ = worker.join();
|
|
||||||
Err(io::Error::other("KV backend worker stopped during startup"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn request<T>(
|
|
||||||
&self,
|
|
||||||
timeout: Duration,
|
|
||||||
build: impl FnOnce(SyncSender<Result<T, KvBackendError>>) -> KvCommand,
|
|
||||||
) -> Result<T, KvBackendError> {
|
|
||||||
let (response, receiver) = mpsc::sync_channel(1);
|
|
||||||
// `try_send` is intentional: waiting for queue capacity here would
|
|
||||||
// stall the Actor and defeat the Runtime's invocation deadline.
|
|
||||||
match self.inner.sender.try_send(build(response)) {
|
|
||||||
Ok(()) => {}
|
|
||||||
Err(TrySendError::Full(_)) => return Err(KvBackendError::Busy),
|
|
||||||
Err(TrySendError::Disconnected(_)) => {
|
|
||||||
return Err(KvBackendError::Operation(
|
|
||||||
"backend worker is unavailable".to_owned(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
receiver
|
|
||||||
.recv_timeout(timeout.min(MAX_RESPONSE_TIMEOUT))
|
|
||||||
.map_err(|_| KvBackendError::Timeout)?
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Clone for ToastyKvBackend {
|
|
||||||
fn clone(&self) -> Self {
|
|
||||||
Self {
|
|
||||||
inner: Arc::clone(&self.inner),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl fmt::Debug for ToastyKvBackend {
|
|
||||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
||||||
formatter
|
|
||||||
.debug_struct("ToastyKvBackend")
|
|
||||||
.finish_non_exhaustive()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl KvBackend for ToastyKvBackend {
|
|
||||||
fn get(
|
|
||||||
&self,
|
|
||||||
service_id: &str,
|
|
||||||
key: &str,
|
|
||||||
timeout: Duration,
|
|
||||||
) -> Result<Option<Vec<u8>>, KvBackendError> {
|
|
||||||
self.request(timeout, |response| KvCommand::Get {
|
|
||||||
service_id: service_id.to_owned(),
|
|
||||||
key: key.to_owned(),
|
|
||||||
response,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn set(
|
|
||||||
&self,
|
|
||||||
service_id: &str,
|
|
||||||
key: &str,
|
|
||||||
value: &[u8],
|
|
||||||
timeout: Duration,
|
|
||||||
) -> Result<(), KvBackendError> {
|
|
||||||
self.request(timeout, |response| KvCommand::Set {
|
|
||||||
service_id: service_id.to_owned(),
|
|
||||||
key: key.to_owned(),
|
|
||||||
value: value.to_vec(),
|
|
||||||
response,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn delete(&self, service_id: &str, key: &str, timeout: Duration) -> Result<(), KvBackendError> {
|
|
||||||
self.request(timeout, |response| KvCommand::Delete {
|
|
||||||
service_id: service_id.to_owned(),
|
|
||||||
key: key.to_owned(),
|
|
||||||
response,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Drop for BackendInner {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
// Only the final Arc owner reaches this Drop. Joining guarantees the
|
|
||||||
// worker no longer holds the Persistence handle when Console shuts
|
|
||||||
// down. If commands precede Shutdown, the FIFO channel drains them.
|
|
||||||
let _ = self.sender.send(KvCommand::Shutdown);
|
|
||||||
if let Ok(worker) = self.worker.get_mut()
|
|
||||||
&& let Some(worker) = worker.take()
|
|
||||||
{
|
|
||||||
let _ = worker.join();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn run_worker(
|
|
||||||
persistence: Persistence,
|
|
||||||
receiver: Receiver<KvCommand>,
|
|
||||||
ready: SyncSender<io::Result<()>>,
|
|
||||||
) {
|
|
||||||
// Toasty is driven from one thread so synchronous Host calls never need to
|
|
||||||
// enter or block the Console server's multi-thread Tokio runtime.
|
|
||||||
let runtime = match tokio::runtime::Builder::new_current_thread()
|
|
||||||
.enable_all()
|
|
||||||
.build()
|
|
||||||
{
|
|
||||||
Ok(runtime) => runtime,
|
|
||||||
Err(error) => {
|
|
||||||
let _ = ready.send(Err(error));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let _ = ready.send(Ok(()));
|
|
||||||
|
|
||||||
// A single consumer also defines the ordering of mutations submitted to
|
|
||||||
// this backend. Ordering across different Wasmeld processes is delegated
|
|
||||||
// to libSQL and is not guaranteed by this queue.
|
|
||||||
while let Ok(command) = receiver.recv() {
|
|
||||||
match command {
|
|
||||||
KvCommand::Get {
|
|
||||||
service_id,
|
|
||||||
key,
|
|
||||||
response,
|
|
||||||
} => {
|
|
||||||
let result = runtime
|
|
||||||
.block_on(persistence.kv_get(&service_id, &key))
|
|
||||||
.map_err(operation_failed);
|
|
||||||
let _ = response.send(result);
|
|
||||||
}
|
|
||||||
KvCommand::Set {
|
|
||||||
service_id,
|
|
||||||
key,
|
|
||||||
value,
|
|
||||||
response,
|
|
||||||
} => {
|
|
||||||
let result = runtime
|
|
||||||
.block_on(persistence.kv_set(service_id, key, value))
|
|
||||||
.map_err(operation_failed);
|
|
||||||
let _ = response.send(result);
|
|
||||||
}
|
|
||||||
KvCommand::Delete {
|
|
||||||
service_id,
|
|
||||||
key,
|
|
||||||
response,
|
|
||||||
} => {
|
|
||||||
let result = runtime
|
|
||||||
.block_on(persistence.kv_delete(&service_id, &key))
|
|
||||||
.map_err(operation_failed);
|
|
||||||
let _ = response.send(result);
|
|
||||||
}
|
|
||||||
KvCommand::Shutdown => return,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn operation_failed(error: toasty::Error) -> KvBackendError {
|
|
||||||
tracing::error!(%error, "persistent KV operation failed");
|
|
||||||
KvBackendError::Operation("persistent storage failure".to_owned())
|
|
||||||
}
|
|
||||||
+465
-534
File diff suppressed because it is too large
Load Diff
@@ -1,32 +1,11 @@
|
|||||||
//! Standalone Wasmeld control-plane and data-plane server entry point.
|
//! Standalone Wasmeld management server entry point.
|
||||||
//!
|
|
||||||
//! Configuration is supplied through environment variables:
|
|
||||||
//!
|
|
||||||
//! - `WASMELD_ADDR` binds the management API (default `127.0.0.1:8080`).
|
|
||||||
//! - `WASMELD_GATEWAY_ADDR` binds the public byte API (default `0.0.0.0:8081`).
|
|
||||||
//! - `WASMELD_ARTIFACT_DIR`, `WASMELD_WIT_REGISTRY_DIR`, and
|
|
||||||
//! `WASMELD_DATABASE_PATH` select durable local storage.
|
|
||||||
//! - `WASMELD_ALLOWED_ORIGINS` is a comma-separated management UI CORS list.
|
|
||||||
//! - `RUST_LOG` configures tracing.
|
|
||||||
//!
|
|
||||||
//! For example:
|
|
||||||
//!
|
|
||||||
//! ```text
|
|
||||||
//! WASMELD_ADDR=127.0.0.1:8080 \
|
|
||||||
//! WASMELD_GATEWAY_ADDR=0.0.0.0:8081 \
|
|
||||||
//! RUST_LOG=wasmeld_console=debug \
|
|
||||||
//! cargo run -p wasmeld-console
|
|
||||||
//! ```
|
|
||||||
//!
|
|
||||||
//! The defaults expose only the gateway on non-loopback interfaces.
|
|
||||||
//! Authentication and TLS must be supplied by the deployment environment.
|
|
||||||
|
|
||||||
use std::{env, net::SocketAddr, path::PathBuf, sync::Arc};
|
use std::{env, net::SocketAddr, path::PathBuf, sync::Arc};
|
||||||
|
|
||||||
use axum::http::HeaderValue;
|
use axum::http::HeaderValue;
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
use tracing_subscriber::EnvFilter;
|
use tracing_subscriber::EnvFilter;
|
||||||
use wasmeld_console::{Console, ConsoleConfig, app, gateway_app};
|
use wasmeld_console::{Console, ConsoleConfig, app};
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
@@ -37,12 +16,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
)
|
)
|
||||||
.init();
|
.init();
|
||||||
|
|
||||||
let management_address = env::var("WASMELD_ADDR")
|
let address = env::var("WASMELD_ADDR")
|
||||||
.unwrap_or_else(|_| "127.0.0.1:8080".to_owned())
|
.unwrap_or_else(|_| "127.0.0.1:8080".to_owned())
|
||||||
.parse::<SocketAddr>()?;
|
.parse::<SocketAddr>()?;
|
||||||
let gateway_address = env::var("WASMELD_GATEWAY_ADDR")
|
|
||||||
.unwrap_or_else(|_| "0.0.0.0:8081".to_owned())
|
|
||||||
.parse::<SocketAddr>()?;
|
|
||||||
let artifact_dir = env::var_os("WASMELD_ARTIFACT_DIR")
|
let artifact_dir = env::var_os("WASMELD_ARTIFACT_DIR")
|
||||||
.map(PathBuf::from)
|
.map(PathBuf::from)
|
||||||
.unwrap_or_else(|| PathBuf::from("var/wasmeld/components"));
|
.unwrap_or_else(|| PathBuf::from("var/wasmeld/components"));
|
||||||
@@ -62,32 +38,13 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
})
|
})
|
||||||
.await?,
|
.await?,
|
||||||
);
|
);
|
||||||
let management = app(Arc::clone(&console), allowed_origins);
|
let application = app(console, allowed_origins);
|
||||||
let gateway = gateway_app(Arc::clone(&console));
|
let listener = tokio::net::TcpListener::bind(address).await?;
|
||||||
let management_listener = tokio::net::TcpListener::bind(management_address).await?;
|
|
||||||
let gateway_listener = tokio::net::TcpListener::bind(gateway_address).await?;
|
|
||||||
// Both servers share one shutdown edge so neither listener remains alive
|
|
||||||
// after Ctrl+C has begun process termination.
|
|
||||||
let (shutdown_sender, shutdown_receiver) = tokio::sync::watch::channel(false);
|
|
||||||
let signal_task = tokio::spawn(async move {
|
|
||||||
shutdown_signal().await;
|
|
||||||
let _ = shutdown_sender.send(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
info!(%management_address, "Wasmeld management API listening");
|
info!(%address, "Wasmeld listening");
|
||||||
info!(%gateway_address, "Wasmeld Gateway listening");
|
axum::serve(listener, application)
|
||||||
let result = tokio::try_join!(
|
.with_graceful_shutdown(shutdown_signal())
|
||||||
axum::serve(management_listener, management)
|
.await?;
|
||||||
.with_graceful_shutdown(wait_for_shutdown(shutdown_receiver.clone())),
|
|
||||||
axum::serve(gateway_listener, gateway)
|
|
||||||
.with_graceful_shutdown(wait_for_shutdown(shutdown_receiver)),
|
|
||||||
);
|
|
||||||
signal_task.abort();
|
|
||||||
result?;
|
|
||||||
// Axum has stopped accepting requests at this point. Flush accepted
|
|
||||||
// telemetry before dropping Console; persistence failures are traced by
|
|
||||||
// the worker because telemetry is an operational, best-effort stream.
|
|
||||||
console.flush_invocation_telemetry().await;
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,11 +63,3 @@ async fn shutdown_signal() {
|
|||||||
tracing::warn!("failed to install Ctrl+C handler");
|
tracing::warn!("failed to install Ctrl+C handler");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn wait_for_shutdown(mut receiver: tokio::sync::watch::Receiver<bool>) {
|
|
||||||
while !*receiver.borrow() {
|
|
||||||
if receiver.changed().await.is_err() {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,24 +1,9 @@
|
|||||||
//! Toasty models and transactional libSQL snapshots for Console control data.
|
//! Toasty models and transactional libSQL snapshots for Console control data.
|
||||||
//!
|
//!
|
||||||
//! Component bytes and WIT packages remain filesystem artifacts. This module
|
//! Component bytes and WIT packages remain filesystem artifacts. This module
|
||||||
//! persists service manifests, deployments, metrics, the bounded event
|
//! persists only service manifests, metrics, and the bounded event history.
|
||||||
//! history, and service-scoped Host KV entries.
|
|
||||||
//!
|
|
||||||
//! There are two write paths:
|
|
||||||
//!
|
|
||||||
//! - [`Persistence::sync`] stores a complete control-plane snapshot.
|
|
||||||
//! - [`Persistence::record_invocations`] applies ordered metric deltas.
|
|
||||||
//!
|
|
||||||
//! Callers serialize these paths with the Console persistence mutex. Existing
|
|
||||||
//! service counters are intentionally owned by the delta path; a snapshot
|
|
||||||
//! updates their manifest and timestamp without replacing those counters.
|
|
||||||
|
|
||||||
use std::{
|
use std::path::Path;
|
||||||
collections::BTreeSet,
|
|
||||||
path::Path,
|
|
||||||
sync::Arc,
|
|
||||||
time::{SystemTime, UNIX_EPOCH},
|
|
||||||
};
|
|
||||||
|
|
||||||
use toasty::Db;
|
use toasty::Db;
|
||||||
use toasty_driver_turso::Turso;
|
use toasty_driver_turso::Turso;
|
||||||
@@ -46,162 +31,53 @@ pub(crate) struct StoredEvent {
|
|||||||
pub message: String,
|
pub message: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, toasty::Model)]
|
|
||||||
pub(crate) struct StoredDeployment {
|
|
||||||
#[key]
|
|
||||||
pub service_id: String,
|
|
||||||
pub active_revision: String,
|
|
||||||
pub updated_at_ms: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, toasty::Model)]
|
|
||||||
#[key(service_id, entry_key)]
|
|
||||||
pub(crate) struct StoredKvEntry {
|
|
||||||
pub service_id: String,
|
|
||||||
pub entry_key: String,
|
|
||||||
pub value: Vec<u8>,
|
|
||||||
pub updated_at_ms: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One invocation's durable metric delta and event.
|
|
||||||
///
|
|
||||||
/// The caller assigns the event ID before enqueueing the update. A missing
|
|
||||||
/// service row is tolerated because unregister may race queued telemetry; the
|
|
||||||
/// event is still retained for diagnostics.
|
|
||||||
pub(crate) struct InvocationUpdate {
|
|
||||||
pub service_key: String,
|
|
||||||
pub failed: bool,
|
|
||||||
pub updated_at_ms: u64,
|
|
||||||
pub latency_ms: u64,
|
|
||||||
pub event: StoredEvent,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Serialized access to the Toasty database connection.
|
/// Serialized access to the Toasty database connection.
|
||||||
///
|
|
||||||
/// This mutex protects a single Toasty [`Db`] handle. It is separate from the
|
|
||||||
/// higher-level Console persistence mutex, which coordinates whole snapshot
|
|
||||||
/// and invocation-delta operations before they acquire this connection.
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub(crate) struct Persistence {
|
pub(crate) struct Persistence {
|
||||||
db: Arc<Mutex<Db>>,
|
db: Mutex<Db>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Persistence {
|
impl Persistence {
|
||||||
/// Opens the database and installs or advances its schema.
|
/// Opens the database and installs the schema for a new file.
|
||||||
pub async fn open(path: &Path) -> toasty::Result<Self> {
|
pub async fn open(path: &Path) -> toasty::Result<Self> {
|
||||||
let is_new_database = !path.exists();
|
let is_new_database = !path.exists();
|
||||||
let db = Db::builder()
|
let db = Db::builder()
|
||||||
.models(toasty::models!(
|
.models(toasty::models!(StoredService, StoredEvent))
|
||||||
StoredService,
|
|
||||||
StoredEvent,
|
|
||||||
StoredDeployment,
|
|
||||||
StoredKvEntry
|
|
||||||
))
|
|
||||||
.build(Turso::file(path))
|
.build(Turso::file(path))
|
||||||
.await?;
|
.await?;
|
||||||
if is_new_database {
|
if is_new_database {
|
||||||
db.push_schema().await?;
|
db.push_schema().await?;
|
||||||
} else {
|
|
||||||
// This project predates Toasty's migration history. Keep the
|
|
||||||
// additive bootstrap explicit so existing databases can be opened
|
|
||||||
// without recreating the service and event tables.
|
|
||||||
let mut db = db;
|
|
||||||
toasty::sql::statement(
|
|
||||||
r#"CREATE TABLE IF NOT EXISTS "stored_deployments" (
|
|
||||||
"service_id" TEXT NOT NULL,
|
|
||||||
"active_revision" TEXT NOT NULL,
|
|
||||||
"updated_at_ms" INTEGER NOT NULL,
|
|
||||||
PRIMARY KEY ("service_id")
|
|
||||||
)"#,
|
|
||||||
)
|
|
||||||
.exec(&mut db)
|
|
||||||
.await?;
|
|
||||||
toasty::sql::statement(
|
|
||||||
r#"CREATE TABLE IF NOT EXISTS "stored_kv_entries" (
|
|
||||||
"service_id" TEXT NOT NULL,
|
|
||||||
"entry_key" TEXT NOT NULL,
|
|
||||||
"value" BLOB NOT NULL,
|
|
||||||
"updated_at_ms" INTEGER NOT NULL,
|
|
||||||
PRIMARY KEY ("service_id", "entry_key")
|
|
||||||
)"#,
|
|
||||||
)
|
|
||||||
.exec(&mut db)
|
|
||||||
.await?;
|
|
||||||
return Ok(Self {
|
|
||||||
db: Arc::new(Mutex::new(db)),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
Ok(Self {
|
Ok(Self { db: Mutex::new(db) })
|
||||||
db: Arc::new(Mutex::new(db)),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Loads the complete service and deployment sets plus retained events.
|
/// Loads the complete service set and retained event history.
|
||||||
pub async fn load(
|
pub async fn load(&self) -> toasty::Result<(Vec<StoredService>, Vec<StoredEvent>)> {
|
||||||
&self,
|
|
||||||
) -> toasty::Result<(Vec<StoredService>, Vec<StoredEvent>, Vec<StoredDeployment>)> {
|
|
||||||
let mut db = self.db.lock().await;
|
let mut db = self.db.lock().await;
|
||||||
let services = StoredService::all().exec(&mut *db).await?;
|
let services = StoredService::all().exec(&mut *db).await?;
|
||||||
let events = StoredEvent::all().exec(&mut *db).await?;
|
let events = StoredEvent::all().exec(&mut *db).await?;
|
||||||
let deployments = StoredDeployment::all().exec(&mut *db).await?;
|
Ok((services, events))
|
||||||
Ok((services, events, deployments))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Atomically upserts a Console snapshot and prunes expired events.
|
/// Atomically upserts a Console snapshot and prunes expired events.
|
||||||
///
|
|
||||||
/// The input is authoritative for services and deployments: rows omitted
|
|
||||||
/// from those collections are removed. Events older than the oldest
|
|
||||||
/// supplied retained event are pruned. Existing invocation counters are
|
|
||||||
/// preserved because [`Self::record_invocations`] owns counter changes.
|
|
||||||
pub async fn sync(
|
pub async fn sync(
|
||||||
&self,
|
&self,
|
||||||
services: Vec<StoredService>,
|
services: Vec<StoredService>,
|
||||||
events: Vec<StoredEvent>,
|
events: Vec<StoredEvent>,
|
||||||
deployments: Vec<StoredDeployment>,
|
|
||||||
) -> toasty::Result<()> {
|
) -> toasty::Result<()> {
|
||||||
let mut db = self.db.lock().await;
|
let mut db = self.db.lock().await;
|
||||||
let mut tx = db.transaction().await?;
|
let mut tx = db.transaction().await?;
|
||||||
|
|
||||||
let stored_services = StoredService::all().exec(&mut tx).await?;
|
|
||||||
let service_keys = services
|
|
||||||
.iter()
|
|
||||||
.map(|service| service.service_key.clone())
|
|
||||||
.collect::<BTreeSet<_>>();
|
|
||||||
let existing_service_keys = stored_services
|
|
||||||
.iter()
|
|
||||||
.map(|service| service.service_key.clone())
|
|
||||||
.collect::<BTreeSet<_>>();
|
|
||||||
for stored in stored_services {
|
|
||||||
if !service_keys.contains(&stored.service_key) {
|
|
||||||
StoredService::delete_by_service_key(&mut tx, &stored.service_key).await?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for service in services {
|
for service in services {
|
||||||
if existing_service_keys.contains(&service.service_key) {
|
StoredService::upsert_by_service_key(service.service_key)
|
||||||
// Invocation metrics are written as ordered deltas by
|
.manifest_toml(service.manifest_toml)
|
||||||
// `record_invocations`; control snapshots must not overwrite
|
.updated_at_ms(service.updated_at_ms)
|
||||||
// or double-count those concurrent updates.
|
.calls(service.calls)
|
||||||
StoredService::filter_by_service_key(&service.service_key)
|
.errors(service.errors)
|
||||||
.update()
|
.last_latency_ms(service.last_latency_ms)
|
||||||
.manifest_toml(service.manifest_toml)
|
.exec(&mut tx)
|
||||||
.updated_at_ms(service.updated_at_ms)
|
.await?;
|
||||||
.exec(&mut tx)
|
|
||||||
.await?;
|
|
||||||
} else {
|
|
||||||
StoredService::create()
|
|
||||||
.service_key(service.service_key)
|
|
||||||
.manifest_toml(service.manifest_toml)
|
|
||||||
.updated_at_ms(service.updated_at_ms)
|
|
||||||
.calls(0)
|
|
||||||
.errors(0)
|
|
||||||
.last_latency_ms(None)
|
|
||||||
.exec(&mut tx)
|
|
||||||
.await?;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Console stores events newest-first, so the last element is the
|
|
||||||
// oldest ID that should survive snapshot pruning.
|
|
||||||
if let Some(oldest_event) = events.last() {
|
if let Some(oldest_event) = events.last() {
|
||||||
StoredEvent::filter(StoredEvent::fields().id().lt(oldest_event.id))
|
StoredEvent::filter(StoredEvent::fields().id().lt(oldest_event.id))
|
||||||
.delete()
|
.delete()
|
||||||
@@ -220,119 +96,6 @@ impl Persistence {
|
|||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
let deployment_ids = deployments
|
|
||||||
.iter()
|
|
||||||
.map(|deployment| deployment.service_id.clone())
|
|
||||||
.collect::<BTreeSet<_>>();
|
|
||||||
for stored in StoredDeployment::all().exec(&mut tx).await? {
|
|
||||||
if !deployment_ids.contains(&stored.service_id) {
|
|
||||||
StoredDeployment::delete_by_service_id(&mut tx, &stored.service_id).await?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for deployment in deployments {
|
|
||||||
StoredDeployment::upsert_by_service_id(deployment.service_id)
|
|
||||||
.active_revision(deployment.active_revision)
|
|
||||||
.updated_at_ms(deployment.updated_at_ms)
|
|
||||||
.exec(&mut tx)
|
|
||||||
.await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
tx.commit().await
|
tx.commit().await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Atomically records invocation deltas and their bounded event history.
|
|
||||||
///
|
|
||||||
/// All supplied updates commit together. Metrics use saturating arithmetic
|
|
||||||
/// so corrupted or extremely old databases cannot wrap counters.
|
|
||||||
pub(crate) async fn record_invocations(
|
|
||||||
&self,
|
|
||||||
updates: Vec<InvocationUpdate>,
|
|
||||||
) -> toasty::Result<()> {
|
|
||||||
let mut db = self.db.lock().await;
|
|
||||||
let mut tx = db.transaction().await?;
|
|
||||||
let mut newest_event_id = 0;
|
|
||||||
|
|
||||||
for update in updates {
|
|
||||||
if let Some(stored) = StoredService::filter_by_service_key(&update.service_key)
|
|
||||||
.first()
|
|
||||||
.exec(&mut tx)
|
|
||||||
.await?
|
|
||||||
{
|
|
||||||
StoredService::filter_by_service_key(&update.service_key)
|
|
||||||
.update()
|
|
||||||
.calls(stored.calls.saturating_add(1))
|
|
||||||
.errors(stored.errors.saturating_add(u64::from(update.failed)))
|
|
||||||
.updated_at_ms(update.updated_at_ms)
|
|
||||||
.last_latency_ms(Some(update.latency_ms))
|
|
||||||
.exec(&mut tx)
|
|
||||||
.await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
newest_event_id = newest_event_id.max(update.event.id);
|
|
||||||
StoredEvent::upsert_by_id(update.event.id)
|
|
||||||
.timestamp_ms(update.event.timestamp_ms)
|
|
||||||
.kind(update.event.kind)
|
|
||||||
.service_id(update.event.service_id)
|
|
||||||
.revision(update.event.revision)
|
|
||||||
.message(update.event.message)
|
|
||||||
.exec(&mut tx)
|
|
||||||
.await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
let oldest_retained_event_id = newest_event_id.saturating_sub(255);
|
|
||||||
if oldest_retained_event_id > 0 {
|
|
||||||
StoredEvent::filter(StoredEvent::fields().id().lt(oldest_retained_event_id))
|
|
||||||
.delete()
|
|
||||||
.exec(&mut tx)
|
|
||||||
.await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
tx.commit().await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) async fn kv_get(
|
|
||||||
&self,
|
|
||||||
service_id: &str,
|
|
||||||
entry_key: &str,
|
|
||||||
) -> toasty::Result<Option<Vec<u8>>> {
|
|
||||||
// The namespace is the stable service ID rather than its revision.
|
|
||||||
// Deploying a new revision therefore preserves application state.
|
|
||||||
let mut db = self.db.lock().await;
|
|
||||||
Ok(
|
|
||||||
StoredKvEntry::filter_by_service_id_and_entry_key(service_id, entry_key)
|
|
||||||
.first()
|
|
||||||
.exec(&mut *db)
|
|
||||||
.await?
|
|
||||||
.map(|entry| entry.value),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) async fn kv_set(
|
|
||||||
&self,
|
|
||||||
service_id: String,
|
|
||||||
entry_key: String,
|
|
||||||
value: Vec<u8>,
|
|
||||||
) -> toasty::Result<()> {
|
|
||||||
// Upsert makes a retry after an ambiguous Host timeout idempotent for
|
|
||||||
// the same `(service_id, entry_key, value)`.
|
|
||||||
let mut db = self.db.lock().await;
|
|
||||||
StoredKvEntry::upsert_by_service_id_and_entry_key(service_id, entry_key)
|
|
||||||
.value(value)
|
|
||||||
.updated_at_ms(unix_time_ms())
|
|
||||||
.exec(&mut *db)
|
|
||||||
.await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) async fn kv_delete(&self, service_id: &str, entry_key: &str) -> toasty::Result<()> {
|
|
||||||
let mut db = self.db.lock().await;
|
|
||||||
StoredKvEntry::delete_by_service_id_and_entry_key(&mut *db, service_id, entry_key).await
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn unix_time_ms() -> u64 {
|
|
||||||
SystemTime::now()
|
|
||||||
.duration_since(UNIX_EPOCH)
|
|
||||||
.map(|duration| u64::try_from(duration.as_millis()).unwrap_or(u64::MAX))
|
|
||||||
.unwrap_or_default()
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,108 +0,0 @@
|
|||||||
//! Embedded Solid management UI served by the control-plane listener.
|
|
||||||
//!
|
|
||||||
//! The router is empty in normal Debug builds because Vite owns Web assets
|
|
||||||
//! during development. Release builds embed Cargo `OUT_DIR/web`; requests only
|
|
||||||
//! resolve exact files, so an unknown URL cannot accidentally receive the Host
|
|
||||||
//! document as an SPA fallback.
|
|
||||||
|
|
||||||
use axum::Router;
|
|
||||||
|
|
||||||
#[cfg(wasmeld_embedded_web)]
|
|
||||||
use axum::{
|
|
||||||
body::{Body, Bytes},
|
|
||||||
extract::Path,
|
|
||||||
http::{StatusCode, header},
|
|
||||||
response::{IntoResponse, Response},
|
|
||||||
routing::get,
|
|
||||||
};
|
|
||||||
#[cfg(wasmeld_embedded_web)]
|
|
||||||
use include_dir::{Dir, include_dir};
|
|
||||||
|
|
||||||
#[cfg(wasmeld_embedded_web)]
|
|
||||||
static WEB_ASSETS: Dir<'static> = include_dir!("$OUT_DIR/web");
|
|
||||||
|
|
||||||
pub(crate) fn router() -> Router {
|
|
||||||
#[cfg(wasmeld_embedded_web)]
|
|
||||||
{
|
|
||||||
Router::new()
|
|
||||||
.route("/", get(index))
|
|
||||||
.route("/{*path}", get(asset))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(not(wasmeld_embedded_web))]
|
|
||||||
{
|
|
||||||
Router::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(wasmeld_embedded_web)]
|
|
||||||
async fn index() -> Response {
|
|
||||||
response("index.html")
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(wasmeld_embedded_web)]
|
|
||||||
async fn asset(Path(path): Path<String>) -> Response {
|
|
||||||
response(&path)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(wasmeld_embedded_web)]
|
|
||||||
fn response(path: &str) -> Response {
|
|
||||||
// The wildcard route is public input. Reject non-canonical paths before
|
|
||||||
// looking inside the compile-time directory, even though `include_dir`
|
|
||||||
// itself cannot access the host filesystem at runtime.
|
|
||||||
if !valid_asset_path(path) {
|
|
||||||
return StatusCode::NOT_FOUND.into_response();
|
|
||||||
}
|
|
||||||
let Some(file) = WEB_ASSETS.get_file(path) else {
|
|
||||||
return StatusCode::NOT_FOUND.into_response();
|
|
||||||
};
|
|
||||||
let content_type = mime_guess::from_path(path)
|
|
||||||
.first_or_octet_stream()
|
|
||||||
.essence_str()
|
|
||||||
.to_owned();
|
|
||||||
let cache_control = if path.starts_with("assets/") {
|
|
||||||
"public, max-age=31536000, immutable"
|
|
||||||
} else {
|
|
||||||
"no-cache"
|
|
||||||
};
|
|
||||||
let mut builder = Response::builder()
|
|
||||||
.status(StatusCode::OK)
|
|
||||||
.header(header::CONTENT_TYPE, content_type)
|
|
||||||
.header(header::CACHE_CONTROL, cache_control)
|
|
||||||
.header("x-content-type-options", "nosniff");
|
|
||||||
if path == "sw.js" {
|
|
||||||
builder = builder.header("service-worker-allowed", "/");
|
|
||||||
}
|
|
||||||
builder
|
|
||||||
.body(Body::from(Bytes::from_static(file.contents())))
|
|
||||||
.unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(wasmeld_embedded_web)]
|
|
||||||
fn valid_asset_path(path: &str) -> bool {
|
|
||||||
!path.is_empty()
|
|
||||||
&& !path.starts_with('/')
|
|
||||||
&& !path.contains('\\')
|
|
||||||
&& path
|
|
||||||
.split('/')
|
|
||||||
.all(|segment| !matches!(segment, "" | "." | ".."))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(all(test, wasmeld_embedded_web))]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn embeds_both_document_boundaries_and_pwa_assets() {
|
|
||||||
for path in ["index.html", "page.html", "manifest.webmanifest", "sw.js"] {
|
|
||||||
assert!(WEB_ASSETS.get_file(path).is_some(), "missing {path}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rejects_non_canonical_asset_paths() {
|
|
||||||
assert!(!valid_asset_path("../index.html"));
|
|
||||||
assert!(!valid_asset_path("assets//app.js"));
|
|
||||||
assert!(!valid_asset_path(r"assets\app.js"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -37,40 +37,28 @@ struct StoredWitPackage {
|
|||||||
/// Errors produced by WIT Registry storage and integrity checks.
|
/// Errors produced by WIT Registry storage and integrity checks.
|
||||||
#[derive(Debug, Error)]
|
#[derive(Debug, Error)]
|
||||||
pub enum WitRegistryError {
|
pub enum WitRegistryError {
|
||||||
/// Uploaded binary WIT exceeds the configured byte limit.
|
|
||||||
#[error("WIT package exceeds the {limit}-byte limit")]
|
#[error("WIT package exceeds the {limit}-byte limit")]
|
||||||
TooLarge {
|
TooLarge { limit: usize },
|
||||||
/// Maximum accepted artifact bytes.
|
|
||||||
limit: usize,
|
|
||||||
},
|
|
||||||
|
|
||||||
/// The immutable `name@version` already exists.
|
|
||||||
#[error("WIT package {0} is already published and cannot be overwritten")]
|
#[error("WIT package {0} is already published and cannot be overwritten")]
|
||||||
AlreadyExists(String),
|
AlreadyExists(String),
|
||||||
|
|
||||||
/// No indexed artifact matches the requested identity.
|
|
||||||
#[error("WIT package {0} was not found")]
|
#[error("WIT package {0} was not found")]
|
||||||
NotFound(String),
|
NotFound(String),
|
||||||
|
|
||||||
/// Uploaded or persisted bytes are not a valid versioned binary WIT package.
|
|
||||||
#[error(transparent)]
|
#[error(transparent)]
|
||||||
InvalidPackage(#[from] WitPackageError),
|
InvalidPackage(#[from] WitPackageError),
|
||||||
|
|
||||||
/// Directory layout and decoded artifact identity disagree.
|
|
||||||
#[error("invalid WIT registry data: {0}")]
|
#[error("invalid WIT registry data: {0}")]
|
||||||
InvalidStorage(String),
|
InvalidStorage(String),
|
||||||
|
|
||||||
/// Registry directory or artifact filesystem access failed.
|
|
||||||
#[error("failed to access {path}: {source}")]
|
#[error("failed to access {path}: {source}")]
|
||||||
Storage {
|
Storage {
|
||||||
/// Path involved in the failed operation.
|
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
/// Underlying filesystem failure.
|
|
||||||
#[source]
|
#[source]
|
||||||
source: io::Error,
|
source: io::Error,
|
||||||
},
|
},
|
||||||
|
|
||||||
/// The process-local Registry index lock was poisoned.
|
|
||||||
#[error("WIT registry lock was poisoned")]
|
#[error("WIT registry lock was poisoned")]
|
||||||
LockPoisoned,
|
LockPoisoned,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
|
|||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
use tower::ServiceExt;
|
use tower::ServiceExt;
|
||||||
use wasmeld_console::{Console, ConsoleConfig, app, gateway_app};
|
use wasmeld_console::{Console, ConsoleConfig, app};
|
||||||
use wasmeld_package::{
|
use wasmeld_package::{
|
||||||
module::{ModuleLock, sync_dependencies},
|
module::{ModuleLock, sync_dependencies},
|
||||||
wit_package::build_wit_package,
|
wit_package::build_wit_package,
|
||||||
@@ -40,7 +40,6 @@ async fn manages_a_resident_component_over_http() {
|
|||||||
assert_eq!(status, StatusCode::CREATED, "{registered}");
|
assert_eq!(status, StatusCode::CREATED, "{registered}");
|
||||||
assert_eq!(registered["id"], "echo");
|
assert_eq!(registered["id"], "echo");
|
||||||
assert_eq!(registered["status"], "stopped");
|
assert_eq!(registered["status"], "stopped");
|
||||||
assert_eq!(registered["capabilities"], json!([]));
|
|
||||||
|
|
||||||
let response = application
|
let response = application
|
||||||
.clone()
|
.clone()
|
||||||
@@ -90,302 +89,6 @@ async fn manages_a_resident_component_over_http() {
|
|||||||
assert!(events["events"].as_array().unwrap().len() >= 5);
|
assert!(events["events"].as_array().unwrap().len() >= 5);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn reports_exact_component_host_capabilities() {
|
|
||||||
let artifact_dir = TempDir::new().expect("temporary artifact directory");
|
|
||||||
let application = test_app(artifact_dir.path()).await;
|
|
||||||
let component = fs::read(component_artifact("clock_probe_component.wasm"))
|
|
||||||
.expect("clock probe component should be readable");
|
|
||||||
|
|
||||||
let response = application
|
|
||||||
.clone()
|
|
||||||
.oneshot(package_request("clock-probe", "0.1.0", &component))
|
|
||||||
.await
|
|
||||||
.expect("register request should complete");
|
|
||||||
assert_eq!(response.status(), StatusCode::CREATED);
|
|
||||||
let registered = response_json(response).await;
|
|
||||||
assert_eq!(
|
|
||||||
registered["capabilities"],
|
|
||||||
json!([{
|
|
||||||
"interface": "wasmeld:clock/monotonic-clock@0.1.0",
|
|
||||||
"package": "wasmeld:clock",
|
|
||||||
"name": "monotonic-clock",
|
|
||||||
"version": "0.1.0"
|
|
||||||
}])
|
|
||||||
);
|
|
||||||
|
|
||||||
let response = application
|
|
||||||
.oneshot(get_request("/api/v1/services"))
|
|
||||||
.await
|
|
||||||
.expect("service list request should complete");
|
|
||||||
let services = response_json(response).await;
|
|
||||||
assert_eq!(
|
|
||||||
services["services"][0]["capabilities"],
|
|
||||||
registered["capabilities"]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn routes_raw_gateway_calls_through_the_active_deployment() {
|
|
||||||
let artifact_dir = TempDir::new().expect("temporary artifact directory");
|
|
||||||
let (management, gateway) = test_apps(artifact_dir.path()).await;
|
|
||||||
let component = fs::read(echo_component()).expect("echo component should be readable");
|
|
||||||
|
|
||||||
let response = gateway
|
|
||||||
.clone()
|
|
||||||
.oneshot(raw_request(
|
|
||||||
"/v1/services/echo/invoke",
|
|
||||||
"application/octet-stream",
|
|
||||||
b"not deployed",
|
|
||||||
))
|
|
||||||
.await
|
|
||||||
.expect("gateway request should complete");
|
|
||||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
|
||||||
assert_eq!(
|
|
||||||
response_json(response).await["error"]["code"],
|
|
||||||
"service_not_found"
|
|
||||||
);
|
|
||||||
|
|
||||||
let response = gateway
|
|
||||||
.clone()
|
|
||||||
.oneshot(get_request("/api/v1/services"))
|
|
||||||
.await
|
|
||||||
.expect("gateway route isolation request should complete");
|
|
||||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
|
||||||
|
|
||||||
for revision in ["0.1.0", "0.2.0"] {
|
|
||||||
let response = management
|
|
||||||
.clone()
|
|
||||||
.oneshot(package_request("echo", revision, &component))
|
|
||||||
.await
|
|
||||||
.expect("register request should complete");
|
|
||||||
assert_eq!(response.status(), StatusCode::CREATED);
|
|
||||||
}
|
|
||||||
|
|
||||||
let response = management
|
|
||||||
.clone()
|
|
||||||
.oneshot(json_request(
|
|
||||||
"/api/v1/deployments/echo/activate",
|
|
||||||
json!({ "revision": "0.1.0" }),
|
|
||||||
))
|
|
||||||
.await
|
|
||||||
.expect("activation request should complete");
|
|
||||||
assert_eq!(response.status(), StatusCode::OK);
|
|
||||||
let deployment = response_json(response).await;
|
|
||||||
assert_eq!(deployment["active_revision"], "0.1.0");
|
|
||||||
assert_eq!(deployment["status"], "running");
|
|
||||||
|
|
||||||
let response = gateway
|
|
||||||
.clone()
|
|
||||||
.oneshot(raw_request(
|
|
||||||
"/v1/services/echo/invoke",
|
|
||||||
"application/octet-stream",
|
|
||||||
b"public bytes",
|
|
||||||
))
|
|
||||||
.await
|
|
||||||
.expect("gateway invocation should complete");
|
|
||||||
assert_eq!(response.status(), StatusCode::OK);
|
|
||||||
assert_eq!(
|
|
||||||
response.headers()["content-type"],
|
|
||||||
"application/octet-stream"
|
|
||||||
);
|
|
||||||
assert_eq!(response.headers()["x-wasmeld-revision"], "0.1.0");
|
|
||||||
assert_eq!(
|
|
||||||
to_bytes(response.into_body(), usize::MAX).await.unwrap(),
|
|
||||||
b"public bytes".as_slice()
|
|
||||||
);
|
|
||||||
|
|
||||||
let response = management
|
|
||||||
.clone()
|
|
||||||
.oneshot(json_request(
|
|
||||||
"/api/v1/deployments/echo/activate",
|
|
||||||
json!({ "revision": "0.2.0" }),
|
|
||||||
))
|
|
||||||
.await
|
|
||||||
.expect("switch request should complete");
|
|
||||||
assert_eq!(response.status(), StatusCode::OK);
|
|
||||||
|
|
||||||
let response = gateway
|
|
||||||
.clone()
|
|
||||||
.oneshot(raw_request(
|
|
||||||
"/v1/services/echo/invoke",
|
|
||||||
"application/octet-stream",
|
|
||||||
b"switched",
|
|
||||||
))
|
|
||||||
.await
|
|
||||||
.expect("gateway invocation should complete");
|
|
||||||
assert_eq!(response.status(), StatusCode::OK);
|
|
||||||
assert_eq!(response.headers()["x-wasmeld-revision"], "0.2.0");
|
|
||||||
|
|
||||||
let response = gateway
|
|
||||||
.clone()
|
|
||||||
.oneshot(raw_request(
|
|
||||||
"/v1/services/echo/invoke",
|
|
||||||
"application/json",
|
|
||||||
b"{}",
|
|
||||||
))
|
|
||||||
.await
|
|
||||||
.expect("unsupported media request should complete");
|
|
||||||
assert_eq!(response.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE);
|
|
||||||
assert_eq!(
|
|
||||||
response_json(response).await["error"]["code"],
|
|
||||||
"unsupported_media_type"
|
|
||||||
);
|
|
||||||
|
|
||||||
let response = management
|
|
||||||
.clone()
|
|
||||||
.oneshot(empty_post("/api/v1/services/echo/0.2.0/stop"))
|
|
||||||
.await
|
|
||||||
.expect("active actor stop request should complete");
|
|
||||||
assert_eq!(response.status(), StatusCode::OK);
|
|
||||||
let response = gateway
|
|
||||||
.oneshot(raw_request(
|
|
||||||
"/v1/services/echo/invoke",
|
|
||||||
"application/octet-stream",
|
|
||||||
b"stopped",
|
|
||||||
))
|
|
||||||
.await
|
|
||||||
.expect("stopped deployment request should complete");
|
|
||||||
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
|
|
||||||
assert_eq!(
|
|
||||||
response_json(response).await["error"]["code"],
|
|
||||||
"service_unavailable"
|
|
||||||
);
|
|
||||||
|
|
||||||
let response = management
|
|
||||||
.oneshot(get_request("/api/v1/deployments"))
|
|
||||||
.await
|
|
||||||
.expect("deployment list request should complete");
|
|
||||||
let deployments = response_json(response).await;
|
|
||||||
assert_eq!(deployments["deployments"].as_array().unwrap().len(), 1);
|
|
||||||
assert_eq!(deployments["deployments"][0]["active_revision"], "0.2.0");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn unregisters_only_inactive_service_revisions_and_prunes_persistence() {
|
|
||||||
let artifact_dir = TempDir::new().expect("temporary artifact directory");
|
|
||||||
let component = fs::read(echo_component()).expect("echo component should be readable");
|
|
||||||
|
|
||||||
{
|
|
||||||
let application = test_app(artifact_dir.path()).await;
|
|
||||||
for revision in ["0.1.0", "0.2.0"] {
|
|
||||||
let response = application
|
|
||||||
.clone()
|
|
||||||
.oneshot(package_request("dev-echo", revision, &component))
|
|
||||||
.await
|
|
||||||
.expect("register request should complete");
|
|
||||||
assert_eq!(response.status(), StatusCode::CREATED);
|
|
||||||
}
|
|
||||||
|
|
||||||
let response = application
|
|
||||||
.clone()
|
|
||||||
.oneshot(json_request(
|
|
||||||
"/api/v1/deployments/dev-echo/activate",
|
|
||||||
json!({ "revision": "0.1.0" }),
|
|
||||||
))
|
|
||||||
.await
|
|
||||||
.expect("activation request should complete");
|
|
||||||
assert_eq!(response.status(), StatusCode::OK);
|
|
||||||
|
|
||||||
let response = application
|
|
||||||
.clone()
|
|
||||||
.oneshot(delete_request("/api/v1/services/dev-echo/0.1.0"))
|
|
||||||
.await
|
|
||||||
.expect("active unregister request should complete");
|
|
||||||
assert_eq!(response.status(), StatusCode::CONFLICT);
|
|
||||||
assert_eq!(
|
|
||||||
response_json(response).await["error"]["code"],
|
|
||||||
"active_deployment"
|
|
||||||
);
|
|
||||||
|
|
||||||
let response = application
|
|
||||||
.clone()
|
|
||||||
.oneshot(json_request(
|
|
||||||
"/api/v1/deployments/dev-echo/activate",
|
|
||||||
json!({ "revision": "0.2.0" }),
|
|
||||||
))
|
|
||||||
.await
|
|
||||||
.expect("replacement activation should complete");
|
|
||||||
assert_eq!(response.status(), StatusCode::OK);
|
|
||||||
|
|
||||||
let response = application
|
|
||||||
.clone()
|
|
||||||
.oneshot(delete_request("/api/v1/services/dev-echo/0.1.0"))
|
|
||||||
.await
|
|
||||||
.expect("inactive unregister request should complete");
|
|
||||||
assert_eq!(response.status(), StatusCode::OK);
|
|
||||||
assert!(!artifact_dir.path().join("dev-echo@0.1.0.wasm").exists());
|
|
||||||
assert!(!artifact_dir.path().join("dev-echo@0.1.0.toml").exists());
|
|
||||||
}
|
|
||||||
|
|
||||||
let application = test_app(artifact_dir.path()).await;
|
|
||||||
let response = application
|
|
||||||
.oneshot(get_request("/api/v1/services"))
|
|
||||||
.await
|
|
||||||
.expect("restored service list request should complete");
|
|
||||||
let body = response_json(response).await;
|
|
||||||
let services = body["services"].as_array().unwrap();
|
|
||||||
assert_eq!(services.len(), 1);
|
|
||||||
assert_eq!(services[0]["revision"], "0.2.0");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn restores_the_active_deployment_with_a_fresh_actor() {
|
|
||||||
let artifact_dir = TempDir::new().expect("temporary artifact directory");
|
|
||||||
let component = fs::read(echo_component()).expect("echo component should be readable");
|
|
||||||
|
|
||||||
{
|
|
||||||
let (management, gateway) = test_apps(artifact_dir.path()).await;
|
|
||||||
let response = management
|
|
||||||
.clone()
|
|
||||||
.oneshot(package_request("restored", "1.0.0", &component))
|
|
||||||
.await
|
|
||||||
.expect("register request should complete");
|
|
||||||
assert_eq!(response.status(), StatusCode::CREATED);
|
|
||||||
let response = management
|
|
||||||
.oneshot(json_request(
|
|
||||||
"/api/v1/deployments/restored/activate",
|
|
||||||
json!({ "revision": "1.0.0" }),
|
|
||||||
))
|
|
||||||
.await
|
|
||||||
.expect("activation request should complete");
|
|
||||||
assert_eq!(response.status(), StatusCode::OK);
|
|
||||||
|
|
||||||
let response = gateway
|
|
||||||
.oneshot(raw_request(
|
|
||||||
"/v1/services/restored/invoke",
|
|
||||||
"application/octet-stream",
|
|
||||||
b"before restart",
|
|
||||||
))
|
|
||||||
.await
|
|
||||||
.expect("gateway invocation should complete");
|
|
||||||
assert_eq!(response.status(), StatusCode::OK);
|
|
||||||
}
|
|
||||||
|
|
||||||
let (management, gateway) = test_apps(artifact_dir.path()).await;
|
|
||||||
let response = management
|
|
||||||
.oneshot(get_request("/api/v1/deployments/restored"))
|
|
||||||
.await
|
|
||||||
.expect("restored deployment request should complete");
|
|
||||||
let deployment = response_json(response).await;
|
|
||||||
assert_eq!(deployment["active_revision"], "1.0.0");
|
|
||||||
assert_eq!(deployment["status"], "running");
|
|
||||||
|
|
||||||
let response = gateway
|
|
||||||
.oneshot(raw_request(
|
|
||||||
"/v1/services/restored/invoke",
|
|
||||||
"application/octet-stream",
|
|
||||||
b"after restart",
|
|
||||||
))
|
|
||||||
.await
|
|
||||||
.expect("restored gateway invocation should complete");
|
|
||||||
assert_eq!(response.status(), StatusCode::OK);
|
|
||||||
assert_eq!(
|
|
||||||
to_bytes(response.into_body(), usize::MAX).await.unwrap(),
|
|
||||||
b"after restart".as_slice()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn reloads_manifests_without_restoring_actor_memory() {
|
async fn reloads_manifests_without_restoring_actor_memory() {
|
||||||
let artifact_dir = TempDir::new().expect("temporary artifact directory");
|
let artifact_dir = TempDir::new().expect("temporary artifact directory");
|
||||||
@@ -408,7 +111,6 @@ async fn reloads_manifests_without_restoring_actor_memory() {
|
|||||||
assert_eq!(response.status(), StatusCode::OK);
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
|
||||||
let response = application
|
let response = application
|
||||||
.clone()
|
|
||||||
.oneshot(json_request(
|
.oneshot(json_request(
|
||||||
"/api/v1/services/persisted/0.1.0/invoke",
|
"/api/v1/services/persisted/0.1.0/invoke",
|
||||||
json!({ "input_base64": BASE64.encode(b"persist metrics") }),
|
json!({ "input_base64": BASE64.encode(b"persist metrics") }),
|
||||||
@@ -416,12 +118,6 @@ async fn reloads_manifests_without_restoring_actor_memory() {
|
|||||||
.await
|
.await
|
||||||
.expect("invoke request should complete");
|
.expect("invoke request should complete");
|
||||||
assert_eq!(response.status(), StatusCode::OK);
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
|
||||||
let response = application
|
|
||||||
.oneshot(empty_post("/api/v1/services/persisted/0.1.0/stop"))
|
|
||||||
.await
|
|
||||||
.expect("stop request should flush control state");
|
|
||||||
assert_eq!(response.status(), StatusCode::OK);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let application = test_app(artifact_dir.path()).await;
|
let application = test_app(artifact_dir.path()).await;
|
||||||
@@ -444,80 +140,6 @@ async fn reloads_manifests_without_restoring_actor_memory() {
|
|||||||
assert!(body["events"].as_array().unwrap().len() >= 3);
|
assert!(body["events"].as_array().unwrap().len() >= 3);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn persists_service_scoped_kv_across_revisions_and_restarts() {
|
|
||||||
const KV_WORLD: &str = "component:kv-probe/kv-probe-component@0.1.0";
|
|
||||||
|
|
||||||
let artifact_dir = TempDir::new().expect("temporary artifact directory");
|
|
||||||
let component = fs::read(component_artifact("kv_probe_component.wasm"))
|
|
||||||
.expect("KV probe component should be readable");
|
|
||||||
|
|
||||||
{
|
|
||||||
let application = test_app(artifact_dir.path()).await;
|
|
||||||
for (id, revision) in [
|
|
||||||
("shared-kv", "0.1.0"),
|
|
||||||
("shared-kv", "0.2.0"),
|
|
||||||
("isolated-kv", "0.1.0"),
|
|
||||||
] {
|
|
||||||
let response = application
|
|
||||||
.clone()
|
|
||||||
.oneshot(package_request_with_world(
|
|
||||||
id, revision, KV_WORLD, &component,
|
|
||||||
))
|
|
||||||
.await
|
|
||||||
.expect("KV component registration should complete");
|
|
||||||
assert_eq!(response.status(), StatusCode::CREATED);
|
|
||||||
let registered = response_json(response).await;
|
|
||||||
assert_eq!(
|
|
||||||
registered["capabilities"][0]["interface"],
|
|
||||||
"wasmeld:kv/store@0.1.0"
|
|
||||||
);
|
|
||||||
|
|
||||||
let response = application
|
|
||||||
.clone()
|
|
||||||
.oneshot(empty_post(&format!(
|
|
||||||
"/api/v1/services/{id}/{revision}/start"
|
|
||||||
)))
|
|
||||||
.await
|
|
||||||
.expect("KV actor start should complete");
|
|
||||||
assert_eq!(response.status(), StatusCode::OK);
|
|
||||||
}
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
invoke_service(&application, "shared-kv", "0.1.0", b"set:answer:forty-two").await,
|
|
||||||
b""
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
invoke_service(&application, "shared-kv", "0.2.0", b"get:answer").await,
|
|
||||||
b"forty-two"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
invoke_service(&application, "isolated-kv", "0.1.0", b"get:answer").await,
|
|
||||||
b""
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let application = test_app(artifact_dir.path()).await;
|
|
||||||
let response = application
|
|
||||||
.clone()
|
|
||||||
.oneshot(empty_post("/api/v1/services/shared-kv/0.2.0/start"))
|
|
||||||
.await
|
|
||||||
.expect("restored KV actor start should complete");
|
|
||||||
assert_eq!(response.status(), StatusCode::OK);
|
|
||||||
assert_eq!(
|
|
||||||
invoke_service(&application, "shared-kv", "0.2.0", b"get:answer").await,
|
|
||||||
b"forty-two"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
invoke_service(&application, "shared-kv", "0.2.0", b"delete:answer").await,
|
|
||||||
b""
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
invoke_service(&application, "shared-kv", "0.2.0", b"get:answer").await,
|
|
||||||
b""
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn manages_the_embedded_runtime_lifecycle() {
|
async fn manages_the_embedded_runtime_lifecycle() {
|
||||||
let artifact_dir = TempDir::new().expect("temporary artifact directory");
|
let artifact_dir = TempDir::new().expect("temporary artifact directory");
|
||||||
@@ -851,10 +473,6 @@ async fn client_fetches_transitive_wit_dependencies_from_the_registry() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn test_app(artifact_dir: &Path) -> Router {
|
async fn test_app(artifact_dir: &Path) -> Router {
|
||||||
test_apps(artifact_dir).await.0
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn test_apps(artifact_dir: &Path) -> (Router, Router) {
|
|
||||||
let console = Console::new(ConsoleConfig {
|
let console = Console::new(ConsoleConfig {
|
||||||
artifact_dir: artifact_dir.to_path_buf(),
|
artifact_dir: artifact_dir.to_path_buf(),
|
||||||
wit_registry_dir: artifact_dir.join("wit-packages"),
|
wit_registry_dir: artifact_dir.join("wit-packages"),
|
||||||
@@ -863,28 +481,16 @@ async fn test_apps(artifact_dir: &Path) -> (Router, Router) {
|
|||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("console should start");
|
.expect("console should start");
|
||||||
let console = Arc::new(console);
|
app(Arc::new(console), Vec::new())
|
||||||
(app(Arc::clone(&console), Vec::new()), gateway_app(console))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn package_request(id: &str, revision: &str, component: &[u8]) -> Request<Body> {
|
fn package_request(id: &str, revision: &str, component: &[u8]) -> Request<Body> {
|
||||||
|
let mut package = Cursor::new(Vec::new());
|
||||||
let world = if id == "counter" {
|
let world = if id == "counter" {
|
||||||
"component:counter/counter-component@0.1.0"
|
"component:counter/counter-component@0.1.0"
|
||||||
} else if id == "clock-probe" {
|
|
||||||
"component:clock-probe/clock-probe-component@0.1.0"
|
|
||||||
} else {
|
} else {
|
||||||
"component:echo/echo-component@0.1.0"
|
"component:echo/echo-component@0.1.0"
|
||||||
};
|
};
|
||||||
package_request_with_world(id, revision, world, component)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn package_request_with_world(
|
|
||||||
id: &str,
|
|
||||||
revision: &str,
|
|
||||||
world: &str,
|
|
||||||
component: &[u8],
|
|
||||||
) -> Request<Body> {
|
|
||||||
let mut package = Cursor::new(Vec::new());
|
|
||||||
write_package(&mut package, id, revision, world, component)
|
write_package(&mut package, id, revision, world, component)
|
||||||
.expect("test package should be valid");
|
.expect("test package should be valid");
|
||||||
package_bytes_request(package.get_ref())
|
package_bytes_request(package.get_ref())
|
||||||
@@ -946,15 +552,6 @@ fn json_request(uri: &str, value: Value) -> Request<Body> {
|
|||||||
.unwrap()
|
.unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn raw_request(uri: &str, content_type: &str, body: &[u8]) -> Request<Body> {
|
|
||||||
Request::builder()
|
|
||||||
.method("POST")
|
|
||||||
.uri(uri)
|
|
||||||
.header(header::CONTENT_TYPE, content_type)
|
|
||||||
.body(Body::from(body.to_vec()))
|
|
||||||
.unwrap()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn empty_post(uri: &str) -> Request<Body> {
|
fn empty_post(uri: &str) -> Request<Body> {
|
||||||
Request::builder()
|
Request::builder()
|
||||||
.method("POST")
|
.method("POST")
|
||||||
@@ -963,14 +560,6 @@ fn empty_post(uri: &str) -> Request<Body> {
|
|||||||
.unwrap()
|
.unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn delete_request(uri: &str) -> Request<Body> {
|
|
||||||
Request::builder()
|
|
||||||
.method("DELETE")
|
|
||||||
.uri(uri)
|
|
||||||
.body(Body::empty())
|
|
||||||
.unwrap()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get_request(uri: &str) -> Request<Body> {
|
fn get_request(uri: &str) -> Request<Body> {
|
||||||
Request::builder().uri(uri).body(Body::empty()).unwrap()
|
Request::builder().uri(uri).body(Body::empty()).unwrap()
|
||||||
}
|
}
|
||||||
@@ -982,23 +571,6 @@ async fn response_json(response: axum::response::Response) -> Value {
|
|||||||
serde_json::from_slice(&bytes).expect("response should be JSON")
|
serde_json::from_slice(&bytes).expect("response should be JSON")
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn invoke_service(application: &Router, id: &str, revision: &str, input: &[u8]) -> Vec<u8> {
|
|
||||||
let response = application
|
|
||||||
.clone()
|
|
||||||
.oneshot(json_request(
|
|
||||||
&format!("/api/v1/services/{id}/{revision}/invoke"),
|
|
||||||
json!({ "input_base64": BASE64.encode(input) }),
|
|
||||||
))
|
|
||||||
.await
|
|
||||||
.expect("component invocation should complete");
|
|
||||||
let status = response.status();
|
|
||||||
let body = response_json(response).await;
|
|
||||||
assert_eq!(status, StatusCode::OK, "{body}");
|
|
||||||
BASE64
|
|
||||||
.decode(body["output_base64"].as_str().unwrap())
|
|
||||||
.unwrap()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn echo_component() -> PathBuf {
|
fn echo_component() -> PathBuf {
|
||||||
component_artifact("echo_component.wasm")
|
component_artifact("echo_component.wasm")
|
||||||
}
|
}
|
||||||
@@ -1008,8 +580,6 @@ fn component_artifact(name: &str) -> PathBuf {
|
|||||||
for manifest in [
|
for manifest in [
|
||||||
"components/echo/Cargo.toml",
|
"components/echo/Cargo.toml",
|
||||||
"components/counter/Cargo.toml",
|
"components/counter/Cargo.toml",
|
||||||
"components/clock-probe/Cargo.toml",
|
|
||||||
"components/kv-probe/Cargo.toml",
|
|
||||||
] {
|
] {
|
||||||
let status = Command::new("rustup")
|
let status = Command::new("rustup")
|
||||||
.args([
|
.args([
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"printWidth": 100,
|
|
||||||
"tabWidth": 2,
|
|
||||||
"useTabs": false,
|
|
||||||
"semi": true,
|
|
||||||
"singleQuote": false,
|
|
||||||
"trailingComma": "all"
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
{
|
|
||||||
"plugins": ["typescript"],
|
|
||||||
"categories": {
|
|
||||||
"correctness": "error",
|
|
||||||
"suspicious": "warn"
|
|
||||||
},
|
|
||||||
"rules": {
|
|
||||||
"typescript/no-explicit-any": "error",
|
|
||||||
"no-console": "warn"
|
|
||||||
},
|
|
||||||
"ignorePatterns": ["dist/**", "node_modules/**"]
|
|
||||||
}
|
|
||||||
@@ -1,192 +0,0 @@
|
|||||||
# Wasmeld Console Web
|
|
||||||
|
|
||||||
管理面使用 Solid 和 Tailwind CSS v4。它不是传统单文档 SPA,而是由一个常驻 Host
|
|
||||||
文档和一个可替换 Page iframe 组成:
|
|
||||||
|
|
||||||
```text
|
|
||||||
index.html / src/host
|
|
||||||
├── 导航、搜索、弹窗、通知
|
|
||||||
├── 管理 API 轮询和共享状态
|
|
||||||
└── page.html?view=<view>&instance=<n>
|
|
||||||
└── src/pages/<view>
|
|
||||||
```
|
|
||||||
|
|
||||||
普通页面切换时,Host 先发送 `dispose`,再销毁 iframe 并创建新的文档。页面所属的
|
|
||||||
Solid reactive owner、事件监听器、第三方 UI 库和页面局部缓存会随文档一起释放,避免
|
|
||||||
长期导航后把所有页面资源都留在同一个 JavaScript realm 中。
|
|
||||||
|
|
||||||
页面 ID、标题、导航图标、保活偏好和懒加载入口统一声明在
|
|
||||||
`src/pages/registry.ts` 的 `PAGE_DEFINITIONS`。新增页面时创建
|
|
||||||
`src/pages/<id>/index.tsx` 并添加一条注册记录即可,不需要分别维护路由、导航和加载映射。
|
|
||||||
|
|
||||||
一级导航页面默认在对应注册记录中设置 `keepAlive: true`。它们数量固定,切换时保留
|
|
||||||
DOM、JavaScript realm、Solid 状态、表单和滚动位置。未来的详情页、临时编辑页等短期
|
|
||||||
页面应设置 `keepAlive: false`,离开后立即销毁:
|
|
||||||
|
|
||||||
- 离开时发送 `deactivate`,页面应暂停轮询、媒体、动画或其它后台工作。
|
|
||||||
- 返回时发送 `activate`,恢复页面任务,iframe 的 `instance` 和 `timeOrigin` 不变。
|
|
||||||
- 页面连续隐藏 30 分钟后失效,Host 发送 `dispose` 并销毁 iframe;再次访问会创建新文档。
|
|
||||||
- 失效时间从 `deactivate` 开始计算,页面在前台显示的时间不计入;到期前返回会重置计时。
|
|
||||||
- 关闭 Host 或离开非保活页面时同样发送 `dispose`,随后销毁文档。
|
|
||||||
|
|
||||||
这里不使用按页面数量淘汰的 LRU。打开另一个一级页面不会造成先前页面被立即释放,
|
|
||||||
同时长期不再使用的隐藏页面最终仍会归还内存。
|
|
||||||
|
|
||||||
Page SDK 通过 `PageProps.lifecycle` 提供框架无关的 `onShow`、`onHide` 和
|
|
||||||
`onUnload` 事件。它们分别对应 Host 协议内部的 `activate`、`deactivate` 和
|
|
||||||
`dispose`,重复消息会被过滤;浏览器 `pagehide` 还会作为 unload 兜底。每次订阅都会
|
|
||||||
返回取消函数。如果懒加载组件订阅时页面已经显示或隐藏,`onShow` 或 `onHide` 会立即
|
|
||||||
重放当前事件,避免错过首次初始化;通用的 `lifecycle.on(type, listener)` 只监听未来
|
|
||||||
变化。`PageProps.active` 继续用于 Solid 响应式渲染和 effect,页面自身不应通过
|
|
||||||
`display: none` 判断状态,因为 Host 可能改变具体的隐藏实现。
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
export default function StreamPage(props: PageProps) {
|
|
||||||
onCleanup(props.lifecycle.onShow(refreshData));
|
|
||||||
onCleanup(props.lifecycle.onHide(pauseStream));
|
|
||||||
onCleanup(props.lifecycle.onUnload(closeStream));
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
if (props.active()) {
|
|
||||||
resumeStream();
|
|
||||||
} else {
|
|
||||||
pauseStream();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
// ...
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`onShow` 在首次显示和保活后再次显示时触发,适合主动刷新可能已经过期的数据。调用记录
|
|
||||||
页面使用该事件请求 Host 刷新 Runtime 快照。`onUnload` 最多触发一次;收到该事件后
|
|
||||||
不得再创建定时器、连接或其它长期资源。浏览器销毁文档时不会等待异步任务,因此
|
|
||||||
`onUnload` 只应用于同步清理;必须发送的少量遥测数据应使用 `sendBeacon`。旧的
|
|
||||||
`wasmeld:activate`、
|
|
||||||
`wasmeld:deactivate`、`wasmeld:dispose` 和 `wasmeld:lifecycle` DOM 事件暂时保留,
|
|
||||||
新页面应使用类型化 SDK。
|
|
||||||
|
|
||||||
iframe 是资源生命周期边界,不是安全边界。Host 和 Page 都是 Wasmeld 自己构建并同源
|
|
||||||
发布的可信代码;Wasm 服务的安全边界仍然在后端 Wasmtime Sandbox 和 Host Capability
|
|
||||||
Registry 中。
|
|
||||||
|
|
||||||
## 目录
|
|
||||||
|
|
||||||
```text
|
|
||||||
src/host/ 常驻 Host Shell、iframe 隐藏超时管理和 Host-owned dialogs
|
|
||||||
src/components/
|
|
||||||
feedback/ 空状态等反馈组合
|
|
||||||
layout/ PageHeading 等页面结构
|
|
||||||
services/ ServiceTable、StatusBadge 等服务领域组件
|
|
||||||
ui/ Button、Input、Select、Textarea、Card 等无业务基础组件
|
|
||||||
src/lib/ API client、领域模型和纯函数
|
|
||||||
src/primitives/ Solid reactive primitives;Solid 不使用 React Hooks 约定
|
|
||||||
src/pages/ 集中式页面注册表;每个 iframe 页面一个目录和独立懒加载 chunk
|
|
||||||
src/sdk/ Host/Page postMessage 协议与两侧 client
|
|
||||||
src/styles/ Tailwind v4 theme、文档基础规则和全局 keyframes
|
|
||||||
```
|
|
||||||
|
|
||||||
`components/ui` 只能依赖通用样式和基础函数,不得引用 Runtime、Service、WIT 或具体页面
|
|
||||||
状态。领域组件可以组合 UI 基础组件,但 UI 基础组件不能反向依赖领域目录。
|
|
||||||
|
|
||||||
UI 基础组件采用与 shadcn 相同的源码内样式模式:
|
|
||||||
|
|
||||||
- 组件基础样式和可枚举变体使用 Tailwind class 与 `class-variance-authority` 声明。
|
|
||||||
- 调用者的 `class` 通过 `cn()` 合并;`clsx` 处理条件值,`tailwind-merge` 解决冲突。
|
|
||||||
- Button、Input、Select、Textarea、Card、Table 等组件暴露类型化的变体属性,不依赖
|
|
||||||
`.btn`、`.select` 之类的全局语义 class。
|
|
||||||
- `src/styles/app.css` 不定义组件层,只保留主题 token、文档基础规则和无法内联的全局
|
|
||||||
keyframes。
|
|
||||||
|
|
||||||
业务特有的结构留在 `feedback`、`layout`、`services` 或页面目录中,并组合 UI primitive。
|
|
||||||
导航项等只在一个业务上下文出现的控件可以使用就地 Tailwind class,不需要为了形式统一
|
|
||||||
强行包装成通用组件。
|
|
||||||
|
|
||||||
`src/sdk/protocol.ts` 中的 `SDK_VERSION` 是文档间协议版本。新增可选消息可以保持原版本;
|
|
||||||
删除字段、改变字段语义或产生不兼容状态时必须升级版本,并让两侧同时发布。消息接收端
|
|
||||||
同时检查 origin、source、channel 和 version,不接收任意窗口的控制命令。
|
|
||||||
|
|
||||||
Host 会继续向隐藏的保活页面发送只读状态快照,但拒绝其管理命令。这样页面可以在恢复时
|
|
||||||
立即显示最新 Runtime 状态,同时停用后的定时器不能意外触发注册、启停或 Deployment
|
|
||||||
操作。
|
|
||||||
|
|
||||||
## 浏览器 Tab 状态
|
|
||||||
|
|
||||||
同源普通 Tab 和已安装 PWA 窗口通过 `BroadcastChannel` 共享 Host 全局状态。状态按
|
|
||||||
所有权分为三层:
|
|
||||||
|
|
||||||
| 状态 | 跨 Tab | Host 到 Page | 生命周期 |
|
|
||||||
| ------------------------------------------ | -------- | --------------------- | --------------- |
|
|
||||||
| Runtime 快照、连接、API 地址、控制操作状态 | 始终共享 | 分发给已存在的 iframe | Host Tab |
|
|
||||||
| 当前路由、搜索、弹窗、toast、iframe 保活集 | 不共享 | 当前 Tab 自己管理 | Browser Tab |
|
|
||||||
| 页面筛选、表单、滚动位置、页面资源 | 不共享 | 不进入 Host | iframe document |
|
|
||||||
|
|
||||||
`src/host/tab-sync.ts` 使用独立版本的 Host Tab 协议:
|
|
||||||
|
|
||||||
- 每个 Host Tab 有随机 `sender` 和单调递增 `sequence`。
|
|
||||||
- 新 Tab 发送 `hello`,已打开 Tab 立即返回当前 Host 全局状态。
|
|
||||||
- 所有 Host Tab 都可以发布,不依赖可能失效的 leader。
|
|
||||||
- 接收方拒绝自身消息、旧序列和字段不合法的消息。
|
|
||||||
- 应用远端状态时抑制本地广播 effect,避免 Tab 间回声循环。
|
|
||||||
- 浏览器不支持 `BroadcastChannel` 时退化为单 Tab,不影响页面和 API 操作。
|
|
||||||
|
|
||||||
Host 收到本地或远端全局状态后,只遍历当前 Tab 的 FrameCache。活动 iframe 和隐藏的
|
|
||||||
keep-alive iframe 会收到最新快照;从未打开或已被销毁的 Page 没有同步目标,创建并
|
|
||||||
发送 `ready` 后才取得当时的最新状态。Page 私有信号永远不会上传到 Host Tab channel。
|
|
||||||
|
|
||||||
## 开发
|
|
||||||
|
|
||||||
先在仓库根目录启动 Rust 后端:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cargo +stable run -p wasmeld-console
|
|
||||||
```
|
|
||||||
|
|
||||||
再启动 Web 开发服务器:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd crates/wasmeld-console/web
|
|
||||||
npm ci
|
|
||||||
npm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
打开 `http://127.0.0.1:3000`。Vite 开发环境默认连接
|
|
||||||
`http://127.0.0.1:8080`;设置页可以覆盖该地址。Debug Cargo 构建不会调用 npm,
|
|
||||||
因此 Rust 与 Web 的增量开发互不阻塞。
|
|
||||||
|
|
||||||
常用检查:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run format:check
|
|
||||||
npm run lint
|
|
||||||
npm test
|
|
||||||
npm audit --audit-level=high
|
|
||||||
```
|
|
||||||
|
|
||||||
## 发布与嵌入
|
|
||||||
|
|
||||||
Release 构建由 `../build.rs` 自动执行:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cargo +stable build --release -p wasmeld-console
|
|
||||||
```
|
|
||||||
|
|
||||||
构建脚本使用 `npm ci` 严格读取 `package-lock.json`,将 Vite 输出写入 Cargo
|
|
||||||
`OUT_DIR`,再由 Rust `include_dir!` 嵌入可执行文件。发布机器必须提供 Node.js 22.13+
|
|
||||||
和 npm。可以用 `NPM=/absolute/path/to/npm` 指定 npm;只有需要调试嵌入行为时,才在
|
|
||||||
非 Release 构建中设置 `WASMELD_BUILD_WEB=1`。
|
|
||||||
|
|
||||||
Release 管理 Listener 同时服务 `/`、`/page.html`、静态资源、PWA manifest 和
|
|
||||||
`/sw.js`。资源路径必须精确存在,不做 SPA fallback。带内容哈希的 `assets/` 使用长期
|
|
||||||
immutable 缓存,其它入口使用 `no-cache`。
|
|
||||||
|
|
||||||
## PWA 边界
|
|
||||||
|
|
||||||
一个 Host-owned service worker 管理整个应用:
|
|
||||||
|
|
||||||
- 构建时预缓存 Host、Page 和带哈希静态资源。
|
|
||||||
- 文档请求使用 network-first,离线时分别回退到 `index.html` 或 `page.html`。
|
|
||||||
- `/api/*` 和 `/healthz` 永远走网络,不缓存运行状态或管理操作。
|
|
||||||
- 新 worker 激活时删除旧的 `wasmeld-*` 缓存。
|
|
||||||
|
|
||||||
PWA 只提供安装和静态壳离线能力。没有后端连接时页面会显示离线状态,不会用旧 API
|
|
||||||
响应伪造 Runtime 状态。
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="zh-CN">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<meta name="theme-color" content="#173f35" />
|
|
||||||
<meta name="description" content="WebAssembly Component Runtime 管理控制台" />
|
|
||||||
<link rel="icon" href="/icon.svg" type="image/svg+xml" />
|
|
||||||
<link rel="manifest" href="/manifest.webmanifest" />
|
|
||||||
<title>Wasmeld Console</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="app"></div>
|
|
||||||
<script type="module" src="/src/host/main.tsx"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "wasmeld-console-web",
|
|
||||||
"version": "0.1.0",
|
|
||||||
"private": true,
|
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
|
||||||
"dev": "vite dev",
|
|
||||||
"build": "vite build && tsc --noEmit",
|
|
||||||
"preview": "vite preview",
|
|
||||||
"test": "npm run build && node --experimental-strip-types --test tests/*.test.mjs",
|
|
||||||
"lint": "oxlint . --deny-warnings",
|
|
||||||
"format": "oxfmt .",
|
|
||||||
"format:check": "oxfmt --check ."
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"class-variance-authority": "0.7.1",
|
|
||||||
"clsx": "2.1.1",
|
|
||||||
"lucide-solid": "1.27.0",
|
|
||||||
"solid-js": "1.9.14",
|
|
||||||
"tailwind-merge": "3.6.0"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@tailwindcss/vite": "4.3.3",
|
|
||||||
"@types/node": "24.10.1",
|
|
||||||
"oxfmt": "0.61.0",
|
|
||||||
"oxlint": "1.76.0",
|
|
||||||
"tailwindcss": "4.3.3",
|
|
||||||
"typescript": "7.0.2",
|
|
||||||
"vite": "8.1.5",
|
|
||||||
"vite-plugin-solid": "2.11.14"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=22.13.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="zh-CN" class="frame-root">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<meta name="theme-color" content="#f4f7f6" />
|
|
||||||
<title>Wasmeld Console Page</title>
|
|
||||||
</head>
|
|
||||||
<body class="frame-document">
|
|
||||||
<div id="app"></div>
|
|
||||||
<script type="module" src="/src/pages/main.tsx"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
|
||||||
<rect width="512" height="512" rx="88" fill="#173f35"/>
|
|
||||||
<path d="M104 132h70l36 196 46-143h52l46 143 36-196h70l-69 248h-67l-42-128-42 128h-67z" fill="#e5f4ef"/>
|
|
||||||
<rect x="104" y="98" width="304" height="18" rx="9" fill="#45b68d"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 306 B |
@@ -1,18 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "Wasmeld Console",
|
|
||||||
"short_name": "Wasmeld",
|
|
||||||
"description": "WebAssembly Component Runtime management console",
|
|
||||||
"theme_color": "#173f35",
|
|
||||||
"background_color": "#f3f6f5",
|
|
||||||
"display": "standalone",
|
|
||||||
"start_url": "/",
|
|
||||||
"scope": "/",
|
|
||||||
"icons": [
|
|
||||||
{
|
|
||||||
"src": "/icon.svg",
|
|
||||||
"sizes": "any",
|
|
||||||
"type": "image/svg+xml",
|
|
||||||
"purpose": "any maskable"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
import type { Component } from "solid-js";
|
|
||||||
import type { LucideProps } from "lucide-solid";
|
|
||||||
|
|
||||||
export function EmptyState(props: { icon: Component<LucideProps>; title: string; detail: string }) {
|
|
||||||
const Icon = props.icon;
|
|
||||||
return (
|
|
||||||
<div class="flex min-h-56 flex-col items-center justify-center gap-2 px-6 text-center text-muted">
|
|
||||||
<Icon size={22} />
|
|
||||||
<strong class="text-sm text-ink">{props.title}</strong>
|
|
||||||
<span class="text-xs">{props.detail}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import type { JSXElement } from "solid-js";
|
|
||||||
|
|
||||||
export function PageHeading(props: {
|
|
||||||
eyebrow: string;
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
actions?: JSXElement;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<header class="mb-5 flex flex-col justify-between gap-4 sm:flex-row sm:items-end">
|
|
||||||
<div>
|
|
||||||
<span class="text-[11px] font-bold uppercase text-cyan-strong">{props.eyebrow}</span>
|
|
||||||
<h1 class="mt-1 text-2xl font-bold text-ink">{props.title}</h1>
|
|
||||||
<p class="mt-1 max-w-3xl text-sm text-muted">{props.description}</p>
|
|
||||||
</div>
|
|
||||||
{props.actions && <div class="flex shrink-0 items-center gap-2">{props.actions}</div>}
|
|
||||||
</header>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import { splitProps, type JSX } from "solid-js";
|
|
||||||
import { cn } from "../../lib/cn";
|
|
||||||
|
|
||||||
export type PageProps = JSX.HTMLAttributes<HTMLElement>;
|
|
||||||
|
|
||||||
/** Standard constrained content area rendered inside a Page iframe. */
|
|
||||||
export function Page(props: PageProps) {
|
|
||||||
const [local, rest] = splitProps(props, ["class", "children"]);
|
|
||||||
return (
|
|
||||||
<main
|
|
||||||
{...rest}
|
|
||||||
data-slot="page"
|
|
||||||
class={cn("mx-auto w-full max-w-[1480px] px-4 py-5 sm:px-6 lg:px-8 lg:py-7", local.class)}
|
|
||||||
>
|
|
||||||
{local.children}
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,201 +0,0 @@
|
|||||||
import {
|
|
||||||
Box,
|
|
||||||
Braces,
|
|
||||||
CircleStop,
|
|
||||||
Play,
|
|
||||||
RadioTower,
|
|
||||||
RefreshCw,
|
|
||||||
RotateCcw,
|
|
||||||
Search,
|
|
||||||
SquareTerminal,
|
|
||||||
} from "lucide-solid";
|
|
||||||
import { For, Show } from "solid-js";
|
|
||||||
import { serviceKey, type Service } from "../../lib/model";
|
|
||||||
import type { HostSharedState, PageCommand } from "../../sdk/protocol";
|
|
||||||
import { EmptyState } from "../feedback/empty-state";
|
|
||||||
import { IconButton } from "../ui/button";
|
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../ui/table";
|
|
||||||
import { StatusBadge } from "./status-badge";
|
|
||||||
|
|
||||||
export function ServiceTable(props: {
|
|
||||||
services: Service[];
|
|
||||||
state: HostSharedState;
|
|
||||||
command: (command: PageCommand) => void;
|
|
||||||
compact?: boolean;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<Show
|
|
||||||
when={props.services.length > 0}
|
|
||||||
fallback={
|
|
||||||
<EmptyState icon={Search} title="没有匹配的服务" detail="调整搜索关键词或状态筛选。" />
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead>服务</TableHead>
|
|
||||||
<TableHead>状态</TableHead>
|
|
||||||
<Show when={!props.compact}>
|
|
||||||
<TableHead>制品</TableHead>
|
|
||||||
<TableHead>Host 能力</TableHead>
|
|
||||||
</Show>
|
|
||||||
<TableHead>调用</TableHead>
|
|
||||||
<TableHead>延迟</TableHead>
|
|
||||||
<TableHead>
|
|
||||||
<span class="sr-only">操作</span>
|
|
||||||
</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
<For each={props.services}>
|
|
||||||
{(service) => {
|
|
||||||
const key = serviceKey(service);
|
|
||||||
return (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell>
|
|
||||||
<div class="flex min-w-48 items-center gap-3">
|
|
||||||
<span class="flex size-8 shrink-0 items-center justify-center rounded-md bg-brand-soft text-brand">
|
|
||||||
<Box size={15} />
|
|
||||||
</span>
|
|
||||||
<span class="min-w-0">
|
|
||||||
<span class="flex items-center gap-2">
|
|
||||||
<strong class="truncate">{service.id}</strong>
|
|
||||||
<Show when={service.active}>
|
|
||||||
<span class="inline-flex items-center gap-1 rounded-sm bg-cyan-soft px-1.5 py-0.5 text-[10px] font-semibold text-cyan-strong">
|
|
||||||
<RadioTower size={10} />
|
|
||||||
对外
|
|
||||||
</span>
|
|
||||||
</Show>
|
|
||||||
</span>
|
|
||||||
<small class="mt-0.5 block text-xs text-muted">{service.revision}</small>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<StatusBadge status={service.status} />
|
|
||||||
</TableCell>
|
|
||||||
<Show when={!props.compact}>
|
|
||||||
<TableCell
|
|
||||||
class="max-w-48 truncate font-mono text-xs text-muted"
|
|
||||||
title={service.artifact}
|
|
||||||
>
|
|
||||||
{service.artifact}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Show
|
|
||||||
when={service.capabilities.length > 0}
|
|
||||||
fallback={<span class="text-xs text-muted">无</span>}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="inline-flex max-w-56 items-center gap-1.5 text-xs text-muted"
|
|
||||||
title={service.capabilities
|
|
||||||
.map((capability) => capability.interface)
|
|
||||||
.join("\n")}
|
|
||||||
>
|
|
||||||
<Braces size={12} />
|
|
||||||
<span class="truncate">
|
|
||||||
{service.capabilities[0]?.package}/{service.capabilities[0]?.name}
|
|
||||||
</span>
|
|
||||||
<Show when={service.capabilities.length > 1}>
|
|
||||||
<small>+{service.capabilities.length - 1}</small>
|
|
||||||
</Show>
|
|
||||||
</span>
|
|
||||||
</Show>
|
|
||||||
</TableCell>
|
|
||||||
</Show>
|
|
||||||
<TableCell class="font-mono text-xs">
|
|
||||||
{service.calls.toLocaleString("zh-CN")}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell class="font-mono text-xs">
|
|
||||||
{service.latencyMs === null ? "—" : `${service.latencyMs} ms`}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<div class="flex items-center justify-end gap-1">
|
|
||||||
<IconButton
|
|
||||||
size="small"
|
|
||||||
aria-label="设为对外版本"
|
|
||||||
title={service.active ? "当前对外版本" : "设为对外版本"}
|
|
||||||
disabled={
|
|
||||||
service.active ||
|
|
||||||
props.state.snapshot?.runtime.status !== "running" ||
|
|
||||||
props.state.deploymentAction !== null
|
|
||||||
}
|
|
||||||
onClick={() => props.command({ type: "open-activate", serviceKey: key })}
|
|
||||||
>
|
|
||||||
{props.state.deploymentAction === key ? (
|
|
||||||
<RefreshCw class="animate-spin" size={15} />
|
|
||||||
) : (
|
|
||||||
<RadioTower size={15} />
|
|
||||||
)}
|
|
||||||
</IconButton>
|
|
||||||
<Show
|
|
||||||
when={service.status === "running"}
|
|
||||||
fallback={
|
|
||||||
<IconButton
|
|
||||||
size="small"
|
|
||||||
aria-label={`启动 ${service.id}`}
|
|
||||||
title="启动"
|
|
||||||
disabled={props.state.serviceAction !== null}
|
|
||||||
onClick={() =>
|
|
||||||
props.command({
|
|
||||||
type: "service-action",
|
|
||||||
serviceKey: key,
|
|
||||||
action: "start",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Play size={15} />
|
|
||||||
</IconButton>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<IconButton
|
|
||||||
size="small"
|
|
||||||
aria-label={`调用 ${service.id}`}
|
|
||||||
title="测试调用"
|
|
||||||
onClick={() => props.command({ type: "open-invoke", serviceKey: key })}
|
|
||||||
>
|
|
||||||
<SquareTerminal size={15} />
|
|
||||||
</IconButton>
|
|
||||||
<IconButton
|
|
||||||
size="small"
|
|
||||||
aria-label={`重启 ${service.id}`}
|
|
||||||
title="重启"
|
|
||||||
disabled={props.state.serviceAction !== null}
|
|
||||||
onClick={() =>
|
|
||||||
props.command({
|
|
||||||
type: "service-action",
|
|
||||||
serviceKey: key,
|
|
||||||
action: "restart",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<RotateCcw size={15} />
|
|
||||||
</IconButton>
|
|
||||||
<IconButton
|
|
||||||
size="small"
|
|
||||||
tone="danger"
|
|
||||||
aria-label={`停止 ${service.id}`}
|
|
||||||
title="停止"
|
|
||||||
disabled={props.state.serviceAction !== null}
|
|
||||||
onClick={() =>
|
|
||||||
props.command({
|
|
||||||
type: "service-action",
|
|
||||||
serviceKey: key,
|
|
||||||
action: "stop",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<CircleStop size={15} />
|
|
||||||
</IconButton>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
</For>
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</Show>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import { cva } from "class-variance-authority";
|
|
||||||
import { STATUS_META, type ServiceStatus } from "../../lib/model";
|
|
||||||
|
|
||||||
const statusBadgeVariants = cva(
|
|
||||||
"inline-flex items-center gap-1.5 whitespace-nowrap text-xs font-semibold before:size-1.5 before:rounded-full before:bg-current before:content-['']",
|
|
||||||
{
|
|
||||||
variants: {
|
|
||||||
status: {
|
|
||||||
running: "text-brand",
|
|
||||||
stopped: "text-muted",
|
|
||||||
faulted: "text-coral-strong",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
export function StatusBadge(props: { status: ServiceStatus }) {
|
|
||||||
return (
|
|
||||||
<span data-slot="service-status" class={statusBadgeVariants({ status: props.status })}>
|
|
||||||
{STATUS_META[props.status].label}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
import { cva, type VariantProps } from "class-variance-authority";
|
|
||||||
import { splitProps, type JSX } from "solid-js";
|
|
||||||
import { cn } from "../../lib/cn";
|
|
||||||
|
|
||||||
export const buttonVariants = cva(
|
|
||||||
"inline-flex items-center justify-center gap-2 rounded-md border text-sm font-semibold transition-colors disabled:cursor-not-allowed disabled:opacity-45",
|
|
||||||
{
|
|
||||||
variants: {
|
|
||||||
variant: {
|
|
||||||
primary: "border-brand bg-brand text-white hover:border-brand-strong hover:bg-brand-strong",
|
|
||||||
secondary: "border-line bg-white text-ink hover:bg-canvas",
|
|
||||||
danger: "border-coral-strong bg-white text-coral-strong hover:bg-coral-soft",
|
|
||||||
},
|
|
||||||
size: {
|
|
||||||
default: "min-h-10 px-4 py-2",
|
|
||||||
small: "min-h-9 px-4 py-1.5",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
defaultVariants: {
|
|
||||||
variant: "secondary",
|
|
||||||
size: "default",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
export type ButtonProps = JSX.ButtonHTMLAttributes<HTMLButtonElement> &
|
|
||||||
VariantProps<typeof buttonVariants>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Standard text or icon-and-text command button.
|
|
||||||
*
|
|
||||||
* Size variants define a minimum target height instead of fixing the block
|
|
||||||
* height, so wrapped labels and enlarged user fonts can grow without clipping.
|
|
||||||
* The component defaults to `type="button"` so using it inside a form never
|
|
||||||
* submits accidentally. Set `type="submit"` explicitly for form actions.
|
|
||||||
*/
|
|
||||||
export function Button(props: ButtonProps) {
|
|
||||||
const [local, rest] = splitProps(props, ["variant", "size", "class", "children", "type"]);
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
{...rest}
|
|
||||||
data-slot="button"
|
|
||||||
type={local.type ?? "button"}
|
|
||||||
class={cn(buttonVariants({ variant: local.variant, size: local.size }), local.class)}
|
|
||||||
>
|
|
||||||
{local.children}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const iconButtonVariants = cva(
|
|
||||||
"inline-flex shrink-0 items-center justify-center rounded-md border border-line bg-white text-muted transition-colors hover:bg-canvas hover:text-ink disabled:cursor-not-allowed disabled:opacity-45",
|
|
||||||
{
|
|
||||||
variants: {
|
|
||||||
tone: {
|
|
||||||
default: "",
|
|
||||||
danger: "text-coral-strong",
|
|
||||||
},
|
|
||||||
size: {
|
|
||||||
default: "size-9",
|
|
||||||
small: "size-8",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
defaultVariants: {
|
|
||||||
tone: "default",
|
|
||||||
size: "default",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
export type IconButtonProps = Omit<JSX.ButtonHTMLAttributes<HTMLButtonElement>, "aria-label"> &
|
|
||||||
VariantProps<typeof iconButtonVariants> & {
|
|
||||||
"aria-label": string;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Square icon-only button for familiar toolbar and row actions.
|
|
||||||
*
|
|
||||||
* Callers must provide an accessible `aria-label`; `title` is recommended when
|
|
||||||
* the icon's meaning benefits from a visible hover explanation.
|
|
||||||
*/
|
|
||||||
export function IconButton(props: IconButtonProps) {
|
|
||||||
const [local, rest] = splitProps(props, ["tone", "size", "class", "children", "type"]);
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
{...rest}
|
|
||||||
data-slot="icon-button"
|
|
||||||
type={local.type ?? "button"}
|
|
||||||
class={cn(iconButtonVariants({ tone: local.tone, size: local.size }), local.class)}
|
|
||||||
>
|
|
||||||
{local.children}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export type IconLinkProps = Omit<JSX.AnchorHTMLAttributes<HTMLAnchorElement>, "aria-label"> &
|
|
||||||
Pick<VariantProps<typeof iconButtonVariants>, "size"> & {
|
|
||||||
"aria-label": string;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Anchor counterpart to IconButton for downloads and external navigation. */
|
|
||||||
export function IconLink(props: IconLinkProps) {
|
|
||||||
const [local, rest] = splitProps(props, ["size", "class", "children"]);
|
|
||||||
return (
|
|
||||||
<a
|
|
||||||
{...rest}
|
|
||||||
data-slot="icon-link"
|
|
||||||
class={cn(iconButtonVariants({ size: local.size }), local.class)}
|
|
||||||
>
|
|
||||||
{local.children}
|
|
||||||
</a>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
import { cva, type VariantProps } from "class-variance-authority";
|
|
||||||
import { splitProps, type JSX } from "solid-js";
|
|
||||||
import { Dynamic } from "solid-js/web";
|
|
||||||
import { cn } from "../../lib/cn";
|
|
||||||
|
|
||||||
type CardElement = "article" | "aside" | "div" | "section";
|
|
||||||
|
|
||||||
export const cardVariants = cva("rounded-md border border-line bg-surface", {
|
|
||||||
variants: {
|
|
||||||
padding: {
|
|
||||||
none: "",
|
|
||||||
small: "p-4",
|
|
||||||
medium: "p-5",
|
|
||||||
},
|
|
||||||
elevation: {
|
|
||||||
flat: "",
|
|
||||||
raised: "shadow-sm",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
defaultVariants: {
|
|
||||||
padding: "none",
|
|
||||||
elevation: "flat",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export type CardProps = JSX.HTMLAttributes<HTMLElement> &
|
|
||||||
VariantProps<typeof cardVariants> & {
|
|
||||||
as?: CardElement;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generic bordered surface.
|
|
||||||
*
|
|
||||||
* `as` preserves document semantics at the call site; Card only owns visual
|
|
||||||
* framing and must not encode page or domain behavior.
|
|
||||||
*/
|
|
||||||
export function Card(props: CardProps) {
|
|
||||||
const [local, rest] = splitProps(props, ["as", "padding", "elevation", "class", "children"]);
|
|
||||||
return (
|
|
||||||
<Dynamic
|
|
||||||
component={local.as ?? "div"}
|
|
||||||
{...rest}
|
|
||||||
data-slot="card"
|
|
||||||
class={cn(cardVariants({ padding: local.padding, elevation: local.elevation }), local.class)}
|
|
||||||
>
|
|
||||||
{local.children}
|
|
||||||
</Dynamic>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export type CardHeaderProps = JSX.HTMLAttributes<HTMLDivElement>;
|
|
||||||
|
|
||||||
/** Standard Card heading/action row. */
|
|
||||||
export function CardHeader(props: CardHeaderProps) {
|
|
||||||
const [local, rest] = splitProps(props, ["class", "children"]);
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
{...rest}
|
|
||||||
data-slot="card-header"
|
|
||||||
class={cn(
|
|
||||||
"flex min-h-16 items-center justify-between gap-4 border-b border-line px-5 py-3 [&_h2]:text-base [&_h2]:font-semibold [&_p]:mt-0.5 [&_p]:text-xs [&_p]:text-muted",
|
|
||||||
local.class,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{local.children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
import { splitProps, type JSX } from "solid-js";
|
|
||||||
import { cn } from "../../lib/cn";
|
|
||||||
|
|
||||||
export type FieldProps = JSX.LabelHTMLAttributes<HTMLLabelElement>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Associates a label and form control while keeping field layout consistent.
|
|
||||||
*
|
|
||||||
* Keep validation and help text at the call site: those messages are domain
|
|
||||||
* content, while Field only owns spacing and label typography.
|
|
||||||
*/
|
|
||||||
export function Field(props: FieldProps) {
|
|
||||||
const [local, rest] = splitProps(props, ["class", "children"]);
|
|
||||||
return (
|
|
||||||
<label {...rest} data-slot="field" class={cn("block", local.class)}>
|
|
||||||
{local.children}
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export type FieldLabelProps = JSX.HTMLAttributes<HTMLSpanElement>;
|
|
||||||
|
|
||||||
export function FieldLabel(props: FieldLabelProps) {
|
|
||||||
const [local, rest] = splitProps(props, ["class", "children"]);
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
{...rest}
|
|
||||||
data-slot="field-label"
|
|
||||||
class={cn("mb-1.5 block text-xs font-semibold text-muted", local.class)}
|
|
||||||
>
|
|
||||||
{local.children}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
import { cva, type VariantProps } from "class-variance-authority";
|
|
||||||
import { splitProps, type JSX } from "solid-js";
|
|
||||||
import { cn } from "../../lib/cn";
|
|
||||||
|
|
||||||
export const inputVariants = cva(
|
|
||||||
"w-full rounded-md border border-line bg-white px-3 text-sm text-ink outline-none transition-colors focus:border-brand disabled:cursor-not-allowed disabled:opacity-45 aria-invalid:border-coral-strong",
|
|
||||||
{
|
|
||||||
variants: {
|
|
||||||
density: {
|
|
||||||
default: "min-h-10 py-2",
|
|
||||||
compact: "min-h-9 py-1.5",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
defaultVariants: {
|
|
||||||
density: "default",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
export type InputProps = JSX.InputHTMLAttributes<HTMLInputElement> &
|
|
||||||
VariantProps<typeof inputVariants>;
|
|
||||||
|
|
||||||
/** Native single-line input with a minimum, rather than fixed, control height. */
|
|
||||||
export function Input(props: InputProps) {
|
|
||||||
const [local, rest] = splitProps(props, ["density", "class"]);
|
|
||||||
return (
|
|
||||||
<input
|
|
||||||
{...rest}
|
|
||||||
data-slot="input"
|
|
||||||
class={cn(inputVariants({ density: local.density }), local.class)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
import { cva, type VariantProps } from "class-variance-authority";
|
|
||||||
import { splitProps, type JSX } from "solid-js";
|
|
||||||
import { cn } from "../../lib/cn";
|
|
||||||
|
|
||||||
export function SegmentedControl(props: JSX.HTMLAttributes<HTMLDivElement>) {
|
|
||||||
const [local, rest] = splitProps(props, ["class", "children"]);
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
{...rest}
|
|
||||||
data-slot="segmented-control"
|
|
||||||
class={cn("inline-flex rounded-md border border-line bg-canvas p-1", local.class)}
|
|
||||||
>
|
|
||||||
{local.children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const segmentVariants = cva(
|
|
||||||
"min-h-8 rounded-sm px-3 py-1 text-xs font-semibold text-muted transition-colors",
|
|
||||||
{
|
|
||||||
variants: {
|
|
||||||
active: {
|
|
||||||
true: "bg-white text-ink shadow-sm",
|
|
||||||
false: "hover:text-ink",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
defaultVariants: {
|
|
||||||
active: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
export type SegmentProps = JSX.ButtonHTMLAttributes<HTMLButtonElement> &
|
|
||||||
VariantProps<typeof segmentVariants>;
|
|
||||||
|
|
||||||
/** One mutually exclusive option inside a SegmentedControl. */
|
|
||||||
export function Segment(props: SegmentProps) {
|
|
||||||
const [local, rest] = splitProps(props, ["active", "class", "children", "type"]);
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
{...rest}
|
|
||||||
data-slot="segment"
|
|
||||||
type={local.type ?? "button"}
|
|
||||||
aria-pressed={local.active === true}
|
|
||||||
class={cn(segmentVariants({ active: local.active }), local.class)}
|
|
||||||
>
|
|
||||||
{local.children}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
import { cva, type VariantProps } from "class-variance-authority";
|
|
||||||
import { splitProps, type JSX } from "solid-js";
|
|
||||||
import { cn } from "../../lib/cn";
|
|
||||||
|
|
||||||
export const selectVariants = cva(
|
|
||||||
"w-full rounded-md border border-line bg-white px-3 text-sm text-ink outline-none transition-colors focus:border-brand disabled:cursor-not-allowed disabled:opacity-45 aria-invalid:border-coral-strong",
|
|
||||||
{
|
|
||||||
variants: {
|
|
||||||
density: {
|
|
||||||
default: "min-h-10 py-2",
|
|
||||||
compact: "min-h-9 py-1.5",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
defaultVariants: {
|
|
||||||
density: "default",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
export type SelectProps = JSX.SelectHTMLAttributes<HTMLSelectElement> &
|
|
||||||
VariantProps<typeof selectVariants>;
|
|
||||||
|
|
||||||
/** Native select with browser behavior preserved and a content-growable height. */
|
|
||||||
export function Select(props: SelectProps) {
|
|
||||||
const [local, rest] = splitProps(props, ["density", "class", "children"]);
|
|
||||||
return (
|
|
||||||
<select
|
|
||||||
{...rest}
|
|
||||||
data-slot="select"
|
|
||||||
class={cn(selectVariants({ density: local.density }), local.class)}
|
|
||||||
>
|
|
||||||
{local.children}
|
|
||||||
</select>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
import { splitProps, type JSX } from "solid-js";
|
|
||||||
import { cn } from "../../lib/cn";
|
|
||||||
|
|
||||||
export type TableProps = JSX.HTMLAttributes<HTMLTableElement> & {
|
|
||||||
containerClass?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Responsive native table with a horizontal overflow boundary. */
|
|
||||||
export function Table(props: TableProps) {
|
|
||||||
const [local, rest] = splitProps(props, ["class", "containerClass", "children"]);
|
|
||||||
return (
|
|
||||||
<div data-slot="table-container" class={cn("w-full overflow-x-auto", local.containerClass)}>
|
|
||||||
<table
|
|
||||||
{...rest}
|
|
||||||
data-slot="table"
|
|
||||||
class={cn("w-full min-w-[760px] border-collapse text-left text-sm", local.class)}
|
|
||||||
>
|
|
||||||
{local.children}
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TableHeader(props: JSX.HTMLAttributes<HTMLTableSectionElement>) {
|
|
||||||
const [local, rest] = splitProps(props, ["class", "children"]);
|
|
||||||
return (
|
|
||||||
<thead {...rest} data-slot="table-header" class={cn(local.class)}>
|
|
||||||
{local.children}
|
|
||||||
</thead>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TableBody(props: JSX.HTMLAttributes<HTMLTableSectionElement>) {
|
|
||||||
const [local, rest] = splitProps(props, ["class", "children"]);
|
|
||||||
return (
|
|
||||||
<tbody {...rest} data-slot="table-body" class={cn(local.class)}>
|
|
||||||
{local.children}
|
|
||||||
</tbody>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TableRow(props: JSX.HTMLAttributes<HTMLTableRowElement>) {
|
|
||||||
const [local, rest] = splitProps(props, ["class", "children"]);
|
|
||||||
return (
|
|
||||||
<tr
|
|
||||||
{...rest}
|
|
||||||
data-slot="table-row"
|
|
||||||
class={cn("transition-colors hover:bg-[#f8faf9] [&:last-child_td]:border-b-0", local.class)}
|
|
||||||
>
|
|
||||||
{local.children}
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TableHead(props: JSX.ThHTMLAttributes<HTMLTableCellElement>) {
|
|
||||||
const [local, rest] = splitProps(props, ["class", "children"]);
|
|
||||||
return (
|
|
||||||
<th
|
|
||||||
{...rest}
|
|
||||||
data-slot="table-head"
|
|
||||||
class={cn(
|
|
||||||
"border-b border-line bg-canvas px-4 py-2.5 text-[11px] font-semibold uppercase text-muted",
|
|
||||||
local.class,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{local.children}
|
|
||||||
</th>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TableCell(props: JSX.TdHTMLAttributes<HTMLTableCellElement>) {
|
|
||||||
const [local, rest] = splitProps(props, ["class", "children"]);
|
|
||||||
return (
|
|
||||||
<td
|
|
||||||
{...rest}
|
|
||||||
data-slot="table-cell"
|
|
||||||
class={cn("border-b border-line px-4 py-3 align-middle", local.class)}
|
|
||||||
>
|
|
||||||
{local.children}
|
|
||||||
</td>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
import { cva, type VariantProps } from "class-variance-authority";
|
|
||||||
import { splitProps, type JSX } from "solid-js";
|
|
||||||
import { cn } from "../../lib/cn";
|
|
||||||
|
|
||||||
export const textareaVariants = cva(
|
|
||||||
"min-h-36 w-full rounded-md border border-line bg-white px-3 py-3 text-sm text-ink outline-none transition-colors focus:border-brand disabled:cursor-not-allowed disabled:opacity-45 aria-invalid:border-coral-strong",
|
|
||||||
{
|
|
||||||
variants: {
|
|
||||||
resize: {
|
|
||||||
vertical: "resize-y",
|
|
||||||
none: "resize-none",
|
|
||||||
},
|
|
||||||
typography: {
|
|
||||||
mono: "font-mono",
|
|
||||||
sans: "font-sans",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
defaultVariants: {
|
|
||||||
resize: "vertical",
|
|
||||||
typography: "mono",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
export type TextareaProps = JSX.TextareaHTMLAttributes<HTMLTextAreaElement> &
|
|
||||||
VariantProps<typeof textareaVariants>;
|
|
||||||
|
|
||||||
/** Standard multiline input with explicit resize and typography variants. */
|
|
||||||
export function Textarea(props: TextareaProps) {
|
|
||||||
const [local, rest] = splitProps(props, ["resize", "typography", "class", "children"]);
|
|
||||||
return (
|
|
||||||
<textarea
|
|
||||||
{...rest}
|
|
||||||
data-slot="textarea"
|
|
||||||
class={cn(
|
|
||||||
textareaVariants({ resize: local.resize, typography: local.typography }),
|
|
||||||
local.class,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{local.children}
|
|
||||||
</textarea>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,577 +0,0 @@
|
|||||||
import { Boxes, Search, ShieldCheck, WifiOff } from "lucide-solid";
|
|
||||||
import {
|
|
||||||
batch,
|
|
||||||
createEffect,
|
|
||||||
createMemo,
|
|
||||||
createSignal,
|
|
||||||
For,
|
|
||||||
on,
|
|
||||||
onCleanup,
|
|
||||||
onMount,
|
|
||||||
Show,
|
|
||||||
} from "solid-js";
|
|
||||||
import {
|
|
||||||
activateDeployment,
|
|
||||||
changeRuntimeState,
|
|
||||||
changeServiceState,
|
|
||||||
fetchSnapshot,
|
|
||||||
getApiBase,
|
|
||||||
invokeComponent,
|
|
||||||
publishWitPackage,
|
|
||||||
registerComponent,
|
|
||||||
saveApiBase,
|
|
||||||
type BackendSnapshot,
|
|
||||||
type InvokeResult,
|
|
||||||
} from "../lib/api";
|
|
||||||
import { serviceKey, type ConnectionState, type Service } from "../lib/model";
|
|
||||||
import { servicesFrom } from "../lib/view-state";
|
|
||||||
import { isView, PAGE_DEFINITIONS, pageDefinition, type View } from "../pages/registry";
|
|
||||||
import { Input } from "../components/ui/input";
|
|
||||||
import { pageUrl, sendLifecycle, sendState } from "../sdk/host";
|
|
||||||
import {
|
|
||||||
isPageMessage,
|
|
||||||
type HostGlobalState,
|
|
||||||
type HostSharedState,
|
|
||||||
type PageCommand,
|
|
||||||
type RuntimeAction,
|
|
||||||
} from "../sdk/protocol";
|
|
||||||
import { DialogLayer, type DialogState } from "./dialogs";
|
|
||||||
import { FrameCache, type FrameEntry } from "./frame-cache";
|
|
||||||
import { createBrowserHostTabSync, type HostTabSync } from "./tab-sync";
|
|
||||||
|
|
||||||
const KEEP_ALIVE_INACTIVE_TTL_MS = 30 * 60 * 1000;
|
|
||||||
|
|
||||||
export default function HostApp() {
|
|
||||||
// All backend ownership stays in this persistent document. Page iframes are
|
|
||||||
// disposable renderers and can only request typed commands through the SDK.
|
|
||||||
const initial = initialView();
|
|
||||||
const frameCache = new FrameCache(initial, {
|
|
||||||
inactiveTtlMs: KEEP_ALIVE_INACTIVE_TTL_MS,
|
|
||||||
shouldKeepAlive: (view) => pageDefinition(view).keepAlive,
|
|
||||||
});
|
|
||||||
const [view, setView] = createSignal(initial);
|
|
||||||
const [frames, setFrames] = createSignal(frameCache.snapshot());
|
|
||||||
const [readyViews, setReadyViews] = createSignal<ReadonlySet<View>>(new Set<View>());
|
|
||||||
const [snapshot, setSnapshot] = createSignal<BackendSnapshot | null>(null);
|
|
||||||
const [connection, setConnection] = createSignal<ConnectionState>("connecting");
|
|
||||||
const [apiBase, setApiBase] = createSignal(getApiBase());
|
|
||||||
const [query, setQuery] = createSignal("");
|
|
||||||
const [runtimeAction, setRuntimeAction] = createSignal<RuntimeAction | null>(null);
|
|
||||||
const [serviceAction, setServiceAction] = createSignal<string | null>(null);
|
|
||||||
const [deploymentAction, setDeploymentAction] = createSignal<string | null>(null);
|
|
||||||
const [dialog, setDialog] = createSignal<DialogState | null>(null);
|
|
||||||
const [dialogBusy, setDialogBusy] = createSignal(false);
|
|
||||||
const [toast, setToast] = createSignal<string | null>(null);
|
|
||||||
const frameElements = new Map<View, HTMLIFrameElement>();
|
|
||||||
let tabSync: HostTabSync | null = null;
|
|
||||||
let applyingSharedHostState = false;
|
|
||||||
let toastTimer: number | undefined;
|
|
||||||
let frameExpirationTimer: number | undefined;
|
|
||||||
let refreshGeneration = 0;
|
|
||||||
|
|
||||||
const globalState = createMemo<HostGlobalState>(() => ({
|
|
||||||
snapshot: snapshot(),
|
|
||||||
connection: connection(),
|
|
||||||
apiBase: apiBase(),
|
|
||||||
runtimeAction: runtimeAction(),
|
|
||||||
serviceAction: serviceAction(),
|
|
||||||
deploymentAction: deploymentAction(),
|
|
||||||
}));
|
|
||||||
const state = createMemo<HostSharedState>(() => ({
|
|
||||||
...globalState(),
|
|
||||||
view: view(),
|
|
||||||
query: query(),
|
|
||||||
}));
|
|
||||||
const activeServices = createMemo(
|
|
||||||
() => servicesFrom(state()).filter((service) => service.status === "running").length,
|
|
||||||
);
|
|
||||||
const frameReady = createMemo(() => readyViews().has(view()));
|
|
||||||
const title = createMemo(() => pageDefinition(view()).label);
|
|
||||||
|
|
||||||
function stateFor(targetView: View): HostSharedState {
|
|
||||||
return { ...state(), view: targetView };
|
|
||||||
}
|
|
||||||
|
|
||||||
function applySharedHostState(next: HostGlobalState) {
|
|
||||||
applyingSharedHostState = true;
|
|
||||||
refreshGeneration += 1;
|
|
||||||
batch(() => {
|
|
||||||
setSnapshot(next.snapshot);
|
|
||||||
setConnection(next.connection);
|
|
||||||
setApiBase(next.apiBase);
|
|
||||||
setRuntimeAction(next.runtimeAction);
|
|
||||||
setServiceAction(next.serviceAction);
|
|
||||||
setDeploymentAction(next.deploymentAction);
|
|
||||||
});
|
|
||||||
applyingSharedHostState = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function refresh() {
|
|
||||||
const generation = ++refreshGeneration;
|
|
||||||
try {
|
|
||||||
const next = await fetchSnapshot(apiBase());
|
|
||||||
if (generation !== refreshGeneration) return;
|
|
||||||
batch(() => {
|
|
||||||
setSnapshot(next);
|
|
||||||
setConnection("online");
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
if (generation !== refreshGeneration) return;
|
|
||||||
batch(() => {
|
|
||||||
setSnapshot(null);
|
|
||||||
setConnection("offline");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function notify(message: string) {
|
|
||||||
if (toastTimer !== undefined) window.clearTimeout(toastTimer);
|
|
||||||
setToast(message);
|
|
||||||
toastTimer = window.setTimeout(() => setToast(null), 2800);
|
|
||||||
}
|
|
||||||
|
|
||||||
function navigate(next: View, pushHistory = true) {
|
|
||||||
if (next === view()) return;
|
|
||||||
|
|
||||||
const previous = view();
|
|
||||||
const transition = frameCache.activate(next);
|
|
||||||
const removedViews = new Set(transition.removed.map((entry) => entry.view));
|
|
||||||
|
|
||||||
// Retained frames are paused before being hidden. Transient and expired
|
|
||||||
// frames receive dispose before Solid removes their document from the DOM.
|
|
||||||
if (!removedViews.has(previous)) sendFrameLifecycle(previous, "deactivate");
|
|
||||||
for (const entry of transition.removed) sendFrameLifecycle(entry.view, "dispose");
|
|
||||||
|
|
||||||
setReadyViews((current) => {
|
|
||||||
const remaining = new Set(current);
|
|
||||||
for (const removed of removedViews) remaining.delete(removed);
|
|
||||||
return remaining;
|
|
||||||
});
|
|
||||||
setFrames(transition.entries);
|
|
||||||
setView(next);
|
|
||||||
setQuery("");
|
|
||||||
|
|
||||||
if (readyViews().has(next)) {
|
|
||||||
sendFrameState(next);
|
|
||||||
sendFrameLifecycle(next, "activate");
|
|
||||||
}
|
|
||||||
if (pushHistory) {
|
|
||||||
const url = new URL(window.location.href);
|
|
||||||
url.searchParams.set("view", next);
|
|
||||||
window.history.pushState({ view: next }, "", url);
|
|
||||||
}
|
|
||||||
scheduleFrameExpiration();
|
|
||||||
}
|
|
||||||
|
|
||||||
function findService(key: string): Service | null {
|
|
||||||
return servicesFrom(state()).find((service) => serviceKey(service) === key) ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function performRuntimeAction(action: RuntimeAction) {
|
|
||||||
setRuntimeAction(action);
|
|
||||||
try {
|
|
||||||
await changeRuntimeState(apiBase(), action);
|
|
||||||
await refresh();
|
|
||||||
notify(
|
|
||||||
action === "start"
|
|
||||||
? "Runtime 已启动"
|
|
||||||
: action === "stop"
|
|
||||||
? "Runtime 已停止"
|
|
||||||
: "Runtime 已重启",
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
notify(error instanceof Error ? error.message : "Runtime 操作失败");
|
|
||||||
} finally {
|
|
||||||
setRuntimeAction(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function performServiceAction(key: string, action: "start" | "stop" | "restart") {
|
|
||||||
const service = findService(key);
|
|
||||||
if (!service) return;
|
|
||||||
setServiceAction(key);
|
|
||||||
try {
|
|
||||||
await changeServiceState(apiBase(), service, action);
|
|
||||||
await refresh();
|
|
||||||
notify(
|
|
||||||
`${service.id} ${action === "stop" ? "已停止" : action === "restart" ? "已重启" : "已启动"}`,
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
notify(error instanceof Error ? error.message : "Actor 操作失败");
|
|
||||||
} finally {
|
|
||||||
setServiceAction(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleCommand(command: PageCommand) {
|
|
||||||
switch (command.type) {
|
|
||||||
case "refresh":
|
|
||||||
await refresh();
|
|
||||||
return;
|
|
||||||
case "navigate":
|
|
||||||
navigate(command.view);
|
|
||||||
return;
|
|
||||||
case "open-register":
|
|
||||||
setDialog({ type: "register" });
|
|
||||||
return;
|
|
||||||
case "open-wit-publish":
|
|
||||||
setDialog({ type: "wit-publish" });
|
|
||||||
return;
|
|
||||||
case "open-invoke": {
|
|
||||||
const service = findService(command.serviceKey);
|
|
||||||
if (service) setDialog({ type: "invoke", service });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
case "open-activate": {
|
|
||||||
const service = findService(command.serviceKey);
|
|
||||||
if (!service) return;
|
|
||||||
const currentRevision =
|
|
||||||
snapshot()?.deployments.find((item) => item.service_id === service.id)?.active_revision ??
|
|
||||||
null;
|
|
||||||
setDialog({ type: "activate", service, currentRevision });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
case "runtime-action":
|
|
||||||
await performRuntimeAction(command.action);
|
|
||||||
return;
|
|
||||||
case "service-action":
|
|
||||||
await performServiceAction(command.serviceKey, command.action);
|
|
||||||
return;
|
|
||||||
case "save-api-base":
|
|
||||||
try {
|
|
||||||
const next = saveApiBase(command.value);
|
|
||||||
setApiBase(next);
|
|
||||||
setSnapshot(null);
|
|
||||||
setConnection("connecting");
|
|
||||||
await refresh();
|
|
||||||
notify("API 地址已保存");
|
|
||||||
} catch (error) {
|
|
||||||
notify(error instanceof Error ? error.message : "API 地址无效");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const receive = (event: MessageEvent<unknown>) => {
|
|
||||||
if (event.origin !== window.location.origin || !isPageMessage(event.data)) return;
|
|
||||||
const sourceView = viewForSource(event.source);
|
|
||||||
if (!sourceView || event.data.view !== sourceView) return;
|
|
||||||
|
|
||||||
if (event.data.type === "ready") {
|
|
||||||
setReadyViews((current) => new Set(current).add(sourceView));
|
|
||||||
sendFrameState(sourceView);
|
|
||||||
sendFrameLifecycle(sourceView, sourceView === view() ? "activate" : "deactivate");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Hidden documents cannot initiate management operations. This matters for
|
|
||||||
// page-owned timers or libraries that may finish work after deactivation.
|
|
||||||
if (sourceView !== view()) return;
|
|
||||||
void handleCommand(event.data.command);
|
|
||||||
};
|
|
||||||
|
|
||||||
function viewForSource(source: MessageEventSource | null): View | null {
|
|
||||||
for (const [frameView, element] of frameElements) {
|
|
||||||
if (source === element.contentWindow) return frameView;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function sendFrameState(targetView: View) {
|
|
||||||
const target = frameElements.get(targetView)?.contentWindow;
|
|
||||||
if (target) sendState(target, stateFor(targetView));
|
|
||||||
}
|
|
||||||
|
|
||||||
function sendFrameLifecycle(targetView: View, phase: "activate" | "deactivate" | "dispose") {
|
|
||||||
const target = frameElements.get(targetView)?.contentWindow;
|
|
||||||
if (target) sendLifecycle(target, phase);
|
|
||||||
}
|
|
||||||
|
|
||||||
function scheduleFrameExpiration() {
|
|
||||||
if (frameExpirationTimer !== undefined) {
|
|
||||||
window.clearTimeout(frameExpirationTimer);
|
|
||||||
frameExpirationTimer = undefined;
|
|
||||||
}
|
|
||||||
const delay = frameCache.timeUntilExpiration();
|
|
||||||
if (delay === null) return;
|
|
||||||
frameExpirationTimer = window.setTimeout(expireInactiveFrames, delay);
|
|
||||||
}
|
|
||||||
|
|
||||||
function expireInactiveFrames() {
|
|
||||||
frameExpirationTimer = undefined;
|
|
||||||
const expiration = frameCache.expireInactive();
|
|
||||||
if (expiration.removed.length > 0) {
|
|
||||||
const removedViews = new Set(expiration.removed.map((entry) => entry.view));
|
|
||||||
for (const entry of expiration.removed) sendFrameLifecycle(entry.view, "dispose");
|
|
||||||
setReadyViews((current) => {
|
|
||||||
const remaining = new Set(current);
|
|
||||||
for (const removed of removedViews) remaining.delete(removed);
|
|
||||||
return remaining;
|
|
||||||
});
|
|
||||||
setFrames(expiration.entries);
|
|
||||||
}
|
|
||||||
scheduleFrameExpiration();
|
|
||||||
}
|
|
||||||
|
|
||||||
const popState = () => {
|
|
||||||
const requested = new URLSearchParams(window.location.search).get("view");
|
|
||||||
navigate(isView(requested) ? requested : "overview", false);
|
|
||||||
};
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
window.addEventListener("message", receive);
|
|
||||||
window.addEventListener("popstate", popState);
|
|
||||||
tabSync = createBrowserHostTabSync({
|
|
||||||
getState: globalState,
|
|
||||||
applyState: applySharedHostState,
|
|
||||||
});
|
|
||||||
void refresh();
|
|
||||||
const interval = window.setInterval(() => void refresh(), 5000);
|
|
||||||
onCleanup(() => window.clearInterval(interval));
|
|
||||||
});
|
|
||||||
|
|
||||||
onCleanup(() => {
|
|
||||||
window.removeEventListener("message", receive);
|
|
||||||
window.removeEventListener("popstate", popState);
|
|
||||||
tabSync?.close();
|
|
||||||
if (toastTimer !== undefined) window.clearTimeout(toastTimer);
|
|
||||||
if (frameExpirationTimer !== undefined) window.clearTimeout(frameExpirationTimer);
|
|
||||||
for (const frameView of frameElements.keys()) sendFrameLifecycle(frameView, "dispose");
|
|
||||||
});
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
state();
|
|
||||||
const ready = readyViews();
|
|
||||||
for (const frame of frames()) {
|
|
||||||
if (ready.has(frame.view)) sendFrameState(frame.view);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
createEffect(
|
|
||||||
on(
|
|
||||||
globalState,
|
|
||||||
(next) => {
|
|
||||||
if (!applyingSharedHostState) tabSync?.publish(next);
|
|
||||||
},
|
|
||||||
{ defer: true },
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
async function register(file: File) {
|
|
||||||
setDialogBusy(true);
|
|
||||||
try {
|
|
||||||
const registered = await registerComponent(apiBase(), file);
|
|
||||||
await refresh();
|
|
||||||
setDialog(null);
|
|
||||||
notify(`${registered.id}@${registered.revision} 已注册`);
|
|
||||||
} finally {
|
|
||||||
setDialogBusy(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function publishWit(file: File) {
|
|
||||||
setDialogBusy(true);
|
|
||||||
try {
|
|
||||||
const published = await publishWitPackage(apiBase(), file);
|
|
||||||
await refresh();
|
|
||||||
setDialog(null);
|
|
||||||
notify(`${published.name}@${published.version} 已发布`);
|
|
||||||
} finally {
|
|
||||||
setDialogBusy(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function activate(service: Service) {
|
|
||||||
setDialogBusy(true);
|
|
||||||
setDeploymentAction(serviceKey(service));
|
|
||||||
try {
|
|
||||||
await activateDeployment(apiBase(), service);
|
|
||||||
await refresh();
|
|
||||||
setDialog(null);
|
|
||||||
notify(`${service.id}@${service.revision} 已设为对外版本`);
|
|
||||||
} finally {
|
|
||||||
setDialogBusy(false);
|
|
||||||
setDeploymentAction(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function invoke(service: Service, input: Uint8Array): Promise<InvokeResult> {
|
|
||||||
const result = await invokeComponent(apiBase(), service, input);
|
|
||||||
await refresh();
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div class="grid h-dvh min-w-0 grid-cols-[minmax(0,1fr)] grid-rows-[auto_56px_minmax(0,1fr)_30px] overflow-hidden bg-canvas lg:grid-cols-[232px_minmax(0,1fr)] lg:grid-rows-[64px_minmax(0,1fr)_30px]">
|
|
||||||
<aside class="border-line min-w-0 bg-[#16221f] text-white lg:row-span-3 lg:flex lg:min-h-0 lg:flex-col lg:border-r">
|
|
||||||
<div class="flex h-16 shrink-0 items-center gap-3 px-4 lg:px-5">
|
|
||||||
<span class="flex size-9 items-center justify-center rounded-md bg-[#e5f4ef] text-brand-strong">
|
|
||||||
<Boxes size={19} />
|
|
||||||
</span>
|
|
||||||
<div>
|
|
||||||
<strong class="block text-[15px]">Wasmeld</strong>
|
|
||||||
<span class="block text-[10px] text-[#9ab0aa]">COMPONENT RUNTIME</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<nav class="flex gap-1 overflow-x-auto px-3 pb-3 lg:block lg:min-h-0 lg:flex-1 lg:space-y-1 lg:overflow-y-auto lg:pb-0">
|
|
||||||
<For each={PAGE_DEFINITIONS}>
|
|
||||||
{(item) => {
|
|
||||||
const Icon = item.icon;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
class="flex h-10 shrink-0 items-center gap-3 rounded-md px-3 text-sm text-[#b8c7c3] transition-colors hover:bg-white/7 hover:text-white lg:w-full"
|
|
||||||
classList={{ "bg-white/10 text-white": view() === item.id }}
|
|
||||||
type="button"
|
|
||||||
onClick={() => navigate(item.id)}
|
|
||||||
>
|
|
||||||
<Icon size={16} />
|
|
||||||
{item.label}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
</For>
|
|
||||||
</nav>
|
|
||||||
<div class="hidden border-t border-white/10 p-4 lg:block">
|
|
||||||
<div class="flex items-center justify-between text-xs">
|
|
||||||
<span class="text-[#9ab0aa]">连接状态</span>
|
|
||||||
<span
|
|
||||||
classList={{
|
|
||||||
"font-semibold text-[#76d5b4]": connection() === "online",
|
|
||||||
"font-semibold text-[#ffac93]": connection() === "offline",
|
|
||||||
"font-semibold text-[#f5cf7a]": connection() === "connecting",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{connection() === "online" ? "在线" : connection() === "offline" ? "离线" : "连接中"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="mt-3 flex items-center justify-between text-xs">
|
|
||||||
<span class="text-[#9ab0aa]">活跃 Actor</span>
|
|
||||||
<strong>{activeServices()}</strong>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<header class="border-line col-start-1 flex min-w-0 items-center justify-between gap-3 border-b bg-white px-4 lg:col-start-2 lg:px-6">
|
|
||||||
<div class="min-w-0">
|
|
||||||
<span class="hidden text-xs text-muted sm:inline">运行平台 / </span>
|
|
||||||
<strong class="text-sm">{title()}</strong>
|
|
||||||
</div>
|
|
||||||
<label class="relative w-full max-w-80">
|
|
||||||
<Search
|
|
||||||
class="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-muted"
|
|
||||||
size={15}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
density="compact"
|
|
||||||
class="pl-9"
|
|
||||||
value={query()}
|
|
||||||
aria-label="搜索"
|
|
||||||
placeholder="搜索服务、制品或事件"
|
|
||||||
onInput={(event) => setQuery(event.currentTarget.value)}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<section class="relative col-start-1 min-h-0 min-w-0 overflow-hidden lg:col-start-2">
|
|
||||||
<For each={frames()}>
|
|
||||||
{(frame) => (
|
|
||||||
<PageFrame
|
|
||||||
frame={frame}
|
|
||||||
active={view() === frame.view}
|
|
||||||
title={pageDefinition(frame.view).label}
|
|
||||||
register={(frameView, element) => frameElements.set(frameView, element)}
|
|
||||||
unregister={(frameView, element) => {
|
|
||||||
if (frameElements.get(frameView) === element) frameElements.delete(frameView);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
<Show when={!frameReady()}>
|
|
||||||
<div class="pointer-events-none absolute inset-0 flex items-center justify-center bg-canvas">
|
|
||||||
<div class="flex flex-col items-center gap-3 text-sm text-muted">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span class="size-2 animate-pulse rounded-full bg-brand" />
|
|
||||||
正在加载 {title()}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="h-1 w-40 overflow-hidden rounded-sm bg-line"
|
|
||||||
role="progressbar"
|
|
||||||
aria-label={`${title()}加载进度`}
|
|
||||||
>
|
|
||||||
<span class="block h-full w-[36%] animate-[loading-progress-swing_1s_ease-in-out_infinite_alternate] rounded-sm bg-brand motion-reduce:translate-x-[89%] motion-reduce:animate-none" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
<Show when={connection() === "offline"}>
|
|
||||||
<div class="absolute bottom-4 left-1/2 flex -translate-x-1/2 items-center gap-2 rounded-md border border-coral-strong/20 bg-coral-soft px-4 py-2 text-xs font-semibold text-coral-strong shadow-lg">
|
|
||||||
<WifiOff size={14} />
|
|
||||||
管理 API 不可用
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<footer class="border-line col-start-1 flex items-center justify-between border-t bg-white px-4 text-[10px] text-muted lg:col-start-2 lg:px-6">
|
|
||||||
<span class="inline-flex items-center gap-1.5">
|
|
||||||
<ShieldCheck size={12} />
|
|
||||||
{snapshot()?.runtime.status === "running" ? "Sandbox 正常" : "Runtime 已停止"}
|
|
||||||
</span>
|
|
||||||
<span>Component Model · WASI P2</span>
|
|
||||||
</footer>
|
|
||||||
|
|
||||||
<Show when={dialog()}>
|
|
||||||
{(activeDialog) => (
|
|
||||||
<DialogLayer
|
|
||||||
dialog={activeDialog()}
|
|
||||||
busy={dialogBusy()}
|
|
||||||
onClose={() => !dialogBusy() && setDialog(null)}
|
|
||||||
onRegister={register}
|
|
||||||
onPublishWit={publishWit}
|
|
||||||
onActivate={activate}
|
|
||||||
onInvoke={invoke}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<Show when={toast()}>
|
|
||||||
{(message) => (
|
|
||||||
<div class="fixed bottom-12 right-5 z-70 max-w-sm rounded-md bg-[#16221f] px-4 py-3 text-sm text-white shadow-xl">
|
|
||||||
{message()}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function initialView(): View {
|
|
||||||
const requested = new URLSearchParams(window.location.search).get("view");
|
|
||||||
return isView(requested) ? requested : "overview";
|
|
||||||
}
|
|
||||||
|
|
||||||
function PageFrame(props: {
|
|
||||||
frame: FrameEntry;
|
|
||||||
active: boolean;
|
|
||||||
title: string;
|
|
||||||
register: (view: View, element: HTMLIFrameElement) => void;
|
|
||||||
unregister: (view: View, element: HTMLIFrameElement) => void;
|
|
||||||
}) {
|
|
||||||
let element: HTMLIFrameElement | undefined;
|
|
||||||
|
|
||||||
onCleanup(() => {
|
|
||||||
if (element) props.unregister(props.frame.view, element);
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<iframe
|
|
||||||
ref={(next) => {
|
|
||||||
element = next;
|
|
||||||
props.register(props.frame.view, next);
|
|
||||||
}}
|
|
||||||
class="size-full border-0 bg-canvas"
|
|
||||||
classList={{ hidden: !props.active }}
|
|
||||||
src={pageUrl(props.frame.view, props.frame.instance)}
|
|
||||||
title={props.title}
|
|
||||||
aria-hidden={!props.active}
|
|
||||||
tabIndex={props.active ? 0 : -1}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,368 +0,0 @@
|
|||||||
import {
|
|
||||||
ArrowRight,
|
|
||||||
Clock3,
|
|
||||||
CloudUpload,
|
|
||||||
FileCode2,
|
|
||||||
Package,
|
|
||||||
Play,
|
|
||||||
RadioTower,
|
|
||||||
RefreshCw,
|
|
||||||
ShieldCheck,
|
|
||||||
SquareTerminal,
|
|
||||||
X,
|
|
||||||
} from "lucide-solid";
|
|
||||||
import { createMemo, createSignal, onCleanup, onMount, Show, type JSXElement } from "solid-js";
|
|
||||||
import { Button, IconButton } from "../components/ui/button";
|
|
||||||
import { Field, FieldLabel } from "../components/ui/field";
|
|
||||||
import { Segment, SegmentedControl } from "../components/ui/segmented-control";
|
|
||||||
import { Textarea } from "../components/ui/textarea";
|
|
||||||
import type { InvokeResult } from "../lib/api";
|
|
||||||
import {
|
|
||||||
formatInvocationOutput,
|
|
||||||
invocationInputSize,
|
|
||||||
parseInvocationInput,
|
|
||||||
type Service,
|
|
||||||
} from "../lib/model";
|
|
||||||
|
|
||||||
export type DialogState =
|
|
||||||
| { type: "register" }
|
|
||||||
| { type: "wit-publish" }
|
|
||||||
| { type: "invoke"; service: Service }
|
|
||||||
| { type: "activate"; service: Service; currentRevision: string | null };
|
|
||||||
|
|
||||||
export function DialogLayer(props: {
|
|
||||||
dialog: DialogState;
|
|
||||||
busy: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
onRegister: (file: File) => Promise<void>;
|
|
||||||
onPublishWit: (file: File) => Promise<void>;
|
|
||||||
onInvoke: (service: Service, input: Uint8Array) => Promise<InvokeResult>;
|
|
||||||
onActivate: (service: Service) => Promise<void>;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Show when={props.dialog.type === "register"}>
|
|
||||||
<UploadDialog
|
|
||||||
kind="component"
|
|
||||||
busy={props.busy}
|
|
||||||
onClose={props.onClose}
|
|
||||||
onSubmit={props.onRegister}
|
|
||||||
/>
|
|
||||||
</Show>
|
|
||||||
<Show when={props.dialog.type === "wit-publish"}>
|
|
||||||
<UploadDialog
|
|
||||||
kind="wit"
|
|
||||||
busy={props.busy}
|
|
||||||
onClose={props.onClose}
|
|
||||||
onSubmit={props.onPublishWit}
|
|
||||||
/>
|
|
||||||
</Show>
|
|
||||||
<Show when={props.dialog.type === "invoke" && "service" in props.dialog}>
|
|
||||||
<InvokeDialog
|
|
||||||
service={(props.dialog as Extract<DialogState, { type: "invoke" }>).service}
|
|
||||||
onClose={props.onClose}
|
|
||||||
onInvoke={props.onInvoke}
|
|
||||||
/>
|
|
||||||
</Show>
|
|
||||||
<Show when={props.dialog.type === "activate" && "service" in props.dialog}>
|
|
||||||
<ActivateDialog
|
|
||||||
dialog={props.dialog as Extract<DialogState, { type: "activate" }>}
|
|
||||||
busy={props.busy}
|
|
||||||
onClose={props.onClose}
|
|
||||||
onActivate={props.onActivate}
|
|
||||||
/>
|
|
||||||
</Show>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function DialogFrame(props: {
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
icon: JSXElement;
|
|
||||||
closeDisabled?: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
children: JSXElement;
|
|
||||||
footer: JSXElement;
|
|
||||||
}) {
|
|
||||||
const onKeyDown = (event: KeyboardEvent) => {
|
|
||||||
if (event.key === "Escape" && !props.closeDisabled) props.onClose();
|
|
||||||
};
|
|
||||||
onMount(() => window.addEventListener("keydown", onKeyDown));
|
|
||||||
onCleanup(() => window.removeEventListener("keydown", onKeyDown));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
class="fixed inset-0 z-50 flex items-center justify-center bg-[#0d1715]/55 p-3 backdrop-blur-[1px]"
|
|
||||||
role="presentation"
|
|
||||||
>
|
|
||||||
<dialog
|
|
||||||
open
|
|
||||||
class="relative m-0 max-h-[calc(100dvh-24px)] w-full max-w-3xl overflow-auto rounded-md border border-line bg-white p-0 shadow-2xl"
|
|
||||||
aria-label={props.title}
|
|
||||||
>
|
|
||||||
<header class="flex items-start justify-between gap-4 border-b border-line px-5 py-4 sm:px-7 sm:py-5">
|
|
||||||
<div class="flex min-w-0 items-start gap-3">
|
|
||||||
<span class="flex size-10 shrink-0 items-center justify-center rounded-md bg-brand-soft text-brand">
|
|
||||||
{props.icon}
|
|
||||||
</span>
|
|
||||||
<div>
|
|
||||||
<h2 class="text-lg font-bold sm:text-xl">{props.title}</h2>
|
|
||||||
<p class="mt-1 text-sm text-muted">{props.description}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<IconButton
|
|
||||||
aria-label="关闭"
|
|
||||||
title="关闭"
|
|
||||||
disabled={props.closeDisabled}
|
|
||||||
onClick={props.onClose}
|
|
||||||
>
|
|
||||||
<X size={18} />
|
|
||||||
</IconButton>
|
|
||||||
</header>
|
|
||||||
<div class="px-5 py-5 sm:px-7">{props.children}</div>
|
|
||||||
<footer class="flex items-center justify-end gap-2 border-t border-line bg-[#fafbfb] px-5 py-4 sm:px-7">
|
|
||||||
{props.footer}
|
|
||||||
</footer>
|
|
||||||
</dialog>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function UploadDialog(props: {
|
|
||||||
kind: "component" | "wit";
|
|
||||||
busy: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
onSubmit: (file: File) => Promise<void>;
|
|
||||||
}) {
|
|
||||||
const [file, setFile] = createSignal<File | null>(null);
|
|
||||||
const [error, setError] = createSignal("");
|
|
||||||
const isWit = () => props.kind === "wit";
|
|
||||||
const title = () => (isWit() ? "发布 WIT 包" : "注册组件包");
|
|
||||||
|
|
||||||
async function submit() {
|
|
||||||
const selected = file();
|
|
||||||
if (!selected || props.busy) return;
|
|
||||||
const extension = isWit() ? ".wasm" : ".wasmpkg";
|
|
||||||
const limit = isWit() ? 4 * 1024 * 1024 : 64 * 1024 * 1024;
|
|
||||||
if (!selected.name.toLowerCase().endsWith(extension)) {
|
|
||||||
setError(`请选择 ${extension} 文件`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (selected.size > limit) {
|
|
||||||
setError(`文件不能超过 ${isWit() ? "4 MB" : "64 MB"}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setError("");
|
|
||||||
await props.onSubmit(selected).catch((submitError: unknown) => {
|
|
||||||
setError(submitError instanceof Error ? submitError.message : `${title()}失败`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DialogFrame
|
|
||||||
title={title()}
|
|
||||||
description={
|
|
||||||
isWit()
|
|
||||||
? "包名、版本和直接依赖从二进制 WIT Package 读取。"
|
|
||||||
: "组件身份、WIT World 和运行边界由平台自动校验。"
|
|
||||||
}
|
|
||||||
icon={isWit() ? <FileCode2 size={19} /> : <CloudUpload size={19} />}
|
|
||||||
closeDisabled={props.busy}
|
|
||||||
onClose={props.onClose}
|
|
||||||
footer={
|
|
||||||
<>
|
|
||||||
<Button disabled={props.busy} onClick={props.onClose}>
|
|
||||||
取消
|
|
||||||
</Button>
|
|
||||||
<Button variant="primary" disabled={props.busy || !file()} onClick={() => void submit()}>
|
|
||||||
{props.busy ? <RefreshCw class="animate-spin" size={16} /> : <ShieldCheck size={16} />}
|
|
||||||
{props.busy ? "正在校验" : isWit() ? "校验并发布" : "校验并注册"}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<label class="flex min-h-48 cursor-pointer flex-col items-center justify-center gap-2 rounded-md border border-dashed border-line bg-[#fbfcfc] px-5 text-center text-muted transition-colors hover:border-brand hover:bg-brand-soft">
|
|
||||||
<input
|
|
||||||
class="sr-only"
|
|
||||||
type="file"
|
|
||||||
accept={isWit() ? ".wasm,application/wasm" : ".wasmpkg,application/zip"}
|
|
||||||
onChange={(event) => {
|
|
||||||
setFile(event.currentTarget.files?.[0] ?? null);
|
|
||||||
setError("");
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{file() ? (
|
|
||||||
isWit() ? (
|
|
||||||
<FileCode2 size={28} />
|
|
||||||
) : (
|
|
||||||
<Package size={28} />
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
<CloudUpload size={28} />
|
|
||||||
)}
|
|
||||||
<strong class="text-sm text-ink">
|
|
||||||
{file()?.name ?? `选择 ${isWit() ? "WIT Package" : "组件包"}`}
|
|
||||||
</strong>
|
|
||||||
<span class="text-xs">
|
|
||||||
{file()
|
|
||||||
? `${(file()!.size / 1024).toFixed(1)} KB · 等待校验`
|
|
||||||
: `${isWit() ? ".wasm · 最大 4 MB" : ".wasmpkg · 最大 64 MB"}`}
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<Show when={error()}>
|
|
||||||
<div class="mt-3 rounded-md border border-coral-strong/30 bg-coral-soft px-3 py-2 text-sm text-coral-strong">
|
|
||||||
{error()}
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
</DialogFrame>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ActivateDialog(props: {
|
|
||||||
dialog: Extract<DialogState, { type: "activate" }>;
|
|
||||||
busy: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
onActivate: (service: Service) => Promise<void>;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<DialogFrame
|
|
||||||
title="切换对外版本"
|
|
||||||
description="更新该服务在 Gateway 上接收请求的活动版本。"
|
|
||||||
icon={<RadioTower size={19} />}
|
|
||||||
closeDisabled={props.busy}
|
|
||||||
onClose={props.onClose}
|
|
||||||
footer={
|
|
||||||
<>
|
|
||||||
<Button disabled={props.busy} onClick={props.onClose}>
|
|
||||||
取消
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
disabled={props.busy}
|
|
||||||
onClick={() => void props.onActivate(props.dialog.service)}
|
|
||||||
>
|
|
||||||
{props.busy ? <RefreshCw class="animate-spin" size={16} /> : <RadioTower size={16} />}
|
|
||||||
{props.busy ? "正在切换" : "确认切换"}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div class="grid grid-cols-[1fr_auto_1fr] items-center gap-3 rounded-md border border-line bg-canvas p-4">
|
|
||||||
<div>
|
|
||||||
<span class="text-xs text-muted">当前版本</span>
|
|
||||||
<strong class="mt-1 block font-mono text-xs">
|
|
||||||
{props.dialog.currentRevision ?? "尚未部署"}
|
|
||||||
</strong>
|
|
||||||
</div>
|
|
||||||
<ArrowRight size={18} class="text-muted" />
|
|
||||||
<div>
|
|
||||||
<span class="text-xs text-muted">目标版本</span>
|
|
||||||
<strong class="mt-1 block font-mono text-xs text-brand">
|
|
||||||
{props.dialog.service.revision}
|
|
||||||
</strong>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p class="mt-4 text-sm leading-6 text-muted">
|
|
||||||
Wasmeld 将先确认 {props.dialog.service.id}@{props.dialog.service.revision} 的 Actor
|
|
||||||
可用,再更新对外路由。
|
|
||||||
</p>
|
|
||||||
</DialogFrame>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function InvokeDialog(props: {
|
|
||||||
service: Service;
|
|
||||||
onClose: () => void;
|
|
||||||
onInvoke: (service: Service, input: Uint8Array) => Promise<InvokeResult>;
|
|
||||||
}) {
|
|
||||||
const [input, setInput] = createSignal(props.service.id === "echo" ? "hello wasm" : "");
|
|
||||||
const [format, setFormat] = createSignal<"utf8" | "hex">("utf8");
|
|
||||||
const [output, setOutput] = createSignal("");
|
|
||||||
const [outputMeta, setOutputMeta] = createSignal("");
|
|
||||||
const [running, setRunning] = createSignal(false);
|
|
||||||
const [failed, setFailed] = createSignal(false);
|
|
||||||
const inputBytes = createMemo(() => invocationInputSize(input(), format()));
|
|
||||||
|
|
||||||
async function invoke() {
|
|
||||||
setRunning(true);
|
|
||||||
setOutput("");
|
|
||||||
setOutputMeta("");
|
|
||||||
setFailed(false);
|
|
||||||
try {
|
|
||||||
const result = await props.onInvoke(props.service, parseInvocationInput(input(), format()));
|
|
||||||
const formatted = formatInvocationOutput(props.service, result.output, format());
|
|
||||||
setOutput(formatted.text);
|
|
||||||
setOutputMeta(
|
|
||||||
`${result.outputBytes} B · ${formatted.format}${formatted.automatic ? " · 自动" : ""} · ${result.latencyMs} ms`,
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
setFailed(true);
|
|
||||||
setOutput(error instanceof Error ? error.message : "组件调用失败");
|
|
||||||
} finally {
|
|
||||||
setRunning(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DialogFrame
|
|
||||||
title={`调用 ${props.service.id}@${props.service.revision}`}
|
|
||||||
description="请求将进入该实例的串行 mailbox。"
|
|
||||||
icon={<SquareTerminal size={19} />}
|
|
||||||
closeDisabled={running()}
|
|
||||||
onClose={props.onClose}
|
|
||||||
footer={
|
|
||||||
<>
|
|
||||||
<Button disabled={running()} onClick={props.onClose}>
|
|
||||||
关闭
|
|
||||||
</Button>
|
|
||||||
<Button variant="primary" disabled={running()} onClick={() => void invoke()}>
|
|
||||||
{running() ? <RefreshCw class="animate-spin" size={16} /> : <Play size={16} />}
|
|
||||||
执行调用
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div class="mb-3 flex items-center justify-between gap-3">
|
|
||||||
<SegmentedControl aria-label="输入格式">
|
|
||||||
<Segment active={format() === "utf8"} onClick={() => setFormat("utf8")}>
|
|
||||||
UTF-8
|
|
||||||
</Segment>
|
|
||||||
<Segment active={format() === "hex"} onClick={() => setFormat("hex")}>
|
|
||||||
HEX
|
|
||||||
</Segment>
|
|
||||||
</SegmentedControl>
|
|
||||||
<span class="font-mono text-xs text-muted">{inputBytes()} B</span>
|
|
||||||
</div>
|
|
||||||
<Field>
|
|
||||||
<FieldLabel>输入</FieldLabel>
|
|
||||||
<Textarea
|
|
||||||
value={input()}
|
|
||||||
spellcheck={false}
|
|
||||||
placeholder={format() === "utf8" ? "输入请求内容" : "00 ff a1"}
|
|
||||||
onInput={(event) => setInput(event.currentTarget.value)}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
<div class="mt-4">
|
|
||||||
<div class="mb-1.5 flex items-center justify-between">
|
|
||||||
<span class="text-xs font-semibold text-muted">输出</span>
|
|
||||||
<Show when={outputMeta()}>
|
|
||||||
<span class="inline-flex items-center gap-1 font-mono text-[11px] text-brand">
|
|
||||||
<Clock3 size={12} />
|
|
||||||
{outputMeta()}
|
|
||||||
</span>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
<pre
|
|
||||||
classList={{
|
|
||||||
"min-h-32 overflow-auto rounded-md border p-4 font-mono text-sm whitespace-pre-wrap": true,
|
|
||||||
"border-coral-strong/30 bg-coral-soft text-coral-strong": failed(),
|
|
||||||
"border-[#263532] bg-[#16221f] text-[#c8eee1]": !failed(),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{running() ? "invoking..." : output() || "等待调用"}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
</DialogFrame>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,163 +0,0 @@
|
|||||||
import type { View } from "../pages/registry";
|
|
||||||
|
|
||||||
export type FrameEntry = {
|
|
||||||
view: View;
|
|
||||||
instance: number;
|
|
||||||
keepAlive: boolean;
|
|
||||||
/**
|
|
||||||
* Time at which this document became hidden, using the cache clock.
|
|
||||||
*
|
|
||||||
* Active and transient documents use `null`. Measuring from deactivation
|
|
||||||
* prevents a page that has been visible for a long time from expiring as
|
|
||||||
* soon as the user navigates away.
|
|
||||||
*/
|
|
||||||
inactiveSince: number | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type FrameCacheOptions = {
|
|
||||||
/**
|
|
||||||
* How long a keep-alive document may remain continuously hidden.
|
|
||||||
*/
|
|
||||||
inactiveTtlMs: number;
|
|
||||||
shouldKeepAlive: (view: View) => boolean;
|
|
||||||
/** Injectable monotonic clock for deterministic tests. */
|
|
||||||
now?: () => number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type FrameTransition = {
|
|
||||||
entries: FrameEntry[];
|
|
||||||
removed: FrameEntry[];
|
|
||||||
activated: FrameEntry;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type FrameExpiration = Omit<FrameTransition, "activated">;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Owns iframe document identities and expires retained, hidden documents.
|
|
||||||
*
|
|
||||||
* Entries retain object identity between snapshots so Solid's `<For>` keeps
|
|
||||||
* the corresponding iframe DOM node alive. `instance` is never reused, which
|
|
||||||
* makes a recreated document observable in its URL and prevents stale browser
|
|
||||||
* history or service-worker responses from masquerading as a retained frame.
|
|
||||||
*
|
|
||||||
* Primary pages may all opt into retention because their set is finite.
|
|
||||||
* Transient/detail pages opt out and are removed immediately after navigation.
|
|
||||||
* Retained pages are released only after a continuous inactive interval, not
|
|
||||||
* because another primary page happened to be opened.
|
|
||||||
*/
|
|
||||||
export class FrameCache {
|
|
||||||
readonly #options: FrameCacheOptions;
|
|
||||||
readonly #entries = new Map<View, FrameEntry>();
|
|
||||||
#active: View;
|
|
||||||
#nextInstance = 1;
|
|
||||||
|
|
||||||
constructor(initialView: View, options: FrameCacheOptions) {
|
|
||||||
if (!Number.isFinite(options.inactiveTtlMs) || options.inactiveTtlMs <= 0) {
|
|
||||||
throw new RangeError("inactiveTtlMs must be a positive finite number");
|
|
||||||
}
|
|
||||||
this.#options = options;
|
|
||||||
this.#active = initialView;
|
|
||||||
this.#entries.set(initialView, this.#create(initialView));
|
|
||||||
}
|
|
||||||
|
|
||||||
get active(): View {
|
|
||||||
return this.#active;
|
|
||||||
}
|
|
||||||
|
|
||||||
snapshot(): FrameEntry[] {
|
|
||||||
return [...this.#entries.values()];
|
|
||||||
}
|
|
||||||
|
|
||||||
activate(view: View): FrameTransition {
|
|
||||||
const removed: FrameEntry[] = [];
|
|
||||||
const previous = this.#entries.get(this.#active);
|
|
||||||
const now = this.#now();
|
|
||||||
|
|
||||||
if (previous && previous.view !== view) {
|
|
||||||
if (previous.keepAlive) {
|
|
||||||
previous.inactiveSince = now;
|
|
||||||
} else {
|
|
||||||
this.#entries.delete(previous.view);
|
|
||||||
removed.push(previous);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prune before activation so revisiting an already-expired page creates a
|
|
||||||
// fresh iframe instead of reviving stale document state.
|
|
||||||
removed.push(...this.#removeExpired(now));
|
|
||||||
|
|
||||||
let activated = this.#entries.get(view);
|
|
||||||
if (!activated) {
|
|
||||||
activated = this.#create(view);
|
|
||||||
this.#entries.set(view, activated);
|
|
||||||
}
|
|
||||||
activated.inactiveSince = null;
|
|
||||||
this.#active = view;
|
|
||||||
|
|
||||||
return {
|
|
||||||
entries: this.snapshot(),
|
|
||||||
removed,
|
|
||||||
activated,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Removes retained documents whose continuous hidden interval has elapsed.
|
|
||||||
*
|
|
||||||
* Host calls this from a timer and sends `dispose` before applying the
|
|
||||||
* returned snapshot to the DOM.
|
|
||||||
*/
|
|
||||||
expireInactive(): FrameExpiration {
|
|
||||||
return {
|
|
||||||
removed: this.#removeExpired(this.#now()),
|
|
||||||
entries: this.snapshot(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the delay until the next hidden document expires.
|
|
||||||
*
|
|
||||||
* `null` means no retained page is currently hidden. Recomputing the delay
|
|
||||||
* after every navigation and expiration avoids one interval timer per page.
|
|
||||||
*/
|
|
||||||
timeUntilExpiration(): number | null {
|
|
||||||
const now = this.#now();
|
|
||||||
let delay: number | null = null;
|
|
||||||
for (const entry of this.#entries.values()) {
|
|
||||||
if (!entry.keepAlive || entry.inactiveSince === null) continue;
|
|
||||||
const remaining = Math.max(0, entry.inactiveSince + this.#options.inactiveTtlMs - now);
|
|
||||||
if (delay === null || remaining < delay) delay = remaining;
|
|
||||||
}
|
|
||||||
return delay;
|
|
||||||
}
|
|
||||||
|
|
||||||
#create(view: View): FrameEntry {
|
|
||||||
return {
|
|
||||||
view,
|
|
||||||
instance: this.#nextInstance++,
|
|
||||||
keepAlive: this.#options.shouldKeepAlive(view),
|
|
||||||
inactiveSince: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
#removeExpired(now: number): FrameEntry[] {
|
|
||||||
const removed: FrameEntry[] = [];
|
|
||||||
for (const entry of this.#entries.values()) {
|
|
||||||
if (
|
|
||||||
entry.view === this.#active ||
|
|
||||||
!entry.keepAlive ||
|
|
||||||
entry.inactiveSince === null ||
|
|
||||||
now - entry.inactiveSince < this.#options.inactiveTtlMs
|
|
||||||
) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
this.#entries.delete(entry.view);
|
|
||||||
removed.push(entry);
|
|
||||||
}
|
|
||||||
return removed;
|
|
||||||
}
|
|
||||||
|
|
||||||
#now(): number {
|
|
||||||
return this.#options.now?.() ?? performance.now();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
import { render } from "solid-js/web";
|
|
||||||
import "../styles/app.css";
|
|
||||||
import HostApp from "./app";
|
|
||||||
|
|
||||||
const root = document.getElementById("app");
|
|
||||||
if (!root) throw new Error("missing #app root");
|
|
||||||
render(() => <HostApp />, root);
|
|
||||||
|
|
||||||
if (import.meta.env.PROD && "serviceWorker" in navigator) {
|
|
||||||
window.addEventListener("load", () => {
|
|
||||||
void navigator.serviceWorker.register("/sw.js");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,152 +0,0 @@
|
|||||||
import type { HostGlobalState, RuntimeAction } from "../sdk/protocol";
|
|
||||||
|
|
||||||
const HOST_TAB_CHANNEL = "wasmeld-console:host-state";
|
|
||||||
const HOST_TAB_PROTOCOL_VERSION = 1;
|
|
||||||
|
|
||||||
type HostTabMessage =
|
|
||||||
| {
|
|
||||||
channel: typeof HOST_TAB_CHANNEL;
|
|
||||||
version: typeof HOST_TAB_PROTOCOL_VERSION;
|
|
||||||
type: "hello";
|
|
||||||
sender: string;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
channel: typeof HOST_TAB_CHANNEL;
|
|
||||||
version: typeof HOST_TAB_PROTOCOL_VERSION;
|
|
||||||
type: "state";
|
|
||||||
sender: string;
|
|
||||||
sequence: number;
|
|
||||||
state: HostGlobalState;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type TabChannel = {
|
|
||||||
postMessage: (message: unknown) => void;
|
|
||||||
addEventListener: (type: "message", listener: (event: MessageEvent<unknown>) => void) => void;
|
|
||||||
removeEventListener: (type: "message", listener: (event: MessageEvent<unknown>) => void) => void;
|
|
||||||
close: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type HostTabSyncOptions = {
|
|
||||||
getState: () => HostGlobalState;
|
|
||||||
applyState: (state: HostGlobalState) => void;
|
|
||||||
channel?: TabChannel;
|
|
||||||
tabId?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Replicates Host-owned control-plane state between same-origin browser tabs.
|
|
||||||
*
|
|
||||||
* Every tab may publish; there is deliberately no leader election. Sequence
|
|
||||||
* numbers are scoped to a sender and suppress stale delivery, while consumers
|
|
||||||
* suppress their own Solid broadcast effect when applying a remote snapshot.
|
|
||||||
* Page-local state never enters this channel.
|
|
||||||
*/
|
|
||||||
export class HostTabSync {
|
|
||||||
readonly #channel: TabChannel;
|
|
||||||
readonly #tabId: string;
|
|
||||||
readonly #getState: () => HostGlobalState;
|
|
||||||
readonly #applyState: (state: HostGlobalState) => void;
|
|
||||||
readonly #latestSequence = new Map<string, number>();
|
|
||||||
#sequence = 0;
|
|
||||||
#closed = false;
|
|
||||||
|
|
||||||
constructor(options: HostTabSyncOptions) {
|
|
||||||
this.#channel = options.channel ?? new BroadcastChannel(HOST_TAB_CHANNEL);
|
|
||||||
this.#tabId = options.tabId ?? crypto.randomUUID();
|
|
||||||
this.#getState = options.getState;
|
|
||||||
this.#applyState = options.applyState;
|
|
||||||
this.#channel.addEventListener("message", this.#receive);
|
|
||||||
this.#send({
|
|
||||||
channel: HOST_TAB_CHANNEL,
|
|
||||||
version: HOST_TAB_PROTOCOL_VERSION,
|
|
||||||
type: "hello",
|
|
||||||
sender: this.#tabId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
publish(state: HostGlobalState): void {
|
|
||||||
this.#send({
|
|
||||||
channel: HOST_TAB_CHANNEL,
|
|
||||||
version: HOST_TAB_PROTOCOL_VERSION,
|
|
||||||
type: "state",
|
|
||||||
sender: this.#tabId,
|
|
||||||
sequence: ++this.#sequence,
|
|
||||||
state,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
close(): void {
|
|
||||||
if (this.#closed) return;
|
|
||||||
this.#closed = true;
|
|
||||||
this.#channel.removeEventListener("message", this.#receive);
|
|
||||||
this.#channel.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
readonly #receive = (event: MessageEvent<unknown>) => {
|
|
||||||
const message = event.data;
|
|
||||||
if (!isHostTabMessage(message) || message.sender === this.#tabId) return;
|
|
||||||
|
|
||||||
if (message.type === "hello") {
|
|
||||||
this.publish(this.#getState());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const latest = this.#latestSequence.get(message.sender) ?? 0;
|
|
||||||
if (message.sequence <= latest) return;
|
|
||||||
this.#latestSequence.set(message.sender, message.sequence);
|
|
||||||
this.#applyState(message.state);
|
|
||||||
};
|
|
||||||
|
|
||||||
#send(message: HostTabMessage): void {
|
|
||||||
if (!this.#closed) this.#channel.postMessage(message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createBrowserHostTabSync(
|
|
||||||
options: Omit<HostTabSyncOptions, "channel">,
|
|
||||||
): HostTabSync | null {
|
|
||||||
if (typeof BroadcastChannel === "undefined") return null;
|
|
||||||
return new HostTabSync(options);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isHostTabMessage(value: unknown): value is HostTabMessage {
|
|
||||||
if (!isRecord(value)) return false;
|
|
||||||
if (
|
|
||||||
value.channel !== HOST_TAB_CHANNEL ||
|
|
||||||
value.version !== HOST_TAB_PROTOCOL_VERSION ||
|
|
||||||
typeof value.sender !== "string"
|
|
||||||
) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (value.type === "hello") return true;
|
|
||||||
return (
|
|
||||||
value.type === "state" &&
|
|
||||||
Number.isSafeInteger(value.sequence) &&
|
|
||||||
(value.sequence as number) > 0 &&
|
|
||||||
isHostGlobalState(value.state)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isHostGlobalState(value: unknown): value is HostGlobalState {
|
|
||||||
if (!isRecord(value)) return false;
|
|
||||||
return (
|
|
||||||
(value.snapshot === null || isRecord(value.snapshot)) &&
|
|
||||||
isConnection(value.connection) &&
|
|
||||||
typeof value.apiBase === "string" &&
|
|
||||||
(value.runtimeAction === null || isRuntimeAction(value.runtimeAction)) &&
|
|
||||||
(value.serviceAction === null || typeof value.serviceAction === "string") &&
|
|
||||||
(value.deploymentAction === null || typeof value.deploymentAction === "string")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isConnection(value: unknown): value is HostGlobalState["connection"] {
|
|
||||||
return value === "connecting" || value === "online" || value === "offline";
|
|
||||||
}
|
|
||||||
|
|
||||||
function isRuntimeAction(value: unknown): value is RuntimeAction {
|
|
||||||
return value === "start" || value === "stop" || value === "restart";
|
|
||||||
}
|
|
||||||
|
|
||||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
||||||
return typeof value === "object" && value !== null;
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import { clsx, type ClassValue } from "clsx";
|
|
||||||
import { twMerge } from "tailwind-merge";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Merges conditional Tailwind classes using the same pattern as shadcn.
|
|
||||||
*
|
|
||||||
* `clsx` evaluates conditional input while `tailwind-merge` lets a caller's
|
|
||||||
* class override a component default from the same Tailwind class group:
|
|
||||||
*
|
|
||||||
* ```ts
|
|
||||||
* cn("h-10 px-4", compact && "h-8", props.class)
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export function cn(...inputs: ClassValue[]): string {
|
|
||||||
return twMerge(clsx(inputs));
|
|
||||||
}
|
|
||||||
@@ -1,164 +0,0 @@
|
|||||||
import type { BackendEvent, BackendService } from "./api";
|
|
||||||
|
|
||||||
export type ConnectionState = "connecting" | "online" | "offline";
|
|
||||||
export type ServiceStatus = "running" | "stopped" | "faulted";
|
|
||||||
export type EventTone = "success" | "warning" | "danger" | "neutral";
|
|
||||||
|
|
||||||
export type Service = {
|
|
||||||
id: string;
|
|
||||||
revision: string;
|
|
||||||
artifact: string;
|
|
||||||
world: string;
|
|
||||||
status: ServiceStatus;
|
|
||||||
updatedAt: string;
|
|
||||||
memoryMb: number;
|
|
||||||
fuel: string;
|
|
||||||
deadlineMs: number;
|
|
||||||
mailbox: number;
|
|
||||||
maxInputKb: number;
|
|
||||||
capabilities: BackendService["capabilities"];
|
|
||||||
calls: number;
|
|
||||||
errors: number;
|
|
||||||
latencyMs: number | null;
|
|
||||||
active: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type RuntimeEvent = {
|
|
||||||
id: number;
|
|
||||||
time: string;
|
|
||||||
title: string;
|
|
||||||
detail: string;
|
|
||||||
tone: EventTone;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type FormattedInvocationOutput = {
|
|
||||||
text: string;
|
|
||||||
format: "UTF-8" | "HEX" | "U64 LE";
|
|
||||||
automatic: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const STATUS_META: Record<ServiceStatus, { label: string }> = {
|
|
||||||
running: { label: "运行中" },
|
|
||||||
stopped: { label: "已停止" },
|
|
||||||
faulted: { label: "故障" },
|
|
||||||
};
|
|
||||||
|
|
||||||
export function serviceKey(service: Pick<Service, "id" | "revision">): string {
|
|
||||||
return `${service.id}@${service.revision}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function toService(service: BackendService, activeRevision?: string): Service {
|
|
||||||
return {
|
|
||||||
id: service.id,
|
|
||||||
revision: service.revision,
|
|
||||||
artifact: service.artifact.split(/[\\/]/).pop() ?? service.artifact,
|
|
||||||
world: service.world,
|
|
||||||
status: service.status,
|
|
||||||
updatedAt: new Date(service.updated_at_ms).toLocaleString("zh-CN", { hour12: false }),
|
|
||||||
memoryMb: Math.round(service.limits.memory_bytes / 1024 / 1024),
|
|
||||||
fuel: compactNumber(service.limits.fuel_per_call),
|
|
||||||
deadlineMs: service.limits.deadline_ms,
|
|
||||||
mailbox: service.limits.mailbox_capacity,
|
|
||||||
maxInputKb: Math.round(service.limits.max_input_bytes / 1024),
|
|
||||||
capabilities: service.capabilities,
|
|
||||||
calls: service.calls,
|
|
||||||
errors: service.errors,
|
|
||||||
latencyMs: service.last_latency_ms,
|
|
||||||
active: service.revision === activeRevision,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function toEvent(event: BackendEvent): RuntimeEvent {
|
|
||||||
const identity =
|
|
||||||
event.service_id && event.revision ? `${event.service_id}@${event.revision}` : "Wasmeld";
|
|
||||||
const meta: Record<BackendEvent["kind"], { label: string; tone: EventTone }> = {
|
|
||||||
registered: { label: "已注册", tone: "success" },
|
|
||||||
unregistered: { label: "已注销", tone: "neutral" },
|
|
||||||
deployed: { label: "部署切换", tone: "success" },
|
|
||||||
started: { label: "已启动", tone: "success" },
|
|
||||||
stopped: { label: "已停止", tone: "neutral" },
|
|
||||||
invoked: { label: "调用完成", tone: "success" },
|
|
||||||
failed: { label: "操作失败", tone: "danger" },
|
|
||||||
};
|
|
||||||
return {
|
|
||||||
id: event.id,
|
|
||||||
time: new Date(event.timestamp_ms).toLocaleTimeString("zh-CN", { hour12: false }),
|
|
||||||
title: `${identity} ${meta[event.kind].label}`,
|
|
||||||
detail: event.message,
|
|
||||||
tone: meta[event.kind].tone,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function compactNumber(value: number): string {
|
|
||||||
if (value >= 1_000_000) return `${value / 1_000_000}M`;
|
|
||||||
if (value >= 1_000) return `${value / 1_000}K`;
|
|
||||||
return value.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatDuration(milliseconds: number): string {
|
|
||||||
const totalSeconds = Math.floor(milliseconds / 1000);
|
|
||||||
const hours = Math.floor(totalSeconds / 3600);
|
|
||||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
|
||||||
const seconds = totalSeconds % 60;
|
|
||||||
return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m ${seconds}s`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseInvocationInput(value: string, format: "utf8" | "hex"): Uint8Array {
|
|
||||||
if (format === "utf8") return new TextEncoder().encode(value);
|
|
||||||
const compact = value.replace(/\s+/g, "");
|
|
||||||
if (compact.length % 2 !== 0 || /[^0-9a-f]/i.test(compact)) {
|
|
||||||
throw new Error("HEX 输入必须由成对的十六进制字符组成");
|
|
||||||
}
|
|
||||||
return Uint8Array.from(compact.match(/.{2}/g)?.map((byte) => Number.parseInt(byte, 16)) ?? []);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function invocationInputSize(value: string, format: "utf8" | "hex"): number {
|
|
||||||
try {
|
|
||||||
return parseInvocationInput(value, format).byteLength;
|
|
||||||
} catch {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatInvocationOutput(
|
|
||||||
service: Service,
|
|
||||||
output: Uint8Array,
|
|
||||||
format: "utf8" | "hex",
|
|
||||||
): FormattedInvocationOutput {
|
|
||||||
if (service.id === "counter" && output.byteLength === 8) {
|
|
||||||
return {
|
|
||||||
text: new DataView(output.buffer, output.byteOffset, output.byteLength)
|
|
||||||
.getBigUint64(0, true)
|
|
||||||
.toString(),
|
|
||||||
format: "U64 LE",
|
|
||||||
automatic: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (format === "hex") {
|
|
||||||
return { text: formatHex(output) || "(empty)", format: "HEX", automatic: false };
|
|
||||||
}
|
|
||||||
const text = decodeReadableUtf8(output);
|
|
||||||
if (text !== null) {
|
|
||||||
return { text: text || "(empty)", format: "UTF-8", automatic: false };
|
|
||||||
}
|
|
||||||
return { text: formatHex(output), format: "HEX", automatic: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatHex(bytes: Uint8Array): string {
|
|
||||||
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(" ");
|
|
||||||
}
|
|
||||||
|
|
||||||
function decodeReadableUtf8(bytes: Uint8Array): string | null {
|
|
||||||
let text: string;
|
|
||||||
try {
|
|
||||||
text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
for (const character of text) {
|
|
||||||
const codePoint = character.codePointAt(0) ?? 0;
|
|
||||||
const allowedWhitespace = codePoint === 0x09 || codePoint === 0x0a || codePoint === 0x0d;
|
|
||||||
if ((codePoint < 0x20 && !allowedWhitespace) || codePoint === 0x7f) return null;
|
|
||||||
}
|
|
||||||
return text;
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
import type { HostSharedState } from "../sdk/protocol";
|
|
||||||
import { toEvent, toService, type RuntimeEvent, type Service } from "./model";
|
|
||||||
|
|
||||||
export function servicesFrom(state: HostSharedState | null): Service[] {
|
|
||||||
if (!state?.snapshot) return [];
|
|
||||||
const deployments = new Map(
|
|
||||||
state.snapshot.deployments.map((deployment) => [
|
|
||||||
deployment.service_id,
|
|
||||||
deployment.active_revision,
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
return state.snapshot.services.map((service) => toService(service, deployments.get(service.id)));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function eventsFrom(state: HostSharedState | null): RuntimeEvent[] {
|
|
||||||
return state?.snapshot?.events.map(toEvent) ?? [];
|
|
||||||
}
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
import { History, RefreshCw } from "lucide-solid";
|
|
||||||
import { createMemo, For, onCleanup, Show } from "solid-js";
|
|
||||||
import { EmptyState } from "../../components/feedback/empty-state";
|
|
||||||
import { Page } from "../../components/layout/page";
|
|
||||||
import { PageHeading } from "../../components/layout/page-heading";
|
|
||||||
import { IconButton } from "../../components/ui/button";
|
|
||||||
import { Card } from "../../components/ui/card";
|
|
||||||
import { eventsFrom } from "../../lib/view-state";
|
|
||||||
import type { PageProps } from "../types";
|
|
||||||
|
|
||||||
const toneClass = {
|
|
||||||
success: "bg-brand",
|
|
||||||
warning: "bg-amber-strong",
|
|
||||||
danger: "bg-coral-strong",
|
|
||||||
neutral: "bg-muted",
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function ActivityPage(props: PageProps) {
|
|
||||||
// A retained Activity iframe may have been hidden for a while. Request a
|
|
||||||
// fresh snapshot whenever it becomes visible instead of waiting for the
|
|
||||||
// Host's next background poll.
|
|
||||||
onCleanup(props.lifecycle.onShow(() => props.command({ type: "refresh" })));
|
|
||||||
|
|
||||||
const events = createMemo(() => {
|
|
||||||
const query = props.state()?.query.trim().toLowerCase() ?? "";
|
|
||||||
return eventsFrom(props.state()).filter(
|
|
||||||
(event) =>
|
|
||||||
!query ||
|
|
||||||
event.title.toLowerCase().includes(query) ||
|
|
||||||
event.detail.toLowerCase().includes(query),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Page>
|
|
||||||
<PageHeading
|
|
||||||
eyebrow="EVENTS"
|
|
||||||
title="调用记录"
|
|
||||||
description="最近 256 条持久化生命周期与调用事件。"
|
|
||||||
actions={
|
|
||||||
<IconButton
|
|
||||||
aria-label="刷新"
|
|
||||||
title="刷新"
|
|
||||||
onClick={() => props.command({ type: "refresh" })}
|
|
||||||
>
|
|
||||||
<RefreshCw size={16} />
|
|
||||||
</IconButton>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Card as="section">
|
|
||||||
<Show
|
|
||||||
when={events().length > 0}
|
|
||||||
fallback={
|
|
||||||
<EmptyState icon={History} title="暂无事件" detail="Runtime 操作和调用会显示在这里。" />
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div class="divide-y divide-line">
|
|
||||||
<For each={events()}>
|
|
||||||
{(event) => (
|
|
||||||
<article class="grid gap-2 px-5 py-4 sm:grid-cols-[10px_minmax(180px,0.8fr)_2fr_84px] sm:items-center">
|
|
||||||
<span class={`size-2 rounded-full ${toneClass[event.tone]}`} />
|
|
||||||
<strong class="text-sm">{event.title}</strong>
|
|
||||||
<p class="text-xs leading-5 text-muted">{event.detail}</p>
|
|
||||||
<time class="font-mono text-xs text-muted sm:text-right">{event.time}</time>
|
|
||||||
</article>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
</Card>
|
|
||||||
</Page>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
import { Cpu, RefreshCw, Server, SquareTerminal } from "lucide-solid";
|
|
||||||
import { createMemo, For, Show } from "solid-js";
|
|
||||||
import { EmptyState } from "../../components/feedback/empty-state";
|
|
||||||
import { Page } from "../../components/layout/page";
|
|
||||||
import { PageHeading } from "../../components/layout/page-heading";
|
|
||||||
import { StatusBadge } from "../../components/services/status-badge";
|
|
||||||
import { Button, IconButton } from "../../components/ui/button";
|
|
||||||
import { Card } from "../../components/ui/card";
|
|
||||||
import { serviceKey } from "../../lib/model";
|
|
||||||
import { servicesFrom } from "../../lib/view-state";
|
|
||||||
import type { PageProps } from "../types";
|
|
||||||
|
|
||||||
export default function InstancesPage(props: PageProps) {
|
|
||||||
const services = createMemo(() => servicesFrom(props.state()));
|
|
||||||
const running = createMemo(
|
|
||||||
() => services().filter((service) => service.status === "running").length,
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Page>
|
|
||||||
<PageHeading
|
|
||||||
eyebrow="ACTORS"
|
|
||||||
title="运行实例"
|
|
||||||
description={`${running()} 个 Actor 保持独立 Store 与串行 mailbox。`}
|
|
||||||
actions={
|
|
||||||
<IconButton
|
|
||||||
aria-label="刷新"
|
|
||||||
title="刷新"
|
|
||||||
onClick={() => props.command({ type: "refresh" })}
|
|
||||||
>
|
|
||||||
<RefreshCw size={16} />
|
|
||||||
</IconButton>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Show
|
|
||||||
when={services().length > 0}
|
|
||||||
fallback={
|
|
||||||
<Card>
|
|
||||||
<EmptyState icon={Server} title="暂无实例" detail="先注册一个组件包。" />
|
|
||||||
</Card>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<section class="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
|
||||||
<For each={services()}>
|
|
||||||
{(service) => (
|
|
||||||
<Card as="article" padding="medium">
|
|
||||||
<div class="flex items-start justify-between gap-3">
|
|
||||||
<div class="flex min-w-0 items-center gap-3">
|
|
||||||
<span class="flex size-10 shrink-0 items-center justify-center rounded-md bg-cyan-soft text-cyan-strong">
|
|
||||||
<Cpu size={18} />
|
|
||||||
</span>
|
|
||||||
<div class="min-w-0">
|
|
||||||
<strong class="block truncate">{service.id}</strong>
|
|
||||||
<span class="mt-1 block truncate font-mono text-xs text-muted">
|
|
||||||
{service.revision}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<StatusBadge status={service.status} />
|
|
||||||
</div>
|
|
||||||
<dl class="mt-5 grid grid-cols-2 gap-x-4 gap-y-3 border-t border-line pt-4 text-xs">
|
|
||||||
<div>
|
|
||||||
<dt class="text-muted">内存上限</dt>
|
|
||||||
<dd class="mt-1 font-semibold">{service.memoryMb} MB</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt class="text-muted">Mailbox</dt>
|
|
||||||
<dd class="mt-1 font-semibold">{service.mailbox}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt class="text-muted">Deadline</dt>
|
|
||||||
<dd class="mt-1 font-semibold">{service.deadlineMs} ms</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt class="text-muted">最近延迟</dt>
|
|
||||||
<dd class="mt-1 font-semibold">
|
|
||||||
{service.latencyMs === null ? "—" : `${service.latencyMs} ms`}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
<div class="mt-5 flex justify-end gap-2">
|
|
||||||
<Show when={service.status === "running"}>
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
onClick={() =>
|
|
||||||
props.command({
|
|
||||||
type: "open-invoke",
|
|
||||||
serviceKey: serviceKey(service),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SquareTerminal size={15} />
|
|
||||||
调用
|
|
||||||
</Button>
|
|
||||||
</Show>
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
disabled={props.state()?.serviceAction !== null}
|
|
||||||
onClick={() =>
|
|
||||||
props.command({
|
|
||||||
type: "service-action",
|
|
||||||
serviceKey: serviceKey(service),
|
|
||||||
action: service.status === "running" ? "stop" : "start",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{service.status === "running" ? "停止" : "启动"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</section>
|
|
||||||
</Show>
|
|
||||||
</Page>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
import { AlertTriangle, LoaderCircle } from "lucide-solid";
|
|
||||||
import { lazy, Show, Suspense } from "solid-js";
|
|
||||||
import { render } from "solid-js/web";
|
|
||||||
import { createFrameBridge } from "../primitives/create-frame-bridge";
|
|
||||||
import "../styles/app.css";
|
|
||||||
import { isView, pageDefinition } from "./registry";
|
|
||||||
|
|
||||||
function FramePage() {
|
|
||||||
const requested = new URLSearchParams(window.location.search).get("view");
|
|
||||||
const view = isView(requested) ? requested : null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Show
|
|
||||||
when={view}
|
|
||||||
fallback={
|
|
||||||
<main class="flex min-h-dvh items-center justify-center p-6 text-center text-coral-strong">
|
|
||||||
<div>
|
|
||||||
<AlertTriangle class="mx-auto mb-3" size={28} />
|
|
||||||
<strong class="block">未知页面</strong>
|
|
||||||
<span class="mt-1 block text-sm text-muted">{requested ?? "(empty)"}</span>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{(activeView) => {
|
|
||||||
const bridge = createFrameBridge(activeView());
|
|
||||||
const definition = pageDefinition(activeView());
|
|
||||||
const Page = lazy(definition.load);
|
|
||||||
document.title = `${definition.label} · Wasmeld`;
|
|
||||||
return (
|
|
||||||
<Suspense
|
|
||||||
fallback={
|
|
||||||
<main class="flex min-h-dvh items-center justify-center text-muted">
|
|
||||||
<LoaderCircle class="animate-spin" size={24} />
|
|
||||||
</main>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Page
|
|
||||||
state={bridge.state}
|
|
||||||
active={bridge.active}
|
|
||||||
lifecycle={bridge.lifecycle}
|
|
||||||
command={bridge.command}
|
|
||||||
/>
|
|
||||||
</Suspense>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
</Show>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const root = document.getElementById("app");
|
|
||||||
if (!root) throw new Error("missing #app root");
|
|
||||||
render(() => <FramePage />, root);
|
|
||||||
@@ -1,267 +0,0 @@
|
|||||||
import {
|
|
||||||
Activity,
|
|
||||||
AlertTriangle,
|
|
||||||
CircleStop,
|
|
||||||
CloudUpload,
|
|
||||||
Cpu,
|
|
||||||
Package,
|
|
||||||
Play,
|
|
||||||
RefreshCw,
|
|
||||||
RotateCcw,
|
|
||||||
Zap,
|
|
||||||
} from "lucide-solid";
|
|
||||||
import { createMemo, For, Show, type JSXElement } from "solid-js";
|
|
||||||
import { Page } from "../../components/layout/page";
|
|
||||||
import { PageHeading } from "../../components/layout/page-heading";
|
|
||||||
import { ServiceTable } from "../../components/services/service-table";
|
|
||||||
import { Button, IconButton } from "../../components/ui/button";
|
|
||||||
import { Card, CardHeader } from "../../components/ui/card";
|
|
||||||
import { eventsFrom, servicesFrom } from "../../lib/view-state";
|
|
||||||
import { formatDuration } from "../../lib/model";
|
|
||||||
import type { PageProps } from "../types";
|
|
||||||
|
|
||||||
export default function OverviewPage(props: PageProps) {
|
|
||||||
const services = createMemo(() => servicesFrom(props.state()));
|
|
||||||
const events = createMemo(() => eventsFrom(props.state()));
|
|
||||||
const running = createMemo(
|
|
||||||
() => services().filter((service) => service.status === "running").length,
|
|
||||||
);
|
|
||||||
const totalCalls = createMemo(() =>
|
|
||||||
services().reduce((total, service) => total + service.calls, 0),
|
|
||||||
);
|
|
||||||
const errors = createMemo(() => services().reduce((total, service) => total + service.errors, 0));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Page>
|
|
||||||
<Show when={props.state()} fallback={<OverviewSkeleton />}>
|
|
||||||
{(state) => (
|
|
||||||
<>
|
|
||||||
<PageHeading
|
|
||||||
eyebrow="LOCAL RUNTIME"
|
|
||||||
title="运行概览"
|
|
||||||
description={`${services().length} 个已注册版本,${running()} 个 Actor 正在运行。`}
|
|
||||||
actions={
|
|
||||||
<>
|
|
||||||
<IconButton
|
|
||||||
aria-label="刷新"
|
|
||||||
title="刷新"
|
|
||||||
onClick={() => props.command({ type: "refresh" })}
|
|
||||||
>
|
|
||||||
<RefreshCw size={16} />
|
|
||||||
</IconButton>
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
onClick={() => props.command({ type: "open-register" })}
|
|
||||||
>
|
|
||||||
<CloudUpload size={16} />
|
|
||||||
注册组件
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<section
|
|
||||||
class="mb-5 grid grid-cols-1 border-l border-t border-line sm:grid-cols-2 xl:grid-cols-4"
|
|
||||||
aria-label="关键指标"
|
|
||||||
>
|
|
||||||
<Metric
|
|
||||||
icon={<Package size={18} />}
|
|
||||||
tone="bg-cyan-soft text-cyan-strong"
|
|
||||||
label="服务版本"
|
|
||||||
value={services().length.toString()}
|
|
||||||
note={`${state().snapshot?.deployments.length ?? 0} 个对外服务`}
|
|
||||||
/>
|
|
||||||
<Metric
|
|
||||||
icon={<Activity size={18} />}
|
|
||||||
tone="bg-brand-soft text-brand"
|
|
||||||
label="运行实例"
|
|
||||||
value={running().toString()}
|
|
||||||
note={`${services().length - running()} 个未运行`}
|
|
||||||
/>
|
|
||||||
<Metric
|
|
||||||
icon={<Zap size={18} />}
|
|
||||||
tone="bg-amber-soft text-amber-strong"
|
|
||||||
label="累计调用"
|
|
||||||
value={totalCalls().toLocaleString("zh-CN")}
|
|
||||||
note="持久化统计"
|
|
||||||
/>
|
|
||||||
<Metric
|
|
||||||
icon={<AlertTriangle size={18} />}
|
|
||||||
tone="bg-coral-soft text-coral-strong"
|
|
||||||
label="调用异常"
|
|
||||||
value={errors().toString()}
|
|
||||||
note={errors() > 0 ? "需要检查" : "无异常"}
|
|
||||||
/>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<Card
|
|
||||||
as="section"
|
|
||||||
class="mb-5 grid lg:grid-cols-[minmax(280px,1.4fr)_repeat(3,minmax(130px,1fr))]"
|
|
||||||
>
|
|
||||||
<div class="flex items-center gap-3 border-b border-line px-5 py-4 lg:border-b-0 lg:border-r">
|
|
||||||
<span class="flex size-10 items-center justify-center rounded-md bg-brand-soft text-brand">
|
|
||||||
<Cpu size={18} />
|
|
||||||
</span>
|
|
||||||
<div class="min-w-0 flex-1">
|
|
||||||
<strong class="block">Wasmeld Runtime</strong>
|
|
||||||
<span class="mt-0.5 block truncate text-xs text-muted">
|
|
||||||
{state().snapshot?.runtime.status === "running"
|
|
||||||
? "Wasmtime sandbox 正常"
|
|
||||||
: "Engine 与 Actor 已释放"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex gap-1">
|
|
||||||
<Show
|
|
||||||
when={state().snapshot?.runtime.status === "running"}
|
|
||||||
fallback={
|
|
||||||
<IconButton
|
|
||||||
aria-label="启动 Runtime"
|
|
||||||
title="启动 Runtime"
|
|
||||||
disabled={state().runtimeAction !== null}
|
|
||||||
onClick={() => props.command({ type: "runtime-action", action: "start" })}
|
|
||||||
>
|
|
||||||
<Play size={15} />
|
|
||||||
</IconButton>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<IconButton
|
|
||||||
aria-label="重启 Runtime"
|
|
||||||
title="重启 Runtime"
|
|
||||||
disabled={state().runtimeAction !== null}
|
|
||||||
onClick={() => props.command({ type: "runtime-action", action: "restart" })}
|
|
||||||
>
|
|
||||||
<RotateCcw size={15} />
|
|
||||||
</IconButton>
|
|
||||||
<IconButton
|
|
||||||
tone="danger"
|
|
||||||
aria-label="停止 Runtime"
|
|
||||||
title="停止 Runtime"
|
|
||||||
disabled={state().runtimeAction !== null}
|
|
||||||
onClick={() => props.command({ type: "runtime-action", action: "stop" })}
|
|
||||||
>
|
|
||||||
<CircleStop size={15} />
|
|
||||||
</IconButton>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<RuntimeStat
|
|
||||||
label="已加载版本"
|
|
||||||
value={(state().snapshot?.runtime.registered_services ?? 0).toString()}
|
|
||||||
detail="已编译 Component"
|
|
||||||
/>
|
|
||||||
<RuntimeStat
|
|
||||||
label="活跃 Store"
|
|
||||||
value={(state().snapshot?.runtime.running_services ?? 0).toString()}
|
|
||||||
detail="串行 Actor"
|
|
||||||
/>
|
|
||||||
<RuntimeStat
|
|
||||||
label="运行时间"
|
|
||||||
value={
|
|
||||||
state().snapshot?.runtime.status === "running"
|
|
||||||
? formatDuration(state().snapshot?.runtime.uptime_ms ?? 0)
|
|
||||||
: "—"
|
|
||||||
}
|
|
||||||
detail="当前 Runtime"
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<div class="grid gap-5 xl:grid-cols-[minmax(0,1.7fr)_minmax(300px,0.8fr)]">
|
|
||||||
<Card as="section" class="min-w-0">
|
|
||||||
<CardHeader>
|
|
||||||
<div>
|
|
||||||
<h2>服务状态</h2>
|
|
||||||
<p>已注册版本及当前 Actor 状态</p>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
onClick={() => props.command({ type: "navigate", view: "services" })}
|
|
||||||
>
|
|
||||||
查看全部
|
|
||||||
</Button>
|
|
||||||
</CardHeader>
|
|
||||||
<ServiceTable
|
|
||||||
services={services().slice(0, 5)}
|
|
||||||
state={state()}
|
|
||||||
command={props.command}
|
|
||||||
compact
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card as="aside">
|
|
||||||
<CardHeader>
|
|
||||||
<div>
|
|
||||||
<h2>最近事件</h2>
|
|
||||||
<p>生命周期与调用结果</p>
|
|
||||||
</div>
|
|
||||||
<span class="inline-flex items-center gap-1.5 text-[11px] font-bold text-brand">
|
|
||||||
<span class="size-1.5 rounded-full bg-brand" />
|
|
||||||
LIVE
|
|
||||||
</span>
|
|
||||||
</CardHeader>
|
|
||||||
<div class="divide-y divide-line">
|
|
||||||
<For
|
|
||||||
each={events().slice(0, 6)}
|
|
||||||
fallback={<p class="px-5 py-12 text-center text-sm text-muted">暂无事件</p>}
|
|
||||||
>
|
|
||||||
{(event) => (
|
|
||||||
<div class="px-5 py-3">
|
|
||||||
<div class="flex items-center justify-between gap-3">
|
|
||||||
<strong class="truncate text-xs">{event.title}</strong>
|
|
||||||
<span class="font-mono text-[10px] text-muted">{event.time}</span>
|
|
||||||
</div>
|
|
||||||
<p class="mt-1 line-clamp-2 text-xs leading-5 text-muted">{event.detail}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
</Page>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Metric(props: {
|
|
||||||
icon: JSXElement;
|
|
||||||
tone: string;
|
|
||||||
label: string;
|
|
||||||
value: string;
|
|
||||||
note: string;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<article class="flex min-h-24 items-center gap-3 border-b border-r border-line bg-white px-4 py-3">
|
|
||||||
<div class={`flex size-10 shrink-0 items-center justify-center rounded-md ${props.tone}`}>
|
|
||||||
{props.icon}
|
|
||||||
</div>
|
|
||||||
<div class="min-w-0 flex-1">
|
|
||||||
<span class="block text-xs text-muted">{props.label}</span>
|
|
||||||
<strong class="mt-1 block text-2xl font-bold">{props.value}</strong>
|
|
||||||
</div>
|
|
||||||
<small class="self-end whitespace-nowrap pb-1 text-[11px] text-muted">{props.note}</small>
|
|
||||||
</article>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function RuntimeStat(props: { label: string; value: string; detail: string }) {
|
|
||||||
return (
|
|
||||||
<div class="border-b border-line px-5 py-4 last:border-b-0 lg:border-b-0 lg:border-r lg:last:border-r-0">
|
|
||||||
<span class="block text-xs text-muted">{props.label}</span>
|
|
||||||
<strong class="mt-1 block text-lg">{props.value}</strong>
|
|
||||||
<small class="mt-0.5 block text-[11px] text-muted">{props.detail}</small>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function OverviewSkeleton() {
|
|
||||||
return (
|
|
||||||
<div class="space-y-5 py-5">
|
|
||||||
<div class="h-3 w-36 animate-pulse rounded-sm bg-[#dfe6e4]" />
|
|
||||||
<div class="h-8 w-64 animate-pulse rounded-sm bg-[#dfe6e4]" />
|
|
||||||
<div class="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
|
||||||
<For each={[1, 2, 3, 4]}>{() => <div class="h-24 animate-pulse bg-white" />}</For>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
import {
|
|
||||||
FileCode2,
|
|
||||||
History,
|
|
||||||
LayoutDashboard,
|
|
||||||
Package,
|
|
||||||
Server,
|
|
||||||
Settings,
|
|
||||||
type LucideProps,
|
|
||||||
} from "lucide-solid";
|
|
||||||
import type { Component } from "solid-js";
|
|
||||||
import type { PageProps } from "./types";
|
|
||||||
|
|
||||||
type PageModule = {
|
|
||||||
default: Component<PageProps>;
|
|
||||||
};
|
|
||||||
|
|
||||||
type PageDefinition = {
|
|
||||||
id: string;
|
|
||||||
label: string;
|
|
||||||
icon: Component<LucideProps>;
|
|
||||||
/**
|
|
||||||
* Requests that Host retain this page's iframe after navigation.
|
|
||||||
*
|
|
||||||
* Primary navigation pages normally opt in; detail and short-lived workflow
|
|
||||||
* pages should opt out. This is still a preference rather than ownership of
|
|
||||||
* the iframe lifetime: Host expires documents that remain hidden beyond its
|
|
||||||
* inactivity TTL. A retained page must pause background work while
|
|
||||||
* `active()` is false and release all resources when disposed.
|
|
||||||
*/
|
|
||||||
keepAlive: boolean;
|
|
||||||
/**
|
|
||||||
* Loads only this page's code inside the iframe document.
|
|
||||||
*
|
|
||||||
* Keep this as a dynamic import. Turning it into a static import would make
|
|
||||||
* every iframe evaluate every page and weaken the document lifecycle
|
|
||||||
* boundary used to release page-owned memory.
|
|
||||||
*/
|
|
||||||
load: () => Promise<PageModule>;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The single source of truth for Wasmeld Console pages.
|
|
||||||
*
|
|
||||||
* Add a page here after creating `src/pages/<id>/index.tsx`. Host navigation,
|
|
||||||
* route validation, iframe titles, keep-alive policy and page module loading
|
|
||||||
* are all derived from this registry, so they cannot drift independently.
|
|
||||||
*/
|
|
||||||
export const PAGE_DEFINITIONS = [
|
|
||||||
{
|
|
||||||
id: "overview",
|
|
||||||
label: "运行概览",
|
|
||||||
icon: LayoutDashboard,
|
|
||||||
keepAlive: true,
|
|
||||||
load: () => import("./overview"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "services",
|
|
||||||
label: "服务版本",
|
|
||||||
icon: Package,
|
|
||||||
keepAlive: true,
|
|
||||||
load: () => import("./services"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "instances",
|
|
||||||
label: "运行实例",
|
|
||||||
icon: Server,
|
|
||||||
keepAlive: true,
|
|
||||||
load: () => import("./instances"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "wit-packages",
|
|
||||||
label: "WIT 包",
|
|
||||||
icon: FileCode2,
|
|
||||||
keepAlive: true,
|
|
||||||
load: () => import("./wit-packages"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "activity",
|
|
||||||
label: "调用记录",
|
|
||||||
icon: History,
|
|
||||||
keepAlive: true,
|
|
||||||
load: () => import("./activity"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "settings",
|
|
||||||
label: "运行设置",
|
|
||||||
icon: Settings,
|
|
||||||
keepAlive: true,
|
|
||||||
load: () => import("./settings"),
|
|
||||||
},
|
|
||||||
] as const satisfies readonly PageDefinition[];
|
|
||||||
|
|
||||||
export type View = (typeof PAGE_DEFINITIONS)[number]["id"];
|
|
||||||
|
|
||||||
export const VIEWS: readonly View[] = PAGE_DEFINITIONS.map((page) => page.id);
|
|
||||||
|
|
||||||
const PAGE_BY_ID = new Map<View, PageDefinition>(PAGE_DEFINITIONS.map((page) => [page.id, page]));
|
|
||||||
|
|
||||||
export function isView(value: string | null): value is View {
|
|
||||||
return value !== null && PAGE_BY_ID.has(value as View);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function pageDefinition(view: View): PageDefinition {
|
|
||||||
// `View` is derived from PAGE_DEFINITIONS, so every valid value has an entry.
|
|
||||||
return PAGE_BY_ID.get(view)!;
|
|
||||||
}
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
import { CloudUpload, Filter, RefreshCw } from "lucide-solid";
|
|
||||||
import { createMemo, createSignal, Show } from "solid-js";
|
|
||||||
import { Page } from "../../components/layout/page";
|
|
||||||
import { PageHeading } from "../../components/layout/page-heading";
|
|
||||||
import { ServiceTable } from "../../components/services/service-table";
|
|
||||||
import { Button, IconButton } from "../../components/ui/button";
|
|
||||||
import { Card, CardHeader } from "../../components/ui/card";
|
|
||||||
import { Select } from "../../components/ui/select";
|
|
||||||
import { servicesFrom } from "../../lib/view-state";
|
|
||||||
import type { ServiceStatus } from "../../lib/model";
|
|
||||||
import type { PageProps } from "../types";
|
|
||||||
|
|
||||||
export default function ServicesPage(props: PageProps) {
|
|
||||||
const [status, setStatus] = createSignal<"all" | ServiceStatus>("all");
|
|
||||||
const services = createMemo(() => servicesFrom(props.state()));
|
|
||||||
const filtered = createMemo(() => {
|
|
||||||
const query = props.state()?.query.trim().toLowerCase() ?? "";
|
|
||||||
return services().filter(
|
|
||||||
(service) =>
|
|
||||||
(status() === "all" || service.status === status()) &&
|
|
||||||
(!query ||
|
|
||||||
service.id.toLowerCase().includes(query) ||
|
|
||||||
service.revision.toLowerCase().includes(query) ||
|
|
||||||
service.artifact.toLowerCase().includes(query)),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Page>
|
|
||||||
<PageHeading
|
|
||||||
eyebrow="COMPONENTS"
|
|
||||||
title="服务版本"
|
|
||||||
description="管理不可变 Component Revision、Actor 状态和对外 Deployment。"
|
|
||||||
actions={
|
|
||||||
<>
|
|
||||||
<label class="relative">
|
|
||||||
<Filter
|
|
||||||
class="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-muted"
|
|
||||||
size={14}
|
|
||||||
/>
|
|
||||||
<Select
|
|
||||||
class="w-36 pl-9"
|
|
||||||
value={status()}
|
|
||||||
onChange={(event) => setStatus(event.currentTarget.value as "all" | ServiceStatus)}
|
|
||||||
>
|
|
||||||
<option value="all">全部状态</option>
|
|
||||||
<option value="running">运行中</option>
|
|
||||||
<option value="stopped">已停止</option>
|
|
||||||
<option value="faulted">故障</option>
|
|
||||||
</Select>
|
|
||||||
</label>
|
|
||||||
<Button variant="primary" onClick={() => props.command({ type: "open-register" })}>
|
|
||||||
<CloudUpload size={16} />
|
|
||||||
注册组件
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Card as="section">
|
|
||||||
<CardHeader>
|
|
||||||
<div>
|
|
||||||
<h2>已注册版本</h2>
|
|
||||||
<p>{filtered().length} 个匹配项</p>
|
|
||||||
</div>
|
|
||||||
<IconButton
|
|
||||||
aria-label="刷新"
|
|
||||||
title="刷新"
|
|
||||||
onClick={() => props.command({ type: "refresh" })}
|
|
||||||
>
|
|
||||||
<RefreshCw size={16} />
|
|
||||||
</IconButton>
|
|
||||||
</CardHeader>
|
|
||||||
<Show when={props.state()}>
|
|
||||||
{(state) => (
|
|
||||||
<ServiceTable services={filtered()} state={state()} command={props.command} />
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
</Card>
|
|
||||||
</Page>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
import { Database, Globe2, Save, ShieldCheck } from "lucide-solid";
|
|
||||||
import { createEffect, createSignal } from "solid-js";
|
|
||||||
import { displayApiBase } from "../../lib/api";
|
|
||||||
import { Page } from "../../components/layout/page";
|
|
||||||
import { PageHeading } from "../../components/layout/page-heading";
|
|
||||||
import { Button } from "../../components/ui/button";
|
|
||||||
import { Card, CardHeader } from "../../components/ui/card";
|
|
||||||
import { Field, FieldLabel } from "../../components/ui/field";
|
|
||||||
import { Input } from "../../components/ui/input";
|
|
||||||
import type { PageProps } from "../types";
|
|
||||||
|
|
||||||
export default function SettingsPage(props: PageProps) {
|
|
||||||
const [endpoint, setEndpoint] = createSignal("");
|
|
||||||
let initialized = false;
|
|
||||||
createEffect(() => {
|
|
||||||
const state = props.state();
|
|
||||||
if (state && !initialized) {
|
|
||||||
setEndpoint(state.apiBase);
|
|
||||||
initialized = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Page>
|
|
||||||
<PageHeading
|
|
||||||
eyebrow="CONFIGURATION"
|
|
||||||
title="运行设置"
|
|
||||||
description="配置管理 API 连接;运行边界由后端策略统一控制。"
|
|
||||||
/>
|
|
||||||
<div class="grid gap-5 xl:grid-cols-[minmax(0,1.4fr)_minmax(280px,0.7fr)]">
|
|
||||||
<Card as="section">
|
|
||||||
<CardHeader>
|
|
||||||
<div>
|
|
||||||
<h2>管理 API</h2>
|
|
||||||
<p>留空表示使用当前页面同源地址</p>
|
|
||||||
</div>
|
|
||||||
<Globe2 size={18} class="text-cyan-strong" />
|
|
||||||
</CardHeader>
|
|
||||||
<form
|
|
||||||
class="p-5"
|
|
||||||
onSubmit={(event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
props.command({ type: "save-api-base", value: endpoint() });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Field>
|
|
||||||
<FieldLabel>Endpoint</FieldLabel>
|
|
||||||
<Input
|
|
||||||
type="url"
|
|
||||||
value={endpoint()}
|
|
||||||
placeholder={window.location.origin}
|
|
||||||
onInput={(event) => setEndpoint(event.currentTarget.value)}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
<p class="mt-2 text-xs text-muted">
|
|
||||||
当前解析地址:{displayApiBase(props.state()?.apiBase ?? "")}
|
|
||||||
</p>
|
|
||||||
<div class="mt-5 flex justify-end">
|
|
||||||
<Button variant="primary" type="submit">
|
|
||||||
<Save size={15} />
|
|
||||||
保存连接
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</Card>
|
|
||||||
<aside class="space-y-3">
|
|
||||||
<Info
|
|
||||||
icon={ShieldCheck}
|
|
||||||
title="Sandbox"
|
|
||||||
detail="网络与 Host 能力默认拒绝,按 WIT import 精确链接。"
|
|
||||||
/>
|
|
||||||
<Info icon={Database} title="持久化" detail="控制状态和服务 KV 存储于本地 libSQL。" />
|
|
||||||
</aside>
|
|
||||||
</div>
|
|
||||||
</Page>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Info(props: { icon: typeof ShieldCheck; title: string; detail: string }) {
|
|
||||||
const Icon = props.icon;
|
|
||||||
return (
|
|
||||||
<Card as="article" padding="small" class="flex gap-3">
|
|
||||||
<span class="flex size-9 shrink-0 items-center justify-center rounded-md bg-brand-soft text-brand">
|
|
||||||
<Icon size={16} />
|
|
||||||
</span>
|
|
||||||
<div>
|
|
||||||
<strong class="text-sm">{props.title}</strong>
|
|
||||||
<p class="mt-1 text-xs leading-5 text-muted">{props.detail}</p>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import type { Accessor } from "solid-js";
|
|
||||||
import type { PageLifecycle } from "../sdk/lifecycle";
|
|
||||||
import type { HostSharedState, PageCommand } from "../sdk/protocol";
|
|
||||||
|
|
||||||
export type PageProps = {
|
|
||||||
state: Accessor<HostSharedState | null>;
|
|
||||||
/**
|
|
||||||
* False while a keep-alive iframe is hidden. Page-owned polling, media or
|
|
||||||
* render loops should pause until this accessor becomes true again.
|
|
||||||
*/
|
|
||||||
active: Accessor<boolean>;
|
|
||||||
/**
|
|
||||||
* Transition events for imperative Page work.
|
|
||||||
*
|
|
||||||
* Use `onShow` to refresh stale data, `onHide` to pause background work, and
|
|
||||||
* `onUnload` for final cleanup. Subscriptions return an unsubscribe function.
|
|
||||||
*/
|
|
||||||
lifecycle: PageLifecycle;
|
|
||||||
command: (command: PageCommand) => void;
|
|
||||||
};
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
import { CloudUpload, Download, FileCode2, Search } from "lucide-solid";
|
|
||||||
import { createMemo, For, Show } from "solid-js";
|
|
||||||
import { EmptyState } from "../../components/feedback/empty-state";
|
|
||||||
import { Page } from "../../components/layout/page";
|
|
||||||
import { PageHeading } from "../../components/layout/page-heading";
|
|
||||||
import { Button, IconLink } from "../../components/ui/button";
|
|
||||||
import { Card, CardHeader } from "../../components/ui/card";
|
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from "../../components/ui/table";
|
|
||||||
import { witPackageDownloadUrl } from "../../lib/api";
|
|
||||||
import type { PageProps } from "../types";
|
|
||||||
|
|
||||||
export default function WitPackagesPage(props: PageProps) {
|
|
||||||
const packages = createMemo(() => {
|
|
||||||
const state = props.state();
|
|
||||||
const query = state?.query.trim().toLowerCase() ?? "";
|
|
||||||
return (
|
|
||||||
state?.snapshot?.witPackages.filter(
|
|
||||||
(item) =>
|
|
||||||
!query ||
|
|
||||||
item.name.toLowerCase().includes(query) ||
|
|
||||||
item.version.toLowerCase().includes(query) ||
|
|
||||||
item.sha256.toLowerCase().includes(query),
|
|
||||||
) ?? []
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Page>
|
|
||||||
<PageHeading
|
|
||||||
eyebrow="INTERFACES"
|
|
||||||
title="WIT 包"
|
|
||||||
description="管理不可变、版本化的 Component 接口依赖。"
|
|
||||||
actions={
|
|
||||||
<Button variant="primary" onClick={() => props.command({ type: "open-wit-publish" })}>
|
|
||||||
<CloudUpload size={16} />
|
|
||||||
发布 WIT 包
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Card as="section">
|
|
||||||
<CardHeader>
|
|
||||||
<div>
|
|
||||||
<h2>Registry</h2>
|
|
||||||
<p>{packages().length} 个匹配包版本</p>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<Show
|
|
||||||
when={packages().length > 0}
|
|
||||||
fallback={
|
|
||||||
<EmptyState icon={Search} title="没有匹配的 WIT 包" detail="发布或调整搜索关键词。" />
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead>包</TableHead>
|
|
||||||
<TableHead>版本</TableHead>
|
|
||||||
<TableHead>直接依赖</TableHead>
|
|
||||||
<TableHead>SHA-256</TableHead>
|
|
||||||
<TableHead>
|
|
||||||
<span class="sr-only">下载</span>
|
|
||||||
</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
<For each={packages()}>
|
|
||||||
{(item) => (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell>
|
|
||||||
<span class="flex items-center gap-2">
|
|
||||||
<FileCode2 size={15} class="text-cyan-strong" />
|
|
||||||
<strong>{item.name}</strong>
|
|
||||||
</span>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell class="font-mono text-xs">{item.version}</TableCell>
|
|
||||||
<TableCell class="text-xs text-muted">
|
|
||||||
{item.dependencies.length
|
|
||||||
? item.dependencies
|
|
||||||
.map((dependency) => `${dependency.name}@${dependency.version}`)
|
|
||||||
.join(", ")
|
|
||||||
: "无"}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell
|
|
||||||
class="max-w-64 truncate font-mono text-xs text-muted"
|
|
||||||
title={item.sha256}
|
|
||||||
>
|
|
||||||
{item.sha256}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell class="text-right">
|
|
||||||
<IconLink
|
|
||||||
aria-label={`下载 ${item.name}@${item.version}`}
|
|
||||||
title="下载"
|
|
||||||
href={witPackageDownloadUrl(props.state()?.apiBase ?? "", item)}
|
|
||||||
>
|
|
||||||
<Download size={15} />
|
|
||||||
</IconLink>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</Show>
|
|
||||||
</Card>
|
|
||||||
</Page>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
import { createSignal, onCleanup, onMount } from "solid-js";
|
|
||||||
import type { View } from "../pages/registry";
|
|
||||||
import { createPageLifecycleDispatcher, type PageLifecycleEventType } from "../sdk/lifecycle";
|
|
||||||
import { isHostMessage, type HostSharedState, type PageCommand } from "../sdk/protocol";
|
|
||||||
import { notifyReady, sendCommand } from "../sdk/page";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Connects one iframe document to the persistent Host Shell.
|
|
||||||
*
|
|
||||||
* A keep-alive document remains mounted while inactive and exposes that state
|
|
||||||
* through `active`. Host `dispose`, browser `pagehide` and Solid cleanup all
|
|
||||||
* converge on one deduplicated SDK unload transition. The document boundary
|
|
||||||
* itself remains the final guarantee that an evicted Page releases its realm.
|
|
||||||
*/
|
|
||||||
export function createFrameBridge(view: View) {
|
|
||||||
const [state, setState] = createSignal<HostSharedState | null>(null);
|
|
||||||
const [active, setActive] = createSignal(false);
|
|
||||||
const pageLifecycle = createPageLifecycleDispatcher();
|
|
||||||
|
|
||||||
const receive = (event: MessageEvent<unknown>) => {
|
|
||||||
if (event.origin !== window.location.origin || event.source !== window.parent) return;
|
|
||||||
if (!isHostMessage(event.data)) return;
|
|
||||||
if (event.data.type === "state" && event.data.state.view === view) {
|
|
||||||
setState(event.data.state);
|
|
||||||
}
|
|
||||||
if (event.data.type === "lifecycle") {
|
|
||||||
const phase = event.data.phase;
|
|
||||||
const lifecycleEvent = pageLifecycle.dispatch(eventForPhase(phase), "host");
|
|
||||||
if (!lifecycleEvent) return;
|
|
||||||
setActive(lifecycleEvent.current === "visible");
|
|
||||||
|
|
||||||
// Preserve the original DOM events for existing pages. New code should
|
|
||||||
// use the typed SDK lifecycle returned by this bridge.
|
|
||||||
window.dispatchEvent(
|
|
||||||
new CustomEvent("wasmeld:lifecycle", {
|
|
||||||
detail: { phase },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
window.dispatchEvent(new Event(`wasmeld:${phase}`));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
window.addEventListener("message", receive);
|
|
||||||
const unload = (event: PageTransitionEvent) => {
|
|
||||||
// A persisted document is frozen in the back-forward cache, not unloaded.
|
|
||||||
// Its Host and iframe will resume together when the browser restores it.
|
|
||||||
if (event.persisted) return;
|
|
||||||
if (pageLifecycle.dispatch("unload", "document")) setActive(false);
|
|
||||||
};
|
|
||||||
window.addEventListener("pagehide", unload);
|
|
||||||
notifyReady(view);
|
|
||||||
onCleanup(() => window.removeEventListener("pagehide", unload));
|
|
||||||
});
|
|
||||||
|
|
||||||
onCleanup(() => {
|
|
||||||
window.removeEventListener("message", receive);
|
|
||||||
pageLifecycle.dispatch("unload", "document");
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
state,
|
|
||||||
active,
|
|
||||||
lifecycle: pageLifecycle.lifecycle,
|
|
||||||
command(command: PageCommand) {
|
|
||||||
sendCommand(view, command);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function eventForPhase(phase: "activate" | "deactivate" | "dispose"): PageLifecycleEventType {
|
|
||||||
switch (phase) {
|
|
||||||
case "activate":
|
|
||||||
return "show";
|
|
||||||
case "deactivate":
|
|
||||||
return "hide";
|
|
||||||
case "dispose":
|
|
||||||
return "unload";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user