chore: remove update center
This commit is contained in:
@@ -36,7 +36,6 @@ mod search;
|
|||||||
mod site_announcements;
|
mod site_announcements;
|
||||||
pub(crate) mod stats;
|
pub(crate) mod stats;
|
||||||
mod tasks;
|
mod tasks;
|
||||||
mod update_center;
|
|
||||||
mod user;
|
mod user;
|
||||||
mod user_api_keys;
|
mod user_api_keys;
|
||||||
#[path = "video_agent_legacy.rs"]
|
#[path = "video_agent_legacy.rs"]
|
||||||
@@ -72,7 +71,6 @@ pub fn build_router(state: AppState) -> Router {
|
|||||||
.merge(workflow_ui::routes())
|
.merge(workflow_ui::routes())
|
||||||
.merge(stats::routes())
|
.merge(stats::routes())
|
||||||
.merge(tasks::routes())
|
.merge(tasks::routes())
|
||||||
.merge(update_center::routes())
|
|
||||||
.merge(user::routes())
|
.merge(user::routes())
|
||||||
.merge(user_api_keys::routes())
|
.merge(user_api_keys::routes())
|
||||||
.merge(workspace::routes())
|
.merge(workspace::routes())
|
||||||
|
|||||||
@@ -1,18 +1,11 @@
|
|||||||
// Background tasks routes — list and inspect async operations
|
// Background tasks routes — list and inspect async operations
|
||||||
|
|
||||||
use std::convert::Infallible;
|
|
||||||
|
|
||||||
use crate::{db::models::background_task::BackgroundTask, error::AppResult, AppState};
|
use crate::{db::models::background_task::BackgroundTask, error::AppResult, AppState};
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Path, Query, State},
|
extract::{Path, Query, State},
|
||||||
response::{
|
|
||||||
sse::{Event, Sse},
|
|
||||||
IntoResponse,
|
|
||||||
},
|
|
||||||
routing::get,
|
routing::get,
|
||||||
Json, Router,
|
Json, Router,
|
||||||
};
|
};
|
||||||
use futures::stream::Stream;
|
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
@@ -26,10 +19,6 @@ pub fn routes() -> Router<AppState> {
|
|||||||
Router::new()
|
Router::new()
|
||||||
.route("/api/tasks", get(list_tasks))
|
.route("/api/tasks", get(list_tasks))
|
||||||
.route("/api/tasks/{id}", get(get_task))
|
.route("/api/tasks/{id}", get(get_task))
|
||||||
.route(
|
|
||||||
"/api/update-center/tasks/{id}/stream",
|
|
||||||
get(stream_task_logs),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// GET /api/tasks — list background tasks
|
/// GET /api/tasks — list background tasks
|
||||||
@@ -67,113 +56,3 @@ async fn get_task(State(state): State<AppState>, Path(id): Path<String>) -> AppR
|
|||||||
)),
|
)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// GET /api/update-center/tasks/{id}/stream — SSE stream of task progress
|
|
||||||
async fn stream_task_logs(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
Path(id): Path<String>,
|
|
||||||
) -> Result<Sse<impl Stream<Item = Result<Event, Infallible>>>, axum::response::Response> {
|
|
||||||
info!("Streaming task logs for: {}", id);
|
|
||||||
|
|
||||||
// Check task exists
|
|
||||||
let task: Option<BackgroundTask> =
|
|
||||||
sqlx::query_as::<_, BackgroundTask>("SELECT * FROM background_tasks WHERE id = ?")
|
|
||||||
.bind(&id)
|
|
||||||
.fetch_optional(&*state.db)
|
|
||||||
.await
|
|
||||||
.map_err(|e| {
|
|
||||||
(
|
|
||||||
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
format!("DB error: {e}"),
|
|
||||||
)
|
|
||||||
.into_response()
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let task = match task {
|
|
||||||
Some(t) => t,
|
|
||||||
None => {
|
|
||||||
return Err((
|
|
||||||
axum::http::StatusCode::NOT_FOUND,
|
|
||||||
json!({ "success": false, "message": "task not found" }).to_string(),
|
|
||||||
)
|
|
||||||
.into_response());
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Stream task state with polling
|
|
||||||
let initial_status = task.status.clone();
|
|
||||||
let is_done = initial_status != "pending" && initial_status != "running";
|
|
||||||
|
|
||||||
let state_clone = state.clone();
|
|
||||||
let stream = async_stream::stream! {
|
|
||||||
// Yield current task status
|
|
||||||
yield Ok::<_, Infallible>(Event::default()
|
|
||||||
.event("status")
|
|
||||||
.data(json!({ "status": initial_status }).to_string()));
|
|
||||||
|
|
||||||
yield Ok::<_, Infallible>(Event::default()
|
|
||||||
.event("log")
|
|
||||||
.data(json!({
|
|
||||||
"seq": 1,
|
|
||||||
"message": format!("Task {}: {}", initial_status, task.message.as_deref().unwrap_or("")),
|
|
||||||
"createdAt": task.created_at.as_deref().unwrap_or(""),
|
|
||||||
}).to_string()));
|
|
||||||
|
|
||||||
if is_done {
|
|
||||||
yield Ok::<_, Infallible>(Event::default()
|
|
||||||
.event("done")
|
|
||||||
.data(json!({ "status": initial_status }).to_string()));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Poll for updates
|
|
||||||
let poll_id = id.clone();
|
|
||||||
for i in 0..120 { // Poll up to 120 times (2 minutes)
|
|
||||||
tokio::time::sleep(tokio::time::Duration::from_millis(1000)).await;
|
|
||||||
|
|
||||||
let current: Option<BackgroundTask> = sqlx::query_as(
|
|
||||||
"SELECT * FROM background_tasks WHERE id = ?"
|
|
||||||
)
|
|
||||||
.bind(&poll_id)
|
|
||||||
.fetch_optional(&*state_clone.db)
|
|
||||||
.await
|
|
||||||
.ok()
|
|
||||||
.flatten();
|
|
||||||
|
|
||||||
match current {
|
|
||||||
Some(t) => {
|
|
||||||
let status = t.status;
|
|
||||||
let updated_at = t.updated_at.unwrap_or_default();
|
|
||||||
|
|
||||||
yield Ok::<_, Infallible>(Event::default()
|
|
||||||
.event("log")
|
|
||||||
.data(json!({
|
|
||||||
"seq": i + 2,
|
|
||||||
"message": format!("Status: {} — {}", status, t.message.as_deref().unwrap_or("")),
|
|
||||||
"createdAt": updated_at,
|
|
||||||
}).to_string()));
|
|
||||||
|
|
||||||
if status != "pending" && status != "running" {
|
|
||||||
yield Ok::<_, Infallible>(Event::default()
|
|
||||||
.event("done")
|
|
||||||
.data(json!({ "status": status }).to_string()));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
yield Ok::<_, Infallible>(Event::default()
|
|
||||||
.event("done")
|
|
||||||
.data(json!({ "status": "unknown", "message": "task removed" }).to_string()));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Timeout
|
|
||||||
yield Ok::<_, Infallible>(Event::default()
|
|
||||||
.event("done")
|
|
||||||
.data(json!({ "status": "timeout" }).to_string()));
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(Sse::new(stream))
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,435 +0,0 @@
|
|||||||
// Update Center routes — deploy/rollback orchestration via helper API
|
|
||||||
|
|
||||||
#![allow(clippy::type_complexity)]
|
|
||||||
|
|
||||||
use crate::{
|
|
||||||
error::{AppError, AppResult},
|
|
||||||
AppState,
|
|
||||||
};
|
|
||||||
use axum::{
|
|
||||||
extract::State,
|
|
||||||
routing::{get, post, put},
|
|
||||||
Json, Router,
|
|
||||||
};
|
|
||||||
use serde_json::{json, Value};
|
|
||||||
|
|
||||||
const UC_CONFIG_KEY: &str = "update_center_config";
|
|
||||||
|
|
||||||
const TASK_TYPE_DEPLOY: &str = "update-center-deploy";
|
|
||||||
const TASK_TYPE_ROLLBACK: &str = "update-center-rollback";
|
|
||||||
|
|
||||||
const DEDUPE_KEY_DEPLOY: &str = "update-center:deploy";
|
|
||||||
const DEDUPE_KEY_ROLLBACK: &str = "update-center:rollback";
|
|
||||||
|
|
||||||
pub fn routes() -> Router<AppState> {
|
|
||||||
Router::new()
|
|
||||||
.route("/api/update-center/status", get(get_status))
|
|
||||||
.route("/api/update-center/check", post(check_status))
|
|
||||||
.route("/api/update-center/config", put(update_config))
|
|
||||||
.route("/api/update-center/deploy", post(deploy_handler))
|
|
||||||
.route("/api/update-center/rollback", post(rollback_handler))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// GET /api/update-center/status
|
|
||||||
async fn get_status(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
|
||||||
let config = load_uc_config(&state).await?;
|
|
||||||
let helper_token = get_helper_token();
|
|
||||||
|
|
||||||
let last_deploy = load_last_task(&state, TASK_TYPE_DEPLOY).await?;
|
|
||||||
let last_rollback = load_last_task(&state, TASK_TYPE_ROLLBACK).await?;
|
|
||||||
|
|
||||||
Ok(Json(json!({
|
|
||||||
"enabled": config.get("enabled").and_then(|v| v.as_bool()).unwrap_or(false),
|
|
||||||
"helperBaseUrl": config.get("helperBaseUrl"),
|
|
||||||
"namespace": config.get("namespace"),
|
|
||||||
"releaseName": config.get("releaseName"),
|
|
||||||
"helperTokenConfigured": !helper_token.is_empty(),
|
|
||||||
"lastDeploy": last_deploy,
|
|
||||||
"lastRollback": last_rollback,
|
|
||||||
})))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// POST /api/update-center/check — refresh status cache
|
|
||||||
async fn check_status(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
|
||||||
// Same as get_status for now
|
|
||||||
get_status(State(state)).await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// PUT /api/update-center/config
|
|
||||||
async fn update_config(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
Json(body): Json<Value>,
|
|
||||||
) -> AppResult<Json<Value>> {
|
|
||||||
let serialized = serde_json::to_string(&body)
|
|
||||||
.map_err(|e| AppError::BadRequest(format!("Invalid config JSON: {e}")))?;
|
|
||||||
|
|
||||||
sqlx::query("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)")
|
|
||||||
.bind(UC_CONFIG_KEY)
|
|
||||||
.bind(&serialized)
|
|
||||||
.execute(&*state.db)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(Json(json!({
|
|
||||||
"success": true,
|
|
||||||
"config": body,
|
|
||||||
})))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// POST /api/update-center/deploy
|
|
||||||
async fn deploy_handler(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
Json(body): Json<Value>,
|
|
||||||
) -> AppResult<Json<Value>> {
|
|
||||||
let config = load_uc_config(&state).await?;
|
|
||||||
let helper_token = get_helper_token();
|
|
||||||
|
|
||||||
let helper_base = config
|
|
||||||
.get("helperBaseUrl")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("")
|
|
||||||
.trim();
|
|
||||||
let namespace = config
|
|
||||||
.get("namespace")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("")
|
|
||||||
.trim();
|
|
||||||
let chart_ref = config
|
|
||||||
.get("chartRef")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("")
|
|
||||||
.trim();
|
|
||||||
let image_repo = config
|
|
||||||
.get("imageRepository")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("")
|
|
||||||
.trim();
|
|
||||||
|
|
||||||
if helper_base.is_empty()
|
|
||||||
|| namespace.is_empty()
|
|
||||||
|| chart_ref.is_empty()
|
|
||||||
|| helper_token.is_empty()
|
|
||||||
{
|
|
||||||
return Err(AppError::BadRequest(
|
|
||||||
"Update center not fully configured".into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let target_tag = body
|
|
||||||
.get("targetTag")
|
|
||||||
.or_else(|| body.get("targetVersion"))
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.map(str::trim)
|
|
||||||
.unwrap_or("");
|
|
||||||
|
|
||||||
if target_tag.is_empty() {
|
|
||||||
return Err(AppError::BadRequest("targetTag is required".into()));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract owned values for the spawn
|
|
||||||
let helper_base_owned = helper_base.trim_end_matches('/').to_string();
|
|
||||||
let namespace_owned = namespace.to_string();
|
|
||||||
let chart_ref_owned = chart_ref.to_string();
|
|
||||||
let image_repo_owned = image_repo.to_string();
|
|
||||||
let release_name = config
|
|
||||||
.get("releaseName")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|s| !s.is_empty())
|
|
||||||
.map(ToOwned::to_owned);
|
|
||||||
let target_tag_owned = target_tag.to_string();
|
|
||||||
let helper_token_owned = helper_token;
|
|
||||||
let title = format!("部署 {}", target_tag);
|
|
||||||
|
|
||||||
let task_id = uuid::Uuid::new_v4().to_string();
|
|
||||||
|
|
||||||
// Create background task
|
|
||||||
sqlx::query(
|
|
||||||
"INSERT INTO background_tasks (id, task_type, title, status, message, dedupe_key)
|
|
||||||
VALUES (?, ?, ?, 'running', ?, ?)",
|
|
||||||
)
|
|
||||||
.bind(&task_id)
|
|
||||||
.bind(TASK_TYPE_DEPLOY)
|
|
||||||
.bind(&title)
|
|
||||||
.bind(format!(
|
|
||||||
"Starting deploy of {} to {}/{}",
|
|
||||||
target_tag, namespace, chart_ref
|
|
||||||
))
|
|
||||||
.bind(DEDUPE_KEY_DEPLOY)
|
|
||||||
.execute(&*state.db)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
// Call deploy helper in background (fire-and-forget via tokio)
|
|
||||||
let client = reqwest::Client::new();
|
|
||||||
let deploy_url = format!("{}/api/deploy", helper_base_owned);
|
|
||||||
let task_id_clone = task_id.clone();
|
|
||||||
let db_pool = state.db.clone();
|
|
||||||
|
|
||||||
tokio::spawn(async move {
|
|
||||||
let payload = json!({
|
|
||||||
"namespace": namespace_owned,
|
|
||||||
"releaseName": release_name,
|
|
||||||
"chartRef": chart_ref_owned,
|
|
||||||
"imageRepository": image_repo_owned,
|
|
||||||
"targetTag": target_tag_owned,
|
|
||||||
});
|
|
||||||
|
|
||||||
match client
|
|
||||||
.post(&deploy_url)
|
|
||||||
.header("Authorization", format!("Bearer {}", helper_token_owned))
|
|
||||||
.json(&payload)
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(resp) => {
|
|
||||||
let status = resp.status();
|
|
||||||
let status_code = status.as_u16();
|
|
||||||
let body_text = resp.text().await.unwrap_or_default();
|
|
||||||
|
|
||||||
if status.is_success() {
|
|
||||||
let _ = sqlx::query(
|
|
||||||
"UPDATE background_tasks
|
|
||||||
SET status = 'succeeded', message = ?, finished_at = datetime('now')
|
|
||||||
WHERE id = ?",
|
|
||||||
)
|
|
||||||
.bind(format!("Deploy completed (HTTP {})", status_code))
|
|
||||||
.bind(&task_id_clone)
|
|
||||||
.execute(&*db_pool)
|
|
||||||
.await;
|
|
||||||
} else {
|
|
||||||
let _ = sqlx::query(
|
|
||||||
"UPDATE background_tasks
|
|
||||||
SET status = 'failed', error = ?, finished_at = datetime('now')
|
|
||||||
WHERE id = ?",
|
|
||||||
)
|
|
||||||
.bind(format!(
|
|
||||||
"Helper returned HTTP {}: {}",
|
|
||||||
status_code, body_text
|
|
||||||
))
|
|
||||||
.bind(&task_id_clone)
|
|
||||||
.execute(&*db_pool)
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
let _ = sqlx::query(
|
|
||||||
"UPDATE background_tasks
|
|
||||||
SET status = 'failed', error = ?, finished_at = datetime('now')
|
|
||||||
WHERE id = ?",
|
|
||||||
)
|
|
||||||
.bind(format!("Helper unreachable: {}", e))
|
|
||||||
.bind(&task_id_clone)
|
|
||||||
.execute(&*db_pool)
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
Ok(Json(json!({
|
|
||||||
"success": true,
|
|
||||||
"reused": false,
|
|
||||||
"task": {
|
|
||||||
"id": task_id,
|
|
||||||
"type": TASK_TYPE_DEPLOY,
|
|
||||||
"title": title,
|
|
||||||
"status": "running",
|
|
||||||
},
|
|
||||||
})))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// POST /api/update-center/rollback
|
|
||||||
async fn rollback_handler(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
Json(body): Json<Value>,
|
|
||||||
) -> AppResult<Json<Value>> {
|
|
||||||
let config = load_uc_config(&state).await?;
|
|
||||||
let helper_token = get_helper_token();
|
|
||||||
|
|
||||||
let helper_base = config
|
|
||||||
.get("helperBaseUrl")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("")
|
|
||||||
.trim();
|
|
||||||
let namespace = config
|
|
||||||
.get("namespace")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("")
|
|
||||||
.trim();
|
|
||||||
|
|
||||||
if helper_base.is_empty() || namespace.is_empty() || helper_token.is_empty() {
|
|
||||||
return Err(AppError::BadRequest(
|
|
||||||
"Update center not fully configured".into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let target_revision = body
|
|
||||||
.get("targetRevision")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.map(str::trim)
|
|
||||||
.unwrap_or("");
|
|
||||||
|
|
||||||
if target_revision.is_empty() {
|
|
||||||
return Err(AppError::BadRequest("targetRevision is required".into()));
|
|
||||||
}
|
|
||||||
|
|
||||||
let helper_base_owned = helper_base.trim_end_matches('/').to_string();
|
|
||||||
let namespace_owned = namespace.to_string();
|
|
||||||
let release_name = config
|
|
||||||
.get("releaseName")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|s| !s.is_empty())
|
|
||||||
.map(ToOwned::to_owned);
|
|
||||||
let target_revision_owned = target_revision.to_string();
|
|
||||||
let helper_token_owned = helper_token;
|
|
||||||
let title = format!("回退 {}", target_revision);
|
|
||||||
|
|
||||||
let task_id = uuid::Uuid::new_v4().to_string();
|
|
||||||
|
|
||||||
sqlx::query(
|
|
||||||
"INSERT INTO background_tasks (id, task_type, title, status, message, dedupe_key)
|
|
||||||
VALUES (?, ?, ?, 'running', ?, ?)",
|
|
||||||
)
|
|
||||||
.bind(&task_id)
|
|
||||||
.bind(TASK_TYPE_ROLLBACK)
|
|
||||||
.bind(&title)
|
|
||||||
.bind(format!(
|
|
||||||
"Starting rollback to {} in {}",
|
|
||||||
target_revision, namespace
|
|
||||||
))
|
|
||||||
.bind(DEDUPE_KEY_ROLLBACK)
|
|
||||||
.execute(&*state.db)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let client = reqwest::Client::new();
|
|
||||||
let rollback_url = format!("{}/api/rollback", helper_base_owned);
|
|
||||||
let task_id_clone = task_id.clone();
|
|
||||||
let db_pool = state.db.clone();
|
|
||||||
|
|
||||||
tokio::spawn(async move {
|
|
||||||
let payload = json!({
|
|
||||||
"namespace": namespace_owned,
|
|
||||||
"releaseName": release_name,
|
|
||||||
"targetRevision": target_revision_owned,
|
|
||||||
});
|
|
||||||
|
|
||||||
match client
|
|
||||||
.post(&rollback_url)
|
|
||||||
.header("Authorization", format!("Bearer {}", helper_token_owned))
|
|
||||||
.json(&payload)
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(resp) => {
|
|
||||||
let status = resp.status();
|
|
||||||
let status_code = status.as_u16();
|
|
||||||
let body_text = resp.text().await.unwrap_or_default();
|
|
||||||
|
|
||||||
if status.is_success() {
|
|
||||||
let _ = sqlx::query(
|
|
||||||
"UPDATE background_tasks
|
|
||||||
SET status = 'succeeded', message = ?, finished_at = datetime('now')
|
|
||||||
WHERE id = ?",
|
|
||||||
)
|
|
||||||
.bind(format!("Rollback completed (HTTP {})", status_code))
|
|
||||||
.bind(&task_id_clone)
|
|
||||||
.execute(&*db_pool)
|
|
||||||
.await;
|
|
||||||
} else {
|
|
||||||
let _ = sqlx::query(
|
|
||||||
"UPDATE background_tasks
|
|
||||||
SET status = 'failed', error = ?, finished_at = datetime('now')
|
|
||||||
WHERE id = ?",
|
|
||||||
)
|
|
||||||
.bind(format!(
|
|
||||||
"Helper returned HTTP {}: {}",
|
|
||||||
status_code, body_text
|
|
||||||
))
|
|
||||||
.bind(&task_id_clone)
|
|
||||||
.execute(&*db_pool)
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
let _ = sqlx::query(
|
|
||||||
"UPDATE background_tasks
|
|
||||||
SET status = 'failed', error = ?, finished_at = datetime('now')
|
|
||||||
WHERE id = ?",
|
|
||||||
)
|
|
||||||
.bind(format!("Helper unreachable: {}", e))
|
|
||||||
.bind(&task_id_clone)
|
|
||||||
.execute(&*db_pool)
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
Ok(Json(json!({
|
|
||||||
"success": true,
|
|
||||||
"reused": false,
|
|
||||||
"task": {
|
|
||||||
"id": task_id,
|
|
||||||
"type": TASK_TYPE_ROLLBACK,
|
|
||||||
"title": title,
|
|
||||||
"status": "running",
|
|
||||||
},
|
|
||||||
})))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Helpers ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
fn get_helper_token() -> String {
|
|
||||||
std::env::var("UPDATE_CENTER_HELPER_TOKEN")
|
|
||||||
.or_else(|_| std::env::var("DEPLOY_HELPER_TOKEN"))
|
|
||||||
.unwrap_or_default()
|
|
||||||
.trim()
|
|
||||||
.to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn load_uc_config(state: &AppState) -> AppResult<Value> {
|
|
||||||
let row: Option<(String,)> = sqlx::query_as("SELECT value FROM settings WHERE key = ?")
|
|
||||||
.bind(UC_CONFIG_KEY)
|
|
||||||
.fetch_optional(&*state.db)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
match row {
|
|
||||||
Some((v,)) => serde_json::from_str(&v)
|
|
||||||
.map_err(|e| AppError::BadRequest(format!("Invalid update center config JSON: {e}"))),
|
|
||||||
None => Ok(json!({})),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn load_last_task(state: &AppState, task_type: &str) -> AppResult<Option<Value>> {
|
|
||||||
let row: Option<(
|
|
||||||
String,
|
|
||||||
String,
|
|
||||||
Option<String>,
|
|
||||||
Option<String>,
|
|
||||||
String,
|
|
||||||
Option<String>,
|
|
||||||
Option<String>,
|
|
||||||
)> = sqlx::query_as(
|
|
||||||
"SELECT id, status, message, error, created_at, started_at, finished_at
|
|
||||||
FROM background_tasks
|
|
||||||
WHERE task_type = ?
|
|
||||||
ORDER BY created_at DESC
|
|
||||||
LIMIT 1",
|
|
||||||
)
|
|
||||||
.bind(task_type)
|
|
||||||
.fetch_optional(&*state.db)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(row.map(
|
|
||||||
|(id, status, message, error, created_at, started_at, finished_at)| {
|
|
||||||
json!({
|
|
||||||
"id": id,
|
|
||||||
"type": task_type,
|
|
||||||
"status": status,
|
|
||||||
"message": message,
|
|
||||||
"error": error,
|
|
||||||
"createdAt": created_at,
|
|
||||||
"startedAt": started_at,
|
|
||||||
"finishedAt": finished_at,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
))
|
|
||||||
}
|
|
||||||
@@ -10,5 +10,4 @@ pub mod registration_limiter;
|
|||||||
pub mod route_rebuild;
|
pub mod route_rebuild;
|
||||||
pub mod runtime_logs;
|
pub mod runtime_logs;
|
||||||
pub mod shared;
|
pub mod shared;
|
||||||
pub mod update_center;
|
|
||||||
pub mod workspace;
|
pub mod workspace;
|
||||||
|
|||||||
@@ -1,115 +0,0 @@
|
|||||||
use crate::error::{AppError, AppResult};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use serde_json::Value;
|
|
||||||
use sqlx::SqlitePool;
|
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
const UPDATE_CONFIG_KEY: &str = "update_center_config";
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct UpdateCenter {
|
|
||||||
db: Arc<SqlitePool>,
|
|
||||||
client: reqwest::Client,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct UpdateInfo {
|
|
||||||
pub current_version: String,
|
|
||||||
pub latest_version: Option<String>,
|
|
||||||
pub update_available: bool,
|
|
||||||
pub release_notes: Option<String>,
|
|
||||||
pub download_url: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl UpdateCenter {
|
|
||||||
pub fn new(db: Arc<SqlitePool>) -> Self {
|
|
||||||
Self {
|
|
||||||
db,
|
|
||||||
client: reqwest::Client::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn check_for_updates(&self) -> AppResult<UpdateInfo> {
|
|
||||||
let config = self.load_config().await?;
|
|
||||||
let current_version = env!("CARGO_PKG_VERSION").to_string();
|
|
||||||
let Some(endpoint) = config
|
|
||||||
.get("check_url")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.filter(|url| !url.trim().is_empty())
|
|
||||||
else {
|
|
||||||
return Ok(UpdateInfo {
|
|
||||||
current_version,
|
|
||||||
latest_version: None,
|
|
||||||
update_available: false,
|
|
||||||
release_notes: None,
|
|
||||||
download_url: None,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
let payload: Value = self
|
|
||||||
.client
|
|
||||||
.get(endpoint)
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.map_err(|err| AppError::UpstreamError(format!("Update check failed: {err}")))?
|
|
||||||
.json()
|
|
||||||
.await
|
|
||||||
.map_err(|err| AppError::UpstreamError(format!("Invalid update response: {err}")))?;
|
|
||||||
|
|
||||||
let latest_version = payload
|
|
||||||
.get("version")
|
|
||||||
.or_else(|| payload.get("latestVersion"))
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(str::to_string);
|
|
||||||
let update_available = latest_version
|
|
||||||
.as_deref()
|
|
||||||
.map(|latest| latest != current_version)
|
|
||||||
.unwrap_or(false);
|
|
||||||
|
|
||||||
Ok(UpdateInfo {
|
|
||||||
current_version,
|
|
||||||
latest_version,
|
|
||||||
update_available,
|
|
||||||
release_notes: payload
|
|
||||||
.get("release_notes")
|
|
||||||
.or_else(|| payload.get("releaseNotes"))
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(str::to_string),
|
|
||||||
download_url: payload
|
|
||||||
.get("download_url")
|
|
||||||
.or_else(|| payload.get("downloadUrl"))
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(str::to_string),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn apply_update(&self, version: &str) -> AppResult<String> {
|
|
||||||
let task_id = format!("update-{}", uuid::Uuid::new_v4());
|
|
||||||
sqlx::query(
|
|
||||||
"INSERT INTO background_tasks
|
|
||||||
(id, task_type, title, status, message, dedupe_key, created_at, updated_at)
|
|
||||||
VALUES (?, 'update-center-deploy', ?, 'pending', ?, ?, datetime('now'), datetime('now'))",
|
|
||||||
)
|
|
||||||
.bind(&task_id)
|
|
||||||
.bind(format!("Apply BoosAPI {version}"))
|
|
||||||
.bind("Update request accepted; an external supervisor should perform download and restart")
|
|
||||||
.bind(format!("update-center:deploy:{version}"))
|
|
||||||
.execute(&*self.db)
|
|
||||||
.await?;
|
|
||||||
Ok(task_id)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn load_config(&self) -> AppResult<Value> {
|
|
||||||
let raw: Option<String> = sqlx::query_scalar("SELECT value FROM settings WHERE key = ?")
|
|
||||||
.bind(UPDATE_CONFIG_KEY)
|
|
||||||
.fetch_optional(&*self.db)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
raw.as_deref()
|
|
||||||
.map(serde_json::from_str)
|
|
||||||
.transpose()
|
|
||||||
.map_err(|err| AppError::BadRequest(format!("Invalid update center config: {err}")))?
|
|
||||||
.map(Ok)
|
|
||||||
.unwrap_or_else(|| Ok(Value::Object(Default::default())))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,161 +0,0 @@
|
|||||||
export type UpdateVersionCandidateLike = {
|
|
||||||
normalizedVersion?: string | null;
|
|
||||||
displayVersion?: string | null;
|
|
||||||
tagName?: string | null;
|
|
||||||
digest?: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type UpdateHelperRuntimeLike = {
|
|
||||||
imageTag?: string | null;
|
|
||||||
imageDigest?: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type UpdateReminderCandidate = {
|
|
||||||
source: 'github-release' | 'docker-hub-tag';
|
|
||||||
kind: 'new-version' | 'new-digest';
|
|
||||||
candidateKey: string;
|
|
||||||
displayVersion: string;
|
|
||||||
tagName: string;
|
|
||||||
digest: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const STABLE_SEMVER_PATTERN = /^v?(\d+)\.(\d+)\.(\d+)(?:\+[\w.-]+)?$/i;
|
|
||||||
|
|
||||||
function normalizeString(value?: string | null): string {
|
|
||||||
return String(value || '').trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeDigest(value?: string | null): string {
|
|
||||||
const digest = normalizeString(value);
|
|
||||||
return /^sha256:[a-f0-9]{64}$/i.test(digest) ? digest.toLowerCase() : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeStableTag(value?: string | null): string {
|
|
||||||
return normalizeStableVersion(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasSameImageTag(left?: string | null, right?: string | null): boolean {
|
|
||||||
const rawLeft = normalizeString(left);
|
|
||||||
const rawRight = normalizeString(right);
|
|
||||||
if (!rawLeft || !rawRight) return false;
|
|
||||||
|
|
||||||
const stableLeft = normalizeStableTag(left);
|
|
||||||
const stableRight = normalizeStableTag(right);
|
|
||||||
if (stableLeft && stableRight) {
|
|
||||||
return stableLeft === stableRight;
|
|
||||||
}
|
|
||||||
|
|
||||||
return rawLeft === rawRight;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizeStableVersion(value?: string | null): string {
|
|
||||||
const raw = normalizeString(value);
|
|
||||||
if (!raw) return '';
|
|
||||||
const match = raw.match(STABLE_SEMVER_PATTERN);
|
|
||||||
if (!match) return '';
|
|
||||||
return [
|
|
||||||
Number.parseInt(match[1], 10),
|
|
||||||
Number.parseInt(match[2], 10),
|
|
||||||
Number.parseInt(match[3], 10),
|
|
||||||
].join('.');
|
|
||||||
}
|
|
||||||
|
|
||||||
export function compareStableVersions(left?: string | null, right?: string | null): number | null {
|
|
||||||
const normalizedLeft = normalizeStableVersion(left);
|
|
||||||
const normalizedRight = normalizeStableVersion(right);
|
|
||||||
if (!normalizedLeft || !normalizedRight) return null;
|
|
||||||
|
|
||||||
const leftParts = normalizedLeft.split('.').map((item) => Number.parseInt(item, 10));
|
|
||||||
const rightParts = normalizedRight.split('.').map((item) => Number.parseInt(item, 10));
|
|
||||||
for (let index = 0; index < 3; index += 1) {
|
|
||||||
if (leftParts[index] === rightParts[index]) continue;
|
|
||||||
return leftParts[index] < rightParts[index] ? -1 : 1;
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isSameImageTarget(
|
|
||||||
current: UpdateHelperRuntimeLike | null | undefined,
|
|
||||||
target: { tag?: string | null; digest?: string | null },
|
|
||||||
): boolean {
|
|
||||||
const currentDigest = normalizeDigest(current?.imageDigest);
|
|
||||||
const targetDigest = normalizeDigest(target.digest);
|
|
||||||
if (currentDigest && targetDigest) {
|
|
||||||
return currentDigest === targetDigest;
|
|
||||||
}
|
|
||||||
|
|
||||||
return hasSameImageTag(current?.imageTag, target.tag);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildUpdateReminderCandidateKey(
|
|
||||||
source: 'github-release' | 'docker-hub-tag',
|
|
||||||
candidate: { tagName?: string | null; digest?: string | null },
|
|
||||||
): string {
|
|
||||||
const tagName = normalizeString(candidate.tagName);
|
|
||||||
const digest = normalizeDigest(candidate.digest);
|
|
||||||
if (!tagName) return '';
|
|
||||||
if (source === 'docker-hub-tag' && digest) {
|
|
||||||
return `${source}:${tagName}@${digest}`;
|
|
||||||
}
|
|
||||||
return `${source}:${tagName}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveUpdateReminderCandidate(input: {
|
|
||||||
currentVersion?: string | null;
|
|
||||||
helper: UpdateHelperRuntimeLike | null | undefined;
|
|
||||||
githubRelease: UpdateVersionCandidateLike | null | undefined;
|
|
||||||
dockerHubTag: UpdateVersionCandidateLike | null | undefined;
|
|
||||||
}): UpdateReminderCandidate | null {
|
|
||||||
const githubCandidateVersion = normalizeString(input.githubRelease?.normalizedVersion);
|
|
||||||
const githubTag = normalizeString(input.githubRelease?.tagName || githubCandidateVersion);
|
|
||||||
if (githubTag) {
|
|
||||||
const githubTargetVersion = githubCandidateVersion || githubTag;
|
|
||||||
const versionCompare = compareStableVersions(input.currentVersion, githubTargetVersion);
|
|
||||||
const helperVersionCompare = compareStableVersions(input.helper?.imageTag, githubTargetVersion);
|
|
||||||
if (versionCompare === -1 && (helperVersionCompare == null || helperVersionCompare === -1)) {
|
|
||||||
return {
|
|
||||||
source: 'github-release',
|
|
||||||
kind: 'new-version',
|
|
||||||
candidateKey: buildUpdateReminderCandidateKey('github-release', { tagName: githubTag, digest: null }),
|
|
||||||
displayVersion: normalizeString(input.githubRelease?.displayVersion || input.githubRelease?.normalizedVersion || githubTag),
|
|
||||||
tagName: githubTag,
|
|
||||||
digest: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const dockerCandidateVersion = normalizeString(input.dockerHubTag?.normalizedVersion);
|
|
||||||
const dockerTag = normalizeString(input.dockerHubTag?.tagName || dockerCandidateVersion);
|
|
||||||
const dockerDigest = normalizeDigest(input.dockerHubTag?.digest);
|
|
||||||
if (!dockerTag) return null;
|
|
||||||
|
|
||||||
if (isSameImageTarget(input.helper, { tag: dockerTag, digest: dockerDigest })) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const dockerVersionCompare = compareStableVersions(input.currentVersion, dockerCandidateVersion || dockerTag);
|
|
||||||
if (dockerVersionCompare === -1) {
|
|
||||||
return {
|
|
||||||
source: 'docker-hub-tag',
|
|
||||||
kind: 'new-version',
|
|
||||||
candidateKey: buildUpdateReminderCandidateKey('docker-hub-tag', { tagName: dockerTag, digest: dockerDigest || null }),
|
|
||||||
displayVersion: normalizeString(input.dockerHubTag?.displayVersion || input.dockerHubTag?.normalizedVersion || dockerTag),
|
|
||||||
tagName: dockerTag,
|
|
||||||
digest: dockerDigest || null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const helperDigest = normalizeDigest(input.helper?.imageDigest);
|
|
||||||
if (dockerDigest && helperDigest && hasSameImageTag(input.helper?.imageTag, dockerTag) && helperDigest !== dockerDigest) {
|
|
||||||
return {
|
|
||||||
source: 'docker-hub-tag',
|
|
||||||
kind: 'new-digest',
|
|
||||||
candidateKey: buildUpdateReminderCandidateKey('docker-hub-tag', { tagName: dockerTag, digest: dockerDigest }),
|
|
||||||
displayVersion: normalizeString(input.dockerHubTag?.displayVersion || input.dockerHubTag?.normalizedVersion || dockerTag),
|
|
||||||
tagName: dockerTag,
|
|
||||||
digest: dockerDigest,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
@@ -1783,43 +1783,6 @@ export const api = {
|
|||||||
method: "PUT",
|
method: "PUT",
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
}),
|
}),
|
||||||
getUpdateCenterStatus: () => request("/api/update-center/status"),
|
|
||||||
saveUpdateCenterConfig: (data: any) =>
|
|
||||||
request("/api/update-center/config", {
|
|
||||||
method: "PUT",
|
|
||||||
body: JSON.stringify(data),
|
|
||||||
}),
|
|
||||||
checkUpdateCenter: () =>
|
|
||||||
request("/api/update-center/check", {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify({}),
|
|
||||||
}),
|
|
||||||
deployUpdateCenter: (data: {
|
|
||||||
source: "github-release" | "docker-hub-tag";
|
|
||||||
targetTag: string;
|
|
||||||
targetDigest?: string | null;
|
|
||||||
}) =>
|
|
||||||
request("/api/update-center/deploy", {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify(data),
|
|
||||||
}),
|
|
||||||
rollbackUpdateCenter: (data: { targetRevision: string }) =>
|
|
||||||
request("/api/update-center/rollback", {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify(data),
|
|
||||||
}),
|
|
||||||
streamUpdateCenterTaskLogs: (
|
|
||||||
taskId: string,
|
|
||||||
handlers: {
|
|
||||||
onLog?: (entry: any) => void;
|
|
||||||
onDone?: (payload: any) => void;
|
|
||||||
signal?: AbortSignal;
|
|
||||||
},
|
|
||||||
) =>
|
|
||||||
streamSse(
|
|
||||||
`/api/update-center/tasks/${encodeURIComponent(taskId)}/stream`,
|
|
||||||
handlers,
|
|
||||||
),
|
|
||||||
testSystemProxy: (data: SystemProxyTestRequest) =>
|
testSystemProxy: (data: SystemProxyTestRequest) =>
|
||||||
request("/api/settings/system-proxy/test", {
|
request("/api/settings/system-proxy/test", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|||||||
@@ -1,60 +0,0 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
||||||
import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer';
|
|
||||||
import { MemoryRouter } from 'react-router-dom';
|
|
||||||
|
|
||||||
import About from './About.js';
|
|
||||||
|
|
||||||
const { apiMock } = vi.hoisted(() => ({
|
|
||||||
apiMock: {
|
|
||||||
getUpdateCenterStatus: vi.fn(),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock('../api.js', () => ({
|
|
||||||
api: apiMock,
|
|
||||||
}));
|
|
||||||
|
|
||||||
function collectText(node: ReactTestInstance): string {
|
|
||||||
return (node.children || []).map((child) => {
|
|
||||||
if (typeof child === 'string') return child;
|
|
||||||
return collectText(child);
|
|
||||||
}).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function flushMicrotasks() {
|
|
||||||
await act(async () => {
|
|
||||||
await Promise.resolve();
|
|
||||||
await Promise.resolve();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('About page', () => {
|
|
||||||
afterEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows product information without update prompts or project links', async () => {
|
|
||||||
let root!: ReactTestRenderer;
|
|
||||||
try {
|
|
||||||
await act(async () => {
|
|
||||||
root = create(
|
|
||||||
<MemoryRouter>
|
|
||||||
<About />
|
|
||||||
</MemoryRouter>,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
await flushMicrotasks();
|
|
||||||
|
|
||||||
const text = collectText(root.root);
|
|
||||||
expect(text).toContain('关于 BoosAPI');
|
|
||||||
expect(text).toContain('核心特色');
|
|
||||||
expect(text).toContain('兼容入口');
|
|
||||||
expect(text).not.toContain('更新提醒');
|
|
||||||
expect(text).not.toContain('前往更新中心');
|
|
||||||
expect(text).not.toContain('项目链接');
|
|
||||||
expect(apiMock.getUpdateCenterStatus).not.toHaveBeenCalled();
|
|
||||||
} finally {
|
|
||||||
root?.unmount();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -20,7 +20,6 @@ import {
|
|||||||
visualRulesToPayloadRules,
|
visualRulesToPayloadRules,
|
||||||
} from './settings/payloadRulesVisual.js';
|
} from './settings/payloadRulesVisual.js';
|
||||||
import { PAYLOAD_RULE_PROTOCOL_OPTIONS } from './settings/payloadRuleProtocolOptions.js';
|
import { PAYLOAD_RULE_PROTOCOL_OPTIONS } from './settings/payloadRuleProtocolOptions.js';
|
||||||
import UpdateCenterSection from './settings/UpdateCenterSection.js';
|
|
||||||
import {
|
import {
|
||||||
applyRoutingProfilePreset,
|
applyRoutingProfilePreset,
|
||||||
resolveRoutingProfilePreset,
|
resolveRoutingProfilePreset,
|
||||||
@@ -2924,8 +2923,6 @@ export default function Settings() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="settings-maintenance" style={settingsModernSectionStyle}>
|
<div id="settings-maintenance" style={settingsModernSectionStyle}>
|
||||||
<UpdateCenterSection />
|
|
||||||
|
|
||||||
<div className="card animate-slide-up stagger-6" style={settingsModernCardStyle}>
|
<div className="card animate-slide-up stagger-6" style={settingsModernCardStyle}>
|
||||||
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 12 }}>维护工具</div>
|
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 12 }}>维护工具</div>
|
||||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||||
|
|||||||
@@ -76,12 +76,6 @@ describe('navigationFocus helpers', () => {
|
|||||||
type: 'proxy',
|
type: 'proxy',
|
||||||
})).toBe('/routes');
|
})).toBe('/routes');
|
||||||
|
|
||||||
expect(buildEventNavigationPath({
|
|
||||||
relatedType: 'update_center',
|
|
||||||
relatedId: null,
|
|
||||||
type: 'status',
|
|
||||||
})).toBe('/settings');
|
|
||||||
|
|
||||||
expect(buildEventNavigationPath({ type: 'proxy' })).toBe('/logs');
|
expect(buildEventNavigationPath({ type: 'proxy' })).toBe('/logs');
|
||||||
expect(buildEventNavigationPath({ type: 'checkin' })).toBe('/checkin');
|
expect(buildEventNavigationPath({ type: 'checkin' })).toBe('/checkin');
|
||||||
expect(buildEventNavigationPath({ type: 'site_notice' })).toBe('/site-announcements');
|
expect(buildEventNavigationPath({ type: 'site_notice' })).toBe('/site-announcements');
|
||||||
|
|||||||
@@ -98,9 +98,6 @@ export function buildEventNavigationPath(event: {
|
|||||||
if (relatedType === 'site_announcement' && relatedId) {
|
if (relatedType === 'site_announcement' && relatedId) {
|
||||||
return buildAnnouncementFocusPath(relatedId);
|
return buildAnnouncementFocusPath(relatedId);
|
||||||
}
|
}
|
||||||
if (relatedType === 'update_center') {
|
|
||||||
return '/settings';
|
|
||||||
}
|
|
||||||
if (relatedType === 'route') {
|
if (relatedType === 'route') {
|
||||||
return '/routes';
|
return '/routes';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,104 +0,0 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
|
|
||||||
import {
|
|
||||||
buildUpdateReminder,
|
|
||||||
describeDockerDeployState,
|
|
||||||
} from './updateCenterPresentation.js';
|
|
||||||
|
|
||||||
describe('updateCenterPresentation', () => {
|
|
||||||
it('returns an unknown reminder when no candidate source data is available', () => {
|
|
||||||
expect(buildUpdateReminder({
|
|
||||||
currentVersion: '1.2.3',
|
|
||||||
helper: null,
|
|
||||||
githubRelease: null,
|
|
||||||
dockerHubTag: null,
|
|
||||||
})).toEqual({
|
|
||||||
label: '无法检查更新',
|
|
||||||
badgeClassName: 'badge badge-muted',
|
|
||||||
detail: '暂未获取到可比较的版本信息。',
|
|
||||||
highlight: false,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('treats a same-version Docker target with a different digest as a real new-digest deploy', () => {
|
|
||||||
const state = describeDockerDeployState({
|
|
||||||
enabled: true,
|
|
||||||
helperHealthy: true,
|
|
||||||
currentVersion: '1.2.3',
|
|
||||||
helper: {
|
|
||||||
imageTag: '1.2.3',
|
|
||||||
imageDigest: 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
|
||||||
},
|
|
||||||
candidate: {
|
|
||||||
normalizedVersion: '1.2.3',
|
|
||||||
tagName: '1.2.3',
|
|
||||||
digest: 'sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(state.kind).toBe('new-digest');
|
|
||||||
expect(state.canDeploy).toBe(true);
|
|
||||||
expect(state.badgeLabel).toBe('发现新 digest');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('treats semver tags with and without a v prefix as the same Docker digest target', () => {
|
|
||||||
const state = describeDockerDeployState({
|
|
||||||
enabled: true,
|
|
||||||
helperHealthy: true,
|
|
||||||
currentVersion: '1.2.3',
|
|
||||||
helper: {
|
|
||||||
imageTag: 'v1.2.3',
|
|
||||||
imageDigest: 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
|
||||||
},
|
|
||||||
candidate: {
|
|
||||||
normalizedVersion: '1.2.3',
|
|
||||||
tagName: '1.2.3',
|
|
||||||
digest: 'sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(state.kind).toBe('new-digest');
|
|
||||||
expect(state.canDeploy).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('treats alias tags like latest as a new digest deploy target when the digest changes', () => {
|
|
||||||
const state = describeDockerDeployState({
|
|
||||||
enabled: true,
|
|
||||||
helperHealthy: true,
|
|
||||||
currentVersion: '1.2.3',
|
|
||||||
helper: {
|
|
||||||
imageTag: 'latest',
|
|
||||||
imageDigest: 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
|
||||||
},
|
|
||||||
candidate: {
|
|
||||||
normalizedVersion: 'latest',
|
|
||||||
tagName: 'latest',
|
|
||||||
digest: 'sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(state.kind).toBe('new-digest');
|
|
||||||
expect(state.canDeploy).toBe(true);
|
|
||||||
expect(state.badgeLabel).toBe('发现新 digest');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not advertise an older GitHub reminder when the helper is already ahead', () => {
|
|
||||||
expect(buildUpdateReminder({
|
|
||||||
currentVersion: '1.2.3',
|
|
||||||
helper: {
|
|
||||||
imageTag: '1.4.0',
|
|
||||||
},
|
|
||||||
githubRelease: {
|
|
||||||
normalizedVersion: '1.3.0',
|
|
||||||
displayVersion: '1.3.0',
|
|
||||||
tagName: 'v1.3.0',
|
|
||||||
},
|
|
||||||
dockerHubTag: null,
|
|
||||||
})).toEqual({
|
|
||||||
label: '已是最新',
|
|
||||||
badgeClassName: 'badge badge-muted',
|
|
||||||
detail: '当前运行版本与已发现的部署目标没有明显差异。',
|
|
||||||
highlight: false,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,277 +0,0 @@
|
|||||||
import {
|
|
||||||
compareStableVersions,
|
|
||||||
isSameImageTarget,
|
|
||||||
resolveUpdateReminderCandidate,
|
|
||||||
type UpdateHelperRuntimeLike,
|
|
||||||
type UpdateVersionCandidateLike,
|
|
||||||
} from '../../../shared/updateCenterReminder.js';
|
|
||||||
|
|
||||||
export type UpdateDeployState = {
|
|
||||||
kind: 'disabled' | 'missing' | 'helper-unhealthy' | 'same-version' | 'same-image' | 'new-version' | 'new-digest' | 'available';
|
|
||||||
badgeClassName: string;
|
|
||||||
badgeLabel: string;
|
|
||||||
reason: string;
|
|
||||||
canDeploy: boolean;
|
|
||||||
highlight: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type UpdateReminder = {
|
|
||||||
label: string;
|
|
||||||
badgeClassName: string;
|
|
||||||
detail: string;
|
|
||||||
highlight: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
function normalizeString(value?: string | null): string {
|
|
||||||
return String(value || '').trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeDigest(value?: string | null): string {
|
|
||||||
const digest = normalizeString(value);
|
|
||||||
return /^sha256:[a-f0-9]{64}$/i.test(digest) ? digest.toLowerCase() : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function describeGitHubDeployState(input: {
|
|
||||||
enabled: boolean;
|
|
||||||
helperHealthy: boolean;
|
|
||||||
helperError?: string | null;
|
|
||||||
currentVersion?: string | null;
|
|
||||||
helperImageTag?: string | null;
|
|
||||||
candidate: UpdateVersionCandidateLike | null | undefined;
|
|
||||||
}): UpdateDeployState {
|
|
||||||
if (!input.enabled) {
|
|
||||||
return {
|
|
||||||
kind: 'disabled',
|
|
||||||
badgeClassName: 'badge badge-muted',
|
|
||||||
badgeLabel: '已停用',
|
|
||||||
reason: '当前来源已停用,开启后才会参与检查和部署。',
|
|
||||||
canDeploy: false,
|
|
||||||
highlight: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const candidateVersion = normalizeString(input.candidate?.normalizedVersion);
|
|
||||||
const candidateTag = normalizeString(input.candidate?.tagName || candidateVersion);
|
|
||||||
if (!candidateVersion && !candidateTag) {
|
|
||||||
return {
|
|
||||||
kind: 'missing',
|
|
||||||
badgeClassName: 'badge badge-warning',
|
|
||||||
badgeLabel: '未发现版本',
|
|
||||||
reason: '当前来源还没有可部署版本。',
|
|
||||||
canDeploy: false,
|
|
||||||
highlight: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!input.helperHealthy) {
|
|
||||||
return {
|
|
||||||
kind: 'helper-unhealthy',
|
|
||||||
badgeClassName: 'badge badge-warning',
|
|
||||||
badgeLabel: '等待 helper',
|
|
||||||
reason: input.helperError || 'Deploy Helper 未健康,先修复 helper 再部署。',
|
|
||||||
canDeploy: false,
|
|
||||||
highlight: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const candidateTargetVersion = candidateVersion || candidateTag;
|
|
||||||
const versionCompare = compareStableVersions(input.currentVersion, candidateTargetVersion);
|
|
||||||
const helperVersionCompare = compareStableVersions(input.helperImageTag, candidateTargetVersion);
|
|
||||||
if (versionCompare === 0 || helperVersionCompare === 0 || helperVersionCompare === 1) {
|
|
||||||
return {
|
|
||||||
kind: 'same-version',
|
|
||||||
badgeClassName: 'badge badge-muted',
|
|
||||||
badgeLabel: '当前运行',
|
|
||||||
reason: helperVersionCompare === 1
|
|
||||||
? 'Deploy Helper 已指向更高版本,无需回退到较旧的 GitHub 稳定版。'
|
|
||||||
: '当前已运行该版本,无需重复部署。',
|
|
||||||
canDeploy: false,
|
|
||||||
highlight: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (versionCompare === -1) {
|
|
||||||
return {
|
|
||||||
kind: 'new-version',
|
|
||||||
badgeClassName: 'badge badge-success',
|
|
||||||
badgeLabel: '发现新版本',
|
|
||||||
reason: '检测到比当前运行版本更新的稳定版,可直接发起部署。',
|
|
||||||
canDeploy: true,
|
|
||||||
highlight: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
kind: 'available',
|
|
||||||
badgeClassName: 'badge badge-info',
|
|
||||||
badgeLabel: '可部署',
|
|
||||||
reason: '版本可用,点击按钮即可通过 helper 发起滚动更新。',
|
|
||||||
canDeploy: true,
|
|
||||||
highlight: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function describeDockerDeployState(input: {
|
|
||||||
enabled: boolean;
|
|
||||||
helperHealthy: boolean;
|
|
||||||
helperError?: string | null;
|
|
||||||
currentVersion?: string | null;
|
|
||||||
helper: UpdateHelperRuntimeLike | null | undefined;
|
|
||||||
candidate: UpdateVersionCandidateLike | null | undefined;
|
|
||||||
}): UpdateDeployState {
|
|
||||||
if (!input.enabled) {
|
|
||||||
return {
|
|
||||||
kind: 'disabled',
|
|
||||||
badgeClassName: 'badge badge-muted',
|
|
||||||
badgeLabel: '已停用',
|
|
||||||
reason: '当前来源已停用,开启后才会参与检查和部署。',
|
|
||||||
canDeploy: false,
|
|
||||||
highlight: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const candidateVersion = normalizeString(input.candidate?.normalizedVersion);
|
|
||||||
const candidateTag = normalizeString(input.candidate?.tagName || candidateVersion);
|
|
||||||
const candidateDigest = normalizeDigest(input.candidate?.digest);
|
|
||||||
if (!candidateVersion && !candidateTag) {
|
|
||||||
return {
|
|
||||||
kind: 'missing',
|
|
||||||
badgeClassName: 'badge badge-warning',
|
|
||||||
badgeLabel: '未发现版本',
|
|
||||||
reason: '当前来源还没有可部署版本。',
|
|
||||||
canDeploy: false,
|
|
||||||
highlight: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!input.helperHealthy) {
|
|
||||||
return {
|
|
||||||
kind: 'helper-unhealthy',
|
|
||||||
badgeClassName: 'badge badge-warning',
|
|
||||||
badgeLabel: '等待 helper',
|
|
||||||
reason: input.helperError || 'Deploy Helper 未健康,先修复 helper 再部署。',
|
|
||||||
canDeploy: false,
|
|
||||||
highlight: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isSameImageTarget(input.helper, { tag: candidateTag, digest: candidateDigest })) {
|
|
||||||
return {
|
|
||||||
kind: 'same-image',
|
|
||||||
badgeClassName: 'badge badge-muted',
|
|
||||||
badgeLabel: '当前运行',
|
|
||||||
reason: '当前已运行该镜像,无需重复部署。',
|
|
||||||
canDeploy: false,
|
|
||||||
highlight: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const candidateTargetVersion = candidateVersion || candidateTag;
|
|
||||||
const helperVersionCompare = compareStableVersions(input.helper?.imageTag, candidateTargetVersion);
|
|
||||||
if (helperVersionCompare === 1) {
|
|
||||||
return {
|
|
||||||
kind: 'same-version',
|
|
||||||
badgeClassName: 'badge badge-muted',
|
|
||||||
badgeLabel: '当前运行',
|
|
||||||
reason: 'Deploy Helper 已指向更高版本,无需回退到较旧镜像。',
|
|
||||||
canDeploy: false,
|
|
||||||
highlight: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const versionCompare = compareStableVersions(input.currentVersion, candidateTargetVersion);
|
|
||||||
if (versionCompare === -1 && (helperVersionCompare == null || helperVersionCompare === -1)) {
|
|
||||||
return {
|
|
||||||
kind: 'new-version',
|
|
||||||
badgeClassName: 'badge badge-success',
|
|
||||||
badgeLabel: '发现新版本',
|
|
||||||
reason: 'Docker Hub 已出现更高版本,可直接发起部署。',
|
|
||||||
canDeploy: true,
|
|
||||||
highlight: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const helperDigest = normalizeDigest(input.helper?.imageDigest);
|
|
||||||
const hasSameStableTag = isSameImageTarget(
|
|
||||||
{
|
|
||||||
imageTag: input.helper?.imageTag,
|
|
||||||
imageDigest: null,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
tag: candidateTag,
|
|
||||||
digest: null,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
if (candidateDigest && helperDigest && hasSameStableTag && helperDigest !== candidateDigest) {
|
|
||||||
return {
|
|
||||||
kind: 'new-digest',
|
|
||||||
badgeClassName: 'badge badge-success',
|
|
||||||
badgeLabel: '发现新 digest',
|
|
||||||
reason: '标签未变,但镜像 digest 已更新,适合按镜像级别滚动更新。',
|
|
||||||
canDeploy: true,
|
|
||||||
highlight: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
kind: 'available',
|
|
||||||
badgeClassName: 'badge badge-info',
|
|
||||||
badgeLabel: '可部署',
|
|
||||||
reason: '版本可用,点击按钮即可通过 helper 发起滚动更新。',
|
|
||||||
canDeploy: true,
|
|
||||||
highlight: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildUpdateReminder(input: {
|
|
||||||
currentVersion?: string | null;
|
|
||||||
helper: UpdateHelperRuntimeLike | null | undefined;
|
|
||||||
githubRelease: UpdateVersionCandidateLike | null | undefined;
|
|
||||||
dockerHubTag: UpdateVersionCandidateLike | null | undefined;
|
|
||||||
}): UpdateReminder {
|
|
||||||
const hasGitHubCandidate = Boolean(normalizeString(
|
|
||||||
input.githubRelease?.displayVersion
|
|
||||||
|| input.githubRelease?.normalizedVersion
|
|
||||||
|| input.githubRelease?.tagName,
|
|
||||||
));
|
|
||||||
const hasDockerCandidate = Boolean(normalizeString(
|
|
||||||
input.dockerHubTag?.displayVersion
|
|
||||||
|| input.dockerHubTag?.normalizedVersion
|
|
||||||
|| input.dockerHubTag?.tagName
|
|
||||||
|| input.dockerHubTag?.digest,
|
|
||||||
));
|
|
||||||
if (!hasGitHubCandidate && !hasDockerCandidate) {
|
|
||||||
return {
|
|
||||||
label: '无法检查更新',
|
|
||||||
badgeClassName: 'badge badge-muted',
|
|
||||||
detail: '暂未获取到可比较的版本信息。',
|
|
||||||
highlight: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const candidate = resolveUpdateReminderCandidate({
|
|
||||||
currentVersion: input.currentVersion,
|
|
||||||
helper: input.helper,
|
|
||||||
githubRelease: input.githubRelease,
|
|
||||||
dockerHubTag: input.dockerHubTag,
|
|
||||||
});
|
|
||||||
if (candidate) {
|
|
||||||
return {
|
|
||||||
label: candidate.kind === 'new-digest' ? '发现新 digest' : '发现新版本',
|
|
||||||
badgeClassName: 'badge badge-success',
|
|
||||||
detail: candidate.kind === 'new-digest'
|
|
||||||
? 'Docker Hub 的 alias tag 已指向新 digest,可按需部署。'
|
|
||||||
: candidate.source === 'github-release'
|
|
||||||
? `GitHub 稳定版 ${normalizeString(input.githubRelease?.displayVersion || input.githubRelease?.normalizedVersion)} 已可部署。`
|
|
||||||
: `Docker Hub ${normalizeString(input.dockerHubTag?.displayVersion || input.dockerHubTag?.normalizedVersion)} 已可部署。`,
|
|
||||||
highlight: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
label: '已是最新',
|
|
||||||
badgeClassName: 'badge badge-muted',
|
|
||||||
detail: '当前运行版本与已发现的部署目标没有明显差异。',
|
|
||||||
highlight: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
export type UpdateCenterHistoryEntry = {
|
|
||||||
revision?: string;
|
|
||||||
updatedAt?: string | null;
|
|
||||||
status?: string | null;
|
|
||||||
description?: string | null;
|
|
||||||
imageTag?: string | null;
|
|
||||||
imageDigest?: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
type UpdateCenterHistoryEntryCardProps = {
|
|
||||||
entry: UpdateCenterHistoryEntry;
|
|
||||||
currentRevision: string;
|
|
||||||
helperHealthy: boolean;
|
|
||||||
deploying: boolean;
|
|
||||||
compact?: boolean;
|
|
||||||
formatTaskTime: (value?: string | null) => string;
|
|
||||||
formatImageTarget: (tag?: string | null, digest?: string | null) => string;
|
|
||||||
onRollback: (revision: string) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function UpdateCenterHistoryEntryCard({
|
|
||||||
entry,
|
|
||||||
currentRevision,
|
|
||||||
helperHealthy,
|
|
||||||
deploying,
|
|
||||||
compact = false,
|
|
||||||
formatTaskTime,
|
|
||||||
formatImageTarget,
|
|
||||||
onRollback,
|
|
||||||
}: UpdateCenterHistoryEntryCardProps) {
|
|
||||||
const revision = String(entry?.revision || '').trim();
|
|
||||||
const isCurrentRevision = revision && revision === currentRevision;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
border: '1px solid var(--color-border-light)',
|
|
||||||
borderRadius: 'var(--radius-sm)',
|
|
||||||
padding: compact ? 10 : 12,
|
|
||||||
display: 'grid',
|
|
||||||
gap: compact ? 5 : 6,
|
|
||||||
background: isCurrentRevision
|
|
||||||
? 'color-mix(in srgb, var(--color-primary) 6%, var(--color-bg-card))'
|
|
||||||
: 'var(--color-bg-card)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, flexWrap: 'wrap' }}>
|
|
||||||
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text-primary)' }}>
|
|
||||||
revision {revision || '-'}
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
|
||||||
{entry?.status ? <span className="badge badge-muted">{entry.status}</span> : null}
|
|
||||||
{isCurrentRevision ? <span className="badge badge-info">当前运行</span> : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text-primary)', fontFamily: 'var(--font-mono)' }}>
|
|
||||||
{formatImageTarget(entry?.imageTag, entry?.imageDigest) || '未记录镜像信息'}
|
|
||||||
</div>
|
|
||||||
{entry?.description ? (
|
|
||||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', lineHeight: 1.5 }}>
|
|
||||||
{entry.description}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', lineHeight: 1.5 }}>
|
|
||||||
更新时间:{formatTaskTime(entry?.updatedAt)}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
if (isCurrentRevision) return;
|
|
||||||
onRollback(revision);
|
|
||||||
}}
|
|
||||||
disabled={!helperHealthy || deploying || isCurrentRevision || !revision}
|
|
||||||
className="btn btn-ghost"
|
|
||||||
style={{ border: '1px solid var(--color-border)' }}
|
|
||||||
>
|
|
||||||
回退到 revision {revision}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
import CenteredModal from '../../components/CenteredModal.js';
|
|
||||||
import UpdateCenterHistoryEntryCard, { type UpdateCenterHistoryEntry } from './UpdateCenterHistoryEntryCard.js';
|
|
||||||
|
|
||||||
type UpdateCenterHistoryModalProps = {
|
|
||||||
open: boolean;
|
|
||||||
helperHealthy: boolean;
|
|
||||||
deploying: boolean;
|
|
||||||
currentRevision: string;
|
|
||||||
history: UpdateCenterHistoryEntry[];
|
|
||||||
formatTaskTime: (value?: string | null) => string;
|
|
||||||
formatImageTarget: (tag?: string | null, digest?: string | null) => string;
|
|
||||||
onClose: () => void;
|
|
||||||
onRollback: (revision: string) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function UpdateCenterHistoryModal({
|
|
||||||
open,
|
|
||||||
helperHealthy,
|
|
||||||
deploying,
|
|
||||||
currentRevision,
|
|
||||||
history,
|
|
||||||
formatTaskTime,
|
|
||||||
formatImageTarget,
|
|
||||||
onClose,
|
|
||||||
onRollback,
|
|
||||||
}: UpdateCenterHistoryModalProps) {
|
|
||||||
return (
|
|
||||||
<CenteredModal
|
|
||||||
open={open}
|
|
||||||
onClose={onClose}
|
|
||||||
title="全部 revision"
|
|
||||||
maxWidth={880}
|
|
||||||
closeOnBackdrop
|
|
||||||
closeOnEscape
|
|
||||||
footer={(
|
|
||||||
<button type="button" className="btn btn-ghost" onClick={onClose}>
|
|
||||||
关闭
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div style={{ display: 'grid', gap: 10 }}>
|
|
||||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', lineHeight: 1.6 }}>
|
|
||||||
这里保留 helper 读到的全部 Helm revision。默认列表只显示最近几条,弹窗里再展开全部回退记录,避免设置页被历史卡片拉得过长。
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'grid', gap: 10, maxHeight: 520, overflowY: 'auto', paddingRight: 4 }}>
|
|
||||||
{history.map((entry) => {
|
|
||||||
const revision = String(entry?.revision || '').trim();
|
|
||||||
return (
|
|
||||||
<UpdateCenterHistoryEntryCard
|
|
||||||
key={revision || 'unknown-revision-modal'}
|
|
||||||
entry={entry}
|
|
||||||
currentRevision={currentRevision}
|
|
||||||
helperHealthy={helperHealthy}
|
|
||||||
deploying={deploying}
|
|
||||||
formatTaskTime={formatTaskTime}
|
|
||||||
formatImageTarget={formatImageTarget}
|
|
||||||
onRollback={onRollback}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CenteredModal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,778 +0,0 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
||||||
import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer';
|
|
||||||
import { MemoryRouter } from 'react-router-dom';
|
|
||||||
import { ToastProvider } from '../../components/Toast.js';
|
|
||||||
|
|
||||||
import UpdateCenterSection from './UpdateCenterSection.js';
|
|
||||||
|
|
||||||
const { apiMock } = vi.hoisted(() => ({
|
|
||||||
apiMock: {
|
|
||||||
getUpdateCenterStatus: vi.fn(),
|
|
||||||
saveUpdateCenterConfig: vi.fn(),
|
|
||||||
checkUpdateCenter: vi.fn(),
|
|
||||||
deployUpdateCenter: vi.fn(),
|
|
||||||
rollbackUpdateCenter: vi.fn(),
|
|
||||||
streamUpdateCenterTaskLogs: vi.fn(),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock('../../api.js', () => ({
|
|
||||||
api: apiMock,
|
|
||||||
}));
|
|
||||||
|
|
||||||
function collectText(node: ReactTestInstance): string {
|
|
||||||
return (node.children || []).map((child) => {
|
|
||||||
if (typeof child === 'string') return child;
|
|
||||||
return collectText(child);
|
|
||||||
}).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function flushMicrotasks() {
|
|
||||||
await act(async () => {
|
|
||||||
await Promise.resolve();
|
|
||||||
await Promise.resolve();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('UpdateCenterSection', () => {
|
|
||||||
const originalDocument = globalThis.document;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
globalThis.document = {
|
|
||||||
body: {
|
|
||||||
nodeType: 1,
|
|
||||||
style: {
|
|
||||||
overflow: '',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
addEventListener: vi.fn(),
|
|
||||||
removeEventListener: vi.fn(),
|
|
||||||
} as unknown as Document;
|
|
||||||
apiMock.getUpdateCenterStatus.mockResolvedValue({
|
|
||||||
currentVersion: '1.2.3',
|
|
||||||
config: {
|
|
||||||
enabled: true,
|
|
||||||
helperBaseUrl: 'http://metapi-deploy-helper.ai.svc.cluster.local:9850',
|
|
||||||
namespace: 'ai',
|
|
||||||
releaseName: 'metapi',
|
|
||||||
chartRef: 'oci://ghcr.io/cita-777/charts/metapi',
|
|
||||||
imageRepository: '1467078763/metapi',
|
|
||||||
githubReleasesEnabled: true,
|
|
||||||
dockerHubTagsEnabled: true,
|
|
||||||
defaultDeploySource: 'github-release',
|
|
||||||
},
|
|
||||||
githubRelease: {
|
|
||||||
normalizedVersion: '1.3.0',
|
|
||||||
displayVersion: '1.3.0',
|
|
||||||
},
|
|
||||||
dockerHubTag: {
|
|
||||||
normalizedVersion: 'latest',
|
|
||||||
tagName: 'latest',
|
|
||||||
digest: 'sha256:efb2ee6553866bd3268dcc54c02fa5f9789728c51ed4af63328aaba6da67df35',
|
|
||||||
displayVersion: 'latest @ sha256:efb2ee655386',
|
|
||||||
publishedAt: '2026-03-29T11:54:35.591877Z',
|
|
||||||
},
|
|
||||||
dockerHubRecentTags: [
|
|
||||||
{
|
|
||||||
normalizedVersion: 'dev',
|
|
||||||
tagName: 'dev',
|
|
||||||
digest: 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
|
||||||
displayVersion: 'dev @ sha256:aaaaaaaaaaaa',
|
|
||||||
publishedAt: '2026-03-30T11:54:35.591877Z',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
normalizedVersion: 'dev-20260417-f67ade2',
|
|
||||||
tagName: 'dev-20260417-f67ade2',
|
|
||||||
digest: 'sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
|
|
||||||
displayVersion: 'dev-20260417-f67ade2 @ sha256:bbbbbbbbbbbb',
|
|
||||||
publishedAt: '2026-03-30T10:54:35.591877Z',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
normalizedVersion: 'feature-without-raw-tag',
|
|
||||||
digest: 'sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc',
|
|
||||||
displayVersion: 'feature-without-raw-tag @ sha256:cccccccccccc',
|
|
||||||
publishedAt: '2026-03-30T09:54:35.591877Z',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
helper: {
|
|
||||||
ok: true,
|
|
||||||
healthy: true,
|
|
||||||
revision: '17',
|
|
||||||
imageTag: 'latest',
|
|
||||||
imageDigest: 'sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc',
|
|
||||||
history: [
|
|
||||||
{
|
|
||||||
revision: '17',
|
|
||||||
updatedAt: '2026-03-29T12:00:00Z',
|
|
||||||
status: 'deployed',
|
|
||||||
description: 'Upgrade complete',
|
|
||||||
imageRepository: '1467078763/metapi',
|
|
||||||
imageTag: 'latest',
|
|
||||||
imageDigest: 'sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
revision: '16',
|
|
||||||
updatedAt: '2026-03-28T12:00:00Z',
|
|
||||||
status: 'superseded',
|
|
||||||
description: 'Rollback to stable digest',
|
|
||||||
imageRepository: '1467078763/metapi',
|
|
||||||
imageTag: 'main',
|
|
||||||
imageDigest: 'sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
revision: '15',
|
|
||||||
updatedAt: '2026-03-27T12:00:00Z',
|
|
||||||
status: 'superseded',
|
|
||||||
description: 'Earlier stable release',
|
|
||||||
imageRepository: '1467078763/metapi',
|
|
||||||
imageTag: '1.2.2',
|
|
||||||
imageDigest: 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
runtime: {
|
|
||||||
lastCheckedAt: '2026-03-30 20:30:00',
|
|
||||||
lastCheckError: null,
|
|
||||||
lastResolvedSource: 'github-release',
|
|
||||||
lastResolvedDisplayVersion: '1.3.0',
|
|
||||||
lastResolvedCandidateKey: 'github-release:v1.3.0',
|
|
||||||
lastNotifiedCandidateKey: 'github-release:v1.3.0',
|
|
||||||
lastNotifiedAt: '2026-03-30 20:31:00',
|
|
||||||
},
|
|
||||||
runningTask: null,
|
|
||||||
lastFinishedTask: null,
|
|
||||||
});
|
|
||||||
apiMock.saveUpdateCenterConfig.mockResolvedValue({
|
|
||||||
success: true,
|
|
||||||
config: {
|
|
||||||
enabled: true,
|
|
||||||
helperBaseUrl: 'http://updated-helper.ai.svc.cluster.local:9850',
|
|
||||||
namespace: 'ai',
|
|
||||||
releaseName: 'metapi',
|
|
||||||
chartRef: 'oci://ghcr.io/cita-777/charts/metapi',
|
|
||||||
imageRepository: '1467078763/metapi',
|
|
||||||
githubReleasesEnabled: true,
|
|
||||||
dockerHubTagsEnabled: true,
|
|
||||||
defaultDeploySource: 'github-release',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
apiMock.checkUpdateCenter.mockResolvedValue({
|
|
||||||
currentVersion: '1.2.3',
|
|
||||||
config: {
|
|
||||||
enabled: true,
|
|
||||||
helperBaseUrl: 'http://metapi-deploy-helper.ai.svc.cluster.local:9850',
|
|
||||||
namespace: 'ai',
|
|
||||||
releaseName: 'metapi',
|
|
||||||
chartRef: 'oci://ghcr.io/cita-777/charts/metapi',
|
|
||||||
imageRepository: '1467078763/metapi',
|
|
||||||
githubReleasesEnabled: true,
|
|
||||||
dockerHubTagsEnabled: true,
|
|
||||||
defaultDeploySource: 'github-release',
|
|
||||||
},
|
|
||||||
githubRelease: {
|
|
||||||
normalizedVersion: '1.3.0',
|
|
||||||
displayVersion: '1.3.0',
|
|
||||||
},
|
|
||||||
dockerHubTag: {
|
|
||||||
normalizedVersion: 'latest',
|
|
||||||
tagName: 'latest',
|
|
||||||
digest: 'sha256:efb2ee6553866bd3268dcc54c02fa5f9789728c51ed4af63328aaba6da67df35',
|
|
||||||
displayVersion: 'latest @ sha256:efb2ee655386',
|
|
||||||
publishedAt: '2026-03-29T11:54:35.591877Z',
|
|
||||||
},
|
|
||||||
dockerHubRecentTags: [
|
|
||||||
{
|
|
||||||
normalizedVersion: 'dev',
|
|
||||||
tagName: 'dev',
|
|
||||||
digest: 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
|
||||||
displayVersion: 'dev @ sha256:aaaaaaaaaaaa',
|
|
||||||
publishedAt: '2026-03-30T11:54:35.591877Z',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
helper: {
|
|
||||||
ok: true,
|
|
||||||
healthy: true,
|
|
||||||
revision: '18',
|
|
||||||
imageTag: 'latest',
|
|
||||||
imageDigest: 'sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd',
|
|
||||||
history: [],
|
|
||||||
},
|
|
||||||
runtime: {
|
|
||||||
lastCheckedAt: '2026-03-30 20:35:00',
|
|
||||||
lastCheckError: null,
|
|
||||||
lastResolvedSource: 'github-release',
|
|
||||||
lastResolvedDisplayVersion: '1.3.0',
|
|
||||||
lastResolvedCandidateKey: 'github-release:v1.3.0',
|
|
||||||
lastNotifiedCandidateKey: 'github-release:v1.3.0',
|
|
||||||
lastNotifiedAt: '2026-03-30 20:31:00',
|
|
||||||
},
|
|
||||||
runningTask: null,
|
|
||||||
lastFinishedTask: null,
|
|
||||||
});
|
|
||||||
apiMock.deployUpdateCenter.mockResolvedValue({
|
|
||||||
success: true,
|
|
||||||
reused: false,
|
|
||||||
task: {
|
|
||||||
id: 'task-1',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
apiMock.rollbackUpdateCenter.mockResolvedValue({
|
|
||||||
success: true,
|
|
||||||
reused: false,
|
|
||||||
task: {
|
|
||||||
id: 'task-2',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
apiMock.streamUpdateCenterTaskLogs.mockImplementation(async (_taskId: string, handlers: { onLog?: (entry: { message: string }) => void; onDone?: (payload: { status: string }) => void }) => {
|
|
||||||
handlers.onLog?.({ message: 'Running helm upgrade' });
|
|
||||||
handlers.onLog?.({ message: 'Waiting for rollout' });
|
|
||||||
handlers.onDone?.({ status: 'succeeded' });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
globalThis.document = originalDocument;
|
|
||||||
});
|
|
||||||
|
|
||||||
it('loads status, saves config updates, and renders streamed deploy logs', async () => {
|
|
||||||
let root!: ReactTestRenderer;
|
|
||||||
try {
|
|
||||||
await act(async () => {
|
|
||||||
root = create(
|
|
||||||
<MemoryRouter>
|
|
||||||
<ToastProvider>
|
|
||||||
<UpdateCenterSection />
|
|
||||||
</ToastProvider>
|
|
||||||
</MemoryRouter>,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
await flushMicrotasks();
|
|
||||||
|
|
||||||
const helperInput = root.root.find((node) => (
|
|
||||||
node.type === 'input'
|
|
||||||
&& node.props.value === 'http://metapi-deploy-helper.ai.svc.cluster.local:9850'
|
|
||||||
));
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
helperInput.props.onChange({ target: { value: 'http://updated-helper.ai.svc.cluster.local:9850' } });
|
|
||||||
});
|
|
||||||
const checkboxInputs = root.root.findAll((node) => node.type === 'input' && node.props.type === 'checkbox');
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
checkboxInputs[1].props.onChange({ target: { checked: false } });
|
|
||||||
checkboxInputs[2].props.onChange({ target: { checked: false } });
|
|
||||||
});
|
|
||||||
|
|
||||||
const defaultSourceTrigger = root.root.find((node) => (
|
|
||||||
node.type === 'button'
|
|
||||||
&& typeof node.props.className === 'string'
|
|
||||||
&& node.props.className.includes('modern-select-trigger')
|
|
||||||
));
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
defaultSourceTrigger.props.onClick();
|
|
||||||
});
|
|
||||||
|
|
||||||
const dockerHubOption = root.root.find((node) => (
|
|
||||||
node.type === 'button'
|
|
||||||
&& typeof node.props.className === 'string'
|
|
||||||
&& node.props.className.includes('modern-select-option')
|
|
||||||
&& collectText(node).includes('Docker Hub Tags')
|
|
||||||
));
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
dockerHubOption.props.onClick();
|
|
||||||
});
|
|
||||||
|
|
||||||
const saveButton = root.root.find((node) => (
|
|
||||||
node.type === 'button'
|
|
||||||
&& typeof node.props.onClick === 'function'
|
|
||||||
&& collectText(node).includes('保存更新中心配置')
|
|
||||||
));
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
await saveButton.props.onClick();
|
|
||||||
});
|
|
||||||
await flushMicrotasks();
|
|
||||||
|
|
||||||
expect(apiMock.saveUpdateCenterConfig).toHaveBeenCalledWith(expect.objectContaining({
|
|
||||||
helperBaseUrl: 'http://updated-helper.ai.svc.cluster.local:9850',
|
|
||||||
githubReleasesEnabled: false,
|
|
||||||
dockerHubTagsEnabled: false,
|
|
||||||
defaultDeploySource: 'docker-hub-tag',
|
|
||||||
}));
|
|
||||||
|
|
||||||
const deployButton = root.root.find((node) => (
|
|
||||||
node.type === 'button'
|
|
||||||
&& typeof node.props.onClick === 'function'
|
|
||||||
&& collectText(node).includes('部署 GitHub 稳定版')
|
|
||||||
));
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
await deployButton.props.onClick();
|
|
||||||
});
|
|
||||||
await flushMicrotasks();
|
|
||||||
|
|
||||||
expect(apiMock.deployUpdateCenter).toHaveBeenCalledWith({
|
|
||||||
source: 'github-release',
|
|
||||||
targetTag: '1.3.0',
|
|
||||||
targetDigest: null,
|
|
||||||
});
|
|
||||||
expect(apiMock.streamUpdateCenterTaskLogs).toHaveBeenCalledWith('task-1', expect.any(Object));
|
|
||||||
expect(apiMock.checkUpdateCenter).toHaveBeenCalledTimes(1);
|
|
||||||
|
|
||||||
const text = collectText(root.root);
|
|
||||||
expect(text).toContain('latest @ sha256:efb2ee655386');
|
|
||||||
expect(text).toContain('发现新版本');
|
|
||||||
expect(text).toContain('后台检查');
|
|
||||||
expect(text).toContain('1.3.0');
|
|
||||||
expect(text).toContain('Running helm upgrade');
|
|
||||||
expect(text).toContain('Waiting for rollout');
|
|
||||||
expect(text).toContain('任务状态 · 已完成');
|
|
||||||
} finally {
|
|
||||||
root?.unmount();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('disables deploy actions when the helper is unhealthy', async () => {
|
|
||||||
apiMock.getUpdateCenterStatus.mockResolvedValueOnce({
|
|
||||||
currentVersion: '1.2.3',
|
|
||||||
config: {
|
|
||||||
enabled: true,
|
|
||||||
helperBaseUrl: 'http://metapi-deploy-helper.ai.svc.cluster.local:9850',
|
|
||||||
namespace: 'ai',
|
|
||||||
releaseName: 'metapi',
|
|
||||||
chartRef: 'oci://ghcr.io/cita-777/charts/metapi',
|
|
||||||
imageRepository: '1467078763/metapi',
|
|
||||||
githubReleasesEnabled: true,
|
|
||||||
dockerHubTagsEnabled: true,
|
|
||||||
defaultDeploySource: 'github-release',
|
|
||||||
},
|
|
||||||
githubRelease: {
|
|
||||||
normalizedVersion: '1.3.0',
|
|
||||||
},
|
|
||||||
dockerHubTag: {
|
|
||||||
normalizedVersion: 'latest',
|
|
||||||
displayVersion: 'latest @ sha256:efb2ee655386',
|
|
||||||
},
|
|
||||||
helper: {
|
|
||||||
ok: false,
|
|
||||||
healthy: false,
|
|
||||||
error: 'helper unavailable',
|
|
||||||
},
|
|
||||||
runningTask: null,
|
|
||||||
lastFinishedTask: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
let root!: ReactTestRenderer;
|
|
||||||
try {
|
|
||||||
await act(async () => {
|
|
||||||
root = create(
|
|
||||||
<MemoryRouter>
|
|
||||||
<ToastProvider>
|
|
||||||
<UpdateCenterSection />
|
|
||||||
</ToastProvider>
|
|
||||||
</MemoryRouter>,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
await flushMicrotasks();
|
|
||||||
|
|
||||||
const githubDeployButton = root.root.find((node) => (
|
|
||||||
node.type === 'button'
|
|
||||||
&& typeof node.props.className === 'string'
|
|
||||||
&& node.props.className.includes('btn')
|
|
||||||
&& collectText(node).includes('部署 GitHub 稳定版')
|
|
||||||
));
|
|
||||||
const dockerDeployButton = root.root.find((node) => (
|
|
||||||
node.type === 'button'
|
|
||||||
&& typeof node.props.className === 'string'
|
|
||||||
&& node.props.className.includes('btn')
|
|
||||||
&& collectText(node).includes('部署 Docker Hub 标签')
|
|
||||||
));
|
|
||||||
|
|
||||||
expect(githubDeployButton.props.disabled).toBe(true);
|
|
||||||
expect(dockerDeployButton.props.disabled).toBe(true);
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
githubDeployButton.props.onClick?.();
|
|
||||||
dockerDeployButton.props.onClick?.();
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(apiMock.deployUpdateCenter).not.toHaveBeenCalled();
|
|
||||||
} finally {
|
|
||||||
root?.unmount();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('deploys manual Docker Hub tags so dev and branch images are reachable from the UI', async () => {
|
|
||||||
let root!: ReactTestRenderer;
|
|
||||||
try {
|
|
||||||
await act(async () => {
|
|
||||||
root = create(
|
|
||||||
<MemoryRouter>
|
|
||||||
<ToastProvider>
|
|
||||||
<UpdateCenterSection />
|
|
||||||
</ToastProvider>
|
|
||||||
</MemoryRouter>,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
await flushMicrotasks();
|
|
||||||
|
|
||||||
const manualTagInput = root.root.find((node) => (
|
|
||||||
node.type === 'input'
|
|
||||||
&& node.props.placeholder === 'dev / dev-20260417-f67ade2 / sha-f67ade2'
|
|
||||||
));
|
|
||||||
const manualDigestInput = root.root.find((node) => (
|
|
||||||
node.type === 'input'
|
|
||||||
&& node.props.placeholder === '可选 digest:sha256:...'
|
|
||||||
));
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
manualTagInput.props.onChange({ target: { value: 'dev-20260417-f67ade2' } });
|
|
||||||
manualDigestInput.props.onChange({
|
|
||||||
target: {
|
|
||||||
value: 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const customDeployButton = root.root.find((node) => (
|
|
||||||
node.type === 'button'
|
|
||||||
&& typeof node.props.onClick === 'function'
|
|
||||||
&& collectText(node).includes('部署自定义 Docker 标签')
|
|
||||||
));
|
|
||||||
|
|
||||||
expect(customDeployButton.props.disabled).toBe(false);
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
await customDeployButton.props.onClick();
|
|
||||||
});
|
|
||||||
await flushMicrotasks();
|
|
||||||
|
|
||||||
expect(apiMock.deployUpdateCenter).toHaveBeenCalledWith({
|
|
||||||
source: 'docker-hub-tag',
|
|
||||||
targetTag: 'dev-20260417-f67ade2',
|
|
||||||
targetDigest: 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
|
||||||
});
|
|
||||||
expect(apiMock.streamUpdateCenterTaskLogs).toHaveBeenCalledWith('task-1', expect.any(Object));
|
|
||||||
} finally {
|
|
||||||
root?.unmount();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('deploys auto-discovered recent non-stable Docker Hub tags without manual input', async () => {
|
|
||||||
let root!: ReactTestRenderer;
|
|
||||||
try {
|
|
||||||
await act(async () => {
|
|
||||||
root = create(
|
|
||||||
<MemoryRouter>
|
|
||||||
<ToastProvider>
|
|
||||||
<UpdateCenterSection />
|
|
||||||
</ToastProvider>
|
|
||||||
</MemoryRouter>,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
await flushMicrotasks();
|
|
||||||
|
|
||||||
expect(collectText(root.root)).toContain('Digest:sha256:bbbbbbbbbbbb');
|
|
||||||
expect(collectText(root.root)).not.toContain('feature-without-raw-tag');
|
|
||||||
expect(collectText(root.root)).toContain('dev-20260417-f67ade2 @ sha256:bbbbbbbbbbbb');
|
|
||||||
|
|
||||||
const deployRecentButton = root.root.find((node) => (
|
|
||||||
node.type === 'button'
|
|
||||||
&& typeof node.props.onClick === 'function'
|
|
||||||
&& collectText(node).includes('部署 dev-20260417-f67ade2')
|
|
||||||
));
|
|
||||||
|
|
||||||
expect(deployRecentButton.props.disabled).toBe(false);
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
await deployRecentButton.props.onClick();
|
|
||||||
});
|
|
||||||
await flushMicrotasks();
|
|
||||||
|
|
||||||
expect(apiMock.deployUpdateCenter).toHaveBeenCalledWith({
|
|
||||||
source: 'docker-hub-tag',
|
|
||||||
targetTag: 'dev-20260417-f67ade2',
|
|
||||||
targetDigest: 'sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
|
|
||||||
});
|
|
||||||
expect(apiMock.streamUpdateCenterTaskLogs).toHaveBeenCalledWith('task-1', expect.any(Object));
|
|
||||||
} finally {
|
|
||||||
root?.unmount();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('disables only the recent Docker candidate that already matches the running helper image', async () => {
|
|
||||||
apiMock.getUpdateCenterStatus.mockResolvedValueOnce({
|
|
||||||
currentVersion: '1.2.3',
|
|
||||||
config: {
|
|
||||||
enabled: true,
|
|
||||||
helperBaseUrl: 'http://metapi-deploy-helper.ai.svc.cluster.local:9850',
|
|
||||||
namespace: 'ai',
|
|
||||||
releaseName: 'metapi',
|
|
||||||
chartRef: 'oci://ghcr.io/cita-777/charts/metapi',
|
|
||||||
imageRepository: '1467078763/metapi',
|
|
||||||
githubReleasesEnabled: true,
|
|
||||||
dockerHubTagsEnabled: true,
|
|
||||||
defaultDeploySource: 'docker-hub-tag',
|
|
||||||
},
|
|
||||||
githubRelease: {
|
|
||||||
normalizedVersion: '1.3.0',
|
|
||||||
displayVersion: '1.3.0',
|
|
||||||
},
|
|
||||||
dockerHubTag: {
|
|
||||||
normalizedVersion: 'latest',
|
|
||||||
tagName: 'latest',
|
|
||||||
digest: 'sha256:efb2ee6553866bd3268dcc54c02fa5f9789728c51ed4af63328aaba6da67df35',
|
|
||||||
displayVersion: 'latest @ sha256:efb2ee655386',
|
|
||||||
},
|
|
||||||
dockerHubRecentTags: [
|
|
||||||
{
|
|
||||||
normalizedVersion: 'dev',
|
|
||||||
tagName: 'dev',
|
|
||||||
digest: 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
|
||||||
displayVersion: 'dev @ sha256:aaaaaaaaaaaa',
|
|
||||||
publishedAt: '2026-03-30T11:54:35.591877Z',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
normalizedVersion: 'dev-20260417-next',
|
|
||||||
tagName: 'dev-20260417-next',
|
|
||||||
digest: 'sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
|
|
||||||
displayVersion: 'dev-20260417-next @ sha256:bbbbbbbbbbbb',
|
|
||||||
publishedAt: '2026-03-30T10:54:35.591877Z',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
helper: {
|
|
||||||
ok: true,
|
|
||||||
healthy: true,
|
|
||||||
revision: '17',
|
|
||||||
imageTag: 'dev',
|
|
||||||
imageDigest: 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
|
||||||
history: [],
|
|
||||||
},
|
|
||||||
runtime: {
|
|
||||||
lastCheckedAt: '2026-03-30 20:30:00',
|
|
||||||
lastCheckError: null,
|
|
||||||
lastResolvedSource: 'docker-hub-tag',
|
|
||||||
lastResolvedDisplayVersion: 'dev',
|
|
||||||
lastResolvedCandidateKey: 'docker-hub-tag:dev@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
|
||||||
lastNotifiedCandidateKey: null,
|
|
||||||
lastNotifiedAt: null,
|
|
||||||
},
|
|
||||||
runningTask: null,
|
|
||||||
lastFinishedTask: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
let root!: ReactTestRenderer;
|
|
||||||
try {
|
|
||||||
await act(async () => {
|
|
||||||
root = create(
|
|
||||||
<MemoryRouter>
|
|
||||||
<ToastProvider>
|
|
||||||
<UpdateCenterSection />
|
|
||||||
</ToastProvider>
|
|
||||||
</MemoryRouter>,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
await flushMicrotasks();
|
|
||||||
|
|
||||||
const runningCandidateButton = root.root.find((node) => (
|
|
||||||
node.type === 'button'
|
|
||||||
&& typeof node.props.onClick === 'function'
|
|
||||||
&& collectText(node) === '部署 dev'
|
|
||||||
));
|
|
||||||
const nextCandidateButton = root.root.find((node) => (
|
|
||||||
node.type === 'button'
|
|
||||||
&& typeof node.props.onClick === 'function'
|
|
||||||
&& collectText(node) === '部署 dev-20260417-next'
|
|
||||||
));
|
|
||||||
|
|
||||||
expect(runningCandidateButton.props.disabled).toBe(true);
|
|
||||||
expect(runningCandidateButton.props.title).toContain('当前已运行该镜像');
|
|
||||||
expect(nextCandidateButton.props.disabled).toBe(false);
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
await nextCandidateButton.props.onClick();
|
|
||||||
});
|
|
||||||
await flushMicrotasks();
|
|
||||||
|
|
||||||
expect(apiMock.deployUpdateCenter).toHaveBeenCalledWith({
|
|
||||||
source: 'docker-hub-tag',
|
|
||||||
targetTag: 'dev-20260417-next',
|
|
||||||
targetDigest: 'sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
|
|
||||||
});
|
|
||||||
expect(apiMock.streamUpdateCenterTaskLogs).toHaveBeenCalledWith('task-1', expect.any(Object));
|
|
||||||
} finally {
|
|
||||||
root?.unmount();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps rollback history compact and opens the full revision list in a centered modal', async () => {
|
|
||||||
let root!: ReactTestRenderer;
|
|
||||||
try {
|
|
||||||
await act(async () => {
|
|
||||||
root = create(
|
|
||||||
<MemoryRouter>
|
|
||||||
<ToastProvider>
|
|
||||||
<UpdateCenterSection />
|
|
||||||
</ToastProvider>
|
|
||||||
</MemoryRouter>,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
await flushMicrotasks();
|
|
||||||
|
|
||||||
const compactText = collectText(root.root);
|
|
||||||
expect(compactText).toContain('展开全部 3 条');
|
|
||||||
expect(compactText).not.toContain('Earlier stable release');
|
|
||||||
expect(compactText).not.toContain('回退到 revision 15');
|
|
||||||
|
|
||||||
const openHistoryButton = root.root.find((node) => (
|
|
||||||
node.type === 'button'
|
|
||||||
&& typeof node.props.onClick === 'function'
|
|
||||||
&& collectText(node).includes('展开全部 3 条')
|
|
||||||
));
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
await openHistoryButton.props.onClick();
|
|
||||||
});
|
|
||||||
await flushMicrotasks();
|
|
||||||
|
|
||||||
const modal = root.root.find((node) => (
|
|
||||||
typeof node.props.className === 'string'
|
|
||||||
&& node.props.className.includes('modal-content')
|
|
||||||
&& collectText(node).includes('全部 revision')
|
|
||||||
));
|
|
||||||
|
|
||||||
expect(collectText(modal)).toContain('Earlier stable release');
|
|
||||||
expect(collectText(modal)).toContain('回退到 revision 15');
|
|
||||||
} finally {
|
|
||||||
root?.unmount();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('blocks Docker Hub deploys when the current helper image already matches the target digest', async () => {
|
|
||||||
apiMock.getUpdateCenterStatus.mockResolvedValueOnce({
|
|
||||||
currentVersion: '1.2.3',
|
|
||||||
config: {
|
|
||||||
enabled: true,
|
|
||||||
helperBaseUrl: 'http://metapi-deploy-helper.ai.svc.cluster.local:9850',
|
|
||||||
namespace: 'ai',
|
|
||||||
releaseName: 'metapi',
|
|
||||||
chartRef: 'oci://ghcr.io/cita-777/charts/metapi',
|
|
||||||
imageRepository: '1467078763/metapi',
|
|
||||||
githubReleasesEnabled: true,
|
|
||||||
dockerHubTagsEnabled: true,
|
|
||||||
defaultDeploySource: 'docker-hub-tag',
|
|
||||||
},
|
|
||||||
githubRelease: {
|
|
||||||
normalizedVersion: '1.3.0',
|
|
||||||
displayVersion: '1.3.0',
|
|
||||||
},
|
|
||||||
dockerHubTag: {
|
|
||||||
normalizedVersion: 'latest',
|
|
||||||
tagName: 'latest',
|
|
||||||
digest: 'sha256:efb2ee6553866bd3268dcc54c02fa5f9789728c51ed4af63328aaba6da67df35',
|
|
||||||
displayVersion: 'latest @ sha256:efb2ee655386',
|
|
||||||
},
|
|
||||||
helper: {
|
|
||||||
ok: true,
|
|
||||||
healthy: true,
|
|
||||||
revision: '17',
|
|
||||||
imageTag: 'latest',
|
|
||||||
imageDigest: 'sha256:efb2ee6553866bd3268dcc54c02fa5f9789728c51ed4af63328aaba6da67df35',
|
|
||||||
},
|
|
||||||
runningTask: null,
|
|
||||||
lastFinishedTask: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
let root!: ReactTestRenderer;
|
|
||||||
try {
|
|
||||||
await act(async () => {
|
|
||||||
root = create(
|
|
||||||
<MemoryRouter>
|
|
||||||
<ToastProvider>
|
|
||||||
<UpdateCenterSection />
|
|
||||||
</ToastProvider>
|
|
||||||
</MemoryRouter>,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
await flushMicrotasks();
|
|
||||||
|
|
||||||
const dockerDeployButton = root.root.find((node) => (
|
|
||||||
node.type === 'button'
|
|
||||||
&& typeof node.props.className === 'string'
|
|
||||||
&& node.props.className.includes('btn')
|
|
||||||
&& collectText(node).includes('部署 Docker Hub 标签')
|
|
||||||
));
|
|
||||||
|
|
||||||
expect(dockerDeployButton.props.disabled).toBe(true);
|
|
||||||
expect(collectText(root.root)).toContain('当前已运行该镜像');
|
|
||||||
} finally {
|
|
||||||
root?.unmount();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('parses timezone-less SQL runtime timestamps as UTC before formatting', async () => {
|
|
||||||
const parseSpy = vi.spyOn(Date, 'parse');
|
|
||||||
let root!: ReactTestRenderer;
|
|
||||||
try {
|
|
||||||
await act(async () => {
|
|
||||||
root = create(
|
|
||||||
<MemoryRouter>
|
|
||||||
<ToastProvider>
|
|
||||||
<UpdateCenterSection />
|
|
||||||
</ToastProvider>
|
|
||||||
</MemoryRouter>,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
await flushMicrotasks();
|
|
||||||
|
|
||||||
const parseInputs = parseSpy.mock.calls.map(([value]) => String(value));
|
|
||||||
expect(parseInputs).toContain('2026-03-30T20:30:00Z');
|
|
||||||
expect(parseInputs).not.toContain('2026-03-30 20:30:00');
|
|
||||||
} finally {
|
|
||||||
parseSpy.mockRestore();
|
|
||||||
root?.unmount();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders rollback history and triggers rollback tasks for previous revisions', async () => {
|
|
||||||
let root!: ReactTestRenderer;
|
|
||||||
try {
|
|
||||||
await act(async () => {
|
|
||||||
root = create(
|
|
||||||
<MemoryRouter>
|
|
||||||
<ToastProvider>
|
|
||||||
<UpdateCenterSection />
|
|
||||||
</ToastProvider>
|
|
||||||
</MemoryRouter>,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
await flushMicrotasks();
|
|
||||||
|
|
||||||
const rollbackButton = root.root.find((node) => (
|
|
||||||
node.type === 'button'
|
|
||||||
&& typeof node.props.onClick === 'function'
|
|
||||||
&& collectText(node).includes('回退到 revision 16')
|
|
||||||
));
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
await rollbackButton.props.onClick();
|
|
||||||
});
|
|
||||||
await flushMicrotasks();
|
|
||||||
|
|
||||||
expect(apiMock.rollbackUpdateCenter).toHaveBeenCalledWith({
|
|
||||||
targetRevision: '16',
|
|
||||||
});
|
|
||||||
expect(apiMock.streamUpdateCenterTaskLogs).toHaveBeenCalledWith('task-2', expect.any(Object));
|
|
||||||
expect(apiMock.checkUpdateCenter).toHaveBeenCalledTimes(1);
|
|
||||||
|
|
||||||
const text = collectText(root.root);
|
|
||||||
expect(text).toContain('最近状态:succeeded');
|
|
||||||
} finally {
|
|
||||||
root?.unmount();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user