From fb66ecd2a006fc59f0d64599e8e01964bbd737a4 Mon Sep 17 00:00:00 2001 From: OG T Date: Thu, 9 Apr 2026 11:06:57 +0800 Subject: [PATCH] =?UTF-8?q?refactor(web):=20Panel=20=E6=8A=BD=E5=8F=96?= =?UTF-8?q?=E5=85=A8=E9=9D=A2=E5=AE=8C=E6=88=90=20=E2=80=94=20=E4=B8=89?= =?UTF-8?q?=E5=80=8B=E6=95=B4=E5=90=88=E9=A0=81=E9=9D=A2=E8=A7=A3=E6=B1=BA?= =?UTF-8?q?=E9=9B=99=E9=87=8D=20AppLayout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /observability: AppsPanel + ServicesPanel (共 5/5 Tab 完成) /automation: AutoRepairPanel + NeuralCommandPanel + DriftPanel (3/3) /operations: DeploymentsPanel + TicketsPanel + CostPanel + ActionLogsPanel + BillingPanel (5/5) 原始頁面全部精簡為 AppLayout + Panel,零雙重 Layout。 Co-Authored-By: Claude Sonnet 4.6 --- .../web/src/app/[locale]/action-logs/page.tsx | 546 +----------------- apps/web/src/app/[locale]/apps/page.tsx | 95 +-- .../web/src/app/[locale]/auto-repair/page.tsx | 451 +-------------- apps/web/src/app/[locale]/billing/page.tsx | 105 +--- apps/web/src/app/[locale]/cost/page.tsx | 87 +-- .../web/src/app/[locale]/deployments/page.tsx | 105 +--- apps/web/src/app/[locale]/drift/page.tsx | 315 +--------- .../src/app/[locale]/neural-command/page.tsx | 200 +------ apps/web/src/app/[locale]/services/page.tsx | 112 +--- apps/web/src/app/[locale]/tickets/page.tsx | 112 +--- .../src/components/panels/AutoRepairPanel.tsx | 2 +- apps/web/src/components/panels/index.ts | 12 +- 12 files changed, 53 insertions(+), 2089 deletions(-) diff --git a/apps/web/src/app/[locale]/action-logs/page.tsx b/apps/web/src/app/[locale]/action-logs/page.tsx index ea7fca0b..61c366f7 100644 --- a/apps/web/src/app/[locale]/action-logs/page.tsx +++ b/apps/web/src/app/[locale]/action-logs/page.tsx @@ -1,551 +1,17 @@ 'use client' /** - * Action Log Page - K8s 操作稽核日誌 - * ================================== - * Phase 4: 行動日誌介面 - * - * Features: - * - 真實 API 數據 (GET /api/v1/audit-logs) - * - 分頁顯示 - * - 統計概覽 - * - 操作類型、狀態篩選 - * - 執行時間、耗時、簽核者資訊 - * - * i18n: 100% next-intl,零硬編碼 - * - * 版本: v1.1 - * 變更: 2026-03-31 (台北時區) - Claude Code - * - #19 P2: 新增 AbortController 防止 unmount 時記憶體洩漏 + * Action Log Page — 路由入口 + * @updated 2026-04-09 Claude Code - 精簡為 AppLayout + ActionLogsPanel */ -import { useState, useEffect, useCallback, useRef } from 'react' -import { useTranslations } from 'next-intl' import { AppLayout } from '@/components/layout' -import { DataPincerPanel } from '@/components/cyber' -import { cn } from '@/lib/utils' -import { - FileText, - CheckCircle2, - XCircle, - Clock, - Activity, - ChevronLeft, - ChevronRight, - RefreshCw, - AlertCircle, - Zap, - TrendingUp, -} from 'lucide-react' +import { ActionLogsPanel } from '@/components/panels/ActionLogsPanel' -// ============================================================================= -// Types -// ============================================================================= - -interface AuditLog { - id: string - approval_id: string - operation_type: string - target_resource: string - namespace: string - success: boolean - error_message: string | null - k8s_response: Record | null - executed_by: string - execution_duration_ms: number | null - dry_run_passed: boolean - dry_run_message: string | null - created_at: string -} - -interface AuditLogListResponse { - count: number - logs: AuditLog[] - page: number - page_size: number - total_pages: number -} - -interface AuditStats { - total_executions: number - success_count: number - failure_count: number - success_rate: number - avg_duration_ms: number | null - by_operation_type: Record - by_namespace: Record - last_24h_count: number -} - -// ============================================================================= -// API Helper -// ============================================================================= - -const getApiBaseUrl = (): string => { - if (typeof window === 'undefined') return '' - // 統帥鐵律: 禁止任何 Fallback IP - const url = process.env.NEXT_PUBLIC_API_URL - if (!url) { - // eslint-disable-next-line no-console - console.error('[AWOOOI ERROR] Missing NEXT_PUBLIC_API_URL') - return '' - } - return url -} - -// ============================================================================= -// Stat Card Component -// ============================================================================= - -function StatCard({ - icon: Icon, - label, - value, - subValue, - variant = 'default', -}: { - icon: typeof Activity - label: string - value: string | number - subValue?: string - variant?: 'default' | 'success' | 'warning' -}) { +export default function ActionLogPage({ params }: { params: { locale: string } }) { return ( -
-
-
- -
-
-

- {label} -

-

{value}

