@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,77 +1,170 @@
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
- } from '@reddb-io/ui-kit'
20
- import {
21
- Settings2,
22
- Database,
23
- Network,
24
- Plug,
25
- Shield,
26
- Search,
27
- RefreshCw,
5
+ AlertCircle,
28
6
  Download,
29
- Lock,
7
+ FileJson,
8
+ KeyRound,
9
+ Eye,
10
+ EyeOff,
11
+ Link2,
12
+ Plus,
13
+ RefreshCw,
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'
37
28
  import {
38
- SECTIONS,
39
- resolveSection,
40
- resolveControl,
41
- sourceForKey,
42
- flattenConfig,
43
- filterKeys,
29
+ SettingsAuthoringClient,
30
+ type ConfigEntry,
31
+ type SecretEntry,
32
+ type SecretReference,
33
+ } from '$lib/settings-authoring-client'
34
+ import {
35
+ SETTINGS_PANES,
36
+ filterConfigEntries,
37
+ filterSecretEntries,
38
+ filterSettingsPanesByGrant,
39
+ initialConfigEditValue,
40
+ parseConfigEditValue,
41
+ resolveConfigControl,
42
+ resolvePane,
43
+ resolveSecretControl,
44
44
  type ControlDescriptor,
45
- type SettingsSource,
45
+ type SettingsPane,
46
46
  } from '$lib/settings-sections'
47
47
 
48
48
  const icons: Record<string, typeof Icon> = {
49
+ key: KeyRound,
49
50
  settings: Settings2,
50
- database: Database,
51
- network: Network,
52
- plug: Plug,
53
- shield: Shield,
54
51
  }
55
52
 
56
- // The grant a denied source is gated on — shown on the "absent" chip.
57
- const GRANTS: Record<SettingsSource, string> = {
58
- stats: 'stats:read',
59
- cluster: 'cluster:status',
60
- capabilities: 'capabilities',
61
- session: 'auth:whoami',
62
- }
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
63
59
 
64
- let activeId = $state(SECTIONS[0].id)
65
- const active = $derived(resolveSection(activeId))
60
+ const hasVisiblePane = $derived(visiblePanes.length > 0)
61
+ const activePane = $derived(resolvePane(activePaneId, visiblePanes))
66
62
 
67
- // Per-section search query, keyed by section id, so switching tabs preserves
68
- // each section's typed query. The input is focusable via the ⌘K idiom.
69
63
  let queries = $state<Record<string, string>>({})
70
64
  let searchEl = $state<HTMLInputElement | null>(null)
71
- const query = $derived(queries[activeId] ?? '')
65
+ const query = $derived(hasVisiblePane ? (queries[activePane.id] ?? '') : '')
72
66
 
73
- function setQuery(v: string) {
74
- queries = { ...queries, [activeId]: v }
67
+ let configEntries = $state<ConfigEntry[]>([])
68
+ let secretEntries = $state<SecretEntry[]>([])
69
+ let selectedConfigKey = $state<string | null>(null)
70
+ let selectedSecretKey = $state<string | null>(null)
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
99
+
100
+ const connected = $derived(connection.connected)
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
+ )
138
+
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
75
168
  }
76
169
 
77
170
  function onWindowKey(e: KeyboardEvent) {
@@ -82,139 +175,384 @@
82
175
  }
83
176
  }
84
177
 
85
- // Flattened live config and, per source, the reason its grant is absent
86
- // (null ⇒ available). Both are rebuilt atomically on every refresh.
87
- let values = $state<Record<string, unknown>>({})
88
- let denied = $state<Record<SettingsSource, string | null>>({
89
- stats: null,
90
- cluster: null,
91
- capabilities: null,
92
- session: null,
93
- })
94
- let loading = $state(false)
178
+ async function refreshPermissions() {
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()
95
195
 
96
- const connected = $derived(connection.connected)
97
- const activeUrl = $derived(connection.active.url)
196
+ if (!client || !connected) {
197
+ permissionsLoading = false
198
+ return
199
+ }
200
+
201
+ permissionsLoading = true
202
+ const gate = new PermissionGate(client)
203
+ try {
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
+ }
221
+ } catch (e) {
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
+ }
98
230
 
