feat(dashboard): group data-plane read surfaces + CodeEditor readOnly fix
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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<string, string>;
|
||||
@@ -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<GroupCollection[]>(
|
||||
`/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}/collections`
|
||||
),
|
||||
scripts: (idOrSlug: string) =>
|
||||
adminRequest<Script[]>(
|
||||
`/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}/scripts`
|
||||
),
|
||||
triggers: (idOrSlug: string) =>
|
||||
adminRequest<GroupTriggerTemplate[]>(
|
||||
`/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}/triggers`
|
||||
),
|
||||
routes: (idOrSlug: string) =>
|
||||
adminRequest<GroupRouteTemplate[]>(
|
||||
`/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}/routes`
|
||||
),
|
||||
suppressions: (idOrSlug: string) =>
|
||||
adminRequest<SuppressionInfo[]>(
|
||||
`/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}/suppressions`
|
||||
),
|
||||
extensionPoints: (idOrSlug: string) =>
|
||||
adminRequest<ExtensionPointInfo[]>(
|
||||
`/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<GroupDeadLetter[]>(
|
||||
`/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}/dead-letters${qs ? `?${qs}` : ''}`
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
projects: {
|
||||
list: () => adminRequest<ProjectListItem[]>('/api/v1/admin/projects')
|
||||
},
|
||||
|
||||
domains: {
|
||||
listForApp: (idOrSlug: string) =>
|
||||
adminRequest<AppDomain[]>(
|
||||
|
||||
Reference in New Issue
Block a user