@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.
- package/build/_app/immutable/assets/0.DgbuWrJy.css +1 -0
- package/build/_app/immutable/assets/AnalyticsView.BLAJezw4.css +1 -0
- package/build/_app/immutable/assets/{Workspace.CvP5De7i.css → Workspace.DM2E1WiF.css} +1 -1
- package/build/_app/immutable/chunks/85-Fh5Zp.js +404 -0
- package/build/_app/immutable/chunks/{C9rXzYLu.js → 8zg2gJwV.js} +1 -1
- package/build/_app/immutable/chunks/B5euRbKa.js +1 -0
- package/build/_app/immutable/chunks/BRIMmWzq.js +1 -0
- package/build/_app/immutable/chunks/BgOTHNR5.js +1 -0
- package/build/_app/immutable/chunks/Bq4DTWK2.js +2 -0
- package/build/_app/immutable/chunks/Btd6PFkC.js +1 -0
- package/build/_app/immutable/chunks/CGFL56WJ.js +1 -0
- package/build/_app/immutable/chunks/CJFJ501n.js +18 -0
- package/build/_app/immutable/chunks/CLBx21Hz.js +2 -0
- package/build/_app/immutable/chunks/CnD4hwxC.js +1106 -0
- package/build/_app/immutable/chunks/DCJ0QyYH.js +1 -0
- package/build/_app/immutable/chunks/DQCYUNtS.js +1 -0
- package/build/_app/immutable/chunks/DRlh90Cv.js +1 -0
- package/build/_app/immutable/chunks/{C278awGv.js → DV9PDoM3.js} +1 -1
- package/build/_app/immutable/entry/app.BDog2y0J.js +2 -0
- package/build/_app/immutable/entry/start.D1J45dq5.js +1 -0
- package/build/_app/immutable/nodes/0.BTrC4th2.js +1 -0
- package/build/_app/immutable/nodes/1.Ds1Tbjsm.js +1 -0
- package/build/_app/immutable/nodes/10.D_FCLs0Z.js +96 -0
- package/build/_app/immutable/nodes/12.DPE6v6Zf.js +3 -0
- package/build/_app/immutable/nodes/14.CAG5_cGP.js +1 -0
- package/build/_app/immutable/nodes/15.CcJlZGxa.js +60 -0
- package/build/_app/immutable/nodes/8.CAG5_cGP.js +1 -0
- package/build/_app/version.json +1 -1
- package/build/index.html +18 -15
- package/dist/embed/AnalyticsView-D8z1Nsed.js +23337 -0
- package/dist/embed/AnalyticsView-D8z1Nsed.js.map +1 -0
- package/dist/embed/RedUiEmbedRoot-CtqTd_FG.js +31302 -0
- package/dist/embed/RedUiEmbedRoot-CtqTd_FG.js.map +1 -0
- package/dist/embed/index-0hA141D2.js +85 -0
- package/dist/embed/index-0hA141D2.js.map +1 -0
- package/dist/embed/index-client-BeiP-va5.js +3303 -0
- package/dist/embed/index-client-BeiP-va5.js.map +1 -0
- package/dist/embed/index-w1Qo_uW0.js +677 -0
- package/dist/embed/index-w1Qo_uW0.js.map +1 -0
- package/dist/embed/index.js +1 -1
- package/dist/embed/{sigma.esm-D5yoExsH.js → sigma.esm-qqKBpvin.js} +2 -2
- package/dist/embed/{sigma.esm-D5yoExsH.js.map → sigma.esm-qqKBpvin.js.map} +1 -1
- package/package.json +5 -3
- package/src/app.css +86 -20
- package/src/lib/AnalyticsView.svelte +209 -0
- package/src/lib/AskView.svelte +255 -0
- package/src/lib/ClusterView.svelte +415 -95
- package/src/lib/CommandPalette.svelte +2 -0
- package/src/lib/GlobalProgressBar.svelte +9 -0
- package/src/lib/MetricChart.svelte +85 -0
- package/src/lib/QueryEditor.svelte +48 -7
- package/src/lib/ResultsPane.svelte +76 -8
- package/src/lib/SchemaTree.svelte +25 -1
- package/src/lib/SecurityView.svelte +176 -65
- package/src/lib/SettingsView.svelte +1111 -254
- package/src/lib/Topbar.svelte +29 -1
- package/src/lib/Workspace.svelte +18 -3
- package/src/lib/activity.svelte.ts +44 -35
- package/src/lib/collection-preview.ts +14 -0
- package/src/lib/connections.svelte.ts +73 -4
- package/src/lib/index.ts +3 -0
- package/src/lib/permission-gate.ts +120 -0
- package/src/lib/policies.ts +136 -0
- package/src/lib/query-tabs.svelte.ts +132 -53
- package/src/lib/reddb/analytics.ts +208 -0
- package/src/lib/reddb/ask.ts +123 -0
- package/src/lib/reddb/client.ts +159 -58
- package/src/lib/reddb/cluster-status.ts +306 -0
- package/src/lib/reddb/index.ts +3 -0
- package/src/lib/reddb/local-url-provider.ts +32 -1
- package/src/lib/reddb/redwire-ws-spike.ts +590 -0
- package/src/lib/router.svelte.ts +12 -0
- package/src/lib/settings-authoring-client.ts +336 -0
- package/src/lib/settings-sections.ts +292 -196
- package/build/_app/immutable/assets/0.DQDLkSdR.css +0 -1
- package/build/_app/immutable/chunks/65XjEwaF.js +0 -367
- package/build/_app/immutable/chunks/BGcIe2ep.js +0 -2
- package/build/_app/immutable/chunks/BjFADNkA.js +0 -992
- package/build/_app/immutable/chunks/BrvvOVaJ.js +0 -1
- package/build/_app/immutable/chunks/D9CTkMjK.js +0 -1
- package/build/_app/immutable/chunks/oiO4RLqp.js +0 -1
- package/build/_app/immutable/chunks/pDJnDxXM.js +0 -1
- package/build/_app/immutable/entry/app.DdaMXHAv.js +0 -2
- package/build/_app/immutable/entry/start.-MLBtQXB.js +0 -1
- package/build/_app/immutable/nodes/0.Ysoz3Y3h.js +0 -1
- package/build/_app/immutable/nodes/1.C2jbGTOJ.js +0 -1
- package/build/_app/immutable/nodes/12.DQG5zpEx.js +0 -60
- package/build/_app/immutable/nodes/8.CrHI4F9p.js +0 -134
- package/dist/embed/RedUiEmbedRoot-BwFUA5X-.js +0 -28624
- package/dist/embed/RedUiEmbedRoot-BwFUA5X-.js.map +0 -1
- package/dist/embed/index-DGhgtnaK.js +0 -577
- package/dist/embed/index-DGhgtnaK.js.map +0 -1
- package/dist/embed/index-client-ItCEU64C.js +0 -3087
- package/dist/embed/index-client-ItCEU64C.js.map +0 -1
- /package/build/_app/immutable/assets/{12.Dblyk0Kr.css → 15.Dblyk0Kr.css} +0 -0
- /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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
65
|
-
const
|
|
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[
|
|
65
|
+
const query = $derived(hasVisiblePane ? (queries[activePane.id] ?? '') : '')
|
|
72
66
|
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
97
|
-
|
|
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
|
-
|
|
102
|
-
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
115
|
-
|
|
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
|
|
119
|
-
|
|
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
|
-
|
|
324
|
+
setWriteStatus('config', 'error', (e as Error).message)
|
|
325
|
+
} finally {
|
|
326
|
+
writing = false
|
|
122
327
|
}
|
|
328
|
+
}
|
|
123
329
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
if (!
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
375
|
+
setWriteStatus('config', 'error', (e as Error).message)
|
|
376
|
+
} finally {
|
|
377
|
+
writing = false
|
|
147
378
|
}
|
|
379
|
+
}
|
|
148
380
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
|
509
|
+
let value = n
|
|
193
510
|
let i = 0
|
|
194
|
-
while (
|
|
195
|
-
|
|
511
|
+
while (value >= 1024 && i < units.length - 1) {
|
|
512
|
+
value /= 1024
|
|
196
513
|
i++
|
|
197
514
|
}
|
|
198
|
-
return `${i === 0 ?
|
|
515
|
+
return `${i === 0 ? value : value.toFixed(1)} ${units[i]}`
|
|
199
516
|
}
|
|
200
517
|
|
|
201
|
-
function
|
|
202
|
-
if (
|
|
203
|
-
|
|
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
|
-
|
|
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
|
-
|
|
232
|
-
<div
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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
|
|
272
|
-
{@const
|
|
586
|
+
{#each visiblePanes as pane (pane.id)}
|
|
587
|
+
{@const PaneIcon = icons[pane.icon] ?? Settings2}
|
|
273
588
|
<NavItem
|
|
274
|
-
label={
|
|
275
|
-
active={
|
|
276
|
-
onclick={() => (
|
|
589
|
+
label={pane.label}
|
|
590
|
+
active={pane.id === activePane.id}
|
|
591
|
+
onclick={() => (activePaneId = pane.id)}
|
|
277
592
|
>
|
|
278
|
-
{#snippet icon()}<
|
|
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
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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
|
-
|
|
302
|
-
|
|
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
|
-
|
|
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
|
-
|
|
824
|
+
</PageHeader>
|
|
317
825
|
|
|
318
|
-
|
|
319
|
-
<
|
|
320
|
-
|
|
321
|
-
{
|
|
322
|
-
|
|
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
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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
|
-
<
|
|
343
|
-
|
|
344
|
-
</
|
|
345
|
-
|
|
346
|
-
</
|
|
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
|
-
<
|
|
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
|
-
{/
|
|
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
|
-
|
|
1229
|
+
{/if}
|
|
373
1230
|
</div>
|
|
374
|
-
|
|
375
|
-
</
|
|
376
|
-
|
|
1231
|
+
</main>
|
|
1232
|
+
</div>
|
|
1233
|
+
</div>
|