99
231
  async function refresh() {
100
232
  const client = connection.client
101
- if (!client || !connected) {
102
- values = {}
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
103
247
  return
104
248
  }
249
+
105
250
  loading = true
106
- const next: Record<string, unknown> = {}
107
- const nextDenied: Record<SettingsSource, string | null> = {
108
- stats: null,
109
- cluster: null,
110
- capabilities: null,
111
- session: null,
251
+ if (activePane.id === 'secrets') {
252
+ secretsLoadError = null
253
+ revealStatus = null
254
+ clearRevealedSecret()
255
+ } else {
256
+ configLoadError = null
112
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(),
263
+ )
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
286
+ }
287
+ } finally {
288
+ loading = false
289
+ }
290
+ }
113
291
 
114
- // /capabilities is the negotiation itself — always available from the store.
115
- Object.assign(next, flattenConfig({ capabilities: connection.capabilities }))
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
+ }
116
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
117
315
  try {
118
- const stats = await activity.track('settings · stats', () => client.stats())
119
- Object.assign(next, flattenConfig(stats as unknown as Record<string, unknown>))
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()
120
323
  } catch (e) {
121
- nextDenied.stats = (e as Error).message
324
+ setWriteStatus('config', 'error', (e as Error).message)
325
+ } finally {
326
+ writing = false
122
327
  }
328
+ }
123
329
 
124
- // Cluster topology is capability-gated: if the server can't honour
125
- // /cluster/status, those keys are absent + explained, never faked.
126
- if (!connection.capabilities.clusterStatus) {
127
- nextDenied.cluster = 'Server does not expose /cluster/status.'
128
- } else {
129
- try {
130
- const cluster = await activity.track('settings · cluster', () =>
131
- client.clusterStatus(),
132
- )
133
- Object.assign(next, flattenConfig(cluster as unknown as Record<string, unknown>))
134
- } catch (e) {
135
- nextDenied.cluster = (e as Error).message
136
- }
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
137
348
  }
349
+ }
350
+
351
+ async function setNewConfig() {
352
+ const client = connection.client
353
+ if (!client || !canWriteConfig || writing) return
138
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
139
364
  try {
140
- const session = await activity.track('settings · whoami', () => client.whoami())
141
- Object.assign(
142
- next,
143
- 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),
144
368
  )
369
+ selectedConfigKey = key
370
+ newConfigKey = ''
371
+ newConfigValue = ''
372
+ setWriteStatus('config', 'ok', `${key} set.`)
373
+ await refresh()
145
374
  } catch (e) {
146
- nextDenied.session = (e as Error).message
375
+ setWriteStatus('config', 'error', (e as Error).message)
376
+ } finally {
377
+ writing = false
147
378
  }
379
+ }
148
380
 
149
- values = next
150
- denied = nextDenied
151
- loading = false
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()
405
+ } catch (e) {
406
+ secretWriteValue = ''
407
+ setWriteStatus('secrets', 'error', (e as Error).message)
408
+ } finally {
409
+ writing = false
410
+ }
152
411
  }
153
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
417
+
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
+ }
464
+ }
465
+
466
+ $effect(() => {
467
+ void activeUrl
468
+ void connected
469
+ refreshPermissions()
470
+ })
471
+
154
472
  $effect(() => {
155
473
  void activeUrl
156
474
  void connected
475
+ void activePaneId
476
+ void permissions
477
+ void visiblePanes
157
478
  refresh()
158
479
  })
159
480
 
