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,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>
);
}