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>
This commit is contained in:
MechaCat02
2026-05-26 21:01:20 +02:00
parent ad5492a4bd
commit a393f11344
5 changed files with 273 additions and 72 deletions

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

@@ -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;