Files
3x-ui-agent/frontend/src/layouts/AppSidebar.tsx
T
Rogue 29de9761c8
CI / go-test (push) Waiting to run
CI / postgres-durable-first (push) Waiting to run
CI / codegen (push) Waiting to run
CI / govulncheck (push) Waiting to run
CI / race (push) Waiting to run
CI / fuzz-smoke (push) Waiting to run
CI / golangci (push) Waiting to run
CI / frontend (push) Waiting to run
Docs CI / build (push) Waiting to run
Docs Deploy (GitHub Pages) / build (push) Waiting to run
Docs Deploy (GitHub Pages) / deploy (push) Blocked by required conditions
Release 3X-UI / build (386) (push) Waiting to run
Release 3X-UI / build (amd64) (push) Waiting to run
Release 3X-UI / build (arm64) (push) Waiting to run
Release 3X-UI / build (armv5) (push) Waiting to run
Release 3X-UI / build (armv6) (push) Waiting to run
Release 3X-UI / build (armv7) (push) Waiting to run
Release 3X-UI / build (s390x) (push) Waiting to run
Release 3X-UI / Build for Windows (amd64) (push) Waiting to run
Release 3X-UI / Publish rolling dev release (push) Blocked by required conditions
CodeQL Advanced / Analyze (go) (push) Waiting to run
CodeQL Advanced / Analyze (actions) (push) Waiting to run
CodeQL Advanced / Analyze (javascript-typescript) (push) Waiting to run
V2.0.1: listen override fix, sing-box core fetch, probe settings page + fast report, spx removal, core mem env
- 0: sing-box gen overrides fragment listen/port from the inbound row
  (empty listen -> "::"), Update/Add recompute the stored fragment;
  non-loopback E2E against the real core binary.
- 1: agent fetches the sing-box core from the Gitea release when
  missing, self-checks it, and only then latches the switch.
- 2: node list shows the sing-box core version (heartbeat field,
  persisted on the node row), xray version as fallback.
- 3: public-page fallback returns 404 to anonymous visitors when the
  page is disabled.
- 4: settings restart hint bar removed, dead i18n keys cleaned.
- 5: two PUT calls missing JSON headers fixed (audit: only those two),
  batch probe-task creation endpoint + UI.
- 6: cf-probe retirement card and coverage pipeline removed.
- 7: generated vless links no longer carry spx (panel export + sub);
  parser still accepts spx; golden fixtures updated.
- probe batch: dedicated probe settings tab (public-page settings,
  tasks, per-node meta), retention_days editable, per-node traffic
  calibration + stat-interface pinning, runtime metrics split into a
  lightweight fast report (default 3s, 1-60s adjustable).
- 8: sing-box core processes start with GOMEMLIMIT=32MiB, GOGC=50.
- 9: dead /getTwoFactorEnable docs entries removed (frontend call and
  contract entry already gone at base; verified by grep).
2026-10-23 07:53:11 +00:00

