From e604ed6a94f48c9480024e0f23ad041bb3d97374 Mon Sep 17 00:00:00 2001 From: DJJ Date: Sat, 5 Sep 2026 19:46:01 +0800 Subject: [PATCH 1/5] =?UTF-8?q?feat(dashboard):=20=E8=AE=A9=E5=B7=A5?= =?UTF-8?q?=E5=85=B7=E6=B7=BB=E5=8A=A0=E5=90=8E=E7=9A=84=E6=8E=88=E6=9D=83?= =?UTF-8?q?=E4=B8=8E=E9=A6=96=E6=AC=A1=E4=BD=BF=E7=94=A8=E6=9C=89=E6=98=8E?= =?UTF-8?q?=E7=A1=AE=E4=B8=8B=E4=B8=80=E6=AD=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 内置与自定义连接共用完成态,区分注册成功、等待授权和授权失败,避免用户在尚未就绪时误以为任务完成。设备页优先承载日常列表,接入指导由统一入口按需打开;不改变凭证回滚或离线任务契约。 --- .../src/components/add-tool/AddToolWizard.tsx | 66 +++--- .../components/add-tool/CatalogConfigStep.tsx | 78 +++---- .../components/add-tool/MountCompletion.tsx | 68 ++++++ .../src/components/add-tool/addToolSources.ts | 12 +- .../src/components/add-tool/useMountRunner.ts | 10 +- .../src/pages/system/DevicesPage.tsx | 205 ++++++++++-------- .../src/pages/system/forms/MountDialog.tsx | 63 ++++-- .../dashboard/test/addToolWizard.dom.test.tsx | 65 +++++- .../dashboard/test/devicesPage.dom.test.tsx | 65 ++++++ .../test/mountCompletion.dom.test.tsx | 55 +++++ 10 files changed, 491 insertions(+), 196 deletions(-) create mode 100644 packages/dashboard/src/components/add-tool/MountCompletion.tsx create mode 100644 packages/dashboard/test/devicesPage.dom.test.tsx create mode 100644 packages/dashboard/test/mountCompletion.dom.test.tsx diff --git a/packages/dashboard/src/components/add-tool/AddToolWizard.tsx b/packages/dashboard/src/components/add-tool/AddToolWizard.tsx index 9c0f7862..1ee3c094 100644 --- a/packages/dashboard/src/components/add-tool/AddToolWizard.tsx +++ b/packages/dashboard/src/components/add-tool/AddToolWizard.tsx @@ -13,25 +13,24 @@ import { MountDialog } from '@/pages/system/forms/MountDialog' import { useIntegrationCatalog } from '@/lib/queries' import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' +import { type MountCompletion, MountCompletionActions, MountCompletionSummary } from './MountCompletion' import { ADD_SOURCES, type AddSource, availablePresets } from './addToolSources' import { CatalogConfigStep } from './CatalogConfigStep' type WizardStep = 'source' | 'catalog-config' function WizardBody({ - step, - setStep, defaultPath, onClose, }: { defaultPath?: string onClose: () => void - setStep: (step: WizardStep) => void - step: WizardStep }) { + const [step, setStep] = useState('source') const navigate = useNavigate() const catalog = useIntegrationCatalog() const [selectedProvider, setSelectedProvider] = useState('') + const [completion, setCompletion] = useState(null) const catalogIds = useMemo( () => new Set((catalog.data ?? []).map(item => item.id)), @@ -57,6 +56,17 @@ function WizardBody({ setStep('catalog-config') } + if (completion) { + return ( + <> +
+ +
+ + + ) + } + if (step === 'catalog-config') { return ( {card}} /> ) @@ -158,41 +169,46 @@ function WizardBody({ export function AddToolWizard({ trigger, defaultPath, + open: controlledOpen, + onOpenChange, }: { defaultPath?: string - trigger?: ReactNode + onOpenChange?: (open: boolean) => void + open?: boolean + trigger?: ReactNode | null }) { - const [open, setOpen] = useState(false) - const [step, setStep] = useState('source') - - const close = () => { - setOpen(false) - // 关闭动画后复位,避免看到步骤闪回。 - setTimeout(() => setStep('source'), 200) + const [internalOpen, setInternalOpen] = useState(false) + const open = controlledOpen ?? internalOpen + const changeOpen = (next: boolean) => { + if (controlledOpen === undefined) setInternalOpen(next) + onOpenChange?.(next) } + const close = () => changeOpen(false) return ( - (next ? setOpen(true) : close())} open={open}> - - {trigger ?? ( - - )} - + + {trigger !== null && ( + + {trigger ?? ( + + )} + + )} - + 添加工具 - 从来源开始:内置集成一站式挂载,自定义类型走通用挂载器。 + 选择工具来源,完成连接配置,然后查看可以使用的工具。 - + ) diff --git a/packages/dashboard/src/components/add-tool/CatalogConfigStep.tsx b/packages/dashboard/src/components/add-tool/CatalogConfigStep.tsx index 62dd8c06..9fd48a4c 100644 --- a/packages/dashboard/src/components/add-tool/CatalogConfigStep.tsx +++ b/packages/dashboard/src/components/add-tool/CatalogConfigStep.tsx @@ -1,5 +1,4 @@ -import { ArrowLeft, CheckCircle2, ExternalLink, Loader2, Rocket } from 'lucide-react' -import { Link } from 'react-router' +import { ArrowLeft, Loader2, Rocket } from 'lucide-react' import { useState } from 'react' import type { CatalogListItem } from '@/lib/types' import { @@ -12,7 +11,7 @@ import { import { CatalogIntegrationFields } from '@/pages/system/forms/CatalogIntegrationFields' import { Button } from '@/components/ui/button' import { useSecretList } from '@/lib/queries' -import { encodeTreePath } from '@/lib/path' +import { MountCompletionActions, MountCompletionSummary } from './MountCompletion' import { INTEGRATION_PRESETS } from './addToolSources' import { MountStepsView } from './MountStepsView' import { useMountRunner } from './useMountRunner' @@ -20,13 +19,11 @@ import { useMountRunner } from './useMountRunner' /** 挂载编排的结果视图:步骤时间线 + 成功/失败收尾。 */ function MountRunView({ runner, - provider, onRetry, onDone, }: { onDone: () => void onRetry: () => void - provider: string runner: ReturnType }) { const { state } = runner @@ -35,18 +32,13 @@ function MountRunView({
{state.succeeded ? ( -
- -
-

- {provider} - {' '} - 挂载成功 -

-

- {state.mountedPath} -

-
+
+
) : state.running @@ -62,42 +54,24 @@ function MountRunView({ - {state.authorizationUrl && ( - - - 打开授权页完成授权 - - )}
-
- {state.succeeded - ? ( - <> - - - - ) - : !state.running - ? ( - <> - - - - ) - : null} -
+ {state.succeeded + ? ( + + ) + : !state.running && ( +
+ + +
+ )} ) } @@ -156,7 +130,7 @@ export function CatalogConfigStep({ // 挂载编排进行中或已出结果:展示可见步骤视图。 if (runner.state.steps.length > 0) { return ( - + ) } diff --git a/packages/dashboard/src/components/add-tool/MountCompletion.tsx b/packages/dashboard/src/components/add-tool/MountCompletion.tsx new file mode 100644 index 00000000..851cb313 --- /dev/null +++ b/packages/dashboard/src/components/add-tool/MountCompletion.tsx @@ -0,0 +1,68 @@ +import { ArrowRight, CheckCircle2, ExternalLink, TriangleAlert } from 'lucide-react' +import { Link } from 'react-router' +import { Button } from '@/components/ui/button' +import { encodeTreePath } from '@/lib/path' + +export interface MountCompletion { + authorization: 'not-required' | 'authorized' | 'pending' | 'failed' + authorizationUrl?: string | null + path: string +} + +export function MountCompletionSummary({ result }: { result: MountCompletion }) { + const needsAttention = result.authorization === 'pending' || result.authorization === 'failed' + const Icon = needsAttention ? TriangleAlert : CheckCircle2 + return ( +
+
+ +
+

+ {result.authorization === 'pending' + ? '已添加,等待授权' + : result.authorization === 'failed' + ? '已添加,授权未完成' + : '工具已添加'} +

+

{result.path}

+

+ {result.authorization === 'pending' + ? result.authorizationUrl + ? '请在授权页完成账号连接,再到工具详情查看授权状态。' + : '尚未确认账号授权,请到工具详情中查看状态并继续授权。' + : result.authorization === 'failed' + ? '连接配置已保存,但发起授权失败。可在工具详情中重新授权,无需重复添加。' + : result.authorization === 'authorized' + ? '授权已确认。打开工具详情,查看命令与调用所需参数。' + : '连接配置已保存。打开工具详情,查看命令与连接状态。'} +

+
+
+ {result.authorizationUrl && ( + + )} +
+ ) +} + +export function MountCompletionActions({ result, onDone }: { + onDone: () => void + result: MountCompletion +}) { + return ( +
+ + +
+ ) +} diff --git a/packages/dashboard/src/components/add-tool/addToolSources.ts b/packages/dashboard/src/components/add-tool/addToolSources.ts index 897cf77c..942170d3 100644 --- a/packages/dashboard/src/components/add-tool/addToolSources.ts +++ b/packages/dashboard/src/components/add-tool/addToolSources.ts @@ -35,41 +35,41 @@ export const ADD_SOURCES: readonly AddSource[] = [ { kind: 'catalog', title: '内置集成', - blurb: '这个部署自带、开箱即用的集成。选一个填好凭证即可挂载。', + blurb: '选择当前网关提供的集成,按提示配置连接与凭证。', icon: Blocks, }, { kind: 'mcp', title: 'MCP Server', - blurb: '接入一个 MCP server(SSE / streamable HTTP),支持托管 OAuth 或 authRef。', + blurb: '连接 MCP 服务,支持账号授权与 API 凭证。', icon: Plug, mountKind: 'mcp', }, { kind: 'http', title: 'HTTP 端点', - blurb: '把任意 HTTP API 按工具表映射成可调用工具。', + blurb: '将 HTTP API 配置为可调用的工具。', icon: Globe, mountKind: 'http', }, { kind: 'context', title: 'Context 存储', - blurb: '挂一个存储 namespace(S3 / S3 / provider),用于读写上下文条目。', + blurb: '连接存储服务,集中管理可读写的上下文。', icon: Database, mountKind: 'context', }, { kind: 'skillhub', title: 'Skill 目录', - blurb: '挂一个 Agent 技能目录(S3 / S3)。', + blurb: '连接技能目录,让 Agent 发现和读取技能。', icon: Boxes, mountKind: 'skillhub', }, { kind: 'remote', title: '远端 HTBP', - blurb: '联邦另一个 HTBP 网关的子树(需在联邦白名单内)。', + blurb: '接入另一个网关的工具,需先允许该网关的联邦连接。', icon: Waypoints, mountKind: 'remote', }, diff --git a/packages/dashboard/src/components/add-tool/useMountRunner.ts b/packages/dashboard/src/components/add-tool/useMountRunner.ts index 8fc4c0bd..77772d46 100644 --- a/packages/dashboard/src/components/add-tool/useMountRunner.ts +++ b/packages/dashboard/src/components/add-tool/useMountRunner.ts @@ -2,19 +2,22 @@ import { useCallback, useState } from 'react' import type { IntegrationCalls } from '@/pages/system/forms/integrationPlan' import { useMountOrchestrator } from '@/pages/system/forms/mountOrchestration' import { useInvalidate, useOAuthAuthorize } from '@/lib/queries' +import type { MountCompletion } from './MountCompletion' import { diagnoseMountError, initialMountSteps, type MountStep } from './mountDiagnostics' export interface MountRunState { + authorization: MountCompletion['authorization'] /** 授权步骤产生的 URL(需用户在新标签完成)。 */ authorizationUrl: string | null mountedPath: string | null running: boolean steps: MountStep[] - /** 全部成功后为 true(挂载路径供 UI 展示"打开节点")。 */ + /** Registry 写入成功,不代表授权完成或已验证所有命令。 */ succeeded: boolean } const IDLE: MountRunState = { + authorization: 'not-required', steps: [], running: false, succeeded: false, @@ -82,16 +85,21 @@ export function useMountRunner() { await invalidate() if (calls.needsAuthorize) { + setState(prev => ({ ...prev, authorization: 'pending' })) patchStep('authorize', { state: 'running' }) try { const auth = await oauth.mutateAsync(calls.mount.path) if (auth.status === 'authorized') { + setState(prev => ({ ...prev, authorization: 'authorized' })) patchStep('authorize', { state: 'done' }) } else if (auth.authorizationUrl) { patchStep('authorize', { state: 'done' }) setState(prev => ({ ...prev, authorizationUrl: auth.authorizationUrl ?? null })) + } else { + patchStep('authorize', { state: 'pending' }) } } catch (error) { + setState(prev => ({ ...prev, authorization: 'failed' })) // 授权失败不回滚挂载:节点已挂好,授权可稍后在节点上重试。 patchStep('authorize', { state: 'failed', diff --git a/packages/dashboard/src/pages/system/DevicesPage.tsx b/packages/dashboard/src/pages/system/DevicesPage.tsx index 03297452..f6f6288d 100644 --- a/packages/dashboard/src/pages/system/DevicesPage.tsx +++ b/packages/dashboard/src/pages/system/DevicesPage.tsx @@ -2,15 +2,14 @@ import { ArrowRight, Clock3, Cpu, - ExternalLink, + Plus, RefreshCw, ShieldCheck, - Terminal, Wifi, WifiLow, WifiOff, } from 'lucide-react' -import { Link } from 'react-router' +import { Link, useSearchParams } from 'react-router' import { useState } from 'react' import { Table, @@ -20,10 +19,10 @@ import { TableHeader, TableRow, } from '@/components/ui/table' +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { derivePresence, PRESENCE_HINT, - PRESENCE_STALE_AFTER_MS, PRESENCE_TONE, } from '@/lib/presence' import { DeviceMailboxPanel } from '@/components/device/DeviceMailboxPanel' @@ -58,6 +57,16 @@ function formatActivity(value?: string): string { */ export function DevicesPage() { const list = useRegistryList('device') + const [searchParams, setSearchParams] = useSearchParams() + const connectOpen = searchParams.get('connect') === '1' + const changeConnectOpen = (open: boolean) => { + setSearchParams((current) => { + const next = new URLSearchParams(current) + if (open) next.set('connect', '1') + else next.delete('connect') + return next + }, { replace: true }) + } const { active } = useSession() const [refreshedAt, setRefreshedAt] = useState(null) const baseUrl = active?.baseUrl || window.location.origin @@ -91,77 +100,89 @@ export function DevicesPage() {
void refresh()} - size="sm" - type="button" - variant="outline" - > - - {list.isRefetching ? '正在刷新' : '刷新状态'} - + <> + + + )} - description="查看反向注册机器的实时会话状态,并把新设备安全接入同一棵能力树。" - eyebrow="SYSTEM / DEVICES" - title="设备接入" + description="查看设备状态、打开已注册工具,管理离线任务。" + title="设备" /> -
-
-
-
- - - -

Connect a device

-
-

让内网机器主动连接网关

-

- 在目标机器执行命令,保持进程运行。连接成功后,设备声明的 shell / fs - 能力会出现在左侧能力树中。 -

-
- - - 需要具备 register 权限的 SK - - - 管理 Secret Key - - + + + + 连接设备 + 在目标机器运行连接命令,把设备工具接入当前网关。 + +
+
+

+ 在目标机器执行命令,保持进程运行。连接成功后,设备声明的 shell / fs + 工具将出现在设备列表与能力树中。 +

+
+ + + 需要具备 register 权限的 SK + + changeConnectOpen(false)} + to="/manage/sk" + > + 管理 Secret Key + + +
-
-
-
- - Terminal - - +
+
+ + 连接命令 + + +
+ + {connectCmd} + +

+ 登录档案中的 SK 会由 CLI 提示输入或读取本机配置,不会写入这条命令。 +

- - {connectCmd} - -

- 登录档案中的 SK 会由 CLI 提示输入或读取本机配置,不会写入这条命令。 -

-
-
+ + +
-
-
+
+
已加载设备

{devices.length}

-
+
在线会话 @@ -170,7 +191,7 @@ export function DevicesPage() { {counts.online}

-
+
疑似失联 @@ -181,7 +202,7 @@ export function DevicesPage() { {counts.stale}

-
+
离线保留 @@ -237,19 +258,29 @@ export function DevicesPage() { ) : devices.length === 0 ? ( - -

在目标机器上运行上方 connect 命令,shell / fs 将自动挂上能力树。

+ changeConnectOpen(true)} size="sm"> + + 连接第一台设备 + + )} + className="m-4" + icon={Cpu} + title="还没有设备接入" + > +

连接一台电脑或服务器,即可在这里查看和使用它声明的工具。

) : ( - +
设备路径 会话状态 - 能力说明 - 最近活动 - + 能力说明 + 最近活动 + 操作 @@ -258,7 +289,7 @@ export function DevicesPage() { {devices.map(({ node: device, presence }) => ( -
+
-

{device.path}

-

device namespace

+ {device.path} +

设备

- +

{device.description || '设备通过反向通道注册的能力集合'}

@@ -287,7 +318,7 @@ export function DevicesPage() { updatedAt 只能表示"注册信息最后一次改动",拿它当活跃度会低报。 旧数据没有 lastSeenAt 时回落到 updatedAt。 */} - +

{formatActivity(presence.lastSeenAt ?? device.updatedAt)}

@@ -303,11 +334,12 @@ export function DevicesPage() {
@@ -334,21 +366,10 @@ export function DevicesPage() { })} /> -
-

- 普通 registry delete 只允许删除叶节点,不支持递归删除带 shell / fs - 后代的设备根,因此此页不会提供必然失败的“清理设备”按钮。 offline - 仅表示当前会话不可用,调用会返回可重试的 503。 -

-

- stale 表示连接位仍为真但已超过 - {' '} - {Math.round(PRESENCE_STALE_AFTER_MS / 1000)} - {' '} - 秒没有存活观察(心跳丢失、进程被杀或连接半开),调用很可能失败;重连后会自动回到 - online。 -

-
+

+ 离线设备的工具仍可查看。支持离线投递的命令可排队等待设备上线;实时调用需要在线会话。 + 疑似失联表示近期未收到存活信号,设备重连后将自动恢复状态。 +

) } diff --git a/packages/dashboard/src/pages/system/forms/MountDialog.tsx b/packages/dashboard/src/pages/system/forms/MountDialog.tsx index cbe6a515..4c5c4ce2 100644 --- a/packages/dashboard/src/pages/system/forms/MountDialog.tsx +++ b/packages/dashboard/src/pages/system/forms/MountDialog.tsx @@ -1,6 +1,7 @@ import { Loader2, Plus, TriangleAlert } from 'lucide-react' import { type ReactNode, useMemo, useState } from 'react' import { toast } from 'sonner' +import type { MountCompletion } from '@/components/add-tool/MountCompletion' import type { RegistryNode } from '@/lib/types' import { Dialog, @@ -14,6 +15,7 @@ import { import { useIntegrationCatalog, useInvalidate, + useOAuthAuthorize, usePluginList, useSecretList, } from '@/lib/queries' @@ -70,6 +72,7 @@ export function MountDialog({ trigger, open: controlledOpen, onOpenChange, + onComplete, }: { /** 打开时预选的 kind(向导按来源分流时用);缺省 mcp。 */ defaultKind?: MountKind @@ -77,6 +80,7 @@ export function MountDialog({ existingNodes?: RegistryNode[] existingPaths: string[] hasUnloadedPaths?: boolean + onComplete?: (result: MountCompletion) => void onOpenChange?: (open: boolean) => void open?: boolean /** null 表示仅由受控 open 打开,不渲染触发按钮。 */ @@ -84,6 +88,8 @@ export function MountDialog({ }) { const orchestrator = useMountOrchestrator() const oauthFollowUp = useOAuthFollowUp() + const oauth = useOAuthAuthorize() + const [finishing, setFinishing] = useState(false) const invalidate = useInvalidate() const plugins = usePluginList() const catalog = useIntegrationCatalog() @@ -148,20 +154,41 @@ export function MountDialog({ ? `已写入挂载 ${mounted}` : `已挂载 ${mounted}`, ) - if (controlledOpen === undefined) setInternalOpen(false) - onOpenChange?.(false) - setErr(null) - setForm({ ...INITIAL_REGISTRY_MOUNT_FORM, path: '' }) invalidate() const needsOAuth = form.kind === 'mcp' ? form.mcpAuthMode === 'oauth' : form.kind === 'tool' && credentialPlanFor(toolExportOptions, form.toolExport).kind === 'oauth' - if (needsOAuth) oauthFollowUp.start(mounted, 'tb tool auth') + if (onComplete) { + const completion: MountCompletion = { + authorization: needsOAuth ? 'pending' : 'not-required', + path: mounted, + } + if (needsOAuth) { + setFinishing(true) + try { + const auth = await oauth.mutateAsync(mounted) + completion.authorization = auth.status === 'authorized' ? 'authorized' : 'pending' + completion.authorizationUrl = auth.status === 'authorized' ? null : auth.authorizationUrl ?? null + } catch { + // Registry 已写入;授权 follow-up 失败绝不触发凭证回滚。 + completion.authorization = 'failed' + } finally { + setFinishing(false) + } + } + onComplete(completion) + } else if (needsOAuth) { + oauthFollowUp.start(mounted, 'tb tool auth') + } + if (controlledOpen === undefined) setInternalOpen(false) + onOpenChange?.(false) + setErr(null) + setForm({ ...INITIAL_REGISTRY_MOUNT_FORM, path: '' }) } const changeOpen = (next: boolean) => { - if (orchestrator.isPending) return + if (orchestrator.isPending || finishing) return if (controlledOpen === undefined) setInternalOpen(next) onOpenChange?.(next) if (next) { @@ -190,16 +217,14 @@ export function MountDialog({ )} {isReplacement ? '替换现有节点' : '挂载节点'} - system/registry write - {' '} - 是 upsert:同 path 会替换原记录。切换 kind 会保留各分支草稿。 + 配置工具的类型、路径与连接方式。同一路径已存在时,将替换原有连接配置。 @@ -211,7 +236,7 @@ export function MountDialog({ title="基础身份" > setForm(current => ({ @@ -263,13 +288,15 @@ export function MountDialog({ - diff --git a/packages/dashboard/test/addToolWizard.dom.test.tsx b/packages/dashboard/test/addToolWizard.dom.test.tsx index f009b1ec..628687a5 100644 --- a/packages/dashboard/test/addToolWizard.dom.test.tsx +++ b/packages/dashboard/test/addToolWizard.dom.test.tsx @@ -19,8 +19,16 @@ const TAVILY: CatalogListItem = { nodeKinds: ['tool'], } +const authorize = vi.hoisted(() => vi.fn(async (): Promise<{ authorizationUrl?: string, status: string }> => ({ status: 'authorized' }))) + const calls: Array<{ args: Record, commandPath: string }> = [] +vi.stubGlobal('ResizeObserver', class { + disconnect() {} + observe() {} + unobserve() {} +}) + vi.mock('@/lib/queries', () => ({ useInvalidate: () => async () => {}, useIntegrationCatalog: () => ({ data: [TAVILY] }), @@ -38,20 +46,72 @@ vi.mock('@/lib/queries', () => ({ return { json: {} } }, }), - useOAuthAuthorize: () => ({ mutateAsync: async () => ({ status: 'authorized' }) }), + useOAuthAuthorize: () => ({ mutateAsync: authorize }), })) vi.mock('sonner', () => ({ toast: { success: () => {}, info: () => {}, error: () => {} } })) +await import('@/components/SchemaFormRenderer') const { AddToolWizard } = await import('@/components/add-tool/AddToolWizard') afterEach(() => { cleanup() calls.length = 0 + authorize.mockClear() vi.restoreAllMocks() }) describe('AddToolWizard 渲染与挂载', () => { + it('受控入口无默认按钮,关闭请求由调用方接收,重新打开回到来源', async () => { + const onOpenChange = vi.fn() + const view = (open: boolean) => ( + + ) + const { rerender } = render(view(true)) + expect(screen.queryByRole('button', { name: '添加工具' })).toBeNull() + fireEvent.click(await screen.findByText('Tavily 搜索')) + expect(await screen.findByLabelText('挂载路径 *')).toBeTruthy() + fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' }) + await waitFor(() => expect(onOpenChange).toHaveBeenCalledWith(false)) + rerender(view(false)) + rerender(view(true)) + expect(await screen.findByText('选择来源')).toBeTruthy() + }) + + it('自定义 MCP 添加后进入统一完成页,查看工具关闭向导', async () => { + render() + fireEvent.click(screen.getByRole('button', { name: '添加工具' })) + fireEvent.click(await screen.findByText('MCP Server')) + fireEvent.change(await screen.findByLabelText(/^path.*\*$/), { target: { value: 'tools/mcp' } }) + fireEvent.change(screen.getByLabelText(/^描述.*\*$/), { target: { value: 'MCP tools' } }) + fireEvent.change(screen.getByLabelText(/^url.*\*$/), { target: { value: 'https://mcp.example.test' } }) + fireEvent.click(screen.getByRole('button', { name: '挂载 tools/mcp' })) + expect(await screen.findByText('工具已添加')).toBeTruthy() + const tools = screen.getByRole('link', { name: '查看可用工具' }) + expect(tools.getAttribute('href')).toBe('/nodes/tools/mcp?tab=invoke') + fireEvent.click(tools) + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()) + }) + + it.each(['pending', 'failed'] as const)('自定义 MCP 的 %s 授权保留已添加状态,不重写或回滚', async (outcome) => { + if (outcome === 'failed') authorize.mockRejectedValueOnce(new Error('OAuth unavailable')) + else authorize.mockResolvedValueOnce({ status: 'pending', authorizationUrl: 'https://auth.example.test/consent' }) + render() + fireEvent.click(screen.getByRole('button', { name: '添加工具' })) + fireEvent.click(await screen.findByText('MCP Server')) + fireEvent.change(await screen.findByLabelText(/^path.*\*$/), { target: { value: 'tools/oauth' } }) + fireEvent.change(screen.getByLabelText(/^描述.*\*$/), { target: { value: 'OAuth tools' } }) + fireEvent.change(screen.getByLabelText(/^url.*\*$/), { target: { value: 'https://mcp.example.test' } }) + fireEvent.click(screen.getByRole('button', { name: '无(公开上游)' })) + fireEvent.click(await screen.findByText('oauth — 网关托管 OAuth')) + fireEvent.click(screen.getByRole('button', { name: '挂载 tools/oauth' })) + expect(await screen.findByText(outcome === 'failed' ? '已添加,授权未完成' : '已添加,等待授权')).toBeTruthy() + expect(calls.map(call => call.commandPath)).toEqual(['system/registry/write']) + expect(authorize).toHaveBeenCalledWith('tools/oauth') + expect(screen.getByRole('link', { name: '查看可用工具' }).getAttribute('href')).toBe('/nodes/tools/oauth?tab=invoke') + if (outcome === 'pending') expect(screen.getByRole('link', { name: '打开授权页完成授权' })).toBeTruthy() + }) + it('打开后展示来源选择与常用集成预设', async () => { render( @@ -88,7 +148,8 @@ describe('AddToolWizard 渲染与挂载', () => { expect(calls.some(c => c.commandPath === 'system/registry/write')).toBe(true) }) // 可见步骤:挂载成功 - expect(await screen.findByText('tavily 挂载成功')).toBeTruthy() + expect(await screen.findByText('工具已添加')).toBeTruthy() + expect(screen.getByRole('link', { name: '查看可用工具' }).getAttribute('href')).toBe('/nodes/tools/tavily?tab=invoke') // 没有写 secret(单值留空) expect(calls.some(c => c.commandPath.startsWith('system/secret/'))).toBe(false) }) diff --git a/packages/dashboard/test/devicesPage.dom.test.tsx b/packages/dashboard/test/devicesPage.dom.test.tsx new file mode 100644 index 00000000..8449fa77 --- /dev/null +++ b/packages/dashboard/test/devicesPage.dom.test.tsx @@ -0,0 +1,65 @@ +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { MemoryRouter, useLocation } from 'react-router' +import { DevicesPage } from '@/pages/system/DevicesPage' + +const state = vi.hoisted(() => ({ + devices: [{ path: 'device/laptop', deviceId: 'raw:laptop', kind: 'directory', online: false }], + fetchNextPage: vi.fn(), + refetch: vi.fn(async () => ({ isError: false })), +})) +vi.mock('@/lib/queries', () => ({ + useRegistryList: () => ({ + data: { items: state.devices }, hasNextPage: true, isFetchingNextPage: false, + isPending: false, isError: false, isRefetching: false, + fetchNextPage: state.fetchNextPage, refetch: state.refetch, + }), +})) +vi.mock('@/lib/session-context', () => ({ useSession: () => ({ active: { baseUrl: 'https://gateway.example.test' } }) })) +vi.mock('@/components/device/DeviceMailboxPanel', () => ({ + DeviceMailboxPanel: ({ targets }: { targets: unknown[] }) =>
{JSON.stringify(targets)}
, +})) +function Location() { + return {useLocation().search} +} +function page(url = '/manage/devices') { + return render( + + + + , + ) +} +afterEach(() => { + cleanup() + vi.clearAllMocks() +}) + +describe('设备工作台', () => { + it('首屏保留离线设备、工具入口与分页,按真实 deviceId 传递邮箱目标', () => { + page() + expect(screen.queryByRole('dialog')).toBeNull() + expect(screen.getByRole('link', { name: '打开 device/laptop' }).getAttribute('href')).toBe('/nodes/device/laptop?tab=invoke') + expect(screen.getByTestId('mailbox').textContent).toContain('raw:laptop') + fireEvent.click(screen.getByRole('button', { name: /加载下一页/ })) + expect(state.fetchNextPage).toHaveBeenCalledOnce() + }) + it('深链接打开连接流程,关闭保留其他查询参数,连接命令不包含 SK', async () => { + page('/manage/devices?connect=1&view=all') + expect(screen.getByRole('dialog', { name: '连接设备' })).toBeTruthy() + expect(screen.getByText('tb connect https://gateway.example.test')).toBeTruthy() + fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' }) + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()) + expect(screen.getByTestId('location').textContent).toBe('?view=all') + fireEvent.click(screen.getByRole('button', { name: '连接设备' })) + expect(screen.getByRole('dialog')).toBeTruthy() + }) + it('空态提供可操作的首次接入引导', () => { + const devices = state.devices + state.devices = [] + page() + state.devices = devices + fireEvent.click(screen.getByRole('button', { name: '连接第一台设备' })) + expect(screen.getByRole('dialog', { name: '连接设备' })).toBeTruthy() + }) +}) diff --git a/packages/dashboard/test/mountCompletion.dom.test.tsx b/packages/dashboard/test/mountCompletion.dom.test.tsx new file mode 100644 index 00000000..da5eda7c --- /dev/null +++ b/packages/dashboard/test/mountCompletion.dom.test.tsx @@ -0,0 +1,55 @@ +import { act, cleanup, render, renderHook, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { MemoryRouter } from 'react-router' +import { MountCompletionSummary } from '@/components/add-tool/MountCompletion' +import { useMountRunner } from '@/components/add-tool/useMountRunner' + +const oauth = vi.hoisted(() => vi.fn()) +const invoke = vi.hoisted(() => vi.fn(async () => ({ json: {} }))) +vi.mock('@/lib/queries', () => ({ + useInvoke: () => ({ mutateAsync: invoke }), + useSecretList: () => ({ data: { items: [] }, hasNextPage: false }), + useInvalidate: () => async () => {}, + useOAuthAuthorize: () => ({ mutateAsync: oauth }), +})) + +afterEach(() => { + cleanup() + vi.clearAllMocks() +}) + +describe('添加后的授权状态', () => { + it.each([ + ['authorized', { status: 'authorized' }, '工具已添加'], + ['pending', { status: 'pending', authorizationUrl: 'https://auth.example.test/consent' }, '已添加,等待授权'], + ['pending', { status: 'pending' }, '已添加,等待授权'], + ['failed', new Error('OAuth unavailable'), '已添加,授权未完成'], + ] as const)('%s 不把 registry 成功冒充全部就绪', async (authorization, response, title) => { + if (response instanceof Error) oauth.mockRejectedValueOnce(response) + else oauth.mockResolvedValueOnce(response) + const { result } = renderHook(() => useMountRunner()) + await act(async () => { + await result.current.run({ + mount: { path: 'tools/oauth', kind: 'tool', description: 'OAuth tool', config: { provider: 'test' } }, + needsAuthorize: true, + }) + }) + expect(result.current.state.succeeded).toBe(true) + expect(result.current.state.authorization).toBe(authorization) + expect(invoke.mock.calls).toHaveLength(1) + render( + + + , + ) + expect(screen.getByText(title)).toBeTruthy() + if (authorization === 'pending' && !(response instanceof Error) && 'authorizationUrl' in response) { + expect(screen.getByRole('link', { name: '打开授权页完成授权' }).getAttribute('href')).toBe(response.authorizationUrl) + } + }) +}) From 2513a4ff108f248e8c9456b9d96ebb489fc79720 Mon Sep 17 00:00:00 2001 From: DJJ Date: Sat, 5 Sep 2026 19:47:04 +0800 Subject: [PATCH 2/5] =?UTF-8?q?feat(dashboard):=20=E7=94=A8=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1=E5=B7=A5=E4=BD=9C=E5=8F=B0=E7=BC=A9=E7=9F=AD=E5=8F=91?= =?UTF-8?q?=E7=8E=B0=E4=B8=8E=E5=86=8D=E6=AC=A1=E8=B0=83=E7=94=A8=E5=B7=A5?= =?UTF-8?q?=E5=85=B7=E7=9A=84=E8=B7=AF=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 工作台承接收藏、最近使用与设备,统一搜索直接进入宽屏调用页,保留原搜索上下文和旧能力树深链接。用同一套排版、主题和入口名称降低管理页面的认知负担。 升级行为变化:默认首页由能力树改为工作台,能力树移至 /canvas;远端参数 Schema 获取失败时必须重试成功后再调用,不再回落到未知参数的 JSON 调用。Dashboard 发布内容升为 0.29.0,其余包与 HTTP/CLI 契约不变。 --- packages/dashboard/package.json | 2 +- packages/dashboard/src/App.tsx | 18 +- packages/dashboard/src/canvas/CanvasPage.tsx | 4 +- .../dashboard/src/canvas/WorkspaceShell.tsx | 404 +++++++----------- .../src/components/CommandPalette.tsx | 310 +++++++------- .../dashboard/src/components/EmptyState.tsx | 8 +- .../src/components/FavoriteToolButton.tsx | 25 ++ .../dashboard/src/components/PageHeader.tsx | 11 +- .../src/components/PresenceBadge.tsx | 6 +- .../src/components/layout/navigation.ts | 37 +- .../src/components/node/CmdPanel.tsx | 219 +++++----- .../src/components/node/CommandWorkspace.tsx | 40 +- .../src/components/ui/button-variants.ts | 6 +- .../dashboard/src/components/ui/command.tsx | 11 +- .../dashboard/src/components/ui/dialog.tsx | 6 +- packages/dashboard/src/index.css | 88 ++-- packages/dashboard/src/lib/favorites.ts | 91 ++++ packages/dashboard/src/lib/queries.ts | 10 +- packages/dashboard/src/lib/session.tsx | 6 +- packages/dashboard/src/lib/toolNavigation.ts | 39 ++ packages/dashboard/src/lib/useFavorites.ts | 11 + packages/dashboard/src/pages/LoginPage.tsx | 66 +-- packages/dashboard/src/pages/SearchPage.tsx | 32 +- packages/dashboard/src/pages/ToolPage.tsx | 90 ++++ packages/dashboard/src/pages/ToolsPage.tsx | 95 ++++ .../dashboard/src/pages/WorkspacePage.tsx | 172 ++++++++ .../src/pages/system/FederationPage.tsx | 2 +- .../src/pages/system/PluginsPage.tsx | 2 +- .../src/pages/system/RegistryPage.tsx | 2 +- .../src/pages/system/SecretsPage.tsx | 2 +- .../dashboard/src/pages/system/SkPage.tsx | 2 +- .../dashboard/src/pages/system/StorePage.tsx | 2 +- .../test/commandPalette.dom.test.tsx | 130 ++++++ packages/dashboard/test/favorites.test.ts | 71 +++ .../dashboard/test/invokeHistory.dom.test.tsx | 81 ++++ .../dashboard/test/searchPage.dom.test.tsx | 41 +- .../dashboard/test/storePage.dom.test.tsx | 2 +- .../dashboard/test/toolNavigation.test.ts | 21 + packages/dashboard/test/toolPage.dom.test.tsx | 152 +++++++ .../dashboard/test/workbench.dom.test.tsx | 108 +++++ 40 files changed, 1708 insertions(+), 717 deletions(-) create mode 100644 packages/dashboard/src/components/FavoriteToolButton.tsx create mode 100644 packages/dashboard/src/lib/favorites.ts create mode 100644 packages/dashboard/src/lib/toolNavigation.ts create mode 100644 packages/dashboard/src/lib/useFavorites.ts create mode 100644 packages/dashboard/src/pages/ToolPage.tsx create mode 100644 packages/dashboard/src/pages/ToolsPage.tsx create mode 100644 packages/dashboard/src/pages/WorkspacePage.tsx create mode 100644 packages/dashboard/test/commandPalette.dom.test.tsx create mode 100644 packages/dashboard/test/favorites.test.ts create mode 100644 packages/dashboard/test/invokeHistory.dom.test.tsx create mode 100644 packages/dashboard/test/toolNavigation.test.ts create mode 100644 packages/dashboard/test/toolPage.dom.test.tsx create mode 100644 packages/dashboard/test/workbench.dom.test.tsx diff --git a/packages/dashboard/package.json b/packages/dashboard/package.json index c2e887da..6a644097 100644 --- a/packages/dashboard/package.json +++ b/packages/dashboard/package.json @@ -1,6 +1,6 @@ { "name": "@tool-bridge/dashboard", - "version": "0.28.0", + "version": "0.29.0", "description": "Tool Bridge self-hosted Dashboard as prebuilt static assets", "type": "module", "license": "MIT", diff --git a/packages/dashboard/src/App.tsx b/packages/dashboard/src/App.tsx index 5edc32a1..15e35e69 100644 --- a/packages/dashboard/src/App.tsx +++ b/packages/dashboard/src/App.tsx @@ -1,4 +1,4 @@ -import { Navigate, Route, Routes, useLocation } from 'react-router' +import { Navigate, Route, Routes, useLocation, useSearchParams } from 'react-router' import { lazy, type ReactNode, Suspense } from 'react' import { useSession } from '@/lib/session-context' @@ -13,6 +13,15 @@ const LoginPage = lazy(() => const CanvasPage = lazy(() => import('@/canvas/CanvasPage').then(module => ({ default: module.CanvasPage })), ) +const WorkspacePage = lazy(() => import('@/pages/WorkspacePage').then(module => ({ default: module.WorkspacePage }))) +const ToolsPage = lazy(() => import('@/pages/ToolsPage').then(module => ({ default: module.ToolsPage }))) +const ToolPage = lazy(() => import('@/pages/ToolPage').then(module => ({ default: module.ToolPage }))) + +function ToolsIndex() { + const [params] = useSearchParams() + return params.has('tool') ? : +} + const SearchPage = lazy(() => import('@/pages/SearchPage').then(module => ({ default: module.SearchPage })), ) @@ -53,7 +62,7 @@ function AppBooting() {
- control plane / loading + 正在打开工作区
) @@ -91,6 +100,9 @@ export default function App() { }> }> + } path="tools" /> + } path="tools/*" /> + } path="canvas" /> } path="manage/keys" /> } path="manage/maintenance" /> } path="manage/deployment" /> @@ -115,7 +127,7 @@ export default function App() { - + )} index diff --git a/packages/dashboard/src/canvas/CanvasPage.tsx b/packages/dashboard/src/canvas/CanvasPage.tsx index 247021fe..8845949e 100644 --- a/packages/dashboard/src/canvas/CanvasPage.tsx +++ b/packages/dashboard/src/canvas/CanvasPage.tsx @@ -86,7 +86,7 @@ export function CanvasPage() { (path: string) => navigate(`/nodes/${encodeTreePath(path)}`), [navigate], ) - const close = useCallback(() => navigate('/'), [navigate]) + const close = useCallback(() => navigate('/canvas'), [navigate]) const openCommand = useCallback( (path: string, commandName: string) => { navigate(`/nodes/${encodeTreePath(path)}?tool=${encodeURIComponent(commandName)}`) @@ -100,7 +100,7 @@ export function CanvasPage() { const onUnmounted = useCallback( (path: string) => { const parent = parentPath(path) - navigate(parent === '' ? '/' : `/nodes/${encodeTreePath(parent)}`) + navigate(parent === '' ? '/canvas' : `/nodes/${encodeTreePath(parent)}`) }, [navigate], ) diff --git a/packages/dashboard/src/canvas/WorkspaceShell.tsx b/packages/dashboard/src/canvas/WorkspaceShell.tsx index ee02522b..57cbe9de 100644 --- a/packages/dashboard/src/canvas/WorkspaceShell.tsx +++ b/packages/dashboard/src/canvas/WorkspaceShell.tsx @@ -1,242 +1,129 @@ -import { Command, GitBranch, Menu, Moon, Search, Sun } from 'lucide-react' +import { ChevronDown, Menu, Moon, PanelLeftClose, PanelLeftOpen, Plus, Search, Settings, Sun } from 'lucide-react' import { NavLink, Outlet, useLocation, useNavigate } from 'react-router' -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { useTheme } from 'next-themes' -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from '@/components/ui/dropdown-menu' +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' +import { type ManageLink, RESOURCE_LINKS, SETTINGS_LINKS, WORKSPACE_LINKS } from '@/components/layout/navigation' +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' -import { MANAGE_LINKS } from '@/components/layout/navigation' +import { AddToolWizard } from '@/components/add-tool/AddToolWizard' import { CommandPalette } from '@/components/CommandPalette' -import { useHealthz, useStatus } from '@/lib/queries' import { useSession } from '@/lib/session-context' import { Button } from '@/components/ui/button' +import { useHealthz } from '@/lib/queries' import { cn } from '@/lib/utils' const isMac = typeof navigator !== 'undefined' && /Mac/.test(navigator.platform) -function healthDot(healthy: boolean | undefined, error: boolean): string { - if (error) return 'bg-destructive' - if (healthy) return 'bg-ok shadow-[0_0_7px_var(--ok)]' - return 'bg-warn' -} - -function healthText(healthy: boolean | undefined, error: boolean): string { - if (error) return '网关不可达' - if (healthy) return '网关运行正常' - return '正在检查网关' -} - -/** 顶栏:品牌 + 全局搜索/跳转 + 健康 + 主题 + profile。 */ -function TopBar({ - onOpenPalette, - onToggleRail, -}: { - onOpenPalette: () => void - onToggleRail: () => void -}) { - const health = useHealthz() - const status = useStatus() - const { resolvedTheme, setTheme } = useTheme() - const isDark = resolvedTheme !== 'light' - const navigate = useNavigate() +function ConnectionMenu() { const { active, profiles, switchTo, logout } = useSession() - const healthy = health.data?.healthy - + const navigate = useNavigate() + const health = useHealthz() + const status = health.isError ? '网关不可达' : health.data?.healthy ? '网关运行正常' : '正在检查网关' return ( -
- - - - - tool - - - bridge - - - - - -
- - - - - - - - - - - - - - 连接档案 - - {active?.baseUrl || window.location.origin} - - - - {profiles.map(profile => ( - { - switchTo(profile.name) - navigate('/') - }} - > - {profile.name} - {profile.id === active?.id && } - - ))} - - - 退出登录 - - - -
-
+ + + + 当前连接 + {active?.baseUrl || window.location.origin} + + + {profiles.map(profile => ( + { + switchTo(profile.name) + navigate('/') + }} + > + {profile.name} + {profile.id === active?.id && 当前} + + ))} + + 退出登录 + + ) } -function RailLink({ - to, - label, - icon, - collapsed, - exact, -}: { - collapsed: boolean - exact?: boolean - icon: React.ReactNode - label: string - to: string -}) { +function RailLink({ item, collapsed }: { collapsed: boolean, item: ManageLink }) { + const location = useLocation() + const { icon: Icon, label, to } = item + const related = (to === '/tools' && location.pathname === '/search') || (to === '/canvas' && location.pathname.startsWith('/nodes/')) const link = ( - cn( - 'flex h-9 items-center gap-2.5 rounded-lg px-2.5 text-sm text-foreground/80 transition-colors', - 'hover:bg-secondary/60 hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none', - isActive && 'bg-primary/12 font-medium text-primary', - collapsed && 'justify-center px-0', - )} - end={exact} + aria-label={label} + className={({ isActive }) => cn('flex min-h-10 items-center gap-3 rounded-lg px-3 text-sm text-muted-foreground transition-colors hover:bg-secondary/60 hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none', (isActive || related) && 'bg-secondary font-medium text-foreground', collapsed && 'justify-center px-0')} + end={to === '/'} to={to} > - {icon} + {!collapsed && {label}} ) - if (!collapsed) return link - return ( - - {link} - - {label} - - - ) + return collapsed + ? ( + + {link} + {label} + + ) + : link } -/** 左侧库务栏:画布入口 + 管理页。桌面常驻(可折叠成图标),移动经抽屉。 */ -function ManageRail({ collapsed }: { collapsed: boolean }) { +function Navigation({ collapsed }: { collapsed: boolean }) { + const location = useLocation() + const inSettings = SETTINGS_LINKS.some(item => item.to === location.pathname) + const [settingsOpen, setSettingsOpen] = useState(inSettings) + useEffect(() => { + if (inSettings) setSettingsOpen(true) + }, [inSettings]) return ( -