feat(web): #126 Frontend Replay UI 整合
All checks were successful
E2E Health Check / e2e-health (push) Successful in 18s

- 新增 useUXAudit hook (5 分鐘自動刷新)
- 新增 UXAuditCard 組件 (健康度 + Replay 連結)
- 整合到錯誤追蹤頁面
- i18n: zh-TW + en 翻譯

功能:
- UX 健康度評分 (good/moderate/poor)
- 有錯誤的 Replay 連結
- 憤怒點擊/死亡點擊統計
- Replay Dashboard 快捷連結

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
OG T
2026-03-31 16:04:44 +08:00
parent d03668669b
commit 2f02f1523a
9 changed files with 530 additions and 3 deletions

View File

@@ -103,11 +103,14 @@ jobs:
# 2026-03-31 ogt: 移除中間通知
# 2026-03-31 ogt: P0-1 Secrets 自動注入 (ADR-035 強制)
# 2026-03-31 ogt: 加入 AI API Keys (修復 mock_fallback 問題)
- name: Inject K8s Secrets
env:
SSH_PRIVATE_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
TG_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
TG_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
NVIDIA_API_KEY: ${{ secrets.NVIDIA_API_KEY }}
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
run: |
mkdir -p ~/.ssh
echo "$SSH_PRIVATE_KEY" > ~/.ssh/deploy_key
@@ -120,9 +123,28 @@ jobs:
sudo kubectl patch secret awoooi-secrets -n awoooi-prod --type='json' -p='[
{"op":"replace","path":"/data/OPENCLAW_TG_BOT_TOKEN","value":"'$(echo -n "${TG_BOT_TOKEN}" | base64)'"},
{"op":"replace","path":"/data/OPENCLAW_TG_CHAT_ID","value":"'$(echo -n "${TG_CHAT_ID}" | base64)'"}
]' || echo "⚠️ Secrets patch 跳過 (可能尚未建立)"
]' || echo "⚠️ Telegram Secrets patch 跳過"
echo "✅ Secrets 注入完成"
# 2026-03-31 ogt: 注入 AI API Keys (修復 NVIDIA/Gemini mock_fallback)
# NVIDIA NIM (免費 tier)
if [ -n "${NVIDIA_API_KEY}" ] && [ "${NVIDIA_API_KEY}" != "" ]; then
sudo kubectl patch secret awoooi-secrets -n awoooi-prod --type='json' -p='[
{"op":"replace","path":"/data/NVIDIA_API_KEY","value":"'$(echo -n "${NVIDIA_API_KEY}" | base64)'"}
]' && echo "✅ NVIDIA_API_KEY 已注入" || echo "⚠️ NVIDIA_API_KEY patch 失敗"
else
echo "⚠️ NVIDIA_API_KEY 未設定,跳過"
fi
# Gemini (備援)
if [ -n "${GEMINI_API_KEY}" ] && [ "${GEMINI_API_KEY}" != "" ]; then
sudo kubectl patch secret awoooi-secrets -n awoooi-prod --type='json' -p='[
{"op":"replace","path":"/data/GEMINI_API_KEY","value":"'$(echo -n "${GEMINI_API_KEY}" | base64)'"}
]' && echo "✅ GEMINI_API_KEY 已注入" || echo "⚠️ GEMINI_API_KEY patch 失敗"
else
echo "⚠️ GEMINI_API_KEY 未設定,跳過"
fi
echo "✅ 所有 Secrets 注入完成"
SECRETS
- name: Deploy to K8s

View File

@@ -518,6 +518,24 @@
"minutes": "{count}m ago",
"hours": "{count}h ago",
"days": "{count}d ago"
},
"uxAudit": {
"title": "UX Audit",
"noData": "No Session Replay data",
"replaysWithErrors": "Replays with Errors",
"uiErrors": "UI Errors",
"rageClicks": "Rage Clicks",
"deadClicks": "Dead Clicks",
"recentReplays": "Recent Replays",
"recentUIErrors": "Recent UI Errors",
"replayWithErrors": "Replay with {count} errors",
"occurrences": "{count} occurrences",
"viewDashboard": "View Replay Dashboard",
"health": {
"good": "Good",
"moderate": "Moderate",
"poor": "Poor"
}
}
}
}

View File

