feat(dashboard): four-tab detail UI with routing + sandbox config

Restructures the script detail page from one wall of cards into a
focused four-tab layout. Each tab does one thing well:

  * Edit       source editor + Test invoke panel (the existing
               quick-iteration loop, unchanged in shape)
  * Routing    routes table + add-route form with live kind
               auto-detection + match-preview tool
  * Settings   name / description / timeout, plus a collapsible
               "Advanced sandbox" disclosure with the six Rhai
               limits (admin-ceiling-validated server-side)
  * Executions the existing log viewer, gets the room it deserves

Why four tabs (not two columns, not one tab per surface, not URL
sub-routes):
  * Two columns crowded out the routing UX, which is genuinely
    list+form-shaped and wants vertical space.
  * URL sub-routes (/scripts/{id}/routing) would let users
    bookmark a tab but cost a bunch of router files for a
    feature nobody asked for; tab state in $state is one line
    and gets us 95% of the UX.
  * Sandbox config tucked inside Settings' "Advanced" disclosure
    matches the 95%-never-touch reality without hiding it.

Routing tab highlights:
  * Path input + path-kind selector with live auto-detect: type
    /greet/:name and the kind selector flips to "param"; the
    user can override and the backend trusts the override (lets
    /greet/:name be matched literally if exact is selected).
  * Soft warning when selection mismatches the input shape — not
    blocking, with the rationale in the message.
  * Host kind auto-detect mirrors the path side (*.example.com →
    wildcard; non-empty literal → strict; empty → any).
  * Method dropdown defaults to ANY.
  * 409 conflicts render inline with the conflicting route's
    method/host/path so the user can read both at a glance.
  * Match-preview panel: synthetic URL + method → "matches THIS
    script" or "matches a DIFFERENT script" or "no match", with
    extracted params shown. Powered by /api/v1/admin/routes:match.
  * Full URLs in the routes list use the operator's
    PICLOUD_PUBLIC_BASE_URL from /version.

Settings tab:
  * Name / description / timeout editable from the UI for the
    first time (previously only source was).
  * "Advanced sandbox" details element collapsed by default;
    six number inputs with placeholder "(default)" meaning
    "use platform default for this knob". Save sends a fresh
    sandbox object; ceiling errors surface as the manager's 422
    message inline.

API client (dashboard/src/lib/api.ts):
  * Adds `Route`, `RouteInput`, `CheckRouteResponse`,
    `MatchRouteResponse`, `ScriptSandbox`, `VersionInfo` types.
  * Adds `api.routes.{listForScript,create,remove,check,match}`.
  * Adds `api.version()`.
  * `UpdateScriptInput` gains `sandbox`.

route-utils.ts:
  * `guessPathKind`, `guessHostKind` heuristics.
  * `pathKindMismatchWarning` for the soft-warning UX.

Verified live:
  /admin/                       → 200, dashboard HTML
  /admin/scripts/<id>           → SPA detail with all four tabs
  POST /api/v1/admin/scripts/<id>/routes → 201, route persists
  GET /demo/widget              → 200 (script answers via routing
                                    table after the dashboard's
                                    POST refreshes it)
  /version                      → public_base_url, schema 3, sdk 1.1

No backend changes — Commit 3 is purely additive UI on top of the
infrastructure Commits 1 and 2 already shipped.

Bumps: product 0.4.0 → 0.5.0 (user-visible UX change). Schema, SDK,
API, wire unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-05-23 22:07:36 +02:00
parent 07e2a62d98
commit ed462726de
7 changed files with 892 additions and 189 deletions

16
Cargo.lock generated
View File

