diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 9a3b2a5..5f3cff6 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -116,7 +116,7 @@ export default { }, "dashboard": { "title": "Dashboard", - "subtitle": "System & Core Overview", + "subtitle": "Runtime, system monitoring and traffic overview", "status": "Core Status", "running": "Running", "stoppedStatus": "Stopped", @@ -170,6 +170,14 @@ export default { "coreName": "Mihomo", "tcp": "TCP", "udp": "UDP" + "systemCpu": "System CPU", + "systemMemory": "System Memory", + "systemDisk": "System Disk", + "recentActive": "Active in recent window", + "lastUpdated": "Last updated", + "autoRefresh": "Auto refresh", + "autoRefreshPaused": "Auto refresh paused", + "pause": "Pause", }, "listeners": { "subtitle": "Create and manage Mihomo inbound listeners.", diff --git a/frontend/src/i18n/locales/zh-CN.ts b/frontend/src/i18n/locales/zh-CN.ts index 00fd356..0509624 100644 --- a/frontend/src/i18n/locales/zh-CN.ts +++ b/frontend/src/i18n/locales/zh-CN.ts @@ -116,7 +116,7 @@ export default { }, "dashboard": { "title": "仪表盘", - "subtitle": "系统与核心状态概览", + "subtitle": "运行时、系统监控与流量概览", "status": "核心状态", "running": "运行中", "stoppedStatus": "已停止", @@ -170,6 +170,14 @@ export default { "coreName": "Mihomo", "tcp": "TCP", "udp": "UDP" + "systemCpu": "系统 CPU", + "systemMemory": "系统内存", + "systemDisk": "系统硬盘", + "recentActive": "最近窗口活跃", + "lastUpdated": "最后更新", + "autoRefresh": "自动刷新", + "autoRefreshPaused": "自动刷新已暂停", + "pause": "暂停", }, "listeners": { "subtitle": "创建与管理 Mihomo 入站节点。", diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index cd9ffca..89c5c9c 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -1,5 +1,17 @@ import React, { useCallback, useEffect, useState } from 'react'; -import { Button, Card, Col, Row, Space, Typography, message, theme } from 'antd'; +import { + Button, + Card, + Col, + Progress, + Row, + Space, + Table, + Typography, + message, + theme, + Tag, +} from 'antd'; import { Link } from 'react-router-dom'; import { IconPlay, IconStop, IconRestart } from '../icons'; import { @@ -9,7 +21,6 @@ import { restartMihomo, isTransientNetworkError, type DashboardResponse, - type ProcessUsageSample, } from '../api/system'; import { isCanceledError } from '../api/client'; import { useI18n } from '../i18n'; @@ -17,12 +28,10 @@ import useIsMobile from '../hooks/useIsMobile'; import { formatBytes } from '../utils/format'; import { startVisiblePolling } from '../utils/visiblePolling'; -const { Text } = Typography; +const { Text, Title } = Typography; -/** Dashboard refresh cadence — CPU, memory and traffic rates read as live. */ const DASHBOARD_POLL_MS = 1000; -/** Ring buffer length for sparklines / speed chart (~2 min at 1 Hz). */ -const HISTORY_LEN = 120; +const HISTORY_LEN = 90; const formatRate = (bps: number) => `${formatBytes(bps)}/s`; const clampPct = (v: unknown) => { @@ -38,573 +47,500 @@ function pushHistory(buf: number[], value: number, max = HISTORY_LEN): number[] return next; } -/** Compact metric sparkline — thin stroke, no soft blob fill. */ -const Sparkline: React.FC<{ - data: number[]; - color: string; - height?: number; -}> = ({ data, color, height = 32 }) => { - const w = 160; - const h = height; - if (data.length < 2) { - return ; - } - const min = Math.min(...data); - const max = Math.max(...data); - const span = max - min || 1; - const coords = data.map((v, i) => { - const x = (i / (data.length - 1)) * w; - const y = h - ((v - min) / span) * (h - 6) - 3; - return [x, y] as const; - }); - // Straight segments only (no smooth curve) — reads like a real counter strip. - const line = coords.map(([x, y]) => `${x.toFixed(1)},${y.toFixed(1)}`).join(' '); - const baseline = coords.map(([x]) => `${x.toFixed(1)},${(h - 1).toFixed(1)}`).join(' '); - return ( - - - - - ); -}; - -/** Single-series total rate chart — one muted stroke, plain grid. */ -/** Up/down rate chart — same muted color; down solid, up dashed. */ -const SpeedChart: React.FC<{ - up: number[]; - down: number[]; - color: string; - height?: number; -}> = ({ up, down, color, height = 160 }) => { - const w = 480; - const h = height; - const n = Math.max(up.length, down.length, 2); - const pad = (series: number[]) => - series.length < n ? Array(n - series.length).fill(0).concat(series) : series.slice(-n); - const upS = pad(up); - const downS = pad(down); - const max = Math.max(...upS, ...downS, 1); - const toPts = (series: number[]) => - series - .map((v, i) => { - const x = (i / (n - 1)) * w; - const y = h - (Math.max(0, v) / max) * (h - 12) - 6; - return `${x.toFixed(1)},${y.toFixed(1)}`; - }) - .join(' '); - return ( - - {[0, 0.5, 1].map((p) => ( - - ))} - - - - ); -}; - -type MetricCardProps = { +/** m-ui style metric card: title + icon slot, big number, optional progress / hint */ +const MetricCard: React.FC<{ title: string; - value: string; - unit?: string; - detail?: string; - peak?: string; - series: number[]; - color: string; - isMobile: boolean; -}; - -const MetricCard: React.FC = ({ title, value, unit, detail, peak, series, color, isMobile }) => { + value: React.ReactNode; + hint?: React.ReactNode; + progress?: number; + progressColor?: string; + loading?: boolean; +}> = ({ title, value, hint, progress, progressColor, loading }) => { const { token } = theme.useToken(); return ( -
- - {title} - - {peak ? ( - - {peak} - - ) : null} +
+ {title}
-
- - {value} - - {unit ? ( - - {unit} - - ) : null} +
+ {value}
- {detail ? ( -
{detail}
- ) : ( -
- )} - + {progress != null ? ( + + ) : null} + {hint ? ( +
{hint}
+ ) : null} ); }; -type HistoryState = { - cpu: number[]; - mem: number[]; - disk: number[]; - up: number[]; - down: number[]; - tcp: number[]; - udp: number[]; +const RateChart: React.FC<{ up: number[]; down: number[]; height?: number }> = ({ + up, + down, + height = 160, +}) => { + const { token } = theme.useToken(); + const w = 480; + const h = height; + const max = Math.max(1, ...up, ...down); + const path = (data: number[]) => { + if (data.length < 2) return ''; + return data + .map((v, i) => { + const x = (i / (data.length - 1)) * w; + const y = h - (v / max) * (h - 12) - 6; + return `${x.toFixed(1)},${y.toFixed(1)}`; + }) + .join(' '); + }; + return ( + + {[0.25, 0.5, 0.75].map((f) => ( + + ))} + + + + ); }; -const emptyHistory = (): HistoryState => ({ - cpu: [], - mem: [], - disk: [], - up: [], - down: [], - tcp: [], - udp: [], -}); - -const Dashboard: React.FC = () => { +const DashboardPage: React.FC = () => { const { t } = useI18n(); - const isMobile = useIsMobile(); const { token } = theme.useToken(); + const isMobile = useIsMobile(); const [data, setData] = useState(null); - const [busy, setBusy] = useState(false); - const [hist, setHist] = useState(emptyHistory); + const [loading, setLoading] = useState(true); + const [actionLoading, setActionLoading] = useState(false); + const [autoRefresh, setAutoRefresh] = useState(true); + const [updatedAt, setUpdatedAt] = useState(null); + const [hist, setHist] = useState({ up: [] as number[], down: [] as number[] }); const load = useCallback(async (signal?: AbortSignal) => { try { - const d = await fetchDashboard(signal); - if (signal?.aborted) return; - setData(d); - const sys = d.system; - const tr = d.traffic; + const res = await fetchDashboard(signal); + setData(res); + setUpdatedAt(new Date()); setHist((prev) => ({ - cpu: pushHistory(prev.cpu, clampPct(sys?.cpu?.percent)), - mem: pushHistory(prev.mem, clampPct(sys?.memory?.percent)), - disk: pushHistory(prev.disk, clampPct(sys?.disk?.percent)), - up: pushHistory(prev.up, Number(tr?.uploadRate) || 0), - down: pushHistory(prev.down, Number(tr?.downloadRate) || 0), - tcp: pushHistory(prev.tcp, Number(tr?.tcpConnections) || 0), - udp: pushHistory(prev.udp, Number(tr?.udpConnections) || 0), + up: pushHistory(prev.up, Number(res.traffic?.uploadRate) || 0), + down: pushHistory(prev.down, Number(res.traffic?.downloadRate) || 0), })); - } catch (e: unknown) { - if (signal?.aborted || isCanceledError(e)) return; - const msg = e instanceof Error ? e.message : t('dashboard.unavailable'); - message.error(msg || t('dashboard.unavailable')); - } - }, [t]); - - useEffect(() => startVisiblePolling((signal) => load(signal), DASHBOARD_POLL_MS), [load]); - - const act = async (a: 'start' | 'stop' | 'restart') => { - setBusy(true); - try { - if (a === 'start') await startMihomo(); - else if (a === 'stop') await stopMihomo(); - else await restartMihomo(); - message.success(t(`dashboard.${a === 'start' ? 'started' : a === 'stop' ? 'stopped' : 'restarted'}`)); - await load(); - } catch (e: unknown) { - if (a === 'restart' && isTransientNetworkError(e)) { - await new Promise((r) => setTimeout(r, 1500)); - try { - await load(); - message.success(t('dashboard.restarted')); - return; - } catch { - /* fall through */ - } - } - const msg = e instanceof Error ? e.message : t('dashboard.operationFailed'); - message.error(msg || t('dashboard.operationFailed')); + } catch (e) { + if (isCanceledError(e)) return; } finally { - setBusy(false); + setLoading(false); + } + }, []); + + useEffect(() => { + if (!autoRefresh) { + load(); + return; + } + return startVisiblePolling(() => load(), DASHBOARD_POLL_MS); + }, [load, autoRefresh]); + + const runAction = async (fn: () => Promise, okMsg: string) => { + setActionLoading(true); + try { + await fn(); + message.success(okMsg); + await load(); + } catch (e: any) { + if (isTransientNetworkError(e)) { + message.success(okMsg); + setTimeout(() => load(), 1500); + } else { + message.error(e?.message || t('common.error')); + } + } finally { + setActionLoading(false); } }; + const mihomo = data?.mihomo; + const running = !!mihomo?.running; const sys = data?.system; - const users = data?.users; const traffic = data?.traffic; - const panel: ProcessUsageSample | undefined = data?.panel; - const core: ProcessUsageSample | undefined = data?.core; - const coreRunning = !!data?.mihomo?.running; + const users = data?.users; + const panel = data?.panel; + const core = data?.core; const cpuPct = clampPct(sys?.cpu?.percent); const memPct = clampPct(sys?.memory?.percent); const diskPct = clampPct(sys?.disk?.percent); const online = users?.online ?? traffic?.onlineUsers ?? 0; - const conns = traffic?.activeConnections ?? 0; - const tcpConns = traffic?.tcpConnections ?? 0; - const udpConns = traffic?.udpConnections ?? 0; - const upRate = traffic?.uploadRate || 0; - const downRate = traffic?.downloadRate || 0; - const peakUp = hist.up.length ? Math.max(0, ...hist.up) : 0; - const peakDown = hist.down.length ? Math.max(0, ...hist.down) : 0; + const upRate = Number(traffic?.uploadRate) || 0; + const downRate = Number(traffic?.downloadRate) || 0; + const tcp = Number(traffic?.tcpConnections) || 0; + const udp = Number(traffic?.udpConnections) || 0; + const active = Number(traffic?.activeConnections) || tcp + udp; - const gutter: [number, number] = isMobile ? [8, 8] : [12, 12]; + const timeStr = updatedAt + ? updatedAt.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', second: '2-digit' }) + : '—'; + + const gutter: [number, number] = isMobile ? [10, 10] : [16, 16]; + const showLoading = loading && !data; return ( -
- {/* Top control bar — 3X-UI style */} - + {/* Header — m-ui: title + subtitle + controls */} +
-
- - {!coreRunning ? ( - - ) : null} - - - {!isMobile ? ( - <> - - - - ) : null} - +
+ + {t('dashboard.title') || t('nav.dashboard') || '仪表盘'} + + + {t('dashboard.subtitle') || '运行时、系统监控与流量概览'} +
- + + + {t('dashboard.lastUpdated') || '最后更新'}: {timeStr} + + + {autoRefresh + ? `${t('dashboard.autoRefresh') || '自动刷新'}: ${DASHBOARD_POLL_MS / 1000}s` + : t('dashboard.autoRefreshPaused') || '自动刷新已暂停'} + + + + {running ? t('dashboard.running') || '运行中' : t('dashboard.stopped') || '已停止'} + + + +
- {/* Resource metric cards */} - - + {/* Row 1 — system metrics (m-ui 4 cards) */} + + - + a + b, 0) / hist.mem.length) * 10) / 10}%` : undefined} - series={hist.mem} - color={token.colorTextSecondary} - isMobile={isMobile} + loading={showLoading} + title={t('dashboard.systemMemory') || '系统内存'} + value={`${memPct.toFixed(1)}%`} + progress={memPct} + progressColor="#f59e0b" + hint={ + sys + ? `${formatBytes(sys.memory.used)} / ${formatBytes(sys.memory.total)}` + : undefined + } /> - + - + v + (hist.udp[i] || 0)) : [0, online]} - color={token.colorTextSecondary} - isMobile={isMobile} + loading={showLoading} + title={t('dashboard.onlineUsers') || '在线用户'} + value={{online}} + hint={t('dashboard.recentActive') || '最近窗口活跃'} /> - {/* Speed chart + connection stats */} - - - -
-
- - {t('dashboard.traffic')} - -
- {t('dashboard.uploadRate')} · {t('dashboard.peak')} {formatRate(peakUp)} · {t('dashboard.downloadRate')} · {t('dashboard.peak')}{' '} - {formatRate(peakDown)} -
-
- - - ↑ {t('dashboard.upload')} - - {formatRate(upRate)} - - (···) - - - ↓ {t('dashboard.download')} - - {formatRate(downRate)} - - (—) - - -
- - - - - {t('dashboard.totalUpload')} - -
{formatBytes(traffic?.totalUpload || 0)}
- - - - {t('dashboard.totalDownload')} - -
{formatBytes(traffic?.totalDownload || 0)}
- - - - {t('dashboard.onlineUsers')} - -
{online}
- -
-
+ {/* Row 2 — traffic / connections */} + + + - - - - {t('dashboard.activeConnections')} - -
- {conns} -
- - {t('dashboard.openSockets')} - -
- - {t('dashboard.tcp')} - {tcpConns} - (—) - - - {t('dashboard.udp')} - {udpConns} - (···) - -
-
- -
-
-
-
{t('dashboard.total')}
-
{data?.listeners?.total ?? 0}
-
-
-
{t('dashboard.enabled')}
-
{data?.listeners?.enabled ?? 0}
-
-
-
{t('dashboard.disabled')}
-
{data?.listeners?.disabled ?? 0}
-
-
- - {t('dashboard.listeners')} - -
+ + + + + + + +
- {/* Bottom strip: panel / core process */} - - - - - {t('dashboard.uptime')} - -
{data?.mihomo?.uptime || '—'}
- - {t('dashboard.coreName')} - - - - - {t('dashboard.panel')} - -
- {panel?.memory_used != null ? formatBytes(panel.memory_used) : '—'} - {panel?.cpu_percent != null ? ( - - {t('dashboard.cpu')} {clampPct(panel.cpu_percent)}% - - ) : null} -
- - {panel?.pid ? `PID ${panel.pid}` : t('dashboard.panelUsage')} - - - - - {t('dashboard.core')} - -
- {coreRunning && core?.memory_used != null ? formatBytes(core.memory_used) : '—'} - {coreRunning && core?.cpu_percent != null ? ( - - {t('dashboard.cpu')} {clampPct(core.cpu_percent)}% - - ) : null} -
- - {coreRunning && core?.pid ? `PID ${core.pid}` : t('dashboard.coreUsage')} - - - - - {t('dashboard.onlineUsers')} - -
- {online} - - / {users?.total ?? 0} + {/* Row 3 — chart + runtime */} + + + + ↓ {formatRate(downRate)} · ↑ {formatRate(upRate)} + } + styles={{ body: { padding: 16 } }} + style={{ borderRadius: 10, height: '100%' }} + > +
+ + ━ {t('dashboard.download') || '下载'} + + + ┄ {t('dashboard.upload') || '上传'} +
- - {t('dashboard.enabledUsers')}: {users?.enabled ?? 0} - - - - - {t('dashboard.version')} - -
- {data?.mihomo?.version || '—'} + + + + + + Mihomo + + {running ? t('dashboard.running') : t('dashboard.stopped')} + + + } + styles={{ body: { padding: 16 } }} + style={{ borderRadius: 10, height: '100%' }} + > +
+
+ {t('dashboard.version') || '版本'} + {mihomo?.version || '—'} +
+
+ {t('dashboard.uptime') || '运行时间'} + {mihomo?.uptime || '—'} +
+
+ {t('dashboard.panel') || '面板'} + + {panel?.memory_used != null ? formatBytes(panel.memory_used) : '—'} + {panel?.cpu_percent != null ? ` · ${clampPct(panel.cpu_percent)}%` : ''} + {panel?.pid ? ` · PID ${panel.pid}` : ''} + +
+
+ {t('dashboard.core') || '核心'} + + {running && core?.memory_used != null ? formatBytes(core.memory_used) : '—'} + {running && core?.cpu_percent != null ? ` · ${clampPct(core.cpu_percent)}%` : ''} + {running && core?.pid ? ` · PID ${core.pid}` : ''} + +
- - {coreRunning ? t('dashboard.running') : t('dashboard.stoppedStatus')} - - - -
+
+ {!running ? ( + + ) : ( + <> + + + + )} + + + + + + +
+ + + + + {/* Row 4 — quick stats table style (m-ui traffic users simplified) */} + + + + + + + + +
+ + + ); }; -export default Dashboard; +export default DashboardPage;