Compare commits
3 Commits
main
..
612ab9a16d
| Author | SHA1 | Date | |
|---|---|---|---|
| 612ab9a16d | |||
| 8c2dce66b3 | |||
| 90742837fc |
@@ -1,26 +0,0 @@
|
|||||||
{
|
|
||||||
"lsp": {
|
|
||||||
"tailwindcss-language-server": {
|
|
||||||
"settings": {
|
|
||||||
"classFunctions": [
|
|
||||||
"cva",
|
|
||||||
"cx",
|
|
||||||
"cn"
|
|
||||||
],
|
|
||||||
"experimental": {
|
|
||||||
"classRegex": [
|
|
||||||
"[cls|className]\\s\\:\\=\\s\"([^\"]*)"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"languages": {
|
|
||||||
"CSS": {
|
|
||||||
"language_servers": [
|
|
||||||
"tailwindcss-intellisense-css",
|
|
||||||
"!vscode-css-language-server"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+1
-1
@@ -33,7 +33,7 @@ sha2 = "0.10.9"
|
|||||||
thiserror = "2.0.17"
|
thiserror = "2.0.17"
|
||||||
toasty = "=0.9.0"
|
toasty = "=0.9.0"
|
||||||
toasty-driver-turso = "=0.9.0"
|
toasty-driver-turso = "=0.9.0"
|
||||||
tokio = { version = "1.53.1", features = ["io-util", "macros", "net", "rt-multi-thread", "signal", "sync", "time"] }
|
tokio = { version = "1.53.1", features = ["macros", "net", "rt-multi-thread", "signal", "sync"] }
|
||||||
toml = "0.9.8"
|
toml = "0.9.8"
|
||||||
tower-http = { version = "0.7.0", features = ["cors", "trace"] }
|
tower-http = { version = "0.7.0", features = ["cors", "trace"] }
|
||||||
tracing = "0.1.44"
|
tracing = "0.1.44"
|
||||||
|
|||||||
@@ -29,12 +29,6 @@ Host driver <- validated operation <- raw effect <-
|
|||||||
线程外;协议级驱动以及文件监听、串口、系统信号等其它来源通过独立版本的 WIT 能力演进,
|
线程外;协议级驱动以及文件监听、串口、系统信号等其它来源通过独立版本的 WIT 能力演进,
|
||||||
不需要扩大基础 service world。
|
不需要扩大基础 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
|
```text
|
||||||
|
|||||||
@@ -70,12 +70,9 @@ impl ResidentGuest for ResidentProbe {
|
|||||||
command: source.kind,
|
command: source.kind,
|
||||||
payload: source.payload,
|
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::StreamOpened(_)
|
||||||
| Event::StreamWritable(_)
|
| Event::StreamWritable(_)
|
||||||
|
| Event::StreamHalfClosed(_)
|
||||||
| Event::StreamClosed(_)
|
| Event::StreamClosed(_)
|
||||||
| Event::Shutdown => Vec::new(),
|
| Event::Shutdown => Vec::new(),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -30,71 +30,6 @@ Host KV 使用 `(service_id, key)` 复合主键存储在该数据库中:同一
|
|||||||
编译后的本地机器码默认缓存在 `var/wasmeld/component-cache`,相同 Component 在
|
编译后的本地机器码默认缓存在 `var/wasmeld/component-cache`,相同 Component 在
|
||||||
Console 或 Runtime 重启后可由 Wasmtime 直接复用;缓存不包含 Actor 内存。
|
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
|
`wasmeld-console` 需要 Rust 1.95 或更高版本;Wasm 组件继续使用项目约定的 Rust 1.90
|
||||||
|
|||||||
@@ -585,8 +585,7 @@ impl IntoResponse for GatewayError {
|
|||||||
| ConsoleError::InvalidDatabaseData(_)
|
| ConsoleError::InvalidDatabaseData(_)
|
||||||
| ConsoleError::Runtime(_)
|
| ConsoleError::Runtime(_)
|
||||||
| ConsoleError::Join(_)
|
| ConsoleError::Join(_)
|
||||||
| ConsoleError::LockPoisoned
|
| ConsoleError::LockPoisoned => (
|
||||||
| ConsoleError::ResidentSupervisor(_) => (
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
"internal_error",
|
"internal_error",
|
||||||
"internal gateway error".to_owned(),
|
"internal gateway error".to_owned(),
|
||||||
@@ -681,10 +680,7 @@ impl IntoResponse for ApiError {
|
|||||||
| ConsoleError::WitRegistry(WitRegistryError::Storage { .. })
|
| ConsoleError::WitRegistry(WitRegistryError::Storage { .. })
|
||||||
| ConsoleError::WitRegistry(WitRegistryError::LockPoisoned)
|
| ConsoleError::WitRegistry(WitRegistryError::LockPoisoned)
|
||||||
| ConsoleError::Join(_)
|
| ConsoleError::Join(_)
|
||||||
| ConsoleError::LockPoisoned
|
| ConsoleError::LockPoisoned => (StatusCode::INTERNAL_SERVER_ERROR, "internal_error"),
|
||||||
| ConsoleError::ResidentSupervisor(_) => {
|
|
||||||
(StatusCode::INTERNAL_SERVER_ERROR, "internal_error")
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
(
|
(
|
||||||
|
|||||||
@@ -36,12 +36,11 @@ mod api;
|
|||||||
mod invocation_persistence;
|
mod invocation_persistence;
|
||||||
mod kv_backend;
|
mod kv_backend;
|
||||||
mod persistence;
|
mod persistence;
|
||||||
mod resident_supervisor;
|
|
||||||
mod web;
|
mod web;
|
||||||
mod wit_registry;
|
mod wit_registry;
|
||||||
|
|
||||||
use std::{
|
use std::{
|
||||||
collections::{BTreeMap, HashMap, VecDeque},
|
collections::{BTreeMap, VecDeque},
|
||||||
fs, io,
|
fs, io,
|
||||||
path::{Path, PathBuf},
|
path::{Path, PathBuf},
|
||||||
sync::{Arc, Mutex, MutexGuard, RwLock, RwLockReadGuard},
|
sync::{Arc, Mutex, MutexGuard, RwLock, RwLockReadGuard},
|
||||||
@@ -55,19 +54,14 @@ use wasmeld_package::{
|
|||||||
ComponentPackage, PackageError, read_package, wit_package::WitPackageMetadata,
|
ComponentPackage, PackageError, read_package, wit_package::WitPackageMetadata,
|
||||||
};
|
};
|
||||||
use wasmeld_runtime::{
|
use wasmeld_runtime::{
|
||||||
ActorHandle, CapabilityDescriptor, ComponentExecution, ResourceLimits, Runtime, RuntimeConfig,
|
CapabilityDescriptor, ResourceLimits, Runtime, RuntimeConfig, RuntimeError, ServiceKey,
|
||||||
RuntimeError, ServiceKey, ServiceManifest,
|
ServiceManifest,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub use api::{app, gateway_app};
|
pub use api::{app, gateway_app};
|
||||||
use invocation_persistence::InvocationPersistence;
|
use invocation_persistence::InvocationPersistence;
|
||||||
use kv_backend::ToastyKvBackend;
|
use kv_backend::ToastyKvBackend;
|
||||||
use persistence::{InvocationUpdate, Persistence, StoredDeployment, StoredEvent, StoredService};
|
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;
|
use wit_registry::WitRegistry;
|
||||||
pub use wit_registry::WitRegistryError;
|
pub use wit_registry::WitRegistryError;
|
||||||
|
|
||||||
@@ -93,12 +87,6 @@ pub struct ConsoleConfig {
|
|||||||
pub registration_limits: ResourceLimits,
|
pub registration_limits: ResourceLimits,
|
||||||
/// Wasmtime Engine and epoch settings.
|
/// Wasmtime Engine and epoch settings.
|
||||||
pub runtime: RuntimeConfig,
|
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 {
|
impl Default for ConsoleConfig {
|
||||||
@@ -111,7 +99,6 @@ impl Default for ConsoleConfig {
|
|||||||
max_wit_package_bytes: DEFAULT_MAX_WIT_PACKAGE_BYTES,
|
max_wit_package_bytes: DEFAULT_MAX_WIT_PACKAGE_BYTES,
|
||||||
registration_limits: ResourceLimits::default(),
|
registration_limits: ResourceLimits::default(),
|
||||||
runtime: RuntimeConfig::default(),
|
runtime: RuntimeConfig::default(),
|
||||||
resident_services: BTreeMap::new(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -128,9 +115,6 @@ pub struct Console {
|
|||||||
// to avoid deadlocks between HTTP control operations.
|
// to avoid deadlocks between HTTP control operations.
|
||||||
runtime: RwLock<Option<Runtime>>,
|
runtime: RwLock<Option<Runtime>>,
|
||||||
runtime_config: RuntimeConfig,
|
runtime_config: RuntimeConfig,
|
||||||
async_runtime: tokio::runtime::Handle,
|
|
||||||
resident_services: BTreeMap<String, ResidentServiceConfig>,
|
|
||||||
resident_supervisors: Mutex<HashMap<ServiceKey, ResidentSupervisor>>,
|
|
||||||
artifact_dir: PathBuf,
|
artifact_dir: PathBuf,
|
||||||
wit_registry: WitRegistry,
|
wit_registry: WitRegistry,
|
||||||
max_artifact_bytes: usize,
|
max_artifact_bytes: usize,
|
||||||
@@ -502,10 +486,6 @@ pub enum ConsoleError {
|
|||||||
/// An internal lifecycle, state, or Runtime lock was poisoned.
|
/// An internal lifecycle, state, or Runtime lock was poisoned.
|
||||||
#[error("console state lock was poisoned")]
|
#[error("console state lock was poisoned")]
|
||||||
LockPoisoned,
|
LockPoisoned,
|
||||||
|
|
||||||
/// A resident Host driver could not start, dispatch, or drain.
|
|
||||||
#[error("resident supervisor failed: {0}")]
|
|
||||||
ResidentSupervisor(String),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Console {
|
impl Console {
|
||||||
@@ -515,12 +495,6 @@ impl Console {
|
|||||||
/// configuration. Their previous Store and linear memory are not restored.
|
/// configuration. Their previous Store and linear memory are not restored.
|
||||||
pub async fn new(config: ConsoleConfig) -> Result<Self, ConsoleError> {
|
pub async fn new(config: ConsoleConfig) -> Result<Self, ConsoleError> {
|
||||||
config.registration_limits.validate()?;
|
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 {
|
fs::create_dir_all(&config.artifact_dir).map_err(|source| ConsoleError::Storage {
|
||||||
path: config.artifact_dir.clone(),
|
path: config.artifact_dir.clone(),
|
||||||
source,
|
source,
|
||||||
@@ -573,9 +547,6 @@ impl Console {
|
|||||||
let console = Self {
|
let console = Self {
|
||||||
runtime: RwLock::new(Some(runtime)),
|
runtime: RwLock::new(Some(runtime)),
|
||||||
runtime_config,
|
runtime_config,
|
||||||
async_runtime: tokio::runtime::Handle::current(),
|
|
||||||
resident_services: config.resident_services,
|
|
||||||
resident_supervisors: Mutex::new(HashMap::new()),
|
|
||||||
artifact_dir,
|
artifact_dir,
|
||||||
wit_registry,
|
wit_registry,
|
||||||
max_artifact_bytes: config.max_artifact_bytes,
|
max_artifact_bytes: config.max_artifact_bytes,
|
||||||
@@ -700,16 +671,6 @@ impl Console {
|
|||||||
Ok(started) => {
|
Ok(started) => {
|
||||||
*runtime = Some(started);
|
*runtime = Some(started);
|
||||||
drop(runtime);
|
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.mark_runtime_started()?;
|
||||||
self.push_event(
|
self.push_event(
|
||||||
EventKind::Started,
|
EventKind::Started,
|
||||||
@@ -743,7 +704,6 @@ impl Console {
|
|||||||
drop(runtime);
|
drop(runtime);
|
||||||
return self.runtime_view();
|
return self.runtime_view();
|
||||||
};
|
};
|
||||||
self.shutdown_all_resident_supervisors();
|
|
||||||
let stopped = running.stop_all();
|
let stopped = running.stop_all();
|
||||||
drop(runtime);
|
drop(runtime);
|
||||||
self.mark_runtime_stopped()?;
|
self.mark_runtime_stopped()?;
|
||||||
@@ -778,9 +738,9 @@ impl Console {
|
|||||||
.write()
|
.write()
|
||||||
.map_err(|_| ConsoleError::LockPoisoned)?;
|
.map_err(|_| ConsoleError::LockPoisoned)?;
|
||||||
|
|
||||||
if let Some(running) = runtime.take() {
|
if let Some(running) = runtime.take()
|
||||||
self.shutdown_all_resident_supervisors();
|
&& let Err(error) = running.stop_all()
|
||||||
if let Err(error) = running.stop_all() {
|
{
|
||||||
drop(runtime);
|
drop(runtime);
|
||||||
self.mark_runtime_stopped()?;
|
self.mark_runtime_stopped()?;
|
||||||
self.push_event(
|
self.push_event(
|
||||||
@@ -790,22 +750,11 @@ impl Console {
|
|||||||
)?;
|
)?;
|
||||||
return Err(error.into());
|
return Err(error.into());
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
match self.build_runtime() {
|
match self.build_runtime() {
|
||||||
Ok(started) => {
|
Ok(started) => {
|
||||||
*runtime = Some(started);
|
*runtime = Some(started);
|
||||||
drop(runtime);
|
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.mark_runtime_started()?;
|
||||||
self.push_event(
|
self.push_event(
|
||||||
EventKind::Started,
|
EventKind::Started,
|
||||||
@@ -834,120 +783,25 @@ impl Console {
|
|||||||
.values()
|
.values()
|
||||||
.map(|service| service.manifest.clone())
|
.map(|service| service.manifest.clone())
|
||||||
.collect::<Vec<_>>();
|
.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);
|
drop(state);
|
||||||
|
|
||||||
let runtime = Runtime::new(self.runtime_config.clone())?;
|
let runtime = Runtime::new(self.runtime_config.clone())?;
|
||||||
for manifest in manifests {
|
for manifest in manifests {
|
||||||
runtime.register_from_file(manifest)?;
|
runtime.register_from_file(manifest)?;
|
||||||
}
|
}
|
||||||
|
for key in deployments {
|
||||||
|
runtime.start(&key, Vec::new())?;
|
||||||
|
}
|
||||||
Ok(runtime)
|
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> {
|
fn mark_runtime_started(&self) -> Result<(), ConsoleError> {
|
||||||
let mut state = self.state()?;
|
let mut state = self.state()?;
|
||||||
let deployed = state
|
let deployed = state
|
||||||
@@ -1077,7 +931,6 @@ impl Console {
|
|||||||
|
|
||||||
let runtime = self.runtime()?;
|
let runtime = self.runtime()?;
|
||||||
let runtime = runtime.as_ref().ok_or(ConsoleError::RuntimeNotRunning)?;
|
let runtime = runtime.as_ref().ok_or(ConsoleError::RuntimeNotRunning)?;
|
||||||
self.shutdown_resident_supervisor(key);
|
|
||||||
runtime.unregister(key)?;
|
runtime.unregister(key)?;
|
||||||
self.state()?.services.remove(key);
|
self.state()?.services.remove(key);
|
||||||
|
|
||||||
@@ -1103,7 +956,7 @@ impl Console {
|
|||||||
let runtime = self.runtime()?;
|
let runtime = self.runtime()?;
|
||||||
let runtime = runtime.as_ref().ok_or(ConsoleError::RuntimeNotRunning)?;
|
let runtime = runtime.as_ref().ok_or(ConsoleError::RuntimeNotRunning)?;
|
||||||
self.ensure_managed(key)?;
|
self.ensure_managed(key)?;
|
||||||
self.start_actor(runtime, key, init_config)?;
|
runtime.start(key, init_config)?;
|
||||||
let view = self.update_status(key, ServiceStatus::Running)?;
|
let view = self.update_status(key, ServiceStatus::Running)?;
|
||||||
self.push_event(EventKind::Started, Some(key), "actor started".to_owned())?;
|
self.push_event(EventKind::Started, Some(key), "actor started".to_owned())?;
|
||||||
Ok(view)
|
Ok(view)
|
||||||
@@ -1113,7 +966,7 @@ impl Console {
|
|||||||
let runtime = self.runtime()?;
|
let runtime = self.runtime()?;
|
||||||
let runtime = runtime.as_ref().ok_or(ConsoleError::RuntimeNotRunning)?;
|
let runtime = runtime.as_ref().ok_or(ConsoleError::RuntimeNotRunning)?;
|
||||||
self.ensure_managed(key)?;
|
self.ensure_managed(key)?;
|
||||||
self.stop_actor(runtime, key)?;
|
runtime.stop(key)?;
|
||||||
let view = self.update_status(key, ServiceStatus::Stopped)?;
|
let view = self.update_status(key, ServiceStatus::Stopped)?;
|
||||||
self.push_event(EventKind::Stopped, Some(key), "actor stopped".to_owned())?;
|
self.push_event(EventKind::Stopped, Some(key), "actor stopped".to_owned())?;
|
||||||
Ok(view)
|
Ok(view)
|
||||||
@@ -1123,11 +976,11 @@ impl Console {
|
|||||||
let runtime = self.runtime()?;
|
let runtime = self.runtime()?;
|
||||||
let runtime = runtime.as_ref().ok_or(ConsoleError::RuntimeNotRunning)?;
|
let runtime = runtime.as_ref().ok_or(ConsoleError::RuntimeNotRunning)?;
|
||||||
self.ensure_managed(key)?;
|
self.ensure_managed(key)?;
|
||||||
match self.stop_actor(runtime, key) {
|
match runtime.stop(key) {
|
||||||
Ok(()) | Err(RuntimeError::ActorUnavailable(_)) => {}
|
Ok(()) | Err(RuntimeError::ActorUnavailable(_)) => {}
|
||||||
Err(error) => return Err(error.into()),
|
Err(error) => return Err(error.into()),
|
||||||
}
|
}
|
||||||
self.start_actor(runtime, key, init_config)?;
|
runtime.start(key, init_config)?;
|
||||||
let view = self.update_status(key, ServiceStatus::Running)?;
|
let view = self.update_status(key, ServiceStatus::Running)?;
|
||||||
self.push_event(EventKind::Started, Some(key), "actor restarted".to_owned())?;
|
self.push_event(EventKind::Started, Some(key), "actor restarted".to_owned())?;
|
||||||
Ok(view)
|
Ok(view)
|
||||||
@@ -1153,7 +1006,7 @@ impl Console {
|
|||||||
// Starting is idempotent. The old revision stays resident so calls
|
// Starting is idempotent. The old revision stays resident so calls
|
||||||
// that resolved immediately before this switch can finish safely and
|
// that resolved immediately before this switch can finish safely and
|
||||||
// rollback does not require recompilation.
|
// rollback does not require recompilation.
|
||||||
self.start_actor(runtime, &key, init_config)?;
|
runtime.start(&key, init_config)?;
|
||||||
|
|
||||||
let updated_at_ms = unix_time_ms();
|
let updated_at_ms = unix_time_ms();
|
||||||
let view = {
|
let view = {
|
||||||
@@ -1368,7 +1221,7 @@ impl Console {
|
|||||||
let runtime = self.runtime()?;
|
let runtime = self.runtime()?;
|
||||||
let runtime = runtime.as_ref().ok_or(ConsoleError::RuntimeNotRunning)?;
|
let runtime = runtime.as_ref().ok_or(ConsoleError::RuntimeNotRunning)?;
|
||||||
for key in &deployments {
|
for key in &deployments {
|
||||||
self.start_actor(runtime, key, Vec::new())?;
|
runtime.start(key, Vec::new())?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1509,22 +1362,6 @@ 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(
|
fn runtime_capabilities(
|
||||||
runtime: &Runtime,
|
runtime: &Runtime,
|
||||||
key: &ServiceKey,
|
key: &ServiceKey,
|
||||||
|
|||||||
@@ -1,877 +0,0 @@
|
|||||||
//! 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,402 +0,0 @@
|
|||||||
//! 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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,12 +1,9 @@
|
|||||||
use std::{
|
use std::{
|
||||||
collections::BTreeMap,
|
|
||||||
fs,
|
fs,
|
||||||
io::Cursor,
|
io::Cursor,
|
||||||
net::{SocketAddr, TcpListener as StdTcpListener},
|
|
||||||
path::{Path, PathBuf},
|
path::{Path, PathBuf},
|
||||||
process::Command,
|
process::Command,
|
||||||
sync::{Arc, OnceLock},
|
sync::{Arc, OnceLock},
|
||||||
time::Duration,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
@@ -17,15 +14,8 @@ use axum::{
|
|||||||
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
|
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
use tokio::{
|
|
||||||
io::{AsyncReadExt, AsyncWriteExt},
|
|
||||||
net::TcpStream,
|
|
||||||
};
|
|
||||||
use tower::ServiceExt;
|
use tower::ServiceExt;
|
||||||
use wasmeld_console::{
|
use wasmeld_console::{Console, ConsoleConfig, app, gateway_app};
|
||||||
Console, ConsoleConfig, NetworkScope, ResidentPolicy, ResidentServiceConfig,
|
|
||||||
ResidentTcpListenerConfig, ResidentTimerConfig, app, gateway_app,
|
|
||||||
};
|
|
||||||
use wasmeld_package::{
|
use wasmeld_package::{
|
||||||
module::{ModuleLock, sync_dependencies},
|
module::{ModuleLock, sync_dependencies},
|
||||||
wit_package::build_wit_package,
|
wit_package::build_wit_package,
|
||||||
@@ -100,192 +90,6 @@ async fn manages_a_resident_component_over_http() {
|
|||||||
assert!(events["events"].as_array().unwrap().len() >= 5);
|
assert!(events["events"].as_array().unwrap().len() >= 5);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn 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]
|
#[tokio::test]
|
||||||
async fn reports_exact_component_host_capabilities() {
|
async fn reports_exact_component_host_capabilities() {
|
||||||
let artifact_dir = TempDir::new().expect("temporary artifact directory");
|
let artifact_dir = TempDir::new().expect("temporary artifact directory");
|
||||||
@@ -1195,53 +999,6 @@ async fn invoke_service(application: &Router, id: &str, revision: &str, input: &
|
|||||||
.unwrap()
|
.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 {
|
fn echo_component() -> PathBuf {
|
||||||
component_artifact("echo_component.wasm")
|
component_artifact("echo_component.wasm")
|
||||||
}
|
}
|
||||||
@@ -1253,7 +1010,6 @@ fn component_artifact(name: &str) -> PathBuf {
|
|||||||
"components/counter/Cargo.toml",
|
"components/counter/Cargo.toml",
|
||||||
"components/clock-probe/Cargo.toml",
|
"components/clock-probe/Cargo.toml",
|
||||||
"components/kv-probe/Cargo.toml",
|
"components/kv-probe/Cargo.toml",
|
||||||
"components/resident-probe/Cargo.toml",
|
|
||||||
] {
|
] {
|
||||||
let status = Command::new("rustup")
|
let status = Command::new("rustup")
|
||||||
.args([
|
.args([
|
||||||
|
|||||||
@@ -11,59 +11,9 @@ index.html / src/host
|
|||||||
└── src/pages/<view>
|
└── src/pages/<view>
|
||||||
```
|
```
|
||||||
|
|
||||||
普通页面切换时,Host 先发送 `dispose`,再销毁 iframe 并创建新的文档。页面所属的
|
Host 始终只保留一个 iframe。切换一级页面时,Host 先发送 `dispose`,再销毁 iframe
|
||||||
Solid reactive owner、事件监听器、第三方 UI 库和页面局部缓存会随文档一起释放,避免
|
并创建新的文档。页面所属的 Solid reactive owner、事件监听器、第三方 UI 库和页面局部
|
||||||
长期导航后把所有页面资源都留在同一个 JavaScript realm 中。
|
缓存会随文档一起释放,避免长期导航后把各页面资源都留在同一个 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 自己构建并同源
|
iframe 是资源生命周期边界,不是安全边界。Host 和 Page 都是 Wasmeld 自己构建并同源
|
||||||
发布的可信代码;Wasm 服务的安全边界仍然在后端 Wasmtime Sandbox 和 Host Capability
|
发布的可信代码;Wasm 服务的安全边界仍然在后端 Wasmtime Sandbox 和 Host Capability
|
||||||
@@ -72,67 +22,19 @@ Registry 中。
|
|||||||
## 目录
|
## 目录
|
||||||
|
|
||||||
```text
|
```text
|
||||||
src/host/ 常驻 Host Shell、iframe 隐藏超时管理和 Host-owned dialogs
|
src/host/ 常驻 Host Shell 和 Host-owned dialogs
|
||||||
src/components/
|
src/components/ui/ 跨页面复用的无业务 UI
|
||||||
feedback/ 空状态等反馈组合
|
|
||||||
layout/ PageHeading 等页面结构
|
|
||||||
services/ ServiceTable、StatusBadge 等服务领域组件
|
|
||||||
ui/ Button、Input、Select、Textarea、Card 等无业务基础组件
|
|
||||||
src/lib/ API client、领域模型和纯函数
|
src/lib/ API client、领域模型和纯函数
|
||||||
src/primitives/ Solid reactive primitives;Solid 不使用 React Hooks 约定
|
src/primitives/ Solid reactive primitives;Solid 不使用 React Hooks 约定
|
||||||
src/pages/ 集中式页面注册表;每个 iframe 页面一个目录和独立懒加载 chunk
|
src/pages/ 每个 iframe 页面一个目录和独立懒加载 chunk
|
||||||
src/sdk/ Host/Page postMessage 协议与两侧 client
|
src/sdk/ Host/Page postMessage 协议与两侧 client
|
||||||
src/styles/ Tailwind v4 theme、文档基础规则和全局 keyframes
|
src/styles/ Tailwind v4 theme 和共享组件样式
|
||||||
```
|
```
|
||||||
|
|
||||||
`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` 是文档间协议版本。新增可选消息可以保持原版本;
|
`src/sdk/protocol.ts` 中的 `SDK_VERSION` 是文档间协议版本。新增可选消息可以保持原版本;
|
||||||
删除字段、改变字段语义或产生不兼容状态时必须升级版本,并让两侧同时发布。消息接收端
|
删除字段、改变字段语义或产生不兼容状态时必须升级版本,并让两侧同时发布。消息接收端
|
||||||
同时检查 origin、source、channel 和 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 后端:
|
先在仓库根目录启动 Rust 后端:
|
||||||
|
|||||||
+91
-35
@@ -8,11 +8,8 @@
|
|||||||
"name": "wasmeld-console-web",
|
"name": "wasmeld-console-web",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"class-variance-authority": "0.7.1",
|
|
||||||
"clsx": "2.1.1",
|
|
||||||
"lucide-solid": "1.27.0",
|
"lucide-solid": "1.27.0",
|
||||||
"solid-js": "1.9.14",
|
"solid-js": "1.9.14"
|
||||||
"tailwind-merge": "3.6.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/vite": "4.3.3",
|
"@tailwindcss/vite": "4.3.3",
|
||||||
@@ -548,6 +545,9 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -565,6 +565,9 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -582,6 +585,9 @@
|
|||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -599,6 +605,9 @@
|
|||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -616,6 +625,9 @@
|
|||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -633,6 +645,9 @@
|
|||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -650,6 +665,9 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -667,6 +685,9 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -871,6 +892,9 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -888,6 +912,9 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -905,6 +932,9 @@
|
|||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -922,6 +952,9 @@
|
|||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -939,6 +972,9 @@
|
|||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -956,6 +992,9 @@
|
|||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -973,6 +1012,9 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -990,6 +1032,9 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1160,6 +1205,9 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1177,6 +1225,9 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1194,6 +1245,9 @@
|
|||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1211,6 +1265,9 @@
|
|||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1228,6 +1285,9 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1245,6 +1305,9 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1487,6 +1550,9 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1504,6 +1570,9 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1521,6 +1590,9 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1538,6 +1610,9 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2149,27 +2224,6 @@
|
|||||||
],
|
],
|
||||||
"license": "CC-BY-4.0"
|
"license": "CC-BY-4.0"
|
||||||
},
|
},
|
||||||
"node_modules/class-variance-authority": {
|
|
||||||
"version": "0.7.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz",
|
|
||||||
"integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==",
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"dependencies": {
|
|
||||||
"clsx": "^2.1.1"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://polar.sh/cva"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/clsx": {
|
|
||||||
"version": "2.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
|
||||||
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/convert-source-map": {
|
"node_modules/convert-source-map": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
||||||
@@ -2511,6 +2565,9 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2532,6 +2589,9 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2553,6 +2613,9 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2574,6 +2637,9 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2974,16 +3040,6 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/tailwind-merge": {
|
|
||||||
"version": "3.6.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz",
|
|
||||||
"integrity": "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==",
|
|
||||||
"license": "MIT",
|
|
||||||
"funding": {
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/dcastil"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/tailwindcss": {
|
"node_modules/tailwindcss": {
|
||||||
"version": "4.3.3",
|
"version": "4.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz",
|
||||||
|
|||||||
@@ -7,17 +7,14 @@
|
|||||||
"dev": "vite dev",
|
"dev": "vite dev",
|
||||||
"build": "vite build && tsc --noEmit",
|
"build": "vite build && tsc --noEmit",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"test": "npm run build && node --experimental-strip-types --test tests/*.test.mjs",
|
"test": "npm run build && node --test tests/build-output.test.mjs",
|
||||||
"lint": "oxlint . --deny-warnings",
|
"lint": "oxlint . --deny-warnings",
|
||||||
"format": "oxfmt .",
|
"format": "oxfmt .",
|
||||||
"format:check": "oxfmt --check ."
|
"format:check": "oxfmt --check ."
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"class-variance-authority": "0.7.1",
|
|
||||||
"clsx": "2.1.1",
|
|
||||||
"lucide-solid": "1.27.0",
|
"lucide-solid": "1.27.0",
|
||||||
"solid-js": "1.9.14",
|
"solid-js": "1.9.14"
|
||||||
"tailwind-merge": "3.6.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/vite": "4.3.3",
|
"@tailwindcss/vite": "4.3.3",
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
import type { JSXElement } from "solid-js";
|
|
||||||
|
|
||||||
export function PageHeading(props: {
|
|
||||||
eyebrow: string;
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
actions?: JSXElement;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<header class="mb-5 flex flex-col justify-between gap-4 sm:flex-row sm:items-end">
|
|
||||||
<div>
|
|
||||||
<span class="text-[11px] font-bold uppercase text-cyan-strong">{props.eyebrow}</span>
|
|
||||||
<h1 class="mt-1 text-2xl font-bold text-ink">{props.title}</h1>
|
|
||||||
<p class="mt-1 max-w-3xl text-sm text-muted">{props.description}</p>
|
|
||||||
</div>
|
|
||||||
{props.actions && <div class="flex shrink-0 items-center gap-2">{props.actions}</div>}
|
|
||||||
</header>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import { splitProps, type JSX } from "solid-js";
|
|
||||||
import { cn } from "../../lib/cn";
|
|
||||||
|
|
||||||
export type PageProps = JSX.HTMLAttributes<HTMLElement>;
|
|
||||||
|
|
||||||
/** Standard constrained content area rendered inside a Page iframe. */
|
|
||||||
export function Page(props: PageProps) {
|
|
||||||
const [local, rest] = splitProps(props, ["class", "children"]);
|
|
||||||
return (
|
|
||||||
<main
|
|
||||||
{...rest}
|
|
||||||
data-slot="page"
|
|
||||||
class={cn("mx-auto w-full max-w-[1480px] px-4 py-5 sm:px-6 lg:px-8 lg:py-7", local.class)}
|
|
||||||
>
|
|
||||||
{local.children}
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,201 +0,0 @@
|
|||||||
import {
|
|
||||||
Box,
|
|
||||||
Braces,
|
|
||||||
CircleStop,
|
|
||||||
Play,
|
|
||||||
RadioTower,
|
|
||||||
RefreshCw,
|
|
||||||
RotateCcw,
|
|
||||||
Search,
|
|
||||||
SquareTerminal,
|
|
||||||
} from "lucide-solid";
|
|
||||||
import { For, Show } from "solid-js";
|
|
||||||
import { serviceKey, type Service } from "../../lib/model";
|
|
||||||
import type { HostSharedState, PageCommand } from "../../sdk/protocol";
|
|
||||||
import { EmptyState } from "../feedback/empty-state";
|
|
||||||
import { IconButton } from "../ui/button";
|
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../ui/table";
|
|
||||||
import { StatusBadge } from "./status-badge";
|
|
||||||
|
|
||||||
export function ServiceTable(props: {
|
|
||||||
services: Service[];
|
|
||||||
state: HostSharedState;
|
|
||||||
command: (command: PageCommand) => void;
|
|
||||||
compact?: boolean;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<Show
|
|
||||||
when={props.services.length > 0}
|
|
||||||
fallback={
|
|
||||||
<EmptyState icon={Search} title="没有匹配的服务" detail="调整搜索关键词或状态筛选。" />
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead>服务</TableHead>
|
|
||||||
<TableHead>状态</TableHead>
|
|
||||||
<Show when={!props.compact}>
|
|
||||||
<TableHead>制品</TableHead>
|
|
||||||
<TableHead>Host 能力</TableHead>
|
|
||||||
</Show>
|
|
||||||
<TableHead>调用</TableHead>
|
|
||||||
<TableHead>延迟</TableHead>
|
|
||||||
<TableHead>
|
|
||||||
<span class="sr-only">操作</span>
|
|
||||||
</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
<For each={props.services}>
|
|
||||||
{(service) => {
|
|
||||||
const key = serviceKey(service);
|
|
||||||
return (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell>
|
|
||||||
<div class="flex min-w-48 items-center gap-3">
|
|
||||||
<span class="flex size-8 shrink-0 items-center justify-center rounded-md bg-brand-soft text-brand">
|
|
||||||
<Box size={15} />
|
|
||||||
</span>
|
|
||||||
<span class="min-w-0">
|
|
||||||
<span class="flex items-center gap-2">
|
|
||||||
<strong class="truncate">{service.id}</strong>
|
|
||||||
<Show when={service.active}>
|
|
||||||
<span class="inline-flex items-center gap-1 rounded-sm bg-cyan-soft px-1.5 py-0.5 text-[10px] font-semibold text-cyan-strong">
|
|
||||||
<RadioTower size={10} />
|
|
||||||
对外
|
|
||||||
</span>
|
|
||||||
</Show>
|
|
||||||
</span>
|
|
||||||
<small class="mt-0.5 block text-xs text-muted">{service.revision}</small>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<StatusBadge status={service.status} />
|
|
||||||
</TableCell>
|
|
||||||
<Show when={!props.compact}>
|
|
||||||
<TableCell
|
|
||||||
class="max-w-48 truncate font-mono text-xs text-muted"
|
|
||||||
title={service.artifact}
|
|
||||||
>
|
|
||||||
{service.artifact}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Show
|
|
||||||
when={service.capabilities.length > 0}
|
|
||||||
fallback={<span class="text-xs text-muted">无</span>}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="inline-flex max-w-56 items-center gap-1.5 text-xs text-muted"
|
|
||||||
title={service.capabilities
|
|
||||||
.map((capability) => capability.interface)
|
|
||||||
.join("\n")}
|
|
||||||
>
|
|
||||||
<Braces size={12} />
|
|
||||||
<span class="truncate">
|
|
||||||
{service.capabilities[0]?.package}/{service.capabilities[0]?.name}
|
|
||||||
</span>
|
|
||||||
<Show when={service.capabilities.length > 1}>
|
|
||||||
<small>+{service.capabilities.length - 1}</small>
|
|
||||||
</Show>
|
|
||||||
</span>
|
|
||||||
</Show>
|
|
||||||
</TableCell>
|
|
||||||
</Show>
|
|
||||||
<TableCell class="font-mono text-xs">
|
|
||||||
{service.calls.toLocaleString("zh-CN")}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell class="font-mono text-xs">
|
|
||||||
{service.latencyMs === null ? "—" : `${service.latencyMs} ms`}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<div class="flex items-center justify-end gap-1">
|
|
||||||
<IconButton
|
|
||||||
size="small"
|
|
||||||
aria-label="设为对外版本"
|
|
||||||
title={service.active ? "当前对外版本" : "设为对外版本"}
|
|
||||||
disabled={
|
|
||||||
service.active ||
|
|
||||||
props.state.snapshot?.runtime.status !== "running" ||
|
|
||||||
props.state.deploymentAction !== null
|
|
||||||
}
|
|
||||||
onClick={() => props.command({ type: "open-activate", serviceKey: key })}
|
|
||||||
>
|
|
||||||
{props.state.deploymentAction === key ? (
|
|
||||||
<RefreshCw class="animate-spin" size={15} />
|
|
||||||
) : (
|
|
||||||
<RadioTower size={15} />
|
|
||||||
)}
|
|
||||||
</IconButton>
|
|
||||||
<Show
|
|
||||||
when={service.status === "running"}
|
|
||||||
fallback={
|
|
||||||
<IconButton
|
|
||||||
size="small"
|
|
||||||
aria-label={`启动 ${service.id}`}
|
|
||||||
title="启动"
|
|
||||||
disabled={props.state.serviceAction !== null}
|
|
||||||
onClick={() =>
|
|
||||||
props.command({
|
|
||||||
type: "service-action",
|
|
||||||
serviceKey: key,
|
|
||||||
action: "start",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Play size={15} />
|
|
||||||
</IconButton>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<IconButton
|
|
||||||
size="small"
|
|
||||||
aria-label={`调用 ${service.id}`}
|
|
||||||
title="测试调用"
|
|
||||||
onClick={() => props.command({ type: "open-invoke", serviceKey: key })}
|
|
||||||
>
|
|
||||||
<SquareTerminal size={15} />
|
|
||||||
</IconButton>
|
|
||||||
<IconButton
|
|
||||||
size="small"
|
|
||||||
aria-label={`重启 ${service.id}`}
|
|
||||||
title="重启"
|
|
||||||
disabled={props.state.serviceAction !== null}
|
|
||||||
onClick={() =>
|
|
||||||
props.command({
|
|
||||||
type: "service-action",
|
|
||||||
serviceKey: key,
|
|
||||||
action: "restart",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<RotateCcw size={15} />
|
|
||||||
</IconButton>
|
|
||||||
<IconButton
|
|
||||||
size="small"
|
|
||||||
tone="danger"
|
|
||||||
aria-label={`停止 ${service.id}`}
|
|
||||||
title="停止"
|
|
||||||
disabled={props.state.serviceAction !== null}
|
|
||||||
onClick={() =>
|
|
||||||
props.command({
|
|
||||||
type: "service-action",
|
|
||||||
serviceKey: key,
|
|
||||||
action: "stop",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<CircleStop size={15} />
|
|
||||||
</IconButton>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
</For>
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</Show>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import { cva } from "class-variance-authority";
|
|
||||||
import { STATUS_META, type ServiceStatus } from "../../lib/model";
|
|
||||||
|
|
||||||
const statusBadgeVariants = cva(
|
|
||||||
"inline-flex items-center gap-1.5 whitespace-nowrap text-xs font-semibold before:size-1.5 before:rounded-full before:bg-current before:content-['']",
|
|
||||||
{
|
|
||||||
variants: {
|
|
||||||
status: {
|
|
||||||
running: "text-brand",
|
|
||||||
stopped: "text-muted",
|
|
||||||
faulted: "text-coral-strong",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
export function StatusBadge(props: { status: ServiceStatus }) {
|
|
||||||
return (
|
|
||||||
<span data-slot="service-status" class={statusBadgeVariants({ status: props.status })}>
|
|
||||||
{STATUS_META[props.status].label}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
import { cva, type VariantProps } from "class-variance-authority";
|
|
||||||
import { splitProps, type JSX } from "solid-js";
|
|
||||||
import { cn } from "../../lib/cn";
|
|
||||||
|
|
||||||
export const buttonVariants = cva(
|
|
||||||
"inline-flex items-center justify-center gap-2 rounded-md border text-sm font-semibold transition-colors disabled:cursor-not-allowed disabled:opacity-45",
|
|
||||||
{
|
|
||||||
variants: {
|
|
||||||
variant: {
|
|
||||||
primary: "border-brand bg-brand text-white hover:border-brand-strong hover:bg-brand-strong",
|
|
||||||
secondary: "border-line bg-white text-ink hover:bg-canvas",
|
|
||||||
danger: "border-coral-strong bg-white text-coral-strong hover:bg-coral-soft",
|
|
||||||
},
|
|
||||||
size: {
|
|
||||||
default: "min-h-10 px-4 py-2",
|
|
||||||
small: "min-h-9 px-4 py-1.5",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
defaultVariants: {
|
|
||||||
variant: "secondary",
|
|
||||||
size: "default",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
export type ButtonProps = JSX.ButtonHTMLAttributes<HTMLButtonElement> &
|
|
||||||
VariantProps<typeof buttonVariants>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Standard text or icon-and-text command button.
|
|
||||||
*
|
|
||||||
* Size variants define a minimum target height instead of fixing the block
|
|
||||||
* height, so wrapped labels and enlarged user fonts can grow without clipping.
|
|
||||||
* The component defaults to `type="button"` so using it inside a form never
|
|
||||||
* submits accidentally. Set `type="submit"` explicitly for form actions.
|
|
||||||
*/
|
|
||||||
export function Button(props: ButtonProps) {
|
|
||||||
const [local, rest] = splitProps(props, ["variant", "size", "class", "children", "type"]);
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
{...rest}
|
|
||||||
data-slot="button"
|
|
||||||
type={local.type ?? "button"}
|
|
||||||
class={cn(buttonVariants({ variant: local.variant, size: local.size }), local.class)}
|
|
||||||
>
|
|
||||||
{local.children}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const iconButtonVariants = cva(
|
|
||||||
"inline-flex shrink-0 items-center justify-center rounded-md border border-line bg-white text-muted transition-colors hover:bg-canvas hover:text-ink disabled:cursor-not-allowed disabled:opacity-45",
|
|
||||||
{
|
|
||||||
variants: {
|
|
||||||
tone: {
|
|
||||||
default: "",
|
|
||||||
danger: "text-coral-strong",
|
|
||||||
},
|
|
||||||
size: {
|
|
||||||
default: "size-9",
|
|
||||||
small: "size-8",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
defaultVariants: {
|
|
||||||
tone: "default",
|
|
||||||
size: "default",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
export type IconButtonProps = Omit<JSX.ButtonHTMLAttributes<HTMLButtonElement>, "aria-label"> &
|
|
||||||
VariantProps<typeof iconButtonVariants> & {
|
|
||||||
"aria-label": string;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Square icon-only button for familiar toolbar and row actions.
|
|
||||||
*
|
|
||||||
* Callers must provide an accessible `aria-label`; `title` is recommended when
|
|
||||||
* the icon's meaning benefits from a visible hover explanation.
|
|
||||||
*/
|
|
||||||
export function IconButton(props: IconButtonProps) {
|
|
||||||
const [local, rest] = splitProps(props, ["tone", "size", "class", "children", "type"]);
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
{...rest}
|
|
||||||
data-slot="icon-button"
|
|
||||||
type={local.type ?? "button"}
|
|
||||||
class={cn(iconButtonVariants({ tone: local.tone, size: local.size }), local.class)}
|
|
||||||
>
|
|
||||||
{local.children}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export type IconLinkProps = Omit<JSX.AnchorHTMLAttributes<HTMLAnchorElement>, "aria-label"> &
|
|
||||||
Pick<VariantProps<typeof iconButtonVariants>, "size"> & {
|
|
||||||
"aria-label": string;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Anchor counterpart to IconButton for downloads and external navigation. */
|
|
||||||
export function IconLink(props: IconLinkProps) {
|
|
||||||
const [local, rest] = splitProps(props, ["size", "class", "children"]);
|
|
||||||
return (
|
|
||||||
<a
|
|
||||||
{...rest}
|
|
||||||
data-slot="icon-link"
|
|
||||||
class={cn(iconButtonVariants({ size: local.size }), local.class)}
|
|
||||||
>
|
|
||||||
{local.children}
|
|
||||||
</a>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
import { cva, type VariantProps } from "class-variance-authority";
|
|
||||||
import { splitProps, type JSX } from "solid-js";
|
|
||||||
import { Dynamic } from "solid-js/web";
|
|
||||||
import { cn } from "../../lib/cn";
|
|
||||||
|
|
||||||
type CardElement = "article" | "aside" | "div" | "section";
|
|
||||||
|
|
||||||
export const cardVariants = cva("rounded-md border border-line bg-surface", {
|
|
||||||
variants: {
|
|
||||||
padding: {
|
|
||||||
none: "",
|
|
||||||
small: "p-4",
|
|
||||||
medium: "p-5",
|
|
||||||
},
|
|
||||||
elevation: {
|
|
||||||
flat: "",
|
|
||||||
raised: "shadow-sm",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
defaultVariants: {
|
|
||||||
padding: "none",
|
|
||||||
elevation: "flat",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export type CardProps = JSX.HTMLAttributes<HTMLElement> &
|
|
||||||
VariantProps<typeof cardVariants> & {
|
|
||||||
as?: CardElement;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generic bordered surface.
|
|
||||||
*
|
|
||||||
* `as` preserves document semantics at the call site; Card only owns visual
|
|
||||||
* framing and must not encode page or domain behavior.
|
|
||||||
*/
|
|
||||||
export function Card(props: CardProps) {
|
|
||||||
const [local, rest] = splitProps(props, ["as", "padding", "elevation", "class", "children"]);
|
|
||||||
return (
|
|
||||||
<Dynamic
|
|
||||||
component={local.as ?? "div"}
|
|
||||||
{...rest}
|
|
||||||
data-slot="card"
|
|
||||||
class={cn(cardVariants({ padding: local.padding, elevation: local.elevation }), local.class)}
|
|
||||||
>
|
|
||||||
{local.children}
|
|
||||||
</Dynamic>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export type CardHeaderProps = JSX.HTMLAttributes<HTMLDivElement>;
|
|
||||||
|
|
||||||
/** Standard Card heading/action row. */
|
|
||||||
export function CardHeader(props: CardHeaderProps) {
|
|
||||||
const [local, rest] = splitProps(props, ["class", "children"]);
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
{...rest}
|
|
||||||
data-slot="card-header"
|
|
||||||
class={cn(
|
|
||||||
"flex min-h-16 items-center justify-between gap-4 border-b border-line px-5 py-3 [&_h2]:text-base [&_h2]:font-semibold [&_p]:mt-0.5 [&_p]:text-xs [&_p]:text-muted",
|
|
||||||
local.class,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{local.children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
+3
-3
@@ -4,10 +4,10 @@ import type { LucideProps } from "lucide-solid";
|
|||||||
export function EmptyState(props: { icon: Component<LucideProps>; title: string; detail: string }) {
|
export function EmptyState(props: { icon: Component<LucideProps>; title: string; detail: string }) {
|
||||||
const Icon = props.icon;
|
const Icon = props.icon;
|
||||||
return (
|
return (
|
||||||
<div class="flex min-h-56 flex-col items-center justify-center gap-2 px-6 text-center text-muted">
|
<div class="empty-state">
|
||||||
<Icon size={22} />
|
<Icon size={22} />
|
||||||
<strong class="text-sm text-ink">{props.title}</strong>
|
<strong>{props.title}</strong>
|
||||||
<span class="text-xs">{props.detail}</span>
|
<span>{props.detail}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
import { splitProps, type JSX } from "solid-js";
|
|
||||||
import { cn } from "../../lib/cn";
|
|
||||||
|
|
||||||
export type FieldProps = JSX.LabelHTMLAttributes<HTMLLabelElement>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Associates a label and form control while keeping field layout consistent.
|
|
||||||
*
|
|
||||||
* Keep validation and help text at the call site: those messages are domain
|
|
||||||
* content, while Field only owns spacing and label typography.
|
|
||||||
*/
|
|
||||||
export function Field(props: FieldProps) {
|
|
||||||
const [local, rest] = splitProps(props, ["class", "children"]);
|
|
||||||
return (
|
|
||||||
<label {...rest} data-slot="field" class={cn("block", local.class)}>
|
|
||||||
{local.children}
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export type FieldLabelProps = JSX.HTMLAttributes<HTMLSpanElement>;
|
|
||||||
|
|
||||||
export function FieldLabel(props: FieldLabelProps) {
|
|
||||||
const [local, rest] = splitProps(props, ["class", "children"]);
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
{...rest}
|
|
||||||
data-slot="field-label"
|
|
||||||
class={cn("mb-1.5 block text-xs font-semibold text-muted", local.class)}
|
|
||||||
>
|
|
||||||
{local.children}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
import { cva, type VariantProps } from "class-variance-authority";
|
|
||||||
import { splitProps, type JSX } from "solid-js";
|
|
||||||
import { cn } from "../../lib/cn";
|
|
||||||
|
|
||||||
export const inputVariants = cva(
|
|
||||||
"w-full rounded-md border border-line bg-white px-3 text-sm text-ink outline-none transition-colors focus:border-brand disabled:cursor-not-allowed disabled:opacity-45 aria-invalid:border-coral-strong",
|
|
||||||
{
|
|
||||||
variants: {
|
|
||||||
density: {
|
|
||||||
default: "min-h-10 py-2",
|
|
||||||
compact: "min-h-9 py-1.5",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
defaultVariants: {
|
|
||||||
density: "default",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
export type InputProps = JSX.InputHTMLAttributes<HTMLInputElement> &
|
|
||||||
VariantProps<typeof inputVariants>;
|
|
||||||
|
|
||||||
/** Native single-line input with a minimum, rather than fixed, control height. */
|
|
||||||
export function Input(props: InputProps) {
|
|
||||||
const [local, rest] = splitProps(props, ["density", "class"]);
|
|
||||||
return (
|
|
||||||
<input
|
|
||||||
{...rest}
|
|
||||||
data-slot="input"
|
|
||||||
class={cn(inputVariants({ density: local.density }), local.class)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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="page-heading">
|
||||||
|
<div>
|
||||||
|
<span class="eyebrow">{props.eyebrow}</span>
|
||||||
|
<h1>{props.title}</h1>
|
||||||
|
<p>{props.description}</p>
|
||||||
|
</div>
|
||||||
|
{props.actions && <div class="heading-actions">{props.actions}</div>}
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
import { cva, type VariantProps } from "class-variance-authority";
|
|
||||||
import { splitProps, type JSX } from "solid-js";
|
|
||||||
import { cn } from "../../lib/cn";
|
|
||||||
|
|
||||||
export function SegmentedControl(props: JSX.HTMLAttributes<HTMLDivElement>) {
|
|
||||||
const [local, rest] = splitProps(props, ["class", "children"]);
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
{...rest}
|
|
||||||
data-slot="segmented-control"
|
|
||||||
class={cn("inline-flex rounded-md border border-line bg-canvas p-1", local.class)}
|
|
||||||
>
|
|
||||||
{local.children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const segmentVariants = cva(
|
|
||||||
"min-h-8 rounded-sm px-3 py-1 text-xs font-semibold text-muted transition-colors",
|
|
||||||
{
|
|
||||||
variants: {
|
|
||||||
active: {
|
|
||||||
true: "bg-white text-ink shadow-sm",
|
|
||||||
false: "hover:text-ink",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
defaultVariants: {
|
|
||||||
active: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
export type SegmentProps = JSX.ButtonHTMLAttributes<HTMLButtonElement> &
|
|
||||||
VariantProps<typeof segmentVariants>;
|
|
||||||
|
|
||||||
/** One mutually exclusive option inside a SegmentedControl. */
|
|
||||||
export function Segment(props: SegmentProps) {
|
|
||||||
const [local, rest] = splitProps(props, ["active", "class", "children", "type"]);
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
{...rest}
|
|
||||||
data-slot="segment"
|
|
||||||
type={local.type ?? "button"}
|
|
||||||
aria-pressed={local.active === true}
|
|
||||||
class={cn(segmentVariants({ active: local.active }), local.class)}
|
|
||||||
>
|
|
||||||
{local.children}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
import { cva, type VariantProps } from "class-variance-authority";
|
|
||||||
import { splitProps, type JSX } from "solid-js";
|
|
||||||
import { cn } from "../../lib/cn";
|
|
||||||
|
|
||||||
export const selectVariants = cva(
|
|
||||||
"w-full rounded-md border border-line bg-white px-3 text-sm text-ink outline-none transition-colors focus:border-brand disabled:cursor-not-allowed disabled:opacity-45 aria-invalid:border-coral-strong",
|
|
||||||
{
|
|
||||||
variants: {
|
|
||||||
density: {
|
|
||||||
default: "min-h-10 py-2",
|
|
||||||
compact: "min-h-9 py-1.5",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
defaultVariants: {
|
|
||||||
density: "default",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
export type SelectProps = JSX.SelectHTMLAttributes<HTMLSelectElement> &
|
|
||||||
VariantProps<typeof selectVariants>;
|
|
||||||
|
|
||||||
/** Native select with browser behavior preserved and a content-growable height. */
|
|
||||||
export function Select(props: SelectProps) {
|
|
||||||
const [local, rest] = splitProps(props, ["density", "class", "children"]);
|
|
||||||
return (
|
|
||||||
<select
|
|
||||||
{...rest}
|
|
||||||
data-slot="select"
|
|
||||||
class={cn(selectVariants({ density: local.density }), local.class)}
|
|
||||||
>
|
|
||||||
{local.children}
|
|
||||||
</select>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
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 "./empty-state";
|
||||||
|
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="调整搜索关键词或状态筛选。" />
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>服务</th>
|
||||||
|
<th>状态</th>
|
||||||
|
<Show when={!props.compact}>
|
||||||
|
<th>制品</th>
|
||||||
|
<th>Host 能力</th>
|
||||||
|
</Show>
|
||||||
|
<th>调用</th>
|
||||||
|
<th>延迟</th>
|
||||||
|
<th>
|
||||||
|
<span class="sr-only">操作</span>
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<For each={props.services}>
|
||||||
|
{(service) => {
|
||||||
|
const key = serviceKey(service);
|
||||||
|
return (
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<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>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<StatusBadge status={service.status} />
|
||||||
|
</td>
|
||||||
|
<Show when={!props.compact}>
|
||||||
|
<td class="code max-w-48 truncate text-muted" title={service.artifact}>
|
||||||
|
{service.artifact}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<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>
|
||||||
|
</td>
|
||||||
|
</Show>
|
||||||
|
<td class="font-mono text-xs">{service.calls.toLocaleString("zh-CN")}</td>
|
||||||
|
<td class="font-mono text-xs">
|
||||||
|
{service.latencyMs === null ? "—" : `${service.latencyMs} ms`}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="row-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
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="spin" size={15} />
|
||||||
|
) : (
|
||||||
|
<RadioTower size={15} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<Show
|
||||||
|
when={service.status === "running"}
|
||||||
|
fallback={
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={`启动 ${service.id}`}
|
||||||
|
title="启动"
|
||||||
|
disabled={props.state.serviceAction !== null}
|
||||||
|
onClick={() =>
|
||||||
|
props.command({
|
||||||
|
type: "service-action",
|
||||||
|
serviceKey: key,
|
||||||
|
action: "start",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Play size={15} />
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={`调用 ${service.id}`}
|
||||||
|
title="测试调用"
|
||||||
|
onClick={() => props.command({ type: "open-invoke", serviceKey: key })}
|
||||||
|
>
|
||||||
|
<SquareTerminal size={15} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={`重启 ${service.id}`}
|
||||||
|
title="重启"
|
||||||
|
disabled={props.state.serviceAction !== null}
|
||||||
|
onClick={() =>
|
||||||
|
props.command({
|
||||||
|
type: "service-action",
|
||||||
|
serviceKey: key,
|
||||||
|
action: "restart",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<RotateCcw size={15} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={`停止 ${service.id}`}
|
||||||
|
title="停止"
|
||||||
|
disabled={props.state.serviceAction !== null}
|
||||||
|
onClick={() =>
|
||||||
|
props.command({
|
||||||
|
type: "service-action",
|
||||||
|
serviceKey: key,
|
||||||
|
action: "stop",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<CircleStop size={15} />
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { STATUS_META, type ServiceStatus } from "../../lib/model";
|
||||||
|
|
||||||
|
export function StatusBadge(props: { status: ServiceStatus }) {
|
||||||
|
const meta = () => STATUS_META[props.status];
|
||||||
|
return <span class={`status-badge ${meta().className}`}>{meta().label}</span>;
|
||||||
|
}
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
import { splitProps, type JSX } from "solid-js";
|
|
||||||
import { cn } from "../../lib/cn";
|
|
||||||
|
|
||||||
export type TableProps = JSX.HTMLAttributes<HTMLTableElement> & {
|
|
||||||
containerClass?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Responsive native table with a horizontal overflow boundary. */
|
|
||||||
export function Table(props: TableProps) {
|
|
||||||
const [local, rest] = splitProps(props, ["class", "containerClass", "children"]);
|
|
||||||
return (
|
|
||||||
<div data-slot="table-container" class={cn("w-full overflow-x-auto", local.containerClass)}>
|
|
||||||
<table
|
|
||||||
{...rest}
|
|
||||||
data-slot="table"
|
|
||||||
class={cn("w-full min-w-[760px] border-collapse text-left text-sm", local.class)}
|
|
||||||
>
|
|
||||||
{local.children}
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TableHeader(props: JSX.HTMLAttributes<HTMLTableSectionElement>) {
|
|
||||||
const [local, rest] = splitProps(props, ["class", "children"]);
|
|
||||||
return (
|
|
||||||
<thead {...rest} data-slot="table-header" class={cn(local.class)}>
|
|
||||||
{local.children}
|
|
||||||
</thead>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TableBody(props: JSX.HTMLAttributes<HTMLTableSectionElement>) {
|
|
||||||
const [local, rest] = splitProps(props, ["class", "children"]);
|
|
||||||
return (
|
|
||||||
<tbody {...rest} data-slot="table-body" class={cn(local.class)}>
|
|
||||||
{local.children}
|
|
||||||
</tbody>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TableRow(props: JSX.HTMLAttributes<HTMLTableRowElement>) {
|
|
||||||
const [local, rest] = splitProps(props, ["class", "children"]);
|
|
||||||
return (
|
|
||||||
<tr
|
|
||||||
{...rest}
|
|
||||||
data-slot="table-row"
|
|
||||||
class={cn("transition-colors hover:bg-[#f8faf9] [&:last-child_td]:border-b-0", local.class)}
|
|
||||||
>
|
|
||||||
{local.children}
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TableHead(props: JSX.ThHTMLAttributes<HTMLTableCellElement>) {
|
|
||||||
const [local, rest] = splitProps(props, ["class", "children"]);
|
|
||||||
return (
|
|
||||||
<th
|
|
||||||
{...rest}
|
|
||||||
data-slot="table-head"
|
|
||||||
class={cn(
|
|
||||||
"border-b border-line bg-canvas px-4 py-2.5 text-[11px] font-semibold uppercase text-muted",
|
|
||||||
local.class,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{local.children}
|
|
||||||
</th>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TableCell(props: JSX.TdHTMLAttributes<HTMLTableCellElement>) {
|
|
||||||
const [local, rest] = splitProps(props, ["class", "children"]);
|
|
||||||
return (
|
|
||||||
<td
|
|
||||||
{...rest}
|
|
||||||
data-slot="table-cell"
|
|
||||||
class={cn("border-b border-line px-4 py-3 align-middle", local.class)}
|
|
||||||
>
|
|
||||||
{local.children}
|
|
||||||
</td>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
import { cva, type VariantProps } from "class-variance-authority";
|
|
||||||
import { splitProps, type JSX } from "solid-js";
|
|
||||||
import { cn } from "../../lib/cn";
|
|
||||||
|
|
||||||
export const textareaVariants = cva(
|
|
||||||
"min-h-36 w-full rounded-md border border-line bg-white px-3 py-3 text-sm text-ink outline-none transition-colors focus:border-brand disabled:cursor-not-allowed disabled:opacity-45 aria-invalid:border-coral-strong",
|
|
||||||
{
|
|
||||||
variants: {
|
|
||||||
resize: {
|
|
||||||
vertical: "resize-y",
|
|
||||||
none: "resize-none",
|
|
||||||
},
|
|
||||||
typography: {
|
|
||||||
mono: "font-mono",
|
|
||||||
sans: "font-sans",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
defaultVariants: {
|
|
||||||
resize: "vertical",
|
|
||||||
typography: "mono",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
export type TextareaProps = JSX.TextareaHTMLAttributes<HTMLTextAreaElement> &
|
|
||||||
VariantProps<typeof textareaVariants>;
|
|
||||||
|
|
||||||
/** Standard multiline input with explicit resize and typography variants. */
|
|
||||||
export function Textarea(props: TextareaProps) {
|
|
||||||
const [local, rest] = splitProps(props, ["resize", "typography", "class", "children"]);
|
|
||||||
return (
|
|
||||||
<textarea
|
|
||||||
{...rest}
|
|
||||||
data-slot="textarea"
|
|
||||||
class={cn(
|
|
||||||
textareaVariants({ resize: local.resize, typography: local.typography }),
|
|
||||||
local.class,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{local.children}
|
|
||||||
</textarea>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,15 +1,26 @@
|
|||||||
import { Boxes, Search, ShieldCheck, WifiOff } from "lucide-solid";
|
|
||||||
import {
|
import {
|
||||||
batch,
|
Boxes,
|
||||||
|
FileCode2,
|
||||||
|
History,
|
||||||
|
LayoutDashboard,
|
||||||
|
Package,
|
||||||
|
Search,
|
||||||
|
Server,
|
||||||
|
Settings,
|
||||||
|
ShieldCheck,
|
||||||
|
WifiOff,
|
||||||
|
} from "lucide-solid";
|
||||||
|
import {
|
||||||
createEffect,
|
createEffect,
|
||||||
createMemo,
|
createMemo,
|
||||||
createSignal,
|
createSignal,
|
||||||
For,
|
For,
|
||||||
on,
|
|
||||||
onCleanup,
|
onCleanup,
|
||||||
onMount,
|
onMount,
|
||||||
Show,
|
Show,
|
||||||
|
type Component,
|
||||||
} from "solid-js";
|
} from "solid-js";
|
||||||
|
import type { LucideProps } from "lucide-solid";
|
||||||
import {
|
import {
|
||||||
activateDeployment,
|
activateDeployment,
|
||||||
changeRuntimeState,
|
changeRuntimeState,
|
||||||
@@ -23,35 +34,36 @@ import {
|
|||||||
type BackendSnapshot,
|
type BackendSnapshot,
|
||||||
type InvokeResult,
|
type InvokeResult,
|
||||||
} from "../lib/api";
|
} from "../lib/api";
|
||||||
import { serviceKey, type ConnectionState, type Service } from "../lib/model";
|
import { isView, serviceKey, type ConnectionState, type Service, type View } from "../lib/model";
|
||||||
import { servicesFrom } from "../lib/view-state";
|
import { servicesFrom } from "../lib/view-state";
|
||||||
import { isView, PAGE_DEFINITIONS, pageDefinition, type View } from "../pages/registry";
|
import { disposePage, pageUrl, sendState } from "../sdk/host";
|
||||||
import { Input } from "../components/ui/input";
|
|
||||||
import { pageUrl, sendLifecycle, sendState } from "../sdk/host";
|
|
||||||
import {
|
import {
|
||||||
isPageMessage,
|
isPageMessage,
|
||||||
type HostGlobalState,
|
|
||||||
type HostSharedState,
|
type HostSharedState,
|
||||||
type PageCommand,
|
type PageCommand,
|
||||||
type RuntimeAction,
|
type RuntimeAction,
|
||||||
} from "../sdk/protocol";
|
} from "../sdk/protocol";
|
||||||
import { DialogLayer, type DialogState } from "./dialogs";
|
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;
|
const NAVIGATION: Array<{
|
||||||
|
id: View;
|
||||||
|
label: string;
|
||||||
|
icon: Component<LucideProps>;
|
||||||
|
}> = [
|
||||||
|
{ 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 default function HostApp() {
|
export default function HostApp() {
|
||||||
// All backend ownership stays in this persistent document. Page iframes are
|
// All backend ownership stays in this persistent document. Page iframes are
|
||||||
// disposable renderers and can only request typed commands through the SDK.
|
// disposable renderers and can only request typed commands through the SDK.
|
||||||
const initial = initialView();
|
const [view, setView] = createSignal(initialView());
|
||||||
const frameCache = new FrameCache(initial, {
|
const [frameInstance, setFrameInstance] = createSignal(1);
|
||||||
inactiveTtlMs: KEEP_ALIVE_INACTIVE_TTL_MS,
|
const [frameReady, setFrameReady] = createSignal(false);
|
||||||
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 [snapshot, setSnapshot] = createSignal<BackendSnapshot | null>(null);
|
||||||
const [connection, setConnection] = createSignal<ConnectionState>("connecting");
|
const [connection, setConnection] = createSignal<ConnectionState>("connecting");
|
||||||
const [apiBase, setApiBase] = createSignal(getApiBase());
|
const [apiBase, setApiBase] = createSignal(getApiBase());
|
||||||
@@ -62,65 +74,39 @@ export default function HostApp() {
|
|||||||
const [dialog, setDialog] = createSignal<DialogState | null>(null);
|
const [dialog, setDialog] = createSignal<DialogState | null>(null);
|
||||||
const [dialogBusy, setDialogBusy] = createSignal(false);
|
const [dialogBusy, setDialogBusy] = createSignal(false);
|
||||||
const [toast, setToast] = createSignal<string | null>(null);
|
const [toast, setToast] = createSignal<string | null>(null);
|
||||||
const frameElements = new Map<View, HTMLIFrameElement>();
|
let iframe: HTMLIFrameElement | undefined;
|
||||||
let tabSync: HostTabSync | null = null;
|
|
||||||
let applyingSharedHostState = false;
|
|
||||||
let toastTimer: number | undefined;
|
let toastTimer: number | undefined;
|
||||||
let frameExpirationTimer: number | undefined;
|
|
||||||
let refreshGeneration = 0;
|
let refreshGeneration = 0;
|
||||||
|
|
||||||
const globalState = createMemo<HostGlobalState>(() => ({
|
const state = createMemo<HostSharedState>(() => ({
|
||||||
|
view: view(),
|
||||||
snapshot: snapshot(),
|
snapshot: snapshot(),
|
||||||
connection: connection(),
|
connection: connection(),
|
||||||
|
query: query(),
|
||||||
apiBase: apiBase(),
|
apiBase: apiBase(),
|
||||||
runtimeAction: runtimeAction(),
|
runtimeAction: runtimeAction(),
|
||||||
serviceAction: serviceAction(),
|
serviceAction: serviceAction(),
|
||||||
deploymentAction: deploymentAction(),
|
deploymentAction: deploymentAction(),
|
||||||
}));
|
}));
|
||||||
const state = createMemo<HostSharedState>(() => ({
|
|
||||||
...globalState(),
|
|
||||||
view: view(),
|
|
||||||
query: query(),
|
|
||||||
}));
|
|
||||||
const activeServices = createMemo(
|
const activeServices = createMemo(
|
||||||
() => servicesFrom(state()).filter((service) => service.status === "running").length,
|
() => servicesFrom(state()).filter((service) => service.status === "running").length,
|
||||||
);
|
);
|
||||||
const frameReady = createMemo(() => readyViews().has(view()));
|
const frameSource = createMemo(() => pageUrl(view(), frameInstance()));
|
||||||
const title = createMemo(() => pageDefinition(view()).label);
|
const title = createMemo(
|
||||||
|
() => NAVIGATION.find((item) => item.id === 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() {
|
async function refresh() {
|
||||||
const generation = ++refreshGeneration;
|
const generation = ++refreshGeneration;
|
||||||
try {
|
try {
|
||||||
const next = await fetchSnapshot(apiBase());
|
const next = await fetchSnapshot(apiBase());
|
||||||
if (generation !== refreshGeneration) return;
|
if (generation !== refreshGeneration) return;
|
||||||
batch(() => {
|
|
||||||
setSnapshot(next);
|
setSnapshot(next);
|
||||||
setConnection("online");
|
setConnection("online");
|
||||||
});
|
|
||||||
} catch {
|
} catch {
|
||||||
if (generation !== refreshGeneration) return;
|
if (generation !== refreshGeneration) return;
|
||||||
batch(() => {
|
|
||||||
setSnapshot(null);
|
setSnapshot(null);
|
||||||
setConnection("offline");
|
setConnection("offline");
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,35 +118,19 @@ export default function HostApp() {
|
|||||||
|
|
||||||
function navigate(next: View, pushHistory = true) {
|
function navigate(next: View, pushHistory = true) {
|
||||||
if (next === view()) return;
|
if (next === view()) return;
|
||||||
|
// Give page-owned libraries a synchronous cleanup hook, then change the
|
||||||
const previous = view();
|
// keyed URL so Solid removes the old iframe realm in the same transition.
|
||||||
const transition = frameCache.activate(next);
|
const target = iframe?.contentWindow;
|
||||||
const removedViews = new Set(transition.removed.map((entry) => entry.view));
|
if (target) disposePage(target);
|
||||||
|
setFrameReady(false);
|
||||||
// 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);
|
setView(next);
|
||||||
|
setFrameInstance((instance) => instance + 1);
|
||||||
setQuery("");
|
setQuery("");
|
||||||
|
|
||||||
if (readyViews().has(next)) {
|
|
||||||
sendFrameState(next);
|
|
||||||
sendFrameLifecycle(next, "activate");
|
|
||||||
}
|
|
||||||
if (pushHistory) {
|
if (pushHistory) {
|
||||||
const url = new URL(window.location.href);
|
const url = new URL(window.location.href);
|
||||||
url.searchParams.set("view", next);
|
url.searchParams.set("view", next);
|
||||||
window.history.pushState({ view: next }, "", url);
|
window.history.pushState({ view: next }, "", url);
|
||||||
}
|
}
|
||||||
scheduleFrameExpiration();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function findService(key: string): Service | null {
|
function findService(key: string): Service | null {
|
||||||
@@ -252,65 +222,22 @@ export default function HostApp() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const receive = (event: MessageEvent<unknown>) => {
|
const receive = (event: MessageEvent<unknown>) => {
|
||||||
if (event.origin !== window.location.origin || !isPageMessage(event.data)) return;
|
if (
|
||||||
const sourceView = viewForSource(event.source);
|
event.origin !== window.location.origin ||
|
||||||
if (!sourceView || event.data.view !== sourceView) return;
|
event.source !== iframe?.contentWindow ||
|
||||||
|
!isPageMessage(event.data) ||
|
||||||
if (event.data.type === "ready") {
|
event.data.view !== view()
|
||||||
setReadyViews((current) => new Set(current).add(sourceView));
|
) {
|
||||||
sendFrameState(sourceView);
|
return;
|
||||||
sendFrameLifecycle(sourceView, sourceView === view() ? "activate" : "deactivate");
|
}
|
||||||
|
if (event.data.type === "ready") {
|
||||||
|
setFrameReady(true);
|
||||||
|
if (iframe?.contentWindow) sendState(iframe.contentWindow, state());
|
||||||
return;
|
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);
|
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 popState = () => {
|
||||||
const requested = new URLSearchParams(window.location.search).get("view");
|
const requested = new URLSearchParams(window.location.search).get("view");
|
||||||
navigate(isView(requested) ? requested : "overview", false);
|
navigate(isView(requested) ? requested : "overview", false);
|
||||||
@@ -319,10 +246,6 @@ export default function HostApp() {
|
|||||||
onMount(() => {
|
onMount(() => {
|
||||||
window.addEventListener("message", receive);
|
window.addEventListener("message", receive);
|
||||||
window.addEventListener("popstate", popState);
|
window.addEventListener("popstate", popState);
|
||||||
tabSync = createBrowserHostTabSync({
|
|
||||||
getState: globalState,
|
|
||||||
applyState: applySharedHostState,
|
|
||||||
});
|
|
||||||
void refresh();
|
void refresh();
|
||||||
const interval = window.setInterval(() => void refresh(), 5000);
|
const interval = window.setInterval(() => void refresh(), 5000);
|
||||||
onCleanup(() => window.clearInterval(interval));
|
onCleanup(() => window.clearInterval(interval));
|
||||||
@@ -331,30 +254,14 @@ export default function HostApp() {
|
|||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
window.removeEventListener("message", receive);
|
window.removeEventListener("message", receive);
|
||||||
window.removeEventListener("popstate", popState);
|
window.removeEventListener("popstate", popState);
|
||||||
tabSync?.close();
|
|
||||||
if (toastTimer !== undefined) window.clearTimeout(toastTimer);
|
if (toastTimer !== undefined) window.clearTimeout(toastTimer);
|
||||||
if (frameExpirationTimer !== undefined) window.clearTimeout(frameExpirationTimer);
|
|
||||||
for (const frameView of frameElements.keys()) sendFrameLifecycle(frameView, "dispose");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
state();
|
const next = state();
|
||||||
const ready = readyViews();
|
if (frameReady() && iframe?.contentWindow) sendState(iframe.contentWindow, next);
|
||||||
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) {
|
async function register(file: File) {
|
||||||
setDialogBusy(true);
|
setDialogBusy(true);
|
||||||
try {
|
try {
|
||||||
@@ -403,7 +310,7 @@ export default function HostApp() {
|
|||||||
<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]">
|
<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">
|
<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">
|
<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">
|
<span class="flex size-9 items-center justify-center rounded-md bg-[#e5f4ef] text-[#155a46]">
|
||||||
<Boxes size={19} />
|
<Boxes size={19} />
|
||||||
</span>
|
</span>
|
||||||
<div>
|
<div>
|
||||||
@@ -412,7 +319,7 @@ export default function HostApp() {
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<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}>
|
<For each={NAVIGATION}>
|
||||||
{(item) => {
|
{(item) => {
|
||||||
const Icon = item.icon;
|
const Icon = item.icon;
|
||||||
return (
|
return (
|
||||||
@@ -459,9 +366,8 @@ export default function HostApp() {
|
|||||||
class="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-muted"
|
class="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-muted"
|
||||||
size={15}
|
size={15}
|
||||||
/>
|
/>
|
||||||
<Input
|
<input
|
||||||
density="compact"
|
class="input h-9 pl-9"
|
||||||
class="pl-9"
|
|
||||||
value={query()}
|
value={query()}
|
||||||
aria-label="搜索"
|
aria-label="搜索"
|
||||||
placeholder="搜索服务、制品或事件"
|
placeholder="搜索服务、制品或事件"
|
||||||
@@ -471,34 +377,24 @@ export default function HostApp() {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section class="relative col-start-1 min-h-0 min-w-0 overflow-hidden lg:col-start-2">
|
<section class="relative col-start-1 min-h-0 min-w-0 overflow-hidden lg:col-start-2">
|
||||||
<For each={frames()}>
|
<Show keyed when={frameSource()}>
|
||||||
{(frame) => (
|
{(source) => (
|
||||||
<PageFrame
|
<iframe
|
||||||
frame={frame}
|
ref={(element) => {
|
||||||
active={view() === frame.view}
|
iframe = element;
|
||||||
title={pageDefinition(frame.view).label}
|
|
||||||
register={(frameView, element) => frameElements.set(frameView, element)}
|
|
||||||
unregister={(frameView, element) => {
|
|
||||||
if (frameElements.get(frameView) === element) frameElements.delete(frameView);
|
|
||||||
}}
|
}}
|
||||||
|
class="size-full border-0 bg-canvas"
|
||||||
|
src={source}
|
||||||
|
title={title()}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</For>
|
</Show>
|
||||||
<Show when={!frameReady()}>
|
<Show when={!frameReady()}>
|
||||||
<div class="pointer-events-none absolute inset-0 flex items-center justify-center bg-canvas">
|
<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 text-sm text-muted">
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span class="size-2 animate-pulse rounded-full bg-brand" />
|
<span class="size-2 animate-pulse rounded-full bg-brand" />
|
||||||
正在加载 {title()}
|
正在加载 {title()}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={connection() === "offline"}>
|
<Show when={connection() === "offline"}>
|
||||||
@@ -533,7 +429,7 @@ export default function HostApp() {
|
|||||||
|
|
||||||
<Show when={toast()}>
|
<Show when={toast()}>
|
||||||
{(message) => (
|
{(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">
|
<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()}
|
{message()}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -546,32 +442,3 @@ function initialView(): View {
|
|||||||
const requested = new URLSearchParams(window.location.search).get("view");
|
const requested = new URLSearchParams(window.location.search).get("view");
|
||||||
return isView(requested) ? requested : "overview";
|
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}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -12,10 +12,6 @@ import {
|
|||||||
X,
|
X,
|
||||||
} from "lucide-solid";
|
} from "lucide-solid";
|
||||||
import { createMemo, createSignal, onCleanup, onMount, Show, type JSXElement } from "solid-js";
|
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 type { InvokeResult } from "../lib/api";
|
||||||
import {
|
import {
|
||||||
formatInvocationOutput,
|
formatInvocationOutput,
|
||||||
@@ -92,38 +88,29 @@ function DialogFrame(props: {
|
|||||||
onCleanup(() => window.removeEventListener("keydown", onKeyDown));
|
onCleanup(() => window.removeEventListener("keydown", onKeyDown));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div class="dialog-backdrop" role="presentation">
|
||||||
class="fixed inset-0 z-50 flex items-center justify-center bg-[#0d1715]/55 p-3 backdrop-blur-[1px]"
|
<dialog open class="dialog" aria-label={props.title}>
|
||||||
role="presentation"
|
<header class="dialog-header">
|
||||||
>
|
<div class="dialog-title">
|
||||||
<dialog
|
<span>{props.icon}</span>
|
||||||
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>
|
<div>
|
||||||
<h2 class="text-lg font-bold sm:text-xl">{props.title}</h2>
|
<h2>{props.title}</h2>
|
||||||
<p class="mt-1 text-sm text-muted">{props.description}</p>
|
<p>{props.description}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<IconButton
|
<button
|
||||||
|
class="icon-btn"
|
||||||
|
type="button"
|
||||||
aria-label="关闭"
|
aria-label="关闭"
|
||||||
title="关闭"
|
title="关闭"
|
||||||
disabled={props.closeDisabled}
|
disabled={props.closeDisabled}
|
||||||
onClick={props.onClose}
|
onClick={props.onClose}
|
||||||
>
|
>
|
||||||
<X size={18} />
|
<X size={18} />
|
||||||
</IconButton>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
<div class="px-5 py-5 sm:px-7">{props.children}</div>
|
<div class="dialog-body">{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">
|
<footer class="dialog-footer">{props.footer}</footer>
|
||||||
{props.footer}
|
|
||||||
</footer>
|
|
||||||
</dialog>
|
</dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -172,19 +159,28 @@ function UploadDialog(props: {
|
|||||||
onClose={props.onClose}
|
onClose={props.onClose}
|
||||||
footer={
|
footer={
|
||||||
<>
|
<>
|
||||||
<Button disabled={props.busy} onClick={props.onClose}>
|
<button
|
||||||
|
class="btn btn-secondary"
|
||||||
|
type="button"
|
||||||
|
disabled={props.busy}
|
||||||
|
onClick={props.onClose}
|
||||||
|
>
|
||||||
取消
|
取消
|
||||||
</Button>
|
</button>
|
||||||
<Button variant="primary" disabled={props.busy || !file()} onClick={() => void submit()}>
|
<button
|
||||||
{props.busy ? <RefreshCw class="animate-spin" size={16} /> : <ShieldCheck size={16} />}
|
class="btn btn-primary"
|
||||||
|
type="button"
|
||||||
|
disabled={props.busy || !file()}
|
||||||
|
onClick={() => void submit()}
|
||||||
|
>
|
||||||
|
{props.busy ? <RefreshCw class="spin" size={16} /> : <ShieldCheck size={16} />}
|
||||||
{props.busy ? "正在校验" : isWit() ? "校验并发布" : "校验并注册"}
|
{props.busy ? "正在校验" : isWit() ? "校验并发布" : "校验并注册"}
|
||||||
</Button>
|
</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">
|
<label class="upload-zone">
|
||||||
<input
|
<input
|
||||||
class="sr-only"
|
|
||||||
type="file"
|
type="file"
|
||||||
accept={isWit() ? ".wasm,application/wasm" : ".wasmpkg,application/zip"}
|
accept={isWit() ? ".wasm,application/wasm" : ".wasmpkg,application/zip"}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
@@ -201,19 +197,15 @@ function UploadDialog(props: {
|
|||||||
) : (
|
) : (
|
||||||
<CloudUpload size={28} />
|
<CloudUpload size={28} />
|
||||||
)}
|
)}
|
||||||
<strong class="text-sm text-ink">
|
<strong>{file()?.name ?? `选择 ${isWit() ? "WIT Package" : "组件包"}`}</strong>
|
||||||
{file()?.name ?? `选择 ${isWit() ? "WIT Package" : "组件包"}`}
|
<span>
|
||||||
</strong>
|
|
||||||
<span class="text-xs">
|
|
||||||
{file()
|
{file()
|
||||||
? `${(file()!.size / 1024).toFixed(1)} KB · 等待校验`
|
? `${(file()!.size / 1024).toFixed(1)} KB · 等待校验`
|
||||||
: `${isWit() ? ".wasm · 最大 4 MB" : ".wasmpkg · 最大 64 MB"}`}
|
: `${isWit() ? ".wasm · 最大 4 MB" : ".wasmpkg · 最大 64 MB"}`}
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<Show when={error()}>
|
<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">
|
<div class="form-error">{error()}</div>
|
||||||
{error()}
|
|
||||||
</div>
|
|
||||||
</Show>
|
</Show>
|
||||||
</DialogFrame>
|
</DialogFrame>
|
||||||
);
|
);
|
||||||
@@ -234,33 +226,29 @@ function ActivateDialog(props: {
|
|||||||
onClose={props.onClose}
|
onClose={props.onClose}
|
||||||
footer={
|
footer={
|
||||||
<>
|
<>
|
||||||
<Button disabled={props.busy} onClick={props.onClose}>
|
<button class="btn btn-secondary" disabled={props.busy} onClick={props.onClose}>
|
||||||
取消
|
取消
|
||||||
</Button>
|
</button>
|
||||||
<Button
|
<button
|
||||||
variant="primary"
|
class="btn btn-primary"
|
||||||
disabled={props.busy}
|
disabled={props.busy}
|
||||||
onClick={() => void props.onActivate(props.dialog.service)}
|
onClick={() => void props.onActivate(props.dialog.service)}
|
||||||
>
|
>
|
||||||
{props.busy ? <RefreshCw class="animate-spin" size={16} /> : <RadioTower size={16} />}
|
{props.busy ? <RefreshCw class="spin" size={16} /> : <RadioTower size={16} />}
|
||||||
{props.busy ? "正在切换" : "确认切换"}
|
{props.busy ? "正在切换" : "确认切换"}
|
||||||
</Button>
|
</button>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div class="grid grid-cols-[1fr_auto_1fr] items-center gap-3 rounded-md border border-line bg-canvas p-4">
|
<div class="grid grid-cols-[1fr_auto_1fr] items-center gap-3 rounded-md border border-line bg-canvas p-4">
|
||||||
<div>
|
<div>
|
||||||
<span class="text-xs text-muted">当前版本</span>
|
<span class="text-xs text-muted">当前版本</span>
|
||||||
<strong class="mt-1 block font-mono text-xs">
|
<strong class="code mt-1 block">{props.dialog.currentRevision ?? "尚未部署"}</strong>
|
||||||
{props.dialog.currentRevision ?? "尚未部署"}
|
|
||||||
</strong>
|
|
||||||
</div>
|
</div>
|
||||||
<ArrowRight size={18} class="text-muted" />
|
<ArrowRight size={18} class="text-muted" />
|
||||||
<div>
|
<div>
|
||||||
<span class="text-xs text-muted">目标版本</span>
|
<span class="text-xs text-muted">目标版本</span>
|
||||||
<strong class="mt-1 block font-mono text-xs text-brand">
|
<strong class="code mt-1 block text-brand">{props.dialog.service.revision}</strong>
|
||||||
{props.dialog.service.revision}
|
|
||||||
</strong>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-4 text-sm leading-6 text-muted">
|
<p class="mt-4 text-sm leading-6 text-muted">
|
||||||
@@ -313,36 +301,37 @@ function InvokeDialog(props: {
|
|||||||
onClose={props.onClose}
|
onClose={props.onClose}
|
||||||
footer={
|
footer={
|
||||||
<>
|
<>
|
||||||
<Button disabled={running()} onClick={props.onClose}>
|
<button class="btn btn-secondary" disabled={running()} onClick={props.onClose}>
|
||||||
关闭
|
关闭
|
||||||
</Button>
|
</button>
|
||||||
<Button variant="primary" disabled={running()} onClick={() => void invoke()}>
|
<button class="btn btn-primary" disabled={running()} onClick={() => void invoke()}>
|
||||||
{running() ? <RefreshCw class="animate-spin" size={16} /> : <Play size={16} />}
|
{running() ? <RefreshCw class="spin" size={16} /> : <Play size={16} />}
|
||||||
执行调用
|
执行调用
|
||||||
</Button>
|
</button>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div class="mb-3 flex items-center justify-between gap-3">
|
<div class="mb-3 flex items-center justify-between gap-3">
|
||||||
<SegmentedControl aria-label="输入格式">
|
<div class="segmented">
|
||||||
<Segment active={format() === "utf8"} onClick={() => setFormat("utf8")}>
|
<button classList={{ active: format() === "utf8" }} onClick={() => setFormat("utf8")}>
|
||||||
UTF-8
|
UTF-8
|
||||||
</Segment>
|
</button>
|
||||||
<Segment active={format() === "hex"} onClick={() => setFormat("hex")}>
|
<button classList={{ active: format() === "hex" }} onClick={() => setFormat("hex")}>
|
||||||
HEX
|
HEX
|
||||||
</Segment>
|
</button>
|
||||||
</SegmentedControl>
|
|
||||||
<span class="font-mono text-xs text-muted">{inputBytes()} B</span>
|
|
||||||
</div>
|
</div>
|
||||||
<Field>
|
<span class="code text-muted">{inputBytes()} B</span>
|
||||||
<FieldLabel>输入</FieldLabel>
|
</div>
|
||||||
<Textarea
|
<label class="field">
|
||||||
|
<span>输入</span>
|
||||||
|
<textarea
|
||||||
|
class="textarea"
|
||||||
value={input()}
|
value={input()}
|
||||||
spellcheck={false}
|
spellcheck={false}
|
||||||
placeholder={format() === "utf8" ? "输入请求内容" : "00 ff a1"}
|
placeholder={format() === "utf8" ? "输入请求内容" : "00 ff a1"}
|
||||||
onInput={(event) => setInput(event.currentTarget.value)}
|
onInput={(event) => setInput(event.currentTarget.value)}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</label>
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
<div class="mb-1.5 flex items-center justify-between">
|
<div class="mb-1.5 flex items-center justify-between">
|
||||||
<span class="text-xs font-semibold text-muted">输出</span>
|
<span class="text-xs font-semibold text-muted">输出</span>
|
||||||
|
|||||||
@@ -1,163 +0,0 @@
|
|||||||
import type { View } from "../pages/registry";
|
|
||||||
|
|
||||||
export type FrameEntry = {
|
|
||||||
view: View;
|
|
||||||
instance: number;
|
|
||||||
keepAlive: boolean;
|
|
||||||
/**
|
|
||||||
* Time at which this document became hidden, using the cache clock.
|
|
||||||
*
|
|
||||||
* Active and transient documents use `null`. Measuring from deactivation
|
|
||||||
* prevents a page that has been visible for a long time from expiring as
|
|
||||||
* soon as the user navigates away.
|
|
||||||
*/
|
|
||||||
inactiveSince: number | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type FrameCacheOptions = {
|
|
||||||
/**
|
|
||||||
* How long a keep-alive document may remain continuously hidden.
|
|
||||||
*/
|
|
||||||
inactiveTtlMs: number;
|
|
||||||
shouldKeepAlive: (view: View) => boolean;
|
|
||||||
/** Injectable monotonic clock for deterministic tests. */
|
|
||||||
now?: () => number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type FrameTransition = {
|
|
||||||
entries: FrameEntry[];
|
|
||||||
removed: FrameEntry[];
|
|
||||||
activated: FrameEntry;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type FrameExpiration = Omit<FrameTransition, "activated">;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Owns iframe document identities and expires retained, hidden documents.
|
|
||||||
*
|
|
||||||
* Entries retain object identity between snapshots so Solid's `<For>` keeps
|
|
||||||
* the corresponding iframe DOM node alive. `instance` is never reused, which
|
|
||||||
* makes a recreated document observable in its URL and prevents stale browser
|
|
||||||
* history or service-worker responses from masquerading as a retained frame.
|
|
||||||
*
|
|
||||||
* Primary pages may all opt into retention because their set is finite.
|
|
||||||
* Transient/detail pages opt out and are removed immediately after navigation.
|
|
||||||
* Retained pages are released only after a continuous inactive interval, not
|
|
||||||
* because another primary page happened to be opened.
|
|
||||||
*/
|
|
||||||
export class FrameCache {
|
|
||||||
readonly #options: FrameCacheOptions;
|
|
||||||
readonly #entries = new Map<View, FrameEntry>();
|
|
||||||
#active: View;
|
|
||||||
#nextInstance = 1;
|
|
||||||
|
|
||||||
constructor(initialView: View, options: FrameCacheOptions) {
|
|
||||||
if (!Number.isFinite(options.inactiveTtlMs) || options.inactiveTtlMs <= 0) {
|
|
||||||
throw new RangeError("inactiveTtlMs must be a positive finite number");
|
|
||||||
}
|
|
||||||
this.#options = options;
|
|
||||||
this.#active = initialView;
|
|
||||||
this.#entries.set(initialView, this.#create(initialView));
|
|
||||||
}
|
|
||||||
|
|
||||||
get active(): View {
|
|
||||||
return this.#active;
|
|
||||||
}
|
|
||||||
|
|
||||||
snapshot(): FrameEntry[] {
|
|
||||||
return [...this.#entries.values()];
|
|
||||||
}
|
|
||||||
|
|
||||||
activate(view: View): FrameTransition {
|
|
||||||
const removed: FrameEntry[] = [];
|
|
||||||
const previous = this.#entries.get(this.#active);
|
|
||||||
const now = this.#now();
|
|
||||||
|
|
||||||
if (previous && previous.view !== view) {
|
|
||||||
if (previous.keepAlive) {
|
|
||||||
previous.inactiveSince = now;
|
|
||||||
} else {
|
|
||||||
this.#entries.delete(previous.view);
|
|
||||||
removed.push(previous);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prune before activation so revisiting an already-expired page creates a
|
|
||||||
// fresh iframe instead of reviving stale document state.
|
|
||||||
removed.push(...this.#removeExpired(now));
|
|
||||||
|
|
||||||
let activated = this.#entries.get(view);
|
|
||||||
if (!activated) {
|
|
||||||
activated = this.#create(view);
|
|
||||||
this.#entries.set(view, activated);
|
|
||||||
}
|
|
||||||
activated.inactiveSince = null;
|
|
||||||
this.#active = view;
|
|
||||||
|
|
||||||
return {
|
|
||||||
entries: this.snapshot(),
|
|
||||||
removed,
|
|
||||||
activated,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Removes retained documents whose continuous hidden interval has elapsed.
|
|
||||||
*
|
|
||||||
* Host calls this from a timer and sends `dispose` before applying the
|
|
||||||
* returned snapshot to the DOM.
|
|
||||||
*/
|
|
||||||
expireInactive(): FrameExpiration {
|
|
||||||
return {
|
|
||||||
removed: this.#removeExpired(this.#now()),
|
|
||||||
entries: this.snapshot(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the delay until the next hidden document expires.
|
|
||||||
*
|
|
||||||
* `null` means no retained page is currently hidden. Recomputing the delay
|
|
||||||
* after every navigation and expiration avoids one interval timer per page.
|
|
||||||
*/
|
|
||||||
timeUntilExpiration(): number | null {
|
|
||||||
const now = this.#now();
|
|
||||||
let delay: number | null = null;
|
|
||||||
for (const entry of this.#entries.values()) {
|
|
||||||
if (!entry.keepAlive || entry.inactiveSince === null) continue;
|
|
||||||
const remaining = Math.max(0, entry.inactiveSince + this.#options.inactiveTtlMs - now);
|
|
||||||
if (delay === null || remaining < delay) delay = remaining;
|
|
||||||
}
|
|
||||||
return delay;
|
|
||||||
}
|
|
||||||
|
|
||||||
#create(view: View): FrameEntry {
|
|
||||||
return {
|
|
||||||
view,
|
|
||||||
instance: this.#nextInstance++,
|
|
||||||
keepAlive: this.#options.shouldKeepAlive(view),
|
|
||||||
inactiveSince: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
#removeExpired(now: number): FrameEntry[] {
|
|
||||||
const removed: FrameEntry[] = [];
|
|
||||||
for (const entry of this.#entries.values()) {
|
|
||||||
if (
|
|
||||||
entry.view === this.#active ||
|
|
||||||
!entry.keepAlive ||
|
|
||||||
entry.inactiveSince === null ||
|
|
||||||
now - entry.inactiveSince < this.#options.inactiveTtlMs
|
|
||||||
) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
this.#entries.delete(entry.view);
|
|
||||||
removed.push(entry);
|
|
||||||
}
|
|
||||||
return removed;
|
|
||||||
}
|
|
||||||
|
|
||||||
#now(): number {
|
|
||||||
return this.#options.now?.() ?? performance.now();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,152 +0,0 @@
|
|||||||
import type { HostGlobalState, RuntimeAction } from "../sdk/protocol";
|
|
||||||
|
|
||||||
const HOST_TAB_CHANNEL = "wasmeld-console:host-state";
|
|
||||||
const HOST_TAB_PROTOCOL_VERSION = 1;
|
|
||||||
|
|
||||||
type HostTabMessage =
|
|
||||||
| {
|
|
||||||
channel: typeof HOST_TAB_CHANNEL;
|
|
||||||
version: typeof HOST_TAB_PROTOCOL_VERSION;
|
|
||||||
type: "hello";
|
|
||||||
sender: string;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
channel: typeof HOST_TAB_CHANNEL;
|
|
||||||
version: typeof HOST_TAB_PROTOCOL_VERSION;
|
|
||||||
type: "state";
|
|
||||||
sender: string;
|
|
||||||
sequence: number;
|
|
||||||
state: HostGlobalState;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type TabChannel = {
|
|
||||||
postMessage: (message: unknown) => void;
|
|
||||||
addEventListener: (type: "message", listener: (event: MessageEvent<unknown>) => void) => void;
|
|
||||||
removeEventListener: (type: "message", listener: (event: MessageEvent<unknown>) => void) => void;
|
|
||||||
close: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type HostTabSyncOptions = {
|
|
||||||
getState: () => HostGlobalState;
|
|
||||||
applyState: (state: HostGlobalState) => void;
|
|
||||||
channel?: TabChannel;
|
|
||||||
tabId?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Replicates Host-owned control-plane state between same-origin browser tabs.
|
|
||||||
*
|
|
||||||
* Every tab may publish; there is deliberately no leader election. Sequence
|
|
||||||
* numbers are scoped to a sender and suppress stale delivery, while consumers
|
|
||||||
* suppress their own Solid broadcast effect when applying a remote snapshot.
|
|
||||||
* Page-local state never enters this channel.
|
|
||||||
*/
|
|
||||||
export class HostTabSync {
|
|
||||||
readonly #channel: TabChannel;
|
|
||||||
readonly #tabId: string;
|
|
||||||
readonly #getState: () => HostGlobalState;
|
|
||||||
readonly #applyState: (state: HostGlobalState) => void;
|
|
||||||
readonly #latestSequence = new Map<string, number>();
|
|
||||||
#sequence = 0;
|
|
||||||
#closed = false;
|
|
||||||
|
|
||||||
constructor(options: HostTabSyncOptions) {
|
|
||||||
this.#channel = options.channel ?? new BroadcastChannel(HOST_TAB_CHANNEL);
|
|
||||||
this.#tabId = options.tabId ?? crypto.randomUUID();
|
|
||||||
this.#getState = options.getState;
|
|
||||||
this.#applyState = options.applyState;
|
|
||||||
this.#channel.addEventListener("message", this.#receive);
|
|
||||||
this.#send({
|
|
||||||
channel: HOST_TAB_CHANNEL,
|
|
||||||
version: HOST_TAB_PROTOCOL_VERSION,
|
|
||||||
type: "hello",
|
|
||||||
sender: this.#tabId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
publish(state: HostGlobalState): void {
|
|
||||||
this.#send({
|
|
||||||
channel: HOST_TAB_CHANNEL,
|
|
||||||
version: HOST_TAB_PROTOCOL_VERSION,
|
|
||||||
type: "state",
|
|
||||||
sender: this.#tabId,
|
|
||||||
sequence: ++this.#sequence,
|
|
||||||
state,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
close(): void {
|
|
||||||
if (this.#closed) return;
|
|
||||||
this.#closed = true;
|
|
||||||
this.#channel.removeEventListener("message", this.#receive);
|
|
||||||
this.#channel.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
readonly #receive = (event: MessageEvent<unknown>) => {
|
|
||||||
const message = event.data;
|
|
||||||
if (!isHostTabMessage(message) || message.sender === this.#tabId) return;
|
|
||||||
|
|
||||||
if (message.type === "hello") {
|
|
||||||
this.publish(this.#getState());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const latest = this.#latestSequence.get(message.sender) ?? 0;
|
|
||||||
if (message.sequence <= latest) return;
|
|
||||||
this.#latestSequence.set(message.sender, message.sequence);
|
|
||||||
this.#applyState(message.state);
|
|
||||||
};
|
|
||||||
|
|
||||||
#send(message: HostTabMessage): void {
|
|
||||||
if (!this.#closed) this.#channel.postMessage(message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createBrowserHostTabSync(
|
|
||||||
options: Omit<HostTabSyncOptions, "channel">,
|
|
||||||
): HostTabSync | null {
|
|
||||||
if (typeof BroadcastChannel === "undefined") return null;
|
|
||||||
return new HostTabSync(options);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isHostTabMessage(value: unknown): value is HostTabMessage {
|
|
||||||
if (!isRecord(value)) return false;
|
|
||||||
if (
|
|
||||||
value.channel !== HOST_TAB_CHANNEL ||
|
|
||||||
value.version !== HOST_TAB_PROTOCOL_VERSION ||
|
|
||||||
typeof value.sender !== "string"
|
|
||||||
) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (value.type === "hello") return true;
|
|
||||||
return (
|
|
||||||
value.type === "state" &&
|
|
||||||
Number.isSafeInteger(value.sequence) &&
|
|
||||||
(value.sequence as number) > 0 &&
|
|
||||||
isHostGlobalState(value.state)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isHostGlobalState(value: unknown): value is HostGlobalState {
|
|
||||||
if (!isRecord(value)) return false;
|
|
||||||
return (
|
|
||||||
(value.snapshot === null || isRecord(value.snapshot)) &&
|
|
||||||
isConnection(value.connection) &&
|
|
||||||
typeof value.apiBase === "string" &&
|
|
||||||
(value.runtimeAction === null || isRuntimeAction(value.runtimeAction)) &&
|
|
||||||
(value.serviceAction === null || typeof value.serviceAction === "string") &&
|
|
||||||
(value.deploymentAction === null || typeof value.deploymentAction === "string")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isConnection(value: unknown): value is HostGlobalState["connection"] {
|
|
||||||
return value === "connecting" || value === "online" || value === "offline";
|
|
||||||
}
|
|
||||||
|
|
||||||
function isRuntimeAction(value: unknown): value is RuntimeAction {
|
|
||||||
return value === "start" || value === "stop" || value === "restart";
|
|
||||||
}
|
|
||||||
|
|
||||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
||||||
return typeof value === "object" && value !== null;
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import { clsx, type ClassValue } from "clsx";
|
|
||||||
import { twMerge } from "tailwind-merge";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Merges conditional Tailwind classes using the same pattern as shadcn.
|
|
||||||
*
|
|
||||||
* `clsx` evaluates conditional input while `tailwind-merge` lets a caller's
|
|
||||||
* class override a component default from the same Tailwind class group:
|
|
||||||
*
|
|
||||||
* ```ts
|
|
||||||
* cn("h-10 px-4", compact && "h-8", props.class)
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export function cn(...inputs: ClassValue[]): string {
|
|
||||||
return twMerge(clsx(inputs));
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,15 @@
|
|||||||
import type { BackendEvent, BackendService } from "./api";
|
import type { BackendEvent, BackendService } from "./api";
|
||||||
|
|
||||||
|
export const VIEWS = [
|
||||||
|
"overview",
|
||||||
|
"services",
|
||||||
|
"instances",
|
||||||
|
"wit-packages",
|
||||||
|
"activity",
|
||||||
|
"settings",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type View = (typeof VIEWS)[number];
|
||||||
export type ConnectionState = "connecting" | "online" | "offline";
|
export type ConnectionState = "connecting" | "online" | "offline";
|
||||||
export type ServiceStatus = "running" | "stopped" | "faulted";
|
export type ServiceStatus = "running" | "stopped" | "faulted";
|
||||||
export type EventTone = "success" | "warning" | "danger" | "neutral";
|
export type EventTone = "success" | "warning" | "danger" | "neutral";
|
||||||
@@ -37,12 +47,16 @@ export type FormattedInvocationOutput = {
|
|||||||
automatic: boolean;
|
automatic: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const STATUS_META: Record<ServiceStatus, { label: string }> = {
|
export const STATUS_META: Record<ServiceStatus, { label: string; className: string }> = {
|
||||||
running: { label: "运行中" },
|
running: { label: "运行中", className: "status-running" },
|
||||||
stopped: { label: "已停止" },
|
stopped: { label: "已停止", className: "status-stopped" },
|
||||||
faulted: { label: "故障" },
|
faulted: { label: "故障", className: "status-faulted" },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function isView(value: string | null): value is View {
|
||||||
|
return VIEWS.includes(value as View);
|
||||||
|
}
|
||||||
|
|
||||||
export function serviceKey(service: Pick<Service, "id" | "revision">): string {
|
export function serviceKey(service: Pick<Service, "id" | "revision">): string {
|
||||||
return `${service.id}@${service.revision}`;
|
return `${service.id}@${service.revision}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
import { History, RefreshCw } from "lucide-solid";
|
import { History, RefreshCw } from "lucide-solid";
|
||||||
import { createMemo, For, onCleanup, Show } from "solid-js";
|
import { createMemo, For, Show } from "solid-js";
|
||||||
import { EmptyState } from "../../components/feedback/empty-state";
|
import { EmptyState } from "../../components/ui/empty-state";
|
||||||
import { Page } from "../../components/layout/page";
|
import { PageHeading } from "../../components/ui/page-heading";
|
||||||
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 { eventsFrom } from "../../lib/view-state";
|
||||||
import type { PageProps } from "../types";
|
import type { PageProps } from "../types";
|
||||||
|
|
||||||
@@ -16,11 +13,6 @@ const toneClass = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function ActivityPage(props: PageProps) {
|
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 events = createMemo(() => {
|
||||||
const query = props.state()?.query.trim().toLowerCase() ?? "";
|
const query = props.state()?.query.trim().toLowerCase() ?? "";
|
||||||
return eventsFrom(props.state()).filter(
|
return eventsFrom(props.state()).filter(
|
||||||
@@ -32,22 +24,18 @@ export default function ActivityPage(props: PageProps) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page>
|
<main class="page">
|
||||||
<PageHeading
|
<PageHeading
|
||||||
eyebrow="EVENTS"
|
eyebrow="EVENTS"
|
||||||
title="调用记录"
|
title="调用记录"
|
||||||
description="最近 256 条持久化生命周期与调用事件。"
|
description="最近 256 条持久化生命周期与调用事件。"
|
||||||
actions={
|
actions={
|
||||||
<IconButton
|
<button class="icon-btn" title="刷新" onClick={() => props.command({ type: "refresh" })}>
|
||||||
aria-label="刷新"
|
|
||||||
title="刷新"
|
|
||||||
onClick={() => props.command({ type: "refresh" })}
|
|
||||||
>
|
|
||||||
<RefreshCw size={16} />
|
<RefreshCw size={16} />
|
||||||
</IconButton>
|
</button>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Card as="section">
|
<section class="panel">
|
||||||
<Show
|
<Show
|
||||||
when={events().length > 0}
|
when={events().length > 0}
|
||||||
fallback={
|
fallback={
|
||||||
@@ -67,7 +55,7 @@ export default function ActivityPage(props: PageProps) {
|
|||||||
</For>
|
</For>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</Card>
|
</section>
|
||||||
</Page>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
import { Cpu, RefreshCw, Server, SquareTerminal } from "lucide-solid";
|
import { Cpu, RefreshCw, Server, SquareTerminal } from "lucide-solid";
|
||||||
import { createMemo, For, Show } from "solid-js";
|
import { createMemo, For, Show } from "solid-js";
|
||||||
import { EmptyState } from "../../components/feedback/empty-state";
|
import { EmptyState } from "../../components/ui/empty-state";
|
||||||
import { Page } from "../../components/layout/page";
|
import { PageHeading } from "../../components/ui/page-heading";
|
||||||
import { PageHeading } from "../../components/layout/page-heading";
|
import { StatusBadge } from "../../components/ui/status-badge";
|
||||||
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 { serviceKey } from "../../lib/model";
|
||||||
import { servicesFrom } from "../../lib/view-state";
|
import { servicesFrom } from "../../lib/view-state";
|
||||||
import type { PageProps } from "../types";
|
import type { PageProps } from "../types";
|
||||||
@@ -17,33 +14,29 @@ export default function InstancesPage(props: PageProps) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page>
|
<main class="page">
|
||||||
<PageHeading
|
<PageHeading
|
||||||
eyebrow="ACTORS"
|
eyebrow="ACTORS"
|
||||||
title="运行实例"
|
title="运行实例"
|
||||||
description={`${running()} 个 Actor 保持独立 Store 与串行 mailbox。`}
|
description={`${running()} 个 Actor 保持独立 Store 与串行 mailbox。`}
|
||||||
actions={
|
actions={
|
||||||
<IconButton
|
<button class="icon-btn" title="刷新" onClick={() => props.command({ type: "refresh" })}>
|
||||||
aria-label="刷新"
|
|
||||||
title="刷新"
|
|
||||||
onClick={() => props.command({ type: "refresh" })}
|
|
||||||
>
|
|
||||||
<RefreshCw size={16} />
|
<RefreshCw size={16} />
|
||||||
</IconButton>
|
</button>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Show
|
<Show
|
||||||
when={services().length > 0}
|
when={services().length > 0}
|
||||||
fallback={
|
fallback={
|
||||||
<Card>
|
<div class="panel">
|
||||||
<EmptyState icon={Server} title="暂无实例" detail="先注册一个组件包。" />
|
<EmptyState icon={Server} title="暂无实例" detail="先注册一个组件包。" />
|
||||||
</Card>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<section class="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
<section class="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||||
<For each={services()}>
|
<For each={services()}>
|
||||||
{(service) => (
|
{(service) => (
|
||||||
<Card as="article" padding="medium">
|
<article class="panel p-5">
|
||||||
<div class="flex items-start justify-between gap-3">
|
<div class="flex items-start justify-between gap-3">
|
||||||
<div class="flex min-w-0 items-center 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">
|
<span class="flex size-10 shrink-0 items-center justify-center rounded-md bg-cyan-soft text-cyan-strong">
|
||||||
@@ -51,9 +44,7 @@ export default function InstancesPage(props: PageProps) {
|
|||||||
</span>
|
</span>
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<strong class="block truncate">{service.id}</strong>
|
<strong class="block truncate">{service.id}</strong>
|
||||||
<span class="mt-1 block truncate font-mono text-xs text-muted">
|
<span class="code mt-1 block truncate text-muted">{service.revision}</span>
|
||||||
{service.revision}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<StatusBadge status={service.status} />
|
<StatusBadge status={service.status} />
|
||||||
@@ -80,8 +71,8 @@ export default function InstancesPage(props: PageProps) {
|
|||||||
</dl>
|
</dl>
|
||||||
<div class="mt-5 flex justify-end gap-2">
|
<div class="mt-5 flex justify-end gap-2">
|
||||||
<Show when={service.status === "running"}>
|
<Show when={service.status === "running"}>
|
||||||
<Button
|
<button
|
||||||
size="small"
|
class="btn btn-secondary h-9"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
props.command({
|
props.command({
|
||||||
type: "open-invoke",
|
type: "open-invoke",
|
||||||
@@ -91,10 +82,10 @@ export default function InstancesPage(props: PageProps) {
|
|||||||
>
|
>
|
||||||
<SquareTerminal size={15} />
|
<SquareTerminal size={15} />
|
||||||
调用
|
调用
|
||||||
</Button>
|
</button>
|
||||||
</Show>
|
</Show>
|
||||||
<Button
|
<button
|
||||||
size="small"
|
class="btn btn-secondary h-9"
|
||||||
disabled={props.state()?.serviceAction !== null}
|
disabled={props.state()?.serviceAction !== null}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
props.command({
|
props.command({
|
||||||
@@ -105,13 +96,13 @@ export default function InstancesPage(props: PageProps) {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
{service.status === "running" ? "停止" : "启动"}
|
{service.status === "running" ? "停止" : "启动"}
|
||||||
</Button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</article>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
</section>
|
</section>
|
||||||
</Show>
|
</Show>
|
||||||
</Page>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,21 @@
|
|||||||
import { AlertTriangle, LoaderCircle } from "lucide-solid";
|
import { AlertTriangle, LoaderCircle } from "lucide-solid";
|
||||||
import { lazy, Show, Suspense } from "solid-js";
|
import { lazy, Show, Suspense, type Component } from "solid-js";
|
||||||
import { render } from "solid-js/web";
|
import { render } from "solid-js/web";
|
||||||
|
import { isView, type View } from "../lib/model";
|
||||||
import { createFrameBridge } from "../primitives/create-frame-bridge";
|
import { createFrameBridge } from "../primitives/create-frame-bridge";
|
||||||
import "../styles/app.css";
|
import "../styles/app.css";
|
||||||
import { isView, pageDefinition } from "./registry";
|
import type { PageProps } from "./types";
|
||||||
|
|
||||||
|
// Each view is a dynamic import so a freshly-created iframe evaluates only the
|
||||||
|
// page it owns. Destroying that iframe releases the chunk's runtime objects.
|
||||||
|
const pages: Record<View, Component<PageProps>> = {
|
||||||
|
overview: lazy(() => import("./overview")),
|
||||||
|
services: lazy(() => import("./services")),
|
||||||
|
instances: lazy(() => import("./instances")),
|
||||||
|
"wit-packages": lazy(() => import("./wit-packages")),
|
||||||
|
activity: lazy(() => import("./activity")),
|
||||||
|
settings: lazy(() => import("./settings")),
|
||||||
|
};
|
||||||
|
|
||||||
function FramePage() {
|
function FramePage() {
|
||||||
const requested = new URLSearchParams(window.location.search).get("view");
|
const requested = new URLSearchParams(window.location.search).get("view");
|
||||||
@@ -24,23 +36,17 @@ function FramePage() {
|
|||||||
>
|
>
|
||||||
{(activeView) => {
|
{(activeView) => {
|
||||||
const bridge = createFrameBridge(activeView());
|
const bridge = createFrameBridge(activeView());
|
||||||
const definition = pageDefinition(activeView());
|
const Page = pages[activeView()];
|
||||||
const Page = lazy(definition.load);
|
document.title = `${activeView()} · Wasmeld`;
|
||||||
document.title = `${definition.label} · Wasmeld`;
|
|
||||||
return (
|
return (
|
||||||
<Suspense
|
<Suspense
|
||||||
fallback={
|
fallback={
|
||||||
<main class="flex min-h-dvh items-center justify-center text-muted">
|
<main class="flex min-h-dvh items-center justify-center text-muted">
|
||||||
<LoaderCircle class="animate-spin" size={24} />
|
<LoaderCircle class="spin" size={24} />
|
||||||
</main>
|
</main>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Page
|
<Page state={bridge.state} command={bridge.command} />
|
||||||
state={bridge.state}
|
|
||||||
active={bridge.active}
|
|
||||||
lifecycle={bridge.lifecycle}
|
|
||||||
command={bridge.command}
|
|
||||||
/>
|
|
||||||
</Suspense>
|
</Suspense>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -11,11 +11,8 @@ import {
|
|||||||
Zap,
|
Zap,
|
||||||
} from "lucide-solid";
|
} from "lucide-solid";
|
||||||
import { createMemo, For, Show, type JSXElement } from "solid-js";
|
import { createMemo, For, Show, type JSXElement } from "solid-js";
|
||||||
import { Page } from "../../components/layout/page";
|
import { PageHeading } from "../../components/ui/page-heading";
|
||||||
import { PageHeading } from "../../components/layout/page-heading";
|
import { ServiceTable } from "../../components/ui/service-table";
|
||||||
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 { eventsFrom, servicesFrom } from "../../lib/view-state";
|
||||||
import { formatDuration } from "../../lib/model";
|
import { formatDuration } from "../../lib/model";
|
||||||
import type { PageProps } from "../types";
|
import type { PageProps } from "../types";
|
||||||
@@ -32,7 +29,7 @@ export default function OverviewPage(props: PageProps) {
|
|||||||
const errors = createMemo(() => services().reduce((total, service) => total + service.errors, 0));
|
const errors = createMemo(() => services().reduce((total, service) => total + service.errors, 0));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page>
|
<main class="page">
|
||||||
<Show when={props.state()} fallback={<OverviewSkeleton />}>
|
<Show when={props.state()} fallback={<OverviewSkeleton />}>
|
||||||
{(state) => (
|
{(state) => (
|
||||||
<>
|
<>
|
||||||
@@ -42,28 +39,28 @@ export default function OverviewPage(props: PageProps) {
|
|||||||
description={`${services().length} 个已注册版本,${running()} 个 Actor 正在运行。`}
|
description={`${services().length} 个已注册版本,${running()} 个 Actor 正在运行。`}
|
||||||
actions={
|
actions={
|
||||||
<>
|
<>
|
||||||
<IconButton
|
<button
|
||||||
|
class="icon-btn"
|
||||||
|
type="button"
|
||||||
aria-label="刷新"
|
aria-label="刷新"
|
||||||
title="刷新"
|
title="刷新"
|
||||||
onClick={() => props.command({ type: "refresh" })}
|
onClick={() => props.command({ type: "refresh" })}
|
||||||
>
|
>
|
||||||
<RefreshCw size={16} />
|
<RefreshCw size={16} />
|
||||||
</IconButton>
|
</button>
|
||||||
<Button
|
<button
|
||||||
variant="primary"
|
class="btn btn-primary"
|
||||||
|
type="button"
|
||||||
onClick={() => props.command({ type: "open-register" })}
|
onClick={() => props.command({ type: "open-register" })}
|
||||||
>
|
>
|
||||||
<CloudUpload size={16} />
|
<CloudUpload size={16} />
|
||||||
注册组件
|
注册组件
|
||||||
</Button>
|
</button>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<section
|
<section class="metric-grid" aria-label="关键指标">
|
||||||
class="mb-5 grid grid-cols-1 border-l border-t border-line sm:grid-cols-2 xl:grid-cols-4"
|
|
||||||
aria-label="关键指标"
|
|
||||||
>
|
|
||||||
<Metric
|
<Metric
|
||||||
icon={<Package size={18} />}
|
icon={<Package size={18} />}
|
||||||
tone="bg-cyan-soft text-cyan-strong"
|
tone="bg-cyan-soft text-cyan-strong"
|
||||||
@@ -94,10 +91,7 @@ export default function OverviewPage(props: PageProps) {
|
|||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<Card
|
<section class="panel mb-5 grid lg:grid-cols-[minmax(280px,1.4fr)_repeat(3,minmax(130px,1fr))]">
|
||||||
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">
|
<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">
|
<span class="flex size-10 items-center justify-center rounded-md bg-brand-soft text-brand">
|
||||||
<Cpu size={18} />
|
<Cpu size={18} />
|
||||||
@@ -114,33 +108,32 @@ export default function OverviewPage(props: PageProps) {
|
|||||||
<Show
|
<Show
|
||||||
when={state().snapshot?.runtime.status === "running"}
|
when={state().snapshot?.runtime.status === "running"}
|
||||||
fallback={
|
fallback={
|
||||||
<IconButton
|
<button
|
||||||
aria-label="启动 Runtime"
|
class="icon-btn"
|
||||||
title="启动 Runtime"
|
title="启动 Runtime"
|
||||||
disabled={state().runtimeAction !== null}
|
disabled={state().runtimeAction !== null}
|
||||||
onClick={() => props.command({ type: "runtime-action", action: "start" })}
|
onClick={() => props.command({ type: "runtime-action", action: "start" })}
|
||||||
>
|
>
|
||||||
<Play size={15} />
|
<Play size={15} />
|
||||||
</IconButton>
|
</button>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<IconButton
|
<button
|
||||||
aria-label="重启 Runtime"
|
class="icon-btn"
|
||||||
title="重启 Runtime"
|
title="重启 Runtime"
|
||||||
disabled={state().runtimeAction !== null}
|
disabled={state().runtimeAction !== null}
|
||||||
onClick={() => props.command({ type: "runtime-action", action: "restart" })}
|
onClick={() => props.command({ type: "runtime-action", action: "restart" })}
|
||||||
>
|
>
|
||||||
<RotateCcw size={15} />
|
<RotateCcw size={15} />
|
||||||
</IconButton>
|
</button>
|
||||||
<IconButton
|
<button
|
||||||
tone="danger"
|
class="icon-btn text-coral-strong"
|
||||||
aria-label="停止 Runtime"
|
|
||||||
title="停止 Runtime"
|
title="停止 Runtime"
|
||||||
disabled={state().runtimeAction !== null}
|
disabled={state().runtimeAction !== null}
|
||||||
onClick={() => props.command({ type: "runtime-action", action: "stop" })}
|
onClick={() => props.command({ type: "runtime-action", action: "stop" })}
|
||||||
>
|
>
|
||||||
<CircleStop size={15} />
|
<CircleStop size={15} />
|
||||||
</IconButton>
|
</button>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -163,32 +156,32 @@ export default function OverviewPage(props: PageProps) {
|
|||||||
}
|
}
|
||||||
detail="当前 Runtime"
|
detail="当前 Runtime"
|
||||||
/>
|
/>
|
||||||
</Card>
|
</section>
|
||||||
|
|
||||||
<div class="grid gap-5 xl:grid-cols-[minmax(0,1.7fr)_minmax(300px,0.8fr)]">
|
<div class="grid gap-5 xl:grid-cols-[minmax(0,1.7fr)_minmax(300px,0.8fr)]">
|
||||||
<Card as="section" class="min-w-0">
|
<section class="panel min-w-0">
|
||||||
<CardHeader>
|
<div class="panel-header">
|
||||||
<div>
|
<div>
|
||||||
<h2>服务状态</h2>
|
<h2>服务状态</h2>
|
||||||
<p>已注册版本及当前 Actor 状态</p>
|
<p>已注册版本及当前 Actor 状态</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<button
|
||||||
size="small"
|
class="btn btn-secondary h-9"
|
||||||
onClick={() => props.command({ type: "navigate", view: "services" })}
|
onClick={() => props.command({ type: "navigate", view: "services" })}
|
||||||
>
|
>
|
||||||
查看全部
|
查看全部
|
||||||
</Button>
|
</button>
|
||||||
</CardHeader>
|
</div>
|
||||||
<ServiceTable
|
<ServiceTable
|
||||||
services={services().slice(0, 5)}
|
services={services().slice(0, 5)}
|
||||||
state={state()}
|
state={state()}
|
||||||
command={props.command}
|
command={props.command}
|
||||||
compact
|
compact
|
||||||
/>
|
/>
|
||||||
</Card>
|
</section>
|
||||||
|
|
||||||
<Card as="aside">
|
<aside class="panel">
|
||||||
<CardHeader>
|
<div class="panel-header">
|
||||||
<div>
|
<div>
|
||||||
<h2>最近事件</h2>
|
<h2>最近事件</h2>
|
||||||
<p>生命周期与调用结果</p>
|
<p>生命周期与调用结果</p>
|
||||||
@@ -197,7 +190,7 @@ export default function OverviewPage(props: PageProps) {
|
|||||||
<span class="size-1.5 rounded-full bg-brand" />
|
<span class="size-1.5 rounded-full bg-brand" />
|
||||||
LIVE
|
LIVE
|
||||||
</span>
|
</span>
|
||||||
</CardHeader>
|
</div>
|
||||||
<div class="divide-y divide-line">
|
<div class="divide-y divide-line">
|
||||||
<For
|
<For
|
||||||
each={events().slice(0, 6)}
|
each={events().slice(0, 6)}
|
||||||
@@ -214,12 +207,12 @@ export default function OverviewPage(props: PageProps) {
|
|||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
</Page>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,15 +224,13 @@ function Metric(props: {
|
|||||||
note: string;
|
note: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<article class="flex min-h-24 items-center gap-3 border-b border-r border-line bg-white px-4 py-3">
|
<article class="metric">
|
||||||
<div class={`flex size-10 shrink-0 items-center justify-center rounded-md ${props.tone}`}>
|
<div class={`metric-icon ${props.tone}`}>{props.icon}</div>
|
||||||
{props.icon}
|
<div class="metric-copy">
|
||||||
|
<span>{props.label}</span>
|
||||||
|
<strong>{props.value}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="min-w-0 flex-1">
|
<small>{props.note}</small>
|
||||||
<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>
|
</article>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -257,8 +248,8 @@ function RuntimeStat(props: { label: string; value: string; detail: string }) {
|
|||||||
function OverviewSkeleton() {
|
function OverviewSkeleton() {
|
||||||
return (
|
return (
|
||||||
<div class="space-y-5 py-5">
|
<div class="space-y-5 py-5">
|
||||||
<div class="h-3 w-36 animate-pulse rounded-sm bg-[#dfe6e4]" />
|
<div class="skeleton-line w-36" />
|
||||||
<div class="h-8 w-64 animate-pulse rounded-sm bg-[#dfe6e4]" />
|
<div class="skeleton-line h-8 w-64" />
|
||||||
<div class="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
<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>
|
<For each={[1, 2, 3, 4]}>{() => <div class="h-24 animate-pulse bg-white" />}</For>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,106 +0,0 @@
|
|||||||
import {
|
|
||||||
FileCode2,
|
|
||||||
History,
|
|
||||||
LayoutDashboard,
|
|
||||||
Package,
|
|
||||||
Server,
|
|
||||||
Settings,
|
|
||||||
type LucideProps,
|
|
||||||
} from "lucide-solid";
|
|
||||||
import type { Component } from "solid-js";
|
|
||||||
import type { PageProps } from "./types";
|
|
||||||
|
|
||||||
type PageModule = {
|
|
||||||
default: Component<PageProps>;
|
|
||||||
};
|
|
||||||
|
|
||||||
type PageDefinition = {
|
|
||||||
id: string;
|
|
||||||
label: string;
|
|
||||||
icon: Component<LucideProps>;
|
|
||||||
/**
|
|
||||||
* Requests that Host retain this page's iframe after navigation.
|
|
||||||
*
|
|
||||||
* Primary navigation pages normally opt in; detail and short-lived workflow
|
|
||||||
* pages should opt out. This is still a preference rather than ownership of
|
|
||||||
* the iframe lifetime: Host expires documents that remain hidden beyond its
|
|
||||||
* inactivity TTL. A retained page must pause background work while
|
|
||||||
* `active()` is false and release all resources when disposed.
|
|
||||||
*/
|
|
||||||
keepAlive: boolean;
|
|
||||||
/**
|
|
||||||
* Loads only this page's code inside the iframe document.
|
|
||||||
*
|
|
||||||
* Keep this as a dynamic import. Turning it into a static import would make
|
|
||||||
* every iframe evaluate every page and weaken the document lifecycle
|
|
||||||
* boundary used to release page-owned memory.
|
|
||||||
*/
|
|
||||||
load: () => Promise<PageModule>;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The single source of truth for Wasmeld Console pages.
|
|
||||||
*
|
|
||||||
* Add a page here after creating `src/pages/<id>/index.tsx`. Host navigation,
|
|
||||||
* route validation, iframe titles, keep-alive policy and page module loading
|
|
||||||
* are all derived from this registry, so they cannot drift independently.
|
|
||||||
*/
|
|
||||||
export const PAGE_DEFINITIONS = [
|
|
||||||
{
|
|
||||||
id: "overview",
|
|
||||||
label: "运行概览",
|
|
||||||
icon: LayoutDashboard,
|
|
||||||
keepAlive: true,
|
|
||||||
load: () => import("./overview"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "services",
|
|
||||||
label: "服务版本",
|
|
||||||
icon: Package,
|
|
||||||
keepAlive: true,
|
|
||||||
load: () => import("./services"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "instances",
|
|
||||||
label: "运行实例",
|
|
||||||
icon: Server,
|
|
||||||
keepAlive: true,
|
|
||||||
load: () => import("./instances"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "wit-packages",
|
|
||||||
label: "WIT 包",
|
|
||||||
icon: FileCode2,
|
|
||||||
keepAlive: true,
|
|
||||||
load: () => import("./wit-packages"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "activity",
|
|
||||||
label: "调用记录",
|
|
||||||
icon: History,
|
|
||||||
keepAlive: true,
|
|
||||||
load: () => import("./activity"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "settings",
|
|
||||||
label: "运行设置",
|
|
||||||
icon: Settings,
|
|
||||||
keepAlive: true,
|
|
||||||
load: () => import("./settings"),
|
|
||||||
},
|
|
||||||
] as const satisfies readonly PageDefinition[];
|
|
||||||
|
|
||||||
export type View = (typeof PAGE_DEFINITIONS)[number]["id"];
|
|
||||||
|
|
||||||
export const VIEWS: readonly View[] = PAGE_DEFINITIONS.map((page) => page.id);
|
|
||||||
|
|
||||||
const PAGE_BY_ID = new Map<View, PageDefinition>(PAGE_DEFINITIONS.map((page) => [page.id, page]));
|
|
||||||
|
|
||||||
export function isView(value: string | null): value is View {
|
|
||||||
return value !== null && PAGE_BY_ID.has(value as View);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function pageDefinition(view: View): PageDefinition {
|
|
||||||
// `View` is derived from PAGE_DEFINITIONS, so every valid value has an entry.
|
|
||||||
return PAGE_BY_ID.get(view)!;
|
|
||||||
}
|
|
||||||
@@ -1,11 +1,7 @@
|
|||||||
import { CloudUpload, Filter, RefreshCw } from "lucide-solid";
|
import { CloudUpload, Filter, RefreshCw } from "lucide-solid";
|
||||||
import { createMemo, createSignal, Show } from "solid-js";
|
import { createMemo, createSignal, Show } from "solid-js";
|
||||||
import { Page } from "../../components/layout/page";
|
import { PageHeading } from "../../components/ui/page-heading";
|
||||||
import { PageHeading } from "../../components/layout/page-heading";
|
import { ServiceTable } from "../../components/ui/service-table";
|
||||||
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 { servicesFrom } from "../../lib/view-state";
|
||||||
import type { ServiceStatus } from "../../lib/model";
|
import type { ServiceStatus } from "../../lib/model";
|
||||||
import type { PageProps } from "../types";
|
import type { PageProps } from "../types";
|
||||||
@@ -26,7 +22,7 @@ export default function ServicesPage(props: PageProps) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page>
|
<main class="page">
|
||||||
<PageHeading
|
<PageHeading
|
||||||
eyebrow="COMPONENTS"
|
eyebrow="COMPONENTS"
|
||||||
title="服务版本"
|
title="服务版本"
|
||||||
@@ -38,8 +34,8 @@ export default function ServicesPage(props: PageProps) {
|
|||||||
class="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-muted"
|
class="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-muted"
|
||||||
size={14}
|
size={14}
|
||||||
/>
|
/>
|
||||||
<Select
|
<select
|
||||||
class="w-36 pl-9"
|
class="select w-36 pl-9"
|
||||||
value={status()}
|
value={status()}
|
||||||
onChange={(event) => setStatus(event.currentTarget.value as "all" | ServiceStatus)}
|
onChange={(event) => setStatus(event.currentTarget.value as "all" | ServiceStatus)}
|
||||||
>
|
>
|
||||||
@@ -47,35 +43,34 @@ export default function ServicesPage(props: PageProps) {
|
|||||||
<option value="running">运行中</option>
|
<option value="running">运行中</option>
|
||||||
<option value="stopped">已停止</option>
|
<option value="stopped">已停止</option>
|
||||||
<option value="faulted">故障</option>
|
<option value="faulted">故障</option>
|
||||||
</Select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<Button variant="primary" onClick={() => props.command({ type: "open-register" })}>
|
<button
|
||||||
|
class="btn btn-primary"
|
||||||
|
onClick={() => props.command({ type: "open-register" })}
|
||||||
|
>
|
||||||
<CloudUpload size={16} />
|
<CloudUpload size={16} />
|
||||||
注册组件
|
注册组件
|
||||||
</Button>
|
</button>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Card as="section">
|
<section class="panel">
|
||||||
<CardHeader>
|
<div class="panel-header">
|
||||||
<div>
|
<div>
|
||||||
<h2>已注册版本</h2>
|
<h2>已注册版本</h2>
|
||||||
<p>{filtered().length} 个匹配项</p>
|
<p>{filtered().length} 个匹配项</p>
|
||||||
</div>
|
</div>
|
||||||
<IconButton
|
<button class="icon-btn" title="刷新" onClick={() => props.command({ type: "refresh" })}>
|
||||||
aria-label="刷新"
|
|
||||||
title="刷新"
|
|
||||||
onClick={() => props.command({ type: "refresh" })}
|
|
||||||
>
|
|
||||||
<RefreshCw size={16} />
|
<RefreshCw size={16} />
|
||||||
</IconButton>
|
</button>
|
||||||
</CardHeader>
|
</div>
|
||||||
<Show when={props.state()}>
|
<Show when={props.state()}>
|
||||||
{(state) => (
|
{(state) => (
|
||||||
<ServiceTable services={filtered()} state={state()} command={props.command} />
|
<ServiceTable services={filtered()} state={state()} command={props.command} />
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
</Card>
|
</section>
|
||||||
</Page>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,7 @@
|
|||||||
import { Database, Globe2, Save, ShieldCheck } from "lucide-solid";
|
import { Database, Globe2, Save, ShieldCheck } from "lucide-solid";
|
||||||
import { createEffect, createSignal } from "solid-js";
|
import { createEffect, createSignal } from "solid-js";
|
||||||
import { displayApiBase } from "../../lib/api";
|
import { displayApiBase } from "../../lib/api";
|
||||||
import { Page } from "../../components/layout/page";
|
import { PageHeading } from "../../components/ui/page-heading";
|
||||||
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";
|
import type { PageProps } from "../types";
|
||||||
|
|
||||||
export default function SettingsPage(props: PageProps) {
|
export default function SettingsPage(props: PageProps) {
|
||||||
@@ -21,21 +16,21 @@ export default function SettingsPage(props: PageProps) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page>
|
<main class="page">
|
||||||
<PageHeading
|
<PageHeading
|
||||||
eyebrow="CONFIGURATION"
|
eyebrow="CONFIGURATION"
|
||||||
title="运行设置"
|
title="运行设置"
|
||||||
description="配置管理 API 连接;运行边界由后端策略统一控制。"
|
description="配置管理 API 连接;运行边界由后端策略统一控制。"
|
||||||
/>
|
/>
|
||||||
<div class="grid gap-5 xl:grid-cols-[minmax(0,1.4fr)_minmax(280px,0.7fr)]">
|
<div class="grid gap-5 xl:grid-cols-[minmax(0,1.4fr)_minmax(280px,0.7fr)]">
|
||||||
<Card as="section">
|
<section class="panel">
|
||||||
<CardHeader>
|
<div class="panel-header">
|
||||||
<div>
|
<div>
|
||||||
<h2>管理 API</h2>
|
<h2>管理 API</h2>
|
||||||
<p>留空表示使用当前页面同源地址</p>
|
<p>留空表示使用当前页面同源地址</p>
|
||||||
</div>
|
</div>
|
||||||
<Globe2 size={18} class="text-cyan-strong" />
|
<Globe2 size={18} class="text-cyan-strong" />
|
||||||
</CardHeader>
|
</div>
|
||||||
<form
|
<form
|
||||||
class="p-5"
|
class="p-5"
|
||||||
onSubmit={(event) => {
|
onSubmit={(event) => {
|
||||||
@@ -43,26 +38,27 @@ export default function SettingsPage(props: PageProps) {
|
|||||||
props.command({ type: "save-api-base", value: endpoint() });
|
props.command({ type: "save-api-base", value: endpoint() });
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Field>
|
<label class="field">
|
||||||
<FieldLabel>Endpoint</FieldLabel>
|
<span>Endpoint</span>
|
||||||
<Input
|
<input
|
||||||
|
class="input"
|
||||||
type="url"
|
type="url"
|
||||||
value={endpoint()}
|
value={endpoint()}
|
||||||
placeholder={window.location.origin}
|
placeholder={window.location.origin}
|
||||||
onInput={(event) => setEndpoint(event.currentTarget.value)}
|
onInput={(event) => setEndpoint(event.currentTarget.value)}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</label>
|
||||||
<p class="mt-2 text-xs text-muted">
|
<p class="mt-2 text-xs text-muted">
|
||||||
当前解析地址:{displayApiBase(props.state()?.apiBase ?? "")}
|
当前解析地址:{displayApiBase(props.state()?.apiBase ?? "")}
|
||||||
</p>
|
</p>
|
||||||
<div class="mt-5 flex justify-end">
|
<div class="mt-5 flex justify-end">
|
||||||
<Button variant="primary" type="submit">
|
<button class="btn btn-primary" type="submit">
|
||||||
<Save size={15} />
|
<Save size={15} />
|
||||||
保存连接
|
保存连接
|
||||||
</Button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</Card>
|
</section>
|
||||||
<aside class="space-y-3">
|
<aside class="space-y-3">
|
||||||
<Info
|
<Info
|
||||||
icon={ShieldCheck}
|
icon={ShieldCheck}
|
||||||
@@ -72,14 +68,14 @@ export default function SettingsPage(props: PageProps) {
|
|||||||
<Info icon={Database} title="持久化" detail="控制状态和服务 KV 存储于本地 libSQL。" />
|
<Info icon={Database} title="持久化" detail="控制状态和服务 KV 存储于本地 libSQL。" />
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
</Page>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Info(props: { icon: typeof ShieldCheck; title: string; detail: string }) {
|
function Info(props: { icon: typeof ShieldCheck; title: string; detail: string }) {
|
||||||
const Icon = props.icon;
|
const Icon = props.icon;
|
||||||
return (
|
return (
|
||||||
<Card as="article" padding="small" class="flex gap-3">
|
<article class="panel flex gap-3 p-4">
|
||||||
<span class="flex size-9 shrink-0 items-center justify-center rounded-md bg-brand-soft text-brand">
|
<span class="flex size-9 shrink-0 items-center justify-center rounded-md bg-brand-soft text-brand">
|
||||||
<Icon size={16} />
|
<Icon size={16} />
|
||||||
</span>
|
</span>
|
||||||
@@ -87,6 +83,6 @@ function Info(props: { icon: typeof ShieldCheck; title: string; detail: string }
|
|||||||
<strong class="text-sm">{props.title}</strong>
|
<strong class="text-sm">{props.title}</strong>
|
||||||
<p class="mt-1 text-xs leading-5 text-muted">{props.detail}</p>
|
<p class="mt-1 text-xs leading-5 text-muted">{props.detail}</p>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</article>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,7 @@
|
|||||||
import type { Accessor } from "solid-js";
|
import type { Accessor } from "solid-js";
|
||||||
import type { PageLifecycle } from "../sdk/lifecycle";
|
|
||||||
import type { HostSharedState, PageCommand } from "../sdk/protocol";
|
import type { HostSharedState, PageCommand } from "../sdk/protocol";
|
||||||
|
|
||||||
export type PageProps = {
|
export type PageProps = {
|
||||||
state: Accessor<HostSharedState | null>;
|
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;
|
command: (command: PageCommand) => void;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,18 +1,7 @@
|
|||||||
import { CloudUpload, Download, FileCode2, Search } from "lucide-solid";
|
import { CloudUpload, Download, FileCode2, Search } from "lucide-solid";
|
||||||
import { createMemo, For, Show } from "solid-js";
|
import { createMemo, For, Show } from "solid-js";
|
||||||
import { EmptyState } from "../../components/feedback/empty-state";
|
import { EmptyState } from "../../components/ui/empty-state";
|
||||||
import { Page } from "../../components/layout/page";
|
import { PageHeading } from "../../components/ui/page-heading";
|
||||||
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 { witPackageDownloadUrl } from "../../lib/api";
|
||||||
import type { PageProps } from "../types";
|
import type { PageProps } from "../types";
|
||||||
|
|
||||||
@@ -32,83 +21,85 @@ export default function WitPackagesPage(props: PageProps) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page>
|
<main class="page">
|
||||||
<PageHeading
|
<PageHeading
|
||||||
eyebrow="INTERFACES"
|
eyebrow="INTERFACES"
|
||||||
title="WIT 包"
|
title="WIT 包"
|
||||||
description="管理不可变、版本化的 Component 接口依赖。"
|
description="管理不可变、版本化的 Component 接口依赖。"
|
||||||
actions={
|
actions={
|
||||||
<Button variant="primary" onClick={() => props.command({ type: "open-wit-publish" })}>
|
<button
|
||||||
|
class="btn btn-primary"
|
||||||
|
onClick={() => props.command({ type: "open-wit-publish" })}
|
||||||
|
>
|
||||||
<CloudUpload size={16} />
|
<CloudUpload size={16} />
|
||||||
发布 WIT 包
|
发布 WIT 包
|
||||||
</Button>
|
</button>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Card as="section">
|
<section class="panel">
|
||||||
<CardHeader>
|
<div class="panel-header">
|
||||||
<div>
|
<div>
|
||||||
<h2>Registry</h2>
|
<h2>Registry</h2>
|
||||||
<p>{packages().length} 个匹配包版本</p>
|
<p>{packages().length} 个匹配包版本</p>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</div>
|
||||||
<Show
|
<Show
|
||||||
when={packages().length > 0}
|
when={packages().length > 0}
|
||||||
fallback={
|
fallback={
|
||||||
<EmptyState icon={Search} title="没有匹配的 WIT 包" detail="发布或调整搜索关键词。" />
|
<EmptyState icon={Search} title="没有匹配的 WIT 包" detail="发布或调整搜索关键词。" />
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Table>
|
<div class="table-wrap">
|
||||||
<TableHeader>
|
<table class="data-table">
|
||||||
<TableRow>
|
<thead>
|
||||||
<TableHead>包</TableHead>
|
<tr>
|
||||||
<TableHead>版本</TableHead>
|
<th>包</th>
|
||||||
<TableHead>直接依赖</TableHead>
|
<th>版本</th>
|
||||||
<TableHead>SHA-256</TableHead>
|
<th>直接依赖</th>
|
||||||
<TableHead>
|
<th>SHA-256</th>
|
||||||
|
<th>
|
||||||
<span class="sr-only">下载</span>
|
<span class="sr-only">下载</span>
|
||||||
</TableHead>
|
</th>
|
||||||
</TableRow>
|
</tr>
|
||||||
</TableHeader>
|
</thead>
|
||||||
<TableBody>
|
<tbody>
|
||||||
<For each={packages()}>
|
<For each={packages()}>
|
||||||
{(item) => (
|
{(item) => (
|
||||||
<TableRow>
|
<tr>
|
||||||
<TableCell>
|
<td>
|
||||||
<span class="flex items-center gap-2">
|
<span class="flex items-center gap-2">
|
||||||
<FileCode2 size={15} class="text-cyan-strong" />
|
<FileCode2 size={15} class="text-cyan-strong" />
|
||||||
<strong>{item.name}</strong>
|
<strong>{item.name}</strong>
|
||||||
</span>
|
</span>
|
||||||
</TableCell>
|
</td>
|
||||||
<TableCell class="font-mono text-xs">{item.version}</TableCell>
|
<td class="code">{item.version}</td>
|
||||||
<TableCell class="text-xs text-muted">
|
<td class="text-xs text-muted">
|
||||||
{item.dependencies.length
|
{item.dependencies.length
|
||||||
? item.dependencies
|
? item.dependencies
|
||||||
.map((dependency) => `${dependency.name}@${dependency.version}`)
|
.map((dependency) => `${dependency.name}@${dependency.version}`)
|
||||||
.join(", ")
|
.join(", ")
|
||||||
: "无"}
|
: "无"}
|
||||||
</TableCell>
|
</td>
|
||||||
<TableCell
|
<td class="code max-w-64 truncate text-muted" title={item.sha256}>
|
||||||
class="max-w-64 truncate font-mono text-xs text-muted"
|
|
||||||
title={item.sha256}
|
|
||||||
>
|
|
||||||
{item.sha256}
|
{item.sha256}
|
||||||
</TableCell>
|
</td>
|
||||||
<TableCell class="text-right">
|
<td class="text-right">
|
||||||
<IconLink
|
<a
|
||||||
aria-label={`下载 ${item.name}@${item.version}`}
|
class="icon-btn"
|
||||||
title="下载"
|
title="下载"
|
||||||
href={witPackageDownloadUrl(props.state()?.apiBase ?? "", item)}
|
href={witPackageDownloadUrl(props.state()?.apiBase ?? "", item)}
|
||||||
>
|
>
|
||||||
<Download size={15} />
|
<Download size={15} />
|
||||||
</IconLink>
|
</a>
|
||||||
</TableCell>
|
</td>
|
||||||
</TableRow>
|
</tr>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
</TableBody>
|
</tbody>
|
||||||
</Table>
|
</table>
|
||||||
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</Card>
|
</section>
|
||||||
</Page>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,17 @@
|
|||||||
import { createSignal, onCleanup, onMount } from "solid-js";
|
import { createSignal, onCleanup, onMount } from "solid-js";
|
||||||
import type { View } from "../pages/registry";
|
import type { View } from "../lib/model";
|
||||||
import { createPageLifecycleDispatcher, type PageLifecycleEventType } from "../sdk/lifecycle";
|
|
||||||
import { isHostMessage, type HostSharedState, type PageCommand } from "../sdk/protocol";
|
import { isHostMessage, type HostSharedState, type PageCommand } from "../sdk/protocol";
|
||||||
import { notifyReady, sendCommand } from "../sdk/page";
|
import { notifyReady, sendCommand } from "../sdk/page";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Connects one iframe document to the persistent Host Shell.
|
* Connects one iframe document to the persistent Host Shell.
|
||||||
*
|
*
|
||||||
* A keep-alive document remains mounted while inactive and exposes that state
|
* The listener and state belong to the iframe's Solid owner. Navigating or
|
||||||
* through `active`. Host `dispose`, browser `pagehide` and Solid cleanup all
|
* removing the iframe runs `onCleanup`, so the old document cannot continue to
|
||||||
* converge on one deduplicated SDK unload transition. The document boundary
|
* receive Host snapshots.
|
||||||
* itself remains the final guarantee that an evicted Page releases its realm.
|
|
||||||
*/
|
*/
|
||||||
export function createFrameBridge(view: View) {
|
export function createFrameBridge(view: View) {
|
||||||
const [state, setState] = createSignal<HostSharedState | null>(null);
|
const [state, setState] = createSignal<HostSharedState | null>(null);
|
||||||
const [active, setActive] = createSignal(false);
|
|
||||||
const pageLifecycle = createPageLifecycleDispatcher();
|
|
||||||
|
|
||||||
const receive = (event: MessageEvent<unknown>) => {
|
const receive = (event: MessageEvent<unknown>) => {
|
||||||
if (event.origin !== window.location.origin || event.source !== window.parent) return;
|
if (event.origin !== window.location.origin || event.source !== window.parent) return;
|
||||||
@@ -23,58 +19,22 @@ export function createFrameBridge(view: View) {
|
|||||||
if (event.data.type === "state" && event.data.state.view === view) {
|
if (event.data.type === "state" && event.data.state.view === view) {
|
||||||
setState(event.data.state);
|
setState(event.data.state);
|
||||||
}
|
}
|
||||||
if (event.data.type === "lifecycle") {
|
if (event.data.type === "dispose") {
|
||||||
const phase = event.data.phase;
|
window.dispatchEvent(new Event("wasmeld:dispose"));
|
||||||
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(() => {
|
onMount(() => {
|
||||||
window.addEventListener("message", receive);
|
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);
|
notifyReady(view);
|
||||||
onCleanup(() => window.removeEventListener("pagehide", unload));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => window.removeEventListener("message", receive));
|
||||||
window.removeEventListener("message", receive);
|
|
||||||
pageLifecycle.dispatch("unload", "document");
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
state,
|
state,
|
||||||
active,
|
|
||||||
lifecycle: pageLifecycle.lifecycle,
|
|
||||||
command(command: PageCommand) {
|
command(command: PageCommand) {
|
||||||
sendCommand(view, command);
|
sendCommand(view, command);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function eventForPhase(phase: "activate" | "deactivate" | "dispose"): PageLifecycleEventType {
|
|
||||||
switch (phase) {
|
|
||||||
case "activate":
|
|
||||||
return "show";
|
|
||||||
case "deactivate":
|
|
||||||
return "hide";
|
|
||||||
case "dispose":
|
|
||||||
return "unload";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,11 +1,5 @@
|
|||||||
import type { View } from "../pages/registry";
|
import type { View } from "../lib/model";
|
||||||
import {
|
import { SDK_CHANNEL, SDK_VERSION, type HostMessage, type HostSharedState } from "./protocol";
|
||||||
SDK_CHANNEL,
|
|
||||||
SDK_VERSION,
|
|
||||||
type HostMessage,
|
|
||||||
type HostSharedState,
|
|
||||||
type PageLifecyclePhase,
|
|
||||||
} from "./protocol";
|
|
||||||
|
|
||||||
export function sendState(target: Window, state: HostSharedState): void {
|
export function sendState(target: Window, state: HostSharedState): void {
|
||||||
send(target, {
|
send(target, {
|
||||||
@@ -17,13 +11,12 @@ export function sendState(target: Window, state: HostSharedState): void {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sendLifecycle(target: Window, phase: PageLifecyclePhase): void {
|
export function disposePage(target: Window): void {
|
||||||
send(target, {
|
send(target, {
|
||||||
channel: SDK_CHANNEL,
|
channel: SDK_CHANNEL,
|
||||||
version: SDK_VERSION,
|
version: SDK_VERSION,
|
||||||
source: "host",
|
source: "host",
|
||||||
type: "lifecycle",
|
type: "dispose",
|
||||||
phase,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,124 +0,0 @@
|
|||||||
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";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,15 +1,6 @@
|
|||||||
import type { View } from "../pages/registry";
|
import type { View } from "../lib/model";
|
||||||
import { SDK_CHANNEL, SDK_VERSION, type PageCommand, type PageMessage } from "./protocol";
|
import { SDK_CHANNEL, SDK_VERSION, type PageCommand, type PageMessage } from "./protocol";
|
||||||
|
|
||||||
export type {
|
|
||||||
PageLifecycle,
|
|
||||||
PageLifecycleEvent,
|
|
||||||
PageLifecycleEventType,
|
|
||||||
PageLifecycleListener,
|
|
||||||
PageLifecycleSource,
|
|
||||||
PageLifecycleState,
|
|
||||||
} from "./lifecycle";
|
|
||||||
|
|
||||||
export function notifyReady(view: View): void {
|
export function notifyReady(view: View): void {
|
||||||
send({
|
send({
|
||||||
channel: SDK_CHANNEL,
|
channel: SDK_CHANNEL,
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import type { BackendSnapshot } from "../lib/api";
|
import type { BackendSnapshot } from "../lib/api";
|
||||||
import type { ConnectionState } from "../lib/model";
|
import type { ConnectionState, View } from "../lib/model";
|
||||||
import type { View } from "../pages/registry";
|
|
||||||
|
|
||||||
export const SDK_CHANNEL = "wasmeld-console";
|
export const SDK_CHANNEL = "wasmeld-console";
|
||||||
|
|
||||||
@@ -15,29 +14,19 @@ export const SDK_VERSION = 1;
|
|||||||
|
|
||||||
export type RuntimeAction = "start" | "stop" | "restart";
|
export type RuntimeAction = "start" | "stop" | "restart";
|
||||||
export type ServiceAction = "start" | "stop" | "restart";
|
export type ServiceAction = "start" | "stop" | "restart";
|
||||||
export type PageLifecyclePhase = "activate" | "deactivate" | "dispose";
|
|
||||||
|
|
||||||
/**
|
export type HostSharedState = {
|
||||||
* Control-plane state shared unconditionally between same-origin Host tabs.
|
// This is a serializable snapshot, never a Solid signal crossing realms.
|
||||||
*
|
view: View;
|
||||||
* Navigation, search, dialogs, notifications and iframe caches are excluded:
|
|
||||||
* those values describe one browser tab rather than the Wasmeld runtime.
|
|
||||||
*/
|
|
||||||
export type HostGlobalState = {
|
|
||||||
snapshot: BackendSnapshot | null;
|
snapshot: BackendSnapshot | null;
|
||||||
connection: ConnectionState;
|
connection: ConnectionState;
|
||||||
|
query: string;
|
||||||
apiBase: string;
|
apiBase: string;
|
||||||
runtimeAction: RuntimeAction | null;
|
runtimeAction: RuntimeAction | null;
|
||||||
serviceAction: string | null;
|
serviceAction: string | null;
|
||||||
deploymentAction: string | null;
|
deploymentAction: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type HostSharedState = HostGlobalState & {
|
|
||||||
// This is a serializable snapshot, never a Solid signal crossing realms.
|
|
||||||
view: View;
|
|
||||||
query: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type PageCommand =
|
export type PageCommand =
|
||||||
| { type: "refresh" }
|
| { type: "refresh" }
|
||||||
| { type: "navigate"; view: View }
|
| { type: "navigate"; view: View }
|
||||||
@@ -78,8 +67,7 @@ export type HostMessage =
|
|||||||
channel: typeof SDK_CHANNEL;
|
channel: typeof SDK_CHANNEL;
|
||||||
version: typeof SDK_VERSION;
|
version: typeof SDK_VERSION;
|
||||||
source: "host";
|
source: "host";
|
||||||
type: "lifecycle";
|
type: "dispose";
|
||||||
phase: PageLifecyclePhase;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function isPageMessage(value: unknown): value is PageMessage {
|
export function isPageMessage(value: unknown): value is PageMessage {
|
||||||
@@ -94,15 +82,12 @@ export function isPageMessage(value: unknown): value is PageMessage {
|
|||||||
|
|
||||||
export function isHostMessage(value: unknown): value is HostMessage {
|
export function isHostMessage(value: unknown): value is HostMessage {
|
||||||
if (!isRecord(value)) return false;
|
if (!isRecord(value)) return false;
|
||||||
if (value.channel !== SDK_CHANNEL || value.version !== SDK_VERSION || value.source !== "host") {
|
return (
|
||||||
return false;
|
value.channel === SDK_CHANNEL &&
|
||||||
}
|
value.version === SDK_VERSION &&
|
||||||
if (value.type === "state") return isRecord(value.state);
|
value.source === "host" &&
|
||||||
return value.type === "lifecycle" && isPageLifecyclePhase(value.phase);
|
(value.type === "state" || value.type === "dispose")
|
||||||
}
|
);
|
||||||
|
|
||||||
function isPageLifecyclePhase(value: unknown): value is PageLifecyclePhase {
|
|
||||||
return value === "activate" || value === "deactivate" || value === "dispose";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
|||||||
@@ -64,12 +64,266 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes loading-progress-swing {
|
@layer components {
|
||||||
from {
|
.btn {
|
||||||
transform: translateX(0);
|
@apply inline-flex h-10 items-center justify-center gap-2 rounded-md border px-4 text-sm font-semibold transition-colors disabled:cursor-not-allowed disabled:opacity-45;
|
||||||
}
|
}
|
||||||
|
|
||||||
to {
|
.btn-primary {
|
||||||
transform: translateX(178%);
|
@apply border-brand bg-brand text-white hover:border-brand-strong hover:bg-brand-strong;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
@apply border-line bg-white text-ink hover:bg-canvas;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
@apply border-coral-strong bg-white text-coral-strong hover:bg-coral-soft;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn {
|
||||||
|
@apply inline-flex size-9 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
@apply border-line bg-surface rounded-md border;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header {
|
||||||
|
@apply border-line flex min-h-16 items-center justify-between gap-4 border-b px-5 py-3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header h2 {
|
||||||
|
@apply text-base font-semibold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header p {
|
||||||
|
@apply mt-0.5 text-xs text-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page {
|
||||||
|
@apply mx-auto w-full max-w-[1480px] px-4 py-5 sm:px-6 lg:px-8 lg:py-7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-heading {
|
||||||
|
@apply mb-5 flex flex-col justify-between gap-4 sm:flex-row sm:items-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-heading h1 {
|
||||||
|
@apply mt-1 text-2xl font-bold text-ink;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-heading p {
|
||||||
|
@apply mt-1 max-w-3xl text-sm text-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
@apply text-cyan-strong text-[11px] font-bold uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heading-actions {
|
||||||
|
@apply flex shrink-0 items-center gap-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-grid {
|
||||||
|
@apply mb-5 grid grid-cols-1 border-l border-t border-line sm:grid-cols-2 xl:grid-cols-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric {
|
||||||
|
@apply flex min-h-24 items-center gap-3 border-b border-r border-line bg-white px-4 py-3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-icon {
|
||||||
|
@apply flex size-10 shrink-0 items-center justify-center rounded-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-copy {
|
||||||
|
@apply min-w-0 flex-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-copy span {
|
||||||
|
@apply block text-xs text-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-copy strong {
|
||||||
|
@apply mt-1 block text-2xl font-bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric > small {
|
||||||
|
@apply self-end whitespace-nowrap pb-1 text-[11px] text-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-wrap {
|
||||||
|
@apply w-full overflow-x-auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table {
|
||||||
|
@apply w-full min-w-[760px] border-collapse text-left text-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table th {
|
||||||
|
@apply border-line bg-canvas border-b px-4 py-2.5 text-[11px] font-semibold uppercase text-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table td {
|
||||||
|
@apply border-line border-b px-4 py-3 align-middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table tbody tr {
|
||||||
|
@apply transition-colors hover:bg-[#f8faf9];
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table tbody tr:last-child td {
|
||||||
|
@apply border-b-0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
@apply inline-flex items-center gap-1.5 whitespace-nowrap text-xs font-semibold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge::before {
|
||||||
|
content: "";
|
||||||
|
@apply size-1.5 rounded-full bg-current;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-running {
|
||||||
|
@apply text-brand;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-stopped {
|
||||||
|
@apply text-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-faulted {
|
||||||
|
@apply text-coral-strong;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-actions {
|
||||||
|
@apply flex items-center justify-end gap-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-actions button {
|
||||||
|
@apply inline-flex size-8 items-center justify-center rounded-md text-muted hover:bg-canvas hover:text-ink disabled:cursor-not-allowed disabled:opacity-35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
@apply flex min-h-56 flex-col items-center justify-center gap-2 px-6 text-center text-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state strong {
|
||||||
|
@apply text-sm text-ink;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state span {
|
||||||
|
@apply text-xs;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code {
|
||||||
|
@apply font-mono text-xs;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
@apply block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field > span {
|
||||||
|
@apply mb-1.5 block text-xs font-semibold text-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input,
|
||||||
|
.select,
|
||||||
|
.textarea {
|
||||||
|
@apply border-line w-full rounded-md border bg-white px-3 text-sm text-ink outline-none transition-colors focus:border-brand;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input,
|
||||||
|
.select {
|
||||||
|
@apply h-10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textarea {
|
||||||
|
@apply min-h-36 resize-y py-3 font-mono;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-backdrop {
|
||||||
|
@apply fixed inset-0 z-50 flex items-center justify-center bg-[#0d1715]/55 p-3 backdrop-blur-[1px];
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog {
|
||||||
|
@apply border-line max-h-[calc(100dvh-24px)] w-full max-w-3xl overflow-auto rounded-md border bg-white p-0 shadow-2xl;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-header {
|
||||||
|
@apply border-line flex items-start justify-between gap-4 border-b px-5 py-4 sm:px-7 sm:py-5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-title {
|
||||||
|
@apply flex min-w-0 items-start gap-3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-title > span {
|
||||||
|
@apply bg-brand-soft text-brand flex size-10 shrink-0 items-center justify-center rounded-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-title h2 {
|
||||||
|
@apply text-lg font-bold sm:text-xl;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-title p {
|
||||||
|
@apply mt-1 text-sm text-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-body {
|
||||||
|
@apply px-5 py-5 sm:px-7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-footer {
|
||||||
|
@apply border-line flex items-center justify-end gap-2 border-t bg-[#fafbfb] px-5 py-4 sm:px-7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-zone {
|
||||||
|
@apply border-line flex min-h-48 cursor-pointer flex-col items-center justify-center gap-2 rounded-md border border-dashed bg-[#fbfcfc] px-5 text-center text-muted transition-colors hover:border-brand hover:bg-brand-soft;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-zone input {
|
||||||
|
@apply sr-only;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-zone strong {
|
||||||
|
@apply text-sm text-ink;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-zone span {
|
||||||
|
@apply text-xs;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-error {
|
||||||
|
@apply border-coral-strong/30 bg-coral-soft text-coral-strong mt-3 rounded-md border px-3 py-2 text-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.segmented {
|
||||||
|
@apply border-line inline-flex rounded-md border bg-canvas p-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.segmented button {
|
||||||
|
@apply h-8 rounded-sm px-3 text-xs font-semibold text-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
.segmented button.active {
|
||||||
|
@apply bg-white text-ink shadow-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-line {
|
||||||
|
@apply h-3 animate-pulse rounded-sm bg-[#dfe6e4];
|
||||||
|
}
|
||||||
|
|
||||||
|
.spin {
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
import assert from "node:assert/strict";
|
|
||||||
import { readFile, readdir } from "node:fs/promises";
|
|
||||||
import test from "node:test";
|
|
||||||
|
|
||||||
const uiDirectory = new URL("../src/components/ui/", import.meta.url);
|
|
||||||
const appStylesheet = new URL("../src/styles/app.css", import.meta.url);
|
|
||||||
|
|
||||||
test("UI primitives do not depend on page, Host or domain modules", async () => {
|
|
||||||
const files = (await readdir(uiDirectory)).filter(
|
|
||||||
(name) => name.endsWith(".ts") || name.endsWith(".tsx"),
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const file of files) {
|
|
||||||
const source = await readFile(new URL(file, uiDirectory), "utf8");
|
|
||||||
const imports = [...source.matchAll(/from\s+["']([^"']+)["']/g)].map((match) => match[1]);
|
|
||||||
|
|
||||||
for (const specifier of imports) {
|
|
||||||
assert.doesNotMatch(
|
|
||||||
specifier,
|
|
||||||
/(?:^|\/)(?:feedback|host|layout|pages|sdk|services)(?:\/|$)|\/lib\/(?:api|model|view-state)$/,
|
|
||||||
`${file} must remain business-independent: ${specifier}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("global stylesheet does not own component variants", async () => {
|
|
||||||
const source = await readFile(appStylesheet, "utf8");
|
|
||||||
|
|
||||||
assert.doesNotMatch(
|
|
||||||
source,
|
|
||||||
/@layer\s+components/,
|
|
||||||
"component classes and variants belong beside their Solid components",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
import assert from "node:assert/strict";
|
|
||||||
import test from "node:test";
|
|
||||||
import { FrameCache } from "../src/host/frame-cache.ts";
|
|
||||||
|
|
||||||
test("retains opted-in iframe identities across navigation", () => {
|
|
||||||
const cache = new FrameCache("services", {
|
|
||||||
inactiveTtlMs: 1_000,
|
|
||||||
shouldKeepAlive: (view) => view === "services" || view === "settings",
|
|
||||||
});
|
|
||||||
const services = cache.snapshot()[0];
|
|
||||||
|
|
||||||
const away = cache.activate("overview");
|
|
||||||
assert.deepEqual(
|
|
||||||
away.entries.map((entry) => entry.view),
|
|
||||||
["services", "overview"],
|
|
||||||
);
|
|
||||||
|
|
||||||
const back = cache.activate("services");
|
|
||||||
assert.equal(back.activated, services);
|
|
||||||
assert.equal(back.activated.instance, services.instance);
|
|
||||||
assert.deepEqual(
|
|
||||||
back.removed.map((entry) => entry.view),
|
|
||||||
["overview"],
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("recreates non-keep-alive documents after leaving them", () => {
|
|
||||||
const cache = new FrameCache("overview", {
|
|
||||||
inactiveTtlMs: 1_000,
|
|
||||||
shouldKeepAlive: () => false,
|
|
||||||
});
|
|
||||||
const firstInstance = cache.snapshot()[0].instance;
|
|
||||||
|
|
||||||
cache.activate("activity");
|
|
||||||
const transition = cache.activate("overview");
|
|
||||||
|
|
||||||
assert.notEqual(transition.activated.instance, firstInstance);
|
|
||||||
assert.deepEqual(
|
|
||||||
transition.removed.map((entry) => entry.view),
|
|
||||||
["activity"],
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("expires a keep-alive document after its continuous hidden TTL", () => {
|
|
||||||
let now = 5_000;
|
|
||||||
const cache = new FrameCache("services", {
|
|
||||||
inactiveTtlMs: 1_000,
|
|
||||||
shouldKeepAlive: () => true,
|
|
||||||
now: () => now,
|
|
||||||
});
|
|
||||||
const servicesInstance = cache.snapshot()[0].instance;
|
|
||||||
|
|
||||||
// Time spent visible does not consume the inactive TTL.
|
|
||||||
now = 50_000;
|
|
||||||
cache.activate("settings");
|
|
||||||
now = 50_999;
|
|
||||||
assert.equal(cache.timeUntilExpiration(), 1);
|
|
||||||
assert.deepEqual(cache.expireInactive().removed, []);
|
|
||||||
|
|
||||||
now = 51_000;
|
|
||||||
const expiration = cache.expireInactive();
|
|
||||||
assert.deepEqual(
|
|
||||||
expiration.removed.map((entry) => entry.view),
|
|
||||||
["services"],
|
|
||||||
);
|
|
||||||
assert.deepEqual(
|
|
||||||
expiration.entries.map((entry) => entry.view),
|
|
||||||
["settings"],
|
|
||||||
);
|
|
||||||
|
|
||||||
const recreated = cache.activate("services");
|
|
||||||
assert.notEqual(recreated.activated.instance, servicesInstance);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("revisiting a hidden page before its TTL preserves and resets it", () => {
|
|
||||||
let now = 0;
|
|
||||||
const cache = new FrameCache("services", {
|
|
||||||
inactiveTtlMs: 1_000,
|
|
||||||
shouldKeepAlive: () => true,
|
|
||||||
now: () => now,
|
|
||||||
});
|
|
||||||
const services = cache.snapshot()[0];
|
|
||||||
|
|
||||||
cache.activate("settings");
|
|
||||||
now = 999;
|
|
||||||
const revisited = cache.activate("services");
|
|
||||||
assert.equal(revisited.activated, services);
|
|
||||||
|
|
||||||
now = 1_500;
|
|
||||||
assert.deepEqual(cache.expireInactive().removed, []);
|
|
||||||
assert.equal(cache.timeUntilExpiration(), 499);
|
|
||||||
|
|
||||||
now = 1_999;
|
|
||||||
const expiration = cache.expireInactive();
|
|
||||||
assert.deepEqual(
|
|
||||||
expiration.removed.map((entry) => entry.view),
|
|
||||||
["settings"],
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("rejects invalid inactive TTL values", () => {
|
|
||||||
for (const inactiveTtlMs of [0, -1, Number.POSITIVE_INFINITY, Number.NaN]) {
|
|
||||||
assert.throws(
|
|
||||||
() =>
|
|
||||||
new FrameCache("overview", {
|
|
||||||
inactiveTtlMs,
|
|
||||||
shouldKeepAlive: () => true,
|
|
||||||
}),
|
|
||||||
RangeError,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
import assert from "node:assert/strict";
|
|
||||||
import test from "node:test";
|
|
||||||
import { createPageLifecycleDispatcher } from "../src/sdk/lifecycle.ts";
|
|
||||||
|
|
||||||
test("maps page visibility transitions to show, hide and unload events", () => {
|
|
||||||
const dispatcher = createPageLifecycleDispatcher();
|
|
||||||
const events = [];
|
|
||||||
dispatcher.lifecycle.onShow((event) => events.push(event));
|
|
||||||
dispatcher.lifecycle.onHide((event) => events.push(event));
|
|
||||||
dispatcher.lifecycle.onUnload((event) => events.push(event));
|
|
||||||
|
|
||||||
assert.equal(dispatcher.lifecycle.state(), "initializing");
|
|
||||||
dispatcher.dispatch("show", "host");
|
|
||||||
dispatcher.dispatch("hide", "host");
|
|
||||||
dispatcher.dispatch("show", "host");
|
|
||||||
dispatcher.dispatch("unload", "host");
|
|
||||||
|
|
||||||
assert.equal(dispatcher.lifecycle.state(), "unloaded");
|
|
||||||
assert.deepEqual(
|
|
||||||
events.map(({ type, previous, current, source }) => ({
|
|
||||||
type,
|
|
||||||
previous,
|
|
||||||
current,
|
|
||||||
source,
|
|
||||||
})),
|
|
||||||
[
|
|
||||||
{ type: "show", previous: "initializing", current: "visible", source: "host" },
|
|
||||||
{ type: "hide", previous: "visible", current: "hidden", source: "host" },
|
|
||||||
{ type: "show", previous: "hidden", current: "visible", source: "host" },
|
|
||||||
{ type: "unload", previous: "visible", current: "unloaded", source: "host" },
|
|
||||||
],
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("deduplicates lifecycle events and ignores transitions after unload", () => {
|
|
||||||
const dispatcher = createPageLifecycleDispatcher();
|
|
||||||
const events = [];
|
|
||||||
dispatcher.lifecycle.on("show", (event) => events.push(event.type));
|
|
||||||
dispatcher.lifecycle.on("unload", (event) => events.push(event.type));
|
|
||||||
|
|
||||||
assert.equal(dispatcher.dispatch("show", "host")?.type, "show");
|
|
||||||
assert.equal(dispatcher.dispatch("show", "host"), null);
|
|
||||||
assert.equal(dispatcher.dispatch("unload", "host")?.type, "unload");
|
|
||||||
assert.equal(dispatcher.dispatch("unload", "document"), null);
|
|
||||||
assert.equal(dispatcher.dispatch("show", "host"), null);
|
|
||||||
assert.deepEqual(events, ["show", "unload"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("unsubscribe removes a page lifecycle listener", () => {
|
|
||||||
const dispatcher = createPageLifecycleDispatcher();
|
|
||||||
let calls = 0;
|
|
||||||
const unsubscribe = dispatcher.lifecycle.onHide(() => {
|
|
||||||
calls += 1;
|
|
||||||
});
|
|
||||||
|
|
||||||
unsubscribe();
|
|
||||||
unsubscribe();
|
|
||||||
dispatcher.dispatch("hide", "host");
|
|
||||||
assert.equal(calls, 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("show and hide helpers replay current state for lazy pages", () => {
|
|
||||||
const dispatcher = createPageLifecycleDispatcher();
|
|
||||||
dispatcher.dispatch("show", "host");
|
|
||||||
|
|
||||||
const replayed = [];
|
|
||||||
dispatcher.lifecycle.onShow((event) => replayed.push(event.type));
|
|
||||||
dispatcher.lifecycle.onHide((event) => replayed.push(event.type));
|
|
||||||
dispatcher.lifecycle.on("show", (event) => replayed.push(`future:${event.type}`));
|
|
||||||
assert.deepEqual(replayed, ["show"]);
|
|
||||||
|
|
||||||
dispatcher.dispatch("hide", "host");
|
|
||||||
dispatcher.lifecycle.onHide((event) => replayed.push(`late:${event.type}`));
|
|
||||||
assert.deepEqual(replayed, ["show", "hide", "late:hide"]);
|
|
||||||
});
|
|
||||||
@@ -1,124 +0,0 @@
|
|||||||
import assert from "node:assert/strict";
|
|
||||||
import test from "node:test";
|
|
||||||
import { HostTabSync } from "../src/host/tab-sync.ts";
|
|
||||||
|
|
||||||
class FakeBus {
|
|
||||||
channels = new Set();
|
|
||||||
messages = [];
|
|
||||||
|
|
||||||
createChannel() {
|
|
||||||
const channel = new FakeChannel(this);
|
|
||||||
this.channels.add(channel);
|
|
||||||
return channel;
|
|
||||||
}
|
|
||||||
|
|
||||||
deliver(sender, message) {
|
|
||||||
const copy = structuredClone(message);
|
|
||||||
this.messages.push(copy);
|
|
||||||
for (const channel of this.channels) {
|
|
||||||
if (channel !== sender) channel.receive(copy);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
replay(message) {
|
|
||||||
for (const channel of this.channels) channel.receive(structuredClone(message));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class FakeChannel {
|
|
||||||
listeners = new Set();
|
|
||||||
|
|
||||||
constructor(bus) {
|
|
||||||
this.bus = bus;
|
|
||||||
}
|
|
||||||
|
|
||||||
postMessage(message) {
|
|
||||||
this.bus.deliver(this, message);
|
|
||||||
}
|
|
||||||
|
|
||||||
addEventListener(type, listener) {
|
|
||||||
if (type === "message") this.listeners.add(listener);
|
|
||||||
}
|
|
||||||
|
|
||||||
removeEventListener(type, listener) {
|
|
||||||
if (type === "message") this.listeners.delete(listener);
|
|
||||||
}
|
|
||||||
|
|
||||||
receive(data) {
|
|
||||||
for (const listener of this.listeners) listener({ data });
|
|
||||||
}
|
|
||||||
|
|
||||||
close() {
|
|
||||||
this.bus.channels.delete(this);
|
|
||||||
this.listeners.clear();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
test("new tabs request and receive the current Host state", () => {
|
|
||||||
const bus = new FakeBus();
|
|
||||||
const firstState = hostState("http://runtime-a.test");
|
|
||||||
const received = [];
|
|
||||||
const first = new HostTabSync({
|
|
||||||
tabId: "tab-a",
|
|
||||||
channel: bus.createChannel(),
|
|
||||||
getState: () => firstState,
|
|
||||||
applyState: () => {},
|
|
||||||
});
|
|
||||||
const second = new HostTabSync({
|
|
||||||
tabId: "tab-b",
|
|
||||||
channel: bus.createChannel(),
|
|
||||||
getState: () => hostState("http://runtime-b.test"),
|
|
||||||
applyState: (state) => received.push(state),
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.deepEqual(received, [firstState]);
|
|
||||||
first.close();
|
|
||||||
second.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("publishes Host state but rejects self, stale and malformed messages", () => {
|
|
||||||
const bus = new FakeBus();
|
|
||||||
const received = [];
|
|
||||||
const first = new HostTabSync({
|
|
||||||
tabId: "tab-a",
|
|
||||||
channel: bus.createChannel(),
|
|
||||||
getState: () => hostState(""),
|
|
||||||
applyState: (state) => received.push(state),
|
|
||||||
});
|
|
||||||
const second = new HostTabSync({
|
|
||||||
tabId: "tab-b",
|
|
||||||
channel: bus.createChannel(),
|
|
||||||
getState: () => hostState(""),
|
|
||||||
applyState: () => {},
|
|
||||||
});
|
|
||||||
received.length = 0;
|
|
||||||
|
|
||||||
const update = hostState("http://shared.test");
|
|
||||||
second.publish(update);
|
|
||||||
assert.deepEqual(received, [update]);
|
|
||||||
|
|
||||||
const stateMessage = bus.messages.findLast(
|
|
||||||
(message) => message.type === "state" && message.sender === "tab-b",
|
|
||||||
);
|
|
||||||
bus.replay(stateMessage);
|
|
||||||
bus.replay({
|
|
||||||
...stateMessage,
|
|
||||||
sequence: stateMessage.sequence + 1,
|
|
||||||
state: { ...stateMessage.state, connection: "invalid" },
|
|
||||||
});
|
|
||||||
assert.deepEqual(received, [update]);
|
|
||||||
|
|
||||||
first.close();
|
|
||||||
second.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
function hostState(apiBase) {
|
|
||||||
return {
|
|
||||||
snapshot: null,
|
|
||||||
connection: "online",
|
|
||||||
apiBase,
|
|
||||||
runtimeAction: null,
|
|
||||||
serviceAction: null,
|
|
||||||
deploymentAction: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -515,15 +515,6 @@ impl ResidentSession {
|
|||||||
self.actor.key()
|
self.actor.key()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the largest stream chunk that a driver may deliver.
|
|
||||||
///
|
|
||||||
/// A stream driver should size its read buffer to this value or a smaller
|
|
||||||
/// protocol-specific limit. The Runtime applies the same limit to both
|
|
||||||
/// incoming `stream-data` events and Component `write-stream` effects.
|
|
||||||
pub fn max_stream_chunk_bytes(&self) -> usize {
|
|
||||||
self.actor.resident_stream_chunk_limit()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Registers a configured listener or datagram endpoint after policy checks.
|
/// Registers a configured listener or datagram endpoint after policy checks.
|
||||||
///
|
///
|
||||||
/// Registration does not bind an operating-system socket. The driver binds
|
/// Registration does not bind an operating-system socket. The driver binds
|
||||||
@@ -673,7 +664,6 @@ impl ResidentSession {
|
|||||||
reason: StreamCloseReason,
|
reason: StreamCloseReason,
|
||||||
) -> Result<Vec<ResidentOperation>, ResidentHostError> {
|
) -> Result<Vec<ResidentOperation>, ResidentHostError> {
|
||||||
self.ensure_kind(stream_id, ResidentResourceKind::Stream, "stream")?;
|
self.ensure_kind(stream_id, ResidentResourceKind::Stream, "stream")?;
|
||||||
*self.stream_state_mut(stream_id)? = StreamState::Closing;
|
|
||||||
let result = self.dispatch(ResidentEvent::StreamClosed { stream_id, reason });
|
let result = self.dispatch(ResidentEvent::StreamClosed { stream_id, reason });
|
||||||
self.resources.remove(&stream_id);
|
self.resources.remove(&stream_id);
|
||||||
result
|
result
|
||||||
@@ -1066,13 +1056,7 @@ impl ResidentSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl ResidentPolicy {
|
impl ResidentPolicy {
|
||||||
/// Validates one endpoint without registering or binding it.
|
fn validate_endpoint(&self, endpoint: &ResidentEndpoint) -> Result<(), ResidentHostError> {
|
||||||
///
|
|
||||||
/// Management layers can call this while loading static configuration so
|
|
||||||
/// an address outside policy fails before any Actor is started. Drivers
|
|
||||||
/// must still register the endpoint immediately before bind because policy
|
|
||||||
/// is also a runtime ownership boundary.
|
|
||||||
pub fn validate_endpoint(&self, endpoint: &ResidentEndpoint) -> Result<(), ResidentHostError> {
|
|
||||||
match endpoint {
|
match endpoint {
|
||||||
ResidentEndpoint::Tcp { bind, .. } if !self.tcp_listen.allows(*bind) => {
|
ResidentEndpoint::Tcp { bind, .. } if !self.tcp_listen.allows(*bind) => {
|
||||||
Err(ResidentHostError::EndpointDenied(format!(
|
Err(ResidentHostError::EndpointDenied(format!(
|
||||||
|
|||||||
@@ -615,33 +615,10 @@ impl ActorHandle {
|
|||||||
self.execution
|
self.execution
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns whether two handles address the exact same Actor instance.
|
|
||||||
///
|
|
||||||
/// Service keys are revision identities and therefore survive an Actor
|
|
||||||
/// fault/restart. Host resource supervisors use this stronger check to
|
|
||||||
/// retain themselves for an idempotent start but replace stale resources
|
|
||||||
/// when the Runtime created a fresh Store for the same revision.
|
|
||||||
pub fn is_same_instance(&self, other: &Self) -> bool {
|
|
||||||
Arc::ptr_eq(&self.status, &other.status)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns the immutable mailbox capacity configured for this Actor.
|
|
||||||
///
|
|
||||||
/// Host supervisors use the same bound for their external event queue so a
|
|
||||||
/// restored revision retains the limits stored in its own manifest instead
|
|
||||||
/// of inheriting newer process defaults.
|
|
||||||
pub fn mailbox_capacity(&self) -> usize {
|
|
||||||
self.limits.mailbox_capacity
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn resident_resource_limit(&self) -> usize {
|
pub(crate) fn resident_resource_limit(&self) -> usize {
|
||||||
self.limits.resident.max_resources
|
self.limits.resident.max_resources
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn resident_stream_chunk_limit(&self) -> usize {
|
|
||||||
self.limits.resident.max_stream_chunk_bytes
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Sends an invocation and waits up to the remaining service deadline.
|
/// Sends an invocation and waits up to the remaining service deadline.
|
||||||
///
|
///
|
||||||
/// Cloned handles compete for the same bounded mailbox. A Wasm trap is
|
/// Cloned handles compete for the same bounded mailbox. A Wasm trap is
|
||||||
|
|||||||
@@ -96,16 +96,12 @@ fn faulted_actor_can_be_started_again() {
|
|||||||
let runtime = Runtime::new(RuntimeConfig::default()).expect("runtime should start");
|
let runtime = Runtime::new(RuntimeConfig::default()).expect("runtime should start");
|
||||||
let (key, actor) = start_component(&runtime, "fault", "fault_component.wasm");
|
let (key, actor) = start_component(&runtime, "fault", "fault_component.wasm");
|
||||||
|
|
||||||
let already_running = runtime.start(&key, Vec::new()).unwrap();
|
|
||||||
assert!(actor.is_same_instance(&already_running));
|
|
||||||
|
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
actor.invoke(b"fault".to_vec()),
|
actor.invoke(b"fault".to_vec()),
|
||||||
Err(RuntimeError::ActorFault { .. })
|
Err(RuntimeError::ActorFault { .. })
|
||||||
));
|
));
|
||||||
|
|
||||||
let restarted = runtime.start(&key, Vec::new()).unwrap();
|
let restarted = runtime.start(&key, Vec::new()).unwrap();
|
||||||
assert!(!actor.is_same_instance(&restarted));
|
|
||||||
assert_eq!(restarted.invoke(b"ready".to_vec()).unwrap(), b"ready");
|
assert_eq!(restarted.invoke(b"ready".to_vec()).unwrap(), b"ready");
|
||||||
|
|
||||||
runtime.stop(&key).unwrap();
|
runtime.stop(&key).unwrap();
|
||||||
|
|||||||
Reference in New Issue
Block a user