Compare commits

...

3 Commits

Author SHA1 Message Date
MechaCat02
a393f11344 feat(dashboard): auto-slug app names and infer route host kind from input
Two related polish passes on forms the operator hits most.

App create form: the slug field used to come before the name field and
demanded the operator hand-roll a valid slug. Now the name field comes
first and the slug is derived from it live, GitLab-style — Unicode
NFKD-decomposed, combining marks stripped (so `Café` → `cafe`), `ß`
mapped to `ss`, non-`[a-z0-9]` runs collapsed to `-`, trimmed and capped
at the backend's 63-char limit. The auto-sync releases as soon as the
operator edits the slug manually, and re-engages if they clear it. The
slug input itself runs every keystroke and paste through the same
normalizer, so dirty input never reaches the form state.

Route create form: the three-way host-kind `<select>` plus a sometimes-
disabled input was confusing — operators routinely picked the wrong
kind, typed a host the app didn't claim, and only saw the error after
hitting Create. Replace with a single text input that infers the kind
from what's there (`*` → any, `*.foo.com` → wildcard, `foo.com` →
strict), shows the detected kind as a colored chip beside the field, and
suggests the app's existing domain claims via a `<datalist>`. The same
matching logic the backend runs in `validate_route_host_against_app`
now lives in `route-utils.ts` so the form can surface a soft "not
covered by any claim" warning *before* submit. Path also pre-fills to
`/` so the most common case is one click away.

Lockfile drift from `npm install` (pre-existing 0.5.0 → 0.5.1 version
sync, npm metadata cleanup) is folded in here since it surfaced during
this work.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 21:01:20 +02:00
MechaCat02
ad5492a4bd feat(manager-core,dashboard): cascading app delete with styled confirmation modal
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) <noreply@anthropic.com>
2026-05-26 21:01:05 +02:00
MechaCat02
ee0dbc428f chore(compose): require bootstrap admin env vars instead of defaulting to admin/admin
The previous interpolation used `${PICLOUD_ADMIN_USERNAME:-admin}` and
`${PICLOUD_ADMIN_PASSWORD:-admin}`, which made docker compose silently
bootstrap a production stack with `admin`/`admin` whenever the operator
forgot to set them. Flip to `${VAR:?…}` so an unset value aborts
`docker compose up` with a clear "set this var" message; dev still gets
the convenient default through the gitignored `.env` (documented in
`.env.example`).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 21:00:52 +02:00
12 changed files with 777 additions and 99 deletions

View File

@@ -29,3 +29,11 @@ RUST_LOG=info,picloud=debug
# 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.
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

View File

@@ -61,6 +61,11 @@ pub trait AppRepository: Send + Sync {
take_over_history: bool,
) -> Result<App, 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>;
}
@@ -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> {
let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM scripts WHERE app_id = $1")
.bind(id.into_inner())

View File

@@ -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<AppsState>,
Path(id_or_slug): Path<String>,
Query(q): Query<DeleteAppQuery>,
) -> Result<StatusCode, AppsApiError> {
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)
}

View File

@@ -1,12 +1,12 @@
{
"name": "picloud-dashboard",
"version": "0.5.0",
"version": "0.5.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "picloud-dashboard",
"version": "0.5.0",
"version": "0.5.1",
"dependencies": {
"@codemirror/autocomplete": "^6.20.2",
"@codemirror/commands": "^6.10.3",
@@ -1275,7 +1275,6 @@
"integrity": "sha512-mQjlkNo+rJvpln7V2IGY2j99BqhcFbS4UN0AQNKNYfhBAFZTuCDAdW3a1sgf330mvtNvsBXn3HpAhcmvdJTcIQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@sveltejs/acorn-typescript": "^1.0.5",
@@ -1318,7 +1317,6 @@
"integrity": "sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@sveltejs/vite-plugin-svelte-inspector": "^4.0.1",
"debug": "^4.4.1",
@@ -1398,7 +1396,6 @@
"integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~6.21.0"
}
@@ -1455,7 +1452,6 @@
"integrity": "sha512-zORHqO/tuhxY1zWuTvMUqddRxpiFJ72xVfcNoWpqdLjs6lfPbuQBJuW4pk+49/uBMy7Ssr4bzgjiKmmDB1UbZQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.59.4",
"@typescript-eslint/types": "8.59.4",
@@ -1563,7 +1559,6 @@
"integrity": "sha512-F1o7WJcCq+bc8dwcO/YsSEOudAH8RDtaOhM6wcAQhcUsFhnWQl81JKy48q1hoxAU0qrzM89+31GYh1515Zde3Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
@@ -1815,7 +1810,6 @@
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -2217,7 +2211,6 @@
"integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -3010,7 +3003,6 @@
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -3038,7 +3030,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.12",
"picocolors": "^1.1.1",
@@ -3172,7 +3163,6 @@
"integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"prettier": "bin/prettier.cjs"
},
@@ -3433,7 +3423,6 @@
"integrity": "sha512-fTjjT8cHLDwigcu2j3pv7Jq04LklXevPB8uBgyHNiTXv+RMNvVnrjS4UEYrLMkhuq1vpCodHjiW+z/95SDs/fg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@jridgewell/remapping": "^2.3.4",
"@jridgewell/sourcemap-codec": "^1.5.0",
@@ -3614,7 +3603,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -3677,7 +3665,6 @@
"integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.4.4",

