# ADR-UI-04: Approval Queue UI **狀態**:Accepted **日期**:2026-05-03(台北) **決策者**:統帥 **範圍**:M7 Approval Queue + M8 Approval Decision 的詳細設計 **關聯**:ADR-UI-01(架構)、ADR-114(WAITING_APPROVAL state)、ADR-116(approval_token) --- ## M7 — Approval Queue ### 頁面設計 **路由**:`/awooop/approvals` **即時更新(SSE)— 重要:approval 過期倒數** ``` ┌────────────────────────────────────────────────────────────────────────────┐ │ Approval Queue 🔵 3 pending │ ├──────────────┬────────────────────────────┬────────────────┬──────────────┤ │ Run ID │ Request Summary │ Risk Level │ Expires in │ ├──────────────┼────────────────────────────┼────────────────┼──────────────┤ │ abc123... │ k8s_exec_pod(api, restart) │ 🔴 HIGH │ 12:34 ⏱️ │ │ def456... │ k8s_scale_deployment(3→1) │ 🟡 MEDIUM │ 06:12 ⏱️ │ │ ghi789... │ pg_mutate(DELETE incidents) │ 🔴 HIGH │ 02:01 ⚠️ │ └──────────────┴────────────────────────────┴────────────────┴──────────────┘ ``` **倒數計時顏色**: - > 10 分鐘:灰色 - 5-10 分鐘:⏱️ 橙色 - < 5 分鐘:⚠️ 紅色閃爍 - 過期:❌ 已過期(不可再 approve) **倒數計時邏輯**(前端計算,不依賴 SSE 更新): ```typescript function TokenCountdown({ expiresAt }: { expiresAt: string }) { const [remaining, setRemaining] = useState(() => Math.max(0, differenceInSeconds(new Date(expiresAt), new Date())) ) useEffect(() => { const interval = setInterval(() => { setRemaining(prev => Math.max(0, prev - 1)) }, 1000) return () => clearInterval(interval) }, []) const minutes = Math.floor(remaining / 60) const seconds = remaining % 60 const isUrgent = remaining < 300 // < 5分鐘 return ( {minutes}:{seconds.toString().padStart(2, "0")} ) } ``` **Telegram 通知連動**: - 新的 WAITING_APPROVAL run → 同時發 Telegram 通知給有 `approver` role 的 principal - 通知格式(ADR-116 + approval_token): ``` [APPROVAL REQUEST] Run abc123 Operation: k8s_exec_pod(api, restart) Risk: HIGH | Expires: 15min [Approve] [Reject] ← Telegram inline buttons ``` - Telegram approve/reject → 走 callback → Phase 8 resume API --- ## M8 — Approval Decision ### 頁面設計 **路由**:`/awooop/approvals/[run_id]` **頁面佈局**: ``` ┌─────────────────────────────────────────────────────────────────────────┐ │ Approval Request Expires: 12:34 ⏱️ │ ├─────────────────────────────────────────────────────────────────────────┤ │ Run: abc12345-... │ │ Agent: openclaw-sre | Project: awoooi │ │ Subject: awoooi:telegram:123456789 (陳統帥) │ │ Trigger: "請幫我把 api deployment 縮到 1 個 replica" │ ├─────────────────────────────────────────────────────────────────────────┤ │ REQUESTED OPERATION │ │ ────────────────────────────────────────────────────────────────────── │ │ Tool: k8s_scale_deployment │ │ Risk Level: 🔴 HIGH │ │ Parameters: │ │ namespace: awoooi-prod │ │ deployment: api │ │ replicas: 1 (current: 3) │ │ │ │ Compensation available: ✅ (scale back to 3 if rejected/rolled back) │ ├─────────────────────────────────────────────────────────────────────────┤ │ SAGA Context(前面做了什麼) │ │ ────────────────────────────────────────────────────────────────────── │ │ Step 1 ✅ LLM decision: scale api to 1 (reason: SLO budget exceeded) │ │ Step 2 ⏳ WAITING: scale_deployment approval │ ├─────────────────────────────────────────────────────────────────────────┤ │ APPROVAL DECISION │ │ │ │ Comment (optional): ____________________________________ │ │ │ │ [✅ Approve] [❌ Reject] │ └─────────────────────────────────────────────────────────────────────────┘ ``` ### Approve / Reject 流程 ```typescript async function handleApprovalDecision( runId: string, decision: "approve" | "reject", comment: string ) { // 1. 取得 approval_token(Platform API 生成 HS256 token) const { approval_token } = await fetchApprovalToken(runId) // 2. 送出決策 const response = await fetch(`/v1/platform/runs/${runId}/resume`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ approval_token, // ADR-116 D4 decision, comment, approver_subject_id: currentUser.platform_subject_id }) }) if (!response.ok) { const error = await response.json() if (error.code === "E-APPROVAL-002") { // approval_token 過期 → 提示用戶刷新頁面 toast.error("Approval token has expired. Please refresh.") } else if (error.code === "E-APPROVAL-003") { // jti 已使用(重複提交) toast.error("This approval has already been submitted.") } return } // 3. 成功 → 跳回 Approval Queue router.push("/awooop/approvals") toast.success(`Run ${decision === "approve" ? "approved" : "rejected"} successfully.`) } ``` ### 錯誤處理 | 錯誤碼 | UI 行為 | |--------|---------| | E-APPROVAL-001 | 「此 run 不再需要 approval」→ 跳回 Queue | | E-APPROVAL-002 | 「Token 已過期,請刷新」→ 重新取 token | | E-APPROVAL-003 | 「已送出過 approval,請勿重複提交」→ 禁用按鈕 | | E-APPROVAL-004 | 「您沒有 approver 權限」→ 顯示錯誤,跳回首頁 | ### Token 取得機制 ```typescript // GET /v1/platform/runs/{run_id}/approval-token // → 返回 HS256 approval_token(只對目前 session 的用戶有效) async function fetchApprovalToken(runId: string): Promise<{ approval_token: string }> { const response = await fetch(`/v1/platform/runs/${runId}/approval-token`) if (!response.ok) throw new Error("Failed to fetch approval token") return response.json() } ``` **注意**:approval_token 在 API 端生成後,每次頁面載入都重新取得(15min TTL,不快取)。 --- ## 後果 ### Benefits - 倒數計時讓 approver 清楚知道緊迫程度(防止 approval 超時) - SAGA Context 顯示:approver 可以看到 run 到目前為止做了什麼,做出知情決策 - Compensation 顯示:approver 知道 reject 後會自動回滾什麼操作 - Telegram 按鈕連動:approver 可以在 Telegram 直接 approve/reject,不需要開網頁 ### Costs - 倒數計時 setInterval(每頁面一個,不影響效能) - approval_token 每次頁面載入都重新取得(一次 API call,可接受) ### Risks - 倒數計時與 server 時鐘有偏差(前端計算 vs server exp claim) - 緩解:頁面載入時以 server 返回的 `expires_at` 為準,不用 `Date.now()` --- ## 驗收標準 - [ ] M7:WAITING_APPROVAL run 即時顯示 + 倒數計時(Phase 5) - [ ] M7:< 5 分鐘 → 紅色閃爍(視覺測試) - [ ] M8:SAGA Context 正確顯示前序步驟(Phase 5) - [ ] M8:Approve → run 恢復 RESUMED 狀態(整合測試) - [ ] M8:Reject → SAGA 補償觸發(整合測試) - [ ] M8:E-APPROVAL-002(token 過期)→ 錯誤提示正確(整合測試) - [ ] Telegram inline button approve → Phase 8 resume API(整合測試) ## 關聯 - ADR-UI-01(Operator Console 路由架構) - ADR-UI-03(M6 Run Detail,SAGA 步驟資料共用) - ADR-114(WAITING_APPROVAL state,lease 不 renew) - ADR-116(approval_token HS256,M8 核心) - ADR-119(SAGA compensation,reject 觸發)