diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 591f8992a0..7c3311cb30 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -770,6 +770,48 @@ paths: schema: type: boolean + /workspaces/migrate: + post: + summary: migrate workspace data from source to target + operationId: migrateWorkspace + tags: + - workspace + requestBody: + description: migration request + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/MigrateWorkspaceRequest" + responses: + "200": + description: migration completed + content: + text/plain: + schema: + type: string + + /workspaces/migration/status: + get: + summary: get migration status + operationId: getMigrationStatus + tags: + - workspace + parameters: + - name: source_workspace + in: query + required: true + description: source workspace id to check migration status + schema: + type: string + responses: + "200": + description: migration status + content: + application/json: + schema: + $ref: "#/components/schemas/MigrateJobResponse" + /settings/get_ducklake_instance_catalog_db_status: post: summary: Returns the set-up statuses of ducklake instance catalog dbs @@ -17613,6 +17655,52 @@ components: - SKIP - FAIL + MigrateWorkspaceRequest: + type: object + properties: + source_workspace_id: + type: string + description: source workspace id + target_workspace_id: + type: string + description: target workspace id + target_workspace_name: + type: string + description: target workspace name + migration_type: + type: string + enum: [all, metadata, jobs] + description: type of migration to perform + disable_workspace: + type: boolean + default: true + description: whether to disable source workspace after migration + required: + - source_workspace_id + - target_workspace_name + - target_workspace_id + - migration_type + + MigrateJobResponse: + type: object + properties: + source_workspace: + type: string + total_jobs: + type: integer + format: int64 + remaining_jobs: + type: integer + format: int64 + migration_progress: + type: number + format: float + required: + - source_workspace + - total_jobs + - remaining_jobs + - migration_progress + DucklakeInstanceCatalogDbStatusLogs: type: object properties: diff --git a/backend/windmill-api/src/workspaces.rs b/backend/windmill-api/src/workspaces.rs index ff399ee7c6..4b140097e2 100644 --- a/backend/windmill-api/src/workspaces.rs +++ b/backend/windmill-api/src/workspaces.rs @@ -143,10 +143,6 @@ pub fn workspaced_service() -> Router { .route("/get_workspace_name", get(get_workspace_name)) .route("/change_workspace_name", post(change_workspace_name)) .route("/change_workspace_color", post(change_workspace_color)) - .route( - "/change_workspace_id", - post(crate::workspaces_extra::change_workspace_id), - ) .route("/usage", get(get_usage)) .route("/used_triggers", get(get_used_triggers)) .route("/critical_alerts", get(get_critical_alerts)) @@ -188,6 +184,11 @@ pub fn global_service() -> Router { "/create_workspace_require_superadmin", get(create_workspace_require_superadmin), ) + .route("/migrate", post(crate::workspaces_extra::migrate_workspace)) + .route( + "/migration/status", + get(crate::workspaces_extra::get_migration_status), + ) } #[derive(FromRow, Serialize)] diff --git a/backend/windmill-api/src/workspaces_extra.rs b/backend/windmill-api/src/workspaces_extra.rs index 33ddbd17e5..8faccc7d0b 100644 --- a/backend/windmill-api/src/workspaces_extra.rs +++ b/backend/windmill-api/src/workspaces_extra.rs @@ -13,6 +13,7 @@ use sqlx::{Postgres, Transaction}; use windmill_audit::audit_oss::audit_log; use windmill_audit::ActionKind; +use windmill_common::error::JsonResult; use windmill_common::worker::CLOUD_HOSTED; use windmill_common::{ @@ -21,399 +22,29 @@ use windmill_common::{ utils::require_admin, }; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; #[derive(Deserialize)] -pub(crate) struct ChangeWorkspaceId { - new_id: String, - new_name: String, +pub(crate) struct MigrateWorkspaceRequest { + source_workspace_id: String, + target_workspace_name: String, + target_workspace_id: String, + migration_type: MigrationType, + #[serde(default = "default_disable_workspace")] + disable_workspace: bool, } -pub(crate) async fn change_workspace_id( - authed: ApiAuthed, - Path(old_id): Path, - Extension(db): Extension, - Json(rw): Json, -) -> Result { - if *CLOUD_HOSTED && !is_super_admin_email(&db, &authed.email).await? { - return Err(Error::BadRequest( - "This feature is not available on the cloud".to_string(), - )); - } +fn default_disable_workspace() -> bool { + true +} - if *CREATE_WORKSPACE_REQUIRE_SUPERADMIN { - require_super_admin(&db, &authed.email).await?; - } else { - require_admin(authed.is_admin, &authed.username)?; - } - - let mut tx = db.begin().await?; - - check_w_id_conflict(&mut tx, &rw.new_id).await?; - - // duplicate workspace with new id name - sqlx::query!( - "INSERT INTO workspace SELECT $1, $2, owner, deleted, premium FROM workspace WHERE id = $3", - &rw.new_id, - &rw.new_name, - &old_id - ) - .execute(&mut *tx) - .await?; - - sqlx::query!( - "UPDATE account SET workspace_id = $1 WHERE workspace_id = $2", - &rw.new_id, - &old_id - ) - .execute(&mut *tx) - .await?; - - sqlx::query!( - "UPDATE app SET workspace_id = $1 WHERE workspace_id = $2", - &rw.new_id, - &old_id - ) - .execute(&mut *tx) - .await?; - - sqlx::query!( - "UPDATE audit SET workspace_id = $1 WHERE workspace_id = $2", - &rw.new_id, - &old_id - ) - .execute(&mut *tx) - .await?; - - sqlx::query!( - "UPDATE capture SET workspace_id = $1 WHERE workspace_id = $2", - &rw.new_id, - &old_id - ) - .execute(&mut *tx) - .await?; - - sqlx::query!( - "UPDATE capture_config SET workspace_id = $1 WHERE workspace_id = $2", - &rw.new_id, - &old_id - ) - .execute(&mut *tx) - .await?; - - sqlx::query!( - "UPDATE http_trigger SET workspace_id = $1 WHERE workspace_id = $2", - &rw.new_id, - &old_id - ) - .execute(&mut *tx) - .await?; - - sqlx::query!( - "UPDATE websocket_trigger SET workspace_id = $1 WHERE workspace_id = $2", - &rw.new_id, - &old_id - ) - .execute(&mut *tx) - .await?; - - sqlx::query!( - "UPDATE kafka_trigger SET workspace_id = $1 WHERE workspace_id = $2", - &rw.new_id, - &old_id - ) - .execute(&mut *tx) - .await?; - - sqlx::query!( - "UPDATE nats_trigger SET workspace_id = $1 WHERE workspace_id = $2", - &rw.new_id, - &old_id - ) - .execute(&mut *tx) - .await?; - - sqlx::query!( - "UPDATE v2_job_completed SET workspace_id = $1 WHERE workspace_id = $2", - &rw.new_id, - &old_id - ) - .execute(&mut *tx) - .await?; - - sqlx::query!( - "UPDATE dependency_map SET workspace_id = $1 WHERE workspace_id = $2", - &rw.new_id, - &old_id - ) - .execute(&mut *tx) - .await?; - - sqlx::query!( - "UPDATE deployment_metadata SET workspace_id = $1 WHERE workspace_id = $2", - &rw.new_id, - &old_id - ) - .execute(&mut *tx) - .await?; - - sqlx::query!( - "UPDATE draft SET workspace_id = $1 WHERE workspace_id = $2", - &rw.new_id, - &old_id - ) - .execute(&mut *tx) - .await?; - - sqlx::query!( - "UPDATE favorite SET workspace_id = $1 WHERE workspace_id = $2", - &rw.new_id, - &old_id - ) - .execute(&mut *tx) - .await?; - - sqlx::query!( - "INSERT INTO flow - (workspace_id, path, summary, description, archived, extra_perms, dependency_job, draft_only, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at) - SELECT $1, path, summary, description, archived, extra_perms, dependency_job, draft_only, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at - FROM flow WHERE workspace_id = $2", - &rw.new_id, - &old_id - ) - .execute(&mut *tx) - .await?; - - sqlx::query!( - "UPDATE flow_version SET workspace_id = $1 WHERE workspace_id = $2", - &rw.new_id, - &old_id - ) - .execute(&mut *tx) - .await?; - - sqlx::query!( - "UPDATE workspace_runnable_dependencies SET workspace_id = $1 WHERE workspace_id = $2", - &rw.new_id, - &old_id - ) - .execute(&mut *tx) - .await?; - - sqlx::query!( - "UPDATE asset SET workspace_id = $1 WHERE workspace_id = $2", - &rw.new_id, - &old_id - ) - .execute(&mut *tx) - .await?; - - sqlx::query!( - "UPDATE flow_node SET workspace_id = $1 WHERE workspace_id = $2", - &rw.new_id, - &old_id - ) - .execute(&mut *tx) - .await?; - - sqlx::query!("DELETE FROM flow WHERE workspace_id = $1", &old_id) - .execute(&mut *tx) - .await?; - - // have to duplicate group_ with new workspace id because of foreign key constraint - sqlx::query!( - "INSERT INTO group_ SELECT $1, name, summary, extra_perms FROM group_ WHERE workspace_id = $2", - &rw.new_id, - &old_id - ) - .execute(&mut *tx) - .await?; - - sqlx::query!( - "UPDATE usr_to_group SET workspace_id = $1 WHERE workspace_id = $2", - &rw.new_id, - &old_id - ) - .execute(&mut *tx) - .await?; - - // then delete old group_ - sqlx::query!("DELETE FROM group_ WHERE workspace_id = $1", &old_id) - .execute(&mut *tx) - .await?; - - sqlx::query!( - "UPDATE folder SET workspace_id = $1 WHERE workspace_id = $2", - &rw.new_id, - &old_id - ) - .execute(&mut *tx) - .await?; - - sqlx::query!( - "UPDATE input SET workspace_id = $1 WHERE workspace_id = $2", - &rw.new_id, - &old_id - ) - .execute(&mut *tx) - .await?; - - sqlx::query!( - "UPDATE job_logs SET workspace_id = $1 WHERE workspace_id = $2", - &rw.new_id, - &old_id - ) - .execute(&mut *tx) - .await?; - - sqlx::query!( - "UPDATE job_stats SET workspace_id = $1 WHERE workspace_id = $2", - &rw.new_id, - &old_id - ) - .execute(&mut *tx) - .await?; - - sqlx::query!( - "UPDATE v2_job_queue SET workspace_id = $1 WHERE workspace_id = $2", - &rw.new_id, - &old_id - ) - .execute(&mut *tx) - .await?; - - sqlx::query!( - "UPDATE v2_job SET workspace_id = $1 WHERE workspace_id = $2", - &rw.new_id, - &old_id - ) - .execute(&mut *tx) - .await?; - - sqlx::query!( - "UPDATE raw_app SET workspace_id = $1 WHERE workspace_id = $2", - &rw.new_id, - &old_id - ) - .execute(&mut *tx) - .await?; - - sqlx::query!( - "UPDATE resource SET workspace_id = $1 WHERE workspace_id = $2", - &rw.new_id, - &old_id - ) - .execute(&mut *tx) - .await?; - - sqlx::query!( - "UPDATE resource_type SET workspace_id = $1 WHERE workspace_id = $2", - &rw.new_id, - &old_id - ) - .execute(&mut *tx) - .await?; - - sqlx::query!( - "UPDATE schedule SET workspace_id = $1 WHERE workspace_id = $2", - &rw.new_id, - &old_id - ) - .execute(&mut *tx) - .await?; - - sqlx::query!( - "UPDATE script SET workspace_id = $1 WHERE workspace_id = $2", - &rw.new_id, - &old_id - ) - .execute(&mut *tx) - .await?; - - sqlx::query!( - "UPDATE token SET workspace_id = $1 WHERE workspace_id = $2", - &rw.new_id, - &old_id - ) - .execute(&mut *tx) - .await?; - - sqlx::query!( - "UPDATE usage SET id = $1 WHERE id = $2 AND is_workspace = true", - &rw.new_id, - &old_id - ) - .execute(&mut *tx) - .await?; - - sqlx::query!( - "UPDATE usr SET workspace_id = $1 WHERE workspace_id = $2", - &rw.new_id, - &old_id - ) - .execute(&mut *tx) - .await?; - - sqlx::query!( - "UPDATE variable SET workspace_id = $1 WHERE workspace_id = $2", - &rw.new_id, - &old_id - ) - .execute(&mut *tx) - .await?; - - sqlx::query!( - "UPDATE workspace_env SET workspace_id = $1 WHERE workspace_id = $2", - &rw.new_id, - &old_id - ) - .execute(&mut *tx) - .await?; - - sqlx::query!( - "UPDATE workspace_invite SET workspace_id = $1 WHERE workspace_id = $2", - &rw.new_id, - &old_id - ) - .execute(&mut *tx) - .await?; - - sqlx::query!( - "UPDATE workspace_key SET workspace_id = $1 WHERE workspace_id = $2", - &rw.new_id, - &old_id - ) - .execute(&mut *tx) - .await?; - - sqlx::query!( - "UPDATE workspace_settings SET workspace_id = $1 WHERE workspace_id = $2", - &rw.new_id, - &old_id - ) - .execute(&mut *tx) - .await?; - - // delete old workspace - sqlx::query!("DELETE FROM workspace WHERE id = $1", &old_id) - .execute(&mut *tx) - .await?; - - audit_log( - &mut *tx, - &authed, - "workspace.change_workspace_id", - ActionKind::Update, - &rw.new_id, - Some(&authed.email), - None, - ) - .await?; - tx.commit().await?; - Ok(format!( - "updated workspace from {} to {}", - &old_id, &rw.new_id - )) +#[derive(Default, Deserialize, Serialize, Debug, Clone, Copy, PartialEq)] +#[serde(rename_all(serialize = "lowercase", deserialize = "lowercase"))] +pub enum MigrationType { + #[default] + All, + Metadata, + Jobs, } #[derive(Deserialize)] @@ -602,3 +233,288 @@ async fn is_workspace_owner( .await?; Ok(owner.map(|o| o == authed.email).unwrap_or(false)) } + +pub(crate) async fn migrate_workspace( + authed: ApiAuthed, + Extension(db): Extension, + Json(req): Json, +) -> Result { + if *CLOUD_HOSTED && !is_super_admin_email(&db, &authed.email).await? { + return Err(Error::BadRequest( + "This feature is not available on the cloud".to_string(), + )); + } + + if *CREATE_WORKSPACE_REQUIRE_SUPERADMIN { + require_super_admin(&db, &authed.email).await?; + } else { + require_admin(authed.is_admin, &authed.username)?; + } + + require_super_admin(&db, &authed.email).await?; + + let mut tx = db.begin().await?; + + check_w_id_conflict(&mut tx, &req.target_workspace_id).await?; + + sqlx::query!( + "INSERT INTO + workspace SELECT $1, $2, owner, deleted, premium FROM workspace WHERE id = $3", + &req.target_workspace_id, + &req.target_workspace_name, + &req.source_workspace_id + ) + .execute(&mut *tx) + .await?; + + match req.migration_type { + MigrationType::Metadata | MigrationType::All => { + migrate_metadata_tables(&mut tx, &req.source_workspace_id, &req.target_workspace_id) + .await?; + } + _ => {} + } + + match req.migration_type { + MigrationType::Jobs | MigrationType::All => { + migrate_job_tables(&mut tx, &req.source_workspace_id, &req.target_workspace_id).await?; + } + _ => {} + } + + if req.disable_workspace && req.migration_type != MigrationType::Jobs { + sqlx::query!( + "UPDATE workspace SET deleted = true WHERE id = $1", + &req.source_workspace_id + ) + .execute(&mut *tx) + .await?; + } + + audit_log( + &mut *tx, + &authed, + "workspace.migrate", + ActionKind::Update, + &req.target_workspace_id, + Some(&authed.email), + Some( + [ + ("source", req.source_workspace_id.as_str()), + ("target", req.target_workspace_id.as_str()), + ( + "type", + match req.migration_type { + MigrationType::All => "all", + MigrationType::Metadata => "metadata", + MigrationType::Jobs => "jobs", + }, + ), + ] + .into(), + ), + ) + .await?; + + tx.commit().await?; + + Ok(format!( + "Migrated {} from {} to {}", + match req.migration_type { + MigrationType::All => "all data", + MigrationType::Metadata => "metadata", + MigrationType::Jobs => "jobs", + }, + &req.source_workspace_id, + &req.target_workspace_id + )) +} + +async fn migrate_metadata_tables( + tx: &mut Transaction<'_, Postgres>, + source: &str, + target: &str, +) -> Result<()> { + // Simple tables that can be updated directly (in same order as change_workspace_id) + let simple_tables = vec![ + "account", + "app", + "audit", + "capture", + "capture_config", + "http_trigger", + "websocket_trigger", + "kafka_trigger", + "nats_trigger", + "dependency_map", + "deployment_metadata", + "draft", + "favorite", + "flow_version", + "workspace_runnable_dependencies", + "asset", + "flow_node", + "folder", + "input", + "raw_app", + "resource", + "resource_type", + "schedule", + "script", + "token", + "usr", + "variable", + "workspace_env", + "workspace_invite", + "workspace_key", + "workspace_settings", + ]; + + for table in simple_tables { + sqlx::query(&format!( + "UPDATE {} SET workspace_id = $1 WHERE workspace_id = $2", + table + )) + .bind(target) + .bind(source) + .execute(&mut **tx) + .await?; + } + + sqlx::query!( + "INSERT INTO flow + (workspace_id, path, summary, description, archived, extra_perms, dependency_job, draft_only, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at) + SELECT $1, path, summary, description, archived, extra_perms, dependency_job, draft_only, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at + FROM flow WHERE workspace_id = $2", + target, + source + ) + .execute(&mut **tx) + .await?; + + sqlx::query!("DELETE FROM flow WHERE workspace_id = $1", source) + .execute(&mut **tx) + .await?; + + sqlx::query!( + "INSERT INTO group_ SELECT $1, name, summary, extra_perms FROM group_ WHERE workspace_id = $2", + target, + source + ) + .execute(&mut **tx) + .await?; + + sqlx::query!( + "UPDATE usr_to_group SET workspace_id = $1 WHERE workspace_id = $2", + target, + source + ) + .execute(&mut **tx) + .await?; + + sqlx::query!("DELETE FROM group_ WHERE workspace_id = $1", source) + .execute(&mut **tx) + .await?; + + sqlx::query!( + "UPDATE usage SET id = $1 WHERE id = $2 AND is_workspace = true", + target, + source + ) + .execute(&mut **tx) + .await?; + + Ok(()) +} + +async fn migrate_job_tables( + tx: &mut Transaction<'_, Postgres>, + source: &str, + target: &str, +) -> Result<()> { + sqlx::query!( + "UPDATE job_logs SET workspace_id = $1 WHERE workspace_id = $2", + target, + source + ) + .execute(&mut **tx) + .await?; + + sqlx::query!( + "UPDATE job_stats SET workspace_id = $1 WHERE workspace_id = $2", + target, + source + ) + .execute(&mut **tx) + .await?; + + sqlx::query!( + "UPDATE v2_job_queue SET workspace_id = $1 WHERE workspace_id = $2", + target, + source + ) + .execute(&mut **tx) + .await?; + + sqlx::query!( + "UPDATE v2_job SET workspace_id = $1 WHERE workspace_id = $2", + target, + source + ) + .execute(&mut **tx) + .await?; + + sqlx::query!( + "UPDATE v2_job_completed SET workspace_id = $1 WHERE workspace_id = $2", + target, + source + ) + .execute(&mut **tx) + .await?; + + Ok(()) +} + +#[derive(Serialize)] +pub(crate) struct MigrationStatus { + source_workspace: String, + total_jobs: i64, + remaining_jobs: i64, + migration_progress: f64, +} + +pub(crate) async fn get_migration_status( + authed: ApiAuthed, + Query(params): Query>, + Extension(db): Extension, +) -> JsonResult { + require_admin(authed.is_admin, &authed.username)?; + + let source_workspace = params + .get("source_workspace") + .ok_or_else(|| Error::BadRequest("source_workspace parameter required".to_string()))?; + + let source_jobs = sqlx::query_scalar!( + "SELECT COALESCE( + (SELECT COUNT(*) FROM v2_job WHERE workspace_id = $1) + + (SELECT COUNT(*) FROM v2_job_completed WHERE workspace_id = $1) + + (SELECT COUNT(*) FROM v2_job_queue WHERE workspace_id = $1), + 0 + )", + source_workspace + ) + .fetch_one(&db) + .await? + .unwrap_or(0); + + let total_jobs = source_jobs; + + let progress = if total_jobs > 0 { 0.0 } else { 100.0 }; + + Ok(Json(MigrationStatus { + source_workspace: source_workspace.to_string(), + total_jobs, + remaining_jobs: source_jobs, + migration_progress: progress, + })) +} diff --git a/cli/src/commands/workspace/migrate.ts b/cli/src/commands/workspace/migrate.ts new file mode 100644 index 0000000000..bb069cf19a --- /dev/null +++ b/cli/src/commands/workspace/migrate.ts @@ -0,0 +1,86 @@ +// deno-lint-ignore-file no-explicit-any +import { Command, colors, log } from "../../../deps.ts"; +import { GlobalOptions } from "../../types.ts"; +import { requireLogin } from "../../core/auth.ts"; +import { setClient } from "../../../deps.ts"; +import * as wmill from "../../../gen/services.gen.ts"; +import { getActiveWorkspace } from "./workspace.ts"; + +async function migrate( + opts: GlobalOptions & { + all?: boolean; + metadataOnly?: boolean; + jobsOnly?: boolean; + noArchiveSource?: boolean; + }, + sourceWorkspace: string, + targetWorkspace: string +) { + await requireLogin(opts); + + const workspace = await getActiveWorkspace(opts); + if (!workspace) { + throw new Error("No active workspace. Please run 'wmill workspace add' first."); + } + + setClient( + workspace.token, + workspace.remote.endsWith("/") + ? workspace.remote.substring(0, workspace.remote.length - 1) + : workspace.remote + ); + + // Determine migration type from flags + let migrationType: "all" | "metadata" | "jobs" = "all"; + if (opts.jobsOnly) { + migrationType = "jobs"; + } else if (opts.metadataOnly) { + migrationType = "metadata"; + } + + const archiveSource = !opts.noArchiveSource; + + log.info(colors.blue("Starting workspace migration:")); + log.info(` Source: ${colors.bold(sourceWorkspace)}`); + log.info(` Target: ${colors.bold(targetWorkspace)}`); + log.info(` Type: ${colors.bold(migrationType)}`); + log.info(` Archive source: ${colors.bold(String(archiveSource))}`); + log.info(""); + + try { + const result = await wmill.migrateWorkspace({ + requestBody: { + source_workspace: sourceWorkspace, + target_workspace: targetWorkspace, + migration_type: migrationType, + archive_source: archiveSource + } + }); + + log.info(colors.green(`✅ ${result}`)); + + if (migrationType === "metadata") { + log.info(""); + log.info( + colors.yellow( + "⚠️ Metadata migration complete. Run with --jobs-only to migrate job history." + ) + ); + } + } catch (error) { + log.error(colors.red(`❌ Migration failed: ${error.message}`)); + throw error; + } +} + +const command = new Command() + .name("migrate") + .description("Migrate workspace data from source to target workspace") + .arguments(" ") + .option("--all", "Migrate all tables (default)") + .option("--metadata-only", "Migrate all tables except job tables") + .option("--jobs-only", "Migrate only job tables (v2_job, v2_job_completed, v2_job_queue)") + .option("--no-archive-source", "Do not archive source workspace after migration") + .action(migrate as any); + +export default command; diff --git a/cli/src/commands/workspace/workspace.ts b/cli/src/commands/workspace/workspace.ts index e67617a562..9a34d492fd 100644 --- a/cli/src/commands/workspace/workspace.ts +++ b/cli/src/commands/workspace/workspace.ts @@ -5,6 +5,7 @@ import { loginInteractive, tryGetLoginInfo } from "../../core/login.ts"; import { colors, Command, Confirm, Input, log, setClient, Table } from "../../../deps.ts"; import { requireLogin } from "../../core/auth.ts"; import { createWorkspaceFork, deleteWorkspaceFork } from "./fork.ts"; +import migrate from "./migrate.ts"; import * as wmill from "../../../gen/services.gen.ts"; @@ -477,6 +478,7 @@ const command = new Command() .command("delete-fork") .description("Delete a forked workspace and git branch") .arguments("") - .action(deleteWorkspaceFork as any); + .action(deleteWorkspaceFork as any) + .command("migrate", migrate); export default command; diff --git a/frontend/src/lib/components/settings/ChangeWorkspaceId.svelte b/frontend/src/lib/components/settings/ChangeWorkspaceId.svelte index 26057c2446..e280b5e230 100644 --- a/frontend/src/lib/components/settings/ChangeWorkspaceId.svelte +++ b/frontend/src/lib/components/settings/ChangeWorkspaceId.svelte @@ -8,14 +8,16 @@ import { Pen } from 'lucide-svelte' import { isCloudHosted } from '$lib/cloud' - let newName = '' - let newId = '' - let checking = false - let errorId = '' + let { open = $bindable(false) } = $props() - $: newId = newName.toLowerCase().replace(/\s/gi, '-') + let newName = $state('') + let newId = $derived(newName.toLowerCase().replace(/\s/gi, '-')) + let checking = $state(false) + let errorId = $state('') - $: validateName(newId) + $effect(() => { + validateName(newId) + }) async function validateName(id: string): Promise { checking = true @@ -30,30 +32,35 @@ checking = false } - let loading = false + let loading = $state(false) + let showMigrationInfo = $state(false) + let oldWorkspaceId = $state('') + async function renameWorkspace() { try { loading = true - await WorkspaceService.changeWorkspaceId({ - workspace: $workspaceStore!, + oldWorkspaceId = $workspaceStore! + + await WorkspaceService.migrateWorkspace({ requestBody: { - new_name: newName, - new_id: newId + source_workspace_id: $workspaceStore!, + target_workspace_name: newName, + target_workspace_id: newId, + migration_type: 'metadata', + disable_workspace: true } }) - open = false - sendUserToast(`Renamed workspace to ${newName}. Reloading...`) - await new Promise((resolve) => setTimeout(resolve, 1000)) - window.location.href = '/workspace_settings?tab=general&workspace=' + newId + open = false + showMigrationInfo = true + + sendUserToast(`Workspace metadata migrated to ${newId}`) } catch (err) { sendUserToast(`Error renaming workspace: ${err}`, true) } finally { loading = false } } - - export let open = false
@@ -113,3 +120,23 @@ + +{#if showMigrationInfo} + +
+

Workspace metadata has been successfully migrated to {newId}.

+

+ Job history has not been migrated yet. Click the button below to sync jobs from the old + workspace. +

+ +
+
+{/if} diff --git a/frontend/src/lib/components/settings/WorkspaceMigration.svelte b/frontend/src/lib/components/settings/WorkspaceMigration.svelte new file mode 100644 index 0000000000..93f927c4dd --- /dev/null +++ b/frontend/src/lib/components/settings/WorkspaceMigration.svelte @@ -0,0 +1,165 @@ + + +
+
+

Workspace Job Migration

+

+ Migrate job history from {sourceWorkspace} to + {$workspaceStore} +

+
+ + {#if status} +
+
+
+ Jobs Remaining: + {status.remaining_jobs.toLocaleString()} / {status.total_jobs.toLocaleString()} +
+ +
+
+ {#if progress > 10} + {progress.toFixed(1)}% + {/if} +
+
+ + {#if migrating} +
+
+ Migration in progress... +
+ {/if} + + {#if !migrationComplete && !migrating && status.total_jobs > 0} + + {/if} + + {#if migrationComplete} + +

All jobs have been successfully migrated!

+ +
+ {/if} + + {#if status.total_jobs === 0} + +

The source workspace has no job history to migrate.

+ +
+ {/if} +
+
+ {:else} +
+
+
+ {/if} +
diff --git a/frontend/src/routes/(root)/(logged)/workspace_settings/migration/+page.svelte b/frontend/src/routes/(root)/(logged)/workspace_settings/migration/+page.svelte new file mode 100644 index 0000000000..2d03972445 --- /dev/null +++ b/frontend/src/routes/(root)/(logged)/workspace_settings/migration/+page.svelte @@ -0,0 +1,24 @@ + + +
+ {#if sourceWorkspace} + + {:else} +
+

Loading...

+
+ {/if} +