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