- {subValue && ( -

- {subValue} -

- )} -
-
-
- ) -} - -// ============================================================================= -// Main Component -// ============================================================================= - -export default function ActionLogPage({ - params, -}: { - params: { locale: string } -}) { - const t = useTranslations() - const locale = params.locale - - // State - const [logs, setLogs] = useState([]) - const [stats, setStats] = useState(null) - const [isLoading, setIsLoading] = useState(true) - const [error, setError] = useState(null) - const [page, setPage] = useState(1) - const [totalPages, setTotalPages] = useState(1) - const [totalCount, setTotalCount] = useState(0) - - // #19 P2: AbortController 防止 unmount 時記憶體洩漏 - const abortControllerRef = useRef(null) - - // ========================================================================== - // Fetch Audit Logs - // ========================================================================== - const fetchLogs = useCallback(async (pageNum: number) => { - const apiBaseUrl = getApiBaseUrl() - if (!apiBaseUrl) return - - // 取消前一次未完成的請求 - abortControllerRef.current?.abort() - const controller = new AbortController() - abortControllerRef.current = controller - - setIsLoading(true) - setError(null) - - try { - const response = await fetch( - `${apiBaseUrl}/api/v1/audit-logs?page=${pageNum}&page_size=10`, - { - headers: { 'Content-Type': 'application/json' }, - signal: controller.signal, - } - ) - - if (!response.ok) { - throw new Error(`API Error: ${response.status}`) - } - - const data: AuditLogListResponse = await response.json() - setLogs(data.logs) - setPage(data.page) - setTotalPages(data.total_pages) - setTotalCount(data.count) - } catch (err) { - // 忽略 AbortError (正常行為) - if (err instanceof Error && err.name === 'AbortError') { - return - } - const message = err instanceof Error ? err.message : 'Unknown error' - setError(message) - console.error('[ActionLog] Fetch error:', message) // eslint-disable-line no-console - } finally { - setIsLoading(false) - } - }, []) - - // ========================================================================== - // Fetch Stats - // ========================================================================== - const fetchStats = useCallback(async () => { - const apiBaseUrl = getApiBaseUrl() - if (!apiBaseUrl) return - - try { - const response = await fetch(`${apiBaseUrl}/api/v1/audit-logs/stats`, { - headers: { 'Content-Type': 'application/json' }, - signal: abortControllerRef.current?.signal, - }) - - if (response.ok) { - const data: AuditStats = await response.json() - setStats(data) - } - } catch (err) { - // 忽略 AbortError - if (err instanceof Error && err.name === 'AbortError') { - return - } - console.error('[ActionLog] Stats fetch error:', err) // eslint-disable-line no-console - } - }, []) - - // ========================================================================== - // Initial Fetch + Cleanup - // ========================================================================== - useEffect(() => { - fetchLogs(1) - fetchStats() - - // #19 P2: Cleanup - 組件 unmount 時取消所有請求 - return () => { - abortControllerRef.current?.abort() - } - }, [fetchLogs, fetchStats]) - - // ========================================================================== - // Pagination Handlers - // ========================================================================== - const handlePrevPage = () => { - if (page > 1) { - fetchLogs(page - 1) - } - } - - const handleNextPage = () => { - if (page < totalPages) { - fetchLogs(page + 1) - } - } - - // ========================================================================== - // Format Helpers - // ========================================================================== - const formatDate = (isoString: string) => { - try { - const date = new Date(isoString) - return date.toLocaleString(locale === 'zh-TW' ? 'zh-TW' : 'en-US', { - month: 'short', - day: 'numeric', - hour: '2-digit', - minute: '2-digit', - second: '2-digit', - }) - } catch { - return isoString - } - } - - const formatDuration = (ms: number | null) => { - if (ms === null) return '-' - if (ms < 1000) return `${ms}ms` - return `${(ms / 1000).toFixed(2)}s` - } - - // ========================================================================== - // Render - // ========================================================================== - return ( - - {/* Page Title */} -
-

- {t('actionLog.title')} -

-

- {t('actionLog.subtitle')} -

-
- - {/* Stats Overview */} - {stats && ( -
- - - - -
- )} - - {/* Main Content */} - - {/* Toolbar */} -
-
- {totalCount > 0 - ? `${totalCount} ${t('actionLog.columns.operation').toLowerCase()}s` - : ''} -
- -
- - {/* Error State */} - {error && ( -
- - - {t('actionLog.fetchError')}: {error} - -
- )} - - {/* Loading State */} - {isLoading && logs.length === 0 && ( -
- - - {t('actionLog.loading')} - -
- )} - - {/* Empty State */} - {!isLoading && logs.length === 0 && !error && ( -
- -

- {t('actionLog.noLogs')} -

-
- )} - - {/* Logs Table */} - {logs.length > 0 && ( -
- - - - - - - - - - - - - - {logs.map((log) => ( - - - - - - - - - - ))} - -
- {t('actionLog.columns.time')} - - {t('actionLog.columns.operation')} - - {t('actionLog.columns.target')} - - {t('actionLog.columns.namespace')} - - {t('actionLog.columns.status')} - - {t('actionLog.columns.duration')} - - {t('actionLog.columns.executor')} -
- {formatDate(log.created_at)} - - - {t(`actionLog.operations.${log.operation_type}` as never) || - log.operation_type} - - - {log.target_resource} - - {log.namespace} - - {log.success ? ( - - - - {t('actionLog.status.success')} - - - ) : ( - - - - {t('actionLog.status.failure')} - - - )} - - {formatDuration(log.execution_duration_ms)} - - {log.executed_by} -
-
- )} - - {/* Pagination */} - {totalPages > 1 && ( -
- - {t('actionLog.pagination.page', { - current: page, - total: totalPages, - })} - -
- - -
-
- )} -
- - {/* Footer */} -
-
-

- {t('footer.copyright')} -

-

- {t('footer.poweredBy')} v1.0.0 -

