diff --git a/crates/manager-core/src/app_repo.rs b/crates/manager-core/src/app_repo.rs index b46b394..2589493 100644 --- a/crates/manager-core/src/app_repo.rs +++ b/crates/manager-core/src/app_repo.rs @@ -61,6 +61,11 @@ pub trait AppRepository: Send + Sync { take_over_history: bool, ) -> Result; async fn delete(&self, id: AppId) -> Result<(), ScriptRepositoryError>; + /// Delete the app along with all its scripts (which in turn cascades + /// routes and execution logs via their `script_id` FK). Domains and + /// app-slug-history rows cascade off the app row itself. Runs in a + /// single transaction so a partial delete cannot be observed. + async fn delete_cascade(&self, id: AppId) -> Result<(), ScriptRepositoryError>; async fn count_scripts_in_app(&self, id: AppId) -> Result; } @@ -347,6 +352,25 @@ impl AppRepository for PostgresAppRepository { } } + async fn delete_cascade(&self, id: AppId) -> Result<(), ScriptRepositoryError> { + let mut tx = self.pool.begin().await?; + sqlx::query("DELETE FROM scripts WHERE app_id = $1") + .bind(id.into_inner()) + .execute(&mut *tx) + .await?; + let res = sqlx::query("DELETE FROM apps WHERE id = $1") + .bind(id.into_inner()) + .execute(&mut *tx) + .await?; + if res.rows_affected() == 0 { + return Err(ScriptRepositoryError::Conflict(format!( + "app {id} not found" + ))); + } + tx.commit().await?; + Ok(()) + } + async fn count_scripts_in_app(&self, id: AppId) -> Result { let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM scripts WHERE app_id = $1") .bind(id.into_inner()) diff --git a/crates/manager-core/src/apps_api.rs b/crates/manager-core/src/apps_api.rs index 7012a20..eec0a0d 100644 --- a/crates/manager-core/src/apps_api.rs +++ b/crates/manager-core/src/apps_api.rs @@ -11,7 +11,7 @@ use std::sync::Arc; -use axum::extract::{Path, State}; +use axum::extract::{Path, Query, State}; use axum::http::StatusCode; use axum::response::{IntoResponse, Json, Response}; use axum::routing::{delete, get, post}; @@ -120,6 +120,16 @@ pub struct CreateDomainRequest { pub pattern: String, } +/// Query params for `DELETE /apps/{id_or_slug}`. `force=true` opts into +/// a cascading delete that also removes every script in the app (and +/// thereby their routes and execution logs). Without it the request is +/// rejected when the app still contains scripts. +#[derive(Debug, Default, Deserialize)] +pub struct DeleteAppQuery { + #[serde(default)] + pub force: bool, +} + #[derive(Debug, Serialize)] pub struct AppLookupResponse { #[serde(flatten)] @@ -231,17 +241,21 @@ async fn patch_app( async fn delete_app( State(s): State, Path(id_or_slug): Path, + Query(q): Query, ) -> Result { let app = resolve_app(&*s.apps, &id_or_slug).await?.app; - // Soft pre-check for a clean error; the DB FK is the real guard - // (ON DELETE RESTRICT on scripts.app_id). - let n_scripts = s.apps.count_scripts_in_app(app.id).await?; - if n_scripts > 0 { - return Err(AppsApiError::HasScripts(n_scripts)); + if q.force { + s.apps.delete_cascade(app.id).await?; + } else { + // Soft pre-check for a clean error; the DB FK is the real guard + // (ON DELETE RESTRICT on scripts.app_id). + let n_scripts = s.apps.count_scripts_in_app(app.id).await?; + if n_scripts > 0 { + return Err(AppsApiError::HasScripts(n_scripts)); + } + s.apps.delete(app.id).await?; } - - s.apps.delete(app.id).await?; refresh_domain_cache(&s).await?; Ok(StatusCode::NO_CONTENT) } diff --git a/dashboard/src/lib/ConfirmModal.svelte b/dashboard/src/lib/ConfirmModal.svelte new file mode 100644 index 0000000..9933fe7 --- /dev/null +++ b/dashboard/src/lib/ConfirmModal.svelte @@ -0,0 +1,328 @@ + + + + + + + + diff --git a/dashboard/src/lib/api.ts b/dashboard/src/lib/api.ts index 0ae644a..f719269 100644 --- a/dashboard/src/lib/api.ts +++ b/dashboard/src/lib/api.ts @@ -367,10 +367,13 @@ export const api = { method: 'PATCH', body: JSON.stringify(input) }), - remove: (idOrSlug: string) => - adminRequest(`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}`, { - method: 'DELETE' - }), + remove: (idOrSlug: string, opts: { force?: boolean } = {}) => { + const qs = opts.force ? '?force=true' : ''; + return adminRequest( + `/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}${qs}`, + { method: 'DELETE' } + ); + }, slugCheck: (idOrSlug: string, newSlug: string) => adminRequest( `/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/slug:check`, diff --git a/dashboard/src/routes/apps/[slug]/+page.svelte b/dashboard/src/routes/apps/[slug]/+page.svelte index c661e4b..c18edeb 100644 --- a/dashboard/src/routes/apps/[slug]/+page.svelte +++ b/dashboard/src/routes/apps/[slug]/+page.svelte @@ -10,6 +10,7 @@ type Script } from '$lib/api'; import CodeEditor from '$lib/CodeEditor.svelte'; + import ConfirmModal from '$lib/ConfirmModal.svelte'; const SAMPLE_SOURCE = '#{\n statusCode: 200,\n body: #{ ok: true, echo: ctx.request.body }\n}'; @@ -46,6 +47,14 @@ let settingsError = $state(null); let slugTakeoverNeeded = $state(null); + // Delete confirmations + let confirmingDeleteApp = $state(false); + let deletingApp = $state(false); + let deleteAppError = $state(null); + let domainToRemove = $state(null); + let removingDomain = $state(false); + let removeDomainError = $state(null); + async function loadApp() { loading = true; loadError = null; @@ -135,14 +144,23 @@ } } - async function removeDomain(d: AppDomain) { - if (!app) return; - if (!window.confirm(`Delete domain claim ${d.pattern}?`)) return; + function askRemoveDomain(d: AppDomain) { + removeDomainError = null; + domainToRemove = d; + } + + async function confirmRemoveDomain() { + if (!app || !domainToRemove) return; + removingDomain = true; + removeDomainError = null; try { - await api.domains.remove(app.id, d.id); + await api.domains.remove(app.id, domainToRemove.id); + domainToRemove = null; await loadDomains(app.id); } catch (e) { - alert(e instanceof Error ? e.message : String(e)); + removeDomainError = e instanceof Error ? e.message : String(e); + } finally { + removingDomain = false; } } @@ -183,17 +201,25 @@ } } - async function deleteApp() { + function askDeleteApp() { + deleteAppError = null; + confirmingDeleteApp = true; + } + + async function confirmDeleteApp() { if (!app) return; - const yes = window.confirm( - `Delete app "${app.name}"? This requires zero scripts and zero domain claims.` - ); - if (!yes) return; + deletingApp = true; + deleteAppError = null; try { - await api.apps.remove(app.id); + // force=true cascades scripts (and thereby their routes + + // execution logs); domains and slug-history rows cascade off + // the app row itself. + await api.apps.remove(app.id, { force: true }); await goto(`${base}/apps`); } catch (e) { - alert(e instanceof Error ? e.message : String(e)); + deleteAppError = e instanceof Error ? e.message : String(e); + } finally { + deletingApp = false; } } @@ -330,7 +356,7 @@ @@ -402,12 +428,80 @@

Delete app

- Requires the app to have zero scripts and zero domain claims. + Permanently removes the app along with all its scripts, routes, + execution logs, and domain claims.

- +
{/if} + + {#if confirmingDeleteApp} + (confirmingDeleteApp = false)} + > +

+ This will permanently delete everything inside + {app.name}. There is no undo. +

+
    +
  • + Scripts{scripts.length} +
  • +
  • + Domain claims{domains.length} +
  • +
  • + Routes & execution logsall +
  • +
+ {#if domains.length > 0} +

The following hosts will stop pointing at this app:

+
    + {#each domains as d (d.id)} +
  • + {d.pattern}{d.shape} +
  • + {/each} +
+ {/if} + {#if deleteAppError} + + {/if} +
+ {/if} + + {#if domainToRemove} + (domainToRemove = null)} + > +

+ {app.name} will stop answering on + {domainToRemove.pattern}. +

+

+ Routes already bound to this host are blocked from deletion by the + API; if so, you’ll see an error here. +

+ {#if removeDomainError} + + {/if} +
+ {/if} {/if}