@reddb-io/ui 0.3.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/build/_app/immutable/assets/0.DgbuWrJy.css +1 -0
  2. package/build/_app/immutable/assets/AnalyticsView.BLAJezw4.css +1 -0
  3. package/build/_app/immutable/assets/{Workspace.CvP5De7i.css → Workspace.DM2E1WiF.css} +1 -1
  4. package/build/_app/immutable/chunks/85-Fh5Zp.js +404 -0
  5. package/build/_app/immutable/chunks/{C9rXzYLu.js → 8zg2gJwV.js} +1 -1
  6. package/build/_app/immutable/chunks/B5euRbKa.js +1 -0
  7. package/build/_app/immutable/chunks/BRIMmWzq.js +1 -0
  8. package/build/_app/immutable/chunks/BgOTHNR5.js +1 -0
  9. package/build/_app/immutable/chunks/Bq4DTWK2.js +2 -0
  10. package/build/_app/immutable/chunks/Btd6PFkC.js +1 -0
  11. package/build/_app/immutable/chunks/CGFL56WJ.js +1 -0
  12. package/build/_app/immutable/chunks/CJFJ501n.js +18 -0
  13. package/build/_app/immutable/chunks/CLBx21Hz.js +2 -0
  14. package/build/_app/immutable/chunks/CnD4hwxC.js +1106 -0
  15. package/build/_app/immutable/chunks/DCJ0QyYH.js +1 -0
  16. package/build/_app/immutable/chunks/DQCYUNtS.js +1 -0
  17. package/build/_app/immutable/chunks/DRlh90Cv.js +1 -0
  18. package/build/_app/immutable/chunks/{C278awGv.js → DV9PDoM3.js} +1 -1
  19. package/build/_app/immutable/entry/app.BDog2y0J.js +2 -0
  20. package/build/_app/immutable/entry/start.D1J45dq5.js +1 -0
  21. package/build/_app/immutable/nodes/0.BTrC4th2.js +1 -0
  22. package/build/_app/immutable/nodes/1.Ds1Tbjsm.js +1 -0
  23. package/build/_app/immutable/nodes/10.D_FCLs0Z.js +96 -0
  24. package/build/_app/immutable/nodes/12.DPE6v6Zf.js +3 -0
  25. package/build/_app/immutable/nodes/14.CAG5_cGP.js +1 -0
  26. package/build/_app/immutable/nodes/15.CcJlZGxa.js +60 -0
  27. package/build/_app/immutable/nodes/8.CAG5_cGP.js +1 -0
  28. package/build/_app/version.json +1 -1
  29. package/build/index.html +18 -15
  30. package/dist/embed/AnalyticsView-D8z1Nsed.js +23337 -0
  31. package/dist/embed/AnalyticsView-D8z1Nsed.js.map +1 -0
  32. package/dist/embed/RedUiEmbedRoot-CtqTd_FG.js +31302 -0
  33. package/dist/embed/RedUiEmbedRoot-CtqTd_FG.js.map +1 -0
  34. package/dist/embed/index-0hA141D2.js +85 -0
  35. package/dist/embed/index-0hA141D2.js.map +1 -0
  36. package/dist/embed/index-client-BeiP-va5.js +3303 -0
  37. package/dist/embed/index-client-BeiP-va5.js.map +1 -0
  38. package/dist/embed/index-w1Qo_uW0.js +677 -0
  39. package/dist/embed/index-w1Qo_uW0.js.map +1 -0
  40. package/dist/embed/index.js +1 -1
  41. package/dist/embed/{sigma.esm-D5yoExsH.js → sigma.esm-qqKBpvin.js} +2 -2
  42. package/dist/embed/{sigma.esm-D5yoExsH.js.map → sigma.esm-qqKBpvin.js.map} +1 -1
  43. package/package.json +5 -3
  44. package/src/app.css +86 -20
  45. package/src/lib/AnalyticsView.svelte +209 -0
  46. package/src/lib/AskView.svelte +255 -0
  47. package/src/lib/ClusterView.svelte +415 -95
  48. package/src/lib/CommandPalette.svelte +2 -0
  49. package/src/lib/GlobalProgressBar.svelte +9 -0
  50. package/src/lib/MetricChart.svelte +85 -0
  51. package/src/lib/QueryEditor.svelte +48 -7
  52. package/src/lib/ResultsPane.svelte +76 -8
  53. package/src/lib/SchemaTree.svelte +25 -1
  54. package/src/lib/SecurityView.svelte +176 -65
  55. package/src/lib/SettingsView.svelte +1111 -254
  56. package/src/lib/Topbar.svelte +29 -1
  57. package/src/lib/Workspace.svelte +18 -3
  58. package/src/lib/activity.svelte.ts +44 -35
  59. package/src/lib/collection-preview.ts +14 -0
  60. package/src/lib/connections.svelte.ts +73 -4
  61. package/src/lib/index.ts +3 -0
  62. package/src/lib/permission-gate.ts +120 -0
  63. package/src/lib/policies.ts +136 -0
  64. package/src/lib/query-tabs.svelte.ts +132 -53
  65. package/src/lib/reddb/analytics.ts +208 -0
  66. package/src/lib/reddb/ask.ts +123 -0
  67. package/src/lib/reddb/client.ts +159 -58
  68. package/src/lib/reddb/cluster-status.ts +306 -0
  69. package/src/lib/reddb/index.ts +3 -0
  70. package/src/lib/reddb/local-url-provider.ts +32 -1
  71. package/src/lib/reddb/redwire-ws-spike.ts +590 -0
  72. package/src/lib/router.svelte.ts +12 -0
  73. package/src/lib/settings-authoring-client.ts +336 -0
  74. package/src/lib/settings-sections.ts +292 -196
  75. package/build/_app/immutable/assets/0.DQDLkSdR.css +0 -1
  76. package/build/_app/immutable/chunks/65XjEwaF.js +0 -367
  77. package/build/_app/immutable/chunks/BGcIe2ep.js +0 -2
  78. package/build/_app/immutable/chunks/BjFADNkA.js +0 -992
  79. package/build/_app/immutable/chunks/BrvvOVaJ.js +0 -1
  80. package/build/_app/immutable/chunks/D9CTkMjK.js +0 -1
  81. package/build/_app/immutable/chunks/oiO4RLqp.js +0 -1
  82. package/build/_app/immutable/chunks/pDJnDxXM.js +0 -1
  83. package/build/_app/immutable/entry/app.DdaMXHAv.js +0 -2
  84. package/build/_app/immutable/entry/start.-MLBtQXB.js +0 -1
  85. package/build/_app/immutable/nodes/0.Ysoz3Y3h.js +0 -1
  86. package/build/_app/immutable/nodes/1.C2jbGTOJ.js +0 -1
  87. package/build/_app/immutable/nodes/12.DQG5zpEx.js +0 -60
  88. package/build/_app/immutable/nodes/8.CrHI4F9p.js +0 -134
  89. package/dist/embed/RedUiEmbedRoot-BwFUA5X-.js +0 -28624
  90. package/dist/embed/RedUiEmbedRoot-BwFUA5X-.js.map +0 -1
  91. package/dist/embed/index-DGhgtnaK.js +0 -577
  92. package/dist/embed/index-DGhgtnaK.js.map +0 -1
  93. package/dist/embed/index-client-ItCEU64C.js +0 -3087
  94. package/dist/embed/index-client-ItCEU64C.js.map +0 -1
  95. /package/build/_app/immutable/assets/{12.Dblyk0Kr.css → 15.Dblyk0Kr.css} +0 -0
  96. /package/build/_app/immutable/nodes/{10.CAG5_cGP.js → 13.CAG5_cGP.js} +0 -0
