mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-10 13:21:00 +00:00
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:
@@ -48,6 +48,7 @@ interface SparklineProps {
|
||||
yTickStep?: number;
|
||||
tickCountX?: number;
|
||||
showTooltip?: boolean;
|
||||
showLegend?: boolean;
|
||||
valueMin?: number;
|
||||
valueMax?: number | null;
|
||||
yFormatter?: (v: number) => string;
|
||||
@@ -80,13 +81,23 @@ interface SparklineView {
|
||||
extremaPoints: ExtremaResult | null;
|
||||
}
|
||||
|
||||
function hexToRgba(hex: string, alpha: number): string {
|
||||
let h = hex.trim();
|
||||
function hexToRgba(color: string, alpha: number): string {
|
||||
const trimmed = color.trim();
|
||||
const fn = trimmed.match(/^rgba?\(([^)]+)\)$/i);
|
||||
if (fn) {
|
||||
const parts = fn[1].split(/[,/]\s*|\s+/).filter(Boolean).map(Number);
|
||||
if (parts.length >= 3 && parts.slice(0, 3).every((n) => Number.isFinite(n))) {
|
||||
const baseAlpha = parts.length > 3 && Number.isFinite(parts[3]) ? parts[3] : 1;
|
||||
return `rgba(${parts[0]}, ${parts[1]}, ${parts[2]}, ${baseAlpha * alpha})`;
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
let h = trimmed;
|
||||
if (h.startsWith('#')) h = h.slice(1);
|
||||
if (h.length === 3) h = h.split('').map((c) => c + c).join('');
|
||||
if (h.length !== 6) return hex;
|
||||
if (h.length !== 6) return trimmed;
|
||||
const int = Number.parseInt(h, 16);
|
||||
if (Number.isNaN(int)) return hex;
|
||||
if (Number.isNaN(int)) return trimmed;
|
||||
const r = (int >> 16) & 255;
|
||||
const g = (int >> 8) & 255;
|
||||
const b = int & 255;
|
||||
@@ -129,6 +140,7 @@ export default function Sparkline(props: SparklineProps) {
|
||||
yTickStep = 25,
|
||||
tickCountX = 4,
|
||||
showTooltip = false,
|
||||
showLegend = true,
|
||||
valueMin = 0,
|
||||
valueMax = 100,
|
||||
yFormatter = (v: number) => `${Math.round(v)}%`,
|
||||
@@ -542,10 +554,6 @@ export default function Sparkline(props: SparklineProps) {
|
||||
);
|
||||
}, [points, hasSeries2, hasSeries3, valueMin, valueMax]);
|
||||
|
||||
useEffect(() => {
|
||||
plotRef.current?.redraw(false);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const redraw = () => plotRef.current?.redraw(false);
|
||||
const moBody = new MutationObserver(redraw);
|
||||
@@ -570,7 +578,7 @@ export default function Sparkline(props: SparklineProps) {
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{legendItems.length > 0 && (
|
||||
{showLegend && legendItems.length > 0 && (
|
||||
<div className="sparkline-legend" aria-hidden="true">
|
||||
{legendItems.map((s) => (
|
||||
<span key={s.name} className="extrema-item" style={{ color: s.color }}>● {s.name}</span>
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
.ant-sidebar {
|
||||
flex: 0 0 var(--sider-rail, 72px);
|
||||
width: var(--sider-rail, 72px);
|
||||
position: relative;
|
||||
z-index: 210;
|
||||
}
|
||||
|
||||
.ant-sidebar > .ant-layout-sider {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
@@ -5,6 +12,16 @@
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.ant-sidebar > .ant-layout-sider:not(.ant-layout-sider-collapsed) {
|
||||
box-shadow: 0 0 32px rgba(0, 0, 0, 0.22);
|
||||
}
|
||||
|
||||
.sider-nav .ant-menu-item .anticon,
|
||||
.sider-nav .ant-menu-submenu-title .anticon,
|
||||
.sider-utility .ant-menu-item .anticon {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.sider-brand,
|
||||
.drawer-brand {
|
||||
font-weight: 600;
|
||||
@@ -18,16 +35,12 @@
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 14px 16px 14px 24px;
|
||||
height: 58px;
|
||||
padding: 0 16px 0 24px;
|
||||
border-bottom: 1px solid var(--ant-color-border-secondary);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.sider-brand-collapsed {
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
padding: 14px 4px;
|
||||
letter-spacing: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.brand-block {
|
||||
@@ -37,10 +50,6 @@
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.sider-brand-collapsed .brand-block {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.brand-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -246,11 +255,6 @@
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.sider-version.is-collapsed {
|
||||
justify-content: center;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.drawer-footer {
|
||||
flex: 0 0 auto;
|
||||
padding: 8px 8px 12px;
|
||||
@@ -261,8 +265,7 @@
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.ant-sidebar > .ant-layout-sider .ant-layout-sider-children,
|
||||
.ant-sidebar > .ant-layout-sider .ant-layout-sider-trigger {
|
||||
.ant-sidebar > .ant-layout-sider .ant-layout-sider-children {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -272,6 +275,11 @@
|
||||
min-width: 0 !important;
|
||||
width: 0 !important;
|
||||
}
|
||||
|
||||
.ant-sidebar {
|
||||
flex: 0 0 0;
|
||||
width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
body.dark .ant-drawer-content,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import type { ComponentType } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { ComponentType, CSSProperties } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Drawer, Layout, Menu } from 'antd';
|
||||
@@ -39,11 +39,14 @@ import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme';
|
||||
import { useAllSettings } from '@/api/queries/useAllSettings';
|
||||
import './AppSidebar.css';
|
||||
|
||||
const SIDEBAR_COLLAPSED_KEY = 'isSidebarCollapsed';
|
||||
const DONATE_URL = 'https://donate.sanaei.dev/';
|
||||
const DOCS_URL = 'https://docs.sanaei.dev/';
|
||||
const REPO_URL = 'https://github.com/MHSanaei/3x-ui';
|
||||
const LOGOUT_KEY = '__logout__';
|
||||
const RAIL_WIDTH = 72;
|
||||
const railStyle = { '--sider-rail': `${RAIL_WIDTH}px` } as CSSProperties;
|
||||
|
||||
let hoveredAcrossRemounts = false;
|
||||
|
||||
type IconName = 'dashboard' | 'inbound' | 'team' | 'groups' | 'setting' | 'tool' | 'cluster' | 'hosts' | 'logout' | 'apidocs' | 'outbound' | 'routing';
|
||||
|
||||
@@ -62,14 +65,6 @@ const iconByName: Record<IconName, ComponentType> = {
|
||||
routing: SwapOutlined,
|
||||
};
|
||||
|
||||
function readCollapsed(): boolean {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(SIDEBAR_COLLAPSED_KEY) || 'false');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function DonateButton({ ariaLabel }: { ariaLabel: string }) {
|
||||
return (
|
||||
<a
|
||||
@@ -108,7 +103,7 @@ function VersionBadge({ version, collapsed }: { version: string; collapsed?: boo
|
||||
href={REPO_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`sider-version${collapsed ? ' is-collapsed' : ''}`}
|
||||
className="sider-version"
|
||||
aria-label={`GitHub ${label}`}
|
||||
title={label}
|
||||
>
|
||||
@@ -148,8 +143,23 @@ export default function AppSidebar() {
|
||||
const { allSetting } = useAllSettings();
|
||||
const showSubFormats = !!(allSetting.subJsonEnable || allSetting.subClashEnable);
|
||||
|
||||
const [collapsed, setCollapsed] = useState<boolean>(() => readCollapsed());
|
||||
const [hovered, setHovered] = useState(() => hoveredAcrossRemounts);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const railCollapsed = !hovered;
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const updateHovered = useCallback((value: boolean) => {
|
||||
hoveredAcrossRemounts = value;
|
||||
setHovered(value);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => {
|
||||
const el = rootRef.current;
|
||||
if (el) updateHovered(el.matches(':hover'));
|
||||
}, 150);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [updateHovered]);
|
||||
|
||||
const currentTheme: 'light' | 'dark' = isDark ? 'dark' : 'light';
|
||||
const panelVersion = window.X_UI_CUR_VER || '';
|
||||
@@ -218,7 +228,7 @@ export default function AppSidebar() {
|
||||
if (tab.key === '/xray') {
|
||||
return { key: tab.key, icon: <Icon />, label: tab.title, children: xrayChildren };
|
||||
}
|
||||
return { key: tab.key, icon: <Icon />, label: tab.title };
|
||||
return { key: tab.key, icon: <Icon />, label: tab.title, title: '' };
|
||||
}),
|
||||
[settingsChildren, xrayChildren]);
|
||||
|
||||
@@ -235,13 +245,6 @@ export default function AppSidebar() {
|
||||
openLink(String(key));
|
||||
}, [openLink]);
|
||||
|
||||
const onSiderCollapse = useCallback((isCollapsed: boolean, type: 'clickTrigger' | 'responsive') => {
|
||||
if (type === 'clickTrigger') {
|
||||
localStorage.setItem(SIDEBAR_COLLAPSED_KEY, String(isCollapsed));
|
||||
setCollapsed(isCollapsed);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const cycleTheme = useCallback((id: string) => {
|
||||
pauseAnimationsUntilLeave(id);
|
||||
if (!isDark) {
|
||||
@@ -256,20 +259,24 @@ export default function AppSidebar() {
|
||||
}, [isDark, isUltra, toggleTheme, toggleUltra]);
|
||||
|
||||
return (
|
||||
<div className="ant-sidebar">
|
||||
<div
|
||||
ref={rootRef}
|
||||
className="ant-sidebar"
|
||||
style={railStyle}
|
||||
onMouseEnter={() => updateHovered(true)}
|
||||
onMouseLeave={() => updateHovered(false)}
|
||||
>
|
||||
<Layout.Sider
|
||||
theme={currentTheme}
|
||||
width={220}
|
||||
collapsible
|
||||
collapsed={collapsed}
|
||||
breakpoint="md"
|
||||
onCollapse={onSiderCollapse}
|
||||
collapsedWidth={RAIL_WIDTH}
|
||||
collapsed={railCollapsed}
|
||||
>
|
||||
<div className={`sider-brand${collapsed ? ' sider-brand-collapsed' : ''}`}>
|
||||
<div className="sider-brand">
|
||||
<div className="brand-block">
|
||||
<span className="brand-text">{collapsed ? '3X' : '3X-UI'}</span>
|
||||
<span className="brand-text">{railCollapsed ? '3X' : '3X-UI'}</span>
|
||||
</div>
|
||||
{!collapsed && (
|
||||
{!railCollapsed && (
|
||||
<div className="brand-actions">
|
||||
<DocsButton ariaLabel={t('menu.docs') || 'Documentation'} />
|
||||
<DonateButton ariaLabel={t('menu.donate') || 'Donate'} />
|
||||
@@ -287,7 +294,7 @@ export default function AppSidebar() {
|
||||
theme={currentTheme}
|
||||
mode="inline"
|
||||
selectedKeys={[selectedKey]}
|
||||
openKeys={collapsed ? undefined : openKeys}
|
||||
openKeys={railCollapsed ? undefined : openKeys}
|
||||
onOpenChange={(keys) => setOpenKeys(keys as string[])}
|
||||
className="sider-nav"
|
||||
items={toMenuItems(navItems)}
|
||||
@@ -302,7 +309,7 @@ export default function AppSidebar() {
|
||||
onClick={onMenuClick}
|
||||
/>
|
||||
<div className="sider-footer">
|
||||
<VersionBadge version={panelVersion} collapsed={collapsed} />
|
||||
<VersionBadge version={panelVersion} collapsed={railCollapsed} />
|
||||
</div>
|
||||
</Layout.Sider>
|
||||
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { NumberFormatter } from '@/utils';
|
||||
|
||||
export const USAGE_WARN_PERCENT = 80;
|
||||
export const USAGE_CRIT_PERCENT = 90;
|
||||
export const USAGE_WARN_COLOR = '#faad14';
|
||||
export const USAGE_CRIT_COLOR = '#ff4d4f';
|
||||
|
||||
export class CurTotal {
|
||||
current: number;
|
||||
total: number;
|
||||
@@ -16,9 +21,9 @@ export class CurTotal {
|
||||
|
||||
get color(): string {
|
||||
const p = this.percent;
|
||||
if (p < 80) return '#1677ff';
|
||||
if (p < 90) return '#faad14';
|
||||
return '#ff4d4f';
|
||||
if (p < USAGE_WARN_PERCENT) return '#1677ff';
|
||||
if (p < USAGE_CRIT_PERCENT) return USAGE_WARN_COLOR;
|
||||
return USAGE_CRIT_COLOR;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -685,6 +685,14 @@ export class CPUFormatter {
|
||||
}
|
||||
|
||||
export class TimeFormatter {
|
||||
static formatClock(unixSec: number): string {
|
||||
const d = new Date(unixSec * 1000);
|
||||
const hh = String(d.getHours()).padStart(2, '0');
|
||||
const mm = String(d.getMinutes()).padStart(2, '0');
|
||||
const ss = String(d.getSeconds()).padStart(2, '0');
|
||||
return `${hh}:${mm}:${ss}`;
|
||||
}
|
||||
|
||||
static formatSecond(second: number): string {
|
||||
if (second < 60) return second.toFixed(0) + 's';
|
||||
if (second < 3600) return (second / 60).toFixed(0) + 'm';
|
||||
|
||||
Reference in New Issue
Block a user