feat(ui): redesign the overview page as a trend-first command deck

Replace the ten-small-cards overview with an action bar, four vitals
tiles carrying 72-sample sparklines seeded from /server/history, a
two-series throughput chart, a TCP/UDP connections chart, and a
grouped system strip (uptime xray|os, panel ram|threads, ip
addresses). StatusCard and XrayStatusCard are deleted; every modal
stays reachable from the action bar, the Xray error message moves
into a tooltip on the state pill, and the panel version text keeps
opening the update modal (the dev-channel switch lives there) even
when no update is available. Live values sit beside the
upload/download and tcp/udp legends, a health sentence appears only
when a vital crosses the shared warn/crit thresholds now exported
from models/status, and load average is left to System History.

The sidebar becomes an auto-collapsed 72px icon rail that expands as
an overlay on hover: rail width, brand-row height and menu paddings
are pinned so nothing shifts during the transition, the collapsed-menu
tooltips are disabled, hover state survives the per-page sidebar
remounts (with a matches(':hover') resync), and the manual collapse
trigger is gone.

Sparkline gains rgb()/rgba() support in its fill gradient, a
showLegend prop so pages stop reaching into its internals, and loses
a dependency-less repaint effect that doubled canvas paints. Chart
tooltips show clock time via the new TimeFormatter.formatClock;
accents come from theme tokens instead of status.cpu.color. Verified
by screenshot at 390/800/1150/1280/1400/1600px in light and dark,
en and fa-IR, plus programmatic geometry checks on the sidebar.
Locale files gain 8 keys and lose 9 dead ones across all 13
languages.
This commit is contained in:
Sanaei
2026-07-29 20:17:37 +02:00
parent 87ebcc7a6f
commit c3fa73d5a0
30 changed files with 1467 additions and 765 deletions
@@ -0,0 +1,74 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Card, theme } from 'antd';
import { Sparkline } from '@/components/viz';
import type { Status } from '@/models/status';
interface ConnectionsCardProps {
status: Status;
tcp: number[];
udp: number[];
labels: string[];
isMobile: boolean;
}
export default function ConnectionsCard({ status, tcp, udp, labels, isMobile }: ConnectionsCardProps) {
const { t } = useTranslation();
const { token } = theme.useToken();
const accent = token.colorPrimary;
const udpColor = token.colorTextTertiary;
const referenceLines = useMemo(
() => [
{ y: status.udpCount, color: udpColor, dash: '2 4' },
{ y: status.tcpCount, color: accent, dash: '2 4' },
],
[status.tcpCount, status.udpCount, accent, udpColor],
);
return (
<Card hoverable styles={{ body: { padding: 0 } }}>
<div className="ov-wide-head ov-wide-head-stack">
<div className="ov-kicker">{t('pages.index.connectionCount')}</div>
<div className="ov-conn-total">
<span className="ov-tile-number">{status.tcpCount + status.udpCount}</span>
<span className="ov-tile-unit">{t('pages.index.openSockets')}</span>
</div>
</div>
<div className="ov-conn-legend">
<div className="ov-legend-label">
<span className="ov-swatch" style={{ background: accent }} />
TCP
<span className="ov-legend-num">{status.tcpCount.toLocaleString()}</span>
</div>
<div className="ov-legend-label">
<span className="ov-swatch" style={{ background: udpColor }} />
UDP
<span className="ov-legend-num">{status.udpCount.toLocaleString()}</span>
</div>
</div>
<div className="ov-wide-chart">
<Sparkline
data={tcp}
data2={udp}
labels={labels}
height={isMobile ? 120 : 170}
strokeWidth={1.5}
fillOpacity={0.24}
showTooltip
showLegend={false}
valueMax={null}
stroke={accent}
stroke2={udpColor}
name1="TCP"
name2="UDP"
yFormatter={(v) => Math.round(v).toLocaleString()}
referenceLines={referenceLines}
/>
</div>
</Card>
);
}
+448 -29
View File
@@ -1,51 +1,470 @@
/* Overview page — trend-first layout. Every colour comes from the AntD theme
tokens so light / dark / ultra-dark keep working and the sidebar is untouched.
Set --ov-accent once here if you want a fixed accent instead of the primary. */
.index-page {
--ov-accent: var(--ant-color-primary);
--ov-line: var(--ant-color-border);
--ov-label: var(--ant-color-text-secondary);
--ov-faint: var(--ant-color-text-tertiary);
--ov-gap: 12px;
--ov-pad: 20px;
}
@media (max-width: 768px) {
.index-page .content-area {
padding: 12px;
padding-top: 64px;
}
.index-page {
--ov-gap: 8px;
--ov-pad: 14px;
}
}
.index-page .action {
cursor: pointer;
justify-content: center;
max-width: 100%;
flex-wrap: nowrap;
.ov-page {
display: flex;
flex-direction: column;
gap: var(--ov-gap);
}
.index-page .action > span:not(.anticon) {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
/* — action bar — */
.ov-bar {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.index-page .action-update {
color: var(--ant-color-warning);
font-weight: 600;
}
.index-page .action-update .anticon {
color: var(--ant-color-warning);
}
.index-page .history-tag {
cursor: pointer;
.ov-state {
display: inline-flex;
align-items: center;
gap: 4px;
gap: 8px;
padding: 4px 12px;
border: 1px solid var(--ov-line);
border-radius: 999px;
font-size: 13px;
color: var(--ant-color-text);
}
.ov-state-dot {
position: relative;
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
flex: none;
}
.ov-state[data-state='running'] .ov-state-dot::after {
content: '';
position: absolute;
inset: -1px;
border-radius: 50%;
border: 1px solid currentColor;
animation: ovPulse 1.6s infinite ease-out;
}
@keyframes ovPulse {
0% { transform: scale(0.9); opacity: 0.5; }
100% { transform: scale(2.4); opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.ov-state[data-state='running'] .ov-state-dot::after {
animation: none;
}
}
.ov-state-version,
.ov-panel-version {
padding: 0;
border: 0;
background: transparent;
font: inherit;
cursor: pointer;
color: var(--ov-label);
transition: color 0.2s;
}
.ov-state-version:hover,
.ov-state-version:focus-visible,
.ov-panel-version:hover,
.ov-panel-version:focus-visible {
color: var(--ant-color-primary);
}
.ov-panel-version {
font-size: 12px;
color: var(--ov-faint);
}
.ov-update-tag {
cursor: pointer;
margin-inline-end: 0;
}
.index-page .ip-toggle-icon {
cursor: pointer;
font-size: 16px;
.ov-error-detail {
white-space: pre-wrap;
word-break: break-word;
}
.index-page .ip-hidden .ant-statistic-content-value {
filter: blur(6px);
.ov-bar-actions {
margin-inline-start: auto;
display: flex;
align-items: center;
gap: 4px;
flex-wrap: wrap;
}
@media (max-width: 768px) {
.ov-bar-actions {
margin-inline-start: 0;
width: 100%;
justify-content: space-between;
}
}
.ov-bar-sep {
width: 1px;
height: 20px;
background: var(--ov-line);
margin: 0 4px;
}
.ov-health {
display: flex;
align-items: center;
gap: 8px;
font-size: 12.5px;
}
.ov-health-mark {
width: 14px;
height: 1px;
background: currentColor;
flex: none;
}
.ov-rule {
height: 1px;
border: 0;
margin: 0;
background: linear-gradient(
to right,
transparent,
var(--ov-line) 48px,
var(--ov-line) calc(100% - 48px),
transparent
);
}
/* — shared type — */
.ov-kicker {
font-size: 11px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--ov-label);
}
.ov-kicker-icon {
display: flex;
align-items: center;
gap: 7px;
}
.ov-sub {
font-size: 12.5px;
margin-top: 4px;
color: var(--ov-faint);
}
.ov-mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
}
/* — vitals tiles — */
.ov-vitals {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: var(--ov-gap);
}
@media (max-width: 1100px) {
.ov-vitals { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 560px) {
.ov-vitals { grid-template-columns: minmax(0, 1fr); }
}
.ov-tile {
overflow: hidden;
}
.ov-tile-head {
display: flex;
align-items: center;
gap: 8px;
padding: 14px var(--ov-pad) 0;
color: var(--ov-accent);
}
.ov-tile-icon {
display: inline-flex;
font-size: 15px;
}
.ov-tile-value {
display: flex;
align-items: baseline;
gap: 4px;
padding: 12px var(--ov-pad) 0;
}
.ov-tile-number {
font-size: 34px;
font-weight: 600;
line-height: 1;
letter-spacing: -0.02em;
color: var(--ant-color-text);
font-variant-numeric: tabular-nums;
}
.ov-tile-unit {
font-size: 14px;
color: var(--ov-label);
}
.ov-tile-detail {
padding: 5px var(--ov-pad) 0;
font-size: 12px;
color: var(--ov-label);
}
.ov-tile-foot {
display: flex;
justify-content: space-between;
gap: 8px;
padding: 14px var(--ov-pad) 0;
font-size: 10px;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--ov-faint);
}
.ov-tile-chart {
margin-top: 6px;
}
/* — throughput + connections — */
.ov-mid {
display: grid;
grid-template-columns: minmax(0, 2fr) minmax(0, 1fr);
gap: var(--ov-gap);
}
@media (max-width: 1100px) {
.ov-mid { grid-template-columns: minmax(0, 1fr); }
}
.ov-wide-head {
display: flex;
align-items: flex-start;
flex-wrap: wrap;
gap: 16px;
padding: var(--ov-pad) var(--ov-pad) 0;
}
.ov-wide-head-stack {
flex-direction: column;
gap: 0;
}
.ov-wide-legend {
margin-inline-start: auto;
display: flex;
gap: 22px;
text-align: end;
}
.ov-legend-label {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 6px;
font-size: 11px;
color: var(--ov-label);
}
.ov-legend-num {
font-size: 13px;
font-weight: 600;
color: var(--ant-color-text);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.ov-conn-total {
display: flex;
align-items: baseline;
gap: 6px;
margin-top: 12px;
}
.ov-conn-legend {
display: flex;
gap: 16px;
padding: 16px var(--ov-pad) 0;
}
.ov-conn-legend > div {
flex: 1 1 0;
}
.ov-conn-legend .ov-legend-label {
justify-content: flex-start;
}
.ov-swatch {
width: 14px;
height: 2px;
flex: none;
}
.ov-wide-chart {
padding: 12px 8px 0;
}
.ov-wide-foot {
display: flex;
gap: 16px;
margin: 12px var(--ov-pad) 0;
padding: 14px 0 var(--ov-pad);
border-top: 1px solid var(--ov-line);
}
.ov-wide-foot > div {
flex: 1 1 0;
}
.ov-foot-sep {
width: 1px;
background: var(--ov-line);
}
.ov-foot-value {
font-size: 18px;
font-weight: 600;
margin-top: 4px;
color: var(--ant-color-text);
font-variant-numeric: tabular-nums;
}
.ov-foot-part {
display: inline-block;
white-space: nowrap;
}
@media (max-width: 560px) {
.ov-wide-foot {
flex-direction: column;
gap: 10px;
}
.ov-wide-foot .ov-foot-sep {
display: none;
}
}
/* — system strip — */
/* tracks follow SystemStrip's cell order:
uptime (xray | os) · panel (memory | threads) · ip addresses */
.ov-strip-grid {
display: grid;
grid-template-columns:
minmax(max-content, 1.2fr) minmax(max-content, 1.2fr) minmax(0, 1.6fr);
gap: 16px;
padding: var(--ov-pad);
}
@media (max-width: 1439px) {
.ov-strip-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}
@media (max-width: 1100px) {
.ov-strip-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 560px) {
.ov-strip-grid { grid-template-columns: minmax(0, 1fr); }
}
@media (min-width: 1440px) {
.ov-strip-cell + .ov-strip-cell {
border-inline-start: 1px solid var(--ov-line);
padding-inline-start: 16px;
}
}
.ov-strip-value {
font-size: 19px;
font-weight: 600;
margin-top: 6px;
color: var(--ant-color-text);
font-variant-numeric: tabular-nums;
}
.ov-strip-split {
display: flex;
align-items: stretch;
gap: 14px;
}
.ov-strip-split-sep {
width: 1px;
background: var(--ov-line);
margin-top: 8px;
}
.ov-strip-sub {
font-size: 10px;
letter-spacing: 0.06em;
text-transform: uppercase;
margin-top: 8px;
color: var(--ov-faint);
}
.ov-strip-sub + .ov-strip-value {
margin-top: 2px;
}
.ov-ip {
margin-top: 7px;
font-size: 13px;
overflow-wrap: anywhere;
transition: filter 0.2s ease;
}
.index-page .ip-visible .ant-statistic-content-value {
filter: none;
.ov-ip-v6 {
margin-top: 3px;
color: var(--ov-label);
}
/* — preserved from the previous overview — */
.index-page .ip-toggle-icon {
cursor: pointer;
font-size: 15px;
margin-inline-start: auto;
}
.index-page .ip-hidden {
filter: blur(6px);
}
+124 -309
View File
@@ -1,53 +1,29 @@
import { lazy, useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, ConfigProvider, Layout, Modal, Result, Spin, message } from 'antd';
import {
Button,
Card,
Col,
ConfigProvider,
Layout,
message,
Modal,
Result,
Row,
Space,
Spin,
Statistic,
Tag,
Tooltip,
} from 'antd';
import {
BarsOutlined,
ControlOutlined,
CloudServerOutlined,
CloudDownloadOutlined,
CloudUploadOutlined,
ArrowUpOutlined,
ArrowDownOutlined,
AreaChartOutlined,
GlobalOutlined,
SwapOutlined,
EyeOutlined,
EyeInvisibleOutlined,
ThunderboltOutlined,
DesktopOutlined,
DatabaseOutlined,
ForkOutlined,
CopyOutlined,
TelegramFilled,
CloudDownloadOutlined,
DashboardOutlined,
DatabaseOutlined,
HddOutlined,
SwapOutlined,
} from '@ant-design/icons';
import { HttpUtil, SizeFormatter, TimeFormatter, ClipboardManager, FileManager } from '@/utils';
import { formatPanelVersion } from '@/lib/panel-version';
import { activateOnKey } from '@/utils/a11y';
import { HttpUtil, CPUFormatter, SizeFormatter, ClipboardManager, FileManager } from '@/utils';
import { USAGE_CRIT_COLOR, USAGE_CRIT_PERCENT, USAGE_WARN_COLOR, USAGE_WARN_PERCENT } from '@/models/status';
import { useTheme } from '@/hooks/useTheme';
import { useStatusQuery } from '@/api/queries/useStatusQuery';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import AppSidebar from '@/layouts/AppSidebar';
import { LazyMount } from '@/components/utility';
import { setMessageInstance } from '@/utils/messageBus';
import StatusCard from './StatusCard';
import XrayStatusCard from './XrayStatusCard';
import OverviewActionBar from './OverviewActionBar';
import VitalTile from './VitalTile';
import ThroughputCard from './ThroughputCard';
import ConnectionsCard from './ConnectionsCard';
import SystemStrip from './SystemStrip';
import { mean, peak, useOverviewHistory } from './useOverviewHistory';
import type { PanelUpdateInfo } from './PanelUpdateModal';
const JsonEditor = lazy(() => import('@/components/form/JsonEditor'));
const PanelUpdateModal = lazy(() => import('./PanelUpdateModal'));
@@ -90,6 +66,8 @@ export default function IndexPage() {
const [loading, setLoading] = useState(false);
const [loadingTip, setLoadingTip] = useState(t('loading'));
const history = useOverviewHistory(status, fetched && !fetchError);
useEffect(() => {
HttpUtil.post<{ accessLogEnable?: boolean; devChannelEnable?: boolean }>(
'/panel/api/setting/defaultSettings',
@@ -127,10 +105,6 @@ export default function IndexPage() {
await refresh();
}, [refresh]);
function openPanelVersion() {
setPanelUpdateOpen(true);
}
async function handleChannelChange(dev: boolean) {
const res = await HttpUtil.post('/panel/api/server/setUpdateChannel', { dev });
if (!res?.success) return;
@@ -139,10 +113,6 @@ export default function IndexPage() {
if (msg?.success && msg.obj) setPanelUpdateInfo(msg.obj);
}
function openTelegram() {
window.open('https://t.me/XrayUI', '_blank', 'noopener,noreferrer');
}
async function openConfig() {
setLoading(true);
try {
@@ -165,6 +135,23 @@ export default function IndexPage() {
}
const pageClass = `index-page ${isDark ? 'is-dark' : ''} ${isUltra ? 'is-ultra' : ''}`.trim();
const totalDisk = status.disk.total;
const freeDisk = Math.max(0, totalDisk - status.disk.current);
const health = useMemo(() => {
const items = [
{ name: t('pages.index.cpu'), value: status.cpu.percent },
{ name: t('pages.index.memory'), value: status.mem.percent },
{ name: t('pages.index.swap'), value: status.swap.percent },
{ name: t('pages.index.storage'), value: status.disk.percent },
];
const list = (xs: typeof items) => xs.map((i) => `${i.name} ${i.value.toFixed(0)}%`).join(', ');
const crit = items.filter((i) => i.value >= USAGE_CRIT_PERCENT);
if (crit.length) return { text: t('pages.index.healthCritical', { list: list(crit) }), color: USAGE_CRIT_COLOR };
const warm = items.filter((i) => i.value >= USAGE_WARN_PERCENT);
if (warm.length) return { text: t('pages.index.healthWarm', { list: list(warm) }), color: USAGE_WARN_COLOR };
return null;
}, [status, t]);
return (
<ConfigProvider theme={antdThemeConfig}>
@@ -190,277 +177,105 @@ export default function IndexPage() {
extra={<Button type="primary" onClick={refresh}>{t('refresh')}</Button>}
/>
) : (
<Row gutter={[isMobile ? 8 : 16, 12]}>
<Col span={24}>
<StatusCard status={status} isMobile={isMobile} />
</Col>
<div className="ov-page">
<OverviewActionBar
status={status}
isMobile={isMobile}
accessLogEnable={accessLogEnable}
panelVersion={displayVersion}
latestVersion={panelUpdateInfo.latestVersion}
updateAvailable={panelUpdateInfo.updateAvailable}
onStopXray={stopXray}
onRestartXray={restartXray}
onOpenLogs={() => setLogsOpen(true)}
onOpenXrayLogs={() => setXrayLogsOpen(true)}
onOpenConfig={openConfig}
onOpenBackup={() => setBackupOpen(true)}
onOpenSystemHistory={() => setSysHistoryOpen(true)}
onOpenXrayMetrics={() => setXrayMetricsOpen(true)}
onOpenPanelUpdate={() => setPanelUpdateOpen(true)}
onOpenVersionSwitch={() => setVersionOpen(true)}
/>
<Col xs={24} lg={12}>
<XrayStatusCard
status={status}
{health && (
<div className="ov-health" style={{ color: health.color }}>
<span className="ov-health-mark" />
{health.text}
</div>
)}
<hr className="ov-rule" />
<div className="ov-vitals">
<VitalTile
icon={<DashboardOutlined />}
label={t('pages.index.cpu')}
percent={status.cpu.percent}
statusColor={status.cpu.color}
detail={`${CPUFormatter.cpuCoreFormat(status.cpuCores)} / ${status.logicalPro}T · ${CPUFormatter.cpuSpeedFormat(status.cpuSpeedMhz)}`}
footLeft={`${t('pages.index.avg')} ${mean(history.series.cpu).toFixed(0)}%`}
footRight={`${t('pages.index.peak')} ${peak(history.series.cpu).toFixed(0)}%`}
data={history.series.cpu}
isMobile={isMobile}
accessLogEnable={accessLogEnable}
onStopXray={stopXray}
onRestartXray={restartXray}
onOpenXrayLogs={() => setXrayLogsOpen(true)}
onOpenLogs={() => setLogsOpen(true)}
onOpenVersionSwitch={() => setVersionOpen(true)}
/>
</Col>
<Col xs={24} lg={12}>
<Card
title={t('menu.link')}
hoverable
actions={[
<Space className="action" key="logs" role="button" tabIndex={0} aria-label={t('pages.index.logs')} onClick={() => setLogsOpen(true)} onKeyDown={activateOnKey(() => setLogsOpen(true))}>
<BarsOutlined />
{!isMobile && <span>{t('pages.index.logs')}</span>}
</Space>,
<Space className="action" key="config" role="button" tabIndex={0} aria-label={t('pages.index.config')} onClick={openConfig} onKeyDown={activateOnKey(openConfig)}>
<ControlOutlined />
{!isMobile && <span>{t('pages.index.config')}</span>}
</Space>,
<Space className="action" key="backup" role="button" tabIndex={0} aria-label={t('pages.index.backupTitle')} onClick={() => setBackupOpen(true)} onKeyDown={activateOnKey(() => setBackupOpen(true))}>
<CloudServerOutlined />
{!isMobile && <span>{t('pages.index.backupTitle')}</span>}
</Space>,
]}
<VitalTile
icon={<DatabaseOutlined />}
label={t('pages.index.memory')}
percent={status.mem.percent}
statusColor={status.mem.color}
detail={`${SizeFormatter.sizeFormat(status.mem.current)} / ${SizeFormatter.sizeFormat(status.mem.total)}`}
footLeft={`${t('pages.index.avg')} ${mean(history.series.mem).toFixed(0)}%`}
footRight={`${t('pages.index.peak')} ${peak(history.series.mem).toFixed(0)}%`}
data={history.series.mem}
isMobile={isMobile}
/>
</Col>
<Col xs={24} lg={12}>
<Card
title={
<Space>
<span>3X-UI</span>
{isMobile && displayVersion && (
<Tag color={panelUpdateInfo.updateAvailable ? 'orange' : 'green'}>
{panelUpdateInfo.updateAvailable
? formatPanelVersion(panelUpdateInfo.latestVersion)
: formatPanelVersion(displayVersion)}
</Tag>
)}
</Space>
}
hoverable
actions={[
<Space className="action" key="tg" role="button" tabIndex={0} aria-label="@XrayUI" onClick={openTelegram} onKeyDown={activateOnKey(openTelegram)}>
<TelegramFilled aria-hidden="true" />
{!isMobile && <span>@XrayUI</span>}
</Space>,
<Space
key="panel-version"
className={`action ${panelUpdateInfo.updateAvailable ? 'action-update' : ''}`}
role="button"
tabIndex={0}
aria-label={t('pages.index.updatePanel')}
onClick={openPanelVersion}
onKeyDown={activateOnKey(openPanelVersion)}
>
<CloudDownloadOutlined />
{!isMobile && (
<span>
{panelUpdateInfo.updateAvailable
? `${t('update')} ${formatPanelVersion(panelUpdateInfo.latestVersion)}`
: formatPanelVersion(displayVersion)}
</span>
)}
</Space>,
]}
<VitalTile
icon={<SwapOutlined />}
label={t('pages.index.swap')}
percent={status.swap.percent}
statusColor={status.swap.color}
detail={`${SizeFormatter.sizeFormat(status.swap.current)} / ${SizeFormatter.sizeFormat(status.swap.total)}`}
footLeft={`${t('pages.index.avg')} ${mean(history.series.swap).toFixed(1)}%`}
footRight={`${t('pages.index.peak')} ${peak(history.series.swap).toFixed(0)}%`}
data={history.series.swap}
isMobile={isMobile}
/>
</Col>
<Col xs={24} lg={12}>
<Card
title={t('pages.index.charts')}
hoverable
actions={[
<Space
className="action"
key="sys-history"
role="button"
tabIndex={0}
aria-label={t('pages.index.systemHistoryTitle')}
onClick={() => setSysHistoryOpen(true)}
onKeyDown={activateOnKey(() => setSysHistoryOpen(true))}
>
<AreaChartOutlined />
{!isMobile && <span>{t('pages.index.systemHistoryTitle')}</span>}
</Space>,
<Space
className="action"
key="xray-metrics"
role="button"
tabIndex={0}
aria-label={t('pages.index.xrayMetricsTitle')}
onClick={() => setXrayMetricsOpen(true)}
onKeyDown={activateOnKey(() => setXrayMetricsOpen(true))}
>
<AreaChartOutlined />
{!isMobile && <span>{t('pages.index.xrayMetricsTitle')}</span>}
</Space>,
]}
<VitalTile
icon={<HddOutlined />}
label={t('pages.index.storage')}
percent={status.disk.percent}
statusColor={status.disk.color}
detail={`${SizeFormatter.sizeFormat(status.disk.current)} / ${SizeFormatter.sizeFormat(totalDisk)}`}
footLeft={`${t('pages.index.free')} ${SizeFormatter.sizeFormat(freeDisk)}`}
footRight={`${t('pages.index.avg')} ${mean(history.series.diskUsage).toFixed(1)}%`}
data={history.series.diskUsage}
isMobile={isMobile}
/>
</Col>
</div>
<Col xs={24} lg={12}>
<Card title={t('pages.index.operationHours')} hoverable>
<Row gutter={isMobile ? [8, 8] : 0}>
<Col span={12}>
<Statistic
title="Xray"
value={TimeFormatter.formatSecond(status.appStats.uptime)}
prefix={<ThunderboltOutlined />}
/>
</Col>
<Col span={12}>
<Statistic
title="OS"
value={TimeFormatter.formatSecond(status.uptime)}
prefix={<DesktopOutlined />}
/>
</Col>
</Row>
</Card>
</Col>
<div className="ov-mid">
<ThroughputCard
status={status}
up={history.series.netUp}
down={history.series.netDown}
labels={history.labels}
isMobile={isMobile}
/>
<ConnectionsCard
status={status}
tcp={history.series.tcpCount}
udp={history.series.udpCount}
labels={history.labels}
isMobile={isMobile}
/>
</div>
<Col xs={24} lg={12}>
<Card title={t('usage')} hoverable>
<Row gutter={isMobile ? [8, 8] : 0}>
<Col span={12}>
<Statistic
title={t('pages.index.memory')}
value={SizeFormatter.sizeFormat(status.appStats.mem)}
prefix={<DatabaseOutlined />}
/>
</Col>
<Col span={12}>
<Statistic
title={t('pages.index.threads')}
value={status.appStats.threads}
prefix={<ForkOutlined />}
/>
</Col>
</Row>
</Card>
</Col>
<Col xs={24} lg={12}>
<Card title={t('pages.index.overallSpeed')} hoverable>
<Row gutter={isMobile ? [8, 8] : 0}>
<Col span={12}>
<Statistic
title={t('pages.index.upload')}
value={SizeFormatter.sizeFormat(status.netIO.up)}
prefix={<ArrowUpOutlined />}
suffix="/s"
/>
</Col>
<Col span={12}>
<Statistic
title={t('pages.index.download')}
value={SizeFormatter.sizeFormat(status.netIO.down)}
prefix={<ArrowDownOutlined />}
suffix="/s"
/>
</Col>
</Row>
</Card>
</Col>
<Col xs={24} lg={12}>
<Card title={t('pages.index.totalData')} hoverable>
<Row gutter={isMobile ? [8, 8] : 0}>
<Col span={12}>
<Statistic
title={t('pages.index.sent')}
value={SizeFormatter.sizeFormat(status.netTraffic.sent)}
prefix={<CloudUploadOutlined />}
/>
</Col>
<Col span={12}>
<Statistic
title={t('pages.index.received')}
value={SizeFormatter.sizeFormat(status.netTraffic.recv)}
prefix={<CloudDownloadOutlined />}
/>
</Col>
</Row>
</Card>
</Col>
<Col xs={24} lg={12}>
<Card
title={t('pages.index.ipAddresses')}
hoverable
extra={
<Tooltip
title={t('pages.index.toggleIpVisibility')}
placement={isMobile ? 'topRight' : 'top'}
>
{showIp ? (
<EyeOutlined
className="ip-toggle-icon"
role="button"
tabIndex={0}
aria-label={t('pages.index.toggleIpVisibility')}
onClick={() => setShowIp(false)}
onKeyDown={activateOnKey(() => setShowIp(false))}
/>
) : (
<EyeInvisibleOutlined
className="ip-toggle-icon"
role="button"
tabIndex={0}
aria-label={t('pages.index.toggleIpVisibility')}
onClick={() => setShowIp(true)}
onKeyDown={activateOnKey(() => setShowIp(true))}
/>
)}
</Tooltip>
}
>
<Row className={showIp ? 'ip-visible' : 'ip-hidden'} gutter={isMobile ? [8, 8] : 0}>
<Col span={isMobile ? 24 : 12}>
<Statistic
title="IPv4"
value={status.publicIP.ipv4}
prefix={<GlobalOutlined />}
/>
</Col>
<Col span={isMobile ? 24 : 12}>
<Statistic
title="IPv6"
value={status.publicIP.ipv6}
prefix={<GlobalOutlined />}
/>
</Col>
</Row>
</Card>
</Col>
<Col xs={24} lg={12}>
<Card title={t('pages.index.connectionCount')} hoverable>
<Row gutter={isMobile ? [8, 8] : 0}>
<Col span={12}>
<Statistic
title="TCP"
value={status.tcpCount}
prefix={<SwapOutlined />}
/>
</Col>
<Col span={12}>
<Statistic
title="UDP"
value={status.udpCount}
prefix={<SwapOutlined />}
/>
</Col>
</Row>
</Card>
</Col>
</Row>
<SystemStrip
status={status}
showIp={showIp}
onToggleIp={() => setShowIp((v) => !v)}
/>
</div>
)}
</Spin>
</Layout.Content>
@@ -0,0 +1,163 @@
import { Fragment } from 'react';
import type { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Tag, Tooltip } from 'antd';
import {
ArrowUpOutlined,
AreaChartOutlined,
BarsOutlined,
CloudDownloadOutlined,
CloudServerOutlined,
ControlOutlined,
FileTextOutlined,
PoweroffOutlined,
ReloadOutlined,
} from '@ant-design/icons';
import { formatPanelVersion } from '@/lib/panel-version';
import type { Status } from '@/models/status';
interface OverviewActionBarProps {
status: Status;
isMobile: boolean;
accessLogEnable: boolean;
panelVersion: string;
latestVersion: string;
updateAvailable: boolean;
onStopXray: () => void;
onRestartXray: () => void;
onOpenLogs: () => void;
onOpenXrayLogs: () => void;
onOpenConfig: () => void;
onOpenBackup: () => void;
onOpenSystemHistory: () => void;
onOpenXrayMetrics: () => void;
onOpenPanelUpdate: () => void;
onOpenVersionSwitch: () => void;
}
interface BarAction {
key: string;
icon: ReactNode;
text: string;
onClick: () => void;
primary?: boolean;
}
const XRAY_STATE_KEYS: Record<string, string> = {
running: 'pages.index.xrayStatusRunning',
stop: 'pages.index.xrayStatusStop',
error: 'pages.index.xrayStatusError',
};
export default function OverviewActionBar({
status,
isMobile,
accessLogEnable,
panelVersion,
latestVersion,
updateAvailable,
onStopXray,
onRestartXray,
onOpenLogs,
onOpenXrayLogs,
onOpenConfig,
onOpenBackup,
onOpenSystemHistory,
onOpenXrayMetrics,
onOpenPanelUpdate,
onOpenVersionSwitch,
}: OverviewActionBarProps) {
const { t } = useTranslation();
const stateText = t(XRAY_STATE_KEYS[status.xray.state] ?? 'pages.index.xrayStatusUnknown');
const hasVersion = !!status.xray.version && status.xray.version !== 'Unknown';
const size = isMobile ? ('small' as const) : ('middle' as const);
const actionGroups: BarAction[][] = [
[
{ key: 'restart', icon: <ReloadOutlined />, text: t('pages.index.restartXray'), onClick: onRestartXray, primary: true },
{ key: 'stop', icon: <PoweroffOutlined />, text: t('pages.index.stopXray'), onClick: onStopXray },
],
[
{ key: 'logs', icon: <BarsOutlined />, text: t('pages.index.logs'), onClick: onOpenLogs },
...(accessLogEnable
? [{ key: 'accessLogs', icon: <FileTextOutlined />, text: t('pages.index.accessLogs'), onClick: onOpenXrayLogs }]
: []),
{ key: 'config', icon: <ControlOutlined />, text: t('pages.index.config'), onClick: onOpenConfig },
{ key: 'backup', icon: <CloudServerOutlined />, text: t('pages.index.backupTitle'), onClick: onOpenBackup },
],
[
{ key: 'history', icon: <AreaChartOutlined />, text: t('pages.index.systemHistoryTitle'), onClick: onOpenSystemHistory },
{ key: 'metrics', icon: <ArrowUpOutlined />, text: t('pages.index.xrayMetricsTitle'), onClick: onOpenXrayMetrics },
],
];
const statePill = (
<span className="ov-state" data-state={status.xray.state}>
<span className="ov-state-dot" style={{ color: status.xray.color }} />
<span>{`${t('pages.index.xrayStatus')} · ${stateText}`}</span>
{hasVersion && (
<Tooltip title={t('pages.index.xraySwitch')}>
<button
type="button"
className="ov-state-version"
onClick={onOpenVersionSwitch}
>
{`v${status.xray.version}`}
</button>
</Tooltip>
)}
</span>
);
return (
<div className="ov-bar">
{status.xray.state === 'error' && status.xray.errorMsg ? (
<Tooltip title={<span className="ov-error-detail">{status.xray.errorMsg}</span>}>
{statePill}
</Tooltip>
) : (
statePill
)}
{updateAvailable ? (
<Tag
className="ov-update-tag"
color="warning"
icon={<CloudDownloadOutlined />}
onClick={onOpenPanelUpdate}
>
{`${t('update')} ${formatPanelVersion(latestVersion)}`}
</Tag>
) : (
<Tooltip title={t('pages.index.updatePanel')}>
<button type="button" className="ov-panel-version ov-mono" onClick={onOpenPanelUpdate}>
{formatPanelVersion(panelVersion)}
</button>
</Tooltip>
)}
<div className="ov-bar-actions">
{actionGroups.map((group, groupIndex) => (
<Fragment key={group[0].key}>
{groupIndex > 0 && <span className="ov-bar-sep" />}
{group.map((action) => (
<Button
key={action.key}
type={action.primary ? undefined : 'text'}
color={action.primary ? 'primary' : undefined}
variant={action.primary ? 'outlined' : undefined}
size={size}
icon={action.icon}
aria-label={action.text}
onClick={action.onClick}
>
{isMobile ? undefined : action.text}
</Button>
))}
</Fragment>
))}
</div>
</div>
);
}
-9
View File
@@ -1,9 +0,0 @@
.status-card .text-center {
text-align: center;
}
.status-card .ant-progress-text,
.status-card .ant-progress-indicator {
font-size: 12px !important;
font-weight: 500;
}
-115
View File
@@ -1,115 +0,0 @@
import { useTranslation } from 'react-i18next';
import { Card, Col, Progress, Row, Tooltip } from 'antd';
import { AreaChartOutlined } from '@ant-design/icons';
import { CPUFormatter, SizeFormatter } from '@/utils';
import { useTheme } from '@/hooks/useTheme';
import type { Status } from '@/models/status';
import './StatusCard.css';
interface StatusCardProps {
status: Status;
isMobile: boolean;
}
export default function StatusCard({ status, isMobile }: StatusCardProps) {
const { t } = useTranslation();
const { isDark, isUltra } = useTheme();
const gaugeSize = isMobile ? 60 : 90;
const strokeWidth = isMobile ? 7 : 5;
const railColor = isDark
? isUltra ? 'rgba(255, 255, 255, 0.1)' : 'rgba(255, 255, 255, 0.16)'
: 'rgba(0, 0, 0, 0.08)';
return (
<Card hoverable className="status-card">
<Row gutter={[0, isMobile ? 16 : 0]}>
<Col xs={24} md={12}>
<Row>
<Col span={12} className="text-center">
<Progress
type="dashboard"
status="normal"
strokeColor={status.cpu.color}
railColor={railColor}
strokeWidth={strokeWidth}
percent={status.cpu.percent}
size={gaugeSize}
/>
<div>
<b>{t('pages.index.cpu')}:</b> {CPUFormatter.cpuCoreFormat(status.cpuCores)}
<Tooltip
title={
<>
<div>
<b>{t('pages.index.logicalProcessors')}:</b> {status.logicalPro}
</div>
<div>
<b>{t('pages.index.frequency')}:</b>{' '}
{CPUFormatter.cpuSpeedFormat(status.cpuSpeedMhz)}
</div>
</>
}
>
<AreaChartOutlined />
</Tooltip>
</div>
</Col>
<Col span={12} className="text-center">
<Progress
type="dashboard"
status="normal"
strokeColor={status.mem.color}
railColor={railColor}
strokeWidth={strokeWidth}
percent={status.mem.percent}
size={gaugeSize}
/>
<div>
<b>{t('pages.index.memory')}:</b> {SizeFormatter.sizeFormat(status.mem.current)} /{' '}
{SizeFormatter.sizeFormat(status.mem.total)}
</div>
</Col>
</Row>
</Col>
<Col xs={24} md={12}>
<Row>
<Col span={12} className="text-center">
<Progress
type="dashboard"
status="normal"
strokeColor={status.swap.color}
railColor={railColor}
strokeWidth={strokeWidth}
percent={status.swap.percent}
size={gaugeSize}
/>
<div>
<b>{t('pages.index.swap')}:</b> {SizeFormatter.sizeFormat(status.swap.current)} /{' '}
{SizeFormatter.sizeFormat(status.swap.total)}
</div>
</Col>
<Col span={12} className="text-center">
<Progress
type="dashboard"
status="normal"
strokeColor={status.disk.color}
railColor={railColor}
strokeWidth={strokeWidth}
percent={status.disk.percent}
size={gaugeSize}
/>
<div>
<b>{t('pages.index.storage')}:</b> {SizeFormatter.sizeFormat(status.disk.current)} /{' '}
{SizeFormatter.sizeFormat(status.disk.total)}
</div>
</Col>
</Row>
</Col>
</Row>
</Card>
);
}
+97
View File
@@ -0,0 +1,97 @@
import { useTranslation } from 'react-i18next';
import { Card, Tooltip } from 'antd';
import {
ClockCircleOutlined,
DatabaseOutlined,
EyeInvisibleOutlined,
EyeOutlined,
GlobalOutlined,
} from '@ant-design/icons';
import { SizeFormatter, TimeFormatter } from '@/utils';
import { activateOnKey } from '@/utils/a11y';
import type { Status } from '@/models/status';
interface SystemStripProps {
status: Status;
showIp: boolean;
onToggleIp: () => void;
}
export default function SystemStrip({ status, showIp, onToggleIp }: SystemStripProps) {
const { t } = useTranslation();
return (
<Card hoverable styles={{ body: { padding: 0 } }}>
<div className="ov-strip-grid">
<div className="ov-strip-cell">
<div className="ov-kicker ov-kicker-icon">
<ClockCircleOutlined />
{t('pages.index.uptime')}
</div>
<div className="ov-strip-split">
<div>
<div className="ov-strip-sub">Xray</div>
<div className="ov-strip-value">{TimeFormatter.formatSecond(status.appStats.uptime)}</div>
</div>
<span className="ov-strip-split-sep" />
<div>
<div className="ov-strip-sub">OS</div>
<div className="ov-strip-value">{TimeFormatter.formatSecond(status.uptime)}</div>
</div>
</div>
</div>
<div className="ov-strip-cell">
<div className="ov-kicker ov-kicker-icon">
<DatabaseOutlined />
{t('pages.index.panel')}
</div>
<div className="ov-strip-split">
<div>
<div className="ov-strip-sub">{t('pages.index.memory')}</div>
<div className="ov-strip-value">{SizeFormatter.sizeFormat(status.appStats.mem)}</div>
</div>
<span className="ov-strip-split-sep" />
<div>
<div className="ov-strip-sub">{t('pages.index.threads')}</div>
<div className="ov-strip-value">{status.appStats.threads}</div>
</div>
</div>
</div>
<div className="ov-strip-cell">
<div className="ov-kicker ov-kicker-icon">
<GlobalOutlined />
{t('pages.index.ipAddresses')}
<Tooltip title={t('pages.index.toggleIpVisibility')}>
{showIp ? (
<EyeOutlined
className="ip-toggle-icon"
role="button"
tabIndex={0}
aria-label={t('pages.index.toggleIpVisibility')}
onClick={onToggleIp}
onKeyDown={activateOnKey(onToggleIp)}
/>
) : (
<EyeInvisibleOutlined
className="ip-toggle-icon"
role="button"
tabIndex={0}
aria-label={t('pages.index.toggleIpVisibility')}
onClick={onToggleIp}
onKeyDown={activateOnKey(onToggleIp)}
/>
)}
</Tooltip>
</div>
<div className={`ov-ip${showIp ? '' : ' ip-hidden'}`}>
<div className="ov-mono">{status.publicIP.ipv4}</div>
<div className="ov-mono ov-ip-v6">{status.publicIP.ipv6}</div>
</div>
</div>
</div>
</Card>
);
}
@@ -0,0 +1,97 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Card, theme } from 'antd';
import { ArrowDownOutlined, ArrowUpOutlined } from '@ant-design/icons';
import { SizeFormatter } from '@/utils';
import { Sparkline } from '@/components/viz';
import type { Status } from '@/models/status';
import { mean, peak } from './useOverviewHistory';
interface ThroughputCardProps {
status: Status;
up: number[];
down: number[];
labels: string[];
isMobile: boolean;
}
export default function ThroughputCard({ status, up, down, labels, isMobile }: ThroughputCardProps) {
const { t } = useTranslation();
const { token } = theme.useToken();
const accent = token.colorPrimary;
const downColor = token.colorTextTertiary;
const referenceLines = useMemo(
() => [
{ y: status.netIO.down, color: downColor, dash: '2 4' },
{ y: status.netIO.up, color: accent, dash: '2 4' },
],
[status.netIO.up, status.netIO.down, accent, downColor],
);
return (
<Card hoverable styles={{ body: { padding: 0 } }}>
<div className="ov-wide-head">
<div>
<div className="ov-kicker">{t('pages.index.overallSpeed')}</div>
<div className="ov-sub">
{`${t('pages.index.throughputSub')} · ${t('pages.index.peak')} ${SizeFormatter.speedFormat(peak(down))}`}
</div>
</div>
<div className="ov-wide-legend">
<div className="ov-legend-label">
<ArrowUpOutlined style={{ color: accent }} />
{t('pages.index.upload')}
<span className="ov-legend-num">{SizeFormatter.speedFormat(status.netIO.up)}</span>
</div>
<div className="ov-legend-label">
<ArrowDownOutlined style={{ color: downColor }} />
{t('pages.index.download')}
<span className="ov-legend-num">{SizeFormatter.speedFormat(status.netIO.down)}</span>
</div>
</div>
</div>
<div className="ov-wide-chart">
<Sparkline
data={up}
data2={down}
labels={labels}
height={isMobile ? 140 : 186}
strokeWidth={1.75}
fillOpacity={0.24}
showTooltip
showLegend={false}
valueMax={null}
stroke={accent}
stroke2={downColor}
name1={t('pages.index.upload')}
name2={t('pages.index.download')}
yFormatter={SizeFormatter.speedFormat}
referenceLines={referenceLines}
/>
</div>
<div className="ov-wide-foot">
<div>
<div className="ov-kicker">{t('pages.index.sent')}</div>
<div className="ov-foot-value">{SizeFormatter.sizeFormat(status.netTraffic.sent)}</div>
</div>
<span className="ov-foot-sep" />
<div>
<div className="ov-kicker">{t('pages.index.received')}</div>
<div className="ov-foot-value">{SizeFormatter.sizeFormat(status.netTraffic.recv)}</div>
</div>
<span className="ov-foot-sep" />
<div>
<div className="ov-kicker">{t('pages.index.avgWindow')}</div>
<div className="ov-foot-value">
<span className="ov-foot-part">{`${SizeFormatter.speedFormat(mean(up))}`}</span>{' '}
<span className="ov-foot-part">{`${SizeFormatter.speedFormat(mean(down))}`}</span>
</div>
</div>
</div>
</Card>
);
}
+75
View File
@@ -0,0 +1,75 @@
import { useMemo } from 'react';
import type { ReactNode } from 'react';
import { Card, theme } from 'antd';
import { Sparkline } from '@/components/viz';
import { mean, peak } from './useOverviewHistory';
interface VitalTileProps {
icon: ReactNode;
label: string;
percent: number;
statusColor: string;
detail: string;
footLeft: string;
footRight: string;
data: number[];
isMobile: boolean;
}
export default function VitalTile({
icon,
label,
percent,
statusColor,
detail,
footLeft,
footRight,
data,
isMobile,
}: VitalTileProps) {
const { token } = theme.useToken();
const meanColor = token.colorTextTertiary;
const referenceLines = useMemo(
() => (data.length > 1 ? [{ y: mean(data), dash: '3 4', color: meanColor }] : []),
[data, meanColor],
);
return (
<Card hoverable className="ov-tile" styles={{ body: { padding: 0 } }}>
<div className="ov-tile-head">
<span className="ov-tile-icon">{icon}</span>
<span className="ov-kicker">{label}</span>
</div>
<div className="ov-tile-value">
<span className="ov-tile-number">{percent.toFixed(1)}</span>
<span className="ov-tile-unit">%</span>
</div>
<div className="ov-tile-detail">{detail}</div>
<div className="ov-tile-foot">
<span>{footLeft}</span>
<span>{footRight}</span>
</div>
<div className="ov-tile-chart">
<Sparkline
data={data}
height={isMobile ? 48 : 62}
strokeWidth={1.5}
fillOpacity={0.3}
showGrid={false}
showMarker={false}
valueMax={peak(data) > 0 ? null : 100}
stroke={statusColor}
referenceLines={referenceLines}
yFormatter={(v) => `${v.toFixed(0)}%`}
name1={label}
/>
</div>
</Card>
);
}
@@ -1,14 +0,0 @@
.xray-status-card .action {
cursor: pointer;
justify-content: center;
}
.error-line {
display: block;
max-width: 400px;
white-space: pre-wrap;
}
.cursor-pointer {
cursor: pointer;
}
-123
View File
@@ -1,123 +0,0 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Badge, Card, Col, Popover, Row, Space, Tag } from 'antd';
import {
BarsOutlined,
PoweroffOutlined,
ReloadOutlined,
ToolOutlined,
} from '@ant-design/icons';
import type { Status } from '@/models/status';
import { activateOnKey } from '@/utils/a11y';
import './XrayStatusCard.css';
interface XrayStatusCardProps {
status: Status;
isMobile: boolean;
accessLogEnable: boolean;
onStopXray: () => void;
onRestartXray: () => void;
onOpenLogs: () => void;
onOpenXrayLogs: () => void;
onOpenVersionSwitch: () => void;
}
const XRAY_STATE_KEYS: Record<string, string> = {
running: 'pages.index.xrayStatusRunning',
stop: 'pages.index.xrayStatusStop',
error: 'pages.index.xrayStatusError',
};
export default function XrayStatusCard({
status,
isMobile,
accessLogEnable,
onStopXray,
onRestartXray,
onOpenLogs,
onOpenXrayLogs,
onOpenVersionSwitch,
}: XrayStatusCardProps) {
const { t } = useTranslation();
const stateText = t(XRAY_STATE_KEYS[status.xray.state] ?? 'pages.index.xrayStatusUnknown');
const title = (
<Space>
<span>{t('pages.index.xrayStatus')}</span>
{isMobile && status.xray.version && status.xray.version !== 'Unknown' && (
<Tag color="green">v{status.xray.version}</Tag>
)}
</Space>
);
const errorLines = useMemo(
() => (status.xray.errorMsg || '').split('\n'),
[status.xray.errorMsg],
);
const extra =
status.xray.state !== 'error' ? (
<Badge status="processing" text={stateText} color={status.xray.color} />
) : (
<Popover
title={
<Row align="middle" justify="space-between">
<Col>
<span>{t('pages.index.xrayStatusError')}</span>
</Col>
<Col>
<BarsOutlined className="cursor-pointer" role="button" tabIndex={0} aria-label={t('pages.index.logs')} onClick={onOpenLogs} onKeyDown={activateOnKey(onOpenLogs)} />
</Col>
</Row>
}
content={
<>
{errorLines.map((line, i) => (
<span key={i} className="error-line">
{line}
</span>
))}
</>
}
>
<Badge status="processing" text={stateText} color={status.xray.color} />
</Popover>
);
const actions = [
// the xray log viewer reads the access log file, so the button only makes
// sense when one is configured (unlike IP limit, which no longer needs it)
...(accessLogEnable
? [
<Space className="action" key="xraylogs" role="button" tabIndex={0} aria-label={t('pages.index.accessLogs')} onClick={onOpenXrayLogs} onKeyDown={activateOnKey(onOpenXrayLogs)}>
<BarsOutlined />
{!isMobile && <span>{t('pages.index.accessLogs')}</span>}
</Space>,
]
: []),
<Space className="action" key="stop" role="button" tabIndex={0} aria-label={t('pages.index.stopXray')} onClick={onStopXray} onKeyDown={activateOnKey(onStopXray)}>
<PoweroffOutlined />
{!isMobile && <span>{t('pages.index.stopXray')}</span>}
</Space>,
<Space className="action" key="restart" role="button" tabIndex={0} aria-label={t('pages.index.restartXray')} onClick={onRestartXray} onKeyDown={activateOnKey(onRestartXray)}>
<ReloadOutlined />
{!isMobile && <span>{t('pages.index.restartXray')}</span>}
</Space>,
<Space className="action" key="switch" role="button" tabIndex={0} aria-label={t('pages.index.xraySwitch')} onClick={onOpenVersionSwitch} onKeyDown={activateOnKey(onOpenVersionSwitch)}>
<ToolOutlined />
{!isMobile && (
<span>
{status.xray.version && status.xray.version !== 'Unknown'
? `v${status.xray.version}`
: t('pages.index.xraySwitch')}
</span>
)}
</Space>,
];
return (
<Card hoverable title={title} extra={extra} actions={actions} className="xray-status-card" />
);
}
@@ -0,0 +1,135 @@
import { useEffect, useMemo, useState } from 'react';
import { HttpUtil, TimeFormatter } from '@/utils';
import type { Status } from '@/models/status';
const OVERVIEW_WINDOW = 72;
const SEED_BUCKET_SECONDS = 2;
const SERIES_KEYS = ['cpu', 'mem', 'swap', 'diskUsage', 'netUp', 'netDown', 'tcpCount', 'udpCount'] as const;
export type OverviewSeriesKey = (typeof SERIES_KEYS)[number];
export interface OverviewHistory {
series: Record<OverviewSeriesKey, number[]>;
labels: string[];
}
interface HistoryPoint {
t: number;
v: number;
}
interface HistoryWindow {
series: Record<OverviewSeriesKey, number[]>;
times: number[];
}
function emptySeries(): Record<OverviewSeriesKey, number[]> {
return Object.fromEntries(SERIES_KEYS.map((key) => [key, [] as number[]])) as Record<OverviewSeriesKey, number[]>;
}
function emptyWindow(): HistoryWindow {
return { series: emptySeries(), times: [] };
}
function sampleOf(status: Status): Record<OverviewSeriesKey, number> {
return {
cpu: status.cpu.percent,
mem: status.mem.percent,
swap: status.swap.percent,
diskUsage: status.disk.percent,
netUp: status.netIO.up,
netDown: status.netIO.down,
tcpCount: status.tcpCount,
udpCount: status.udpCount,
};
}
function tailWindow<T>(values: T[]): T[] {
return values.slice(-OVERVIEW_WINDOW);
}
export function mean(values: number[]): number {
if (values.length === 0) return 0;
let total = 0;
for (const v of values) total += v;
return total / values.length;
}
export function peak(values: number[]): number {
let max = 0;
for (const v of values) if (v > max) max = v;
return max;
}
/* the seed bucket must be in the backend's allowedHistoryBuckets whitelist;
2s is the smallest and matches the status poll cadence */
export function useOverviewHistory(status: Status, hasData: boolean): OverviewHistory {
const [trend, setTrend] = useState<HistoryWindow>(emptyWindow);
useEffect(() => {
let cancelled = false;
const seed = async () => {
const responses = new Map<OverviewSeriesKey, HistoryPoint[]>();
await Promise.all(
SERIES_KEYS.map(async (key) => {
const msg = await HttpUtil.get<HistoryPoint[]>(
`/panel/api/server/history/${key}/${SEED_BUCKET_SECONDS}`,
undefined,
{ silent: true },
);
if (msg?.success && Array.isArray(msg.obj)) responses.set(key, msg.obj);
}),
);
if (cancelled || responses.size === 0) return;
let axis: HistoryPoint[] = [];
for (const points of responses.values()) {
if (points.length > axis.length) axis = points;
}
axis = tailWindow(axis);
if (axis.length === 0) return;
const seedTimes = axis.map((p) => Number(p.t) || 0);
const seedSeries = emptySeries();
for (const key of SERIES_KEYS) {
const byTs = new Map<number, number>();
for (const p of responses.get(key) ?? []) byTs.set(Number(p.t) || 0, Number(p.v) || 0);
seedSeries[key] = seedTimes.map((ts) => byTs.get(ts) ?? 0);
}
setTrend((prev) => {
const merged = emptyWindow();
merged.times = tailWindow(seedTimes.concat(prev.times));
for (const key of SERIES_KEYS) {
merged.series[key] = tailWindow(seedSeries[key].concat(prev.series[key]));
}
return merged;
});
};
seed().catch(() => undefined);
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
if (!hasData) return;
setTrend((prev) => {
const point = sampleOf(status);
const next = emptyWindow();
next.times = tailWindow(prev.times.concat(Math.floor(Date.now() / 1000)));
for (const key of SERIES_KEYS) {
next.series[key] = tailWindow(prev.series[key].concat(point[key]));
}
return next;
});
}, [status, hasData]);
const labels = useMemo(() => trend.times.map(TimeFormatter.formatClock), [trend.times]);
return useMemo(() => ({ series: trend.series, labels }), [trend.series, labels]);
}