refactor(frontend): read the panel's navigation from one route manifest (#6922)

The router, the sidebar, the page titles and the command palette each kept
their own list of the panel's pages, so a page added to one could go
missing from another without any test noticing. They all read
src/routes/panelRoutes.tsx now, and a contract test holds the manifest to
the router, the translation bundle and the palette keywords.

The unused useZodForm helper goes, and CONTRIBUTING, frontend/CLAUDE.md and
the frontend README describe the useForm + rhfZodValidate pattern the
panel's forms actually use.
This commit is contained in:
25f2002654
2026-10-25 00:18:31 +05:30
committed by GitHub
parent 746bdbe9f4
commit 14c9bdff89
14 changed files with 242 additions and 254 deletions
+1 -1
View File
@@ -186,7 +186,7 @@ Only a genuinely **standalone bundle** (like `login` or `subpage`, reachable wit
- **Function components + hooks** everywhere. No class components.
- **Comments in committed Go/TS/TSX: 2 lines MAX per comment block**, spent on the *why* a name cannot hold — an invariant, an issue number, a non-obvious constraint. Names should carry the meaning; rename rather than annotate. Compiler and tool directives (`//go:build`, `//go:generate`, `//nolint:`) are exempt, and HTML `<!-- ... -->` is fine for template structure.
- **Persian and Arabic users are first-class.** When writing Persian text in toasts or labels, isolate code identifiers on their own lines so RTL reading flows. (Full RTL layout is not currently wired through AntD `ConfigProvider direction` — only the Jalali date picker is RTL-aware — so treat RTL as an open area, not a solved one.)
- **Schemas over `any`.** New config shapes go in `src/schemas/`; oxlint's `typescript/no-explicit-any` is an error and production schemas use no `.loose()`. Validate form fields with `antdRule(Schema.shape.field, t)` rather than inline `z.string()` in rules.
- **Schemas over `any`.** New config shapes go in `src/schemas/`; oxlint's `typescript/no-explicit-any` is an error and production schemas use no `.loose()`. Validate form fields with `rules={{ validate: rhfZodValidate(Schema.shape.field) }}` rather than inline `z.string()` in rules.
- **Document new endpoints.** Every new `g.POST`/`g.GET` in `internal/web/controller/` needs a matching entry in `src/pages/api-docs/endpoints.ts` — it drives both the in-panel API docs and the generated OpenAPI/Zod (`npm run gen:api` / `gen:zod`).
- **Do not break link generation.** Share-link logic lives in `src/lib/xray/` (`inbound-link.ts`, `outbound-link-parser.ts`, …) and is round-tripped by the golden fixture suite — run `npm run test` after any change to URL generation, defaults, or TLS/Reality handling, and regenerate snapshots (`npx vitest run -u`) only for intentional changes. Two runtime paths consume it: the **inbounds page** and the **clients page** subscription links (`/panel/api/clients/subLinks/:subId` → backend `GetSubs`); exercise both.
- **Vite is pinned to an exact version** (no `^`) in `frontend/package.json` — read the live version there rather than trusting a number quoted here — so local, CI, and release builds resolve identically. Bump it deliberately and verify both `npm run dev` and `npm run build` afterward.
+9 -6
View File
@@ -33,11 +33,12 @@ The `@` import alias maps to `src/`.
- Function components + hooks only; no class components.
- Comments in committed TS/TSX: 2 lines MAX per comment block, spent on the
*why* a name cannot hold (same rule as root CLAUDE.md). HTML comments are fine.
- TS strict; oxlint's `typescript/no-explicit-any` is an error. Build forms with `useZodForm` +
`FormField` from `@/components/form/rhf` (wrap the tree in `FormProvider`);
validate through the `zodResolver` or per-field
- TS strict; oxlint's `typescript/no-explicit-any` is an error. Build forms with React Hook
Form's `useForm` + `FormField` from `@/components/form/rhf` (wrap the tree in
`FormProvider`) and validate each field with
`rules={{ validate: rhfZodValidate(Schema.shape.field) }}` — messages are Zod
issue keys resolved via `t()`, never inline `z.string()`. AntD `<Form>` stays
issue keys resolved via `t()`, never inline `z.string()`. A whole-form
`zodResolver` is only for a form that submits one schema. AntD `<Form>` stays
only as a layout wrapper. Complex shared config editors (FinalMask / Sniffing /
Sockopt) remain AntD-`Form` islands wrapped as value/onChange adapters in
`src/lib/xray/forms/fields/`, bound via a `Controller`.
@@ -54,8 +55,10 @@ The `@` import alias maps to `src/`.
## Adding a panel route
1. `src/pages/<page>/<Page>.tsx` (kebab folder, PascalCase component).
2. Register in `src/routes.tsx` under `/panel` (lazy import).
3. Add a sidebar link in `src/layouts/AppSidebar.tsx` if it needs nav.
2. Add it to `src/routes/panelRoutes.tsx`. The router, the sidebar, the page
title and the command palette all read that one list;
`src/test/navigation-contract.test.tsx` holds it to the router and to the
translation bundle.
Only standalone bundles (login/subpage) need a new `.html` + `src/entries/*` +
`rollupOptions.input` (in `vite.config.js`) + a Go controller route.
+23 -20
View File
@@ -30,7 +30,7 @@ production-style links work without round-tripping through Go.
| Command | What |
|---|---|
| `npm run dev` | Vite dev server with API + WS proxy to Go |
| `npm run build` | Regenerates OpenAPI + Zod, then builds into `../internal/web/dist/` |
| `npm run build` | Regenerates the OpenAPI spec, then builds into `../internal/web/dist/` |
| `npm run preview` | Serve the built bundle locally |
| `npm run typecheck` | `tsc --noEmit` (strict, no emit) |
| `npm run lint` | oxlint over `src/` + `tools/` (`.oxlintrc.json`) |
@@ -42,7 +42,7 @@ production-style links work without round-tripping through Go.
| `npm run storybook` | Storybook dev server on `:6006` (component workbench + autodocs) |
| `npm run build-storybook` | Static Storybook build — CI compile-checks every story |
| `npm run gen:api` | Build `public/openapi.json` from `pages/api-docs/endpoints.ts` |
| `npm run gen:zod` | Run the Go-side openapigen tool → `src/generated/{zod,types}.ts` |
| `npm run gen:zod` | Run the Go-side openapigen tool → `src/generated/{zod,types,schemas,examples}.ts` |
CI runs `typecheck`, `lint`, `format:check`, `test`, `build`, and
`build-storybook` on every PR (see `../.github/workflows/ci.yml`).
@@ -156,9 +156,10 @@ Patterns:
security.
- **Three validation layers**, non-overlapping:
- API boundary: `parseMsg(msg, schema, ctx)` inside TanStack
Query `queryFn` — warn-only in prod, throws in dev
- Form input: `antdRule(schema.shape.field)` on every `<Form.Item>` —
blocks submit + per-field inline error
Query `queryFn` — warns on a mismatch and returns the payload; pass
`{ strict: true }` to throw instead
- Form input: `rules={{ validate: rhfZodValidate(schema.shape.field) }}` on
every `FormField` — blocks submit + per-field inline error
- Wire request: `Schema.parse(payload)` inside `mutationFn` — throws,
because a malformed payload here is always a developer bug
- **No `.loose()` or `[key: string]: any`** in production schemas.
@@ -166,27 +167,29 @@ Patterns:
## Form pattern (Pattern A)
All non-trivial modals use this single pattern:
All non-trivial modals use this single pattern — React Hook Form for the
state, AntD's `<Form>` only as the layout:
```tsx
const [form] = Form.useForm<InboundFormValues>();
const form = useForm<InboundFormValues>({ defaultValues: initialValues });
const onFinish = async () => {
const values = await form.validateFields();
await createInbound.mutateAsync(values);
};
const onSubmit = (values: InboundFormValues) => createInbound.mutateAsync(values);
<Form form={form} onFinish={onFinish}>
<Form.Item
name="port"
label="Port"
rules={[antdRule(InboundFormSchema.shape.port, t)]}
>
<InputNumber min={1} max={65535} />
</Form.Item>
</Form>
<FormProvider {...form}>
<Form layout="vertical" onFinish={form.handleSubmit(onSubmit)}>
<FormField
name="port"
label={t('pages.inbounds.port')}
rules={{ validate: rhfZodValidate(InboundFormSchema.shape.port) }}
>
<InputNumber min={1} max={65535} />
</FormField>
</Form>
</FormProvider>
```
`FormField` (from `@/components/form/rhf`) bridges a React Hook Form
`Controller` to an AntD input and renders the field's error as help text.
No `safeParse`-on-submit handlers, no `useRef<any>` for form
references, no inline `z.string().min(1)` in rules. Conditional
fields use `<Form.Item dependencies={...} shouldUpdate>` with the
@@ -4,7 +4,6 @@ import { useNavigate } from 'react-router';
import { useTranslation } from 'react-i18next';
import { ConfigProvider, Tag, Tooltip, message } from 'antd';
import {
ApiOutlined,
ApartmentOutlined,
CheckCircleFilled,
ClockCircleOutlined,
@@ -13,13 +12,11 @@ import {
ClusterOutlined,
CodeOutlined,
CopyOutlined,
CrownOutlined,
DashboardOutlined,
DatabaseOutlined,
DiscordOutlined,
ExportOutlined,
FileTextOutlined,
GlobalOutlined,
ImportOutlined,
LoadingOutlined,
MailOutlined,
@@ -31,8 +28,6 @@ import {
SettingOutlined,
SunOutlined,
SwapOutlined,
TagsOutlined,
TeamOutlined,
TelegramFilled,
ToolOutlined,
} from '@ant-design/icons';
@@ -42,6 +37,7 @@ import { activateOnKey } from '@/utils/a11y';
import { useInboundOptions } from '@/api/queries/useInboundOptions';
import { useAllSettings } from '@/api/queries/useAllSettings';
import { useTheme } from '@/hooks/useTheme';
import { PANEL_ROUTES } from '@/routes/panelRoutes';
import type { ClientRecord, InboundOption } from '@/schemas/client';
import { commandPaletteStore, useCommandPalette } from './useCommandPalette';
import './CommandPalette.css';
@@ -62,6 +58,34 @@ interface PaletteItem {
};
}
// The search terms of every panel page, so the palette finds it by what it
// does rather than by its title.
export const PALETTE_KEYWORDS: Record<string, string[]> = {
'/': ['overview', 'dashboard', 'cpu', 'ram', 'memory', 'traffic', 'speed'],
'/inbounds': [
'inbounds',
'ports',
'vless',
'vmess',
'reality',
'trojan',
'shadowsocks',
'wireguard',
'hysteria',
'masque',
],
'/clients': ['clients', 'users', 'sub', 'traffic', 'quota'],
'/groups': ['groups', 'tags', 'batch'],
'/nodes': ['nodes', 'servers', 'cluster', 'remote nodes'],
'/hosts': ['hosts', 'sni', 'domains'],
'/outbound': ['outbounds', 'freedom', 'blackhole', 'socks', 'http', 'warp', 'nord', 'pia'],
'/routing': ['routing', 'rules', 'geoip', 'geosite', 'direct', 'block'],
'/settings': ['settings', 'config', 'port', 'password', 'ssl', 'telegram'],
'/xray': ['xray', 'templates', 'balancer', 'dns'],
'/api-docs': ['api', 'api docs', 'swagger', 'rest api', 'endpoints'],
'/sponsors': ['sponsors', 'sponsor', 'partners'],
};
export default function CommandPalette() {
const { t } = useTranslation();
const [messageApi, messageContextHolder] = message.useMessage();
@@ -343,91 +367,12 @@ export default function CommandPalette() {
});
});
const pages = [
{
path: '/',
title: t('menu.dashboard'),
keywords: ['overview', 'dashboard', 'cpu', 'ram', 'memory', 'traffic', 'speed'],
icon: <DashboardOutlined aria-hidden />,
},
{
path: '/inbounds',
title: t('menu.inbounds'),
keywords: [
'inbounds',
'ports',
'vless',
'vmess',
'reality',
'trojan',
'shadowsocks',
'wireguard',
'hysteria',
'masque',
],
icon: <ImportOutlined aria-hidden />,
},
{
path: '/clients',
title: t('menu.clients'),
keywords: ['clients', 'users', 'sub', 'traffic', 'quota'],
icon: <TeamOutlined aria-hidden />,
},
{
path: '/groups',
title: t('menu.groups'),
keywords: ['groups', 'tags', 'batch'],
icon: <TagsOutlined aria-hidden />,
},
{
path: '/nodes',
title: t('menu.nodes'),
keywords: ['nodes', 'servers', 'cluster', 'remote nodes'],
icon: <ClusterOutlined aria-hidden />,
},
{
path: '/hosts',
title: t('menu.hosts'),
keywords: ['hosts', 'sni', 'domains'],
icon: <GlobalOutlined aria-hidden />,
},
{
path: '/outbound',
title: t('menu.outbounds'),
keywords: ['outbounds', 'freedom', 'blackhole', 'socks', 'http', 'warp', 'nord', 'pia'],
icon: <ExportOutlined aria-hidden />,
},
{
path: '/routing',
title: t('menu.routing'),
keywords: ['routing', 'rules', 'geoip', 'geosite', 'direct', 'block'],
icon: <SwapOutlined aria-hidden />,
},
{
path: '/settings',
title: t('menu.settings'),
keywords: ['settings', 'config', 'port', 'password', 'ssl', 'telegram'],
icon: <SettingOutlined aria-hidden />,
},
{
path: '/xray',
title: t('menu.xray'),
keywords: ['xray', 'templates', 'balancer', 'dns'],
icon: <ToolOutlined aria-hidden />,
},
{
path: '/api-docs',
title: t('menu.apiDocs'),
keywords: ['api', 'api docs', 'swagger', 'rest api', 'endpoints'],
icon: <ApiOutlined aria-hidden />,
},
{
path: '/sponsors',
title: t('menu.sponsors'),
keywords: ['sponsors', 'sponsor', 'partners'],
icon: <CrownOutlined aria-hidden />,
},
];
const pages = PANEL_ROUTES.map((route) => ({
path: route.path,
title: t(route.titleKey),
keywords: PALETTE_KEYWORDS[route.path] ?? [],
icon: <route.icon aria-hidden />,
}));
pages
.filter((p) => matches(p.title, undefined, p.keywords))
@@ -2,11 +2,11 @@ import { useEffect } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { expect, waitFor } from 'storybook/test';
import { Button, Form, Input, InputNumber, Select, Switch, Typography } from 'antd';
import { FormProvider } from 'react-hook-form';
import { FormProvider, useForm, useWatch } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { FormField } from './FormField';
import { useZodForm } from './useZodForm';
const GB = 1024 * 1024 * 1024;
@@ -60,7 +60,8 @@ const ClientSchema = z.object({
});
function ClientDemo() {
const methods = useZodForm(ClientSchema, {
const methods = useForm<z.infer<typeof ClientSchema>>({
resolver: zodResolver(ClientSchema),
defaultValues: { email: 'user1@example.com', flow: 'xtls-rprx-vision', enable: true },
});
return (
@@ -95,8 +96,11 @@ const TrafficSchema = z.object({
});
function TrafficDemo() {
const methods = useZodForm(TrafficSchema, { defaultValues: { totalBytes: 50 * GB } });
const totalBytes = methods.watch('totalBytes');
const methods = useForm<z.infer<typeof TrafficSchema>>({
resolver: zodResolver(TrafficSchema),
defaultValues: { totalBytes: 50 * GB },
});
const totalBytes = useWatch({ control: methods.control, name: 'totalBytes' });
return (
<FormProvider {...methods}>
<Form layout="vertical" style={{ maxWidth: 360 }}>
@@ -128,7 +132,10 @@ const InboundSchema = z.object({
});
function ValidationDemo() {
const methods = useZodForm(InboundSchema, { defaultValues: { remark: '', port: 0 } });
const methods = useForm<z.infer<typeof InboundSchema>>({
resolver: zodResolver(InboundSchema),
defaultValues: { remark: '', port: 0 },
});
useEffect(() => {
void methods.trigger();
}, [methods]);
@@ -157,7 +164,8 @@ const RealitySchema = z.object({
});
function NestedDemo() {
const methods = useZodForm(RealitySchema, {
const methods = useForm<z.infer<typeof RealitySchema>>({
resolver: zodResolver(RealitySchema),
defaultValues: {
streamSettings: {
realitySettings: { dest: 'yahoo.com:443', serverNames: 'yahoo.com,www.yahoo.com' },
@@ -1,5 +1,4 @@
export { FormField, type FormFieldProps } from './FormField';
export { useZodForm } from './useZodForm';
export { rhfZodValidate } from './rhfZodValidate';
export { normalizeAntdOnChange, type ValueProp } from './normalizeAntdOnChange';
export { toDotted, type FieldName } from './toDotted';
@@ -1,20 +0,0 @@
import { useForm } from 'react-hook-form';
import type { FieldValues, Resolver, UseFormProps, UseFormReturn } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import type { z } from 'zod';
export function useZodForm<TFieldValues extends FieldValues>(
schema: z.ZodType<TFieldValues>,
options?: Omit<UseFormProps<TFieldValues>, 'resolver'>,
): UseFormReturn<TFieldValues> {
const resolver = zodResolver(
schema as z.ZodType<TFieldValues, TFieldValues>,
) as Resolver<TFieldValues>;
return useForm<TFieldValues>({
mode: 'onSubmit',
reValidateMode: 'onChange',
shouldUnregister: false,
...options,
resolver,
});
}
+2 -15
View File
@@ -2,27 +2,14 @@ import { useEffect } from 'react';
import { useLocation } from 'react-router';
import { useTranslation } from 'react-i18next';
const TITLE_KEYS: Record<string, string> = {
'/': 'menu.dashboard',
'/inbounds': 'menu.inbounds',
'/clients': 'menu.clients',
'/groups': 'menu.groups',
'/nodes': 'menu.nodes',
'/hosts': 'menu.hosts',
'/settings': 'menu.settings',
'/xray': 'menu.xray',
'/outbound': 'menu.outbounds',
'/routing': 'menu.routing',
'/api-docs': 'menu.apiDocs',
'/sponsors': 'menu.sponsors',
};
import { PANEL_TITLE_KEYS } from '@/routes/panelRoutes';
export function usePageTitle() {
const { pathname } = useLocation();
const { t } = useTranslation();
useEffect(() => {
const key = TITLE_KEYS[pathname];
const key = PANEL_TITLE_KEYS[pathname];
const title = key ? t(key) : '3X-UI';
const host = window.location.hostname;
document.title = host ? `${host} - ${title}` : title;
+24 -61
View File
@@ -5,21 +5,15 @@ import { useTranslation } from 'react-i18next';
import { Drawer, Layout, Menu, Tooltip } from 'antd';
import type { MenuProps } from 'antd';
import {
ApiOutlined,
ApartmentOutlined,
CloseOutlined,
CloudServerOutlined,
ClusterOutlined,
CodeOutlined,
CrownOutlined,
DashboardOutlined,
DatabaseOutlined,
DiscordOutlined,
ExportOutlined,
GithubOutlined,
GlobalOutlined,
HeartOutlined,
ImportOutlined,
LogoutOutlined,
MailOutlined,
MenuOutlined,
@@ -32,11 +26,7 @@ import {
SearchOutlined,
SettingOutlined,
SunOutlined,
SwapOutlined,
TagsOutlined,
TeamOutlined,
TelegramFilled,
ToolOutlined,
} from '@ant-design/icons';
import { HttpUtil } from '@/utils';
@@ -44,6 +34,7 @@ import { formatPanelVersion } from '@/lib/panel-version';
import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme';
import { useAllSettings } from '@/api/queries/useAllSettings';
import { useCommandPalette } from '@/components/command-palette/useCommandPalette';
import { PANEL_ROUTES } from '@/routes/panelRoutes';
import './AppSidebar.css';
const DONATE_URL = 'https://donate.sanaei.dev/';
@@ -59,37 +50,6 @@ const SIDEBAR_PINNED_KEY = 'sidebar-pinned';
let hoveredAcrossRemounts = false;
type IconName =
| 'dashboard'
| 'inbound'
| 'team'
| 'groups'
| 'setting'
| 'tool'
| 'cluster'
| 'hosts'
| 'logout'
| 'sponsors'
| '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,
sponsors: CrownOutlined,
apidocs: ApiOutlined,
outbound: ExportOutlined,
routing: SwapOutlined,
};
function DonateButton({ ariaLabel }: { ariaLabel: string }) {
return (
<a
@@ -228,27 +188,20 @@ export default function AppSidebar() {
const currentTheme: 'light' | 'dark' = isDark ? 'dark' : 'light';
const panelVersion = window.X_UI_CUR_VER || '';
const tabs = useMemo<{ key: string; icon: IconName; title: string }[]>(
const tabs = useMemo<{ key: string; icon: ComponentType; 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: '/groups', icon: 'groups', title: t('menu.groups') },
{ key: '/nodes', icon: 'cluster', title: t('menu.nodes') },
{ key: '/hosts', icon: 'hosts', title: t('menu.hosts') },
{ key: '/outbound', icon: 'outbound', title: t('menu.outbounds') },
{ key: '/routing', icon: 'routing', title: t('menu.routing') },
{ key: '/settings', icon: 'setting', title: t('menu.settings') },
{ key: '/xray', icon: 'tool', title: t('menu.xray') },
{ key: '/api-docs', icon: 'apidocs', title: t('menu.apiDocs') },
{ key: '/sponsors', icon: 'sponsors', title: t('menu.sponsors') },
{ key: LOGOUT_KEY, icon: 'logout', title: t('logout') },
...PANEL_ROUTES.map((route) => ({
key: route.path,
icon: route.icon,
title: t(route.titleKey),
})),
{ key: LOGOUT_KEY, icon: LogoutOutlined, 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 navItems = useMemo(() => tabs.filter((tab) => tab.key !== LOGOUT_KEY), [tabs]);
const utilItems = useMemo(() => tabs.filter((tab) => tab.key === LOGOUT_KEY), [tabs]);
const settingsChildren = useMemo<NonNullable<MenuProps['items']>>(() => {
const children: NonNullable<MenuProps['items']> = [
@@ -341,14 +294,24 @@ export default function AppSidebar() {
const toMenuItems = useCallback(
(items: typeof tabs): MenuProps['items'] =>
items.map((tab) => {
const Icon = iconByName[tab.icon];
const Icon = tab.icon;
if (tab.key === '/settings') {
return { key: tab.key, icon: <Icon />, label: tab.title, children: settingsChildren };
return {
key: tab.key,
icon: <Icon aria-hidden />,
label: tab.title,
children: settingsChildren,
};
}
if (tab.key === '/xray') {
return { key: tab.key, icon: <Icon />, label: tab.title, children: xrayChildren };
return {
key: tab.key,
icon: <Icon aria-hidden />,
label: tab.title,
children: xrayChildren,
};
}
return { key: tab.key, icon: <Icon />, label: tab.title, title: '' };
return { key: tab.key, icon: <Icon aria-hidden />, label: tab.title, title: '' };
}),
[settingsChildren, xrayChildren],
);
+7 -27
View File
@@ -1,19 +1,9 @@
import { lazy, Suspense } from 'react';
import { Suspense } from 'react';
import { createBrowserRouter, type RouteObject } from 'react-router';
import { Spin } from 'antd';
import PanelLayout from '@/layouts/PanelLayout';
const IndexPage = lazy(() => import('@/pages/index/IndexPage'));
const InboundsPage = lazy(() => import('@/pages/inbounds/InboundsPage'));
const ClientsPage = lazy(() => import('@/pages/clients/ClientsPage'));
const GroupsPage = lazy(() => import('@/pages/groups/GroupsPage'));
const NodesPage = lazy(() => import('@/pages/nodes/NodesPage'));
const HostsPage = lazy(() => import('@/pages/hosts/HostsPage'));
const SettingsPage = lazy(() => import('@/pages/settings/SettingsPage'));
const XrayPage = lazy(() => import('@/pages/xray/XrayPage'));
const ApiDocsPage = lazy(() => import('@/pages/api-docs/ApiDocsPage'));
const SponsorsPage = lazy(() => import('@/pages/sponsors/SponsorsPage'));
import { PANEL_ROUTES } from '@/routes/panelRoutes';
function withSuspense(node: React.ReactNode) {
return (
@@ -36,24 +26,14 @@ function withSuspense(node: React.ReactNode) {
);
}
const routes: RouteObject[] = [
export const routes: RouteObject[] = [
{
path: '/',
element: <PanelLayout />,
children: [
{ index: true, element: withSuspense(<IndexPage />) },
{ path: 'inbounds', element: withSuspense(<InboundsPage />) },
{ path: 'clients', element: withSuspense(<ClientsPage />) },
{ path: 'groups', element: withSuspense(<GroupsPage />) },
{ path: 'nodes', element: withSuspense(<NodesPage />) },
{ path: 'hosts', element: withSuspense(<HostsPage />) },
{ path: 'settings', element: withSuspense(<SettingsPage />) },
{ path: 'xray', element: withSuspense(<XrayPage />) },
{ path: 'outbound', element: withSuspense(<XrayPage />) },
{ path: 'routing', element: withSuspense(<XrayPage />) },
{ path: 'api-docs', element: withSuspense(<ApiDocsPage />) },
{ path: 'sponsors', element: withSuspense(<SponsorsPage />) },
],
children: PANEL_ROUTES.map((route) => {
const element = withSuspense(<route.component />);
return route.path === '/' ? { index: true, element } : { path: route.path.slice(1), element };
}),
},
];
+57
View File
@@ -0,0 +1,57 @@
import { lazy } from 'react';
import type { ComponentType } from 'react';
import {
ApiOutlined,
ClusterOutlined,
CrownOutlined,
DashboardOutlined,
ExportOutlined,
GlobalOutlined,
ImportOutlined,
SettingOutlined,
SwapOutlined,
TagsOutlined,
TeamOutlined,
ToolOutlined,
} from '@ant-design/icons';
const IndexPage = lazy(() => import('@/pages/index/IndexPage'));
const InboundsPage = lazy(() => import('@/pages/inbounds/InboundsPage'));
const ClientsPage = lazy(() => import('@/pages/clients/ClientsPage'));
const GroupsPage = lazy(() => import('@/pages/groups/GroupsPage'));
const NodesPage = lazy(() => import('@/pages/nodes/NodesPage'));
const HostsPage = lazy(() => import('@/pages/hosts/HostsPage'));
const SettingsPage = lazy(() => import('@/pages/settings/SettingsPage'));
const XrayPage = lazy(() => import('@/pages/xray/XrayPage'));
const ApiDocsPage = lazy(() => import('@/pages/api-docs/ApiDocsPage'));
const SponsorsPage = lazy(() => import('@/pages/sponsors/SponsorsPage'));
export interface PanelRoute {
path: string;
titleKey: string;
icon: ComponentType;
component: ComponentType;
}
// The router, the sidebar, the page titles and the command palette all read this list, so a
// page cannot go missing from one of them.
export const PANEL_ROUTES: PanelRoute[] = [
{ path: '/', titleKey: 'menu.dashboard', icon: DashboardOutlined, component: IndexPage },
{ path: '/inbounds', titleKey: 'menu.inbounds', icon: ImportOutlined, component: InboundsPage },
{ path: '/clients', titleKey: 'menu.clients', icon: TeamOutlined, component: ClientsPage },
{ path: '/groups', titleKey: 'menu.groups', icon: TagsOutlined, component: GroupsPage },
{ path: '/nodes', titleKey: 'menu.nodes', icon: ClusterOutlined, component: NodesPage },
{ path: '/hosts', titleKey: 'menu.hosts', icon: GlobalOutlined, component: HostsPage },
{ path: '/outbound', titleKey: 'menu.outbounds', icon: ExportOutlined, component: XrayPage },
{ path: '/routing', titleKey: 'menu.routing', icon: SwapOutlined, component: XrayPage },
{ path: '/settings', titleKey: 'menu.settings', icon: SettingOutlined, component: SettingsPage },
{ path: '/xray', titleKey: 'menu.xray', icon: ToolOutlined, component: XrayPage },
{ path: '/api-docs', titleKey: 'menu.apiDocs', icon: ApiOutlined, component: ApiDocsPage },
{ path: '/sponsors', titleKey: 'menu.sponsors', icon: CrownOutlined, component: SponsorsPage },
];
export const PANEL_ROUTE_PATHS = PANEL_ROUTES.map((route) => route.path);
export const PANEL_TITLE_KEYS: Record<string, string> = Object.fromEntries(
PANEL_ROUTES.map((route) => [route.path, route.titleKey]),
);
+7 -3
View File
@@ -1,17 +1,21 @@
import { screen } from '@testing-library/react';
import { FormProvider } from 'react-hook-form';
import { FormProvider, useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { Form, InputNumber } from 'antd';
import { describe, expect, it } from 'vitest';
import { z } from 'zod';
import { FormField, useZodForm } from '@/components/form/rhf';
import { FormField } from '@/components/form/rhf';
import { renderWithProviders } from './test-utils';
const Schema = z.object({ totalGB: z.number() });
function Harness() {
const methods = useZodForm(Schema, { defaultValues: { totalGB: 0 } });
const methods = useForm<z.infer<typeof Schema>>({
resolver: zodResolver(Schema),
defaultValues: { totalGB: 0 },
});
return (
<FormProvider {...methods}>
<Form layout="vertical">
@@ -0,0 +1,57 @@
import { readFileSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { describe, expect, it } from 'vitest';
import { PALETTE_KEYWORDS } from '@/components/command-palette/CommandPalette';
import { PANEL_ROUTES, PANEL_ROUTE_PATHS } from '@/routes/panelRoutes';
import { routes } from '@/routes';
// Four hand-kept nav lists let a page go missing from the sidebar or fall back to the "3X-UI"
// title; the manifest replaced them, and these tests hold it to the router and the bundle.
const translationDir = resolve(process.cwd(), '..', 'internal', 'web', 'translation');
function flattenKeys(obj: Record<string, unknown>, prefix = ''): string[] {
const keys: string[] = [];
for (const [k, v] of Object.entries(obj)) {
const key = `${prefix}${k}`;
if (v !== null && typeof v === 'object') {
keys.push(...flattenKeys(v as Record<string, unknown>, `${key}.`));
} else {
keys.push(key);
}
}
return keys;
}
describe('panel navigation', () => {
it('lists every route once', () => {
expect(new Set(PANEL_ROUTE_PATHS).size).toBe(PANEL_ROUTE_PATHS.length);
});
it('routes every manifest path', () => {
const childPaths = (routes[0].children ?? []).map((child) =>
child.index ? '/' : `/${child.path ?? ''}`,
);
expect(childPaths.sort()).toEqual([...PANEL_ROUTE_PATHS].sort());
});
it('translates every title key', () => {
const enKeys = new Set(
flattenKeys(
JSON.parse(readFileSync(join(translationDir, 'en-US.json'), 'utf8')) as Record<
string,
unknown
>,
),
);
for (const route of PANEL_ROUTES) {
expect(enKeys.has(route.titleKey)).toBe(true);
}
});
it('gives every route command-palette keywords', () => {
expect(Object.keys(PALETTE_KEYWORDS).sort()).toEqual([...PANEL_ROUTE_PATHS].sort());
});
});
+5 -3
View File
@@ -1,10 +1,11 @@
import { describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { FormProvider } from 'react-hook-form';
import { FormProvider, useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { Form, Input, InputNumber, Switch } from 'antd';
import { z } from 'zod';
import { FormField, useZodForm } from '@/components/form/rhf';
import { FormField } from '@/components/form/rhf';
const GB = 1024 * 1024 * 1024;
@@ -18,7 +19,8 @@ const Schema = z.object({
type Values = z.infer<typeof Schema>;
function Harness({ onSubmit }: { onSubmit: (values: Values) => void }) {
const methods = useZodForm(Schema, {
const methods = useForm<Values>({
resolver: zodResolver(Schema),
defaultValues: { email: '', enabled: false, slug: '', bytes: 2 * GB },
});
return (