diff --git a/dashboard/package-lock.json b/dashboard/package-lock.json index bfa7e22..c77c214 100644 --- a/dashboard/package-lock.json +++ b/dashboard/package-lock.json @@ -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", diff --git a/dashboard/src/lib/route-utils.ts b/dashboard/src/lib/route-utils.ts index a015b11..3c45b31 100644 --- a/dashboard/src/lib/route-utils.ts +++ b/dashboard/src/lib/route-utils.ts @@ -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 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 . 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)]; +} diff --git a/dashboard/src/lib/slugify.ts b/dashboard/src/lib/slugify.ts new file mode 100644 index 0000000..da4d558 --- /dev/null +++ b/dashboard/src/lib/slugify.ts @@ -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; +} diff --git a/dashboard/src/routes/apps/+page.svelte b/dashboard/src/routes/apps/+page.svelte index a553210..cbfd75a 100644 --- a/dashboard/src/routes/apps/+page.svelte +++ b/dashboard/src/routes/apps/+page.svelte @@ -1,6 +1,7 @@