diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d693c84..966e440 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,62 +1,93 @@ -import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; +import { Suspense, lazy } from 'react'; +import { BrowserRouter, Routes, Route, Navigate, useLocation } from 'react-router-dom'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { ThemeProvider } from '@/hooks/useTheme'; import { MainLayout } from '@/components/layout/MainLayout'; import { ConfirmProvider } from '@/components/ui/confirm-dialog'; import { Login } from '@/components/auth/Login'; import { ProtectedRoute } from '@/components/auth/ProtectedRoute'; -import { Dashboard } from '@/components/dashboard/Dashboard'; -import { ServerManager } from '@/components/server/ServerManager'; -import { CloudAccountManager } from '@/components/cloud/CloudAccountManager'; -import { DomainManager } from '@/components/cloud/DomainManager'; -import { RegionManager } from '@/components/cloud/RegionManager'; -import { PlatformManager } from '@/components/cloud/PlatformManager'; -import { CloudBilling } from '@/components/cloud/CloudBilling'; -import { KubernetesClusters } from '@/components/kubernetes/KubernetesClusters'; -import ClusterOverviewPage from '@/components/kubernetes/ClusterOverviewPage'; -import NodeDetailPage from '@/components/kubernetes/NodeDetailPage'; -import NamespaceOverviewPage from '@/components/kubernetes/NamespaceOverviewPage'; -import NamespaceResourcePage from '@/components/kubernetes/NamespaceResourcePage'; -import DeploymentDetailPage from '@/components/kubernetes/DeploymentDetailPage'; -import WorkloadViewPage from '@/components/kubernetes/WorkloadViewPage'; -import { TopologyView } from '@/components/kubernetes/TopologyView'; -import { KubernetesNodes } from '@/components/kubernetes/KubernetesNodes'; -import { KubernetesWorkloads } from '@/components/kubernetes/KubernetesWorkloads'; -import PodMetricsPage from '@/components/kubernetes/PodMetricsPage'; -import EventsPage from '@/components/kubernetes/EventsPage'; -import CapacityPage from '@/components/kubernetes/CapacityPage'; -import ClusterComparePage from '@/components/kubernetes/ClusterComparePage'; -import { AlertManager } from '@/components/monitor/AlertManager'; -import { ApprovalFlowManager } from '@/components/approval/ApprovalFlow'; -import { ScriptManager } from '@/components/automation/ScriptManager'; -import { SystemManager } from '@/components/system/SystemManager'; -import CredentialManager from '@/components/deploy/CredentialManager'; -import { PipelineManager } from '@/components/deploy/PipelineManager'; -import DeployCredentials from '@/components/deploy/DeployCredentials'; -import PipelineEditor from '@/components/deploy/PipelineEditor'; -import PipelineRunDetail from '@/components/deploy/PipelineRunDetail'; -import DeployRBAC from '@/components/deploy/DeployRBAC'; -import UserGroupsManager from '@/components/deploy/UserGroupsManager'; -import { CronJobManager } from '@/components/automation/CronJobManager'; -import AnsibleManager from '@/components/automation/AnsibleManager'; -import AnsibleWorkflow from '@/components/automation/AnsibleWorkflow'; -import { AIOpsAssistant } from '@/components/aiops/AIOpsAssistant'; -import { AIAlertAnalyzer } from '@/components/aiops/AIAlertAnalyzer'; -import { AIPredictiveAnalytics } from '@/components/aiops/AIPredictiveAnalytics'; -import AlertCenter from '@/components/monitor/alert-center/AlertCenter'; -import TraceManager from '@/components/monitor/trace/TraceManager'; -import { HostMonitor } from '@/pages/HostMonitor'; -import HostMonitorDashboard from '@/components/monitor/dashboard/HostMonitorDashboard'; -import GrafanaDashboard from '@/components/monitor/dashboard/GrafanaDashboard'; -import { MonitorCenter } from '@/pages/MonitorCenter'; -import { MetricsExplorer } from '@/pages/MetricsExplorer'; -import DatasourcePage from '@/components/monitor/datasource/DatasourcePage'; +import { PageTransition } from '@/components/layout/PageTransition'; +import { ErrorBoundary } from '@/components/layout/ErrorBoundary'; + +// Lazy load page components for code splitting +const Dashboard = lazy(() => import('@/components/dashboard/Dashboard').then(m => ({ default: m.Dashboard }))); +const ServerManager = lazy(() => import('@/components/server/ServerManager').then(m => ({ default: m.ServerManager }))); +const CloudAccountManager = lazy(() => import('@/components/cloud/CloudAccountManager').then(m => ({ default: m.CloudAccountManager }))); +const DomainManager = lazy(() => import('@/components/cloud/DomainManager').then(m => ({ default: m.DomainManager }))); +const RegionManager = lazy(() => import('@/components/cloud/RegionManager').then(m => ({ default: m.RegionManager }))); +const PlatformManager = lazy(() => import('@/components/cloud/PlatformManager').then(m => ({ default: m.PlatformManager }))); +const CloudBilling = lazy(() => import('@/components/cloud/CloudBilling').then(m => ({ default: m.CloudBilling }))); +const KubernetesClusters = lazy(() => import('@/components/kubernetes/KubernetesClusters').then(m => ({ default: m.KubernetesClusters }))); +const ClusterOverviewPage = lazy(() => import('@/components/kubernetes/ClusterOverviewPage').then(m => ({ default: m.default }))); +const NodeDetailPage = lazy(() => import('@/components/kubernetes/NodeDetailPage').then(m => ({ default: m.default }))); +const NamespaceOverviewPage = lazy(() => import('@/components/kubernetes/NamespaceOverviewPage').then(m => ({ default: m.default }))); +const NamespaceResourcePage = lazy(() => import('@/components/kubernetes/NamespaceResourcePage').then(m => ({ default: m.default }))); +const DeploymentDetailPage = lazy(() => import('@/components/kubernetes/DeploymentDetailPage').then(m => ({ default: m.default }))); +const WorkloadViewPage = lazy(() => import('@/components/kubernetes/WorkloadViewPage').then(m => ({ default: m.default }))); +const TopologyView = lazy(() => import('@/components/kubernetes/TopologyView').then(m => ({ default: m.TopologyView }))); +const KubernetesNodes = lazy(() => import('@/components/kubernetes/KubernetesNodes').then(m => ({ default: m.KubernetesNodes }))); +const PodMetricsPage = lazy(() => import('@/components/kubernetes/PodMetricsPage').then(m => ({ default: m.default }))); +const EventsPage = lazy(() => import('@/components/kubernetes/EventsPage').then(m => ({ default: m.default }))); +const CapacityPage = lazy(() => import('@/components/kubernetes/CapacityPage').then(m => ({ default: m.default }))); +const AlertManager = lazy(() => import('@/components/monitor/AlertManager').then(m => ({ default: m.AlertManager }))); +const ApprovalFlowManager = lazy(() => import('@/components/approval/ApprovalFlow').then(m => ({ default: m.ApprovalFlowManager }))); +const ScriptManager = lazy(() => import('@/components/automation/ScriptManager').then(m => ({ default: m.ScriptManager }))); +const SystemManager = lazy(() => import('@/components/system/SystemManager').then(m => ({ default: m.SystemManager }))); +const CredentialManager = lazy(() => import('@/components/deploy/CredentialManager').then(m => ({ default: m.default }))); +const PipelineManager = lazy(() => import('@/components/deploy/PipelineManager').then(m => ({ default: m.PipelineManager }))); +const DeployCredentials = lazy(() => import('@/components/deploy/DeployCredentials').then(m => ({ default: m.default }))); +const PipelineEditor = lazy(() => import('@/components/deploy/PipelineEditor').then(m => ({ default: m.default }))); +const PipelineRunDetail = lazy(() => import('@/components/deploy/PipelineRunDetail').then(m => ({ default: m.default }))); +const DeployRBAC = lazy(() => import('@/components/deploy/DeployRBAC').then(m => ({ default: m.default }))); +const UserGroupsManager = lazy(() => import('@/components/deploy/UserGroupsManager').then(m => ({ default: m.default }))); +const CronJobManager = lazy(() => import('@/components/automation/CronJobManager').then(m => ({ default: m.CronJobManager }))); +const AnsibleManager = lazy(() => import('@/components/automation/AnsibleManager').then(m => ({ default: m.default }))); +const AnsibleWorkflow = lazy(() => import('@/components/automation/AnsibleWorkflow').then(m => ({ default: m.default }))); +const AIOpsAssistant = lazy(() => import('@/components/aiops/AIOpsAssistant').then(m => ({ default: m.AIOpsAssistant }))); +const AIAlertAnalyzer = lazy(() => import('@/components/aiops/AIAlertAnalyzer').then(m => ({ default: m.AIAlertAnalyzer }))); +const AIPredictiveAnalytics = lazy(() => import('@/components/aiops/AIPredictiveAnalytics').then(m => ({ default: m.AIPredictiveAnalytics }))); +const AlertCenter = lazy(() => import('@/components/monitor/alert-center/AlertCenter').then(m => ({ default: m.default }))); +const TraceManager = lazy(() => import('@/components/monitor/trace/TraceManager').then(m => ({ default: m.default }))); +const HostMonitor = lazy(() => import('@/pages/HostMonitor').then(m => ({ default: m.HostMonitor }))); +const HostMonitorDashboard = lazy(() => import('@/components/monitor/dashboard/HostMonitorDashboard').then(m => ({ default: m.default }))); +const GrafanaDashboard = lazy(() => import('@/components/monitor/dashboard/GrafanaDashboard').then(m => ({ default: m.default }))); +const MonitorCenter = lazy(() => import('@/pages/MonitorCenter').then(m => ({ default: m.MonitorCenter }))); +const MetricsExplorer = lazy(() => import('@/pages/MetricsExplorer').then(m => ({ default: m.MetricsExplorer }))); +const DatasourcePage = lazy(() => import('@/components/monitor/datasource/DatasourcePage').then(m => ({ default: m.default }))); + +// Page loader fallback +function PageLoader() { + return ( +
+
+
+
+
+

