diff --git a/apps/preview-deployer-web/src/PreviewDeployerApp.test.tsx b/apps/preview-deployer-web/src/PreviewDeployerApp.test.tsx
index d0b68bc21..261e5a6ae 100644
--- a/apps/preview-deployer-web/src/PreviewDeployerApp.test.tsx
+++ b/apps/preview-deployer-web/src/PreviewDeployerApp.test.tsx
@@ -86,6 +86,7 @@ test('shows health and web url, then confirms uninstall', async () => {
resolvedCommit: '1234567890abcdef',
status: 'running',
health: 'healthy',
+ webPort: 8400,
webUrl: 'http://192.168.35.82:8400',
createdAt: 1_787_270_400,
updatedAt: 1_787_270_460,
@@ -94,6 +95,7 @@ test('shows health and web url, then confirms uninstall', async () => {
render();
expect(await screen.findByText('健康')).toBeTruthy();
+ expect(screen.getByText('端口 8400')).toBeTruthy();
expect(
screen.getByRole('link', { name: /打开 Web/u }).getAttribute('href'),
).toBe('http://192.168.35.82:8400');
diff --git a/apps/preview-deployer-web/src/PreviewDeployerApp.tsx b/apps/preview-deployer-web/src/PreviewDeployerApp.tsx
index 290e6d3cf..2bfd2482a 100644
--- a/apps/preview-deployer-web/src/PreviewDeployerApp.tsx
+++ b/apps/preview-deployer-web/src/PreviewDeployerApp.tsx
@@ -530,6 +530,9 @@ function DeploymentCard({
{HEALTH_LABELS[deployment.health]}
+ {deployment.webPort ? (
+ 端口 {deployment.webPort}
+ ) : null}
diff --git a/apps/preview-deployer-web/src/styles.css b/apps/preview-deployer-web/src/styles.css
index 3316766b6..a7c3a531c 100644
--- a/apps/preview-deployer-web/src/styles.css
+++ b/apps/preview-deployer-web/src/styles.css
@@ -381,6 +381,11 @@ a {
font-size: 11px;
font-weight: 700;
}
+.port-badge {
+ color: #475569;
+ background: #f1f5f9;
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+}
.badge-running {
color: #14734a;
background: #e9f9f0;
diff --git a/apps/preview-deployer-web/src/types.ts b/apps/preview-deployer-web/src/types.ts
index d538aee26..da08df216 100644
--- a/apps/preview-deployer-web/src/types.ts
+++ b/apps/preview-deployer-web/src/types.ts
@@ -17,6 +17,7 @@ export interface PreviewDeployment {
resolvedCommit?: string | null;
status: DeploymentStatus;
health: DeploymentHealth;
+ webPort?: number | null;
webUrl?: string | null;
jenkinsBuildUrl?: string | null;
createdAt: string | number;
diff --git a/docs/technical/【开发运维】Jenkins容器预览部署控制面技术方案-2026-08-15.md b/docs/technical/【开发运维】Jenkins容器预览部署控制面技术方案-2026-08-15.md
index 786295289..cb68b9542 100644
--- a/docs/technical/【开发运维】Jenkins容器预览部署控制面技术方案-2026-08-15.md
+++ b/docs/technical/【开发运维】Jenkins容器预览部署控制面技术方案-2026-08-15.md
@@ -92,6 +92,8 @@ SpacetimeDB 2.7 CLI 发布到受控 Compose 网络地址时固定使用 `--yes=r
页面状态统一为 `queued / building / deploying / running / uninstalling / stopped / failed / cancelled`,健康状态统一为 `pending / healthy / unhealthy / unknown`。
+发布记录卡片直接展示后端校验后的 `webPort`。卸载成功的 `stopped` 记录仍保留在服务端持久状态中用于审计和所有权校验,但列表 API 不再返回,页面刷新后立即从发布记录中消失。
+
Jenkins 在构建完成、归档 artifact 和更新 REST 状态之间可能短暂返回不可解析的状态正文。控制服务对队列、构建状态和 artifact 查询执行有限重试;单次瞬态响应不得把已经成功并健康的部署永久写成 `failed`。
控制服务查询 Jenkins 队列与构建状态时必须使用 `tree` 参数限制到所需字段,避免完整 `api/json` 的大体积深层对象触发 JSON 递归深度限制。预览 Compose 中的外部生成 worker 使用 `restart: on-failure`;它若早于 API 完成模型定价运行时身份初始化而启动失败,应由 Docker 自动重启并在身份就绪后稳定运行。
diff --git a/server-rs/crates/preview-deployer-server/src/jenkins.rs b/server-rs/crates/preview-deployer-server/src/jenkins.rs
index 74cf27217..9bb553a68 100644
--- a/server-rs/crates/preview-deployer-server/src/jenkins.rs
+++ b/server-rs/crates/preview-deployer-server/src/jenkins.rs
@@ -58,6 +58,7 @@ pub struct PreviewResult {
pub health: Option,
pub phase: Option,
pub health_status: Option,
+ pub web_port: Option,
pub web_url: Option,
pub message: Option,
}
diff --git a/server-rs/crates/preview-deployer-server/src/lib.rs b/server-rs/crates/preview-deployer-server/src/lib.rs
index 11e79db54..0c021b2b0 100644
--- a/server-rs/crates/preview-deployer-server/src/lib.rs
+++ b/server-rs/crates/preview-deployer-server/src/lib.rs
@@ -24,6 +24,7 @@ use tower_http::{
trace::TraceLayer,
};
use tracing::{error, warn};
+use url::Url;
use uuid::Uuid;
pub use config::Config;
@@ -86,6 +87,8 @@ pub struct Deployment {
pub resolved_commit: Option,
pub status: DeploymentStatus,
pub health: HealthStatus,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub web_port: Option,
#[serde(skip_serializing_if = "Option::is_none")]
pub web_url: Option,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -385,6 +388,7 @@ async fn list_deployments(
.read()
.await
.values()
+ .filter(|record| record.public.status != DeploymentStatus::Stopped)
.map(|record| record.public.clone())
.collect();
deployments.sort_by(|left, right| right.created_at.cmp(&left.created_at));
@@ -494,6 +498,7 @@ async fn create_deployment(
resolved_commit: None,
status: DeploymentStatus::Queued,
health: HealthStatus::Pending,
+ web_port: None,
web_url: None,
jenkins_build_url: None,
created_at: now,
@@ -770,8 +775,23 @@ async fn apply_outcome(state: &AppState, id: &str, outcome: JenkinsOutcome) {
record.public.status = DeploymentStatus::Failed;
record.public.health = HealthStatus::Unknown;
record.public.message = Some("Jenkins 返回了无效 Web 地址".to_string());
+ } else if result
+ .web_port
+ .is_some_and(|value| !(8400..=8499).contains(&value))
+ || result
+ .web_url
+ .as_deref()
+ .zip(result.web_port)
+ .is_some_and(|(url, port)| {
+ Url::parse(url).ok().and_then(|url| url.port()) != Some(port)
+ })
+ {
+ record.public.status = DeploymentStatus::Failed;
+ record.public.health = HealthStatus::Unknown;
+ record.public.message = Some("Jenkins 返回了无效 Web 端口".to_string());
} else if record.operation == Operation::Deploy
&& (result.resolved_commit.is_none()
+ || result.web_port.is_none()
|| result.web_url.is_none()
|| result.phase.as_deref() != Some("RUNNING")
|| result.health_status.as_deref() != Some("HEALTHY"))
@@ -794,6 +814,9 @@ async fn apply_outcome(state: &AppState, id: &str, outcome: JenkinsOutcome) {
if let Some(value) = result.web_url {
record.public.web_url = Some(value);
}
+ if let Some(value) = result.web_port {
+ record.public.web_port = Some(value);
+ }
if let Some(value) = result.health {
record.public.health = value;
}
@@ -838,6 +861,7 @@ async fn apply_outcome(state: &AppState, id: &str, outcome: JenkinsOutcome) {
Operation::Uninstall => {
record.public.status = DeploymentStatus::Stopped;
record.public.health = HealthStatus::Unknown;
+ record.public.web_port = None;
record.public.web_url = None;
record.public.can_uninstall = false;
if record.public.message.is_none() {
@@ -877,7 +901,7 @@ fn load_deployments(config: &Config) -> Result
return Err("预览部署状态文件 schemaVersion 不受支持".to_string());
}
let mut deployments = HashMap::new();
- for record in persisted.deployments {
+ for mut record in persisted.deployments {
validate_deployment_id(&record.public.id)
.map_err(|_| "状态文件包含无效部署 ID".to_string())?;
let branch = validate_branch(&record.public.branch)
@@ -896,6 +920,27 @@ fn load_deployments(config: &Config) -> Result
{
return Err("状态文件包含无效 Web 地址".to_string());
}
+ let url_port = record
+ .public
+ .web_url
+ .as_deref()
+ .and_then(|value| Url::parse(value).ok())
+ .and_then(|url| url.port());
+ if record
+ .public
+ .web_port
+ .is_some_and(|value| !(8400..=8499).contains(&value))
+ || record
+ .public
+ .web_port
+ .zip(url_port)
+ .is_some_and(|(saved, parsed)| saved != parsed)
+ {
+ return Err("状态文件包含无效 Web 端口".to_string());
+ }
+ if record.public.web_port.is_none() {
+ record.public.web_port = url_port;
+ }
if deployments
.insert(record.public.id.clone(), record)
.is_some()
diff --git a/server-rs/crates/preview-deployer-server/src/tests.rs b/server-rs/crates/preview-deployer-server/src/tests.rs
index dc13bf960..a7d25d42d 100644
--- a/server-rs/crates/preview-deployer-server/src/tests.rs
+++ b/server-rs/crates/preview-deployer-server/src/tests.rs
@@ -133,6 +133,7 @@ async fn mock_artifact(axum::extract::Path(build): axum::extract::Path) ->
"resolvedCommit": "0123456789abcdef0123456789abcdef01234567",
"phase": "RUNNING",
"healthStatus": "HEALTHY",
+ "webPort": 8400,
"webUrl": "http://192.168.35.82:8400",
"message": "预览实例已发布"
}))
@@ -365,6 +366,7 @@ async fn deploy_and_uninstall_use_fixed_job_and_apply_owned_artifacts() {
.clone();
assert_eq!(deployment.status, super::DeploymentStatus::Running);
assert_eq!(deployment.health, super::HealthStatus::Healthy);
+ assert_eq!(deployment.web_port, Some(8400));
assert_eq!(
deployment.web_url.as_deref(),
Some("http://192.168.35.82:8400")
@@ -421,8 +423,27 @@ async fn deploy_and_uninstall_use_fixed_job_and_apply_owned_artifacts() {
.clone();
assert_eq!(deployment.status, super::DeploymentStatus::Stopped);
assert_eq!(deployment.health, super::HealthStatus::Unknown);
+ assert_eq!(deployment.web_port, None);
assert_eq!(deployment.web_url, None);
assert!(!deployment.can_uninstall);
+
+ let list_request = axum::http::Request::builder()
+ .method("GET")
+ .uri("/api/preview-deployer/deployments")
+ .header(header::HOST, HOST)
+ .header(header::COOKIE, &cookie)
+ .body(Body::empty())
+ .unwrap();
+ let list_response = app.clone().oneshot(list_request).await.unwrap();
+ assert_eq!(list_response.status(), StatusCode::OK);
+ let list_body = list_response
+ .into_body()
+ .collect()
+ .await
+ .unwrap()
+ .to_bytes();
+ let list: Value = serde_json::from_slice(&list_body).unwrap();
+ assert_eq!(list["deployments"], json!([]));
let state_file = state.config.state_file.clone();
let mut recovered_config = test_config(state.config.jenkins_root_url.clone());
recovered_config.state_file = state_file.clone();
@@ -499,6 +520,7 @@ async fn duplicate_active_branch_is_rejected_without_second_jenkins_trigger() {
resolved_commit: None,
status: super::DeploymentStatus::Building,
health: super::HealthStatus::Pending,
+ web_port: None,
web_url: None,
jenkins_build_url: None,
created_at: now,
@@ -576,3 +598,43 @@ fn branch_commit_and_web_url_validation_are_strict() {
"192.168.35.82"
));
}
+
+#[test]
+fn legacy_running_state_recovers_web_port_from_validated_url() {
+ let config = test_config(Url::parse("http://127.0.0.1:18080/jenkins/").unwrap());
+ let id = super::derive_deployment_id("feature/legacy-running");
+ std::fs::write(
+ &config.state_file,
+ serde_json::to_vec(&json!({
+ "schemaVersion": 1,
+ "deployments": [{
+ "public": {
+ "id": id,
+ "branch": "feature/legacy-running",
+ "status": "running",
+ "health": "healthy",
+ "webUrl": "http://192.168.35.82:8407",
+ "createdAt": 1,
+ "updatedAt": 2,
+ "canUninstall": true
+ },
+ "operation": "deploy"
+ }]
+ }))
+ .unwrap(),
+ )
+ .unwrap();
+
+ let state = AppState::new(config).unwrap();
+ assert_eq!(
+ state
+ .deployments
+ .blocking_read()
+ .get(&id)
+ .unwrap()
+ .public
+ .web_port,
+ Some(8407)
+ );
+ std::fs::remove_file(&state.config.state_file).unwrap();
+}