-
-
+ + ) } diff --git a/apps/web/src/app/[locale]/apps/page.tsx b/apps/web/src/app/[locale]/apps/page.tsx index 472854c5..a9520587 100644 --- a/apps/web/src/app/[locale]/apps/page.tsx +++ b/apps/web/src/app/[locale]/apps/page.tsx @@ -1,103 +1,18 @@ 'use client' /** - * 應用 Page — 真實主機服務狀態 - * @created 2026-04-01 ogt - 路由佔位 - * @updated 2026-04-03 Claude Code - 串接 /api/v1/dashboard 真實數據 + * 應用 Page — 路由入口 + * @created 2026-04-01 ogt + * @updated 2026-04-09 Claude Code - 精簡為 AppLayout + AppsPanel */ -import { useState, useEffect } from 'react' -import { useTranslations } from 'next-intl' import { AppLayout } from '@/components/layout' - -const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? '' - -interface HostService { - name: string - status: string - port: number | null - latency_ms: number | null -} - -interface Host { - ip: string - name: string - status: string - services: HostService[] -} - -const STATUS_COLOR: Record = { - up: '#22C55E', - healthy: '#22C55E', - down: '#cc2200', - degraded: '#F59E0B', - unreachable: '#87867f', -} +import { AppsPanel } from '@/components/panels/AppsPanel' export default function AppsPage({ params }: { params: { locale: string } }) { - const t = useTranslations('apps') - const [hosts, setHosts] = useState([]) - const [loading, setLoading] = useState(true) - const [error, setError] = useState(null) - - useEffect(() => { - fetch(`${API_BASE}/api/v1/dashboard`) - .then(r => r.json()) - .then(data => { setHosts(data.hosts ?? []); setLoading(false) }) - .catch(err => { setError(String(err)); setLoading(false) }) - }, []) - - const allServices = hosts.flatMap(h => - h.services.map(s => ({ ...s, hostName: h.name, hostIp: h.ip })) - ) - return ( -
-
-

{t('title')}

-

{t('subtitle')}

-
-
-
- - {t('title')} ({loading ? '...' : allServices.length}) -
- {loading ? ( -
{t('loading')}
- ) : error ? ( -
{t('error')}
- ) : allServices.length === 0 ? ( -
{t('noApps')}
- ) : ( - - - - {[t('service'), t('host'), t('port'), t('latency'), t('status')].map(col => ( - - ))} - - - - {allServices.map((s, i) => ( - - - - - - - - ))} - -
{col}
{s.name}{s.hostName} ({s.hostIp}){s.port ?? '—'}{s.latency_ms != null ? `${s.latency_ms.toFixed(0)}ms` : '—'} - - - {s.status} - -
- )} -
-
+
) } diff --git a/apps/web/src/app/[locale]/auto-repair/page.tsx b/apps/web/src/app/[locale]/auto-repair/page.tsx index 7f0e2d4b..e48d5137 100644 --- a/apps/web/src/app/[locale]/auto-repair/page.tsx +++ b/apps/web/src/app/[locale]/auto-repair/page.tsx @@ -1,460 +1,17 @@ 'use client' /** - * Auto Repair Page - 自動修復 - * ============================ - * 顯示自動修復統計 + Incident 評估 + 執行紀錄 - * 資料來源: - * GET /api/v1/auto-repair/stats - * GET /api/v1/auto-repair/evaluate/{incident_id} - * GET /api/v1/incidents (活躍 incident 清單) - * - * @updated 2026-04-01 ogt - 從佔位符升級為完整頁面 - * @updated 2026-04-02 Claude Code - i18n 合規修復 (零硬編碼) + * Auto Repair Page — 路由入口 + * @updated 2026-04-09 Claude Code - 精簡為 AppLayout + AutoRepairPanel */ -import { useState, useEffect, useCallback, useRef } from 'react' -import { useTranslations } from 'next-intl' import { AppLayout } from '@/components/layout' -import { useIncidents } from '@/hooks/useIncidents' -import { cn } from '@/lib/utils' -import { - Wrench, RefreshCw, AlertCircle, - CheckCircle2, XCircle, ShieldAlert, - Play, ChevronDown, ChevronUp, Zap, -} from 'lucide-react' - -// ============================================================================= -// Types -// ============================================================================= - -// 2026-04-07 Claude Code: Sprint 4 C2 — disposition_summary 擴充 -interface DispositionSummary { - auto_repair: number - human_approved: number - manual_resolved: number - cold_start_trust: number - total: number - auto_rate: number -} - -interface AutoRepairStats { - approved_playbooks: number - high_quality_playbooks: number - total_executions: number - overall_success_rate: number - auto_repair_eligible: boolean - disposition_summary?: DispositionSummary -} - -interface EvaluateResponse { - can_auto_repair: boolean - playbook_id: string | null - playbook_name: string | null - reason: string - risk_level: string - blocked_by: string | null - success_rate: number | null - total_executions: number | null -} - -interface ExecuteResponse { - success: boolean - incident_id: string - playbook_id: string - executed_steps: string[] - error: string | null - execution_time_ms: number -} - -// ============================================================================= -// Helpers -// ============================================================================= - -const getApiBaseUrl = () => { - if (typeof window === 'undefined') return '' - const url = process.env.NEXT_PUBLIC_API_URL - if (!url) console.error('[AWOOOI ERROR] Missing NEXT_PUBLIC_API_URL') // eslint-disable-line no-console - return url ?? '' -} - -const RISK_STYLE: Record = { - LOW: 'bg-status-healthy/10 text-status-healthy border-status-healthy/20', - MEDIUM: 'bg-status-warning/10 text-status-warning border-status-warning/20', - HIGH: 'bg-status-critical/10 text-status-critical border-status-critical/20', - CRITICAL: 'bg-status-critical/10 text-status-critical border-status-critical/20', -} - -// ============================================================================= -// Sub-components -// ============================================================================= - -function StatCard({ - label, value, sub, highlight, -}: { label: string; value: string | number; sub?: string; highlight?: boolean }) { - return ( -
-

{label}

-

- {value} -

- {sub &&

{sub}

} -
- ) -} - -// ============================================================================= -// Incident Evaluate Row -// ============================================================================= - -function IncidentEvalRow({ - incidentId, severity, -}: { incidentId: string; severity: string }) { - const t = useTranslations('autoRepair') - const [eval_, setEval] = useState(null) - const [loading, setLoading] = useState(false) - const [executing, setExecuting] = useState(false) - const [result, setResult] = useState(null) - const [expanded, setExpanded] = useState(false) - - const fetchEval = useCallback(async () => { - const base = getApiBaseUrl() - if (!base) return - setLoading(true) - try { - const res = await fetch(`${base}/api/v1/auto-repair/evaluate/${incidentId}`) - if (res.ok) { - setEval(await res.json()) - } - } catch { - // API 不可用時靜默處理,不噴 console error - } finally { - setLoading(false) - } - }, [incidentId]) - - useEffect(() => { fetchEval() }, [fetchEval]) - - const handleExecute = async () => { - if (!eval_?.playbook_id) return - setExecuting(true) - try { - const base = getApiBaseUrl() - const res = await fetch(`${base}/api/v1/auto-repair/execute`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ incident_id: incidentId, playbook_id: eval_.playbook_id }), - }) - if (res.ok) setResult(await res.json()) - } finally { - setExecuting(false) - } - } - - return ( -
- {/* Header row */} -
setExpanded(v => !v)} - > - {severity} - {incidentId} - - {loading && } - {!loading && eval_ && ( - eval_.can_auto_repair - ? {t('canAutoRepair')} - : {t('notEligibleShort')} - )} - {expanded ? : } -
- - {/* Expanded detail */} - {expanded && eval_ && ( -
-
-
- Playbook -

{eval_.playbook_name ?? '—'}

-
-
- {t('riskLevel')} -

- - {eval_.risk_level} - -

-
- {eval_.success_rate != null && ( -
- {t('successRate')} -

{(eval_.success_rate * 100).toFixed(1)}%

-
- )} - {eval_.total_executions != null && ( -
- {t('execCount')} -

{eval_.total_executions}

-
- )} -
- -
- {t('decisionReason')} -

{eval_.reason}

-
- - {/* Execute result */} - {result && ( -
-
- {result.success - ? - : } - - {result.success ? t('execSuccess', { ms: result.execution_time_ms }) : t('execFailed', { error: result.error })} - -
- {result.executed_steps.length > 0 && ( -
    - {result.executed_steps.map((step, i) => ( -
  • - - {step} -
  • - ))} -
- )} -
- )} - - {/* Execute button */} - {eval_.can_auto_repair && !result && ( - - )} -
- )} -
- ) -} - -// ============================================================================= -// Page -// ============================================================================= +import { AutoRepairPanel } from '@/components/panels/AutoRepairPanel' export default function AutoRepairPage({ params }: { params: { locale: string } }) { - const t = useTranslations('autoRepair') - const tNav = useTranslations('nav') - const tCommon = useTranslations('common') - - const [stats, setStats] = useState(null) - const [statsLoading, setStatsLoading] = useState(true) - const [statsError, setStatsError] = useState(null) - const [disposition, setDisposition] = useState<{ total: number; auto_repair: number; human_approved: number; manual_resolved: number; cold_start_trust: number; auto_rate: number } | null>(null) - const abortRef = useRef(null) - - const { incidents, isLoading: incidentsLoading } = useIncidents({ - pollInterval: 30000, - enablePolling: true, - }) - - const eligibleIncidents = (incidents ?? []).filter(i => - i.severity === 'P1' || i.severity === 'P2' - ) - - const fetchStats = useCallback(async () => { - const base = getApiBaseUrl() - if (!base) return - abortRef.current?.abort() - const ctrl = new AbortController() - abortRef.current = ctrl - setStatsLoading(true) - setStatsError(null) - try { - const [res, dispRes] = await Promise.all([ - fetch(`${base}/api/v1/auto-repair/stats`, { signal: ctrl.signal }), - fetch(`${base}/api/v1/stats/disposition`, { signal: ctrl.signal }).catch(() => null), - ]) - if (!res.ok) throw new Error(`HTTP ${res.status}`) - setStats(await res.json()) - if (dispRes?.ok) { - const d = await dispRes.json() - setDisposition(d.summary ?? null) - } - } catch (e) { - if (e instanceof Error && e.name === 'AbortError') return - setStatsError(e instanceof Error ? e.message : 'Unknown error') - } finally { - setStatsLoading(false) - } - }, []) - - useEffect(() => { - fetchStats() - return () => { abortRef.current?.abort() } - }, [fetchStats]) - return ( - {/* Header */} -
-
-