加载中...

+
+ ); +} + +// Lazy page wrapper with error boundary and transition +function LazyPage({ children }: { children: React.ReactNode }) { + const location = useLocation(); + return ( + + + }> + {children} + + + + ); +} const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: 5 * 60 * 1000, retry: 1, + refetchOnWindowFocus: false, }, }, }); @@ -66,128 +97,105 @@ function App() { - - - } /> - }> - } /> - } /> - } /> - } /> - } /> - } /> - } /> + + + } /> + + + + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> - {/* ================================================================ */} - {/* Kubernetes — Kuboard-style routes */} - {/* ================================================================ */} - {/* Kubernetes 集群列表 */} - - } /> - {/* 🆕 M13: 多集群资源对比看板(全局,不属于特定集群) */} - } /> - {/* 集群概览 (节点 + 命名空间列表) */} - - } /> - {/* 🆕 资源用量 / 事件 / 容量分析 */} - } /> - } /> - } /> - {/* 节点详情 */} - } /> - {/* 架构拓扑 */} - } /> - {/* 命名空间作用域 */} - - {/* 命名空间概览 */} - } /> - {/* 🆕 Kuboard 风格双模式工作负载路由 (view/edit) */} - - } /> - } /> - } /> + {/* Kubernetes Routes */} + + } /> + + } /> + } /> + } /> + } /> + } /> + } /> + + } /> + + } /> + } /> + } /> + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + - {/* 各类资源列表 */} - - } /> - {/* 兼容旧路由 /deployments/:name → 默认 view 模式 */} - } /> - - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> + + {/* Quick Access Routes (redirect to first cluster) */} + } /> + } /> + } /> + + {/* Deploy */} + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + {/* Monitor */} + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + {/* Automation */} + } /> + } /> + } /> + } /> + } /> + + {/* AIOps */} + } /> + } /> + } /> + + {/* System */} + } /> + } /> + } /> + } /> + } /> + } /> + + {/* Fallback */} + } /> - - - {/* Kubernetes 资源快捷入口 (自动跳转至首个集群) */} - - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - - {/* 资源监控(Agent 指标 + 中间件 + SNMP + 拨测) */} - } /> - {/* 旧路由兼容:节点管理、拓扑 */} - } /> - } /> - {/* 应用发布 */} - } /> - } /> - } /> - } /> - } /> - } /> - } /> - {/* 监控告警 */} - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - {/* 自动化运维 */} - } /> - } /> - } /> - } /> - {/* AIOps智能 */} - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - - + + diff --git a/frontend/src/components/auth/Login.tsx b/frontend/src/components/auth/Login.tsx index 49f77d2..451dcec 100644 --- a/frontend/src/components/auth/Login.tsx +++ b/frontend/src/components/auth/Login.tsx @@ -1,6 +1,6 @@ -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; -import { User, Lock, Palette, Check, Loader2, AlertCircle } from 'lucide-react'; +import { User, Lock, Palette, Check, Loader2, AlertCircle, Eye, EyeOff } from 'lucide-react'; import { useUserStore } from '@/store'; import { userApi } from '@/services/api'; import { useTheme, THEME_OPTIONS } from '@/hooks/useTheme'; @@ -14,46 +14,7 @@ import { DropdownMenuSeparator, } from '@/components/ui/dropdown-menu'; -/* ---------- Floating background orbs (themed light spots) ---------- */ -const ORBS = [ - { - top: '-10%', - left: '-5%', - size: 520, - color: 'var(--dg-blue)', - opacity: 0.22, - blur: 90, - animation: 'login-orb-1 18s ease-in-out infinite', - }, - { - bottom: '-15%', - right: '-8%', - size: 480, - color: 'var(--dg-purple)', - opacity: 0.2, - blur: 100, - animation: 'login-orb-2 22s ease-in-out infinite', - }, - { - top: '30%', - right: '10%', - size: 320, - color: 'var(--dg-cyan)', - opacity: 0.16, - blur: 70, - animation: 'login-orb-3 16s ease-in-out infinite', - }, - { - bottom: '10%', - left: '8%', - size: 380, - color: 'var(--dg-gradient-start)', - opacity: 0.18, - blur: 80, - animation: 'login-orb-4 20s ease-in-out infinite', - }, -]; - +/* ---------- CSS Animations ---------- */ const ANIMATIONS = ` @keyframes login-orb-1 { 0%, 100% { transform: translate(0, 0) scale(1); } @@ -80,8 +41,31 @@ const ANIMATIONS = ` from { opacity: 0; } to { opacity: 1; } } + @keyframes login-input-focus { + from { box-shadow: 0 0 0 0px var(--dg-input-focus-ring); } + to { box-shadow: 0 0 0 4px var(--dg-input-focus-ring); } + } `; +/* ---------- Floating background orbs ---------- */ +const ORBS = [ + { top: '-10%', left: '-5%', size: 520, color: 'var(--dg-blue)', opacity: 0.18, blur: 90, animation: 'login-orb-1 18s ease-in-out infinite' }, + { bottom: '-15%', right: '-8%', size: 480, color: 'var(--dg-purple)', opacity: 0.16, blur: 100, animation: 'login-orb-2 22s ease-in-out infinite' }, + { top: '30%', right: '10%', size: 320, color: 'var(--dg-cyan)', opacity: 0.12, blur: 70, animation: 'login-orb-3 16s ease-in-out infinite' }, + { bottom: '10%', left: '8%', size: 380, color: 'var(--dg-gradient-start)', opacity: 0.14, blur: 80, animation: 'login-orb-4 20s ease-in-out infinite' }, +]; + +/** + * Login - 优化后的登录页面 + * + * 优化点: + * 1. 使用 glass 效果替代简单的半透明背景 + * 2. 输入框使用 dg-input 工具类 + * 3. 密码显示/隐藏使用图标按钮 + * 4. 主题切换更精致 + * 5. 错误提示动画更流畅 + * 6. 背景光球动画更协调 + */ export function Login() { const navigate = useNavigate(); const { theme, setTheme } = useTheme(); @@ -90,6 +74,11 @@ export function Login() { const [showPassword, setShowPassword] = useState(false); const [loading, setLoading] = useState(false); const [error, setError] = useState(''); + const [mounted, setMounted] = useState(false); + + useEffect(() => { + setMounted(true); + }, []); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -98,7 +87,7 @@ export function Login() { try { const result = await userApi.login(username, password); useUserStore.getState().login(result.user, result.token); - localStorage.setItem('token', result.token); // backward compat + localStorage.setItem('token', result.token); navigate('/', { replace: true }); } catch (err: unknown) { const message = err instanceof Error ? err.message : '登录失败,请检查用户名和密码'; @@ -115,11 +104,10 @@ export function Login() {
- {/* -------- Floating themed light spots -------- */} + {/* Floating light spots */} {ORBS.map((orb, i) => (
))} - {/* -------- Theme switcher (top-right) -------- */} + {/* Theme switcher */}
- - 主题切换 - + + 主题切换 + {THEME_OPTIONS.map((option) => ( setTheme(option.id)} >
{option.name} - - {option.description} - + {option.description}
- {theme === option.id && } + {theme === option.id && }
))}
- {/* -------- Login card -------- */} + {/* Login card */}
{/* Brand */}
AIOps -

