docs: 完整治理架構 ADR-010/011/012 + CLAUDE.md 鐵律更新
2026-03-23 重大事故修復與治理: 1. ADR-010: Secrets 集中管理 (Bitwarden + Sealed Secrets) 2. ADR-011: NetworkPolicy 變更治理 (偵測 + 告警 + 人工決策) 3. ADR-012: 危險操作治理 (Tier 分級 + CI/CD 攔截 + 審計) 4. UX-001: 告警疲勞解決方案 (時間衰減 + 智慧分組) CLAUDE.md 更新: - 新增最高優先級鐵律 (禁止 ClawBot、OpenClaw 核心、禁止危險 API) - 新增任務開始前必讀 Memory 對照表 事故教訓: - Telegram Token 連續三次被 logOut 失效 - AWOOOI API 程式碼呼叫 logOut 導致災難 - 已停用 AWOOOI API Telegram,OpenClaw 為唯一 Gateway Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
327
docs/design/UX-001-incident-card-fatigue.md
Normal file
327
docs/design/UX-001-incident-card-fatigue.md
Normal file
@@ -0,0 +1,327 @@
|
||||
# UX-001: 事件卡片告警疲勞解決方案
|
||||
|
||||
**狀態**: 提案
|
||||
**日期**: 2026-03-23
|
||||
**提案者**: 統帥
|
||||
**問題**: 事件卡片長時間堆積導致用戶麻木,失去警示效果
|
||||
|
||||
---
|
||||
|
||||
## 問題分析
|
||||
|
||||
```
|
||||
現況:
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ 活躍事件 │
|
||||
│ ┌────────────┐ ┌────────────┐ ┌────────────┐ │
|
||||
│ │ P0 紅色 │ │ P0 紅色 │ │ P0 紅色 │ │
|
||||
│ │ 09:39 │ │ 09:44 │ │ 09:57 │ ... │
|
||||
│ │ mitigating │ │ mitigating │ │ mitigating │ │
|
||||
│ └────────────┘ └────────────┘ └────────────┘ │
|
||||
│ │
|
||||
│ 問題:全部看起來一樣 → 視覺疲勞 → 無人處理 │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 心理學原理
|
||||
|
||||
1. **Weber-Fechner Law**: 持續刺激會導致感知鈍化
|
||||
2. **Banner Blindness**: 重複視覺元素被大腦自動忽略
|
||||
3. **Decision Fatigue**: 太多選擇導致不做選擇
|
||||
|
||||
---
|
||||
|
||||
## 解決方案:四層優化
|
||||
|
||||
### Layer 1: 時間衰減視覺 (Time-based Visual Decay)
|
||||
|
||||
```
|
||||
新舊卡片視覺差異:
|
||||
|
||||
🔴 剛發生 (< 5分鐘) → 紅色脈動動畫 + 聲音提示
|
||||
🟠 較新 (5-30分鐘) → 橘色漸層 + 輕微動畫
|
||||
🟡 等待中 (30分-2小時) → 黃色靜態
|
||||
⚪ 陳舊 (> 2小時) → 灰色 + 淡化 50%
|
||||
```
|
||||
|
||||
```tsx
|
||||
// 時間衰減樣式
|
||||
const getCardStyle = (createdAt: Date) => {
|
||||
const ageMinutes = (Date.now() - createdAt.getTime()) / 60000;
|
||||
|
||||
if (ageMinutes < 5) return {
|
||||
borderColor: 'red',
|
||||
animation: 'pulse 1s infinite',
|
||||
playSound: true,
|
||||
};
|
||||
if (ageMinutes < 30) return {
|
||||
borderColor: 'orange',
|
||||
animation: 'subtle-glow 2s infinite',
|
||||
};
|
||||
if (ageMinutes < 120) return {
|
||||
borderColor: 'yellow',
|
||||
opacity: 0.9,
|
||||
};
|
||||
return {
|
||||
borderColor: 'gray',
|
||||
opacity: 0.5,
|
||||
filter: 'grayscale(50%)',
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
### Layer 2: 智慧分組 (Smart Grouping)
|
||||
|
||||
```
|
||||
優化後佈局:
|
||||
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ 🚨 需要立即處理 (2) [全部處理] │
|
||||
│ ┌────────────────────────────────────────────────┐ │
|
||||
│ │ 🔴 postgres-primary-0 P0 剛剛 │ │
|
||||
│ │ 🔴 awoooi-worker P0 3分鐘前 │ │
|
||||
│ └────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ⏳ 等待處理 (3) [批量關閉 ▼] │
|
||||
│ ┌────────────────────────────────────────────────┐ │
|
||||
│ │ 🟡 harbor-core P0 45分鐘前 [Y] [n] │ │
|
||||
│ │ 🟡 postgres-native P0 1小時前 [Y] [n] │ │
|
||||
│ │ 🟡 health-check-test P2 2小時前 [Y] [n] │ │
|
||||
│ └────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ 📦 陳舊事件 (1) [自動關閉倒數] │
|
||||
│ ┌────────────────────────────────────────────────┐ │
|
||||
│ │ ⚪ postgres-primary-0 P0 3小時前 將在1h自動關 │ │
|
||||
│ └────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Layer 3: 升級機制 (Escalation)
|
||||
|
||||
```
|
||||
時間軸自動升級:
|
||||
|
||||
0-15分鐘: Dashboard 顯示
|
||||
15-30分鐘: Telegram 通知 (首次)
|
||||
30-60分鐘: Telegram 再次提醒 + @mention 負責人
|
||||
1-2小時: 電話通知 (P0/P1)
|
||||
2小時+: 自動降級為 P3 或自動關閉 (視規則)
|
||||
```
|
||||
|
||||
```yaml
|
||||
# 升級規則配置
|
||||
escalation_rules:
|
||||
P0:
|
||||
- after: 15m
|
||||
action: telegram_notify
|
||||
message: "🚨 P0 事件等待處理: {incident_id}"
|
||||
- after: 30m
|
||||
action: telegram_mention
|
||||
mention: "@oncall"
|
||||
- after: 1h
|
||||
action: phone_call
|
||||
to: oncall_phone
|
||||
- after: 4h
|
||||
action: auto_close
|
||||
reason: "超過 4 小時無人處理,自動關閉"
|
||||
|
||||
P1:
|
||||
- after: 30m
|
||||
action: telegram_notify
|
||||
- after: 2h
|
||||
action: auto_downgrade
|
||||
to: P3
|
||||
|
||||
P2:
|
||||
- after: 2h
|
||||
action: auto_close
|
||||
reason: "低優先級超時自動關閉"
|
||||
```
|
||||
|
||||
### Layer 4: 智慧合併 (Smart Merge)
|
||||
|
||||
```
|
||||
同類事件合併:
|
||||
|
||||
Before:
|
||||
├── postgres-primary-0 崩潰 (09:44)
|
||||
├── postgres-primary-0 重啟 (09:57)
|
||||
├── postgres-primary-0 OOM (10:15)
|
||||
|
||||
After:
|
||||
├── 📦 postgres-primary-0 問題群組 (3 個事件)
|
||||
│ └── 最近: OOM (10:15)
|
||||
│ └── [展開詳情]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 實施計畫
|
||||
|
||||
### Phase 1: 視覺優化 (Week 1)
|
||||
|
||||
```tsx
|
||||
// apps/web/src/components/incident/IncidentCard.tsx
|
||||
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { zhTW } from 'date-fns/locale';
|
||||
|
||||
interface IncidentCardProps {
|
||||
incident: Incident;
|
||||
}
|
||||
|
||||
export const IncidentCard = ({ incident }: IncidentCardProps) => {
|
||||
const ageMinutes = getAgeInMinutes(incident.created_at);
|
||||
const urgencyLevel = getUrgencyLevel(ageMinutes, incident.priority);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className={cn(
|
||||
"incident-card",
|
||||
urgencyLevel === 'critical' && "animate-pulse border-red-500",
|
||||
urgencyLevel === 'urgent' && "border-orange-400",
|
||||
urgencyLevel === 'waiting' && "border-yellow-300 opacity-90",
|
||||
urgencyLevel === 'stale' && "border-gray-300 opacity-50 grayscale-50"
|
||||
)}
|
||||
initial={{ scale: 0.95, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
>
|
||||
{/* 時間徽章 */}
|
||||
<div className="absolute top-2 right-2">
|
||||
<Badge variant={urgencyLevel}>
|
||||
{formatDistanceToNow(incident.created_at, {
|
||||
addSuffix: true,
|
||||
locale: zhTW
|
||||
})}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* 自動關閉倒數 (陳舊事件) */}
|
||||
{urgencyLevel === 'stale' && (
|
||||
<div className="text-xs text-gray-400 mt-2">
|
||||
⏱️ 將在 {getAutoCloseCountdown(incident)} 自動關閉
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ... 其他內容 */}
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
### Phase 2: 分組佈局 (Week 2)
|
||||
|
||||
```tsx
|
||||
// apps/web/src/components/incident/IncidentDashboard.tsx
|
||||
|
||||
export const IncidentDashboard = () => {
|
||||
const { incidents } = useIncidents();
|
||||
|
||||
const grouped = useMemo(() => ({
|
||||
critical: incidents.filter(i => getAgeMinutes(i) < 15),
|
||||
waiting: incidents.filter(i => getAgeMinutes(i) >= 15 && getAgeMinutes(i) < 120),
|
||||
stale: incidents.filter(i => getAgeMinutes(i) >= 120),
|
||||
}), [incidents]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 緊急區 - 紅色背景 */}
|
||||
{grouped.critical.length > 0 && (
|
||||
<Section
|
||||
title="🚨 需要立即處理"
|
||||
count={grouped.critical.length}
|
||||
variant="critical"
|
||||
action={<Button>全部處理</Button>}
|
||||
>
|
||||
{grouped.critical.map(i => <IncidentCard key={i.id} incident={i} />)}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* 等待區 - 黃色背景 */}
|
||||
{grouped.waiting.length > 0 && (
|
||||
<Section
|
||||
title="⏳ 等待處理"
|
||||
count={grouped.waiting.length}
|
||||
variant="waiting"
|
||||
collapsible
|
||||
>
|
||||
{grouped.waiting.map(i => <IncidentCard key={i.id} incident={i} />)}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* 陳舊區 - 可折疊 */}
|
||||
{grouped.stale.length > 0 && (
|
||||
<Section
|
||||
title="📦 陳舊事件"
|
||||
count={grouped.stale.length}
|
||||
variant="stale"
|
||||
collapsible
|
||||
defaultCollapsed
|
||||
>
|
||||
{grouped.stale.map(i => <IncidentCard key={i.id} incident={i} />)}
|
||||
</Section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
### Phase 3: 升級機制 (Week 3)
|
||||
|
||||
```python
|
||||
# apps/api/src/services/escalation_service.py
|
||||
|
||||
class EscalationService:
|
||||
"""事件升級服務"""
|
||||
|
||||
RULES = {
|
||||
'P0': [
|
||||
{'after_minutes': 15, 'action': 'telegram_notify'},
|
||||
{'after_minutes': 30, 'action': 'telegram_mention', 'mention': '@oncall'},
|
||||
{'after_minutes': 60, 'action': 'phone_call'},
|
||||
{'after_minutes': 240, 'action': 'auto_close'},
|
||||
],
|
||||
'P1': [
|
||||
{'after_minutes': 30, 'action': 'telegram_notify'},
|
||||
{'after_minutes': 120, 'action': 'auto_downgrade', 'to': 'P3'},
|
||||
],
|
||||
'P2': [
|
||||
{'after_minutes': 120, 'action': 'auto_close'},
|
||||
],
|
||||
}
|
||||
|
||||
async def check_escalations(self):
|
||||
"""定期檢查需要升級的事件"""
|
||||
incidents = await self.get_open_incidents()
|
||||
|
||||
for incident in incidents:
|
||||
age_minutes = self.get_age_minutes(incident)
|
||||
rules = self.RULES.get(incident.priority, [])
|
||||
|
||||
for rule in rules:
|
||||
if age_minutes >= rule['after_minutes']:
|
||||
if not await self.is_escalation_sent(incident.id, rule):
|
||||
await self.execute_escalation(incident, rule)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 預期效果
|
||||
|
||||
| 指標 | 現況 | 優化後 |
|
||||
|------|------|--------|
|
||||
| 平均處理時間 | > 2 小時 | < 30 分鐘 |
|
||||
| 忽略率 | ~80% | < 20% |
|
||||
| 用戶注意力 | 分散 | 集中在關鍵事件 |
|
||||
| 告警疲勞 | 嚴重 | 可控 |
|
||||
|
||||
---
|
||||
|
||||
## 驗收標準
|
||||
|
||||
- [ ] 新事件有脈動動畫吸引注意
|
||||
- [ ] 陳舊事件自動淡化
|
||||
- [ ] 事件按時間分組顯示
|
||||
- [ ] P0 事件 15 分鐘未處理自動 Telegram 通知
|
||||
- [ ] 超過 4 小時自動關閉 (可配置)
|
||||
- [ ] 同類事件智慧合併
|
||||
Reference in New Issue
Block a user