Compare commits
3 Commits
4c41374db4
...
feat/multi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a393f11344 | ||
|
|
ad5492a4bd | ||
|
|
ee0dbc428f |
@@ -29,3 +29,11 @@ RUST_LOG=info,picloud=debug
|
|||||||
# Public base URL the dashboard uses to render full URLs for user routes.
|
# Public base URL the dashboard uses to render full URLs for user routes.
|
||||||
# Set to the host:port (and scheme) users actually reach in their browser.
|
# Set to the host:port (and scheme) users actually reach in their browser.
|
||||||
PICLOUD_PUBLIC_BASE_URL=http://localhost:8000
|
PICLOUD_PUBLIC_BASE_URL=http://localhost:8000
|
||||||
|
|
||||||
|
# ---------- Bootstrap admin ----------
|
||||||
|
# Required. Used once on first startup to seed the admin_users table.
|
||||||
|
# Ignored on subsequent boots if the table is non-empty. For prod,
|
||||||
|
# prefer PICLOUD_ADMIN_PASSWORD_HASH (pre-computed Argon2id PHC) so the
|
||||||
|
# raw password never lands in env or compose files; see blueprint §11.5.
|
||||||
|
PICLOUD_ADMIN_USERNAME=admin
|
||||||
|
PICLOUD_ADMIN_PASSWORD=admin
|
||||||
|
|||||||
@@ -61,6 +61,11 @@ pub trait AppRepository: Send + Sync {
|
|||||||
take_over_history: bool,
|
take_over_history: bool,
|
||||||
) -> Result<App, ScriptRepositoryError>;
|
) -> Result<App, ScriptRepositoryError>;
|
||||||
async fn delete(&self, id: AppId) -> Result<(), ScriptRepositoryError>;
|
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<i64, ScriptRepositoryError>;
|
async fn count_scripts_in_app(&self, id: AppId) -> Result<i64, ScriptRepositoryError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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<i64, ScriptRepositoryError> {
|
async fn count_scripts_in_app(&self, id: AppId) -> Result<i64, ScriptRepositoryError> {
|
||||||
let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM scripts WHERE app_id = $1")
|
let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM scripts WHERE app_id = $1")
|
||||||
.bind(id.into_inner())
|
.bind(id.into_inner())
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use axum::extract::{Path, State};
|
use axum::extract::{Path, Query, State};
|
||||||
use axum::http::StatusCode;
|
use axum::http::StatusCode;
|
||||||
use axum::response::{IntoResponse, Json, Response};
|
use axum::response::{IntoResponse, Json, Response};
|
||||||
use axum::routing::{delete, get, post};
|
use axum::routing::{delete, get, post};
|
||||||
@@ -120,6 +120,16 @@ pub struct CreateDomainRequest {
|
|||||||
pub pattern: String,
|
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)]
|
#[derive(Debug, Serialize)]
|
||||||
pub struct AppLookupResponse {
|
pub struct AppLookupResponse {
|
||||||
#[serde(flatten)]
|
#[serde(flatten)]
|
||||||
@@ -231,17 +241,21 @@ async fn patch_app(
|
|||||||
async fn delete_app(
|
async fn delete_app(
|
||||||
State(s): State<AppsState>,
|
State(s): State<AppsState>,
|
||||||
Path(id_or_slug): Path<String>,
|
Path(id_or_slug): Path<String>,
|
||||||
|
Query(q): Query<DeleteAppQuery>,
|
||||||
) -> Result<StatusCode, AppsApiError> {
|
) -> Result<StatusCode, AppsApiError> {
|
||||||
let app = resolve_app(&*s.apps, &id_or_slug).await?.app;
|
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
|
if q.force {
|
||||||
// (ON DELETE RESTRICT on scripts.app_id).
|
s.apps.delete_cascade(app.id).await?;
|
||||||
let n_scripts = s.apps.count_scripts_in_app(app.id).await?;
|
} else {
|
||||||
if n_scripts > 0 {
|
// Soft pre-check for a clean error; the DB FK is the real guard
|
||||||
return Err(AppsApiError::HasScripts(n_scripts));
|
// (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?;
|
refresh_domain_cache(&s).await?;
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|||||||
17
dashboard/package-lock.json
generated
17
dashboard/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "picloud-dashboard",
|
"name": "picloud-dashboard",
|
||||||
"version": "0.5.0",
|
"version": "0.5.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "picloud-dashboard",
|
"name": "picloud-dashboard",
|
||||||
"version": "0.5.0",
|
"version": "0.5.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/autocomplete": "^6.20.2",
|
"@codemirror/autocomplete": "^6.20.2",
|
||||||
"@codemirror/commands": "^6.10.3",
|
"@codemirror/commands": "^6.10.3",
|
||||||
@@ -1275,7 +1275,6 @@
|
|||||||
"integrity": "sha512-mQjlkNo+rJvpln7V2IGY2j99BqhcFbS4UN0AQNKNYfhBAFZTuCDAdW3a1sgf330mvtNvsBXn3HpAhcmvdJTcIQ==",
|
"integrity": "sha512-mQjlkNo+rJvpln7V2IGY2j99BqhcFbS4UN0AQNKNYfhBAFZTuCDAdW3a1sgf330mvtNvsBXn3HpAhcmvdJTcIQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@standard-schema/spec": "^1.0.0",
|
"@standard-schema/spec": "^1.0.0",
|
||||||
"@sveltejs/acorn-typescript": "^1.0.5",
|
"@sveltejs/acorn-typescript": "^1.0.5",
|
||||||
@@ -1318,7 +1317,6 @@
|
|||||||
"integrity": "sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ==",
|
"integrity": "sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@sveltejs/vite-plugin-svelte-inspector": "^4.0.1",
|
"@sveltejs/vite-plugin-svelte-inspector": "^4.0.1",
|
||||||
"debug": "^4.4.1",
|
"debug": "^4.4.1",
|
||||||
@@ -1398,7 +1396,6 @@
|
|||||||
"integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==",
|
"integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~6.21.0"
|
"undici-types": "~6.21.0"
|
||||||
}
|
}
|
||||||
@@ -1455,7 +1452,6 @@
|
|||||||
"integrity": "sha512-zORHqO/tuhxY1zWuTvMUqddRxpiFJ72xVfcNoWpqdLjs6lfPbuQBJuW4pk+49/uBMy7Ssr4bzgjiKmmDB1UbZQ==",
|
"integrity": "sha512-zORHqO/tuhxY1zWuTvMUqddRxpiFJ72xVfcNoWpqdLjs6lfPbuQBJuW4pk+49/uBMy7Ssr4bzgjiKmmDB1UbZQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "8.59.4",
|
"@typescript-eslint/scope-manager": "8.59.4",
|
||||||
"@typescript-eslint/types": "8.59.4",
|
"@typescript-eslint/types": "8.59.4",
|
||||||
@@ -1563,7 +1559,6 @@
|
|||||||
"integrity": "sha512-F1o7WJcCq+bc8dwcO/YsSEOudAH8RDtaOhM6wcAQhcUsFhnWQl81JKy48q1hoxAU0qrzM89+31GYh1515Zde3Q==",
|
"integrity": "sha512-F1o7WJcCq+bc8dwcO/YsSEOudAH8RDtaOhM6wcAQhcUsFhnWQl81JKy48q1hoxAU0qrzM89+31GYh1515Zde3Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||||
},
|
},
|
||||||
@@ -1815,7 +1810,6 @@
|
|||||||
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
@@ -2217,7 +2211,6 @@
|
|||||||
"integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
|
"integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.8.0",
|
"@eslint-community/eslint-utils": "^4.8.0",
|
||||||
"@eslint-community/regexpp": "^4.12.1",
|
"@eslint-community/regexpp": "^4.12.1",
|
||||||
@@ -3010,7 +3003,6 @@
|
|||||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -3038,7 +3030,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.12",
|
"nanoid": "^3.3.12",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
@@ -3172,7 +3163,6 @@
|
|||||||
"integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==",
|
"integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"prettier": "bin/prettier.cjs"
|
"prettier": "bin/prettier.cjs"
|
||||||
},
|
},
|
||||||
@@ -3433,7 +3423,6 @@
|
|||||||
"integrity": "sha512-fTjjT8cHLDwigcu2j3pv7Jq04LklXevPB8uBgyHNiTXv+RMNvVnrjS4UEYrLMkhuq1vpCodHjiW+z/95SDs/fg==",
|
"integrity": "sha512-fTjjT8cHLDwigcu2j3pv7Jq04LklXevPB8uBgyHNiTXv+RMNvVnrjS4UEYrLMkhuq1vpCodHjiW+z/95SDs/fg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jridgewell/remapping": "^2.3.4",
|
"@jridgewell/remapping": "^2.3.4",
|
||||||
"@jridgewell/sourcemap-codec": "^1.5.0",
|
"@jridgewell/sourcemap-codec": "^1.5.0",
|
||||||
@@ -3614,7 +3603,6 @@
|
|||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
@@ -3677,7 +3665,6 @@
|
|||||||
"integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==",
|
"integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.25.0",
|
"esbuild": "^0.25.0",
|
||||||
"fdir": "^6.4.4",
|
"fdir": "^6.4.4",
|
||||||
|
|||||||
328
dashboard/src/lib/ConfirmModal.svelte
Normal file
328
dashboard/src/lib/ConfirmModal.svelte
Normal file
@@ -0,0 +1,328 @@
|
|||||||
|
<!--
|
||||||
|
Confirmation modal — replaces window.confirm/prompt for destructive
|
||||||
|
actions so the dashboard can render the full context (counts, lists,
|
||||||
|
warnings) in its own style.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
{#if showing}
|
||||||
|
<ConfirmModal
|
||||||
|
title="Delete app"
|
||||||
|
variant="danger"
|
||||||
|
confirmLabel="Delete app"
|
||||||
|
confirmPhrase={app.slug}
|
||||||
|
onConfirm={() => doDelete()}
|
||||||
|
onCancel={() => (showing = false)}
|
||||||
|
>
|
||||||
|
<p>Body content — counts, lists, warnings.</p>
|
||||||
|
</ConfirmModal>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
When `confirmPhrase` is set the confirm button stays disabled until
|
||||||
|
the user types the phrase exactly — same pattern GitHub uses for
|
||||||
|
irreversible repo deletes. Omit it for low-stakes confirmations.
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
|
type Variant = 'danger' | 'neutral';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
title: string;
|
||||||
|
children: Snippet;
|
||||||
|
onConfirm: () => void | Promise<void>;
|
||||||
|
onCancel: () => void;
|
||||||
|
variant?: Variant;
|
||||||
|
confirmLabel?: string;
|
||||||
|
cancelLabel?: string;
|
||||||
|
/** When set, the confirm button is disabled until the user types
|
||||||
|
* this string exactly (case-sensitive). */
|
||||||
|
confirmPhrase?: string;
|
||||||
|
/** Shown above the confirm input. Defaults to a sensible message
|
||||||
|
* that mentions the phrase. */
|
||||||
|
confirmPhrasePrompt?: string;
|
||||||
|
/** While true the buttons are disabled and the confirm label is
|
||||||
|
* replaced with a "busy" form (e.g. "Deleting…"). */
|
||||||
|
busy?: boolean;
|
||||||
|
busyLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
onConfirm,
|
||||||
|
onCancel,
|
||||||
|
variant = 'neutral',
|
||||||
|
confirmLabel = 'Confirm',
|
||||||
|
cancelLabel = 'Cancel',
|
||||||
|
confirmPhrase,
|
||||||
|
confirmPhrasePrompt,
|
||||||
|
busy = false,
|
||||||
|
busyLabel
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
let typed = $state('');
|
||||||
|
let phraseMatches = $derived(
|
||||||
|
confirmPhrase === undefined || typed === confirmPhrase
|
||||||
|
);
|
||||||
|
|
||||||
|
function handleKeydown(event: KeyboardEvent) {
|
||||||
|
if (event.key === 'Escape' && !busy) {
|
||||||
|
event.preventDefault();
|
||||||
|
onCancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBackdrop(event: MouseEvent) {
|
||||||
|
// Only cancel when clicking the backdrop itself, not bubbled
|
||||||
|
// clicks from the dialog content.
|
||||||
|
if (event.target === event.currentTarget && !busy) {
|
||||||
|
onCancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleConfirm() {
|
||||||
|
if (!phraseMatches || busy) return;
|
||||||
|
await onConfirm();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Focus management: when the modal mounts, focus the slug-retype
|
||||||
|
// input (if present) or the cancel button (so an accidental Enter
|
||||||
|
// doesn't auto-confirm a destructive action).
|
||||||
|
let inputRef = $state<HTMLInputElement | null>(null);
|
||||||
|
let cancelRef = $state<HTMLButtonElement | null>(null);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (inputRef) inputRef.focus();
|
||||||
|
else if (cancelRef) cancelRef.focus();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onkeydown={handleKeydown} />
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="backdrop"
|
||||||
|
role="presentation"
|
||||||
|
onclick={handleBackdrop}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="dialog"
|
||||||
|
class:danger={variant === 'danger'}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="confirm-title"
|
||||||
|
>
|
||||||
|
<h2 id="confirm-title">{title}</h2>
|
||||||
|
<div class="body">
|
||||||
|
{@render children()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if confirmPhrase}
|
||||||
|
<label class="phrase">
|
||||||
|
<span>
|
||||||
|
{confirmPhrasePrompt ?? `Type the slug to confirm:`}
|
||||||
|
<code>{confirmPhrase}</code>
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
bind:this={inputRef}
|
||||||
|
bind:value={typed}
|
||||||
|
autocomplete="off"
|
||||||
|
autocapitalize="off"
|
||||||
|
autocorrect="off"
|
||||||
|
spellcheck="false"
|
||||||
|
disabled={busy}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="secondary"
|
||||||
|
bind:this={cancelRef}
|
||||||
|
onclick={onCancel}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
{cancelLabel}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class={variant === 'danger' ? 'danger' : ''}
|
||||||
|
onclick={handleConfirm}
|
||||||
|
disabled={!phraseMatches || busy}
|
||||||
|
>
|
||||||
|
{busy ? (busyLabel ?? `${confirmLabel}…`) : confirmLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(2, 6, 23, 0.7);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 1rem;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog {
|
||||||
|
background: #0f172a;
|
||||||
|
color: #e2e8f0;
|
||||||
|
border: 1px solid #334155;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 1.5rem;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 32rem;
|
||||||
|
max-height: calc(100vh - 2rem);
|
||||||
|
overflow-y: auto;
|
||||||
|
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog.danger {
|
||||||
|
border-color: #7f1d1d;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog.danger h2 {
|
||||||
|
color: #fca5a5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.body {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.body :global(p) {
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.body :global(p:last-child) {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.body :global(code) {
|
||||||
|
background: #1e293b;
|
||||||
|
padding: 0.1rem 0.3rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.body :global(strong) {
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.body :global(ul) {
|
||||||
|
margin: 0.5rem 0 0.75rem;
|
||||||
|
padding-left: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.body :global(.impact-list) {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0.75rem 0;
|
||||||
|
background: #1e293b;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
max-height: 12rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.body :global(.impact-list li) {
|
||||||
|
padding: 0.25rem 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.body :global(.impact-list li + li) {
|
||||||
|
border-top: 1px solid #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
.body :global(.modal-error) {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
padding: 0.6rem 0.75rem;
|
||||||
|
border: 1px solid #b91c1c;
|
||||||
|
background: #450a0a;
|
||||||
|
color: #fecaca;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.body :global(.muted) {
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phrase {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
margin: 1rem 0 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phrase code {
|
||||||
|
background: #1e293b;
|
||||||
|
padding: 0.1rem 0.3rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
color: #fca5a5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phrase input {
|
||||||
|
background: #0b1220;
|
||||||
|
color: #e2e8f0;
|
||||||
|
border: 1px solid #334155;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
font: inherit;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phrase input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #38bdf8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
background: #38bdf8;
|
||||||
|
color: #0b1220;
|
||||||
|
border: none;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-weight: 600;
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.secondary {
|
||||||
|
background: transparent;
|
||||||
|
color: #94a3b8;
|
||||||
|
border: 1px solid #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.danger {
|
||||||
|
background: #7f1d1d;
|
||||||
|
color: #fecaca;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -367,10 +367,13 @@ export const api = {
|
|||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
body: JSON.stringify(input)
|
body: JSON.stringify(input)
|
||||||
}),
|
}),
|
||||||
remove: (idOrSlug: string) =>
|
remove: (idOrSlug: string, opts: { force?: boolean } = {}) => {
|
||||||
adminRequest<null>(`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}`, {
|
const qs = opts.force ? '?force=true' : '';
|
||||||
method: 'DELETE'
|
return adminRequest<null>(
|
||||||
}),
|
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}${qs}`,
|
||||||
|
{ method: 'DELETE' }
|
||||||
|
);
|
||||||
|
},
|
||||||
slugCheck: (idOrSlug: string, newSlug: string) =>
|
slugCheck: (idOrSlug: string, newSlug: string) =>
|
||||||
adminRequest<SlugCheckResponse>(
|
adminRequest<SlugCheckResponse>(
|
||||||
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/slug:check`,
|
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/slug:check`,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { HostKind, PathKind } from './api';
|
import type { AppDomain, HostKind, PathKind } from './api';
|
||||||
|
|
||||||
/** Guess a path kind from the literal user input. The dashboard pre-fills
|
/** Guess a path kind from the literal user input. The dashboard pre-fills
|
||||||
* the kind selector but the user can override (the backend trusts the
|
* the kind selector but the user can override (the backend trusts the
|
||||||
@@ -30,3 +30,97 @@ export function pathKindMismatchWarning(raw: string, kind: PathKind): string | n
|
|||||||
: 'this looks like a literal path';
|
: 'this looks like a literal path';
|
||||||
return `Selected kind is "${kind}", but ${hint}. Routing will use the selected kind.`;
|
return `Selected kind is "${kind}", but ${hint}. Routing will use the selected kind.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Parse the user's free-text host input into the (kind, stored host)
|
||||||
|
* pair the API expects. Mirrors the dashboard's pre-existing storage
|
||||||
|
* convention: wildcard route hosts are stored as the bare suffix
|
||||||
|
* (`*.foo.com` → `foo.com`); display-time formatting re-adds the `*.`. */
|
||||||
|
export interface ParsedHostInput {
|
||||||
|
kind: HostKind;
|
||||||
|
/** What gets sent in the API payload as `host`. */
|
||||||
|
host: string;
|
||||||
|
/** Canonical display form, for chips/labels. */
|
||||||
|
display: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseHostInput(raw: string): ParsedHostInput {
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
if (trimmed === '' || trimmed === '*') {
|
||||||
|
return { kind: 'any', host: '', display: '*' };
|
||||||
|
}
|
||||||
|
if (trimmed.startsWith('*.')) {
|
||||||
|
const suffix = trimmed.slice(2);
|
||||||
|
return { kind: 'wildcard', host: suffix, display: `*.${suffix}` };
|
||||||
|
}
|
||||||
|
return { kind: 'strict', host: trimmed, display: trimmed };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Frontend mirror of `validate_route_host_against_app` in
|
||||||
|
* crates/manager-core/src/route_admin.rs. Lets us surface a live
|
||||||
|
* warning instead of waiting for a 422. The server runs the same
|
||||||
|
* check on submit — this is purely an early signal. */
|
||||||
|
export type HostClaimCheck =
|
||||||
|
| { ok: true; matched: string }
|
||||||
|
| { ok: false; reason: string };
|
||||||
|
|
||||||
|
export function checkHostAgainstClaims(
|
||||||
|
parsed: ParsedHostInput,
|
||||||
|
claims: AppDomain[]
|
||||||
|
): HostClaimCheck {
|
||||||
|
if (parsed.kind === 'any') return { ok: true, matched: '*' };
|
||||||
|
if (claims.length === 0) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
reason: 'this app has no domain claims yet — add one in the app’s Domains tab'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const hostLower = parsed.host.toLowerCase();
|
||||||
|
for (const claim of claims) {
|
||||||
|
const claimLower = claim.pattern.toLowerCase();
|
||||||
|
const claimSuffix = claimLower.split('.').slice(1).join('.');
|
||||||
|
if (parsed.kind === 'strict') {
|
||||||
|
if (claim.shape === 'exact' && hostLower === claimLower) {
|
||||||
|
return { ok: true, matched: claim.pattern };
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
(claim.shape === 'wildcard' || claim.shape === 'parameterized') &&
|
||||||
|
claimSuffix &&
|
||||||
|
hostLower.endsWith(`.${claimSuffix}`)
|
||||||
|
) {
|
||||||
|
return { ok: true, matched: claim.pattern };
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// wildcard route
|
||||||
|
if (
|
||||||
|
(claim.shape === 'wildcard' || claim.shape === 'parameterized') &&
|
||||||
|
claimSuffix === hostLower
|
||||||
|
) {
|
||||||
|
return { ok: true, matched: claim.pattern };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
reason: `${parsed.display} is not covered by any of this app’s claims`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Suggestion strings for the host input's <datalist>. We always
|
||||||
|
* include the wildcard `*` (any host) as the first option, then the
|
||||||
|
* app's claims rendered in the form the route input expects. */
|
||||||
|
export function hostSuggestions(claims: AppDomain[]): string[] {
|
||||||
|
const items: string[] = ['*'];
|
||||||
|
for (const claim of claims) {
|
||||||
|
if (claim.shape === 'exact') {
|
||||||
|
items.push(claim.pattern);
|
||||||
|
} else {
|
||||||
|
// Both wildcard and parameterized claims are usable as a
|
||||||
|
// wildcard route. Render as `*.suffix` — we can't preserve
|
||||||
|
// the {param} binding name through the current route form.
|
||||||
|
const suffix = claim.pattern.split('.').slice(1).join('.');
|
||||||
|
if (suffix) items.push(`*.${suffix}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Dedupe while preserving order.
|
||||||
|
return [...new Set(items)];
|
||||||
|
}
|
||||||
|
|||||||
30
dashboard/src/lib/slugify.ts
Normal file
30
dashboard/src/lib/slugify.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
// Slug normalization for app slugs, mirrored against the backend's
|
||||||
|
// validate_slug rules in crates/manager-core/src/apps_api.rs:
|
||||||
|
// - regex: ^[a-z0-9][a-z0-9-]{0,62}$
|
||||||
|
// - 1..=63 chars, lowercase ascii alphanumerics + `-`
|
||||||
|
// - must start with [a-z0-9]
|
||||||
|
// - reserved words are enforced server-side only
|
||||||
|
//
|
||||||
|
// Normalization rules are GitLab-style (close to `Babosa::Latin#to_slug`):
|
||||||
|
// 1. NFKD-decompose Unicode and drop combining marks (é → e, ñ → n,
|
||||||
|
// ü → u, etc.).
|
||||||
|
// 2. ß → ss (a single common case the strip-marks pass misses).
|
||||||
|
// 3. Lowercase.
|
||||||
|
// 4. Replace any run of non-[a-z0-9] with a single `-`.
|
||||||
|
// 5. Trim leading/trailing `-`.
|
||||||
|
// 6. Truncate to 63 chars.
|
||||||
|
|
||||||
|
export const SLUG_MAX = 63;
|
||||||
|
|
||||||
|
export function slugify(input: string): string {
|
||||||
|
if (!input) return '';
|
||||||
|
let s = input.normalize('NFKD').replace(/[\u0300-\u036f]/g, '');
|
||||||
|
s = s.toLowerCase().replace(/ß/g, 'ss');
|
||||||
|
s = s.replace(/[^a-z0-9]+/g, '-');
|
||||||
|
s = s.replace(/^-+|-+$/g, '');
|
||||||
|
if (s.length > SLUG_MAX) {
|
||||||
|
// Truncate, then re-trim in case the cut landed on a `-`.
|
||||||
|
s = s.slice(0, SLUG_MAX).replace(/-+$/g, '');
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { base } from '$app/paths';
|
import { base } from '$app/paths';
|
||||||
import { api, ApiError, type App } from '$lib/api';
|
import { api, ApiError, type App } from '$lib/api';
|
||||||
|
import { slugify, SLUG_MAX } from '$lib/slugify';
|
||||||
|
|
||||||
let apps = $state<App[] | null>(null);
|
let apps = $state<App[] | null>(null);
|
||||||
let listError = $state<string | null>(null);
|
let listError = $state<string | null>(null);
|
||||||
@@ -10,10 +11,34 @@
|
|||||||
let createSlug = $state('');
|
let createSlug = $state('');
|
||||||
let createName = $state('');
|
let createName = $state('');
|
||||||
let createDescription = $state('');
|
let createDescription = $state('');
|
||||||
|
// Auto-derive slug from name until the user takes manual control of
|
||||||
|
// the slug field. Clearing the slug input releases the lock so the
|
||||||
|
// auto-derive resumes — matches the GitLab project-create UX.
|
||||||
|
let slugTouched = $state(false);
|
||||||
let creating = $state(false);
|
let creating = $state(false);
|
||||||
let createError = $state<string | null>(null);
|
let createError = $state<string | null>(null);
|
||||||
let createHistoricalConflict = $state<App | null>(null);
|
let createHistoricalConflict = $state<App | null>(null);
|
||||||
|
|
||||||
|
function onNameInput(event: Event) {
|
||||||
|
const value = (event.target as HTMLInputElement).value;
|
||||||
|
createName = value;
|
||||||
|
if (!slugTouched) {
|
||||||
|
createSlug = slugify(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSlugInput(event: Event) {
|
||||||
|
const raw = (event.target as HTMLInputElement).value;
|
||||||
|
const normalized = slugify(raw);
|
||||||
|
createSlug = normalized;
|
||||||
|
// Re-sync the input element so a paste of "Hello World!" shows
|
||||||
|
// "hello-world" immediately, not the raw value.
|
||||||
|
if (raw !== normalized) {
|
||||||
|
(event.target as HTMLInputElement).value = normalized;
|
||||||
|
}
|
||||||
|
slugTouched = normalized.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
loading = true;
|
loading = true;
|
||||||
listError = null;
|
listError = null;
|
||||||
@@ -33,6 +58,7 @@
|
|||||||
createDescription = '';
|
createDescription = '';
|
||||||
createError = null;
|
createError = null;
|
||||||
createHistoricalConflict = null;
|
createHistoricalConflict = null;
|
||||||
|
slugTouched = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submitCreate(event: Event, forceTakeover = false) {
|
async function submitCreate(event: Event, forceTakeover = false) {
|
||||||
@@ -88,17 +114,28 @@
|
|||||||
<form class="create-form" onsubmit={(e) => submitCreate(e)}>
|
<form class="create-form" onsubmit={(e) => submitCreate(e)}>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<label>
|
<label>
|
||||||
<span>Slug</span>
|
<span>Name</span>
|
||||||
<input
|
<input
|
||||||
bind:value={createSlug}
|
value={createName}
|
||||||
|
oninput={onNameInput}
|
||||||
required
|
required
|
||||||
pattern="[a-z0-9][a-z0-9-]*"
|
placeholder="My App"
|
||||||
placeholder="my-app"
|
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
<span>Name</span>
|
<span>Slug</span>
|
||||||
<input bind:value={createName} required placeholder="My App" />
|
<input
|
||||||
|
value={createSlug}
|
||||||
|
oninput={onSlugInput}
|
||||||
|
required
|
||||||
|
pattern="[a-z0-9][a-z0-9-]*"
|
||||||
|
maxlength={SLUG_MAX}
|
||||||
|
placeholder="my-app"
|
||||||
|
autocomplete="off"
|
||||||
|
autocapitalize="off"
|
||||||
|
autocorrect="off"
|
||||||
|
spellcheck="false"
|
||||||
|
/>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<label>
|
<label>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
type Script
|
type Script
|
||||||
} from '$lib/api';
|
} from '$lib/api';
|
||||||
import CodeEditor from '$lib/CodeEditor.svelte';
|
import CodeEditor from '$lib/CodeEditor.svelte';
|
||||||
|
import ConfirmModal from '$lib/ConfirmModal.svelte';
|
||||||
|
|
||||||
const SAMPLE_SOURCE =
|
const SAMPLE_SOURCE =
|
||||||
'#{\n statusCode: 200,\n body: #{ ok: true, echo: ctx.request.body }\n}';
|
'#{\n statusCode: 200,\n body: #{ ok: true, echo: ctx.request.body }\n}';
|
||||||
@@ -46,6 +47,14 @@
|
|||||||
let settingsError = $state<string | null>(null);
|
let settingsError = $state<string | null>(null);
|
||||||
let slugTakeoverNeeded = $state<App | null>(null);
|
let slugTakeoverNeeded = $state<App | null>(null);
|
||||||
|
|
||||||
|
// Delete confirmations
|
||||||
|
let confirmingDeleteApp = $state(false);
|
||||||
|
let deletingApp = $state(false);
|
||||||
|
let deleteAppError = $state<string | null>(null);
|
||||||
|
let domainToRemove = $state<AppDomain | null>(null);
|
||||||
|
let removingDomain = $state(false);
|
||||||
|
let removeDomainError = $state<string | null>(null);
|
||||||
|
|
||||||
async function loadApp() {
|
async function loadApp() {
|
||||||
loading = true;
|
loading = true;
|
||||||
loadError = null;
|
loadError = null;
|
||||||
@@ -135,14 +144,23 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function removeDomain(d: AppDomain) {
|
function askRemoveDomain(d: AppDomain) {
|
||||||
if (!app) return;
|
removeDomainError = null;
|
||||||
if (!window.confirm(`Delete domain claim ${d.pattern}?`)) return;
|
domainToRemove = d;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmRemoveDomain() {
|
||||||
|
if (!app || !domainToRemove) return;
|
||||||
|
removingDomain = true;
|
||||||
|
removeDomainError = null;
|
||||||
try {
|
try {
|
||||||
await api.domains.remove(app.id, d.id);
|
await api.domains.remove(app.id, domainToRemove.id);
|
||||||
|
domainToRemove = null;
|
||||||
await loadDomains(app.id);
|
await loadDomains(app.id);
|
||||||
} catch (e) {
|
} 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;
|
if (!app) return;
|
||||||
const yes = window.confirm(
|
deletingApp = true;
|
||||||
`Delete app "${app.name}"? This requires zero scripts and zero domain claims.`
|
deleteAppError = null;
|
||||||
);
|
|
||||||
if (!yes) return;
|
|
||||||
try {
|
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`);
|
await goto(`${base}/apps`);
|
||||||
} catch (e) {
|
} 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 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="secondary danger"
|
class="secondary danger"
|
||||||
onclick={() => void removeDomain(d)}
|
onclick={() => askRemoveDomain(d)}
|
||||||
>
|
>
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
@@ -402,12 +428,80 @@
|
|||||||
<div class="danger-zone">
|
<div class="danger-zone">
|
||||||
<h3>Delete app</h3>
|
<h3>Delete app</h3>
|
||||||
<p class="muted">
|
<p class="muted">
|
||||||
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.
|
||||||
</p>
|
</p>
|
||||||
<button type="button" class="danger" onclick={deleteApp}>Delete app</button>
|
<button type="button" class="danger" onclick={askDeleteApp}>Delete app</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if confirmingDeleteApp}
|
||||||
|
<ConfirmModal
|
||||||
|
title="Delete app “{app.name}”"
|
||||||
|
variant="danger"
|
||||||
|
confirmLabel="Delete app"
|
||||||
|
busyLabel="Deleting…"
|
||||||
|
confirmPhrase={app.slug}
|
||||||
|
confirmPhrasePrompt="Type the app slug to confirm:"
|
||||||
|
busy={deletingApp}
|
||||||
|
onConfirm={confirmDeleteApp}
|
||||||
|
onCancel={() => (confirmingDeleteApp = false)}
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
This will <strong>permanently delete</strong> everything inside
|
||||||
|
<strong>{app.name}</strong>. There is no undo.
|
||||||
|
</p>
|
||||||
|
<ul class="impact-list">
|
||||||
|
<li>
|
||||||
|
<span>Scripts</span><strong>{scripts.length}</strong>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span>Domain claims</span><strong>{domains.length}</strong>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span>Routes & execution logs</span><strong>all</strong>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
{#if domains.length > 0}
|
||||||
|
<p>The following hosts will stop pointing at this app:</p>
|
||||||
|
<ul class="impact-list">
|
||||||
|
{#each domains as d (d.id)}
|
||||||
|
<li>
|
||||||
|
<code>{d.pattern}</code><span class="muted">{d.shape}</span>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
{#if deleteAppError}
|
||||||
|
<p class="modal-error">{deleteAppError}</p>
|
||||||
|
{/if}
|
||||||
|
</ConfirmModal>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if domainToRemove}
|
||||||
|
<ConfirmModal
|
||||||
|
title="Delete domain claim"
|
||||||
|
variant="danger"
|
||||||
|
confirmLabel="Delete claim"
|
||||||
|
busyLabel="Deleting…"
|
||||||
|
busy={removingDomain}
|
||||||
|
onConfirm={confirmRemoveDomain}
|
||||||
|
onCancel={() => (domainToRemove = null)}
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
<strong>{app.name}</strong> will stop answering on
|
||||||
|
<code>{domainToRemove.pattern}</code>.
|
||||||
|
</p>
|
||||||
|
<p class="muted">
|
||||||
|
Routes already bound to this host are blocked from deletion by the
|
||||||
|
API; if so, you’ll see an error here.
|
||||||
|
</p>
|
||||||
|
{#if removeDomainError}
|
||||||
|
<p class="modal-error">{removeDomainError}</p>
|
||||||
|
{/if}
|
||||||
|
</ConfirmModal>
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
import {
|
import {
|
||||||
api,
|
api,
|
||||||
ApiError,
|
ApiError,
|
||||||
|
type AppDomain,
|
||||||
type ExecutionLog,
|
type ExecutionLog,
|
||||||
type Route,
|
type Route,
|
||||||
type RouteInput,
|
type RouteInput,
|
||||||
@@ -12,7 +13,13 @@
|
|||||||
type VersionInfo
|
type VersionInfo
|
||||||
} from '$lib/api';
|
} from '$lib/api';
|
||||||
import { logLevelColor, statusColor } from '$lib/styles';
|
import { logLevelColor, statusColor } from '$lib/styles';
|
||||||
import { guessHostKind, guessPathKind, pathKindMismatchWarning } from '$lib/route-utils';
|
import {
|
||||||
|
checkHostAgainstClaims,
|
||||||
|
guessPathKind,
|
||||||
|
hostSuggestions,
|
||||||
|
parseHostInput,
|
||||||
|
pathKindMismatchWarning
|
||||||
|
} from '$lib/route-utils';
|
||||||
import CodeEditor from '$lib/CodeEditor.svelte';
|
import CodeEditor from '$lib/CodeEditor.svelte';
|
||||||
import { format as formatRhai } from '$lib/rhai';
|
import { format as formatRhai } from '$lib/rhai';
|
||||||
|
|
||||||
@@ -39,6 +46,7 @@
|
|||||||
let info = $state<VersionInfo | null>(null);
|
let info = $state<VersionInfo | null>(null);
|
||||||
|
|
||||||
let appSlug = $state<string | null>(null);
|
let appSlug = $state<string | null>(null);
|
||||||
|
let appDomains = $state<AppDomain[]>([]);
|
||||||
|
|
||||||
async function loadScript() {
|
async function loadScript() {
|
||||||
scriptLoading = true;
|
scriptLoading = true;
|
||||||
@@ -50,14 +58,23 @@
|
|||||||
editableDescription = script.description ?? '';
|
editableDescription = script.description ?? '';
|
||||||
editableTimeout = script.timeout_seconds;
|
editableTimeout = script.timeout_seconds;
|
||||||
editableSandbox = { ...(script.sandbox ?? {}) };
|
editableSandbox = { ...(script.sandbox ?? {}) };
|
||||||
// Resolve the owning app's slug for the breadcrumb. Failure
|
// Resolve the owning app's slug for the breadcrumb and its
|
||||||
// is non-fatal — the page works without it.
|
// domain claims for the route form's suggestions + live
|
||||||
|
// validation. Both are non-fatal — the page works without
|
||||||
|
// them.
|
||||||
|
const appId = script.app_id;
|
||||||
void api.apps
|
void api.apps
|
||||||
.get(script.app_id)
|
.get(appId)
|
||||||
.then((a) => {
|
.then((a) => {
|
||||||
appSlug = a.slug;
|
appSlug = a.slug;
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
|
void api.domains
|
||||||
|
.listForApp(appId)
|
||||||
|
.then((d) => {
|
||||||
|
appDomains = d;
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
scriptError = e instanceof Error ? e.message : String(e);
|
scriptError = e instanceof Error ? e.message : String(e);
|
||||||
script = null;
|
script = null;
|
||||||
@@ -175,12 +192,14 @@
|
|||||||
let routesLoading = $state(true);
|
let routesLoading = $state(true);
|
||||||
|
|
||||||
let showAddRoute = $state(false);
|
let showAddRoute = $state(false);
|
||||||
let newRoutePath = $state('');
|
let newRoutePath = $state('/');
|
||||||
let newRoutePathKind = $state<'exact' | 'prefix' | 'param'>('exact');
|
let newRoutePathKind = $state<'exact' | 'prefix' | 'param'>('exact');
|
||||||
let newRouteHost = $state('');
|
// Host input is free-form; the kind is derived from what the user
|
||||||
let newRouteHostKind = $state<'any' | 'strict' | 'wildcard'>('any');
|
// typed (see `parsedHost` below). Default `*` = Any, matching the
|
||||||
|
// canonical display form for an unrestricted host.
|
||||||
|
let newRouteHost = $state('*');
|
||||||
let newRouteMethod = $state('');
|
let newRouteMethod = $state('');
|
||||||
let routeKindAutoUpdate = $state(true);
|
let pathKindAutoUpdate = $state(true);
|
||||||
let creatingRoute = $state(false);
|
let creatingRoute = $state(false);
|
||||||
let createRouteError = $state<string | null>(null);
|
let createRouteError = $state<string | null>(null);
|
||||||
|
|
||||||
@@ -188,17 +207,17 @@
|
|||||||
let previewMethod = $state('GET');
|
let previewMethod = $state('GET');
|
||||||
let previewResult = $state<string | null>(null);
|
let previewResult = $state<string | null>(null);
|
||||||
|
|
||||||
// Auto-update kind selectors as the user types.
|
// Auto-update the path-kind selector as the user types.
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (routeKindAutoUpdate) {
|
if (pathKindAutoUpdate) {
|
||||||
newRoutePathKind = guessPathKind(newRoutePath);
|
newRoutePathKind = guessPathKind(newRoutePath);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
$effect(() => {
|
|
||||||
if (routeKindAutoUpdate) {
|
let parsedHost = $derived(parseHostInput(newRouteHost));
|
||||||
newRouteHostKind = guessHostKind(newRouteHost);
|
let hostCheck = $derived(checkHostAgainstClaims(parsedHost, appDomains));
|
||||||
}
|
let hostDatalistId = 'route-host-suggestions';
|
||||||
});
|
let suggestions = $derived(hostSuggestions(appDomains));
|
||||||
|
|
||||||
let pathKindWarning = $derived(
|
let pathKindWarning = $derived(
|
||||||
newRoutePath.trim() ? pathKindMismatchWarning(newRoutePath, newRoutePathKind) : null
|
newRoutePath.trim() ? pathKindMismatchWarning(newRoutePath, newRoutePathKind) : null
|
||||||
@@ -222,18 +241,18 @@
|
|||||||
createRouteError = null;
|
createRouteError = null;
|
||||||
try {
|
try {
|
||||||
const input: RouteInput = {
|
const input: RouteInput = {
|
||||||
host_kind: newRouteHostKind,
|
host_kind: parsedHost.kind,
|
||||||
host: newRouteHostKind === 'any' ? '' : newRouteHost.trim(),
|
host: parsedHost.host,
|
||||||
path_kind: newRoutePathKind,
|
path_kind: newRoutePathKind,
|
||||||
path: newRoutePath.trim(),
|
path: newRoutePath.trim(),
|
||||||
method: newRouteMethod.trim() || null
|
method: newRouteMethod.trim() || null
|
||||||
};
|
};
|
||||||
await api.routes.create(id, input);
|
await api.routes.create(id, input);
|
||||||
showAddRoute = false;
|
showAddRoute = false;
|
||||||
newRoutePath = '';
|
newRoutePath = '/';
|
||||||
newRouteHost = '';
|
newRouteHost = '*';
|
||||||
newRouteMethod = '';
|
newRouteMethod = '';
|
||||||
routeKindAutoUpdate = true;
|
pathKindAutoUpdate = true;
|
||||||
await loadRoutes();
|
await loadRoutes();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof ApiError && e.status === 409) {
|
if (e instanceof ApiError && e.status === 409) {
|
||||||
@@ -502,7 +521,7 @@
|
|||||||
<span>Path</span>
|
<span>Path</span>
|
||||||
<input
|
<input
|
||||||
bind:value={newRoutePath}
|
bind:value={newRoutePath}
|
||||||
oninput={() => (routeKindAutoUpdate = true)}
|
oninput={() => (pathKindAutoUpdate = true)}
|
||||||
placeholder="/greet, /greet/:name, /webhooks/*"
|
placeholder="/greet, /greet/:name, /webhooks/*"
|
||||||
required
|
required
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
@@ -513,7 +532,7 @@
|
|||||||
<span>Path kind</span>
|
<span>Path kind</span>
|
||||||
<select
|
<select
|
||||||
bind:value={newRoutePathKind}
|
bind:value={newRoutePathKind}
|
||||||
onchange={() => (routeKindAutoUpdate = false)}
|
onchange={() => (pathKindAutoUpdate = false)}
|
||||||
>
|
>
|
||||||
<option value="exact">exact</option>
|
<option value="exact">exact</option>
|
||||||
<option value="param">param</option>
|
<option value="param">param</option>
|
||||||
@@ -532,31 +551,43 @@
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<label class="full">
|
||||||
<label>
|
<span class="host-label">
|
||||||
<span>Host kind</span>
|
Host
|
||||||
<select
|
<span class="kind-chip kind-{parsedHost.kind}">
|
||||||
bind:value={newRouteHostKind}
|
{parsedHost.kind}
|
||||||
onchange={() => (routeKindAutoUpdate = false)}
|
</span>
|
||||||
>
|
</span>
|
||||||
<option value="any">ANY</option>
|
<input
|
||||||
<option value="strict">strict</option>
|
bind:value={newRouteHost}
|
||||||
<option value="wildcard">wildcard</option>
|
placeholder="* · app.example.com · *.example.com"
|
||||||
</select>
|
list={hostDatalistId}
|
||||||
</label>
|
autocomplete="off"
|
||||||
<label class:disabled={newRouteHostKind === 'any'}>
|
autocapitalize="off"
|
||||||
<span>Host</span>
|
autocorrect="off"
|
||||||
<input
|
spellcheck="false"
|
||||||
bind:value={newRouteHost}
|
/>
|
||||||
oninput={() => (routeKindAutoUpdate = true)}
|
<datalist id={hostDatalistId}>
|
||||||
disabled={newRouteHostKind === 'any'}
|
{#each suggestions as s (s)}
|
||||||
placeholder={newRouteHostKind === 'wildcard'
|
<option value={s}></option>
|
||||||
? '*.example.com'
|
{/each}
|
||||||
: 'sub.example.com'}
|
</datalist>
|
||||||
autocomplete="off"
|
<small class="muted">
|
||||||
/>
|
<code>*</code> = any host claimed by this app ·
|
||||||
</label>
|
<code>*.foo.com</code> = wildcard · <code>foo.com</code> =
|
||||||
</div>
|
strict
|
||||||
|
</small>
|
||||||
|
</label>
|
||||||
|
{#if !hostCheck.ok}
|
||||||
|
<div class="warning inline">
|
||||||
|
{hostCheck.reason}.
|
||||||
|
{#if appDomains.length > 0}
|
||||||
|
Claims:
|
||||||
|
{#each appDomains as d, i (d.id)}<code>{d.pattern}</code>{#if i < appDomains.length - 1},
|
||||||
|
{/if}{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
{#if pathKindWarning}
|
{#if pathKindWarning}
|
||||||
<div class="warning inline">{pathKindWarning}</div>
|
<div class="warning inline">{pathKindWarning}</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -951,9 +982,6 @@
|
|||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
color: #cbd5e1;
|
color: #cbd5e1;
|
||||||
}
|
}
|
||||||
label.disabled {
|
|
||||||
opacity: 0.6;
|
|
||||||
}
|
|
||||||
label.full {
|
label.full {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
@@ -1070,6 +1098,31 @@
|
|||||||
background: #581c87;
|
background: #581c87;
|
||||||
color: #e9d5ff;
|
color: #e9d5ff;
|
||||||
}
|
}
|
||||||
|
.host-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.kind-chip {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
padding: 0.1rem 0.4rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
.kind-chip.kind-any {
|
||||||
|
background: #1e293b;
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
|
.kind-chip.kind-strict {
|
||||||
|
background: #14532d;
|
||||||
|
color: #bbf7d0;
|
||||||
|
}
|
||||||
|
.kind-chip.kind-wildcard {
|
||||||
|
background: #1e3a8a;
|
||||||
|
color: #bfdbfe;
|
||||||
|
}
|
||||||
.route-row .method {
|
.route-row .method {
|
||||||
color: #fbbf24;
|
color: #fbbf24;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
|||||||
@@ -40,6 +40,12 @@ services:
|
|||||||
DATABASE_URL: postgres://${POSTGRES_USER:-picloud}:${POSTGRES_PASSWORD:-picloud}@postgres:5432/${POSTGRES_DB:-picloud}
|
DATABASE_URL: postgres://${POSTGRES_USER:-picloud}:${POSTGRES_PASSWORD:-picloud}@postgres:5432/${POSTGRES_DB:-picloud}
|
||||||
RUST_LOG: ${RUST_LOG:-info}
|
RUST_LOG: ${RUST_LOG:-info}
|
||||||
PICLOUD_PUBLIC_BASE_URL: ${PICLOUD_PUBLIC_BASE_URL:-http://localhost:8000}
|
PICLOUD_PUBLIC_BASE_URL: ${PICLOUD_PUBLIC_BASE_URL:-http://localhost:8000}
|
||||||
|
# Bootstrap admin (Phase 3a). Read once on first start to seed the
|
||||||
|
# admin_users table; ignored on subsequent boots if the table is
|
||||||
|
# non-empty. No defaults on purpose — leaving these unset in prod
|
||||||
|
# is a foot-gun. For dev, .env.example documents sensible values.
|
||||||
|
PICLOUD_ADMIN_USERNAME: ${PICLOUD_ADMIN_USERNAME:?set PICLOUD_ADMIN_USERNAME (see .env.example)}
|
||||||
|
PICLOUD_ADMIN_PASSWORD: ${PICLOUD_ADMIN_PASSWORD:?set PICLOUD_ADMIN_PASSWORD (see .env.example)}
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|||||||
Reference in New Issue
Block a user