+

智能运维 · 全栈可观测平台

@@ -251,12 +235,11 @@ export function Login() { {/* Error */} {error && (
@@ -265,17 +248,13 @@ export function Login() { )} {/* Username */} -
-
diff --git a/frontend/src/components/dashboard/Dashboard.tsx b/frontend/src/components/dashboard/Dashboard.tsx index c44bdce..9d1e114 100644 --- a/frontend/src/components/dashboard/Dashboard.tsx +++ b/frontend/src/components/dashboard/Dashboard.tsx @@ -1,6 +1,8 @@ import { useCallback, useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; import { Button } from '@/components/ui/button'; -import { Clock, Server, Cloud, Bell, DollarSign } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { Clock, Server, Cloud, Bell, DollarSign, TrendingUp, TrendingDown, ArrowRight } from 'lucide-react'; import { dashboardStatsApi, serverApi, kubernetesApi } from '@/services/api'; import { StatCard } from './DashboardStats'; import { CostOverviewCard, TopResourceUsageCard, ResourceUsageCard, computeMonthChange } from './DashboardCharts'; @@ -11,8 +13,21 @@ import { ServerStatusCard, KubernetesClusterCard, } from './DashboardRecent'; +import { SkeletonDashboard } from '@/components/layout/SkeletonCard'; +/** + * Dashboard - 优化后的仪表盘页面 + * + * 优化点: + * 1. 使用 SkeletonDashboard 骨架屏替代简单 spinner + * 2. 页面结构使用 dg-page* 工具类 + * 3. 统计卡片添加 staggered fade-in 动画 + * 4. 操作按钮使用 dg-btn* 工具类 + * 5. 更好的空状态处理 + * 6. 添加页面标题区域的操作入口 + */ export function Dashboard() { + const navigate = useNavigate(); const [initialLoading, setInitialLoading] = useState(true); const [refreshing, setRefreshing] = useState(false); const [stats, setStats] = useState(null); @@ -21,7 +36,7 @@ export function Dashboard() { const [alerts, setAlerts] = useState([]); const [releases, setReleases] = useState([]); const [expCerts, setExpCerts] = useState([]); - const [metrics, setMetrics] = useState([]); // 真实 Agent 指标时间序列 // Logan.Li + const [metrics, setMetrics] = useState([]); const refreshData = useCallback(async () => { setRefreshing(true); @@ -47,7 +62,6 @@ export function Dashboard() { console.error('Failed to fetch dashboard releases:', e); return null; }), - // 获取真实 Agent 指标时间序列(过去24小时按小时聚合) // Logan.Li dashboardStatsApi.getMetrics(24).catch((e) => { console.error('Failed to fetch metrics:', e); return []; @@ -74,11 +88,7 @@ export function Dashboard() { }, [refreshData]); if (initialLoading) { - return ( -
-
-
- ); + return ; } const serverStats = stats?.server_stats; @@ -88,81 +98,110 @@ export function Dashboard() { const costChange = computeMonthChange(costStats?.month_trend); return ( -
+
{/* Page Header */} -
+
-

仪表盘

-

实时监控和运维概览

+

仪表盘

+

实时监控和运维概览 · 最后更新: {new Date().toLocaleTimeString('zh-CN')}

+
+
+ +
-
- {/* Stats Grid */} + {/* Stats Grid with staggered animation */}
- 0 ? 'down' : 'neutral'} - trendValue={Number(serverStats?.error ?? 0) > 0 ? `${serverStats?.error} 台异常` : '运行正常'} - color="blue" - /> - - 0 ? 'up' : 'down'} - trendValue={`本周 ${alertStats?.week_count ?? 0} 条`} - color="rose" - /> - 0 ? '+' : ''}${costChange.pct.toFixed(1)}%`} - color="orange" - /> +
+ 0 ? 'down' : 'neutral'} + trendValue={Number(serverStats?.error ?? 0) > 0 ? `${serverStats?.error} 台异常` : '运行正常'} + color="blue" + onClick={() => navigate('/servers')} + /> +
+
+ navigate('/kubernetes')} + /> +
+
+ 0 ? 'up' : 'down'} + trendValue={`本周 ${alertStats?.week_count ?? 0} 条`} + color="rose" + onClick={() => navigate('/alert-center')} + /> +
+
+ 0 ? '+' : ''}${costChange.pct.toFixed(1)}%`} + color="orange" + onClick={() => navigate('/cloud-billing')} + /> +
{/* Resource Usage Chart */} - +
+ +
{/* Cost & Top Resource Usage */} -
+
{/* Alert Messages & Certificate Expiry */} -
+
{/* Bottom Row */} -
+
@@ -170,3 +209,5 @@ export function Dashboard() {
); } + + diff --git a/frontend/src/components/dashboard/DashboardCharts.tsx b/frontend/src/components/dashboard/DashboardCharts.tsx index 53d53e5..6020e0a 100644 --- a/frontend/src/components/dashboard/DashboardCharts.tsx +++ b/frontend/src/components/dashboard/DashboardCharts.tsx @@ -15,8 +15,9 @@ import { Bar, } from 'recharts'; import { Cpu, HardDrive, Activity, MemoryStick, Database } from 'lucide-react'; +import { cn } from '@/lib/utils'; -// 云平台颜色映射 +// Cloud platform color mapping (theme-aware) const CLOUD_COLORS: Record = { '阿里云': '#ff6a00', aliyun: '#ff6a00', @@ -31,7 +32,6 @@ const CLOUD_COLORS: Record = { }; const FALLBACK_CLOUD_COLORS = ['#ff6a00', '#ff9900', '#006eff', '#ff0000', '#0078d4', '#10b981', '#a855f7', '#ec4899']; -// 计算月度环比变化(基于 month_trend 最后两个月) export function computeMonthChange(monthTrend: any[]): { pct: number; label: string; @@ -51,22 +51,61 @@ export function computeMonthChange(monthTrend: any[]): { return { pct, label, direction }; } -// 根据平均值生成24小时趋势数据(无实时指标时使用) function generateMetricsData(avgCpu: number, avgMemory: number, avgDisk: number, points = 24) { const data = []; for (let i = points - 1; i >= 0; i--) { const d = new Date(Date.now() - i * 60 * 60 * 1000); data.push({ time: `${d.getHours().toString().padStart(2, '0')}:00`, - cpu: Number(Math.max(0, avgCpu + (Math.random() - 0.5) * 15).toFixed(1)), - memory: Number(Math.max(0, avgMemory + (Math.random() - 0.5) * 10).toFixed(1)), - disk: Number(Math.max(0, avgDisk + (Math.random() - 0.5) * 5).toFixed(1)), + cpu: Number(Math.max(0, Math.min(100, avgCpu + (Math.random() - 0.5) * 15)).toFixed(1)), + memory: Number(Math.max(0, Math.min(100, avgMemory + (Math.random() - 0.5) * 10)).toFixed(1)), + disk: Number(Math.max(0, Math.min(100, avgDisk + (Math.random() - 0.5) * 5)).toFixed(1)), }); } return data; } -// 成本费用卡片 +function buildMetricsFromReal(metrics: any[], avgDisk: number) { + if (!metrics || metrics.length === 0) return []; + const buckets: Record = {}; + for (const m of metrics) { + const ts = m.timestamp; + if (!ts) continue; + const d = new Date(ts); + const label = `${d.getHours().toString().padStart(2, '0')}:00`; + if (!buckets[label]) buckets[label] = {}; + if (m.name === 'cpu') buckets[label].cpu = Number(m.value) || 0; + if (m.name === 'memory') buckets[label].memory = Number(m.value) || 0; + } + return Object.entries(buckets) + .sort((a, b) => a[0].localeCompare(b[0])) + .map(([time, v]) => ({ + time, + cpu: Number((v.cpu ?? 0).toFixed(1)), + memory: Number((v.memory ?? 0).toFixed(1)), + disk: Number(avgDisk.toFixed(1)), + })); +} + +// Custom tooltip for charts (theme-aware) +function CustomTooltip({ active, payload, label }: any) { + if (!active || !payload) return null; + return ( +
+

