@reddb-io/ui 0.2.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/{BHBXmd1Y.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-C2pqg5JH.js → sigma.esm-qqKBpvin.js} +2 -2
  42. package/dist/embed/{sigma.esm-C2pqg5JH.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 +1137 -226
  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 +294 -180
  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/BMc_qoms.js +0 -973
  79. package/build/_app/immutable/chunks/BrvvOVaJ.js +0 -1
  80. package/build/_app/immutable/chunks/CwxFwvow.js +0 -1
  81. package/build/_app/immutable/chunks/D9CTkMjK.js +0 -1
  82. package/build/_app/immutable/chunks/oiO4RLqp.js +0 -1
  83. package/build/_app/immutable/entry/app.CGfmRH-N.js +0 -2
  84. package/build/_app/immutable/entry/start._LhFJimm.js +0 -1
  85. package/build/_app/immutable/nodes/0.BYhY6O5-.js +0 -1
  86. package/build/_app/immutable/nodes/1.4XYGRMul.js +0 -1
  87. package/build/_app/immutable/nodes/12.DXZfNPNK.js +0 -60
  88. package/build/_app/immutable/nodes/8.C7OOsMOJ.js +0 -134
  89. package/dist/embed/RedUiEmbedRoot-Dia9lxfb.js +0 -28570
  90. package/dist/embed/RedUiEmbedRoot-Dia9lxfb.js.map +0 -1
  91. package/dist/embed/index-Bq0TT7Ao.js +0 -577
  92. package/dist/embed/index-Bq0TT7Ao.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,322 +1,1233 @@
1
1
  <script lang="ts">
2
- // Settings view — a data-driven, permission-aware config inspector.
3
- //
4
- // The sections, their curated keys, and the label/description/enum copy all
5
- // live in `settings-sections` as DATA. This view does three things: reads the
6
- // *real* config from `connection.client` (no mocks/fixtures), flattens it to
7
- // dotted keys, and renders each curated key through `resolveControl` as a
8
- // `ListRow`. When the connection is unreachable it falls back to EmptyState.
9
- //
10
- // Permission-aware by default: a key whose source endpoint is denied or
11
- // unsupported is not greyed out — its control is absent, replaced by a small
12
- // chip naming the missing grant (with the server's reason in the tooltip).
2
+ // Settings surface: a bounded pane shell backed by live SQL introspection.
3
+ import { ListRow, NavItem, Pill, SectionHeading } from '@reddb-io/ui-kit'
13
4
  import {
14
- SplitView,
15
- NavItem,
16
- SectionHeading,
17
- Pill,
18
- ListRow,
19
- Kbd,
20
- } from '@reddb-io/ui-kit'
21
- import {
22
- Settings2,
23
- Database,
24
- Network,
25
- Plug,
26
- Shield,
27
- Search,
5
+ AlertCircle,
6
+ Download,
7
+ FileJson,
8
+ KeyRound,
9
+ Eye,
10
+ EyeOff,
11
+ Link2,
12
+ Plus,
28
13
  RefreshCw,
29
- Lock,
14
+ Search,
15
+ Save,
16
+ Settings2,
17
+ ShieldOff,
18
+ SlidersHorizontal,
19
+ Trash2,
30
20
  Unplug,
31
21
  type Icon,
32
22
  } from 'lucide-svelte'
33
23
  import PageHeader from '$lib/PageHeader.svelte'
34
24
  import EmptyState from '$lib/EmptyState.svelte'
35
- import { connection } from '$lib/connections.svelte'
36
25
  import { activity } from '$lib/activity.svelte'
26
+ import { connection } from '$lib/connections.svelte'
27
+ import { PermissionGate } from '$lib/permission-gate'
28
+ import {
29
+ SettingsAuthoringClient,
30
+ type ConfigEntry,
31
+ type SecretEntry,
32
+ type SecretReference,
33
+ } from '$lib/settings-authoring-client'
37
34
  import {
38
- SECTIONS,
39
- resolveSection,
40
- resolveControl,
41
- sourceForKey,
42
- flattenConfig,
35
+ SETTINGS_PANES,
36
+ filterConfigEntries,
37
+ filterSecretEntries,
38
+ filterSettingsPanesByGrant,
39
+ initialConfigEditValue,
40
+ parseConfigEditValue,
41
+ resolveConfigControl,
42
+ resolvePane,
43
+ resolveSecretControl,
43
44
  type ControlDescriptor,
44
- type SettingsSource,
45
+ type SettingsPane,
45
46
  } from '$lib/settings-sections'
46
47
 
47
48
  const icons: Record<string, typeof Icon> = {
49
+ key: KeyRound,
48
50
  settings: Settings2,
49
- database: Database,
50
- network: Network,
51
- plug: Plug,
52
- shield: Shield,
53
51
  }
54
52
 
55
- // The grant a denied source is gated on — shown on the "absent" chip.
56
- const GRANTS: Record<SettingsSource, string> = {
57
- stats: 'stats:read',
58
- cluster: 'cluster:status',
59
- capabilities: 'capabilities',
60
- session: 'auth:whoami',
61
- }
53
+ let activePaneId = $state(SETTINGS_PANES[0].id)
54
+ let visiblePanes = $state<SettingsPane[]>([])
55
+ let permissions = $state<PermissionGate | null>(null)
56
+ let permissionsLoading = $state(false)
57
+ let permissionError = $state<string | null>(null)
58
+ let permissionRun = 0
62
59
 
63
- let activeId = $state(SECTIONS[0].id)
64
- const active = $derived(resolveSection(activeId))
65
-
66
- // Flattened live config and, per source, the reason its grant is absent
67
- // (null ⇒ available). Both are rebuilt atomically on every refresh.
68
- let values = $state<Record<string, unknown>>({})
69
- let denied = $state<Record<SettingsSource, string | null>>({
70
- stats: null,
71
- cluster: null,
72
- capabilities: null,
73
- session: null,
74
- })
60
+ const hasVisiblePane = $derived(visiblePanes.length > 0)
61
+ const activePane = $derived(resolvePane(activePaneId, visiblePanes))
62
+
63
+ let queries = $state<Record<string, string>>({})
64
+ let searchEl = $state<HTMLInputElement | null>(null)
65
+ const query = $derived(hasVisiblePane ? (queries[activePane.id] ?? '') : '')
66
+
67
+ let configEntries = $state<ConfigEntry[]>([])
68
+ let secretEntries = $state<SecretEntry[]>([])
69
+ let selectedConfigKey = $state<string | null>(null)
70
+ let selectedSecretKey = $state<string | null>(null)
75
71
  let loading = $state(false)
72
+ let configLoadError = $state<string | null>(null)
73
+ let secretsLoadError = $state<string | null>(null)
74
+ let writing = $state(false)
75
+ let writeStatus = $state<{
76
+ tone: 'ok' | 'error'
77
+ message: string
78
+ paneId: string
79
+ } | null>(null)
80
+ let editingConfigKey = $state<string | null>(null)
81
+ let editValue = $state('')
82
+ let newConfigKey = $state('')
83
+ let newConfigKind = $state<'text' | 'number' | 'boolean' | 'list'>('text')
84
+ let newConfigValue = $state('')
85
+ let editingSecretSelectionKey = $state<string | null>(null)
86
+ let secretWriteKey = $state('')
87
+ let secretWriteValue = $state('')
88
+ let revealing = $state(false)
89
+ let revealStatus = $state<{
90
+ tone: 'ok' | 'error'
91
+ message: string
92
+ key: string
93
+ } | null>(null)
94
+ let revealedSecret = $state<{
95
+ key: string
96
+ value: string
97
+ } | null>(null)
98
+ let revealTimer: ReturnType<typeof setTimeout> | null = null
76
99
 
77
100
  const connected = $derived(connection.connected)
78
101
  const activeUrl = $derived(connection.active.url)
102
+ const loadError = $derived(activePane.id === 'secrets' ? secretsLoadError : configLoadError)
103
+ const unreachable = $derived(!connected || loadError !== null)
104
+ const configRows = $derived(filterConfigEntries(configEntries, query))
105
+ const secretRows = $derived(filterSecretEntries(secretEntries, query))
106
+ const activeRowCount = $derived(activePane.id === 'secrets' ? secretRows.length : configRows.length)
107
+ const activeTotalCount = $derived(activePane.id === 'secrets' ? secretEntries.length : configEntries.length)
108
+ const selectedEntry = $derived(
109
+ configRows.find((entry) => entry.key === selectedConfigKey) ?? configRows[0] ?? null,
110
+ )
111
+ const selectedSecret = $derived(
112
+ secretRows.find((entry) => entry.key === selectedSecretKey) ?? secretRows[0] ?? null,
113
+ )
114
+ const selectedControl = $derived(
115
+ selectedEntry ? resolveConfigControl(selectedEntry) : null,
116
+ )
117
+ const selectedSecretControl = $derived(
118
+ selectedSecret ? resolveSecretControl(selectedSecret) : null,
119
+ )
120
+ const canWriteConfig = $derived(
121
+ activePane.id === 'config' &&
122
+ !!activePane.writeGrant &&
123
+ permissions?.cachedCan(activePane.writeGrant) === true,
124
+ )
125
+ const canWriteSecrets = $derived(
126
+ activePane.id === 'secrets' &&
127
+ !!activePane.writeGrant &&
128
+ permissions?.cachedCan(activePane.writeGrant) === true,
129
+ )
130
+ const canRevealSecrets = $derived(
131
+ activePane.id === 'secrets' &&
132
+ !!activePane.revealGrant &&
133
+ permissions?.cachedCan(activePane.revealGrant) === true,
134
+ )
135
+ const selectedSecretPlaintext = $derived(
136
+ selectedSecret && revealedSecret?.key === selectedSecret.key ? revealedSecret.value : null,
137
+ )
79
138
 
80
- async function refresh() {
139
+ function setQuery(value: string) {
140
+ if (!hasVisiblePane) return
141
+ queries = { ...queries, [activePane.id]: value }
142
+ }
143
+
144
+ function setWriteStatus(paneId: string, tone: 'ok' | 'error', message: string) {
145
+ writeStatus = { paneId, tone, message }
146
+ }
147
+
148
+ function clearRevealedSecret() {
149
+ if (revealTimer) {
150
+ clearTimeout(revealTimer)
151
+ revealTimer = null
152
+ }
153
+ revealedSecret = null
154
+ }
155
+
156
+ function showRevealedSecret(key: string, value: string) {
157
+ clearRevealedSecret()
158
+ revealedSecret = { key, value }
159
+ revealTimer = setTimeout(() => {
160
+ revealedSecret = null
161
+ revealTimer = null
162
+ }, 30_000)
163
+ }
164
+
165
+ function selectSecretKey(key: string) {
166
+ if (selectedSecretKey !== key) clearRevealedSecret()
167
+ selectedSecretKey = key
168
+ }
169
+
170
+ function onWindowKey(e: KeyboardEvent) {
171
+ if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
172
+ e.preventDefault()
173
+ searchEl?.focus()
174
+ searchEl?.select()
175
+ }
176
+ }
177
+
178
+ async function refreshPermissions() {
81
179
  const client = connection.client
180
+ const run = ++permissionRun
181
+ permissions = null
182
+ visiblePanes = []
183
+ permissionError = null
184
+ configEntries = []
185
+ secretEntries = []
186
+ selectedConfigKey = null
187
+ selectedSecretKey = null
188
+ writeStatus = null
189
+ editingConfigKey = null
190
+ editingSecretSelectionKey = null
191
+ secretWriteKey = ''
192
+ secretWriteValue = ''
193
+ revealStatus = null
194
+ clearRevealedSecret()
195
+
82
196
  if (!client || !connected) {
83
- values = {}
197
+ permissionsLoading = false
84
198
  return
85
199
  }
86
- loading = true
87
- const next: Record<string, unknown> = {}
88
- const nextDenied: Record<SettingsSource, string | null> = {
89
- stats: null,
90
- cluster: null,
91
- capabilities: null,
92
- session: null,
93
- }
94
-
95
- // /capabilities is the negotiation itself — always available from the store.
96
- Object.assign(next, flattenConfig({ capabilities: connection.capabilities }))
97
200
 
201
+ permissionsLoading = true
202
+ const gate = new PermissionGate(client)
98
203
  try {
99
- const stats = await activity.track('settings · stats', () => client.stats())
100
- Object.assign(next, flattenConfig(stats as unknown as Record<string, unknown>))
204
+ await activity.track('settings · auth.can panes', () =>
205
+ gate.preload(
206
+ SETTINGS_PANES.flatMap((pane) =>
207
+ [pane.readGrant, pane.writeGrant, pane.revealGrant].filter(
208
+ (check): check is NonNullable<typeof check> => !!check,
209
+ ),
210
+ ),
211
+ ),
212
+ )
213
+ if (run !== permissionRun) return
214
+
215
+ permissions = gate
216
+ const nextPanes = filterSettingsPanesByGrant(SETTINGS_PANES, gate)
217
+ visiblePanes = nextPanes
218
+ if (nextPanes.length > 0 && !nextPanes.some((pane) => pane.id === activePaneId)) {
219
+ activePaneId = nextPanes[0].id
220
+ }
101
221
  } catch (e) {
102
- nextDenied.stats = (e as Error).message
222
+ if (run !== permissionRun) return
223
+ visiblePanes = []
224
+ permissions = null
225
+ permissionError = (e as Error).message
226
+ } finally {
227
+ if (run === permissionRun) permissionsLoading = false
228
+ }
229
+ }
230
+
231
+ async function refresh() {
232
+ const client = connection.client
233
+ const gate = permissions
234
+ if (
235
+ !client ||
236
+ !connected ||
237
+ !gate ||
238
+ !hasVisiblePane ||
239
+ !gate.cachedCan(activePane.readGrant)
240
+ ) {
241
+ configEntries = []
242
+ secretEntries = []
243
+ selectedConfigKey = null
244
+ selectedSecretKey = null
245
+ configLoadError = null
246
+ secretsLoadError = null
247
+ return
103
248
  }
104
249
 
105
- // Cluster topology is capability-gated: if the server can't honour
106
- // /cluster/status, those keys are absent + explained, never faked.
107
- if (!connection.capabilities.clusterStatus) {
108
- nextDenied.cluster = 'Server does not expose /cluster/status.'
250
+ loading = true
251
+ if (activePane.id === 'secrets') {
252
+ secretsLoadError = null
253
+ revealStatus = null
254
+ clearRevealedSecret()
109
255
  } else {
110
- try {
111
- const cluster = await activity.track('settings · cluster', () =>
112
- client.clusterStatus(),
256
+ configLoadError = null
257
+ }
258
+ try {
259
+ const authoring = new SettingsAuthoringClient(client)
260
+ if (activePane.id === 'secrets') {
261
+ const next = await activity.track('settings · show secrets', () =>
262
+ authoring.showSecrets(),
113
263
  )
114
- Object.assign(next, flattenConfig(cluster as unknown as Record<string, unknown>))
115
- } catch (e) {
116
- nextDenied.cluster = (e as Error).message
264
+ secretEntries = next
265
+ if (!selectedSecretKey || !next.some((entry) => entry.key === selectedSecretKey)) {
266
+ selectedSecretKey = next[0]?.key ?? null
267
+ }
268
+ } else {
269
+ const next = await activity.track('settings · show config', () =>
270
+ authoring.showConfig(),
271
+ )
272
+ configEntries = next
273
+ if (!selectedConfigKey || !next.some((entry) => entry.key === selectedConfigKey)) {
274
+ selectedConfigKey = next[0]?.key ?? null
275
+ }
276
+ }
277
+ } catch (e) {
278
+ if (activePane.id === 'secrets') {
279
+ secretEntries = []
280
+ selectedSecretKey = null
281
+ secretsLoadError = (e as Error).message
282
+ } else {
283
+ configEntries = []
284
+ selectedConfigKey = null
285
+ configLoadError = (e as Error).message
117
286
  }
287
+ } finally {
288
+ loading = false
289
+ }
290
+ }
291
+
292
+ function parseNewConfigValue() {
293
+ return parseConfigEditValue(
294
+ {
295
+ key: newConfigKey,
296
+ label: newConfigKey,
297
+ kind: newConfigKind,
298
+ },
299
+ newConfigKind === 'boolean' && !newConfigValue.trim() ? 'false' : newConfigValue,
300
+ )
301
+ }
302
+
303
+ async function saveSelectedConfig() {
304
+ const client = connection.client
305
+ if (!client || !selectedEntry || !selectedControl || !canWriteConfig || writing) return
306
+
307
+ const parsed = parseConfigEditValue(selectedControl, editValue)
308
+ if (!parsed.ok) {
309
+ setWriteStatus('config', 'error', parsed.error ?? 'Invalid config value.')
310
+ return
311
+ }
312
+
313
+ writing = true
314
+ writeStatus = null
315
+ try {
316
+ const authoring = new SettingsAuthoringClient(client)
317
+ await activity.track('settings · set config', () =>
318
+ authoring.setConfig(selectedEntry.key, parsed.value ?? null),
319
+ )
320
+ selectedConfigKey = selectedEntry.key
321
+ setWriteStatus('config', 'ok', `${selectedEntry.key} saved.`)
322
+ await refresh()
323
+ } catch (e) {
324
+ setWriteStatus('config', 'error', (e as Error).message)
325
+ } finally {
326
+ writing = false
327
+ }
328
+ }
329
+
330
+ async function unsetSelectedConfig() {
331
+ const client = connection.client
332
+ if (!client || !selectedEntry || !canWriteConfig || writing) return
333
+ if (!window.confirm(`Unset ${selectedEntry.key}?`)) return
334
+
335
+ const key = selectedEntry.key
336
+ writing = true
337
+ writeStatus = null
338
+ try {
339
+ const authoring = new SettingsAuthoringClient(client)
340
+ await activity.track('settings · delete config', () => authoring.unsetConfig(key))
341
+ selectedConfigKey = key
342
+ setWriteStatus('config', 'ok', `${key} unset.`)
343
+ await refresh()
344
+ } catch (e) {
345
+ setWriteStatus('config', 'error', (e as Error).message)
346
+ } finally {
347
+ writing = false
118
348
  }
349
+ }
350
+
351
+ async function setNewConfig() {
352
+ const client = connection.client
353
+ if (!client || !canWriteConfig || writing) return
119
354
 
355
+ const parsed = parseNewConfigValue()
356
+ if (!parsed.ok) {
357
+ setWriteStatus('config', 'error', parsed.error ?? 'Invalid config value.')
358
+ return
359
+ }
360
+
361
+ const key = newConfigKey.trim()
362
+ writing = true
363
+ writeStatus = null
120
364
  try {
121
- const session = await activity.track('settings · whoami', () => client.whoami())
122
- Object.assign(
123
- next,
124
- flattenConfig({ session: session as unknown as Record<string, unknown> }),
365
+ const authoring = new SettingsAuthoringClient(client)
366
+ await activity.track('settings · set config', () =>
367
+ authoring.setConfig(key, parsed.value ?? null),
125
368
  )
369
+ selectedConfigKey = key
370
+ newConfigKey = ''
371
+ newConfigValue = ''
372
+ setWriteStatus('config', 'ok', `${key} set.`)
373
+ await refresh()
374
+ } catch (e) {
375
+ setWriteStatus('config', 'error', (e as Error).message)
376
+ } finally {
377
+ writing = false
378
+ }
379
+ }
380
+
381
+ async function setSecretValue() {
382
+ const client = connection.client
383
+ if (!client || !canWriteSecrets || writing) return
384
+
385
+ const key = secretWriteKey.trim()
386
+ const value = secretWriteValue
387
+ if (!key) {
388
+ setWriteStatus('secrets', 'error', 'Secret key is required.')
389
+ return
390
+ }
391
+ if (!value) {
392
+ setWriteStatus('secrets', 'error', 'Secret value is required.')
393
+ return
394
+ }
395
+
396
+ writing = true
397
+ writeStatus = null
398
+ try {
399
+ const authoring = new SettingsAuthoringClient(client)
400
+ await activity.track('settings · set secret', () => authoring.setSecret(key, value))
401
+ selectedSecretKey = key
402
+ secretWriteValue = ''
403
+ setWriteStatus('secrets', 'ok', `${key} set.`)
404
+ await refresh()
126
405
  } catch (e) {
127
- nextDenied.session = (e as Error).message
406
+ secretWriteValue = ''
407
+ setWriteStatus('secrets', 'error', (e as Error).message)
408
+ } finally {
409
+ writing = false
128
410
  }
411
+ }
412
+
413
+ async function deleteSelectedSecret() {
414
+ const client = connection.client
415
+ if (!client || !selectedSecret || !canWriteSecrets || writing) return
416
+ if (!window.confirm(`Delete secret ${selectedSecret.key}?`)) return
129
417
 
130
- values = next
131
- denied = nextDenied
132
- loading = false
418
+ const key = selectedSecret.key
419
+ writing = true
420
+ writeStatus = null
421
+ try {
422
+ const authoring = new SettingsAuthoringClient(client)
423
+ await activity.track('settings · delete secret', () => authoring.deleteSecret(key))
424
+ secretWriteValue = ''
425
+ selectedSecretKey = key
426
+ setWriteStatus('secrets', 'ok', `${key} deleted.`)
427
+ await refresh()
428
+ } catch (e) {
429
+ secretWriteValue = ''
430
+ setWriteStatus('secrets', 'error', (e as Error).message)
431
+ } finally {
432
+ writing = false
433
+ }
434
+ }
435
+
436
+ async function revealSelectedSecret() {
437
+ const client = connection.client
438
+ if (!client || !selectedSecret || !canRevealSecrets || revealing) return
439
+
440
+ const key = selectedSecret.key
441
+ revealing = true
442
+ revealStatus = null
443
+ clearRevealedSecret()
444
+ try {
445
+ const authoring = new SettingsAuthoringClient(client)
446
+ const revealed = await activity.track('settings · reveal secret', () =>
447
+ authoring.revealSecret(key),
448
+ )
449
+ showRevealedSecret(revealed.key, revealed.value)
450
+ revealStatus = {
451
+ tone: 'ok',
452
+ key,
453
+ message: 'Plaintext revealed temporarily.',
454
+ }
455
+ } catch (e) {
456
+ revealStatus = {
457
+ tone: 'error',
458
+ key,
459
+ message: (e as Error).message,
460
+ }
461
+ } finally {
462
+ revealing = false
463
+ }
133
464
  }
134
465
 
135
466
  $effect(() => {
136
467
  void activeUrl
137
468
  void connected
469
+ refreshPermissions()
470
+ })
471
+
472
+ $effect(() => {
473
+ void activeUrl
474
+ void connected
475
+ void activePaneId
476
+ void permissions
477
+ void visiblePanes
138
478
  refresh()
139
479
  })
140
480
 
141
- type RenderRow =
142
- | { type: 'value'; key: string; label: string; control: ControlDescriptor; value: unknown }
143
- | { type: 'denied'; key: string; label: string; reason: string; grant: string }
144
-
145
- // Resolve the active section's curated keys to render rows. A key whose
146
- // source is denied becomes a "denied" row (absent control + explanation); a
147
- // key with a live value becomes a "value" row; a key the server simply does
148
- // not report is dropped (graceful degradation — no empty placeholder).
149
- const rows = $derived.by<RenderRow[]>(() => {
150
- const out: RenderRow[] = []
151
- for (const key of active.keys) {
152
- const src = sourceForKey(key)
153
- const reason = denied[src]
154
- const label = resolveControl(key).label
155
- if (reason) {
156
- out.push({ type: 'denied', key, label, reason, grant: GRANTS[src] })
157
- } else if (key in values) {
158
- out.push({
159
- type: 'value',
160
- key,
161
- label,
162
- control: resolveControl(key, values[key]),
163
- value: values[key],
164
- })
165
- }
481
+ $effect(() => {
482
+ if (!selectedEntry) {
483
+ editingConfigKey = null
484
+ editValue = ''
485
+ return
486
+ }
487
+ if (editingConfigKey !== selectedEntry.key) {
488
+ editingConfigKey = selectedEntry.key
489
+ editValue = initialConfigEditValue(selectedEntry)
490
+ writeStatus = null
491
+ }
492
+ })
493
+
494
+ $effect(() => {
495
+ if (activePane.id !== 'secrets' || !selectedSecret) return
496
+ if (
497
+ editingSecretSelectionKey !== selectedSecret.key &&
498
+ (!secretWriteKey.trim() || secretWriteKey === editingSecretSelectionKey)
499
+ ) {
500
+ secretWriteKey = selectedSecret.key
501
+ editingSecretSelectionKey = selectedSecret.key
502
+ writeStatus = null
166
503
  }
167
- return out
168
504
  })
169
505
 
170
506
  function formatBytes(n: number): string {
171
507
  if (!Number.isFinite(n)) return String(n)
172
508
  const units = ['B', 'KB', 'MB', 'GB', 'TB']
173
- let v = n
509
+ let value = n
174
510
  let i = 0
175
- while (v >= 1024 && i < units.length - 1) {
176
- v /= 1024
511
+ while (value >= 1024 && i < units.length - 1) {
512
+ value /= 1024
177
513
  i++
178
514
  }
179
- return `${i === 0 ? v : v.toFixed(1)} ${units[i]}`
515
+ return `${i === 0 ? value : value.toFixed(1)} ${units[i]}`
516
+ }
517
+
518
+ function formatSecretReference(ref: SecretReference): string {
519
+ if (ref.masked) return '&***'
520
+ return `&${ref.collection ? `${ref.collection}.` : ''}${ref.key}`
521
+ }
522
+
523
+ function isSecretReferenceValue(value: unknown): value is SecretReference {
524
+ return !!value && typeof value === 'object' && !Array.isArray(value) && 'type' in value && value.type === 'secret_ref'
180
525
  }
181
526
 
182
- function formatValue(control: ControlDescriptor, value: unknown): string {
183
- if (control.kind === 'number' && control.key.endsWith('_bytes')) {
184
- return formatBytes(Number(value))
527
+ function formatValue(entry: ConfigEntry): string {
528
+ const value = entry.value
529
+ if (isSecretReferenceValue(value)) return formatSecretReference(value)
530
+ if (typeof value === 'number' && entry.key.endsWith('_bytes')) return formatBytes(value)
531
+ if (Array.isArray(value)) return `${value.length} item${value.length === 1 ? '' : 's'}`
532
+ if (value && typeof value === 'object') return JSON.stringify(value)
533
+ if (value === null || value === undefined) return 'null'
534
+ return String(value)
535
+ }
536
+
537
+ function formatLongValue(value: unknown): string {
538
+ if (Array.isArray(value) || (value && typeof value === 'object')) {
539
+ return JSON.stringify(value, null, 2)
185
540
  }
541
+ if (value === null || value === undefined) return 'null'
186
542
  return String(value)
187
543
  }
188
544
 
189
- // Reuse the global command palette as the search surface — the same ⌘K idiom
190
- // the topbar uses, so Esc-to-close comes for free from the palette overlay.
191
- function openPalette() {
192
- window.dispatchEvent(
193
- new KeyboardEvent('keydown', { key: 'k', metaKey: true, ctrlKey: true, bubbles: true }),
194
- )
545
+ function exportConfig() {
546
+ const payload = {
547
+ url: activeUrl,
548
+ exportedAt: new Date().toISOString(),
549
+ projection: 'SHOW CONFIG',
550
+ config: configEntries.map((entry) => ({
551
+ key: entry.key,
552
+ value: entry.value,
553
+ value_type: entry.valueType ?? null,
554
+ schema_version: entry.schemaVersion ?? null,
555
+ })),
556
+ }
557
+ const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' })
558
+ const url = URL.createObjectURL(blob)
559
+ const a = document.createElement('a')
560
+ a.href = url
561
+ a.download = `reddb-config-${new Date().toISOString().slice(0, 10)}.json`
562
+ a.click()
563
+ URL.revokeObjectURL(url)
564
+ }
565
+
566
+ function paneCount(pane: SettingsPane): number {
567
+ if (pane.id === 'secrets') return secretEntries.length
568
+ return configEntries.length
195
569
  }
196
570
  </script>
197
571
 
198
- {#if !connected}
199
- <div class="h-full overflow-auto bg-bg-0 p-6">
200
- <PageHeader
201
- eyebrow="Settings"
202
- title="Settings"
203
- subtitle="Live configuration for the active reddb connection."
204
- />
205
- <EmptyState
206
- icon={Unplug}
207
- title="No active connection"
208
- message="Connect to a reddb instance to inspect its live configuration."
209
- hint="red serve"
210
- />
211
- </div>
212
- {:else}
213
- <SplitView searchShortcut={null}>
214
- {#snippet search()}
215
- <button
216
- type="button"
217
- onclick={openPalette}
218
- class="flex h-7 w-full items-center gap-2 rounded-md border border-line-2 bg-bg-2 px-2.5 text-xs text-fg-2 transition-colors hover:border-line-3 hover:text-fg-1"
219
- >
220
- <Search class="size-3.5 text-fg-3" />
221
- <span class="flex-1 text-left">Search settings</span>
222
- <span class="inline-flex gap-0.5"><Kbd>⌘</Kbd><Kbd>K</Kbd></span>
223
- </button>
224
- {/snippet}
225
-
226
- {#snippet nav()}
572
+ <svelte:window onkeydown={onWindowKey} />
573
+
574
+ <div class="@container h-full min-h-0 bg-bg-0">
575
+ <div
576
+ class="grid h-full min-h-0 grid-cols-1 @min-[62rem]:grid-cols-[13rem_minmax(18rem,24rem)_minmax(0,1fr)]"
577
+ >
578
+ <aside
579
+ class="flex min-h-0 flex-col border-b border-line-1 bg-bg-1 @min-[62rem]:border-b-0 @min-[62rem]:border-r"
580
+ >
581
+ <div class="border-b border-line-1 px-3 py-3">
582
+ <div class="type-label">Settings</div>
583
+ <div class="mt-1 text-[13px] text-fg-1">Governance surface</div>
584
+ </div>
227
585
  <nav class="grid gap-0.5 p-2">
228
- {#each SECTIONS as section (section.id)}
229
- {@const SectionIcon = icons[section.icon] ?? Settings2}
586
+ {#each visiblePanes as pane (pane.id)}
587
+ {@const PaneIcon = icons[pane.icon] ?? Settings2}
230
588
  <NavItem
231
- label={section.label}
232
- active={section.id === active.id}
233
- onclick={() => (activeId = section.id)}
589
+ label={pane.label}
590
+ active={pane.id === activePane.id}
591
+ onclick={() => (activePaneId = pane.id)}
234
592
  >
235
- {#snippet icon()}<SectionIcon class="size-3.5" />{/snippet}
593
+ {#snippet icon()}<PaneIcon class="size-3.5" />{/snippet}
594
+ {#snippet trailing()}<Pill>{paneCount(pane)}</Pill>{/snippet}
236
595
  </NavItem>
237
596
  {/each}
238
597
  </nav>
239
- {/snippet}
598
+ <div class="mt-auto border-t border-line-1 px-3 py-2.5 text-[11px] text-fg-3">
599
+ {#if permissionsLoading}
600
+ checking grants
601
+ {:else if hasVisiblePane}
602
+ <span class="font-mono text-fg-2">{activeTotalCount}</span> live rows
603
+ {:else}
604
+ no readable panes
605
+ {/if}
606
+ </div>
607
+ </aside>
240
608
 
241
- {#snippet footer()}
242
- <div class="px-3 py-2.5 text-[11px] text-fg-3">
243
- Live from <span class="font-mono text-fg-2">{activeUrl}</span>.
609
+ <section
610
+ class="flex min-h-0 flex-col border-b border-line-1 bg-bg-1 @min-[62rem]:border-b-0 @min-[62rem]:border-r"
611
+ aria-label={`${activePane.label} entries`}
612
+ >
613
+ <div class="border-b border-line-1 p-2">
614
+ <div class="relative">
615
+ <Search
616
+ class="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-fg-3"
617
+ />
618
+ <input
619
+ bind:this={searchEl}
620
+ type="text"
621
+ placeholder={`Search ${activePane.label.toLowerCase()} ⌘K`}
622
+ value={query}
623
+ disabled={permissionsLoading || !hasVisiblePane || unreachable}
624
+ oninput={(e) => setQuery(e.currentTarget.value)}
625
+ onkeydown={(e) => {
626
+ if (e.key === 'Escape') {
627
+ setQuery('')
628
+ e.currentTarget.blur()
629
+ }
630
+ }}
631
+ class="h-7 w-full rounded-md border border-line-2 bg-bg-2 pl-8 pr-2.5 text-xs text-fg-1 placeholder:text-fg-3 outline-none transition-colors focus:border-line-3 disabled:cursor-not-allowed disabled:opacity-50"
632
+ />
633
+ </div>
244
634
  </div>
245
- {/snippet}
246
635
 
247
- {#snippet content()}
248
- {@const SectionIcon = icons[active.icon] ?? Settings2}
636
+ <div class="min-h-0 flex-1 overflow-y-auto">
637
+ {#if permissionsLoading}
638
+ <div class="grid gap-1 p-2">
639
+ {#each Array(4) as _, i (i)}
640
+ <div class="h-12 rounded-md bg-bg-2 motion-safe:animate-pulse"></div>
641
+ {/each}
642
+ </div>
643
+ {:else if !hasVisiblePane}
644
+ <EmptyState
645
+ icon={ShieldOff}
646
+ title={connected ? 'No readable settings panes' : 'No active connection'}
647
+ message={connected
648
+ ? 'The current principal has no Settings read grant.'
649
+ : 'Connect to a reddb instance to inspect its live configuration.'}
650
+ hint={permissionError ?? (connected ? 'auth.can denied settings reads' : 'red serve')}
651
+ />
652
+ {:else if unreachable}
653
+ <EmptyState
654
+ icon={Unplug}
655
+ title={connected ? `${activePane.label} is unreachable` : 'No active connection'}
656
+ message={connected
657
+ ? `red-ui could not read ${activePane.id === 'secrets' ? 'SHOW SECRETS' : 'SHOW CONFIG'} from the active target.`
658
+ : 'Connect to a reddb instance to inspect its live configuration.'}
659
+ hint={connected ? loadError ?? activeUrl : 'red serve'}
660
+ />
661
+ {:else if loading && activeRowCount === 0}
662
+ <div class="grid gap-1 p-2">
663
+ {#each Array(8) as _, i (i)}
664
+ <div class="h-12 rounded-md bg-bg-2 motion-safe:animate-pulse"></div>
665
+ {/each}
666
+ </div>
667
+ {:else if activeRowCount === 0}
668
+ <EmptyState
669
+ icon={Search}
670
+ title={query ? `No ${activePane.label.toLowerCase()} match` : `No ${activePane.label.toLowerCase()} reported`}
671
+ message={query
672
+ ? `Search checks the ${activePane.label.toLowerCase()} key and metadata.`
673
+ : `${activePane.id === 'secrets' ? 'SHOW SECRETS' : 'SHOW CONFIG'} returned no rows for this connection.`}
674
+ />
675
+ {:else if activePane.id === 'secrets'}
676
+ <div class="grid gap-1 p-2">
677
+ {#each secretRows as entry (entry.key)}
678
+ {@const control = resolveSecretControl(entry)}
679
+ <div
680
+ class={[
681
+ 'grid grid-cols-[minmax(0,1fr)_auto] items-center gap-2 rounded-md px-2.5 py-2 transition-colors',
682
+ selectedSecret?.key === entry.key
683
+ ? 'bg-bg-3 text-fg-0'
684
+ : 'text-fg-1 hover:bg-bg-2',
685
+ ].join(' ')}
686
+ >
687
+ <button
688
+ type="button"
689
+ aria-current={selectedSecret?.key === entry.key ? 'page' : undefined}
690
+ onclick={() => selectSecretKey(entry.key)}
691
+ class="min-w-0 text-left"
692
+ >
693
+ <span class="block truncate text-[13px]">{control.label}</span>
694
+ <span class="block truncate font-mono text-[11px] text-fg-3">{entry.key}</span>
695
+ </button>
696
+
697
+ <div class="inline-flex h-6 items-center gap-1.5 rounded border border-line-2 bg-bg-2 px-1.5 font-mono text-[11px] text-fg-2">
698
+ <KeyRound class="size-3" />
699
+ {entry.value}
700
+ </div>
701
+ </div>
702
+ {/each}
703
+ </div>
704
+ {:else}
705
+ <div class="grid gap-1 p-2">
706
+ {#each configRows as entry (entry.key)}
707
+ {@const control = resolveConfigControl(entry)}
708
+ <div
709
+ class={[
710
+ 'grid grid-cols-[minmax(0,1fr)_auto] items-center gap-2 rounded-md px-2.5 py-2 transition-colors',
711
+ selectedEntry?.key === entry.key
712
+ ? 'bg-bg-3 text-fg-0'
713
+ : 'text-fg-1 hover:bg-bg-2',
714
+ ].join(' ')}
715
+ >
716
+ <button
717
+ type="button"
718
+ aria-current={selectedEntry?.key === entry.key ? 'page' : undefined}
719
+ onclick={() => (selectedConfigKey = entry.key)}
720
+ class="min-w-0 text-left"
721
+ >
722
+ <span class="block truncate text-[13px]">{control.label}</span>
723
+ <span class="block truncate font-mono text-[11px] text-fg-3">{entry.key}</span>
724
+ </button>
725
+
726
+ {#if control.kind === 'boolean'}
727
+ <label
728
+ class="inline-flex h-6 items-center gap-1.5 rounded border border-line-2 bg-bg-2 px-1.5 font-mono text-[11px] text-fg-2"
729
+ >
730
+ <input
731
+ type="checkbox"
732
+ checked={entry.value === true}
733
+ disabled
734
+ tabindex="-1"
735
+ class="size-3 accent-current"
736
+ />
737
+ {entry.value ? 'true' : 'false'}
738
+ </label>
739
+ {:else if control.kind === 'enum'}
740
+ <select
741
+ disabled
742
+ tabindex="-1"
743
+ value={String(entry.value)}
744
+ class="h-6 w-28 rounded border border-line-2 bg-bg-2 px-1.5 font-mono text-[11px] text-accent disabled:opacity-100"
745
+ aria-label={control.label}
746
+ >
747
+ {#if !control.options?.includes(String(entry.value))}
748
+ <option value={String(entry.value)}>{formatValue(entry)}</option>
749
+ {/if}
750
+ {#each control.options ?? [] as option}
751
+ <option value={option}>{option}</option>
752
+ {/each}
753
+ </select>
754
+ {:else}
755
+ <input
756
+ readonly
757
+ tabindex="-1"
758
+ aria-label={control.label}
759
+ value={formatValue(entry)}
760
+ class="h-6 w-28 rounded border border-line-2 bg-bg-2 px-1.5 font-mono text-[11px] text-fg-2 outline-none"
761
+ />
762
+ {/if}
763
+ </div>
764
+ {/each}
765
+ </div>
766
+ {/if}
767
+ </div>
768
+
769
+ <div class="grid gap-2 border-t border-line-1 px-3 py-2.5">
770
+ {#if activePane.id === 'config'}
771
+ <button
772
+ type="button"
773
+ onclick={exportConfig}
774
+ disabled={permissionsLoading || !hasVisiblePane || loading || configEntries.length === 0}
775
+ class="inline-flex h-7 items-center justify-center gap-1.5 rounded-md border border-line-2 bg-bg-2 px-2.5 text-[11px] font-mono text-fg-1 transition-colors hover:border-line-3 hover:text-fg-0 disabled:cursor-not-allowed disabled:opacity-40"
776
+ >
777
+ <Download class="size-3.5" />
778
+ export config
779
+ </button>
780
+ {/if}
781
+ <div class="truncate text-[11px] text-fg-3">
782
+ {#if hasVisiblePane}
783
+ Live from <span class="font-mono text-fg-2">{activeUrl}</span>.
784
+ {:else}
785
+ Requires a Settings read grant.
786
+ {/if}
787
+ </div>
788
+ </div>
789
+ </section>
790
+
791
+ <main class="min-h-0 min-w-0 overflow-auto bg-bg-0">
249
792
  <div class="p-6">
250
- <PageHeader eyebrow="Settings" title={active.label} subtitle={active.blurb}>
793
+ {#if permissionsLoading}
794
+ <PageHeader eyebrow="Settings" title="Settings" subtitle="Checking Settings grants." />
795
+ <EmptyState
796
+ icon={ShieldOff}
797
+ title="Checking settings grants"
798
+ message="red-ui is asking reddb which Settings panes this principal can read."
799
+ hint="POST /auth/can"
800
+ />
801
+ {:else if !hasVisiblePane}
802
+ <PageHeader eyebrow="Settings" title="Settings" subtitle="No readable governance surface." />
803
+ <EmptyState
804
+ icon={ShieldOff}
805
+ title={connected ? 'No readable settings panes' : 'No active connection'}
806
+ message={connected
807
+ ? 'Missing Settings read grant for this principal.'
808
+ : 'Start or select a reddb target, then return here to inspect live config.'}
809
+ hint={permissionError ?? (connected ? 'missing Settings read grant' : 'docker compose -f docker/compose.yml up -d')}
810
+ />
811
+ {:else}
812
+ <PageHeader eyebrow="Settings" title={activePane.label} subtitle={activePane.blurb}>
251
813
  {#snippet actions()}
252
814
  <button
253
815
  type="button"
254
816
  onclick={refresh}
255
- disabled={loading}
817
+ disabled={loading || !connected}
256
818
  class="inline-flex h-7 items-center gap-1.5 rounded-md border border-line-2 bg-bg-2 px-2.5 text-[11px] font-mono text-fg-1 hover:border-line-3 hover:text-fg-0 disabled:cursor-not-allowed disabled:opacity-40"
257
819
  >
258
820
  <RefreshCw class={loading ? 'size-3.5 animate-spin' : 'size-3.5'} />
259
821
  refresh
260
822
  </button>
261
823
  {/snippet}
262
- </PageHeader>
824
+ </PageHeader>
263
825
 
264
- <section class="mb-6">
265
- <SectionHeading title={active.label} class="mb-2">
266
- {#snippet icon()}<SectionIcon class="size-3.5" />{/snippet}
267
- {#snippet meta()}<Pill>{rows.length}</Pill>{/snippet}
268
- </SectionHeading>
826
+ {#if unreachable}
827
+ <EmptyState
828
+ icon={Unplug}
829
+ title={connected ? `Unable to read ${activePane.label.toLowerCase()}` : 'No active connection'}
830
+ message={connected
831
+ ? `The ${activePane.label} pane is intentionally empty because ${activePane.id === 'secrets' ? 'SHOW SECRETS' : 'SHOW CONFIG'} did not complete.`
832
+ : 'Start or select a reddb target, then return here to inspect live config.'}
833
+ hint={connected ? loadError ?? activeUrl : 'docker compose -f docker/compose.yml up -d'}
834
+ />
835
+ {:else if activePane.id === 'secrets'}
836
+ <section class="grid gap-4">
837
+ {#if !selectedSecret || !selectedSecretControl}
838
+ <EmptyState
839
+ icon={KeyRound}
840
+ title="No secret selected"
841
+ message="Select a live SHOW SECRETS row to inspect its masked metadata."
842
+ />
843
+ {:else}
844
+ <section>
845
+ <SectionHeading title={selectedSecretControl.label} class="mb-2">
846
+ {#snippet icon()}<KeyRound class="size-3.5" />{/snippet}
847
+ {#snippet meta()}<Pill>masked</Pill>{/snippet}
848
+ </SectionHeading>
269
849
 
270
- {#if rows.length === 0}
271
- <div
272
- class="rounded-lg border border-dashed border-line-1 bg-bg-1 px-3 py-6 text-center text-[12px] text-fg-3"
273
- >
274
- This server reports nothing for these keys.
275
- </div>
276
- {:else}
277
- <div
278
- class="divide-y divide-line-1 overflow-hidden rounded-lg border border-line-1 bg-bg-1"
279
- >
280
- {#each rows as row (row.key)}
281
- {#if row.type === 'denied'}
282
- <ListRow title={row.label} hint={row.key}>
283
- {#snippet action()}
284
- <span
285
- class="inline-flex items-center gap-1.5 rounded-md border border-line-2 bg-bg-2 px-2 py-1 text-[11px] font-mono text-fg-3"
286
- title={row.reason}
850
+ <div class="divide-y divide-line-1 overflow-hidden rounded-lg border border-line-1 bg-bg-1">
851
+ <ListRow
852
+ title={selectedSecretControl.label}
853
+ description="Vault metadata row; plaintext is masked unless break-glass reveal is granted."
854
+ hint={selectedSecret.key}
855
+ wide
856
+ >
857
+ {#snippet action()}
858
+ <input
859
+ readonly
860
+ value={selectedSecretPlaintext ?? selectedSecret.value}
861
+ aria-label={selectedSecretControl.label}
862
+ class="h-8 w-full rounded-md border border-line-2 bg-bg-2 px-2.5 font-mono text-[12px] text-fg-1 outline-none"
863
+ />
864
+ {/snippet}
865
+ </ListRow>
866
+
867
+ <ListRow title="Key" hint={selectedSecret.key}>
868
+ {#snippet action()}
869
+ <span class="font-mono text-[12px] text-fg-1">{selectedSecret.key}</span>
870
+ {/snippet}
871
+ </ListRow>
872
+
873
+ <ListRow title="Metadata">
874
+ {#snippet action()}
875
+ <div class="flex flex-wrap justify-end gap-1.5">
876
+ <Pill>{selectedSecret.status ?? 'status unknown'}</Pill>
877
+ <Pill
878
+ >version {selectedSecret.version === undefined
879
+ ? 'unknown'
880
+ : selectedSecret.version}</Pill
881
+ >
882
+ </div>
883
+ {/snippet}
884
+ </ListRow>
885
+
886
+ {#if canRevealSecrets}
887
+ <ListRow title="Break-glass reveal" description="Calls reddb's audited UNSEAL VAULT path and clears plaintext after 30 seconds.">
888
+ {#snippet action()}
889
+ {#if selectedSecretPlaintext !== null}
890
+ <button
891
+ type="button"
892
+ onclick={clearRevealedSecret}
893
+ class="inline-flex h-8 items-center gap-1.5 rounded-md border border-line-2 bg-bg-2 px-2.5 text-[11px] font-mono text-fg-1 hover:border-line-3 hover:text-fg-0"
894
+ >
895
+ <EyeOff class="size-3.5" />
896
+ hide
897
+ </button>
898
+ {:else}
899
+ <button
900
+ type="button"
901
+ onclick={revealSelectedSecret}
902
+ disabled={revealing}
903
+ class="inline-flex h-8 items-center gap-1.5 rounded-md border border-warn/40 bg-warn/5 px-2.5 text-[11px] font-mono text-warn hover:border-warn/70 disabled:cursor-not-allowed disabled:opacity-40"
904
+ >
905
+ <Eye class={revealing ? 'size-3.5 animate-pulse' : 'size-3.5'} />
906
+ reveal
907
+ </button>
908
+ {/if}
909
+ {/snippet}
910
+ </ListRow>
911
+ {/if}
912
+
913
+ {#if canWriteSecrets}
914
+ <ListRow title="Authoring" description="Delete the selected vault secret through DELETE SECRET.">
915
+ {#snippet action()}
916
+ <button
917
+ type="button"
918
+ onclick={deleteSelectedSecret}
919
+ disabled={writing}
920
+ class="inline-flex h-8 items-center gap-1.5 rounded-md border border-danger/30 bg-danger/5 px-2.5 text-[11px] font-mono text-danger hover:border-danger/60 disabled:cursor-not-allowed disabled:opacity-40"
921
+ >
922
+ <Trash2 class="size-3.5" />
923
+ delete
924
+ </button>
925
+ {/snippet}
926
+ </ListRow>
927
+ {/if}
928
+ </div>
929
+ </section>
930
+ {/if}
931
+
932
+ {#if revealStatus && revealStatus.key === selectedSecret?.key}
933
+ <div
934
+ class={[
935
+ 'inline-flex max-w-full items-center gap-1.5 rounded-md border px-2.5 py-1.5 font-mono text-[11px]',
936
+ revealStatus.tone === 'ok'
937
+ ? 'border-ok/30 bg-ok/5 text-ok'
938
+ : 'border-danger/30 bg-danger/5 text-danger',
939
+ ].join(' ')}
940
+ role="status"
941
+ >
942
+ <AlertCircle class="size-3 shrink-0" />
943
+ <span class="truncate">{revealStatus.message}</span>
944
+ </div>
945
+ {/if}
946
+
947
+ {#if canWriteSecrets}
948
+ {#if writeStatus?.paneId === 'secrets'}
949
+ <div
950
+ class={[
951
+ 'inline-flex max-w-full items-center gap-1.5 rounded-md border px-2.5 py-1.5 font-mono text-[11px]',
952
+ writeStatus.tone === 'ok'
953
+ ? 'border-ok/30 bg-ok/5 text-ok'
954
+ : 'border-danger/30 bg-danger/5 text-danger',
955
+ ].join(' ')}
956
+ role="status"
957
+ >
958
+ <AlertCircle class="size-3 shrink-0" />
959
+ <span class="truncate">{writeStatus.message}</span>
960
+ </div>
961
+ {/if}
962
+
963
+ <section>
964
+ <SectionHeading title="Set secret" class="mb-2">
965
+ {#snippet icon()}<Plus class="size-3.5" />{/snippet}
966
+ </SectionHeading>
967
+ <div class="grid gap-2 rounded-lg border border-line-1 bg-bg-1 p-3">
968
+ <input
969
+ type="text"
970
+ value={secretWriteKey}
971
+ oninput={(e) => (secretWriteKey = e.currentTarget.value)}
972
+ placeholder="mycompany.payments.key"
973
+ disabled={writing}
974
+ aria-label="Secret key"
975
+ class="h-8 rounded-md border border-line-2 bg-bg-2 px-2.5 font-mono text-[12px] text-fg-1 outline-none placeholder:text-fg-3 disabled:cursor-not-allowed disabled:opacity-50"
976
+ />
977
+ <input
978
+ type="password"
979
+ value={secretWriteValue}
980
+ oninput={(e) => (secretWriteValue = e.currentTarget.value)}
981
+ placeholder="New secret value"
982
+ disabled={writing}
983
+ autocomplete="new-password"
984
+ aria-label="New secret value"
985
+ class="h-8 rounded-md border border-line-2 bg-bg-2 px-2.5 font-mono text-[12px] text-fg-1 outline-none placeholder:text-fg-3 disabled:cursor-not-allowed disabled:opacity-50"
986
+ />
987
+ <div class="flex justify-end">
988
+ <button
989
+ type="button"
990
+ onclick={setSecretValue}
991
+ disabled={writing || !secretWriteKey.trim() || !secretWriteValue}
992
+ class="inline-flex h-8 shrink-0 items-center gap-1.5 rounded-md border border-line-2 bg-bg-2 px-2.5 text-[11px] font-mono text-fg-1 hover:border-line-3 hover:text-fg-0 disabled:cursor-not-allowed disabled:opacity-40"
287
993
  >
288
- <Lock class="size-3" />
289
- requires {row.grant}
290
- </span>
291
- {/snippet}
292
- </ListRow>
994
+ <Save class="size-3.5" />
995
+ set
996
+ </button>
997
+ </div>
998
+ </div>
999
+ </section>
1000
+ {/if}
1001
+ </section>
1002
+ {:else if !selectedEntry || !selectedControl}
1003
+ <EmptyState
1004
+ icon={FileJson}
1005
+ title="No config entry selected"
1006
+ message="Select a live SHOW CONFIG row to inspect its read-only control."
1007
+ />
1008
+ {:else}
1009
+ <section>
1010
+ <SectionHeading title={selectedControl.label} class="mb-2">
1011
+ {#snippet icon()}
1012
+ {#if selectedControl.kind === 'secret-reference'}
1013
+ <Link2 class="size-3.5" />
293
1014
  {:else}
294
- <ListRow
295
- title={row.label}
296
- description={row.control.description}
297
- hint={row.key}
298
- >
299
- {#snippet action()}
300
- {#if row.control.kind === 'boolean'}
301
- <Pill tone={row.value ? 'accent' : 'muted'}>{row.value ? 'on' : 'off'}</Pill>
302
- {:else if row.control.kind === 'enum'}
303
- <span
304
- class="font-mono text-[12px] text-accent"
305
- title={`Options: ${row.control.options?.join(', ')}`}
306
- >{row.value}</span>
307
- {:else}
308
- <span class="font-mono text-[12px] text-fg-1"
309
- >{formatValue(row.control, row.value)}</span
310
- >
311
- {/if}
312
- {/snippet}
313
- </ListRow>
1015
+ <SlidersHorizontal class="size-3.5" />
314
1016
  {/if}
315
- {/each}
1017
+ {/snippet}
1018
+ {#snippet meta()}<Pill>{selectedControl.kind}</Pill>{/snippet}
1019
+ </SectionHeading>
1020
+
1021
+ <div class="divide-y divide-line-1 overflow-hidden rounded-lg border border-line-1 bg-bg-1">
1022
+ <ListRow
1023
+ title={selectedControl.label}
1024
+ description={selectedControl.description}
1025
+ hint={selectedEntry.key}
1026
+ wide
1027
+ >
1028
+ {#snippet action()}
1029
+ {#if selectedControl.kind === 'boolean'}
1030
+ <label
1031
+ class="inline-flex h-8 items-center gap-2 rounded-md border border-line-2 bg-bg-2 px-2.5 font-mono text-[12px] text-fg-1"
1032
+ >
1033
+ <input
1034
+ type="checkbox"
1035
+ checked={canWriteConfig ? editValue === 'true' : selectedEntry.value === true}
1036
+ disabled={!canWriteConfig || writing}
1037
+ onchange={(e) => (editValue = e.currentTarget.checked ? 'true' : 'false')}
1038
+ class="size-3 accent-current"
1039
+ />
1040
+ {canWriteConfig ? editValue : selectedEntry.value ? 'true' : 'false'}
1041
+ </label>
1042
+ {:else if selectedControl.kind === 'enum'}
1043
+ <select
1044
+ disabled={!canWriteConfig || writing}
1045
+ value={canWriteConfig ? editValue : String(selectedEntry.value)}
1046
+ onchange={(e) => (editValue = e.currentTarget.value)}
1047
+ class="h-8 min-w-[12rem] rounded-md border border-line-2 bg-bg-2 px-2.5 font-mono text-[12px] text-fg-1 disabled:opacity-100"
1048
+ >
1049
+ {#if !selectedControl.options?.includes(String(selectedEntry.value))}
1050
+ <option value={String(selectedEntry.value)}>{formatValue(selectedEntry)}</option>
1051
+ {/if}
1052
+ {#each selectedControl.options ?? [] as option}
1053
+ <option value={option}>{option}</option>
1054
+ {/each}
1055
+ </select>
1056
+ {:else if selectedControl.kind === 'list'}
1057
+ <textarea
1058
+ readonly={!canWriteConfig}
1059
+ disabled={writing}
1060
+ rows="5"
1061
+ value={canWriteConfig ? editValue : formatLongValue(selectedEntry.value)}
1062
+ oninput={(e) => (editValue = e.currentTarget.value)}
1063
+ class="min-h-24 w-full resize-y rounded-md border border-line-2 bg-bg-2 px-2.5 py-2 font-mono text-[12px] text-fg-1 outline-none"
1064
+ ></textarea>
1065
+ {:else if selectedControl.kind === 'secret-reference'}
1066
+ <input
1067
+ readonly
1068
+ type="text"
1069
+ value={formatValue(selectedEntry)}
1070
+ class="h-8 w-full rounded-md border border-line-2 bg-bg-2 px-2.5 font-mono text-[12px] text-fg-1 outline-none"
1071
+ />
1072
+ {:else}
1073
+ <input
1074
+ readonly={!canWriteConfig}
1075
+ disabled={writing}
1076
+ type={selectedControl.kind === 'number' ? 'text' : 'text'}
1077
+ value={canWriteConfig ? editValue : formatValue(selectedEntry)}
1078
+ oninput={(e) => (editValue = e.currentTarget.value)}
1079
+ class="h-8 w-full rounded-md border border-line-2 bg-bg-2 px-2.5 font-mono text-[12px] text-fg-1 outline-none"
1080
+ />
1081
+ {/if}
1082
+ {/snippet}
1083
+ </ListRow>
1084
+
1085
+ <ListRow title="Key" hint={selectedEntry.key}>
1086
+ {#snippet action()}
1087
+ <span class="font-mono text-[12px] text-fg-1">{selectedEntry.key}</span>
1088
+ {/snippet}
1089
+ </ListRow>
1090
+
1091
+ <ListRow title="Schema">
1092
+ {#snippet action()}
1093
+ <div class="flex flex-wrap justify-end gap-1.5">
1094
+ <Pill>{selectedControl.valueType ?? 'runtime type'}</Pill>
1095
+ <Pill
1096
+ >schema {selectedControl.schemaVersion === undefined
1097
+ ? 'unknown'
1098
+ : selectedControl.schemaVersion}</Pill
1099
+ >
1100
+ </div>
1101
+ {/snippet}
1102
+ </ListRow>
1103
+
1104
+ {#if canWriteConfig}
1105
+ <ListRow title="Authoring" description="Submit changes through SET CONFIG or unset through DELETE CONFIG.">
1106
+ {#snippet action()}
1107
+ <div class="flex flex-wrap justify-end gap-2">
1108
+ <button
1109
+ type="button"
1110
+ onclick={saveSelectedConfig}
1111
+ disabled={writing || selectedControl.kind === 'secret-reference'}
1112
+ title={selectedControl.kind === 'secret-reference'
1113
+ ? 'Secret references can be unset, but not edited as plaintext config.'
1114
+ : 'Save config value'}
1115
+ class="inline-flex h-8 items-center gap-1.5 rounded-md border border-line-2 bg-bg-2 px-2.5 text-[11px] font-mono text-fg-1 hover:border-line-3 hover:text-fg-0 disabled:cursor-not-allowed disabled:opacity-40"
1116
+ >
1117
+ <Save class="size-3.5" />
1118
+ save
1119
+ </button>
1120
+ <button
1121
+ type="button"
1122
+ onclick={unsetSelectedConfig}
1123
+ disabled={writing}
1124
+ class="inline-flex h-8 items-center gap-1.5 rounded-md border border-danger/30 bg-danger/5 px-2.5 text-[11px] font-mono text-danger hover:border-danger/60 disabled:cursor-not-allowed disabled:opacity-40"
1125
+ >
1126
+ <Trash2 class="size-3.5" />
1127
+ unset
1128
+ </button>
1129
+ </div>
1130
+ {/snippet}
1131
+ </ListRow>
1132
+ {/if}
316
1133
  </div>
1134
+
1135
+ {#if canWriteConfig}
1136
+ {#if writeStatus?.paneId === 'config'}
1137
+ <div
1138
+ class={[
1139
+ 'mt-3 inline-flex max-w-full items-center gap-1.5 rounded-md border px-2.5 py-1.5 font-mono text-[11px]',
1140
+ writeStatus.tone === 'ok'
1141
+ ? 'border-ok/30 bg-ok/5 text-ok'
1142
+ : 'border-danger/30 bg-danger/5 text-danger',
1143
+ ].join(' ')}
1144
+ role="status"
1145
+ >
1146
+ <AlertCircle class="size-3 shrink-0" />
1147
+ <span class="truncate">{writeStatus.message}</span>
1148
+ </div>
1149
+ {/if}
1150
+
1151
+ <section class="mt-4 border-t border-line-1 pt-4">
1152
+ <SectionHeading title="Set config key" class="mb-2">
1153
+ {#snippet icon()}<Plus class="size-3.5" />{/snippet}
1154
+ </SectionHeading>
1155
+ <div class="grid gap-2 rounded-lg border border-line-1 bg-bg-1 p-3">
1156
+ <div class="grid gap-2 @min-[42rem]:grid-cols-[minmax(0,1fr)_8rem]">
1157
+ <input
1158
+ type="text"
1159
+ value={newConfigKey}
1160
+ oninput={(e) => (newConfigKey = e.currentTarget.value)}
1161
+ placeholder="red.example.key"
1162
+ disabled={writing}
1163
+ aria-label="New config key"
1164
+ class="h-8 rounded-md border border-line-2 bg-bg-2 px-2.5 font-mono text-[12px] text-fg-1 outline-none placeholder:text-fg-3 disabled:cursor-not-allowed disabled:opacity-50"
1165
+ />
1166
+ <select
1167
+ value={newConfigKind}
1168
+ onchange={(e) => (newConfigKind = e.currentTarget.value as typeof newConfigKind)}
1169
+ disabled={writing}
1170
+ aria-label="New config value type"
1171
+ class="h-8 rounded-md border border-line-2 bg-bg-2 px-2.5 font-mono text-[12px] text-fg-1 outline-none disabled:cursor-not-allowed disabled:opacity-50"
1172
+ >
1173
+ <option value="text">text</option>
1174
+ <option value="number">number</option>
1175
+ <option value="boolean">boolean</option>
1176
+ <option value="list">list</option>
1177
+ </select>
1178
+ </div>
1179
+
1180
+ {#if newConfigKind === 'boolean'}
1181
+ <label class="inline-flex h-8 w-fit items-center gap-2 rounded-md border border-line-2 bg-bg-2 px-2.5 font-mono text-[12px] text-fg-1">
1182
+ <input
1183
+ type="checkbox"
1184
+ checked={newConfigValue === 'true'}
1185
+ disabled={writing}
1186
+ onchange={(e) => (newConfigValue = e.currentTarget.checked ? 'true' : 'false')}
1187
+ class="size-3 accent-current"
1188
+ />
1189
+ {newConfigValue === 'true' ? 'true' : 'false'}
1190
+ </label>
1191
+ {:else if newConfigKind === 'list'}
1192
+ <textarea
1193
+ rows="4"
1194
+ value={newConfigValue}
1195
+ oninput={(e) => (newConfigValue = e.currentTarget.value)}
1196
+ placeholder='["main", "release"]'
1197
+ disabled={writing}
1198
+ aria-label="New config value"
1199
+ class="min-h-20 resize-y rounded-md border border-line-2 bg-bg-2 px-2.5 py-2 font-mono text-[12px] text-fg-1 outline-none placeholder:text-fg-3 disabled:cursor-not-allowed disabled:opacity-50"
1200
+ ></textarea>
1201
+ {:else}
1202
+ <input
1203
+ type="text"
1204
+ value={newConfigValue}
1205
+ oninput={(e) => (newConfigValue = e.currentTarget.value)}
1206
+ placeholder={newConfigKind === 'number' ? '1000' : 'value'}
1207
+ disabled={writing}
1208
+ aria-label="New config value"
1209
+ class="h-8 rounded-md border border-line-2 bg-bg-2 px-2.5 font-mono text-[12px] text-fg-1 outline-none placeholder:text-fg-3 disabled:cursor-not-allowed disabled:opacity-50"
1210
+ />
1211
+ {/if}
1212
+
1213
+ <div class="flex justify-end">
1214
+ <button
1215
+ type="button"
1216
+ onclick={setNewConfig}
1217
+ disabled={writing || !newConfigKey.trim()}
1218
+ class="inline-flex h-8 shrink-0 items-center gap-1.5 rounded-md border border-line-2 bg-bg-2 px-2.5 text-[11px] font-mono text-fg-1 hover:border-line-3 hover:text-fg-0 disabled:cursor-not-allowed disabled:opacity-40"
1219
+ >
1220
+ <Plus class="size-3.5" />
1221
+ set
1222
+ </button>
1223
+ </div>
1224
+ </div>
1225
+ </section>
1226
+ {/if}
1227
+ </section>
317
1228
  {/if}
318
- </section>
1229
+ {/if}
319
1230
  </div>
320
- {/snippet}
321
- </SplitView>
322
- {/if}
1231
+ </main>
1232
+ </div>
1233
+ </div>