@ddtcorex/dsh-maestro-config 0.1.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.
- package/README.md +10 -0
- package/cordis.patch.yml +6 -0
- package/lib/client.js +3028 -0
- package/lib/index.d.ts +12 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +44 -0
- package/lib/index.js.map +1 -0
- package/lib/service.d.ts +16 -0
- package/lib/service.d.ts.map +1 -0
- package/lib/service.js +24 -0
- package/lib/service.js.map +1 -0
- package/lib/types/client/api.d.ts +23 -0
- package/lib/types/client/api.d.ts.map +1 -0
- package/lib/types/client/index.d.ts +7 -0
- package/lib/types/client/index.d.ts.map +1 -0
- package/lib/types/client/maestro-card.d.ts +9 -0
- package/lib/types/client/maestro-card.d.ts.map +1 -0
- package/lib/types/client/settings-nav-icon.d.ts +21 -0
- package/lib/types/client/settings-nav-icon.d.ts.map +1 -0
- package/lib/types/client/webhook-secret.d.ts +5 -0
- package/lib/types/client/webhook-secret.d.ts.map +1 -0
- package/package.json +65 -0
- package/src/client/api.ts +23 -0
- package/src/client/index.tsx +79 -0
- package/src/client/maestro-card.jsx +702 -0
- package/src/client/settings-nav-icon.ts +68 -0
- package/src/client/webhook-secret.ts +13 -0
- package/src/host/index.ts +58 -0
- package/src/host/service.ts +33 -0
|
@@ -0,0 +1,702 @@
|
|
|
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
|
+
/** QR code centered in a light tile with an even scanner-friendly quiet zone. */
|
|
90
|
+
function QrImage({ url, size = 104 }) {
|
|
91
|
+
const [dataUrl, setDataUrl] = useState(null)
|
|
92
|
+
useEffect(() => {
|
|
93
|
+
let live = true
|
|
94
|
+
QRCode.toDataURL(url, { margin: 0, width: size * 2 })
|
|
95
|
+
.then((d) => { if (live) setDataUrl(d) })
|
|
96
|
+
.catch(() => {})
|
|
97
|
+
return () => { live = false }
|
|
98
|
+
}, [url, size])
|
|
99
|
+
return h('div', {
|
|
100
|
+
style: {
|
|
101
|
+
background: '#ffffff',
|
|
102
|
+
borderRadius: 10,
|
|
103
|
+
boxSizing: 'border-box',
|
|
104
|
+
width: size + 20,
|
|
105
|
+
height: size + 20,
|
|
106
|
+
display: 'flex',
|
|
107
|
+
alignItems: 'center',
|
|
108
|
+
justifyContent: 'center',
|
|
109
|
+
lineHeight: 0,
|
|
110
|
+
flex: 'none',
|
|
111
|
+
alignSelf: 'flex-start',
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
dataUrl === null
|
|
115
|
+
? h('div', { style: { width: size, height: size, background: 'var(--dsw-alias-bg-skeleton)', borderRadius: 4 } })
|
|
116
|
+
: h('img', { src: dataUrl, alt: url, width: size, height: size, style: { display: 'block' } }),
|
|
117
|
+
)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function NamedTunnelSetupNote() {
|
|
121
|
+
return h('div', { style: captionStyle },
|
|
122
|
+
h('p', { style: { ...captionStyle, marginBottom: 4 } }, 'Named tunnel needs a one-time manual setup (requires your own Cloudflare account — cannot be automated):'),
|
|
123
|
+
h('ol', { style: { margin: '4px 0', paddingLeft: 20 } },
|
|
124
|
+
h('li', null, 'cloudflared tunnel login'),
|
|
125
|
+
h('li', null, 'cloudflared tunnel create dsh-maestro-webhook'),
|
|
126
|
+
h('li', null, 'cloudflared tunnel route dns dsh-maestro-webhook <your-hostname>'),
|
|
127
|
+
h('li', null, 'Paste the printed Tunnel ID, the credentials file path (~/.cloudflared/<id>.json), and the hostname below.'),
|
|
128
|
+
),
|
|
129
|
+
)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function ReviewModelSelector({ value, catalog, fallbackValue, fallbackLabel, onChange, label }) {
|
|
133
|
+
const groups = catalog?.groups ?? []
|
|
134
|
+
const providers = groups.map(g => g.provider)
|
|
135
|
+
const selectedProvider = value?.provider ?? ''
|
|
136
|
+
const providerGroup = groups.find(g => g.provider === selectedProvider)
|
|
137
|
+
const models = providerGroup?.models ?? []
|
|
138
|
+
const selectedEffort = value?.reasoningEffort ?? ''
|
|
139
|
+
const [open, setOpen] = useState(false)
|
|
140
|
+
const [pane, setPane] = useState('root')
|
|
141
|
+
const rootRef = useRef(null)
|
|
142
|
+
useEffect(() => {
|
|
143
|
+
if (!open) return
|
|
144
|
+
const onDown = (e) => { if (rootRef.current && !rootRef.current.contains(e.target)) { setOpen(false); setPane('root') } }
|
|
145
|
+
const onKey = (e) => { if (e.key === 'Escape') { setOpen(false); setPane('root') } }
|
|
146
|
+
document.addEventListener('mousedown', onDown)
|
|
147
|
+
document.addEventListener('keydown', onKey)
|
|
148
|
+
return () => { document.removeEventListener('mousedown', onDown); document.removeEventListener('keydown', onKey) }
|
|
149
|
+
}, [open])
|
|
150
|
+
const update = (field, newVal) => {
|
|
151
|
+
if (newVal === '' && field === 'provider') { onChange(null); setOpen(false); setPane('root'); return }
|
|
152
|
+
const next = { provider: value?.provider ?? '', model: value?.model ?? '', ...(value?.reasoningEffort ? { reasoningEffort: value.reasoningEffort } : {}) }
|
|
153
|
+
if (field === 'provider') { const g = groups.find(x => x.provider === newVal); next.provider = newVal; next.model = g?.models[0] ?? '' }
|
|
154
|
+
else if (field === 'model') { next.model = newVal }
|
|
155
|
+
else if (field === 'reasoningEffort') { if (newVal === '') delete next.reasoningEffort; else next.reasoningEffort = newVal }
|
|
156
|
+
if (!next.provider || !next.model) { onChange(null) } else { onChange(next) }
|
|
157
|
+
}
|
|
158
|
+
const effectiveFallback = fallbackValue !== undefined ? fallbackValue : (catalog?.current ?? null)
|
|
159
|
+
const effectiveFallbackLabel = fallbackLabel ?? 'Use DSH default'
|
|
160
|
+
const triggerLabel = value
|
|
161
|
+
? `${value.provider} / ${value.model}${value.reasoningEffort ? ` · ${value.reasoningEffort}` : ''}`
|
|
162
|
+
: effectiveFallback
|
|
163
|
+
? `${effectiveFallbackLabel} · ${effectiveFallback.provider}/${effectiveFallback.model}${effectiveFallback.reasoningEffort ? ` · ${effectiveFallback.reasoningEffort}` : ''}`
|
|
164
|
+
: effectiveFallbackLabel
|
|
165
|
+
const triggerStyle = {
|
|
166
|
+
height: 32,
|
|
167
|
+
padding: '0 12px 0 14px',
|
|
168
|
+
borderRadius: 20,
|
|
169
|
+
border: '1px solid var(--dsw-alias-border-l2)',
|
|
170
|
+
background: 'var(--dsw-alias-bg-layer-2)',
|
|
171
|
+
color: 'var(--dsw-alias-label-primary)',
|
|
172
|
+
font: 'inherit',
|
|
173
|
+
fontSize: 13,
|
|
174
|
+
display: 'inline-flex',
|
|
175
|
+
alignItems: 'center',
|
|
176
|
+
gap: 8,
|
|
177
|
+
cursor: 'pointer',
|
|
178
|
+
maxWidth: 320,
|
|
179
|
+
whiteSpace: 'nowrap',
|
|
180
|
+
}
|
|
181
|
+
const menuStyle = {
|
|
182
|
+
position: 'absolute',
|
|
183
|
+
top: 'calc(100% + 8px)',
|
|
184
|
+
left: 0,
|
|
185
|
+
minWidth: 300,
|
|
186
|
+
maxWidth: 360,
|
|
187
|
+
background: 'var(--dsw-alias-bg-layer-1)',
|
|
188
|
+
border: '1px solid var(--dsw-alias-border-l2)',
|
|
189
|
+
borderRadius: 12,
|
|
190
|
+
boxShadow: '0 8 24px rgba(0,0,0,.12)',
|
|
191
|
+
zIndex: 20,
|
|
192
|
+
padding: 6,
|
|
193
|
+
}
|
|
194
|
+
const rowStyle = {
|
|
195
|
+
width: '100%',
|
|
196
|
+
display: 'flex',
|
|
197
|
+
alignItems: 'center',
|
|
198
|
+
justifyContent: 'space-between',
|
|
199
|
+
gap: 12,
|
|
200
|
+
padding: '9px 10px',
|
|
201
|
+
borderRadius: 8,
|
|
202
|
+
border: 'none',
|
|
203
|
+
background: 'transparent',
|
|
204
|
+
color: 'var(--dsw-alias-label-primary)',
|
|
205
|
+
font: 'inherit',
|
|
206
|
+
fontSize: 13,
|
|
207
|
+
cursor: 'pointer',
|
|
208
|
+
textAlign: 'left',
|
|
209
|
+
}
|
|
210
|
+
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' } })
|
|
211
|
+
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' }))
|
|
212
|
+
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' }))
|
|
213
|
+
const effortLabel = selectedEffort === '' ? 'Default effort' : selectedEffort
|
|
214
|
+
const modelLabel = selectedProvider === '' ? 'Select model' : (value?.model ?? 'Select model')
|
|
215
|
+
return h('div', { ref: rootRef, style: { position: 'relative', display: 'inline-block', maxWidth: '100%' } },
|
|
216
|
+
label && h('span', { style: fieldLabelStyle }, label),
|
|
217
|
+
h('button', { type: 'button', style: triggerStyle, onClick: () => { setOpen(v => !v); setPane('root') }, 'aria-expanded': open, 'aria-haspopup': 'menu', title: triggerLabel },
|
|
218
|
+
h('span', { style: { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, triggerLabel),
|
|
219
|
+
chevronDown,
|
|
220
|
+
),
|
|
221
|
+
open && h('div', { style: menuStyle, role: 'menu' },
|
|
222
|
+
pane === 'root' && h('div', null,
|
|
223
|
+
h('button', { type: 'button', style: { ...rowStyle, background: !value ? 'var(--dsw-alias-bg-layer-2)' : 'transparent' }, onClick: () => { onChange(null); setOpen(false) } },
|
|
224
|
+
h('span', null, effectiveFallbackLabel),
|
|
225
|
+
check(!value),
|
|
226
|
+
),
|
|
227
|
+
h('div', { style: { height: 1, background: 'var(--dsw-alias-border-l2)', margin: '6px 2px' } }),
|
|
228
|
+
h('button', { type: 'button', style: rowStyle, onClick: () => setPane('model') },
|
|
229
|
+
h('span', null, 'Model'),
|
|
230
|
+
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),
|
|
231
|
+
),
|
|
232
|
+
h('button', { type: 'button', style: rowStyle, onClick: () => setPane('effort') },
|
|
233
|
+
h('span', null, 'Effort'),
|
|
234
|
+
h('span', { style: { display: 'flex', alignItems: 'center', gap: 8, color: 'var(--dsw-alias-label-secondary)' } }, effortLabel, chevronRight),
|
|
235
|
+
),
|
|
236
|
+
value && h('p', { style: { ...captionStyle, margin: '8px 4px 2px' } }, `Selected: ${value.provider} / ${value.model}${value.reasoningEffort ? ` (${value.reasoningEffort})` : ''}`),
|
|
237
|
+
!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})` : ''}`),
|
|
238
|
+
),
|
|
239
|
+
pane === 'model' && h('div', null,
|
|
240
|
+
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')),
|
|
241
|
+
h('div', { style: { maxHeight: 260, overflowY: 'auto', marginTop: 4 } },
|
|
242
|
+
providers.length === 0 ? h('p', { style: captionStyle }, 'No providers') :
|
|
243
|
+
providers.map(p => {
|
|
244
|
+
const g = groups.find(x => x.provider === p)
|
|
245
|
+
const ms = g?.models ?? []
|
|
246
|
+
return h('div', { key: p, style: { marginBottom: 8 } },
|
|
247
|
+
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),
|
|
248
|
+
ms.length === 0 ? h('p', { style: { ...captionStyle, padding: '2px 10px 2px 28px' } }, 'No models') :
|
|
249
|
+
h('div', { style: { marginLeft: 12, borderLeft: '1px solid var(--dsw-alias-border-l2)', paddingLeft: 6, display: 'flex', flexDirection: 'column', gap: 2 } },
|
|
250
|
+
ms.map(m => h('button', { key: m, type: 'button', style: { ...rowStyle, paddingLeft: 10, background: value?.provider === p && value?.model === m ? 'var(--dsw-alias-bg-layer-2)' : 'transparent' }, onClick: () => { update('model', m); if (value?.provider !== p) update('provider', p); else { const next = { provider: p, model: m, ...(selectedEffort ? { reasoningEffort: selectedEffort } : {}) }; onChange(next); setPane('root') } } }, h('span', { style: { overflow: 'hidden', textOverflow: 'ellipsis' } }, m), check(value?.provider === p && value?.model === m)))),
|
|
251
|
+
)
|
|
252
|
+
}),
|
|
253
|
+
),
|
|
254
|
+
),
|
|
255
|
+
pane === 'effort' && h('div', null,
|
|
256
|
+
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')),
|
|
257
|
+
h('div', { style: { marginTop: 4 } },
|
|
258
|
+
[
|
|
259
|
+
{ id: '', label: 'Default effort' },
|
|
260
|
+
{ id: 'low', label: 'low' },
|
|
261
|
+
{ id: 'medium', label: 'medium' },
|
|
262
|
+
{ id: 'high', label: 'high' },
|
|
263
|
+
].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))),
|
|
264
|
+
),
|
|
265
|
+
),
|
|
266
|
+
),
|
|
267
|
+
)
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function ProjectMappingsEditor({ mappings, onChange, catalog, globalReviewModel }) {
|
|
271
|
+
const rows = mappings.length > 0 ? mappings : [{ projectPath: '', localRepoPath: '', reviewProfile: 'magento2' }]
|
|
272
|
+
const updateRow = (index, field, value) => {
|
|
273
|
+
const next = rows.map((row, i) => (i === index ? { ...row, [field]: value } : row))
|
|
274
|
+
onChange(next.filter(r => r.projectPath !== '' || r.localRepoPath !== ''))
|
|
275
|
+
}
|
|
276
|
+
const removeRow = (index) => onChange(rows.filter((_, i) => i !== index))
|
|
277
|
+
const addRow = () => onChange([...rows, { projectPath: '', localRepoPath: '', reviewProfile: 'magento2' }])
|
|
278
|
+
return h('div', null,
|
|
279
|
+
h('span', { style: fieldLabelStyle }, 'Tracked projects (GitLab path → local repo checkout → review profile → review model override)'),
|
|
280
|
+
rows.map((row, i) => h('div', { key: i, 'data-maestro-mapping-row': '', style: { display: 'flex', gap: 8, marginBottom: 8, alignItems: 'center', flexWrap: 'wrap' } },
|
|
281
|
+
h('input', { placeholder: 'group/project', style: { ...inputStyle, flex: '1 1 120px' }, value: row.projectPath, onChange: e => updateRow(i, 'projectPath', e.target.value) }),
|
|
282
|
+
h('input', { placeholder: '/path/to/local/clone', style: { ...inputStyle, flex: '1 1 140px' }, value: row.localRepoPath, onChange: e => updateRow(i, 'localRepoPath', e.target.value) }),
|
|
283
|
+
h('select', { style: { ...inputStyle, flex: '0 0 130px' }, value: row.reviewProfile ?? 'magento2', onChange: e => updateRow(i, 'reviewProfile', e.target.value) },
|
|
284
|
+
h('option', { value: 'magento2' }, 'Magento 2'),
|
|
285
|
+
h('option', { value: 'generic' }, 'Generic'),
|
|
286
|
+
),
|
|
287
|
+
h('div', { style: { flex: '0 0 auto' } },
|
|
288
|
+
h(ReviewModelSelector, {
|
|
289
|
+
value: row.reviewModel ?? null,
|
|
290
|
+
catalog,
|
|
291
|
+
fallbackValue: globalReviewModel ?? catalog?.current ?? null,
|
|
292
|
+
fallbackLabel: globalReviewModel ? 'Use Global' : 'Use DSH default',
|
|
293
|
+
onChange: v => updateRow(i, 'reviewModel', v),
|
|
294
|
+
label: null,
|
|
295
|
+
}),
|
|
296
|
+
),
|
|
297
|
+
h('button', { onClick: () => removeRow(i), style: secondaryButtonStyle, title: 'Remove mapping' }, '✕'),
|
|
298
|
+
)),
|
|
299
|
+
h('button', { onClick: addRow, style: secondaryButtonStyle }, '+ Add mapping'),
|
|
300
|
+
)
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Password field for a stored secret the server never echoes back
|
|
305
|
+
* (`getConfig` masks secrets). Empty by default; a typed value saves on blur,
|
|
306
|
+
* an untouched field keeps the stored secret, and Clear writes '' to erase it.
|
|
307
|
+
*/
|
|
308
|
+
function SecretInput({ label, placeholder, hasSaved, onSave }) {
|
|
309
|
+
const [draft, setDraft] = useState('')
|
|
310
|
+
const clear = () => { setDraft(''); onSave('') }
|
|
311
|
+
return h('div', null,
|
|
312
|
+
h('label', { style: fieldLabelStyle }, label),
|
|
313
|
+
h('div', { style: { display: 'flex', gap: 8 } },
|
|
314
|
+
h('input', {
|
|
315
|
+
placeholder: hasSaved === true ? 'saved — leave blank to keep' : placeholder,
|
|
316
|
+
type: 'password',
|
|
317
|
+
autoComplete: 'off',
|
|
318
|
+
style: inputStyle,
|
|
319
|
+
value: draft,
|
|
320
|
+
onChange: e => setDraft(e.target.value),
|
|
321
|
+
onBlur: () => { if (draft !== '') onSave(draft) },
|
|
322
|
+
}),
|
|
323
|
+
hasSaved === true && h('button', { type: 'button', style: secondaryButtonStyle, onClick: clear }, 'Clear'),
|
|
324
|
+
),
|
|
325
|
+
)
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** Simple checked/unchecked toggle bound to a boolean settings key. */
|
|
329
|
+
function ToggleField({ label, caption, checked, onChange }) {
|
|
330
|
+
return h('label', { style: { display: 'flex', alignItems: 'flex-start', gap: 8, margin: '8px 0', cursor: 'pointer' } },
|
|
331
|
+
h('input', { type: 'checkbox', checked: checked === true, onChange: e => onChange(e.target.checked), style: { marginTop: 3 } }),
|
|
332
|
+
h('span', null,
|
|
333
|
+
h('div', { style: { fontSize: 13 } }, label),
|
|
334
|
+
caption != null && h('div', { style: captionStyle }, caption),
|
|
335
|
+
),
|
|
336
|
+
)
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** Newest-first list of recorded review runs from the host's reviews.json. */
|
|
340
|
+
function ReviewHistoryPanel({ rpcCall }) {
|
|
341
|
+
const [entries, setEntries] = useState(null)
|
|
342
|
+
useEffect(() => {
|
|
343
|
+
rpcCall(MAESTRO_ENDPOINTS.reviewsList, {})
|
|
344
|
+
.then(res => { if (res?.ok) setEntries(res.value ?? []) })
|
|
345
|
+
.catch(() => setEntries([]))
|
|
346
|
+
}, [])
|
|
347
|
+
if (entries === null) return h('p', { style: captionStyle }, 'Loading review history…')
|
|
348
|
+
if (entries.length === 0) return h('p', { style: captionStyle }, 'No reviews recorded yet.')
|
|
349
|
+
const icon = entry => entry.status === 'completed' ? '✅' : entry.status === 'failed' ? '⚠️' : '👀'
|
|
350
|
+
return h('ul', { style: { listStyle: 'none', margin: 0, padding: 0 } },
|
|
351
|
+
entries.map(entry => h('li', { key: entry.id, style: { padding: '6px 0', borderBottom: '1px solid var(--dsw-alias-separator-default, #333)', fontSize: 13 } },
|
|
352
|
+
h('span', null, `${icon(entry)} ${entry.projectPath} !${entry.mrIid} · ${entry.mode}${entry.trigger !== 'mention' ? ` · ${entry.trigger}` : ''}`),
|
|
353
|
+
h('div', { style: captionStyle },
|
|
354
|
+
`${new Date(entry.startedAt).toLocaleString()}${entry.summary ? ` — ${entry.summary}` : ''}${entry.error ? ` — ${entry.error}` : ''}`),
|
|
355
|
+
)),
|
|
356
|
+
)
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** One selectable LAN address chip + the QR of the currently selected URL. */
|
|
360
|
+
function LanAccess({ proxyStatus, lanPin }) {
|
|
361
|
+
const urls = proxyStatus?.lanUrls ?? []
|
|
362
|
+
const [selected, setSelected] = useState(0)
|
|
363
|
+
const index = Math.min(selected, Math.max(urls.length - 1, 0))
|
|
364
|
+
if (!proxyStatus?.running) {
|
|
365
|
+
return h('p', { style: errorStyle }, proxyStatus?.errorMessage ?? 'Proxy not running')
|
|
366
|
+
}
|
|
367
|
+
return h('div', null,
|
|
368
|
+
h('p', { style: captionStyle }, lanPin?.enabled === true
|
|
369
|
+
? 'Open this full DSH UI from any device on your network — visitors enter the LAN PIN below.'
|
|
370
|
+
: 'Open this full DSH UI from any device on your network — no PIN needed.'),
|
|
371
|
+
urls.length > 0 && h('div', { style: { display: 'flex', gap: 6, flexWrap: 'wrap', marginBottom: 12 } },
|
|
372
|
+
urls.map((url, i) => h('button', {
|
|
373
|
+
key: url,
|
|
374
|
+
onClick: () => setSelected(i),
|
|
375
|
+
style: {
|
|
376
|
+
...secondaryButtonStyle,
|
|
377
|
+
height: 26,
|
|
378
|
+
padding: '0 10px',
|
|
379
|
+
fontSize: 12,
|
|
380
|
+
borderRadius: 13,
|
|
381
|
+
...(i === index
|
|
382
|
+
? { background: 'var(--dsw-alias-button-primary-fill)', borderColor: 'transparent', color: 'var(--dsw-alias-label-primary-foreground)' }
|
|
383
|
+
: {}),
|
|
384
|
+
},
|
|
385
|
+
}, url.replace(/^http:\/\//, ''))),
|
|
386
|
+
),
|
|
387
|
+
urls.length > 0 && h('div', { style: { display: 'flex', gap: 14, alignItems: 'center' } },
|
|
388
|
+
h(QrImage, { url: urls[index], size: 116 }),
|
|
389
|
+
h('div', null,
|
|
390
|
+
h('div', { style: codeStyle }, urls[index]),
|
|
391
|
+
h('p', { style: captionStyle }, 'Scan with a phone connected to the same network.'),
|
|
392
|
+
),
|
|
393
|
+
),
|
|
394
|
+
lanPin !== null && h(LanPinRow, { lanPin }),
|
|
395
|
+
)
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/** Opt-in LAN PIN: off keeps the LAN open; on shows the PIN with Show/Rotate. */
|
|
399
|
+
function LanPinRow({ lanPin }) {
|
|
400
|
+
return h('div', { style: { marginTop: 12 } },
|
|
401
|
+
h('label', { style: { display: 'flex', gap: 8, alignItems: 'center', cursor: 'pointer' } },
|
|
402
|
+
h('input', {
|
|
403
|
+
type: 'checkbox',
|
|
404
|
+
checked: lanPin.enabled,
|
|
405
|
+
onChange: e => lanPin.onToggle(e.target.checked),
|
|
406
|
+
style: { width: 15, height: 15, accentColor: 'var(--dsw-alias-button-primary-fill)' },
|
|
407
|
+
}),
|
|
408
|
+
h('span', { style: { ...fieldLabelStyle, margin: 0 } }, 'Require a PIN on the LAN'),
|
|
409
|
+
),
|
|
410
|
+
lanPin.enabled && h('div', { style: { display: 'flex', gap: 8, alignItems: 'center', marginTop: 8 } },
|
|
411
|
+
h('span', { style: { ...fieldLabelStyle, margin: 0 } }, 'LAN PIN'),
|
|
412
|
+
h('code', { style: { ...codeStyle, fontSize: 15, letterSpacing: 2 } }, lanPin.show ? lanPin.pin ?? '••••••••' : '••••••••'),
|
|
413
|
+
lanPin.show
|
|
414
|
+
? h('button', { onClick: lanPin.onHide, style: secondaryButtonStyle }, 'Hide')
|
|
415
|
+
: h('button', { onClick: lanPin.onShow, style: secondaryButtonStyle }, 'Show'),
|
|
416
|
+
h('button', { onClick: lanPin.onRotate, style: secondaryButtonStyle }, 'Rotate'),
|
|
417
|
+
),
|
|
418
|
+
)
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function PublicAccess({ status, pin, showPin, onRevealPin, onHidePin, onRotatePin }) {
|
|
422
|
+
return h('div', null,
|
|
423
|
+
status?.running && status?.publicUrl
|
|
424
|
+
? h('div', null,
|
|
425
|
+
h('div', { style: { display: 'flex', gap: 14, alignItems: 'center', marginBottom: 12 } },
|
|
426
|
+
h(QrImage, { url: status.publicUrl, size: 116 }),
|
|
427
|
+
h('div', null,
|
|
428
|
+
h('div', { style: codeStyle }, status.publicUrl),
|
|
429
|
+
h('p', { style: captionStyle }, 'Works from anywhere; visitors enter the PIN below.'),
|
|
430
|
+
),
|
|
431
|
+
),
|
|
432
|
+
)
|
|
433
|
+
: h('p', { style: captionStyle }, 'Start the tunnel to get a public address.'),
|
|
434
|
+
h('div', { style: { display: 'flex', gap: 8, alignItems: 'center' } },
|
|
435
|
+
h('span', { style: { ...fieldLabelStyle, margin: 0 } }, 'Access PIN'),
|
|
436
|
+
h('code', { style: { ...codeStyle, fontSize: 15, letterSpacing: 2 } }, showPin && pin !== null ? pin : '••••••••'),
|
|
437
|
+
showPin
|
|
438
|
+
? h('button', { onClick: onHidePin, style: secondaryButtonStyle }, 'Hide')
|
|
439
|
+
: h('button', { onClick: onRevealPin, style: secondaryButtonStyle }, 'Show'),
|
|
440
|
+
h('button', { onClick: onRotatePin, style: secondaryButtonStyle }, 'Rotate'),
|
|
441
|
+
),
|
|
442
|
+
h('p', { style: captionStyle }, 'Stays the same across tunnel and DSH restarts; use Rotate when you need a new PIN.'),
|
|
443
|
+
)
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
export function MaestroSettingsTab({ rpcCall }) {
|
|
447
|
+
const [status, setStatus] = useState(null)
|
|
448
|
+
const [proxyStatus, setProxyStatus] = useState(null)
|
|
449
|
+
const [config, setConfig] = useState({ tunnelMode: 'quick', projectMappings: [] })
|
|
450
|
+
const [catalog, setCatalog] = useState(null)
|
|
451
|
+
const [busy, setBusy] = useState(false)
|
|
452
|
+
const [error, setError] = useState(null)
|
|
453
|
+
const [pin, setPin] = useState(null)
|
|
454
|
+
const [showPin, setShowPin] = useState(false)
|
|
455
|
+
const [lanPinEnabled, setLanPinEnabled] = useState(false)
|
|
456
|
+
const [lanPin, setLanPin] = useState(null)
|
|
457
|
+
const [showLanPin, setShowLanPin] = useState(false)
|
|
458
|
+
|
|
459
|
+
const call = async (endpoint, payload) => {
|
|
460
|
+
const res = await rpcCall(endpoint, payload)
|
|
461
|
+
if (!res?.ok) throw new Error(res?.error?.message ?? 'RPC failed')
|
|
462
|
+
return res.value
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const refresh = async () => {
|
|
466
|
+
try { setStatus(await call(MAESTRO_ENDPOINTS.status, {})) } catch { /* transient failure, ignore */ }
|
|
467
|
+
try { setProxyStatus(await call(MAESTRO_ENDPOINTS.proxyStatus, {})) } catch { /* proxy row may be starting */ }
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// Load previously-saved config once on mount — without this, every field
|
|
471
|
+
// (including project mappings) would render empty on every page load even
|
|
472
|
+
// after being saved, since RPC state is not persisted in the component.
|
|
473
|
+
useEffect(() => {
|
|
474
|
+
call(MAESTRO_ENDPOINTS.getConfig, {})
|
|
475
|
+
.then(saved => setConfig(prev => ({ ...prev, ...saved })))
|
|
476
|
+
.catch(() => { /* first run, no config saved yet — keep defaults */ })
|
|
477
|
+
call(MAESTRO_ENDPOINTS.lanPinStatus, {})
|
|
478
|
+
.then(value => { setLanPinEnabled(value.enabled); if (value.enabled) setLanPin(value.pin ?? null) })
|
|
479
|
+
.catch(() => { /* host without the LAN PIN endpoints — keep the row hidden */ })
|
|
480
|
+
call(MAESTRO_ENDPOINTS.modelsList, {})
|
|
481
|
+
.then(value => setCatalog(value))
|
|
482
|
+
.catch(() => { /* catalog may be unavailable before provider is configured */ })
|
|
483
|
+
}, [])
|
|
484
|
+
|
|
485
|
+
useEffect(() => {
|
|
486
|
+
refresh()
|
|
487
|
+
const t = setInterval(refresh, 3000)
|
|
488
|
+
return () => clearInterval(t)
|
|
489
|
+
}, [])
|
|
490
|
+
|
|
491
|
+
const revealPin = async () => {
|
|
492
|
+
if (pin === null) {
|
|
493
|
+
try { setPin((await call(MAESTRO_ENDPOINTS.getPin, {})).pin) } catch (err) { setError(err.message) }
|
|
494
|
+
}
|
|
495
|
+
setShowPin(true)
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
const rotatePin = async () => {
|
|
499
|
+
setError(null)
|
|
500
|
+
try {
|
|
501
|
+
const fresh = (await call(MAESTRO_ENDPOINTS.rotatePin, {})).pin
|
|
502
|
+
setPin(fresh)
|
|
503
|
+
setShowPin(true)
|
|
504
|
+
} catch (err) {
|
|
505
|
+
setError(err.message)
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
const toggleLanPin = async (enabled) => {
|
|
510
|
+
setError(null)
|
|
511
|
+
const previous = lanPinEnabled
|
|
512
|
+
setLanPinEnabled(enabled)
|
|
513
|
+
try {
|
|
514
|
+
await call(MAESTRO_ENDPOINTS.lanPinSetEnabled, { enabled })
|
|
515
|
+
if (enabled) {
|
|
516
|
+
const value = await call(MAESTRO_ENDPOINTS.lanPinStatus, {})
|
|
517
|
+
setLanPin(value.pin ?? null)
|
|
518
|
+
setShowLanPin(true)
|
|
519
|
+
} else {
|
|
520
|
+
setLanPin(null)
|
|
521
|
+
setShowLanPin(false)
|
|
522
|
+
}
|
|
523
|
+
} catch (err) {
|
|
524
|
+
setLanPinEnabled(previous)
|
|
525
|
+
setError(err.message)
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
const revealLanPin = async () => {
|
|
530
|
+
if (lanPin === null) {
|
|
531
|
+
try { setLanPin((await call(MAESTRO_ENDPOINTS.lanPinStatus, {})).pin ?? null) } catch (err) { setError(err.message) }
|
|
532
|
+
}
|
|
533
|
+
setShowLanPin(true)
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
const rotateLanPin = async () => {
|
|
537
|
+
setError(null)
|
|
538
|
+
try {
|
|
539
|
+
const fresh = (await call(MAESTRO_ENDPOINTS.lanPinRotate, {})).pin
|
|
540
|
+
setLanPin(fresh)
|
|
541
|
+
setShowLanPin(true)
|
|
542
|
+
} catch (err) {
|
|
543
|
+
setError(err.message)
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
const startTunnel = async () => {
|
|
548
|
+
setBusy(true)
|
|
549
|
+
setError(null)
|
|
550
|
+
try { setStatus(await call(MAESTRO_ENDPOINTS.tunnelStart, {})) } catch (err) { setError(err.message) } finally { setBusy(false) }
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
const stopTunnel = async () => {
|
|
554
|
+
setBusy(true)
|
|
555
|
+
setError(null)
|
|
556
|
+
try { setStatus(await call(MAESTRO_ENDPOINTS.tunnelStop, {})) } catch (err) { setError(err.message) } finally { setBusy(false) }
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
const saveField = async (field, value) => {
|
|
560
|
+
setError(null)
|
|
561
|
+
setConfig(prev => ({ ...prev, [field]: value }))
|
|
562
|
+
try {
|
|
563
|
+
await call(MAESTRO_ENDPOINTS.saveConfig, { [field]: value })
|
|
564
|
+
} catch (err) {
|
|
565
|
+
setError(err.message)
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
return h('div', { 'data-maestro-settings-card': '', style: { maxWidth: 520 } },
|
|
570
|
+
h('h3', { style: { ...headingStyle, fontSize: 15, margin: '0 0 12px' } }, 'Maestro'),
|
|
571
|
+
|
|
572
|
+
h('label', { style: fieldLabelStyle }, 'Tunnel mode'),
|
|
573
|
+
h('select', { value: config.tunnelMode, style: inputStyle, onChange: e => saveField('tunnelMode', e.target.value) },
|
|
574
|
+
h('option', { value: 'quick' }, 'Quick (no setup, URL changes on restart)'),
|
|
575
|
+
h('option', { value: 'named' }, 'Named (stable URL, one-time setup)'),
|
|
576
|
+
),
|
|
577
|
+
config.tunnelMode === 'named' && h(NamedTunnelSetupNote),
|
|
578
|
+
config.tunnelMode === 'named' && h('div', null,
|
|
579
|
+
h('label', { style: fieldLabelStyle }, 'Tunnel ID'),
|
|
580
|
+
h('input', { placeholder: 'Tunnel ID', style: inputStyle, value: config.tunnelId ?? '', onChange: e => saveField('tunnelId', e.target.value) }),
|
|
581
|
+
h('label', { style: fieldLabelStyle }, 'Credentials file path'),
|
|
582
|
+
h('input', { placeholder: '~/.cloudflared/<id>.json', style: inputStyle, value: config.tunnelCredentialsFile ?? '', onChange: e => saveField('tunnelCredentialsFile', e.target.value) }),
|
|
583
|
+
h('label', { style: fieldLabelStyle }, 'Hostname'),
|
|
584
|
+
h('input', { placeholder: 'dsh.example.com', style: inputStyle, value: config.tunnelHostname ?? '', onChange: e => saveField('tunnelHostname', e.target.value) }),
|
|
585
|
+
),
|
|
586
|
+
h('div', { style: { marginTop: 12 } },
|
|
587
|
+
status?.running
|
|
588
|
+
? h('button', { disabled: busy, onClick: stopTunnel, style: secondaryButtonStyle }, 'Stop tunnel')
|
|
589
|
+
: h('button', { disabled: busy, onClick: startTunnel, style: primaryButtonStyle }, 'Start tunnel'),
|
|
590
|
+
),
|
|
591
|
+
|
|
592
|
+
h('div', { style: sectionStyle },
|
|
593
|
+
h('h4', { style: headingStyle }, 'Remote access (LAN)'),
|
|
594
|
+
h(LanAccess, {
|
|
595
|
+
proxyStatus,
|
|
596
|
+
lanPin: lanPinEnabled === null ? null : {
|
|
597
|
+
enabled: lanPinEnabled,
|
|
598
|
+
pin: lanPin,
|
|
599
|
+
show: showLanPin,
|
|
600
|
+
onShow: revealLanPin,
|
|
601
|
+
onHide: () => setShowLanPin(false),
|
|
602
|
+
onRotate: rotateLanPin,
|
|
603
|
+
onToggle: toggleLanPin,
|
|
604
|
+
},
|
|
605
|
+
}),
|
|
606
|
+
),
|
|
607
|
+
|
|
608
|
+
h('div', { style: sectionStyle },
|
|
609
|
+
h('h4', { style: headingStyle }, 'Public access'),
|
|
610
|
+
h(PublicAccess, { status, pin, showPin, onRevealPin: revealPin, onHidePin: () => setShowPin(false), onRotatePin: rotatePin }),
|
|
611
|
+
),
|
|
612
|
+
|
|
613
|
+
h('div', { style: sectionStyle },
|
|
614
|
+
h('h4', { style: headingStyle }, 'Telegram notifications'),
|
|
615
|
+
h('p', { style: captionStyle }, 'Send one protected startup update with the current public-access PIN to a single Telegram chat.'),
|
|
616
|
+
h(SecretInput, {
|
|
617
|
+
label: 'Bot token',
|
|
618
|
+
placeholder: '123456:ABC-DEF...',
|
|
619
|
+
hasSaved: config.hasTelegramBotToken,
|
|
620
|
+
onSave: value => saveField('telegramBotToken', value),
|
|
621
|
+
}),
|
|
622
|
+
h('label', { style: fieldLabelStyle }, 'Chat ID'),
|
|
623
|
+
h('input', { placeholder: '-1001234567890', autoComplete: 'off', style: inputStyle, value: config.telegramChatId ?? '', onChange: e => saveField('telegramChatId', e.target.value) }),
|
|
624
|
+
h(ToggleField, {
|
|
625
|
+
label: 'Also notify about finished reviews',
|
|
626
|
+
caption: 'One message per review run with its outcome and a short summary.',
|
|
627
|
+
checked: config.telegramReviewNotifications,
|
|
628
|
+
onChange: checked => saveField('telegramReviewNotifications', checked),
|
|
629
|
+
}),
|
|
630
|
+
h('p', { style: captionStyle }, 'Leave either credential blank to disable notifications. Telegram delivery failures never prevent DSH from starting.'),
|
|
631
|
+
),
|
|
632
|
+
|
|
633
|
+
h('div', { style: sectionStyle },
|
|
634
|
+
h('h4', { style: headingStyle }, 'GitLab'),
|
|
635
|
+
h('label', { style: fieldLabelStyle }, 'GitLab base URL'),
|
|
636
|
+
h('input', { placeholder: 'https://gitlab.example.com', style: inputStyle, value: config.gitlabBaseUrl ?? '', onChange: e => saveField('gitlabBaseUrl', e.target.value) }),
|
|
637
|
+
h(SecretInput, {
|
|
638
|
+
label: 'GitLab token',
|
|
639
|
+
placeholder: 'GitLab token',
|
|
640
|
+
hasSaved: config.hasGitlabToken,
|
|
641
|
+
onSave: value => saveField('gitlabToken', value),
|
|
642
|
+
}),
|
|
643
|
+
h('label', { style: fieldLabelStyle }, 'Bot username'),
|
|
644
|
+
h('input', { placeholder: 'maestro-bot', style: inputStyle, value: config.botUsername ?? '', onChange: e => saveField('botUsername', e.target.value) }),
|
|
645
|
+
h(SecretInput, {
|
|
646
|
+
label: 'Webhook secret',
|
|
647
|
+
placeholder: 'Webhook secret',
|
|
648
|
+
hasSaved: config.hasWebhookSecret,
|
|
649
|
+
onSave: value => saveField('webhookSecret', value),
|
|
650
|
+
}),
|
|
651
|
+
h('button', { type: 'button', style: { ...secondaryButtonStyle, marginTop: 10 }, onClick: () => saveField('webhookSecret', generateWebhookSecret()) }, 'Generate new secret'),
|
|
652
|
+
h('p', { style: captionStyle }, 'In GitLab: Settings → Webhooks, set Secret token to this value and enable Merge request events.'),
|
|
653
|
+
h('p', { style: captionStyle }, 'Webhook URL:'),
|
|
654
|
+
h('code', { style: codeStyle }, gitlabWebhookUrl(config.tunnelHostname)),
|
|
655
|
+
h('p', { style: captionStyle }, 'Overrides MAESTRO_GITLAB_WEBHOOK_SECRET immediately, no restart needed.'),
|
|
656
|
+
),
|
|
657
|
+
|
|
658
|
+
h('div', { style: sectionStyle },
|
|
659
|
+
h('h4', { style: headingStyle }, 'Review automation'),
|
|
660
|
+
h(ToggleField, {
|
|
661
|
+
label: 'Re-review when new commits are pushed',
|
|
662
|
+
caption: 'After a completed review, further pushes to the same MR trigger an automatic quick re-review.',
|
|
663
|
+
checked: config.autoRereviewOnPush,
|
|
664
|
+
onChange: checked => saveField('autoRereviewOnPush', checked),
|
|
665
|
+
}),
|
|
666
|
+
h(ReviewHistoryPanel, { rpcCall }),
|
|
667
|
+
),
|
|
668
|
+
|
|
669
|
+
h('div', { style: sectionStyle },
|
|
670
|
+
h('h4', { style: headingStyle }, 'Review model'),
|
|
671
|
+
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.'),
|
|
672
|
+
h(ReviewModelSelector, {
|
|
673
|
+
value: config.reviewModel ?? null,
|
|
674
|
+
catalog,
|
|
675
|
+
fallbackValue: catalog?.current ?? null,
|
|
676
|
+
fallbackLabel: 'Use DSH default',
|
|
677
|
+
onChange: v => saveField('reviewModel', v),
|
|
678
|
+
label: 'Global review model',
|
|
679
|
+
}),
|
|
680
|
+
),
|
|
681
|
+
|
|
682
|
+
h('div', { style: sectionStyle },
|
|
683
|
+
h('h4', { style: headingStyle }, 'Projects'),
|
|
684
|
+
h(ProjectMappingsEditor, { mappings: config.projectMappings ?? [], onChange: mappings => saveField('projectMappings', mappings), catalog, globalReviewModel: config.reviewModel ?? null }),
|
|
685
|
+
),
|
|
686
|
+
|
|
687
|
+
error && h('p', { style: errorStyle }, error),
|
|
688
|
+
)
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// Injected synchronously at apply time (deferred effects can stall behind
|
|
692
|
+
// unavailable slot scopes); the external plugin's stylesheet handles
|
|
693
|
+
// everything outside this card.
|
|
694
|
+
function installMaestroMobileCss() {
|
|
695
|
+
if (document.querySelector('style[data-plugin-css="maestro/mobile-maestro.css"]') !== null) return
|
|
696
|
+
const tag = document.createElement('style')
|
|
697
|
+
tag.dataset.plugin = name
|
|
698
|
+
tag.dataset.pluginCss = 'maestro/mobile-maestro.css'
|
|
699
|
+
tag.textContent = MAESTRO_MOBILE_CSS + SETTINGS_NAV_CSS
|
|
700
|
+
document.head.appendChild(tag)
|
|
701
|
+
}
|
|
702
|
+
|