-
- {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 (
+
+ );
};
-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 ? (
- } onClick={() => act('start')} loading={busy} size={isMobile ? 'middle' : 'middle'}>
- {t('dashboard.start')}
-
- ) : null}
- } onClick={() => act('restart')} loading={busy}>
- {t('dashboard.restart')}
-
- } danger onClick={() => act('stop')} loading={busy} disabled={!coreRunning}>
- {t('dashboard.stop')}
-
- {!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 ? (
+ }
+ loading={actionLoading}
+ onClick={() => runAction(startMihomo, t('dashboard.started'))}
+ >
+ {t('dashboard.start')}
+
+ ) : (
+ <>
+ }
+ loading={actionLoading}
+ onClick={() => runAction(restartMihomo, t('dashboard.restarted'))}
+ >
+ {t('dashboard.restart')}
+
+ }
+ loading={actionLoading}
+ onClick={() => runAction(stopMihomo, t('dashboard.stopped'))}
+ >
+ {t('dashboard.stop')}
+
+ >
+ )}
+
+
+
+
+
+
+
+
+
+
+
+ {/* Row 4 — quick stats table style (m-ui traffic users simplified) */}
+
+
+
+
+
+
+
+
+
+
+
+
);
};
-export default Dashboard;
+export default DashboardPage;