466 lines
13 KiB
TypeScript

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';
import type { MenuProps } from 'antd';
import {
ApiOutlined,
CloseOutlined,
CloudServerOutlined,
ClusterOutlined,
DashboardOutlined,
ExportOutlined,
GithubOutlined,
GlobalOutlined,
HeartOutlined,
ImportOutlined,
LogoutOutlined,
MenuOutlined,
MessageOutlined,
MoonFilled,
MoonOutlined,
PushpinFilled,
PushpinOutlined,
RadarChartOutlined,
ReadOutlined,
SafetyOutlined,
SettingOutlined,
SunOutlined,
SwapOutlined,
TagsOutlined,
TeamOutlined,
ToolOutlined,
} from '@ant-design/icons';
import { HttpUtil } from '@/utils';
import { formatPanelVersion } from '@/lib/panel-version';
import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme';
import { useAllSettings } from '@/api/queries/useAllSettings';
import './AppSidebar.css';
const DONATE_URL = 'https://donate.sanaei.dev/';
const DOCS_URL = 'https://docs.sanaei.dev/';
const REPO_URL = 'https://git.881218.xyz/rogue/3x-ui-agent';
const LOGOUT_KEY = '__logout__';
const RAIL_WIDTH = 72;
const SIDER_WIDTH = 220;
const SIDEBAR_PINNED_KEY = 'sidebar-pinned';
let hoveredAcrossRemounts = false;
type IconName =
| 'dashboard'
| 'inbound'
| 'team'
| 'groups'
| 'setting'
| 'tool'
| 'cluster'
| 'hosts'
| 'logout'
| 'apidocs'
| 'outbound'
| 'routing';
const iconByName: Record<IconName, ComponentType> = {
dashboard: DashboardOutlined,
inbound: ImportOutlined,
team: TeamOutlined,
groups: TagsOutlined,
setting: SettingOutlined,
tool: ToolOutlined,
cluster: ClusterOutlined,
hosts: GlobalOutlined,
logout: LogoutOutlined,
apidocs: ApiOutlined,
outbound: ExportOutlined,
routing: SwapOutlined,
};
function DonateButton({ ariaLabel }: { ariaLabel: string }) {
return (
<a
href={DONATE_URL}
target="_blank"
rel="noopener noreferrer"
className="sidebar-donate"
aria-label={ariaLabel}
title={ariaLabel}
>
<HeartOutlined />
</a>
);
}
function DocsButton({ ariaLabel }: { ariaLabel: string }) {
return (
<a
href={DOCS_URL}
target="_blank"
rel="noopener noreferrer"
className="sidebar-docs"
aria-label={ariaLabel}
title={ariaLabel}
>
<ReadOutlined />
</a>
);
}
function VersionBadge({ version, collapsed }: { version: string; collapsed?: boolean }) {
if (!version) return null;
const label = formatPanelVersion(version);
return (
<a
href={REPO_URL}
target="_blank"
rel="noopener noreferrer"
className="sider-version"
aria-label={`GitHub ${label}`}
title={label}
>
<GithubOutlined />
{!collapsed && <span className="sider-version-text">{label}</span>}
</a>
);
}
function ThemeCycleButton({
id,
isDark,
isUltra,
onCycle,
ariaLabel,
}: {
id: string;
isDark: boolean;
isUltra: boolean;
onCycle: () => void;
ariaLabel: string;
}) {
const icon = !isDark ? <SunOutlined /> : !isUltra ? <MoonOutlined /> : <MoonFilled />;
return (
<button
id={id}
type="button"
className="sidebar-theme-cycle"
aria-label={ariaLabel}
title={ariaLabel}
onClick={onCycle}
>
{icon}
</button>
);
}
function readSidebarPinned() {
try {
return localStorage.getItem(SIDEBAR_PINNED_KEY) === 'true';
} catch {
return false;
}
}
function saveSidebarPinned(pinned: boolean) {
try {
localStorage.setItem(SIDEBAR_PINNED_KEY, String(pinned));
} catch {}
}
export default function AppSidebar() {
const { t } = useTranslation();
const { isDark, isUltra, toggleTheme, toggleUltra } = useTheme();
const navigate = useNavigate();
const { pathname, hash } = useLocation();
useAllSettings();
const [hovered, setHovered] = useState(() => hoveredAcrossRemounts);
const [pinned, setPinned] = useState(readSidebarPinned);
const [drawerOpen, setDrawerOpen] = useState(false);
const railCollapsed = !hovered && !pinned;
const railStyle = useMemo(
() => ({ '--sider-rail': `${pinned ? SIDER_WIDTH : RAIL_WIDTH}px` }) as CSSProperties,
[pinned],
);
const rootRef = useRef<HTMLDivElement>(null);
const updateHovered = useCallback((value: boolean) => {
hoveredAcrossRemounts = value;
setHovered(value);
}, []);
const togglePinned = useCallback(() => {
const next = !pinned;
saveSidebarPinned(next);
setPinned(next);
}, [pinned]);
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 || '';
const tabs = useMemo<{ key: string; icon: IconName; title: string }[]>(
() => [
{ key: '/', icon: 'dashboard', title: t('menu.dashboard') },
{ key: '/inbounds', icon: 'inbound', title: t('menu.inbounds') },
{ key: '/clients', icon: 'team', title: t('menu.clients') },
{ key: '/nodes', icon: 'cluster', title: t('menu.nodes') },
{ key: '/hosts', icon: 'hosts', title: t('menu.hosts') },
{ key: '/settings', icon: 'setting', title: t('menu.settings') },
{ key: LOGOUT_KEY, icon: 'logout', title: t('logout') },
],
[t],
);
const navItems = useMemo(() => tabs.filter((tab) => tab.icon !== 'logout'), [tabs]);
const utilItems = useMemo(() => tabs.filter((tab) => tab.icon === 'logout'), [tabs]);
const settingsChildren = useMemo<NonNullable<MenuProps['items']>>(() => {
const children: NonNullable<MenuProps['items']> = [
{
key: '/settings#general',
icon: <SettingOutlined />,
label: t('pages.settings.panelSettings'),
},
{
key: '/settings#security',
icon: <SafetyOutlined />,
label: t('pages.settings.securitySettings'),
},
{
key: '/settings#telegram',
icon: <MessageOutlined />,
label: t('pages.settings.TGBotSettings'),
},
{
key: '/settings#subscription',
icon: <CloudServerOutlined />,
label: t('pages.settings.subSettings'),
},
{
key: '/settings#public-page',
icon: <GlobalOutlined />,
label: t('pages.settings.probeThemes.title'),
},
{
key: '/settings#probe',
icon: <RadarChartOutlined />,
label: t('pages.settings.probe.title'),
},
];
return children;
}, [t]);
const settingsActive = pathname === '/settings';
const selectedKey = settingsActive
? `/settings${hash || '#general'}`
: pathname === ''
? '/'
: pathname;
const openSubmenu = settingsActive ? '/settings' : null;
const [openKeys, setOpenKeys] = useState<string[]>(() => (openSubmenu ? [openSubmenu] : []));
if (openSubmenu && !openKeys.includes(openSubmenu)) {
setOpenKeys([...openKeys, openSubmenu]);
}
const toMenuItems = useCallback(
(items: typeof tabs): MenuProps['items'] =>
items.map((tab) => {
const Icon = iconByName[tab.icon];
if (tab.key === '/settings') {
return { key: tab.key, icon: <Icon />, label: tab.title, children: settingsChildren };
}
return { key: tab.key, icon: <Icon />, label: tab.title, title: '' };
}),
[settingsChildren],
);
const openLink = useCallback(
async (key: string) => {
if (key === LOGOUT_KEY) {
await HttpUtil.post('/logout');
window.location.href = window.X_UI_BASE_PATH || '/';
return;
}
navigate(key);
},
[navigate],
);
const onMenuClick = useCallback<NonNullable<MenuProps['onClick']>>(
({ key }) => {
openLink(String(key));
},
[openLink],
);
const cycleTheme = useCallback(
(id: string) => {
pauseAnimationsUntilLeave(id);
if (!isDark) {
toggleTheme();
if (isUltra) toggleUltra();
} else if (!isUltra) {
toggleUltra();
} else {
toggleUltra();
toggleTheme();
}
},
[isDark, isUltra, toggleTheme, toggleUltra],
);
return (
<div
ref={rootRef}
className={`ant-sidebar${pinned ? ' sidebar-pinned' : ''}`}
style={railStyle}
onMouseEnter={() => updateHovered(true)}
onMouseLeave={() => updateHovered(false)}
>
<Layout.Sider
theme={currentTheme}
width={SIDER_WIDTH}
collapsedWidth={RAIL_WIDTH}
collapsed={railCollapsed}
>
<div className="sider-brand">
<div className="brand-block">
<span className="brand-text">{railCollapsed ? 'L' : 'L-UI'}</span>
</div>
{!railCollapsed && (
<div className="brand-actions">
<button
type="button"
className="sidebar-pin"
aria-label={t('menu.pinSidebar')}
aria-pressed={pinned}
title={t(pinned ? 'menu.unpinSidebar' : 'menu.pinSidebar')}
onClick={togglePinned}
>
{pinned ? <PushpinFilled /> : <PushpinOutlined />}
</button>
<DocsButton ariaLabel={t('menu.docs') || 'Documentation'} />
<DonateButton ariaLabel={t('menu.donate') || 'Donate'} />
<ThemeCycleButton
id="theme-cycle"
isDark={isDark}
isUltra={isUltra}
onCycle={() => cycleTheme('theme-cycle')}
ariaLabel={t('menu.theme')}
/>
</div>
)}
</div>
<Menu
theme={currentTheme}
mode="inline"
selectedKeys={[selectedKey]}
openKeys={railCollapsed ? undefined : openKeys}
onOpenChange={(keys) => setOpenKeys(keys as string[])}
className="sider-nav"
items={toMenuItems(navItems)}
onClick={onMenuClick}
/>
<Menu
theme={currentTheme}
mode="inline"
selectedKeys={[selectedKey]}
className="sider-utility"
items={toMenuItems(utilItems)}
onClick={onMenuClick}
/>
<div className="sider-footer">
<VersionBadge version={panelVersion} collapsed={railCollapsed} />
</div>
</Layout.Sider>
<Drawer
placement="left"
closable={false}
open={drawerOpen}
rootClassName={currentTheme}
size="min(82vw, 320px)"
styles={{
wrapper: { padding: 0 },
body: { padding: 0, display: 'flex', flexDirection: 'column', height: '100%' },
header: { display: 'none' },
}}
onClose={() => setDrawerOpen(false)}
>
<div className="drawer-header">
<div className="brand-block">
<span className="drawer-brand">L-UI</span>
</div>
<div className="drawer-header-actions">
<DocsButton ariaLabel={t('menu.docs') || 'Documentation'} />
<DonateButton ariaLabel={t('menu.donate') || 'Donate'} />
<ThemeCycleButton
id="theme-cycle-drawer"
isDark={isDark}
isUltra={isUltra}
onCycle={() => cycleTheme('theme-cycle-drawer')}
ariaLabel={t('menu.theme')}
/>
<button
className="drawer-close"
type="button"
aria-label={t('close')}
onClick={() => setDrawerOpen(false)}
>
<CloseOutlined />
</button>
</div>
</div>
<Menu
theme={currentTheme}
mode="inline"
selectedKeys={[selectedKey]}
openKeys={openKeys}
onOpenChange={(keys) => setOpenKeys(keys as string[])}
className="drawer-menu drawer-nav"
items={toMenuItems(navItems)}
onClick={(info) => {
onMenuClick(info);
setDrawerOpen(false);
}}
/>
<Menu
theme={currentTheme}
mode="inline"
selectedKeys={[selectedKey]}
className="drawer-menu drawer-utility"
items={toMenuItems(utilItems)}
onClick={(info) => {
onMenuClick(info);
setDrawerOpen(false);
}}
/>
<div className="drawer-footer">
<VersionBadge version={panelVersion} />
</div>
</Drawer>
{!drawerOpen && (
<button
className="drawer-handle"
type="button"
aria-label={t('menu.openMenu')}
onClick={() => setDrawerOpen(true)}
>
<MenuOutlined />
</button>
)}
</div>
);
}