From 0c740f70e35ada833a00942398546d09d014f6fc Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 3 Jul 2026 21:19:34 +0800 Subject: [PATCH] =?UTF-8?q?feat(ui):=20=E5=85=A8=E7=AB=99=20UI/UX=20?= =?UTF-8?q?=E5=B0=88=E6=A5=AD=E5=8C=96=E9=87=8D=E6=A7=8B=20=E2=80=94=20Pan?= =?UTF-8?q?el=20+=20Page=20Tailwind=20=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 改善範圍 ### Panels (components/panels/) - CostPanel: inline style → Tailwind + Stat Cards + 效能分佈長條圖 + Loading skeleton + Refresh - ApmPanel: Tailwind + 彩色狀態徽章 + ring 發光 + Sparkline 趨勢圖 - AppsPanel: Tailwind + 卡片 Grid + 狀態 / 延遲指示器 + Summary Pills - ServicesPanel: Tailwind + 3 欄 Summary Cards + 資源 CPU/RAM Progress Bar - BillingPanel: Tailwind + Stat Cards + 成功/失敗進度條 + 操作類型分類圖 - DeploymentsPanel: Tailwind + CI/CD 時間軸事件 + 主機健康卡片帶服務 Grid - SecurityPanel: Tailwind + 彩色 Stat Cards + 問題列表帶 Sentry 等級徽章 - CompliancePanel: Tailwind + 嚴重性分佈視覺化 + Auto-Repair 三欄指標面板 - ErrorsPanel: 頭部 Tailwind 化 + 統一 bg-[#f5f4ed] 背景 ### Pages (app/[locale]/) - operations: 5 個彩色功能模組卡片(帶描述、hover 上浮) - automation: 3 個功能模組卡片 + 系統狀態橫幅 - help: Hero + 4 能力卡片 + 版本資訊表 + Docs 快捷連結 - notifications: Summary Stats + 頻道卡片(帶 Telegram/Slack 圖示) - settings: 加上 bg-[#f5f4ed] 背景 + max-width wrapper - topology: 標題列 Tailwind 化 + Network icon + Live 綠點 ## 技術規範 - 零 inline style(除必要 dynamic 值) - 全 Tailwind CSS - TypeScript tsc --noEmit 通過 - 保留所有 API 邏輯不變,僅優化 UI 呈現層 --- apps/web/src/app/[locale]/automation/page.tsx | 126 +++++-- apps/web/src/app/[locale]/help/page.tsx | 131 +++++-- .../src/app/[locale]/notifications/page.tsx | 189 +++++++--- apps/web/src/app/[locale]/operations/page.tsx | 137 +++++-- apps/web/src/app/[locale]/settings/page.tsx | 19 +- apps/web/src/app/[locale]/topology/page.tsx | 35 +- apps/web/src/components/panels/ApmPanel.tsx | 181 +++++++-- apps/web/src/components/panels/AppsPanel.tsx | 247 +++++++++--- .../src/components/panels/BillingPanel.tsx | 254 ++++++++++--- .../src/components/panels/CompliancePanel.tsx | 229 +++++++++--- apps/web/src/components/panels/CostPanel.tsx | 196 ++++++++-- .../components/panels/DeploymentsPanel.tsx | 351 ++++++++++++------ .../web/src/components/panels/ErrorsPanel.tsx | 42 ++- .../src/components/panels/SecurityPanel.tsx | 241 +++++++++--- .../src/components/panels/ServicesPanel.tsx | 281 ++++++++++---- apps/web/tsconfig.tsbuildinfo | 2 +- 16 files changed, 2024 insertions(+), 637 deletions(-) diff --git a/apps/web/src/app/[locale]/automation/page.tsx b/apps/web/src/app/[locale]/automation/page.tsx index d68838f4b..e69eeb9d3 100644 --- a/apps/web/src/app/[locale]/automation/page.tsx +++ b/apps/web/src/app/[locale]/automation/page.tsx @@ -3,9 +3,8 @@ /** * 自動化 (/automation) — Sprint 5 整合頁面 * 整合: 自動修復 + 神經指揮 + Drift 偵測 - * 2026-06-26 IA reset: 不再用頁面級分頁承載主導航,改為入口卡片。 * 建立時間: 2026-04-08 (台北時區) - * 更新時間: 2026-04-09 — 全部 Tab Panel 抽取完成 + * 更新時間: 2026-07-03 — UI/UX 全面升級,功能模組介紹卡片 */ import Link from 'next/link' @@ -13,51 +12,132 @@ import type { Route as NextRoute } from 'next' import { useTranslations } from 'next-intl' import { Activity, + ArrowRight, GitBranch, Wrench, + Zap, } from 'lucide-react' import { AppLayout } from '@/components/layout' +interface AutomationModule { + id: string + label: string + description: string + href: string + icon: typeof Wrench + accentColor: string + bgColor: string + borderColor: string + badgeColor: string +} + export default function AutomationPage({ params }: { params: { locale: string } }) { const t = useTranslations('nav') + const ta = useTranslations('automation') - const links = [ - { id: 'auto-repair', label: t('autoRepair'), href: `/${params.locale}/auto-repair`, Icon: Wrench }, - { id: 'neural-command', label: t('neuralCommand'), href: `/${params.locale}/neural-command`, Icon: Activity }, - { id: 'drift', label: t('drift'), href: `/${params.locale}/drift`, Icon: GitBranch }, + const modules: AutomationModule[] = [ + { + id: 'auto-repair', + label: t('autoRepair'), + description: ta('autoRepairDesc') ?? 'Automatically detect and repair infrastructure issues without human intervention.', + href: `/${params.locale}/auto-repair`, + icon: Wrench, + accentColor: 'text-[#d97757]', + bgColor: 'bg-[#fff7f3]', + borderColor: 'border-[#f5cdb8] hover:border-[#d97757]', + badgeColor: 'bg-[#fff7f3] border-[#f5cdb8] text-[#d97757]', + }, + { + id: 'neural-command', + label: t('neuralCommand'), + description: ta('neuralCommandDesc') ?? 'AI-powered command center with cognitive decision making and autonomous execution.', + href: `/${params.locale}/neural-command`, + icon: Activity, + accentColor: 'text-[#4A90D9]', + bgColor: 'bg-[#f0f6ff]', + borderColor: 'border-[#c7dcf7] hover:border-[#4A90D9]', + badgeColor: 'bg-[#f0f6ff] border-[#c7dcf7] text-[#4A90D9]', + }, + { + id: 'drift', + label: t('drift'), + description: ta('driftDesc') ?? 'Detect configuration drift across your entire infrastructure in real-time.', + href: `/${params.locale}/drift`, + icon: GitBranch, + accentColor: 'text-[#17602a]', + bgColor: 'bg-[#f0faf2]', + borderColor: 'border-[#8fc29a] hover:border-[#17602a]', + badgeColor: 'bg-[#f0faf2] border-[#8fc29a] text-[#17602a]', + }, ] return ( -
-
-
-

