Compare commits
21 Commits
a5718da1da
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| b5aa008729 | |||
| 7939285e9f | |||
| ef2eddc31f | |||
| c9a594d669 | |||
| 427abd15f9 | |||
| 8c08adc9c2 | |||
| 9ff3223c44 | |||
| 23fa6137ff | |||
| 868da49a52 | |||
| 391901a71a | |||
| 88a7fba0b0 | |||
| 93f4a505ba | |||
| 1b2c50c951 | |||
| ccad05fa5a | |||
| f6ad94e948 | |||
| 70ee5e33da | |||
| 4e32a1246b | |||
| 223ead37c8 | |||
| aea42ba0a6 | |||
| 73d7c76446 | |||
| a1b808013a |
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"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
+21
@@ -1844,6 +1844,25 @@ dependencies = [
|
||||
"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]]
|
||||
name = "index_vec"
|
||||
version = "0.1.4"
|
||||
@@ -4709,6 +4728,8 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"base64 0.23.0",
|
||||
"include_dir",
|
||||
"mime_guess",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
+3
-1
@@ -23,6 +23,8 @@ license = "Apache-2.0"
|
||||
[workspace.dependencies]
|
||||
axum = { version = "0.8.9", features = ["multipart"] }
|
||||
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"] }
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
serde_json = "1.0.149"
|
||||
@@ -31,7 +33,7 @@ sha2 = "0.10.9"
|
||||
thiserror = "2.0.17"
|
||||
toasty = "=0.9.0"
|
||||
toasty-driver-turso = "=0.9.0"
|
||||
tokio = { version = "1.53.1", features = ["macros", "net", "rt-multi-thread", "signal", "sync"] }
|
||||
tokio = { version = "1.53.1", features = ["io-util", "macros", "net", "rt-multi-thread", "signal", "sync", "time"] }
|
||||
toml = "0.9.8"
|
||||
tower-http = { version = "0.7.0", features = ["cors", "trace"] }
|
||||
tracing = "0.1.44"
|
||||
|
||||
@@ -29,14 +29,20 @@ Host driver <- validated operation <- raw effect <-
|
||||
线程外;协议级驱动以及文件监听、串口、系统信号等其它来源通过独立版本的 WIT 能力演进,
|
||||
不需要扩大基础 service world。
|
||||
|
||||
Console 已接入 Timer 与 TCP Host Driver:平台配置的 Timer、Listener 和 Stream 在
|
||||
Tokio task 中等待,并通过有界队列交给专用 `ResidentSupervisor` 线程串行进入
|
||||
`ResidentSession`。TCP 地址执行默认拒绝的 Host policy,字节流支持有界读写、半关闭和
|
||||
pause/resume;服务停止、重启和 Runtime Deployment 恢复会按 Revision 创建或释放 Host
|
||||
资源。UDP 和 Unix Driver 仍待接入。
|
||||
|
||||
## 结构
|
||||
|
||||
```text
|
||||
crates/wasmeld-runtime Component Runtime 与 Sandbox
|
||||
crates/wasmeld-console 管理 API 与持久化
|
||||
└── web/ Solid + Tailwind v4 管理面
|
||||
crates/wasmeld-package wasmeld CLI、组件包与 WIT 依赖管理
|
||||
components/ 示例 Component
|
||||
console/ TanStack Start 管理面
|
||||
wit/ Wasmeld WIT package 源码
|
||||
```
|
||||
|
||||
@@ -65,13 +71,26 @@ curl http://127.0.0.1:8081/v1/services/echo/invoke \
|
||||
--data-binary 'hello'
|
||||
```
|
||||
|
||||
管理面:
|
||||
管理面开发服务器:
|
||||
|
||||
```bash
|
||||
cd console
|
||||
cd crates/wasmeld-console/web
|
||||
npm ci
|
||||
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`:
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
//! 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 {
|
||||
// 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!({
|
||||
path: "wit",
|
||||
world: "clock-probe-component",
|
||||
|
||||
@@ -4,12 +4,12 @@ schema_version = 1
|
||||
name = "wasmeld:clock"
|
||||
version = "0.1.0"
|
||||
source = "path+../../wit/clock"
|
||||
sha256 = "93079c69d1b9cb3afc28da6fe4a7e37f724d0a09e780c847f8524d3920a90960"
|
||||
sha256 = "43ece485a550eca94cf06cb711f0b42c9f4977d275e9a042c3ac84eeb1f941e1"
|
||||
replaced = true
|
||||
|
||||
[[package]]
|
||||
name = "wasmeld:service"
|
||||
version = "0.1.0"
|
||||
source = "path+../../wit/service"
|
||||
sha256 = "d5497307bbcd1e159f7707f385b488a6f2e26362d5256d5bc1080ac603a51305"
|
||||
sha256 = "1b2069606ccbf5202789667570bc824f702f34b147aa38e81c2ab677444ffa0e"
|
||||
replaced = true
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
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 {
|
||||
include wasmeld:service/service-component@0.1.0;
|
||||
import wasmeld:clock/monotonic-clock@0.1.0;
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
//! 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};
|
||||
|
||||
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!({
|
||||
path: "wit",
|
||||
world: "counter-component",
|
||||
|
||||
@@ -4,5 +4,5 @@ schema_version = 1
|
||||
name = "wasmeld:service"
|
||||
version = "0.1.0"
|
||||
source = "path+../../wit/service"
|
||||
sha256 = "d5497307bbcd1e159f7707f385b488a6f2e26362d5256d5bc1080ac603a51305"
|
||||
sha256 = "1b2069606ccbf5202789667570bc824f702f34b147aa38e81c2ab677444ffa0e"
|
||||
replaced = true
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package component:counter@0.1.0;
|
||||
|
||||
/// Counter only needs the base service lifecycle and imports no Host capability.
|
||||
world counter-component {
|
||||
include wasmeld:service/service-component@0.1.0;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
//! Smallest request/response Component example: it returns input bytes unchanged.
|
||||
|
||||
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!({
|
||||
path: "wit",
|
||||
world: "echo-component",
|
||||
|
||||
@@ -4,5 +4,5 @@ schema_version = 1
|
||||
name = "wasmeld:service"
|
||||
version = "0.1.0"
|
||||
source = "path+../../wit/service"
|
||||
sha256 = "d5497307bbcd1e159f7707f385b488a6f2e26362d5256d5bc1080ac603a51305"
|
||||
sha256 = "1b2069606ccbf5202789667570bc824f702f34b147aa38e81c2ab677444ffa0e"
|
||||
replaced = true
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package component:echo@0.1.0;
|
||||
|
||||
/// Echo only needs the base service lifecycle and imports no Host capability.
|
||||
world echo-component {
|
||||
include wasmeld:service/service-component@0.1.0;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
//! 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 {
|
||||
// Compile the versioned WIT world into canonical ABI Rust bindings.
|
||||
wit_bindgen::generate!({
|
||||
path: "wit",
|
||||
world: "fault-component",
|
||||
@@ -13,6 +19,8 @@ impl bindings::Guest for Fault {
|
||||
}
|
||||
|
||||
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");
|
||||
Ok(input)
|
||||
}
|
||||
|
||||
@@ -4,5 +4,5 @@ schema_version = 1
|
||||
name = "wasmeld:service"
|
||||
version = "0.1.0"
|
||||
source = "path+../../wit/service"
|
||||
sha256 = "d5497307bbcd1e159f7707f385b488a6f2e26362d5256d5bc1080ac603a51305"
|
||||
sha256 = "1b2069606ccbf5202789667570bc824f702f34b147aa38e81c2ab677444ffa0e"
|
||||
replaced = true
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package component:fault@0.1.0;
|
||||
|
||||
/// Fault fixture exports only the base service lifecycle.
|
||||
world fault-component {
|
||||
include wasmeld:service/service-component@0.1.0;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
//! 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",
|
||||
|
||||
@@ -4,12 +4,12 @@ schema_version = 1
|
||||
name = "wasmeld:kv"
|
||||
version = "0.1.0"
|
||||
source = "path+../../wit/kv"
|
||||
sha256 = "6dc3a84dc03c7104aa8b305518466c95e4440e1031c4bef3b59843f40f24d0fb"
|
||||
sha256 = "10dc81b1f04bd9c8f7b324a96e0c1d24d5af0c2dabfc723d003b8729cfd933d9"
|
||||
replaced = true
|
||||
|
||||
[[package]]
|
||||
name = "wasmeld:service"
|
||||
version = "0.1.0"
|
||||
source = "path+../../wit/service"
|
||||
sha256 = "d5497307bbcd1e159f7707f385b488a6f2e26362d5256d5bc1080ac603a51305"
|
||||
sha256 = "1b2069606ccbf5202789667570bc824f702f34b147aa38e81c2ab677444ffa0e"
|
||||
replaced = true
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
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,6 +1,14 @@
|
||||
//! 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",
|
||||
@@ -35,6 +43,8 @@ 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 {
|
||||
@@ -60,9 +70,12 @@ impl ResidentGuest for ResidentProbe {
|
||||
command: source.kind,
|
||||
payload: source.payload,
|
||||
})],
|
||||
// A TCP FIN is a read-half close. This fixture has no additional
|
||||
// response to send, so it explicitly asks the Host to close the
|
||||
// remaining write half and release the stream resource.
|
||||
Event::StreamHalfClosed(stream_id) => vec![Effect::CloseStream(stream_id)],
|
||||
Event::StreamOpened(_)
|
||||
| Event::StreamWritable(_)
|
||||
| Event::StreamHalfClosed(_)
|
||||
| Event::StreamClosed(_)
|
||||
| Event::Shutdown => Vec::new(),
|
||||
})
|
||||
|
||||
@@ -4,12 +4,12 @@ schema_version = 1
|
||||
name = "wasmeld:resident"
|
||||
version = "0.1.0"
|
||||
source = "path+../../wit/resident"
|
||||
sha256 = "683a3c7196a6dd77b3f619227aa093430ff6995c097d39bb82d501b7c59a196c"
|
||||
sha256 = "a13728b0e6f000c03eb0f88e2e54bad1c6e5160ac3ca4b9e87619247c0043d1b"
|
||||
replaced = true
|
||||
|
||||
[[package]]
|
||||
name = "wasmeld:service"
|
||||
version = "0.1.0"
|
||||
source = "path+../../wit/service"
|
||||
sha256 = "d5497307bbcd1e159f7707f385b488a6f2e26362d5256d5bc1080ac603a51305"
|
||||
sha256 = "1b2069606ccbf5202789667570bc824f702f34b147aa38e81c2ab677444ffa0e"
|
||||
replaced = true
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
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,4 +1,11 @@
|
||||
//! 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 {
|
||||
// Compile the base service WIT contract into guest and export adapters.
|
||||
wit_bindgen::generate!({
|
||||
path: "wit",
|
||||
world: "spin-component",
|
||||
|
||||
@@ -4,5 +4,5 @@ schema_version = 1
|
||||
name = "wasmeld:service"
|
||||
version = "0.1.0"
|
||||
source = "path+../../wit/service"
|
||||
sha256 = "d5497307bbcd1e159f7707f385b488a6f2e26362d5256d5bc1080ac603a51305"
|
||||
sha256 = "1b2069606ccbf5202789667570bc824f702f34b147aa38e81c2ab677444ffa0e"
|
||||
replaced = true
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package component:spin@0.1.0;
|
||||
|
||||
/// Spin fixture exports only the base service lifecycle.
|
||||
world spin-component {
|
||||
include wasmeld:service/service-component@0.1.0;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
//! 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 {
|
||||
// The declared service WIT contains no WASI clock capability; the direct
|
||||
// standard-library use below deliberately creates an undeclared import.
|
||||
wit_bindgen::generate!({
|
||||
path: "wit",
|
||||
world: "wasi-clock-probe-component",
|
||||
|
||||
@@ -4,5 +4,5 @@ schema_version = 1
|
||||
name = "wasmeld:service"
|
||||
version = "0.1.0"
|
||||
source = "path+../../wit/service"
|
||||
sha256 = "d5497307bbcd1e159f7707f385b488a6f2e26362d5256d5bc1080ac603a51305"
|
||||
sha256 = "1b2069606ccbf5202789667570bc824f702f34b147aa38e81c2ab677444ffa0e"
|
||||
replaced = true
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
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 {
|
||||
include wasmeld:service/service-component@0.1.0;
|
||||
}
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxfmt/configuration_schema.json",
|
||||
"ignorePatterns": ["src/routeTree.gen.ts"]
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"$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
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
# 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 服务端渲染和静态资源。
|
||||
@@ -1,37 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -1,379 +0,0 @@
|
||||
import {
|
||||
ArrowRight,
|
||||
Clock3,
|
||||
CloudUpload,
|
||||
FileCode2,
|
||||
Package,
|
||||
Play,
|
||||
RadioTower,
|
||||
RefreshCw,
|
||||
ShieldCheck,
|
||||
SquareTerminal,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { FormEvent, ReactNode, useState } from "react";
|
||||
import { InvokeResult, RegisterComponentInput } from "../api";
|
||||
import {
|
||||
FormattedInvocationOutput,
|
||||
Service,
|
||||
formatInvocationOutput,
|
||||
invocationInputSize,
|
||||
parseInvocationInput,
|
||||
} from "../console-model";
|
||||
|
||||
function DialogFrame({
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
onClose,
|
||||
closeDisabled = false,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
icon: ReactNode;
|
||||
onClose: () => void;
|
||||
closeDisabled?: boolean;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="dialog-backdrop">
|
||||
<dialog
|
||||
open
|
||||
className="dialog"
|
||||
aria-label={title}
|
||||
onCancel={(event) => {
|
||||
if (closeDisabled) event.preventDefault();
|
||||
else onClose();
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Escape" && !closeDisabled) onClose();
|
||||
}}
|
||||
>
|
||||
<div className="dialog-header">
|
||||
<div className="dialog-title">
|
||||
<span>{icon}</span>
|
||||
<div>
|
||||
<h2>{title}</h2>
|
||||
<p>{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="icon-button"
|
||||
type="button"
|
||||
aria-label="关闭"
|
||||
title="关闭"
|
||||
disabled={closeDisabled}
|
||||
onClick={onClose}
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
{children}
|
||||
</dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ActivateDeploymentDialog({
|
||||
service,
|
||||
currentRevision,
|
||||
submitting,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: {
|
||||
service: Service;
|
||||
currentRevision: string | null;
|
||||
submitting: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
}) {
|
||||
return (
|
||||
<DialogFrame
|
||||
title="切换对外版本"
|
||||
description="更新该服务在 Gateway 上接收请求的活动版本。"
|
||||
icon={<RadioTower size={19} />}
|
||||
onClose={onClose}
|
||||
closeDisabled={submitting}
|
||||
>
|
||||
<div className="deployment-confirmation">
|
||||
<div className="deployment-route">
|
||||
<div>
|
||||
<span>当前版本</span>
|
||||
<strong>{currentRevision ?? "尚未部署"}</strong>
|
||||
</div>
|
||||
<ArrowRight size={18} />
|
||||
<div className="deployment-target">
|
||||
<span>目标版本</span>
|
||||
<strong>{service.revision}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<p>
|
||||
Wasmeld 将先确认 {service.id}@{service.revision} 的 Actor 可用,再更新对外路由。
|
||||
</p>
|
||||
</div>
|
||||
<div className="dialog-footer">
|
||||
<button className="secondary-button" type="button" disabled={submitting} onClick={onClose}>
|
||||
取消
|
||||
</button>
|
||||
<button className="primary-button" type="button" disabled={submitting} onClick={onConfirm}>
|
||||
{submitting ? <RefreshCw className="spin" size={16} /> : <RadioTower size={16} />}
|
||||
{submitting ? "正在切换" : "确认切换"}
|
||||
</button>
|
||||
</div>
|
||||
</DialogFrame>
|
||||
);
|
||||
}
|
||||
|
||||
export function DeployDialog({
|
||||
onClose,
|
||||
onRegister,
|
||||
}: {
|
||||
onClose: () => void;
|
||||
onRegister: (input: RegisterComponentInput) => Promise<void>;
|
||||
}) {
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const fileName = file?.name ?? "";
|
||||
|
||||
async function submit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (!file) return;
|
||||
if (!file.name.toLowerCase().endsWith(".wasmpkg")) {
|
||||
setError("请选择 .wasmpkg 组件包");
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
setError("");
|
||||
try {
|
||||
await onRegister({ file });
|
||||
} catch (submitError) {
|
||||
setError(submitError instanceof Error ? submitError.message : "组件注册失败");
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<DialogFrame
|
||||
title="注册组件包"
|
||||
description="组件元数据与运行边界由平台自动校验。"
|
||||
icon={<CloudUpload size={19} />}
|
||||
onClose={onClose}
|
||||
>
|
||||
<form className="dialog-form" onSubmit={submit}>
|
||||
<label className={fileName ? "upload-zone has-file" : "upload-zone"}>
|
||||
<input
|
||||
type="file"
|
||||
accept=".wasmpkg,application/zip,application/vnd.wasm.component-package"
|
||||
onChange={(event) => {
|
||||
setFile(event.target.files?.[0] ?? null);
|
||||
setError("");
|
||||
}}
|
||||
/>
|
||||
{fileName ? <Package size={28} /> : <CloudUpload size={28} />}
|
||||
<strong>{fileName || "选择组件包"}</strong>
|
||||
<span>
|
||||
{file
|
||||
? `${(file.size / 1024 / 1024).toFixed(2)} MB · 等待校验`
|
||||
: ".wasmpkg · 最大 64 MB"}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{error && <div className="form-error">{error}</div>}
|
||||
|
||||
<div className="dialog-footer">
|
||||
<button className="secondary-button" type="button" onClick={onClose}>
|
||||
取消
|
||||
</button>
|
||||
<button className="primary-button" type="submit" disabled={submitting || !fileName}>
|
||||
{submitting ? <RefreshCw className="spin" size={16} /> : <ShieldCheck size={16} />}
|
||||
{submitting ? "正在注册" : "校验并注册"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogFrame>
|
||||
);
|
||||
}
|
||||
|
||||
export function WitPackageDialog({
|
||||
onClose,
|
||||
onPublish,
|
||||
}: {
|
||||
onClose: () => void;
|
||||
onPublish: (file: File) => Promise<void>;
|
||||
}) {
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const fileName = file?.name ?? "";
|
||||
|
||||
async function submit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (!file) return;
|
||||
if (!file.name.toLowerCase().endsWith(".wasm")) {
|
||||
setError("请选择由 wasmeld wit build 生成的 .wasm 文件");
|
||||
return;
|
||||
}
|
||||
if (file.size > 4 * 1024 * 1024) {
|
||||
setError("WIT 包不能超过 4 MB");
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
setError("");
|
||||
try {
|
||||
await onPublish(file);
|
||||
} catch (submitError) {
|
||||
setError(submitError instanceof Error ? submitError.message : "WIT 包发布失败");
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<DialogFrame
|
||||
title="发布 WIT 包"
|
||||
description="包名、语义版本和直接依赖将从二进制 WIT Package 读取。"
|
||||
icon={<FileCode2 size={19} />}
|
||||
onClose={onClose}
|
||||
>
|
||||
<form className="dialog-form" onSubmit={submit}>
|
||||
<label className={fileName ? "upload-zone has-file" : "upload-zone"}>
|
||||
<input
|
||||
type="file"
|
||||
accept=".wasm,application/wasm"
|
||||
onChange={(event) => {
|
||||
setFile(event.target.files?.[0] ?? null);
|
||||
setError("");
|
||||
}}
|
||||
/>
|
||||
{fileName ? <FileCode2 size={28} /> : <CloudUpload size={28} />}
|
||||
<strong>{fileName || "选择二进制 WIT Package"}</strong>
|
||||
<span>
|
||||
{file ? `${(file.size / 1024).toFixed(1)} KB · 等待校验` : ".wasm · 最大 4 MB"}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{error && <div className="form-error">{error}</div>}
|
||||
|
||||
<div className="dialog-footer">
|
||||
<button className="secondary-button" type="button" onClick={onClose}>
|
||||
取消
|
||||
</button>
|
||||
<button className="primary-button" type="submit" disabled={submitting || !fileName}>
|
||||
{submitting ? <RefreshCw className="spin" size={16} /> : <ShieldCheck size={16} />}
|
||||
{submitting ? "正在发布" : "校验并发布"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogFrame>
|
||||
);
|
||||
}
|
||||
|
||||
export function InvokeDialog({
|
||||
service,
|
||||
onClose,
|
||||
onInvoke,
|
||||
}: {
|
||||
service: Service;
|
||||
onClose: () => void;
|
||||
onInvoke: (service: Service, input: Uint8Array) => Promise<InvokeResult>;
|
||||
}) {
|
||||
const [input, setInput] = useState(service.id === "echo" ? "hello wasm" : "");
|
||||
const [format, setFormat] = useState<"utf8" | "hex">("utf8");
|
||||
const [output, setOutput] = useState("");
|
||||
const [outputBytes, setOutputBytes] = useState<number | null>(null);
|
||||
const [outputFormat, setOutputFormat] = useState<FormattedInvocationOutput | null>(null);
|
||||
const [running, setRunning] = useState(false);
|
||||
const [failed, setFailed] = useState(false);
|
||||
const [latency, setLatency] = useState<number | null>(null);
|
||||
|
||||
async function invoke() {
|
||||
setRunning(true);
|
||||
setOutput("");
|
||||
setOutputBytes(null);
|
||||
setOutputFormat(null);
|
||||
setFailed(false);
|
||||
setLatency(null);
|
||||
try {
|
||||
const result = await onInvoke(service, parseInvocationInput(input, format));
|
||||
const formatted = formatInvocationOutput(service, result.output, format);
|
||||
setOutput(formatted.text);
|
||||
setOutputBytes(result.outputBytes);
|
||||
setOutputFormat(formatted);
|
||||
setLatency(result.latencyMs);
|
||||
} catch (invokeError) {
|
||||
setFailed(true);
|
||||
setOutput(invokeError instanceof Error ? invokeError.message : "组件调用失败");
|
||||
} finally {
|
||||
setRunning(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<DialogFrame
|
||||
title={`调用 ${service.id}@${service.revision}`}
|
||||
description="请求将进入该实例的串行 mailbox。"
|
||||
icon={<SquareTerminal size={19} />}
|
||||
onClose={onClose}
|
||||
>
|
||||
<div className="invoke-body">
|
||||
<div className="invoke-toolbar">
|
||||
<div className="segmented" aria-label="输入编码">
|
||||
<button
|
||||
type="button"
|
||||
className={format === "utf8" ? "active" : ""}
|
||||
onClick={() => setFormat("utf8")}
|
||||
>
|
||||
UTF-8
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={format === "hex" ? "active" : ""}
|
||||
onClick={() => setFormat("hex")}
|
||||
>
|
||||
HEX
|
||||
</button>
|
||||
</div>
|
||||
<span>{invocationInputSize(input, format)} B</span>
|
||||
</div>
|
||||
<label className="code-field">
|
||||
<span>输入</span>
|
||||
<textarea
|
||||
value={input}
|
||||
onChange={(event) => setInput(event.target.value)}
|
||||
spellCheck={false}
|
||||
placeholder={format === "utf8" ? "输入请求内容" : "00 ff a1"}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className={failed ? "invoke-output failed" : "invoke-output"}>
|
||||
<div>
|
||||
<span>输出</span>
|
||||
{output && latency !== null && outputBytes !== null && outputFormat && (
|
||||
<div className="invoke-output-meta">
|
||||
<span>
|
||||
{outputBytes} B · {outputFormat.format}
|
||||
{outputFormat.automatic ? " · 自动" : ""}
|
||||
</span>
|
||||
<small>
|
||||
<Clock3 size={13} />
|
||||
{latency} ms
|
||||
</small>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<pre>{running ? "invoking..." : output || "等待调用"}</pre>
|
||||
</div>
|
||||
</div>
|
||||
<div className="dialog-footer">
|
||||
<button className="secondary-button" type="button" onClick={onClose}>
|
||||
关闭
|
||||
</button>
|
||||
<button className="primary-button" type="button" disabled={running} onClick={invoke}>
|
||||
{running ? <RefreshCw className="spin" size={16} /> : <Play size={16} />}
|
||||
执行调用
|
||||
</button>
|
||||
</div>
|
||||
</DialogFrame>
|
||||
);
|
||||
}
|
||||
@@ -1,986 +0,0 @@
|
||||
import {
|
||||
Activity,
|
||||
AlertTriangle,
|
||||
Archive,
|
||||
ArrowRight,
|
||||
Box,
|
||||
Braces,
|
||||
Check,
|
||||
CircleStop,
|
||||
CloudUpload,
|
||||
Cpu,
|
||||
Download,
|
||||
FileCode2,
|
||||
Package,
|
||||
Play,
|
||||
RadioTower,
|
||||
RefreshCw,
|
||||
RotateCcw,
|
||||
Search,
|
||||
Server,
|
||||
SquareTerminal,
|
||||
X,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import { ReactNode, useState } from "react";
|
||||
import {
|
||||
BackendCapability,
|
||||
BackendRuntime,
|
||||
BackendWitPackage,
|
||||
witPackageDownloadUrl,
|
||||
} from "../api";
|
||||
import {
|
||||
EventTone,
|
||||
RuntimeEvent,
|
||||
Service,
|
||||
ServiceStatus,
|
||||
STATUS_META,
|
||||
formatDuration,
|
||||
serviceKey,
|
||||
} from "../console-model";
|
||||
|
||||
function PageHeading({
|
||||
eyebrow,
|
||||
title,
|
||||
description,
|
||||
actions,
|
||||
}: {
|
||||
eyebrow: string;
|
||||
title: string;
|
||||
description: string;
|
||||
actions?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="page-heading">
|
||||
<div>
|
||||
<span className="eyebrow">{eyebrow}</span>
|
||||
<h1>{title}</h1>
|
||||
<p>{description}</p>
|
||||
</div>
|
||||
{actions && <div className="heading-actions">{actions}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Overview({
|
||||
services,
|
||||
filteredServices,
|
||||
events,
|
||||
running,
|
||||
faulted,
|
||||
totalCalls,
|
||||
deploymentCount,
|
||||
runtime,
|
||||
selectedService,
|
||||
onSelect,
|
||||
onDeploy,
|
||||
onInvoke,
|
||||
onStatus,
|
||||
onActivate,
|
||||
onViewAll,
|
||||
onRefresh,
|
||||
onRuntimeAction,
|
||||
runtimeAction,
|
||||
deploymentAction,
|
||||
}: {
|
||||
services: Service[];
|
||||
filteredServices: Service[];
|
||||
events: RuntimeEvent[];
|
||||
running: number;
|
||||
faulted: number;
|
||||
totalCalls: number;
|
||||
deploymentCount: number;
|
||||
runtime: BackendRuntime | null;
|
||||
selectedService: Service | null;
|
||||
onSelect: (service: Service) => void;
|
||||
onDeploy: () => void;
|
||||
onInvoke: (service: Service) => void;
|
||||
onStatus: (service: Service, status: ServiceStatus) => void;
|
||||
onActivate: (service: Service) => void;
|
||||
onViewAll: () => void;
|
||||
onRefresh: () => void;
|
||||
onRuntimeAction: (action: "start" | "stop" | "restart") => void;
|
||||
runtimeAction: "start" | "stop" | "restart" | null;
|
||||
deploymentAction: string | null;
|
||||
}) {
|
||||
const errorCount = services.reduce((sum, service) => sum + service.errors, 0);
|
||||
const latencies = services
|
||||
.map((service) => service.latencyMs)
|
||||
.filter((latency): latency is number => latency !== null);
|
||||
const latestLatency = latencies.length ? Math.max(...latencies) : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeading
|
||||
eyebrow="LOCAL RUNTIME"
|
||||
title="运行概览"
|
||||
description={`${services.length} 个已注册版本,${running} 个 Actor 正在运行。`}
|
||||
actions={
|
||||
<>
|
||||
<button
|
||||
className="icon-button"
|
||||
type="button"
|
||||
aria-label="刷新"
|
||||
title="刷新"
|
||||
onClick={onRefresh}
|
||||
>
|
||||
<RefreshCw size={17} />
|
||||
</button>
|
||||
<button className="primary-button" type="button" onClick={onDeploy}>
|
||||
<CloudUpload size={17} />
|
||||
注册组件
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<section className="metric-grid" aria-label="关键指标">
|
||||
<Metric
|
||||
icon={<Package size={18} />}
|
||||
label="服务版本"
|
||||
value={services.length.toString()}
|
||||
note={`${deploymentCount} 个对外服务`}
|
||||
tone="cyan"
|
||||
/>
|
||||
<Metric
|
||||
icon={<Activity size={18} />}
|
||||
label="运行实例"
|
||||
value={running.toString()}
|
||||
note={`${services.length - running} 个未运行`}
|
||||
tone="green"
|
||||
/>
|
||||
<Metric
|
||||
icon={<Zap size={18} />}
|
||||
label="累计调用"
|
||||
value={totalCalls.toLocaleString("zh-CN")}
|
||||
note="持久化统计"
|
||||
tone="amber"
|
||||
/>
|
||||
<Metric
|
||||
icon={<AlertTriangle size={18} />}
|
||||
label="异常"
|
||||
value={(faulted + errorCount).toString()}
|
||||
note={faulted + errorCount ? "需要检查" : "无异常"}
|
||||
tone="coral"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="runtime-band">
|
||||
<div className="runtime-band-title">
|
||||
<div className="runtime-symbol">
|
||||
<Cpu size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<strong>Wasmeld Runtime</strong>
|
||||
<span>
|
||||
{runtime?.status === "running"
|
||||
? "Engine 已启用 fuel 与 epoch interruption"
|
||||
: "Engine 与所有 Actor 已释放"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="runtime-controls" aria-label="Runtime 生命周期">
|
||||
{runtime?.status === "stopped" ? (
|
||||
<button
|
||||
className="icon-button"
|
||||
type="button"
|
||||
aria-label="启动 Runtime"
|
||||
title="启动 Runtime"
|
||||
disabled={runtimeAction !== null}
|
||||
onClick={() => onRuntimeAction("start")}
|
||||
>
|
||||
<Play size={15} />
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
className="icon-button"
|
||||
type="button"
|
||||
aria-label="重启 Runtime"
|
||||
title="重启 Runtime"
|
||||
disabled={runtimeAction !== null || !runtime}
|
||||
onClick={() => onRuntimeAction("restart")}
|
||||
>
|
||||
<RotateCcw size={15} />
|
||||
</button>
|
||||
<button
|
||||
className="icon-button runtime-stop-button"
|
||||
type="button"
|
||||
aria-label="停止 Runtime"
|
||||
title="停止 Runtime"
|
||||
disabled={runtimeAction !== null || !runtime}
|
||||
onClick={() => onRuntimeAction("stop")}
|
||||
>
|
||||
<CircleStop size={15} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<RuntimeStat
|
||||
label="已加载版本"
|
||||
value={(runtime?.registered_services ?? 0).toString()}
|
||||
detail={`${runtime?.managed_services ?? services.length} 个受管制品`}
|
||||
/>
|
||||
<RuntimeStat
|
||||
label="活跃 Store"
|
||||
value={(runtime?.running_services ?? running).toString()}
|
||||
detail="串行 Actor"
|
||||
/>
|
||||
<RuntimeStat
|
||||
label="最近延迟"
|
||||
value={latestLatency === null ? "—" : `${latestLatency} ms`}
|
||||
detail="各服务最近调用"
|
||||
/>
|
||||
<RuntimeStat
|
||||
label="运行时间"
|
||||
value={runtime?.status === "running" ? formatDuration(runtime.uptime_ms) : "—"}
|
||||
detail="当前 Runtime 实例"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<div className="overview-grid">
|
||||
<section className="panel service-panel">
|
||||
<div className="panel-header">
|
||||
<div>
|
||||
<h2>服务状态</h2>
|
||||
<p>已注册版本及当前 Actor 状态</p>
|
||||
</div>
|
||||
<button className="text-button" type="button" onClick={onViewAll}>
|
||||
查看全部
|
||||
<ArrowRight size={15} />
|
||||
</button>
|
||||
</div>
|
||||
<ServiceTable
|
||||
services={filteredServices.slice(0, 5)}
|
||||
selectedKey={selectedService ? serviceKey(selectedService) : ""}
|
||||
onSelect={onSelect}
|
||||
onInvoke={onInvoke}
|
||||
onStatus={onStatus}
|
||||
onActivate={onActivate}
|
||||
canActivate={runtime?.status === "running"}
|
||||
deploymentAction={deploymentAction}
|
||||
compact
|
||||
/>
|
||||
</section>
|
||||
|
||||
<aside className="panel event-panel">
|
||||
<div className="panel-header">
|
||||
<div>
|
||||
<h2>实时事件</h2>
|
||||
<p>Runtime 生命周期与调用结果</p>
|
||||
</div>
|
||||
<span className="live-label">
|
||||
<span />
|
||||
LIVE
|
||||
</span>
|
||||
</div>
|
||||
<EventList events={events.slice(0, 5)} />
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
{selectedService && (
|
||||
<section className="detail-strip">
|
||||
<div className="detail-identity">
|
||||
<div className="artifact-icon">
|
||||
<FileCode2 size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<span>当前选中</span>
|
||||
<strong>
|
||||
{selectedService.id}
|
||||
<small>@{selectedService.revision}</small>
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
<DetailValue label="内存上限" value={`${selectedService.memoryMb} MB`} />
|
||||
<DetailValue label="Fuel / 调用" value={selectedService.fuel} />
|
||||
<DetailValue label="Deadline" value={`${selectedService.deadlineMs} ms`} />
|
||||
<DetailValue label="Mailbox" value={selectedService.mailbox.toString()} />
|
||||
<div className="detail-actions">
|
||||
{!selectedService.active && (
|
||||
<button
|
||||
className="secondary-button"
|
||||
type="button"
|
||||
disabled={runtime?.status !== "running" || deploymentAction !== null}
|
||||
onClick={() => onActivate(selectedService)}
|
||||
>
|
||||
<RadioTower size={16} />
|
||||
设为对外
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="secondary-button"
|
||||
type="button"
|
||||
disabled={selectedService.status !== "running"}
|
||||
onClick={() => onInvoke(selectedService)}
|
||||
>
|
||||
<SquareTerminal size={16} />
|
||||
测试调用
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Metric({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
note,
|
||||
tone,
|
||||
}: {
|
||||
icon: ReactNode;
|
||||
label: string;
|
||||
value: string;
|
||||
note: string;
|
||||
tone: string;
|
||||
}) {
|
||||
return (
|
||||
<article className="metric">
|
||||
<div className={`metric-icon metric-${tone}`}>{icon}</div>
|
||||
<div className="metric-copy">
|
||||
<span>{label}</span>
|
||||
<strong>{value}</strong>
|
||||
</div>
|
||||
<small>{note}</small>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function RuntimeStat({ label, value, detail }: { label: string; value: string; detail: string }) {
|
||||
return (
|
||||
<div className="runtime-stat">
|
||||
<span>{label}</span>
|
||||
<strong>{value}</strong>
|
||||
<small>{detail}</small>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailValue({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="detail-value">
|
||||
<span>{label}</span>
|
||||
<strong>{value}</strong>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: ServiceStatus }) {
|
||||
const meta = STATUS_META[status];
|
||||
return (
|
||||
<span className={`status-badge ${meta.className}`}>
|
||||
<span />
|
||||
{meta.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function CapabilitySummary({ capabilities }: { capabilities: BackendCapability[] }) {
|
||||
if (capabilities.length === 0) {
|
||||
return <span className="capability-empty">无</span>;
|
||||
}
|
||||
|
||||
const [first, ...remaining] = capabilities;
|
||||
return (
|
||||
<span
|
||||
className="capability-summary"
|
||||
title={capabilities.map((capability) => capability.interface).join("\n")}
|
||||
>
|
||||
<Braces size={12} />
|
||||
<span>
|
||||
{first.package}/{first.name} @{first.version}
|
||||
</span>
|
||||
{remaining.length > 0 && <small>+{remaining.length}</small>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function ServiceTable({
|
||||
services,
|
||||
selectedKey,
|
||||
onSelect,
|
||||
onInvoke,
|
||||
onStatus,
|
||||
onActivate,
|
||||
canActivate,
|
||||
deploymentAction,
|
||||
compact = false,
|
||||
}: {
|
||||
services: Service[];
|
||||
selectedKey?: string;
|
||||
onSelect: (service: Service) => void;
|
||||
onInvoke: (service: Service) => void;
|
||||
onStatus: (service: Service, status: ServiceStatus) => void;
|
||||
onActivate: (service: Service) => void;
|
||||
canActivate: boolean;
|
||||
deploymentAction: string | null;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
if (services.length === 0) {
|
||||
return (
|
||||
<div className="empty-state">
|
||||
<Search size={22} />
|
||||
<strong>没有匹配的服务</strong>
|
||||
<span>调整搜索关键词或状态筛选。</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="table-scroll">
|
||||
<table className={`data-table ${compact ? "service-table-compact" : "service-table-full"}`}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>服务</th>
|
||||
<th>状态</th>
|
||||
{!compact && <th>制品</th>}
|
||||
{!compact && <th>Host 能力</th>}
|
||||
<th>调用</th>
|
||||
<th>延迟</th>
|
||||
<th>
|
||||
<span className="sr-only">操作</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{services.map((service) => (
|
||||
<tr
|
||||
key={`${service.id}-${service.revision}`}
|
||||
className={selectedKey === serviceKey(service) ? "selected" : ""}
|
||||
>
|
||||
<td>
|
||||
<button
|
||||
className="service-cell service-select"
|
||||
type="button"
|
||||
aria-label={`选择 ${service.id}@${service.revision}`}
|
||||
onClick={() => onSelect(service)}
|
||||
>
|
||||
<span className="service-glyph">
|
||||
<Box size={16} />
|
||||
</span>
|
||||
<span>
|
||||
<span className="service-title">
|
||||
<strong>{service.id}</strong>
|
||||
{service.active && (
|
||||
<span className="active-deployment-badge">
|
||||
<RadioTower size={10} />
|
||||
对外
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<small>
|
||||
{service.revision} · {service.description}
|
||||
</small>
|
||||
</span>
|
||||
</button>
|
||||
</td>
|
||||
<td>
|
||||
<StatusBadge status={service.status} />
|
||||
</td>
|
||||
{!compact && <td className="artifact-cell">{service.artifact}</td>}
|
||||
{!compact && (
|
||||
<td>
|
||||
<CapabilitySummary capabilities={service.capabilities} />
|
||||
</td>
|
||||
)}
|
||||
<td className="numeric-cell">{service.calls.toLocaleString("zh-CN")}</td>
|
||||
<td className="numeric-cell">
|
||||
{service.latencyMs ? `${service.latencyMs} ms` : "—"}
|
||||
</td>
|
||||
<td>
|
||||
<div className="row-actions">
|
||||
<button
|
||||
className={service.active ? "active-deployment-button" : ""}
|
||||
type="button"
|
||||
aria-label={
|
||||
service.active
|
||||
? `${service.id}@${service.revision} 是当前对外版本`
|
||||
: `将 ${service.id}@${service.revision} 设为对外版本`
|
||||
}
|
||||
title={
|
||||
service.active
|
||||
? "当前对外版本"
|
||||
: canActivate
|
||||
? "设为对外版本"
|
||||
: "请先启动 Runtime"
|
||||
}
|
||||
disabled={service.active || !canActivate || deploymentAction !== null}
|
||||
onClick={() => onActivate(service)}
|
||||
>
|
||||
{deploymentAction === serviceKey(service) ? (
|
||||
<RefreshCw className="spin" size={15} />
|
||||
) : (
|
||||
<RadioTower size={15} />
|
||||
)}
|
||||
</button>
|
||||
{service.status === "running" ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`调用 ${service.id}`}
|
||||
title="测试调用"
|
||||
onClick={() => onInvoke(service)}
|
||||
>
|
||||
<SquareTerminal size={15} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`重启 ${service.id}`}
|
||||
title="重启"
|
||||
onClick={() => onStatus(service, "running")}
|
||||
>
|
||||
<RotateCcw size={15} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`停止 ${service.id}`}
|
||||
title="停止"
|
||||
onClick={() => onStatus(service, "stopped")}
|
||||
>
|
||||
<CircleStop size={15} />
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`启动 ${service.id}`}
|
||||
title="启动"
|
||||
onClick={() => onStatus(service, "running")}
|
||||
>
|
||||
<Play size={15} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EventList({ events }: { events: RuntimeEvent[] }) {
|
||||
const icons: Record<EventTone, ReactNode> = {
|
||||
success: <Check size={14} />,
|
||||
warning: <AlertTriangle size={14} />,
|
||||
danger: <X size={14} />,
|
||||
neutral: <CircleStop size={14} />,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="event-list">
|
||||
{events.map((event) => (
|
||||
<div className="event-row" key={event.id}>
|
||||
<span className={`event-icon event-${event.tone}`}>{icons[event.tone]}</span>
|
||||
<div>
|
||||
<strong>{event.title}</strong>
|
||||
<span>{event.detail}</span>
|
||||
</div>
|
||||
<time>{event.time}</time>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ServicesView({
|
||||
services,
|
||||
statusFilter,
|
||||
onFilter,
|
||||
onDeploy,
|
||||
onInvoke,
|
||||
onSelect,
|
||||
onStatus,
|
||||
onActivate,
|
||||
canActivate,
|
||||
deploymentAction,
|
||||
}: {
|
||||
services: Service[];
|
||||
statusFilter: "all" | ServiceStatus;
|
||||
onFilter: (status: "all" | ServiceStatus) => void;
|
||||
onDeploy: () => void;
|
||||
onInvoke: (service: Service) => void;
|
||||
onSelect: (service: Service) => void;
|
||||
onStatus: (service: Service, status: ServiceStatus) => void;
|
||||
onActivate: (service: Service) => void;
|
||||
canActivate: boolean;
|
||||
deploymentAction: string | null;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<PageHeading
|
||||
eyebrow="REGISTRY"
|
||||
title="服务版本"
|
||||
description="管理不可变 Wasm Component 制品及当前对外版本。"
|
||||
actions={
|
||||
<button className="primary-button" type="button" onClick={onDeploy}>
|
||||
<CloudUpload size={17} />
|
||||
注册组件
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<section className="panel full-panel">
|
||||
<div className="filterbar">
|
||||
<div className="segmented" aria-label="状态筛选">
|
||||
{[
|
||||
["all", "全部"],
|
||||
["running", "运行中"],
|
||||
["stopped", "已停止"],
|
||||
["faulted", "故障"],
|
||||
].map(([value, label]) => (
|
||||
<button
|
||||
type="button"
|
||||
key={value}
|
||||
className={statusFilter === value ? "active" : ""}
|
||||
onClick={() => onFilter(value as "all" | ServiceStatus)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<span className="result-count">{services.length} 个版本</span>
|
||||
</div>
|
||||
<ServiceTable
|
||||
services={services}
|
||||
onSelect={onSelect}
|
||||
onInvoke={onInvoke}
|
||||
onStatus={onStatus}
|
||||
onActivate={onActivate}
|
||||
canActivate={canActivate}
|
||||
deploymentAction={deploymentAction}
|
||||
/>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function WitPackagesView({
|
||||
apiBase,
|
||||
packages,
|
||||
onPublish,
|
||||
}: {
|
||||
apiBase: string;
|
||||
packages: BackendWitPackage[];
|
||||
onPublish: () => void;
|
||||
}) {
|
||||
function downloadPackage(packageMetadata: BackendWitPackage) {
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = witPackageDownloadUrl(apiBase, packageMetadata);
|
||||
anchor.download = `${packageMetadata.name.replace(":", "-")}-${packageMetadata.version}.wasm`;
|
||||
document.body.append(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeading
|
||||
eyebrow="WIT REGISTRY"
|
||||
title="WIT 包"
|
||||
description="管理组件开发所依赖的不可变、版本化 WIT Package。"
|
||||
actions={
|
||||
<button className="primary-button" type="button" onClick={onPublish}>
|
||||
<CloudUpload size={17} />
|
||||
发布 WIT 包
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<section className="panel full-panel">
|
||||
<div className="filterbar">
|
||||
<span className="registry-note">发布后不可覆盖;包名、版本与依赖由制品自动解析。</span>
|
||||
<span className="result-count">{packages.length} 个包版本</span>
|
||||
</div>
|
||||
{packages.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<FileCode2 size={22} />
|
||||
<strong>没有匹配的 WIT 包</strong>
|
||||
<span>发布二进制 WIT Package,或调整搜索关键词。</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="table-scroll">
|
||||
<table className="data-table wit-package-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Package</th>
|
||||
<th>依赖</th>
|
||||
<th>SHA-256</th>
|
||||
<th>
|
||||
<span className="sr-only">操作</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{packages.map((packageMetadata) => {
|
||||
const dependencies = packageMetadata.dependencies
|
||||
.map((dependency) => `${dependency.name}@${dependency.version}`)
|
||||
.join(", ");
|
||||
return (
|
||||
<tr key={`${packageMetadata.name}@${packageMetadata.version}`}>
|
||||
<td aria-label={`${packageMetadata.name}@${packageMetadata.version}`}>
|
||||
<div className="service-cell">
|
||||
<span className="service-glyph">
|
||||
<FileCode2 size={16} />
|
||||
</span>
|
||||
<span>
|
||||
<strong>{packageMetadata.name}</strong>
|
||||
<small>{packageMetadata.version}</small>
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="artifact-cell" title={dependencies || "无直接依赖"}>
|
||||
{dependencies || "无直接依赖"}
|
||||
</td>
|
||||
<td className="artifact-cell" title={packageMetadata.sha256}>
|
||||
{packageMetadata.sha256}
|
||||
</td>
|
||||
<td>
|
||||
<div className="row-actions">
|
||||
<button
|
||||
type="button"
|
||||
title="下载 WIT 包"
|
||||
aria-label={`下载 ${packageMetadata.name}@${packageMetadata.version}`}
|
||||
onClick={() => downloadPackage(packageMetadata)}
|
||||
>
|
||||
<Download size={15} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function InstancesView({
|
||||
services,
|
||||
onInvoke,
|
||||
onStatus,
|
||||
onRefresh,
|
||||
}: {
|
||||
services: Service[];
|
||||
onInvoke: (service: Service) => void;
|
||||
onStatus: (service: Service, status: ServiceStatus) => void;
|
||||
onRefresh: () => void;
|
||||
}) {
|
||||
const instances = services.filter(
|
||||
(service) => service.status === "running" || service.status === "faulted",
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeading
|
||||
eyebrow="ACTORS"
|
||||
title="运行实例"
|
||||
description={`${instances.length} 个 Actor 保持独立 Store 与 Instance。`}
|
||||
actions={
|
||||
<button
|
||||
className="icon-button"
|
||||
type="button"
|
||||
title="刷新"
|
||||
aria-label="刷新"
|
||||
onClick={onRefresh}
|
||||
>
|
||||
<RefreshCw size={17} />
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<section className="instance-list">
|
||||
{instances.length === 0 ? (
|
||||
<output className="instance-placeholder">
|
||||
<div className="instance-placeholder-icon">
|
||||
<Cpu size={22} />
|
||||
</div>
|
||||
<strong>暂无运行实例</strong>
|
||||
<span>0 ACTIVE ACTORS</span>
|
||||
</output>
|
||||
) : (
|
||||
instances.map((service) => (
|
||||
<article className="instance-row" key={service.id}>
|
||||
<div className="instance-main">
|
||||
<span className={`instance-indicator ${service.status}`} />
|
||||
<div className="artifact-icon">
|
||||
<Cpu size={19} />
|
||||
</div>
|
||||
<div>
|
||||
<strong>
|
||||
{service.id}@{service.revision}
|
||||
</strong>
|
||||
<span>actor/{service.id}-01</span>
|
||||
</div>
|
||||
</div>
|
||||
<DetailValue
|
||||
label="Store 内存"
|
||||
value={
|
||||
service.status === "running"
|
||||
? `${Math.round(service.memoryMb * 0.42)} MB`
|
||||
: "已释放"
|
||||
}
|
||||
/>
|
||||
<DetailValue label="调用总数" value={service.calls.toLocaleString("zh-CN")} />
|
||||
<DetailValue
|
||||
label="最近延迟"
|
||||
value={service.latencyMs ? `${service.latencyMs} ms` : "—"}
|
||||
/>
|
||||
<div className="instance-status">
|
||||
<StatusBadge status={service.status} />
|
||||
<span>{service.updatedAt}</span>
|
||||
</div>
|
||||
<div className="row-actions">
|
||||
<button
|
||||
type="button"
|
||||
title="测试调用"
|
||||
aria-label={`调用 ${service.id}`}
|
||||
disabled={service.status !== "running"}
|
||||
onClick={() => onInvoke(service)}
|
||||
>
|
||||
<SquareTerminal size={16} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title="重启"
|
||||
aria-label={`重启 ${service.id}`}
|
||||
onClick={() => onStatus(service, "running")}
|
||||
>
|
||||
<RotateCcw size={16} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title="停止"
|
||||
aria-label={`停止 ${service.id}`}
|
||||
disabled={service.status !== "running"}
|
||||
onClick={() => onStatus(service, "stopped")}
|
||||
>
|
||||
<CircleStop size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
))
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function ActivityView({
|
||||
events,
|
||||
services,
|
||||
}: {
|
||||
events: RuntimeEvent[];
|
||||
services: Service[];
|
||||
}) {
|
||||
const calls = services.reduce((sum, service) => sum + service.calls, 0);
|
||||
const errors = services.reduce((sum, service) => sum + service.errors, 0);
|
||||
const latencies = services
|
||||
.map((service) => service.latencyMs)
|
||||
.filter((latency): latency is number => latency !== null);
|
||||
const averageLatency = latencies.length
|
||||
? latencies.reduce((sum, latency) => sum + latency, 0) / latencies.length
|
||||
: null;
|
||||
|
||||
function exportEvents() {
|
||||
const blob = new Blob([JSON.stringify(events, null, 2)], {
|
||||
type: "application/json",
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = "wasmeld-events.json";
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeading
|
||||
eyebrow="EVENT STREAM"
|
||||
title="调用记录"
|
||||
description="Runtime 生命周期、调用结果与沙箱拒绝记录。"
|
||||
actions={
|
||||
<button className="secondary-button" type="button" onClick={exportEvents}>
|
||||
<Archive size={16} />
|
||||
导出记录
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<section className="panel full-panel">
|
||||
<div className="activity-summary">
|
||||
<div>
|
||||
<span>时间范围</span>
|
||||
<strong>当前进程</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>成功率</span>
|
||||
<strong>{calls ? `${(((calls - errors) / calls) * 100).toFixed(2)}%` : "—"}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>平均延迟</span>
|
||||
<strong>{averageLatency === null ? "—" : `${averageLatency.toFixed(1)} ms`}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>记录数</span>
|
||||
<strong>{events.length}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<EventList events={events} />
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsView({
|
||||
endpoint,
|
||||
onSaveEndpoint,
|
||||
}: {
|
||||
endpoint: string;
|
||||
onSaveEndpoint: (value: string) => void;
|
||||
}) {
|
||||
const [value, setValue] = useState(endpoint);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeading
|
||||
eyebrow="CONFIGURATION"
|
||||
title="运行设置"
|
||||
description="本地控制台连接与 Runtime 默认参数。"
|
||||
/>
|
||||
<form
|
||||
className="settings-form"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
onSaveEndpoint(value);
|
||||
}}
|
||||
>
|
||||
<section className="settings-section">
|
||||
<div className="settings-heading">
|
||||
<Server size={18} />
|
||||
<div>
|
||||
<h2>控制端连接</h2>
|
||||
<p>管理 API 的本地访问地址。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-field wide-field">
|
||||
<label htmlFor="endpoint">API 地址</label>
|
||||
<input id="endpoint" value={value} onChange={(event) => setValue(event.target.value)} />
|
||||
<span>连接状态将在顶部工具栏显示。</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="form-footer">
|
||||
<button className="primary-button" type="submit">
|
||||
保存设置
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
/* 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>>
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
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>;
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
/// <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>
|
||||
);
|
||||
}
|
||||
@@ -1,483 +0,0 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { ArrowRight, Braces, CheckCircle2, Search, Server, ShieldCheck } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
BackendDeployment,
|
||||
BackendRuntime,
|
||||
BackendWitPackage,
|
||||
RegisterComponentInput,
|
||||
activateDeployment,
|
||||
changeRuntimeState,
|
||||
changeServiceState,
|
||||
fetchSnapshot,
|
||||
getApiBase,
|
||||
invokeComponent,
|
||||
publishWitPackage,
|
||||
registerComponent,
|
||||
saveApiBase,
|
||||
} from "../api";
|
||||
import {
|
||||
ConnectionState,
|
||||
NAV_ITEMS,
|
||||
RuntimeEvent,
|
||||
Service,
|
||||
ServiceStatus,
|
||||
View,
|
||||
formatApiHost,
|
||||
formatDuration,
|
||||
serviceKey,
|
||||
toEvent,
|
||||
toService,
|
||||
} from "../console-model";
|
||||
import {
|
||||
ActivateDeploymentDialog,
|
||||
DeployDialog,
|
||||
InvokeDialog,
|
||||
WitPackageDialog,
|
||||
} from "../components/dialogs";
|
||||
import {
|
||||
ActivityView,
|
||||
InstancesView,
|
||||
Overview,
|
||||
ServicesView,
|
||||
SettingsView,
|
||||
WitPackagesView,
|
||||
} from "../components/views";
|
||||
|
||||
export const Route = createFileRoute("/")({
|
||||
component: Home,
|
||||
});
|
||||
|
||||
function Home() {
|
||||
const [view, setView] = useState<View>("overview");
|
||||
const [services, setServices] = useState<Service[]>([]);
|
||||
const [events, setEvents] = useState<RuntimeEvent[]>([]);
|
||||
const [deployments, setDeployments] = useState<BackendDeployment[]>([]);
|
||||
const [witPackages, setWitPackages] = useState<BackendWitPackage[]>([]);
|
||||
const [runtime, setRuntime] = useState<BackendRuntime | null>(null);
|
||||
const [runtimeAction, setRuntimeAction] = useState<"start" | "stop" | "restart" | null>(null);
|
||||
const [connection, setConnection] = useState<ConnectionState>("connecting");
|
||||
const [apiBase, setApiBase] = useState(getApiBase);
|
||||
const [query, setQuery] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState<"all" | ServiceStatus>("all");
|
||||
const [selectedKey, setSelectedKey] = useState("");
|
||||
const [deployOpen, setDeployOpen] = useState(false);
|
||||
const [witPublishOpen, setWitPublishOpen] = useState(false);
|
||||
const [invokeKey, setInvokeKey] = useState<string | null>(null);
|
||||
const [activationKey, setActivationKey] = useState<string | null>(null);
|
||||
const [deploymentAction, setDeploymentAction] = useState<string | null>(null);
|
||||
const [toast, setToast] = useState<string | null>(null);
|
||||
|
||||
const selectedService =
|
||||
services.find((service) => serviceKey(service) === selectedKey) ?? services[0] ?? null;
|
||||
const invokeService = services.find((service) => serviceKey(service) === invokeKey) ?? null;
|
||||
const activationService =
|
||||
services.find((service) => serviceKey(service) === activationKey) ?? null;
|
||||
|
||||
const filteredServices = useMemo(() => {
|
||||
const normalized = query.trim().toLowerCase();
|
||||
return services.filter((service) => {
|
||||
const matchesText =
|
||||
!normalized ||
|
||||
service.id.toLowerCase().includes(normalized) ||
|
||||
service.description.toLowerCase().includes(normalized) ||
|
||||
service.artifact.toLowerCase().includes(normalized);
|
||||
const matchesStatus = statusFilter === "all" || service.status === statusFilter;
|
||||
return matchesText && matchesStatus;
|
||||
});
|
||||
}, [query, services, statusFilter]);
|
||||
|
||||
const filteredWitPackages = useMemo(() => {
|
||||
const normalized = query.trim().toLowerCase();
|
||||
return witPackages.filter(
|
||||
(packageMetadata) =>
|
||||
!normalized ||
|
||||
packageMetadata.name.toLowerCase().includes(normalized) ||
|
||||
packageMetadata.version.toLowerCase().includes(normalized) ||
|
||||
packageMetadata.sha256.toLowerCase().includes(normalized),
|
||||
);
|
||||
}, [query, witPackages]);
|
||||
|
||||
const running = services.filter((service) => service.status === "running").length;
|
||||
const faulted = services.filter((service) => service.status === "faulted").length;
|
||||
const totalCalls = services.reduce((sum, service) => sum + service.calls, 0);
|
||||
|
||||
const refreshSnapshot = useCallback(async () => {
|
||||
try {
|
||||
const snapshot = await fetchSnapshot(apiBase);
|
||||
const activeRevisions = new Map(
|
||||
snapshot.deployments.map((deployment) => [
|
||||
deployment.service_id,
|
||||
deployment.active_revision,
|
||||
]),
|
||||
);
|
||||
const nextServices = snapshot.services.map((service) =>
|
||||
toService(service, activeRevisions.get(service.id)),
|
||||
);
|
||||
setServices(nextServices);
|
||||
setDeployments(snapshot.deployments);
|
||||
setEvents(snapshot.events.map(toEvent));
|
||||
setRuntime(snapshot.runtime);
|
||||
setWitPackages(snapshot.witPackages);
|
||||
setSelectedKey((current) =>
|
||||
nextServices.some((service) => serviceKey(service) === current)
|
||||
? current
|
||||
: nextServices[0]
|
||||
? serviceKey(nextServices[0])
|
||||
: "",
|
||||
);
|
||||
setConnection("online");
|
||||
} catch {
|
||||
setServices([]);
|
||||
setDeployments([]);
|
||||
setEvents([]);
|
||||
setRuntime(null);
|
||||
setWitPackages([]);
|
||||
setConnection("offline");
|
||||
}
|
||||
}, [apiBase]);
|
||||
|
||||
useEffect(() => {
|
||||
void refreshSnapshot();
|
||||
const interval = window.setInterval(() => void refreshSnapshot(), 5000);
|
||||
return () => window.clearInterval(interval);
|
||||
}, [refreshSnapshot]);
|
||||
|
||||
function notify(message: string) {
|
||||
setToast(message);
|
||||
window.setTimeout(() => setToast(null), 2600);
|
||||
}
|
||||
|
||||
async function setServiceStatus(service: Service, status: ServiceStatus) {
|
||||
const action =
|
||||
status === "stopped" ? "stop" : service.status === "running" ? "restart" : "start";
|
||||
try {
|
||||
await changeServiceState(apiBase, service, action);
|
||||
await refreshSnapshot();
|
||||
notify(
|
||||
`${service.id} ${
|
||||
action === "stop" ? "已停止" : action === "restart" ? "已重启" : "已启动"
|
||||
}`,
|
||||
);
|
||||
} catch (error) {
|
||||
notify(error instanceof Error ? error.message : "运行时操作失败");
|
||||
}
|
||||
}
|
||||
|
||||
async function setRuntimeStatus(action: "start" | "stop" | "restart") {
|
||||
setRuntimeAction(action);
|
||||
try {
|
||||
await changeRuntimeState(apiBase, action);
|
||||
await refreshSnapshot();
|
||||
notify(
|
||||
action === "start"
|
||||
? "Runtime 已启动"
|
||||
: action === "stop"
|
||||
? "Runtime 已停止"
|
||||
: "Runtime 已重启",
|
||||
);
|
||||
} catch (error) {
|
||||
notify(error instanceof Error ? error.message : "Runtime 操作失败");
|
||||
} finally {
|
||||
setRuntimeAction(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function activateService(service: Service) {
|
||||
const key = serviceKey(service);
|
||||
setDeploymentAction(key);
|
||||
try {
|
||||
await activateDeployment(apiBase, service);
|
||||
await refreshSnapshot();
|
||||
setActivationKey(null);
|
||||
notify(`${service.id}@${service.revision} 已设为对外版本`);
|
||||
} catch (error) {
|
||||
notify(error instanceof Error ? error.message : "Deployment 切换失败");
|
||||
} finally {
|
||||
setDeploymentAction(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function runInvocation(service: Service, input: Uint8Array) {
|
||||
const result = await invokeComponent(apiBase, service, input);
|
||||
await refreshSnapshot();
|
||||
return result;
|
||||
}
|
||||
|
||||
async function registerService(input: RegisterComponentInput) {
|
||||
const registered = await registerComponent(apiBase, input);
|
||||
await refreshSnapshot();
|
||||
setSelectedKey(serviceKey(registered));
|
||||
setView("services");
|
||||
setDeployOpen(false);
|
||||
notify("组件注册成功");
|
||||
}
|
||||
|
||||
async function publishWit(file: File) {
|
||||
const packageMetadata = await publishWitPackage(apiBase, file);
|
||||
await refreshSnapshot();
|
||||
setWitPublishOpen(false);
|
||||
notify(`${packageMetadata.name}@${packageMetadata.version} 已发布`);
|
||||
}
|
||||
|
||||
function updateApiBase(value: string) {
|
||||
try {
|
||||
const nextBase = saveApiBase(value);
|
||||
setConnection("connecting");
|
||||
setApiBase(nextBase);
|
||||
notify("API 地址已保存");
|
||||
} catch (error) {
|
||||
notify(error instanceof Error ? error.message : "API 地址无效");
|
||||
}
|
||||
}
|
||||
|
||||
const title = NAV_ITEMS.find((item) => item.id === view)?.label ?? "运行概览";
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<Sidebar
|
||||
view={view}
|
||||
onChange={setView}
|
||||
running={running}
|
||||
connection={connection}
|
||||
apiBase={apiBase}
|
||||
uptimeMs={runtime?.status === "running" ? runtime.uptime_ms : null}
|
||||
runtimeStatus={runtime?.status ?? null}
|
||||
/>
|
||||
|
||||
<div className="app-main">
|
||||
<header className="topbar">
|
||||
<div className="breadcrumb">
|
||||
<span>运行平台</span>
|
||||
<ArrowRight size={14} />
|
||||
<strong>{title}</strong>
|
||||
</div>
|
||||
<div className="topbar-actions">
|
||||
<label className="global-search">
|
||||
<Search size={16} />
|
||||
<input
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="搜索服务或制品"
|
||||
aria-label="搜索服务或制品"
|
||||
/>
|
||||
</label>
|
||||
<div className={`source-state source-${connection}`} title={`管理 API:${apiBase}`}>
|
||||
<span className="source-dot" />
|
||||
{connection === "online"
|
||||
? "后端在线"
|
||||
: connection === "connecting"
|
||||
? "连接中"
|
||||
: "后端离线"}
|
||||
</div>
|
||||
<div className="avatar-button" aria-hidden="true">
|
||||
BD
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="content">
|
||||
{view === "overview" && (
|
||||
<Overview
|
||||
services={services}
|
||||
filteredServices={filteredServices}
|
||||
events={events}
|
||||
running={running}
|
||||
faulted={faulted}
|
||||
totalCalls={totalCalls}
|
||||
deploymentCount={deployments.length}
|
||||
runtime={runtime}
|
||||
selectedService={selectedService}
|
||||
onSelect={(service) => setSelectedKey(serviceKey(service))}
|
||||
onDeploy={() => setDeployOpen(true)}
|
||||
onInvoke={(service) => setInvokeKey(serviceKey(service))}
|
||||
onStatus={setServiceStatus}
|
||||
onActivate={(service) => setActivationKey(serviceKey(service))}
|
||||
onViewAll={() => setView("services")}
|
||||
onRefresh={() => void refreshSnapshot()}
|
||||
onRuntimeAction={(action) => void setRuntimeStatus(action)}
|
||||
runtimeAction={runtimeAction}
|
||||
deploymentAction={deploymentAction}
|
||||
/>
|
||||
)}
|
||||
|
||||
{view === "services" && (
|
||||
<ServicesView
|
||||
services={filteredServices}
|
||||
statusFilter={statusFilter}
|
||||
onFilter={setStatusFilter}
|
||||
onDeploy={() => setDeployOpen(true)}
|
||||
onInvoke={(service) => setInvokeKey(serviceKey(service))}
|
||||
onSelect={(service) => setSelectedKey(serviceKey(service))}
|
||||
onStatus={setServiceStatus}
|
||||
onActivate={(service) => setActivationKey(serviceKey(service))}
|
||||
canActivate={runtime?.status === "running"}
|
||||
deploymentAction={deploymentAction}
|
||||
/>
|
||||
)}
|
||||
|
||||
{view === "instances" && (
|
||||
<InstancesView
|
||||
services={services}
|
||||
onInvoke={(service) => setInvokeKey(serviceKey(service))}
|
||||
onStatus={setServiceStatus}
|
||||
onRefresh={() => void refreshSnapshot()}
|
||||
/>
|
||||
)}
|
||||
|
||||
{view === "activity" && <ActivityView events={events} services={services} />}
|
||||
|
||||
{view === "wit-packages" && (
|
||||
<WitPackagesView
|
||||
apiBase={apiBase}
|
||||
packages={filteredWitPackages}
|
||||
onPublish={() => setWitPublishOpen(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{view === "settings" && (
|
||||
<SettingsView endpoint={apiBase} onSaveEndpoint={updateApiBase} />
|
||||
)}
|
||||
</main>
|
||||
|
||||
<footer className="statusbar">
|
||||
<span>
|
||||
<ShieldCheck size={14} />
|
||||
{connection !== "online"
|
||||
? "等待 Wasmeld"
|
||||
: runtime?.status === "running"
|
||||
? "Wasmtime sandbox 正常"
|
||||
: "Wasmeld Runtime 已停止"}
|
||||
</span>
|
||||
<span>Component Model · WASI P2</span>
|
||||
<span>Runtime 0.1.0</span>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
{deployOpen && (
|
||||
<DeployDialog onClose={() => setDeployOpen(false)} onRegister={registerService} />
|
||||
)}
|
||||
|
||||
{witPublishOpen && (
|
||||
<WitPackageDialog onClose={() => setWitPublishOpen(false)} onPublish={publishWit} />
|
||||
)}
|
||||
|
||||
{invokeService && (
|
||||
<InvokeDialog
|
||||
service={invokeService}
|
||||
onClose={() => setInvokeKey(null)}
|
||||
onInvoke={runInvocation}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activationService && (
|
||||
<ActivateDeploymentDialog
|
||||
service={activationService}
|
||||
currentRevision={
|
||||
services.find((service) => service.id === activationService.id && service.active)
|
||||
?.revision ?? null
|
||||
}
|
||||
submitting={deploymentAction === serviceKey(activationService)}
|
||||
onClose={() => setActivationKey(null)}
|
||||
onConfirm={() => void activateService(activationService)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{toast && (
|
||||
<output className="toast">
|
||||
<CheckCircle2 size={17} />
|
||||
{toast}
|
||||
</output>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Sidebar({
|
||||
view,
|
||||
onChange,
|
||||
running,
|
||||
connection,
|
||||
apiBase,
|
||||
uptimeMs,
|
||||
runtimeStatus,
|
||||
}: {
|
||||
view: View;
|
||||
onChange: (view: View) => void;
|
||||
running: number;
|
||||
connection: ConnectionState;
|
||||
apiBase: string;
|
||||
uptimeMs: number | null;
|
||||
runtimeStatus: BackendRuntime["status"] | null;
|
||||
}) {
|
||||
const runtimeLabel =
|
||||
connection !== "online"
|
||||
? "离线"
|
||||
: runtimeStatus === "running"
|
||||
? "运行中"
|
||||
: runtimeStatus === "stopped"
|
||||
? "已停止"
|
||||
: "连接中";
|
||||
const runtimeHealthClass =
|
||||
connection !== "online"
|
||||
? "health-pulse health-offline"
|
||||
: runtimeStatus === "running"
|
||||
? "health-pulse"
|
||||
: "health-pulse health-stopped";
|
||||
|
||||
return (
|
||||
<aside className="sidebar">
|
||||
<div className="brand">
|
||||
<div className="brand-mark">
|
||||
<Braces size={19} strokeWidth={2.2} />
|
||||
</div>
|
||||
<div>
|
||||
<strong>Wasmeld Console</strong>
|
||||
<span>Runtime Control</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="environment-switcher">
|
||||
<span className="environment-icon">
|
||||
<Server size={15} />
|
||||
</span>
|
||||
<span>
|
||||
<small>环境</small>
|
||||
<strong>Local Runtime</strong>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<nav className="primary-nav" aria-label="主导航">
|
||||
<span className="nav-label">管理</span>
|
||||
{NAV_ITEMS.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={item.id}
|
||||
className={view === item.id ? "nav-item active" : "nav-item"}
|
||||
onClick={() => onChange(item.id)}
|
||||
>
|
||||
<Icon size={17} />
|
||||
<span>{item.label}</span>
|
||||
{item.id === "instances" && running > 0 && (
|
||||
<span className="nav-count">{running}</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="sidebar-runtime">
|
||||
<div className="runtime-title">
|
||||
<span className={runtimeHealthClass} />
|
||||
Runtime {runtimeLabel}
|
||||
</div>
|
||||
<div className="runtime-meta">
|
||||
<span>{formatApiHost(apiBase)}</span>
|
||||
<span>{uptimeMs === null ? "—" : formatDuration(uptimeMs)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,127 +0,0 @@
|
||||
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, /0 个对外服务/);
|
||||
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,
|
||||
views,
|
||||
dialogs,
|
||||
consoleModel,
|
||||
apiClient,
|
||||
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/components/views.tsx", import.meta.url), "utf8"),
|
||||
readFile(new URL("../src/components/dialogs.tsx", import.meta.url), "utf8"),
|
||||
readFile(new URL("../src/console-model.ts", import.meta.url), "utf8"),
|
||||
readFile(new URL("../src/api.ts", 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(dialogs, /切换对外版本/);
|
||||
assert.match(views, /Host 能力/);
|
||||
assert.match(views, /service\.capabilities/);
|
||||
assert.match(consoleModel, /TextDecoder\("utf-8", \{ fatal: true \}\)/);
|
||||
assert.match(dialogs, /outputFormat\.automatic/);
|
||||
assert.match(apiClient, /\/api\/v1\/deployments/);
|
||||
assert.match(apiClient, /capabilities: BackendCapability\[\]/);
|
||||
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,19 +0,0 @@
|
||||
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,6 +8,8 @@ license.workspace = true
|
||||
[dependencies]
|
||||
axum.workspace = true
|
||||
base64.workspace = true
|
||||
include_dir.workspace = true
|
||||
mime_guess.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
semver.workspace = true
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
- 通过独立 Gateway 转发原始二进制调用
|
||||
- 为导入 `wasmeld:kv/store@0.1.0` 的 Component 提供持久化 KV
|
||||
- 暴露调用计数、状态和最近事件
|
||||
- 在 Release 可执行文件中嵌入 Solid + Tailwind v4 管理面
|
||||
|
||||
Wasm 制品保存在本地文件系统。服务 manifest、调用计数和最近 256 条事件通过
|
||||
[Toasty](https://github.com/tokio-rs/toasty) 写入本地 libSQL 数据库,默认路径是
|
||||
@@ -29,6 +30,71 @@ Host KV 使用 `(service_id, key)` 复合主键存储在该数据库中:同一
|
||||
编译后的本地机器码默认缓存在 `var/wasmeld/component-cache`,相同 Component 在
|
||||
Console 或 Runtime 重启后可由 Wasmtime 直接复用;缓存不包含 Actor 内存。
|
||||
|
||||
## 常驻 Host Driver
|
||||
|
||||
导出 `wasmeld:resident/actor@0.1.0` 的 Component 启动时,Console 会为它创建独立的
|
||||
`ResidentSupervisor`。Supervisor 在专用阻塞线程上独占 `ResidentSession`,避免同步
|
||||
Actor mailbox 阻塞 Tokio executor。Timer、TCP Listener 和每条 TCP Stream 由 Tokio
|
||||
task 等待外部事件,再通过容量受限的 channel 汇聚到同一个 Supervisor。
|
||||
|
||||
Host 资源属于平台配置,不由 Component 创建,也不写入 `.wasmpkg`。当前可通过
|
||||
`ConsoleConfig::resident_services` 为稳定服务 ID 配置 TCP Listener 和 Timer:
|
||||
|
||||
```rust
|
||||
use std::collections::BTreeMap;
|
||||
use wasmeld_console::{
|
||||
ConsoleConfig, NetworkScope, ResidentPolicy, ResidentServiceConfig,
|
||||
ResidentTcpListenerConfig, ResidentTimerConfig,
|
||||
};
|
||||
|
||||
let mut resident_services = BTreeMap::new();
|
||||
resident_services.insert(
|
||||
"scheduler".to_owned(),
|
||||
ResidentServiceConfig {
|
||||
policy: ResidentPolicy {
|
||||
tcp_listen: NetworkScope::Loopback,
|
||||
..ResidentPolicy::default()
|
||||
},
|
||||
tcp_listeners: vec![ResidentTcpListenerConfig {
|
||||
name: "internal-api".to_owned(),
|
||||
bind: "127.0.0.1:9000".parse().unwrap(),
|
||||
}],
|
||||
timers: vec![ResidentTimerConfig {
|
||||
name: "heartbeat".to_owned(),
|
||||
initial_delay_ms: 1_000,
|
||||
interval_ms: Some(30_000),
|
||||
}],
|
||||
..ResidentServiceConfig::default()
|
||||
},
|
||||
);
|
||||
let config = ConsoleConfig {
|
||||
resident_services,
|
||||
..ConsoleConfig::default()
|
||||
};
|
||||
```
|
||||
|
||||
`ResidentPolicy` 默认拒绝全部网络地址;配置 Listener 时还必须显式选择 `Loopback`
|
||||
或 `Any`。Listener 在 Actor 启动过程中完成策略校验和 bind,任何失败都会回滚刚启动的
|
||||
Actor,避免出现“Actor 正在运行但端口未监听”的半启动状态。Listener、Timer 和其它
|
||||
顶层资源名称在同一服务内必须唯一,每个 Revision 都会获得独立的 Host handle 和不复用
|
||||
资源 ID。
|
||||
|
||||
TCP Driver 每次最多读取 `limits.resident.max_stream_chunk_bytes`,不会把 socket handle
|
||||
交给 Wasm。TCP 字节在 Supervisor 队列满时等待并把背压传递到内核接收缓冲区,不能像
|
||||
Timer occurrence 一样丢弃。Component 可通过 WIT 返回 `write-stream`、`close-stream`、
|
||||
`pause-stream` 和 `resume-stream`;每条连接的写入/控制队列也是有界的,慢客户端填满
|
||||
队列时只关闭该连接,不阻塞同一 Actor 的其它连接。
|
||||
|
||||
Timer task 只负责等待,队列满时丢弃该次 occurrence,而不是创建无界积压。Component
|
||||
返回 `arm-timer` 会替换 Host 初始日程,`cancel-timer` 会取消它;generation 使取消前
|
||||
已经排队的迟到事件失效。
|
||||
|
||||
停止顺序固定为:停止接收新 Driver 事件、停止 accept、投递 `shutdown`、关闭 Stream
|
||||
和 Timer task、先释放子 Stream 再释放 Listener、最后停止 Actor。服务重启和 Runtime
|
||||
Deployment 恢复都会重新 bind 并创建新 Session,不会复用旧 Revision 的 socket、Timer
|
||||
或 Component 内存。UDP 和 Unix Driver 尚未接入,后续继续复用相同的 Supervisor
|
||||
序列化边界。
|
||||
|
||||
## 启动
|
||||
|
||||
`wasmeld-console` 需要 Rust 1.95 或更高版本;Wasm 组件继续使用项目约定的 Rust 1.90
|
||||
@@ -38,6 +104,25 @@ Console 或 Runtime 重启后可由 Wasmtime 直接复用;缓存不包含 Acto
|
||||
cargo +stable run -p wasmeld-console
|
||||
```
|
||||
|
||||
Debug 构建只启动 API,不构建或提供 Web 静态文件。前端开发服务器需要单独启动:
|
||||
|
||||
```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`
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
//! 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,4 +1,14 @@
|
||||
//! 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::{
|
||||
@@ -21,6 +31,11 @@ use wasmeld_package::wit_package::WitPackageError;
|
||||
///
|
||||
/// 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()
|
||||
@@ -84,12 +99,24 @@ pub fn app(console: Arc<Console>, allowed_origins: Vec<HeaderValue>) -> Router {
|
||||
.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.
|
||||
/// 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()
|
||||
@@ -126,6 +153,9 @@ async fn gateway_invoke(
|
||||
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())
|
||||
})
|
||||
@@ -262,6 +292,9 @@ 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()
|
||||
@@ -453,6 +486,9 @@ where
|
||||
// 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;
|
||||
|
||||
@@ -549,7 +585,8 @@ impl IntoResponse for GatewayError {
|
||||
| ConsoleError::InvalidDatabaseData(_)
|
||||
| ConsoleError::Runtime(_)
|
||||
| ConsoleError::Join(_)
|
||||
| ConsoleError::LockPoisoned => (
|
||||
| ConsoleError::LockPoisoned
|
||||
| ConsoleError::ResidentSupervisor(_) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"internal_error",
|
||||
"internal gateway error".to_owned(),
|
||||
@@ -644,7 +681,10 @@ impl IntoResponse for ApiError {
|
||||
| ConsoleError::WitRegistry(WitRegistryError::Storage { .. })
|
||||
| ConsoleError::WitRegistry(WitRegistryError::LockPoisoned)
|
||||
| ConsoleError::Join(_)
|
||||
| ConsoleError::LockPoisoned => (StatusCode::INTERNAL_SERVER_ERROR, "internal_error"),
|
||||
| ConsoleError::LockPoisoned
|
||||
| ConsoleError::ResidentSupervisor(_) => {
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "internal_error")
|
||||
}
|
||||
};
|
||||
|
||||
(
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
//! 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;
|
||||
|
||||
@@ -10,6 +16,10 @@ 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>,
|
||||
}
|
||||
@@ -21,6 +31,10 @@ enum Command {
|
||||
|
||||
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 {
|
||||
@@ -45,6 +59,10 @@ impl InvocationPersistence {
|
||||
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(());
|
||||
}
|
||||
@@ -54,6 +72,9 @@ impl InvocationPersistence {
|
||||
}
|
||||
|
||||
/// 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");
|
||||
@@ -61,6 +82,10 @@ impl InvocationPersistence {
|
||||
}
|
||||
|
||||
/// 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() {
|
||||
|
||||
@@ -1,4 +1,18 @@
|
||||
//! 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,
|
||||
@@ -15,6 +29,9 @@ 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 {
|
||||
@@ -47,6 +64,11 @@ enum KvCommand {
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -78,6 +100,8 @@ impl ToastyKvBackend {
|
||||
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),
|
||||
@@ -149,6 +173,9 @@ impl KvBackend for ToastyKvBackend {
|
||||
|
||||
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()
|
||||
@@ -163,6 +190,8 @@ fn run_worker(
|
||||
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()
|
||||
@@ -175,6 +204,9 @@ fn run_worker(
|
||||
};
|
||||
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 {
|
||||
|
||||
@@ -5,15 +5,43 @@
|
||||
//! to libSQL through Toasty. It also hosts an immutable filesystem-backed WIT
|
||||
//! package Registry. Actor memory remains process-local and is never presented
|
||||
//! as durable state.
|
||||
//!
|
||||
//! # Ownership model
|
||||
//!
|
||||
//! * [`Console`] owns the in-process Runtime and serializes control-plane
|
||||
//! mutations with a lifecycle mutex.
|
||||
//! * libSQL stores desired service/deployment state and bounded telemetry.
|
||||
//! * Component and WIT bytes are immutable filesystem artifacts addressed by
|
||||
//! identity and digest.
|
||||
//! * On restart, Actors are recreated from persisted manifests; their linear
|
||||
//! memory is intentionally reset.
|
||||
//!
|
||||
//! # Example
|
||||
//!
|
||||
//! ```no_run
|
||||
//! use wasmeld_console::{Console, ConsoleConfig};
|
||||
//!
|
||||
//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
|
||||
//! let console = Console::new(ConsoleConfig::default()).await?;
|
||||
//! for service in console.services()? {
|
||||
//! println!("{}@{}: {:?}", service.id, service.revision, service.status);
|
||||
//! }
|
||||
//! # Ok(())
|
||||
//! # }
|
||||
//! ```
|
||||
|
||||
#![warn(missing_docs)]
|
||||
|
||||
mod api;
|
||||
mod invocation_persistence;
|
||||
mod kv_backend;
|
||||
mod persistence;
|
||||
mod resident_supervisor;
|
||||
mod web;
|
||||
mod wit_registry;
|
||||
|
||||
use std::{
|
||||
collections::{BTreeMap, VecDeque},
|
||||
collections::{BTreeMap, HashMap, VecDeque},
|
||||
fs, io,
|
||||
path::{Path, PathBuf},
|
||||
sync::{Arc, Mutex, MutexGuard, RwLock, RwLockReadGuard},
|
||||
@@ -27,14 +55,19 @@ use wasmeld_package::{
|
||||
ComponentPackage, PackageError, read_package, wit_package::WitPackageMetadata,
|
||||
};
|
||||
use wasmeld_runtime::{
|
||||
CapabilityDescriptor, ResourceLimits, Runtime, RuntimeConfig, RuntimeError, ServiceKey,
|
||||
ServiceManifest,
|
||||
ActorHandle, CapabilityDescriptor, ComponentExecution, ResourceLimits, Runtime, RuntimeConfig,
|
||||
RuntimeError, ServiceKey, ServiceManifest,
|
||||
};
|
||||
|
||||
pub use api::{app, gateway_app};
|
||||
use invocation_persistence::InvocationPersistence;
|
||||
use kv_backend::ToastyKvBackend;
|
||||
use persistence::{InvocationUpdate, Persistence, StoredDeployment, StoredEvent, StoredService};
|
||||
pub use resident_supervisor::{
|
||||
ResidentServiceConfig, ResidentTcpListenerConfig, ResidentTimerConfig,
|
||||
};
|
||||
use resident_supervisor::{ResidentSupervisor, ResidentSupervisorError};
|
||||
pub use wasmeld_runtime::{NetworkScope, ResidentPolicy};
|
||||
use wit_registry::WitRegistry;
|
||||
pub use wit_registry::WitRegistryError;
|
||||
|
||||
@@ -60,6 +93,12 @@ pub struct ConsoleConfig {
|
||||
pub registration_limits: ResourceLimits,
|
||||
/// Wasmtime Engine and epoch settings.
|
||||
pub runtime: RuntimeConfig,
|
||||
/// Host-owned resident resources keyed by stable service ID.
|
||||
///
|
||||
/// Each started revision receives an independent copy of this
|
||||
/// configuration and therefore never shares resource IDs, socket handles,
|
||||
/// or driver tasks with another revision.
|
||||
pub resident_services: BTreeMap<String, ResidentServiceConfig>,
|
||||
}
|
||||
|
||||
impl Default for ConsoleConfig {
|
||||
@@ -72,6 +111,7 @@ impl Default for ConsoleConfig {
|
||||
max_wit_package_bytes: DEFAULT_MAX_WIT_PACKAGE_BYTES,
|
||||
registration_limits: ResourceLimits::default(),
|
||||
runtime: RuntimeConfig::default(),
|
||||
resident_services: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -82,8 +122,15 @@ impl Default for ConsoleConfig {
|
||||
/// registered service metadata, artifacts, metrics, and events remain managed
|
||||
/// by the Console.
|
||||
pub struct Console {
|
||||
// Lock order for lifecycle mutations is `lifecycle` -> `runtime` ->
|
||||
// `state`. Invocation does not take `lifecycle`; it briefly reads Runtime
|
||||
// and then updates state after the Actor call. Keep new code on this order
|
||||
// to avoid deadlocks between HTTP control operations.
|
||||
runtime: RwLock<Option<Runtime>>,
|
||||
runtime_config: RuntimeConfig,
|
||||
async_runtime: tokio::runtime::Handle,
|
||||
resident_services: BTreeMap<String, ResidentServiceConfig>,
|
||||
resident_supervisors: Mutex<HashMap<ServiceKey, ResidentSupervisor>>,
|
||||
artifact_dir: PathBuf,
|
||||
wit_registry: WitRegistry,
|
||||
max_artifact_bytes: usize,
|
||||
@@ -124,26 +171,37 @@ struct DeploymentRecord {
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ServiceStatus {
|
||||
/// Actor is accepting invocations for this revision.
|
||||
Running,
|
||||
/// Revision is registered but has no active Actor.
|
||||
Stopped,
|
||||
/// Its most recent Actor trapped and must be started again.
|
||||
Faulted,
|
||||
}
|
||||
|
||||
/// API projection of one public service deployment.
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct DeploymentView {
|
||||
/// Stable public service ID used by the Gateway route.
|
||||
pub service_id: String,
|
||||
/// Immutable revision currently selected for the service.
|
||||
pub active_revision: String,
|
||||
/// Current lifecycle status of the selected revision.
|
||||
pub status: ServiceStatus,
|
||||
/// Unix timestamp of the most recent deployment change.
|
||||
pub updated_at_ms: u64,
|
||||
}
|
||||
|
||||
/// Exact versioned host interface imported by a registered Component.
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
|
||||
pub struct CapabilityView {
|
||||
/// Fully qualified versioned WIT interface.
|
||||
pub interface: String,
|
||||
/// WIT package identity without the version suffix.
|
||||
pub package: String,
|
||||
/// Interface name inside the package.
|
||||
pub name: String,
|
||||
/// Exact semantic version linked by the Runtime.
|
||||
pub version: String,
|
||||
}
|
||||
|
||||
@@ -161,16 +219,27 @@ impl From<CapabilityDescriptor> for CapabilityView {
|
||||
/// API projection of a registered service and its current metrics.
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct ServiceView {
|
||||
/// Stable service ID.
|
||||
pub id: String,
|
||||
/// Immutable Component revision.
|
||||
pub revision: String,
|
||||
/// Canonical path of the stored Component artifact.
|
||||
pub artifact: String,
|
||||
/// Fully versioned world declared by the package.
|
||||
pub world: String,
|
||||
/// Exact Host interfaces discovered from Component imports.
|
||||
pub capabilities: Vec<CapabilityView>,
|
||||
/// Current Actor status for this revision.
|
||||
pub status: ServiceStatus,
|
||||
/// Unix timestamp of the most recent lifecycle change.
|
||||
pub updated_at_ms: u64,
|
||||
/// Platform limits injected during registration.
|
||||
pub limits: ResourceLimits,
|
||||
/// Persisted number of completed invocation attempts.
|
||||
pub calls: u64,
|
||||
/// Persisted number of failed invocation attempts.
|
||||
pub errors: u64,
|
||||
/// Latency of the most recently persisted invocation.
|
||||
pub last_latency_ms: Option<u64>,
|
||||
}
|
||||
|
||||
@@ -195,11 +264,17 @@ impl ServiceRecord {
|
||||
/// One persisted Console lifecycle or invocation event.
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct EventView {
|
||||
/// Process-independent monotonically increasing event identity.
|
||||
pub id: u64,
|
||||
/// Event creation time as Unix milliseconds.
|
||||
pub timestamp_ms: u64,
|
||||
/// Stable category used by API consumers.
|
||||
pub kind: EventKind,
|
||||
/// Related service ID, or `None` for Runtime-wide events.
|
||||
pub service_id: Option<String>,
|
||||
/// Related immutable revision when the event targets one version.
|
||||
pub revision: Option<String>,
|
||||
/// Human-readable operational detail; not a machine protocol.
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
@@ -207,12 +282,19 @@ pub struct EventView {
|
||||
#[derive(Clone, Copy, Debug, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum EventKind {
|
||||
/// A Component package was registered.
|
||||
Registered,
|
||||
/// An inactive revision and its artifacts were removed.
|
||||
Unregistered,
|
||||
/// A public deployment switched to a revision.
|
||||
Deployed,
|
||||
/// Runtime or Actor startup completed.
|
||||
Started,
|
||||
/// Runtime or Actor shutdown completed.
|
||||
Stopped,
|
||||
/// One data-plane or management invocation completed.
|
||||
Invoked,
|
||||
/// A lifecycle or invocation operation failed.
|
||||
Failed,
|
||||
}
|
||||
|
||||
@@ -348,57 +430,82 @@ struct GatewayInvocation {
|
||||
/// Errors raised by Console state, persistence, Registry, or Runtime operations.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ConsoleError {
|
||||
/// API input failed semantic validation.
|
||||
#[error("{0}")]
|
||||
InvalidRequest(String),
|
||||
|
||||
/// The requested registered service revision does not exist.
|
||||
#[error("service revision {0} is not managed by Wasmeld")]
|
||||
ServiceNotFound(ServiceKey),
|
||||
|
||||
/// The public service has no selected revision.
|
||||
#[error("service {0} has no active deployment")]
|
||||
DeploymentNotFound(String),
|
||||
|
||||
/// Unregister was attempted on the revision currently serving traffic.
|
||||
#[error("service revision {0} is the active deployment")]
|
||||
ActiveDeployment(ServiceKey),
|
||||
|
||||
/// An operation requiring Wasmtime was attempted while Runtime is stopped.
|
||||
#[error("Wasmeld Runtime is not running")]
|
||||
RuntimeNotRunning,
|
||||
|
||||
/// Uploaded package bytes exceed the configured control-plane limit.
|
||||
#[error("artifact exceeds the {limit}-byte limit")]
|
||||
ArtifactTooLarge { limit: usize },
|
||||
ArtifactTooLarge {
|
||||
/// Maximum accepted upload bytes.
|
||||
limit: usize,
|
||||
},
|
||||
|
||||
/// Component package decoding or integrity validation failed.
|
||||
#[error(transparent)]
|
||||
Package(#[from] PackageError),
|
||||
|
||||
/// WIT Registry storage or integrity validation failed.
|
||||
#[error(transparent)]
|
||||
WitRegistry(#[from] WitRegistryError),
|
||||
|
||||
/// Component artifact or manifest filesystem access failed.
|
||||
#[error("failed to access {path}: {source}")]
|
||||
Storage {
|
||||
/// Path involved in the failed operation.
|
||||
path: PathBuf,
|
||||
/// Underlying filesystem failure.
|
||||
#[source]
|
||||
source: io::Error,
|
||||
},
|
||||
|
||||
/// A generated Runtime manifest could not be encoded.
|
||||
#[error("failed to serialize manifest: {0}")]
|
||||
ManifestSerialize(#[from] toml::ser::Error),
|
||||
|
||||
/// Toasty or libSQL control-state persistence failed.
|
||||
#[error("database operation failed: {0}")]
|
||||
Database(#[from] toasty::Error),
|
||||
|
||||
/// The synchronous-to-async KV worker thread could not start.
|
||||
#[error("failed to start persistent KV backend: {0}")]
|
||||
KvBackendStart(#[source] io::Error),
|
||||
|
||||
/// Persisted rows contain an unknown enum, invalid key, or invalid revision.
|
||||
#[error("database contains invalid console data: {0}")]
|
||||
InvalidDatabaseData(String),
|
||||
|
||||
/// Component compilation, Actor lifecycle, or invocation failed.
|
||||
#[error(transparent)]
|
||||
Runtime(#[from] RuntimeError),
|
||||
|
||||
/// A blocking Runtime operation panicked or was cancelled.
|
||||
#[error("blocking runtime task failed: {0}")]
|
||||
Join(#[from] tokio::task::JoinError),
|
||||
|
||||
/// An internal lifecycle, state, or Runtime lock was poisoned.
|
||||
#[error("console state lock was poisoned")]
|
||||
LockPoisoned,
|
||||
|
||||
/// A resident Host driver could not start, dispatch, or drain.
|
||||
#[error("resident supervisor failed: {0}")]
|
||||
ResidentSupervisor(String),
|
||||
}
|
||||
|
||||
impl Console {
|
||||
@@ -408,6 +515,12 @@ impl Console {
|
||||
/// configuration. Their previous Store and linear memory are not restored.
|
||||
pub async fn new(config: ConsoleConfig) -> Result<Self, ConsoleError> {
|
||||
config.registration_limits.validate()?;
|
||||
for (service_id, resident) in &config.resident_services {
|
||||
validate_path_segment("resident service id", service_id)?;
|
||||
resident
|
||||
.validate()
|
||||
.map_err(ConsoleError::ResidentSupervisor)?;
|
||||
}
|
||||
fs::create_dir_all(&config.artifact_dir).map_err(|source| ConsoleError::Storage {
|
||||
path: config.artifact_dir.clone(),
|
||||
source,
|
||||
@@ -460,6 +573,9 @@ impl Console {
|
||||
let console = Self {
|
||||
runtime: RwLock::new(Some(runtime)),
|
||||
runtime_config,
|
||||
async_runtime: tokio::runtime::Handle::current(),
|
||||
resident_services: config.resident_services,
|
||||
resident_supervisors: Mutex::new(HashMap::new()),
|
||||
artifact_dir,
|
||||
wit_registry,
|
||||
max_artifact_bytes: config.max_artifact_bytes,
|
||||
@@ -584,6 +700,16 @@ impl Console {
|
||||
Ok(started) => {
|
||||
*runtime = Some(started);
|
||||
drop(runtime);
|
||||
if let Err(error) = self.restore_deployments() {
|
||||
self.rollback_runtime_start();
|
||||
self.mark_runtime_stopped()?;
|
||||
self.push_event(
|
||||
EventKind::Failed,
|
||||
None,
|
||||
format!("Wasmeld Runtime failed to restore deployments: {error}"),
|
||||
)?;
|
||||
return Err(error);
|
||||
}
|
||||
self.mark_runtime_started()?;
|
||||
self.push_event(
|
||||
EventKind::Started,
|
||||
@@ -617,6 +743,7 @@ impl Console {
|
||||
drop(runtime);
|
||||
return self.runtime_view();
|
||||
};
|
||||
self.shutdown_all_resident_supervisors();
|
||||
let stopped = running.stop_all();
|
||||
drop(runtime);
|
||||
self.mark_runtime_stopped()?;
|
||||
@@ -651,23 +778,34 @@ impl Console {
|
||||
.write()
|
||||
.map_err(|_| ConsoleError::LockPoisoned)?;
|
||||
|
||||
if let Some(running) = runtime.take()
|
||||
&& let Err(error) = running.stop_all()
|
||||
{
|
||||
drop(runtime);
|
||||
self.mark_runtime_stopped()?;
|
||||
self.push_event(
|
||||
EventKind::Failed,
|
||||
None,
|
||||
format!("Wasmeld Runtime failed to restart while stopping actors: {error}"),
|
||||
)?;
|
||||
return Err(error.into());
|
||||
if let Some(running) = runtime.take() {
|
||||
self.shutdown_all_resident_supervisors();
|
||||
if let Err(error) = running.stop_all() {
|
||||
drop(runtime);
|
||||
self.mark_runtime_stopped()?;
|
||||
self.push_event(
|
||||
EventKind::Failed,
|
||||
None,
|
||||
format!("Wasmeld Runtime failed to restart while stopping actors: {error}"),
|
||||
)?;
|
||||
return Err(error.into());
|
||||
}
|
||||
}
|
||||
|
||||
match self.build_runtime() {
|
||||
Ok(started) => {
|
||||
*runtime = Some(started);
|
||||
drop(runtime);
|
||||
if let Err(error) = self.restore_deployments() {
|
||||
self.rollback_runtime_start();
|
||||
self.mark_runtime_stopped()?;
|
||||
self.push_event(
|
||||
EventKind::Failed,
|
||||
None,
|
||||
format!("Wasmeld Runtime failed to restore deployments: {error}"),
|
||||
)?;
|
||||
return Err(error);
|
||||
}
|
||||
self.mark_runtime_started()?;
|
||||
self.push_event(
|
||||
EventKind::Started,
|
||||
@@ -696,25 +834,120 @@ impl Console {
|
||||
.values()
|
||||
.map(|service| service.manifest.clone())
|
||||
.collect::<Vec<_>>();
|
||||
let deployments = state
|
||||
.deployments
|
||||
.iter()
|
||||
.map(|(service_id, deployment)| {
|
||||
ServiceKey::new(service_id.clone(), deployment.active_revision.clone())
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
drop(state);
|
||||
|
||||
let runtime = Runtime::new(self.runtime_config.clone())?;
|
||||
for manifest in manifests {
|
||||
runtime.register_from_file(manifest)?;
|
||||
}
|
||||
for key in deployments {
|
||||
runtime.start(&key, Vec::new())?;
|
||||
}
|
||||
Ok(runtime)
|
||||
}
|
||||
|
||||
fn start_actor(
|
||||
&self,
|
||||
runtime: &Runtime,
|
||||
key: &ServiceKey,
|
||||
init_config: Vec<u8>,
|
||||
) -> Result<ActorHandle, ConsoleError> {
|
||||
let actor = runtime.start(key, init_config)?;
|
||||
if let Err(error) = self.attach_resident_supervisor(actor.clone()) {
|
||||
// A resident Actor without its Host driver would be running in a
|
||||
// misleading half-state. Roll it back before surfacing the error.
|
||||
let _ = runtime.stop(key);
|
||||
return Err(error);
|
||||
}
|
||||
Ok(actor)
|
||||
}
|
||||
|
||||
fn attach_resident_supervisor(&self, actor: ActorHandle) -> Result<(), ConsoleError> {
|
||||
if actor.execution() != ComponentExecution::Resident {
|
||||
return Ok(());
|
||||
}
|
||||
let key = actor.key().clone();
|
||||
let mut supervisors = self
|
||||
.resident_supervisors
|
||||
.lock()
|
||||
.map_err(|_| ConsoleError::LockPoisoned)?;
|
||||
if supervisors
|
||||
.get(&key)
|
||||
.is_some_and(|supervisor| supervisor.owns_actor(&actor))
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(mut stale) = supervisors.remove(&key)
|
||||
&& let Err(error) = stale.shutdown()
|
||||
{
|
||||
tracing::warn!(
|
||||
service = %key,
|
||||
%error,
|
||||
"stale resident supervisor did not drain cleanly before Actor replacement"
|
||||
);
|
||||
}
|
||||
let config = self
|
||||
.resident_services
|
||||
.get(key.id())
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let command_capacity = actor.mailbox_capacity();
|
||||
let supervisor =
|
||||
ResidentSupervisor::start(actor, config, self.async_runtime.clone(), command_capacity)
|
||||
.map_err(supervisor_error)?;
|
||||
supervisors.insert(key, supervisor);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn shutdown_resident_supervisor(&self, key: &ServiceKey) {
|
||||
let supervisor = self
|
||||
.resident_supervisors
|
||||
.lock()
|
||||
.map(|mut supervisors| supervisors.remove(key));
|
||||
match supervisor {
|
||||
Ok(Some(mut supervisor)) => {
|
||||
if let Err(error) = supervisor.shutdown() {
|
||||
tracing::warn!(service = %key, %error, "resident supervisor did not drain cleanly");
|
||||
}
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(_) => tracing::warn!(service = %key, "resident supervisor lock was poisoned"),
|
||||
}
|
||||
}
|
||||
|
||||
fn shutdown_all_resident_supervisors(&self) {
|
||||
let supervisors = self
|
||||
.resident_supervisors
|
||||
.lock()
|
||||
.map(|mut supervisors| std::mem::take(&mut *supervisors));
|
||||
match supervisors {
|
||||
Ok(supervisors) => {
|
||||
for (key, mut supervisor) in supervisors {
|
||||
if let Err(error) = supervisor.shutdown() {
|
||||
tracing::warn!(service = %key, %error, "resident supervisor did not drain cleanly");
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(_) => tracing::warn!("resident supervisor lock was poisoned"),
|
||||
}
|
||||
}
|
||||
|
||||
fn stop_actor(&self, runtime: &Runtime, key: &ServiceKey) -> Result<(), RuntimeError> {
|
||||
self.shutdown_resident_supervisor(key);
|
||||
runtime.stop(key)
|
||||
}
|
||||
|
||||
fn rollback_runtime_start(&self) {
|
||||
self.shutdown_all_resident_supervisors();
|
||||
match self.runtime.write() {
|
||||
Ok(mut runtime) => {
|
||||
if let Some(runtime) = runtime.take()
|
||||
&& let Err(error) = runtime.stop_all()
|
||||
{
|
||||
tracing::warn!(%error, "failed to stop Runtime during start rollback");
|
||||
}
|
||||
}
|
||||
Err(_) => tracing::warn!("Runtime lock was poisoned during start rollback"),
|
||||
}
|
||||
}
|
||||
|
||||
fn mark_runtime_started(&self) -> Result<(), ConsoleError> {
|
||||
let mut state = self.state()?;
|
||||
let deployed = state
|
||||
@@ -793,6 +1026,11 @@ impl Console {
|
||||
};
|
||||
manifest.validate()?;
|
||||
|
||||
// Artifacts are committed before Runtime registration so
|
||||
// `register_from_file` can recover the same bytes after restart.
|
||||
// Every later fallible step removes files it created until the Runtime
|
||||
// owns the registration. The HTTP wrapper persists the state snapshot
|
||||
// after this method returns.
|
||||
write_atomic(&component_path, &component_bytes)?;
|
||||
let manifest_toml = toml::to_string_pretty(&manifest)?;
|
||||
if let Err(error) = write_atomic(&manifest_path, manifest_toml.as_bytes()) {
|
||||
@@ -839,6 +1077,7 @@ impl Console {
|
||||
|
||||
let runtime = self.runtime()?;
|
||||
let runtime = runtime.as_ref().ok_or(ConsoleError::RuntimeNotRunning)?;
|
||||
self.shutdown_resident_supervisor(key);
|
||||
runtime.unregister(key)?;
|
||||
self.state()?.services.remove(key);
|
||||
|
||||
@@ -864,7 +1103,7 @@ impl Console {
|
||||
let runtime = self.runtime()?;
|
||||
let runtime = runtime.as_ref().ok_or(ConsoleError::RuntimeNotRunning)?;
|
||||
self.ensure_managed(key)?;
|
||||
runtime.start(key, init_config)?;
|
||||
self.start_actor(runtime, key, init_config)?;
|
||||
let view = self.update_status(key, ServiceStatus::Running)?;
|
||||
self.push_event(EventKind::Started, Some(key), "actor started".to_owned())?;
|
||||
Ok(view)
|
||||
@@ -874,7 +1113,7 @@ impl Console {
|
||||
let runtime = self.runtime()?;
|
||||
let runtime = runtime.as_ref().ok_or(ConsoleError::RuntimeNotRunning)?;
|
||||
self.ensure_managed(key)?;
|
||||
runtime.stop(key)?;
|
||||
self.stop_actor(runtime, key)?;
|
||||
let view = self.update_status(key, ServiceStatus::Stopped)?;
|
||||
self.push_event(EventKind::Stopped, Some(key), "actor stopped".to_owned())?;
|
||||
Ok(view)
|
||||
@@ -884,11 +1123,11 @@ impl Console {
|
||||
let runtime = self.runtime()?;
|
||||
let runtime = runtime.as_ref().ok_or(ConsoleError::RuntimeNotRunning)?;
|
||||
self.ensure_managed(key)?;
|
||||
match runtime.stop(key) {
|
||||
match self.stop_actor(runtime, key) {
|
||||
Ok(()) | Err(RuntimeError::ActorUnavailable(_)) => {}
|
||||
Err(error) => return Err(error.into()),
|
||||
}
|
||||
runtime.start(key, init_config)?;
|
||||
self.start_actor(runtime, key, init_config)?;
|
||||
let view = self.update_status(key, ServiceStatus::Running)?;
|
||||
self.push_event(EventKind::Started, Some(key), "actor restarted".to_owned())?;
|
||||
Ok(view)
|
||||
@@ -914,7 +1153,7 @@ impl Console {
|
||||
// Starting is idempotent. The old revision stays resident so calls
|
||||
// that resolved immediately before this switch can finish safely and
|
||||
// rollback does not require recompilation.
|
||||
runtime.start(&key, init_config)?;
|
||||
self.start_actor(runtime, &key, init_config)?;
|
||||
|
||||
let updated_at_ms = unix_time_ms();
|
||||
let view = {
|
||||
@@ -950,6 +1189,10 @@ impl Console {
|
||||
let result = runtime.invoke(key, input);
|
||||
let latency_ms = duration_ms(started_at.elapsed());
|
||||
|
||||
// Update counters synchronously so management reads immediately
|
||||
// observe this call. Durable telemetry is queued below and is
|
||||
// intentionally best-effort: queue saturation or a process crash can
|
||||
// lose recent counters/events. Do not use these values for billing.
|
||||
let mut state = self.state()?;
|
||||
let record = state
|
||||
.services
|
||||
@@ -995,6 +1238,9 @@ impl Console {
|
||||
service_id: &str,
|
||||
input: Vec<u8>,
|
||||
) -> Result<GatewayInvocation, ConsoleError> {
|
||||
// Clone the selected revision while holding state, then release the
|
||||
// lock before entering Runtime. An activation racing this call affects
|
||||
// only subsequent resolutions; this call remains pinned to `revision`.
|
||||
let revision = {
|
||||
let state = self.state()?;
|
||||
state
|
||||
@@ -1122,7 +1368,7 @@ impl Console {
|
||||
let runtime = self.runtime()?;
|
||||
let runtime = runtime.as_ref().ok_or(ConsoleError::RuntimeNotRunning)?;
|
||||
for key in &deployments {
|
||||
runtime.start(key, Vec::new())?;
|
||||
self.start_actor(runtime, key, Vec::new())?;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1139,7 +1385,9 @@ impl Console {
|
||||
|
||||
async fn persist_snapshot(&self) -> Result<(), ConsoleError> {
|
||||
// Serialize snapshots so a slower earlier write cannot overwrite a
|
||||
// newer state captured by a concurrent HTTP operation.
|
||||
// newer state captured by a concurrent HTTP operation. This mutex is
|
||||
// also shared with the invocation delta writer, which prevents the two
|
||||
// transaction styles from interleaving.
|
||||
let _sync = self.persistence_sync.lock().await;
|
||||
let (services, events, deployments) = self.persistence_snapshot()?;
|
||||
self.persistence.sync(services, events, deployments).await?;
|
||||
@@ -1261,6 +1509,22 @@ impl Console {
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Console {
|
||||
fn drop(&mut self) {
|
||||
self.shutdown_all_resident_supervisors();
|
||||
if let Ok(runtime) = self.runtime.get_mut()
|
||||
&& let Some(runtime) = runtime.take()
|
||||
&& let Err(error) = runtime.stop_all()
|
||||
{
|
||||
tracing::warn!(%error, "failed to stop Runtime while dropping Console");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn supervisor_error(error: ResidentSupervisorError) -> ConsoleError {
|
||||
ConsoleError::ResidentSupervisor(error.to_string())
|
||||
}
|
||||
|
||||
fn runtime_capabilities(
|
||||
runtime: &Runtime,
|
||||
key: &ServiceKey,
|
||||
|
||||
@@ -1,4 +1,25 @@
|
||||
//! Standalone Wasmeld control-plane and data-plane 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};
|
||||
|
||||
@@ -45,6 +66,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let gateway = gateway_app(Arc::clone(&console));
|
||||
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;
|
||||
@@ -61,6 +84,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
);
|
||||
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(())
|
||||
}
|
||||
|
||||
@@ -3,6 +3,15 @@
|
||||
//! Component bytes and WIT packages remain filesystem artifacts. This module
|
||||
//! persists service manifests, deployments, metrics, the bounded event
|
||||
//! 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::{
|
||||
collections::BTreeSet,
|
||||
@@ -55,6 +64,10 @@ pub(crate) struct StoredKvEntry {
|
||||
}
|
||||
|
||||
/// 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,
|
||||
@@ -64,6 +77,10 @@ pub(crate) struct InvocationUpdate {
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
db: Arc<Mutex<Db>>,
|
||||
@@ -131,6 +148,11 @@ impl Persistence {
|
||||
}
|
||||
|
||||
/// 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(
|
||||
&self,
|
||||
services: Vec<StoredService>,
|
||||
@@ -178,6 +200,8 @@ impl Persistence {
|
||||
}
|
||||
}
|
||||
|
||||
// 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() {
|
||||
StoredEvent::filter(StoredEvent::fields().id().lt(oldest_event.id))
|
||||
.delete()
|
||||
@@ -217,6 +241,9 @@ impl Persistence {
|
||||
}
|
||||
|
||||
/// 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>,
|
||||
@@ -268,6 +295,8 @@ impl Persistence {
|
||||
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)
|
||||
@@ -284,6 +313,8 @@ impl Persistence {
|
||||
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)
|
||||
|
||||
@@ -0,0 +1,877 @@
|
||||
//! Revision-scoped execution of Host-owned resident event sources.
|
||||
//!
|
||||
//! The Wasmtime Actor API is intentionally synchronous: every call waits for a
|
||||
//! bounded mailbox response. Running [`ResidentSession`] directly from a Tokio
|
||||
//! task would therefore block an async worker for up to the Component deadline.
|
||||
//! This module keeps one session on a dedicated supervisor thread. Async
|
||||
//! drivers only wait for external activity and use a bounded channel to submit
|
||||
//! facts such as a timer deadline, accepted TCP connection, or stream chunk.
|
||||
//!
|
||||
//! ```text
|
||||
//! timer / listener / stream task --> bounded queue --> supervisor thread
|
||||
//! ^ |
|
||||
//! |-------- validated operations --------|
|
||||
//! ```
|
||||
//!
|
||||
//! The supervisor is scoped to one immutable service revision. Shutdown first
|
||||
//! prevents new driver events, then delivers the resident `shutdown` callback,
|
||||
//! aborts every Host task, closes socket handles, releases Host resource IDs,
|
||||
//! and only then allows the Console to stop the Actor.
|
||||
|
||||
mod tcp;
|
||||
|
||||
use std::{
|
||||
collections::{HashMap, VecDeque},
|
||||
io,
|
||||
net::SocketAddr,
|
||||
sync::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
mpsc::{self, Receiver, SyncSender, TrySendError},
|
||||
},
|
||||
thread::{self, JoinHandle as ThreadJoinHandle},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use thiserror::Error;
|
||||
use tokio::{net::TcpStream, runtime::Handle, task::JoinHandle as TaskJoinHandle};
|
||||
use wasmeld_runtime::{
|
||||
ActorHandle, ResidentEndpoint, ResidentHostError, ResidentOperation, ResidentPolicy,
|
||||
ResidentSession, ResourceId, ServiceKey, StreamCloseReason,
|
||||
};
|
||||
|
||||
pub use tcp::ResidentTcpListenerConfig;
|
||||
use tcp::{TcpDriverError, TcpEndpointDriver, TcpStreamCommand, TcpStreamDriver};
|
||||
|
||||
/// Platform configuration for Host resources attached to one service ID.
|
||||
///
|
||||
/// A configuration is inherited by every started revision of that service, but
|
||||
/// each revision receives independent Host handles and resource IDs.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct ResidentServiceConfig {
|
||||
/// Defense-in-depth policy checked before any Host endpoint is bound.
|
||||
///
|
||||
/// The default denies every network endpoint. Adding a TCP listener also
|
||||
/// requires setting `tcp_listen` to `loopback` or `any`.
|
||||
pub policy: ResidentPolicy,
|
||||
/// Host-owned TCP listeners registered when a resident Actor starts.
|
||||
pub tcp_listeners: Vec<ResidentTcpListenerConfig>,
|
||||
/// Host-owned timers registered when a resident Actor starts.
|
||||
pub timers: Vec<ResidentTimerConfig>,
|
||||
}
|
||||
|
||||
impl ResidentServiceConfig {
|
||||
pub(crate) fn validate(&self) -> Result<(), String> {
|
||||
let mut names = std::collections::BTreeSet::new();
|
||||
for listener in &self.tcp_listeners {
|
||||
listener.validate()?;
|
||||
self.policy
|
||||
.validate_endpoint(&ResidentEndpoint::Tcp {
|
||||
name: listener.name.clone(),
|
||||
bind: listener.bind,
|
||||
})
|
||||
.map_err(|error| error.to_string())?;
|
||||
if !names.insert(listener.name.as_str()) {
|
||||
return Err(format!(
|
||||
"resident resource name {:?} is duplicated",
|
||||
listener.name
|
||||
));
|
||||
}
|
||||
}
|
||||
for timer in &self.timers {
|
||||
timer.validate()?;
|
||||
if !names.insert(timer.name.as_str()) {
|
||||
return Err(format!(
|
||||
"resident resource name {:?} is duplicated",
|
||||
timer.name
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// One management-owned timer source for a resident Component.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct ResidentTimerConfig {
|
||||
/// Stable operator-facing name, unique within one service configuration.
|
||||
pub name: String,
|
||||
/// Delay before the first event after the Actor starts.
|
||||
pub initial_delay_ms: u64,
|
||||
/// Optional Host-managed repeat period.
|
||||
///
|
||||
/// A Component may replace this schedule by returning `arm-timer` or stop
|
||||
/// it with `cancel-timer`.
|
||||
pub interval_ms: Option<u64>,
|
||||
}
|
||||
|
||||
impl ResidentTimerConfig {
|
||||
fn validate(&self) -> Result<(), String> {
|
||||
if self.name.trim().is_empty() {
|
||||
return Err("resident timer name must not be empty".to_owned());
|
||||
}
|
||||
if self.initial_delay_ms == 0 {
|
||||
return Err(format!(
|
||||
"resident timer {:?} initial_delay_ms must be greater than zero",
|
||||
self.name
|
||||
));
|
||||
}
|
||||
if self.interval_ms == Some(0) {
|
||||
return Err(format!(
|
||||
"resident timer {:?} interval_ms must be greater than zero",
|
||||
self.name
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Failure while creating, driving, or draining a resident session.
|
||||
#[derive(Debug, Error)]
|
||||
pub(crate) enum ResidentSupervisorError {
|
||||
#[error(transparent)]
|
||||
Host(#[from] ResidentHostError),
|
||||
|
||||
#[error("resident driver queue capacity must be greater than zero")]
|
||||
EmptyQueue,
|
||||
|
||||
#[error("failed to start resident supervisor thread: {0}")]
|
||||
ThreadStart(#[source] io::Error),
|
||||
|
||||
#[error("resident supervisor for {0} stopped accepting commands")]
|
||||
ChannelClosed(ServiceKey),
|
||||
|
||||
#[error("resident supervisor for {0} panicked")]
|
||||
WorkerPanicked(ServiceKey),
|
||||
|
||||
#[error("resident timer operation referenced unknown resource {0}")]
|
||||
UnknownTimer(u64),
|
||||
|
||||
#[error("resident stream operation referenced unknown resource {0}")]
|
||||
UnknownStream(u64),
|
||||
|
||||
#[error(transparent)]
|
||||
Tcp(#[from] TcpDriverError),
|
||||
|
||||
#[error("resident driver cannot apply {0} before its driver is installed")]
|
||||
UnsupportedOperation(&'static str),
|
||||
}
|
||||
|
||||
/// Clone-free owner for one revision's resident session and driver tasks.
|
||||
pub(crate) struct ResidentSupervisor {
|
||||
key: ServiceKey,
|
||||
actor: ActorHandle,
|
||||
sender: SyncSender<SupervisorCommand>,
|
||||
accepting_events: Arc<AtomicBool>,
|
||||
worker: Option<ThreadJoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl ResidentSupervisor {
|
||||
pub(crate) fn start(
|
||||
actor: ActorHandle,
|
||||
config: ResidentServiceConfig,
|
||||
async_runtime: Handle,
|
||||
command_capacity: usize,
|
||||
) -> Result<Self, ResidentSupervisorError> {
|
||||
if command_capacity == 0 {
|
||||
return Err(ResidentSupervisorError::EmptyQueue);
|
||||
}
|
||||
let key = actor.key().clone();
|
||||
let session = ResidentSession::new(actor.clone(), config.policy.clone())?;
|
||||
let (sender, receiver) = mpsc::sync_channel(command_capacity);
|
||||
let accepting_events = Arc::new(AtomicBool::new(true));
|
||||
let core = SupervisorCore::new(
|
||||
session,
|
||||
config,
|
||||
async_runtime,
|
||||
sender.clone(),
|
||||
accepting_events.clone(),
|
||||
command_capacity,
|
||||
Instant::now(),
|
||||
)?;
|
||||
let thread_name = format!("wasmeld-resident-{key}");
|
||||
let worker_key = key.clone();
|
||||
let worker = thread::Builder::new()
|
||||
.name(thread_name)
|
||||
.spawn(move || run_supervisor(worker_key, core, receiver))
|
||||
.map_err(ResidentSupervisorError::ThreadStart)?;
|
||||
Ok(Self {
|
||||
key,
|
||||
actor,
|
||||
sender,
|
||||
accepting_events,
|
||||
worker: Some(worker),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn owns_actor(&self, actor: &ActorHandle) -> bool {
|
||||
self.actor.is_same_instance(actor)
|
||||
}
|
||||
|
||||
/// Drains this revision and cancels all Host listener, stream, and timer tasks.
|
||||
pub(crate) fn shutdown(&mut self) -> Result<(), ResidentSupervisorError> {
|
||||
let Some(worker) = self.worker.take() else {
|
||||
return Ok(());
|
||||
};
|
||||
// Async event producers observe this before retrying a full bounded
|
||||
// queue, so the control-plane shutdown command cannot be starved by
|
||||
// network traffic.
|
||||
self.accepting_events.store(false, Ordering::Release);
|
||||
let (response_sender, response_receiver) = mpsc::sync_channel(1);
|
||||
let send_result = self
|
||||
.sender
|
||||
.send(SupervisorCommand::Shutdown { response_sender });
|
||||
let response = match send_result {
|
||||
Ok(()) => response_receiver
|
||||
.recv()
|
||||
.map_err(|_| ResidentSupervisorError::ChannelClosed(self.key.clone()))?,
|
||||
Err(_) => Err(ResidentSupervisorError::ChannelClosed(self.key.clone())),
|
||||
};
|
||||
worker
|
||||
.join()
|
||||
.map_err(|_| ResidentSupervisorError::WorkerPanicked(self.key.clone()))?;
|
||||
response
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ResidentSupervisor {
|
||||
fn drop(&mut self) {
|
||||
if let Err(error) = self.shutdown() {
|
||||
tracing::warn!(service = %self.key, %error, "resident supervisor drop could not drain cleanly");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum SupervisorCommand {
|
||||
TcpAccepted {
|
||||
endpoint_id: ResourceId,
|
||||
stream: TcpStream,
|
||||
peer: SocketAddr,
|
||||
},
|
||||
StreamData {
|
||||
stream_id: ResourceId,
|
||||
bytes: Vec<u8>,
|
||||
},
|
||||
StreamHalfClosed {
|
||||
stream_id: ResourceId,
|
||||
},
|
||||
StreamClosed {
|
||||
stream_id: ResourceId,
|
||||
reason: StreamCloseReason,
|
||||
},
|
||||
TimerFired {
|
||||
timer_id: ResourceId,
|
||||
generation: u64,
|
||||
scheduled_at_ns: u64,
|
||||
},
|
||||
Shutdown {
|
||||
response_sender: SyncSender<Result<(), ResidentSupervisorError>>,
|
||||
},
|
||||
}
|
||||
|
||||
struct SupervisorCore {
|
||||
session: ResidentSession,
|
||||
async_runtime: Handle,
|
||||
sender: SyncSender<SupervisorCommand>,
|
||||
accepting_events: Arc<AtomicBool>,
|
||||
stream_command_capacity: usize,
|
||||
stream_read_bytes: usize,
|
||||
origin: Instant,
|
||||
tcp_endpoints: HashMap<ResourceId, TcpEndpointDriver>,
|
||||
streams: HashMap<ResourceId, TcpStreamDriver>,
|
||||
timers: HashMap<ResourceId, TimerDriver>,
|
||||
}
|
||||
|
||||
impl SupervisorCore {
|
||||
fn new(
|
||||
session: ResidentSession,
|
||||
config: ResidentServiceConfig,
|
||||
async_runtime: Handle,
|
||||
sender: SyncSender<SupervisorCommand>,
|
||||
accepting_events: Arc<AtomicBool>,
|
||||
stream_command_capacity: usize,
|
||||
origin: Instant,
|
||||
) -> Result<Self, ResidentSupervisorError> {
|
||||
let stream_read_bytes = session.max_stream_chunk_bytes();
|
||||
let mut core = Self {
|
||||
session,
|
||||
async_runtime,
|
||||
sender,
|
||||
accepting_events,
|
||||
stream_command_capacity,
|
||||
stream_read_bytes,
|
||||
origin,
|
||||
tcp_endpoints: HashMap::new(),
|
||||
streams: HashMap::new(),
|
||||
timers: HashMap::new(),
|
||||
};
|
||||
for listener in config.tcp_listeners {
|
||||
core.register_tcp_listener(listener)?;
|
||||
}
|
||||
for timer in config.timers {
|
||||
let timer_id = core.session.register_timer(timer.name)?;
|
||||
core.timers.insert(timer_id, TimerDriver::default());
|
||||
core.arm_timer(timer_id, timer.initial_delay_ms, timer.interval_ms)?;
|
||||
}
|
||||
Ok(core)
|
||||
}
|
||||
|
||||
fn register_tcp_listener(
|
||||
&mut self,
|
||||
config: ResidentTcpListenerConfig,
|
||||
) -> Result<(), ResidentSupervisorError> {
|
||||
let endpoint_id = self.session.register_endpoint(ResidentEndpoint::Tcp {
|
||||
name: config.name.clone(),
|
||||
bind: config.bind,
|
||||
})?;
|
||||
|
||||
let driver = match TcpEndpointDriver::start(
|
||||
&config,
|
||||
self.session.owner().clone(),
|
||||
endpoint_id,
|
||||
&self.async_runtime,
|
||||
self.sender.clone(),
|
||||
self.accepting_events.clone(),
|
||||
) {
|
||||
Ok(driver) => driver,
|
||||
Err(error) => {
|
||||
let _ = self.session.release_resource(endpoint_id);
|
||||
return Err(error.into());
|
||||
}
|
||||
};
|
||||
self.tcp_endpoints.insert(endpoint_id, driver);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn tcp_accepted(
|
||||
&mut self,
|
||||
endpoint_id: ResourceId,
|
||||
stream: TcpStream,
|
||||
peer: SocketAddr,
|
||||
) -> Result<(), ResidentSupervisorError> {
|
||||
let (stream_id, operations) = self
|
||||
.session
|
||||
.accept_stream(endpoint_id, Some(peer.to_string()))?;
|
||||
let driver = TcpStreamDriver::start(
|
||||
self.session.owner().clone(),
|
||||
stream_id,
|
||||
stream,
|
||||
self.stream_read_bytes,
|
||||
self.stream_command_capacity,
|
||||
&self.async_runtime,
|
||||
self.sender.clone(),
|
||||
self.accepting_events.clone(),
|
||||
);
|
||||
self.streams.insert(stream_id, driver);
|
||||
if let Err(error) = self.apply_operations(operations) {
|
||||
self.discard_stream(stream_id);
|
||||
return Err(error);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn stream_data(
|
||||
&mut self,
|
||||
stream_id: ResourceId,
|
||||
bytes: Vec<u8>,
|
||||
) -> Result<(), ResidentSupervisorError> {
|
||||
if !self.streams.contains_key(&stream_id) {
|
||||
return Ok(());
|
||||
}
|
||||
let result = self
|
||||
.session
|
||||
.stream_data(stream_id, bytes)
|
||||
.map_err(ResidentSupervisorError::from)
|
||||
.and_then(|operations| self.apply_operations(operations));
|
||||
if result.is_err() {
|
||||
self.discard_stream(stream_id);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn stream_half_closed(&mut self, stream_id: ResourceId) -> Result<(), ResidentSupervisorError> {
|
||||
if !self.streams.contains_key(&stream_id) {
|
||||
return Ok(());
|
||||
}
|
||||
let result = self
|
||||
.session
|
||||
.stream_half_closed(stream_id)
|
||||
.map_err(ResidentSupervisorError::from)
|
||||
.and_then(|operations| self.apply_operations(operations));
|
||||
if result.is_err() {
|
||||
self.discard_stream(stream_id);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn stream_closed(
|
||||
&mut self,
|
||||
stream_id: ResourceId,
|
||||
reason: StreamCloseReason,
|
||||
) -> Result<(), ResidentSupervisorError> {
|
||||
let Some(driver) = self.streams.remove(&stream_id) else {
|
||||
// A task can race with Host-initiated termination. Resource IDs are
|
||||
// never reused, so a late terminal fact is safe to ignore.
|
||||
return Ok(());
|
||||
};
|
||||
drop(driver);
|
||||
let operations = self.session.stream_closed(stream_id, reason)?;
|
||||
self.apply_operations(operations)
|
||||
}
|
||||
|
||||
fn discard_stream(&mut self, stream_id: ResourceId) {
|
||||
self.streams.remove(&stream_id);
|
||||
let _ = self.session.release_resource(stream_id);
|
||||
}
|
||||
|
||||
fn timer_fired(
|
||||
&mut self,
|
||||
timer_id: ResourceId,
|
||||
generation: u64,
|
||||
scheduled_at_ns: u64,
|
||||
) -> Result<(), ResidentSupervisorError> {
|
||||
let interval_ms = {
|
||||
let timer = self
|
||||
.timers
|
||||
.get_mut(&timer_id)
|
||||
.ok_or(ResidentSupervisorError::UnknownTimer(timer_id.get()))?;
|
||||
if timer.generation != generation {
|
||||
return Ok(());
|
||||
}
|
||||
timer.task = None;
|
||||
timer.interval_ms
|
||||
};
|
||||
let operations = self.session.timer_fired(timer_id, scheduled_at_ns)?;
|
||||
let schedule_replaced = operations.iter().any(|operation| {
|
||||
matches!(
|
||||
operation,
|
||||
ResidentOperation::ArmTimer {
|
||||
timer_id: operation_timer,
|
||||
..
|
||||
} | ResidentOperation::CancelTimer {
|
||||
timer_id: operation_timer
|
||||
} if *operation_timer == timer_id
|
||||
)
|
||||
});
|
||||
self.apply_operations(operations)?;
|
||||
if !schedule_replaced && let Some(interval_ms) = interval_ms {
|
||||
self.arm_timer(timer_id, interval_ms, Some(interval_ms))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn apply_operations(
|
||||
&mut self,
|
||||
operations: Vec<ResidentOperation>,
|
||||
) -> Result<(), ResidentSupervisorError> {
|
||||
let mut pending = VecDeque::from(operations);
|
||||
while let Some(operation) = pending.pop_front() {
|
||||
match operation {
|
||||
ResidentOperation::ArmTimer {
|
||||
timer_id,
|
||||
delay_ms,
|
||||
interval_ms,
|
||||
} => self.arm_timer(timer_id, delay_ms, interval_ms)?,
|
||||
ResidentOperation::CancelTimer { timer_id } => self.cancel_timer(timer_id)?,
|
||||
ResidentOperation::WriteStream { stream_id, bytes } => {
|
||||
if !self.queue_stream_command(stream_id, TcpStreamCommand::Write(bytes))? {
|
||||
pending.extend(
|
||||
self.terminate_stream(stream_id, StreamCloseReason::TransportError)?,
|
||||
);
|
||||
}
|
||||
}
|
||||
ResidentOperation::CloseStream { stream_id } => {
|
||||
if !self.queue_stream_command(stream_id, TcpStreamCommand::Close)? {
|
||||
pending.extend(
|
||||
self.terminate_stream(stream_id, StreamCloseReason::HostClosed)?,
|
||||
);
|
||||
}
|
||||
}
|
||||
ResidentOperation::PauseStream { stream_id } => {
|
||||
if !self.queue_stream_command(stream_id, TcpStreamCommand::Pause)? {
|
||||
pending.extend(
|
||||
self.terminate_stream(stream_id, StreamCloseReason::TransportError)?,
|
||||
);
|
||||
}
|
||||
}
|
||||
ResidentOperation::ResumeStream { stream_id } => {
|
||||
if !self.queue_stream_command(stream_id, TcpStreamCommand::Resume)? {
|
||||
pending.extend(
|
||||
self.terminate_stream(stream_id, StreamCloseReason::TransportError)?,
|
||||
);
|
||||
}
|
||||
}
|
||||
ResidentOperation::SendDatagram { .. } => {
|
||||
return Err(ResidentSupervisorError::UnsupportedOperation(
|
||||
"send-datagram",
|
||||
));
|
||||
}
|
||||
ResidentOperation::AcknowledgeMessage { .. } => {
|
||||
return Err(ResidentSupervisorError::UnsupportedOperation(
|
||||
"acknowledge-message",
|
||||
));
|
||||
}
|
||||
ResidentOperation::SourceCommand { .. } => {
|
||||
return Err(ResidentSupervisorError::UnsupportedOperation(
|
||||
"source-command",
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Queues one bounded operation without ever blocking the supervisor.
|
||||
///
|
||||
/// A full per-stream queue means the peer is not consuming output quickly
|
||||
/// enough. The caller terminates that stream instead of stalling unrelated
|
||||
/// connections or allowing an unbounded Host buffer.
|
||||
fn queue_stream_command(
|
||||
&self,
|
||||
stream_id: ResourceId,
|
||||
command: TcpStreamCommand,
|
||||
) -> Result<bool, ResidentSupervisorError> {
|
||||
let driver = self
|
||||
.streams
|
||||
.get(&stream_id)
|
||||
.ok_or(ResidentSupervisorError::UnknownStream(stream_id.get()))?;
|
||||
match driver.try_send(command) {
|
||||
Ok(()) => Ok(true),
|
||||
Err(tokio::sync::mpsc::error::TrySendError::Full(_)) => {
|
||||
tracing::warn!(
|
||||
service = %self.session.owner(),
|
||||
resource_id = stream_id.get(),
|
||||
"resident TCP stream closed because its write/control queue is full"
|
||||
);
|
||||
Ok(false)
|
||||
}
|
||||
Err(tokio::sync::mpsc::error::TrySendError::Closed(_)) => Ok(false),
|
||||
}
|
||||
}
|
||||
|
||||
fn terminate_stream(
|
||||
&mut self,
|
||||
stream_id: ResourceId,
|
||||
reason: StreamCloseReason,
|
||||
) -> Result<Vec<ResidentOperation>, ResidentSupervisorError> {
|
||||
let driver = self
|
||||
.streams
|
||||
.remove(&stream_id)
|
||||
.ok_or(ResidentSupervisorError::UnknownStream(stream_id.get()))?;
|
||||
drop(driver);
|
||||
Ok(self.session.stream_closed(stream_id, reason)?)
|
||||
}
|
||||
|
||||
fn arm_timer(
|
||||
&mut self,
|
||||
timer_id: ResourceId,
|
||||
delay_ms: u64,
|
||||
interval_ms: Option<u64>,
|
||||
) -> Result<(), ResidentSupervisorError> {
|
||||
let timer = self
|
||||
.timers
|
||||
.get_mut(&timer_id)
|
||||
.ok_or(ResidentSupervisorError::UnknownTimer(timer_id.get()))?;
|
||||
timer.cancel_task();
|
||||
timer.generation = next_generation(timer.generation);
|
||||
timer.interval_ms = interval_ms;
|
||||
let generation = timer.generation;
|
||||
let delay = Duration::from_millis(delay_ms);
|
||||
let scheduled_at_ns = u64::try_from(self.origin.elapsed().saturating_add(delay).as_nanos())
|
||||
.unwrap_or(u64::MAX);
|
||||
let sender = self.sender.clone();
|
||||
let accepting_events = self.accepting_events.clone();
|
||||
let owner = self.session.owner().clone();
|
||||
timer.task = Some(self.async_runtime.spawn(async move {
|
||||
tokio::time::sleep(delay).await;
|
||||
if !accepting_events.load(Ordering::Acquire) {
|
||||
return;
|
||||
}
|
||||
match sender.try_send(SupervisorCommand::TimerFired {
|
||||
timer_id,
|
||||
generation,
|
||||
scheduled_at_ns,
|
||||
}) {
|
||||
Ok(()) => {}
|
||||
Err(TrySendError::Full(_)) => {
|
||||
tracing::warn!(
|
||||
service = %owner,
|
||||
resource_id = timer_id.get(),
|
||||
"resident timer occurrence dropped because the supervisor queue is full"
|
||||
);
|
||||
}
|
||||
Err(TrySendError::Disconnected(_)) => {}
|
||||
}
|
||||
}));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cancel_timer(&mut self, timer_id: ResourceId) -> Result<(), ResidentSupervisorError> {
|
||||
let timer = self
|
||||
.timers
|
||||
.get_mut(&timer_id)
|
||||
.ok_or(ResidentSupervisorError::UnknownTimer(timer_id.get()))?;
|
||||
timer.cancel_task();
|
||||
timer.generation = next_generation(timer.generation);
|
||||
timer.interval_ms = None;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn shutdown(&mut self) -> Result<(), ResidentSupervisorError> {
|
||||
self.accepting_events.store(false, Ordering::Release);
|
||||
for endpoint in self.tcp_endpoints.values_mut() {
|
||||
endpoint.abort();
|
||||
}
|
||||
for timer in self.timers.values_mut() {
|
||||
timer.cancel_task();
|
||||
}
|
||||
|
||||
let mut first_error = match self.session.shutdown() {
|
||||
Ok(operations) => self.apply_operations(operations).err(),
|
||||
Err(error) => Some(error.into()),
|
||||
};
|
||||
|
||||
// A shutdown callback may return stream or timer operations. They have
|
||||
// been validated, but no source may remain active beyond this edge.
|
||||
let stream_ids = self.streams.keys().copied().collect::<Vec<_>>();
|
||||
for stream_id in stream_ids {
|
||||
self.streams.remove(&stream_id);
|
||||
if let Err(error) = self.session.release_resource(stream_id)
|
||||
&& first_error.is_none()
|
||||
{
|
||||
first_error = Some(error.into());
|
||||
}
|
||||
}
|
||||
self.streams.clear();
|
||||
|
||||
let endpoint_ids = self.tcp_endpoints.keys().copied().collect::<Vec<_>>();
|
||||
for endpoint_id in endpoint_ids {
|
||||
self.tcp_endpoints.remove(&endpoint_id);
|
||||
if let Err(error) = self.session.release_resource(endpoint_id)
|
||||
&& first_error.is_none()
|
||||
{
|
||||
first_error = Some(error.into());
|
||||
}
|
||||
}
|
||||
self.tcp_endpoints.clear();
|
||||
|
||||
for timer in self.timers.values_mut() {
|
||||
timer.cancel_task();
|
||||
}
|
||||
let timer_ids = self.timers.keys().copied().collect::<Vec<_>>();
|
||||
for timer_id in timer_ids {
|
||||
if let Err(error) = self.session.release_resource(timer_id)
|
||||
&& first_error.is_none()
|
||||
{
|
||||
first_error = Some(error.into());
|
||||
}
|
||||
}
|
||||
self.timers.clear();
|
||||
|
||||
match first_error {
|
||||
Some(error) => Err(error),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run_supervisor(
|
||||
key: ServiceKey,
|
||||
mut core: SupervisorCore,
|
||||
receiver: Receiver<SupervisorCommand>,
|
||||
) {
|
||||
while let Ok(command) = receiver.recv() {
|
||||
match command {
|
||||
SupervisorCommand::TcpAccepted {
|
||||
endpoint_id,
|
||||
stream,
|
||||
peer,
|
||||
} => {
|
||||
if let Err(error) = core.tcp_accepted(endpoint_id, stream, peer) {
|
||||
tracing::warn!(
|
||||
service = %key,
|
||||
resource_id = endpoint_id.get(),
|
||||
%peer,
|
||||
%error,
|
||||
"resident TCP connection was rejected"
|
||||
);
|
||||
}
|
||||
}
|
||||
SupervisorCommand::StreamData { stream_id, bytes } => {
|
||||
if let Err(error) = core.stream_data(stream_id, bytes) {
|
||||
tracing::warn!(
|
||||
service = %key,
|
||||
resource_id = stream_id.get(),
|
||||
%error,
|
||||
"resident TCP stream data failed and the connection was closed"
|
||||
);
|
||||
}
|
||||
}
|
||||
SupervisorCommand::StreamHalfClosed { stream_id } => {
|
||||
if let Err(error) = core.stream_half_closed(stream_id) {
|
||||
tracing::warn!(
|
||||
service = %key,
|
||||
resource_id = stream_id.get(),
|
||||
%error,
|
||||
"resident TCP half-close event failed and the connection was closed"
|
||||
);
|
||||
}
|
||||
}
|
||||
SupervisorCommand::StreamClosed { stream_id, reason } => {
|
||||
if let Err(error) = core.stream_closed(stream_id, reason) {
|
||||
tracing::warn!(
|
||||
service = %key,
|
||||
resource_id = stream_id.get(),
|
||||
%error,
|
||||
"resident TCP terminal event failed"
|
||||
);
|
||||
}
|
||||
}
|
||||
SupervisorCommand::TimerFired {
|
||||
timer_id,
|
||||
generation,
|
||||
scheduled_at_ns,
|
||||
} => {
|
||||
if let Err(error) = core.timer_fired(timer_id, generation, scheduled_at_ns) {
|
||||
let _ = core.cancel_timer(timer_id);
|
||||
tracing::warn!(
|
||||
service = %key,
|
||||
resource_id = timer_id.get(),
|
||||
%error,
|
||||
"resident timer event failed and was cancelled"
|
||||
);
|
||||
}
|
||||
}
|
||||
SupervisorCommand::Shutdown { response_sender } => {
|
||||
let result = core.shutdown();
|
||||
let _ = response_sender.send(result);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Err(error) = core.shutdown() {
|
||||
tracing::warn!(service = %key, %error, "resident supervisor channel closed during drain");
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct TimerDriver {
|
||||
generation: u64,
|
||||
interval_ms: Option<u64>,
|
||||
task: Option<TaskJoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl TimerDriver {
|
||||
fn cancel_task(&mut self) {
|
||||
if let Some(task) = self.task.take() {
|
||||
task.abort();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TimerDriver {
|
||||
fn drop(&mut self) {
|
||||
self.cancel_task();
|
||||
}
|
||||
}
|
||||
|
||||
fn next_generation(current: u64) -> u64 {
|
||||
let next = current.wrapping_add(1);
|
||||
if next == 0 { 1 } else { next }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
ResidentServiceConfig, ResidentTcpListenerConfig, ResidentTimerConfig, next_generation,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_timer_configuration() {
|
||||
for timer in [
|
||||
ResidentTimerConfig {
|
||||
name: String::new(),
|
||||
initial_delay_ms: 1,
|
||||
interval_ms: None,
|
||||
},
|
||||
ResidentTimerConfig {
|
||||
name: "zero-delay".to_owned(),
|
||||
initial_delay_ms: 0,
|
||||
interval_ms: None,
|
||||
},
|
||||
ResidentTimerConfig {
|
||||
name: "zero-interval".to_owned(),
|
||||
initial_delay_ms: 1,
|
||||
interval_ms: Some(0),
|
||||
},
|
||||
] {
|
||||
assert!(
|
||||
ResidentServiceConfig {
|
||||
timers: vec![timer],
|
||||
..ResidentServiceConfig::default()
|
||||
}
|
||||
.validate()
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_duplicate_timer_names() {
|
||||
let timer = ResidentTimerConfig {
|
||||
name: "heartbeat".to_owned(),
|
||||
initial_delay_ms: 1,
|
||||
interval_ms: None,
|
||||
};
|
||||
assert!(
|
||||
ResidentServiceConfig {
|
||||
timers: vec![timer.clone(), timer],
|
||||
..ResidentServiceConfig::default()
|
||||
}
|
||||
.validate()
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_names_shared_by_listener_and_timer() {
|
||||
assert!(
|
||||
ResidentServiceConfig {
|
||||
tcp_listeners: vec![ResidentTcpListenerConfig {
|
||||
name: "events".to_owned(),
|
||||
bind: "127.0.0.1:9000".parse().unwrap(),
|
||||
}],
|
||||
timers: vec![ResidentTimerConfig {
|
||||
name: "events".to_owned(),
|
||||
initial_delay_ms: 1,
|
||||
interval_ms: None,
|
||||
}],
|
||||
..ResidentServiceConfig::default()
|
||||
}
|
||||
.validate()
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_tcp_listener_outside_default_policy() {
|
||||
assert!(
|
||||
ResidentServiceConfig {
|
||||
tcp_listeners: vec![ResidentTcpListenerConfig {
|
||||
name: "denied".to_owned(),
|
||||
bind: "127.0.0.1:9000".parse().unwrap(),
|
||||
}],
|
||||
..ResidentServiceConfig::default()
|
||||
}
|
||||
.validate()
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timer_generation_never_uses_zero() {
|
||||
assert_eq!(next_generation(0), 1);
|
||||
assert_eq!(next_generation(u64::MAX), 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,402 @@
|
||||
//! Tokio TCP transport owned by one resident supervisor.
|
||||
//!
|
||||
//! This module owns operating-system socket handles and knows nothing about
|
||||
//! Wasm bindings or Component effects. It reports accepted connections and
|
||||
//! bounded stream facts through [`SupervisorCommand`], while the parent
|
||||
//! supervisor validates resource identity and translates Component operations
|
||||
//! back into [`TcpStreamCommand`] values.
|
||||
|
||||
use std::{
|
||||
io,
|
||||
net::SocketAddr,
|
||||
sync::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
mpsc::{SyncSender, TrySendError},
|
||||
},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use thiserror::Error;
|
||||
use tokio::{
|
||||
io::{AsyncReadExt, AsyncWriteExt},
|
||||
net::{TcpListener, TcpSocket, TcpStream},
|
||||
runtime::Handle,
|
||||
sync::mpsc,
|
||||
task::JoinHandle,
|
||||
};
|
||||
use wasmeld_runtime::{ResourceId, ServiceKey, StreamCloseReason};
|
||||
|
||||
use super::SupervisorCommand;
|
||||
|
||||
/// One management-owned TCP listener for a resident Component.
|
||||
///
|
||||
/// The address must be numeric; DNS and socket handles are never exposed to
|
||||
/// the Component. Port zero is accepted for tests and private embedding, but a
|
||||
/// fixed configured port is required when external callers must discover it.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct ResidentTcpListenerConfig {
|
||||
/// Stable operator-facing name, unique among all resources for the service.
|
||||
pub name: String,
|
||||
/// Numeric address bound by the Host after policy validation.
|
||||
pub bind: SocketAddr,
|
||||
}
|
||||
|
||||
impl ResidentTcpListenerConfig {
|
||||
pub(super) fn validate(&self) -> Result<(), String> {
|
||||
if self.name.trim().is_empty() {
|
||||
return Err("resident TCP listener name must not be empty".to_owned());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub(crate) enum TcpDriverError {
|
||||
#[error("failed to {action} resident TCP listener {name:?} at {bind}: {source}")]
|
||||
Listener {
|
||||
name: String,
|
||||
bind: SocketAddr,
|
||||
action: &'static str,
|
||||
#[source]
|
||||
source: io::Error,
|
||||
},
|
||||
}
|
||||
|
||||
pub(super) struct TcpEndpointDriver {
|
||||
task: JoinHandle<()>,
|
||||
}
|
||||
|
||||
impl TcpEndpointDriver {
|
||||
pub(super) fn start(
|
||||
config: &ResidentTcpListenerConfig,
|
||||
owner: ServiceKey,
|
||||
endpoint_id: ResourceId,
|
||||
async_runtime: &Handle,
|
||||
sender: SyncSender<SupervisorCommand>,
|
||||
accepting_events: Arc<AtomicBool>,
|
||||
) -> Result<Self, TcpDriverError> {
|
||||
let listener = bind_listener(config, async_runtime)?;
|
||||
let name = config.name.clone();
|
||||
let task = async_runtime.spawn(async move {
|
||||
run_listener(owner, endpoint_id, name, listener, sender, accepting_events).await;
|
||||
});
|
||||
Ok(Self { task })
|
||||
}
|
||||
|
||||
pub(super) fn abort(&mut self) {
|
||||
self.task.abort();
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TcpEndpointDriver {
|
||||
fn drop(&mut self) {
|
||||
self.abort();
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) struct TcpStreamDriver {
|
||||
commands: mpsc::Sender<TcpStreamCommand>,
|
||||
task: JoinHandle<()>,
|
||||
}
|
||||
|
||||
impl TcpStreamDriver {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn start(
|
||||
owner: ServiceKey,
|
||||
stream_id: ResourceId,
|
||||
stream: TcpStream,
|
||||
read_bytes: usize,
|
||||
command_capacity: usize,
|
||||
async_runtime: &Handle,
|
||||
sender: SyncSender<SupervisorCommand>,
|
||||
accepting_events: Arc<AtomicBool>,
|
||||
) -> Self {
|
||||
let (commands, command_receiver) = mpsc::channel(command_capacity);
|
||||
let task = async_runtime.spawn(async move {
|
||||
run_stream(
|
||||
owner,
|
||||
stream_id,
|
||||
stream,
|
||||
read_bytes,
|
||||
command_receiver,
|
||||
sender,
|
||||
accepting_events,
|
||||
)
|
||||
.await;
|
||||
});
|
||||
Self { commands, task }
|
||||
}
|
||||
|
||||
pub(super) fn try_send(
|
||||
&self,
|
||||
command: TcpStreamCommand,
|
||||
) -> Result<(), mpsc::error::TrySendError<TcpStreamCommand>> {
|
||||
self.commands.try_send(command)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TcpStreamDriver {
|
||||
fn drop(&mut self) {
|
||||
// Aborting the task drops both owned socket halves. The session owner
|
||||
// removes the resource identity separately and in deterministic order.
|
||||
self.task.abort();
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) enum TcpStreamCommand {
|
||||
Write(Vec<u8>),
|
||||
Close,
|
||||
Pause,
|
||||
Resume,
|
||||
}
|
||||
|
||||
fn bind_listener(
|
||||
config: &ResidentTcpListenerConfig,
|
||||
async_runtime: &Handle,
|
||||
) -> Result<TcpListener, TcpDriverError> {
|
||||
// TcpSocket gives the platform an explicit backlog and SO_REUSEADDR, which
|
||||
// makes an immediate service restart able to reclaim its stable address
|
||||
// after existing connections are closed.
|
||||
let _runtime_context = async_runtime.enter();
|
||||
let socket = if config.bind.is_ipv4() {
|
||||
TcpSocket::new_v4()
|
||||
} else {
|
||||
TcpSocket::new_v6()
|
||||
}
|
||||
.map_err(|source| listener_error(config, "create socket for", source))?;
|
||||
socket
|
||||
.set_reuseaddr(true)
|
||||
.map_err(|source| listener_error(config, "configure", source))?;
|
||||
socket
|
||||
.bind(config.bind)
|
||||
.map_err(|source| listener_error(config, "bind", source))?;
|
||||
socket
|
||||
.listen(1024)
|
||||
.map_err(|source| listener_error(config, "listen on", source))
|
||||
}
|
||||
|
||||
async fn run_listener(
|
||||
owner: ServiceKey,
|
||||
endpoint_id: ResourceId,
|
||||
name: String,
|
||||
listener: TcpListener,
|
||||
sender: SyncSender<SupervisorCommand>,
|
||||
accepting_events: Arc<AtomicBool>,
|
||||
) {
|
||||
while accepting_events.load(Ordering::Acquire) {
|
||||
match listener.accept().await {
|
||||
Ok((stream, peer)) => {
|
||||
if let Err(error) = stream.set_nodelay(true) {
|
||||
tracing::warn!(
|
||||
service = %owner,
|
||||
endpoint = %name,
|
||||
%peer,
|
||||
%error,
|
||||
"resident TCP connection rejected because TCP_NODELAY could not be set"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if !send_stream_event(
|
||||
&sender,
|
||||
&accepting_events,
|
||||
SupervisorCommand::TcpAccepted {
|
||||
endpoint_id,
|
||||
stream,
|
||||
peer,
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
if !accepting_events.load(Ordering::Acquire) {
|
||||
return;
|
||||
}
|
||||
tracing::warn!(
|
||||
service = %owner,
|
||||
endpoint = %name,
|
||||
%error,
|
||||
"resident TCP accept failed; listener will retry"
|
||||
);
|
||||
// Persistent descriptor exhaustion must not turn into a hot
|
||||
// retry loop. A healthy listener resumes after the backoff.
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_stream(
|
||||
owner: ServiceKey,
|
||||
stream_id: ResourceId,
|
||||
stream: TcpStream,
|
||||
read_bytes: usize,
|
||||
mut commands: mpsc::Receiver<TcpStreamCommand>,
|
||||
sender: SyncSender<SupervisorCommand>,
|
||||
accepting_events: Arc<AtomicBool>,
|
||||
) {
|
||||
let (mut reader, mut writer) = stream.into_split();
|
||||
let mut buffer = vec![0_u8; read_bytes];
|
||||
let mut read_paused = false;
|
||||
let mut peer_half_closed = false;
|
||||
|
||||
loop {
|
||||
if !accepting_events.load(Ordering::Acquire) {
|
||||
return;
|
||||
}
|
||||
|
||||
if read_paused || peer_half_closed {
|
||||
let Some(command) = commands.recv().await else {
|
||||
return;
|
||||
};
|
||||
if let Some(reason) = apply_stream_command(command, &mut writer, &mut read_paused).await
|
||||
{
|
||||
let _ = send_stream_terminal(&sender, &accepting_events, stream_id, reason).await;
|
||||
return;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
tokio::select! {
|
||||
biased;
|
||||
command = commands.recv() => {
|
||||
let Some(command) = command else {
|
||||
return;
|
||||
};
|
||||
if let Some(reason) =
|
||||
apply_stream_command(command, &mut writer, &mut read_paused).await
|
||||
{
|
||||
let _ =
|
||||
send_stream_terminal(&sender, &accepting_events, stream_id, reason).await;
|
||||
return;
|
||||
}
|
||||
}
|
||||
read = reader.read(&mut buffer) => {
|
||||
match read {
|
||||
Ok(0) => {
|
||||
peer_half_closed = true;
|
||||
if !send_stream_event(
|
||||
&sender,
|
||||
&accepting_events,
|
||||
SupervisorCommand::StreamHalfClosed { stream_id },
|
||||
)
|
||||
.await
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
Ok(read) => {
|
||||
if !send_stream_event(
|
||||
&sender,
|
||||
&accepting_events,
|
||||
SupervisorCommand::StreamData {
|
||||
stream_id,
|
||||
bytes: buffer[..read].to_vec(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::debug!(
|
||||
service = %owner,
|
||||
resource_id = stream_id.get(),
|
||||
%error,
|
||||
"resident TCP read failed"
|
||||
);
|
||||
let _ = send_stream_terminal(
|
||||
&sender,
|
||||
&accepting_events,
|
||||
stream_id,
|
||||
StreamCloseReason::TransportError,
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn apply_stream_command(
|
||||
command: TcpStreamCommand,
|
||||
writer: &mut tokio::net::tcp::OwnedWriteHalf,
|
||||
read_paused: &mut bool,
|
||||
) -> Option<StreamCloseReason> {
|
||||
match command {
|
||||
TcpStreamCommand::Write(bytes) => writer
|
||||
.write_all(&bytes)
|
||||
.await
|
||||
.err()
|
||||
.map(|_| StreamCloseReason::TransportError),
|
||||
TcpStreamCommand::Close => {
|
||||
let _ = writer.shutdown().await;
|
||||
Some(StreamCloseReason::HostClosed)
|
||||
}
|
||||
TcpStreamCommand::Pause => {
|
||||
*read_paused = true;
|
||||
None
|
||||
}
|
||||
TcpStreamCommand::Resume => {
|
||||
*read_paused = false;
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn send_stream_terminal(
|
||||
sender: &SyncSender<SupervisorCommand>,
|
||||
accepting_events: &AtomicBool,
|
||||
stream_id: ResourceId,
|
||||
reason: StreamCloseReason,
|
||||
) -> bool {
|
||||
send_stream_event(
|
||||
sender,
|
||||
accepting_events,
|
||||
SupervisorCommand::StreamClosed { stream_id, reason },
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Sends a non-lossy stream fact through the bounded supervisor queue.
|
||||
///
|
||||
/// Unlike timer ticks, TCP chunks cannot be dropped. Waiting here naturally
|
||||
/// stops the connection task from reading more bytes and propagates
|
||||
/// backpressure into the kernel receive buffer. Shutdown flips
|
||||
/// `accepting_events`, which cancels the retry without blocking Actor drain.
|
||||
async fn send_stream_event(
|
||||
sender: &SyncSender<SupervisorCommand>,
|
||||
accepting_events: &AtomicBool,
|
||||
mut command: SupervisorCommand,
|
||||
) -> bool {
|
||||
while accepting_events.load(Ordering::Acquire) {
|
||||
match sender.try_send(command) {
|
||||
Ok(()) => return true,
|
||||
Err(TrySendError::Full(returned)) => {
|
||||
command = returned;
|
||||
tokio::time::sleep(Duration::from_millis(1)).await;
|
||||
}
|
||||
Err(TrySendError::Disconnected(_)) => return false,
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn listener_error(
|
||||
config: &ResidentTcpListenerConfig,
|
||||
action: &'static str,
|
||||
source: io::Error,
|
||||
) -> TcpDriverError {
|
||||
TcpDriverError::Listener {
|
||||
name: config.name.clone(),
|
||||
bind: config.bind,
|
||||
action,
|
||||
source,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
//! 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,28 +37,40 @@ struct StoredWitPackage {
|
||||
/// Errors produced by WIT Registry storage and integrity checks.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum WitRegistryError {
|
||||
/// Uploaded binary WIT exceeds the configured byte limit.
|
||||
#[error("WIT package exceeds the {limit}-byte limit")]
|
||||
TooLarge { limit: usize },
|
||||
TooLarge {
|
||||
/// Maximum accepted artifact bytes.
|
||||
limit: usize,
|
||||
},
|
||||
|
||||
/// The immutable `name@version` already exists.
|
||||
#[error("WIT package {0} is already published and cannot be overwritten")]
|
||||
AlreadyExists(String),
|
||||
|
||||
/// No indexed artifact matches the requested identity.
|
||||
#[error("WIT package {0} was not found")]
|
||||
NotFound(String),
|
||||
|
||||
/// Uploaded or persisted bytes are not a valid versioned binary WIT package.
|
||||
#[error(transparent)]
|
||||
InvalidPackage(#[from] WitPackageError),
|
||||
|
||||
/// Directory layout and decoded artifact identity disagree.
|
||||
#[error("invalid WIT registry data: {0}")]
|
||||
InvalidStorage(String),
|
||||
|
||||
/// Registry directory or artifact filesystem access failed.
|
||||
#[error("failed to access {path}: {source}")]
|
||||
Storage {
|
||||
/// Path involved in the failed operation.
|
||||
path: PathBuf,
|
||||
/// Underlying filesystem failure.
|
||||
#[source]
|
||||
source: io::Error,
|
||||
},
|
||||
|
||||
/// The process-local Registry index lock was poisoned.
|
||||
#[error("WIT registry lock was poisoned")]
|
||||
LockPoisoned,
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
fs,
|
||||
io::Cursor,
|
||||
net::{SocketAddr, TcpListener as StdTcpListener},
|
||||
path::{Path, PathBuf},
|
||||
process::Command,
|
||||
sync::{Arc, OnceLock},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use axum::{
|
||||
@@ -14,8 +17,15 @@ use axum::{
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
|
||||
use serde_json::{Value, json};
|
||||
use tempfile::TempDir;
|
||||
use tokio::{
|
||||
io::{AsyncReadExt, AsyncWriteExt},
|
||||
net::TcpStream,
|
||||
};
|
||||
use tower::ServiceExt;
|
||||
use wasmeld_console::{Console, ConsoleConfig, app, gateway_app};
|
||||
use wasmeld_console::{
|
||||
Console, ConsoleConfig, NetworkScope, ResidentPolicy, ResidentServiceConfig,
|
||||
ResidentTcpListenerConfig, ResidentTimerConfig, app, gateway_app,
|
||||
};
|
||||
use wasmeld_package::{
|
||||
module::{ModuleLock, sync_dependencies},
|
||||
wit_package::build_wit_package,
|
||||
@@ -90,6 +100,192 @@ async fn manages_a_resident_component_over_http() {
|
||||
assert!(events["events"].as_array().unwrap().len() >= 5);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn drives_resident_timers_across_service_and_runtime_restarts() {
|
||||
let artifact_dir = TempDir::new().expect("temporary artifact directory");
|
||||
let mut resident_services = BTreeMap::new();
|
||||
resident_services.insert(
|
||||
"resident-probe".to_owned(),
|
||||
ResidentServiceConfig {
|
||||
timers: vec![ResidentTimerConfig {
|
||||
name: "heartbeat".to_owned(),
|
||||
initial_delay_ms: 5,
|
||||
interval_ms: None,
|
||||
}],
|
||||
..ResidentServiceConfig::default()
|
||||
},
|
||||
);
|
||||
let console = Arc::new(
|
||||
Console::new(ConsoleConfig {
|
||||
artifact_dir: artifact_dir.path().to_path_buf(),
|
||||
wit_registry_dir: artifact_dir.path().join("wit-packages"),
|
||||
database_path: artifact_dir.path().join("console.db"),
|
||||
resident_services,
|
||||
..ConsoleConfig::default()
|
||||
})
|
||||
.await
|
||||
.expect("console should start"),
|
||||
);
|
||||
let application = app(console, Vec::new());
|
||||
let component = fs::read(component_artifact("resident_probe_component.wasm"))
|
||||
.expect("resident probe component should be readable");
|
||||
|
||||
let response = application
|
||||
.clone()
|
||||
.oneshot(package_request_with_world(
|
||||
"resident-probe",
|
||||
"0.1.0",
|
||||
"component:resident-probe/resident-probe-component@0.1.0",
|
||||
&component,
|
||||
))
|
||||
.await
|
||||
.expect("resident package registration should complete");
|
||||
assert_eq!(response.status(), StatusCode::CREATED);
|
||||
|
||||
let response = application
|
||||
.clone()
|
||||
.oneshot(json_request(
|
||||
"/api/v1/deployments/resident-probe/activate",
|
||||
json!({ "revision": "0.1.0" }),
|
||||
))
|
||||
.await
|
||||
.expect("resident deployment activation should complete");
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert!(
|
||||
wait_for_resident_count(&application).await > 0,
|
||||
"configured timer should reach the resident Component"
|
||||
);
|
||||
|
||||
let response = application
|
||||
.clone()
|
||||
.oneshot(empty_post("/api/v1/services/resident-probe/0.1.0/stop"))
|
||||
.await
|
||||
.expect("resident service stop should complete");
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
|
||||
let response = application
|
||||
.clone()
|
||||
.oneshot(empty_post("/api/v1/services/resident-probe/0.1.0/restart"))
|
||||
.await
|
||||
.expect("resident service restart should complete");
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert!(
|
||||
wait_for_resident_count(&application).await > 0,
|
||||
"service restart should create a new timer session"
|
||||
);
|
||||
|
||||
let response = application
|
||||
.clone()
|
||||
.oneshot(empty_post("/api/v1/runtime/restart"))
|
||||
.await
|
||||
.expect("Runtime restart should complete");
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert!(
|
||||
wait_for_resident_count(&application).await > 0,
|
||||
"deployment restore should recreate the timer supervisor"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn drives_tcp_streams_across_service_and_runtime_restarts() {
|
||||
let artifact_dir = TempDir::new().expect("temporary artifact directory");
|
||||
let occupied_listener =
|
||||
StdTcpListener::bind("127.0.0.1:0").expect("occupied test listener should bind");
|
||||
let address = occupied_listener
|
||||
.local_addr()
|
||||
.expect("occupied test listener should have an address");
|
||||
let mut resident_services = BTreeMap::new();
|
||||
resident_services.insert(
|
||||
"resident-probe".to_owned(),
|
||||
ResidentServiceConfig {
|
||||
policy: ResidentPolicy {
|
||||
tcp_listen: NetworkScope::Loopback,
|
||||
..ResidentPolicy::default()
|
||||
},
|
||||
tcp_listeners: vec![ResidentTcpListenerConfig {
|
||||
name: "echo".to_owned(),
|
||||
bind: address,
|
||||
}],
|
||||
..ResidentServiceConfig::default()
|
||||
},
|
||||
);
|
||||
let console = Arc::new(
|
||||
Console::new(ConsoleConfig {
|
||||
artifact_dir: artifact_dir.path().to_path_buf(),
|
||||
wit_registry_dir: artifact_dir.path().join("wit-packages"),
|
||||
database_path: artifact_dir.path().join("console.db"),
|
||||
resident_services,
|
||||
..ConsoleConfig::default()
|
||||
})
|
||||
.await
|
||||
.expect("console should start"),
|
||||
);
|
||||
let application = app(console, Vec::new());
|
||||
let component = fs::read(component_artifact("resident_probe_component.wasm"))
|
||||
.expect("resident probe component should be readable");
|
||||
|
||||
let response = application
|
||||
.clone()
|
||||
.oneshot(package_request_with_world(
|
||||
"resident-probe",
|
||||
"0.1.0",
|
||||
"component:resident-probe/resident-probe-component@0.1.0",
|
||||
&component,
|
||||
))
|
||||
.await
|
||||
.expect("resident package registration should complete");
|
||||
assert_eq!(response.status(), StatusCode::CREATED);
|
||||
|
||||
let response = application
|
||||
.clone()
|
||||
.oneshot(json_request(
|
||||
"/api/v1/deployments/resident-probe/activate",
|
||||
json!({ "revision": "0.1.0" }),
|
||||
))
|
||||
.await
|
||||
.expect("conflicting resident deployment activation should complete");
|
||||
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
|
||||
|
||||
// Driver startup failure must stop the Actor and leave deployment state
|
||||
// untouched. Once the external conflict is gone, the same revision can
|
||||
// start normally without repairing Runtime state manually.
|
||||
drop(occupied_listener);
|
||||
let response = application
|
||||
.clone()
|
||||
.oneshot(json_request(
|
||||
"/api/v1/deployments/resident-probe/activate",
|
||||
json!({ "revision": "0.1.0" }),
|
||||
))
|
||||
.await
|
||||
.expect("resident deployment activation should complete");
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let large_payload = vec![0xA5; 2 * 64 * 1024 + 17];
|
||||
assert_tcp_echo(address, &large_payload).await;
|
||||
|
||||
let response = application
|
||||
.clone()
|
||||
.oneshot(empty_post("/api/v1/services/resident-probe/0.1.0/stop"))
|
||||
.await
|
||||
.expect("resident service stop should complete");
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
|
||||
let response = application
|
||||
.clone()
|
||||
.oneshot(empty_post("/api/v1/services/resident-probe/0.1.0/restart"))
|
||||
.await
|
||||
.expect("resident service restart should complete");
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_tcp_echo(address, b"service restart").await;
|
||||
|
||||
let response = application
|
||||
.clone()
|
||||
.oneshot(empty_post("/api/v1/runtime/restart"))
|
||||
.await
|
||||
.expect("Runtime restart should complete");
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_tcp_echo(address, b"runtime restart").await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reports_exact_component_host_capabilities() {
|
||||
let artifact_dir = TempDir::new().expect("temporary artifact directory");
|
||||
@@ -999,6 +1195,53 @@ async fn invoke_service(application: &Router, id: &str, revision: &str, input: &
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn wait_for_resident_count(application: &Router) -> u64 {
|
||||
for _ in 0..100 {
|
||||
let output = invoke_service(application, "resident-probe", "0.1.0", &[]).await;
|
||||
let count = u64::from_le_bytes(
|
||||
output
|
||||
.try_into()
|
||||
.expect("resident probe response should be a u64"),
|
||||
);
|
||||
if count > 0 {
|
||||
return count;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
0
|
||||
}
|
||||
|
||||
async fn assert_tcp_echo(address: SocketAddr, payload: &[u8]) {
|
||||
let mut stream = connect_tcp(address).await;
|
||||
stream
|
||||
.write_all(payload)
|
||||
.await
|
||||
.expect("TCP request should be written");
|
||||
let mut echoed = vec![0_u8; payload.len()];
|
||||
tokio::time::timeout(Duration::from_secs(2), stream.read_exact(&mut echoed))
|
||||
.await
|
||||
.expect("resident TCP echo should not time out")
|
||||
.expect("resident TCP echo should be readable");
|
||||
assert_eq!(echoed, payload);
|
||||
}
|
||||
|
||||
async fn connect_tcp(address: SocketAddr) -> TcpStream {
|
||||
let mut last_error = None;
|
||||
for _ in 0..100 {
|
||||
match TcpStream::connect(address).await {
|
||||
Ok(stream) => return stream,
|
||||
Err(error) => {
|
||||
last_error = Some(error);
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
panic!(
|
||||
"resident TCP listener {address} did not accept connections: {}",
|
||||
last_error.expect("at least one connection should have been attempted")
|
||||
);
|
||||
}
|
||||
|
||||
fn echo_component() -> PathBuf {
|
||||
component_artifact("echo_component.wasm")
|
||||
}
|
||||
@@ -1010,6 +1253,7 @@ fn component_artifact(name: &str) -> PathBuf {
|
||||
"components/counter/Cargo.toml",
|
||||
"components/clock-probe/Cargo.toml",
|
||||
"components/kv-probe/Cargo.toml",
|
||||
"components/resident-probe/Cargo.toml",
|
||||
] {
|
||||
let status = Command::new("rustup")
|
||||
.args([
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
/node_modules/
|
||||
/dist/
|
||||
/.tanstack/
|
||||
*.tsbuildinfo
|
||||
.env*
|
||||
!.env.example
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"printWidth": 100,
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"semi": true,
|
||||
"singleQuote": false,
|
||||
"trailingComma": "all"
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"plugins": ["typescript"],
|
||||
"categories": {
|
||||
"correctness": "error",
|
||||
"suspicious": "warn"
|
||||
},
|
||||
"rules": {
|
||||
"typescript/no-explicit-any": "error",
|
||||
"no-console": "warn"
|
||||
},
|
||||
"ignorePatterns": ["dist/**", "node_modules/**"]
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
# 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 状态。
|
||||
@@ -0,0 +1,16 @@
|
||||
<!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>
|
||||
+1270
-1144
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<!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>
|
||||
@@ -0,0 +1,5 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 306 B |
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
Before Width: | Height: | Size: 1.0 MiB After Width: | Height: | Size: 1.0 MiB |
@@ -0,0 +1,13 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
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)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,577 @@
|
||||
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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
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");
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
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,4 +1,13 @@
|
||||
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";
|
||||
|
||||
@@ -69,8 +78,12 @@ export type BackendWitPackage = {
|
||||
dependencies: BackendWitDependency[];
|
||||
};
|
||||
|
||||
export type RegisterComponentInput = {
|
||||
file: File;
|
||||
export type BackendSnapshot = {
|
||||
services: BackendService[];
|
||||
deployments: BackendDeployment[];
|
||||
events: BackendEvent[];
|
||||
runtime: BackendRuntime;
|
||||
witPackages: BackendWitPackage[];
|
||||
};
|
||||
|
||||
export type InvokeResult = {
|
||||
@@ -84,8 +97,16 @@ export function getApiBase(): string {
|
||||
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 {
|
||||
const normalized = value.trim().replace(/\/+$/, "");
|
||||
if (!normalized) {
|
||||
window.localStorage.removeItem(API_BASE_STORAGE_KEY);
|
||||
return "";
|
||||
}
|
||||
const parsed = new URL(normalized);
|
||||
if (!["http:", "https:"].includes(parsed.protocol)) {
|
||||
throw new Error("API 地址必须使用 http 或 https");
|
||||
@@ -94,7 +115,7 @@ export function saveApiBase(value: string): string {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export async function fetchSnapshot(apiBase: string) {
|
||||
export async function fetchSnapshot(apiBase: string): Promise<BackendSnapshot> {
|
||||
const [services, deployments, events, runtime, witPackages] = await Promise.all([
|
||||
request<{ services: BackendService[] }>(apiBase, "/api/v1/services"),
|
||||
request<{ deployments: BackendDeployment[] }>(apiBase, "/api/v1/deployments"),
|
||||
@@ -111,30 +132,27 @@ export async function fetchSnapshot(apiBase: string) {
|
||||
};
|
||||
}
|
||||
|
||||
export async function activateDeployment(
|
||||
export function activateDeployment(
|
||||
apiBase: string,
|
||||
service: Pick<BackendService, "id" | "revision">,
|
||||
): Promise<BackendDeployment> {
|
||||
return request(apiBase, `/api/v1/deployments/${encodeURIComponent(service.id)}/activate`, {
|
||||
return request(apiBase, `/api/v1/deployments/${segment(service.id)}/activate`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ revision: service.revision }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function registerComponent(
|
||||
apiBase: string,
|
||||
input: RegisterComponentInput,
|
||||
): Promise<BackendService> {
|
||||
export function registerComponent(apiBase: string, file: File): Promise<BackendService> {
|
||||
const form = new FormData();
|
||||
form.set("package", input.file, input.file.name);
|
||||
form.set("package", file, file.name);
|
||||
return request(apiBase, "/api/v1/services", {
|
||||
method: "POST",
|
||||
body: form,
|
||||
});
|
||||
}
|
||||
|
||||
export async function publishWitPackage(apiBase: string, file: File): Promise<BackendWitPackage> {
|
||||
export function publishWitPackage(apiBase: string, file: File): Promise<BackendWitPackage> {
|
||||
const form = new FormData();
|
||||
form.set("package", file, file.name);
|
||||
return request(apiBase, "/api/v1/wit/packages", {
|
||||
@@ -148,17 +166,17 @@ export function witPackageDownloadUrl(
|
||||
packageMetadata: Pick<BackendWitPackage, "name" | "version">,
|
||||
): string {
|
||||
const [namespace, name] = packageMetadata.name.split(":");
|
||||
return `${apiBase}/api/v1/wit/packages/${encodeURIComponent(namespace)}/${encodeURIComponent(name)}/${encodeURIComponent(packageMetadata.version)}/content`;
|
||||
return `${apiBase}/api/v1/wit/packages/${segment(namespace)}/${segment(name)}/${segment(packageMetadata.version)}/content`;
|
||||
}
|
||||
|
||||
export async function changeServiceState(
|
||||
export function changeServiceState(
|
||||
apiBase: string,
|
||||
service: Pick<BackendService, "id" | "revision">,
|
||||
action: "start" | "stop" | "restart",
|
||||
): Promise<BackendService> {
|
||||
return request(
|
||||
apiBase,
|
||||
`/api/v1/services/${encodeURIComponent(service.id)}/${encodeURIComponent(service.revision)}/${action}`,
|
||||
`/api/v1/services/${segment(service.id)}/${segment(service.revision)}/${action}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
@@ -167,13 +185,11 @@ export async function changeServiceState(
|
||||
);
|
||||
}
|
||||
|
||||
export async function changeRuntimeState(
|
||||
export function changeRuntimeState(
|
||||
apiBase: string,
|
||||
action: "start" | "stop" | "restart",
|
||||
): Promise<BackendRuntime> {
|
||||
return request(apiBase, `/api/v1/runtime/${action}`, {
|
||||
method: "POST",
|
||||
});
|
||||
return request(apiBase, `/api/v1/runtime/${action}`, { method: "POST" });
|
||||
}
|
||||
|
||||
export async function invokeComponent(
|
||||
@@ -185,15 +201,11 @@ export async function invokeComponent(
|
||||
output_base64: string;
|
||||
output_bytes: number;
|
||||
latency_ms: number;
|
||||
}>(
|
||||
apiBase,
|
||||
`/api/v1/services/${encodeURIComponent(service.id)}/${encodeURIComponent(service.revision)}/invoke`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ input_base64: bytesToBase64(input) }),
|
||||
},
|
||||
);
|
||||
}>(apiBase, `/api/v1/services/${segment(service.id)}/${segment(service.revision)}/invoke`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ input_base64: bytesToBase64(input) }),
|
||||
});
|
||||
return {
|
||||
output: base64ToBytes(response.output_base64),
|
||||
outputBytes: response.output_bytes,
|
||||
@@ -212,7 +224,7 @@ async function request<T>(apiBase: string, path: string, init?: RequestInit): Pr
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
throw new Error(`无法连接 Wasmeld:${apiBase}`);
|
||||
throw new Error(`无法连接 Wasmeld:${displayApiBase(apiBase)}`);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -224,6 +236,10 @@ async function request<T>(apiBase: string, path: string, init?: RequestInit): Pr
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
function segment(value: string): string {
|
||||
return encodeURIComponent(value);
|
||||
}
|
||||
|
||||
function bytesToBase64(bytes: Uint8Array): string {
|
||||
let binary = "";
|
||||
for (const byte of bytes) binary += String.fromCharCode(byte);
|
||||
@@ -0,0 +1,16 @@
|
||||
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,15 +1,14 @@
|
||||
import { FileCode2, History, LayoutDashboard, Package, Server, Settings } from "lucide-react";
|
||||
import { BackendCapability, BackendEvent, BackendService } from "./api";
|
||||
import type { BackendEvent, BackendService } from "./api";
|
||||
|
||||
export type View = "overview" | "services" | "instances" | "wit-packages" | "activity" | "settings";
|
||||
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;
|
||||
description: string;
|
||||
artifact: string;
|
||||
world: string;
|
||||
status: ServiceStatus;
|
||||
updatedAt: string;
|
||||
memoryMb: number;
|
||||
@@ -17,7 +16,7 @@ export type Service = {
|
||||
deadlineMs: number;
|
||||
mailbox: number;
|
||||
maxInputKb: number;
|
||||
capabilities: BackendCapability[];
|
||||
capabilities: BackendService["capabilities"];
|
||||
calls: number;
|
||||
errors: number;
|
||||
latencyMs: number | null;
|
||||
@@ -32,28 +31,19 @@ export type RuntimeEvent = {
|
||||
tone: EventTone;
|
||||
};
|
||||
|
||||
export type ConnectionState = "connecting" | "online" | "offline";
|
||||
|
||||
export const NAV_ITEMS: Array<{
|
||||
id: View;
|
||||
label: string;
|
||||
icon: typeof LayoutDashboard;
|
||||
}> = [
|
||||
{ id: "overview", label: "运行概览", icon: LayoutDashboard },
|
||||
{ id: "services", label: "服务版本", icon: Package },
|
||||
{ id: "instances", label: "运行实例", icon: Server },
|
||||
{ id: "wit-packages", label: "WIT 包", icon: FileCode2 },
|
||||
{ id: "activity", label: "调用记录", icon: History },
|
||||
{ id: "settings", label: "运行设置", icon: Settings },
|
||||
];
|
||||
|
||||
export const STATUS_META: Record<ServiceStatus, { label: string; className: string }> = {
|
||||
running: { label: "运行中", className: "status-running" },
|
||||
stopped: { label: "已停止", className: "status-stopped" },
|
||||
faulted: { label: "故障", className: "status-faulted" },
|
||||
export type FormattedInvocationOutput = {
|
||||
text: string;
|
||||
format: "UTF-8" | "HEX" | "U64 LE";
|
||||
automatic: boolean;
|
||||
};
|
||||
|
||||
export function serviceKey(service: Pick<Service, "id" | "revision">) {
|
||||
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}`;
|
||||
}
|
||||
|
||||
@@ -61,12 +51,10 @@ export function toService(service: BackendService, activeRevision?: string): Ser
|
||||
return {
|
||||
id: service.id,
|
||||
revision: service.revision,
|
||||
description: "Wasm Component",
|
||||
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,
|
||||
}),
|
||||
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,
|
||||
@@ -94,22 +82,20 @@ export function toEvent(event: BackendEvent): RuntimeEvent {
|
||||
};
|
||||
return {
|
||||
id: event.id,
|
||||
time: new Date(event.timestamp_ms).toLocaleTimeString("zh-CN", {
|
||||
hour12: false,
|
||||
}),
|
||||
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) {
|
||||
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) {
|
||||
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);
|
||||
@@ -117,15 +103,7 @@ export function formatDuration(milliseconds: number) {
|
||||
return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m ${seconds}s`;
|
||||
}
|
||||
|
||||
export function formatApiHost(value: string) {
|
||||
try {
|
||||
return new URL(value).host;
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseInvocationInput(value: string, format: "utf8" | "hex") {
|
||||
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)) {
|
||||
@@ -134,7 +112,7 @@ export function parseInvocationInput(value: string, format: "utf8" | "hex") {
|
||||
return Uint8Array.from(compact.match(/.{2}/g)?.map((byte) => Number.parseInt(byte, 16)) ?? []);
|
||||
}
|
||||
|
||||
export function invocationInputSize(value: string, format: "utf8" | "hex") {
|
||||
export function invocationInputSize(value: string, format: "utf8" | "hex"): number {
|
||||
try {
|
||||
return parseInvocationInput(value, format).byteLength;
|
||||
} catch {
|
||||
@@ -142,39 +120,11 @@ export function invocationInputSize(value: string, format: "utf8" | "hex") {
|
||||
}
|
||||
}
|
||||
|
||||
export type FormattedInvocationOutput = {
|
||||
text: string;
|
||||
format: "UTF-8" | "HEX" | "U64 LE";
|
||||
automatic: boolean;
|
||||
};
|
||||
|
||||
export function formatHex(bytes: Uint8Array) {
|
||||
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(" ");
|
||||
}
|
||||
|
||||
export function decodeReadableUtf8(bytes: Uint8Array) {
|
||||
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;
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -182,27 +132,33 @@ export function formatInvocationOutput(
|
||||
.toString(),
|
||||
format: "U64 LE",
|
||||
automatic: false,
|
||||
} satisfies FormattedInvocationOutput;
|
||||
};
|
||||
}
|
||||
if (format === "hex") {
|
||||
return {
|
||||
text: formatHex(output) || "(empty)",
|
||||
format: "HEX",
|
||||
automatic: false,
|
||||
} satisfies FormattedInvocationOutput;
|
||||
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,
|
||||
} satisfies FormattedInvocationOutput;
|
||||
return { text: text || "(empty)", format: "UTF-8", automatic: false };
|
||||
}
|
||||
return {
|
||||
text: formatHex(output),
|
||||
format: "HEX",
|
||||
automatic: true,
|
||||
} satisfies FormattedInvocationOutput;
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
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) ?? [];
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
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);
|
||||
@@ -0,0 +1,267 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
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)!;
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
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;
|
||||
};
|
||||
@@ -0,0 +1,114 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
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";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { View } from "../pages/registry";
|
||||
import {
|
||||
SDK_CHANNEL,
|
||||
SDK_VERSION,
|
||||
type HostMessage,
|
||||
type HostSharedState,
|
||||
type PageLifecyclePhase,
|
||||
} from "./protocol";
|
||||
|
||||
export function sendState(target: Window, state: HostSharedState): void {
|
||||
send(target, {
|
||||
channel: SDK_CHANNEL,
|
||||
version: SDK_VERSION,
|
||||
source: "host",
|
||||
type: "state",
|
||||
state,
|
||||
});
|
||||
}
|
||||
|
||||
export function sendLifecycle(target: Window, phase: PageLifecyclePhase): void {
|
||||
send(target, {
|
||||
channel: SDK_CHANNEL,
|
||||
version: SDK_VERSION,
|
||||
source: "host",
|
||||
type: "lifecycle",
|
||||
phase,
|
||||
});
|
||||
}
|
||||
|
||||
export function pageUrl(view: View, instance: number): string {
|
||||
const url = new URL("/page.html", window.location.origin);
|
||||
url.searchParams.set("view", view);
|
||||
url.searchParams.set("instance", instance.toString());
|
||||
return `${url.pathname}${url.search}`;
|
||||
}
|
||||
|
||||
function send(target: Window, message: HostMessage): void {
|
||||
target.postMessage(message, window.location.origin);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
export type PageLifecycleEventType = "show" | "hide" | "unload";
|
||||
export type PageLifecycleState = "initializing" | "visible" | "hidden" | "unloaded";
|
||||
export type PageLifecycleSource = "host" | "document";
|
||||
|
||||
export type PageLifecycleEvent = {
|
||||
type: PageLifecycleEventType;
|
||||
previous: PageLifecycleState;
|
||||
current: PageLifecycleState;
|
||||
/**
|
||||
* `host` means the event came from iframe navigation policy. `document`
|
||||
* means the browser is discarding the document and acts as an unload
|
||||
* fallback when a Host `dispose` message cannot be delivered first.
|
||||
*/
|
||||
source: PageLifecycleSource;
|
||||
};
|
||||
|
||||
export type PageLifecycleListener = (event: PageLifecycleEvent) => void;
|
||||
|
||||
/**
|
||||
* Framework-independent lifecycle exposed to every Page.
|
||||
*
|
||||
* Event subscriptions are best for imperative work at a transition boundary:
|
||||
* refresh data on show, pause a stream on hide, and close external resources
|
||||
* on unload. Each method returns an idempotent unsubscribe function that a UI
|
||||
* framework should call during component cleanup.
|
||||
*/
|
||||
export type PageLifecycle = {
|
||||
state: () => PageLifecycleState;
|
||||
/** Subscribes only to future transitions of the selected type. */
|
||||
on: (type: PageLifecycleEventType, listener: PageLifecycleListener) => () => void;
|
||||
/**
|
||||
* Subscribes to show and immediately replays it when already visible.
|
||||
*
|
||||
* Replay prevents a lazy-loaded Page from missing the initial Host event.
|
||||
*/
|
||||
onShow: (listener: PageLifecycleListener) => () => void;
|
||||
/** Subscribes to hide and immediately replays it when already hidden. */
|
||||
onHide: (listener: PageLifecycleListener) => () => void;
|
||||
onUnload: (listener: PageLifecycleListener) => () => void;
|
||||
};
|
||||
|
||||
export type PageLifecycleDispatcher = {
|
||||
lifecycle: PageLifecycle;
|
||||
/**
|
||||
* Applies one lifecycle transition. Repeated and post-unload transitions are
|
||||
* ignored, so Host `dispose` followed by browser `pagehide` emits one unload.
|
||||
*/
|
||||
dispatch: (
|
||||
type: PageLifecycleEventType,
|
||||
source: PageLifecycleSource,
|
||||
) => PageLifecycleEvent | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates the lifecycle state machine for one iframe document.
|
||||
*
|
||||
* This module deliberately has no Solid or DOM dependency. A React, Vue or
|
||||
* other Page adapter can expose the same object without changing SDK behavior.
|
||||
*/
|
||||
export function createPageLifecycleDispatcher(): PageLifecycleDispatcher {
|
||||
let state: PageLifecycleState = "initializing";
|
||||
let lastEvent: PageLifecycleEvent | null = null;
|
||||
const listeners: Record<PageLifecycleEventType, Set<PageLifecycleListener>> = {
|
||||
show: new Set(),
|
||||
hide: new Set(),
|
||||
unload: new Set(),
|
||||
};
|
||||
|
||||
function on(type: PageLifecycleEventType, listener: PageLifecycleListener): () => void {
|
||||
if (state === "unloaded") return () => {};
|
||||
listeners[type].add(listener);
|
||||
return () => listeners[type].delete(listener);
|
||||
}
|
||||
|
||||
function onCurrent(type: "show" | "hide", listener: PageLifecycleListener): () => void {
|
||||
const unsubscribe = on(type, listener);
|
||||
if (lastEvent?.type === type) listener(lastEvent);
|
||||
return unsubscribe;
|
||||
}
|
||||
|
||||
const lifecycle: PageLifecycle = {
|
||||
state: () => state,
|
||||
on,
|
||||
onShow: (listener) => onCurrent("show", listener),
|
||||
onHide: (listener) => onCurrent("hide", listener),
|
||||
onUnload: (listener) => on("unload", listener),
|
||||
};
|
||||
|
||||
function dispatch(
|
||||
type: PageLifecycleEventType,
|
||||
source: PageLifecycleSource,
|
||||
): PageLifecycleEvent | null {
|
||||
if (state === "unloaded") return null;
|
||||
const current = stateFor(type);
|
||||
if (current === state) return null;
|
||||
|
||||
const event: PageLifecycleEvent = {
|
||||
type,
|
||||
previous: state,
|
||||
current,
|
||||
source,
|
||||
};
|
||||
state = current;
|
||||
lastEvent = event;
|
||||
for (const listener of [...listeners[type]]) listener(event);
|
||||
if (current === "unloaded") {
|
||||
for (const subscriptions of Object.values(listeners)) subscriptions.clear();
|
||||
}
|
||||
return event;
|
||||
}
|
||||
|
||||
return { lifecycle, dispatch };
|
||||
}
|
||||
|
||||
function stateFor(type: PageLifecycleEventType): PageLifecycleState {
|
||||
switch (type) {
|
||||
case "show":
|
||||
return "visible";
|
||||
case "hide":
|
||||
return "hidden";
|
||||
case "unload":
|
||||
return "unloaded";
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user