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