- - {tNav('autoRepair')} -

-

- {t('subtitle')} -

-
- -
- - {/* Error */} - {statsError && ( -
- - {statsError} -
- )} - - {/* Stats */} - {stats && ( -
- - - - -
- )} - - {/* Sprint 4 E3: 處置概況 */} - {disposition && disposition.total > 0 && ( -
-
-

{t('dispositionAuto')}

-

{disposition.auto_repair}

-
-
-

{t('dispositionHuman')}

-

{disposition.human_approved}

-
-
-

{t('dispositionManual')}

-

{disposition.manual_resolved}

-
-
-

{t('dispositionCold')}

-

{disposition.cold_start_trust}

-
-
- )} - - {/* Eligible indicator */} - {stats && ( -
- {stats.auto_repair_eligible - ? - : } -
-

- {stats.auto_repair_eligible ? t('ready') : t('notReady')} -

-

- {stats.auto_repair_eligible - ? t('readyDesc', { count: stats.high_quality_playbooks }) - : t('notReadyDesc')} -

-
-
- )} - - {/* Incident evaluation list */} -
-

- {t('incidentEval')} -

- - {incidentsLoading && ( -
- -
- )} - - {!incidentsLoading && eligibleIncidents.length === 0 && ( -
- -

{t('noEligible')}

-
- )} - -
- {eligibleIncidents.map(incident => ( - - ))} -
-
+
) } diff --git a/apps/web/src/app/[locale]/billing/page.tsx b/apps/web/src/app/[locale]/billing/page.tsx index 5a5bac36..3099a2e7 100644 --- a/apps/web/src/app/[locale]/billing/page.tsx +++ b/apps/web/src/app/[locale]/billing/page.tsx @@ -1,113 +1,18 @@ 'use client' /** - * 使用量 Page — 系統操作使用量統計 - * @created 2026-04-01 ogt - 路由佔位 - * @updated 2026-04-03 Claude Code - 串接 /api/v1/audit-logs/stats 真實數據 + * 使用量 Page — 路由入口 + * @created 2026-04-01 ogt + * @updated 2026-04-09 Claude Code - 精簡為 AppLayout + BillingPanel */ -import { useState, useEffect } from 'react' -import { useTranslations } from 'next-intl' import { AppLayout } from '@/components/layout' - -const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? '' - -interface AuditStats { - total_executions: number - success_count: number - failure_count: number - success_rate: number - avg_duration_ms: number | null - last_24h_count: number - by_operation_type: Record - by_namespace: Record -} +import { BillingPanel } from '@/components/panels/BillingPanel' export default function BillingPage({ params }: { params: { locale: string } }) { - const t = useTranslations('billing') - const [stats, setStats] = useState(null) - const [loading, setLoading] = useState(true) - const [error, setError] = useState(null) - - useEffect(() => { - fetch(`${API_BASE}/api/v1/audit-logs/stats`) - .then(r => r.json()) - .then((d: AuditStats) => { setStats(d); setLoading(false) }) - .catch(err => { setError(String(err)); setLoading(false) }) - }, []) - - const cardStyle = { background: '#fff', border: '0.5px solid #e0ddd4', borderRadius: 12, padding: '16px 18px' } - return ( -
-
-

{t('title')}

-

{t('subtitle')}

-
- - {loading ? ( -
{t('loading')}
- ) : error ? ( -
{t('error')}
- ) : stats ? ( - <> -
- {[ - { label: t('totalUsage'), value: stats.total_executions }, - { label: t('last24h'), value: stats.last_24h_count }, - { label: t('successRate'), value: `${(stats.success_rate * 100).toFixed(1)}%` }, - { label: t('avgDuration'), value: stats.avg_duration_ms ? `${stats.avg_duration_ms.toFixed(0)}ms` : '—' }, - ].map(card => ( -
-
{card.label}
-
{card.value}
-
- ))} -
- - {/* By Operation Type */} - {Object.keys(stats.by_operation_type).length > 0 && ( -
-
- By Operation Type -
- - - {Object.entries(stats.by_operation_type).sort(([, a], [, b]) => b - a).map(([op, count]) => ( - - - - - ))} - -
{op}{count}
-
- )} - - {/* By Namespace */} - {Object.keys(stats.by_namespace).length > 0 && ( -
-
- By Namespace -
- - - {Object.entries(stats.by_namespace).sort(([, a], [, b]) => b - a).map(([ns, count]) => ( - - - - - ))} - -
{ns}{count}
-
- )} - - ) : ( -
{t('noData')}
- )} -
+
) } diff --git a/apps/web/src/app/[locale]/cost/page.tsx b/apps/web/src/app/[locale]/cost/page.tsx index 5675987a..12f0ca9b 100644 --- a/apps/web/src/app/[locale]/cost/page.tsx +++ b/apps/web/src/app/[locale]/cost/page.tsx @@ -1,95 +1,18 @@ 'use client' /** - * 成本分析 Page — AI 執行效能統計 - * @created 2026-04-01 ogt - 路由佔位 - * @updated 2026-04-03 Claude Code - 串接 /api/v1/stats/ai-performance 真實數據 + * 成本分析 Page — 路由入口 + * @created 2026-04-01 ogt + * @updated 2026-04-09 Claude Code - 精簡為 AppLayout + CostPanel */ -import { useState, useEffect } from 'react' -import { useTranslations } from 'next-intl' import { AppLayout } from '@/components/layout' - -const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? '' - -interface AIPerformance { - total_proposals: number - executed_count: number - execution_rate: number - success_count: number - success_rate: number - avg_effectiveness: number | null - effectiveness_distribution: Record -} +import { CostPanel } from '@/components/panels/CostPanel' export default function CostPage({ params }: { params: { locale: string } }) { - const t = useTranslations('cost') - const [data, setData] = useState(null) - const [loading, setLoading] = useState(true) - const [error, setError] = useState(null) - - useEffect(() => { - fetch(`${API_BASE}/api/v1/stats/ai-performance?days=30`) - .then(r => r.json()) - .then((d: AIPerformance) => { setData(d); setLoading(false) }) - .catch(err => { setError(String(err)); setLoading(false) }) - }, []) - - const cardStyle = { background: '#fff', border: '0.5px solid #e0ddd4', borderRadius: 12, padding: '16px 18px' } - return ( -
-
-

{t('title')}

-

{t('subtitle')}

-
- - {loading ? ( -
{t('loading')}
- ) : error ? ( -
{t('error')}
- ) : data ? ( - <> -
- {[ - { label: t('totalProposals'), value: data.total_proposals }, - { label: t('executionRate'), value: `${(data.execution_rate).toFixed(1)}%` }, - { label: t('successRate'), value: `${(data.success_rate).toFixed(1)}%` }, - { label: t('avgEffectiveness'), value: data.avg_effectiveness ? data.avg_effectiveness.toFixed(2) : '—' }, - ].map(card => ( -
-
{card.label}
-
{card.value}
-
- ))} -
- - {/* Effectiveness Distribution */} - {data.effectiveness_distribution && Object.keys(data.effectiveness_distribution).length > 0 && ( -
-
- Effectiveness Distribution (1–5) -
-
- {[1, 2, 3, 4, 5].map(score => { - const count = data.effectiveness_distribution[String(score)] ?? 0 - const barColors = ['#cc2200', '#F59E0B', '#87867f', '#4A90D9', '#22C55E'] - return ( -
-
★{score}
-
{count}
-
- ) - })} -
-
- )} - - ) : ( -
{t('noData')}
- )} -
+
) } diff --git a/apps/web/src/app/[locale]/deployments/page.tsx b/apps/web/src/app/[locale]/deployments/page.tsx index 7c3dc91e..858133d4 100644 --- a/apps/web/src/app/[locale]/deployments/page.tsx +++ b/apps/web/src/app/[locale]/deployments/page.tsx @@ -1,113 +1,18 @@ 'use client' /** - * 部署管理 Page — K3s 服務部署狀態 - * @created 2026-04-01 ogt - 路由佔位 - * @updated 2026-04-03 Claude Code - 串接 /api/v1/dashboard 真實數據 + * 部署管理 Page — 路由入口 + * @created 2026-04-01 ogt + * @updated 2026-04-09 Claude Code - 精簡為 AppLayout + DeploymentsPanel */ -import { useState, useEffect } from 'react' -import { useTranslations } from 'next-intl' import { AppLayout } from '@/components/layout' - -const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? '' - -interface HostService { - name: string - status: string - port: number | null - latency_ms: number | null -} - -interface Host { - ip: string - name: string - role: string - status: string - services: HostService[] - last_check: string -} - -const STATUS_COLOR: Record = { - up: '#22C55E', - healthy: '#22C55E', - down: '#cc2200', - degraded: '#F59E0B', - unreachable: '#87867f', -} +import { DeploymentsPanel } from '@/components/panels/DeploymentsPanel' export default function DeploymentsPage({ params }: { params: { locale: string } }) { - const t = useTranslations('deployments') - const [hosts, setHosts] = useState([]) - const [loading, setLoading] = useState(true) - const [error, setError] = useState(null) - - useEffect(() => { - fetch(`${API_BASE}/api/v1/dashboard`) - .then(r => r.json()) - .then(data => { setHosts(data.hosts ?? []); setLoading(false) }) - .catch(err => { setError(String(err)); setLoading(false) }) - }, []) - - const k3sHosts = hosts.filter(h => h.role === 'k3s' || h.ip.includes('120')) - const displayHosts = k3sHosts.length > 0 ? k3sHosts : hosts - return ( -
-
-

{t('title')}

-

{t('subtitle')}

-
- {loading ? ( -
{t('loading')}
- ) : error ? ( -
{t('error')}
- ) : displayHosts.length === 0 ? ( -
{t('noDeployments')}
- ) : ( - displayHosts.map(host => ( -
-
-
- - {host.name} - {host.ip} -
- - {host.last_check ? new Date(host.last_check).toLocaleTimeString('zh-TW', { timeZone: 'Asia/Taipei' }) : '—'} - -
- - - - {[t('service'), t('port'), t('latency'), t('status')].map(col => ( - - ))} - - - - {host.services.length === 0 ? ( - - ) : host.services.map((s, i) => ( - - - - - - - ))} - -
{col}
{t('noDeployments')}
{s.name}{s.port ?? '—'}{s.latency_ms != null ? `${s.latency_ms.toFixed(0)}ms` : '—'} - - - {s.status} - -
-
- )) - )} -
+
) } diff --git a/apps/web/src/app/[locale]/drift/page.tsx b/apps/web/src/app/[locale]/drift/page.tsx index aad40972..648ce8eb 100644 --- a/apps/web/src/app/[locale]/drift/page.tsx +++ b/apps/web/src/app/[locale]/drift/page.tsx @@ -1,324 +1,17 @@ 'use client' /** - * Config Drift Detection Page - 配置漂移偵測 - * ============================================= - * Phase 25 P2: GitOps 守門員 - * 偵測 K8s 實際狀態 vs Git YAML 漂移 - * - * API: /api/v1/drift/scan, /api/v1/drift/reports - * CronJob: drift-scanner (每小時自動) - * - * 建立時間: 2026-04-04 (台北時區) - * 建立者: Claude Code (Phase 25 P2) - * 關聯設計: docs/superpowers/specs/2026-04-04-nemotron-active-defense-design.md 方向三 + * Config Drift Detection Page — 路由入口 + * @updated 2026-04-09 Claude Code - 精簡為 AppLayout + DriftPanel */ -import { useState, useEffect, useCallback } from 'react' -import { useTranslations } from 'next-intl' import { AppLayout } from '@/components/layout' -import { cn } from '@/lib/utils' -import { - Diff, RefreshCw, AlertTriangle, CheckCircle2, - Clock, Terminal, GitMerge, Info, -} from 'lucide-react' - -// ============================================================================= -// Types -// ============================================================================= - -interface DriftReport { - report_id: string - scanned_at: string - namespace: string - triggered_by: string - high_count: number - medium_count: number - info_count: number - interpretation: string | null - status: 'pending' | 'resolved' | 'ignored' - created_at: string - resolved_at: string | null -} - -interface ScanResult { - report_id: string - summary: string - high_count: number - medium_count: number - info_count: number - has_critical_drift: boolean - interpretation: string | null -} - -// ============================================================================= -// Helpers -// ============================================================================= - -const getApiBase = () => { - if (typeof window === 'undefined') return '' - return process.env.NEXT_PUBLIC_API_URL ?? '' -} - -const fmtTime = (iso: string) => { - try { - return new Date(iso).toLocaleString('zh-TW', { - year: 'numeric', month: '2-digit', day: '2-digit', - hour: '2-digit', minute: '2-digit', - }) - } catch { - return iso - } -} - -// ============================================================================= -// Sub-components -// ============================================================================= - -function DriftLevelBadge({ high, medium, info, t }: { - high: number; medium: number; info: number - t: (k: string) => string -}) { - if (high === 0 && medium === 0 && info === 0) { - return ( - - - {t('noDrift')} - - ) - } - return ( -
- {high > 0 && ( - - - {t('highCount')} {high} - - )} - {medium > 0 && ( - - - {t('mediumCount')} {medium} - - )} - {info > 0 && ( - - {t('infoCount')} {info} - - )} -
- ) -} - -function StatusBadge({ status, t }: { status: DriftReport['status']; t: (k: string) => string }) { - const styles: Record = { - pending: 'bg-status-warning/10 text-status-warning border-status-warning/20', - resolved: 'bg-status-healthy/10 text-status-healthy border-status-healthy/20', - ignored: 'bg-neutral-100 text-neutral-400 border-neutral-200', - } - return ( - - {t(status)} - - ) -} - -// ============================================================================= -// Main Page -// ============================================================================= +import { DriftPanel } from '@/components/panels/DriftPanel' export default function DriftPage({ params }: { params: { locale: string } }) { - const t = useTranslations('drift') - const [reports, setReports] = useState([]) - const [loading, setLoading] = useState(true) - const [scanning, setScanning] = useState(false) - const [scanResult, setScanResult] = useState(null) - const [error, setError] = useState(null) - - const fetchReports = useCallback(async () => { - setLoading(true) - setError(null) - try { - const res = await fetch(`${getApiBase()}/api/v1/drift/reports?limit=20`) - if (!res.ok) throw new Error(`HTTP ${res.status}`) - const data = await res.json() - setReports(data.items ?? []) - } catch (e) { - setError(e instanceof Error ? e.message : 'Failed to fetch') - } finally { - setLoading(false) - } - }, []) - - useEffect(() => { fetchReports() }, [fetchReports]) - - const handleScan = async () => { - setScanning(true) - setScanResult(null) - try { - const res = await fetch(`${getApiBase()}/api/v1/drift/scan`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ namespaces: ['awoooi-prod'], triggered_by: 'web_manual' }), - }) - if (!res.ok) throw new Error(`HTTP ${res.status}`) - const data: ScanResult = await res.json() - setScanResult(data) - // Refresh reports list - await fetchReports() - } catch (e) { - setError(e instanceof Error ? e.message : 'Scan failed') - } finally { - setScanning(false) - } - } - return ( -
- {/* Header */} -
-
- -
-

