管理后台 Agent 引用统一显示名称并可跳转实施计划
管理后台 Agent 引用统一显示名称并可跳转实施计划
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: 后台所有展示 Agent 引用的位置(托管路由列表、客户端详情的关联 Agent 与监听入口、服务令牌绑定列与详情、VPN peer 列表与详情、远程服务器列表与详情、审计日志中 resourceType=agent 的资源 ID)一律显示 Agent 名称,并且点击可跳转到该 Agent 的详情页 /agents/{id};解析不到名称时回退显示原始 ID。请求参数、分页、排序、权限语义、协议与 API 契约全部不变。
Architecture: 改动全部在 web/ 前端,分三层,互不依赖。第一层 web/src/stores/agentIndex.ts:Pinia store,把“当前调用者可见的 Agent 清单”收敛为控制台里唯一一份 Agent 身份索引,加载复用既有 loadAgentsForSelection(getAgents)(游标分页、每页 500),并发去重、失败静默降级、refresh() 就地覆盖。第二层 web/src/components/AgentRef.vue:唯一渲染出口,用 router-link 指向 /agents/{id},标签优先级为“调用方显式传入的名称 > 索引命中的名称 > 原始 ID”,title 恒为完整 ID。第三层各视图:把裸 prop="agentId" 列和 formatList(agentIds) 文本替换成 <AgentRef>。不动 Go 代码、DDL、OpenAPI 与协议帧,因此不触发 Schema 版本与升级文档流程,属于前端展示层的向后兼容变更。
Tech Stack: Vue 3 <script setup> + TypeScript、Pinia 4(defineStore setup 风格,与 web/src/stores/auth.ts 一致)、vue-router router-link、Element Plus 表格列插槽、vue-i18n(zh-CN / en-US 双份)、Vitest + jsdom(既有两种风格:挂载行为断言与 readFileSync 源码断言)。不新增第三方依赖。
Spec: 无新增设计规格与 ADR。引用的既有事实源与约束:
- AGENTS.md §3「单一数据源」、§8「三处以上重复必须抽象」「函数/复杂逻辑必须注释为什么」、Web 管理后台交互基线(服务端排序、cursor 分页、筛选参数化)、§13「核心逻辑必须有测试」、§12 发布回滚要求。
docs/user-guide/agent.md:44:Agent ID 是路由与客户端引用 Agent 的稳定标识;名称只是标签,不得取代 ID 成为标识。migrations/ddl.sql:191-201:agents.name无唯一索引,名称可重复,因此任何以名称替代 ID 的展示都必须保留可读到 ID 的通道(本计划用title+ 详情页IdCell)。docs/user-guide/server-admin.md:63、:83、:246、:266:活文档分别描述 Agent 列表、Client 观测、审计日志、Service Token 页面,必须随本次显示层变更同步。- 既有实现范式:
web/src/views/VpnPeers.vue:292的本地agentName(id)解析、web/src/views/RemoteServers.vue:559的服务端agentName字段、web/src/components/IdCell.vue的“中间截断 + 悬停完整值”。
背景:逐处盘点(HEAD 80de13c)
需要变更的展示点(当前都输出原始 ID,或只输出名称但不可点击):
| # | 页面 | 位置 | 当前渲染 | 目标 |
|---|---|---|---|---|
| 1 | 托管路由列表 | web/src/views/Routes.vue:31 |
<el-table-column prop="agentId"> 原样 ID |
名称 + 链接 |
| 2 | 客户端详情 · 监听入口 | web/src/views/Clients.vue:124 |
prop="agentId" 原样 ID |
名称 + 链接 |
| 3 | 客户端详情 · 关联 Agent | web/src/views/Clients.vue:106 |
formatList(detailClient.agentIds) 顿号拼 ID |
每个 ID 一个名称链接,空则 — |
| 4 | 服务令牌列表 · 绑定列 | web/src/views/Tokens.vue:20 → tokenBinding() :185-189 |
row.agentId \|\| row.nodeId、client 型走 formatAgentScope() :190-193 拼 ID |
agent/client 型显示名称链接;server_node 型不变 |
| 5 | 服务令牌详情 · Agent ID | web/src/views/Tokens.vue:77 |
detailToken.agentId \|\| '—' |
名称 + 链接 |
| 6 | 服务令牌详情 · 授权范围 | web/src/views/Tokens.vue:87 |
formatList(detailToken.scope.agentIds) |
每个 ID 一个名称链接 |
| 7 | VPN peer 列表 · 出口 Agent | web/src/views/VpnPeers.vue:39 |
agentName(row.agentId) 纯文本 |
名称 + 链接(复用组件) |
| 8 | VPN peer 详情 | web/src/views/VpnPeers.vue:122 |
同上 | 名称 + 链接 |
| 9 | 远程服务器列表 | web/src/views/RemoteServers.vue:33 |
agentName(row) 纯文本,值来自服务端 agentName |
名称 + 链接,服务端名称优先 |
| 10 | 远程服务器详情 | web/src/views/RemoteServers.vue:157 |
同上(<dt>/<dd> 区块) |
名称 + 链接 |
| 11 | 审计日志列表 · 资源 ID | web/src/views/AuditLogs.vue:61 |
所有资源类型共用 IdCell |
仅 resourceType === 'agent' 时改用名称链接 |
| 12 | 审计日志详情 · 资源 ID | web/src/views/AuditLogs.vue:90 |
纯文本 ID | 同上条件 |
| 13 | 客户端筛选 · Agent | web/src/views/Clients.vue:30 |
要求手填 ID 的文本框 | 可搜索下拉(值仍是 ID,见任务 7) |
明确不改的地方(保持现状,避免扩大范围):
web/src/views/Agents.vue:20-24代理节点列表:名称列 +IdCell是 ID 的权威展示页。web/src/views/AgentDetail.vue:13与:135:详情页的 “Agent ID” 与关闭连接确认框必须显示精确 ID(docs/user-guide/server-admin.md:79已把它写成操作前置条件)。- 令牌/VPN/远程服务器表单里的 Agent 下拉(
Tokens.vue:42,53,123、RemoteServers.vue:110、VpnPeers.vue:74):option 标签已是${name} · ${id},本身可搜索。 web/src/views/Tunnels.vue:列表没有 Agent 列,本次不新增列。- 客户端连接租约表、Server 节点列、Token 列:不是 Agent 引用。
- 概览页 Patch 面板(
Dashboard.vue:73):已按名称展示,且用途是选择目标,不改。
架构决策
- 前端身份索引,而不是给每个接口加
agentName。 后台在改动前就已经为下拉框整表拉取 Agent 清单:Tokens.vue:248、Routes.vue:381、VpnPeers.vue:433用loadAgentsForSelection(getAgents)(每页 500 直到取尽),RemoteServers.vue:268拉 200,Dashboard.vue:73拉 32。既然数据已经在浏览器里,索引就是唯一新增成本,而且能把这四处重复收敛为一次带缓存的请求(AGENTS.md §8)。反过来走后端 join 要给/api/v1/routes、/api/v1/clients、/api/v1/tokens、/api/v1/vpn/peers四个接口新增派生字段,连带 OpenAPI、docs/README.md、Go handler/service 契约测试与前端类型,改动面大一个数量级且仍然需要前端持有 ID 才能跳转。RemoteServers已有的服务端agentName不删除:AgentRef接受显式名称入参并优先使用,避免把一次已经付过成本的 join 变废。 - 名称是标签,ID 仍是标识。
title恒等于完整 Agent ID,解析失败时标签直接退回 ID(等宽字体,和现在一模一样),因此“两个人重名”“Agent 已删除”“读不到权限外 Agent”三种情况都不会误导。跳转仍用 ID,绝不做“点名称按名称搜索”。 - 解析不到就降级,绝不阻塞列表。
ensureLoaded()只在status === 'idle'时发请求;失败置status = 'error'并吞掉错误,本会话内不再自动重试(避免每页 N 个单元格对同一个 5xx 重试 N 次),显式刷新走refresh()。这样身份索引挂掉时页面退化为今天的行为,而不是变成空白页。 - 权限边界不变。 索引来自
GET /api/v1/agents,服务端已按 owner 过滤;对读取者不可见的 Agent 引用只会显示裸 ID,不会泄露他人 Agent 的名称。这是选择“前端索引”而非“后端 join 到新接口”的安全理由之一:不新增任何跨 owner 的信息出口。 router-link而不是router.push。 真实<a href>才能中键/新标签打开,也符合web/src/layouts/NavRail.vue:3的既有做法;测试断言可以直接查a[href="/agents/agent-abc"]。代价是挂载测试必须提供 Pinia 与 router 插件,本计划把这一点写成明确的测试任务(任务 8)。- 筛选条件继续是 ID。
filters.agentId、scope.agentIds、peer.agentId、route.agentId全部保持 ID 值;docs/user-guide/server-admin.md:85描述的筛选参数与internal/storage/client_repository.go:468-471的精确匹配语义(INSTR引号化,agent-1不会命中agent-12)都不受影响。
Global Constraints
- 不修改任何
internal/Go 代码、migrations/、docs/api/openapi.yaml、协议帧与能力协商。 - 不修改
web/src/api/client.ts的请求参数与类型定义(getAgents签名保持现状)。 - 不新增 i18n 键,除非任务明确列出;已新增的必须同时写
web/src/i18n/messages/zh-CN.ts与en-US.ts,因为web/src/tests/i18n.spec.ts:44断言两份键树结构完全一致。 - 空值仍渲染
—(AgentRef的agentId为空时),保持与VpnPeers.vue:293、Tokens.vue:77现有观感一致。 - 列表列的
prop与服务端排序字段不改:Routes.vue:31、Clients.vue:124、AuditLogs.vue:61都不是sortable="custom"列,onSortChange白名单(Routes.vue:376)也不含agentId,因此不存在“按名称排序”的隐含契约。 - 每个任务先写失败测试并观察红灯输出,再做最小实现;不得为通过新测试而放宽既有断言(只允许按任务描述精确替换失效断言)。
- 未获得用户明确授权,不执行 commit、push、merge 或创建远端 PR。
文件清单
新增:
web/src/stores/agentIndex.tsweb/src/components/AgentRef.vueweb/src/tests/agent-index.spec.tsweb/src/tests/agent-ref.spec.ts
修改:
web/src/views/Routes.vue、web/src/views/Clients.vue、web/src/views/Tokens.vue、web/src/views/VpnPeers.vue、web/src/views/RemoteServers.vue、web/src/views/AuditLogs.vue、web/src/views/Agents.vueweb/src/views/token-form.ts(仅把loadAgentsForSelection的调用方收敛到 store;filterAgents保留)web/src/tests/audit-logs-view.spec.ts、web/src/tests/clients-view.spec.ts、web/src/tests/routes.spec.ts、web/src/tests/agent-token-workflows.spec.ts、web/src/tests/vpn.spec.ts、web/src/tests/remote-servers-view.spec.tsweb/src/i18n/messages/zh-CN.ts、web/src/i18n/messages/en-US.tsdocs/user-guide/server-admin.mddocs/superpowers/plans/README.md(由python3 scripts/gen_doc_index.py生成,禁止手改)
任务 1: Agent 身份索引 store
Files:
- Create:
web/src/stores/agentIndex.ts - Test:
web/src/tests/agent-index.spec.ts
Interfaces:
export type AgentIndexStatus = 'idle' | 'loading' | 'ready' | 'error'export const useAgentIndex: () => { agents: Ref<Agent[]>; status: Ref<AgentIndexStatus>; ensureLoaded: () => Promise<void>; refresh: () => Promise<void>; nameOf: (id?: string | null) => string }- 内部加载器固定为
loadAgentsForSelection(web/src/views/token-form.ts:27)+getAgents(web/src/api/client.ts:250),两者签名不改;因此web/src/tests/agent-token-workflows.spec.ts:20对分页拉全的覆盖继续有效。 - 复用
web/src/stores/auth.ts的 setup-store 写法与defineStore('agent-index', () => {...})命名。
TDD:
- 写失败测试
web/src/tests/agent-index.spec.ts,用setActivePinia(createPinia())与vi.mock('../api/client', ...)(沿用agents-view.spec.ts:9的 actual-spread 风格),断言五件事:① 首次nameOf('agent-a')为''且只发一次{ limit: 500 }请求;② 并发ensureLoaded()两次只触发一次列表请求;③ 游标分页时nextCursor会继续拉取直到取尽;④getAgentsreject 后status === 'error'且后续ensureLoaded()不再发请求;⑤refresh()强制重新拉取并在成功前保留旧名称。 - 运行
cd web && npx vitest run src/tests/agent-index.spec.ts,预期红灯:Cannot find module '../stores/agentIndex'。 - 最小实现:
agents+namescomputed(new Map(agents.map(a => [a.id, a.name])))+ 模块内inflight变量做去重;ensureLoaded判断status.value !== 'idle'直接返回;refresh走同一条加载路径但跳过状态判断;catch 只写status.value = 'error'。 - 预期通过:
cd web && npx vitest run src/tests/agent-index.spec.ts。
为什么这样设计: status === 'error' 后不再自动重试,是因为一次列表页可能有上百个引用单元;失败即静默降级为裸 ID,重试交由用户点页面刷新按钮(任务 8 在 Agents.vue 的改名路径显式调用 refresh())。
任务 2: AgentRef 单元格组件
Files:
- Create:
web/src/components/AgentRef.vue - Modify:
web/src/tests/agent-ref.spec.ts(新建,本任务写组件部分)
Interfaces:
defineProps<{ agentId?: string | null; name?: string | null }>()- 渲染规则:
agentId为假值 →<span class="agent-ref agent-ref--empty">—</span>;否则<router-link :to="\/agents/${agentId}`” :title=”agentId” class=”agent-ref” :class=”{ ‘tm-mono’: !resolved }”></router-link>,其中label = props.name || store.nameOf(props.agentId) || props.agentId,resolved = Boolean(props.name || store.nameOf(props.agentId))`。 - 组件自身在
onMounted调用void useAgentIndex().ensureLoaded()(同任务 1 的去重保证 N 个单元格只发一次请求)。 - 样式使用既有 token:默认
color: var(--tm-brand),:hover { color: var(--tm-brand-hover); text-decoration: underline; },.tm-mono沿用web/src/styles/*.css中的等宽类;未解析时不显示下划线,视觉与IdCell.vue的 ID 文本一致。
TDD:
- 写失败测试
web/src/tests/agent-ref.spec.ts:本地createRouter({ history: createMemoryHistory(), routes: [{ path: '/agents/:id', component: { render: () => null } }] })+createPinia()挂载;桩getAgents返回{ items: [{ id: 'agent-edge', name: 'edge-01', ... }], nextCursor: '' }。断言:① 解析成功时文本是edge-01、href === '/agents/agent-edge'、title === 'agent-edge';② 桩getAgentsreject 时文本回退为agent-edge且href仍存在;③ 传入name时优先使用它,即使索引里没有这个 Agent;④agentId为空时输出—且没有<a>。 - 运行
cd web && npx vitest run src/tests/agent-ref.spec.ts,预期红灯:Cannot find module '../components/AgentRef.vue'。 - 最小实现后预期通过。
任务 3: 托管路由与客户端详情接入
Files:
- Modify:
web/src/views/Routes.vue(:31) - Modify:
web/src/views/Clients.vue(:106、:124) - Modify:
web/src/tests/routes.spec.ts、web/src/tests/clients-view.spec.ts
Interfaces:
Routes.vue:31改为带默认插槽的列:<el-table-column :label="t('routes.agent')" min-width="180"><template #default="{ row }"><AgentRef :agent-id="row.agentId" /></template></el-table-column>。列继续绑定ManagedRoute.agentId(web/src/api/client.ts:60),不引入新字段。Clients.vue监听入口子表(:124)同样换成AgentRef;关联 Agent 描述项(:106)改为<span v-for>渲染detailClient.agentIds(web/src/api/client.ts:215)的名称链接,空数组仍走—。formatList(Clients.vue:362)继续服务 token / Server 节点两列,不改签名。
TDD:
- 先在
routes.spec.ts加源码断言:托管路由列必须出现<AgentRef :agent-id="row.agentId" />且不再出现裸prop="agentId";在clients-view.spec.ts增加挂载断言:桩listClients返回带listeners: [{ protocol: 'tcp', listenAddress: '127.0.0.1:6021', agentId: 'agent-edge', enabled: true }]与agentIds: ['agent-edge']的实例,桩getAgents返回名为edge-01的 Agent,打开详情后断言.agent-ref文本为edge-01、href为/agents/agent-edge。 - 运行
cd web && npx vitest run src/tests/routes.spec.ts src/tests/clients-view.spec.ts,预期红灯:expected ... to contain '<AgentRef :agent-id="row.agentId" />';clients-view.spec.ts因组件依赖 Pinia/router 报getActivePinia() was called but there was no active Pinia。 - 实现两视图改动;把
clients-view.spec.ts:11的vi.mock('vue-router', ...)改为 actual-spread 并app.use(memoryRouter)、app.use(createPinia())(沿用vpn.spec.ts:13的写法),把桩改为{ ...actual, listClients, getClientDetail, listClientConnections, getAgents }且getAgents默认mockResolvedValue({ items: [], nextCursor: '' })。 - 预期通过:同一条命令绿灯。
任务 4: 服务令牌列表与详情接入
Files:
- Modify:
web/src/views/Tokens.vue(:20列、:77详情、:87详情 scope、:185-193两个格式化函数) - Modify:
web/src/tests/agent-token-workflows.spec.ts
Interfaces:
- 绑定列(
Tokens.vue:20,值来自tokenBinding():185-189)改为分支渲染:row.type === 'agent'→AgentRef(:agent-id="row.agentId");row.type === 'client'→row.scope.agentIds?.length时渲染名称链接列表,否则t('tokens.allAgents');server_node→ 继续formatServerNodeScope(row)。列容器保留binding类以维持现有省略与悬停样式。 formatAgentScope()(:190-193)从“返回拼好的 ID 串”改为“返回 ID 数组”(agentScopeIds(row): string[]),文本渲染交给模板里的AgentRef;这是唯一被删除的字符串拼接路径。- 详情两项(
:77、:87)改为AgentRef与名称链接列表。
TDD:
- 在
agent-token-workflows.spec.ts新增挂载或源码断言:令牌列表 source 必须出现formatServerNodeScope与<AgentRef,且不得出现row.scope.agentIds.join(', ');断言agent型令牌详情显示名称而非agent-前缀 ID。 - 运行
cd web && npx vitest run src/tests/agent-token-workflows.spec.ts,预期红灯:expected source to contain '<AgentRef'。 - 实现视图改动,预期通过。
- 回归
cd web && npx vitest run src/tests/tokens-contrast.spec.ts(同页样式基线),预期保持绿灯。
任务 5: VPN peer 与远程服务器接入
Files:
- Modify:
web/src/views/VpnPeers.vue(:39、:122、:292-294) - Modify:
web/src/views/RemoteServers.vue(:33、:157、:559) - Modify:
web/src/tests/vpn.spec.ts、web/src/tests/remote-servers-view.spec.ts
Interfaces:
- 删除
VpnPeers.vue:292的本地agentName(agentId)(它的职责已由AgentRef承担,属于 AGENTS.md §8 的重复抽象);:290的visibleAgents继续用filterAgents服务表单下拉,不受影响。 RemoteServers.vue:559的agentName(row)改为把服务端agentName作为显式入参传给组件:<AgentRef :agent-id="row.agentId" :name="row.agentName" />;RemoteServer.agentName?: string已存在于web/src/api/remote-servers.ts:20,服务端投影(internal/server/remote_server_api.go:32)不改。
TDD:
- 在
vpn.spec.ts与remote-servers-view.spec.ts先加断言:两个列表列必须出现<AgentRef,RemoteServers.vue必须出现:name="row.agentName;VPN 断言不得再出现agentName(row.agentId)。 - 运行
cd web && npx vitest run src/tests/vpn.spec.ts src/tests/remote-servers-view.spec.ts,预期红灯:expected source to contain '<AgentRef'。 - 实现两视图改动,预期通过。
任务 6: 审计日志按资源类型接入
Files:
- Modify:
web/src/views/AuditLogs.vue(:61、:90) - Modify:
web/src/tests/audit-logs-view.spec.ts
Interfaces:
- 仅当
row.resourceType === 'agent'时把IdCell换成<AgentRef :agent-id="row.resourceId" />,其余资源类型(service_token、client、credential、webssh_session、user、server_node、remote_server、oidc_provider、user_device、user_mfa等,来源见internal/server/agent_connection_audit.go:42、internal/server/token_service.go:698)保持IdCell。 - 详情抽屉(
:90)用同一条件;判定收敛为视图内一个isAgentResource(row)辅助函数,避免列表与抽屉两处各写一遍字符串比较。
TDD:
- 先扩展
audit-logs-view.spec.ts:beforeEach补createPinia(),vi.mock('vue-router')改 actual-spread 并app.use(memoryRouter);桩getAgents返回{ id: 'agent-abc', name: 'edge-01' };沿用既有agent.updated/agent-abc用例,断言该行文本含edge-01且a[href="/agents/agent-abc"]存在,而proxy_route行仍渲染裸route-1(agent-abc不再出现在该单元格可见文本里)。 - 运行
cd web && npx vitest run src/tests/audit-logs-view.spec.ts,预期红灯:先是getActivePinia() was called but there was no active Pinia,补插件后是expected 'agent-abc' to contain 'edge-01'。 - 实现条件渲染,预期通过。
任务 7: 客户端 Agent 筛选改为可搜索下拉
Files:
- Modify:
web/src/views/Clients.vue(:30、:206、:299) - Modify:
web/src/i18n/messages/zh-CN.ts、web/src/i18n/messages/en-US.ts - Modify:
web/src/tests/clients-view.spec.ts
Interfaces:
filters.agentId值语义不变(仍是 Agent ID,直接进listClients({ agentId })→internal/server/client_repository.go:468-471的精确匹配),控件由el-input换成el-select filterable clearable,选项来自filterAgents(agentIndex.agents, keyword),与VpnPeers.vue:74的setAgentFilter用法一致;reset()(:299)继续清空该字段。- 新增 i18n 键
clients.searchAgents:zh输入名称或 ID 搜索、enSearch by name or ID(与tokens.searchAgents、vpn.form.searchAgents同措辞,两份必须同时添加以满足i18n.spec.ts:44)。
TDD:
- 先在
clients-view.spec.ts断言筛选区出现v-model="filters.agentId"的el-select、t('clients.searchAgents'),并断言选择某个 Agent 后listClients被以agentId: 'agent-edge'调用。 - 运行
cd web && npx vitest run src/tests/clients-view.spec.ts,预期红灯:expected source to contain 'clients.searchAgents'。 - 实现控件替换与两份 i18n 文案,预期通过;再跑
npx vitest run src/tests/i18n.spec.ts确认键树结构一致。
任务 8: 收敛重复的 Agent 清单加载并在改名后刷新
Files:
- Modify:
web/src/views/Tokens.vue(:173、:248)、web/src/views/Routes.vue(:381)、web/src/views/VpnPeers.vue(:433)、web/src/views/RemoteServers.vue(:268) - Modify:
web/src/views/Agents.vue(createAgent/updateAgent成功路径) - Modify:
web/src/tests/routes.spec.ts:109、web/src/tests/remote-servers-view.spec.ts、web/src/tests/agents-view.spec.ts
Interfaces:
- 四个视图不再各自
getAgents(...),改为读取agentIndex.agents(下拉选项用filterAgents(agentIndex.agents, agentFilter.value),agentsLoading由agentIndex.status === 'loading'派生);loadAgentsForSelection只在agentIndex.ts内部使用。 Agents.vue创建与改名(含启用状态切换)成功后调用void agentIndex.refresh(),使后台其他已打开页面的名称标签在下一次渲染即可更新;索引不清空旧值,因此刷新期间不会出现裸 ID 闪烁。routes.spec.ts:109的源码断言由getAgents改为useAgentIndex(这条断言的意图是“路由表单必须能选到 Agent”,收敛加载方式后仍成立,属精确替换而非放宽)。
TDD:
- 先把
routes.spec.ts:109改为expect(source).toContain('useAgentIndex');在agents-view.spec.ts断言改名成功后调用refresh(桩getAgents被再次调用一次);在remote-servers-view.spec.ts断言 source 不再出现getAgents({ limit: 200 })而出现agentIndex.agents。 - 运行
cd web && npx vitest run src/tests/routes.spec.ts src/tests/agents-view.spec.ts src/tests/remote-servers-view.spec.ts,预期红灯:三条断言分别报expected source to contain 'useAgentIndex'、expected getAgents to have been called 2 times、expected source not to contain 'getAgents({ limit: 200 })'(后者按现有源码即失败)。 - 按接口改四个视图与
Agents.vue,预期通过。 - 全量回归
cd web && npx vitest run:唯一允许的既有变化是agent-token-workflows.spec.ts(继续直接测getAgents分页与filterAgents,不受影响)保持绿灯。
任务 9: 导航守卫扩面与活文档
Files:
- Modify:
web/src/tests/routes.spec.ts(in-app navigationdescribe,:266-312) - Modify:
docs/user-guide/server-admin.md(:63、:85、:108后、:246、:266邻段) - Modify:
docs/superpowers/plans/README.md(生成)
Interfaces:
- 现有守卫只扫描
src/views与src/layouts里的router.push((navigationLiterals():277-287),而本计划把跳转写进src/components/AgentRef.vue的router-link :to="...",会绕过守卫。因此把扫描目录加src/components,并把匹配集扩到to="..."/to="`...`"属性;isDeclared()与segments()不改,checked.length下限从 7 提到 9(新增计数来自AgentRef.vue与NavRail.vue的router-link)。 - 活文档需要反映“显示名称 + 可跳转”,同时保留“ID 才是标识”的既有说法。具体改动:
Agent 列表与详情段补一句“其他页面引用 Agent 时显示名称并可跳转到本页详情;名称可重复,鼠标悬停显示 Agent ID”;Client 运行观测与连接管理段把“Agent 筛选”描述改为可搜索下拉且值仍是 ID;审计日志段把“资源 ID”描述补上“资源类型为 agent 时显示名称链接”;Service Tokens与远程服务器段各补一句名称链接。
TDD:
- 先跑
cd web && npx vitest run src/tests/routes.spec.ts观察当前守卫对src/components完全不感知(checked里没有AgentRef.vue的断言基础),把下限断言改为 9 后应红灯:expected 7 to be greater than or equal to 9。 - 扩面实现后应绿灯,且
checked至少包含src/components/AgentRef.vue /agents/*。 - 文档改动无需测试,但必须执行
python3 scripts/gen_doc_index.py与git diff --check,并确认docs/README.md未出现孤儿链接。
任务间依赖
- 任务 1 → 任务 2(组件依赖 store 的
nameOf/ensureLoaded)。 - 任务 2 → 任务 3/4/5/6/7(各视图只依赖
AgentRef的 props 契约)。 - 任务 3/4/5/6/7 之间互不依赖,可并行;但都必须在任务 8 之前完成,否则任务 8 的“加载收敛”会与视图仍在直接调用
getAgents的中间状态冲突。 - 任务 9 依赖任务 2(
AgentRef.vue必须存在,守卫扩面才有新增计数)。
验证命令
cd web && npx vitest run src/tests/agent-index.spec.ts src/tests/agent-ref.spec.ts
cd web && npm test -- --run
npm run build
./scripts/verify-web-embed.sh
git diff --check
前端产物按 web/package.json:9 的 build 脚本经 scripts/sync-web-dist.mjs 同步到 internal/server/web_dist/,因此 ./scripts/verify-web-embed.sh 必须纳入本次验证;Go 侧无改动,无需 go test ./...,但 go vet ./internal/server 可作为嵌入产物仍能被 embed 的旁证(可选)。
回滚注意事项
- 变更全部位于
web/,回滚即git revert本次提交后重新cd web && npm run build并./scripts/verify-web-embed.sh,不需要回滚数据库、配置或 API。 - 单点风险是
AgentRef与agentIndex:若线上出现索引请求风暴或名称错乱,可把任一视图的<AgentRef>还原为原prop=列(任务 3-7 各自独立可 revert),行为立即回到今天的裸 ID 显示,且不影响其他视图。 - 任务 8 是唯一带行为副作用的改动(下拉框数据源换成共享索引)。若某个视图的筛选出现回归,可单独回退该视图到本地
loadAgentsForSelection调用,索引与新组件都不需要回退。 - 灰度顺序:本变更只影响管理后台静态资源,随 Server 二进制发布;发布后先核对“客户端详情、托管路由列表、审计日志”三个页面的跳转与降级,再确认全量。5 分钟内止损方案就是回退
web/dist并重启 Server。
执行记录(2026-11-05)
计划全部任务已实现并通过验证:cd web && npm test -- --run 503 项全绿(新增
agent-index.spec.ts 6 项、agent-ref.spec.ts 6 项),npm run build 与
./scripts/verify-web-embed.sh 通过,git diff --check 干净。与计划的偏差与精确替换:
- 任务 4:
formatAgentScope()更名为bindingAgentIds()并返回 ID 数组,覆盖agent型 单绑定与client型 scope 两种来源;agent-token-workflows.spec.ts里失效的toContain('formatAgentScope')按同一意图替换为toContain('bindingAgentIds')。 - 任务 7:Agent 清单在下拉框展开时才
ensureLoaded(),而不是挂载即拉取。理由与任务 1 一致:不碰筛选的控制台不应为一份用不到的清单付出请求;clients-view.spec.ts里 “没有 Agent 引用就不请求”的断言因此继续成立。 - 任务 8:
agents-view.spec.ts的“改名后重载列表”调用次数断言由 2 改为 3(挂载列表、 改名后列表、共享索引),并新增“索引以{ limit: 500 }重新拉取”的断言。 - 任务 9:导航守卫新增按文件名点名的断言
src/components/AgentRef.vue /agents/${agentId},避免只靠计数下限而出现“扫描器什么也 没看到仍然通过”的空转。 - 类型基线:
vue-tsc在本仓库不是发布门禁,改动前后src/报错为既有同源基线;本次新增 2 条Tokens.vue的DefaultRow → ServiceToken,与同文件其余 10 条行内 helper 调用完全 同类(Element Plus 表格作用域行类型),未额外放宽 helper 签名。