@@ -518,6 +518,24 @@
"minutes": "{count} 分鐘前",
"hours": "{count} 小時前",
"days": "{count} 天前"
},
"uxAudit": {
"title": "UX 審計",
"noData": "無 Session Replay 數據",
"replaysWithErrors": "有錯誤的 Replay",
"uiErrors": "UI 錯誤",
"rageClicks": "憤怒點擊",
"deadClicks": "死亡點擊",
"recentReplays": "近期 Replay",
"recentUIErrors": "近期 UI 錯誤",
"replayWithErrors": "Replay 包含 {count} 個錯誤",
"occurrences": "{count} 次發生",
"viewDashboard": "查看 Replay Dashboard",
"health": {
"good": "良好",
"moderate": "中等",
"poor": "不佳"
}
}
}
}

View File

@@ -24,10 +24,12 @@
import { useTranslations } from 'next-intl'
import { AppLayout } from '@/components/layout'
import { useErrors } from '@/hooks/useErrors'
import { useUXAudit } from '@/hooks/useUXAudit'
import {
ErrorOverviewCard,
RecentIssuesList,
ErrorTrendChart,
UXAuditCard,
} from '@/components/errors'
import { Bug, RefreshCw } from 'lucide-react'
import type { SentryIssue } from '@/lib/api-client'
@@ -53,6 +55,13 @@ export default function ErrorsPage({
setPeriod,
} = useErrors()
// #126: UX Audit / Session Replay 數據
const {
data: uxAuditData,
loading: uxAuditLoading,
error: uxAuditError,
} = useUXAudit()
const handleIssueClick = (issue: SentryIssue) => {
// Open in new tab if permalink available
if (issue.permalink) {
@@ -124,6 +133,13 @@ export default function ErrorsPage({
activePeriod={activePeriod}
onPeriodChange={setPeriod}
/>
{/* #126: UX Audit / Session Replay */}
<UXAuditCard
data={uxAuditData}
loading={uxAuditLoading}
error={uxAuditError}
/>
</div>
{/* Right Column - Issues List */}

View File

@@ -7,11 +7,14 @@
* - ErrorOverviewCard (#41)
* - RecentIssuesList (#42)
* - ErrorTrendChart (#43)
* - UXAuditCard (#126) - Session Replay 整合
*
* 建立: 2026-03-26 (台北時區)
* 建立者: Claude Code (#41-44 Error UI)
* 更新: 2026-03-31 Claude Code (#126 Replay UI)
*/
export { ErrorOverviewCard } from './error-overview-card'
export { RecentIssuesList } from './recent-issues-list'
export { ErrorTrendChart } from './error-trend-chart'
export { UXAuditCard } from './ux-audit-card'

View File

@@ -0,0 +1,293 @@
'use client'
/**
* UXAuditCard - #126 Session Replay / UX Audit 卡片
* ==================================================
* Phase 19: Sentry Session Replay 前端整合
*
* 功能:
* - 顯示 UX 健康度評分
* - 列出有錯誤的 Replay 連結
* - 顯示憤怒/死亡點擊統計
*
* Nothing.tech 視覺規範:
* - 純白底色 (bg-white)
* - 極細淺灰邊框 (border border-gray-200)
* - 無圓角或微圓角 (rounded-sm)
* - 嚴禁陰影 (shadow-none)
*
* 建立: 2026-03-31 (台北時區)
* 建立者: Claude Code (#126 Replay UI)
*/
import { useTranslations } from 'next-intl'
import { cn } from '@/lib/utils'
import {
Video,
MousePointerClick,
AlertTriangle,
ExternalLink,
Activity,
Zap,
} from 'lucide-react'
import type { UXAuditResponse, UXAuditDetail } from '@/lib/api-client'
// =============================================================================
// Component Props
// =============================================================================
interface UXAuditCardProps {
data: UXAuditResponse | null
loading?: boolean
error?: string | null
className?: string
}
// =============================================================================
// Health Score Config
// =============================================================================
const HEALTH_CONFIG = {
good: {
color: 'text-green-700',
bgColor: 'bg-green-50',
borderColor: 'border-green-200',
label: 'good',
},
moderate: {
color: 'text-amber-700',
bgColor: 'bg-amber-50',
borderColor: 'border-amber-200',
label: 'moderate',
},
poor: {
color: 'text-red-700',
bgColor: 'bg-red-50',
borderColor: 'border-red-200',
label: 'poor',
},
} as const
// =============================================================================
// Replay Item Component
// =============================================================================
function ReplayItem({ detail }: { detail: UXAuditDetail }) {
const t = useTranslations('errors.uxAudit')
if (detail.type === 'replay_with_errors') {
return (
<a
href={detail.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-3 p-2 hover:bg-gray-50 rounded-sm transition-colors group"
>
<div className="flex-shrink-0">
<Video className="h-4 w-4 text-purple-600" />
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-gray-900 truncate">
{t('replayWithErrors', { count: detail.error_count || 0 })}
</div>
{detail.urls && detail.urls.length > 0 && (
<div className="text-xs text-gray-500 truncate">
{detail.urls[0]}
</div>
)}
</div>
<ExternalLink className="h-3.5 w-3.5 text-gray-400 group-hover:text-purple-600 transition-colors" />
</a>
)
}
// UI Error type
return (
<a
href={detail.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-3 p-2 hover:bg-gray-50 rounded-sm transition-colors group"
>
<div className="flex-shrink-0">
<AlertTriangle className="h-4 w-4 text-orange-600" />
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-gray-900 truncate">
{detail.title || 'UI Error'}
</div>
<div className="text-xs text-gray-500">
{t('occurrences', { count: detail.count || 0 })}
</div>
</div>
<ExternalLink className="h-3.5 w-3.5 text-gray-400 group-hover:text-orange-600 transition-colors" />
</a>
)
}
// =============================================================================
// Main Component
// =============================================================================
export function UXAuditCard({
data,
loading = false,
error = null,
className,
}: UXAuditCardProps) {
const t = useTranslations('errors.uxAudit')
// Loading state
if (loading) {
return (
<div className={cn('bg-white border border-gray-200 rounded-sm', className)}>
<div className="p-3 border-b border-gray-100">
<div className="h-4 bg-gray-200 rounded w-1/3 animate-pulse" />
</div>
<div className="p-4 space-y-3 animate-pulse">
<div className="h-8 bg-gray-100 rounded w-1/4" />
<div className="h-4 bg-gray-100 rounded w-full" />
<div className="h-4 bg-gray-100 rounded w-3/4" />
</div>
</div>
)
}
// Error state
if (error) {
return (
<div className={cn('bg-white border border-red-200 rounded-sm p-4', className)}>
<div className="flex items-center gap-2 text-red-600">
<AlertTriangle className="h-4 w-4" />
<span className="text-sm">{error}</span>
</div>
</div>
)
}
// No data state
if (!data) {
return (
<div className={cn('bg-white border border-gray-200 rounded-sm p-6', className)}>
<div className="text-center text-gray-500">
<Video className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p className="text-sm">{t('noData')}</p>
</div>
</div>
)
}
const healthConfig = HEALTH_CONFIG[data.health_score] || HEALTH_CONFIG.moderate
const replayDetails = data.details.filter((d) => d.type === 'replay_with_errors')
const errorDetails = data.details.filter((d) => d.type === 'ui_error')
return (
<div className={cn('bg-white border border-gray-200 rounded-sm', className)}>
{/* Header */}
<div className="px-3 py-2 border-b border-gray-100 flex items-center justify-between">
<div className="flex items-center gap-2">
<Video className="h-4 w-4 text-purple-600" />
<h3 className="text-sm font-medium text-gray-900">{t('title')}</h3>
</div>
<span
className={cn(
'px-2 py-0.5 text-xs font-medium rounded',
healthConfig.bgColor,
healthConfig.color,
)}
>
{t(`health.${healthConfig.label}`)}
</span>
</div>
{/* Stats Grid */}
<div className="grid grid-cols-2 gap-px bg-gray-100 border-b border-gray-100">
{/* Replays with Errors */}
<div className="bg-white p-3">
<div className="flex items-center gap-2">
<Video className="h-4 w-4 text-purple-500" />
<span className="text-xs text-gray-500">{t('replaysWithErrors')}</span>
</div>
<div className="mt-1 text-xl font-semibold text-gray-900">
{data.replays_with_errors}
</div>
</div>
{/* UI Errors */}
<div className="bg-white p-3">
<div className="flex items-center gap-2">
<AlertTriangle className="h-4 w-4 text-orange-500" />
<span className="text-xs text-gray-500">{t('uiErrors')}</span>
</div>
<div className="mt-1 text-xl font-semibold text-gray-900">
{data.ui_errors}
</div>
</div>
{/* Rage Clicks */}
<div className="bg-white p-3">
<div className="flex items-center gap-2">
<Zap className="h-4 w-4 text-red-500" />
<span className="text-xs text-gray-500">{t('rageClicks')}</span>
</div>
<div className="mt-1 text-xl font-semibold text-gray-900">
{data.rage_clicks}
</div>
</div>
{/* Dead Clicks */}
<div className="bg-white p-3">
<div className="flex items-center gap-2">
<MousePointerClick className="h-4 w-4 text-gray-400" />
<span className="text-xs text-gray-500">{t('deadClicks')}</span>
</div>
<div className="mt-1 text-xl font-semibold text-gray-900">
{data.dead_clicks}
</div>
</div>
</div>
{/* Replay List */}
{replayDetails.length > 0 && (
<div className="border-b border-gray-100">
<div className="px-3 py-1.5 bg-gray-50 text-xs font-medium text-gray-600">
{t('recentReplays')}
</div>
<div className="divide-y divide-gray-100">
{replayDetails.slice(0, 5).map((detail, idx) => (
<ReplayItem key={detail.replay_id || idx} detail={detail} />
))}
</div>
</div>
)}
{/* UI Errors List */}
{errorDetails.length > 0 && (
<div className="border-b border-gray-100">
<div className="px-3 py-1.5 bg-gray-50 text-xs font-medium text-gray-600">
{t('recentUIErrors')}
</div>
<div className="divide-y divide-gray-100">
{errorDetails.slice(0, 3).map((detail, idx) => (
<ReplayItem key={detail.issue_id || idx} detail={detail} />
))}
</div>
</div>
)}
{/* Footer: Dashboard Link */}
<div className="px-3 py-2">
<a
href={data.replay_dashboard_url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center gap-1.5 text-xs text-purple-600 hover:text-purple-700 transition-colors"
>
<Activity className="h-3.5 w-3.5" />
{t('viewDashboard')}
<ExternalLink className="h-3 w-3" />
</a>
</div>
</div>
)
}

View File

@@ -0,0 +1,81 @@
/**
* useUXAudit - Sentry Session Replay / UX Audit Hook
* ===================================================
* Phase 19: #126 Frontend Replay UI Integration
*
* 提供 UX 審計數據的 React Hook:
* - Session Replay 統計
* - 憤怒點擊 / 死亡點擊
* - 有錯誤的 Replay 連結
*
* 建立: 2026-03-31 (台北時區)
* 建立者: Claude Code (#126 Replay UI)
*/
import { useState, useEffect, useCallback } from 'react'
import { apiClient, type UXAuditResponse } from '@/lib/api-client'
// =============================================================================
// Types
// =============================================================================
interface UseUXAuditState {
data: UXAuditResponse | null
loading: boolean
error: string | null
}
interface UseUXAuditReturn extends UseUXAuditState {
refetch: () => Promise<void>
}
// =============================================================================
// Hook
// =============================================================================
export function useUXAudit(): UseUXAuditReturn {
const [state, setState] = useState<UseUXAuditState>({
data: null,
loading: true,
error: null,
})
const fetchData = useCallback(async () => {
setState((prev) => ({ ...prev, loading: true, error: null }))
try {
const result = await apiClient.getUXAudit()
setState({
data: result,
loading: false,
error: null,
})
} catch (err) {
setState((prev) => ({
...prev,
loading: false,
error: err instanceof Error ? err.message : 'Failed to fetch UX audit data',
}))
}
}, [])
const refetch = useCallback(() => {
return fetchData()
}, [fetchData])
// Initial fetch
useEffect(() => {
fetchData()
}, [fetchData])
// Auto-refresh every 5 minutes (Replay data changes less frequently)
useEffect(() => {
const interval = setInterval(fetchData, 300000)
return () => clearInterval(interval)
}, [fetchData])
return {
...state,
refetch,
}
}

View File

@@ -216,6 +216,16 @@ export const apiClient = {
})
return handleResponse<ErrorAnalysisResponse>(res)
},
// =========================================================================
// Phase 19: UX Audit / Session Replay (#126)
// 2026-03-31 Claude Code - Frontend Replay UI Integration
// =========================================================================
async getUXAudit() {
const res = await fetch(`${API_BASE_URL}/errors/ux-audit`)
return handleResponse<UXAuditResponse>(res)
},
}
// =========================================================================
@@ -383,3 +393,29 @@ export interface ErrorAnalysisResponse {
sentry_url: string
message?: string
}
// =========================================================================
// Phase 19: UX Audit / Session Replay Types (#126)
// 2026-03-31 Claude Code - Frontend Replay UI Integration
// =========================================================================
export interface UXAuditDetail {
type: 'replay_with_errors' | 'ui_error'
replay_id?: string
issue_id?: string
url: string
error_count?: number
title?: string
count?: number
urls?: string[]
}
export interface UXAuditResponse {
replays_with_errors: number
rage_clicks: number
dead_clicks: number
ui_errors: number
health_score: 'good' | 'moderate' | 'poor'
details: UXAuditDetail[]
replay_dashboard_url: string
}

View File

@@ -5,20 +5,25 @@
---
## 📍 當前狀態 (2026-03-31 12:45 台北)
## 📍 當前狀態 (2026-03-31 15:30 台北)
| 項目 | 狀態 |
|------|------|
| **#126 Frontend Replay UI** | ✅ **完成** (UXAuditCard + useUXAudit hook) |
| **K0 基礎穩定化** | ✅ **低風險完成** (K0.1/3/4/6/7) |
| **Phase 22.0 CI pytest** | ✅ **已完成** (CD Pipeline 加入測試步驟) |
| **Phase 22.4 命名清理** | ✅ **已完成** (ClawBot 舊檔案移除) |
| **P0-1 CD Secrets 注入** | ✅ **已完成** (ADR-035 強制) |
| **P0-2 NVIDIA 模型修正** | ✅ **已完成** (nemotron-mini-4b) |
| **P0-3 OpenClaw 架構審查** | ✅ **已完成** (釐清 NVIDIA 400 導致 Gemini Fallback 備援及 Rule Match 導致 0% 信心度,已修補 Prompt Context 溢出問題) |
| **Phase 18 失敗自動修復** | ✅ **OUTSTANDING** (95/100 + P0 修復 `138a56a`) |
| **Phase 21 定期報告** | ✅ **全部完成!** |
| **Phase 21.1 Daily E2E** | ✅ **已完成** (每日 00:00 台北) |
| **Phase 21.2 K3s Report** | ✅ **已完成** (每日 09:00 台北) |
| **Phase 21.3 Weekly Report** | ✅ **已完成** (每週五 18:00 台北) |
| **Telegram 雙向對話** | ✅ **vfix13-15** (ChatManager + 路由修復) |
| **Nemo-4B 仲裁穩定化** | ✅ **vfix16** (精簡 Prompt + 魯棒解析) |
| **Telegram 會話主權** | ✅ **Webhook Kicker** (終止 188 競爭) |
| **#15 SSE + 樂觀更新** | ✅ **完成** (`8c8664c`) |
| **#16 DOM Bypass** | ✅ **完成** (`0b87018`) |
| **#17 i18n Hydration** | ✅ **完成** (`f25e94e`) |
@@ -68,6 +73,41 @@
| **Wave 2 Worker HPA** | ✅ **已部署** (min:1 max:3, CPU 70%) |
| **Wave C-D 監控** | ✅ **全部完成** (generate + discover + coverage_report) |
## 🛰️ Telegram 雙向對話與 AI 仲裁極限修復 (2026-03-31 16:00 台北)
**完成內容**:
- **vfix13**: 實作 `ChatManager``TelegramGateway` 監聽文字訊息
- **vfix14**: 實作 **侵略性 Polling (2s)** 搶佔 .188 實例會話
- **vfix15**: 修復 `send_notification` 定向路由 (chat_id) 與 LLM 結果解包錯誤
- **vfix16**: 實作 `NEMOTRON_SYSTEM_PROMPT``OpenClaw` 魯棒解析引擎 (防 Pydantic 崩潰)
- **Webhook Kicker**: 成功清除 188 競爭會話K3s Pod 獲取獨佔主導權
**效益**:
- 統帥現在能直接在 Telegram 與 Nemo-4B 進行對話。
- OpenClaw 告警仲裁不再因 JSON 欄位缺失而退化至 0% 信心度。
- 徹底解決了長期困擾的「雙腦衝突」(Split Brain) Polling 問題。
---
## 🎬 #126 Frontend Replay UI 整合 (2026-03-31 15:30 台北)
**完成內容**:
- `apps/web/src/lib/api-client.ts` - 新增 `getUXAudit()` 方法 + `UXAuditResponse` 類型
- `apps/web/src/hooks/useUXAudit.ts` - 新建 Session Replay 數據 Hook
- `apps/web/src/components/errors/ux-audit-card.tsx` - 新建 UX Audit 卡片組件
- `apps/web/src/app/[locale]/errors/page.tsx` - 整合到錯誤追蹤頁面
- `apps/web/messages/zh-TW.json` + `en.json` - 新增 `uxAudit` i18n 翻譯
**功能**:
- 顯示 UX 健康度評分 (good/moderate/poor)
- 顯示有錯誤的 Replay 連結 (點擊跳轉 Sentry Replay)
- 統計憤怒點擊 / 死亡點擊 / UI 錯誤
- 每 5 分鐘自動刷新
**後端 API**: `/api/v1/errors/ux-audit` (Phase 19 已實作)
---
## 🔧 Phase 18 失敗自動修復閉環 (2026-03-31 12:00 台北)
**統帥批准**: 2026-03-31