@@ -1,29 +1,27 @@
1
- // Data-driven settings registry + a pure config-key resolver.
2
- //
3
- // Settings are defined as DATA, not hand-built rows: `SECTIONS` lists the
4
- // curated config keys per section, and three override maps (`LABELS`,
5
- // `DESCRIPTIONS`, `ENUM_OPTIONS`) keyed by config path supply the human copy.
6
- // `resolveControl(key, value)` maps a single reddb config/connection key to a
7
- // rendered control descriptor (human label, description, input kind, enum
8
- // options), degrading gracefully for keys nobody curated. Adding a curated key
9
- // is a one-line edit to a section's `keys` array.
10
- //
11
- // Kept framework-free (no Svelte imports) so the registry and the resolver are
12
- // trivially unit-testable. Icons are mapped to components in `SettingsView`,
13
- // not here — a section carries only an icon *name*. Mirrors the registry idiom
14
- // in `renderers/registry`: a tiny pure surface over curated data.
1
+ // Data-driven settings registry + pure schema resolver for the Settings surface.
2
+ import type {
3
+ ConfigEntry,
4
+ ConfigMutationValue,
5
+ ConfigValueType,
6
+ SecretEntry,
7
+ SecretReference,
8
+ } from "./settings-authoring-client";
9
+ import type { PermissionCheck } from "./permission-gate";
15
10
 
