From ced4b56dee0e70bf56edb69ed1d6513ba6c69f84 Mon Sep 17 00:00:00 2001 From: kdletters Date: Mon, 17 Aug 2026 10:47:58 +0800 Subject: [PATCH 1/2] =?UTF-8?q?=E4=BC=98=E5=8C=96=E9=A2=84=E8=A7=88?= =?UTF-8?q?=E5=8F=91=E5=B8=83=E8=AE=B0=E5=BD=95=E5=B1=95=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 发布记录增加后端校验后的 Web 端口号 列表隐藏已成功卸载的容器记录 兼容恢复旧运行记录并回填 Web 端口 补充后端、前端测试和技术说明 --- .../src/PreviewDeployerApp.test.tsx | 2 + .../src/PreviewDeployerApp.tsx | 3 + apps/preview-deployer-web/src/styles.css | 5 ++ apps/preview-deployer-web/src/types.ts | 1 + ...Jenkins容器预览部署控制面技术方案-2026-08-15.md | 2 + .../preview-deployer-server/src/jenkins.rs | 1 + .../crates/preview-deployer-server/src/lib.rs | 47 +++++++++++++- .../preview-deployer-server/src/tests.rs | 62 +++++++++++++++++++ 8 files changed, 122 insertions(+), 1 deletion(-) 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(); +} From abb39125303d326c92083512c3dbb6ee3bf306e9 Mon Sep 17 00:00:00 2001 From: kdletters Date: Mon, 17 Aug 2026 12:04:31 +0800 Subject: [PATCH 2/2] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E5=88=86=E6=94=AF?= =?UTF-8?q?=E4=B8=8E=E6=8F=90=E4=BA=A4=E6=90=9C=E7=B4=A2=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 增加分支和 commit 的输入搜索下拉构建前复核固定仓库中的分支与 commit 归属补充预览控制面接口合同、配置和测试 --- .../src/PreviewDeployerApp.test.tsx | 85 ++++ .../src/PreviewDeployerApp.tsx | 380 +++++++++++++++++- apps/preview-deployer-web/src/api.test.ts | 44 ++ apps/preview-deployer-web/src/api.ts | 31 ++ apps/preview-deployer-web/src/styles.css | 67 ++- deploy/env/preview-deployer.env.example | 4 + ...Jenkins容器预览部署控制面技术方案-2026-08-15.md | 5 + ...发运维】本地开发验证与生产运维-2026-05-15.md | 2 +- .../crates/preview-deployer-server/Cargo.toml | 2 +- .../preview-deployer-server/src/config.rs | 40 +- .../preview-deployer-server/src/git_refs.rs | 282 +++++++++++++ .../crates/preview-deployer-server/src/lib.rs | 153 ++++++- .../preview-deployer-server/src/tests.rs | 76 +++- 13 files changed, 1147 insertions(+), 24 deletions(-) create mode 100644 server-rs/crates/preview-deployer-server/src/git_refs.rs diff --git a/apps/preview-deployer-web/src/PreviewDeployerApp.test.tsx b/apps/preview-deployer-web/src/PreviewDeployerApp.test.tsx index 261e5a6ae..21f2b900d 100644 --- a/apps/preview-deployer-web/src/PreviewDeployerApp.test.tsx +++ b/apps/preview-deployer-web/src/PreviewDeployerApp.test.tsx @@ -24,6 +24,8 @@ beforeEach(() => { vi.mocked(api.createSession).mockResolvedValue({ authenticated: true }); vi.mocked(api.deleteSession).mockResolvedValue(undefined); vi.mocked(api.listDeployments).mockResolvedValue([]); + vi.mocked(api.searchBranches).mockResolvedValue([]); + vi.mocked(api.searchCommits).mockResolvedValue([]); vi.mocked(api.createDeployment).mockResolvedValue({ id: 'preview-1', branch: 'master', @@ -108,3 +110,86 @@ test('shows health and web url, then confirms uninstall', async () => { expect(api.uninstallDeployment).toHaveBeenCalledWith('preview-2'); }); }); + +test('shows debounced branch results and selects one with the keyboard', async () => { + const user = userEvent.setup(); + vi.mocked(api.searchBranches).mockResolvedValue([ + { name: 'feature/search-one', commitHash: '111111111111' }, + { name: 'feature/search-two', commitHash: '222222222222' }, + ]); + render(); + + const branchInput = await screen.findByRole('combobox', { name: '分支名' }); + await user.clear(branchInput); + await user.type(branchInput, 'feature/search'); + + expect( + await screen.findByRole('option', { name: /feature\/search-one/u }), + ).toBeTruthy(); + expect(api.searchBranches).toHaveBeenLastCalledWith( + 'feature/search', + expect.any(AbortSignal), + ); + await user.keyboard('{ArrowDown}{ArrowDown}{Enter}'); + expect((branchInput as HTMLInputElement).value).toBe('feature/search-two'); +}); + +test('searches commits in the current branch and selects a result', async () => { + const user = userEvent.setup(); + vi.mocked(api.searchCommits).mockResolvedValue([ + { + commitHash: 'aa5221abcdef0123456789', + shortHash: 'aa5221a', + subject: '补充预览搜索', + }, + ]); + render(); + + const commitInput = await screen.findByRole('combobox', { + name: 'Commit Hash', + }); + await user.type(commitInput, 'aa5221a'); + + const option = await screen.findByRole('option', { + name: /aa5221a.*补充预览搜索/u, + }); + expect(api.searchCommits).toHaveBeenCalledWith( + 'master', + 'aa5221a', + expect.any(AbortSignal), + ); + await user.click(option); + expect((commitInput as HTMLInputElement).value).toBe( + 'aa5221abcdef0123456789', + ); +}); + +test('clears commit and ignores stale commit responses when branch changes', async () => { + const user = userEvent.setup(); + let resolveOldSearch: ((items: api.CommitRef[]) => void) | undefined; + vi.mocked(api.searchCommits).mockImplementation( + () => + new Promise((resolve) => { + resolveOldSearch = resolve; + }), + ); + render(); + + const branchInput = await screen.findByRole('combobox', { name: '分支名' }); + const commitInput = screen.getByRole('combobox', { name: 'Commit Hash' }); + await user.type(commitInput, 'abcdef1'); + await waitFor(() => expect(api.searchCommits).toHaveBeenCalled()); + await user.clear(branchInput); + await user.type(branchInput, 'feature/new'); + + expect((commitInput as HTMLInputElement).value).toBe(''); + resolveOldSearch?.([ + { + commitHash: 'abcdef1234567890', + shortHash: 'abcdef1', + subject: '旧分支提交', + }, + ]); + await Promise.resolve(); + expect(screen.queryByText('旧分支提交')).toBeNull(); +}); diff --git a/apps/preview-deployer-web/src/PreviewDeployerApp.tsx b/apps/preview-deployer-web/src/PreviewDeployerApp.tsx index 2bfd2482a..45f7e7d80 100644 --- a/apps/preview-deployer-web/src/PreviewDeployerApp.tsx +++ b/apps/preview-deployer-web/src/PreviewDeployerApp.tsx @@ -13,15 +13,27 @@ import { TriangleAlert, XCircle, } from 'lucide-react'; -import { FormEvent, useCallback, useEffect, useMemo, useState } from 'react'; +import { + FormEvent, + KeyboardEvent as ReactKeyboardEvent, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; import { + type BranchRef, + type CommitRef, createDeployment, createSession, deleteSession, getSession, listDeployments, PreviewDeployerApiError, + searchBranches, + searchCommits, uninstallDeployment, } from './api'; import type { @@ -32,6 +44,7 @@ import type { import { validateBranch, validateCommitHash } from './validation'; const POLL_INTERVAL_MS = 5000; +const SEARCH_DEBOUNCE_MS = 300; const STATUS_LABELS: Record = { queued: '排队中', @@ -66,6 +79,16 @@ export function PreviewDeployerApp() { const [submitting, setSubmitting] = useState(false); const [notice, setNotice] = useState(''); const [error, setError] = useState(''); + const [branchSearch, setBranchSearch] = + useState>(emptySearchState()); + const [commitSearch, setCommitSearch] = + useState>(emptySearchState()); + const [branchActiveIndex, setBranchActiveIndex] = useState(-1); + const [commitActiveIndex, setCommitActiveIndex] = useState(-1); + const [branchSearchEnabled, setBranchSearchEnabled] = useState(false); + const [commitSearchEnabled, setCommitSearchEnabled] = useState(false); + const branchRequestId = useRef(0); + const commitRequestId = useRef(0); const [pendingUninstall, setPendingUninstall] = useState(null); const [uninstallingId, setUninstallingId] = useState(''); @@ -129,6 +152,94 @@ export function PreviewDeployerApp() { return () => window.clearInterval(interval); }, [refresh, sessionStatus]); + useEffect(() => { + const query = branch.trim(); + const requestId = ++branchRequestId.current; + const controller = new AbortController(); + if (sessionStatus !== 'authenticated' || !branchSearchEnabled || !query) { + setBranchSearch(emptySearchState()); + setBranchActiveIndex(-1); + return () => controller.abort(); + } + setBranchSearch({ items: [], loading: true, open: true, error: '' }); + setBranchActiveIndex(-1); + const timer = window.setTimeout(() => { + void searchBranches(query, controller.signal) + .then((items) => { + if (requestId !== branchRequestId.current) return; + setBranchSearch({ items, loading: false, open: true, error: '' }); + }) + .catch((searchError) => { + if ( + controller.signal.aborted || + requestId !== branchRequestId.current + ) + return; + if (isUnauthorized(searchError)) { + handleUnauthorized(); + return; + } + setBranchSearch({ + items: [], + loading: false, + open: true, + error: formatError(searchError), + }); + }); + }, SEARCH_DEBOUNCE_MS); + return () => { + window.clearTimeout(timer); + controller.abort(); + }; + }, [branch, branchSearchEnabled, sessionStatus]); + + useEffect(() => { + const query = commitHash.trim(); + const selectedBranch = branch.trim(); + const requestId = ++commitRequestId.current; + const controller = new AbortController(); + if ( + sessionStatus !== 'authenticated' || + !commitSearchEnabled || + !query || + validateBranch(selectedBranch) + ) { + setCommitSearch(emptySearchState()); + setCommitActiveIndex(-1); + return () => controller.abort(); + } + setCommitSearch({ items: [], loading: true, open: true, error: '' }); + setCommitActiveIndex(-1); + const timer = window.setTimeout(() => { + void searchCommits(selectedBranch, query, controller.signal) + .then((items) => { + if (requestId !== commitRequestId.current) return; + setCommitSearch({ items, loading: false, open: true, error: '' }); + }) + .catch((searchError) => { + if ( + controller.signal.aborted || + requestId !== commitRequestId.current + ) + return; + if (isUnauthorized(searchError)) { + handleUnauthorized(); + return; + } + setCommitSearch({ + items: [], + loading: false, + open: true, + error: formatError(searchError), + }); + }); + }, SEARCH_DEBOUNCE_MS); + return () => { + window.clearTimeout(timer); + controller.abort(); + }; + }, [branch, commitHash, commitSearchEnabled, sessionStatus]); + const runningCount = useMemo( () => deployments.filter((deployment) => deployment.status === 'running') @@ -356,36 +467,166 @@ export function PreviewDeployerApp() { className="deploy-form" onSubmit={(event) => void handleSubmit(event)} > -