160
- type RenderRow =
161
- | { type: 'value'; key: string; label: string; control: ControlDescriptor; value: unknown }
162
- | { type: 'denied'; key: string; label: string; reason: string; grant: string }
163
-
164
- // Resolve the active section's curated keys to render rows. A key whose
165
- // source is denied becomes a "denied" row (absent control + explanation); a
166
- // key with a live value becomes a "value" row; a key the server simply does
167
- // not report is dropped (graceful degradation — no empty placeholder).
168
- const rows = $derived.by<RenderRow[]>(() => {
169
- const out: RenderRow[] = []
170
- for (const key of filterKeys(active.keys, query)) {
171
- const src = sourceForKey(key)
172
- const reason = denied[src]
173
- const label = resolveControl(key).label
174
- if (reason) {
175
- out.push({ type: 'denied', key, label, reason, grant: GRANTS[src] })
176
- } else if (key in values) {
177
- out.push({
178
- type: 'value',
179
- key,
180
- label,
181
- control: resolveControl(key, values[key]),
182
- value: values[key],
183
- })
184
- }
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
185
503
  }
186
- return out
187
504
  })
188
505
 
189
506
  function formatBytes(n: number): string {
190
507
  if (!Number.isFinite(n)) return String(n)
191
508
  const units = ['B', 'KB', 'MB', 'GB', 'TB']
192
- let v = n
509
+ let value = n
193
510
  let i = 0
194
- while (v >= 1024 && i < units.length - 1) {
195
- v /= 1024
511
+ while (value >= 1024 && i < units.length - 1) {
512
+ value /= 1024
196
513
  i++
197
514
  }
198
- return `${i === 0 ? v : v.toFixed(1)} ${units[i]}`
515
+ return `${i === 0 ? value : value.toFixed(1)} ${units[i]}`
199
516
  }
200
517
 
201
- function formatValue(control: ControlDescriptor, value: unknown): string {
202
- if (control.kind === 'number' && control.key.endsWith('_bytes')) {
203
- return formatBytes(Number(value))
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'
525
+ }
526
+
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)
204
540
  }
541
+ if (value === null || value === undefined) return 'null'
205
542
  return String(value)
206
543
  }
207
544
 
208
- // Export the live config snapshot (what's currently inspected) as JSON. This
209
- // is the honest, real action: the snapshot is read from connection.client, so
210
- // it can be downloaded but NOT written back (the client exposes no config
211
- // write/reset). Import/reset would have no real target here, so they are
212
- // intentionally not offered rather than faked.
213
545
  function exportConfig() {
214
546
  const payload = {
215
547
  url: activeUrl,
216
548
  exportedAt: new Date().toISOString(),
217
- config: values,
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
+ })),
218
556
  }
219
557
  const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' })
220
558
  const url = URL.createObjectURL(blob)
@@ -224,153 +562,672 @@
224
562
  a.click()
225
563
  URL.revokeObjectURL(url)
226
564
  }
565
+
566
+ function paneCount(pane: SettingsPane): number {
567
+ if (pane.id === 'secrets') return secretEntries.length
568
+ return configEntries.length
569
+ }
227
570
  </script>
228
571
 
229
572
  <svelte:window onkeydown={onWindowKey} />
230
573
 
