From ad5492a4bdd68dc6c6b45555e649f27efff5aaac Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Tue, 26 May 2026 21:01:05 +0200 Subject: [PATCH] feat(manager-core,dashboard): cascading app delete with styled confirmation modal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deleting an app used to require zero scripts and zero domain claims — practical for empty apps, painful for anything else. Add an opt-in cascade so the operator can wipe an app in one click while keeping the safe default for the no-flag case. Backend: `DELETE /api/v1/admin/apps/{id}?force=true` runs a single transaction that removes every script in the app (routes and execution logs cascade via `script_id` FK), then deletes the app row (domains and slug-history cascade off it). Without `?force=true` the handler still returns the same `409 HasScripts { script_count }` payload it always did. Frontend: a new `ConfirmModal.svelte` replaces the bare `window.confirm` on this page. It's reusable — danger/neutral variants, optional GitHub-style "type the slug to confirm" gate, ESC/backdrop cancel, busy state, and a generic body slot — so future destructive actions can adopt the same pattern instead of growing more browser dialogs. The app delete confirmation now spells out exactly what disappears (script count, domain claim list, "all routes & logs") and only enables the red button once the slug is retyped. The domain-claim delete is also wired through the modal so this page no longer uses `window.confirm` anywhere. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/manager-core/src/app_repo.rs | 24 ++ crates/manager-core/src/apps_api.rs | 30 +- dashboard/src/lib/ConfirmModal.svelte | 328 ++++++++++++++++++ dashboard/src/lib/api.ts | 11 +- dashboard/src/routes/apps/[slug]/+page.svelte | 124 ++++++- 5 files changed, 490 insertions(+), 27 deletions(-) create mode 100644 dashboard/src/lib/ConfirmModal.svelte 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}