16
- /** The input control a config value is rendered through. */
17
- export type ControlKind = "text" | "number" | "boolean" | "enum";
18
-
19
- /** Where a config key is sourced from — gates permission-aware rendering. */
20
- export type SettingsSource = "stats" | "cluster" | "capabilities" | "session";
11
+ /** The read-only control a config value is rendered through. */
12
+ export type ControlKind =
13
+ | "text"
14
+ | "number"
15
+ | "boolean"
16
+ | "enum"
17
+ | "list"
18
+ | "secret-reference";
21
19
 
22
20
  /** A resolved control for one config key. */
23
21
  export interface ControlDescriptor {
24
22
  /** The config path this descriptor was resolved from. */
25
23
  key: string;
26
- /** Human label — a curated override or a humanized key. */
24
+ /** Human label: a curated override or a humanized key. */
27
25
  label: string;
28
26
  /** Optional one-line explanation. */
29
27
  description?: string;
@@ -31,10 +29,18 @@ export interface ControlDescriptor {
31
29
  kind: ControlKind;
32
30
  /** Allowed values when `kind === "enum"`. */
33
31
  options?: readonly string[];
32
+ /** Server-reported config schema type, when SHOW CONFIG projects it. */
33
+ valueType?: ConfigValueType;
34
+ /** Server-reported config schema version, when SHOW CONFIG projects it. */
35
+ schemaVersion?: number;
36
+ /** True when this row must not render plaintext. */
37
+ masked?: boolean;
38
+ /** Vault path referenced by a config value, if this is a secret reference. */
39
+ secretReference?: SecretReference;
34
40
  }
35
41
 
36
- export interface SettingsSection {
37
- /** Stable identifier used as the active-section key (component state only). */
42
+ export interface SettingsPane {
43
+ /** Stable identifier used as the active-pane key (component state only). */
38
44
  id: string;
39
45
  /** Nav label. */
40
46
  label: string;
@@ -42,119 +48,120 @@ export interface SettingsSection {
42
48
  blurb: string;
43
49
  /** Icon name resolved to a lucide component in `SettingsView`. */
44
50
  icon: string;
45
- /** Curated config keys, rendered top-to-bottom. One-line edit to extend. */
46
- keys: string[];
51
+ /** Pane-level grant that must be allowed before this pane is rendered. */
52
+ readGrant: PermissionCheck;
53
+ /** Pane-level grant that enables authoring controls when present. */
54
+ writeGrant?: PermissionCheck;
55
+ /** Pane-level grant that enables break-glass secret reveal controls. */
56
+ revealGrant?: PermissionCheck;
47
57
  }
48
58
 
49
- /**
50
- * The curated sections. Each `keys` entry is a flattened reddb config path
51
- * (see `flattenConfig`) sourced from `/stats`, `/cluster/status`,
52
- * `/capabilities`, or the session (`whoami`). Order is the render order.
53
- */
54
- export const SECTIONS: SettingsSection[] = [
59
+ export const SETTINGS_PANES: SettingsPane[] = [
55
60
  {
56
- id: "overview",
57
- label: "Overview",
58
- blurb: "What this reddb instance is and where it runs.",
61
+ id: "config",
62
+ label: "Config",
63
+ blurb: "Live non-secret configuration from SHOW CONFIG.",
59
64
  icon: "settings",
60
- keys: [
61
- "deployment.mode",
62
- "read_only",
63
- "system.os",
64
- "system.arch",
65
- "system.hostname",
66
- "system.cpu_cores",
67
- ],
68
- },
69
- {
70
- id: "storage",
71
- label: "Storage",
72
- blurb: "Live store footprint for the active connection.",
73
- icon: "database",
74
- keys: [
75
- "store.collection_count",
76
- "store.total_entities",
77
- "store.cross_ref_count",
78
- "store.total_memory_bytes",
79
- ],
80
- },
81
- {
82
- id: "cluster",
83
- label: "Cluster",
84
- blurb: "Deployment and replication topology.",
85
- icon: "network",
86
- keys: [
87
- "deployment.mode",
88
- "deployment.file_path",
89
- "replication.replica_count",
90
- "replication.lag_ms",
91
- ],
65
+ readGrant: {
66
+ action: "config:read",
67
+ resource: { kind: "config", name: "*" },
68
+ },
69
+ writeGrant: {
70
+ action: "config:write",
71
+ resource: { kind: "config", name: "*" },
72
+ },
92
73
  },
93
74
  {
94
- id: "capabilities",
95
- label: "Capabilities",
96
- blurb: "Endpoints negotiated for this server version.",
97
- icon: "plug",
98
- keys: [
99
- "capabilities.vcs",
100
- "capabilities.clusterStatus",
101
- "capabilities.collectionMetadata",
102
- "capabilities.cdcStream",
103
- ],
104
- },
105
- {
106
- id: "session",
107
- label: "Session",
108
- blurb: "Who red-ui is authenticated as on this connection.",
109
- icon: "shield",
110
- keys: ["session.username", "session.role", "session.authenticated"],
75
+ id: "secrets",
76
+ label: "Secrets",
77
+ blurb: "Vault inventory from SHOW SECRETS with values masked.",
78
+ icon: "key",
79
+ readGrant: {
80
+ action: "vault:read_metadata",
81
+ resource: { kind: "vault", name: "*" },
82
+ },
83
+ writeGrant: {
84
+ action: "vault:write",
85
+ resource: { kind: "vault", name: "*" },
86
+ },
87
+ revealGrant: {
88
+ action: "vault:read",
89
+ resource: { kind: "vault", name: "*" },
90
+ },
111
91
  },
112
92
  ];
113
93
 
114
94
  /** Curated label overrides, keyed by config path. */
115
95
  export const LABELS: Record<string, string> = {
116
- "deployment.mode": "Deployment mode",
117
- "deployment.file_path": "Backing file",
118
- read_only: "Read-only",
119
- "system.os": "Operating system",
120
- "system.arch": "Architecture",
121
- "system.hostname": "Hostname",
122
- "system.cpu_cores": "CPU cores",
123
- "store.collection_count": "Collections",
124
- "store.total_entities": "Entities",
125
- "store.cross_ref_count": "Cross-references",
126
- "store.total_memory_bytes": "Memory in use",
127
- "replication.replica_count": "Replicas",
128
- "replication.lag_ms": "Replication lag",
129
- "capabilities.vcs": "Version control",
130
- "capabilities.clusterStatus": "Cluster status",
131
- "capabilities.collectionMetadata": "Collection metadata",
132
- "capabilities.cdcStream": "Change stream",
133
- "session.username": "Username",
134
- "session.role": "Role",
135
- "session.authenticated": "Authenticated",
96
+ "durability.mode": "Durability mode",
97
+ "concurrency.locking.enabled": "Locking",
98
+ "cache.blob.l1_bytes_max": "Blob L1 cache",
99
+ "cache.blob.l2_bytes_max": "Blob L2 cache",
100
+ "red.auth.enabled": "Authentication",
101
+ "red.auth.require_auth": "Require authentication",
102
+ "red.auth.session_ttl_secs": "Session TTL",
103
+ "red.backup.backend": "Backup backend",
104
+ "red.backup.enabled": "Backups",
105
+ "red.backup.interval_secs": "Backup interval",
106
+ "red.cdc.enabled": "Change data capture",
107
+ "red.config.secret.auto_decrypt": "Auto-decrypt config secrets",
108
+ "red.config.secret.auto_encrypt": "Auto-encrypt config secrets",
109
+ "red.server.max_body_size": "Max request body",
110
+ "red.server.max_scan_limit": "Max scan limit",
111
+ "red.server.read_timeout_ms": "Read timeout",
112
+ "red.server.write_timeout_ms": "Write timeout",
113
+ "red.storage.page_size": "Page size",
114
+ "red.storage.page_cache_capacity": "Page cache capacity",
115
+ "red.storage.verify_checksums": "Verify checksums",
116
+ "red.system.os": "Operating system",
117
+ "red.system.arch": "Architecture",
118
+ "red.system.hostname": "Hostname",
119
+ "red.system.cpu_cores": "CPU cores",
120
+ "red.system.total_memory_bytes": "System memory",
121
+ "red.vcs.default_branch": "Default branch",
122
+ "red.vcs.protected_branches": "Protected branches",
123
+ "runtime.result_cache.backend": "Result cache backend",
124
+ "runtime.result_cache.enabled": "Result cache",
125
+ "storage.bgwriter.delay_ms": "Background writer delay",
126
+ "storage.btree.lehman_yao": "Lehman-Yao B-tree",
127
+ "storage.wal.max_interval_ms": "WAL max interval",
128
+ "system.bootstrap.completed": "Bootstrap completed",
129
+ "system.bootstrap.preset": "Bootstrap preset",
136
130
  };
137
131
 
138
132
  /** Curated descriptions, keyed by config path. */
139
133
  export const DESCRIPTIONS: Record<string, string> = {
140
- "deployment.mode": "How this reddb instance is running.",
141
- "deployment.file_path": "On-disk store path for an embedded or local server.",
142
- read_only: "Whether the store rejects writes (a lock or a replica role).",
143
- "store.total_memory_bytes": "Resident memory the store currently holds.",
144
- "replication.lag_ms": "How far this replica trails the primary, in ms.",
145
- "capabilities.vcs": "Commit and diff endpoints — gated when unsupported.",
146
- "capabilities.cdcStream": "Server-sent change stream over /changes/stream.",
147
- "session.role": "The role granted to this principal on the active target.",
134
+ "durability.mode": "Write durability policy currently reported by reddb.",
135
+ "red.auth.enabled":
136
+ "Whether reddb authentication is enabled for this instance.",
137
+ "red.auth.require_auth": "Whether unauthenticated requests are rejected.",
138
+ "red.backup.backend": "The selected backup storage backend.",
139
+ "red.backup.enabled": "Whether scheduled backups are enabled.",
140
+ "red.cdc.enabled": "Whether the change stream subsystem is enabled.",
141
+ "red.server.max_body_size":
142
+ "Maximum accepted HTTP request body size, in bytes.",
143
+ "red.server.max_scan_limit": "Default cap for scan-heavy API paths.",
144
+ "red.storage.page_size": "On-disk page size used by the storage engine.",
145
+ "red.storage.verify_checksums":
146
+ "Whether page checksums are verified on read.",
147
+ "red.vcs.default_branch":
148
+ "Branch name used when VCS features need a default.",
149
+ "red.vcs.protected_branches":
150
+ "Branches protected from destructive operations.",
151
+ "runtime.result_cache.enabled": "Whether query result caching is enabled.",
152
+ "system.bootstrap.preset": "The bootstrap profile that initialized defaults.",
148
153
  };
149
154
 
150
155
  /** Curated enum options, keyed by config path. Presence ⇒ `kind: "enum"`. */
151
156
  export const ENUM_OPTIONS: Record<string, readonly string[]> = {
152
- "deployment.mode": ["embedded", "server", "docker", "replicated"],
157
+ "durability.mode": ["sync", "async", "none"],
158
+ "red.backup.backend": ["local", "s3", "gcs"],
159
+ "runtime.result_cache.backend": ["legacy", "blob_cache"],
153
160
  };
154
161
 
155
162
  /**
156
163
  * Turn a config path into a human label when nobody curated one:
157
- * `store.total_memory_bytes` → "Total memory bytes". Uses the last path
164
+ * `store.total_memory_bytes` becomes "Total memory bytes". Uses the last path
158
165
  * segment, swaps `_`/`-` for spaces, and sentence-cases the result.
159
166
  */
160
167
  export function humanizeKey(key: string): string {
@@ -164,104 +171,193 @@ export function humanizeKey(key: string): string {
164
171
  return words.charAt(0).toUpperCase() + words.slice(1);
165
172
  }
166
173
 
167
- /**
168
- * Resolve a config key (and its live value) to a rendered control descriptor.
169
- *
170
- * Resolution order, each degrading gracefully:
171
- * - label: `LABELS[key]` → `humanizeKey(key)`
172
- * - description: `DESCRIPTIONS[key]` → omitted
173
- * - kind: `ENUM_OPTIONS[key]` ⇒ `"enum"`; else inferred from the value's
174
- * runtime type (boolean / number) → `"text"` for everything else, including
175
- * `undefined`. So an unknown key with no value still yields a valid `"text"`
176
- * descriptor rather than throwing.
177
- */
178
- export function resolveControl(
179
- key: string,
180
- value?: unknown
181
- ): ControlDescriptor {
182
- const options = ENUM_OPTIONS[key];
183
- const kind: ControlKind = options
184
- ? "enum"
185
- : typeof value === "boolean"
186
- ? "boolean"
187
- : typeof value === "number"
188
- ? "number"
189
- : "text";
174
+ function kindFromValueType(
175
+ valueType: ConfigValueType | undefined
176
+ ): ControlKind | null {
177
+ switch (valueType) {
178
+ case "bool":
179
+ case "boolean":
180
+ return "boolean";
181
+ case "int":
182
+ case "integer":
183
+ case "float":
184
+ case "number":
185
+ case "count":
186
+ return "number";
187
+ case "array":
188
+ case "list":
189
+ return "list";
190
+ case "object":
191
+ case "string":
192
+ case "text":
193
+ case "url":
194
+ return "text";
195
+ default:
196
+ return null;
197
+ }
198
+ }
199
+
200
+ function kindFromRuntimeValue(value: unknown): ControlKind {
201
+ if (typeof value === "boolean") return "boolean";
202
+ if (typeof value === "number") return "number";
203
+ if (Array.isArray(value)) return "list";
204
+ return "text";
205
+ }
206
+
207
+ export function isSecretReference(value: unknown): value is SecretReference {
208
+ return (
209
+ !!value &&
210
+ typeof value === "object" &&
211
+ !Array.isArray(value) &&
212
+ (value as SecretReference).type === "secret_ref" &&
213
+ typeof (value as SecretReference).key === "string"
214
+ );
215
+ }
216
+
217
+ export function resolveConfigControl(entry: ConfigEntry): ControlDescriptor {
218
+ const options = ENUM_OPTIONS[entry.key];
219
+ const secretReference = isSecretReference(entry.value)
220
+ ? entry.value
221
+ : undefined;
222
+ const kind: ControlKind = secretReference
223
+ ? "secret-reference"
224
+ : options
225
+ ? "enum"
226
+ : (kindFromValueType(entry.valueType) ??
227
+ kindFromRuntimeValue(entry.value));
190
228
  const descriptor: ControlDescriptor = {
191
- key,
192
- label: LABELS[key] ?? humanizeKey(key),
229
+ key: entry.key,
230
+ label: LABELS[entry.key] ?? humanizeKey(entry.key),
193
231
  kind,
194
232
  };
195
- const description = DESCRIPTIONS[key];
233
+ const description = DESCRIPTIONS[entry.key];
196
234
  if (description) descriptor.description = description;
197
235
  if (options) descriptor.options = options;
236
+ if (entry.valueType) descriptor.valueType = entry.valueType;
237
+ if (entry.schemaVersion !== undefined)
238
+ descriptor.schemaVersion = entry.schemaVersion;
239
+ if (secretReference) descriptor.secretReference = secretReference;
198
240
  return descriptor;
199
241
  }
200
242
 
201
- /** Which live source a config key is read from — drives permission-awareness. */
202
- export function sourceForKey(key: string): SettingsSource {
203
- if (key.startsWith("capabilities.")) return "capabilities";
204
- if (key.startsWith("session.")) return "session";
205
- if (
206
- key.startsWith("deployment.") ||
207
- key.startsWith("storage.") ||
208
- key.startsWith("wal.") ||
209
- key.startsWith("throughput.") ||
210
- key.startsWith("replication.")
211
- ) {
212
- return "cluster";
213
- }
214
- return "stats";
243
+ export function resolveSecretControl(entry: SecretEntry): ControlDescriptor {
244
+ return {
245
+ key: entry.key,
246
+ label: humanizeKey(entry.key),
247
+ kind: "text",
248
+ masked: true,
249
+ };
215
250
  }
216
251
 
217
- /**
218
- * Flatten a nested config snapshot into dotted leaf paths. Nested plain objects
219
- * recurse (`{ store: { collection_count: 3 } }` → `"store.collection_count"`);
220
- * arrays and primitives are kept as leaf values. `undefined`/`null` branches
221
- * are skipped so a server that omits a sub-tree simply yields no keys for it.
222
- */
223
- export function flattenConfig(
224
- input: Record<string, unknown>
225
- ): Record<string, unknown> {
226
- const out: Record<string, unknown> = {};
227
- const walk = (obj: Record<string, unknown>, prefix: string) => {
228
- for (const [k, v] of Object.entries(obj)) {
229
- const key = prefix ? `${prefix}.${k}` : k;
230
- if (v === undefined || v === null) continue;
231
- if (typeof v === "object" && !Array.isArray(v)) {
232
- walk(v as Record<string, unknown>, key);
233
- } else {
234
- out[key] = v;
235
- }
236
- }
237
- };
238
- walk(input, "");
239
- return out;
252
+ export function resolvePane(
253
+ id: string | null,
254
+ panes: readonly SettingsPane[] = SETTINGS_PANES
255
+ ): SettingsPane {
256
+ return panes.find((pane) => pane.id === id) ?? panes[0] ?? SETTINGS_PANES[0];
240
257
  }
241
258
 
242
- /**
243
- * Resolve a section by id, falling back to the first section when the id is
244
- * unknown or null. The settings view drives the active section purely from
245
- * component state, so this never touches the URL.
246
- */
247
- export function resolveSection(id: string | null): SettingsSection {
248
- return SECTIONS.find((s) => s.id === id) ?? SECTIONS[0];
259
+ export function filterSettingsPanesByGrant(
260
+ panes: readonly SettingsPane[],
261
+ gate: { cachedCan(check: PermissionCheck): boolean }
262
+ ): SettingsPane[] {
263
+ return panes.filter((pane) => gate.cachedCan(pane.readGrant));
249
264
  }
250
265
 
251
- /**
252
- * Filter a section's curated keys by a free-text query, matching against both
253
- * the config path AND the resolved human label (case-insensitive). An empty or
254
- * whitespace-only query returns the keys unchanged. Pure + framework-free so
255
- * the settings search is unit-testable without rendering. The settings view
256
- * keeps one query string per section id, so switching sections preserves each
257
- * section's typed query.
258
- */
259
- export function filterKeys(keys: readonly string[], query: string): string[] {
266
+ export function filterConfigEntries(
267
+ entries: readonly ConfigEntry[],
268
+ query: string
269
+ ): ConfigEntry[] {
260
270
  const q = query.trim().toLowerCase();
261
- if (!q) return [...keys];
262
- return keys.filter(
263
- (key) =>
264
- key.toLowerCase().includes(q) ||
265
- resolveControl(key).label.toLowerCase().includes(q)
271
+ if (!q) return [...entries];
272
+ return entries.filter(
273
+ (entry) =>
274
+ entry.key.toLowerCase().includes(q) ||
275
+ resolveConfigControl(entry).label.toLowerCase().includes(q) ||
276
+ (isSecretReference(entry.value) &&
277
+ entry.value.key.toLowerCase().includes(q))
266
278
  );
267
279
  }
280
+
281
+ export function filterSecretEntries(
282
+ entries: readonly SecretEntry[],
283
+ query: string
284
+ ): SecretEntry[] {
285
+ const q = query.trim().toLowerCase();
286
+ if (!q) return [...entries];
287
+ return entries.filter(
288
+ (entry) =>
289
+ entry.key.toLowerCase().includes(q) ||
290
+ resolveSecretControl(entry).label.toLowerCase().includes(q) ||
291
+ entry.status?.toLowerCase().includes(q)
292
+ );
293
+ }
294
+
295
+ export interface ParsedConfigEditValue {
296
+ ok: boolean;
297
+ value?: ConfigMutationValue;
298
+ error?: string;
299
+ }
300
+
301
+ export function initialConfigEditValue(entry: ConfigEntry): string {
302
+ if (
303
+ Array.isArray(entry.value) ||
304
+ (entry.value && typeof entry.value === "object")
305
+ ) {
306
+ return JSON.stringify(entry.value, null, 2);
307
+ }
308
+ if (entry.value === null || entry.value === undefined) return "";
309
+ return String(entry.value);
310
+ }
311
+
312
+ export function parseConfigEditValue(
313
+ control: ControlDescriptor,
314
+ raw: string
315
+ ): ParsedConfigEditValue {
316
+ const value = raw.trim();
317
+ switch (control.kind) {
318
+ case "boolean":
319
+ if (value === "true") return { ok: true, value: true };
320
+ if (value === "false") return { ok: true, value: false };
321
+ return {
322
+ ok: false,
323
+ error: "Boolean config values must be true or false.",
324
+ };
325
+ case "number": {
326
+ if (!value)
327
+ return { ok: false, error: "Number config values cannot be empty." };
328
+ const parsed = Number(value);
329
+ if (!Number.isFinite(parsed)) {
330
+ return { ok: false, error: "Number config values must be finite." };
331
+ }
332
+ return { ok: true, value: parsed };
333
+ }
334
+ case "enum":
335
+ if (!control.options?.includes(raw)) {
336
+ return { ok: false, error: "Choose one of the allowed config values." };
337
+ }
338
+ return { ok: true, value: raw };
339
+ case "list": {
340
+ try {
341
+ const parsed = JSON.parse(raw);
342
+ if (!Array.isArray(parsed)) {
343
+ return {
344
+ ok: false,
345
+ error: "List config values must be a JSON array.",
346
+ };
347
+ }
348
+ return { ok: true, value: parsed };
349
+ } catch {
350
+ return { ok: false, error: "List config values must be valid JSON." };
351
+ }
352
+ }
353
+ case "secret-reference":
354
+ return {
355
+ ok: false,
356
+ error:
357
+ "Secret references can be unset, but not edited as plaintext config.",
358
+ };
359
+ case "text":
360
+ default:
361
+ return { ok: true, value: raw };
362
+ }
363
+ }