@ddtcorex/dsh-maestro-config 0.3.0 → 0.4.0

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.
@@ -1,976 +0,0 @@
1
- // Verbatim port of the proven Maestro Settings card design: fields and
2
- // styles preserved (--dsw-alias-* tokens, masked SecretInput, sectioned
3
- // layout, QR + model selector + project mappings). The RPC channel now
4
- // resolves to the granular dsh-maestro-review settings-rpc row (see api.ts).
5
- // Kept byte-faithful by design; typed entry lives in index.tsx.
6
- /* @ts-nocheck */
7
- import { createElement as h, useEffect, useRef, useState } from 'react'
8
- import QRCode from 'qrcode'
9
- import { MAESTRO_ENDPOINTS } from './api.js'
10
- import { generateWebhookSecret, gitlabWebhookUrl } from './webhook-secret.js'
11
-
12
- // Styling mirrors the host settings cards (ui-settings-plugins fields.module.css
13
- // / ModelsSection.module.css) through the shared --dsw-alias-* tokens, so the
14
- // card follows the active light/dark theme instead of hard-coding colors.
15
- const inputStyle = {
16
- height: 34,
17
- padding: '0 12px',
18
- border: '1px solid var(--dsw-alias-border-l2)',
19
- borderRadius: 8,
20
- background: 'var(--dsw-alias-bg-layer-3)',
21
- font: 'inherit',
22
- fontSize: 13,
23
- color: 'var(--dsw-alias-label-primary)',
24
- width: '100%',
25
- boxSizing: 'border-box',
26
- }
27
-
28
- const fieldLabelStyle = {
29
- display: 'block',
30
- fontSize: 12,
31
- lineHeight: 1.5,
32
- color: 'var(--dsw-alias-label-secondary)',
33
- margin: '10px 0 4px',
34
- }
35
-
36
- const secondaryButtonStyle = {
37
- height: 32,
38
- padding: '0 14px',
39
- borderRadius: 16,
40
- border: '1px solid var(--dsw-alias-border-l2)',
41
- background: 'transparent',
42
- color: 'var(--dsw-alias-label-primary)',
43
- font: 'inherit',
44
- fontSize: 13,
45
- cursor: 'pointer',
46
- flex: 'none',
47
- }
48
-
49
- const primaryButtonStyle = {
50
- ...secondaryButtonStyle,
51
- border: 'none',
52
- background: 'var(--dsw-alias-button-primary-fill)',
53
- color: 'var(--dsw-alias-label-primary-foreground)',
54
- }
55
-
56
- const captionStyle = {
57
- fontSize: 12,
58
- lineHeight: 1.5,
59
- margin: '4px 0',
60
- color: 'var(--dsw-alias-label-secondary)',
61
- }
62
-
63
- const sectionStyle = {
64
- marginTop: 20,
65
- paddingTop: 16,
66
- borderTop: '1px solid var(--dsw-alias-border-l2)',
67
- }
68
-
69
- const headingStyle = {
70
- margin: '0 0 6px',
71
- fontSize: 13,
72
- fontWeight: 600,
73
- color: 'var(--dsw-alias-label-primary)',
74
- }
75
-
76
- const errorStyle = {
77
- color: 'var(--dsw-alias-state-error-primary)',
78
- fontSize: 12,
79
- margin: '8px 0 0',
80
- }
81
-
82
- const codeStyle = {
83
- fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
84
- fontSize: 12,
85
- color: 'var(--dsw-alias-label-primary)',
86
- wordBreak: 'break-all',
87
- }
88
-
89
- const textareaStyle = {
90
- ...inputStyle,
91
- height: 120,
92
- padding: '8px 12px',
93
- resize: 'vertical',
94
- }
95
-
96
- const tabBarStyle = {
97
- display: 'flex',
98
- gap: 8,
99
- borderBottom: '1px solid var(--dsw-alias-border-l2)',
100
- marginBottom: 16,
101
- }
102
-
103
- const tabButtonStyle = (active) => ({
104
- padding: '8px 14px',
105
- border: 'none',
106
- borderBottom: active ? '2px solid var(--dsw-alias-button-primary-fill)' : '2px solid transparent',
107
- background: 'transparent',
108
- color: active ? 'var(--dsw-alias-label-primary)' : 'var(--dsw-alias-label-secondary)',
109
- font: 'inherit',
110
- fontSize: 13,
111
- fontWeight: active ? 600 : 400,
112
- cursor: 'pointer',
113
- })
114
-
115
- /** QR code centered in a light tile with an even scanner-friendly quiet zone. */
116
- function QrImage({ url, size = 104 }) {
117
- const [dataUrl, setDataUrl] = useState(null)
118
- useEffect(() => {
119
- let live = true
120
- QRCode.toDataURL(url, { margin: 0, width: size * 2 })
121
- .then((d) => { if (live) setDataUrl(d) })
122
- .catch(() => {})
123
- return () => { live = false }
124
- }, [url, size])
125
- return h('div', {
126
- style: {
127
- background: '#ffffff',
128
- borderRadius: 10,
129
- boxSizing: 'border-box',
130
- width: size + 20,
131
- height: size + 20,
132
- display: 'flex',
133
- alignItems: 'center',
134
- justifyContent: 'center',
135
- lineHeight: 0,
136
- flex: 'none',
137
- alignSelf: 'flex-start',
138
- },
139
- },
140
- dataUrl === null
141
- ? h('div', { style: { width: size, height: size, background: 'var(--dsw-alias-bg-skeleton)', borderRadius: 4 } })
142
- : h('img', { src: dataUrl, alt: url, width: size, height: size, style: { display: 'block' } }),
143
- )
144
- }
145
-
146
- function NamedTunnelSetupNote() {
147
- return h('div', { style: captionStyle },
148
- h('p', { style: { ...captionStyle, marginBottom: 4 } }, 'Named tunnel needs a one-time manual setup (requires your own Cloudflare account — cannot be automated):'),
149
- h('ol', { style: { margin: '4px 0', paddingLeft: 20 } },
150
- h('li', null, 'cloudflared tunnel login'),
151
- h('li', null, 'cloudflared tunnel create dsh-maestro-webhook'),
152
- h('li', null, 'cloudflared tunnel route dns dsh-maestro-webhook <your-hostname>'),
153
- h('li', null, 'Paste the printed Tunnel ID, the credentials file path (~/.cloudflared/<id>.json), and the hostname below.'),
154
- ),
155
- )
156
- }
157
-
158
- function ReviewModelSelector({ value, catalog, fallbackValue, fallbackLabel, onChange, label }) {
159
- const groups = catalog?.groups ?? []
160
- const providers = groups.map(g => g.provider)
161
- const selectedProvider = value?.provider ?? ''
162
- const providerGroup = groups.find(g => g.provider === selectedProvider)
163
- const models = providerGroup?.models ?? []
164
- const selectedEffort = value?.reasoningEffort ?? ''
165
- const [open, setOpen] = useState(false)
166
- const [pane, setPane] = useState('root')
167
- const rootRef = useRef(null)
168
- useEffect(() => {
169
- if (!open) return
170
- const onDown = (e) => { if (rootRef.current && !rootRef.current.contains(e.target)) { setOpen(false); setPane('root') } }
171
- const onKey = (e) => { if (e.key === 'Escape') { setOpen(false); setPane('root') } }
172
- document.addEventListener('mousedown', onDown)
173
- document.addEventListener('keydown', onKey)
174
- return () => { document.removeEventListener('mousedown', onDown); document.removeEventListener('keydown', onKey) }
175
- }, [open])
176
- const getModelId = (m) => typeof m === 'string' ? m : m.id
177
- const getModelName = (m) => typeof m === 'string' ? m : (m.name ?? m.id)
178
- const selectedModelInfo = (() => {
179
- if (!selectedProvider || !value?.model) return null
180
- const raw = (providerGroup?.models ?? []).find(mm => getModelId(mm) === value.model)
181
- if (raw === undefined) return null
182
- if (typeof raw === 'string') return { id: raw, supportsReasoning: false, reasoningEfforts: [] }
183
- return raw
184
- })()
185
- const supportsReasoning = (() => {
186
- if (!selectedModelInfo) return false
187
- if (typeof selectedModelInfo.supportsReasoning === 'boolean') return selectedModelInfo.supportsReasoning
188
- const efforts = selectedModelInfo.reasoningEfforts ?? selectedModelInfo.reasoning?.efforts?.map(e => e.id) ?? []
189
- return efforts.filter(e => e !== 'off').length > 0
190
- })()
191
- const availableEfforts = (() => {
192
- if (!supportsReasoning) return []
193
- const efforts = selectedModelInfo?.reasoningEfforts ?? selectedModelInfo?.reasoning?.efforts?.map(e => e.id) ?? []
194
- const filtered = efforts.filter(e => e !== 'off' && e !== '')
195
- if (filtered.length > 0) return filtered
196
- return ['low', 'medium', 'high']
197
- })()
198
- const warning = selectedEffort !== '' && !supportsReasoning && selectedModelInfo !== null
199
- ? `⚠️ This model does not support reasoning effort "${selectedEffort}" — reviews will fail. Clear effort or choose a reasoning-capable model.`
200
- : null
201
- const update = (field, newVal) => {
202
- if (newVal === '' && field === 'provider') { onChange(null); setOpen(false); setPane('root'); return }
203
- const next = { provider: value?.provider ?? '', model: value?.model ?? '', ...(value?.reasoningEffort ? { reasoningEffort: value.reasoningEffort } : {}) }
204
- if (field === 'provider') { const g = groups.find(x => x.provider === newVal); const first = g?.models[0]; next.provider = newVal; next.model = first !== undefined ? getModelId(first) : '' }
205
- else if (field === 'model') { next.model = newVal }
206
- else if (field === 'reasoningEffort') { if (newVal === '') delete next.reasoningEffort; else next.reasoningEffort = newVal }
207
- if (!next.provider || !next.model) { onChange(null) } else { onChange(next) }
208
- }
209
- const effectiveFallback = fallbackValue !== undefined ? fallbackValue : (catalog?.current ?? null)
210
- const effectiveFallbackLabel = fallbackLabel ?? 'Use DSH default'
211
- const triggerLabel = value
212
- ? `${value.provider} / ${value.model}${value.reasoningEffort ? ` · ${value.reasoningEffort}` : ''}`
213
- : effectiveFallback
214
- ? `${effectiveFallbackLabel} · ${effectiveFallback.provider}/${effectiveFallback.model}${effectiveFallback.reasoningEffort ? ` · ${effectiveFallback.reasoningEffort}` : ''}`
215
- : effectiveFallbackLabel
216
- const triggerStyle = {
217
- height: 32,
218
- padding: '0 12px 0 14px',
219
- borderRadius: 20,
220
- border: '1px solid var(--dsw-alias-border-l2)',
221
- background: 'var(--dsw-alias-bg-layer-2)',
222
- color: 'var(--dsw-alias-label-primary)',
223
- font: 'inherit',
224
- fontSize: 13,
225
- display: 'inline-flex',
226
- alignItems: 'center',
227
- gap: 8,
228
- cursor: 'pointer',
229
- maxWidth: 320,
230
- whiteSpace: 'nowrap',
231
- }
232
- const menuStyle = {
233
- position: 'absolute',
234
- top: 'calc(100% + 8px)',
235
- left: 0,
236
- minWidth: 300,
237
- maxWidth: 360,
238
- background: 'var(--dsw-alias-bg-layer-1)',
239
- border: '1px solid var(--dsw-alias-border-l2)',
240
- borderRadius: 12,
241
- boxShadow: '0 8 24px rgba(0,0,0,.12)',
242
- zIndex: 20,
243
- padding: 6,
244
- }
245
- const rowStyle = {
246
- width: '100%',
247
- display: 'flex',
248
- alignItems: 'center',
249
- justifyContent: 'space-between',
250
- gap: 12,
251
- padding: '9px 10px',
252
- borderRadius: 8,
253
- border: 'none',
254
- background: 'transparent',
255
- color: 'var(--dsw-alias-label-primary)',
256
- font: 'inherit',
257
- fontSize: 13,
258
- cursor: 'pointer',
259
- textAlign: 'left',
260
- }
261
- const check = (active) => active ? h('svg', { width: 16, height: 16, viewBox: '0 0 16 16', style: { flex: 'none' } }, h('path', { d: 'M3.5 8.2l2.8 2.8L12.5 4.8', fill: 'none', stroke: 'currentColor', strokeWidth: 1.6, strokeLinecap: 'round', strokeLinejoin: 'round' })) : h('span', { style: { width: 16, flex: 'none' } })
262
- const chevronDown = h('svg', { width: 14, height: 14, viewBox: '0 0 14 14', style: { flex: 'none', opacity: 0.7 } }, h('path', { d: 'M3.5 5L7 8.5L10.5 5', fill: 'none', stroke: 'currentColor', strokeWidth: 1.4, strokeLinecap: 'round', strokeLinejoin: 'round' }))
263
- const chevronRight = h('svg', { width: 14, height: 14, viewBox: '0 0 14 14', style: { flex: 'none', opacity: 0.6 } }, h('path', { d: 'M5 3.5L8.5 7L5 10.5', fill: 'none', stroke: 'currentColor', strokeWidth: 1.4, strokeLinecap: 'round', strokeLinejoin: 'round' }))
264
- const effortLabel = selectedEffort === '' ? 'Default effort' : selectedEffort
265
- const modelLabel = selectedProvider === '' ? 'Select model' : (value?.model ?? 'Select model')
266
- return h('div', { ref: rootRef, style: { position: 'relative', display: 'inline-block', maxWidth: '100%' } },
267
- label && h('span', { style: fieldLabelStyle }, label),
268
- h('button', { type: 'button', style: triggerStyle, onClick: () => { setOpen(v => !v); setPane('root') }, 'aria-expanded': open, 'aria-haspopup': 'menu', title: triggerLabel },
269
- h('span', { style: { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, triggerLabel),
270
- chevronDown,
271
- ),
272
- open && h('div', { style: menuStyle, role: 'menu' },
273
- pane === 'root' && h('div', null,
274
- h('button', { type: 'button', style: { ...rowStyle, background: !value ? 'var(--dsw-alias-bg-layer-2)' : 'transparent' }, onClick: () => { onChange(null); setOpen(false) } },
275
- h('span', null, effectiveFallbackLabel),
276
- check(!value),
277
- ),
278
- h('div', { style: { height: 1, background: 'var(--dsw-alias-border-l2)', margin: '6px 2px' } }),
279
- h('button', { type: 'button', style: rowStyle, onClick: () => setPane('model') },
280
- h('span', null, 'Model'),
281
- h('span', { style: { display: 'flex', alignItems: 'center', gap: 8, color: 'var(--dsw-alias-label-secondary)', maxWidth: 160, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, h('span', { style: { overflow: 'hidden', textOverflow: 'ellipsis' } }, modelLabel), chevronRight),
282
- ),
283
- h('button', { type: 'button', style: rowStyle, onClick: () => setPane('effort') },
284
- h('span', null, 'Effort'),
285
- h('span', { style: { display: 'flex', alignItems: 'center', gap: 8, color: 'var(--dsw-alias-label-secondary)' } }, effortLabel, chevronRight),
286
- ),
287
- value && h('p', { style: { ...captionStyle, margin: '8px 4px 2px' } }, `Selected: ${value.provider} / ${value.model}${value.reasoningEffort ? ` (${value.reasoningEffort})` : ''}`),
288
- !value && effectiveFallback && h('p', { style: { ...captionStyle, margin: '8px 4px 2px' } }, `${effectiveFallbackLabel === 'Use Global' ? 'Using Global' : 'Using DSH default'}: ${effectiveFallback.provider} / ${effectiveFallback.model}${effectiveFallback.reasoningEffort ? ` (${effectiveFallback.reasoningEffort})` : ''}`),
289
- warning && h('p', { style: { ...captionStyle, margin: '4px 4px 2px', color: 'var(--dsw-alias-state-error-primary)' } }, warning),
290
- ),
291
- pane === 'model' && h('div', null,
292
- h('button', { type: 'button', style: { ...rowStyle, color: 'var(--dsw-alias-label-secondary)' }, onClick: () => setPane('root') }, h('span', null, '← Back'), h('span', { style: { fontSize: 12 } }, 'Model')),
293
- h('div', { style: { maxHeight: 260, overflowY: 'auto', marginTop: 4 } },
294
- providers.length === 0 ? h('p', { style: captionStyle }, 'No providers') :
295
- providers.map(p => {
296
- const g = groups.find(x => x.provider === p)
297
- const ms = g?.models ?? []
298
- return h('div', { key: p, style: { marginBottom: 8 } },
299
- h('div', { style: { fontSize: 11, fontWeight: 600, color: 'var(--dsw-alias-label-secondary)', padding: '6px 10px 2px', textTransform: 'uppercase', letterSpacing: 0.4, display: 'flex', alignItems: 'center', gap: 6 } }, h('span', { style: { width: 6, height: 6, borderRadius: 3, background: 'var(--dsw-alias-border-l2)', flex: 'none' } }), g?.name ?? p),
300
- ms.length === 0 ? h('p', { style: { ...captionStyle, padding: '2px 10px 2px 28px' } }, 'No models') :
301
- h('div', { style: { marginLeft: 12, borderLeft: '1px solid var(--dsw-alias-border-l2)', paddingLeft: 6, display: 'flex', flexDirection: 'column', gap: 2 } },
302
- ms.map(m => {
303
- const mid = getModelId(m)
304
- const mname = getModelName(m)
305
- const active = value?.provider === p && value?.model === mid
306
- return h('button', { key: mid, type: 'button', style: { ...rowStyle, paddingLeft: 10, background: active ? 'var(--dsw-alias-bg-layer-2)' : 'transparent' }, onClick: () => { update('model', mid); if (value?.provider !== p) update('provider', p); else { const next = { provider: p, model: mid, ...(selectedEffort ? { reasoningEffort: selectedEffort } : {}) }; onChange(next); setPane('root') } } }, h('span', { style: { overflow: 'hidden', textOverflow: 'ellipsis' } }, mname), check(active))
307
- })),
308
- )
309
- }),
310
- ),
311
- ),
312
- pane === 'effort' && h('div', null,
313
- h('button', { type: 'button', style: { ...rowStyle, color: 'var(--dsw-alias-label-secondary)' }, onClick: () => setPane('root') }, h('span', null, '← Back'), h('span', { style: { fontSize: 12 } }, 'Effort')),
314
- selectedModelInfo === null ? h('p', { style: { ...captionStyle, margin: '8px 4px 2px' } }, 'Select a model first to configure effort.') :
315
- !supportsReasoning ? h('div', null,
316
- h('p', { style: { ...captionStyle, margin: '8px 4px 6px' } }, 'This model does not support reasoning effort — using provider default'),
317
- h('div', { style: { marginTop: 4 } },
318
- [{ id: '', label: 'Default effort' }].map(e => h('button', { key: e.id || 'default', type: 'button', style: { ...rowStyle, background: selectedEffort === e.id ? 'var(--dsw-alias-bg-layer-2)' : 'transparent' }, onClick: () => { update('reasoningEffort', e.id); setPane('root') } }, h('span', null, e.label), check(selectedEffort === e.id))),
319
- ),
320
- warning && h('p', { style: { ...captionStyle, margin: '8px 4px 2px', color: 'var(--dsw-alias-state-error-primary)' } }, warning),
321
- ) :
322
- h('div', null,
323
- h('div', { style: { marginTop: 4 } },
324
- [{ id: '', label: 'Default effort' }, ...availableEfforts.map(id => ({ id, label: id }))].map(e => h('button', { key: e.id || 'default', type: 'button', style: { ...rowStyle, background: selectedEffort === e.id ? 'var(--dsw-alias-bg-layer-2)' : 'transparent' }, onClick: () => { update('reasoningEffort', e.id); setPane('root') } }, h('span', null, e.label), check(selectedEffort === e.id))),
325
- ),
326
- warning && h('p', { style: { ...captionStyle, margin: '8px 4px 2px', color: 'var(--dsw-alias-state-error-primary)' } }, warning),
327
- ),
328
- ),
329
- ),
330
- )
331
- }
332
-
333
- function ProjectMappingsEditor({ mappings, onChange, catalog, globalReviewModel }) {
334
- const rows = mappings.length > 0 ? mappings : [{ projectPath: '', localRepoPath: '', reviewProfile: 'magento2' }]
335
- const updateRow = (index, field, value) => {
336
- const next = rows.map((row, i) => (i === index ? { ...row, [field]: value } : row))
337
- onChange(next.filter(r => r.projectPath !== '' || r.localRepoPath !== ''))
338
- }
339
- const removeRow = (index) => onChange(rows.filter((_, i) => i !== index))
340
- const addRow = () => onChange([...rows, { projectPath: '', localRepoPath: '', reviewProfile: 'magento2' }])
341
- return h('div', null,
342
- h('span', { style: fieldLabelStyle }, 'Tracked projects (GitLab path → local repo checkout → review profile → review model override)'),
343
- rows.map((row, i) => h('div', { key: i, 'data-maestro-mapping-row': '', style: { display: 'flex', gap: 8, marginBottom: 8, alignItems: 'center', flexWrap: 'wrap' } },
344
- h('input', { placeholder: 'group/project', style: { ...inputStyle, flex: '1 1 120px' }, value: row.projectPath, onChange: e => updateRow(i, 'projectPath', e.target.value) }),
345
- h('input', { placeholder: '/path/to/local/clone', style: { ...inputStyle, flex: '1 1 140px' }, value: row.localRepoPath, onChange: e => updateRow(i, 'localRepoPath', e.target.value) }),
346
- h('select', { style: { ...inputStyle, flex: '0 0 130px' }, value: row.reviewProfile ?? 'magento2', onChange: e => updateRow(i, 'reviewProfile', e.target.value) },
347
- h('option', { value: 'magento2' }, 'Magento 2'),
348
- h('option', { value: 'generic' }, 'Generic'),
349
- ),
350
- h('div', { style: { flex: '0 0 auto' } },
351
- h(ReviewModelSelector, {
352
- value: row.reviewModel ?? null,
353
- catalog,
354
- fallbackValue: globalReviewModel ?? catalog?.current ?? null,
355
- fallbackLabel: globalReviewModel ? 'Use Global' : 'Use DSH default',
356
- onChange: v => updateRow(i, 'reviewModel', v),
357
- label: null,
358
- }),
359
- ),
360
- h('button', { onClick: () => removeRow(i), style: secondaryButtonStyle, title: 'Remove mapping' }, '✕'),
361
- )),
362
- h('button', { onClick: addRow, style: secondaryButtonStyle }, '+ Add mapping'),
363
- )
364
- }
365
-
366
- /**
367
- * Password field for a stored secret the server never echoes back
368
- * (`getConfig` masks secrets). Empty by default; a typed value saves on blur,
369
- * an untouched field keeps the stored secret, and Clear writes '' to erase it.
370
- */
371
- function SecretInput({ label, placeholder, hasSaved, onSave }) {
372
- const [draft, setDraft] = useState('')
373
- const clear = () => { setDraft(''); onSave('') }
374
- return h('div', null,
375
- h('label', { style: fieldLabelStyle }, label),
376
- h('div', { style: { display: 'flex', gap: 8 } },
377
- h('input', {
378
- placeholder: hasSaved === true ? 'saved — leave blank to keep' : placeholder,
379
- type: 'password',
380
- autoComplete: 'off',
381
- style: inputStyle,
382
- value: draft,
383
- onChange: e => setDraft(e.target.value),
384
- onBlur: () => { if (draft !== '') onSave(draft) },
385
- }),
386
- hasSaved === true && h('button', { type: 'button', style: secondaryButtonStyle, onClick: clear }, 'Clear'),
387
- ),
388
- )
389
- }
390
-
391
- /** Simple checked/unchecked toggle bound to a boolean settings key. */
392
- function ToggleField({ label, caption, checked, onChange }) {
393
- return h('label', { style: { display: 'flex', alignItems: 'flex-start', gap: 8, margin: '8px 0', cursor: 'pointer' } },
394
- h('input', { type: 'checkbox', checked: checked === true, onChange: e => onChange(e.target.checked), style: { marginTop: 3 } }),
395
- h('span', null,
396
- h('div', { style: { fontSize: 13 } }, label),
397
- caption != null && h('div', { style: captionStyle }, caption),
398
- ),
399
- )
400
- }
401
-
402
- /** One selectable LAN address chip + the QR of the currently selected URL. */
403
- function LanAccess({ proxyStatus, lanPin }) {
404
- const urls = proxyStatus?.lanUrls ?? []
405
- const [selected, setSelected] = useState(0)
406
- const index = Math.min(selected, Math.max(urls.length - 1, 0))
407
- if (!proxyStatus?.running) {
408
- return h('p', { style: errorStyle }, proxyStatus?.errorMessage ?? 'Proxy not running')
409
- }
410
- return h('div', null,
411
- h('p', { style: captionStyle }, lanPin?.enabled === true
412
- ? 'Open this full DSH UI from any device on your network — visitors enter the LAN PIN below.'
413
- : 'Open this full DSH UI from any device on your network — no PIN needed.'),
414
- urls.length > 0 && h('div', { style: { display: 'flex', gap: 6, flexWrap: 'wrap', marginBottom: 12 } },
415
- urls.map((url, i) => h('button', {
416
- key: url,
417
- onClick: () => setSelected(i),
418
- style: {
419
- ...secondaryButtonStyle,
420
- height: 26,
421
- padding: '0 10px',
422
- fontSize: 12,
423
- borderRadius: 13,
424
- ...(i === index
425
- ? { background: 'var(--dsw-alias-button-primary-fill)', borderColor: 'transparent', color: 'var(--dsw-alias-label-primary-foreground)' }
426
- : {}),
427
- },
428
- }, url.replace(/^http:\/\//, ''))),
429
- ),
430
- urls.length > 0 && h('div', { style: { display: 'flex', gap: 14, alignItems: 'center' } },
431
- h(QrImage, { url: urls[index], size: 116 }),
432
- h('div', null,
433
- h('div', { style: codeStyle }, urls[index]),
434
- h('p', { style: captionStyle }, 'Scan with a phone connected to the same network.'),
435
- ),
436
- ),
437
- lanPin !== null && h(LanPinRow, { lanPin }),
438
- )
439
- }
440
-
441
- /** Opt-in LAN PIN: off keeps the LAN open; on shows the PIN with Show/Rotate. */
442
- function LanPinRow({ lanPin }) {
443
- return h('div', { style: { marginTop: 12 } },
444
- h('label', { style: { display: 'flex', gap: 8, alignItems: 'center', cursor: 'pointer' } },
445
- h('input', {
446
- type: 'checkbox',
447
- checked: lanPin.enabled,
448
- onChange: e => lanPin.onToggle(e.target.checked),
449
- style: { width: 15, height: 15, accentColor: 'var(--dsw-alias-button-primary-fill)' },
450
- }),
451
- h('span', { style: { ...fieldLabelStyle, margin: 0 } }, 'Require a PIN on the LAN'),
452
- ),
453
- lanPin.enabled && h('div', { style: { display: 'flex', gap: 8, alignItems: 'center', marginTop: 8 } },
454
- h('span', { style: { ...fieldLabelStyle, margin: 0 } }, 'LAN PIN'),
455
- h('code', { style: { ...codeStyle, fontSize: 15, letterSpacing: 2 } }, lanPin.show ? lanPin.pin ?? '••••••••' : '••••••••'),
456
- lanPin.show
457
- ? h('button', { onClick: lanPin.onHide, style: secondaryButtonStyle }, 'Hide')
458
- : h('button', { onClick: lanPin.onShow, style: secondaryButtonStyle }, 'Show'),
459
- h('button', { onClick: lanPin.onRotate, style: secondaryButtonStyle }, 'Rotate'),
460
- ),
461
- )
462
- }
463
-
464
- function PublicAccess({ status, pin, showPin, onRevealPin, onHidePin, onRotatePin }) {
465
- return h('div', null,
466
- status?.running && status?.publicUrl
467
- ? h('div', null,
468
- h('div', { style: { display: 'flex', gap: 14, alignItems: 'center', marginBottom: 12 } },
469
- h(QrImage, { url: status.publicUrl, size: 116 }),
470
- h('div', null,
471
- h('div', { style: codeStyle }, status.publicUrl),
472
- h('p', { style: captionStyle }, 'Works from anywhere; visitors enter the PIN below.'),
473
- ),
474
- ),
475
- )
476
- : h('p', { style: captionStyle }, 'Start the tunnel to get a public address.'),
477
- h('div', { style: { display: 'flex', gap: 8, alignItems: 'center' } },
478
- h('span', { style: { ...fieldLabelStyle, margin: 0 } }, 'Access PIN'),
479
- h('code', { style: { ...codeStyle, fontSize: 15, letterSpacing: 2 } }, showPin && pin !== null ? pin : '••••••••'),
480
- showPin
481
- ? h('button', { onClick: onHidePin, style: secondaryButtonStyle }, 'Hide')
482
- : h('button', { onClick: onRevealPin, style: secondaryButtonStyle }, 'Show'),
483
- h('button', { onClick: onRotatePin, style: secondaryButtonStyle }, 'Rotate'),
484
- ),
485
- h('p', { style: captionStyle }, 'Stays the same across tunnel and DSH restarts; use Rotate when you need a new PIN.'),
486
- )
487
- }
488
-
489
- export function MaestroSettingsTab({ rpcCall, configRpcCall }) {
490
- const [status, setStatus] = useState(null)
491
- const [proxyStatus, setProxyStatus] = useState(null)
492
- const [config, setConfig] = useState({ tunnelMode: 'quick', projectMappings: [] })
493
- const [catalog, setCatalog] = useState(null)
494
- const [busy, setBusy] = useState(false)
495
- const [error, setError] = useState(null)
496
- const [pin, setPin] = useState(null)
497
- const [showPin, setShowPin] = useState(false)
498
- const [lanPinEnabled, setLanPinEnabled] = useState(false)
499
- const [lanPin, setLanPin] = useState(null)
500
- const [showLanPin, setShowLanPin] = useState(false)
501
- // Task 3: Guard/Blacklist/Supervisor/Notifier tabs state
502
- const [activeTab, setActiveTab] = useState('guard')
503
- const [guard, setGuard] = useState({})
504
- const [patternsText, setPatternsText] = useState('')
505
- const [placeholdersText, setPlaceholdersText] = useState('')
506
- const [supervisorCfg, setSupervisorCfg] = useState({})
507
- const [notifierCfg, setNotifierCfg] = useState({})
508
-
509
- const call = async (endpoint, payload) => {
510
- const res = await rpcCall(endpoint, payload)
511
- if (!res?.ok) throw new Error(res?.error?.message ?? 'RPC failed')
512
- return res.value
513
- }
514
-
515
- // Helpers for guard/supervisor/notifier domains via generic config RPC (Task 3)
516
- const unwrap = (res) => {
517
- if (res && typeof res === 'object' && 'ok' in res) {
518
- if (res.ok) return res.value
519
- throw new Error(res.error?.message ?? 'RPC failed')
520
- }
521
- return res
522
- }
523
- const cfgGet = async (domain) => {
524
- if (!configRpcCall) throw new Error('config RPC not available')
525
- const res = await configRpcCall('get', { domain })
526
- return unwrap(res)
527
- }
528
- const cfgSet = async (domain, patch) => {
529
- if (!configRpcCall) throw new Error('config RPC not available')
530
- const res = await configRpcCall('set', { domain, patch })
531
- return unwrap(res)
532
- }
533
- const saveGuard = async (patch) => {
534
- setError(null)
535
- const next = { ...guard, ...patch }
536
- if (patch.gitProtection && guard.gitProtection) next.gitProtection = { ...guard.gitProtection, ...patch.gitProtection }
537
- setGuard(next)
538
- try { await cfgSet('guard', patch) } catch (e) { setError(e.message ?? String(e)) }
539
- }
540
- const commitBlacklistPatterns = async (text) => {
541
- const patterns = text.split('\n').map(s => s.trim()).filter(Boolean)
542
- setError(null)
543
- try { await cfgSet('guardBlacklist', { patterns }) } catch (e) { setError(e.message ?? String(e)) }
544
- }
545
- const commitPlaceholders = async () => {
546
- setError(null)
547
- let obj = {}
548
- try { obj = placeholdersText.trim() ? JSON.parse(placeholdersText) : {}; if (typeof obj !== 'object' || obj === null || Array.isArray(obj)) throw new Error('placeholders must be JSON object') } catch (e) { setError(`placeholders JSON invalid: ${e.message ?? String(e)}`); return }
549
- try { await cfgSet('guardBlacklist', { placeholders: obj }) } catch (e) { setError(e.message ?? String(e)) }
550
- }
551
- const saveSupervisorCfg = async (patch) => {
552
- setError(null)
553
- setSupervisorCfg(prev => ({ ...prev, ...patch }))
554
- try { await cfgSet('supervisor', patch) } catch (e) { setError(e.message ?? String(e)) }
555
- }
556
- const saveNotifierCfg = async (patch) => {
557
- setError(null)
558
- setNotifierCfg(prev => {
559
- const next = { ...prev }
560
- for (const [k, v] of Object.entries(patch)) {
561
- if (k === 'telegram' && typeof v === 'object' && v !== null) next.telegram = { ...(prev.telegram ?? {}), ...v }
562
- else next[k] = v
563
- }
564
- return next
565
- })
566
- try { await cfgSet('notifier', patch) } catch (e) { setError(e.message ?? String(e)) }
567
- }
568
-
569
- const refresh = async () => {
570
- try { setStatus(await call(MAESTRO_ENDPOINTS.status, {})) } catch { /* transient failure, ignore */ }
571
- try { setProxyStatus(await call(MAESTRO_ENDPOINTS.proxyStatus, {})) } catch { /* proxy row may be starting */ }
572
- }
573
-
574
- // Load previously-saved config once on mount — without this, every field
575
- // (including project mappings) would render empty on every page load even
576
- // after being saved, since RPC state is not persisted in the component.
577
- useEffect(() => {
578
- call(MAESTRO_ENDPOINTS.getConfig, {})
579
- .then(saved => setConfig(prev => ({ ...prev, ...saved })))
580
- .catch(() => { /* first run, no config saved yet — keep defaults */ })
581
- // Supervisor model may be stored via generic config service when review not installed — try fallback
582
- if (configRpcCall) {
583
- configRpcCall('get', { domain: 'supervisor' })
584
- .then(res => {
585
- if (res?.ok && res.value?.model) {
586
- setConfig(prev => ({ ...prev, supervisorModel: res.value.model }))
587
- }
588
- })
589
- .catch(() => { /* supervisor domain not yet set or config service unavailable */ })
590
- // Task 3: load Guard/Blacklist/Supervisor/Notifier domains
591
- Promise.all([
592
- cfgGet('guard').catch(() => ({})),
593
- cfgGet('guardBlacklist').catch(() => ({ patterns: [], placeholders: {} })),
594
- cfgGet('supervisor').catch(() => ({})),
595
- cfgGet('notifier').catch(() => ({})),
596
- ]).then(([g, bl, sup, not]) => {
597
- setGuard(g ?? {})
598
- const pats = Array.isArray(bl?.patterns) ? bl.patterns : []
599
- const ph = bl?.placeholders && typeof bl.placeholders === 'object' ? bl.placeholders : {}
600
- setPatternsText(pats.join('\n'))
601
- setPlaceholdersText(JSON.stringify(ph, null, 2))
602
- setSupervisorCfg(sup ?? {})
603
- setNotifierCfg(not ?? {})
604
- }).catch(() => {})
605
- }
606
- call(MAESTRO_ENDPOINTS.lanPinStatus, {})
607
- .then(value => { setLanPinEnabled(value.enabled); if (value.enabled) setLanPin(value.pin ?? null) })
608
- .catch(() => { /* host without the LAN PIN endpoints — keep the row hidden */ })
609
- call(MAESTRO_ENDPOINTS.modelsList, {})
610
- .then(value => setCatalog(value))
611
- .catch(() => { /* catalog may be unavailable before provider is configured */ })
612
- }, [])
613
-
614
- useEffect(() => {
615
- refresh()
616
- const t = setInterval(refresh, 3000)
617
- return () => clearInterval(t)
618
- }, [])
619
-
620
- const revealPin = async () => {
621
- if (pin === null) {
622
- try { setPin((await call(MAESTRO_ENDPOINTS.getPin, {})).pin) } catch (err) { setError(err.message) }
623
- }
624
- setShowPin(true)
625
- }
626
-
627
- const rotatePin = async () => {
628
- setError(null)
629
- try {
630
- const fresh = (await call(MAESTRO_ENDPOINTS.rotatePin, {})).pin
631
- setPin(fresh)
632
- setShowPin(true)
633
- } catch (err) {
634
- setError(err.message)
635
- }
636
- }
637
-
638
- const toggleLanPin = async (enabled) => {
639
- setError(null)
640
- const previous = lanPinEnabled
641
- setLanPinEnabled(enabled)
642
- try {
643
- await call(MAESTRO_ENDPOINTS.lanPinSetEnabled, { enabled })
644
- if (enabled) {
645
- const value = await call(MAESTRO_ENDPOINTS.lanPinStatus, {})
646
- setLanPin(value.pin ?? null)
647
- setShowLanPin(true)
648
- } else {
649
- setLanPin(null)
650
- setShowLanPin(false)
651
- }
652
- } catch (err) {
653
- setLanPinEnabled(previous)
654
- setError(err.message)
655
- }
656
- }
657
-
658
- const revealLanPin = async () => {
659
- if (lanPin === null) {
660
- try { setLanPin((await call(MAESTRO_ENDPOINTS.lanPinStatus, {})).pin ?? null) } catch (err) { setError(err.message) }
661
- }
662
- setShowLanPin(true)
663
- }
664
-
665
- const rotateLanPin = async () => {
666
- setError(null)
667
- try {
668
- const fresh = (await call(MAESTRO_ENDPOINTS.lanPinRotate, {})).pin
669
- setLanPin(fresh)
670
- setShowLanPin(true)
671
- } catch (err) {
672
- setError(err.message)
673
- }
674
- }
675
-
676
- const startTunnel = async () => {
677
- setBusy(true)
678
- setError(null)
679
- try { setStatus(await call(MAESTRO_ENDPOINTS.tunnelStart, {})) } catch (err) { setError(err.message) } finally { setBusy(false) }
680
- }
681
-
682
- const stopTunnel = async () => {
683
- setBusy(true)
684
- setError(null)
685
- try { setStatus(await call(MAESTRO_ENDPOINTS.tunnelStop, {})) } catch (err) { setError(err.message) } finally { setBusy(false) }
686
- }
687
-
688
- const saveField = async (field, value) => {
689
- setError(null)
690
- setConfig(prev => ({ ...prev, [field]: value }))
691
- // Supervisor model can be saved via generic config service when review not installed (independent install)
692
- if (field === 'supervisorModel' && configRpcCall) {
693
- try {
694
- const res = await configRpcCall('set', { domain: 'supervisor', patch: { model: value } })
695
- if (res?.ok) return
696
- // Fall through to review RPC if generic set fails
697
- } catch (e) {
698
- // Fall through to review RPC
699
- }
700
- }
701
- try {
702
- await call(MAESTRO_ENDPOINTS.saveConfig, { [field]: value })
703
- } catch (err) {
704
- setError(err.message)
705
- }
706
- }
707
-
708
- return h('div', { 'data-maestro-settings-card': '', style: { maxWidth: 520 } },
709
- h('h3', { style: { ...headingStyle, fontSize: 15, margin: '0 0 12px' } }, 'Maestro'),
710
-
711
- h('label', { style: fieldLabelStyle }, 'Tunnel mode'),
712
- h('select', { value: config.tunnelMode, style: inputStyle, onChange: e => saveField('tunnelMode', e.target.value) },
713
- h('option', { value: 'quick' }, 'Quick (no setup, URL changes on restart)'),
714
- h('option', { value: 'named' }, 'Named (stable URL, one-time setup)'),
715
- ),
716
- config.tunnelMode === 'named' && h(NamedTunnelSetupNote),
717
- config.tunnelMode === 'named' && h('div', null,
718
- h('label', { style: fieldLabelStyle }, 'Tunnel ID'),
719
- h('input', { placeholder: 'Tunnel ID', style: inputStyle, value: config.tunnelId ?? '', onChange: e => saveField('tunnelId', e.target.value) }),
720
- h('label', { style: fieldLabelStyle }, 'Credentials file path'),
721
- h('input', { placeholder: '~/.cloudflared/<id>.json', style: inputStyle, value: config.tunnelCredentialsFile ?? '', onChange: e => saveField('tunnelCredentialsFile', e.target.value) }),
722
- h('label', { style: fieldLabelStyle }, 'Hostname'),
723
- h('input', { placeholder: 'dsh.example.com', style: inputStyle, value: config.tunnelHostname ?? '', onChange: e => saveField('tunnelHostname', e.target.value) }),
724
- ),
725
- h('div', { style: { marginTop: 12 } },
726
- status?.running
727
- ? h('button', { disabled: busy, onClick: stopTunnel, style: secondaryButtonStyle }, 'Stop tunnel')
728
- : h('button', { disabled: busy, onClick: startTunnel, style: primaryButtonStyle }, 'Start tunnel'),
729
- ),
730
-
731
- h('div', { style: sectionStyle },
732
- h('h4', { style: headingStyle }, 'Remote access (LAN)'),
733
- h(LanAccess, {
734
- proxyStatus,
735
- lanPin: lanPinEnabled === null ? null : {
736
- enabled: lanPinEnabled,
737
- pin: lanPin,
738
- show: showLanPin,
739
- onShow: revealLanPin,
740
- onHide: () => setShowLanPin(false),
741
- onRotate: rotateLanPin,
742
- onToggle: toggleLanPin,
743
- },
744
- }),
745
- ),
746
-
747
- h('div', { style: sectionStyle },
748
- h('h4', { style: headingStyle }, 'Public access'),
749
- h(PublicAccess, { status, pin, showPin, onRevealPin: revealPin, onHidePin: () => setShowPin(false), onRotatePin: rotatePin }),
750
- ),
751
-
752
- h('div', { style: sectionStyle },
753
- h('h4', { style: headingStyle }, 'Telegram notifications'),
754
- h('p', { style: captionStyle }, 'Send one protected startup update with the current public-access PIN to a single Telegram chat.'),
755
- h(SecretInput, {
756
- label: 'Bot token',
757
- placeholder: '123456:ABC-DEF...',
758
- hasSaved: config.hasTelegramBotToken,
759
- onSave: value => saveField('telegramBotToken', value),
760
- }),
761
- h('label', { style: fieldLabelStyle }, 'Chat ID'),
762
- h('input', { placeholder: '-1001234567890', autoComplete: 'off', style: inputStyle, value: config.telegramChatId ?? '', onChange: e => saveField('telegramChatId', e.target.value) }),
763
- h(ToggleField, {
764
- label: 'Also notify about finished reviews',
765
- caption: 'One message per review run with its outcome and a short summary.',
766
- checked: config.telegramReviewNotifications,
767
- onChange: checked => saveField('telegramReviewNotifications', checked),
768
- }),
769
- h('p', { style: captionStyle }, 'Leave either credential blank to disable notifications. Telegram delivery failures never prevent DSH from starting.'),
770
- ),
771
-
772
- h('div', { style: sectionStyle },
773
- h('h4', { style: headingStyle }, 'GitLab'),
774
- h('label', { style: fieldLabelStyle }, 'GitLab base URL'),
775
- h('input', { placeholder: 'https://gitlab.example.com', style: inputStyle, value: config.gitlabBaseUrl ?? '', onChange: e => saveField('gitlabBaseUrl', e.target.value) }),
776
- h(SecretInput, {
777
- label: 'GitLab token',
778
- placeholder: 'GitLab token',
779
- hasSaved: config.hasGitlabToken,
780
- onSave: value => saveField('gitlabToken', value),
781
- }),
782
- h('label', { style: fieldLabelStyle }, 'Bot username'),
783
- h('input', { placeholder: 'maestro-bot', style: inputStyle, value: config.botUsername ?? '', onChange: e => saveField('botUsername', e.target.value) }),
784
- h(SecretInput, {
785
- label: 'Webhook secret',
786
- placeholder: 'Webhook secret',
787
- hasSaved: config.hasWebhookSecret,
788
- onSave: value => saveField('webhookSecret', value),
789
- }),
790
- h('button', { type: 'button', style: { ...secondaryButtonStyle, marginTop: 10 }, onClick: () => saveField('webhookSecret', generateWebhookSecret()) }, 'Generate new secret'),
791
- h('p', { style: captionStyle }, 'In GitLab: Settings → Webhooks, set Secret token to this value and enable Merge request events.'),
792
- h('p', { style: captionStyle }, 'Webhook URL:'),
793
- h('code', { style: codeStyle }, gitlabWebhookUrl(config.tunnelHostname)),
794
- h('p', { style: captionStyle }, 'Overrides MAESTRO_GITLAB_WEBHOOK_SECRET immediately, no restart needed.'),
795
- ),
796
-
797
- h('div', { style: sectionStyle },
798
- h('h4', { style: headingStyle }, 'Review automation'),
799
- h(ToggleField, {
800
- label: 'Re-review when new commits are pushed',
801
- caption: 'After a completed review, further pushes to the same MR trigger an automatic quick re-review.',
802
- checked: config.autoRereviewOnPush,
803
- onChange: checked => saveField('autoRereviewOnPush', checked),
804
- }),
805
- ),
806
-
807
- h('div', { style: sectionStyle },
808
- h('h4', { style: headingStyle }, 'Review model'),
809
- h('p', { style: captionStyle }, 'Model used for automated GitLab reviews (reviewer & auditor). Empty = DSH default. Per-project empty = inherits Global, or DSH default when Global is empty.'),
810
- h(ReviewModelSelector, {
811
- value: config.reviewModel ?? null,
812
- catalog,
813
- fallbackValue: catalog?.current ?? null,
814
- fallbackLabel: 'Use DSH default',
815
- onChange: v => saveField('reviewModel', v),
816
- label: 'Global review model',
817
- }),
818
- ),
819
-
820
- h('div', { style: sectionStyle },
821
- h('h4', { style: headingStyle }, 'Supervisor LLM'),
822
- h('p', { style: captionStyle }, 'Model used by the supervisor debug-agent to auto-fix DSH Web crashes. Empty = DSH default (or Review model if set). Uses the same provider catalog as Review.'),
823
- h(ReviewModelSelector, {
824
- value: config.supervisorModel ?? null,
825
- catalog,
826
- fallbackValue: catalog?.current ?? null,
827
- fallbackLabel: 'Use DSH default',
828
- onChange: v => saveField('supervisorModel', v),
829
- label: 'Supervisor model',
830
- }),
831
- ),
832
-
833
- h('div', { style: sectionStyle },
834
- h('h4', { style: headingStyle }, 'Projects'),
835
- h(ProjectMappingsEditor, { mappings: config.projectMappings ?? [], onChange: mappings => saveField('projectMappings', mappings), catalog, globalReviewModel: config.reviewModel ?? null }),
836
- ),
837
-
838
- // Task 3: Guard/Blacklist/Supervisor/Notifier tabs — data-driven over guard domains
839
- h('div', { style: sectionStyle },
840
- h('h4', { style: headingStyle }, 'Guard / Blacklist / Supervisor / Notifier'),
841
- h('div', { style: tabBarStyle },
842
- h('button', { type: 'button', style: tabButtonStyle(activeTab === 'guard'), onClick: () => setActiveTab('guard') }, 'Guard'),
843
- h('button', { type: 'button', style: tabButtonStyle(activeTab === 'blacklist'), onClick: () => setActiveTab('blacklist') }, 'Blacklist'),
844
- h('button', { type: 'button', style: tabButtonStyle(activeTab === 'supervisor'), onClick: () => setActiveTab('supervisor') }, 'Supervisor'),
845
- h('button', { type: 'button', style: tabButtonStyle(activeTab === 'notifier'), onClick: () => setActiveTab('notifier') }, 'Notifier'),
846
- ),
847
- activeTab === 'guard' && h('div', { 'data-tab': 'guard' },
848
- h('p', { style: captionStyle }, 'Enforce publish block, git protection and cwd containment.'),
849
- h(ToggleField, {
850
- label: 'publishBlocked',
851
- caption: 'Block publish-related commands when enabled.',
852
- checked: guard.publishBlocked === true,
853
- onChange: v => saveGuard({ publishBlocked: v }),
854
- }),
855
- h(ToggleField, {
856
- label: 'gitProtection.enabled',
857
- caption: 'Protect pushes to protected branches.',
858
- checked: guard.gitProtection?.enabled === true,
859
- onChange: v => saveGuard({ gitProtection: { enabled: v, branches: guard.gitProtection?.branches ?? ['master', 'main'] } }),
860
- }),
861
- h('label', { style: fieldLabelStyle }, 'gitProtection.branches (comma separated)'),
862
- h('input', {
863
- style: inputStyle,
864
- value: (guard.gitProtection?.branches ?? ['master', 'main']).join(', '),
865
- placeholder: 'master, main',
866
- onChange: e => {
867
- const branches = e.target.value.split(',').map(s => s.trim()).filter(Boolean)
868
- saveGuard({ gitProtection: { enabled: guard.gitProtection?.enabled ?? true, branches } })
869
- },
870
- }),
871
- h(ToggleField, {
872
- label: 'cwdContainment',
873
- caption: 'Contain file operations to the session cwd.',
874
- checked: guard.cwdContainment === true,
875
- onChange: v => saveGuard({ cwdContainment: v }),
876
- }),
877
- h('label', { style: fieldLabelStyle }, 'credentialPaths (comma separated)'),
878
- h('input', {
879
- style: inputStyle,
880
- value: (guard.credentialPaths ?? []).join(', '),
881
- placeholder: '~/.config/credentials.yaml, ~/.config/cloudflared',
882
- onChange: e => {
883
- const credentialPaths = e.target.value.split(',').map(s => s.trim()).filter(Boolean)
884
- saveGuard({ credentialPaths })
885
- },
886
- }),
887
- ),
888
- activeTab === 'blacklist' && h('div', { 'data-tab': 'blacklist' },
889
- h('p', { style: captionStyle }, 'One pattern per line. These are blocked from being committed or published.'),
890
- h('label', { style: fieldLabelStyle }, 'patterns (one per line)'),
891
- h('textarea', {
892
- style: textareaStyle,
893
- value: patternsText,
894
- placeholder: 'example-project\nacme-shop',
895
- onChange: e => setPatternsText(e.target.value),
896
- onBlur: e => commitBlacklistPatterns(e.target.value),
897
- }),
898
- h('label', { style: fieldLabelStyle }, 'placeholders JSON'),
899
- h('textarea', {
900
- style: { ...textareaStyle, height: 90 },
901
- value: placeholdersText,
902
- placeholder: '{"example-project":"my-project"}',
903
- onChange: e => setPlaceholdersText(e.target.value),
904
- onBlur: () => commitPlaceholders(),
905
- }),
906
- h('p', { style: captionStyle }, 'Map blocked patterns to their placeholder suggestions.'),
907
- h('button', { type: 'button', style: { ...secondaryButtonStyle, marginTop: 8 }, onClick: () => { commitBlacklistPatterns(patternsText); commitPlaceholders() } }, 'Save Blacklist'),
908
- ),
909
- activeTab === 'supervisor' && h('div', { 'data-tab': 'supervisor' },
910
- h('p', { style: captionStyle }, 'Background daemon that auto-resumes crashed sessions.'),
911
- h('label', { style: fieldLabelStyle }, 'intervalMs'),
912
- h('input', {
913
- type: 'number',
914
- style: inputStyle,
915
- value: supervisorCfg.intervalMs ?? '',
916
- placeholder: '5000',
917
- onChange: e => { const v = e.target.value === '' ? undefined : Number(e.target.value); saveSupervisorCfg({ intervalMs: v }) },
918
- }),
919
- h('label', { style: fieldLabelStyle }, 'downThreshold'),
920
- h('input', {
921
- type: 'number',
922
- style: inputStyle,
923
- value: supervisorCfg.downThreshold ?? '',
924
- placeholder: '3',
925
- onChange: e => { const v = e.target.value === '' ? undefined : Number(e.target.value); saveSupervisorCfg({ downThreshold: v }) },
926
- }),
927
- h(ToggleField, {
928
- label: 'autoResumeEnabled',
929
- caption: 'Automatically resume down sessions.',
930
- checked: supervisorCfg.autoResumeEnabled === true,
931
- onChange: v => saveSupervisorCfg({ autoResumeEnabled: v }),
932
- }),
933
- ),
934
- activeTab === 'notifier' && h('div', { 'data-tab': 'notifier' },
935
- h('p', { style: captionStyle }, 'Telegram notifications for Maestro events.'),
936
- h('label', { style: fieldLabelStyle }, 'telegram.botToken'),
937
- h('input', {
938
- type: 'password',
939
- autoComplete: 'off',
940
- style: inputStyle,
941
- value: notifierCfg.telegram?.botToken ?? '',
942
- placeholder: '123456:ABC-DEF...',
943
- onChange: e => saveNotifierCfg({ telegram: { botToken: e.target.value } }),
944
- }),
945
- h('label', { style: fieldLabelStyle }, 'telegram.chatId'),
946
- h('input', {
947
- style: inputStyle,
948
- value: notifierCfg.telegram?.chatId ?? '',
949
- placeholder: '-1001234567890',
950
- onChange: e => saveNotifierCfg({ telegram: { chatId: e.target.value } }),
951
- }),
952
- h(ToggleField, {
953
- label: 'telegram.reviewNotifications',
954
- caption: 'Also notify about finished reviews.',
955
- checked: notifierCfg.telegram?.reviewNotifications === true || notifierCfg.policy?.reviewNotifications === true,
956
- onChange: v => saveNotifierCfg({ telegram: { reviewNotifications: v } }),
957
- }),
958
- ),
959
- ),
960
-
961
- error && h('p', { style: errorStyle }, error),
962
- )
963
- }
964
-
965
- // Injected synchronously at apply time (deferred effects can stall behind
966
- // unavailable slot scopes); the external plugin's stylesheet handles
967
- // everything outside this card.
968
- function installMaestroMobileCss() {
969
- if (document.querySelector('style[data-plugin-css="maestro/mobile-maestro.css"]') !== null) return
970
- const tag = document.createElement('style')
971
- tag.dataset.plugin = name
972
- tag.dataset.pluginCss = 'maestro/mobile-maestro.css'
973
- tag.textContent = MAESTRO_MOBILE_CSS + SETTINGS_NAV_CSS
974
- document.head.appendChild(tag)
975
- }
976
-