- {t('knowledgeAutomation')} -

+
+
+ + {/* Page header */} +
+
+
+
+
+

+ AI Automation +

+

+ {t('knowledgeAutomation')} +

+
+
+

+ {ta('pageDescription') ?? 'Intelligent automation modules that work together to keep your infrastructure healthy, compliant, and self-healing.'} +

-
- {links.map(({ id, label, href, Icon }) => ( + + {/* Module cards */} +
+ {modules.map(({ id, label, description, href, icon: Icon, accentColor, bgColor, borderColor, badgeColor }) => ( -
- - - + {/* Icon + badge */} +
+
+
+ {href.replace(`/${params.locale}`, '')}
-
- {label} + + {/* Content */} +
+

{label}

+

{description}

+
+ + {/* CTA */} +
+ {ta('open') ?? 'Open'} +
))}
-
+ + {/* Status overview hint */} +
+
+

+ {ta('allSystemsOperational') ?? 'All automation systems are operational and monitoring your infrastructure.'} +

+
+
) diff --git a/apps/web/src/app/[locale]/help/page.tsx b/apps/web/src/app/[locale]/help/page.tsx index d22cefa95..d301865f5 100644 --- a/apps/web/src/app/[locale]/help/page.tsx +++ b/apps/web/src/app/[locale]/help/page.tsx @@ -2,56 +2,121 @@ /** * 說明文件 Page - * @created 2026-04-01 ogt - 路由佔位 (awaiting implementation) - * @updated 2026-04-02 ogt - 升級為靜態系統版本資訊頁 + * @created 2026-04-01 ogt + * @updated 2026-07-03 — UI/UX 全面升級,Tailwind + 專業關於頁面設計 */ import { useTranslations } from 'next-intl' import { AppLayout } from '@/components/layout' +import { + BookOpen, + CheckCircle2, + Code2, + ExternalLink, + GitBranch, + Info, + Server, + ShieldCheck, + Sparkles, +} from 'lucide-react' export default function HelpPage({ params }: { params: { locale: string } }) { const t = useTranslations('help') const tb = useTranslations('brand') + const versionRows = [ + { label: t('appVersion'), value: tb('version'), icon: Sparkles }, + { label: t('platform'), value: tb('name'), icon: Server }, + { label: tb('environment'), value: tb('environment'), icon: ShieldCheck }, + ] + + const capabilities = [ + { icon: ShieldCheck, label: t('cap.security') ?? 'Security Governance' }, + { icon: GitBranch, label: t('cap.cicd') ?? 'CI/CD Automation' }, + { icon: Code2, label: t('cap.ai') ?? 'AI Decision Engine' }, + { icon: CheckCircle2, label: t('cap.observe') ?? 'Full-Stack Observability' }, + ] + return ( -
-
-

{t('title')}

-

{t('subtitle')}

-
+
+
- {/* Version Info Card */} -
-
- - {t('version')} -
-
- {[ - { label: t('appVersion'), value: tb('version') }, - { label: t('platform'), value: tb('name') }, - { label: tb('environment'), value: tb('environment') }, - ].map(row => ( -
- {row.label} - {row.value} + {/* Hero */} +
+
+
+
- ))} -
-
+
+

+ System Info +

+

{t('title')}

+

{t('subtitle')}

+
+
- {/* Docs Card */} -
-
- - {t('docs')} + {/* Capabilities */} +
+ {capabilities.map(({ icon: Icon, label }) => ( +
+
+ ))} +
-
- {t('docsDescription')} + + {/* Version Info */} +
+
+
+

{t('version')}

+
+
+ {versionRows.map(({ label, value, icon: Icon }) => ( +
+
+
+ + {value} + +
+ ))} +
+ + {/* Docs Card */} +
+
+
+

{t('docs')}

+
+
+
+
+ + +
+
+
-
+
) } diff --git a/apps/web/src/app/[locale]/notifications/page.tsx b/apps/web/src/app/[locale]/notifications/page.tsx index f73a6e868..7da06f80d 100644 --- a/apps/web/src/app/[locale]/notifications/page.tsx +++ b/apps/web/src/app/[locale]/notifications/page.tsx @@ -4,11 +4,23 @@ * 通知設定 Page * @created 2026-04-01 ogt - 路由佔位 (awaiting implementation) * @updated 2026-04-02 ogt - 升級為真實 UI,串接 /api/v1/notifications/channels + * @updated 2026-07-03 — UI/UX 全面升級,Tailwind + 頻道卡片 + 狀態徽章 */ import { useEffect, useState } from 'react' import { useTranslations } from 'next-intl' import { AppLayout } from '@/components/layout' +import { + Bell, + BellOff, + CheckCircle2, + MessageSquare, + RefreshCw, + Send, + Slack, + XCircle, +} from 'lucide-react' +import { cn } from '@/lib/utils' const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? '' @@ -18,20 +30,29 @@ interface Channel { status: string } -const statusColor = (s: string) => { - if (s === 'active' || s === 'enabled') return '#4caf50' - if (s === 'warning') return '#ff9800' - if (s === 'error' || s === 'disabled') return '#f44336' - return '#87867f' +const STATUS_CFG: Record = { + active: { badge: 'bg-[#f0faf2] border-[#8fc29a] text-[#17602a]', dot: 'bg-[#22C55E]', dotPulse: 'animate-pulse', icon: CheckCircle2, label: 'Active' }, + enabled: { badge: 'bg-[#f0faf2] border-[#8fc29a] text-[#17602a]', dot: 'bg-[#22C55E]', icon: CheckCircle2, label: 'Enabled' }, + warning: { badge: 'bg-[#fff7e8] border-[#d9b36f] text-[#8a5a08]', dot: 'bg-[#F59E0B]', icon: Bell, label: 'Warning' }, + error: { badge: 'bg-[#fff0ed] border-[#f5b8b8] text-[#9f2f25]', dot: 'bg-[#cc2200]', dotPulse: 'animate-pulse', icon: XCircle, label: 'Error' }, + disabled: { badge: 'bg-[#f5f4ed] border-[#e0ddd4] text-[#77736a]', dot: 'bg-[#87867f]', icon: BellOff, label: 'Disabled' }, } +const TYPE_ICON: Record = { + telegram: Send, + slack: Slack, + webhook: MessageSquare, +} + +const fallbackCfg = { badge: 'bg-[#f5f4ed] border-[#e0ddd4] text-[#77736a]', dot: 'bg-[#87867f]', icon: Bell, label: 'Unknown' } + export default function NotificationsPage({ params }: { params: { locale: string } }) { const t = useTranslations('notifications') const tc = useTranslations('common') const [channels, setChannels] = useState([]) const [loading, setLoading] = useState(true) - useEffect(() => { + const fetchData = () => { setLoading(true) fetch(`${API_BASE}/api/v1/notifications/channels`) .then(r => { @@ -41,57 +62,129 @@ export default function NotificationsPage({ params }: { params: { locale: string .then(data => { setChannels(Array.isArray(data) ? data : (data?.channels ?? [])) }) .catch(() => { setChannels([]) }) .finally(() => setLoading(false)) - }, []) + } + + useEffect(() => { fetchData() }, []) // eslint-disable-line react-hooks/exhaustive-deps + + const activeCount = channels.filter(c => c.status === 'active' || c.status === 'enabled').length + const errorCount = channels.filter(c => c.status === 'error').length return ( -
-
-

{t('title')}

-

{t('subtitle')}

-
+
+
-
-
- - {t('title')} + {/* Header */} +
+
+

+ Alerting +

+

{t('title')}

+

{t('subtitle')}

+
+
- {loading && ( -
{tc('loading')}
- )} - - {!loading && channels.length === 0 && ( -
{t('noChannels')}
- )} - + {/* Summary */} {!loading && channels.length > 0 && ( - - - - {[t('channel'), t('type'), t('status')].map(col => ( - - ))} - - - - {channels.map((ch, i) => ( - - - - - - ))} - -
{col}
{ch.name}{ch.type} - - - {ch.status} - -
+
+
+ {activeCount} + Active Channels +
+
0 ? 'border-[#f5b8b8] bg-[#fff0ed]' : 'border-[#e0ddd4] bg-white', + )}> + 0 ? 'text-[#9f2f25]' : 'text-[#77736a]')}> + {errorCount} + + Errors +
+
)} + + {/* Channels list */} +
+
+
+ + {/* Loading */} + {loading && ( +
+ {[0,1,2].map(i => ( +
+
+
+
+
+
+
+
+ ))} +
+ )} + + {/* Empty */} + {!loading && channels.length === 0 && ( +
+
+ )} + + {/* Channel cards */} + {!loading && channels.length > 0 && ( +
+ {channels.map((ch, i) => { + const cfg = STATUS_CFG[ch.status] ?? fallbackCfg + const StatusIcon = cfg.icon + const TypeIcon = TYPE_ICON[ch.type.toLowerCase()] ?? Bell + return ( +
+ {/* Type icon */} +
+
+ + {/* Name + type */} +
+

{ch.name}

+

{ch.type}

+
+ + {/* Status badge */} + + + +
+ ) + })} +
+ )} +
-
+
) } diff --git a/apps/web/src/app/[locale]/operations/page.tsx b/apps/web/src/app/[locale]/operations/page.tsx index 2801a1d23..ac3cf1fd1 100644 --- a/apps/web/src/app/[locale]/operations/page.tsx +++ b/apps/web/src/app/[locale]/operations/page.tsx @@ -3,67 +3,152 @@ /** * 營運 (/operations) — Sprint 5 整合頁面 * 整合: 部署管理 + 工單 + 成本分析 + 行動日誌 + 計費 - * 2026-06-26 IA reset: 不再用頁面級分頁承載主導航,改為入口卡片。 * 建立時間: 2026-04-08 (台北時區) - * 更新時間: 2026-04-09 — 全部 Tab Panel 抽取完成 + * 更新時間: 2026-07-03 — UI/UX 全面升級,功能模組入口儀表板 */ import Link from 'next/link' import type { Route as NextRoute } from 'next' import { useTranslations } from 'next-intl' import { + ArrowRight, BarChart3, ClipboardList, CreditCard, FileText, + Layers, Rocket, } from 'lucide-react' import { AppLayout } from '@/components/layout' +interface OperationModule { + id: string + label: string + description: string + href: string + icon: typeof Rocket + accent: string + bg: string + border: string +} + export default function OperationsPage({ params }: { params: { locale: string } }) { const t = useTranslations('nav') + const to = useTranslations('operations') - const links = [ - { id: 'deployments', label: t('deployments'), href: `/${params.locale}/deployments`, Icon: Rocket }, - { id: 'tickets', label: t('tickets'), href: `/${params.locale}/tickets`, Icon: ClipboardList }, - { id: 'cost', label: t('cost'), href: `/${params.locale}/cost`, Icon: BarChart3 }, - { id: 'actions', label: t('actions'), href: `/${params.locale}/action-logs`, Icon: FileText }, - { id: 'billing', label: t('billing'), href: `/${params.locale}/billing`, Icon: CreditCard }, + const modules: OperationModule[] = [ + { + id: 'deployments', + label: t('deployments'), + description: to('deploymentsDesc') ?? 'Manage and track all your deployment pipelines and release history.', + href: `/${params.locale}/deployments`, + icon: Rocket, + accent: 'text-[#4A90D9]', + bg: 'bg-[#f0f6ff]', + border: 'border-[#c7dcf7]', + }, + { + id: 'tickets', + label: t('tickets'), + description: to('ticketsDesc') ?? 'Track incidents, tasks, and service requests in a unified queue.', + href: `/${params.locale}/tickets`, + icon: ClipboardList, + accent: 'text-[#d97757]', + bg: 'bg-[#fff7f3]', + border: 'border-[#f5cdb8]', + }, + { + id: 'cost', + label: t('cost'), + description: to('costDesc') ?? 'Monitor AI execution performance, success rates, and effectiveness scores.', + href: `/${params.locale}/cost`, + icon: BarChart3, + accent: 'text-[#17602a]', + bg: 'bg-[#f0faf2]', + border: 'border-[#8fc29a]', + }, + { + id: 'actions', + label: t('actions'), + description: to('actionsDesc') ?? 'Audit trail of all automated actions taken across the platform.', + href: `/${params.locale}/action-logs`, + icon: FileText, + accent: 'text-[#8a5a08]', + bg: 'bg-[#fff7e8]', + border: 'border-[#d9b36f]', + }, + { + id: 'billing', + label: t('billing'), + description: to('billingDesc') ?? 'Usage statistics, operation breakdowns, and namespace-level audit logs.', + href: `/${params.locale}/billing`, + icon: CreditCard, + accent: 'text-[#9f2f25]', + bg: 'bg-[#fff0ed]', + border: 'border-[#f5b8b8]', + }, ] return ( -
-
-
-
-

- {t('operationsOverview')} -

+
+
+ + {/* Page header */} +
+
+
+
+
+

+ Platform Operations +

+

+ {t('operationsOverview')} +

+
+

+ {to('pageDescription') ?? 'Centralized operations hub for deployments, tickets, cost analysis, and audit logs.'} +

-
- {links.map(({ id, label, href, Icon }) => ( + + {/* Module grid: 2 + 3 layout */} +
+ {modules.map(({ id, label, description, href, icon: Icon, accent, bg, border }) => ( -
- - - + {/* Icon + route */} +
+
+
+ {href.replace(`/${params.locale}`, '')}
-
- {label} + + {/* Content */} +
+

{label}

+

{description}

+
+ + {/* CTA */} +
+ {to('open') ?? 'Open'} +
))}
-
+ +
) diff --git a/apps/web/src/app/[locale]/settings/page.tsx b/apps/web/src/app/[locale]/settings/page.tsx index 7e2cffa6b..ead151a73 100644 --- a/apps/web/src/app/[locale]/settings/page.tsx +++ b/apps/web/src/app/[locale]/settings/page.tsx @@ -110,12 +110,19 @@ export default function SettingsPage({ params }: { params: { locale: string } }) return ( -
- -

{t('title')}

-
+
+
+
+
+
+
+

System

+

{t('title')}

+
+
-
+
{/* Sidebar nav */}
+
+
) } diff --git a/apps/web/src/app/[locale]/topology/page.tsx b/apps/web/src/app/[locale]/topology/page.tsx index 953610ed6..6782a6724 100644 --- a/apps/web/src/app/[locale]/topology/page.tsx +++ b/apps/web/src/app/[locale]/topology/page.tsx @@ -16,6 +16,7 @@ import { useTranslations } from 'next-intl' import { AppLayout } from '@/components/layout' import { ServiceTopology } from '@/components/topology' import { useDashboardStore } from '@/stores/dashboard.store' +import { Network } from 'lucide-react' export default function TopologyPage({ params }: { params: { locale: string } }) { const t = useTranslations('topology') @@ -30,30 +31,24 @@ export default function TopologyPage({ params }: { params: { locale: string } }) return ( -
+
{/* 標題列 */} -
-

- {t('title')} -

- {t('subtitle')} +
+
+
+
+

{t('title')}

+

{t('subtitle')}

+
+
+ + Live +
{/* React Flow 拓撲圖 (全高度) */} -
+
= { healthy: '#22C55E', warning: '#F59E0B', critical: '#cc2200', unknown: '#87867f' } -const STATUS_CHART_COLOR: Record = { healthy: 'success', warning: 'warning', critical: 'error', unknown: 'primary' } +const STATUS_CFG: Record = { + healthy: { dot: 'bg-[#22C55E]', ring: 'ring-[#22C55E]/20', badge: 'bg-[#f0faf2] border-[#8fc29a] text-[#17602a]', icon: CheckCircle2 }, + warning: { dot: 'bg-[#F59E0B]', ring: 'ring-[#F59E0B]/20', badge: 'bg-[#fff7e8] border-[#d9b36f] text-[#8a5a08]', icon: AlertCircle }, + critical: { dot: 'bg-[#cc2200]', ring: 'ring-[#cc2200]/20', badge: 'bg-[#fff0ed] border-[#f5b8b8] text-[#9f2f25]', icon: XCircle }, + unknown: { dot: 'bg-[#87867f]', ring: 'ring-[#87867f]/20', badge: 'bg-[#f5f4ed] border-[#e0ddd4] text-[#77736a]', icon: Activity }, +} + +const STATUS_CHART_COLOR: Record = { + healthy: 'success', warning: 'warning', critical: 'error', unknown: 'primary', +} export function ApmPanel() { const t = useTranslations('apm') @@ -28,62 +39,162 @@ export function ApmPanel() { const [loading, setLoading] = useState(true) const [error, setError] = useState(null) - useEffect(() => { + const fetchData = () => { + setLoading(true) + setError(null) fetch(`${API_BASE}/api/v1/metrics/gold?service_name=awoooi-api&time_window_minutes=10`) .then(r => r.json()) .then((d: GoldMetricsResponse) => { setData(d); setLoading(false) }) .catch(err => { setError(String(err)); setLoading(false) }) - }, []) + } + + useEffect(() => { fetchData() }, []) // eslint-disable-line react-hooks/exhaustive-deps return ( -
-
-
-

{t('title')}

-

{t('subtitle')}

+
+ {/* Header */} +
+
+

+ Application Performance +

+

{t('title')}

+

{t('subtitle')}

+
+ - - ↗ {t('openSignoz')} -
- {loading ? ( -
{t('loading')}
- ) : error ? ( -
{t('error')}
- ) : data && data.metrics.length > 0 ? ( + + {/* Loading skeleton */} + {loading && ( +
+ {[0,1,2,3].map(i => ( +
+
+
+
+
+
+
+
+ ))} +
+ )} + + {/* Error */} + {!loading && error && ( +
+
+ )} + + {/* Metrics grid */} + {!loading && !error && data && data.metrics.length > 0 && ( <> -
+
{data.metrics.map((m, i) => { const trendPoints = (m.trend ?? []).map((v, idx) => ({ timestamp: idx, value: v })) const hasTrend = trendPoints.length > 1 const chartColor = STATUS_CHART_COLOR[m.status] ?? 'primary' + const cfg = STATUS_CFG[m.status] ?? STATUS_CFG['unknown'] + const StatusIcon = cfg.icon return ( -
-
{m.label}
-
- {typeof m.value === 'number' ? m.value.toFixed(2) : m.value} - {m.unit && {m.unit}} +
+ {/* Card header */} +
+

+ {m.label} +

+ +
-
- - {m.status} + {/* Value */} +
+ + {typeof m.value === 'number' ? m.value.toFixed(2) : m.value} + + {m.unit && ( + {m.unit} + )}
- {hasTrend && } + {/* Sparkline */} + {hasTrend && ( + + )} + {!hasTrend &&
}
) })}
-
- - Service: {data.service_name}{' · '}{new Date(data.timestamp).toLocaleString('zh-TW', { timeZone: 'Asia/Taipei' })} + + {/* Footer metadata */} +
+
+ + Service: {data.service_name} + + + {new Date(data.timestamp).toLocaleString('zh-TW', { timeZone: 'Asia/Taipei' })}
- ) : ( -
-
-
{t('noData')}
-
{t('noDataDescription')}
+ )} + + {/* No data */} + {!loading && !error && data && data.metrics.length === 0 && ( +
+
+ )} + + {!loading && !error && !data && ( +
+
)}
diff --git a/apps/web/src/components/panels/AppsPanel.tsx b/apps/web/src/components/panels/AppsPanel.tsx index 262c8ac78..88763009c 100644 --- a/apps/web/src/components/panels/AppsPanel.tsx +++ b/apps/web/src/components/panels/AppsPanel.tsx @@ -7,10 +7,22 @@ * 供原始頁面和整合頁面 (/observability) 共用 * * 建立時間: 2026-04-09 (台北時區) + * 更新時間: 2026-07-03 — UI/UX 全面升級,Tailwind + 卡片 Grid + 狀態徽章 */ import { useState, useEffect } from 'react' import { useTranslations } from 'next-intl' +import { + AlertCircle, + CheckCircle2, + Clock, + Globe, + RefreshCw, + Server, + WifiOff, + XCircle, +} from 'lucide-react' +import { cn } from '@/lib/utils' const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? '' @@ -28,12 +40,51 @@ interface Host { services: HostService[] } -const STATUS_COLOR: Record = { - up: '#22C55E', - healthy: '#22C55E', - down: '#cc2200', - degraded: '#F59E0B', - unreachable: '#87867f', +interface ServiceRow extends HostService { + hostName: string + hostIp: string +} + +const STATUS_CFG: Record = { + up: { dot: 'bg-[#22C55E]', dotPulse: 'animate-pulse', badge: 'bg-[#f0faf2] border-[#8fc29a] text-[#17602a]', icon: CheckCircle2, label: 'UP' }, + healthy: { dot: 'bg-[#22C55E]', badge: 'bg-[#f0faf2] border-[#8fc29a] text-[#17602a]', icon: CheckCircle2, label: 'Healthy' }, + down: { dot: 'bg-[#cc2200]', dotPulse: 'animate-pulse', badge: 'bg-[#fff0ed] border-[#f5b8b8] text-[#9f2f25]', icon: XCircle, label: 'DOWN' }, + degraded: { dot: 'bg-[#F59E0B]', badge: 'bg-[#fff7e8] border-[#d9b36f] text-[#8a5a08]', icon: AlertCircle, label: 'Degraded' }, + unreachable: { dot: 'bg-[#87867f]', badge: 'bg-[#f5f4ed] border-[#e0ddd4] text-[#77736a]', icon: WifiOff, label: 'N/A' }, +} + +function StatusBadge({ status }: { status: string }) { + const cfg = STATUS_CFG[status] ?? STATUS_CFG['unreachable'] + const Icon = cfg.icon + return ( + + + {cfg.label} + + ) +} + +function LatencyBadge({ ms }: { ms: number | null }) { + if (ms === null) return + const color = + ms < 50 ? 'text-[#17602a]' : + ms < 200 ? 'text-[#8a5a08]' : + 'text-[#9f2f25]' + return ( + + + ) } export function AppsPanel() { @@ -42,62 +93,156 @@ export function AppsPanel() { const [loading, setLoading] = useState(true) const [error, setError] = useState(null) - useEffect(() => { + const fetchData = () => { + setLoading(true) + setError(null) 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 => + useEffect(() => { fetchData() }, []) // eslint-disable-line react-hooks/exhaustive-deps + + const allServices: ServiceRow[] = hosts.flatMap(h => h.services.map(s => ({ ...s, hostName: h.name, hostIp: h.ip })) ) + const totalUp = allServices.filter(s => s.status === 'up' || s.status === 'healthy').length + const totalDown = allServices.filter(s => s.status === 'down').length + const totalDegraded = allServices.filter(s => s.status === 'degraded').length + return ( -
-
-

{t('title')}

-

{t('subtitle')}

-
-
-
- - {t('title')} ({loading ? '...' : allServices.length}) +
+ {/* Header */} +
+
+

+ Services +

+

{t('title')}

+

{t('subtitle')}

- {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} - -
- )} +
+ + {/* Summary pills */} + {!loading && allServices.length > 0 && ( +
+
+
+ {totalDown > 0 && ( +
+
+ )} + {totalDegraded > 0 && ( +
+
+ )} +
+ )} + + {/* Loading skeleton */} + {loading && ( +
+ {[0,1,2,3,4,5].map(i => ( +
+
+
+
+
+
+
+
+ ))} +
+ )} + + {/* Error */} + {!loading && error && ( +
+
+ )} + + {/* Service cards grid */} + {!loading && !error && allServices.length > 0 && ( +
+ {allServices.map((s, i) => { + const cfg = STATUS_CFG[s.status] ?? STATUS_CFG['unreachable'] + return ( +
+ {/* Service name + status */} +
+
+
+

{s.name}

+
+ +
+ + {/* Host info */} +
+
+ + {/* Port + Latency */} +
+ {s.port != null && ( +
+
+ )} +
+ +
+
+
+ ) + })} +
+ )} + + {/* Empty */} + {!loading && !error && allServices.length === 0 && ( +
+
+ )}
) } diff --git a/apps/web/src/components/panels/BillingPanel.tsx b/apps/web/src/components/panels/BillingPanel.tsx index 15b261ff9..ec128eca7 100644 --- a/apps/web/src/components/panels/BillingPanel.tsx +++ b/apps/web/src/components/panels/BillingPanel.tsx @@ -7,10 +7,21 @@ * 供原始頁面和整合頁面 (/operations) 共用 * * 建立時間: 2026-04-09 (台北時區) + * 更新時間: 2026-07-03 — UI/UX 全面升級,Tailwind + Stat Cards + 分類視覺化 */ import { useState, useEffect } from 'react' import { useTranslations } from 'next-intl' +import { + Activity, + AlertCircle, + BarChart3, + CheckCircle2, + Clock, + RefreshCw, + XCircle, +} from 'lucide-react' +import { cn } from '@/lib/utils' const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? '' @@ -31,80 +42,217 @@ export function BillingPanel() { const [loading, setLoading] = useState(true) const [error, setError] = useState(null) - useEffect(() => { + const fetchData = () => { + setLoading(true) + setError(null) 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' } + useEffect(() => { fetchData() }, []) // eslint-disable-line react-hooks/exhaustive-deps return ( -
-
-

{t('title')}

-

{t('subtitle')}

+
+ {/* Header */} +
+
+

+ Operations +

+

{t('title')}

+

{t('subtitle')}

+
+
- {loading ? ( -
{t('loading')}
- ) : error ? ( -
{t('error')}
- ) : stats ? ( - <> -
+ {/* Loading */} + {loading && ( +
+ {[0,1,2,3].map(i => ( +
+
+
+
+ ))} +
+ )} + + {/* Error */} + {!loading && error && ( +
+
+ )} + + {/* Data */} + {!loading && !error && stats && ( +
+ {/* Stat cards */} +
{[ - { 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}
+ { + label: t('totalUsage'), + value: stats.total_executions.toLocaleString(), + icon: Activity, + accent: 'text-[#4A90D9]', + bg: 'bg-[#f0f6ff]', + border: 'border-[#c7dcf7]', + }, + { + label: t('last24h'), + value: stats.last_24h_count.toLocaleString(), + icon: Clock, + accent: 'text-[#d97757]', + bg: 'bg-[#fff7f3]', + border: 'border-[#f5cdb8]', + }, + { + label: t('successRate'), + value: `${(stats.success_rate * 100).toFixed(1)}%`, + icon: CheckCircle2, + accent: 'text-[#17602a]', + bg: 'bg-[#f0faf2]', + border: 'border-[#8fc29a]', + }, + { + label: t('avgDuration'), + value: stats.avg_duration_ms != null ? `${stats.avg_duration_ms.toFixed(0)}ms` : '—', + icon: BarChart3, + accent: 'text-[#8a5a08]', + bg: 'bg-[#fff7e8]', + border: 'border-[#d9b36f]', + }, + ].map(({ label, value, icon: Icon, accent, bg, border }) => ( +
+
+

+ {label} +

+
+

+ {value} +

))}
- {Object.keys(stats.by_operation_type).length > 0 && ( -
-
- By Operation Type + {/* Success vs Failure bar */} + {(stats.success_count > 0 || stats.failure_count > 0) && ( +
+
+
+
+
+ + +
+
+
+ + +
- - - {Object.entries(stats.by_operation_type).sort(([, a], [, b]) => b - a).map(([op, count]) => ( - - - - - ))} - -
{op}{count}
)} - {Object.keys(stats.by_namespace).length > 0 && ( -
-
- By Namespace + {/* By Operation Type */} + {Object.keys(stats.by_operation_type).length > 0 && ( +
+
+
+
+ {Object.entries(stats.by_operation_type) + .sort(([, a], [, b]) => b - a) + .map(([op, count]) => { + const max = Math.max(...Object.values(stats.by_operation_type)) + const pct = max > 0 ? (count / max) * 100 : 0 + return ( +
+ {op} +
+
+
+ + {count.toLocaleString()} + +
+ ) + })}
- - - {Object.entries(stats.by_namespace).sort(([, a], [, b]) => b - a).map(([ns, count]) => ( - - - - - ))} - -
{ns}{count}
)} - - ) : ( -
{t('noData')}
+ + {/* By Namespace */} + {Object.keys(stats.by_namespace).length > 0 && ( +
+
+
+
+ {Object.entries(stats.by_namespace) + .sort(([, a], [, b]) => b - a) + .map(([ns, count]) => { + const max = Math.max(...Object.values(stats.by_namespace)) + const pct = max > 0 ? (count / max) * 100 : 0 + return ( +
+ {ns} +
+
+
+ + {count.toLocaleString()} + +
+ ) + })} +
+
+ )} +
+ )} + + {/* No data */} + {!loading && !error && !stats && ( +
+
)}
) diff --git a/apps/web/src/components/panels/CompliancePanel.tsx b/apps/web/src/components/panels/CompliancePanel.tsx index 94065ef06..63afa4a81 100644 --- a/apps/web/src/components/panels/CompliancePanel.tsx +++ b/apps/web/src/components/panels/CompliancePanel.tsx @@ -3,11 +3,22 @@ /** * CompliancePanel — 合規狀態面板 (不含 AppLayout) * Sprint 5R: 從 /compliance/page.tsx 抽取 - * @updated 2026-04-09 Claude Opus 4.6 Asia/Taipei + * @updated 2026-07-03 — UI/UX 全面升級,Tailwind + 視覺化嚴重性分佈 + Stat Cards */ import { useState, useEffect } from 'react' import { useTranslations } from 'next-intl' +import { + AlertTriangle, + BarChart3, + CheckCircle2, + RefreshCw, + ShieldCheck, + Wrench, + XCircle, + Zap, +} from 'lucide-react' +import { cn } from '@/lib/utils' import { IwoooSReadOnlyBridge } from '@/components/security/iwooos-read-only-bridge' const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? '' @@ -26,6 +37,13 @@ interface AutoRepairStats { auto_repair_eligible: boolean } +const SEV_CFG: Record = { + P0: { color: 'text-[#9f2f25]', bg: 'bg-[#cc2200]', border: 'border-[#f5b8b8]' }, + P1: { color: 'text-[#8a5a08]', bg: 'bg-[#F59E0B]', border: 'border-[#d9b36f]' }, + P2: { color: 'text-[#1f5b9b]', bg: 'bg-[#4A90D9]', border: 'border-[#c7dcf7]' }, + P3: { color: 'text-[#17602a]', bg: 'bg-[#22C55E]', border: 'border-[#8fc29a]' }, +} + export function CompliancePanel() { const t = useTranslations('compliance') const [summary, setSummary] = useState(null) @@ -33,7 +51,9 @@ export function CompliancePanel() { const [loading, setLoading] = useState(true) const [error, setError] = useState(null) - useEffect(() => { + const fetchData = () => { + setLoading(true) + setError(null) Promise.all([ fetch(`${API_BASE}/api/v1/stats/incidents/summary?days=30`).then(r => r.ok ? r.json() : null).catch(() => null), fetch(`${API_BASE}/api/v1/auto-repair/stats`).then(r => r.ok ? r.json() : null).catch(() => null), @@ -44,77 +64,174 @@ export function CompliancePanel() { }) .catch(() => setError('load_failed')) .finally(() => setLoading(false)) - }, []) + } - const cardStyle = { background: '#fff', border: '0.5px solid #e0ddd4', borderRadius: 12, padding: '16px 18px' } - const sevColors: Record = { P0: '#cc2200', P1: '#F59E0B', P2: '#4A90D9', P3: '#22C55E' } + useEffect(() => { fetchData() }, []) // eslint-disable-line react-hooks/exhaustive-deps + + const resolvedColor = + summary && summary.resolved_rate >= 80 ? 'text-[#17602a]' : + summary && summary.resolved_rate >= 50 ? 'text-[#8a5a08]' : + 'text-[#9f2f25]' + + const resolvedBg = + summary && summary.resolved_rate >= 80 ? 'bg-[#f0faf2] border-[#8fc29a]' : + summary && summary.resolved_rate >= 50 ? 'bg-[#fff7e8] border-[#d9b36f]' : + 'bg-[#fff0ed] border-[#f5b8b8]' + + const maxSevCount = summary + ? Math.max(...summary.severity_distribution.map(x => x.count), 1) + : 1 return ( -
-
-

{t('title')}

-

{t('subtitle')}

+
+ {/* Header */} +
+
+

+ Compliance +

+

{t('title')}

+

{t('subtitle')}

+
+
+
+
+ +
- + {/* IwoooS Bridge */} +
+ +
- {loading ? ( -
{t('loading')}
- ) : error ? ( -
{t('error')}
- ) : ( - <> -
- {summary && <> -
-
{t('totalIncidents')}
-
{summary.total_incidents}
-
{t('window30Days')}
+ {/* Loading */} + {loading && ( +
+ {[0,1,2,3,4].map(i => ( +
+
+
+
+ ))} +
+ )} + + {/* Error */} + {!loading && error && ( +
+
+ )} + + {/* Data */} + {!loading && !error && ( +
+ {/* Incident stats */} + {summary && ( +
+
+
+

+ {t('totalIncidents')} +

+
+

+ {summary.total_incidents.toLocaleString()} +

+

{t('window30Days')}

-
-
{t('resolvedRate')}
-
= 80 ? '#22C55E' : summary.resolved_rate >= 50 ? '#F59E0B' : '#cc2200' }}> +
+
+

+ {t('resolvedRate')} +

+
+

{summary.resolved_rate.toFixed(1)}% -

+

- } - {repairStats && <> -
-
{t('approvedPlaybooks')}
-
{repairStats.approved_playbooks}
-
{t('highQualityPlaybooks')}: {repairStats.high_quality_playbooks}
-
-
-
{t('executionSuccessRate')}
-
= 0.8 ? '#22C55E' : '#F59E0B' }}> - {(repairStats.overall_success_rate * 100).toFixed(1)}% -
-
-
-
{t('autoRepairEligible')}
-
- {repairStats.auto_repair_eligible ? t('yes') : t('no')} -
-
- } -
+
+ )} + {/* Severity distribution */} {summary && summary.severity_distribution.length > 0 && ( -
-
- {t('severityDistribution')} +
+
+
-
- {summary.severity_distribution.map(({ severity, count }) => ( -
-
{severity}
-
{count}
+
+
+ {summary.severity_distribution.map(({ severity, count }) => { + const cfg = SEV_CFG[severity] ?? { color: 'text-[#77736a]', bg: 'bg-[#87867f]', border: 'border-[#e0ddd4]' } + const pct = maxSevCount > 0 ? (count / maxSevCount) * 100 : 0 + return ( +
+ {severity} +
+
+
+ {count} +
+ ) + })} +
+
+
+ )} + + {/* Auto-repair stats */} + {repairStats && ( +
+
+
+
+ {[ + { label: t('approvedPlaybooks'), value: repairStats.approved_playbooks, sub: `${repairStats.high_quality_playbooks} ${t('highQualityPlaybooks')}`, icon: CheckCircle2, color: 'text-[#4A90D9]' }, + { label: t('executionSuccessRate'), value: `${(repairStats.overall_success_rate * 100).toFixed(1)}%`, sub: `${repairStats.total_executions} total`, icon: Zap, color: repairStats.overall_success_rate >= 0.8 ? 'text-[#17602a]' : 'text-[#8a5a08]' }, + { label: t('autoRepairEligible'), value: repairStats.auto_repair_eligible ? t('yes') : t('no'), sub: '', icon: Wrench, color: repairStats.auto_repair_eligible ? 'text-[#17602a]' : 'text-[#9f2f25]' }, + ].map(({ label, value, sub, icon: Icon, color }) => ( +
+
+

{label}

+
+

{value}

+ {sub &&

{sub}

}
))}
)} - +
)}
) diff --git a/apps/web/src/components/panels/CostPanel.tsx b/apps/web/src/components/panels/CostPanel.tsx index 02fab0a8c..0b97c04e4 100644 --- a/apps/web/src/components/panels/CostPanel.tsx +++ b/apps/web/src/components/panels/CostPanel.tsx @@ -7,10 +7,13 @@ * 供原始頁面和整合頁面 (/operations) 共用 * * 建立時間: 2026-04-09 (台北時區) + * 更新時間: 2026-07-03 — UI/UX 全面升級,Tailwind + Stat Cards + 視覺化分佈圖 */ import { useState, useEffect } from 'react' import { useTranslations } from 'next-intl' +import { TrendingUp, Zap, CheckCircle2, Star, AlertCircle, RefreshCw } from 'lucide-react' +import { cn } from '@/lib/utils' const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? '' @@ -24,70 +27,181 @@ interface AIPerformance { effectiveness_distribution: Record } +const SCORE_CONFIG = [ + { score: 1, color: 'text-[#cc2200]', bg: 'bg-[#cc2200]', label: '極差' }, + { score: 2, color: 'text-[#F59E0B]', bg: 'bg-[#F59E0B]', label: '差' }, + { score: 3, color: 'text-[#87867f]', bg: 'bg-[#87867f]', label: '普通' }, + { score: 4, color: 'text-[#4A90D9]', bg: 'bg-[#4A90D9]', label: '好' }, + { score: 5, color: 'text-[#22C55E]', bg: 'bg-[#22C55E]', label: '極優' }, +] + export function CostPanel() { const t = useTranslations('cost') const [data, setData] = useState(null) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) - useEffect(() => { + const fetchData = () => { + setLoading(true) + setError(null) 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' } + useEffect(() => { fetchData() }, []) // eslint-disable-line react-hooks/exhaustive-deps + + const statCards = data ? [ + { + label: t('totalProposals'), + value: data.total_proposals.toLocaleString(), + icon: TrendingUp, + accent: 'text-[#4A90D9]', + bg: 'bg-[#f0f6ff]', + border: 'border-[#c7dcf7]', + }, + { + label: t('executionRate'), + value: `${data.execution_rate.toFixed(1)}%`, + icon: Zap, + accent: 'text-[#d97757]', + bg: 'bg-[#fff7f3]', + border: 'border-[#f5cdb8]', + }, + { + label: t('successRate'), + value: `${data.success_rate.toFixed(1)}%`, + icon: CheckCircle2, + accent: 'text-[#17602a]', + bg: 'bg-[#f0faf2]', + border: 'border-[#8fc29a]', + }, + { + label: t('avgEffectiveness'), + value: data.avg_effectiveness != null ? data.avg_effectiveness.toFixed(2) : '—', + icon: Star, + accent: 'text-[#8a5a08]', + bg: 'bg-[#fff7e8]', + border: 'border-[#d9b36f]', + }, + ] : [] + + const maxDistCount = data + ? Math.max(...[1,2,3,4,5].map(s => data.effectiveness_distribution[String(s)] ?? 0), 1) + : 1 return ( -
-
-

{t('title')}

-

{t('subtitle')}

+
+ {/* Header */} +
+
+

+ AI Performance +

+

{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}
+ {/* Loading */} + {loading && ( +
+ {[0,1,2,3].map(i => ( +
+
+
+
+ ))} +
+ )} + + {/* Error */} + {!loading && error && ( +
+
+ )} + + {/* Data */} + {!loading && !error && data && ( +
+ {/* Stat Cards */} +
+ {statCards.map(({ label, value, icon: Icon, accent, bg, border }) => ( +
+
+

+ {label} +

+
+

+ {value} +

))}
- {data.effectiveness_distribution && Object.keys(data.effectiveness_distribution).length > 0 && ( -
-
- Effectiveness Distribution (1–5) + {/* Effectiveness Distribution */} + {Object.keys(data.effectiveness_distribution).length > 0 && ( +
+
+
-
- {[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}
-
- ) - })} +
+
+ {SCORE_CONFIG.map(({ score, color, bg, label }) => { + const count = data.effectiveness_distribution[String(score)] ?? 0 + const pct = maxDistCount > 0 ? (count / maxDistCount) * 100 : 0 + return ( +
+ {label} + {/* Mini bar */} +
+
+
+ {count} + ★{score} +
+ ) + })} +
)} - - ) : ( -
{t('noData')}
+
+ )} + + {/* No data */} + {!loading && !error && !data && ( +
+
)}
) diff --git a/apps/web/src/components/panels/DeploymentsPanel.tsx b/apps/web/src/components/panels/DeploymentsPanel.tsx index 2f4507e2a..f7dda12a1 100644 --- a/apps/web/src/components/panels/DeploymentsPanel.tsx +++ b/apps/web/src/components/panels/DeploymentsPanel.tsx @@ -7,11 +7,23 @@ * 供原始頁面和整合頁面 (/operations) 共用 * * 建立時間: 2026-04-09 (台北時區) + * 更新時間: 2026-07-03 — UI/UX 全面升級,Tailwind + 時間軸事件 + 主機卡片 */ import { useState, useEffect } from 'react' import { useTranslations } from 'next-intl' -import { AlertTriangle, CheckCircle2, Clock3, GitCommit, RefreshCw } from 'lucide-react' +import { + AlertTriangle, + CheckCircle2, + Clock3, + ExternalLink, + GitCommit, + RefreshCw, + Rocket, + Server, + XCircle, +} from 'lucide-react' +import { cn } from '@/lib/utils' const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? '' @@ -54,19 +66,19 @@ interface CicdEventsResponse { limit: number } -const STATUS_COLOR: Record = { - up: '#22C55E', - healthy: '#22C55E', - down: '#cc2200', - degraded: '#F59E0B', - unreachable: '#87867f', +const HOST_STATUS_CFG: Record = { + up: { dot: 'bg-[#22C55E]', badge: 'bg-[#f0faf2] border-[#8fc29a] text-[#17602a]' }, + healthy: { dot: 'bg-[#22C55E]', badge: 'bg-[#f0faf2] border-[#8fc29a] text-[#17602a]' }, + down: { dot: 'bg-[#cc2200]', dotPulse: 'animate-pulse', badge: 'bg-[#fff0ed] border-[#f5b8b8] text-[#9f2f25]' }, + degraded: { dot: 'bg-[#F59E0B]', badge: 'bg-[#fff7e8] border-[#d9b36f] text-[#8a5a08]' }, + unreachable: { dot: 'bg-[#87867f]', badge: 'bg-[#f5f4ed] border-[#e0ddd4] text-[#77736a]' }, } -const CICD_STATUS_STYLE: Record = { - success: { border: '#9bc7a4', background: '#f0faf2', color: '#17602a' }, - running: { border: '#9bb6d9', background: '#eef5ff', color: '#1f5b9b' }, - pending: { border: '#d9b36f', background: '#fff7e8', color: '#8a5a08' }, - failed: { border: '#e2a29b', background: '#fff0ef', color: '#9f2f25' }, +const CICD_STATUS_CFG: Record = { + success: { border: 'border-[#8fc29a]', bg: 'bg-[#f0faf2]', text: 'text-[#17602a]', icon: CheckCircle2 }, + running: { border: 'border-[#c7dcf7]', bg: 'bg-[#f0f6ff]', text: 'text-[#1f5b9b]', icon: RefreshCw }, + pending: { border: 'border-[#d9b36f]', bg: 'bg-[#fff7e8]', text: 'text-[#8a5a08]', icon: Clock3 }, + failed: { border: 'border-[#f5b8b8]', bg: 'bg-[#fff0ed]', text: 'text-[#9f2f25]', icon: XCircle }, } const CICD_STATUS_LABEL_KEYS: Record = { @@ -127,134 +139,249 @@ export function DeploymentsPanel() { const displayHosts = k3sHosts.length > 0 ? k3sHosts : hosts return ( -
-
-

{t('title')}

-

{t('subtitle')}

+
+ {/* Header */} +
+
+

+ Deployments +

+

{t('title')}

+

{t('subtitle')}

+
+
+
-
-
-
-