feat: drop forked databases on workspace deletion with confirmation UI
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -25,6 +25,8 @@ use windmill_common::{
|
||||
auth::is_super_admin_email,
|
||||
error::{Error, Result},
|
||||
utils::require_admin,
|
||||
workspaces::DataTable,
|
||||
PgDatabase,
|
||||
};
|
||||
use windmill_queue::schedule::{get_schedule_opt, push_scheduled_job};
|
||||
|
||||
@@ -660,11 +662,18 @@ pub(crate) struct DeleteWorkspaceQuery {
|
||||
pub(crate) only_delete_forks: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
pub(crate) struct DeleteWorkspaceBody {
|
||||
#[serde(default)]
|
||||
pub(crate) drop_datatable_databases: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
pub(crate) async fn delete_workspace(
|
||||
Extension(db): Extension<DB>,
|
||||
Path(w_id): Path<String>,
|
||||
authed: ApiAuthed,
|
||||
Query(dwq): Query<DeleteWorkspaceQuery>,
|
||||
body: Option<Json<DeleteWorkspaceBody>>,
|
||||
) -> Result<String> {
|
||||
let w_id = match w_id.as_str() {
|
||||
"starter" => Err(Error::BadRequest(
|
||||
@@ -687,6 +696,105 @@ pub(crate) async fn delete_workspace(
|
||||
require_super_admin(&db, &authed.email).await?;
|
||||
}
|
||||
|
||||
// Drop forked datatable databases if requested
|
||||
let drop_dbs = body
|
||||
.and_then(|b| b.0.drop_datatable_databases)
|
||||
.unwrap_or_default();
|
||||
|
||||
if !drop_dbs.is_empty() {
|
||||
let datatable_config = sqlx::query_scalar!(
|
||||
"SELECT datatable->'datatables' FROM workspace_settings WHERE workspace_id = $1",
|
||||
&w_id
|
||||
)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?
|
||||
.flatten();
|
||||
|
||||
if let Some(config) = datatable_config {
|
||||
let datatables: HashMap<String, DataTable> =
|
||||
serde_json::from_value(config).unwrap_or_default();
|
||||
|
||||
for dt_name in &drop_dbs {
|
||||
let dt = match datatables.get(dt_name) {
|
||||
Some(dt) => dt,
|
||||
None => continue,
|
||||
};
|
||||
let forked_from = match &dt.forked_from {
|
||||
Some(v) => v,
|
||||
None => continue,
|
||||
};
|
||||
let db_to_drop = &dt.database.resource_path;
|
||||
|
||||
if dt.database.resource_type
|
||||
== windmill_common::workspaces::DataTableCatalogResourceType::Instance
|
||||
{
|
||||
// Instance DB: drop on the Windmill PG instance
|
||||
if let Err(e) =
|
||||
sqlx::query(&format!("DROP DATABASE IF EXISTS \"{}\"", db_to_drop))
|
||||
.execute(&db)
|
||||
.await
|
||||
{
|
||||
tracing::error!("Failed to drop instance database '{}': {}", db_to_drop, e);
|
||||
} else {
|
||||
tracing::info!("Dropped instance database '{}'", db_to_drop);
|
||||
}
|
||||
} else {
|
||||
// Resource DB: connect to the original resource and DROP DATABASE
|
||||
if let Some(original_resource) = forked_from.get("original_resource") {
|
||||
match serde_json::from_value::<PgDatabase>(original_resource.clone()) {
|
||||
Ok(pg) => {
|
||||
let admin_pg = PgDatabase { dbname: "postgres".to_string(), ..pg };
|
||||
match admin_pg.connect().await {
|
||||
Ok((client, connection)) => {
|
||||
let join_handle =
|
||||
tokio::spawn(async move { connection.await });
|
||||
if let Err(e) = client
|
||||
.execute(
|
||||
&format!(
|
||||
"DROP DATABASE IF EXISTS \"{}\"",
|
||||
db_to_drop
|
||||
),
|
||||
&[],
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::error!(
|
||||
"Failed to drop resource database '{}': {}",
|
||||
db_to_drop,
|
||||
e
|
||||
);
|
||||
} else {
|
||||
tracing::info!(
|
||||
"Dropped resource database '{}'",
|
||||
db_to_drop
|
||||
);
|
||||
}
|
||||
drop(client);
|
||||
let _ = join_handle.await;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
"Failed to connect to drop resource database '{}': {}",
|
||||
db_to_drop,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
"Failed to parse original_resource for '{}': {}",
|
||||
dt_name,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sqlx::query!("DELETE FROM ai_agent_memory WHERE workspace_id = $1", &w_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
@@ -2223,6 +2223,18 @@ paths:
|
||||
required: false
|
||||
schema:
|
||||
type: boolean
|
||||
requestBody:
|
||||
required: false
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
drop_datatable_databases:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: "List of datatable names whose forked databases should be dropped"
|
||||
responses:
|
||||
"200":
|
||||
description: status
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
import { WorkspaceService } from '$lib/gen'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { clearStores } from '$lib/storeUtils'
|
||||
import Toggle from '$lib/components/Toggle.svelte'
|
||||
import { goto } from '$lib/navigation'
|
||||
import ConfirmationModal from '../common/confirmationModal/ConfirmationModal.svelte'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
@@ -81,8 +82,39 @@
|
||||
goto('/user/workspaces')
|
||||
}
|
||||
|
||||
type ForkedDatatable = {
|
||||
name: string
|
||||
resourceType: string
|
||||
resourcePath: string
|
||||
dropOnDelete: boolean
|
||||
}
|
||||
let forkedDatatables: ForkedDatatable[] = $state([])
|
||||
|
||||
async function loadForkedDatatables() {
|
||||
if (!$workspaceStore) return
|
||||
try {
|
||||
const settings = await WorkspaceService.getSettings({ workspace: $workspaceStore })
|
||||
const datatables = settings.datatable?.datatables ?? {}
|
||||
forkedDatatables = Object.entries(datatables)
|
||||
.filter(([_, dt]) => dt.forked_from != null)
|
||||
.map(([name, dt]) => ({
|
||||
name,
|
||||
resourceType: dt.database.resource_type ?? 'instance',
|
||||
resourcePath: dt.database.resource_path ?? '',
|
||||
dropOnDelete: true
|
||||
}))
|
||||
} catch {
|
||||
forkedDatatables = []
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteFork() {
|
||||
await WorkspaceService.deleteWorkspace({ workspace: $workspaceStore ?? '' })
|
||||
const dbsToDrop = forkedDatatables.filter((dt) => dt.dropOnDelete).map((dt) => dt.name)
|
||||
|
||||
await WorkspaceService.deleteWorkspace({
|
||||
workspace: $workspaceStore ?? '',
|
||||
requestBody: dbsToDrop.length > 0 ? { drop_datatable_databases: dbsToDrop } : undefined
|
||||
})
|
||||
sendUserToast('You deleted the workspace')
|
||||
clearStores()
|
||||
goto('/user/workspaces')
|
||||
@@ -440,7 +472,8 @@
|
||||
? [
|
||||
{
|
||||
label: 'Delete Forked Workspace',
|
||||
action: () => {
|
||||
action: async () => {
|
||||
await loadForkedDatatables()
|
||||
deleteWorkspaceForkModal = true
|
||||
},
|
||||
icon: Trash2,
|
||||
@@ -756,6 +789,22 @@
|
||||
>
|
||||
<div class="flex flex-col w-full space-y-4">
|
||||
<span>Are you sure you want to delete this workspace fork? (deleting {$workspaceStore})</span>
|
||||
{#if forkedDatatables.length > 0}
|
||||
<div class="border rounded-md divide-y">
|
||||
<div class="px-4 py-2 text-xs font-semibold text-secondary"> Forked databases </div>
|
||||
{#each forkedDatatables as dt}
|
||||
<div class="flex items-center justify-between px-4 py-2">
|
||||
<div class="flex flex-col">
|
||||
<span class="text-xs font-medium">{dt.name}</span>
|
||||
<span class="text-2xs text-tertiary">
|
||||
{dt.resourceType === 'instance' ? 'Instance' : 'Resource'} DB: {dt.resourcePath}
|
||||
</span>
|
||||
</div>
|
||||
<Toggle bind:checked={dt.dropOnDelete} options={{ right: 'Drop database' }} />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</ConfirmationModal>
|
||||
{/if}
|
||||
|
||||
Reference in New Issue
Block a user