View 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>

View File

@@ -367,10 +367,13 @@ export const api = {
method: 'PATCH',
body: JSON.stringify(input)
}),
remove: (idOrSlug: string) =>
adminRequest<null>(`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}`, {
method: 'DELETE'
}),
remove: (idOrSlug: string, opts: { force?: boolean } = {}) => {
const qs = opts.force ? '?force=true' : '';
return adminRequest<null>(
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}${qs}`,
{ method: 'DELETE' }
);
},
slugCheck: (idOrSlug: string, newSlug: string) =>
adminRequest<SlugCheckResponse>(
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/slug:check`,

View File

@@ -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
* 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';
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 apps 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 apps 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)];
}

View 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;
}

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import { base } from '$app/paths';
import { api, ApiError, type App } from '$lib/api';
import { slugify, SLUG_MAX } from '$lib/slugify';
let apps = $state<App[] | null>(null);
let listError = $state<string | null>(null);
@@ -10,10 +11,34 @@
let createSlug = $state('');
let createName = $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 createError = $state<string | 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() {
loading = true;
listError = null;
@@ -33,6 +58,7 @@
createDescription = '';
createError = null;
createHistoricalConflict = null;
slugTouched = false;
}
async function submitCreate(event: Event, forceTakeover = false) {
@@ -88,17 +114,28 @@
<form class="create-form" onsubmit={(e) => submitCreate(e)}>
<div class="row">
<label>
<span>Slug</span>
<span>Name</span>
<input
bind:value={createSlug}
value={createName}
oninput={onNameInput}
required
pattern="[a-z0-9][a-z0-9-]*"
placeholder="my-app"
placeholder="My App"
/>
</label>
<label>
<span>Name</span>
<input bind:value={createName} required placeholder="My App" />
<span>Slug</span>
<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>
</div>
<label>

View File

@@ -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<string | 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() {
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 @@
<button
type="button"
class="secondary danger"
onclick={() => void removeDomain(d)}
onclick={() => askRemoveDomain(d)}
>
Delete
</button>
@@ -402,12 +428,80 @@
<div class="danger-zone">
<h3>Delete app</h3>
<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>
<button type="button" class="danger" onclick={deleteApp}>Delete app</button>
<button type="button" class="danger" onclick={askDeleteApp}>Delete app</button>
</div>
</section>
{/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 &amp; 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, youll see an error here.
</p>
{#if removeDomainError}
<p class="modal-error">{removeDomainError}</p>
{/if}
</ConfirmModal>
{/if}
{/if}
<style>

View File

@@ -5,6 +5,7 @@
import {
api,
ApiError,
type AppDomain,
type ExecutionLog,
type Route,
type RouteInput,
@@ -12,7 +13,13 @@
type VersionInfo
} from '$lib/api';
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 { format as formatRhai } from '$lib/rhai';
@@ -39,6 +46,7 @@
let info = $state<VersionInfo | null>(null);
let appSlug = $state<string | null>(null);
let appDomains = $state<AppDomain[]>([]);
async function loadScript() {
scriptLoading = true;
@@ -50,14 +58,23 @@
editableDescription = script.description ?? '';
editableTimeout = script.timeout_seconds;
editableSandbox = { ...(script.sandbox ?? {}) };
// Resolve the owning app's slug for the breadcrumb. Failure
// is non-fatal — the page works without it.
// Resolve the owning app's slug for the breadcrumb and its
// 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
.get(script.app_id)
.get(appId)
.then((a) => {
appSlug = a.slug;
})
.catch(() => {});
void api.domains
.listForApp(appId)
.then((d) => {
appDomains = d;
})
.catch(() => {});
} catch (e) {
scriptError = e instanceof Error ? e.message : String(e);
script = null;
@@ -175,12 +192,14 @@
let routesLoading = $state(true);
let showAddRoute = $state(false);
let newRoutePath = $state('');
let newRoutePath = $state('/');
let newRoutePathKind = $state<'exact' | 'prefix' | 'param'>('exact');
let newRouteHost = $state('');
let newRouteHostKind = $state<'any' | 'strict' | 'wildcard'>('any');
// Host input is free-form; the kind is derived from what the user
// typed (see `parsedHost` below). Default `*` = Any, matching the
// canonical display form for an unrestricted host.
let newRouteHost = $state('*');
let newRouteMethod = $state('');
let routeKindAutoUpdate = $state(true);
let pathKindAutoUpdate = $state(true);
let creatingRoute = $state(false);
let createRouteError = $state<string | null>(null);
@@ -188,17 +207,17 @@
let previewMethod = $state('GET');
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(() => {
if (routeKindAutoUpdate) {
if (pathKindAutoUpdate) {
newRoutePathKind = guessPathKind(newRoutePath);
}
});
$effect(() => {
if (routeKindAutoUpdate) {
newRouteHostKind = guessHostKind(newRouteHost);
}
});
let parsedHost = $derived(parseHostInput(newRouteHost));
let hostCheck = $derived(checkHostAgainstClaims(parsedHost, appDomains));
let hostDatalistId = 'route-host-suggestions';
let suggestions = $derived(hostSuggestions(appDomains));
let pathKindWarning = $derived(
newRoutePath.trim() ? pathKindMismatchWarning(newRoutePath, newRoutePathKind) : null
@@ -222,18 +241,18 @@
createRouteError = null;
try {
const input: RouteInput = {
host_kind: newRouteHostKind,
host: newRouteHostKind === 'any' ? '' : newRouteHost.trim(),
host_kind: parsedHost.kind,
host: parsedHost.host,
path_kind: newRoutePathKind,
path: newRoutePath.trim(),
method: newRouteMethod.trim() || null
};
await api.routes.create(id, input);
showAddRoute = false;
newRoutePath = '';
newRouteHost = '';
newRoutePath = '/';
newRouteHost = '*';
newRouteMethod = '';
routeKindAutoUpdate = true;
pathKindAutoUpdate = true;
await loadRoutes();
} catch (e) {
if (e instanceof ApiError && e.status === 409) {
@@ -502,7 +521,7 @@
<span>Path</span>
<input
bind:value={newRoutePath}
oninput={() => (routeKindAutoUpdate = true)}
oninput={() => (pathKindAutoUpdate = true)}
placeholder="/greet, /greet/:name, /webhooks/*"
required
autocomplete="off"
@@ -513,7 +532,7 @@
<span>Path kind</span>
<select
bind:value={newRoutePathKind}
onchange={() => (routeKindAutoUpdate = false)}
onchange={() => (pathKindAutoUpdate = false)}
>
<option value="exact">exact</option>
<option value="param">param</option>
@@ -532,31 +551,43 @@
</select>
</label>
</div>
<div class="row">
<label>
<span>Host kind</span>
<select
bind:value={newRouteHostKind}
onchange={() => (routeKindAutoUpdate = false)}
>
<option value="any">ANY</option>
<option value="strict">strict</option>
<option value="wildcard">wildcard</option>
</select>
</label>
<label class:disabled={newRouteHostKind === 'any'}>
<span>Host</span>
<input
bind:value={newRouteHost}
oninput={() => (routeKindAutoUpdate = true)}
disabled={newRouteHostKind === 'any'}
placeholder={newRouteHostKind === 'wildcard'
? '*.example.com'
: 'sub.example.com'}
autocomplete="off"
/>
</label>
</div>
<label class="full">
<span class="host-label">
Host
<span class="kind-chip kind-{parsedHost.kind}">
{parsedHost.kind}
</span>
</span>
<input
bind:value={newRouteHost}
placeholder="* · app.example.com · *.example.com"
list={hostDatalistId}
autocomplete="off"
autocapitalize="off"
autocorrect="off"
spellcheck="false"
/>
<datalist id={hostDatalistId}>
{#each suggestions as s (s)}
<option value={s}></option>
{/each}
</datalist>
<small class="muted">
<code>*</code> = any host claimed by this app ·
<code>*.foo.com</code> = wildcard · <code>foo.com</code> =
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}
<div class="warning inline">{pathKindWarning}</div>
{/if}
@@ -951,9 +982,6 @@
font-size: 0.85rem;
color: #cbd5e1;
}
label.disabled {
opacity: 0.6;
}
label.full {
width: 100%;
}
@@ -1070,6 +1098,31 @@
background: #581c87;
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 {
color: #fbbf24;
font-weight: 700;

View File

@@ -40,6 +40,12 @@ services:
DATABASE_URL: postgres://${POSTGRES_USER:-picloud}:${POSTGRES_PASSWORD:-picloud}@postgres:5432/${POSTGRES_DB:-picloud}
RUST_LOG: ${RUST_LOG:-info}
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:
postgres:
condition: service_healthy