代理节点列表时间列、翻页、名称筛选与重命名实施计划
代理节点列表时间列、翻页、名称筛选与重命名实施计划
状态:补记计划。实现与验证先于本文完成,第 6 节的红灯输出为实现过程中真实记录的结果,不含推测。
日期:2026-09-29
1. 目标
- 列表展示创建时间与更新时间,默认按创建时间倒排,最新注册的节点在第一页。
- 列表按每页 20 条翻页,支持下一页/上一页。
- 增加名称筛选框。
- 操作列增加“编辑”,管理员可改名称与启用状态,不必删除重建节点。
2. 非目标
- 不改
Agent表结构,不提升SchemaVersion,不新增索引(agents是管理面小表,全表扫描成本可接受;索引优化另议)。 - 不新增协议 frame、不改 API 响应字段:
createdAt/updatedAt与PATCH /api/v1/agents/{agentId}早已存在。 - 不做 offset 分页:列表接口一律 cursor 分页。
- 不放开非管理员改名:授权在服务端,UI 不制造必然 403 的入口。
3. 根因与决策
三点诉求互相牵制,决定了改动必须落到服务端:
agentRepo.List原本是ORDER BY id+WHERE id>?游标。若只在浏览器排序,第一页取到的是id 最小的一批节点,再按创建时间倒排也只是这一页内部的顺序——与“最新接入的节点排最前”相反。- 一旦每页 20 条,浏览器侧的“筛选名称”同样失真:命中项若不在当前页就看不见。
- 因此排序、过滤、分页必须在同一条 SQL 里成立。
决策:
- 页键取
(created_at DESC, id DESC)。created_at不唯一,id必须参与键,否则同一秒内的多行在页边界上会被重复或漏掉;游标即这一对的编码,与client_instance_metadata的复合游标同形(复用decodeCompositeCursor)。 - 两列都是存放 RFC3339 UTC 的 TEXT,数据库比较顺序与界面展示顺序一致,MySQL/SQLite 同构。
keyword走INSTR(name, ?)>0 OR INSTR(id, ?)>0,复用boundedAgentFilter截断 128 字节并剔除\x00。大小写敏感性随驱动不同(SQLite 二进制比较、MySQL 随列 collation),与既有客户端列表口径一致。- 非管理员的可见性改为 SQL 谓词。旧
listAgentsForOwner每次拉 500 行、在内存里丢掉别人的行,既可能漏掉 500 行之外属于该用户的节点,又会返回一个指向其看不到的行的游标;这与AGENTS.md“权限过滤必须在分页语义内完成”相悖,本次一并消除。 - 翻页游标栈放在视图里:cursor 分页没有页号,
上一页只能靠记住每页用过的游标原样重放,而不是重新扫描。
4. 文件清单
internal/storage/agent_contract_test.go(新增)、internal/storage/storage_contract_test.go:runAgentListRepositoryContract,随契约套件在 SQLite 与 MySQL 5.6 上同跑。internal/storage/repository.go:AgentListFilter{OwnerUserID,Keyword}、AgentRepository.List签名、agentRepo.List的过滤 +(created_at,id)倒排序与游标。internal/server/agent_list_status_test.go:倒排翻页不重不漏、keyword在 SQL 内过滤、非管理员分页只见自己的节点;另加TestUpdateAgentNameAndEnabledViaPatch,钉住控制台依赖的PATCH合并语义(名称与开关同时提交、省略name不清空、非管理员 403)。internal/server/api.go:ListAgentsFiltered、listAgentsForOwner改谓词版、listAgents读取keyword。internal/auth/credential_service_test.go:memoryAgents.List跟随接口签名(接口不匹配在该处是运行时判定,必须同步)。web/src/api/client.ts:getAgents增keyword;updateAgent走PATCH,载荷类型Partial<AgentCreateInput>(已含enabled)。web/src/views/Agents.vue:筛选表单、创建/更新时间两列、每页 20 的游标翻页、编辑对话框(名称 + 启用开关)、<style scoped>;创建对话框的开关标签由agents.status归一为agents.enabledColumn,避免与“连通性状态”列混淆。web/src/i18n/messages/{zh-CN,en-US}.ts(新增agents.enabledHint)、web/src/tests/agents-view.spec.ts。docs/user-guide/server-admin.md、docs/api/openapi.yaml(GET的keyword与排序说明;PATCH /api/v1/agents/{agentId}改用新增的AgentPatchRequest并写明禁用的实际后果)。
5. 接口约定
GET /api/v1/agents?limit=20&cursor=<opaque>&keyword=<substring>;响应字段不变,nextCursor为(created_at,id)编码。PATCH /api/v1/agents/{agentId}仅管理员;请求体省略的字段保持原值(沿用既有实现),控制台每次同时提交name与enabled,把界面显示的完整意图一次说清,避免“改名被理解成顺带重新启用”。storage.AgentRepository.List(ctx, AgentListFilter, cursor, limit):filter零值即全量;游标与过滤条件成对使用。
6. TDD 步骤与结果
- 存储契约先写
runAgentListRepositoryContract→go vet ./internal/storage报undefined: AgentListFilter(红)。实现过程中该套件又抓出两处测试自身的错误:夹具里agent-list-c的所有者与过滤值写成同一常量;“跨所有者重放游标”的期望值不成立(别人的行更新于游标位置,返回空页才是正确行为)。改为“带过滤条件逐页走完不重不漏”后才是要断言的性质。 - server 层三条用例实现后全绿;为取真实红灯证据,临时把 handler 的
keyword透传摘掉 →TestListAgentsKeywordFiltersInSQL失败:keyword page = [agent-keyword-3@… agent-keyword-2@… agent-keyword-1@…], want the two searchable agents,恢复后转绿。 - Web 先写 2 条用例 →
requests 20 rows per page…报expected last "spy" call to have been called with [ ObjectContaining {"limit": 20} ],sends the name filter…报Cannot read properties of undefined (reading 'click')(实现前无翻页控件)。实现后 5 passed;期间修正一处测试前提:空结果渲染空态、没有翻页控件,用例需带一行数据才能验证“筛选后回到第一页”。 - 编辑框加启用开关:先写 2 条用例 →
carries the administrative switch through the edit dialog报switch in the edit dialog: expected null to be truthy,改名用例报载荷缺enabled(红)。实现后转绿。TestUpdateAgentNameAndEnabledViaPatch覆盖的是既有服务端行为,属回归护栏而非新实现,一次通过。 memoryAgents未随接口更新时,internal/auth以credential repositories are required失败——该 fake 是运行时类型判定,编译期不报错,已同步签名。
7. 验证命令
npm ci # web 依赖随上游升级(vitest 5、vue 3.5.43、jsdom 30),换基线后必须先装
go test ./internal/storage ./internal/server ./internal/auth -count=1
go test ./... -count=1 -timeout 30m
go test -race ./... -count=1 -timeout 25m # 必须 </dev/null:oneclick 脚本套件有从 stdin 读取的用例,带控制终端会阻塞
go vet ./...
git diff --check
cd web && npm test -- --run && npm run build
cd .. && bash scripts/verify-web-embed.sh
8. 回滚
- 前端与文档可直接
git revert。 - 服务端回滚会同时恢复旧的非管理员内存过滤分页;游标格式变化是单向的:新版本可读旧游标(
decodeCompositeCursor对单值游标退化为整表首页之后的位置),旧版本读到新复合游标会按非法游标处理并回到首页,不产生错误数据。发布前无需清空游标。