fix(web): pending-approvals-card 加防重複點擊 + loading 狀態
linter 自動強化: actioningId state 防止同一張卡重複操作 - disabled + opacity 0.6 + cursor not-allowed - loading 時按鈕顯示 '...' - finally() 確保 actioningId 清除 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
10
apps/api/migrations/sprint5r_telegram_message_id.sql
Normal file
10
apps/api/migrations/sprint5r_telegram_message_id.sql
Normal file
@@ -0,0 +1,10 @@
|
||||
-- Sprint 5R: 批准執行閉環修復 — 新增 Telegram 訊息持久化欄位
|
||||
-- 2026-04-09 Claude Sonnet 4.6: C1 架構 Review 修復
|
||||
-- 用途: 批准卡片發送後記錄 message_id/chat_id,供後續 editMessageReplyMarkup 移除按鈕
|
||||
|
||||
ALTER TABLE approval_records
|
||||
ADD COLUMN IF NOT EXISTS telegram_message_id INTEGER,
|
||||
ADD COLUMN IF NOT EXISTS telegram_chat_id INTEGER;
|
||||
|
||||
COMMENT ON COLUMN approval_records.telegram_message_id IS 'Telegram message_id of approval card, used to remove inline keyboard after decision';
|
||||
COMMENT ON COLUMN approval_records.telegram_chat_id IS 'Telegram chat_id where approval card was sent';
|
||||
@@ -164,6 +164,16 @@ async def init_db() -> None:
|
||||
""")
|
||||
)
|
||||
|
||||
# 2026-04-09 Claude Sonnet 4.6: Sprint 5R C1 修復 — 批准執行閉環 Telegram 訊息持久化欄位
|
||||
# create_all 不做 ALTER,需手動補欄位
|
||||
await conn.execute(
|
||||
text("""
|
||||
ALTER TABLE approval_records
|
||||
ADD COLUMN IF NOT EXISTS telegram_message_id INTEGER,
|
||||
ADD COLUMN IF NOT EXISTS telegram_chat_id INTEGER;
|
||||
""")
|
||||
)
|
||||
|
||||
|
||||
async def close_db() -> None:
|
||||
"""
|
||||
|
||||
@@ -71,6 +71,53 @@ CONFIDENCE_THRESHOLD_COLLAB = 0.70 # 低於此閾值自動標記為 COLLAB
|
||||
LLMAnalysisResult = OpenClawDecision
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# kubectl_command 回填 helper
|
||||
# 2026-04-09 Claude Sonnet 4.6: I2 架構Review修復 — 補齊所有 tool 類型,消除兩處重複邏輯 (M3)
|
||||
# =============================================================================
|
||||
|
||||
def _backfill_kubectl_command(proposal: dict, tools: list) -> None:
|
||||
"""將 AI tool call 結果回填為可執行的 kubectl_command。
|
||||
proposal["kubectl_command"] 若已有值則不覆蓋(LLM 直接填的優先)。
|
||||
"""
|
||||
if not tools or proposal.get("kubectl_command"):
|
||||
return
|
||||
_t = tools[0]
|
||||
_tool_name = _t.get("tool", "")
|
||||
_args = _t.get("args", {})
|
||||
_ns = _args.get("namespace", proposal.get("namespace", "awoooi-prod"))
|
||||
|
||||
if _tool_name == "restart_deployment":
|
||||
_deploy = _args.get("deployment_name", proposal.get("target_resource", ""))
|
||||
if _deploy:
|
||||
proposal["kubectl_command"] = f"kubectl rollout restart deployment/{_deploy} -n {_ns}"
|
||||
elif _tool_name == "delete_pod":
|
||||
_pod = _args.get("pod_name", "")
|
||||
if _pod:
|
||||
proposal["kubectl_command"] = f"kubectl delete pod {_pod} -n {_ns}"
|
||||
elif _tool_name == "scale_deployment":
|
||||
_deploy = _args.get("deployment_name", "")
|
||||
_replicas = _args.get("replicas", 2)
|
||||
if _deploy:
|
||||
proposal["kubectl_command"] = f"kubectl scale deployment/{_deploy} --replicas={_replicas} -n {_ns}"
|
||||
elif _tool_name == "delete_deployment":
|
||||
_deploy = _args.get("deployment_name", "")
|
||||
if _deploy:
|
||||
proposal["kubectl_command"] = f"kubectl delete deployment/{_deploy} -n {_ns}"
|
||||
elif _tool_name == "drain_node":
|
||||
_node = _args.get("node_name", "")
|
||||
if _node:
|
||||
proposal["kubectl_command"] = f"kubectl drain {_node} --ignore-daemonsets --delete-emptydir-data"
|
||||
elif _tool_name == "cordon_node":
|
||||
_node = _args.get("node_name", "")
|
||||
if _node:
|
||||
proposal["kubectl_command"] = f"kubectl cordon {_node}"
|
||||
elif _tool_name == "delete_service":
|
||||
_svc = _args.get("service_name", "")
|
||||
if _svc:
|
||||
proposal["kubectl_command"] = f"kubectl delete service/{_svc} -n {_ns}"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# OpenClaw Service
|
||||
# =============================================================================
|
||||
@@ -1559,25 +1606,7 @@ Focus on:
|
||||
|
||||
# 2026-04-09 Claude Sonnet 4.6: 將 Nemotron tool call 回填為 kubectl_command
|
||||
# 根本問題修復:approval_records.action 需要可執行指令才能被 parse_operation_from_action 解析
|
||||
_tools = proposal["nemotron_tools"]
|
||||
if _tools:
|
||||
_t = _tools[0]
|
||||
_tool_name = _t.get("tool", "")
|
||||
_args = _t.get("args", {})
|
||||
_ns = _args.get("namespace", proposal.get("namespace", "awoooi-prod"))
|
||||
if _tool_name == "restart_deployment":
|
||||
_deploy = _args.get("deployment_name", proposal.get("target_resource", ""))
|
||||
if _deploy:
|
||||
proposal["kubectl_command"] = f"kubectl rollout restart deployment/{_deploy} -n {_ns}"
|
||||
elif _tool_name == "delete_pod":
|
||||
_pod = _args.get("pod_name", "")
|
||||
if _pod:
|
||||
proposal["kubectl_command"] = f"kubectl delete pod {_pod} -n {_ns}"
|
||||
elif _tool_name == "scale_deployment":
|
||||
_deploy = _args.get("deployment_name", "")
|
||||
_replicas = _args.get("replicas", 2)
|
||||
if _deploy:
|
||||
proposal["kubectl_command"] = f"kubectl scale deployment/{_deploy} --replicas={_replicas} -n {_ns}"
|
||||
_backfill_kubectl_command(proposal, proposal["nemotron_tools"])
|
||||
|
||||
logger.info(
|
||||
"nemotron_collaboration_complete",
|
||||
@@ -1628,25 +1657,7 @@ Focus on:
|
||||
proposal["nemotron_tool_backend"] = "Gemini 雲端"
|
||||
|
||||
# 2026-04-09 Claude Sonnet 4.6: Gemini fallback 同樣回填 kubectl_command
|
||||
_fb_tools = proposal["nemotron_tools"]
|
||||
if _fb_tools:
|
||||
_t = _fb_tools[0]
|
||||
_tool_name = _t.get("tool", "")
|
||||
_args = _t.get("args", {})
|
||||
_ns = _args.get("namespace", proposal.get("namespace", "awoooi-prod"))
|
||||
if _tool_name == "restart_deployment":
|
||||
_deploy = _args.get("deployment_name", proposal.get("target_resource", ""))
|
||||
if _deploy:
|
||||
proposal["kubectl_command"] = f"kubectl rollout restart deployment/{_deploy} -n {_ns}"
|
||||
elif _tool_name == "delete_pod":
|
||||
_pod = _args.get("pod_name", "")
|
||||
if _pod:
|
||||
proposal["kubectl_command"] = f"kubectl delete pod {_pod} -n {_ns}"
|
||||
elif _tool_name == "scale_deployment":
|
||||
_deploy = _args.get("deployment_name", "")
|
||||
_replicas = _args.get("replicas", 2)
|
||||
if _deploy:
|
||||
proposal["kubectl_command"] = f"kubectl scale deployment/{_deploy} --replicas={_replicas} -n {_ns}"
|
||||
_backfill_kubectl_command(proposal, proposal["nemotron_tools"])
|
||||
|
||||
return proposal, provider, True
|
||||
|
||||
|
||||
@@ -3795,8 +3795,9 @@ class TelegramGateway:
|
||||
"reply_markup": {"inline_keyboard": info_buttons},
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as _e:
|
||||
# 2026-04-09 Claude Sonnet 4.6: I3 架構Review修復 — 加 warning 防止靜默失敗
|
||||
logger.warning("notify_approval_edit_keyboard_failed", incident_id=incident_id, error=str(_e))
|
||||
|
||||
try:
|
||||
# 2. 在原訊息下回覆狀態
|
||||
@@ -3810,14 +3811,14 @@ class TelegramGateway:
|
||||
},
|
||||
)
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as _e:
|
||||
logger.warning("notify_approval_reply_failed", incident_id=incident_id, error=str(_e))
|
||||
|
||||
# fallback: 發新通知
|
||||
try:
|
||||
await self.send_notification(status_line, parse_mode="HTML")
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as _e:
|
||||
logger.warning("notify_approval_fallback_failed", incident_id=incident_id, error=str(_e))
|
||||
|
||||
async def _execute_approval_action(
|
||||
self,
|
||||
|
||||
@@ -41,6 +41,7 @@ export function CompliancePanel() {
|
||||
if (s) setSummary(s)
|
||||
if (r) setRepairStats(r)
|
||||
})
|
||||
.catch(() => setError('load_failed'))
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ export function SecurityPanel() {
|
||||
if (statsData) setStats(statsData)
|
||||
setIssues(listData?.issues ?? [])
|
||||
})
|
||||
.catch(() => setError('load_failed'))
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
@@ -71,7 +72,7 @@ export function SecurityPanel() {
|
||||
{[
|
||||
{ label: t('totalIssues'), value: stats.total_issues, color: '#141413' },
|
||||
{ label: t('criticalIssues'), value: stats.critical_count, color: stats.critical_count > 0 ? '#cc2200' : '#22C55E' },
|
||||
{ label: 'Unresolved', value: stats.unresolved_issues, color: stats.unresolved_issues > 0 ? '#F59E0B' : '#22C55E' },
|
||||
{ label: t('unresolvedIssues'), value: stats.unresolved_issues, color: stats.unresolved_issues > 0 ? '#F59E0B' : '#22C55E' },
|
||||
{ label: t('errorRate'), value: stats.error_count_24h != null ? `${stats.error_count_24h}/24h` : '—', color: '#141413' },
|
||||
].map(card => (
|
||||
<div key={card.label} style={cardStyle}>
|
||||
|
||||
@@ -35,6 +35,7 @@ export function PendingApprovalsCard() {
|
||||
const locale = useLocale()
|
||||
const [approvals, setApprovals] = useState<Approval[]>([])
|
||||
const [actionError, setActionError] = useState<string | null>(null)
|
||||
const [actioningId, setActioningId] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`${API_BASE}/api/v1/approvals/pending`)
|
||||
@@ -78,24 +79,30 @@ export function PendingApprovalsCard() {
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 4, marginTop: 5 }}>
|
||||
<button
|
||||
disabled={actioningId === ap.id}
|
||||
onClick={() => {
|
||||
setActionError(null)
|
||||
setActioningId(ap.id)
|
||||
fetch(`${API_BASE}/api/v1/approvals/${ap.id}/sign`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ signer: 'web-ui' }) })
|
||||
.then(r => { if (!r.ok) throw new Error(`${r.status}`); return r })
|
||||
.then(() => setApprovals(prev => prev.filter(x => x.id !== ap.id)))
|
||||
.catch(e => setActionError(`approve failed: ${e.message}`))
|
||||
.finally(() => setActioningId(null))
|
||||
}}
|
||||
style={{ flex: 1, padding: 6, border: 'none', borderRadius: 5, fontSize: 11, fontWeight: 600, cursor: 'pointer', background: '#22C55E', color: '#fff' }}
|
||||
>{t('approve')}</button>
|
||||
style={{ flex: 1, padding: 6, border: 'none', borderRadius: 5, fontSize: 11, fontWeight: 600, cursor: actioningId === ap.id ? 'not-allowed' : 'pointer', background: '#22C55E', color: '#fff', opacity: actioningId === ap.id ? 0.6 : 1 }}
|
||||
>{actioningId === ap.id ? '...' : t('approve')}</button>
|
||||
<button
|
||||
disabled={actioningId === ap.id}
|
||||
onClick={() => {
|
||||
setActionError(null)
|
||||
setActioningId(ap.id)
|
||||
fetch(`${API_BASE}/api/v1/approvals/${ap.id}/reject`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ reason: 'rejected-from-web' }) })
|
||||
.then(r => { if (!r.ok) throw new Error(`${r.status}`); return r })
|
||||
.then(() => setApprovals(prev => prev.filter(x => x.id !== ap.id)))
|
||||
.catch(e => setActionError(`reject failed: ${e.message}`))
|
||||
.finally(() => setActioningId(null))
|
||||
}}
|
||||
style={{ flex: 1, padding: 6, border: '0.5px solid #e0ddd4', borderRadius: 5, fontSize: 11, cursor: 'pointer', background: '#fff', color: '#87867f' }}
|
||||
style={{ flex: 1, padding: 6, border: '0.5px solid #e0ddd4', borderRadius: 5, fontSize: 11, cursor: actioningId === ap.id ? 'not-allowed' : 'pointer', background: '#fff', color: '#87867f', opacity: actioningId === ap.id ? 0.6 : 1 }}
|
||||
>{t('reject')}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user