From c03c0c83c51d3cf3b97a84a771fb3972cc969bbd Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Sat, 11 Jul 2026 23:48:09 +0200 Subject: [PATCH] feat(dashboard): group data-plane read surfaces + CodeEditor readOnly fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remediate the dashboard-coverage and editor findings from the 2026-07-11 audit. Close the group-surface gap: a GroupTabBar plus read-only surfaces for the v1.2 group data plane the dashboard previously exposed only via the CLI — vars, secrets (names + timestamps only, never values), shared collections (KV/docs/ files browsers), scripts, triggers, routes, suppressions, extension points, dead-letters, and ownership. All server data renders through Svelte's escaped interpolation (no `{@html}`); each tab hits its existing `/api/v1/admin/groups/{id}/...` read endpoint with independent loading/empty state. B7 — the CodeEditor `readOnly` prop is now reactive via a CodeMirror Compartment, fixing the race where an authorized user opening a script on a warm SPA nav got a permanently read-only editor (role resolved after mount). The reconfigure preserves editor content. A viewer lacking a read cap now sees a calm "you don't have permission" note on every tab (structured LoadError + classifyError) instead of a red error panel, generalizing the one-off Secrets 403 handling. Co-Authored-By: Claude Opus 4.8 (1M context) --- dashboard/src/lib/CodeEditor.svelte | 34 +- dashboard/src/lib/GroupTabBar.svelte | 88 ++ dashboard/src/lib/api.ts | 212 ++++ .../src/routes/groups/[slug]/+page.svelte | 1031 ++++++++++++++++- 4 files changed, 1321 insertions(+), 44 deletions(-) create mode 100644 dashboard/src/lib/GroupTabBar.svelte diff --git a/dashboard/src/lib/CodeEditor.svelte b/dashboard/src/lib/CodeEditor.svelte index fe2de69..09d41a3 100644 --- a/dashboard/src/lib/CodeEditor.svelte +++ b/dashboard/src/lib/CodeEditor.svelte @@ -13,7 +13,7 @@ import { onMount, onDestroy } from 'svelte'; import { basicSetup } from 'codemirror'; import { EditorView, keymap, placeholder as cmPlaceholder } from '@codemirror/view'; - import { EditorState } from '@codemirror/state'; + import { EditorState, Compartment } from '@codemirror/state'; import { indentWithTab } from '@codemirror/commands'; import { json as jsonLang } from '@codemirror/lang-json'; import { rhai as rhaiLang } from './rhai-mode'; @@ -35,12 +35,23 @@ /** When true the editor renders without a cursor and rejects * keystrokes. Parent-driven `value` changes still apply via * the dispatch path below — this only blocks user edits. - * Not reactive after mount; re-mount via `{#key}` if needed. */ + * Reactive after mount: held in a compartment reconfigured by the + * `$effect` below, so a role that resolves after mount (warm SPA + * nav) flips the editor writable without a remount. */ readOnly?: boolean; } = $props(); let host: HTMLDivElement | null = null; let view: EditorView | null = null; + // Holds the readOnly/editable pair so it can be reconfigured live when the + // `readOnly` prop changes (see the $effect below). + const readOnlyCompartment = new Compartment(); + + function readOnlyExtensions(ro: boolean) { + // readOnly blocks the underlying transactions; editable suppresses the + // caret + selection visuals so the user can see it's not editable. + return [EditorState.readOnly.of(ro), EditorView.editable.of(!ro)]; + } // Guard against the update-listener firing while we're pushing an // external value into the editor — without this, parent-driven // `value` changes would echo back through the listener and create a @@ -54,12 +65,8 @@ keymap.of([indentWithTab]), dashboardSyntaxHighlighting, dashboardTheme, - // readOnly + editable together: readOnly blocks the - // underlying transactions, editable suppresses the caret - // + selection visuals so the user can see it's not - // editable. - EditorState.readOnly.of(readOnly), - EditorView.editable.of(!readOnly), + // Reconfigurable readOnly/editable — see the $effect below. + readOnlyCompartment.of(readOnlyExtensions(readOnly)), EditorView.updateListener.of((update) => { if (update.docChanged && !pushingFromOutside) { value = update.state.doc.toString(); @@ -86,6 +93,17 @@ view = null; }); + // Keep the editor's read-only state in sync with the `readOnly` prop after + // mount. On a warm SPA navigation the parent's role/`canWrite` can resolve + // AFTER this component mounts; without this the editor would stay in its + // initial (often read-only) state even though Save is enabled. + $effect(() => { + if (!view) return; + view.dispatch({ + effects: readOnlyCompartment.reconfigure(readOnlyExtensions(readOnly)) + }); + }); + // Push parent-driven `value` updates back into the editor (e.g. // when the script is reloaded after Save, or "Format JSON" rewrites // the body). We only dispatch when the document genuinely differs diff --git a/dashboard/src/lib/GroupTabBar.svelte b/dashboard/src/lib/GroupTabBar.svelte new file mode 100644 index 0000000..7a715cf --- /dev/null +++ b/dashboard/src/lib/GroupTabBar.svelte @@ -0,0 +1,88 @@ + + + + + diff --git a/dashboard/src/lib/api.ts b/dashboard/src/lib/api.ts index 78a50a1..c2cb631 100644 --- a/dashboard/src/lib/api.ts +++ b/dashboard/src/lib/api.ts @@ -491,6 +491,105 @@ export interface SecretListItem { updated_at: string; } +// --------------------------------------------------------------------------- +// v1.2 group read-only inspection surfaces. Every shape below mirrors a Rust +// handler in crates/manager-core/src (vars_api, secrets_api, apply_api, +// group_blobs_api, group_scripts_api, projects_api). All READ-ONLY — group +// shared-collection writes + template authoring happen via the `pic` CLI. +// --------------------------------------------------------------------------- + +/// One group var row (vars_api::VarItem). `env` is `*` (env-agnostic) or a +/// concrete env name; a tombstone suppresses an inherited key. +export interface GroupVarItem { + key: string; + env: string; + value: unknown; + is_tombstone: boolean; + updated_at: string; +} + +/// One group secret row (secrets_api::SecretItem). NAMES + env + updated_at +/// only — values never leave the server. +export interface GroupSecretItem { + name: string; + env: string; + updated_at: string; +} + +/// §11.6 shared-collection kinds. kv/docs/files have a data browser; topic and +/// queue are storeless / consumed differently (no per-group data endpoint). +export type GroupCollectionKind = 'kv' | 'docs' | 'files' | 'topic' | 'queue'; + +/// One declared shared collection (apply_service::CollectionInfo). +export interface GroupCollection { + name: string; + kind: GroupCollectionKind; +} + +/// One group trigger-template row (apply_service::TriggerTemplateInfo). +export interface GroupTriggerTemplate { + kind: string; + target: string; + script: string; + enabled: boolean; + sealed: boolean; + shared: boolean; +} + +/// One group route-template row (apply_service::RouteTemplateInfo). +export interface GroupRouteTemplate { + method: string; + host: string; + path_kind: string; + path: string; + script: string; + dispatch: string; + enabled: boolean; + sealed: boolean; +} + +/// One group/app suppression row (apply_service::SuppressionInfo). +export interface SuppressionInfo { + target_kind: string; + reference: string; +} + +/// One extension-point row (apply_service::ExtensionPointInfo). +export interface ExtensionPointInfo { + name: string; + declared_here: boolean; + provider: string | null; +} + +/// One shared-collection doc entry (group_blobs_api::DocEntry). +export interface GroupDocEntry { + id: string; + data: unknown; +} + +/// One group shared-queue dead-letter (group_blobs_api::DeadLetterDto). Note: +/// this shape is distinct from the per-app `DeadLetterRow` — it carries a +/// `collection` and no trigger/script fields. +export interface GroupDeadLetter { + id: string; + collection: string; + source: string; + op: string; + attempt_count: number; + last_error: string; + created_at: string; + resolved_at: string | null; + payload: unknown; +} + +/// One project row (projects_api::ProjectListItem). +export interface ProjectListItem { + slug: string; + name: string; + owned_groups: number; + created_at: string; +} + export interface ExecutionResult { status: number; headers: Record; @@ -864,9 +963,122 @@ export const api = { `/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}/members/${userId}`, { method: 'DELETE' } ) + }, + + // --- v1.2 read-only inspection surfaces (all GET) -------------- + // The group's OWN vars (not the inherited/resolved view). + vars: (idOrSlug: string) => + adminRequest<{ vars: GroupVarItem[] }>( + `/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}/vars` + ), + // Secret NAMES + env + updated_at only — never values. Requires + // GroupSecretsWrite; a 403 is surfaced as a permission note, not a crash. + secrets: (idOrSlug: string) => + adminRequest<{ secrets: GroupSecretItem[]; next_cursor: string | null }>( + `/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}/secrets` + ), + // Declared shared collections (kv/docs/files/topic/queue). + collections: (idOrSlug: string) => + adminRequest( + `/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}/collections` + ), + scripts: (idOrSlug: string) => + adminRequest( + `/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}/scripts` + ), + triggers: (idOrSlug: string) => + adminRequest( + `/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}/triggers` + ), + routes: (idOrSlug: string) => + adminRequest( + `/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}/routes` + ), + suppressions: (idOrSlug: string) => + adminRequest( + `/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}/suppressions` + ), + extensionPoints: (idOrSlug: string) => + adminRequest( + `/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}/extension-points` + ), + // Shared-KV browser: list keys, then get a single value. + groupKv: { + list: ( + idOrSlug: string, + collection: string, + opts: { cursor?: string; limit?: number } = {} + ) => { + const params = new URLSearchParams(); + params.set('collection', collection); + if (opts.cursor) params.set('cursor', opts.cursor); + if (opts.limit !== undefined) params.set('limit', String(opts.limit)); + return adminRequest<{ keys: string[]; next_cursor: string | null }>( + `/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}/kv?${params.toString()}` + ); + }, + get: (idOrSlug: string, collection: string, key: string) => + adminRequest<{ value: unknown }>( + `/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}/kv/${encodeURIComponent(collection)}/${encodeURIComponent(key)}` + ) + }, + // Shared-docs browser: list documents (id + data), then get one. + groupDocs: { + list: ( + idOrSlug: string, + collection: string, + opts: { cursor?: string; limit?: number } = {} + ) => { + const params = new URLSearchParams(); + params.set('collection', collection); + if (opts.cursor) params.set('cursor', opts.cursor); + if (opts.limit !== undefined) params.set('limit', String(opts.limit)); + return adminRequest<{ docs: GroupDocEntry[]; next_cursor: string | null }>( + `/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}/docs?${params.toString()}` + ); + }, + get: (idOrSlug: string, collection: string, docId: string) => + adminRequest<{ id: string; data: unknown }>( + `/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}/docs/${encodeURIComponent(collection)}/${docId}` + ) + }, + // Shared-files browser: list metadata + download URL (GET streams bytes). + groupFiles: { + list: ( + idOrSlug: string, + collection: string, + opts: { cursor?: string; limit?: number } = {} + ) => { + const params = new URLSearchParams(); + params.set('collection', collection); + if (opts.cursor) params.set('cursor', opts.cursor); + if (opts.limit !== undefined) params.set('limit', String(opts.limit)); + return adminRequest<{ files: FileMeta[]; next_cursor: string | null }>( + `/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}/files?${params.toString()}` + ); + }, + downloadUrl: (idOrSlug: string, collection: string, fileId: string): string => + `/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}/files/${encodeURIComponent(collection)}/${fileId}` + }, + // §11.6 D3 shared-queue dead-letters (newest-first). + deadLetters: ( + idOrSlug: string, + opts: { unresolved?: boolean; limit?: number } = {} + ) => { + const params = new URLSearchParams(); + if (opts.unresolved) params.set('unresolved', 'true'); + if (opts.limit !== undefined) params.set('limit', String(opts.limit)); + const qs = params.toString(); + return adminRequest( + `/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}/dead-letters${qs ? `?${qs}` : ''}` + ); } }, + projects: { + list: () => adminRequest('/api/v1/admin/projects') + }, + domains: { listForApp: (idOrSlug: string) => adminRequest( diff --git a/dashboard/src/routes/groups/[slug]/+page.svelte b/dashboard/src/routes/groups/[slug]/+page.svelte index 5269da7..19b30d7 100644 --- a/dashboard/src/routes/groups/[slug]/+page.svelte +++ b/dashboard/src/routes/groups/[slug]/+page.svelte @@ -9,11 +9,25 @@ type Group, type GroupDetail, type GroupMember, - type AppRole + type AppRole, + type Script, + type GroupVarItem, + type GroupSecretItem, + type GroupCollection, + type GroupCollectionKind, + type GroupTriggerTemplate, + type GroupRouteTemplate, + type SuppressionInfo, + type ExtensionPointInfo, + type GroupDeadLetter, + type ProjectListItem, + type FileMeta, + type GroupDocEntry } from '$lib/api'; import ConfirmModal from '$lib/ConfirmModal.svelte'; import ActionMenu from '$lib/ActionMenu.svelte'; import RoleChip from '$lib/RoleChip.svelte'; + import GroupTabBar from '$lib/GroupTabBar.svelte'; import { currentUser } from '$lib/auth'; import { canCreateApp } from '$lib/capabilities'; @@ -22,9 +36,36 @@ // members, structure, and deletion. The backend is the ground truth. const canManage = $derived(canCreateApp(me)); - type Tab = 'overview' | 'members' | 'settings'; - const isValidTab = (s: string | null): s is Tab => - s === 'overview' || s === 'members' || s === 'settings'; + type Tab = + | 'overview' + | 'members' + | 'vars' + | 'secrets' + | 'collections' + | 'scripts' + | 'triggers' + | 'routes' + | 'suppressions' + | 'extension-points' + | 'dead-letters' + | 'ownership' + | 'settings'; + const validTabs: Tab[] = [ + 'overview', + 'members', + 'vars', + 'secrets', + 'collections', + 'scripts', + 'triggers', + 'routes', + 'suppressions', + 'extension-points', + 'dead-letters', + 'ownership', + 'settings' + ]; + const isValidTab = (s: string | null): s is Tab => validTabs.includes(s as Tab); let activeTab = $derived.by(() => { const qp = page.url.searchParams.get('tab'); return isValidTab(qp) ? qp : 'overview'; @@ -132,6 +173,286 @@ // with a 409, surfaced below). const reparentOptions = $derived(allGroups.filter((g) => g.id !== group?.id)); + // ----------------------------------------------------------------------- + // Read-only inspection tabs. Each keeps its own state + loaded flag so it + // fetches lazily the first time the tab activates (mirrors the app page). + // All are READ-ONLY — shared writes + template authoring happen via `pic`. + // ----------------------------------------------------------------------- + + function errText(e: unknown): string { + return e instanceof ApiError ? e.message : e instanceof Error ? e.message : String(e); + } + + // Structured load error for the read-only tabs: a plain group-viewer's 403 + // on a capability-gated read renders as a calm muted note, not a red panel. + type LoadError = { forbidden: true } | { message: string } | null; + function classifyError(e: unknown): LoadError { + if (e instanceof ApiError && e.status === 403) return { forbidden: true }; + return { message: errText(e) }; + } + + // Vars. + let varsLoaded = $state(false); + let vars = $state([]); + let varsError = $state(null); + async function loadVars() { + if (!group) return; + varsError = null; + try { + const r = await api.groups.vars(group.id); + vars = r.vars; + varsLoaded = true; + } catch (e) { + varsError = classifyError(e); + } + } + + // Secrets — NAMES + env + updated_at only, never values. The list requires + // GroupSecretsWrite, so a plain viewer gets a 403 shown as a muted note. + let secretsLoaded = $state(false); + let secrets = $state([]); + let secretsError = $state(null); + async function loadSecrets() { + if (!group) return; + secretsError = null; + try { + const r = await api.groups.secrets(group.id); + secrets = r.secrets; + secretsLoaded = true; + } catch (e) { + secretsError = classifyError(e); + } + } + + // Collections + KV/Docs/Files browsers. + let collectionsLoaded = $state(false); + let collections = $state([]); + let collectionsError = $state(null); + async function loadCollections() { + if (!group) return; + collectionsError = null; + try { + collections = await api.groups.collections(group.id); + collectionsLoaded = true; + } catch (e) { + collectionsError = classifyError(e); + } + } + + // Shared-collection data browser (one at a time). `browseKind` selects the + // endpoint family; only kv/docs/files have a browser. + let browseKind = $state('kv'); + let browseCollection = $state(''); + let browseActive = $state(''); + let browseActiveKind = $state('kv'); + let browseKeys = $state([]); + let browseDocs = $state([]); + let browseFiles = $state([]); + let browseCursor = $state(null); + let browseLoading = $state(false); + let browseError = $state(null); + // A single fetched KV value / doc, shown inline on demand. + let kvValue = $state<{ key: string; value: unknown } | null>(null); + + async function browse(cursor?: string) { + if (!group) return; + const c = browseCollection.trim(); + if (!c) { + browseError = { message: 'Pick or enter a collection name to browse.' }; + return; + } + browseLoading = true; + browseError = null; + try { + if (browseKind === 'kv') { + const r = await api.groups.groupKv.list(group.id, c, { cursor, limit: 100 }); + browseKeys = cursor ? [...browseKeys, ...r.keys] : r.keys; + browseCursor = r.next_cursor; + } else if (browseKind === 'docs') { + const r = await api.groups.groupDocs.list(group.id, c, { cursor, limit: 100 }); + browseDocs = cursor ? [...browseDocs, ...r.docs] : r.docs; + browseCursor = r.next_cursor; + } else { + const r = await api.groups.groupFiles.list(group.id, c, { cursor, limit: 100 }); + browseFiles = cursor ? [...browseFiles, ...r.files] : r.files; + browseCursor = r.next_cursor; + } + if (!cursor) { + browseActive = c; + browseActiveKind = browseKind; + kvValue = null; + } + } catch (e) { + browseError = classifyError(e); + } finally { + browseLoading = false; + } + } + + async function loadKvValue(key: string) { + if (!group) return; + try { + const r = await api.groups.groupKv.get(group.id, browseActive, key); + kvValue = { key, value: r.value }; + } catch (e) { + browseError = classifyError(e); + } + } + + function pickCollection(c: GroupCollection) { + if (c.kind === 'topic' || c.kind === 'queue') return; + browseKind = c.kind; + browseCollection = c.name; + void browse(); + } + + // Scripts. + let scriptsLoaded = $state(false); + let scripts = $state([]); + let scriptsError = $state(null); + async function loadScripts() { + if (!group) return; + scriptsError = null; + try { + scripts = await api.groups.scripts(group.id); + scriptsLoaded = true; + } catch (e) { + scriptsError = classifyError(e); + } + } + + // Trigger templates. + let triggersLoaded = $state(false); + let triggers = $state([]); + let triggersError = $state(null); + async function loadTriggers() { + if (!group) return; + triggersError = null; + try { + triggers = await api.groups.triggers(group.id); + triggersLoaded = true; + } catch (e) { + triggersError = classifyError(e); + } + } + + // Route templates. + let routesLoaded = $state(false); + let routes = $state([]); + let routesError = $state(null); + async function loadRoutes() { + if (!group) return; + routesError = null; + try { + routes = await api.groups.routes(group.id); + routesLoaded = true; + } catch (e) { + routesError = classifyError(e); + } + } + + // Suppressions. + let suppressionsLoaded = $state(false); + let suppressions = $state([]); + let suppressionsError = $state(null); + async function loadSuppressions() { + if (!group) return; + suppressionsError = null; + try { + suppressions = await api.groups.suppressions(group.id); + suppressionsLoaded = true; + } catch (e) { + suppressionsError = classifyError(e); + } + } + + // Extension points. + let extPointsLoaded = $state(false); + let extPoints = $state([]); + let extPointsError = $state(null); + async function loadExtPoints() { + if (!group) return; + extPointsError = null; + try { + extPoints = await api.groups.extensionPoints(group.id); + extPointsLoaded = true; + } catch (e) { + extPointsError = classifyError(e); + } + } + + // Dead-letters (shared-queue). + let deadLettersLoaded = $state(false); + let deadLetters = $state([]); + let deadLettersError = $state(null); + let dlUnresolvedOnly = $state(false); + async function loadDeadLetters() { + if (!group) return; + deadLettersError = null; + try { + deadLetters = await api.groups.deadLetters(group.id, { + unresolved: dlUnresolvedOnly, + limit: 100 + }); + deadLettersLoaded = true; + } catch (e) { + deadLettersError = classifyError(e); + } + } + + // Ownership — the global projects list. Per-group approval policy has no + // endpoint; it is previewed via `pic plan`. + let projectsLoaded = $state(false); + let projects = $state([]); + let projectsError = $state(null); + async function loadProjects() { + projectsError = null; + try { + projects = await api.projects.list(); + projectsLoaded = true; + } catch (e) { + projectsError = classifyError(e); + } + } + + // Lazy per-tab loading: fetch the active tab's data the first time it is + // shown. `group` must be loaded first. + $effect(() => { + if (!group) return; + switch (activeTab) { + case 'vars': + if (!varsLoaded) void loadVars(); + break; + case 'secrets': + if (canManage && !secretsLoaded) void loadSecrets(); + break; + case 'collections': + if (!collectionsLoaded) void loadCollections(); + break; + case 'scripts': + if (!scriptsLoaded) void loadScripts(); + break; + case 'triggers': + if (!triggersLoaded) void loadTriggers(); + break; + case 'routes': + if (!routesLoaded) void loadRoutes(); + break; + case 'suppressions': + if (!suppressionsLoaded) void loadSuppressions(); + break; + case 'extension-points': + if (!extPointsLoaded) void loadExtPoints(); + break; + case 'dead-letters': + if (!deadLettersLoaded) void loadDeadLetters(); + break; + case 'ownership': + if (!projectsLoaded) void loadProjects(); + break; + } + }); + async function saveSettings(event: Event) { event.preventDefault(); if (!group) return; @@ -256,6 +577,35 @@ } } + function fmtTime(iso: string): string { + try { + return new Date(iso).toLocaleString(); + } catch { + return iso; + } + } + + function fmtSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + } + + function preview(v: unknown, max = 120): string { + let s: string; + try { + s = typeof v === 'string' ? v : JSON.stringify(v); + } catch { + s = String(v); + } + return s.length > max ? s.slice(0, max) + '…' : s; + } + + // Collections that have a data browser (kv/docs/files). + const browsableCollections = $derived( + collections.filter((c) => c.kind === 'kv' || c.kind === 'docs' || c.kind === 'files') + ); + $effect(() => { void slug; void loadGroup(); @@ -293,14 +643,18 @@ {/if} +{#snippet loadErrorNote(err: LoadError)} + {#if err === null} + + {:else if 'forbidden' in err} +