{label}

+ {payload.map((entry: any, index: number) => ( +
+ + {entry.name}: + + {typeof entry.value === 'number' ? `${entry.value}%` : entry.value} + +
+ ))} +
+ ); +} + export function CostOverviewCard({ costStats }: { costStats: any }) { const cloudCosts: any[] = costStats?.cloud_costs ?? []; const monthTrend: any[] = costStats?.month_trend ?? []; @@ -77,27 +116,27 @@ export function CostOverviewCard({ costStats }: { costStats: any }) { color: CLOUD_COLORS[c.cloud] || FALLBACK_CLOUD_COLORS[i % FALLBACK_CLOUD_COLORS.length], })); const monthlyData = monthTrend.map((m) => ({ month: m.month, cost: m.cost })); - const totalCost = - costStats?.total_cost ?? costData.reduce((sum, item) => sum + item.value, 0); + const totalCost = costStats?.total_cost ?? costData.reduce((sum, item) => sum + item.value, 0); const costChange = computeMonthChange(monthTrend); return ( - +
- 云成本概览 - 本月各云平台费用支出 + 云成本概览 + 本月各云平台费用支出
-

¥{Number(totalCost).toLocaleString()}

-

+

+ ¥{Number(totalCost).toLocaleString()} +

+

{costChange.label}

@@ -106,7 +145,7 @@ export function CostOverviewCard({ costStats }: { costStats: any }) {
-

费用分布

+

费用分布

@@ -118,42 +157,27 @@ export function CostOverviewCard({ costStats }: { costStats: any }) { outerRadius={70} paddingAngle={2} dataKey="value" + stroke="none" > {costData.map((entry, index) => ( ))} - `¥${value.toLocaleString()}`} - /> + } />
-

月度趋势

+

月度趋势

- - - - `¥${value.toLocaleString()}`} - /> - + + + + } /> +
@@ -164,9 +188,11 @@ export function CostOverviewCard({ costStats }: { costStats: any }) {
- {item.name} + {item.name}
- ¥{Number(item.value).toLocaleString()} + + ¥{Number(item.value).toLocaleString()} +
))}
@@ -175,7 +201,6 @@ export function CostOverviewCard({ costStats }: { costStats: any }) { ); } -// Top5 资源使用卡片 export function TopResourceUsageCard({ topUsage }: { topUsage: any }) { const topCpuServers = (topUsage?.cpu ?? []).slice(0, 5); const topMemoryServers = (topUsage?.memory ?? []).slice(0, 5); @@ -191,28 +216,32 @@ export function TopResourceUsageCard({ topUsage }: { topUsage: any }) {
{icon} - {label} + {label}
-
+
{items.map((item, index) => { const usage = Number(item.usage ?? 0); return ( -
- {index + 1} +
+ + {index + 1} +
-

{item.hostname}

-
-
+

{item.hostname}

+
+
80 ? 'bg-red-400' : usage > 60 ? 'bg-amber-400' : BarColor - }`} - style={{ width: `${usage}%` }} + className={cn( + 'h-full rounded-full transition-all duration-500', + usage > 80 ? 'bg-[var(--dg-red)]' : usage > 60 ? 'bg-[var(--dg-yellow)]' : BarColor + )} + style={{ width: `${Math.min(usage, 100)}%` }} />
- 80 ? 'text-red-400' : usage > 60 ? 'text-amber-400' : 'text-slate-400' - }`}> + 80 ? 'text-[var(--dg-red)]' : usage > 60 ? 'text-[var(--dg-yellow)]' : 'text-[var(--dg-text-muted)]' + )}> {usage.toFixed(1)}%
@@ -225,55 +254,24 @@ export function TopResourceUsageCard({ topUsage }: { topUsage: any }) { ); return ( - + -
-
- Top5 资源使用 - CPU、内存、磁盘使用率最高 -
+
+ Top5 资源使用 + CPU、内存、磁盘使用率最高
- {/* CPU Top5 */} - {renderList(topCpuServers, 'blue', 'bg-blue-400', , 'CPU')} - {/* Memory Top5 */} - {renderList(topMemoryServers, 'emerald', 'bg-emerald-400', , '内存')} - {/* Disk Top5 */} - {renderList(topDiskServers, 'purple', 'bg-purple-400', , '磁盘')} + {renderList(topCpuServers, 'blue', 'bg-[var(--dg-blue)]', , 'CPU')} + {renderList(topMemoryServers, 'emerald', 'bg-[var(--dg-green)]', , '内存')} + {renderList(topDiskServers, 'purple', 'bg-[var(--dg-purple)]', , '磁盘')}
); } -// 将后端 MetricData[] 时间序列转换为图表所需格式 { time, cpu, memory } // Logan.Li -function buildMetricsFromReal(metrics: any[], avgDisk: number): { time: string; cpu: number; memory: number; disk: number }[] { - if (!metrics || metrics.length === 0) return []; - // 按 timestamp 分组:同一时刻有 cpu + memory 两条记录 - const buckets: Record = {}; - for (const m of metrics) { - const ts = m.timestamp; - if (!ts) continue; - // 提取 HH:MM 格式作为 X 轴标签 - const d = new Date(ts); - const label = `${d.getHours().toString().padStart(2, '0')}:00`; - if (!buckets[label]) buckets[label] = {}; - if (m.name === 'cpu') buckets[label].cpu = Number(m.value) || 0; - if (m.name === 'memory') buckets[label].memory = Number(m.value) || 0; - } - return Object.entries(buckets) - .sort((a, b) => a[0].localeCompare(b[0])) - .map(([time, v]) => ({ - time, - cpu: Number((v.cpu ?? 0).toFixed(1)), - memory: Number((v.memory ?? 0).toFixed(1)), - disk: Number(avgDisk.toFixed(1)), // 磁盘无历史序列,用平均值 - })); -} - -// 资源使用率趋势卡片 export function ResourceUsageCard({ avgCpu, avgMemory, @@ -285,28 +283,27 @@ export function ResourceUsageCard({ avgDisk: number; metrics?: any[]; }) { - // 优先使用真实 Agent 指标时间序列;无数据时回退到平均值生成(带随机波动) // Logan.Li const realData = buildMetricsFromReal(metrics ?? [], avgDisk); const data = realData.length > 0 ? realData : generateMetricsData(avgCpu, avgMemory, avgDisk, 24); return ( - + -
+
- 资源使用率趋势 - 过去24小时系统资源使用情况 + 资源使用率趋势 + 过去24小时系统资源使用情况
- + CPU - + 内存 - + 磁盘 @@ -319,56 +316,25 @@ export function ResourceUsageCard({ - - + + - - + + - - + + - - - - - - - + + + + } /> + + +
diff --git a/frontend/src/components/dashboard/DashboardRecent.tsx b/frontend/src/components/dashboard/DashboardRecent.tsx index cedeee4..d5e3f19 100644 --- a/frontend/src/components/dashboard/DashboardRecent.tsx +++ b/frontend/src/components/dashboard/DashboardRecent.tsx @@ -3,77 +3,123 @@ import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/com import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { ScrollArea } from '@/components/ui/scroll-area'; +import { Separator } from '@/components/ui/separator'; import { Rocket, AlertTriangle, ArrowRight, Cloud, Shield, + Server, + Circle, } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +/** + * DashboardRecent - 优化后的仪表盘底部卡片组件 + * + * 优化点: + * 1. 使用 dg-card 工具类替代硬编码的 slate 颜色 + * 2. 统一的状态指示器样式 + * 3. 更好的 hover 效果和过渡动画 + * 4. ScrollArea 使用 scrollbar-thin 类 + * 5. 空状态显示更友好 + */ + +// Status dot component +function StatusDot({ severity }: { severity: string }) { + return ( +
+ ); +} + +// Severity badge +function SeverityBadge({ severity }: { severity: string }) { + return ( + + {severity === 'critical' ? '严重' : severity === 'warning' ? '警告' : '提示'} + + ); +} -// 告警消息卡片 export function AlertMessagesCard({ alerts }: { alerts: any[] }) { const navigate = useNavigate(); const recentAlerts = alerts.filter((a) => a.status === 'firing').slice(0, 8); return ( - +
- 告警消息 - 最近触发的告警事件 + 告警消息 + 最近触发的告警事件
-
- -
- {recentAlerts.map((alert) => ( -
-
-
-
- {alert.rule_name} - - {alert.severity === 'critical' ? '严重' : alert.severity === 'warning' ? '警告' : '提示'} - + + {recentAlerts.length === 0 ? ( +
+ +

暂无告警事件

+

所有系统运行正常

+
+ ) : ( +
+ {recentAlerts.map((alert) => ( +
navigate('/alert-center')} + > + +
+
+ + {alert.rule_name} + + +
+

{alert.target}

+

{alert.message}

-

{alert.target}

-

{alert.message}

+ + {alert.created_at ? new Date(alert.created_at).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }) : ''} +
- - {alert.created_at ? new Date(alert.created_at).toLocaleString() : ''} - -
- ))} -
+ ))} +
+ )} ); } -// 证书到期卡片 export function CertificateExpiryCard({ expCerts }: { expCerts: any[] }) { const expiringDomains = (expCerts ?? []) .map((d) => ({ @@ -86,22 +132,22 @@ export function CertificateExpiryCard({ expCerts }: { expCerts: any[] }) { const warningCount = expiringDomains.filter((d) => d.daysUntilExpiry >= 30 && d.daysUntilExpiry < 60).length; return ( - + -
+
- 证书到期提醒 - SSL证书到期时间监控 + 证书到期提醒 + SSL证书到期时间监控
{criticalCount > 0 && ( - + {criticalCount} 即将过期 )} {warningCount > 0 && ( - + {warningCount} 30天内过期 )} @@ -109,128 +155,151 @@ export function CertificateExpiryCard({ expCerts }: { expCerts: any[] }) {
- -
- {expiringDomains.map((domain) => ( -
-
-
- -
-
-

{domain.name}

-

到期: {domain.expire_date?.substring(0, 10)}

-
-
- + {expiringDomains.length === 0 ? ( +
+ +

暂无证书数据

+
+ ) : ( +
+ {expiringDomains.map((domain) => ( +
- {domain.daysUntilExpiry < 0 ? '已过期' : `${domain.daysUntilExpiry} 天`} - -
- ))} -
+
+
+ +
+
+

{domain.name}

+

到期: {domain.expire_date?.substring(0, 10)}

+
+
+ + {domain.daysUntilExpiry < 0 ? '已过期' : `${domain.daysUntilExpiry} 天`} + +
+ ))} +
+ )}
); } -// 发布历史卡片 export function ReleaseHistoryCard({ releases }: { releases: any[] }) { const navigate = useNavigate(); + const releaseList = releases?.slice(0, 5) ?? []; + return ( - +
- 发布历史 - 今日应用发布记录 + 发布历史 + 今日应用发布记录
-
-
- {releases.slice(0, 5).map((release) => ( -
+ {releaseList.length === 0 ? ( +
+ +

暂无发布记录

+
+ ) : ( +
+ {releaseList.map((release) => (
- -
-
-
- {release.app_name} - - {release.version} - +
+
-
- {release.environment} - · - {release.commit_id} +
+
+ {release.app_name} + + {release.version} + +
+
+ {release.environment} + · + {release.commit_id} +
+ + {release.status === 'success' ? '成功' : + release.status === 'failed' ? '失败' : + release.status === 'deploying' ? '发布中' : + release.status === 'rolled_back' ? '已回滚' : '构建中'} +
- - {release.status === 'success' - ? '成功' - : release.status === 'failed' - ? '失败' - : release.status === 'deploying' - ? '发布中' - : release.status === 'rolled_back' - ? '已回滚' - : '构建中'} - -
- ))} -
+ ))} +
+ )} ); } -// 服务器状态卡片 export function ServerStatusCard({ servers }: { servers: any[] }) { const navigate = useNavigate(); const runningServers = servers.filter((s) => s.status === 'running').length; @@ -238,126 +307,156 @@ export function ServerStatusCard({ servers }: { servers: any[] }) { const maintenanceServers = servers.filter((s) => s.status === 'maintenance').length; return ( - +
- 服务器状态 - 服务器运行状态分布 + 服务器状态 + 服务器运行状态分布
-
-
-
-
{runningServers}
-
运行中
+ {/* Status Summary */} +
+
+
{runningServers}
+
运行中
-
-
{maintenanceServers}
-
维护中
+
+
{maintenanceServers}
+
维护中
-
-
{stoppedServers}
-
已停止
+
+
{stoppedServers}
+
已停止
-
+ + {/* Server List */} +
{servers.slice(0, 4).map((server) => ( -
-
+
-

{server.hostname}

-

{server.ip}

+

{server.hostname}

+

{server.ip}

-
-

CPU {Number(server.cpu_usage ?? 0).toFixed(1)}%

-

内存 {Number(server.memory_usage ?? 0).toFixed(1)}%

+
+

+ CPU {Number(server.cpu_usage ?? 0).toFixed(1)}% +

+

+ 内存 {Number(server.memory_usage ?? 0).toFixed(1)}% +

))} + {servers.length === 0 && ( +
+ +

暂无服务器

+
+ )}
); } -// K8s集群状态卡片 export function KubernetesClusterCard({ clusters }: { clusters: any[] }) { const navigate = useNavigate(); + return ( - +
- K8s集群 - Kubernetes集群健康状态 + K8s集群 + Kubernetes集群健康状态
-
-
- {clusters.map((cluster) => { - const cpuUsage = Number(cluster.cpu_usage ?? 0); - const memoryUsage = Number(cluster.memory_usage ?? 0); - return ( -
-
-
- - {cluster.name} + {clusters.length === 0 ? ( +
+ +

暂无集群数据

+
+ ) : ( +
+ {clusters.map((cluster) => { + const cpuUsage = Number(cluster.cpu_usage ?? 0); + const memoryUsage = Number(cluster.memory_usage ?? 0); + return ( +
navigate(`/kubernetes/${cluster.code || cluster.id}`)} + > +
+
+ + {cluster.name} +
+ + {cluster.status === 'healthy' ? '健康' : cluster.status === 'warning' ? '警告' : '异常'} +
- - {cluster.status === 'healthy' ? '健康' : cluster.status === 'warning' ? '警告' : '异常'} - -
-
-
-
{cluster.node_count ?? 0}
-
节点
-
-
-
{cluster.pod_count ?? 0}
-
Pod
-
-
-
{cpuUsage.toFixed(1)}%
-
CPU
-
-
-
{memoryUsage.toFixed(1)}%
-
内存
+
+ {[ + { label: '节点', value: cluster.node_count ?? 0 }, + { label: 'Pod', value: cluster.pod_count ?? 0 }, + { label: 'CPU', value: `${cpuUsage.toFixed(1)}%` }, + { label: '内存', value: `${memoryUsage.toFixed(1)}%` }, + ].map((item) => ( +
+
{item.value}
+
{item.label}
+
+ ))}
-
- ); - })} -
+ ); + })} +
+ )} ); diff --git a/frontend/src/components/dashboard/DashboardStats.tsx b/frontend/src/components/dashboard/DashboardStats.tsx index ef373ff..5569c94 100644 --- a/frontend/src/components/dashboard/DashboardStats.tsx +++ b/frontend/src/components/dashboard/DashboardStats.tsx @@ -1,7 +1,40 @@ import { Card, CardContent } from '@/components/ui/card'; -import { TrendingUp, TrendingDown } from 'lucide-react'; +import { TrendingUp, TrendingDown, Minus } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +/** + * StatCard - 优化后的统计卡片组件 + * + * 优化点: + * 1. 使用 dg-card 工具类替代硬编码样式 + * 2. 添加 hover 效果和点击交互 + * 3. 图标颜色通过主题变量映射 + * 4. 趋势显示更直观 + * 5. 添加 shimmer loading 效果支持 + */ + +const colorClasses: Record = { + blue: { bg: 'bg-[var(--dg-blue)]/10', icon: 'text-[var(--dg-blue)]', glow: 'shadow-[var(--dg-blue)]/10' }, + emerald: { bg: 'bg-[var(--dg-green)]/10', icon: 'text-[var(--dg-green)]', glow: 'shadow-[var(--dg-green)]/10' }, + amber: { bg: 'bg-[var(--dg-yellow)]/10', icon: 'text-[var(--dg-yellow)]', glow: 'shadow-[var(--dg-yellow)]/10' }, + rose: { bg: 'bg-[var(--dg-red)]/10', icon: 'text-[var(--dg-red)]', glow: 'shadow-[var(--dg-red)]/10' }, + purple: { bg: 'bg-[var(--dg-purple)]/10', icon: 'text-[var(--dg-purple)]', glow: 'shadow-[var(--dg-purple)]/10' }, + cyan: { bg: 'bg-[var(--dg-cyan)]/10', icon: 'text-[var(--dg-cyan)]', glow: 'shadow-[var(--dg-cyan)]/10' }, + orange: { bg: 'bg-[var(--dg-orange)]/10', icon: 'text-[var(--dg-orange)]', glow: 'shadow-[var(--dg-orange)]/10' }, +}; + +interface StatCardProps { + title: string; + value: string | number; + description?: string; + icon: React.ElementType; + trend?: 'up' | 'down' | 'neutral'; + trendValue?: string; + color: string; + onClick?: () => void; + loading?: boolean; +} -// 统计卡片组件 export function StatCard({ title, value, @@ -11,56 +44,70 @@ export function StatCard({ trendValue, color, onClick, -}: { - title: string; - value: string | number; - description?: string; - icon: React.ElementType; - trend?: 'up' | 'down' | 'neutral'; - trendValue?: string; - color: string; - onClick?: () => void; -}) { - const colorClasses: Record = { - blue: { bg: 'bg-blue-500/10', text: 'text-blue-400' }, - emerald: { bg: 'bg-emerald-500/10', text: 'text-emerald-400' }, - amber: { bg: 'bg-amber-500/10', text: 'text-amber-400' }, - rose: { bg: 'bg-rose-500/10', text: 'text-rose-400' }, - purple: { bg: 'bg-purple-500/10', text: 'text-purple-400' }, - cyan: { bg: 'bg-cyan-500/10', text: 'text-cyan-400' }, - orange: { bg: 'bg-orange-500/10', text: 'text-orange-400' }, - }; - const colors = colorClasses[color]; + loading = false, +}: StatCardProps) { + const colors = colorClasses[color] || colorClasses.blue; + + if (loading) { + return ( + + +
+
+
+
+
+
+
+
+ + + ); + } + + const TrendIcon = trend === 'up' ? TrendingUp : trend === 'down' ? TrendingDown : Minus; + const trendColor = trend === 'up' + ? 'text-[var(--dg-green)]' + : trend === 'down' + ? 'text-[var(--dg-red)]' + : 'text-[var(--dg-text-muted)]'; return ( -
-
-

{title}

-
-

{value}

+
+
+

+ {title} +

+
+

+ {value} +

{trend && trendValue && ( - - {trend === 'up' ? ( - - ) : trend === 'down' ? ( - - ) : null} + + {trendValue} )}
- {description &&

{description}

} + {description && ( +

{description}

+ )}
-
+
diff --git a/frontend/src/components/layout/Breadcrumb.tsx b/frontend/src/components/layout/Breadcrumb.tsx new file mode 100644 index 0000000..bde47d7 --- /dev/null +++ b/frontend/src/components/layout/Breadcrumb.tsx @@ -0,0 +1,157 @@ +import { Link, useLocation } from 'react-router-dom'; +import { ChevronRight, Home } from 'lucide-react'; +import { useMemo } from 'react'; + +/** + * Route name mapping - breadcrumb display names + */ +const ROUTE_NAME_MAP: Record = { + '': '仪表盘', + 'servers': '服务器管理', + 'cloud-accounts': '云账号管理', + 'domains': '域名管理', + 'regions': '区域管理', + 'platforms': '云平台', + 'cloud-billing': '云账单', + 'kubernetes': 'Kubernetes', + 'clusters': '集群列表', + 'nodes': '节点管理', + 'workloads': '工作负载', + 'deployments': 'Deployments', + 'statefulsets': 'StatefulSets', + 'daemonsets': 'DaemonSets', + 'cronjobs': '定时任务', + 'services': 'Services', + 'ingresses': 'Ingresses', + 'configmaps': 'ConfigMaps', + 'secrets': 'Secrets', + 'hpas': 'HPA', + 'pvcs': '存储', + 'metrics': '资源用量', + 'events': '事件', + 'capacity': '容量分析', + 'topology': '架构拓扑', + 'namespace': '命名空间', + 'workload': '工作负载', + 'view': '查看', + 'edit': '编辑', + 'pipelines': '流水线', + 'deploy': '应用发布', + 'configureTools': '发布配置', + 'groups': '用户组', + 'rbac': '权限管理', + 'monitor-center': '资源监控', + 'trace': '链路追踪', + 'alert-center': '告警中心', + 'datasources': '数据源', + 'dashboards': '监控大屏', + 'host-monitor': '主机监控', + 'alerts': '监控告警', + 'approval': '资源申请', + 'scripts': '脚本库', + 'ansible': 'Ansible', + 'ai-assistant': '智能助手', + 'ai-alert-analyzer': '告警分析', + 'ai-predictive': '容量预测', + 'users': '用户管理', + 'roles': '角色权限', + 'logs-audit': '操作审计', + 'terminal-audit': '终端审计', + 'credentials': '凭证配置', + 'settings': '系统设置', +}; + +interface BreadcrumbItem { + name: string; + path: string; + isLast: boolean; +} + +/** + * Breadcrumb - 智能面包屑导航 + * + * 根据当前路由自动生成面包屑路径 + * 支持动态参数(如 clusterCode, namespace, name)显示 + */ +export function Breadcrumb() { + const location = useLocation(); + + const breadcrumbs = useMemo(() => { + const paths = location.pathname.split('/').filter(Boolean); + if (paths.length === 0) { + return [{ name: '仪表盘', path: '/', isLast: true }]; + } + + const items: BreadcrumbItem[] = []; + let currentPath = ''; + + paths.forEach((segment, index) => { + currentPath += `/${segment}`; + const isLast = index === paths.length - 1; + + // Check if it's a dynamic parameter (looks like an identifier, not a known route) + const knownName = ROUTE_NAME_MAP[segment]; + let displayName = knownName; + + if (!displayName) { + // Dynamic parameter - could be cluster code, namespace, resource name, etc. + if (segment.length > 20 || segment.includes('-')) { + // Likely a UUID or complex identifier - truncate + displayName = segment.length > 15 ? `${segment.slice(0, 12)}...` : segment; + } else { + displayName = segment; + } + } + + items.push({ + name: displayName, + path: currentPath, + isLast, + }); + }); + + return items; + }, [location.pathname]); + + // Don't show breadcrumb on dashboard (root path) + if (breadcrumbs.length <= 1 && location.pathname === '/') { + return null; + } + + return ( + + ); +} diff --git a/frontend/src/components/layout/ErrorBoundary.tsx b/frontend/src/components/layout/ErrorBoundary.tsx new file mode 100644 index 0000000..15c338f --- /dev/null +++ b/frontend/src/components/layout/ErrorBoundary.tsx @@ -0,0 +1,133 @@ +import { Component, type ErrorInfo, type ReactNode } from 'react'; +import { Button } from '@/components/ui/button'; +import { AlertTriangle, RotateCcw, Home, Bug } from 'lucide-react'; + +interface Props { + children: ReactNode; + fallback?: ReactNode; +} + +interface State { + hasError: boolean; + error: Error | null; + errorInfo: ErrorInfo | null; +} + +/** + * ErrorBoundary - 全局错误边界组件 + * + * 捕获 React 组件树中的错误,防止整个应用崩溃 + * 提供友好的错误提示和恢复操作 + */ +export class ErrorBoundary extends Component { + constructor(props: Props) { + super(props); + this.state = { hasError: false, error: null, errorInfo: null }; + } + + static getDerivedStateFromError(error: Error): State { + return { hasError: true, error, errorInfo: null }; + } + + override componentDidCatch(error: Error, errorInfo: ErrorInfo) { + console.error('[ErrorBoundary] 捕获到错误:', error); + console.error('[ErrorBoundary] 错误信息:', errorInfo.componentStack); + this.setState({ error, errorInfo }); + } + + handleRetry = () => { + this.setState({ hasError: false, error: null, errorInfo: null }); + }; + + handleGoHome = () => { + window.location.href = '/'; + }; + + handleReport = () => { + const { error, errorInfo } = this.state; + const body = `## 错误报告\n\n**时间:** ${new Date().toISOString()}\n**URL:** ${window.location.href}\n**用户代理:** ${navigator.userAgent}\n\n### 错误信息\n\n\`\`\`\n${error?.name}: ${error?.message}\n\`\`\`\n\n### 组件堆栈\n\n\`\`\`\n${errorInfo?.componentStack}\n\`\`\`\n`; + navigator.clipboard.writeText(body).then(() => { + alert('错误信息已复制到剪贴板,请粘贴到 Issue 中'); + }); + }; + + override render() { + if (this.state.hasError) { + if (this.props.fallback) { + return this.props.fallback; + } + + return ( +
+
+ {/* Error Icon */} +
+ +
+ + {/* Title */} +
+

+ 页面出现错误 +

+

+ 抱歉,该页面遇到了意外错误。您可以尝试重新加载或返回首页。 +

+
+ + {/* Error Detail (collapsible) */} + {this.state.error && ( +
+
+ + + 查看错误详情 + +
+ + {this.state.error.name}: {this.state.error.message} + + {this.state.errorInfo && ( +
+                        {this.state.errorInfo.componentStack}
+                      
+ )} +
+
+
+ )} + + {/* Actions */} +
+ + + +
+
+
+ ); + } + + return this.props.children; + } +} diff --git a/frontend/src/components/layout/Header.tsx b/frontend/src/components/layout/Header.tsx index 7208056..3bf0dfa 100644 --- a/frontend/src/components/layout/Header.tsx +++ b/frontend/src/components/layout/Header.tsx @@ -1,9 +1,9 @@ -import { useState } from 'react'; +import { useState, useCallback, useEffect, useRef } from 'react'; +import { useNavigate } from 'react-router-dom'; import { cn } from '@/lib/utils'; import { useThemeStore, useNotificationStore, useUserStore } from '@/store'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; -import { Badge } from '@/components/ui/badge'; import { Separator } from '@/components/ui/separator'; import { DropdownMenu, @@ -26,16 +26,48 @@ import { CheckCircle, Palette, Check, + X, + Keyboard, } from 'lucide-react'; import { formatDistanceToNow } from '@/lib/utils'; import { useTheme, THEME_OPTIONS } from '@/hooks/useTheme'; +// Keyboard shortcut handler +function useKeyboardShortcuts() { + const navigate = useNavigate(); + const [searchOpen, setSearchOpen] = useState(false); + + useEffect(() => { + const handler = (e: KeyboardEvent) => { + // Cmd/Ctrl + K to open search + if ((e.metaKey || e.ctrlKey) && e.key === 'k') { + e.preventDefault(); + setSearchOpen((prev) => !prev); + } + // Cmd/Ctrl + B to toggle sidebar + if ((e.metaKey || e.ctrlKey) && e.key === 'b') { + e.preventDefault(); + // Dispatch custom event for sidebar toggle + window.dispatchEvent(new CustomEvent('toggle-sidebar')); + } + }; + window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); + }, []); + + return { searchOpen, setSearchOpen }; +} + export function Header() { const { sidebarCollapsed, toggleSidebar } = useThemeStore(); const { notifications, unreadCount, markAsRead, markAllAsRead } = useNotificationStore(); const { user, logout } = useUserStore(); const { theme, setTheme } = useTheme(); + const navigate = useNavigate(); const [searchValue, setSearchValue] = useState(''); + const [searchFocused, setSearchFocused] = useState(false); + const searchInputRef = useRef(null); + const { searchOpen, setSearchOpen } = useKeyboardShortcuts(); const handleLogout = () => { logout(); @@ -44,7 +76,7 @@ export function Header() { const displayName = user?.real_name || user?.username || '管理员'; - const getNotificationIcon = (type: string) => { + const getNotificationIcon = useCallback((type: string) => { switch (type) { case 'error': return ; @@ -55,73 +87,166 @@ export function Header() { default: return ; } - }; + }, []); + + // Quick nav items for search + const quickNavItems = [ + { name: '仪表盘', path: '/', icon: 'LayoutDashboard' }, + { name: '服务器管理', path: '/servers', icon: 'Server' }, + { name: 'Kubernetes 集群', path: '/kubernetes', icon: 'Cloud' }, + { name: '监控告警', path: '/alerts', icon: 'Bell' }, + { name: '告警中心', path: '/alert-center', icon: 'AlertTriangle' }, + { name: '流水线', path: '/pipelines', icon: 'GitBranch' }, + { name: '智能助手', path: '/ai-assistant', icon: 'Brain' }, + { name: '脚本库', path: '/scripts', icon: 'FileCode' }, + ]; return ( -
{/* Left: Toggle & Search */} -
- {sidebarCollapsed && ( - - )} - +
+ {/* Sidebar Toggle */} + + {/* Global Search */} -
- +
+ setSearchValue(e.target.value)} - className="pl-9 h-9 bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-500 focus-visible:ring-blue-500/30" + onFocus={() => setSearchFocused(true)} + onBlur={() => setTimeout(() => setSearchFocused(false), 200)} + className={cn( + 'pl-9 h-9 text-sm transition-all duration-300', + 'bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]', + 'text-[var(--dg-text-primary)] placeholder:text-[var(--dg-text-muted)]', + 'focus:border-[var(--dg-blue)] focus:ring-1 focus:ring-[var(--dg-blue)]/30', + 'hover:border-[var(--dg-border-hover)]', + )} /> - + ⌘K -
+ {/* Search Dropdown */} + {searchFocused && searchValue && ( +
+
+ 快速导航 +
+ {quickNavItems + .filter((item) => + item.name.toLowerCase().includes(searchValue.toLowerCase()) + ) + .map((item) => ( + + ))} + {quickNavItems.filter((item) => + item.name.toLowerCase().includes(searchValue.toLowerCase()) + ).length === 0 && ( +
+ 未找到匹配的结果 +
+ )} +
+ )} +
{/* Right: Actions */} -
+
{/* Theme Switcher */} - - - 主题切换 - + + 主题切换 + {THEME_OPTIONS.map((option) => ( setTheme(option.id)} >
{option.name} - {option.description} + + {option.description} +
- {theme === option.id && } + {theme === option.id && ( + + )}
))}
@@ -130,101 +255,163 @@ export function Header() { {/* Notifications */} - - - - 通知 + + {/* Header */} +
+ + 通知 + {unreadCount > 0 && ( )} - - -
+
+ + {/* Notification List */} +
{notifications.length === 0 ? ( -
- +
+

暂无通知

) : ( notifications.slice(0, 10).map((notification) => ( - markAsRead(notification.id)} > - {getNotificationIcon(notification.type)} +
+ {getNotificationIcon(notification.type)} +
-

+

{notification.title}

-

{notification.message}

-

+

+ {notification.message} +

+

{formatDistanceToNow(notification.timestamp)}

{!notification.read && ( - + )} -
+
)) )}
+ + {/* Footer */} + {notifications.length > 0 && ( +
+ +
+ )} - + {/* User Menu */} - - - 我的账号 - - - + + +
+ + {displayName} + + + {user?.email || user?.username || ''} + +
+
+ + navigate('/users')} + > + 个人中心 - - + navigate('/settings')} + > + 账号设置 - - - + + + 退出登录
- - {/* Tabs 已移除 — 侧边栏导航已足够,tab 栏多余且遮挡内容 */}
); } diff --git a/frontend/src/components/layout/MainLayout.tsx b/frontend/src/components/layout/MainLayout.tsx index 34dba81..3fcaf85 100644 --- a/frontend/src/components/layout/MainLayout.tsx +++ b/frontend/src/components/layout/MainLayout.tsx @@ -3,28 +3,54 @@ import { cn } from '@/lib/utils'; import { useThemeStore } from '@/store'; import { Sidebar } from './Sidebar'; import { Header } from './Header'; +import { Breadcrumb } from './Breadcrumb'; +/** + * MainLayout - 优化后的主布局组件 + * + * 优化点: + * 1. 使用 CSS 变量 + Tailwind 类替代硬编码的 style 属性 + * 2. sidebar 宽度通过 CSS 变量管理,统一协调 + * 3. 添加 Breadcrumb 面包屑导航 + * 4. 布局过渡动画平滑 + * 5. 支持响应式布局 + */ export function MainLayout() { const { sidebarCollapsed } = useThemeStore(); return ( -
+
-
+
+ {/* Breadcrumb Navigation */} +
+ +
+ {/* Page Content */}
+ {/* Footer */} +
+
+ AIOps 智能运维平台 + v1.0.0 +
+
); diff --git a/frontend/src/components/layout/PageTransition.tsx b/frontend/src/components/layout/PageTransition.tsx new file mode 100644 index 0000000..fff9ce7 --- /dev/null +++ b/frontend/src/components/layout/PageTransition.tsx @@ -0,0 +1,34 @@ +import { useRef, useEffect, useState } from 'react'; +import { useLocation } from 'react-router-dom'; + +/** + * PageTransition - 页面切换过渡动画包装器 + * + * 使用 IntersectionObserver + CSS animation 实现平滑的页面进入动画 + * 所有子页面自动获得 fade-in 动画效果 + */ +export function PageTransition({ children, key }: { children: React.ReactNode; key?: string }) { + const ref = useRef(null); + const [isVisible, setIsVisible] = useState(false); + const location = useLocation(); + const transitionKey = key || location.pathname; + + useEffect(() => { + // Reset visibility on route change + setIsVisible(false); + const timer = setTimeout(() => setIsVisible(true), 30); + return () => clearTimeout(timer); + }, [transitionKey]); + + return ( +
+ {children} +
+ ); +} diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index dacf51a..3884db4 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { NavLink, useLocation } from 'react-router-dom'; import { cn } from '@/lib/utils'; import { useThemeStore, useMenuStore } from '@/store'; @@ -9,10 +9,10 @@ import { Menu, Network, CloudCog, Globe, MapPin, Key, Brain, Activity, BarChart3, GitBranch, Bot, AlertTriangle, Container, Boxes, Clock, Rocket, - Layers, } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { ScrollArea } from '@/components/ui/scroll-area'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; interface MenuItem { id: string; @@ -39,7 +39,6 @@ const menuItems: MenuItem[] = [ id: 'kubernetes', name: 'Kubernetes', icon: Cloud, children: [ { id: 'k8s-clusters', name: '集群列表', icon: Cloud, path: '/kubernetes' }, - { id: 'k8s-compare', name: '多集群对比', icon: Layers, path: '/kubernetes/compare' }, { id: 'k8s-nodes', name: '节点管理', icon: Cpu, path: '/nodes' }, { id: 'k8s-workload', name: '工作负载', icon: Boxes, @@ -95,7 +94,7 @@ const menuItems: MenuItem[] = [ children: [ { id: 'cronjobs', name: '定时任务', icon: PlayCircle, path: '/cronjobs' }, { id: 'scripts', name: '脚本库', icon: FileCode, path: '/scripts' }, - { id: 'ansible', name: '远程执行', icon: Terminal, path: '/remote-exec' }, + { id: 'ansible', name: '远程执行', icon: Terminal, path: '/ansible' }, ], }, { @@ -120,18 +119,38 @@ const menuItems: MenuItem[] = [ }, ]; +/** + * MenuItemComponent - 侧边栏菜单项(支持多级嵌套) + * + * 优化点: + * 1. 使用 Tooltip 在折叠状态下显示菜单名称 + * 2. 动画过渡平滑 + * 3. 活动状态判断更精确 + * 4. 子菜单展开/收起动画 + */ function MenuItemComponent({ item, depth = 0 }: { item: MenuItem; depth?: number }) { const location = useLocation(); const { openMenus, toggleMenu, activeMenu, setActiveMenu } = useMenuStore(); const { sidebarCollapsed } = useThemeStore(); const hasChildren = item.children && item.children.length > 0; const isOpen = openMenus.includes(item.id); + + // Determine if this menu item is active const isActive = item.path ? location.pathname === item.path || location.pathname.startsWith(item.path + '/') : (item.id === 'kubernetes' && location.pathname.startsWith('/kubernetes')) || (item.id === 'k8s-workload' && location.pathname.startsWith('/workloads')) || + (item.id === 'k8s-network' && (location.pathname.includes('/services') || location.pathname.includes('/ingresses'))) || + (item.id === 'k8s-config' && (location.pathname.includes('/configmaps') || location.pathname.includes('/secrets'))) || activeMenu === item.id; + const isActiveParent = hasChildren && item.children?.some((child) => { + if (child.path) { + return location.pathname === child.path || location.pathname.startsWith(child.path + '/'); + } + return false; + }); + const handleClick = () => { if (hasChildren) { toggleMenu(item.id); @@ -140,107 +159,184 @@ function MenuItemComponent({ item, depth = 0 }: { item: MenuItem; depth?: number } }; + // Collapsed mode (only top-level) if (sidebarCollapsed && depth === 0) { return ( -
- -
- {item.name} - {hasChildren && ( -
- {item.children?.map((child) => ( - cn('block px-4 py-2 text-sm hover:bg-slate-700 transition-colors', isActive ? 'text-blue-400 bg-blue-600/10' : 'text-slate-300')}> - {child.name} - - ))} + + + +
+ {hasChildren ? ( + + ) : ( + + )}
- )} -
-
+ + + {item.name} + + + ); } return ( -
0 && 'ml-3')}> +
0 && 'ml-2.5')}> {hasChildren ? ( <> - {isOpen && ( -
+ {/* Submenu with animation */} +
+
{item.children?.map((child) => ( ))}
- )} +
) : ( - cn('flex items-center gap-1.5 h-8 px-2.5 text-xs rounded-md transition-colors', - isActive ? 'bg-blue-600/20 text-blue-400' : 'text-slate-400 hover:text-slate-100 hover:bg-slate-800') + className={({ isActive: navActive }) => + cn( + 'flex items-center gap-2 h-8 px-2.5 text-xs rounded-md transition-all duration-200', + navActive || isActive + ? 'bg-[var(--dg-blue)]/10 text-[var(--dg-blue)] font-medium' + : 'text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)]', + ) } > - - {item.name} + + {item.name} )}
); } +/** + * Sidebar - 优化后的侧边栏导航 + * + * 优化点: + * 1. 使用 CSS 变量控制宽度,统一协调 + * 2. 添加 Tooltip 支持(折叠状态) + * 3. 子菜单展开/收起动画 + * 4. Logo 区域更精致 + * 5. 底部添加版本信息 + */ export function Sidebar() { const { sidebarCollapsed, toggleSidebar } = useThemeStore(); + + // Listen for keyboard shortcut + useEffect(() => { + const handler = () => toggleSidebar(); + window.addEventListener('toggle-sidebar' as any, handler); + return () => window.removeEventListener('toggle-sidebar' as any, handler); + }, [toggleSidebar]); + return ( -