diff --git a/Cargo.lock b/Cargo.lock index b452523..c217c1b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1273,7 +1273,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "picloud" -version = "0.4.0" +version = "0.5.0" dependencies = [ "anyhow", "async-trait", @@ -1297,7 +1297,7 @@ dependencies = [ [[package]] name = "picloud-executor" -version = "0.4.0" +version = "0.5.0" dependencies = [ "anyhow", "picloud-executor-core", @@ -1309,7 +1309,7 @@ dependencies = [ [[package]] name = "picloud-executor-core" -version = "0.4.0" +version = "0.5.0" dependencies = [ "chrono", "picloud-shared", @@ -1323,7 +1323,7 @@ dependencies = [ [[package]] name = "picloud-manager" -version = "0.4.0" +version = "0.5.0" dependencies = [ "anyhow", "picloud-manager-core", @@ -1335,7 +1335,7 @@ dependencies = [ [[package]] name = "picloud-manager-core" -version = "0.4.0" +version = "0.5.0" dependencies = [ "async-trait", "axum", @@ -1353,7 +1353,7 @@ dependencies = [ [[package]] name = "picloud-orchestrator" -version = "0.4.0" +version = "0.5.0" dependencies = [ "anyhow", "picloud-orchestrator-core", @@ -1365,7 +1365,7 @@ dependencies = [ [[package]] name = "picloud-orchestrator-core" -version = "0.4.0" +version = "0.5.0" dependencies = [ "async-trait", "axum", @@ -1384,7 +1384,7 @@ dependencies = [ [[package]] name = "picloud-shared" -version = "0.4.0" +version = "0.5.0" dependencies = [ "async-trait", "chrono", diff --git a/Cargo.toml b/Cargo.toml index 717c1fa..f160d01 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ members = [ ] [workspace.package] -version = "0.4.0" +version = "0.5.0" edition = "2021" rust-version = "1.92" license = "MIT OR Apache-2.0" diff --git a/dashboard/package.json b/dashboard/package.json index 24af623..c5a86a4 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -1,6 +1,6 @@ { "name": "picloud-dashboard", - "version": "0.4.0", + "version": "0.5.0", "private": true, "type": "module", "scripts": { diff --git a/dashboard/src/lib/api.ts b/dashboard/src/lib/api.ts index fcb7f0c..41286b5 100644 --- a/dashboard/src/lib/api.ts +++ b/dashboard/src/lib/api.ts @@ -5,6 +5,15 @@ // the same Caddy upstream so the "Test invoke" panel can hit it // 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 { id: string; name: string; @@ -13,10 +22,60 @@ export interface Script { source: string; timeout_seconds: number; memory_limit_mb: number; + sandbox: ScriptSandbox; created_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; + 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 interface ScriptLogEntry { @@ -55,6 +114,7 @@ export interface UpdateScriptInput { source?: string; timeout_seconds?: number; memory_limit_mb?: number; + sandbox?: ScriptSandbox; } export interface ExecutionResult { @@ -101,6 +161,30 @@ function safeJson(text: string): unknown { export const api = { health: () => fetch('/healthz').then((r) => r.text()), + version: () => adminRequest('/version'), + + routes: { + listForScript: (scriptId: string) => + adminRequest(`/api/v1/admin/scripts/${scriptId}/routes`), + create: (scriptId: string, input: RouteInput) => + adminRequest(`/api/v1/admin/scripts/${scriptId}/routes`, { + method: 'POST', + body: JSON.stringify(input) + }), + remove: (routeId: string) => + adminRequest(`/api/v1/admin/routes/${routeId}`, { method: 'DELETE' }), + check: (input: RouteInput) => + adminRequest('/api/v1/admin/routes:check', { + method: 'POST', + body: JSON.stringify(input) + }), + match: (url: string, method = 'GET') => + adminRequest('/api/v1/admin/routes:match', { + method: 'POST', + body: JSON.stringify({ url, method }) + }) + }, + scripts: { list: () => adminRequest('/api/v1/admin/scripts'), get: (id: string) => adminRequest @@ -152,118 +333,341 @@ -
- -
-

Source

- - {#if saveError} -
{saveError}
+
- + + + + - -
-

Test invoke

- - -
- -
- {#if testError} -
{testError}
- {/if} - {#if testResult} -
-
- HTTP {testResult.status} -
-
{JSON.stringify(testResult.body, null, 2)}
+ + {#if tab === 'edit'} +
+
+

Source

+ + {#if saveSourceError} +
{saveSourceError}
+ {/if} +
+
+
+ +
+

Test invoke

+ + +
+ +
+ {#if testError} +
{testError}
+ {/if} + {#if testResult} +
+
HTTP {testResult.status}
+
{JSON.stringify(testResult.body, null, 2)}
+
+ {/if} +

+ Test invoke uses the internal /api/v1/execute/{`{id}`} bypass — your custom routes + aren't checked. To test routes, use the Match preview on the Routing tab or curl the + URL directly. +

+
+
+ + + {:else if tab === 'routing'} +
+
+

Routes

+ +
+ + {#if showAddRoute} +
+ +
+ + +
+
+ + +
+ {#if pathKindWarning} +
{pathKindWarning}
+ {/if} + {#if createRouteError} +
{createRouteError}
+ {/if} +
+ +
+
+ {/if} + + {#if routesLoading} +

Loading routes…

+ {:else if routesError} +
{routesError}
+ {:else if routes.length === 0} +

+ No routes yet. Scripts are still callable via + POST /api/v1/execute/{`{id}`} while you set things up. +

+ {:else} +
    + {#each routes as r (r.id)} +
  • +
    + {r.path_kind} + {r.method ?? 'ANY'} + + {r.host_kind === 'any' + ? '*' + : r.host_kind === 'wildcard' + ? `*.${r.host}` + : r.host} + + {r.path} + +
    + {#if info} +
    → {fullUrlForRoute(r)}
    + {/if} +
  • + {/each} +
{/if}
-
- -
-
-

Recent executions

- -
- {#if logsError} -
{logsError}
- {:else if logs && logs.length === 0} -

No executions yet — try the Test invoke panel above.

- {:else if logs} -
    - {#each logs as log (log.id)} -
  • -
    - - - {log.status} - - {new Date(log.created_at).toLocaleString()} - - {log.response_code ?? '—'} · {log.duration_ms}ms - - -
    -
    - Request body -
    {JSON.stringify(log.request_body, null, 2)}
    -
    -
    - Response body -
    {JSON.stringify(log.response_body, null, 2)}
    -
    - {#if log.script_logs && log.script_logs.length > 0} +
    +

    Match preview

    +

    + Synthetic check: does this URL+method match a route, and which? Useful for + verifying your routing before exposing scripts publicly. +

    +
    + + +
    +
    + +
    + {#if previewResult} +
    {previewResult}
    + {/if} +
    + + + {:else if tab === 'settings'} +
    +

    General

    + + + + +
    + Advanced sandbox +

    + Each value is admin-ceiling-capped at write time. Leave empty to use the + platform default for that knob. +

    +
    + {#each ['max_operations', 'max_string_size', 'max_array_size', 'max_map_size', 'max_call_levels', 'max_expr_depth'] as key (key)} + + {/each} +
    +
    + + {#if saveSettingsError} +
    {saveSettingsError}
    + {/if} +
    + +
    +
    + + + {:else if tab === 'executions'} +
    +
    +

    Recent executions

    + +
    + {#if logsError} +
    {logsError}
    + {:else if logs && logs.length === 0} +

    + No executions yet — try the Test invoke panel on the Edit tab, or curl + one of your routes. +

    + {:else if logs} +
      + {#each logs as log (log.id)} +
    • +
      + + + {log.status} + + {new Date(log.created_at).toLocaleString()} + + {log.response_code ?? '—'} · {log.duration_ms}ms + + {log.request_path} + +
      - Script logs -
        - {#each log.script_logs as entry} -
      • - - {entry.level} - - {entry.message} - {#if entry.data !== null && entry.data !== undefined} -
        {JSON.stringify(entry.data)}
        - {/if} -
      • - {/each} -
      + Request body +
      {JSON.stringify(log.request_body, null, 2)}
      - {/if} -
      -
      -
    • - {/each} -
    - {/if} -
    +
    + Response body +
    {JSON.stringify(log.response_body, null, 2)}
    +
    + {#if log.script_logs && log.script_logs.length > 0} +
    + Script logs +
      + {#each log.script_logs as entry} +
    • + + {entry.level} + + {entry.message} + {#if entry.data !== null && entry.data !== undefined} +
      {JSON.stringify(entry.data)}
      + {/if} +
    • + {/each} +
    +
    + {/if} +
    +
    +
  • + {/each} +
+ {/if} +
+ {/if} {/if}
@@ -283,7 +687,6 @@ align-items: flex-start; margin: 1rem 0 1.5rem; } - h1 { margin: 0; font-size: 1.5rem; @@ -298,7 +701,6 @@ p.muted { margin: 0.25rem 0 0; } - .muted { color: #64748b; } @@ -325,14 +727,53 @@ color: #94a3b8; border: 1px solid #334155; } + button.link { + background: transparent; + color: #94a3b8; + padding: 0.25rem 0.5rem; + font-weight: 500; + text-decoration: underline; + } + button.link.danger { + color: #fca5a5; + } + + .tabs { + display: flex; + gap: 0.5rem; + border-bottom: 1px solid #1e293b; + margin-bottom: 1rem; + } + .tabs button { + background: transparent; + color: #94a3b8; + padding: 0.75rem 1rem; + border-radius: 0; + font-weight: 500; + border-bottom: 2px solid transparent; + margin-bottom: -1px; + } + .tabs button:hover { + color: #e2e8f0; + } + .tabs button.active { + color: #38bdf8; + border-bottom-color: #38bdf8; + } + .badge-count { + background: #334155; + color: #cbd5e1; + font-size: 0.7rem; + padding: 0.05rem 0.4rem; + border-radius: 999px; + margin-left: 0.4rem; + } .grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; - margin-bottom: 1.5rem; } - @media (max-width: 720px) { .grid { grid-template-columns: 1fr; @@ -346,22 +787,40 @@ display: flex; flex-direction: column; gap: 0.75rem; + margin-bottom: 1rem; + } + .card.wide { + grid-column: 1 / -1; + } + .card-header { + display: flex; + justify-content: space-between; + align-items: center; } - textarea { + textarea, + input, + select { background: #0b1220; color: #e2e8f0; border: 1px solid #334155; border-radius: 0.375rem; padding: 0.5rem 0.75rem; + font-family: ui-sans-serif, system-ui, sans-serif; + font-size: 0.9rem; + width: 100%; + box-sizing: border-box; + } + textarea { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace; font-size: 0.85rem; resize: vertical; - width: 100%; - box-sizing: border-box; } - + input:disabled, + select:disabled { + opacity: 0.5; + } label { display: flex; flex-direction: column; @@ -369,20 +828,44 @@ font-size: 0.85rem; color: #cbd5e1; } + label.disabled { + opacity: 0.6; + } + label.full { + width: 100%; + } + label.grow { + flex: 1; + } + + .row { + display: grid; + grid-template-columns: 1fr 2fr; + gap: 0.75rem; + } .actions { display: flex; justify-content: flex-end; } + .error, + .warning { + border-radius: 0.375rem; + padding: 0.75rem; + font-size: 0.875rem; + } .error { border: 1px solid #b91c1c; background: #450a0a; color: #fecaca; - padding: 0.75rem; - border-radius: 0.375rem; } - .error.inline { + .warning { + border: 1px solid #ca8a04; + background: #422006; + color: #fde68a; + } + .inline { font-size: 0.875rem; } @@ -396,12 +879,115 @@ font-weight: 600; margin-bottom: 0.5rem; } - .result pre { + .result pre, + .preview { margin: 0; font-size: 0.85rem; white-space: pre-wrap; word-break: break-word; } + .preview { + background: #0b1220; + border: 1px solid #334155; + border-radius: 0.375rem; + padding: 0.75rem; + font-family: + ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace; + } + .hint { + font-size: 0.8rem; + } + + .route-form { + background: #0f172a; + border: 1px solid #334155; + border-radius: 0.5rem; + padding: 1rem; + display: flex; + flex-direction: column; + gap: 0.5rem; + } + + .route-list { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: 0.5rem; + } + .route-row { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.6rem 0.75rem; + background: #0f172a; + border: 1px solid #334155; + border-radius: 0.375rem; + font-family: + ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace; + font-size: 0.85rem; + } + .route-row .kind { + font-size: 0.7rem; + font-weight: 700; + text-transform: uppercase; + padding: 0.15rem 0.45rem; + border-radius: 0.25rem; + } + .kind-exact { + background: #14532d; + color: #bbf7d0; + } + .kind-prefix { + background: #1e3a8a; + color: #bfdbfe; + } + .kind-param { + background: #581c87; + color: #e9d5ff; + } + .route-row .method { + color: #fbbf24; + font-weight: 700; + min-width: 3rem; + } + .route-row .host { + color: #94a3b8; + } + .route-row .path { + color: #e2e8f0; + flex: 1; + } + .route-url { + font-family: + ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace; + font-size: 0.75rem; + padding: 0 0.75rem 0.4rem; + } + + .advanced { + background: #0f172a; + border: 1px solid #334155; + border-radius: 0.5rem; + padding: 0.75rem 1rem; + } + .advanced summary { + cursor: pointer; + font-weight: 600; + color: #cbd5e1; + } + .sandbox-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 0.75rem; + margin-top: 0.75rem; + } + @media (max-width: 720px) { + .sandbox-grid { + grid-template-columns: 1fr; + } + } .logs-header { display: flex; @@ -409,7 +995,6 @@ align-items: center; margin-bottom: 0.75rem; } - .exec-list { list-style: none; padding: 0; @@ -418,7 +1003,6 @@ flex-direction: column; gap: 0.5rem; } - .exec-list summary { display: flex; gap: 0.75rem; @@ -428,11 +1012,16 @@ border-radius: 0.375rem; cursor: pointer; list-style: none; + font-size: 0.875rem; } .exec-list summary::-webkit-details-marker { display: none; } - + .path-snippet { + font-family: + ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace; + font-size: 0.8rem; + } .badge { font-size: 0.7rem; padding: 0.125rem 0.5rem; @@ -446,7 +1035,6 @@ font-size: 0.875rem; color: #cbd5e1; } - .exec-body { padding: 0.75rem 1rem; background: #0b1220; @@ -465,7 +1053,6 @@ white-space: pre-wrap; word-break: break-word; } - .log-entries { list-style: none; padding: 0; diff --git a/docs/versioning.md b/docs/versioning.md index d25ba7d..72b3c4d 100644 --- a/docs/versioning.md +++ b/docs/versioning.md @@ -124,7 +124,7 @@ A surface can hit its own `1.0` independently of the product. The SDK in particu | | Version | |---|---| -| Product | `0.4.0` | +| Product | `0.5.0` | | SDK | `1.1` (adds `ctx.request.params`, `ctx.request.query`, `ctx.request.rest`) | | API | `1` | | Schema | `3` (matches `migrations/0003_routes.sql`) |