This commit is contained in:
Dieriba toure
2025-10-21 18:27:23 +02:00
parent b7cdd54776
commit e38dd2f1a5
8 changed files with 719 additions and 410 deletions
+88
View File
@@ -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:
+5 -4
View File
@@ -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)]
+304 -388
View File
@@ -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<String>,
Extension(db): Extension<DB>,
Json(rw): Json<ChangeWorkspaceId>,
) -> Result<String> {
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<DB>,
Json(req): Json<MigrateWorkspaceRequest>,
) -> Result<String> {
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<std::collections::HashMap<String, String>>,
Extension(db): Extension<DB>,
) -> JsonResult<MigrationStatus> {
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,
}))
}
+86
View File
@@ -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("<source_workspace:string> <target_workspace:string>")
.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;
+3 -1
View File
@@ -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("<fork_name:string>")
.action(deleteWorkspaceFork as any);
.action(deleteWorkspaceFork as any)
.command("migrate", migrate);
export default command;
@@ -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<void> {
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
</script>
<div>
@@ -113,3 +120,23 @@
</Button>
</svelte:fragment>
</Modal>
{#if showMigrationInfo}
<Alert type="info" title="Migration Status" class="mt-4">
<div class="flex flex-col gap-2">
<p>Workspace metadata has been successfully migrated to <strong>{newId}</strong>.</p>
<p class="text-sm text-secondary">
Job history has not been migrated yet. Click the button below to sync jobs from the old
workspace.
</p>
<Button
size="sm"
on:click={() => {
window.location.href = `/workspace_settings/migration?source=${oldWorkspaceId}&workspace=${newId}`
}}
>
Sync Jobs
</Button>
</div>
</Alert>
{/if}
@@ -0,0 +1,165 @@
<script lang="ts">
import { WorkspaceService, type MigrateJobResponse } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import Button from '../common/button/Button.svelte'
import { sendUserToast } from '$lib/toast'
import Alert from '../common/alert/Alert.svelte'
import { onMount } from 'svelte'
let { sourceWorkspace }: { sourceWorkspace: string } = $props()
let migrating = $state(false)
let migrationComplete = $state(false)
let status = $state<MigrateJobResponse | undefined>(undefined)
let pollInterval: number | null = null
const progress = $derived(status ? status.migration_progress : 0)
async function checkStatus() {
try {
status = await WorkspaceService.getMigrationStatus({
sourceWorkspace
})
if (status && status.remaining_jobs === 0 && status.total_jobs > 0) {
migrationComplete = true
if (pollInterval) {
clearInterval(pollInterval)
pollInterval = null
}
}
} catch (err) {
console.error('Failed to check migration status:', err)
}
}
async function startMigration() {
try {
migrating = true
await WorkspaceService.migrateWorkspace({
requestBody: {
source_workspace: sourceWorkspace,
target_workspace: $workspaceStore!,
migration_type: 'jobs',
disable_workspace: false
}
})
sendUserToast('Job migration completed!')
await checkStatus()
migrationComplete = true
} catch (err) {
sendUserToast(`Migration failed: ${err}`, true)
} finally {
migrating = false
}
}
function startPolling() {
if (!pollInterval) {
pollInterval = setInterval(checkStatus, 2000) as any
}
}
function stopPolling() {
if (pollInterval) {
clearInterval(pollInterval)
pollInterval = null
}
}
onMount(() => {
checkStatus()
return () => {
stopPolling()
}
})
$effect(() => {
if (migrating) {
startPolling()
}
})
</script>
<div class="flex flex-col gap-6">
<div>
<h2 class="text-xl font-semibold mb-2">Workspace Job Migration</h2>
<p class="text-sm text-secondary">
Migrate job history from <strong class="text-primary">{sourceWorkspace}</strong> to
<strong class="text-primary">{$workspaceStore}</strong>
</p>
</div>
{#if status}
<div class="bg-surface-secondary p-6 rounded-md border border-gray-200 dark:border-gray-700">
<div class="flex flex-col gap-4">
<div class="flex justify-between items-center">
<span class="text-sm font-medium">Jobs Remaining:</span>
<span class="text-lg font-semibold"
>{status.remaining_jobs.toLocaleString()} / {status.total_jobs.toLocaleString()}</span
>
</div>
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-4">
<div
class="bg-blue-500 h-4 rounded-full transition-all duration-300 flex items-center justify-center"
style="width: {progress}%"
>
{#if progress > 10}
<span class="text-xs text-white font-medium">{progress.toFixed(1)}%</span>
{/if}
</div>
</div>
{#if migrating}
<div class="flex items-center gap-2 text-sm text-secondary animate-pulse">
<div
class="w-4 h-4 border-2 border-blue-500 border-t-transparent rounded-full animate-spin"
></div>
<span>Migration in progress...</span>
</div>
{/if}
{#if !migrationComplete && !migrating && status.total_jobs > 0}
<Button on:click={startMigration} size="sm" class="w-full">Start Job Migration</Button>
{/if}
{#if migrationComplete}
<Alert type="success" title="Migration Complete">
<p class="mb-2">All jobs have been successfully migrated!</p>
<Button
size="sm"
on:click={() => {
window.location.href = `/workspace_settings?tab=general&workspace=${$workspaceStore}`
}}
>
Back to Workspace Settings
</Button>
</Alert>
{/if}
{#if status.total_jobs === 0}
<Alert type="info" title="No Jobs to Migrate">
<p class="mb-2">The source workspace has no job history to migrate.</p>
<Button
size="sm"
on:click={() => {
window.location.href = `/workspace_settings?tab=general&workspace=${$workspaceStore}`
}}
>
Back to Workspace Settings
</Button>
</Alert>
{/if}
</div>
</div>
{:else}
<div class="flex items-center justify-center p-8">
<div class="w-8 h-8 border-4 border-blue-500 border-t-transparent rounded-full animate-spin"
></div>
</div>
{/if}
</div>
@@ -0,0 +1,24 @@
<script lang="ts">
import { page } from '$app/stores'
import WorkspaceMigration from '$lib/components/settings/WorkspaceMigration.svelte'
import { workspaceStore } from '$lib/stores'
const sourceWorkspace = $derived($page.url.searchParams.get('source') || '')
$effect(() => {
// Redirect if no source workspace provided
if (!sourceWorkspace) {
window.location.href = `/workspace_settings?workspace=${$workspaceStore}`
}
})
</script>
<div class="max-w-4xl mx-auto p-6">
{#if sourceWorkspace}
<WorkspaceMigration {sourceWorkspace} />
{:else}
<div class="flex items-center justify-center p-8">
<p class="text-secondary">Loading...</p>
</div>
{/if}
</div>