诊断问题 (Dramagon Admin · 诊断问题)
2026-09-10 UIUX 对齐(WT#1):主页 KPI 扩展为 5 卡(今日新增 / 未解决 / Agent 自动修复率 / 错误率 / 最常见路由),未解决卡琥珀边框 + 错误率红边框;表格列从 8 扩展为 10(新增全选 checkbox + 路由列);新增 selected / critical / slow 三态视觉;行 ⋯ 按钮 router.push 跳
/admin/diagnostic/[id]全屏页(Drawer 入口在主页保留,WT#4 收口);副标题未解决数字用黄色高亮。Glass 视觉由admin.css §17 段38 个.admin-diagnostic-*class 兜底,严格复用--dramagon-*/--glass-*令牌,不引入新 CSS 变量。详细规范见.learnings/PLANS/diag-uiux-align-2026-09-10.md。
目的
诊断模块不是被动的 admin 日志查看器,而是 Dramagon Studio 的全局可观测性中枢,整合:
- 日常开发维护 — 每个错误可查、可重放、可导出 ZIP 给支持 / GitHub issue。
- 面向切面(AOP) —
withDiagnosticContext装饰器 +apiHandler切点 +ErrorBoundary边界统一收口,不污染业务代码。 - 深度整合日志框架 — 与
createScopedLogger+AsyncLocalStoragelog context 双向绑定(共享traceId);同步DiagnosticIssue行写入 + 异步 publish 到diagnostic-eventsBullMQ Queue。 - API 监控深整合 — 通过
fingerprint与api-timing-samples互查;admin 在api-timing-stats看到 P95 异常 → 跳到对应 diagnostic event。 - 全局异常捕获 + 错误码元数据升级 —
UnifiedErrorCode从 1 维枚举升级为 4 维 metadata(severity × category × retryable × agent_actionable)。 - 消息队列预警 + Agent 自愈 —
diagnostic-eventsQueue 三类消费者:① 通知 worker(email / Slack / webhook);② admin UI 实时面板;③ Agent Diagnostic Worker(自动修复agent_actionable=true错误,否则产出修复 plan)。 - 观测自指 + RBAC + 审计 + 保留期 + 隐私脱敏 — 由 INV-DIAG-01 至 INV-DIAG-15 覆盖。
开始之前
- 仅管理员账号可见;登录会话与 Supabase token 由
requireDiagnosticAdminAuth守卫。 ErrorBoundary自动 emit 通过POST /api/internal/diagnostic/emit(由INTERNAL_TASK_TOKEN保护;绝不在浏览器使用 admin JWT)。- Agent service-account 使用
x-internal-task-token+x-internal-agent-id请求头(详见requireDiagnosticAgentAuth);admin 不能直接调用agent-resolve。 - 所有 admin 操作(resolve / delete / export / bulk-delete / retention-cleanup)写一条
audit_log行,action: diagnostic.*。 DiagnosticIssue表通过resolved字段软删除(true/false);物理删除仅 C2/C3(单条 + 批量)。
步骤
1. 进入诊断仪表板
访问 /admin/diagnostic。页面在 sidebar groups.ops 下(API 耗时)。admin shell 使用与其他 admin 页面相同的玻璃态布局。
2. 查看 KPI strip
仪表板在表格上方展示 5 张卡(玻璃化,admin.css §17 段),对齐 .design/dramagon-redesign/pages/admin-diagnostic.html 原型:
| # | KPI | 数据源 | 视觉 |
|---|---|---|---|
| 1 | 今日新增 | count(DiagnosticIssue where createdAt >= today 0:00) | 默认玻璃描边 |
| 2 | 未解决 incidents | count(DiagnosticIssue where resolved=false) | 琥珀边框 is-warn(admin.css:5282-5285);副标题”其中 N 为 error 级” |
| 3 | Agent 自动修复率 | succeeded / (succeeded + partial),最近 200 条 agent runs | 默认玻璃描边 |
| 4 | 错误率 | count(status>=400) / count(*) over ApiTimingSample 24h 窗口 | 红边框 is-error(admin.css:5287-5290);本轮为占位 ’—‘,依赖 WT#2 后端 /api/admin/diagnostic/stats endpoint |
| 5 | 最常见路由 | GROUP BY apiTimingIds.route top-1,失败次数 | 默认玻璃描边;本轮为占位 ’—‘,依赖 WT#2 后端 stats endpoint |
点击 KPI 卡可跳转到对应过滤后的列表。
副标题格式:
最近 7 天 · 共 247 条 · 18 未解决(琥珀高亮) · 实时刷新中,未解决数字用<strong class="admin-diagnostic-unresolved-highlight">包裹。实时刷新中文案保留但当前无 setInterval/SSE(本轮不实现,WT#5 SSE 收口)。
3. 过滤列表
过滤栏暴露 9 个查询参数(AND 组合),本轮 UIUX 收敛为 5 类 chip(玻璃化,.admin-diagnostic-chip):
| 控件 | 参数 | 类型 | 描述 |
|---|---|---|---|
| 搜索框 | search | 自由文本 | 匹配 description 或 code(大小写不敏感) |
| 级别 chip(全部/error/warning/info) | level | error | warning | info | 严重度,active 状态反色 |
| 来源 select | source | api | worker | queue | auth | react | manual | webhook | unhandledrejection | diagnostic | 来源子系统 |
| 时间窗(2 个 datetime-local) | from / to | ISO 8601 | createdAt 范围 |
| 状态 chip(未解决/已解决/全部) | resolved | true | false | 默认:未解决(琥珀色) |
| fingerprint input | fingerprint | 精确字符串 | 同 fingerprint → 折叠为同一事件 |
| code input | code | 精确字符串 | 按 UnifiedErrorCode 过滤(如 INTERNAL_ERROR、GENERATION_TIMEOUT) |
| pageSize(分页) | page / pageSize | 整数 | 1-based 分页;pageSize 上限 200 |
GET /api/admin/diagnostic/issues?... → { items, total, unresolved, page, pageSize }。
本轮新增:全选 checkbox + 行选 checkbox(state 在
DiagnosticTable内部Set<string>,未接后端)。selected行视觉:左 3px primary 边框 + 8% 红渐变背景;critical行(error + counter>1):左 3px 红 + 10% 红渐变;slow行(warning + code=‘SLOW’):左 3px 黄 + 8% 黄渐变。批量标记已解决按钮本轮不接入,依赖 WT#3 后端POST /api/admin/diagnostic/issues/bulk-resolveendpoint。
4. 打开详情全屏页
本轮 UIUX 变更:主页行 ⋯ 按钮(24×24 glass 圆角,触发 router.push('/admin/diagnostic/${id}'))直接跳全屏详情页;主页行 click 暂仍触发 Drawer(WT#4 收口:Drawer 入口弱化,与 Detail 合并)。onSelect prop 在 DiagnosticTable 仍保留,前端用 _onSelect 抑制 noUnusedParameters,待 WT#4 删除。
/admin/diagnostic/[id] 全屏详情页 5 Tab(玻璃化,.admin-diagnostic-detail-* 8 个新 class,admin.css §18 段):
- Tab 1 · 上下文 —
contextJson(PII 已脱敏)、logContext(AsyncLocalStorage 快照)、requestId、taskId、projectId、traceId。 - Tab 2 · 调用栈 — 抛出点的
error.stack。 - Tab 3 · 同指纹历史 — 同 fingerprint 已 resolved 的最近 20 条,显示历史修复方案。(本轮 Tab 3 名称与原型对齐,生产实现原 Tab 3 为 “Agent Run”,WT#4 收口时切换)
- Tab 4 · Agent Run — 与该 issue 关联的每个
DiagnosticAgentRun,含status/durationMs/errorMessage(本轮保持原 Tab 4 位置,内容不变)。 - Tab 5 · 处理记录(Activity Feed) — 5 节点 timeline:
created/agentPlan/agentRun.{started,succeeded,failed,partial,skipped}/resolved。WT#4 实现,本轮 Activity Feed 仅有 created / agentPlan / resolved 3 节点。
详情页 5 KPI 摘要(路由 / 错误码 / 请求 ID / 用户 / 指纹 / 首次出现 / 最近一次 / 出现次数)与 Drawer 5 Tab 独立,不复用主页统计 API。URL 持久化:
?tab=1§ion=activity(WT#4 实现,刷新页面保持 tab 状态)。
5. 标记 issue 已解决(管理员)
POST /api/admin/diagnostic/issues/[id]/resolve,body { note, commitSha?, prUrl? }。路由:
- 加载 issue;缺失抛
DIAGNOSTIC_ISSUE_NOT_FOUND(404)。 - 已
resolved=true抛DIAGNOSTIC_ISSUE_ALREADY_RESOLVED(409)。 - 更新
resolved / resolvedAt / resolvedById / resolutionNote / resolutionCommitSha / resolutionPrUrl。 - 写
audit_log(action: 'diagnostic.resolve')。
6. 标记 agent 已解决(仅 agent service-account)
POST /api/admin/diagnostic/issues/[id]/agent-resolve(admin 调用返回 403 FORBIDDEN)。agent 必须带 x-internal-task-token + x-internal-agent-id 请求头。Body:
{ "resolution": "fixed" | "plan", "summary": "...", "planMarkdown": "...", "commitSha": "..." }resolution=fixed→ 设置resolved=true,写DiagnosticAgentRun.status=succeeded。resolution=plan→ 写agentPlanMarkdown+agentPlanGeneratedAt,run.status=partial。- 审计动作:
diagnostic.agent-resolve。
7. 批量操作(WT#3 计划)
7.1 批量清空已解决 incidents(已实现)
DELETE /api/admin/diagnostic/issues?resolved=true(resolved=true 是必需参数;否则 400 DIAGNOSTIC_RESOLVED_REQUIRED)。删除所有 DiagnosticIssue where resolved=true,写 audit_log(action: 'diagnostic.bulk-delete')。
7.2 批量标记已解决(WT#3 待实现)
主页 UI 已就绪(checkbox 全选 + “批量标记已解决”按钮),但后端 endpoint POST /api/admin/diagnostic/issues/bulk-resolve 待 WT#3 落地。计划签名:
POST /api/admin/diagnostic/issues/bulk-resolve
Body: { ids: string[], note?: string, commitSha?: string, prUrl?: string }
Response: { resolved: number }需走与单条 resolve 相同的 audit hook(action: 'diagnostic.bulk-resolve'),权限校验 requireAdminAuth。
8. 单条删除
DELETE /api/admin/diagnostic/issues/[id](幂等:缺失 id 返回 { deleted: 0 })。审计动作:diagnostic.delete。
9. 导出 ZIP 诊断包
GET /api/admin/diagnostic/export?from=&to=&resolved= 返回 Content-Type: application/zip,含三个硬编码文件名(INV-DIAG-04):
diagnostic-issues.json— 过滤窗口内完整DiagnosticIssue行(上限 5000 行)。diagnostic-summary.txt— total / resolved / unresolved + top-10 code 计数。diagnostic-export-meta.json—exportedAt+exportedBy+filters。
HTTP Content-Disposition 为 attachment; filename="diagnostic-export-{timestamp}.zip"。审计动作:diagnostic.export。
9.1 实时刷新(SSE,WT#5 计划)
本轮不实现。“实时刷新中”文案在副标题保留但当前无 setInterval / SSE / WebSocket,刷新靠用户点击”刷新”按钮或重新进入页面。
WT#5 计划新增 GET /api/admin/diagnostic/stream SSE 端点(Next.js 15 ReadableStream),事件类型:
issue.created/issue.updated/issue.resolvedagentRun.started/agentRun.succeeded/agentRun.failed
后端 src/lib/diagnostic/publisher.ts 通过 Redis pub/sub(diag-events:stream 通道)派发;emit.ts 与 agent-worker.ts 在 7 + 4 处状态变更后 publish。客户端 use-diagnostic-stream.ts EventSource hook 订阅(SSE 不支持自定义 header,走 ?access_token= 兜底鉴权)。
10. 配置预警规则
访问 /admin/diagnostic/alerts。CRUD:
GET /api/admin/diagnostic/alerts— 列出DiagnosticAlert行。POST /api/admin/diagnostic/alerts— 创建{ ruleName, condition, channels, cooldownSec? }。ruleName唯一。PATCH /api/admin/diagnostic/alerts/[id]— 切换enabled/channels/cooldownSec。
Burst 规则由 emit 自动创建:同 code 在 5 分钟内超过 burstThreshold 时,触发 burst.${code} upsert(例如 INTERNAL_ERROR → burst.INTERNAL_ERROR)。
11. 配置通知订阅
访问 /admin/diagnostic/subscriptions。CRUD:
GET /api/admin/diagnostic/subscriptions— 列出本人的订阅。POST /api/admin/diagnostic/subscriptions— 创建{ filter: {level?, source?, code?, fingerprintPattern?}, channel: 'email'|'slack'|'webhook', target }。DELETE /api/admin/diagnostic/subscriptions/[id]。
通知 worker 按 filter 匹配 payload 并路由到配置的 channel。
11.1 Slack 通知配置(2026-09-11 新增)
DiagnosticSubscription.channel='slack' 时,target 字段填 Slack Incoming Webhook URL(形如 https://hooks.slack.com/services/T.../B.../...)。Notification Worker 把每个匹配事件渲染为 Slack Block Kit 消息,字段包括:
- 标题 —
Dramagon · Diagnostic · {level}(error/warning/info 三色) - 来源 / 错误码 —
source+UnifiedErrorCode - 描述 —
description(截断 500 字符) - Issue 链接 —
${siteUrl}/admin/diagnostic/[id](全屏详情页) - Trace ID —
requestId/taskId/projectId(若有) - Footer —
enqueuedAt+ 折叠计数counter
请求细节:
- HTTP
POST,Content-Type: application/json,Slack 自有 body 格式(非 Block Kit 高级卡片) - 超时 8s(
AbortSignal.timeout(8000));非 2xx 抛DIAGNOSTIC_SLACK_SEND_FAILED(被 worker 吞,不阻断主流程) - 失败 3 次后 BullMQ 重试(
attempts: 3,backoff: exponential 1s)
测试方法(dev 环境):
curl -X POST http://localhost:3000/api/admin/diagnostic/subscriptions \
-H "Cookie: $ADMIN_SESSION" \
-H "Content-Type: application/json" \
-d '{
"filter": { "level": ["error"], "source": ["api"] },
"channel": "slack",
"target": "https://hooks.slack.com/services/REPLACE/ME"
}'
# 触发一次 emit,30 秒内 Slack 应收到一条
curl -X POST http://localhost:3000/api/internal/diagnostic/emit \
-H "x-internal-task-token: $INTERNAL_TASK_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "level": "error", "source": "api", "code": "INTERNAL_ERROR", "description": "slack smoke test" }'安全约束(INV-DIAG-14 同样适用于 webhook channel):
- Webhook URL 必须 当作 secret 处理(
Prisma AdminAuditLog.target写入时需打码,生产部署应通过环境变量注入,数据库不留明文) - Notification Worker 永不 在日志中打印完整
target(截断为https://hooks.slack.com/...8 字符后缀) - 同
target多事件合并发送(burst 场景)按cooldownSec控制,默认 60s
API 端点
| 方法 + 路径 | Auth | Body / Query | Response |
|---|---|---|---|
GET /api/admin/diagnostic/issues | admin | level, source, resolved, from, to, search, fingerprint, code, page, pageSize(≤200) | { items, total, unresolved, page, pageSize } |
GET /api/admin/diagnostic/issues/[id] | admin | — | { item: DiagnosticIssue & { user, resolvedBy, agentRuns } } |
POST /api/admin/diagnostic/issues/[id]/resolve | admin | { note?, commitSha?, prUrl? } | { item } |
POST /api/admin/diagnostic/issues/[id]/agent-resolve | agent | { resolution, summary?, planMarkdown?, commitSha? } | { item, agentRunId } |
DELETE /api/admin/diagnostic/issues/[id] | admin | — | { deleted }(幂等) |
DELETE /api/admin/diagnostic/issues?resolved=true | admin | resolved=true 必需 | { deleted } |
GET /api/admin/diagnostic/export | admin | from, to, resolved? | application/zip blob |
GET /api/admin/diagnostic/alerts | admin | — | { items: DiagnosticAlert[] } |
POST /api/admin/diagnostic/alerts | admin | { ruleName, condition, channels, cooldownSec? } | { item } |
PATCH /api/admin/diagnostic/alerts/[id] | admin | { enabled?, channels?, cooldownSec? } | { item } |
GET /api/admin/diagnostic/subscriptions | admin | — | { items: DiagnosticSubscription[] } |
POST /api/admin/diagnostic/subscriptions | admin | { filter, channel, target } | { item } |
DELETE /api/admin/diagnostic/subscriptions/[id] | admin | — | { deleted } |
GET /api/admin/diagnostic/agent-runs | admin | ?issueId, ?agentId, ?status | { items: DiagnosticAgentRun[] } |
POST /api/internal/diagnostic/emit | INTERNAL_TASK_TOKEN | { level, source, code?, description, context? } | { accepted: true } |
数据模型
DiagnosticIssue 关键字段:
| 字段 | 类型 | 说明 |
|---|---|---|
id | uuid | PK |
level | enum | error | warning | info |
source | enum | api | worker | queue | auth | react | manual | webhook | unhandledrejection | diagnostic |
description | text | 人类可读 |
code | string? | 对齐 UnifiedErrorCode(文本存储) |
agentAction | enum | none | auto | plan |
contextJson | text (JSON) | 已脱敏;失败上下文 |
fingerprint | string? | ${source}::${code}::${slug(description,40)}::${YYYYMMDDHH} |
counter | int | 60s 窗口折叠计数 |
firstSeenAt / lastSeenAt | datetime | 折叠 + 审计 |
traceId / spanId | string? | OTel 128/64-bit hex |
logContext | json? | AsyncLocalStorage 快照 |
userId / taskId / projectId | string? | 跨模块链接 |
apiTimingIds | string[] | 折叠的 api-timing-sample.id 列表 |
resolved / resolvedAt / resolvedById / resolvedByAgentId | bool / datetime / fk / string | 解决状态 |
resolutionNote / resolutionCommitSha / resolutionPrUrl | text? / string? / string? | 审计追踪 |
agentPlanMarkdown / agentPlanGeneratedAt | text? / datetime? | runDiagnosticAgent kind=plan-only 输出 |
createdAt / updatedAt | datetime | 审计 |
DiagnosticSubscription: { userId, filter: {level?, source?, code?, fingerprintPattern?}, channel, target, enabled }。
DiagnosticAlert: { ruleName (unique), condition: {metric, threshold, window}, channels, cooldownSec, enabled, lastFiredAt }。
DiagnosticAgentRun: { issueId, agentId, agentName, status: 'started'|'succeeded'|'failed'|'partial'|'skipped', stepsJson, durationMs, errorMessage? }。
错误码
| HTTP | Code | 场景 |
|---|---|---|
| 400 | DIAGNOSTIC_INVALID_QUERY | level / source 值非法(枚举不匹配) |
| 400 | DIAGNOSTIC_RESOLVED_REQUIRED | DELETE ?resolved != true |
| 400 | DIAGNOSTIC_AGENT_INVALID_PAYLOAD | 缺失 / 非法 resolution 字段 |
| 401 | UNAUTHORIZED | 非 admin / 非 agent / 无效 INTERNAL_TASK_TOKEN |
| 403 | FORBIDDEN | admin 调用 agent-resolve;agent profile 缺失 |
| 404 | DIAGNOSTIC_ISSUE_NOT_FOUND | issue id 不存在 |
| 409 | DIAGNOSTIC_ISSUE_ALREADY_RESOLVED | 重复解决 |
| 500 | DIAGNOSTIC_DB_ERROR | 数据库故障 |
| 500 | DIAGNOSTIC_ZIP_ERROR | ZIP 生成失败 |
INV-DIAG-01 ~ INV-DIAG-15 不变式
| ID | 描述 | 实施位置 |
|---|---|---|
| INV-DIAG-01 | 软删除通过 resolved 字段;物理删除仅 C2/C3 | C2/C3 route handler |
| INV-DIAG-02 | emit 永不抛出(吞错 logWarn) | emit.ts try/catch |
| INV-DIAG-03 | emit 是 O(1) fire-and-forget | emit() 返回 Promise.resolve() |
| INV-DIAG-04 | ZIP 文件名硬编码常量 | export/route.ts ZIP_FILE_NAME_* |
| INV-DIAG-05 | 所有 C1/C1b/C1c/C1d/C2/C3 路由 requireAdminAuth | route 顶部守卫 |
| INV-DIAG-06 | 60s fingerprint 折叠,counter++ | emit.ts fingerprint step |
| INV-DIAG-07 | context email/token/api_key 自动脱敏 | emit.ts redact step |
| INV-DIAG-08 | burst:同 code 5min 内 ≥ threshold → DiagnosticAlert | emit.ts + notification-worker.ts |
| INV-DIAG-09 | Agent worker concurrency=2, attempts=3 指数退避 | agent-worker.ts |
| INV-DIAG-10 | Watchdog 每 5 分钟;buffer > 5000 / queue > 10000 自指 emit | watchdog.ts |
| INV-DIAG-11 | retention:resolved > 90d 删;unresolved > 30d 删 | watchdog.ts |
| INV-DIAG-12 | 所有 admin 操作写 audit_log | audit.ts + 5 个 route 调用 |
| INV-DIAG-13 | DLQ 失败 Job 保留 7d + 自指 emit DIAGNOSTIC_QUEUE_DLQ | queue.ts + agent-worker.ts |
| INV-DIAG-14 | 客户端 emit 走 internal 端点 + INTERNAL_TASK_TOKEN | /api/internal/diagnostic/emit |
| INV-DIAG-15 | 缺失 code 兜底为 INTERNAL_ERROR(不允许 null) | DIAGNOSTIC_CATALOG + getDiagnosticMeta |
故障排查
-
仪表板看不到 incidents:
- 确认
instrumentation.tsPhase 5 已启动(startDiagnosticNotificationWorker+startDiagnosticAgentWorker+startDiagnosticWatchdog)。 - 检查服务端
process.env.INTERNAL_TASK_TOKEN已配置(生产要求)。 - 确认
prisma generate已重新跑过,客户端能识别DiagnosticIssue / DiagnosticAlert / DiagnosticSubscription / DiagnosticAgentRun。
- 确认
-
emit不触发:- 抛出点必须在
try/catch内重新抛出或显式 surface 错误码。 - 检查
src/lib/api-errors.ts的 catch 分支调用emitDiagnosticIssue。
- 抛出点必须在
-
ZIP 导出返回 500
DIAGNOSTIC_ZIP_ERROR:- 检查
archiver已安装(@types/archiver应在 devDependencies)。 - 确认
items有界(take: 5000)— 极大时间窗口可能 OOM。
- 检查
-
Agent worker 不消费:
- 确认
agentAction不为none(仅auto/plan进入 agent worker)。 - 检查 Redis 连通性(
queueRedis与 task queue 共享)。 - 检查
DiagnosticAgentRun行 — 失败应有status=failed+errorMessage填值。
- 确认
-
Burst 预警不触发:
- catalog 中
burstThreshold对低严重度 code(如UNAUTHORIZED)为null。 INTERNAL_ERRORburstThreshold=5;5 分钟内 emit 5 次可触发burst.INTERNAL_ERRORupsert。
- catalog 中
-
Retention 删除太激进:
- Watchdog 每 5 分钟删
unresolved > 30d与resolved > 90d。如需调整,改src/lib/diagnostic/watchdog.ts中的RESOLVED_RETENTION_DAYS/UNRESOLVED_RETENTION_DAYS。
- Watchdog 每 5 分钟删