@@ -1273,7 +1273,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]] [[package]]
name = "picloud" name = "picloud"
version = "0.4.0" version = "0.5.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-trait", "async-trait",
@@ -1297,7 +1297,7 @@ dependencies = [
[[package]] [[package]]
name = "picloud-executor" name = "picloud-executor"
version = "0.4.0" version = "0.5.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"picloud-executor-core", "picloud-executor-core",
@@ -1309,7 +1309,7 @@ dependencies = [
[[package]] [[package]]
name = "picloud-executor-core" name = "picloud-executor-core"
version = "0.4.0" version = "0.5.0"
dependencies = [ dependencies = [
"chrono", "chrono",
"picloud-shared", "picloud-shared",
@@ -1323,7 +1323,7 @@ dependencies = [
[[package]] [[package]]
name = "picloud-manager" name = "picloud-manager"
version = "0.4.0" version = "0.5.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"picloud-manager-core", "picloud-manager-core",
@@ -1335,7 +1335,7 @@ dependencies = [
[[package]] [[package]]
name = "picloud-manager-core" name = "picloud-manager-core"
version = "0.4.0" version = "0.5.0"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"axum", "axum",
@@ -1353,7 +1353,7 @@ dependencies = [
[[package]] [[package]]
name = "picloud-orchestrator" name = "picloud-orchestrator"
version = "0.4.0" version = "0.5.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"picloud-orchestrator-core", "picloud-orchestrator-core",
@@ -1365,7 +1365,7 @@ dependencies = [
[[package]] [[package]]
name = "picloud-orchestrator-core" name = "picloud-orchestrator-core"
version = "0.4.0" version = "0.5.0"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"axum", "axum",
@@ -1384,7 +1384,7 @@ dependencies = [
[[package]] [[package]]
name = "picloud-shared" name = "picloud-shared"
version = "0.4.0" version = "0.5.0"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"chrono", "chrono",

View File

@@ -12,7 +12,7 @@ members = [
] ]
[workspace.package] [workspace.package]
version = "0.4.0" version = "0.5.0"
edition = "2021" edition = "2021"
rust-version = "1.92" rust-version = "1.92"
license = "MIT OR Apache-2.0" license = "MIT OR Apache-2.0"

View File

@@ -1,6 +1,6 @@
{ {
"name": "picloud-dashboard", "name": "picloud-dashboard",
"version": "0.4.0", "version": "0.5.0",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {

View File

@@ -5,6 +5,15 @@
// the same Caddy upstream so the "Test invoke" panel can hit it // the same Caddy upstream so the "Test invoke" panel can hit it
// without any cross-origin gymnastics. // without any cross-origin gymnastics.
export interface ScriptSandbox {
max_operations?: number;
max_string_size?: number;
max_array_size?: number;
max_map_size?: number;
max_call_levels?: number;
max_expr_depth?: number;
}
export interface Script { export interface Script {
id: string; id: string;
name: string; name: string;
@@ -13,10 +22,60 @@ export interface Script {
source: string; source: string;
timeout_seconds: number; timeout_seconds: number;
memory_limit_mb: number; memory_limit_mb: number;
sandbox: ScriptSandbox;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
} }
export type HostKind = 'any' | 'strict' | 'wildcard';
export type PathKind = 'exact' | 'prefix' | 'param';
export interface Route {
id: string;
script_id: string;
host_kind: HostKind;
host: string;
host_param_name: string | null;
path_kind: PathKind;
path: string;
method: string | null;
created_at: string;
}
export interface RouteInput {
host_kind: HostKind;
host?: string;
host_param_name?: string | null;
path_kind: PathKind;
path: string;
method?: string | null;
}
export interface CheckRouteResponse {
ok: boolean;
conflicting_route: Route | null;
conflict_reason: string | null;
}
export interface MatchRouteResponse {
matched: null | {
route_id: string;
script_id: string;
params: Record<string, string>;
rest: string | null;
host_param: [string, string] | null;
};
}
export interface VersionInfo {
product: string;
sdk: string;
api: number;
schema: number;
wire: number;
public_base_url: string;
}
export type ExecutionStatus = 'success' | 'error' | 'timeout' | 'budget_exceeded'; export type ExecutionStatus = 'success' | 'error' | 'timeout' | 'budget_exceeded';
export interface ScriptLogEntry { export interface ScriptLogEntry {
@@ -55,6 +114,7 @@ export interface UpdateScriptInput {
source?: string; source?: string;
timeout_seconds?: number; timeout_seconds?: number;
memory_limit_mb?: number; memory_limit_mb?: number;
sandbox?: ScriptSandbox;
} }
export interface ExecutionResult { export interface ExecutionResult {
@@ -101,6 +161,30 @@ function safeJson(text: string): unknown {
export const api = { export const api = {
health: () => fetch('/healthz').then((r) => r.text()), health: () => fetch('/healthz').then((r) => r.text()),
version: () => adminRequest<VersionInfo>('/version'),
routes: {
listForScript: (scriptId: string) =>
adminRequest<Route[]>(`/api/v1/admin/scripts/${scriptId}/routes`),
create: (scriptId: string, input: RouteInput) =>
adminRequest<Route>(`/api/v1/admin/scripts/${scriptId}/routes`, {
method: 'POST',
body: JSON.stringify(input)
}),
remove: (routeId: string) =>
adminRequest<null>(`/api/v1/admin/routes/${routeId}`, { method: 'DELETE' }),
check: (input: RouteInput) =>
adminRequest<CheckRouteResponse>('/api/v1/admin/routes:check', {
method: 'POST',
body: JSON.stringify(input)
}),
match: (url: string, method = 'GET') =>
adminRequest<MatchRouteResponse>('/api/v1/admin/routes:match', {
method: 'POST',
body: JSON.stringify({ url, method })
})
},
scripts: { scripts: {
list: () => adminRequest<Script[]>('/api/v1/admin/scripts'), list: () => adminRequest<Script[]>('/api/v1/admin/scripts'),
get: (id: string) => adminRequest<Script>(`/api/v1/admin/scripts/${id}`), get: (id: string) => adminRequest<Script>(`/api/v1/admin/scripts/${id}`),

View File

@@ -0,0 +1,32 @@
import type { 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
* selection — selecting `exact` for `/greet/:name` matches it literally). */
export function guessPathKind(raw: string): PathKind {
if (raw.endsWith('/*')) return 'prefix';
if (/(^|\/):[a-zA-Z_]/.test(raw)) return 'param';
return 'exact';
}
/** Guess a host kind. Empty → any, leading "*." → wildcard, else strict. */
export function guessHostKind(raw: string): HostKind {
if (!raw.trim()) return 'any';
if (raw.startsWith('*.')) return 'wildcard';
return 'strict';
}
/** Warn-not-block: does the user's selection match what we'd guess
* from the input? Used by the routing form for the soft mismatch
* hint. Returns null when no warning is needed. */
export function pathKindMismatchWarning(raw: string, kind: PathKind): string | null {
const guessed = guessPathKind(raw);
if (guessed === kind) return null;
const hint =
guessed === 'param'
? 'this looks like a param pattern (contains `:name`)'
: guessed === 'prefix'
? 'this looks like a prefix pattern (ends with `/*`)'
: 'this looks like a literal path';
return `Selected kind is "${kind}", but ${hint}. Routing will use the selected kind.`;
}

File diff suppressed because it is too large Load Diff

View File

@@ -124,7 +124,7 @@ A surface can hit its own `1.0` independently of the product. The SDK in particu
| | Version | | | Version |
|---|---| |---|---|
| Product | `0.4.0` | | Product | `0.5.0` |
| SDK | `1.1` (adds `ctx.request.params`, `ctx.request.query`, `ctx.request.rest`) | | SDK | `1.1` (adds `ctx.request.params`, `ctx.request.query`, `ctx.request.rest`) |
| API | `1` | | API | `1` |
| Schema | `3` (matches `migrations/0003_routes.sql`) | | Schema | `3` (matches `migrations/0003_routes.sql`) |