@modesto_ma/dsh-channels 0.1.0-beta.1 → 0.2.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 +39 -29
- package/docs/access-control-design.md +4 -4
- package/docs/feishu-onboarding.md +11 -7
- package/lib/client.js +1698 -875
- package/lib/client.js.map +1 -1
- package/lib/index.js +447 -78
- package/lib/index.js.map +1 -1
- package/package.json +16 -14
- package/src/client/api.ts +20 -0
- package/src/client/catalog.ts +1 -1
- package/src/client/index.ts +1 -1
- package/src/client/panel/access-card.tsx +294 -0
- package/src/client/panel/access-tab.tsx +46 -0
- package/src/client/panel/advanced-tab.tsx +80 -0
- package/src/client/panel/cleanup-card.tsx +104 -0
- package/src/client/panel/connection-tab.tsx +214 -0
- package/src/client/panel/credentials-card.tsx +104 -0
- package/src/client/panel/index.tsx +862 -0
- package/src/client/panel/model-tab.tsx +23 -0
- package/src/client/panel/sessions-tab.tsx +21 -0
- package/src/client/panel/shared.tsx +488 -0
- package/src/client/panel/workspace-tab.tsx +40 -0
- package/src/client/styles.ts +24 -6
- package/src/config.ts +17 -4
- package/src/index.ts +24 -8
- package/src/service.ts +142 -26
- package/src/settings/contract.ts +207 -35
- package/src/settings/directory-picker.ts +121 -0
- package/src/settings/routes.ts +49 -2
- package/src/settings/surface.ts +159 -21
- package/src/client/panel.tsx +0 -1475
|
@@ -0,0 +1,862 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 设置 →「飞书渠道」页面:数据加载、5 秒状态轮询、全局 draft 与顶部状态头 /
|
|
3
|
+
* tab 导航的外壳。
|
|
4
|
+
*
|
|
5
|
+
* 所有 Host 交互(panel / status / catalog / config / credentials / cleanup /
|
|
6
|
+
* access / pick-directory)都集中在这里;tab 组件只接收渲染所需的数据与
|
|
7
|
+
* 回调。页面不 import 任何 DSH 客户端包,只依赖 react 与 `settings.section`
|
|
8
|
+
* 槽位。
|
|
9
|
+
*
|
|
10
|
+
* @module dsh-channels/client/panel
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
14
|
+
import type { KeyboardEvent } from 'react'
|
|
15
|
+
import { PANEL_COPY, PANEL_TABS, TAB_FIELD_GROUPS } from '../../settings/contract.ts'
|
|
16
|
+
import type {
|
|
17
|
+
AccessWire,
|
|
18
|
+
CatalogWire,
|
|
19
|
+
ChannelStatusWire,
|
|
20
|
+
CleanupRunWire,
|
|
21
|
+
ConfigValue,
|
|
22
|
+
CredentialField,
|
|
23
|
+
EditableKey,
|
|
24
|
+
EditableValues,
|
|
25
|
+
FieldIssueWire,
|
|
26
|
+
PanelTabId,
|
|
27
|
+
PanelWire,
|
|
28
|
+
} from '../../settings/contract.ts'
|
|
29
|
+
import {
|
|
30
|
+
addAccessGroup,
|
|
31
|
+
addAccessUser,
|
|
32
|
+
clearConfig,
|
|
33
|
+
clearCredential,
|
|
34
|
+
fetchCatalog,
|
|
35
|
+
fetchPanel,
|
|
36
|
+
fetchStatus,
|
|
37
|
+
pickDirectory as requestDirectoryPicker,
|
|
38
|
+
reconnect,
|
|
39
|
+
removeAccessGroup,
|
|
40
|
+
removeAccessUser,
|
|
41
|
+
resolveAccessRequest,
|
|
42
|
+
runCleanup,
|
|
43
|
+
saveConfig,
|
|
44
|
+
saveCredentials,
|
|
45
|
+
} from '../api.ts'
|
|
46
|
+
import { applyCatalogFields, catalogFromFields } from '../catalog.ts'
|
|
47
|
+
import { AccessTab } from './access-tab.tsx'
|
|
48
|
+
import { AdvancedTab } from './advanced-tab.tsx'
|
|
49
|
+
import { ConnectionTab } from './connection-tab.tsx'
|
|
50
|
+
import { ModelTab } from './model-tab.tsx'
|
|
51
|
+
import { SessionsTab } from './sessions-tab.tsx'
|
|
52
|
+
import {
|
|
53
|
+
fieldLabel,
|
|
54
|
+
formatBytes,
|
|
55
|
+
messageOf,
|
|
56
|
+
NoticeBar,
|
|
57
|
+
phaseBadge,
|
|
58
|
+
phaseSummary,
|
|
59
|
+
validateDraft,
|
|
60
|
+
} from './shared.tsx'
|
|
61
|
+
import type {
|
|
62
|
+
BusyAction,
|
|
63
|
+
CredentialsView,
|
|
64
|
+
FormView,
|
|
65
|
+
LoadState,
|
|
66
|
+
Notice,
|
|
67
|
+
} from './shared.tsx'
|
|
68
|
+
import { WorkspaceTab } from './workspace-tab.tsx'
|
|
69
|
+
|
|
70
|
+
/** Owner props handed to a `settings.section` contribution (unused beyond parity). */
|
|
71
|
+
export interface FeishuChannelsSectionProps {
|
|
72
|
+
close?: () => void
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Page shell: one panel payload, one status poll, one global config draft. */
|
|
76
|
+
export function FeishuChannelsSection(_props: FeishuChannelsSectionProps): JSX.Element | null {
|
|
77
|
+
const [load, setLoad] = useState<LoadState>({ phase: 'loading' })
|
|
78
|
+
const [activeTab, setActiveTab] = useState<PanelTabId>('connection')
|
|
79
|
+
const [draft, setDraft] = useState<EditableValues>({})
|
|
80
|
+
const [notice, setNotice] = useState<Notice | null>(null)
|
|
81
|
+
const [busy, setBusy] = useState<BusyAction | null>(null)
|
|
82
|
+
const [status, setStatus] = useState<ChannelStatusWire | null>(null)
|
|
83
|
+
const [pollError, setPollError] = useState<string | null>(null)
|
|
84
|
+
const [appIdInput, setAppIdInput] = useState('')
|
|
85
|
+
const [appSecretInput, setAppSecretInput] = useState('')
|
|
86
|
+
const [catalog, setCatalog] = useState<CatalogWire | null>(null)
|
|
87
|
+
const [catalogBusy, setCatalogBusy] = useState(false)
|
|
88
|
+
const [cleanupResult, setCleanupResult] = useState<CleanupRunWire | null>(null)
|
|
89
|
+
const [cleanupError, setCleanupError] = useState<string | null>(null)
|
|
90
|
+
const [accessNotice, setAccessNotice] = useState<string | null>(null)
|
|
91
|
+
const [accessError, setAccessError] = useState<string | null>(null)
|
|
92
|
+
const catalogTicket = useRef(0)
|
|
93
|
+
/** Latest ready panel, read from the poll loop without restarting it. */
|
|
94
|
+
const panelRef = useRef<PanelWire | null>(null)
|
|
95
|
+
const panel = load.phase === 'ready' ? load.panel : null
|
|
96
|
+
/** Host-side lifecycle busy: connecting/restarting blocks every panel write. */
|
|
97
|
+
const hostBusy = (status ?? panel?.status)?.busy === true
|
|
98
|
+
useEffect(() => {
|
|
99
|
+
panelRef.current = panel
|
|
100
|
+
}, [panel])
|
|
101
|
+
|
|
102
|
+
const loadPanel = useCallback(async (): Promise<void> => {
|
|
103
|
+
setLoad({ phase: 'loading' })
|
|
104
|
+
setNotice(null)
|
|
105
|
+
setCleanupResult(null)
|
|
106
|
+
setCleanupError(null)
|
|
107
|
+
setAccessNotice(null)
|
|
108
|
+
setAccessError(null)
|
|
109
|
+
try {
|
|
110
|
+
const next = await fetchPanel()
|
|
111
|
+
setLoad({ phase: 'ready', panel: next })
|
|
112
|
+
setStatus(next.status)
|
|
113
|
+
setDraft({})
|
|
114
|
+
setAppIdInput('')
|
|
115
|
+
setAppSecretInput('')
|
|
116
|
+
} catch (error) {
|
|
117
|
+
setLoad({ phase: 'error', message: messageOf(error) })
|
|
118
|
+
}
|
|
119
|
+
}, [])
|
|
120
|
+
|
|
121
|
+
useEffect(() => {
|
|
122
|
+
void loadPanel()
|
|
123
|
+
}, [loadPanel])
|
|
124
|
+
|
|
125
|
+
// The Host panel payload already carries options resolved for the effective
|
|
126
|
+
// route; re-seed the local catalog whenever a fresh panel arrives (load/save).
|
|
127
|
+
useEffect(() => {
|
|
128
|
+
if (panel === null) {
|
|
129
|
+
setCatalog(null)
|
|
130
|
+
return
|
|
131
|
+
}
|
|
132
|
+
setCatalog(catalogFromFields(panel.config.fields))
|
|
133
|
+
setCatalogBusy(false)
|
|
134
|
+
}, [panel])
|
|
135
|
+
|
|
136
|
+
useEffect(() => {
|
|
137
|
+
if (load.phase !== 'ready') return undefined
|
|
138
|
+
let cancelled = false
|
|
139
|
+
const tick = async (): Promise<void> => {
|
|
140
|
+
try {
|
|
141
|
+
const previousPhase = panelRef.current?.status.phase
|
|
142
|
+
const next = await fetchStatus()
|
|
143
|
+
if (cancelled) return
|
|
144
|
+
setStatus(next)
|
|
145
|
+
setPollError(null)
|
|
146
|
+
// A phase transition changes what the panel payload can offer: the
|
|
147
|
+
// storage domain opens/closes with the connection, so the access
|
|
148
|
+
// whitelists and the cleanup preview go stale. Re-read the panel but
|
|
149
|
+
// never touch the dirty draft.
|
|
150
|
+
if (previousPhase !== undefined && previousPhase !== next.phase) {
|
|
151
|
+
try {
|
|
152
|
+
const refreshed = await fetchPanel()
|
|
153
|
+
if (!cancelled) {
|
|
154
|
+
setLoad({ phase: 'ready', panel: refreshed })
|
|
155
|
+
setStatus(refreshed.status)
|
|
156
|
+
}
|
|
157
|
+
} catch {
|
|
158
|
+
// Keep the status-only update; the next manual refresh retries.
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
} catch (error) {
|
|
162
|
+
if (!cancelled) setPollError(messageOf(error))
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
const timer = window.setInterval(() => { void tick() }, 5_000)
|
|
166
|
+
return () => {
|
|
167
|
+
cancelled = true
|
|
168
|
+
window.clearInterval(timer)
|
|
169
|
+
}
|
|
170
|
+
}, [load.phase])
|
|
171
|
+
|
|
172
|
+
/** Fetch the selector catalog for a draft route (latest request wins). */
|
|
173
|
+
const refreshCatalog = useCallback(async (provider: string, model: string): Promise<void> => {
|
|
174
|
+
catalogTicket.current += 1
|
|
175
|
+
const ticket = catalogTicket.current
|
|
176
|
+
setCatalogBusy(true)
|
|
177
|
+
try {
|
|
178
|
+
const next = await fetchCatalog(provider, model)
|
|
179
|
+
if (catalogTicket.current === ticket) setCatalog(next)
|
|
180
|
+
} catch {
|
|
181
|
+
if (catalogTicket.current !== ticket) return
|
|
182
|
+
// Never keep another route's options: degrade the dependent fields to the
|
|
183
|
+
// free-text controls instead of offering stale values.
|
|
184
|
+
setCatalog(previous => previous === null ? null : {
|
|
185
|
+
provider: previous.provider,
|
|
186
|
+
model: { selectable: false, options: [] },
|
|
187
|
+
reasoningEffort: { selectable: false, options: [] },
|
|
188
|
+
agentPreset: previous.agentPreset,
|
|
189
|
+
})
|
|
190
|
+
setNotice({ kind: 'error', text: PANEL_COPY.catalogFailed })
|
|
191
|
+
} finally {
|
|
192
|
+
if (catalogTicket.current === ticket) setCatalogBusy(false)
|
|
193
|
+
}
|
|
194
|
+
}, [])
|
|
195
|
+
|
|
196
|
+
/** Current form values, used to keep unlisted configured values visible. */
|
|
197
|
+
const currentValues = useMemo(() => {
|
|
198
|
+
if (panel === null) return {}
|
|
199
|
+
const valueOf = (key: EditableKey): string | undefined => {
|
|
200
|
+
const raw = draft[key] ?? panel.config.effective[key] ?? panel.config.base[key]
|
|
201
|
+
return typeof raw === 'string' && raw !== '' ? raw : undefined
|
|
202
|
+
}
|
|
203
|
+
return {
|
|
204
|
+
provider: valueOf('provider'),
|
|
205
|
+
model: valueOf('model'),
|
|
206
|
+
reasoningEffort: valueOf('reasoningEffort'),
|
|
207
|
+
agentPreset: valueOf('agentPreset'),
|
|
208
|
+
}
|
|
209
|
+
}, [draft, panel])
|
|
210
|
+
|
|
211
|
+
const renderedFields = useMemo(
|
|
212
|
+
() => applyCatalogFields(panel?.config.fields ?? [], catalog, currentValues),
|
|
213
|
+
[panel, catalog, currentValues],
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
/** Whether one selector currently renders as a dropdown for this form. */
|
|
217
|
+
const isSelectField = useCallback(
|
|
218
|
+
(key: EditableKey): boolean => renderedFields.some(field => field.key === key && field.kind === 'select'),
|
|
219
|
+
[renderedFields],
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
/** Draft one field, cascading provider → model → effort like the Host catalog. */
|
|
223
|
+
const updateDraft = useCallback((key: EditableKey, value: ConfigValue) => {
|
|
224
|
+
if (panel === null) return
|
|
225
|
+
const providerNow = String(
|
|
226
|
+
draft.provider ?? panel.config.effective.provider ?? panel.config.base.provider ?? '',
|
|
227
|
+
)
|
|
228
|
+
setNotice(null)
|
|
229
|
+
if (key === 'provider') {
|
|
230
|
+
// Changing the provider invalidates the model/effort below it. When the
|
|
231
|
+
// selected provider is the configured one, keep showing its configured
|
|
232
|
+
// model; otherwise fall back to the inherit choice.
|
|
233
|
+
const nextProvider = String(value ?? '')
|
|
234
|
+
const configuredProvider = String(
|
|
235
|
+
panel.config.effective.provider ?? panel.config.base.provider ?? '',
|
|
236
|
+
)
|
|
237
|
+
const keepModel = nextProvider === configuredProvider
|
|
238
|
+
? String(panel.config.effective.model ?? panel.config.base.model ?? '')
|
|
239
|
+
: ''
|
|
240
|
+
setDraft(previous => ({ ...previous, provider: value, model: '', reasoningEffort: '' }))
|
|
241
|
+
if (catalog !== null && isSelectField('provider')) void refreshCatalog(nextProvider, keepModel)
|
|
242
|
+
return
|
|
243
|
+
}
|
|
244
|
+
if (key === 'model') {
|
|
245
|
+
setDraft(previous => ({ ...previous, model: value, reasoningEffort: '' }))
|
|
246
|
+
if (catalog !== null && isSelectField('model')) void refreshCatalog(providerNow, String(value ?? ''))
|
|
247
|
+
return
|
|
248
|
+
}
|
|
249
|
+
setDraft(previous => ({ ...previous, [key]: value }))
|
|
250
|
+
}, [catalog, draft.provider, isSelectField, panel, refreshCatalog])
|
|
251
|
+
|
|
252
|
+
const refreshStatus = useCallback(async (): Promise<void> => {
|
|
253
|
+
try {
|
|
254
|
+
const next = await fetchStatus()
|
|
255
|
+
setStatus(next)
|
|
256
|
+
setPollError(null)
|
|
257
|
+
} catch (error) {
|
|
258
|
+
setPollError(messageOf(error))
|
|
259
|
+
}
|
|
260
|
+
}, [])
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Manual refresh: reload the whole panel payload so access lists, the cleanup
|
|
264
|
+
* preview and `restartPendingKeys` catch up with the Host. The dirty draft is
|
|
265
|
+
* deliberately preserved — refreshing never discards user input.
|
|
266
|
+
*/
|
|
267
|
+
const onRefresh = useCallback(async (): Promise<void> => {
|
|
268
|
+
try {
|
|
269
|
+
const next = await fetchPanel()
|
|
270
|
+
setLoad({ phase: 'ready', panel: next })
|
|
271
|
+
setStatus(next.status)
|
|
272
|
+
setPollError(null)
|
|
273
|
+
setAccessNotice(null)
|
|
274
|
+
setAccessError(null)
|
|
275
|
+
} catch (error) {
|
|
276
|
+
setPollError(messageOf(error))
|
|
277
|
+
}
|
|
278
|
+
}, [])
|
|
279
|
+
|
|
280
|
+
/** Submit every dirty field at once; the Host persists and re-validates. */
|
|
281
|
+
const onSave = useCallback(async (): Promise<void> => {
|
|
282
|
+
if (panel === null) return
|
|
283
|
+
const { values, issues } = validateDraft(panel, draft)
|
|
284
|
+
if (issues.length > 0) {
|
|
285
|
+
// Name the offending fields: with one draft shared by every tab, the
|
|
286
|
+
// user may be looking at a different tab than the invalid field.
|
|
287
|
+
const detail = issues
|
|
288
|
+
.map(issue => `${fieldLabel(panel.config.fields, issue.key as EditableKey)} ${issue.message}`)
|
|
289
|
+
.join(';')
|
|
290
|
+
setNotice({ kind: 'error', text: `${PANEL_COPY.saveInvalid}:${detail}`, issues })
|
|
291
|
+
return
|
|
292
|
+
}
|
|
293
|
+
if (Object.keys(values).length === 0) return
|
|
294
|
+
setBusy('save')
|
|
295
|
+
setNotice(null)
|
|
296
|
+
try {
|
|
297
|
+
const result = await saveConfig(values)
|
|
298
|
+
if (!result.ok) {
|
|
299
|
+
setNotice({ kind: 'error', text: result.error, issues: result.issues })
|
|
300
|
+
return
|
|
301
|
+
}
|
|
302
|
+
setLoad({ phase: 'ready', panel: result.panel })
|
|
303
|
+
setStatus(result.panel.status)
|
|
304
|
+
setCleanupResult(null)
|
|
305
|
+
setCleanupError(null)
|
|
306
|
+
// Clear only the submitted keys: edits typed while the request was in
|
|
307
|
+
// flight stay in the draft instead of being silently discarded.
|
|
308
|
+
setDraft(previous => {
|
|
309
|
+
const next = { ...previous }
|
|
310
|
+
for (const key of Object.keys(values) as EditableKey[]) delete next[key]
|
|
311
|
+
return next
|
|
312
|
+
})
|
|
313
|
+
setNotice({ kind: 'ok', text: PANEL_COPY.saved })
|
|
314
|
+
} catch (error) {
|
|
315
|
+
setNotice({ kind: 'error', text: messageOf(error) })
|
|
316
|
+
} finally {
|
|
317
|
+
setBusy(null)
|
|
318
|
+
}
|
|
319
|
+
}, [draft, panel])
|
|
320
|
+
|
|
321
|
+
/** Drop the draft keys owned by one tab (nothing is written). */
|
|
322
|
+
const discardTab = useCallback((tab: PanelTabId): void => {
|
|
323
|
+
if (panel === null) return
|
|
324
|
+
const groups = new Set<string>(TAB_FIELD_GROUPS[tab])
|
|
325
|
+
const keys = new Set(
|
|
326
|
+
panel.config.fields.filter(field => groups.has(field.group)).map(field => field.key),
|
|
327
|
+
)
|
|
328
|
+
setDraft((previous) => {
|
|
329
|
+
const next = { ...previous }
|
|
330
|
+
for (const key of keys) delete next[key]
|
|
331
|
+
return next
|
|
332
|
+
})
|
|
333
|
+
setNotice(null)
|
|
334
|
+
if (tab === 'model') {
|
|
335
|
+
catalogTicket.current += 1
|
|
336
|
+
setCatalog(catalogFromFields(panel.config.fields))
|
|
337
|
+
setCatalogBusy(false)
|
|
338
|
+
}
|
|
339
|
+
}, [panel])
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Open the host's native directory chooser (workspace root「选择目录…」) and
|
|
343
|
+
* stage the picked absolute path as a draft value. Nothing is persisted until
|
|
344
|
+
*「保存更改」; a canceled dialog is a no-op, not an error.
|
|
345
|
+
*/
|
|
346
|
+
const onPickDirectory = useCallback(async (key: EditableKey): Promise<void> => {
|
|
347
|
+
setNotice(null)
|
|
348
|
+
try {
|
|
349
|
+
const result = await requestDirectoryPicker()
|
|
350
|
+
if (!result.ok) {
|
|
351
|
+
setNotice({ kind: 'error', text: result.error })
|
|
352
|
+
return
|
|
353
|
+
}
|
|
354
|
+
if ('canceled' in result) return
|
|
355
|
+
setDraft(previous => ({ ...previous, [key]: result.path }))
|
|
356
|
+
setNotice({ kind: 'ok', text: `${PANEL_COPY.directoryPicked}:${result.path}` })
|
|
357
|
+
} catch (error) {
|
|
358
|
+
setNotice({ kind: 'error', text: messageOf(error) })
|
|
359
|
+
}
|
|
360
|
+
}, [])
|
|
361
|
+
|
|
362
|
+
/** Drop every Web override and return the refreshed panel. */
|
|
363
|
+
const onClear = useCallback(async (): Promise<void> => {
|
|
364
|
+
setBusy('clear')
|
|
365
|
+
setNotice(null)
|
|
366
|
+
try {
|
|
367
|
+
const result = await clearConfig()
|
|
368
|
+
if (!result.ok) {
|
|
369
|
+
setNotice({ kind: 'error', text: result.error })
|
|
370
|
+
return
|
|
371
|
+
}
|
|
372
|
+
setLoad({ phase: 'ready', panel: result.panel })
|
|
373
|
+
setStatus(result.panel.status)
|
|
374
|
+
setDraft({})
|
|
375
|
+
setCleanupResult(null)
|
|
376
|
+
setCleanupError(null)
|
|
377
|
+
setNotice({ kind: 'ok', text: PANEL_COPY.cleared })
|
|
378
|
+
} catch (error) {
|
|
379
|
+
setNotice({ kind: 'error', text: messageOf(error) })
|
|
380
|
+
} finally {
|
|
381
|
+
setBusy(null)
|
|
382
|
+
}
|
|
383
|
+
}, [])
|
|
384
|
+
|
|
385
|
+
const running = (status ?? panel?.status)?.running === true
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* The single connect/reconnect action: open a stopped channel or restart a
|
|
389
|
+
* running one. Only a restart interrupts live replies, so only it confirms.
|
|
390
|
+
*/
|
|
391
|
+
const onReconnect = useCallback(async (): Promise<void> => {
|
|
392
|
+
if (running) {
|
|
393
|
+
const confirmed = typeof window.confirm === 'function'
|
|
394
|
+
? window.confirm(PANEL_COPY.restartWarning)
|
|
395
|
+
: true
|
|
396
|
+
if (!confirmed) return
|
|
397
|
+
}
|
|
398
|
+
setBusy('reconnect')
|
|
399
|
+
setNotice(null)
|
|
400
|
+
try {
|
|
401
|
+
const result = await reconnect()
|
|
402
|
+
if (!result.ok) {
|
|
403
|
+
setNotice({ kind: 'error', text: result.error })
|
|
404
|
+
// Incomplete credentials: send the user back to the onboarding form.
|
|
405
|
+
if (result.unconfigured === true) setActiveTab('connection')
|
|
406
|
+
await refreshStatus()
|
|
407
|
+
return
|
|
408
|
+
}
|
|
409
|
+
setLoad({ phase: 'ready', panel: result.panel })
|
|
410
|
+
setStatus(result.panel.status)
|
|
411
|
+
setNotice({ kind: 'ok', text: running ? PANEL_COPY.reconnectDone : PANEL_COPY.connectDone })
|
|
412
|
+
} catch (error) {
|
|
413
|
+
setNotice({ kind: 'error', text: messageOf(error) })
|
|
414
|
+
await refreshStatus()
|
|
415
|
+
} finally {
|
|
416
|
+
setBusy(null)
|
|
417
|
+
}
|
|
418
|
+
}, [refreshStatus, running])
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Delete stale workspaces after an explicit confirmation. The Host refreshes
|
|
422
|
+
* the panel in the same response, so the preview counts update immediately;
|
|
423
|
+
* partial failures stay visible in the card instead of being swallowed.
|
|
424
|
+
*/
|
|
425
|
+
const onRunCleanup = useCallback(async (): Promise<void> => {
|
|
426
|
+
if (panel === null) return
|
|
427
|
+
const { cleanup } = panel
|
|
428
|
+
const confirmed = typeof window.confirm === 'function'
|
|
429
|
+
? window.confirm(
|
|
430
|
+
`${PANEL_COPY.cleanupConfirm}\n${PANEL_COPY.cleanupCandidates}:${cleanup.candidates} · ${PANEL_COPY.cleanupCandidateBytes}:${formatBytes(cleanup.candidateBytes)}`,
|
|
431
|
+
)
|
|
432
|
+
: true
|
|
433
|
+
if (!confirmed) return
|
|
434
|
+
setBusy('cleanup')
|
|
435
|
+
setNotice(null)
|
|
436
|
+
setCleanupResult(null)
|
|
437
|
+
setCleanupError(null)
|
|
438
|
+
try {
|
|
439
|
+
const response = await runCleanup()
|
|
440
|
+
if (!response.ok) {
|
|
441
|
+
setCleanupError(response.error)
|
|
442
|
+
return
|
|
443
|
+
}
|
|
444
|
+
setLoad({ phase: 'ready', panel: response.panel })
|
|
445
|
+
setStatus(response.panel.status)
|
|
446
|
+
setCleanupResult(response.result)
|
|
447
|
+
} catch (error) {
|
|
448
|
+
setCleanupError(messageOf(error))
|
|
449
|
+
} finally {
|
|
450
|
+
setBusy(null)
|
|
451
|
+
}
|
|
452
|
+
}, [panel])
|
|
453
|
+
|
|
454
|
+
/** Replace the embedded access snapshot after a whitelist mutation. */
|
|
455
|
+
const applyAccess = useCallback((access: AccessWire): void => {
|
|
456
|
+
setLoad(previous => previous.phase === 'ready'
|
|
457
|
+
? { phase: 'ready', panel: { ...previous.panel, access } }
|
|
458
|
+
: previous)
|
|
459
|
+
}, [])
|
|
460
|
+
|
|
461
|
+
const onAddAccessGroup = useCallback(async (chatId: string, name: string): Promise<boolean> => {
|
|
462
|
+
setBusy('access')
|
|
463
|
+
setAccessError(null)
|
|
464
|
+
setAccessNotice(null)
|
|
465
|
+
try {
|
|
466
|
+
const result = await addAccessGroup(chatId, name)
|
|
467
|
+
if (!result.ok) {
|
|
468
|
+
setAccessError(result.error)
|
|
469
|
+
return false
|
|
470
|
+
}
|
|
471
|
+
applyAccess(result.access)
|
|
472
|
+
setAccessNotice(PANEL_COPY.accessGroupAdded)
|
|
473
|
+
return true
|
|
474
|
+
} catch (error) {
|
|
475
|
+
setAccessError(messageOf(error))
|
|
476
|
+
return false
|
|
477
|
+
} finally {
|
|
478
|
+
setBusy(null)
|
|
479
|
+
}
|
|
480
|
+
}, [applyAccess])
|
|
481
|
+
|
|
482
|
+
const onRemoveAccessGroup = useCallback(async (chatId: string): Promise<void> => {
|
|
483
|
+
setBusy('access')
|
|
484
|
+
setAccessError(null)
|
|
485
|
+
setAccessNotice(null)
|
|
486
|
+
try {
|
|
487
|
+
const result = await removeAccessGroup(chatId)
|
|
488
|
+
if (!result.ok) {
|
|
489
|
+
setAccessError(result.error)
|
|
490
|
+
return
|
|
491
|
+
}
|
|
492
|
+
applyAccess(result.access)
|
|
493
|
+
setAccessNotice(PANEL_COPY.accessGroupRemoved)
|
|
494
|
+
} catch (error) {
|
|
495
|
+
setAccessError(messageOf(error))
|
|
496
|
+
} finally {
|
|
497
|
+
setBusy(null)
|
|
498
|
+
}
|
|
499
|
+
}, [applyAccess])
|
|
500
|
+
|
|
501
|
+
const onAddAccessUser = useCallback(async (openId: string, name: string): Promise<boolean> => {
|
|
502
|
+
setBusy('access')
|
|
503
|
+
setAccessError(null)
|
|
504
|
+
setAccessNotice(null)
|
|
505
|
+
try {
|
|
506
|
+
const result = await addAccessUser(openId, name)
|
|
507
|
+
if (!result.ok) {
|
|
508
|
+
setAccessError(result.error)
|
|
509
|
+
return false
|
|
510
|
+
}
|
|
511
|
+
applyAccess(result.access)
|
|
512
|
+
setAccessNotice(PANEL_COPY.accessUserAdded)
|
|
513
|
+
return true
|
|
514
|
+
} catch (error) {
|
|
515
|
+
setAccessError(messageOf(error))
|
|
516
|
+
return false
|
|
517
|
+
} finally {
|
|
518
|
+
setBusy(null)
|
|
519
|
+
}
|
|
520
|
+
}, [applyAccess])
|
|
521
|
+
|
|
522
|
+
const onRemoveAccessUser = useCallback(async (openId: string): Promise<void> => {
|
|
523
|
+
setBusy('access')
|
|
524
|
+
setAccessError(null)
|
|
525
|
+
setAccessNotice(null)
|
|
526
|
+
try {
|
|
527
|
+
const result = await removeAccessUser(openId)
|
|
528
|
+
if (!result.ok) {
|
|
529
|
+
setAccessError(result.error)
|
|
530
|
+
return
|
|
531
|
+
}
|
|
532
|
+
applyAccess(result.access)
|
|
533
|
+
setAccessNotice(PANEL_COPY.accessUserRemoved)
|
|
534
|
+
} catch (error) {
|
|
535
|
+
setAccessError(messageOf(error))
|
|
536
|
+
} finally {
|
|
537
|
+
setBusy(null)
|
|
538
|
+
}
|
|
539
|
+
}, [applyAccess])
|
|
540
|
+
|
|
541
|
+
const onResolveAccessRequest = useCallback(async (token: string, decision: 'allow' | 'deny'): Promise<void> => {
|
|
542
|
+
setBusy('access')
|
|
543
|
+
setAccessError(null)
|
|
544
|
+
setAccessNotice(null)
|
|
545
|
+
try {
|
|
546
|
+
const result = await resolveAccessRequest(token, decision)
|
|
547
|
+
if (!result.ok) {
|
|
548
|
+
setAccessError(result.error)
|
|
549
|
+
return
|
|
550
|
+
}
|
|
551
|
+
applyAccess(result.access)
|
|
552
|
+
setAccessNotice(decision === 'allow' ? PANEL_COPY.accessRequestAllowed : PANEL_COPY.accessRequestDenied)
|
|
553
|
+
} catch (error) {
|
|
554
|
+
setAccessError(messageOf(error))
|
|
555
|
+
} finally {
|
|
556
|
+
setBusy(null)
|
|
557
|
+
}
|
|
558
|
+
}, [applyAccess])
|
|
559
|
+
|
|
560
|
+
/** Save provided credential values; the Host reconnects automatically when ready. */
|
|
561
|
+
const onSaveCredentials = useCallback(async (): Promise<void> => {
|
|
562
|
+
const values: { appId?: string; appSecret?: string } = {}
|
|
563
|
+
if (appIdInput.trim() !== '') values.appId = appIdInput
|
|
564
|
+
if (appSecretInput.trim() !== '') values.appSecret = appSecretInput
|
|
565
|
+
if (values.appId === undefined && values.appSecret === undefined) return
|
|
566
|
+
setBusy('credentials')
|
|
567
|
+
setNotice(null)
|
|
568
|
+
try {
|
|
569
|
+
const result = await saveCredentials(values)
|
|
570
|
+
if (!result.ok) {
|
|
571
|
+
setNotice({ kind: 'error', text: result.error, issues: result.issues })
|
|
572
|
+
return
|
|
573
|
+
}
|
|
574
|
+
setLoad({ phase: 'ready', panel: result.panel })
|
|
575
|
+
setStatus(result.panel.status)
|
|
576
|
+
setAppIdInput('')
|
|
577
|
+
setAppSecretInput('')
|
|
578
|
+
const base = result.reconnect === 'pending'
|
|
579
|
+
? PANEL_COPY.credentialSavedPending
|
|
580
|
+
: result.reconnect === 'auto'
|
|
581
|
+
? PANEL_COPY.credentialSavedReconnected
|
|
582
|
+
: result.reconnect === 'unconfigured'
|
|
583
|
+
? PANEL_COPY.credentialSavedUnconfigured
|
|
584
|
+
: result.reconnect === 'failed'
|
|
585
|
+
? PANEL_COPY.credentialAutoConnectFailed
|
|
586
|
+
: PANEL_COPY.credentialSaved
|
|
587
|
+
setNotice({
|
|
588
|
+
kind: result.reconnect === 'failed' || result.autoReconnectError !== undefined ? 'error' : 'ok',
|
|
589
|
+
text: result.autoReconnectError === undefined
|
|
590
|
+
? base
|
|
591
|
+
: `${base}:${result.autoReconnectError}`,
|
|
592
|
+
})
|
|
593
|
+
} catch (error) {
|
|
594
|
+
setNotice({ kind: 'error', text: messageOf(error) })
|
|
595
|
+
} finally {
|
|
596
|
+
setBusy(null)
|
|
597
|
+
}
|
|
598
|
+
}, [appIdInput, appSecretInput])
|
|
599
|
+
|
|
600
|
+
const onClearCredential = useCallback(async (field: CredentialField): Promise<void> => {
|
|
601
|
+
const confirmed = typeof window.confirm === 'function'
|
|
602
|
+
? window.confirm(PANEL_COPY.credentialRemoveConfirm)
|
|
603
|
+
: true
|
|
604
|
+
if (!confirmed) return
|
|
605
|
+
setBusy('credentials-clear')
|
|
606
|
+
setNotice(null)
|
|
607
|
+
try {
|
|
608
|
+
const result = await clearCredential(field)
|
|
609
|
+
if (!result.ok) {
|
|
610
|
+
setNotice({ kind: 'error', text: result.error, issues: result.issues })
|
|
611
|
+
return
|
|
612
|
+
}
|
|
613
|
+
setLoad({ phase: 'ready', panel: result.panel })
|
|
614
|
+
setStatus(result.panel.status)
|
|
615
|
+
setNotice({
|
|
616
|
+
kind: 'ok',
|
|
617
|
+
text: result.reconnect === 'pending' ? PANEL_COPY.credentialRemovedPending : PANEL_COPY.credentialRemoved,
|
|
618
|
+
})
|
|
619
|
+
} catch (error) {
|
|
620
|
+
setNotice({ kind: 'error', text: messageOf(error) })
|
|
621
|
+
} finally {
|
|
622
|
+
setBusy(null)
|
|
623
|
+
}
|
|
624
|
+
}, [])
|
|
625
|
+
|
|
626
|
+
const issues = useMemo<FieldIssueWire[]>(
|
|
627
|
+
() => (notice?.kind === 'error' ? notice.issues ?? [] : []),
|
|
628
|
+
[notice],
|
|
629
|
+
)
|
|
630
|
+
const dirtyCount = Object.keys(draft).length
|
|
631
|
+
|
|
632
|
+
/** Which tabs currently own a dirty key (the dot is per tab, not global). */
|
|
633
|
+
const dirtyTabs = useMemo<ReadonlySet<PanelTabId>>(() => {
|
|
634
|
+
const tabs = new Set<PanelTabId>()
|
|
635
|
+
if (panel === null || dirtyCount === 0) return tabs
|
|
636
|
+
const groupOf = new Map(panel.config.fields.map(field => [field.key, field.group]))
|
|
637
|
+
for (const key of Object.keys(draft)) {
|
|
638
|
+
const group = groupOf.get(key as EditableKey)
|
|
639
|
+
if (group === undefined) continue
|
|
640
|
+
for (const tab of PANEL_TABS) {
|
|
641
|
+
if (TAB_FIELD_GROUPS[tab.id].includes(group)) tabs.add(tab.id)
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
return tabs
|
|
645
|
+
}, [dirtyCount, draft, panel])
|
|
646
|
+
|
|
647
|
+
const form = useMemo<FormView | null>(() => {
|
|
648
|
+
if (panel === null) return null
|
|
649
|
+
return {
|
|
650
|
+
panel,
|
|
651
|
+
fields: renderedFields,
|
|
652
|
+
draft,
|
|
653
|
+
issues,
|
|
654
|
+
busy,
|
|
655
|
+
locked: hostBusy,
|
|
656
|
+
dirtyCount,
|
|
657
|
+
onChange: updateDraft,
|
|
658
|
+
onSave: () => { void onSave() },
|
|
659
|
+
onDiscardTab: discardTab,
|
|
660
|
+
onPickDirectory,
|
|
661
|
+
}
|
|
662
|
+
}, [busy, discardTab, dirtyCount, draft, hostBusy, issues, onPickDirectory, onSave, panel, renderedFields, updateDraft])
|
|
663
|
+
|
|
664
|
+
const credentials = useMemo<CredentialsView>(() => ({
|
|
665
|
+
appId: appIdInput,
|
|
666
|
+
appSecret: appSecretInput,
|
|
667
|
+
issues,
|
|
668
|
+
busy,
|
|
669
|
+
locked: hostBusy,
|
|
670
|
+
onAppIdChange: setAppIdInput,
|
|
671
|
+
onAppSecretChange: setAppSecretInput,
|
|
672
|
+
onSave: () => { void onSaveCredentials() },
|
|
673
|
+
onClear: (field: CredentialField) => { void onClearCredential(field) },
|
|
674
|
+
}), [appIdInput, appSecretInput, busy, hostBusy, issues, onClearCredential, onSaveCredentials])
|
|
675
|
+
|
|
676
|
+
if (load.phase === 'loading') {
|
|
677
|
+
return (
|
|
678
|
+
<div className="dsh-channels-cfg">
|
|
679
|
+
<div className="dsh-channels-cfg__loading">{PANEL_COPY.loading}</div>
|
|
680
|
+
</div>
|
|
681
|
+
)
|
|
682
|
+
}
|
|
683
|
+
if (load.phase === 'error') {
|
|
684
|
+
return (
|
|
685
|
+
<div className="dsh-channels-cfg">
|
|
686
|
+
<div className="dsh-channels-cfg__error">
|
|
687
|
+
<strong>{PANEL_COPY.loadFailed}</strong>
|
|
688
|
+
<span>{load.message}</span>
|
|
689
|
+
<div>
|
|
690
|
+
<button type="button" className="dsh-channels-cfg__btn" onClick={() => { void loadPanel() }}>
|
|
691
|
+
{PANEL_COPY.retry}
|
|
692
|
+
</button>
|
|
693
|
+
</div>
|
|
694
|
+
</div>
|
|
695
|
+
</div>
|
|
696
|
+
)
|
|
697
|
+
}
|
|
698
|
+
if (form === null) return null
|
|
699
|
+
const current = status ?? load.panel.status
|
|
700
|
+
|
|
701
|
+
return (
|
|
702
|
+
<div className="dsh-channels-cfg">
|
|
703
|
+
<PanelHeader
|
|
704
|
+
status={current}
|
|
705
|
+
pollError={pollError}
|
|
706
|
+
busy={busy}
|
|
707
|
+
locked={hostBusy}
|
|
708
|
+
onRefresh={() => { void onRefresh() }}
|
|
709
|
+
/>
|
|
710
|
+
<TabNav activeTab={activeTab} dirtyTabs={dirtyTabs} onChange={setActiveTab} />
|
|
711
|
+
{notice === null ? null : <NoticeBar notice={notice} />}
|
|
712
|
+
<div
|
|
713
|
+
role="tabpanel"
|
|
714
|
+
id={`dsh-channels-tabpanel-${activeTab}`}
|
|
715
|
+
aria-labelledby={`dsh-channels-tab-${activeTab}`}
|
|
716
|
+
>
|
|
717
|
+
{activeTab === 'connection'
|
|
718
|
+
? (
|
|
719
|
+
<ConnectionTab
|
|
720
|
+
panel={form.panel}
|
|
721
|
+
status={current}
|
|
722
|
+
busy={busy}
|
|
723
|
+
credentials={credentials}
|
|
724
|
+
onReconnect={() => { void onReconnect() }}
|
|
725
|
+
/>
|
|
726
|
+
)
|
|
727
|
+
: null}
|
|
728
|
+
{activeTab === 'sessions' ? <SessionsTab form={form} /> : null}
|
|
729
|
+
{activeTab === 'model' ? <ModelTab form={form} catalogBusy={catalogBusy} /> : null}
|
|
730
|
+
{activeTab === 'workspace'
|
|
731
|
+
? (
|
|
732
|
+
<WorkspaceTab
|
|
733
|
+
form={form}
|
|
734
|
+
cleanup={form.panel.cleanup}
|
|
735
|
+
cleanupResult={cleanupResult}
|
|
736
|
+
cleanupError={cleanupError}
|
|
737
|
+
onRunCleanup={() => { void onRunCleanup() }}
|
|
738
|
+
/>
|
|
739
|
+
)
|
|
740
|
+
: null}
|
|
741
|
+
{activeTab === 'access'
|
|
742
|
+
? (
|
|
743
|
+
<AccessTab
|
|
744
|
+
form={form}
|
|
745
|
+
accessNotice={accessNotice}
|
|
746
|
+
accessError={accessError}
|
|
747
|
+
onAddGroup={onAddAccessGroup}
|
|
748
|
+
onRemoveGroup={chatId => { void onRemoveAccessGroup(chatId) }}
|
|
749
|
+
onAddUser={onAddAccessUser}
|
|
750
|
+
onRemoveUser={openId => { void onRemoveAccessUser(openId) }}
|
|
751
|
+
onResolveRequest={(token, decision) => { void onResolveAccessRequest(token, decision) }}
|
|
752
|
+
/>
|
|
753
|
+
)
|
|
754
|
+
: null}
|
|
755
|
+
{activeTab === 'advanced'
|
|
756
|
+
? <AdvancedTab form={form} onClear={() => { void onClear() }} />
|
|
757
|
+
: null}
|
|
758
|
+
</div>
|
|
759
|
+
</div>
|
|
760
|
+
)
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
/** Page header: title + phase badge + refresh, visible on every tab. */
|
|
764
|
+
function PanelHeader(props: {
|
|
765
|
+
status: ChannelStatusWire
|
|
766
|
+
pollError: string | null
|
|
767
|
+
busy: BusyAction | null
|
|
768
|
+
locked: boolean
|
|
769
|
+
onRefresh: () => void
|
|
770
|
+
}): JSX.Element {
|
|
771
|
+
const { status, pollError, busy, locked, onRefresh } = props
|
|
772
|
+
const badge = phaseBadge(status.phase)
|
|
773
|
+
return (
|
|
774
|
+
<header className="dsh-channels-cfg__panelHead" title={PANEL_COPY.description}>
|
|
775
|
+
<div className="dsh-channels-cfg__headRow">
|
|
776
|
+
<h2 className="dsh-channels-cfg__title">{PANEL_COPY.title}</h2>
|
|
777
|
+
<span className={`dsh-channels-cfg__badge dsh-channels-cfg__badge--${badge.tone}`}>
|
|
778
|
+
<span className="dsh-channels-cfg__dot" />
|
|
779
|
+
{badge.text}
|
|
780
|
+
</span>
|
|
781
|
+
<span className="dsh-channels-cfg__spacer" />
|
|
782
|
+
<button
|
|
783
|
+
type="button"
|
|
784
|
+
className="dsh-channels-cfg__btn"
|
|
785
|
+
onClick={onRefresh}
|
|
786
|
+
disabled={busy !== null || locked}
|
|
787
|
+
>
|
|
788
|
+
{PANEL_COPY.refresh}
|
|
789
|
+
</button>
|
|
790
|
+
</div>
|
|
791
|
+
<p className="dsh-channels-cfg__summary">{phaseSummary(status.phase)}</p>
|
|
792
|
+
{pollError === null
|
|
793
|
+
? null
|
|
794
|
+
: (
|
|
795
|
+
<span className="dsh-channels-cfg__fieldError" role="status" aria-live="polite">
|
|
796
|
+
{PANEL_COPY.pollFailed}:{pollError}
|
|
797
|
+
</span>
|
|
798
|
+
)}
|
|
799
|
+
</header>
|
|
800
|
+
)
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
/** Top-level tab navigation; the dirty dot keeps unsaved work visible per tab. */
|
|
804
|
+
function TabNav(props: {
|
|
805
|
+
activeTab: PanelTabId
|
|
806
|
+
dirtyTabs: ReadonlySet<PanelTabId>
|
|
807
|
+
onChange: (tab: PanelTabId) => void
|
|
808
|
+
}): JSX.Element {
|
|
809
|
+
const { activeTab, dirtyTabs, onChange } = props
|
|
810
|
+
const refs = useRef<Array<HTMLButtonElement | null>>([])
|
|
811
|
+
|
|
812
|
+
/** Roving-focus keyboard navigation between tabs (WAI-ARIA tabs pattern). */
|
|
813
|
+
const onKeyDown = (index: number, event: KeyboardEvent<HTMLButtonElement>): void => {
|
|
814
|
+
const last = PANEL_TABS.length - 1
|
|
815
|
+
let next: number
|
|
816
|
+
switch (event.key) {
|
|
817
|
+
case 'ArrowRight': next = index === last ? 0 : index + 1; break
|
|
818
|
+
case 'ArrowLeft': next = index === 0 ? last : index - 1; break
|
|
819
|
+
case 'Home': next = 0; break
|
|
820
|
+
case 'End': next = last; break
|
|
821
|
+
default: return
|
|
822
|
+
}
|
|
823
|
+
event.preventDefault()
|
|
824
|
+
onChange(PANEL_TABS[next]!.id)
|
|
825
|
+
refs.current[next]?.focus()
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
return (
|
|
829
|
+
<nav className="dsh-channels-cfg__tabs" role="tablist" aria-label={PANEL_COPY.title}>
|
|
830
|
+
{PANEL_TABS.map((tab, index) => {
|
|
831
|
+
const selected = tab.id === activeTab
|
|
832
|
+
const dirty = dirtyTabs.has(tab.id)
|
|
833
|
+
return (
|
|
834
|
+
<button
|
|
835
|
+
key={tab.id}
|
|
836
|
+
ref={(node) => { refs.current[index] = node }}
|
|
837
|
+
type="button"
|
|
838
|
+
role="tab"
|
|
839
|
+
id={`dsh-channels-tab-${tab.id}`}
|
|
840
|
+
aria-selected={selected}
|
|
841
|
+
aria-controls={selected ? `dsh-channels-tabpanel-${tab.id}` : undefined}
|
|
842
|
+
tabIndex={selected ? 0 : -1}
|
|
843
|
+
className="dsh-channels-cfg__tab"
|
|
844
|
+
onClick={() => onChange(tab.id)}
|
|
845
|
+
onKeyDown={event => onKeyDown(index, event)}
|
|
846
|
+
>
|
|
847
|
+
{tab.label}
|
|
848
|
+
{dirty
|
|
849
|
+
? (
|
|
850
|
+
<span
|
|
851
|
+
className="dsh-channels-cfg__tabDot"
|
|
852
|
+
title={PANEL_COPY.dirtyCount}
|
|
853
|
+
aria-label={PANEL_COPY.dirtyCount}
|
|
854
|
+
/>
|
|
855
|
+
)
|
|
856
|
+
: null}
|
|
857
|
+
</button>
|
|
858
|
+
)
|
|
859
|
+
})}
|
|
860
|
+
</nav>
|
|
861
|
+
)
|
|
862
|
+
}
|