- {t('title')} -

-

- {t('subtitle')} -

-
-
-
- - -
-
- - {/* Scan Result Banner */} - {scanResult && ( -
-
- {scanResult.has_critical_drift - ? - : - } - {scanResult.summary} - {(scanResult.high_count > 0 || scanResult.medium_count > 0) && ( - - — {t('highCount')} {scanResult.high_count}, {t('mediumCount')} {scanResult.medium_count} - - )} -
- {scanResult.interpretation && ( -

- {scanResult.interpretation} -

- )} -
- )} - - {/* Error */} - {error && ( -
- {error} -
- )} - - {/* Content */} -
- {loading && reports.length === 0 ? ( -
- - {t('loading')} -
- ) : reports.length === 0 ? ( -
- -

{t('noReports')}

-

{t('noReportsHint')}

-
- ) : ( -
- {reports.map((report) => ( -
-
- {/* Left: ID + time */} -
- - {report.report_id.slice(0, 8)} - - -
- {/* Right: status + time */} -
- - - - {fmtTime(report.scanned_at)} - -
-
- {/* Interpretation */} - {report.interpretation && ( -

- {report.interpretation} -

- )} - {/* Metadata */} -
- {t('namespace')}: {report.namespace} - {t('triggeredBy')}: {report.triggered_by} -
-
- ))} -
- )} -
-
+
) } diff --git a/apps/web/src/app/[locale]/neural-command/page.tsx b/apps/web/src/app/[locale]/neural-command/page.tsx index c513f269..f52aabb7 100644 --- a/apps/web/src/app/[locale]/neural-command/page.tsx +++ b/apps/web/src/app/[locale]/neural-command/page.tsx @@ -1,209 +1,17 @@ 'use client' /** - * Neural Command Center - 神經指揮中心 - * ===================================== - * SSH_COMMAND 指揮權鏈完整監控頁面 - * - * 功能: - * - Pre-Flight 安全審查面板 (8 項檢查) - * - 即時指揮中心 (OpenClaw 🦞 + NemoTron ⚡) - * - 統計 & 歷史數據 - * - 核鑰授權面板 - * - * API: - * GET /api/v1/auto-repair/stats - * GET /api/v1/playbooks/ - * GET /api/v1/auto-repair/history - * GET /api/v1/approvals (pending) - * - * 建立時間: 2026-04-06 (台北時區) - * 建立者: Claude Code (Sprint 3 SSH_COMMAND 指揮權鏈) + * Neural Command Center Page — 路由入口 + * @updated 2026-04-09 Claude Code - 精簡為 AppLayout + NeuralCommandPanel */ -import { useState, useEffect, useCallback } from 'react' -import { useTranslations } from 'next-intl' import { AppLayout } from '@/components/layout' -import { cn } from '@/lib/utils' -import { - BrainCircuit, Zap, ShieldCheck, CheckCircle2, AlertTriangle, - XCircle, RefreshCw, Clock, Terminal, Database, - ChevronRight, Activity, Lock, Unlock, -} from 'lucide-react' -import { NeuralPreFlight } from '@/components/neural-command/NeuralPreFlight' -import { NeuralLiveCenter } from '@/components/neural-command/NeuralLiveCenter' -import { NeuralStats } from '@/components/neural-command/NeuralStats' -import { NeuralApprovalPanel } from '@/components/neural-command/NeuralApprovalPanel' - -import type { AutoRepairStats, PlaybookItem, RepairHistoryItem, NeuralTab, PendingApprovalItem, ActiveIncident } from '@/components/neural-command/types' -export type { AutoRepairStats, PlaybookItem, RepairHistoryItem, NeuralTab } - -// ============================================================================= -// Tab config -// ============================================================================= - -const TABS: { id: NeuralTab; labelKey: string; Icon: React.ElementType }[] = [ - { id: 'preflight', labelKey: 'preFlightAudit', Icon: ShieldCheck }, - { id: 'live', labelKey: 'liveCommand', Icon: Activity }, - { id: 'stats', labelKey: 'statsHistory', Icon: Database }, - { id: 'approval', labelKey: 'nuclearApproval', Icon: Lock }, -] - -// ============================================================================= -// Page -// ============================================================================= +import { NeuralCommandPanel } from '@/components/panels/NeuralCommandPanel' export default function NeuralCommandPage({ params }: { params: { locale: string } }) { - const t = useTranslations('neuralCommand') - const [activeTab, setActiveTab] = useState('preflight') - const [stats, setStats] = useState(null) - const [playbooks, setPlaybooks] = useState([]) - const [history, setHistory] = useState([]) - const [loading, setLoading] = useState(true) - const [lastRefresh, setLastRefresh] = useState(new Date()) - const [pendingApprovals, setPendingApprovals] = useState(0) - const [pendingApprovalList, setPendingApprovalList] = useState([]) - const [activeIncidents, setActiveIncidents] = useState([]) - const [dispositionSummary, setDispositionSummary] = useState<{ total: number; auto_repair: number; human_approved: number; manual_resolved: number; cold_start_trust: number; auto_rate: number } | null>(null) - - - const fetchData = useCallback(async () => { - try { - const [statsRes, pbRes, histRes, approvalsRes, incidentsRes, dispRes] = await Promise.all([ - fetch('/api/v1/auto-repair/stats'), - fetch('/api/v1/playbooks/'), - fetch('/api/v1/auto-repair/history?limit=20'), - fetch('/api/v1/approvals/pending'), - fetch('/api/v1/incidents?status=firing&limit=10'), - fetch('/api/v1/stats/disposition').catch(() => null), - ]) - - if (statsRes.ok) { - const data = await statsRes.json() - setStats(data) - } - if (pbRes.ok) { - const data = await pbRes.json() - setPlaybooks(data.items?.map((i: { playbook: PlaybookItem }) => i.playbook) ?? []) - } - if (histRes.ok) { - const data = await histRes.json() - setHistory(data.items ?? []) - } - if (approvalsRes.ok) { - const data = await approvalsRes.json() - setPendingApprovals(data.count ?? 0) - setPendingApprovalList(data.approvals ?? []) - } - if (incidentsRes.ok) { - const data = await incidentsRes.json() - setActiveIncidents(data.incidents ?? []) - } - if (dispRes?.ok) { - const data = await dispRes.json() - setDispositionSummary(data.summary ?? null) - } - - setLastRefresh(new Date()) - } catch { - // silently fail — show stale data - } finally { - setLoading(false) - } - }, []) - - useEffect(() => { - fetchData() - const interval = setInterval(fetchData, 30_000) - return () => clearInterval(interval) - }, [fetchData]) - - const approvedPlaybooks = playbooks.filter(p => p.status === 'approved') - return ( -
- - {/* ── Page header ── */} -
-
-
- -
-
-