231
- {#if !connected}
232
- <div class="h-full overflow-auto bg-bg-0 p-6">
233
- <PageHeader
234
- eyebrow="Settings"
235
- title="Settings"
236
- subtitle="Live configuration for the active reddb connection."
237
- />
238
- <EmptyState
239
- icon={Unplug}
240
- title="No active connection"
241
- message="Connect to a reddb instance to inspect its live configuration."
242
- hint="red serve"
243
- />
244
- </div>
245
- {:else}
246
- <SplitView searchShortcut={null}>
247
- {#snippet search()}
248
- <div class="relative">
249
- <Search
250
- class="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-fg-3"
251
- />
252
- <input
253
- bind:this={searchEl}
254
- type="text"
255
- placeholder="Search this section ⌘K"
256
- value={query}
257
- oninput={(e) => setQuery(e.currentTarget.value)}
258
- onkeydown={(e) => {
259
- if (e.key === 'Escape') {
260
- setQuery('')
261
- e.currentTarget.blur()
262
- }
263
- }}
264
- 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"
265
- />
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>
266
584
  </div>
267
- {/snippet}
268
-
269
- {#snippet nav()}
270
585
  <nav class="grid gap-0.5 p-2">
271
- {#each SECTIONS as section (section.id)}
272
- {@const SectionIcon = icons[section.icon] ?? Settings2}
586
+ {#each visiblePanes as pane (pane.id)}
587
+ {@const PaneIcon = icons[pane.icon] ?? Settings2}
273
588
  <NavItem
274
- label={section.label}
275
- active={section.id === active.id}
276
- onclick={() => (activeId = section.id)}
589
+ label={pane.label}
590
+ active={pane.id === activePane.id}
591
+ onclick={() => (activePaneId = pane.id)}
277
592
  >
278
- {#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}
279
595
  </NavItem>
280
596
  {/each}
281
597
  </nav>
282
- {/snippet}
283
-
284
- {#snippet footer()}
285
- <div class="grid gap-2 px-3 py-2.5">
286
- <button
287
- type="button"
288
- onclick={exportConfig}
289
- disabled={loading}
290
- 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"
291
- >
292
- <Download class="size-3.5" />
293
- export config
294
- </button>
295
- <div class="text-[11px] text-fg-3">
296
- Live from <span class="font-mono text-fg-2">{activeUrl}</span>.
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>
608
+
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
+ />
297
633
  </div>
298
634
  </div>
299
- {/snippet}
300
635
 
301
- {#snippet content()}
302
- {@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">
303
792
  <div class="p-6">
304
- <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}>
305
813
  {#snippet actions()}
306
814
  <button
307
815
  type="button"
308
816
  onclick={refresh}
309
- disabled={loading}
817
+ disabled={loading || !connected}
310
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"
311
819
  >
312
820
  <RefreshCw class={loading ? 'size-3.5 animate-spin' : 'size-3.5'} />
313
821
  refresh
314
822
  </button>
315
823
  {/snippet}
316
- </PageHeader>
824
+ </PageHeader>
317
825
 
318
- <section class="mb-6">
319
- <SectionHeading title={active.label} class="mb-2">
320
- {#snippet icon()}<SectionIcon class="size-3.5" />{/snippet}
321
- {#snippet meta()}<Pill>{rows.length}</Pill>{/snippet}
322
- </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>
323
849
 
324
- {#if rows.length === 0}
325
- <div
326
- class="rounded-lg border border-dashed border-line-1 bg-bg-1 px-3 py-6 text-center text-[12px] text-fg-3"
327
- >
328
- This server reports nothing for these keys.
329
- </div>
330
- {:else}
331
- <div
332
- class="divide-y divide-line-1 overflow-hidden rounded-lg border border-line-1 bg-bg-1"
333
- >
334
- {#each rows as row (row.key)}
335
- {#if row.type === 'denied'}
336
- <ListRow title={row.label} hint={row.key}>
337
- {#snippet action()}
338
- <span
339
- 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"
340
- 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"
341
993
  >
342
- <Lock class="size-3" />
343
- requires {row.grant}
344
- </span>
345
- {/snippet}
346
- </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" />
347
1014
  {:else}
348
- <ListRow
349
- title={row.label}
350
- description={row.control.description}
351
- hint={row.key}
352
- >
353
- {#snippet action()}
354
- {#if row.control.kind === 'boolean'}
355
- <Pill tone={row.value ? 'accent' : 'muted'}>{row.value ? 'on' : 'off'}</Pill>
356
- {:else if row.control.kind === 'enum'}
357
- <span
358
- class="font-mono text-[12px] text-accent"
359
- title={`Options: ${row.control.options?.join(', ')}`}
360
- >{row.value}</span>
361
- {:else}
362
- <span class="font-mono text-[12px] text-fg-1"
363
- >{formatValue(row.control, row.value)}</span
364
- >
365
- {/if}
366
- {/snippet}
367
- </ListRow>
1015
+ <SlidersHorizontal class="size-3.5" />
368
1016
  {/if}
369
- {/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}
370
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>
371
1228
  {/if}
372
- </section>
1229
+ {/if}
373
1230
  </div>
374
- {/snippet}
375
- </SplitView>
376
- {/if}
1231
+ </main>
1232
+ </div>
1233
+ </div>