You don't have permission to view this.

+ {:else} +

{err.message}

+ {/if} +{/snippet} + {#if group} - + {#if activeTab === 'overview'}
@@ -454,6 +808,508 @@ {/if}
+ {:else if activeTab === 'vars'} +
+

Vars

+

+ The group's own config vars — inherited by every descendant app, + env-scoped, nearest-owner-wins. Edit via + pic apply or the vars API. A tombstone suppresses an inherited key. +

+ {#if varsError} + {@render loadErrorNote(varsError)} + {:else if !varsLoaded} +

Loading…

+ {:else if vars.length === 0} +

No vars declared on this group.

+ {:else} + + + + + + {#each vars as v (v.key + '@' + v.env)} + + + + + + + {/each} + +
KeyEnvValueUpdated
{v.key}{v.env} + {#if v.is_tombstone} + + tombstone + + {:else} + {preview(v.value)} + {/if} + {fmtTime(v.updated_at)}
+ {/if} +
+ {:else if activeTab === 'secrets' && canManage} +
+

Secrets

+

+ Secret names and their env scope only — values never leave + the server. Set them with pic apply or the secrets API. +

+ {#if secretsError} + {@render loadErrorNote(secretsError)} + {:else if !secretsLoaded} +

Loading…

+ {:else if secrets.length === 0} +

No secrets declared on this group.

+ {:else} + + + + + + {#each secrets as sct (sct.name + '@' + sct.env)} + + + + + + {/each} + +
NameEnvUpdated
{sct.name}{sct.env}{fmtTime(sct.updated_at)}
+ {/if} +
+ {:else if activeTab === 'collections'} +
+

Shared collections

+

+ Cross-app shared stores declared on this group. Reads are open to any + subtree script; writes require an editor+ via + kv::shared_collection(…) / docs / files. + Topic and queue collections are storeless / consumed via triggers, so + they have no data browser here. +

+ {#if collectionsError} + {@render loadErrorNote(collectionsError)} + {:else if !collectionsLoaded} +

Loading…

+ {:else if collections.length === 0} +

No shared collections declared on this group.

+ {:else} + + + + + + {#each collections as c (c.kind + '/' + c.name)} + + + + + + {/each} + +
NameKind
{c.name}{c.kind} + {#if c.kind === 'topic' || c.kind === 'queue'} + no data browser + {:else} + + {/if} +
+ {/if} + +

Data browser

+
{ + e.preventDefault(); + void browse(); + }} + > + + + +
+ {#if browsableCollections.length > 0} +

+ Declared: + {#each browsableCollections as c, i (c.kind + '/' + c.name)} + {#if i > 0}, {/if} + + {/each} +

+ {/if} + + {#if browseError} + {@render loadErrorNote(browseError)} + {/if} + + {#if browseActive} + {#if browseActiveKind === 'kv'} + {#if browseKeys.length === 0 && !browseLoading} +

No keys in KV collection {browseActive}.

+ {:else} + + + + + + {#each browseKeys as k (k)} + + + + + {#if kvValue && kvValue.key === k} + + + + {/if} + {/each} + +
Key
{k} + +
{preview(kvValue.value, 4000)}
+ {/if} + {:else if browseActiveKind === 'docs'} + {#if browseDocs.length === 0 && !browseLoading} +

No documents in docs collection {browseActive}.

+ {:else} + + + + + + {#each browseDocs as d (d.id)} + + + + + {/each} + +
IDData
{d.id}{preview(d.data)}
+ {/if} + {:else if browseActiveKind === 'files'} + {#if browseFiles.length === 0 && !browseLoading} +

No files in files collection {browseActive}.

+ {:else} + + + + + + {#each browseFiles as f (f.id)} + + + + + + + + {/each} + +
NameTypeSizeCreated
{f.name}{f.content_type}{fmtSize(f.size)}{fmtTime(f.created_at)} + + Download + +
+ {/if} + {/if} + {#if browseCursor} + + {/if} + {/if} +
+ {:else if activeTab === 'scripts'} +
+

Scripts

+

+ Group-owned script templates — inherited by every descendant app, + resolved by name (nearest-owner-wins). Author them via pic apply. +

+ {#if scriptsError} + {@render loadErrorNote(scriptsError)} + {:else if !scriptsLoaded} +

Loading…

+ {:else if scripts.length === 0} +

No scripts declared on this group.

+ {:else} + + + + + + {#each scripts as s (s.id)} + + + + + + + {/each} + +
NameKindVersionUpdated
{s.name} + {s.kind} + v{s.version}{fmtTime(s.updated_at)}
+ {/if} +
+ {:else if activeTab === 'triggers'} +
+

Trigger templates

+

+ Event trigger templates declared on this group. Each fires a group-owned + handler on a matching descendant-app (or shared-collection) event. Author + them via pic apply. +

+ {#if triggersError} + {@render loadErrorNote(triggersError)} + {:else if !triggersLoaded} +

Loading…

+ {:else if triggers.length === 0} +

No trigger templates declared on this group.

+ {:else} + + + + + + {#each triggers as t (t.kind + '/' + t.target + '/' + t.script)} + + + + + + + {/each} + +
KindTargetHandlerFlags
{t.kind}{t.target}{t.script} + {#if !t.enabled}disabled{/if} + {#if t.sealed}sealed{/if} + {#if t.shared}shared{/if} +
+ {/if} +
+ {:else if activeTab === 'routes'} +
+

Route templates

+

+ HTTP route templates declared on this group — each descendant app serves + them (nearest-owner shadowing). Author them via pic apply. +

+ {#if routesError} + {@render loadErrorNote(routesError)} + {:else if !routesLoaded} +

Loading…

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

No route templates declared on this group.

+ {:else} + + + + + + + + {#each routes as r (r.method + ' ' + r.path + ' ' + r.script)} + + + + + + + + {/each} + +
MethodPathHandlerDispatchFlags
{r.method} + {r.path} + {#if r.path_kind !== 'exact'}({r.path_kind}){/if} + {r.script}{r.dispatch} + {#if !r.enabled}disabled{/if} + {#if r.sealed}sealed{/if} +
+ {/if} +
+ {:else if activeTab === 'suppressions'} +
+

Suppressions

+

+ Inherited templates this group declines for its whole subtree + ([suppress] in the manifest). A sealed template can't be + declined. Author via pic apply. +

+ {#if suppressionsError} + {@render loadErrorNote(suppressionsError)} + {:else if !suppressionsLoaded} +

Loading…

+ {:else if suppressions.length === 0} +

No suppressions declared on this group.

+ {:else} + + + + + + {#each suppressions as sup (sup.target_kind + '/' + sup.reference)} + + + + + {/each} + +
Target kindReference
{sup.target_kind}{sup.reference}
+ {/if} +
+ {:else if activeTab === 'extension-points'} +
+

Extension points

+

+ Opt-in polymorphism markers declared on this group — a descendant app can + override each with its own module; otherwise the group's default body is + used. Author via pic apply. +

+ {#if extPointsError} + {@render loadErrorNote(extPointsError)} + {:else if !extPointsLoaded} +

Loading…

+ {:else if extPoints.length === 0} +

No extension points declared on this group.

+ {:else} + + + + + + {#each extPoints as ep (ep.name)} + + + + + + {/each} + +
NameDeclared hereProvider
{ep.name} + {#if ep.declared_here} + here + {:else} + inherited + {/if} + {ep.provider ?? '—'}
+ {/if} +
+ {:else if activeTab === 'dead-letters'} +
+

Shared-queue dead-letters

+

+ Exhausted messages from this group's shared durable queues. Read-only — + replay/resolve is not exposed for shared queues. +

+ + {#if deadLettersError} + {@render loadErrorNote(deadLettersError)} + {:else if !deadLettersLoaded} +

Loading…

+ {:else if deadLetters.length === 0} +

No dead-letters on this group.

+ {:else} + + + + + + + + + {#each deadLetters as dl (dl.id)} + + + + + + + + + + {/each} + +
CollectionSourceOpAttemptsLast errorCreatedResolved
{dl.collection}{dl.source}{dl.op}{dl.attempt_count}{preview(dl.last_error, 80)}{fmtTime(dl.created_at)}{dl.resolved_at ? fmtTime(dl.resolved_at) : '—'}
+ {/if} +
+ {:else if activeTab === 'ownership'} +
+

Ownership

+

+ Multi-repo projects (repo roots) and the count of group nodes each owns. + A group is claimed by the first pic apply declaring a + [project]. Per-group approval policy has no live view — it is + previewed via pic plan. +

+ {#if projectsError} + {@render loadErrorNote(projectsError)} + {:else if !projectsLoaded} +

Loading…

+ {:else if projects.length === 0} +

No projects registered on this instance.

+ {:else} + + + + + + {#each projects as p (p.slug)} + + + + + + + {/each} + +
SlugNameOwned groupsCreated
{p.slug}{p.name}{p.owned_groups}{fmtTime(p.created_at)}
+ {/if} +
{:else if activeTab === 'settings' && canManage}

Settings

@@ -590,31 +1446,7 @@ h3 { font-size: 1rem; - margin: 0 0 0.5rem; - } - - .tabbar { - display: flex; - gap: 1.25rem; - border-bottom: 1px solid #1e293b; - margin-bottom: 1.5rem; - } - - .tabbar a { - color: #94a3b8; - text-decoration: none; - font-size: 0.9rem; - padding: 0.5rem 0; - border-bottom: 2px solid transparent; - } - - .tabbar a:hover { - color: #e2e8f0; - } - - .tabbar a.active { - color: #e2e8f0; - border-bottom-color: #38bdf8; + margin: 1.5rem 0 0.5rem; } button { @@ -627,6 +1459,24 @@ cursor: pointer; } + button.secondary { + background: transparent; + color: #38bdf8; + border: 1px solid #334155; + padding: 0.3rem 0.7rem; + font-weight: 500; + } + + a.secondary { + color: #38bdf8; + text-decoration: none; + border: 1px solid #334155; + padding: 0.3rem 0.7rem; + border-radius: 0.375rem; + font-size: 0.85rem; + margin-right: 0.4rem; + } + button.danger { background: #7f1d1d; color: #fecaca; @@ -696,6 +1546,38 @@ font: inherit; } + .collection-form { + display: flex; + align-items: flex-end; + gap: 0.75rem; + margin-bottom: 1rem; + } + .collection-form label { + display: flex; + flex-direction: column; + gap: 0.25rem; + font-size: 0.85rem; + color: #cbd5e1; + } + .collection-form input, + .collection-form select { + background: #0b1220; + color: #e2e8f0; + border: 1px solid #334155; + border-radius: 0.375rem; + padding: 0.5rem 0.75rem; + font: inherit; + } + + .inline-check { + display: inline-flex; + align-items: center; + gap: 0.4rem; + font-size: 0.85rem; + color: #cbd5e1; + margin-bottom: 1rem; + } + .actions { display: flex; justify-content: flex-end; @@ -786,4 +1668,81 @@ display: flex; justify-content: flex-end; } + + /* Read-only inspection grids. */ + table.grid { + width: 100%; + border-collapse: collapse; + font-size: 0.9rem; + margin-top: 0.5rem; + } + table.grid th, + table.grid td { + text-align: left; + padding: 0.45rem 0.6rem; + border-bottom: 1px solid #1e293b; + vertical-align: top; + } + table.grid th { + color: #64748b; + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.05em; + font-weight: 600; + } + .mono { + font-family: monospace; + } + pre.value { + margin: 0; + padding: 0.5rem; + background: #0b1220; + border-radius: 0.375rem; + white-space: pre-wrap; + word-break: break-all; + font-size: 0.8rem; + } + + .badge { + display: inline-block; + background: #0b1220; + border: 1px solid #334155; + color: #cbd5e1; + border-radius: var(--radius-pill, 999px); + padding: 0.05rem 0.45rem; + font-size: 0.72rem; + font-weight: 600; + margin-right: 0.3rem; + } + .badge-muted { + color: #64748b; + } + .badge-sealed { + border-color: #b45309; + color: #fbbf24; + } + .badge-shared { + border-color: #0e7490; + color: #22d3ee; + } + + .hint { + color: #64748b; + font-size: 0.85rem; + margin: 0.5rem 0 1rem; + } + .hint-chip { + background: #0b1220; + color: #e2e8f0; + border: 1px solid #334155; + border-radius: 0.25rem; + padding: 0.1rem 0.4rem; + font-size: 0.8rem; + cursor: pointer; + font-family: monospace; + font-weight: 500; + } + .hint-chip:hover { + border-color: #38bdf8; + }