{t('title')}

-

{t('subtitle')}

-
-
- -
- {/* Agent status pills */} -
- - - OpenClaw - - - - NemoTron - -
- - {/* Refresh */} -
- - {t('lastRefresh', { time: lastRefresh.toLocaleTimeString('zh-TW', { hour: '2-digit', minute: '2-digit', second: '2-digit' }) })} - -
-
-
- - {/* ── Tabs ── */} -
- {TABS.map(({ id, labelKey, Icon }) => ( - - ))} -
- - {/* ── Tab content ── */} -
- {activeTab === 'preflight' && ( - - )} - {activeTab === 'live' && ( - - )} - {activeTab === 'stats' && ( - - )} - {activeTab === 'approval' && ( - - )} -
-
+
) } diff --git a/apps/web/src/app/[locale]/services/page.tsx b/apps/web/src/app/[locale]/services/page.tsx index 6242366f..284eb9b3 100644 --- a/apps/web/src/app/[locale]/services/page.tsx +++ b/apps/web/src/app/[locale]/services/page.tsx @@ -1,120 +1,18 @@ 'use client' /** - * 服務目錄 Page - * @created 2026-04-01 ogt - 路由佔位 (awaiting implementation) - * @updated 2026-04-02 ogt - 升級為真實 UI,串接 /api/v1/dashboard + * 服務目錄 Page — 路由入口 + * @created 2026-04-01 ogt + * @updated 2026-04-09 Claude Code - 精簡為 AppLayout + ServicesPanel */ -import { useEffect, useState } from 'react' -import { useTranslations } from 'next-intl' import { AppLayout } from '@/components/layout' - -const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? '' - -interface ServiceItem { - name: string - host: string - status: string - cpu?: number - ram?: number -} - -const statusColor = (s: string) => { - if (s === 'healthy' || s === 'running') return '#4caf50' - if (s === 'warning') return '#ff9800' - if (s === 'critical' || s === 'error') return '#f44336' - return '#87867f' -} +import { ServicesPanel } from '@/components/panels/ServicesPanel' export default function ServicesPage({ params }: { params: { locale: string } }) { - const t = useTranslations('services') - const tc = useTranslations('common') - const [services, setServices] = useState([]) - const [loading, setLoading] = useState(true) - const [error, setError] = useState(false) - - useEffect(() => { - setLoading(true) - setError(false) - fetch(`${API_BASE}/api/v1/dashboard`) - .then(r => r.json()) - .then(data => { - const hosts: { name: string; services?: { name: string; status: string; cpu?: number; ram?: number }[] }[] = data?.hosts ?? [] - const list: ServiceItem[] = [] - hosts.forEach(h => { - (h.services ?? []).forEach(s => { - list.push({ name: s.name, host: h.name, status: s.status, cpu: s.cpu, ram: s.ram }) - }) - }) - setServices(list) - }) - .catch(() => setError(true)) - .finally(() => setLoading(false)) - }, []) - return ( -
-
-

{t('title')}

-

{t('subtitle')}

-
- -
- {/* Header */} -
- - {t('title')} -
- - {loading && ( -
- {tc('loading')} -
- )} - - {!loading && error && ( -
- {t('fetchError')} -
- )} - - {!loading && !error && services.length === 0 && ( -
- {t('noServices')} -
- )} - - {!loading && !error && services.length > 0 && ( - - - - {[t('name'), t('host'), t('status'), t('cpu'), t('ram')].map(col => ( - - ))} - - - - {services.map((s, i) => ( - - - - - - - - ))} - -
{col}
{s.name}{s.host} - - - {s.status} - - {s.cpu != null ? `${s.cpu}%` : '--'}{s.ram != null ? `${s.ram}%` : '--'}
- )} -
-
+
) } diff --git a/apps/web/src/app/[locale]/tickets/page.tsx b/apps/web/src/app/[locale]/tickets/page.tsx index 2fa6206f..6244ec13 100644 --- a/apps/web/src/app/[locale]/tickets/page.tsx +++ b/apps/web/src/app/[locale]/tickets/page.tsx @@ -1,120 +1,18 @@ 'use client' /** - * 工單 Page — 真實 Incidents 作為工單追蹤 - * @created 2026-04-01 ogt - 路由佔位 - * @updated 2026-04-03 Claude Code - 串接 /api/v1/incidents 真實數據 + * 工單 Page — 路由入口 + * @created 2026-04-01 ogt + * @updated 2026-04-09 Claude Code - 精簡為 AppLayout + TicketsPanel */ -import { useState, useEffect } from 'react' -import { useTranslations } from 'next-intl' import { AppLayout } from '@/components/layout' - -const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? '' - -interface Incident { - id: string - title: string - severity: string - status: string - created_at: string - affected_service: string | null -} - -interface IncidentListResponse { - incidents: Incident[] - total: number -} - -const SEV_COLOR: Record = { - P0: '#cc2200', - P1: '#F59E0B', - P2: '#4A90D9', - P3: '#22C55E', -} - -const STATUS_COLOR: Record = { - open: '#cc2200', - in_progress: '#F59E0B', - resolved: '#22C55E', - closed: '#87867f', -} +import { TicketsPanel } from '@/components/panels/TicketsPanel' export default function TicketsPage({ params }: { params: { locale: string } }) { - const t = useTranslations('tickets') - const [incidents, setIncidents] = useState([]) - const [total, setTotal] = useState(0) - const [loading, setLoading] = useState(true) - const [error, setError] = useState(null) - - useEffect(() => { - fetch(`${API_BASE}/api/v1/incidents`) - .then(r => r.json()) - .then((data: IncidentListResponse) => { - setIncidents(data.incidents ?? []) - setTotal(data.total ?? 0) - setLoading(false) - }) - .catch(err => { setError(String(err)); setLoading(false) }) - }, []) - return ( -
-
-

{t('title')}

-

{t('subtitle')}

-
-
-
- - {t('title')} ({loading ? '...' : total}) -
- {loading ? ( -
{t('loading')}
- ) : error ? ( -
{t('error')}
- ) : incidents.length === 0 ? ( -
{t('noTickets')}
- ) : ( - - - - {[t('id'), t('title_col'), t('priority'), t('status'), t('createdAt')].map(col => ( - - ))} - - - - {incidents.map((inc) => ( - - - - - - - - ))} - -
{col}
{inc.id.slice(0, 8)} -
{inc.title}
- {inc.affected_service && ( -
{inc.affected_service}
- )} -
- - {inc.severity} - - - - {inc.status} - - - {new Date(inc.created_at).toLocaleString('zh-TW', { timeZone: 'Asia/Taipei', month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' })} -
- )} -
-
+
) } diff --git a/apps/web/src/components/panels/AutoRepairPanel.tsx b/apps/web/src/components/panels/AutoRepairPanel.tsx index 61868df4..9788781b 100644 --- a/apps/web/src/components/panels/AutoRepairPanel.tsx +++ b/apps/web/src/components/panels/AutoRepairPanel.tsx @@ -215,7 +215,7 @@ function IncidentEvalRow({ ? : } - {result.success ? t('execSuccess', { ms: result.execution_time_ms }) : t('execFailed', { error: result.error })} + {result.success ? t('execSuccess', { ms: result.execution_time_ms }) : t('execFailed', { error: result.error ?? '' })} {result.executed_steps.length > 0 && ( diff --git a/apps/web/src/components/panels/index.ts b/apps/web/src/components/panels/index.ts index a1e2e667..f9d478ee 100644 --- a/apps/web/src/components/panels/index.ts +++ b/apps/web/src/components/panels/index.ts @@ -2,14 +2,10 @@ * Panel 元件匯出 * Sprint 5: 供整合頁面使用的無 AppLayout 版本 * - * 已抽取: - * - MonitoringPanel (from /monitoring) - * - * 待抽取 (暫時用 lazy import 原始頁面): - * - APMPanel, ErrorsPanel, AppsPanel, ServicesPanel - * - AutoRepairPanel, NeuralCommandPanel, DriftPanel - * - DeploymentsPanel, TicketsPanel, CostPanel, ActionLogsPanel, BillingPanel - * - SecurityPanel, CompliancePanel + * 已抽取 (2026-04-09 全部完成): + * - /observability: MonitoringPanel, ApmPanel, ErrorsPanel, AppsPanel, ServicesPanel + * - /automation: AutoRepairPanel, NeuralCommandPanel, DriftPanel + * - /operations: DeploymentsPanel, TicketsPanel, CostPanel, ActionLogsPanel, BillingPanel */ export { MonitoringPanel } from './MonitoringPanel'