mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-10-15 02:15:01 +03:00
Merge remote-tracking branch 'upstream/main' into sync-3.6.0
# Conflicts: # .github/workflows/claude-bot.yml # .github/workflows/release.yml # DockerInit.sh # frontend/package-lock.json # frontend/package.json # frontend/src/hooks/useClients.ts # frontend/src/layouts/AppSidebar.tsx # frontend/src/main.tsx # internal/config/version # internal/database/model/model.go # internal/web/service/client_wireguard.go # internal/web/service/inbound.go
This commit is contained in:
@@ -88,6 +88,28 @@ function encodeForm(data: unknown): string {
|
||||
return parts.join('&');
|
||||
}
|
||||
|
||||
function appendQuery(url: string, query: string): string {
|
||||
if (query === '') return url;
|
||||
const hashIndex = url.indexOf('#');
|
||||
const path = hashIndex === -1 ? url : url.slice(0, hashIndex);
|
||||
const hash = hashIndex === -1 ? '' : url.slice(hashIndex);
|
||||
const hasQuery = path.includes('?');
|
||||
const separator = !hasQuery ? '?' : path.endsWith('?') || path.endsWith('&') ? '' : '&';
|
||||
return `${path}${separator}${query}${hash}`;
|
||||
}
|
||||
|
||||
function requestSignal(options: HttpRequestOptions): AbortSignal | undefined {
|
||||
if (!options.timeout) return options.signal;
|
||||
const timeout = AbortSignal.timeout(options.timeout);
|
||||
if (!options.signal) return timeout;
|
||||
if (typeof AbortSignal.any === 'function') return AbortSignal.any([options.signal, timeout]);
|
||||
const controller = new AbortController();
|
||||
const abort = () => controller.abort();
|
||||
options.signal.addEventListener('abort', abort, { once: true });
|
||||
timeout.addEventListener('abort', abort, { once: true });
|
||||
return controller.signal;
|
||||
}
|
||||
|
||||
async function performFetch(
|
||||
method: string,
|
||||
url: string,
|
||||
@@ -121,8 +143,8 @@ async function performFetch(
|
||||
}
|
||||
|
||||
const query = encodeForm(options.params);
|
||||
const fullUrl = basePathPrefix + url + (query ? `?${query}` : '');
|
||||
const signal = options.timeout ? AbortSignal.timeout(options.timeout) : options.signal;
|
||||
const fullUrl = basePathPrefix + appendQuery(url, query);
|
||||
const signal = requestSignal(options);
|
||||
|
||||
return fetch(fullUrl, { method: upper, headers, body, credentials: 'same-origin', signal });
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { HttpUtil, Msg } from '@/utils';
|
||||
@@ -6,8 +6,13 @@ import { parseMsg } from '@/utils/zodValidate';
|
||||
import { AllSetting } from '@/models/setting';
|
||||
import { AllSettingSchema, type AllSettingInput } from '@/schemas/setting';
|
||||
import { keys } from '@/api/queryKeys';
|
||||
import { useServerDraft } from '@/hooks/useServerDraft';
|
||||
|
||||
type SettingSavePayload = Partial<AllSetting> & Record<string, unknown>;
|
||||
type SettingSaveResult = {
|
||||
msg: Msg<unknown>;
|
||||
saved?: AllSetting;
|
||||
};
|
||||
|
||||
async function fetchAllSetting(): Promise<AllSettingInput | null> {
|
||||
const msg = await HttpUtil.post('/panel/api/setting/all', undefined, { silent: true });
|
||||
@@ -18,7 +23,6 @@ async function fetchAllSetting(): Promise<AllSettingInput | null> {
|
||||
|
||||
export function useAllSettings() {
|
||||
const queryClient = useQueryClient();
|
||||
const [draft, setDraft] = useState<AllSetting>(() => new AllSetting());
|
||||
const [extraSpinning, setExtraSpinning] = useState(false);
|
||||
|
||||
const query = useQuery({
|
||||
@@ -28,41 +32,54 @@ export function useAllSettings() {
|
||||
});
|
||||
|
||||
const server = useMemo(() => new AllSetting(query.data), [query.data]);
|
||||
|
||||
useEffect(() => {
|
||||
if (query.data !== undefined) {
|
||||
setDraft(new AllSetting(query.data));
|
||||
}
|
||||
}, [query.data]);
|
||||
const { draft, setDraft, isDirty, markSaved } = useServerDraft(
|
||||
query.data === undefined ? undefined : server,
|
||||
(setting) => new AllSetting(setting),
|
||||
(left, right) => left.equals(right),
|
||||
);
|
||||
const allSetting = draft ?? server;
|
||||
|
||||
const updateSetting = useCallback((patch: Partial<AllSetting>) => {
|
||||
setDraft((prev) => {
|
||||
const next = new AllSetting(prev);
|
||||
const next = new AllSetting(prev ?? server);
|
||||
Object.assign(next, patch);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
}, [server, setDraft]);
|
||||
|
||||
const saveMut = useMutation({
|
||||
mutationFn: async (next: SettingSavePayload): Promise<Msg<unknown>> => {
|
||||
const payload = { ...next };
|
||||
const body = AllSettingSchema.partial().safeParse(payload);
|
||||
mutationFn: async ({ payload, saved }: { payload: SettingSavePayload; saved?: AllSetting }): Promise<SettingSaveResult> => {
|
||||
const next = { ...payload };
|
||||
const body = AllSettingSchema.partial().safeParse(next);
|
||||
if (!body.success) {
|
||||
console.warn('[zod] setting/update body failed validation', body.error.issues);
|
||||
}
|
||||
return HttpUtil.post('/panel/api/setting/update', body.success ? { ...payload, ...body.data } : payload);
|
||||
const msg = await HttpUtil.post('/panel/api/setting/update', body.success ? { ...next, ...body.data } : next);
|
||||
return { msg, saved };
|
||||
},
|
||||
onSuccess: (msg) => {
|
||||
if (msg?.success) queryClient.invalidateQueries({ queryKey: keys.settings.all() });
|
||||
onSuccess: ({ msg, saved }) => {
|
||||
if (!msg?.success) return;
|
||||
if (saved) markSaved(saved);
|
||||
queryClient.invalidateQueries({ queryKey: keys.settings.all() });
|
||||
},
|
||||
});
|
||||
|
||||
const saveAll = useCallback(() => saveMut.mutateAsync({ ...draft }), [saveMut, draft]);
|
||||
const savePayload = useCallback((payload: SettingSavePayload) => saveMut.mutateAsync(payload), [saveMut]);
|
||||
const saveDisabled = useMemo(() => server.equals(draft), [server, draft]);
|
||||
const saveAll = useCallback(async () => {
|
||||
const saved = new AllSetting(allSetting);
|
||||
return (await saveMut.mutateAsync({ payload: { ...saved }, saved })).msg;
|
||||
}, [allSetting, saveMut]);
|
||||
const savePayload = useCallback(
|
||||
async (payload: SettingSavePayload) => {
|
||||
const saved = new AllSetting(allSetting);
|
||||
Object.assign(saved, payload);
|
||||
return (await saveMut.mutateAsync({ payload, saved })).msg;
|
||||
},
|
||||
[allSetting, saveMut],
|
||||
);
|
||||
const saveDisabled = !isDirty;
|
||||
|
||||
return {
|
||||
allSetting: draft,
|
||||
allSetting,
|
||||
updateSetting,
|
||||
fetched: query.data !== undefined,
|
||||
spinning: extraSpinning || saveMut.isPending,
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { HttpUtil } from '@/utils';
|
||||
import { parseMsg } from '@/utils/zodValidate';
|
||||
import { FactoryDefaultsSchema, type FactoryDefaults } from '@/schemas/setting';
|
||||
import { keys } from '@/api/queryKeys';
|
||||
|
||||
async function fetchFactoryDefaults(): Promise<FactoryDefaults> {
|
||||
const msg = await HttpUtil.post('/panel/api/setting/factoryDefaults', undefined, { silent: true });
|
||||
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch factory defaults');
|
||||
const validated = parseMsg(msg, FactoryDefaultsSchema, 'setting/factoryDefaults');
|
||||
const parsed = FactoryDefaultsSchema.safeParse(validated.obj);
|
||||
return parsed.success ? parsed.data : {};
|
||||
}
|
||||
|
||||
export function useFactoryDefaults() {
|
||||
return useQuery({
|
||||
queryKey: keys.settings.factoryDefaults(),
|
||||
queryFn: fetchFactoryDefaults,
|
||||
staleTime: Infinity,
|
||||
});
|
||||
}
|
||||
@@ -17,6 +17,7 @@ export const keys = {
|
||||
root: () => ['settings'] as const,
|
||||
all: () => ['settings', 'all'] as const,
|
||||
defaults: () => ['settings', 'defaults'] as const,
|
||||
factoryDefaults: () => ['settings', 'factoryDefaults'] as const,
|
||||
},
|
||||
inbounds: {
|
||||
root: () => ['inbounds'] as const,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo } from 'react';
|
||||
import { memo, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Popover, Progress } from 'antd';
|
||||
|
||||
@@ -17,7 +17,11 @@ export interface ClientTrafficCellProps {
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
export default function ClientTrafficCell({
|
||||
// Every prop is a primitive and the component is pure, so the memo bails out
|
||||
// whenever a client's counters did not move — which is most of them on most
|
||||
// pushes. Each skipped instance is one antd Popover (rc-trigger), one Progress,
|
||||
// a useTranslation subscription and a theme context read, times up to 200 rows.
|
||||
const ClientTrafficCell = memo(function ClientTrafficCell({
|
||||
up = 0,
|
||||
down = 0,
|
||||
total = 0,
|
||||
@@ -83,4 +87,6 @@ export default function ClientTrafficCell({
|
||||
</div>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
export default ClientTrafficCell;
|
||||
|
||||
@@ -16,6 +16,10 @@ body.light .config-block .ant-tag.ant-tag-filled.ant-tag-gold {
|
||||
color: #874d00;
|
||||
}
|
||||
|
||||
body.light .config-block-text {
|
||||
color: #595959;
|
||||
}
|
||||
|
||||
.config-block .ant-collapse-extra {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -121,7 +121,9 @@ export default function DateTimePicker({
|
||||
<DatePicker
|
||||
value={value}
|
||||
onChange={(next) => onChange(next || null)}
|
||||
onCalendarChange={(next) => onChange((Array.isArray(next) ? next[0] : next) || null)}
|
||||
showTime={showTime ? { format: 'HH:mm:ss' } : false}
|
||||
needConfirm={false}
|
||||
format={format}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Tag } from 'antd';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useFactoryDefaults } from '@/api/queries/useFactoryDefaults';
|
||||
|
||||
/**
|
||||
* Value semantics on purpose: the tag answers "does this equal the shipped
|
||||
* default?", not "has the user ever saved this key?" — a stored 2096 and a
|
||||
* fallback 2096 behave identically, so they read identically.
|
||||
*/
|
||||
export function matchesFactoryDefault(current: unknown, factoryDefault: string | undefined): boolean {
|
||||
if (factoryDefault === undefined) return false;
|
||||
if (typeof current === 'number') {
|
||||
const parsed = Number(factoryDefault);
|
||||
return factoryDefault.trim() !== '' && !Number.isNaN(parsed) && parsed === current;
|
||||
}
|
||||
if (typeof current === 'boolean') {
|
||||
if (factoryDefault !== 'true' && factoryDefault !== 'false') return false;
|
||||
return (factoryDefault === 'true') === current;
|
||||
}
|
||||
if (typeof current === 'string') return factoryDefault === current;
|
||||
return false;
|
||||
}
|
||||
|
||||
interface DefaultSettingTagProps {
|
||||
settingKey: string;
|
||||
value: unknown;
|
||||
}
|
||||
|
||||
export default function DefaultSettingTag({ settingKey, value }: DefaultSettingTagProps) {
|
||||
const { t } = useTranslation();
|
||||
const defaults = useFactoryDefaults();
|
||||
|
||||
if (!matchesFactoryDefault(value, defaults.data?.[settingKey])) return null;
|
||||
|
||||
return <Tag style={{ marginLeft: 8 }}>{t('pages.settings.defaultTag')}</Tag>;
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import './SettingListItem.css';
|
||||
interface SettingListItemProps {
|
||||
paddings?: 'small' | 'default';
|
||||
title?: ReactNode;
|
||||
badge?: ReactNode;
|
||||
description?: ReactNode;
|
||||
children?: ReactNode;
|
||||
control?: ReactNode;
|
||||
@@ -13,6 +14,7 @@ interface SettingListItemProps {
|
||||
export default function SettingListItem({
|
||||
paddings = 'default',
|
||||
title,
|
||||
badge,
|
||||
description,
|
||||
children,
|
||||
control,
|
||||
@@ -28,7 +30,12 @@ export default function SettingListItem({
|
||||
<Row gutter={[8, 16]} style={{ width: '100%' }}>
|
||||
<Col xs={24} lg={12}>
|
||||
<div className="setting-list-meta">
|
||||
{title && <div className="setting-list-title" id={titleId}>{title}</div>}
|
||||
{title && (
|
||||
<div className="setting-list-title">
|
||||
<span id={titleId}>{title}</span>
|
||||
{badge}
|
||||
</div>
|
||||
)}
|
||||
{description && <div className="setting-list-description">{description}</div>}
|
||||
</div>
|
||||
</Col>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export { default as InputAddon } from './InputAddon';
|
||||
export { default as InfinityIcon } from './InfinityIcon';
|
||||
export { default as SettingListItem } from './SettingListItem';
|
||||
export { default as DefaultSettingTag } from './DefaultSettingTag';
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -75,6 +75,7 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"subPort": 1,
|
||||
"subProfileUrl": "",
|
||||
"subRoutingRules": "",
|
||||
"subShowIdentityOnAllLinks": false,
|
||||
"subSupportUrl": "",
|
||||
"subThemeDir": "",
|
||||
"subTitle": "",
|
||||
@@ -186,6 +187,7 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"subPort": 1,
|
||||
"subProfileUrl": "",
|
||||
"subRoutingRules": "",
|
||||
"subShowIdentityOnAllLinks": false,
|
||||
"subSupportUrl": "",
|
||||
"subThemeDir": "",
|
||||
"subTitle": "",
|
||||
@@ -460,6 +462,7 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"tag": "in-443-tcp",
|
||||
"total": 0,
|
||||
"trafficReset": "never",
|
||||
"trafficResetDay": 1,
|
||||
"up": 0
|
||||
},
|
||||
"InboundClientIps": {
|
||||
|
||||
@@ -244,6 +244,9 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"subRoutingRules": {
|
||||
"type": "string"
|
||||
},
|
||||
"subShowIdentityOnAllLinks": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"subSupportUrl": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -415,6 +418,7 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"subPort",
|
||||
"subProfileUrl",
|
||||
"subRoutingRules",
|
||||
"subShowIdentityOnAllLinks",
|
||||
"subSupportUrl",
|
||||
"subThemeDir",
|
||||
"subTitle",
|
||||
@@ -711,6 +715,9 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"subRoutingRules": {
|
||||
"type": "string"
|
||||
},
|
||||
"subShowIdentityOnAllLinks": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"subSupportUrl": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -889,6 +896,7 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"subPort",
|
||||
"subProfileUrl",
|
||||
"subRoutingRules",
|
||||
"subShowIdentityOnAllLinks",
|
||||
"subSupportUrl",
|
||||
"subThemeDir",
|
||||
"subTitle",
|
||||
@@ -1873,6 +1881,13 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"trafficResetDay": {
|
||||
"description": "Day of month for monthly traffic resets",
|
||||
"example": 1,
|
||||
"maximum": 31,
|
||||
"minimum": 1,
|
||||
"type": "integer"
|
||||
},
|
||||
"up": {
|
||||
"description": "Upload traffic in bytes",
|
||||
"format": "int64",
|
||||
@@ -1899,6 +1914,7 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"tag",
|
||||
"total",
|
||||
"trafficReset",
|
||||
"trafficResetDay",
|
||||
"up"
|
||||
],
|
||||
"type": "object"
|
||||
|
||||
@@ -83,6 +83,7 @@ export interface AllSetting {
|
||||
subPort: number;
|
||||
subProfileUrl: string;
|
||||
subRoutingRules: string;
|
||||
subShowIdentityOnAllLinks: boolean;
|
||||
subSupportUrl: string;
|
||||
subThemeDir: string;
|
||||
subTitle: string;
|
||||
@@ -195,6 +196,7 @@ export interface AllSettingView {
|
||||
subPort: number;
|
||||
subProfileUrl: string;
|
||||
subRoutingRules: string;
|
||||
subShowIdentityOnAllLinks: boolean;
|
||||
subSupportUrl: string;
|
||||
subThemeDir: string;
|
||||
subTitle: string;
|
||||
@@ -435,6 +437,7 @@ export interface Inbound {
|
||||
tag: string;
|
||||
total: number;
|
||||
trafficReset: string;
|
||||
trafficResetDay: number;
|
||||
up: number;
|
||||
}
|
||||
|
||||
|
||||
@@ -99,6 +99,7 @@ export const AllSettingSchema = z.object({
|
||||
subPort: z.number().int().min(1).max(65535),
|
||||
subProfileUrl: z.string(),
|
||||
subRoutingRules: z.string(),
|
||||
subShowIdentityOnAllLinks: z.boolean(),
|
||||
subSupportUrl: z.string(),
|
||||
subThemeDir: z.string(),
|
||||
subTitle: z.string(),
|
||||
@@ -212,6 +213,7 @@ export const AllSettingViewSchema = z.object({
|
||||
subPort: z.number().int().min(1).max(65535),
|
||||
subProfileUrl: z.string(),
|
||||
subRoutingRules: z.string(),
|
||||
subShowIdentityOnAllLinks: z.boolean(),
|
||||
subSupportUrl: z.string(),
|
||||
subThemeDir: z.string(),
|
||||
subTitle: z.string(),
|
||||
@@ -465,6 +467,7 @@ export const InboundSchema = z.object({
|
||||
tag: z.string(),
|
||||
total: z.number().int(),
|
||||
trafficReset: z.enum(['never', 'hourly', 'daily', 'weekly', 'monthly']),
|
||||
trafficResetDay: z.number().int().min(1).max(31),
|
||||
up: z.number().int(),
|
||||
});
|
||||
export type Inbound = z.infer<typeof InboundSchema>;
|
||||
|
||||
@@ -73,7 +73,9 @@ export interface ClientQueryParams {
|
||||
|
||||
const DEFAULT_QUERY: ClientQueryParams = { page: 1, pageSize: 25 };
|
||||
const DEFAULT_SUMMARY: ClientsSummary = {
|
||||
total: 0, active: 0, online: [], depleted: [], expiring: [], deactive: [],
|
||||
total: 0, active: 0,
|
||||
onlineCount: 0, depletedCount: 0, expiringCount: 0, deactiveCount: 0,
|
||||
online: [], depleted: [], expiring: [], deactive: [],
|
||||
};
|
||||
|
||||
export interface ClientSpeedEntry {
|
||||
@@ -114,7 +116,63 @@ export function computeClientsSummary(
|
||||
if (nearExpiry || nearLimit) expiring.push(email);
|
||||
else active += 1;
|
||||
}
|
||||
return { total: stats.length, active, online, depleted, expiring, deactive };
|
||||
return {
|
||||
total: stats.length,
|
||||
active,
|
||||
onlineCount: online.length,
|
||||
depletedCount: depleted.length,
|
||||
expiringCount: expiring.length,
|
||||
deactiveCount: deactive.length,
|
||||
online,
|
||||
depleted,
|
||||
expiring,
|
||||
deactive,
|
||||
};
|
||||
}
|
||||
|
||||
export function sameSpeedMap(
|
||||
a: Record<string, ClientSpeedEntry>,
|
||||
b: Record<string, ClientSpeedEntry>,
|
||||
): boolean {
|
||||
const aKeys = Object.keys(a);
|
||||
if (aKeys.length !== Object.keys(b).length) return false;
|
||||
for (const key of aKeys) {
|
||||
const left = a[key];
|
||||
const right = b[key];
|
||||
if (!right || left.up !== right.up || left.down !== right.down) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// The field list computeClientsSummary reads, and deliberately nothing else.
|
||||
// lastOnline in particular churns for every online client on every push and no
|
||||
// counter depends on it, so including it here would defeat the comparison.
|
||||
export function sameSummaryInputs(a: ClientStatRow[], b: ClientStatRow[]): boolean {
|
||||
if (a.length !== b.length) return false;
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
const left = a[i];
|
||||
const right = b[i];
|
||||
if (left.email !== right.email
|
||||
|| left.up !== right.up
|
||||
|| left.down !== right.down
|
||||
|| left.total !== right.total
|
||||
|| left.enable !== right.enable
|
||||
|| left.expiryTime !== right.expiryTime) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function pickClientsSummary(
|
||||
serverSummary: ClientsSummary,
|
||||
allClientStats: ClientStatRow[],
|
||||
onlineSet: Set<string>,
|
||||
expireDiffMs: number,
|
||||
trafficDiffBytes: number,
|
||||
): ClientsSummary {
|
||||
if (allClientStats.length === 0) return serverSummary;
|
||||
if (serverSummary.total > allClientStats.length) return serverSummary;
|
||||
const live = computeClientsSummary(allClientStats, onlineSet, expireDiffMs, trafficDiffBytes);
|
||||
return { ...live, total: serverSummary.total || live.total };
|
||||
}
|
||||
|
||||
function buildQS(p: ClientQueryParams): string {
|
||||
@@ -142,7 +200,7 @@ async function fetchClientPage(params: ClientQueryParams): Promise<ClientPageRes
|
||||
const qs = buildQS(params);
|
||||
const msg = await HttpUtil.get(`/panel/api/clients/list/paged?${qs}`, undefined, { silent: true });
|
||||
if (!msg?.success || !msg.obj) throw new Error(msg?.msg || 'Failed to fetch clients');
|
||||
const validated = parseMsg(msg, ClientPageResponseSchema, 'clients/list/paged');
|
||||
const validated = parseMsg(msg, ClientPageResponseSchema, 'clients/list/paged', { strict: true });
|
||||
if (!validated.obj) throw new Error('Empty clients response');
|
||||
return validated.obj;
|
||||
}
|
||||
@@ -161,17 +219,31 @@ async function fetchDefaults(): Promise<Record<string, unknown>> {
|
||||
return validated.obj || {};
|
||||
}
|
||||
|
||||
export function useClients() {
|
||||
export interface UseClientsOptions {
|
||||
// Callers that only need the mutations — the bulk modals, the groups page —
|
||||
// pass false. Mounting them used to start a second 5-second poll of the paged
|
||||
// list whose result they never read, which on a large panel means a full
|
||||
// summary aggregate every 5 seconds for nothing.
|
||||
list?: boolean;
|
||||
}
|
||||
|
||||
export function useClients(options: UseClientsOptions = {}) {
|
||||
const withList = options.list ?? true;
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [query, setQueryState] = useState<ClientQueryParams>(DEFAULT_QUERY);
|
||||
// Null until the page has settled on a query. The clients page cannot build
|
||||
// one until the persisted sort and the panel's configured page size are both
|
||||
// known, and fetching before then cost three sequential requests per load —
|
||||
// the first two thrown away (#trace).
|
||||
const [query, setQueryState] = useState<ClientQueryParams | null>(null);
|
||||
// setQuery shallow-compares so callers can pass a fresh object every render
|
||||
// (the common React pattern) without triggering a re-fetch when nothing
|
||||
// actually changed.
|
||||
const setQuery = useCallback((next: ClientQueryParams) => {
|
||||
setQueryState((prev) => {
|
||||
if (
|
||||
prev.page === next.page
|
||||
prev
|
||||
&& prev.page === next.page
|
||||
&& prev.pageSize === next.pageSize
|
||||
&& (prev.search ?? '') === (next.search ?? '')
|
||||
&& (prev.filter ?? '') === (next.filter ?? '')
|
||||
@@ -193,8 +265,9 @@ export function useClients() {
|
||||
}, []);
|
||||
|
||||
const listQuery = useQuery({
|
||||
queryKey: keys.clients.list(query),
|
||||
queryFn: () => fetchClientPage(query),
|
||||
queryKey: keys.clients.list(query ?? DEFAULT_QUERY),
|
||||
queryFn: () => fetchClientPage(query ?? DEFAULT_QUERY),
|
||||
enabled: withList && query !== null,
|
||||
staleTime: Infinity,
|
||||
// List is sorted/paged server-side, so the WS patch can't add new or
|
||||
// re-sort rows; poll the current page to keep it live (pauses when hidden).
|
||||
@@ -205,6 +278,7 @@ export function useClients() {
|
||||
const inboundOptionsQuery = useQuery({
|
||||
queryKey: keys.inbounds.options(),
|
||||
queryFn: fetchInboundOptions,
|
||||
enabled: withList,
|
||||
staleTime: Infinity,
|
||||
});
|
||||
|
||||
@@ -222,6 +296,7 @@ export function useClients() {
|
||||
const validated = parseMsg(msg, OnlinesSchema, 'clients/onlines');
|
||||
return Array.isArray(validated.obj) ? validated.obj : [];
|
||||
},
|
||||
enabled: withList,
|
||||
staleTime: Infinity,
|
||||
});
|
||||
|
||||
@@ -231,7 +306,11 @@ export function useClients() {
|
||||
const allGroups = listQuery.data?.groups ?? [];
|
||||
const fetched = listQuery.data !== undefined || listQuery.isError;
|
||||
const fetchError = listQuery.error ? (listQuery.error as Error).message : '';
|
||||
const loading = listQuery.isFetching;
|
||||
// isFetching is deliberately NOT read here. Touching it makes it a tracked
|
||||
// property, so the 5s refetchInterval notifies twice per cycle — two whole
|
||||
// page renders even when structural sharing leaves the data identical, and
|
||||
// each one bumps rc-table's immutable mark and re-runs every cell renderer.
|
||||
// Callers that want a spinner for an explicit refresh drive it locally.
|
||||
// Showing kept-previous data for a new key (filter/sort/page) — drives the
|
||||
// table overlay so the 5s background poll doesn't flash it.
|
||||
const transitioning = listQuery.isPlaceholderData;
|
||||
@@ -264,11 +343,12 @@ export function useClients() {
|
||||
const expireDiff = ((defaults.expireDiff as number) ?? 0) * 86400000;
|
||||
const trafficDiff = ((defaults.trafficDiff as number) ?? 0) * 1073741824;
|
||||
const pageSize = (defaults.pageSize as number) ?? 0;
|
||||
// pageSize 0 means "one long page", which is indistinguishable from "the
|
||||
// settings have not arrived yet" — so callers need this flag to know when the
|
||||
// configured page size is real. isFetched (not isSuccess) so a failed
|
||||
// settings request still lets the page fall back and render.
|
||||
const settingsReady = defaultsQuery.isFetched;
|
||||
|
||||
// Live summary: the client_stats WS event refreshes allClientStats every few
|
||||
// seconds, so the top counters track reality without a page refresh. Falls
|
||||
// back to the server-computed summary until the first event lands, and keeps
|
||||
// the server's authoritative total for the headline count.
|
||||
const [allClientStats, setAllClientStats] = useState<ClientStatRow[]>([]);
|
||||
const [clientSpeed, setClientSpeed] = useState<Record<string, ClientSpeedEntry>>({});
|
||||
// AmneziaWG/MTProto run entirely outside xray-core, so their live speed
|
||||
@@ -283,12 +363,10 @@ export function useClients() {
|
||||
// exactly one job.
|
||||
const [amneziawgClientSpeed, setAmneziawgClientSpeed] = useState<Record<string, ClientSpeedEntry>>({});
|
||||
const [mtprotoClientSpeed, setMtprotoClientSpeed] = useState<Record<string, ClientSpeedEntry>>({});
|
||||
const summary = useMemo<ClientsSummary>(() => {
|
||||
const serverSummary = listQuery.data?.summary ?? DEFAULT_SUMMARY;
|
||||
if (allClientStats.length === 0) return serverSummary;
|
||||
const live = computeClientsSummary(allClientStats, new Set(onlines), expireDiff, trafficDiff);
|
||||
return { ...live, total: serverSummary.total || live.total };
|
||||
}, [allClientStats, onlines, expireDiff, trafficDiff, listQuery.data?.summary]);
|
||||
const summary = useMemo<ClientsSummary>(
|
||||
() => pickClientsSummary(listQuery.data?.summary ?? DEFAULT_SUMMARY, allClientStats, new Set(onlines), expireDiff, trafficDiff),
|
||||
[allClientStats, onlines, expireDiff, trafficDiff, listQuery.data?.summary],
|
||||
);
|
||||
|
||||
const invalidateAll = useCallback(
|
||||
() => {
|
||||
@@ -572,15 +650,23 @@ export function useClients() {
|
||||
queryClient.setQueryData(keys.clients.onlines(), p.onlineClients);
|
||||
}
|
||||
if (Array.isArray(p.clientTraffics)) {
|
||||
// Xray reports a row per client whether or not it moved a byte, so most of
|
||||
// this map used to be zeros. A missing entry and a zero entry render
|
||||
// identically (isActiveSpeed treats both as inactive), so the zeros are
|
||||
// dropped and an unchanged result returns the previous object — which lets
|
||||
// React bail out of the update instead of re-rendering the table.
|
||||
const next: Record<string, ClientSpeedEntry> = {};
|
||||
for (const ct of p.clientTraffics) {
|
||||
if (!ct || !ct.email) continue;
|
||||
const up = ct.up || 0;
|
||||
const down = ct.down || 0;
|
||||
if (up === 0 && down === 0) continue;
|
||||
next[ct.email] = {
|
||||
up: (ct.up || 0) / TRAFFIC_POLL_INTERVAL_S,
|
||||
down: (ct.down || 0) / TRAFFIC_POLL_INTERVAL_S,
|
||||
up: up / TRAFFIC_POLL_INTERVAL_S,
|
||||
down: down / TRAFFIC_POLL_INTERVAL_S,
|
||||
};
|
||||
}
|
||||
setClientSpeed(next);
|
||||
setClientSpeed((prev) => (sameSpeedMap(prev, next) ? prev : next));
|
||||
}
|
||||
// Mirrors the block above exactly, but as two independent, protocol-only
|
||||
// maps (see the amneziawgClientSpeed/mtprotoClientSpeed declaration).
|
||||
@@ -610,12 +696,17 @@ export function useClients() {
|
||||
if (!payload || typeof payload !== 'object') return;
|
||||
const p = payload as { clients?: ClientStatRow[]; snapshot?: boolean };
|
||||
if (!Array.isArray(p.clients) || p.clients.length === 0) return;
|
||||
if (p.snapshot !== false) setAllClientStats(p.clients);
|
||||
if (p.snapshot !== false) {
|
||||
const rows = p.clients;
|
||||
setAllClientStats((prev) => (sameSummaryInputs(prev, rows) ? prev : rows));
|
||||
}
|
||||
const active = queryRef.current;
|
||||
if (!active) return;
|
||||
const byEmail = new Map<string, ClientTraffic>();
|
||||
for (const row of p.clients) {
|
||||
if (row && row.email) byEmail.set(row.email, row);
|
||||
}
|
||||
queryClient.setQueryData<ClientPageResponse>(keys.clients.list(queryRef.current), (prev) => {
|
||||
queryClient.setQueryData<ClientPageResponse>(keys.clients.list(active), (prev) => {
|
||||
if (!prev) return prev;
|
||||
let touched = false;
|
||||
const next = prev.items.slice();
|
||||
@@ -663,7 +754,6 @@ export function useClients() {
|
||||
setQuery,
|
||||
inbounds,
|
||||
onlines,
|
||||
loading,
|
||||
transitioning,
|
||||
fetched,
|
||||
fetchError,
|
||||
@@ -673,6 +763,7 @@ export function useClients() {
|
||||
expireDiff,
|
||||
trafficDiff,
|
||||
pageSize,
|
||||
settingsReady,
|
||||
refresh,
|
||||
create,
|
||||
bulkCreate,
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
export function useServerDraft<T>(server: T | undefined, clone: (value: T) => T, equals: (left: T, right: T) => boolean) {
|
||||
const cloneRef = useRef(clone);
|
||||
const equalsRef = useRef(equals);
|
||||
cloneRef.current = clone;
|
||||
equalsRef.current = equals;
|
||||
|
||||
const [draft, setDraft] = useState<T | undefined>();
|
||||
const [baseline, setBaseline] = useState<T | undefined>();
|
||||
const draftRef = useRef(draft);
|
||||
const baselineRef = useRef(baseline);
|
||||
draftRef.current = draft;
|
||||
baselineRef.current = baseline;
|
||||
|
||||
useEffect(() => {
|
||||
if (server === undefined) return;
|
||||
const currentDraft = draftRef.current;
|
||||
const currentBaseline = baselineRef.current;
|
||||
const isDirty = currentDraft !== undefined
|
||||
&& (currentBaseline === undefined || !equalsRef.current(currentDraft, currentBaseline));
|
||||
setBaseline(server);
|
||||
if (isDirty && !equalsRef.current(currentDraft, server)) return;
|
||||
setDraft(cloneRef.current(server));
|
||||
}, [server]);
|
||||
|
||||
const markSaved = useCallback((value: T) => {
|
||||
setBaseline(cloneRef.current(value));
|
||||
}, []);
|
||||
|
||||
const isDirty = useMemo(
|
||||
() => draft !== undefined && (baseline === undefined || !equalsRef.current(draft, baseline)),
|
||||
[baseline, draft],
|
||||
);
|
||||
|
||||
return { draft, setDraft, isDirty, markSaved };
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
||||
import { createContext, useCallback, useContext, useLayoutEffect, useMemo, useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { theme as antdTheme } from 'antd';
|
||||
import type { ThemeConfig } from 'antd';
|
||||
@@ -13,14 +13,18 @@ function readBool(key: string, fallback: boolean): boolean {
|
||||
}
|
||||
|
||||
function applyDom(isDark: boolean, isUltra: boolean) {
|
||||
document.body.setAttribute('class', isDark ? 'dark' : 'light');
|
||||
document.body.classList.remove('dark', 'light');
|
||||
document.body.classList.add(isDark ? 'dark' : 'light');
|
||||
if (isUltra) {
|
||||
document.documentElement.setAttribute('data-theme', 'ultra-dark');
|
||||
} else {
|
||||
document.documentElement.removeAttribute('data-theme');
|
||||
}
|
||||
const msg = document.getElementById('message');
|
||||
if (msg) msg.className = isDark ? 'dark' : 'light';
|
||||
if (msg) {
|
||||
msg.classList.remove('dark', 'light');
|
||||
msg.classList.add(isDark ? 'dark' : 'light');
|
||||
}
|
||||
}
|
||||
|
||||
// module load so the document is in the right theme before React mounts.
|
||||
@@ -92,9 +96,24 @@ const LIGHT_BUTTON_TOKENS = {
|
||||
colorPrimaryActive: '#073ea8',
|
||||
};
|
||||
|
||||
// hashed:false drops the `:where(.css-<hash>)` wrapper antd puts around every
|
||||
// rule. It costs nothing in specificity — `:where()` contributes zero, so the
|
||||
// panel's own `.ant-*` overrides still win — and it removes roughly 5,700
|
||||
// wrappers, 16% of the generated stylesheet, from what the browser has to parse.
|
||||
//
|
||||
// cssVar.key pins the CSS-variable scope. Every panel page mounts its own
|
||||
// ConfigProvider (there is no root one), and without a fixed key each mints a
|
||||
// fresh useId-derived scope, so navigating re-serialises and re-injects the whole
|
||||
// token block under a new class instead of reusing the one already in the head.
|
||||
const SHARED_STYLE_CONFIG = {
|
||||
hashed: false,
|
||||
cssVar: { key: 'xui' },
|
||||
} as const;
|
||||
|
||||
export function buildAntdThemeConfig(isDark: boolean, isUltra: boolean): ThemeConfig {
|
||||
if (!isDark) {
|
||||
return {
|
||||
...SHARED_STYLE_CONFIG,
|
||||
algorithm: antdTheme.defaultAlgorithm,
|
||||
token: LIGHT_CONTRAST_TOKENS,
|
||||
components: {
|
||||
@@ -104,6 +123,7 @@ export function buildAntdThemeConfig(isDark: boolean, isUltra: boolean): ThemeCo
|
||||
};
|
||||
}
|
||||
return {
|
||||
...SHARED_STYLE_CONFIG,
|
||||
algorithm: antdTheme.darkAlgorithm,
|
||||
token: isUltra ? ULTRA_DARK_TOKENS : DARK_TOKENS,
|
||||
components: {
|
||||
@@ -142,7 +162,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
const [isDark, setIsDark] = useState<boolean>(initialDark);
|
||||
const [isUltra, setIsUltra] = useState<boolean>(initialUltra);
|
||||
|
||||
useEffect(() => {
|
||||
useLayoutEffect(() => {
|
||||
applyDom(isDark, isUltra);
|
||||
localStorage.setItem(STORAGE_DARK, String(isDark));
|
||||
localStorage.setItem(STORAGE_ULTRA, String(isUltra));
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
type OutboundTrafficRow,
|
||||
} from '@/schemas/xray';
|
||||
|
||||
const DIRTY_POLL_MS = 1000;
|
||||
const DEFAULT_TEST_URL = 'https://www.google.com/generate_204';
|
||||
// One HTTP-mode batch request tests this many outbounds through a single
|
||||
// shared temp xray instance; chunking keeps responses bounded (~30s worst
|
||||
@@ -22,6 +21,10 @@ const DEFAULT_TEST_URL = 'https://www.google.com/generate_204';
|
||||
// results progressively.
|
||||
const HTTP_BATCH_CHUNK = 16;
|
||||
|
||||
function normalizeOutboundTestUrl(url: string) {
|
||||
return url || DEFAULT_TEST_URL;
|
||||
}
|
||||
|
||||
export function isUdpOutbound(outbound: unknown): boolean {
|
||||
const o = outbound as { protocol?: string; streamSettings?: { network?: string } } | null | undefined;
|
||||
const p = o?.protocol;
|
||||
@@ -125,10 +128,11 @@ export function useXraySetting(): UseXraySettingResult {
|
||||
staleTime: Infinity,
|
||||
});
|
||||
|
||||
const [saveDisabled, setSaveDisabled] = useState(true);
|
||||
const [xraySetting, setXraySettingState] = useState('');
|
||||
const [templateSettings, setTemplateSettingsState] = useState<XraySettingsValue | null>(null);
|
||||
const [outboundTestUrl, setOutboundTestUrlState] = useState(DEFAULT_TEST_URL);
|
||||
const [savedXraySetting, setSavedXraySetting] = useState('');
|
||||
const [savedOutboundTestUrl, setSavedOutboundTestUrl] = useState(DEFAULT_TEST_URL);
|
||||
const [inboundTags, setInboundTags] = useState<string[]>([]);
|
||||
const [clientReverseTags, setClientReverseTags] = useState<string[]>([]);
|
||||
const [subscriptionOutbounds, setSubscriptionOutbounds] = useState<unknown[]>([]);
|
||||
@@ -139,38 +143,40 @@ export function useXraySetting(): UseXraySettingResult {
|
||||
const [subscriptionTestStates, setSubscriptionTestStates] = useState<Record<string, OutboundTestState>>({});
|
||||
const [testingAll, setTestingAll] = useState(false);
|
||||
|
||||
const oldXraySettingRef = useRef('');
|
||||
const oldOutboundTestUrlRef = useRef('');
|
||||
const syncingRef = useRef(false);
|
||||
const xraySettingRef = useRef('');
|
||||
const outboundTestUrlRef = useRef(outboundTestUrl);
|
||||
const savedXraySettingRef = useRef(savedXraySetting);
|
||||
const savedOutboundTestUrlRef = useRef(savedOutboundTestUrl);
|
||||
const templateSettingsRef = useRef<XraySettingsValue | null>(null);
|
||||
const subscriptionOutboundsRef = useRef<unknown[]>([]);
|
||||
|
||||
xraySettingRef.current = xraySetting;
|
||||
outboundTestUrlRef.current = outboundTestUrl;
|
||||
savedXraySettingRef.current = savedXraySetting;
|
||||
savedOutboundTestUrlRef.current = savedOutboundTestUrl;
|
||||
templateSettingsRef.current = templateSettings;
|
||||
subscriptionOutboundsRef.current = subscriptionOutbounds;
|
||||
|
||||
// Seed local editor state from the config query. Runs on first fetch and
|
||||
// every time the query refetches (e.g. after a successful save).
|
||||
useEffect(() => {
|
||||
if (!configQuery.data) return;
|
||||
const obj = configQuery.data;
|
||||
const pretty = JSON.stringify(obj.xraySetting, null, 2);
|
||||
syncingRef.current = true;
|
||||
setXraySettingState(pretty);
|
||||
setTemplateSettingsState(obj.xraySetting);
|
||||
oldXraySettingRef.current = pretty;
|
||||
syncingRef.current = false;
|
||||
const nextUrl = normalizeOutboundTestUrl(obj.outboundTestUrl || '');
|
||||
setInboundTags(obj.inboundTags || []);
|
||||
setClientReverseTags(obj.clientReverseTags || []);
|
||||
setSubscriptionOutbounds(obj.subscriptionOutbounds || []);
|
||||
setSubscriptionOutboundTags(obj.subscriptionOutboundTags || []);
|
||||
const nextUrl = obj.outboundTestUrl || DEFAULT_TEST_URL;
|
||||
const isDirty = savedXraySettingRef.current !== xraySettingRef.current
|
||||
|| savedOutboundTestUrlRef.current !== normalizeOutboundTestUrl(outboundTestUrlRef.current);
|
||||
if (isDirty) return;
|
||||
syncingRef.current = true;
|
||||
setXraySettingState(pretty);
|
||||
setTemplateSettingsState(obj.xraySetting);
|
||||
setSavedXraySetting(pretty);
|
||||
syncingRef.current = false;
|
||||
setOutboundTestUrlState(nextUrl);
|
||||
oldOutboundTestUrlRef.current = nextUrl;
|
||||
setSaveDisabled(true);
|
||||
setSavedOutboundTestUrl(nextUrl);
|
||||
}, [configQuery.data]);
|
||||
|
||||
const fetched = configQuery.data !== undefined || configQuery.isError;
|
||||
@@ -220,7 +226,7 @@ export function useXraySetting(): UseXraySettingResult {
|
||||
const saveMut = useMutation({
|
||||
mutationFn: async () => {
|
||||
const sentXraySetting = xraySettingRef.current;
|
||||
const sentTestUrl = outboundTestUrlRef.current || DEFAULT_TEST_URL;
|
||||
const sentTestUrl = normalizeOutboundTestUrl(outboundTestUrlRef.current);
|
||||
const msg = await HttpUtil.post('/panel/api/xray/update', {
|
||||
xraySetting: sentXraySetting,
|
||||
outboundTestUrl: sentTestUrl,
|
||||
@@ -229,9 +235,8 @@ export function useXraySetting(): UseXraySettingResult {
|
||||
},
|
||||
onSuccess: ({ msg, sentXraySetting, sentTestUrl }) => {
|
||||
if (!msg?.success) return;
|
||||
oldXraySettingRef.current = sentXraySetting;
|
||||
oldOutboundTestUrlRef.current = sentTestUrl;
|
||||
setSaveDisabled(true);
|
||||
setSavedXraySetting(sentXraySetting);
|
||||
setSavedOutboundTestUrl(sentTestUrl);
|
||||
queryClient.invalidateQueries({ queryKey: keys.xray.config() });
|
||||
},
|
||||
});
|
||||
@@ -425,14 +430,8 @@ export function useXraySetting(): UseXraySettingResult {
|
||||
}
|
||||
}, [testingAll, testOutbound, testSubscriptionOutbound, postOutboundTestBatch]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => {
|
||||
const dirtyXray = oldXraySettingRef.current !== xraySettingRef.current;
|
||||
const dirtyUrl = oldOutboundTestUrlRef.current !== outboundTestUrlRef.current;
|
||||
setSaveDisabled(!(dirtyXray || dirtyUrl));
|
||||
}, DIRTY_POLL_MS);
|
||||
return () => window.clearInterval(timer);
|
||||
}, []);
|
||||
const saveDisabled = savedXraySetting === xraySetting
|
||||
&& savedOutboundTestUrl === normalizeOutboundTestUrl(outboundTestUrl);
|
||||
|
||||
const outboundsTraffic = useMemo(() => trafficQuery.data ?? [], [trafficQuery.data]);
|
||||
|
||||
|
||||
@@ -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:not(.sidebar-pinned) > .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,17 +50,21 @@
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.sider-brand-collapsed .brand-block {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.brand-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
gap: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.brand-actions .sidebar-pin,
|
||||
.brand-actions .sidebar-docs,
|
||||
.brand-actions .sidebar-donate,
|
||||
.brand-actions .sidebar-theme-cycle {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
}
|
||||
|
||||
.sidebar-donate {
|
||||
background: transparent;
|
||||
border: none;
|
||||
@@ -221,6 +238,34 @@
|
||||
padding: 8px 8px 12px;
|
||||
}
|
||||
|
||||
.sidebar-pin {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
color: var(--ant-color-text-secondary);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: background-color 0.2s, transform 0.15s, color 0.2s;
|
||||
}
|
||||
|
||||
.sidebar-pin:hover,
|
||||
.sidebar-pin:focus-visible {
|
||||
background-color: color-mix(in srgb, var(--ant-color-primary) 10%, transparent);
|
||||
color: var(--ant-color-primary);
|
||||
transform: scale(1.08);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.sidebar-pin .anticon {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.sider-version {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -236,6 +281,11 @@
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.ant-layout-sider-collapsed .sider-version {
|
||||
justify-content: center;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.sider-version .anticon {
|
||||
font-size: 16px;
|
||||
}
|
||||
@@ -246,11 +296,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 +306,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 +316,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';
|
||||
@@ -23,6 +23,8 @@ import {
|
||||
MessageOutlined,
|
||||
MoonFilled,
|
||||
MoonOutlined,
|
||||
PushpinFilled,
|
||||
PushpinOutlined,
|
||||
ReadOutlined,
|
||||
SafetyOutlined,
|
||||
SettingOutlined,
|
||||
@@ -39,11 +41,15 @@ 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/Kuzz007/3x-ui';
|
||||
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';
|
||||
|
||||
@@ -62,14 +68,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 +106,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}
|
||||
>
|
||||
@@ -140,6 +138,20 @@ function ThemeCycleButton({ id, isDark, isUltra, onCycle, ariaLabel }: {
|
||||
);
|
||||
}
|
||||
|
||||
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();
|
||||
@@ -148,8 +160,34 @@ 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 [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 || '';
|
||||
@@ -218,7 +256,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 +273,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,21 +287,35 @@ export default function AppSidebar() {
|
||||
}, [isDark, isUltra, toggleTheme, toggleUltra]);
|
||||
|
||||
return (
|
||||
<div className="ant-sidebar">
|
||||
<div
|
||||
ref={rootRef}
|
||||
className={`ant-sidebar${pinned ? ' sidebar-pinned' : ''}`}
|
||||
style={railStyle}
|
||||
onMouseEnter={() => updateHovered(true)}
|
||||
onMouseLeave={() => updateHovered(false)}
|
||||
>
|
||||
<Layout.Sider
|
||||
theme={currentTheme}
|
||||
width={220}
|
||||
collapsible
|
||||
collapsed={collapsed}
|
||||
breakpoint="md"
|
||||
onCollapse={onSiderCollapse}
|
||||
width={SIDER_WIDTH}
|
||||
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">
|
||||
<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
|
||||
@@ -287,7 +332,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 +347,7 @@ export default function AppSidebar() {
|
||||
onClick={onMenuClick}
|
||||
/>
|
||||
<div className="sider-footer">
|
||||
<VersionBadge version={panelVersion} collapsed={collapsed} />
|
||||
<VersionBadge version={panelVersion} collapsed={railCollapsed} />
|
||||
</div>
|
||||
</Layout.Sider>
|
||||
|
||||
|
||||
@@ -82,12 +82,43 @@ function defaultTcpMaskSettings(type: string): Record<string, unknown> {
|
||||
case 'header-custom':
|
||||
return { clients: [], servers: [] };
|
||||
case 'xmc':
|
||||
return { hostname: '', usernames: [], password: RandomUtil.randomLowerAndNum(16) };
|
||||
return { hostname: '', profiles: [defaultXmcProfile()], password: RandomUtil.randomLowerAndNum(16) };
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function defaultXmcProfile(): Record<string, unknown> {
|
||||
return { username: '', uuid: '', texturesValue: '', texturesSignature: '' };
|
||||
}
|
||||
|
||||
// xray-core #6487 replaced the xmc mask's `usernames` string list with
|
||||
// `profiles` objects carrying a Mojang-signed session profile, and dropped the
|
||||
// "default to Dream" fallback so at least one complete profile is now
|
||||
// mandatory. The signature can only come from Mojang's session server, so a
|
||||
// legacy username cannot be upgraded automatically — carry it into a profile
|
||||
// stub instead, which keeps the operator's player names visible and leaves the
|
||||
// per-field validators pointing at exactly what still has to be filled in.
|
||||
export function migrateXmcSettings(settings: Record<string, unknown>): { next: Record<string, unknown>; changed: boolean } {
|
||||
const out: Record<string, unknown> = { ...settings };
|
||||
let changed = false;
|
||||
if (!Array.isArray(out.profiles) && Array.isArray(out.usernames)) {
|
||||
out.profiles = out.usernames
|
||||
.filter((name): name is string => typeof name === 'string' && name.trim() !== '')
|
||||
.map((name) => ({ ...defaultXmcProfile(), username: name }));
|
||||
changed = true;
|
||||
}
|
||||
if ('usernames' in out) {
|
||||
delete out.usernames;
|
||||
changed = true;
|
||||
}
|
||||
if (!Array.isArray(out.profiles)) {
|
||||
out.profiles = [];
|
||||
changed = true;
|
||||
}
|
||||
return { next: out, changed };
|
||||
}
|
||||
|
||||
// xray-core #6334 replaced a fragment mask's single `length`/`delay` ranges
|
||||
// with `lengths`/`delays` arrays (the singular keys remain in core only as a
|
||||
// fallback). Lift any legacy singular value into a one-element array so the
|
||||
@@ -171,8 +202,8 @@ function defaultUdpHop(): Record<string, unknown> {
|
||||
export default function FinalMaskForm({ name, network, protocol, form, showAll = false }: FinalMaskFormProps) {
|
||||
const base = asPath(name);
|
||||
|
||||
// Migrate legacy single-range fragment masks to the per-segment arrays once
|
||||
// on mount so configs saved before #6334 render in the list UI.
|
||||
// Migrate legacy TCP mask shapes once on mount so configs saved before
|
||||
// #6334 (fragment ranges) and #6487 (xmc profiles) render in the list UI.
|
||||
const migratedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (migratedRef.current) return;
|
||||
@@ -183,8 +214,12 @@ export default function FinalMaskForm({ name, network, protocol, form, showAll =
|
||||
const next = tcp.map((mask) => {
|
||||
if (!mask || typeof mask !== 'object') return mask;
|
||||
const m = mask as Record<string, unknown>;
|
||||
if (m.type !== 'fragment' || !m.settings || typeof m.settings !== 'object') return mask;
|
||||
const { next: migrated, changed } = migrateFragmentSettings(m.settings as Record<string, unknown>);
|
||||
if (m.type !== 'fragment' && m.type !== 'xmc') return mask;
|
||||
if (!m.settings || typeof m.settings !== 'object') return mask;
|
||||
const settings = m.settings as Record<string, unknown>;
|
||||
const { next: migrated, changed } = m.type === 'fragment'
|
||||
? migrateFragmentSettings(settings)
|
||||
: migrateXmcSettings(settings);
|
||||
if (!changed) return mask;
|
||||
anyChanged = true;
|
||||
return { ...m, settings: migrated };
|
||||
@@ -380,13 +415,7 @@ function TcpMaskItem({
|
||||
<Form.Item label="Hostname" name={[fieldName, 'settings', 'hostname']}>
|
||||
<Input placeholder="Server address mimicked in the handshake" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="Usernames"
|
||||
name={[fieldName, 'settings', 'usernames']}
|
||||
extra="Player names offered to probes; core defaults to Dream when empty."
|
||||
>
|
||||
<Select mode="tags" style={{ width: '100%' }} tokenSeparators={[',']} />
|
||||
</Form.Item>
|
||||
<XmcProfilesList tcpFieldName={fieldName} />
|
||||
<Form.Item label="Password" required>
|
||||
<Space.Compact block>
|
||||
<Form.Item
|
||||
@@ -528,6 +557,92 @@ function getDeep(obj: unknown, path: (string | number)[]): unknown {
|
||||
return cur;
|
||||
}
|
||||
|
||||
// Mojang hands the profile UUID back undashed from the session server and
|
||||
// dashed from most other endpoints; xray-core parses either, so accept both
|
||||
// rather than forcing the operator to reformat what they pasted.
|
||||
const XMC_UUID_PATTERN = /^(?:[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}|[0-9a-fA-F]{32})$/;
|
||||
const XMC_USERNAME_PATTERN = /^[A-Za-z0-9_]{3,16}$/;
|
||||
|
||||
function validateXmcUsername(_rule: unknown, value: unknown): Promise<void> {
|
||||
if (typeof value === 'string' && XMC_USERNAME_PATTERN.test(value)) return Promise.resolve();
|
||||
return Promise.reject(new Error('3-16 characters, letters/digits/underscore only'));
|
||||
}
|
||||
|
||||
function validateXmcUuid(_rule: unknown, value: unknown): Promise<void> {
|
||||
if (typeof value === 'string' && XMC_UUID_PATTERN.test(value.trim())) return Promise.resolve();
|
||||
return Promise.reject(new Error('Enter the profile UUID (dashed or 32 hex characters)'));
|
||||
}
|
||||
|
||||
// Each mask needs at least one fully signed profile since xray-core #6487 —
|
||||
// an empty or partial list makes the core reject the whole config, so the
|
||||
// panel blocks the save here rather than letting the backend drop the mask.
|
||||
function XmcProfilesList({ tcpFieldName }: { tcpFieldName: number }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Form.List name={[tcpFieldName, 'settings', 'profiles']}>
|
||||
{(profiles, { add, remove }) => (
|
||||
<>
|
||||
<Form.Item
|
||||
label="Profiles"
|
||||
extra="Signed Minecraft session profiles; resolve the UUID by username, then fetch the profile with unsigned=false."
|
||||
>
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={<PlusOutlined />}
|
||||
aria-label={t('add')}
|
||||
onClick={() => add(defaultXmcProfile())}
|
||||
/>
|
||||
</Form.Item>
|
||||
{profiles.map((profile, idx) => (
|
||||
<div key={profile.key}>
|
||||
<Divider style={{ margin: 0 }}>
|
||||
Profile {idx + 1}
|
||||
<DeleteOutlined
|
||||
className="danger-icon"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={t('remove')}
|
||||
onClick={() => remove(profile.name)}
|
||||
onKeyDown={activateOnKey(() => remove(profile.name))}
|
||||
/>
|
||||
</Divider>
|
||||
<Form.Item
|
||||
label="Username"
|
||||
name={[profile.name, 'username']}
|
||||
rules={[{ validator: validateXmcUsername }]}
|
||||
>
|
||||
<Input placeholder="Notch" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="UUID"
|
||||
name={[profile.name, 'uuid']}
|
||||
rules={[{ validator: validateXmcUuid }]}
|
||||
>
|
||||
<Input placeholder="069a79f4-44e9-4726-a5be-fca90e38aaf5" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="Textures Value"
|
||||
name={[profile.name, 'texturesValue']}
|
||||
rules={[{ required: true, message: 'Textures value is required' }]}
|
||||
>
|
||||
<Input.TextArea autoSize={{ minRows: 2, maxRows: 4 }} placeholder="Base64 value from the session profile" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="Textures Signature"
|
||||
name={[profile.name, 'texturesSignature']}
|
||||
rules={[{ required: true, message: 'Textures signature is required' }]}
|
||||
>
|
||||
<Input.TextArea autoSize={{ minRows: 2, maxRows: 4 }} placeholder="Base64 signature from the session profile" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</Form.List>
|
||||
);
|
||||
}
|
||||
|
||||
function HeaderCustomGroups({
|
||||
tcpFieldName, form, absoluteSettingsPath,
|
||||
}: {
|
||||
@@ -1031,12 +1146,18 @@ function ItemEditor({
|
||||
onRemove?: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
/**
|
||||
* Switching to `array` clears the packet instead of emptying it to `[]`:
|
||||
* that branch is rand-driven, and xray-core counts even an empty array as a
|
||||
* packet, rejecting an item that carries both a packet and a rand. That
|
||||
* error fails the whole config, so one such item keeps every inbound offline.
|
||||
*/
|
||||
const onTypeChange = (v: string) => {
|
||||
if (v === 'base64') {
|
||||
form.setFieldValue([...absoluteItemPath, 'packet'], RandomUtil.randomBase64());
|
||||
} else if (v === 'array') {
|
||||
form.setFieldValue([...absoluteItemPath, 'rand'], delayMode === 'string' ? '1-8192' : 0);
|
||||
form.setFieldValue([...absoluteItemPath, 'packet'], []);
|
||||
form.setFieldValue([...absoluteItemPath, 'packet'], undefined);
|
||||
} else {
|
||||
form.setFieldValue([...absoluteItemPath, 'packet'], '');
|
||||
}
|
||||
|
||||
@@ -175,7 +175,7 @@ export function createDefaultShadowsocksInboundSettings(
|
||||
// constructor — the field discriminates v1 vs v2 inside the same settings
|
||||
// shape. Callers that explicitly want v1 pass `{ version: 1 }`.
|
||||
export interface HysteriaInboundSeed {
|
||||
version?: number;
|
||||
version?: 2;
|
||||
}
|
||||
|
||||
export function createDefaultHysteriaInboundSettings(
|
||||
|
||||
@@ -42,6 +42,7 @@ export interface RawInboundRow {
|
||||
enable?: boolean;
|
||||
expiryTime?: number;
|
||||
trafficReset?: string;
|
||||
trafficResetDay?: number;
|
||||
lastTrafficResetTime?: number;
|
||||
nodeId?: number | null;
|
||||
shareAddrStrategy?: string;
|
||||
@@ -61,6 +62,7 @@ export interface WireInboundPayload {
|
||||
enable: boolean;
|
||||
expiryTime: number;
|
||||
trafficReset: TrafficReset;
|
||||
trafficResetDay: number;
|
||||
lastTrafficResetTime: number;
|
||||
listen: string;
|
||||
port: number;
|
||||
@@ -203,6 +205,7 @@ export function rawInboundToFormValues(row: RawInboundRow): InboundFormValues {
|
||||
down: row.down ?? 0,
|
||||
total: row.total ?? 0,
|
||||
trafficReset: coerceTrafficReset(row.trafficReset),
|
||||
trafficResetDay: Math.min(31, Math.max(1, row.trafficResetDay ?? 1)),
|
||||
lastTrafficResetTime: row.lastTrafficResetTime ?? 0,
|
||||
nodeId: row.nodeId ?? null,
|
||||
shareAddrStrategy: coerceShareAddrStrategy(row.shareAddrStrategy),
|
||||
@@ -346,6 +349,7 @@ export function formValuesToWirePayload(values: InboundFormValues): WireInboundP
|
||||
enable: values.enable,
|
||||
expiryTime: values.expiryTime,
|
||||
trafficReset: values.trafficReset,
|
||||
trafficResetDay: values.trafficResetDay,
|
||||
lastTrafficResetTime: values.lastTrafficResetTime,
|
||||
listen: values.listen,
|
||||
port: values.port,
|
||||
|
||||
@@ -43,8 +43,7 @@ function xhttpHostFallback(xhttp: XHttpStreamSettings | undefined): string {
|
||||
// Pull the bidirectional SplitHTTPConfig fields out of xhttp into a
|
||||
// compact extra payload. Server-only fields (noSSEHeader, scMaxBufferedPosts,
|
||||
// scStreamUpServerSecs, serverMaxHeaderBytes) are excluded — the client
|
||||
// reading the share link wouldn't honor them. Mirrors the legacy
|
||||
// Inbound.buildXhttpExtra exactly so the shadow link snapshots line up.
|
||||
// reading the share link wouldn't honor them.
|
||||
function buildXhttpExtra(xhttp: XHttpStreamSettings | undefined): Record<string, unknown> | null {
|
||||
if (!xhttp) return null;
|
||||
const extra: Record<string, unknown> = {};
|
||||
@@ -86,6 +85,15 @@ function buildXhttpExtra(xhttp: XHttpStreamSettings | undefined): Record<string,
|
||||
const v = xhttp[k];
|
||||
if (typeof v === 'string' && v.length > 0 && v !== coreDefaults[k]) extra[k] = v;
|
||||
}
|
||||
// xray-core #6258 renamed these fields, but older clients still read the
|
||||
// legacy names from share-link extra. Emit both names so one link works
|
||||
// across old and new clients while the stored panel config stays canonical.
|
||||
if (typeof extra.sessionIDPlacement === 'string') {
|
||||
extra.sessionPlacement = extra.sessionIDPlacement;
|
||||
}
|
||||
if (typeof extra.sessionIDKey === 'string') {
|
||||
extra.sessionKey = extra.sessionIDKey;
|
||||
}
|
||||
|
||||
// Headers on the wire are a record; emit them as a map upstream's
|
||||
// SplitHTTPConfig.headers expects, dropping Host (already on the URL).
|
||||
@@ -705,11 +713,12 @@ function hysteriaPinHex(pin: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
// Hysteria share link: hysteria://<auth>@<host>:<port>?<query>#<remark>.
|
||||
// The URL scheme is "hysteria2" when settings.version === 2 (hysteria v2
|
||||
// AKA hysteria2), "hysteria" otherwise. Salamander obfuscation pulls its
|
||||
// password from finalmask.udp[type=salamander] when present; the broader
|
||||
// finalmask payload still rides under `fm` like the other links.
|
||||
// Hysteria share link: hysteria2://<auth>@<host>:<port>?<query>#<remark>.
|
||||
// The scheme is always hysteria2 — xray-core builds version 2 only, so the
|
||||
// settings schema pins it there and the subscription server emits the same
|
||||
// scheme. Salamander obfuscation pulls its password from
|
||||
// finalmask.udp[type=salamander] when present; the broader finalmask payload
|
||||
// still rides under `fm` like the other links.
|
||||
//
|
||||
// Note: legacy genHysteriaLink reads stream.tls.settings.allowInsecure,
|
||||
// which isn't a field on TlsStreamSettings.Settings — the guard is always
|
||||
@@ -728,8 +737,7 @@ export function genHysteriaLink(input: GenHysteriaLinkInput): string {
|
||||
const stream = inbound.streamSettings;
|
||||
if (!stream || stream.security !== 'tls') return '';
|
||||
|
||||
const settings = inbound.settings;
|
||||
const scheme = settings.version === 2 ? 'hysteria2' : 'hysteria';
|
||||
const scheme = 'hysteria2';
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.set('security', 'tls');
|
||||
|
||||
@@ -104,6 +104,63 @@ export function validateRealityTarget(target: string): string | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a REALITY client-version string the way xray-core's config loader
|
||||
* does: one to three dot-separated numeric parts, each 0-255. Returns the
|
||||
* parts padded to three entries, or undefined when the string is not a valid
|
||||
* version.
|
||||
*/
|
||||
export function parseRealityClientVer(value: string): [number, number, number] | undefined {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return undefined;
|
||||
const parts = trimmed.split('.');
|
||||
if (parts.length > 3) return undefined;
|
||||
const nums: number[] = [];
|
||||
for (const part of parts) {
|
||||
if (!/^\d+$/.test(part)) return undefined;
|
||||
const n = Number(part);
|
||||
if (n > 255) return undefined;
|
||||
nums.push(n);
|
||||
}
|
||||
while (nums.length < 3) nums.push(0);
|
||||
return nums as [number, number, number];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a REALITY client-version field; empty means "not set" and is
|
||||
* valid. The value is saved exactly as typed and xray-core's part parser
|
||||
* accepts no surrounding whitespace, so a value that differs from its
|
||||
* trimmed form is rejected rather than silently passed to the wire.
|
||||
*/
|
||||
export function validateRealityClientVer(value: string): string | undefined {
|
||||
if (!value) return undefined;
|
||||
if (value !== value.trim() || !parseRealityClientVer(value)) {
|
||||
return 'pages.inbounds.form.clientVerInvalid';
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the max client-version field: format first, then that a non-empty
|
||||
* max is not below a non-empty min (an inverted range rejects every client).
|
||||
* An empty or malformed min is left to the min field's own validation.
|
||||
*/
|
||||
export function validateRealityMaxClientVer(max: string, min: string): string | undefined {
|
||||
const formatError = validateRealityClientVer(max);
|
||||
if (formatError) return formatError;
|
||||
const maxParts = parseRealityClientVer(max);
|
||||
const minParts = parseRealityClientVer(min);
|
||||
if (!maxParts || !minParts) return undefined;
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if (maxParts[i] !== minParts[i]) {
|
||||
return maxParts[i] < minParts[i]
|
||||
? 'pages.inbounds.form.maxClientVerBelowMin'
|
||||
: undefined;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function liftLegacyXhttpSessionKeys(obj: Record<string, unknown>): void {
|
||||
const lift = (legacy: string, renamed: string) => {
|
||||
const v = obj[legacy];
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { RouterProvider } from 'react-router';
|
||||
import { RouterProvider } from 'react-router/dom';
|
||||
import { message } from 'antd';
|
||||
import 'antd/dist/reset.css';
|
||||
import '@/styles/utils.css';
|
||||
|
||||
@@ -30,6 +30,7 @@ export type DBInboundInit = Partial<{
|
||||
enable: boolean;
|
||||
expiryTime: number;
|
||||
trafficReset: string;
|
||||
trafficResetDay: number;
|
||||
lastTrafficResetTime: number;
|
||||
listen: string;
|
||||
port: number;
|
||||
@@ -76,6 +77,7 @@ export class DBInbound {
|
||||
enable: boolean;
|
||||
expiryTime: number;
|
||||
trafficReset: string;
|
||||
trafficResetDay: number;
|
||||
lastTrafficResetTime: number;
|
||||
|
||||
listen: string;
|
||||
@@ -105,6 +107,7 @@ export class DBInbound {
|
||||
this.enable = true;
|
||||
this.expiryTime = 0;
|
||||
this.trafficReset = "never";
|
||||
this.trafficResetDay = 1;
|
||||
this.lastTrafficResetTime = 0;
|
||||
|
||||
this.listen = "";
|
||||
|
||||
@@ -14,6 +14,7 @@ export class AllSetting {
|
||||
expireDiff = 0;
|
||||
trafficDiff = 0;
|
||||
remarkTemplate = '{{INBOUND}}-{{EMAIL}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D';
|
||||
subShowIdentityOnAllLinks = false;
|
||||
datepicker: 'gregorian' | 'jalalian' = 'gregorian';
|
||||
tgBotEnable = false;
|
||||
tgBotToken = '';
|
||||
@@ -90,7 +91,7 @@ export class AllSetting {
|
||||
ldapDefaultTotalGB = 0;
|
||||
ldapDefaultExpiryDays = 0;
|
||||
ldapDefaultLimitIP = 0;
|
||||
tgEnabledEvents = '';
|
||||
tgEnabledEvents = 'login.attempt,cpu.high';
|
||||
smtpEnable = false;
|
||||
smtpHost = '';
|
||||
smtpPort = 587;
|
||||
@@ -100,7 +101,7 @@ export class AllSetting {
|
||||
smtpFromName = '';
|
||||
smtpTo = '';
|
||||
smtpEncryptionType = 'starttls';
|
||||
smtpEnabledEvents = '';
|
||||
smtpEnabledEvents = 'login.attempt,cpu.high';
|
||||
smtpCpu = 80;
|
||||
smtpMemory = 80;
|
||||
outboundDownThreshold = 3;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -254,6 +254,11 @@ export const sections: readonly Section[] = [
|
||||
description:
|
||||
'System status, log retrieval, certificate generators, Xray binary management, and backup/restore. All under /panel/api/server.',
|
||||
endpoints: [
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/openapi.json',
|
||||
summary: 'Serve this API description as an OpenAPI 3 document — the same file that powers the API Docs page. Requires a session or Bearer token like the rest of /panel/api. Useful for generating clients or importing into API tooling.',
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/server/status',
|
||||
@@ -559,7 +564,7 @@ export const sections: readonly Section[] = [
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/clients/list/paged',
|
||||
summary: 'Filter, sort, and paginate clients on the server. Each item is a slim row (no uuid/password/auth/flow/security/reverse/tgId) so the clients page can ship 25-ish rows in a few KB instead of the full table. The response also includes a summary computed across the full DB row set so dashboard counters stay stable as the user paginates or filters. Page size capped at 200; fetch /get/:email to obtain the full per-client payload for an edit/info modal.',
|
||||
summary: 'Filter, sort, and paginate clients on the server. Each item is a slim row (no uuid/password/auth/flow/security/reverse/tgId) so the clients page can ship 25-ish rows in a few KB instead of the full table. The response also includes a summary computed across the full DB row set so dashboard counters stay stable as the user paginates or filters: the *Count fields are exact, while the email arrays beside them stop at 200 entries so the payload does not grow with the panel. Page size capped at 200; fetch /get/:email to obtain the full per-client payload for an edit/info modal.',
|
||||
params: [
|
||||
{ name: 'page', in: 'query', type: 'number', desc: '1-indexed page number. Defaults to 1.' },
|
||||
{ name: 'pageSize', in: 'query', type: 'number', desc: 'Rows per page. Defaults to 25, capped at 200.' },
|
||||
@@ -570,7 +575,7 @@ export const sections: readonly Section[] = [
|
||||
{ name: 'order', in: 'query', type: 'string', desc: 'ascend or descend.' },
|
||||
],
|
||||
response:
|
||||
'{\n "success": true,\n "obj": {\n "items": [\n {\n "email": "alice@example.com",\n "subId": "abcd1234",\n "enable": true,\n "totalGB": 53687091200,\n "expiryTime": 1735689600000,\n "limitIp": 0,\n "reset": 0,\n "inboundIds": [3, 5],\n "traffic": { "up": 1024, "down": 4096, "enable": true },\n "createdAt": 1735000000000,\n "updatedAt": 1735100000000\n }\n ],\n "total": 2000,\n "filtered": 47,\n "page": 1,\n "pageSize": 25,\n "summary": {\n "total": 2000,\n "active": 1850,\n "online": ["alice@example.com"],\n "depleted": [],\n "expiring": [],\n "deactive": []\n }\n }\n}',
|
||||
'{\n "success": true,\n "obj": {\n "items": [\n {\n "email": "alice@example.com",\n "subId": "abcd1234",\n "enable": true,\n "totalGB": 53687091200,\n "expiryTime": 1735689600000,\n "limitIp": 0,\n "reset": 0,\n "inboundIds": [3, 5],\n "traffic": { "up": 1024, "down": 4096, "enable": true },\n "createdAt": 1735000000000,\n "updatedAt": 1735100000000\n }\n ],\n "total": 2000,\n "filtered": 47,\n "page": 1,\n "pageSize": 25,\n "summary": {\n "total": 2000,\n "active": 1850,\n "onlineCount": 1,\n "depletedCount": 0,\n "expiringCount": 0,\n "deactiveCount": 150,\n "online": ["alice@example.com"],\n "depleted": [],\n "expiring": [],\n "deactive": ["bob@example.com"]\n }\n }\n}',
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
@@ -582,6 +587,16 @@ export const sections: readonly Section[] = [
|
||||
response:
|
||||
'{\n "success": true,\n "obj": {\n "client": { "id": 1, "email": "alice@example.com", ... },\n "inboundIds": [3, 5],\n "externalLinks": [{ "kind": "link", "value": "vless://...", "remark": "DE" }]\n }\n}',
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/clients/get/tgId/:tgId',
|
||||
summary: 'Fetch clients by Telegram user ID. Returns an array since multiple clients can share the same Telegram ID.',
|
||||
params: [
|
||||
{ name: 'tgId', in: 'path', type: 'integer', desc: 'Telegram user ID (numeric).' },
|
||||
],
|
||||
response:
|
||||
'{\n "success": true,\n "obj": [\n {\n "client": { "id": 1, "email": "alice@example.com", ... },\n "inboundIds": [3, 5],\n "externalLinks": [],\n "usedTraffic": 1048576\n }\n ]\n}',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/clients/add',
|
||||
@@ -1154,6 +1169,11 @@ export const sections: readonly Section[] = [
|
||||
path: '/panel/api/setting/defaultSettings',
|
||||
summary: 'Return the computed default settings based on the request host. Useful to preview what a fresh install would use.',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/setting/factoryDefaults',
|
||||
summary: 'Return the shipped (factory) default value per browser-safe setting key, so clients can tell a stored value apart from the default it would fall back to. Per-install material (secret, panelGuid, mTLS keys) and credential fields are never included.',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/setting/update',
|
||||
|
||||
@@ -56,7 +56,7 @@ export default function ClientBulkAddModal({
|
||||
}: ClientBulkAddModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [messageApi, messageContextHolder] = message.useMessage();
|
||||
const { bulkCreate } = useClients();
|
||||
const { bulkCreate } = useClients({ list: false });
|
||||
|
||||
const methods = useForm<ClientBulkAddFormValues>({ defaultValues: EMPTY });
|
||||
const inboundIds = useWatch({ control: methods.control, name: 'inboundIds' });
|
||||
|
||||
@@ -16,6 +16,13 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.client-email-more {
|
||||
margin-top: 4px;
|
||||
padding-top: 4px;
|
||||
border-top: 1px solid var(--ant-color-border-secondary, rgba(128, 128, 128, 0.2));
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { lazy, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { lazy, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Badge,
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
Result,
|
||||
Row,
|
||||
Select,
|
||||
Space,
|
||||
Spin,
|
||||
Statistic,
|
||||
Switch,
|
||||
@@ -80,12 +79,14 @@ const BulkAttachInboundsModal = lazy(() => import('./BulkAttachInboundsModal'));
|
||||
const BulkDetachInboundsModal = lazy(() => import('./BulkDetachInboundsModal'));
|
||||
const TextModal = lazy(() => import('@/components/feedback/TextModal'));
|
||||
const PromptModal = lazy(() => import('@/components/feedback/PromptModal'));
|
||||
import { ClientInboundChips, ClientRowActions } from './RowCells';
|
||||
import { emptyFilters, activeFilterCount } from './filters';
|
||||
import type { ClientFilters } from './filters';
|
||||
import './ClientsPage.css';
|
||||
|
||||
const FILTER_STATE_KEY = 'clientsFilterState';
|
||||
const DISABLED_PAGE_SIZE = 200;
|
||||
const DEFAULT_TABLE_PAGE_SIZE = 25;
|
||||
|
||||
function UngroupIcon() {
|
||||
return (
|
||||
@@ -126,12 +127,29 @@ function UngroupIcon() {
|
||||
);
|
||||
}
|
||||
|
||||
// The server sends exact counters but caps the email arrays behind them, so a
|
||||
// panel with thousands of depleted clients neither ships nor renders them all.
|
||||
// The trailing chip reports what the popover left out.
|
||||
function ClientEmailList({ emails, total }: { emails: string[]; total: number }) {
|
||||
const hidden = total - emails.length;
|
||||
return (
|
||||
<div className="client-email-list">
|
||||
{emails.map((e) => <div key={e}>{e}</div>)}
|
||||
{hidden > 0 && <div className="client-email-more">+{hidden}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type Bucket = 'active' | 'deactive' | 'depleted' | 'expiring';
|
||||
|
||||
interface PersistedFilterState {
|
||||
searchKey: string;
|
||||
filters: ClientFilters;
|
||||
sort: string;
|
||||
// The page size resolved on the previous visit. Without it the first list
|
||||
// request has to wait for /setting/defaultSettings just to learn how many rows
|
||||
// to ask for, which serialises two round trips on every load.
|
||||
pageSize: number | null;
|
||||
}
|
||||
|
||||
const INBOUND_PROTOCOL_COLORS: Record<string, string> = {
|
||||
@@ -148,6 +166,9 @@ const INBOUND_PROTOCOL_COLORS: Record<string, string> = {
|
||||
tunnel: 'orange',
|
||||
};
|
||||
const INBOUND_CHIP_LIMIT = 1;
|
||||
// A shared empty array keeps the memoised chip cell from seeing a fresh prop for
|
||||
// every unattached client on every render.
|
||||
const EMPTY_INBOUND_IDS: number[] = [];
|
||||
|
||||
function readFilterState(): PersistedFilterState {
|
||||
try {
|
||||
@@ -165,9 +186,10 @@ function readFilterState(): PersistedFilterState {
|
||||
groups: Array.isArray(fromRaw.groups) ? fromRaw.groups : [],
|
||||
},
|
||||
sort: typeof raw.sort === 'string' ? raw.sort : '',
|
||||
pageSize: typeof raw.pageSize === 'number' && raw.pageSize > 0 ? raw.pageSize : null,
|
||||
};
|
||||
} catch {
|
||||
return { searchKey: '', filters: emptyFilters(), sort: '' };
|
||||
return { searchKey: '', filters: emptyFilters(), sort: '', pageSize: null };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -206,11 +228,11 @@ export default function ClientsPage() {
|
||||
|
||||
const {
|
||||
clients, total, filtered,
|
||||
summary: serverSummary,
|
||||
summary,
|
||||
allGroups,
|
||||
setQuery,
|
||||
inbounds, onlines, loading, transitioning, fetched, fetchError, subSettings,
|
||||
tgBotEnable, expireDiff, trafficDiff, pageSize,
|
||||
inbounds, onlines, transitioning, fetched, fetchError, subSettings,
|
||||
tgBotEnable, expireDiff, trafficDiff, pageSize, settingsReady,
|
||||
create, update, remove, bulkDelete, bulkAdjust, bulkEnable, bulkDisable, bulkAddToGroup, bulkRemoveFromGroup, attach, setExternalLinks, bulkAttach, detach, bulkDetach,
|
||||
resetTraffic, resetAllTraffics, delDepleted, delOrphans, exportClients, importClients, setEnable,
|
||||
clientSpeed,
|
||||
@@ -266,14 +288,31 @@ export default function ClientsPage() {
|
||||
const [sortColumn, setSortColumn] = useState<string | null>(initialSort.column);
|
||||
const [sortOrder, setSortOrder] = useState<'ascend' | 'descend' | null>(initialSort.order);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [tablePageSize, setTablePageSize] = useState(25);
|
||||
// Derived, not mirrored into state by an effect: an effect lags one render
|
||||
// behind the settings arriving, and that lag is what made the page fetch the
|
||||
// list once with the placeholder size and again with the real one.
|
||||
const [pageSizeChoice, setPageSizeChoice] = useState<number | null>(null);
|
||||
const settingsPageSize = settingsReady ? (pageSize > 0 ? pageSize : DISABLED_PAGE_SIZE) : null;
|
||||
// Last visit's resolved size stands in until the settings land, so the list
|
||||
// request goes out with the page mount instead of queueing behind them. If the
|
||||
// admin has since changed the setting the authoritative value replaces it and
|
||||
// costs one refetch — only on the load that follows the change. Null means
|
||||
// nothing is known yet, which is the one case worth waiting for.
|
||||
const resolvedPageSize = pageSizeChoice ?? settingsPageSize ?? initial.pageSize;
|
||||
const tablePageSize = resolvedPageSize ?? DEFAULT_TABLE_PAGE_SIZE;
|
||||
// debouncedSearch lags behind the input so we don't spam the server on every
|
||||
// keystroke; the search box still feels instant locally.
|
||||
const [debouncedSearch, setDebouncedSearch] = useState(searchKey);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem(FILTER_STATE_KEY, JSON.stringify({ searchKey, filters, sort: sortValueFor(sortColumn, sortOrder) }));
|
||||
}, [searchKey, filters, sortColumn, sortOrder]);
|
||||
localStorage.setItem(FILTER_STATE_KEY, JSON.stringify({
|
||||
searchKey,
|
||||
filters,
|
||||
sort: sortValueFor(sortColumn, sortOrder),
|
||||
// Only ever persist a size we actually resolved, never the render fallback.
|
||||
pageSize: resolvedPageSize,
|
||||
}));
|
||||
}, [searchKey, filters, sortColumn, sortOrder, resolvedPageSize]);
|
||||
|
||||
useEffect(() => {
|
||||
const handle = window.setTimeout(() => setDebouncedSearch(searchKey), 300);
|
||||
@@ -304,6 +343,10 @@ export default function ClientsPage() {
|
||||
}, [filters.nodeIds, filters.inboundIds, inbounds]);
|
||||
|
||||
useEffect(() => {
|
||||
// With no remembered size and no settings yet, any query we build would be a
|
||||
// guess, and issuing it costs a full server round trip that is thrown away as
|
||||
// soon as the real size arrives.
|
||||
if (resolvedPageSize === null) return;
|
||||
setQuery({
|
||||
page: currentPage,
|
||||
pageSize: tablePageSize,
|
||||
@@ -322,13 +365,21 @@ export default function ClientsPage() {
|
||||
sort: sortColumn || undefined,
|
||||
order: sortOrder || undefined,
|
||||
});
|
||||
}, [setQuery, currentPage, tablePageSize, debouncedSearch, filters, effectiveInboundCsv, sortColumn, sortOrder]);
|
||||
}, [setQuery, resolvedPageSize, currentPage, tablePageSize, debouncedSearch, filters, effectiveInboundCsv, sortColumn, sortOrder]);
|
||||
|
||||
const activeCount = activeFilterCount(filters);
|
||||
|
||||
useEffect(() => {
|
||||
setTablePageSize(pageSize > 0 ? pageSize : DISABLED_PAGE_SIZE);
|
||||
}, [pageSize]);
|
||||
// Row handlers take an email and look the row up here at call time. Keying
|
||||
// them on the record object instead would defeat the memoised cells: every
|
||||
// traffic push replaces the row object of every client whose counters moved,
|
||||
// so the memo would miss on exactly the rows that are busy. Reading through
|
||||
// the ref also means a modal opened mid-poll shows current usage.
|
||||
const rowsByEmail = useRef(new Map<string, ClientRecord>());
|
||||
rowsByEmail.current = useMemo(() => {
|
||||
const map = new Map<string, ClientRecord>();
|
||||
for (const c of clients) map.set(c.email, c);
|
||||
return map;
|
||||
}, [clients]);
|
||||
|
||||
const onlineSet = useMemo(() => new Set(onlines || []), [onlines]);
|
||||
const inboundsById = useMemo(() => {
|
||||
@@ -386,9 +437,6 @@ export default function ClientsPage() {
|
||||
// a rename.
|
||||
const filteredClients = clients;
|
||||
|
||||
// Server-computed counts that stay stable as the user paginates/filters.
|
||||
const summary = serverSummary;
|
||||
|
||||
// Sort is server-side now; the page already arrives in the requested
|
||||
// order, so we just hand it through.
|
||||
const sortedClients = filteredClients;
|
||||
@@ -458,7 +506,9 @@ export default function ClientsPage() {
|
||||
setFormOpen(true);
|
||||
}
|
||||
|
||||
async function onEdit(row: ClientRecord) {
|
||||
const onEdit = useCallback(async (email: string) => {
|
||||
const row = rowsByEmail.current.get(email);
|
||||
if (!row) return;
|
||||
setFormMode('edit');
|
||||
// Paged list omits per-client secrets to keep the row payload tiny;
|
||||
// edit needs them, so fetch the full record first.
|
||||
@@ -469,9 +519,11 @@ export default function ClientsPage() {
|
||||
setEditingAttachedIds([...ids]);
|
||||
setEditingExternalLinks(Array.isArray(full?.externalLinks) ? [...full.externalLinks] : []);
|
||||
setFormOpen(true);
|
||||
}
|
||||
}, [hydrate]);
|
||||
|
||||
function onDelete(row: ClientRecord) {
|
||||
const onDelete = useCallback((email: string) => {
|
||||
const row = rowsByEmail.current.get(email);
|
||||
if (!row) return;
|
||||
modal.confirm({
|
||||
title: t('pages.clients.deleteConfirmTitle', { email: row.email }),
|
||||
content: t('pages.clients.deleteConfirmContent'),
|
||||
@@ -483,9 +535,10 @@ export default function ClientsPage() {
|
||||
if (msg?.success) messageApi.success(t('pages.clients.toasts.deleted'));
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [modal, t, remove, messageApi]);
|
||||
|
||||
function onResetTraffic(row: ClientRecord) {
|
||||
const onResetTraffic = useCallback((email: string) => {
|
||||
const row = rowsByEmail.current.get(email);
|
||||
if (!row?.email) {
|
||||
messageApi.warning(t('pages.clients.resetNotPossible'));
|
||||
return;
|
||||
@@ -500,19 +553,33 @@ export default function ClientsPage() {
|
||||
if (msg?.success) messageApi.success(t('pages.clients.toasts.trafficReset'));
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [modal, t, resetTraffic, messageApi]);
|
||||
|
||||
async function onShowInfo(row: ClientRecord) {
|
||||
const onShowInfo = useCallback(async (email: string) => {
|
||||
const row = rowsByEmail.current.get(email);
|
||||
if (!row) return;
|
||||
const full = await hydrate(row.email);
|
||||
setInfoClient(full ? { ...row, ...full.client, inboundIds: full.inboundIds } : row);
|
||||
setInfoOpen(true);
|
||||
}
|
||||
}, [hydrate]);
|
||||
|
||||
async function onShowQr(row: ClientRecord) {
|
||||
const onShowQr = useCallback(async (email: string) => {
|
||||
const row = rowsByEmail.current.get(email);
|
||||
if (!row) return;
|
||||
const full = await hydrate(row.email);
|
||||
setQrClient(full ? { ...row, ...full.client, inboundIds: full.inboundIds } : row);
|
||||
setQrOpen(true);
|
||||
}
|
||||
}, [hydrate]);
|
||||
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const onRefreshClick = useCallback(async () => {
|
||||
setRefreshing(true);
|
||||
try {
|
||||
await refresh();
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
}
|
||||
}, [refresh]);
|
||||
|
||||
const openText = useCallback((opts: { title: string; content: string; fileName?: string }) => {
|
||||
setTextTitle(opts.title);
|
||||
@@ -747,7 +814,7 @@ export default function ClientsPage() {
|
||||
|
||||
const onTableChange: NonNullable<TableProps<ClientRecord>['onChange']> = (pag) => {
|
||||
if (pag?.current) setCurrentPage(pag.current);
|
||||
if (pag?.pageSize) setTablePageSize(pag.pageSize);
|
||||
if (pag?.pageSize) setPageSizeChoice(pag.pageSize);
|
||||
};
|
||||
|
||||
const columns = useMemo<ColumnsType<ClientRecord>>(() => [
|
||||
@@ -756,23 +823,14 @@ export default function ClientsPage() {
|
||||
key: 'actions',
|
||||
width: 200,
|
||||
render: (_v, record) => (
|
||||
<Space size={4}>
|
||||
<Tooltip title={t('pages.clients.qrCode')}>
|
||||
<Button size="small" type="text" style={{ fontSize: 16 }} icon={<QrcodeOutlined />} aria-label={t('pages.clients.qrCode')} onClick={() => onShowQr(record)} />
|
||||
</Tooltip>
|
||||
<Tooltip title={t('pages.clients.clientInfo')}>
|
||||
<Button size="small" type="text" style={{ fontSize: 16 }} icon={<InfoCircleOutlined />} aria-label={t('pages.clients.clientInfo')} onClick={() => onShowInfo(record)} />
|
||||
</Tooltip>
|
||||
<Tooltip title={t('pages.inbounds.resetTraffic')}>
|
||||
<Button size="small" type="text" style={{ fontSize: 16 }} icon={<RetweetOutlined />} aria-label={t('pages.inbounds.resetTraffic')} onClick={() => onResetTraffic(record)} />
|
||||
</Tooltip>
|
||||
<Tooltip title={t('edit')}>
|
||||
<Button size="small" type="text" style={{ fontSize: 16 }} icon={<EditOutlined />} aria-label={t('edit')} onClick={() => onEdit(record)} />
|
||||
</Tooltip>
|
||||
<Tooltip title={t('delete')}>
|
||||
<Button size="small" type="text" danger style={{ fontSize: 16 }} icon={<DeleteOutlined />} aria-label={t('delete')} onClick={() => onDelete(record)} />
|
||||
</Tooltip>
|
||||
</Space>
|
||||
<ClientRowActions
|
||||
email={record.email}
|
||||
onShowQr={onShowQr}
|
||||
onShowInfo={onShowInfo}
|
||||
onResetTraffic={onResetTraffic}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -854,42 +912,13 @@ export default function ClientsPage() {
|
||||
key: 'inboundIds',
|
||||
width: 170,
|
||||
render: (_v, record) => {
|
||||
const ids = record.inboundIds || [];
|
||||
if (ids.length === 0) return <span style={{ color: 'rgba(0,0,0,0.45)' }}>—</span>;
|
||||
const visible = ids.slice(0, INBOUND_CHIP_LIMIT);
|
||||
const overflow = ids.slice(INBOUND_CHIP_LIMIT);
|
||||
const chip = (id: number, compact: boolean) => {
|
||||
const ib = inboundsById[id];
|
||||
const proto = (ib?.protocol || '').toLowerCase();
|
||||
const color = INBOUND_PROTOCOL_COLORS[proto] ?? 'default';
|
||||
const compactLabel = formatInboundLabel(ib?.tag, ib?.remark);
|
||||
return (
|
||||
<Tooltip key={id} title={inboundLabel(id)}>
|
||||
<Tag color={color} style={{ margin: 2 }}>
|
||||
{compact ? compactLabel : inboundLabel(id)}
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
return (
|
||||
<>
|
||||
{visible.map((id) => chip(id, true))}
|
||||
{overflow.length > 0 && (
|
||||
<Popover
|
||||
trigger="click"
|
||||
placement="bottomRight"
|
||||
content={
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, maxWidth: 280, maxHeight: 280, overflowY: 'auto' }}>
|
||||
{overflow.map((id) => chip(id, false))}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Tag color="default" style={{ margin: 2, cursor: 'pointer' }}>
|
||||
+{overflow.length}
|
||||
</Tag>
|
||||
</Popover>
|
||||
)}
|
||||
</>
|
||||
<ClientInboundChips
|
||||
ids={record.inboundIds || EMPTY_INBOUND_IDS}
|
||||
inboundsById={inboundsById}
|
||||
protocolColors={INBOUND_PROTOCOL_COLORS}
|
||||
chipLimit={INBOUND_CHIP_LIMIT}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
@@ -998,7 +1027,7 @@ export default function ClientsPage() {
|
||||
status="error"
|
||||
title={t('somethingWentWrong')}
|
||||
subTitle={fetchError}
|
||||
extra={<Button type="primary" loading={loading} onClick={refresh}>{t('refresh')}</Button>}
|
||||
extra={<Button type="primary" loading={refreshing} onClick={onRefreshClick}>{t('refresh')}</Button>}
|
||||
/>
|
||||
) : (
|
||||
<Row gutter={[isMobile ? 8 : 16, isMobile ? 8 : 12]}>
|
||||
@@ -1011,37 +1040,37 @@ export default function ClientsPage() {
|
||||
<Col xs={12} sm={8} md={4}>
|
||||
<Popover
|
||||
title={t('online')}
|
||||
open={summary.online.length ? undefined : false}
|
||||
content={<div className="client-email-list">{summary.online.map((e) => <div key={e}>{e}</div>)}</div>}
|
||||
open={summary.onlineCount ? undefined : false}
|
||||
content={<ClientEmailList emails={summary.online} total={summary.onlineCount} />}
|
||||
>
|
||||
<Statistic title={t('online')} value={String(summary.online.length)} prefix={<span className="dot dot-blue" />} />
|
||||
<Statistic title={t('online')} value={String(summary.onlineCount)} prefix={<span className="dot dot-blue" />} />
|
||||
</Popover>
|
||||
</Col>
|
||||
<Col xs={12} sm={8} md={4}>
|
||||
<Popover
|
||||
title={t('depleted')}
|
||||
open={summary.depleted.length ? undefined : false}
|
||||
content={<div className="client-email-list">{summary.depleted.map((e) => <div key={e}>{e}</div>)}</div>}
|
||||
open={summary.depletedCount ? undefined : false}
|
||||
content={<ClientEmailList emails={summary.depleted} total={summary.depletedCount} />}
|
||||
>
|
||||
<Statistic title={t('depleted')} value={String(summary.depleted.length)} prefix={<span className="dot dot-red" />} />
|
||||
<Statistic title={t('depleted')} value={String(summary.depletedCount)} prefix={<span className="dot dot-red" />} />
|
||||
</Popover>
|
||||
</Col>
|
||||
<Col xs={12} sm={8} md={4}>
|
||||
<Popover
|
||||
title={t('depletingSoon')}
|
||||
open={summary.expiring.length ? undefined : false}
|
||||
content={<div className="client-email-list">{summary.expiring.map((e) => <div key={e}>{e}</div>)}</div>}
|
||||
open={summary.expiringCount ? undefined : false}
|
||||
content={<ClientEmailList emails={summary.expiring} total={summary.expiringCount} />}
|
||||
>
|
||||
<Statistic title={t('depletingSoon')} value={String(summary.expiring.length)} prefix={<span className="dot dot-orange" />} />
|
||||
<Statistic title={t('depletingSoon')} value={String(summary.expiringCount)} prefix={<span className="dot dot-orange" />} />
|
||||
</Popover>
|
||||
</Col>
|
||||
<Col xs={12} sm={8} md={4}>
|
||||
<Popover
|
||||
title={t('disabled')}
|
||||
open={summary.deactive.length ? undefined : false}
|
||||
content={<div className="client-email-list">{summary.deactive.map((e) => <div key={e}>{e}</div>)}</div>}
|
||||
open={summary.deactiveCount ? undefined : false}
|
||||
content={<ClientEmailList emails={summary.deactive} total={summary.deactiveCount} />}
|
||||
>
|
||||
<Statistic title={t('disabled')} value={String(summary.deactive.length)} prefix={<span className="dot dot-gray" />} />
|
||||
<Statistic title={t('disabled')} value={String(summary.deactiveCount)} prefix={<span className="dot dot-gray" />} />
|
||||
</Popover>
|
||||
</Col>
|
||||
<Col xs={12} sm={8} md={4}>
|
||||
@@ -1368,7 +1397,7 @@ export default function ClientsPage() {
|
||||
showTotal={(n) => `${n}`}
|
||||
onChange={(p, s) => {
|
||||
setCurrentPage(p);
|
||||
if (s && s !== tablePageSize) setTablePageSize(s);
|
||||
if (s && s !== tablePageSize) setPageSizeChoice(s);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -1395,8 +1424,8 @@ export default function ClientsPage() {
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={t('pages.clients.clientInfo')}
|
||||
onClick={() => onShowInfo(row)}
|
||||
onKeyDown={activateOnKey(() => onShowInfo(row))}
|
||||
onClick={() => onShowInfo(row.email)}
|
||||
onKeyDown={activateOnKey(() => onShowInfo(row.email))}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Switch
|
||||
@@ -1413,23 +1442,23 @@ export default function ClientsPage() {
|
||||
{
|
||||
key: 'qr',
|
||||
label: <><QrcodeOutlined /> {t('pages.clients.qrCode')}</>,
|
||||
onClick: () => onShowQr(row),
|
||||
onClick: () => onShowQr(row.email),
|
||||
},
|
||||
{
|
||||
key: 'reset',
|
||||
label: <><RetweetOutlined /> {t('pages.inbounds.resetTraffic')}</>,
|
||||
onClick: () => onResetTraffic(row),
|
||||
onClick: () => onResetTraffic(row.email),
|
||||
},
|
||||
{
|
||||
key: 'edit',
|
||||
label: <><EditOutlined /> {t('edit')}</>,
|
||||
onClick: () => onEdit(row),
|
||||
onClick: () => onEdit(row.email),
|
||||
},
|
||||
{
|
||||
key: 'delete',
|
||||
danger: true,
|
||||
label: <><DeleteOutlined /> {t('delete')}</>,
|
||||
onClick: () => onDelete(row),
|
||||
onClick: () => onDelete(row.email),
|
||||
},
|
||||
],
|
||||
}}
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Popover, Space, Tag, Tooltip } from 'antd';
|
||||
import {
|
||||
DeleteOutlined,
|
||||
EditOutlined,
|
||||
InfoCircleOutlined,
|
||||
QrcodeOutlined,
|
||||
RetweetOutlined,
|
||||
} from '@ant-design/icons';
|
||||
|
||||
import { formatInboundLabel } from '@/lib/inbounds/label';
|
||||
import type { InboundOption } from '@/hooks/useClients';
|
||||
|
||||
const ICON_BUTTON_STYLE = { fontSize: 16 } as const;
|
||||
|
||||
interface ClientRowActionsProps {
|
||||
email: string;
|
||||
onShowQr: (email: string) => void;
|
||||
onShowInfo: (email: string) => void;
|
||||
onResetTraffic: (email: string) => void;
|
||||
onEdit: (email: string) => void;
|
||||
onDelete: (email: string) => void;
|
||||
}
|
||||
|
||||
// Five Tooltip-wrapped buttons per row, none of which depend on traffic. Left
|
||||
// inline they re-ran rc-tooltip's alignment machinery for every visible row on
|
||||
// every traffic push — 125 Tooltips on a 25-row page, five seconds apart.
|
||||
// Keyed on the email rather than the row object, because a push replaces the row
|
||||
// object of every client whose counters moved; the page resolves the live row.
|
||||
export const ClientRowActions = memo(function ClientRowActions({
|
||||
email,
|
||||
onShowQr,
|
||||
onShowInfo,
|
||||
onResetTraffic,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: ClientRowActionsProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Space size={4}>
|
||||
<Tooltip title={t('pages.clients.qrCode')}>
|
||||
<Button
|
||||
size="small"
|
||||
type="text"
|
||||
style={ICON_BUTTON_STYLE}
|
||||
icon={<QrcodeOutlined />}
|
||||
aria-label={t('pages.clients.qrCode')}
|
||||
onClick={() => onShowQr(email)}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title={t('pages.clients.clientInfo')}>
|
||||
<Button
|
||||
size="small"
|
||||
type="text"
|
||||
style={ICON_BUTTON_STYLE}
|
||||
icon={<InfoCircleOutlined />}
|
||||
aria-label={t('pages.clients.clientInfo')}
|
||||
onClick={() => onShowInfo(email)}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title={t('pages.inbounds.resetTraffic')}>
|
||||
<Button
|
||||
size="small"
|
||||
type="text"
|
||||
style={ICON_BUTTON_STYLE}
|
||||
icon={<RetweetOutlined />}
|
||||
aria-label={t('pages.inbounds.resetTraffic')}
|
||||
onClick={() => onResetTraffic(email)}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title={t('edit')}>
|
||||
<Button
|
||||
size="small"
|
||||
type="text"
|
||||
style={ICON_BUTTON_STYLE}
|
||||
icon={<EditOutlined />}
|
||||
aria-label={t('edit')}
|
||||
onClick={() => onEdit(email)}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title={t('delete')}>
|
||||
<Button
|
||||
size="small"
|
||||
type="text"
|
||||
danger
|
||||
style={ICON_BUTTON_STYLE}
|
||||
icon={<DeleteOutlined />}
|
||||
aria-label={t('delete')}
|
||||
onClick={() => onDelete(email)}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Space>
|
||||
);
|
||||
});
|
||||
|
||||
const CHIP_STYLE = { margin: 2 } as const;
|
||||
const OVERFLOW_CHIP_STYLE = { margin: 2, cursor: 'pointer' } as const;
|
||||
const OVERFLOW_LIST_STYLE = {
|
||||
display: 'flex',
|
||||
flexDirection: 'column' as const,
|
||||
gap: 4,
|
||||
maxWidth: 280,
|
||||
maxHeight: 280,
|
||||
overflowY: 'auto' as const,
|
||||
};
|
||||
|
||||
interface ClientInboundChipsProps {
|
||||
ids: number[];
|
||||
inboundsById: Record<number, InboundOption>;
|
||||
protocolColors: Record<string, string>;
|
||||
chipLimit: number;
|
||||
}
|
||||
|
||||
// Attachments never change on a traffic push either, so the same memoisation
|
||||
// applies: one Tooltip per visible chip plus a Popover for the overflow.
|
||||
export const ClientInboundChips = memo(function ClientInboundChips({
|
||||
ids,
|
||||
inboundsById,
|
||||
protocolColors,
|
||||
chipLimit,
|
||||
}: ClientInboundChipsProps) {
|
||||
if (ids.length === 0) return <span className="cell-empty">—</span>;
|
||||
|
||||
const label = (id: number) => {
|
||||
const ib = inboundsById[id];
|
||||
return formatInboundLabel(ib?.tag, ib?.remark);
|
||||
};
|
||||
const chip = (id: number) => {
|
||||
const proto = (inboundsById[id]?.protocol || '').toLowerCase();
|
||||
return (
|
||||
<Tooltip key={id} title={label(id)}>
|
||||
<Tag color={protocolColors[proto] ?? 'default'} style={CHIP_STYLE}>{label(id)}</Tag>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
const visible = ids.slice(0, chipLimit);
|
||||
const overflow = ids.slice(chipLimit);
|
||||
return (
|
||||
<>
|
||||
{visible.map(chip)}
|
||||
{overflow.length > 0 && (
|
||||
<Popover
|
||||
trigger="click"
|
||||
placement="bottomRight"
|
||||
content={<div style={OVERFLOW_LIST_STYLE}>{overflow.map(chip)}</div>}
|
||||
>
|
||||
<Tag color="default" style={OVERFLOW_CHIP_STYLE}>+{overflow.length}</Tag>
|
||||
</Popover>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -93,7 +93,7 @@ export default function GroupsPage() {
|
||||
useEffect(() => { setMessageInstance(messageApi); }, [messageApi]);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { subSettings, bulkAdjust, bulkAddToGroup, bulkRemoveFromGroup, bulkDelete } = useClients();
|
||||
const { subSettings, bulkAdjust, bulkAddToGroup, bulkRemoveFromGroup, bulkDelete } = useClients({ list: false });
|
||||
|
||||
const groupsQuery = useQuery({
|
||||
queryKey: keys.clients.groups(),
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
isSS2022,
|
||||
} from '@/lib/xray/protocol-capabilities';
|
||||
import {
|
||||
InboundDbFieldsSchema,
|
||||
InboundFormBaseSchema,
|
||||
InboundFormSchema,
|
||||
type InboundFormValues,
|
||||
@@ -256,6 +257,7 @@ export default function InboundFormModal({
|
||||
const wTunnelNetwork = useWatch({ control, name: 'settings.allowedNetwork' });
|
||||
const wTotal = (useWatch({ control, name: 'total' }) as number | undefined) ?? 0;
|
||||
const wExpiry = (useWatch({ control, name: 'expiryTime' }) as number | undefined) ?? 0;
|
||||
const trafficReset = useWatch({ control, name: 'trafficReset' }) ?? 'never';
|
||||
const autoTagRef = useRef(true);
|
||||
const lastWrittenTagRef = useRef('');
|
||||
const currentTagInput = (): InboundTagInput => ({
|
||||
@@ -697,6 +699,16 @@ export default function InboundFormModal({
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{trafficReset === 'monthly' && (
|
||||
<FormField
|
||||
name="trafficResetDay"
|
||||
label={t('pages.inbounds.periodicTrafficResetDay')}
|
||||
rules={{ validate: rhfZodValidate(InboundDbFieldsSchema.shape.trafficResetDay) }}
|
||||
>
|
||||
<InputNumber min={1} max={31} />
|
||||
</FormField>
|
||||
)}
|
||||
|
||||
<Form.Item
|
||||
label={
|
||||
<Tooltip title={t('pages.inbounds.leaveBlankToNeverExpire')}>
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import { useState } from 'react';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Alert, Button, Collapse, Descriptions, Divider, Form, Input, InputNumber, Select, Space, Switch } from 'antd';
|
||||
import { RadarChartOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons';
|
||||
|
||||
import { FormField } from '@/components/form/rhf';
|
||||
import { UTLS_FINGERPRINT } from '@/schemas/primitives';
|
||||
import { validateRealityTarget } from '@/lib/xray/stream-wire-normalize';
|
||||
import {
|
||||
validateRealityClientVer,
|
||||
validateRealityMaxClientVer,
|
||||
validateRealityTarget,
|
||||
} from '@/lib/xray/stream-wire-normalize';
|
||||
import type { RealityScanResult } from '@/generated/types';
|
||||
import RealityTargetScannerModal from './RealityTargetScannerModal';
|
||||
|
||||
@@ -39,7 +44,14 @@ export default function RealityForm({
|
||||
clearMldsa65,
|
||||
}: RealityFormProps) {
|
||||
const { t } = useTranslation();
|
||||
const { getFieldState, trigger } = useFormContext();
|
||||
const [scannerOpen, setScannerOpen] = useState(false);
|
||||
const maxClientVerPath = 'streamSettings.realitySettings.maxClientVer';
|
||||
const revalidateMaxClientVer = () => {
|
||||
if (getFieldState(maxClientVerPath).error) {
|
||||
void trigger(maxClientVerPath);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<FormField
|
||||
@@ -127,14 +139,31 @@ export default function RealityForm({
|
||||
<FormField
|
||||
name={['streamSettings', 'realitySettings', 'minClientVer']}
|
||||
label={t('pages.inbounds.form.minClientVer')}
|
||||
tooltip={t('pages.inbounds.form.minClientVerHint')}
|
||||
onAfterChange={revalidateMaxClientVer}
|
||||
rules={{
|
||||
validate: (value) => {
|
||||
const errKey = validateRealityClientVer(typeof value === 'string' ? value : '');
|
||||
return errKey ? errKey : true;
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Input placeholder="26.3.27" />
|
||||
</FormField>
|
||||
<FormField
|
||||
name={['streamSettings', 'realitySettings', 'maxClientVer']}
|
||||
label={t('pages.inbounds.form.maxClientVer')}
|
||||
tooltip={t('pages.inbounds.form.maxClientVerHint')}
|
||||
rules={{
|
||||
validate: (value, formValues) => {
|
||||
const max = typeof value === 'string' ? value : '';
|
||||
const min = formValues?.streamSettings?.realitySettings?.minClientVer;
|
||||
const errKey = validateRealityMaxClientVer(max, typeof min === 'string' ? min : '');
|
||||
return errKey ? errKey : true;
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Input placeholder="25.9.11" />
|
||||
<Input placeholder="x.y.z" />
|
||||
</FormField>
|
||||
<Form.Item label={t('pages.inbounds.form.shortIds')}>
|
||||
<Space.Compact block style={{ display: 'flex' }}>
|
||||
|
||||
@@ -265,11 +265,11 @@ export default function XhttpForm() {
|
||||
>
|
||||
<Select
|
||||
options={[
|
||||
{ value: '', label: 'Default (body)' },
|
||||
{ value: '', label: 'Default (auto)' },
|
||||
{ value: 'auto', label: 'auto' },
|
||||
{ value: 'body', label: 'body' },
|
||||
{ value: 'header', label: 'header' },
|
||||
{ value: 'cookie', label: 'cookie' },
|
||||
{ value: 'query', label: 'query' },
|
||||
]}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -3,8 +3,9 @@ import { useTranslation } from 'react-i18next';
|
||||
import { Alert, Button, Input, InputNumber, Select, Space, Switch, Tabs } from 'antd';
|
||||
import { MailOutlined, SendOutlined, SettingOutlined } from '@ant-design/icons';
|
||||
import { HttpUtil } from '@/utils';
|
||||
import { onNumber } from '@/utils/onNumber';
|
||||
import type { AllSetting } from '@/models/setting';
|
||||
import { SettingListItem } from '@/components/ui';
|
||||
import { DefaultSettingTag, SettingListItem } from '@/components/ui';
|
||||
import { EmailNotifications } from '@/components/ui/notifications/EmailNotifications';
|
||||
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||
import { catTabLabel } from './catTabLabel';
|
||||
@@ -62,9 +63,9 @@ export default function EmailTab({ allSetting, updateSetting }: EmailTabProps) {
|
||||
onChange={(e) => updateSetting({ smtpHost: e.target.value })} />
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem paddings="small" title={t('pages.settings.smtpPort')} description={t('pages.settings.smtpPortDesc')}>
|
||||
<SettingListItem paddings="small" title={t('pages.settings.smtpPort')} badge={<DefaultSettingTag settingKey="smtpPort" value={allSetting.smtpPort} />} description={t('pages.settings.smtpPortDesc')}>
|
||||
<InputNumber value={allSetting.smtpPort} min={1} max={65535} style={{ width: '100%' }}
|
||||
onChange={(v) => updateSetting({ smtpPort: Number(v) || 587 })} />
|
||||
onChange={onNumber((v) => updateSetting({ smtpPort: v }))} />
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem paddings="small" title={t('pages.settings.smtpUsername')} description={t('pages.settings.smtpUsernameDesc')}>
|
||||
|
||||
@@ -17,7 +17,8 @@ import {
|
||||
} from '@ant-design/icons';
|
||||
import type { AllSetting } from '@/models/setting';
|
||||
import { HttpUtil, LanguageManager } from '@/utils';
|
||||
import { SettingListItem } from '@/components/ui';
|
||||
import { onNumber } from '@/utils/onNumber';
|
||||
import { DefaultSettingTag, SettingListItem } from '@/components/ui';
|
||||
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||
import { catTabLabel } from './catTabLabel';
|
||||
import { sanitizePath } from './uriPath';
|
||||
@@ -168,18 +169,18 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
|
||||
<Input value={allSetting.webDomain} onChange={(e) => updateSetting({ webDomain: e.target.value })} />
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem paddings="small" title={t('pages.settings.panelPort')} description={t('pages.settings.panelPortDesc')}>
|
||||
<SettingListItem paddings="small" title={t('pages.settings.panelPort')} badge={<DefaultSettingTag settingKey="webPort" value={allSetting.webPort} />} description={t('pages.settings.panelPortDesc')}>
|
||||
<InputNumber value={allSetting.webPort} min={1} max={65535} style={{ width: '100%' }}
|
||||
onChange={(v) => updateSetting({ webPort: Number(v) || 0 })} />
|
||||
onChange={onNumber((v) => updateSetting({ webPort: v }))} />
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem paddings="small" title={t('pages.settings.panelUrlPath')} description={t('pages.settings.panelUrlPathDesc')}>
|
||||
<Input value={allSetting.webBasePath} onChange={(e) => updateSetting({ webBasePath: sanitizePath(e.target.value) })} />
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem paddings="small" title={t('pages.settings.sessionMaxAge')} description={t('pages.settings.sessionMaxAgeDesc')}>
|
||||
<SettingListItem paddings="small" title={t('pages.settings.sessionMaxAge')} badge={<DefaultSettingTag settingKey="sessionMaxAge" value={allSetting.sessionMaxAge} />} description={t('pages.settings.sessionMaxAgeDesc')}>
|
||||
<InputNumber value={allSetting.sessionMaxAge} min={60} max={525600} style={{ width: '100%' }}
|
||||
onChange={(v) => updateSetting({ sessionMaxAge: Number(v) || 0 })} />
|
||||
onChange={onNumber((v) => updateSetting({ sessionMaxAge: v }))} />
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem
|
||||
@@ -206,9 +207,9 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
|
||||
/>
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem paddings="small" title={t('pages.settings.pageSize')} description={t('pages.settings.pageSizeDesc')}>
|
||||
<SettingListItem paddings="small" title={t('pages.settings.pageSize')} badge={<DefaultSettingTag settingKey="pageSize" value={allSetting.pageSize} />} description={t('pages.settings.pageSizeDesc')}>
|
||||
<InputNumber value={allSetting.pageSize} min={0} max={1000} step={5} style={{ width: '100%' }}
|
||||
onChange={(v) => updateSetting({ pageSize: Number(v) || 0 })} />
|
||||
onChange={onNumber((v) => updateSetting({ pageSize: v }))} />
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem paddings="small" title={t('pages.settings.restartXrayOnClientDisable')} description={t('pages.settings.restartXrayOnClientDisableDesc')}>
|
||||
@@ -232,13 +233,13 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
|
||||
label: catTabLabel(<BellOutlined />, t('pages.settings.notifications'), isMobile),
|
||||
children: (
|
||||
<>
|
||||
<SettingListItem paddings="small" title={t('pages.settings.expireTimeDiff')} description={t('pages.settings.expireTimeDiffDesc')}>
|
||||
<SettingListItem paddings="small" title={t('pages.settings.expireTimeDiff')} badge={<DefaultSettingTag settingKey="expireDiff" value={allSetting.expireDiff} />} description={t('pages.settings.expireTimeDiffDesc')}>
|
||||
<InputNumber value={allSetting.expireDiff} min={0} style={{ width: '100%' }}
|
||||
onChange={(v) => updateSetting({ expireDiff: Number(v) || 0 })} />
|
||||
onChange={onNumber((v) => updateSetting({ expireDiff: v }))} />
|
||||
</SettingListItem>
|
||||
<SettingListItem paddings="small" title={t('pages.settings.trafficDiff')} description={t('pages.settings.trafficDiffDesc')}>
|
||||
<SettingListItem paddings="small" title={t('pages.settings.trafficDiff')} badge={<DefaultSettingTag settingKey="trafficDiff" value={allSetting.trafficDiff} />} description={t('pages.settings.trafficDiffDesc')}>
|
||||
<InputNumber value={allSetting.trafficDiff} min={0} max={100} style={{ width: '100%' }}
|
||||
onChange={(v) => updateSetting({ trafficDiff: Number(v) || 0 })} />
|
||||
onChange={onNumber((v) => updateSetting({ trafficDiff: v }))} />
|
||||
</SettingListItem>
|
||||
</>
|
||||
),
|
||||
@@ -306,9 +307,9 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
|
||||
<SettingListItem paddings="small" title={t('pages.settings.ldap.host')}>
|
||||
<Input value={allSetting.ldapHost} onChange={(e) => updateSetting({ ldapHost: e.target.value })} />
|
||||
</SettingListItem>
|
||||
<SettingListItem paddings="small" title={t('pages.settings.ldap.port')}>
|
||||
<SettingListItem paddings="small" title={t('pages.settings.ldap.port')} badge={<DefaultSettingTag settingKey="ldapPort" value={allSetting.ldapPort} />}>
|
||||
<InputNumber value={allSetting.ldapPort} min={1} max={65535} style={{ width: '100%' }}
|
||||
onChange={(v) => updateSetting({ ldapPort: Number(v) || 0 })} />
|
||||
onChange={onNumber((v) => updateSetting({ ldapPort: v }))} />
|
||||
</SettingListItem>
|
||||
<SettingListItem paddings="small" title={t('pages.settings.ldap.useTls')}>
|
||||
<Switch checked={allSetting.ldapUseTLS} onChange={(v) => updateSetting({ ldapUseTLS: v })} />
|
||||
@@ -385,17 +386,17 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
|
||||
<SettingListItem paddings="small" title={t('pages.settings.ldap.autoDelete')}>
|
||||
<Switch checked={allSetting.ldapAutoDelete} onChange={(v) => updateSetting({ ldapAutoDelete: v })} />
|
||||
</SettingListItem>
|
||||
<SettingListItem paddings="small" title={t('pages.settings.ldap.defaultTotalGb')}>
|
||||
<SettingListItem paddings="small" title={t('pages.settings.ldap.defaultTotalGb')} badge={<DefaultSettingTag settingKey="ldapDefaultTotalGB" value={allSetting.ldapDefaultTotalGB} />}>
|
||||
<InputNumber value={allSetting.ldapDefaultTotalGB} min={0} style={{ width: '100%' }}
|
||||
onChange={(v) => updateSetting({ ldapDefaultTotalGB: Number(v) || 0 })} />
|
||||
onChange={onNumber((v) => updateSetting({ ldapDefaultTotalGB: v }))} />
|
||||
</SettingListItem>
|
||||
<SettingListItem paddings="small" title={t('pages.settings.ldap.defaultExpiryDays')}>
|
||||
<SettingListItem paddings="small" title={t('pages.settings.ldap.defaultExpiryDays')} badge={<DefaultSettingTag settingKey="ldapDefaultExpiryDays" value={allSetting.ldapDefaultExpiryDays} />}>
|
||||
<InputNumber value={allSetting.ldapDefaultExpiryDays} min={0} style={{ width: '100%' }}
|
||||
onChange={(v) => updateSetting({ ldapDefaultExpiryDays: Number(v) || 0 })} />
|
||||
onChange={onNumber((v) => updateSetting({ ldapDefaultExpiryDays: v }))} />
|
||||
</SettingListItem>
|
||||
<SettingListItem paddings="small" title={t('pages.settings.ldap.defaultIpLimit')}>
|
||||
<SettingListItem paddings="small" title={t('pages.settings.ldap.defaultIpLimit')} badge={<DefaultSettingTag settingKey="ldapDefaultLimitIP" value={allSetting.ldapDefaultLimitIP} />}>
|
||||
<InputNumber value={allSetting.ldapDefaultLimitIP} min={0} style={{ width: '100%' }}
|
||||
onChange={(v) => updateSetting({ ldapDefaultLimitIP: Number(v) || 0 })} />
|
||||
onChange={onNumber((v) => updateSetting({ ldapDefaultLimitIP: v }))} />
|
||||
</SettingListItem>
|
||||
</>
|
||||
),
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
SettingOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { AllSetting } from '@/models/setting';
|
||||
import { onNumber } from '@/utils/onNumber';
|
||||
import { SettingListItem } from '@/components/ui';
|
||||
import { GoRegexInput } from '@/components/form';
|
||||
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||
@@ -279,11 +280,11 @@ export default function SubscriptionFormatsTab({ allSetting, updateSetting }: Su
|
||||
<div className="format-settings">
|
||||
<SettingListItem paddings="small" title={t('pages.settings.subFormats.concurrency')}>
|
||||
<InputNumber value={muxObj.concurrency} min={-1} max={1024} style={{ width: '100%' }}
|
||||
onChange={(v) => setMuxField('concurrency', Number(v) || 0)} />
|
||||
onChange={onNumber((v) => setMuxField('concurrency', v))} />
|
||||
</SettingListItem>
|
||||
<SettingListItem paddings="small" title={t('pages.settings.subFormats.xudpConcurrency')}>
|
||||
<InputNumber value={muxObj.xudpConcurrency} min={-1} max={1024} style={{ width: '100%' }}
|
||||
onChange={(v) => setMuxField('xudpConcurrency', Number(v) || 0)} />
|
||||
onChange={onNumber((v) => setMuxField('xudpConcurrency', v))} />
|
||||
</SettingListItem>
|
||||
<SettingListItem paddings="small" title={t('pages.settings.subFormats.xudpUdp443')}>
|
||||
<Select
|
||||
|
||||
@@ -3,7 +3,8 @@ import { BranchesOutlined, CompassOutlined, IdcardOutlined, InfoCircleOutlined,
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router';
|
||||
import type { AllSetting } from '@/models/setting';
|
||||
import { SettingListItem } from '@/components/ui';
|
||||
import { onNumber } from '@/utils/onNumber';
|
||||
import { DefaultSettingTag, SettingListItem } from '@/components/ui';
|
||||
import { RemarkTemplateField } from '@/components/form';
|
||||
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||
import { catTabLabel } from './catTabLabel';
|
||||
@@ -55,9 +56,9 @@ export default function SubscriptionGeneralTab({ allSetting, updateSetting }: Su
|
||||
<SettingListItem paddings="small" title={t('pages.settings.subDomain')} description={t('pages.settings.subDomainDesc')}>
|
||||
<Input value={allSetting.subDomain} onChange={(e) => updateSetting({ subDomain: e.target.value })} />
|
||||
</SettingListItem>
|
||||
<SettingListItem paddings="small" title={t('pages.settings.subPort')} description={t('pages.settings.subPortDesc')}>
|
||||
<SettingListItem paddings="small" title={t('pages.settings.subPort')} badge={<DefaultSettingTag settingKey="subPort" value={allSetting.subPort} />} description={t('pages.settings.subPortDesc')}>
|
||||
<InputNumber value={allSetting.subPort} min={1} max={65535} style={{ width: '100%' }}
|
||||
onChange={(v) => updateSetting({ subPort: Number(v) || 0 })} />
|
||||
onChange={onNumber((v) => updateSetting({ subPort: v }))} />
|
||||
</SettingListItem>
|
||||
<SettingListItem paddings="small" title={t('pages.settings.subPath')} description={t('pages.settings.subPathDesc')}>
|
||||
<Input
|
||||
@@ -93,10 +94,20 @@ export default function SubscriptionGeneralTab({ allSetting, updateSetting }: Su
|
||||
maxLength={256}
|
||||
/>
|
||||
</SettingListItem>
|
||||
<SettingListItem
|
||||
paddings="small"
|
||||
title={t('pages.settings.subShowIdentityOnAllLinks')}
|
||||
description={t('pages.settings.subShowIdentityOnAllLinksDesc')}
|
||||
>
|
||||
<Switch
|
||||
checked={allSetting.subShowIdentityOnAllLinks}
|
||||
onChange={(v) => updateSetting({ subShowIdentityOnAllLinks: v })}
|
||||
/>
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem paddings="small" title={t('pages.settings.subUpdates')} description={t('pages.settings.subUpdatesDesc')}>
|
||||
<SettingListItem paddings="small" title={t('pages.settings.subUpdates')} badge={<DefaultSettingTag settingKey="subUpdates" value={allSetting.subUpdates} />} description={t('pages.settings.subUpdatesDesc')}>
|
||||
<InputNumber value={allSetting.subUpdates} min={0} max={525600} style={{ width: '100%' }}
|
||||
onChange={(v) => updateSetting({ subUpdates: Number(v) || 0 })} />
|
||||
onChange={onNumber((v) => updateSetting({ subUpdates: v }))} />
|
||||
</SettingListItem>
|
||||
</>
|
||||
),
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Alert, Button, Input, InputNumber, Select, Space, Switch, Tabs } from '
|
||||
import { BellOutlined, SendOutlined, SettingOutlined } from '@ant-design/icons';
|
||||
import { LanguageManager } from '@/utils';
|
||||
import { HttpUtil } from '@/utils';
|
||||
import { onNumber } from '@/utils/onNumber';
|
||||
import type { AllSetting } from '@/models/setting';
|
||||
import { SettingListItem } from '@/components/ui';
|
||||
import { TelegramNotifications } from '@/components/ui/notifications/TelegramNotifications';
|
||||
@@ -122,9 +123,10 @@ function NotifyTimeField({ value, onChange }: { value: string; onChange: (v: str
|
||||
<Space.Compact style={{ width: '100%' }}>
|
||||
<InputNumber
|
||||
min={1}
|
||||
precision={0}
|
||||
style={{ width: '50%' }}
|
||||
value={state.num}
|
||||
onChange={(v) => update({ num: Math.max(1, Number(v) || 1) })}
|
||||
onChange={onNumber((v) => update({ num: Math.max(1, v) }))}
|
||||
aria-label={t('pages.settings.notifyTime.interval')}
|
||||
/>
|
||||
<Select<Unit>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Alert, Empty, Input, InputNumber, Select, Space, Switch, Tag } from 'antd';
|
||||
|
||||
import { onNumber } from '@/utils/onNumber';
|
||||
import { SettingListItem } from '@/components/ui';
|
||||
import {
|
||||
BurstObservatorySchema,
|
||||
@@ -195,7 +196,7 @@ export default function ObservatorySettingsTab({
|
||||
<InputNumber
|
||||
min={1}
|
||||
value={burst.pingConfig.sampling}
|
||||
onChange={(v) => patchPingConfig({ sampling: typeof v === 'number' ? v : burst.pingConfig.sampling })}
|
||||
onChange={onNumber((v) => patchPingConfig({ sampling: v }))}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</SettingListItem>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback } from 'react';
|
||||
import { onNumber } from '@/utils/onNumber';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Alert, Button, Input, InputNumber, Modal, Select, Space, Switch, Tabs } from 'antd';
|
||||
import {
|
||||
@@ -216,10 +217,10 @@ export default function BasicsTab({
|
||||
style={{ width: '100%' }}
|
||||
value={directHappyEyeballs.tryDelayMs}
|
||||
placeholder="150"
|
||||
onChange={(v) => setDirectHappyEyeballs({
|
||||
onChange={onNumber((v) => setDirectHappyEyeballs({
|
||||
...directHappyEyeballs,
|
||||
tryDelayMs: typeof v === 'number' ? v : 0,
|
||||
})}
|
||||
tryDelayMs: v,
|
||||
}))}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Alert, Button, Empty, Input, InputNumber, Modal, Select, Space, Switch, Table, Tabs } from 'antd';
|
||||
import {
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
SettingOutlined,
|
||||
} from '@ant-design/icons';
|
||||
|
||||
import { onNumber } from '@/utils/onNumber';
|
||||
import { SettingListItem } from '@/components/ui';
|
||||
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||
import { catTabLabel } from '@/pages/settings/catTabLabel';
|
||||
@@ -41,6 +42,23 @@ export default function DnsTab({ templateSettings, setTemplateSettings }: DnsTab
|
||||
|
||||
const dns = (templateSettings?.dns as DnsConfig | undefined) ?? null;
|
||||
const dnsEnabled = !!dns;
|
||||
const sourceHosts = dns?.hosts;
|
||||
const incomingHosts = JSON.stringify(sourceHosts ?? {});
|
||||
const lastWrittenHostsRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dnsEnabled) {
|
||||
lastWrittenHostsRef.current = '{}';
|
||||
setHostsList([]);
|
||||
return;
|
||||
}
|
||||
if (incomingHosts === lastWrittenHostsRef.current) return;
|
||||
lastWrittenHostsRef.current = incomingHosts;
|
||||
setHostsList(Object.entries(sourceHosts ?? {}).map(([domain, values]) => ({
|
||||
domain,
|
||||
values: Array.isArray(values) ? [...values] : [String(values)],
|
||||
})));
|
||||
}, [dnsEnabled, incomingHosts, sourceHosts]);
|
||||
|
||||
const mutate = useCallback(
|
||||
(mutator: (next: XraySettingsValue) => void) => {
|
||||
@@ -78,32 +96,18 @@ export default function DnsTab({ templateSettings, setTemplateSettings }: DnsTab
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!dns) {
|
||||
setHostsList([]);
|
||||
return;
|
||||
}
|
||||
const src = dns.hosts || {};
|
||||
setHostsList(
|
||||
Object.entries(src).map(([domain, val]) => ({
|
||||
domain,
|
||||
values: Array.isArray(val) ? [...val] : [String(val)],
|
||||
})),
|
||||
);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [dnsEnabled]);
|
||||
|
||||
function syncHosts(next: HostRow[]) {
|
||||
const obj: Record<string, string | string[]> = {};
|
||||
for (const row of next) {
|
||||
if (!row.domain) continue;
|
||||
const vals = (row.values || []).filter(Boolean);
|
||||
if (vals.length === 0) continue;
|
||||
obj[row.domain] = vals.length === 1 ? vals[0] : vals;
|
||||
}
|
||||
lastWrittenHostsRef.current = JSON.stringify(obj);
|
||||
setHostsList(next);
|
||||
mutate((tt) => {
|
||||
if (!tt.dns) return;
|
||||
const obj: Record<string, string | string[]> = {};
|
||||
for (const row of next) {
|
||||
if (!row.domain) continue;
|
||||
const vals = (row.values || []).filter(Boolean);
|
||||
if (vals.length === 0) continue;
|
||||
obj[row.domain] = vals.length === 1 ? vals[0] : vals;
|
||||
}
|
||||
if (Object.keys(obj).length > 0) {
|
||||
(tt.dns as DnsConfig).hosts = obj;
|
||||
} else if ('hosts' in (tt.dns as DnsConfig)) {
|
||||
@@ -311,7 +315,7 @@ export default function DnsTab({ templateSettings, setTemplateSettings }: DnsTab
|
||||
min={0}
|
||||
step={60}
|
||||
style={{ width: '100%' }}
|
||||
onChange={(v) => setDnsField('serveExpiredTTL', Number(v) || 0)}
|
||||
onChange={onNumber((v) => setDnsField('serveExpiredTTL', v))}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -4,6 +4,8 @@ import { Button, Dropdown, Input, InputNumber, Space } from 'antd';
|
||||
import { MoreOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
|
||||
import { onNumber } from '@/utils/onNumber';
|
||||
|
||||
import { addrFor, domainsFor, expectedIPsFor } from './helpers';
|
||||
import type { DnsServerValue } from './DnsServerModal';
|
||||
|
||||
@@ -113,7 +115,7 @@ export function useFakednsColumns({
|
||||
aria-label={t('pages.xray.fakedns.poolSize')}
|
||||
min={1}
|
||||
size="small"
|
||||
onChange={(v) => updateFakednsField(index, 'poolSize', Number(v) || 0)}
|
||||
onChange={onNumber((v) => updateFakednsField(index, 'poolSize', v))}
|
||||
/>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
} from '@ant-design/icons';
|
||||
|
||||
import { HttpUtil } from '@/utils';
|
||||
import { onNumber } from '@/utils/onNumber';
|
||||
import PromptModal from '@/components/feedback/PromptModal';
|
||||
import TextModal from '@/components/feedback/TextModal';
|
||||
|
||||
@@ -626,14 +627,14 @@ export default function OutboundsTab({
|
||||
<InputNumber
|
||||
min={0}
|
||||
value={intervalHours}
|
||||
onChange={(v) => setIntervalHM(Number(v) || 0, intervalMinutes)}
|
||||
onChange={onNumber((v) => setIntervalHM(v, intervalMinutes))}
|
||||
style={{ width: 80 }}
|
||||
/> {t('pages.xray.outboundSub.hours')}
|
||||
<InputNumber
|
||||
min={0}
|
||||
max={59}
|
||||
value={intervalMinutes}
|
||||
onChange={(v) => setIntervalHM(intervalHours, Number(v) || 0)}
|
||||
onChange={onNumber((v) => setIntervalHM(intervalHours, v))}
|
||||
style={{ width: 80 }}
|
||||
/> {t('pages.xray.outboundSub.minutes')}
|
||||
</Space>
|
||||
|
||||
@@ -240,11 +240,11 @@ export default function XhttpForm({ onXmuxToggle }: XhttpFormProps) {
|
||||
>
|
||||
<Select
|
||||
options={[
|
||||
{ value: '', label: 'Default (body)' },
|
||||
{ value: '', label: 'Default (auto)' },
|
||||
{ value: 'auto', label: 'auto' },
|
||||
{ value: 'body', label: 'body' },
|
||||
{ value: 'header', label: 'header' },
|
||||
{ value: 'cookie', label: 'cookie' },
|
||||
{ value: 'query', label: 'query' },
|
||||
]}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
@@ -96,9 +96,15 @@ export const InboundOptionSchema = z.object({
|
||||
|
||||
export const InboundOptionsSchema = z.array(InboundOptionSchema);
|
||||
|
||||
// The *Count fields are exact; the email arrays stop at the server's cap and
|
||||
// only feed the hover popovers, so never derive a counter from their length.
|
||||
export const ClientsSummarySchema = z.object({
|
||||
total: z.number(),
|
||||
active: z.number(),
|
||||
onlineCount: z.number().optional().default(0),
|
||||
depletedCount: z.number().optional().default(0),
|
||||
expiringCount: z.number().optional().default(0),
|
||||
deactiveCount: z.number().optional().default(0),
|
||||
online: nullableStringArray,
|
||||
depleted: nullableStringArray,
|
||||
expiring: nullableStringArray,
|
||||
|
||||
@@ -22,6 +22,7 @@ export const InboundDbFieldsSchema = z.object({
|
||||
down: z.number().int().min(0).default(0),
|
||||
total: z.number().int().min(0).default(0),
|
||||
trafficReset: TrafficResetSchema.default('never'),
|
||||
trafficResetDay: z.number().int().min(1).max(31).default(1),
|
||||
lastTrafficResetTime: z.number().int().default(0),
|
||||
nodeId: z.number().int().nullable().optional(),
|
||||
shareAddrStrategy: ShareAddrStrategySchema.default('node'),
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
// Hysteria v1 inbound (legacy — upstream xray-core kept v1 support but the
|
||||
// panel defaults to v2). Each client supplies an `auth` token instead of a
|
||||
// UUID/password.
|
||||
// Hysteria inbound. Each client supplies an `auth` token instead of a
|
||||
// UUID/password. xray-core builds version 2 only — it answers anything else
|
||||
// with "version != 2" and rejects the entire config, so a legacy row is
|
||||
// coerced rather than carried through.
|
||||
export const HysteriaClientSchema = z.object({
|
||||
auth: z.string().min(1),
|
||||
email: z.string().min(1),
|
||||
@@ -20,7 +21,7 @@ export const HysteriaClientSchema = z.object({
|
||||
export type HysteriaClient = z.infer<typeof HysteriaClientSchema>;
|
||||
|
||||
export const HysteriaInboundSettingsSchema = z.object({
|
||||
version: z.number().int().min(1).default(2),
|
||||
version: z.preprocess(() => 2, z.literal(2)).default(2),
|
||||
clients: z.array(HysteriaClientSchema).default([]),
|
||||
});
|
||||
export type HysteriaInboundSettings = z.infer<typeof HysteriaInboundSettingsSchema>;
|
||||
|
||||
@@ -31,12 +31,14 @@ export const XHttpXmuxSchema = z.object({
|
||||
export type XHttpXmux = z.infer<typeof XHttpXmuxSchema>;
|
||||
|
||||
// Seed for freshly enabling XMUX on a config that had no xmux block:
|
||||
// mirrors xray-core v26.6.27's own anti-RKN maxConnections=6 fallback
|
||||
// rather than the concurrency strategy.
|
||||
// mirrors xray-core's own maxConnections fallback rather than the
|
||||
// concurrency strategy. v26.7.28 lowered that fallback from 6 to 3 for
|
||||
// anti-TSPU, so track it here to keep a fresh panel config matching what
|
||||
// the core would have picked on its own.
|
||||
export const XMUX_FRESH_DEFAULTS: XHttpXmux = {
|
||||
...XHttpXmuxSchema.parse({}),
|
||||
maxConcurrency: '',
|
||||
maxConnections: 6,
|
||||
maxConnections: 3,
|
||||
};
|
||||
|
||||
// Predefined sessionIDTable names xray-core accepts as a shorthand for a
|
||||
|
||||
@@ -18,6 +18,7 @@ export const AllSettingSchema = z.object({
|
||||
expireDiff: nonNegativeInt.optional(),
|
||||
trafficDiff: nonNegativeInt.max(100).optional(),
|
||||
remarkTemplate: z.string().optional(),
|
||||
subShowIdentityOnAllLinks: z.boolean().optional(),
|
||||
datepicker: z.enum(['gregorian', 'jalalian']).optional(),
|
||||
tgBotEnable: z.boolean().optional(),
|
||||
tgBotToken: z.string().optional(),
|
||||
@@ -102,3 +103,7 @@ export const AllSettingSchema = z.object({
|
||||
}).loose();
|
||||
|
||||
export type AllSettingInput = z.infer<typeof AllSettingSchema>;
|
||||
|
||||
export const FactoryDefaultsSchema = z.record(z.string(), z.string());
|
||||
|
||||
export type FactoryDefaults = z.infer<typeof FactoryDefaultsSchema>;
|
||||
|
||||
@@ -14,6 +14,7 @@ exports[`FinalMaskStreamSettingsSchema fixtures > parses combined byte-stably 1`
|
||||
"tcp": [
|
||||
{
|
||||
"settings": {
|
||||
"length": "10-20",
|
||||
"packets": "1-3",
|
||||
},
|
||||
"type": "fragment",
|
||||
@@ -145,9 +146,7 @@ exports[`FinalMaskStreamSettingsSchema fixtures > parses tcp-mask byte-stably 1`
|
||||
[
|
||||
{
|
||||
"delay": 0,
|
||||
"packet": [
|
||||
"GET / HTTP/1.1",
|
||||
],
|
||||
"packet": "GET / HTTP/1.1",
|
||||
"type": "str",
|
||||
},
|
||||
],
|
||||
@@ -157,9 +156,7 @@ exports[`FinalMaskStreamSettingsSchema fixtures > parses tcp-mask byte-stably 1`
|
||||
[
|
||||
{
|
||||
"delay": 0,
|
||||
"packet": [
|
||||
"HTTP/1.1 200 OK",
|
||||
],
|
||||
"packet": "HTTP/1.1 200 OK",
|
||||
"type": "str",
|
||||
},
|
||||
],
|
||||
@@ -171,8 +168,13 @@ exports[`FinalMaskStreamSettingsSchema fixtures > parses tcp-mask byte-stably 1`
|
||||
"settings": {
|
||||
"hostname": "mc.example.com",
|
||||
"password": "s3cr3t",
|
||||
"usernames": [
|
||||
"Dream",
|
||||
"profiles": [
|
||||
{
|
||||
"texturesSignature": "Zm9yLWZpeHR1cmUtdXNlLW9ubHktbm90LWEtcmVhbC1tb2phbmctc2lnbmF0dXJl",
|
||||
"texturesValue": "eyJ0aW1lc3RhbXAiOjE3MDAwMDAwMDAwMDAsInByb2ZpbGVJZCI6ImVjNzBiY2FmNzAyZjRiYjhiNDhkMjc2ZmE1MmE3ODBjIn0=",
|
||||
"username": "Dream",
|
||||
"uuid": "ec70bcaf-702f-4bb8-b48d-276fa52a780c",
|
||||
},
|
||||
],
|
||||
},
|
||||
"type": "xmc",
|
||||
@@ -219,13 +221,11 @@ exports[`FinalMaskStreamSettingsSchema fixtures > parses udp-mask byte-stably 1`
|
||||
{
|
||||
"delay": "10-16",
|
||||
"rand": "10-20",
|
||||
"type": "rand",
|
||||
"type": "array",
|
||||
},
|
||||
{
|
||||
"delay": "5",
|
||||
"packet": [
|
||||
"ping",
|
||||
],
|
||||
"packet": "ping",
|
||||
"type": "str",
|
||||
},
|
||||
],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`InboundSchema (full) fixtures > parses hysteria-v1-tls byte-stably 1`] = `
|
||||
exports[`InboundSchema (full) fixtures > parses hysteria-tls byte-stably 1`] = `
|
||||
{
|
||||
"down": 0,
|
||||
"enable": true,
|
||||
@@ -9,7 +9,7 @@ exports[`InboundSchema (full) fixtures > parses hysteria-v1-tls byte-stably 1`]
|
||||
"listen": "",
|
||||
"port": 36715,
|
||||
"protocol": "hysteria",
|
||||
"remark": "gina-hysteria-v1",
|
||||
"remark": "gina-hysteria",
|
||||
"settings": {
|
||||
"clients": [
|
||||
{
|
||||
@@ -25,7 +25,7 @@ exports[`InboundSchema (full) fixtures > parses hysteria-v1-tls byte-stably 1`]
|
||||
"totalGB": 0,
|
||||
},
|
||||
],
|
||||
"version": 1,
|
||||
"version": 2,
|
||||
},
|
||||
"shareAddr": "",
|
||||
"shareAddrStrategy": "node",
|
||||
@@ -78,7 +78,7 @@ exports[`InboundSchema (full) fixtures > parses hysteria-v1-tls byte-stably 1`]
|
||||
},
|
||||
},
|
||||
},
|
||||
"tag": "inbound-hysteria-v1",
|
||||
"tag": "inbound-hysteria",
|
||||
"total": 0,
|
||||
"up": 0,
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`genHysteriaLink > hysteria-v1-tls: byte-stable 1`] = `"hysteria://hyst-v1-auth-XYZ@example.test:36715?security=tls&fp=chrome&alpn=h3&sni=hysteria.example.test#parity-test"`;
|
||||
exports[`genHysteriaLink > hysteria-tls: byte-stable 1`] = `"hysteria2://hyst-v1-auth-XYZ@example.test:36715?security=tls&fp=chrome&alpn=h3&sni=hysteria.example.test#parity-test"`;
|
||||
|
||||
exports[`genInboundLinks orchestrator > hysteria-v1-tls: byte-stable 1`] = `"hysteria://hyst-v1-auth-XYZ@override.test:36715?security=tls&fp=chrome&alpn=h3&sni=hysteria.example.test#parity-test"`;
|
||||
exports[`genInboundLinks orchestrator > hysteria-tls: byte-stable 1`] = `"hysteria2://hyst-v1-auth-XYZ@override.test:36715?security=tls&fp=chrome&alpn=h3&sni=hysteria.example.test#parity-test"`;
|
||||
|
||||
exports[`genInboundLinks orchestrator > shadowsocks-tcp-2022: byte-stable 1`] = `"ss://2022-blake3-aes-256-gcm:ZmFrZS1zZXJ2ZXItcGFzc3dvcmQtMDAwMQ%3D%3D:dGVzdC1jbGllbnQtcGFzc3dvcmQtMQ%3D%3D@override.test:8388?type=tcp#parity-test"`;
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ exports[`InboundSettingsSchema fixtures > parses hysteria-basic byte-stably 1`]
|
||||
"totalGB": 0,
|
||||
},
|
||||
],
|
||||
"version": 1,
|
||||
"version": 2,
|
||||
},
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -100,7 +100,7 @@ exports[`NetworkSettingsSchema fixtures > parses xhttp-extra-padding byte-stably
|
||||
"xPaddingBytes": "500-1500",
|
||||
"xPaddingHeader": "X-Pad",
|
||||
"xPaddingKey": "secret-key",
|
||||
"xPaddingMethod": "random",
|
||||
"xPaddingMethod": "tokenish",
|
||||
"xPaddingObfsMode": true,
|
||||
"xPaddingPlacement": "header",
|
||||
},
|
||||
@@ -114,7 +114,7 @@ exports[`NetworkSettingsSchema fixtures > parses xhttp-extra-placement byte-stab
|
||||
"enableXmux": false,
|
||||
"headers": {},
|
||||
"host": "edge.example.test",
|
||||
"mode": "auto",
|
||||
"mode": "packet-up",
|
||||
"noGRPCHeader": false,
|
||||
"noSSEHeader": false,
|
||||
"path": "/sp",
|
||||
@@ -131,7 +131,7 @@ exports[`NetworkSettingsSchema fixtures > parses xhttp-extra-placement byte-stab
|
||||
"sessionIDTable": "",
|
||||
"uplinkChunkSize": 0,
|
||||
"uplinkDataKey": "u",
|
||||
"uplinkDataPlacement": "query",
|
||||
"uplinkDataPlacement": "cookie",
|
||||
"uplinkHTTPMethod": "",
|
||||
"xPaddingBytes": "100-1000",
|
||||
"xPaddingHeader": "",
|
||||
@@ -184,7 +184,7 @@ exports[`NetworkSettingsSchema fixtures > parses xhttp-extra-tuning byte-stably
|
||||
"hMaxRequestTimes": "600-900",
|
||||
"hMaxReusableSecs": "1800-3000",
|
||||
"maxConcurrency": "16-32",
|
||||
"maxConnections": 4,
|
||||
"maxConnections": 0,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { fireEvent, screen } from '@testing-library/react';
|
||||
import { MemoryRouter } from 'react-router';
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import AppSidebar from '@/layouts/AppSidebar';
|
||||
import { renderWithProviders } from './test-utils';
|
||||
|
||||
vi.mock('@/api/queries/useAllSettings', () => ({
|
||||
useAllSettings: () => ({ allSetting: {} }),
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
function renderSidebar() {
|
||||
return renderWithProviders(
|
||||
<MemoryRouter>
|
||||
<AppSidebar />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
test('keeps the sidebar expanded after pinning it from the header and restores the choice', () => {
|
||||
const first = renderSidebar();
|
||||
const sidebar = first.container.querySelector('.ant-layout-sider');
|
||||
const sidebarRoot = first.container.querySelector('.ant-sidebar');
|
||||
|
||||
expect(sidebar?.classList.contains('ant-layout-sider-collapsed')).toBe(true);
|
||||
|
||||
fireEvent.mouseEnter(sidebarRoot!);
|
||||
|
||||
const pinButton = screen.getByRole('button', { name: 'Pin sidebar' });
|
||||
expect(pinButton.closest('.brand-actions')).not.toBeNull();
|
||||
|
||||
fireEvent.click(pinButton);
|
||||
fireEvent.mouseLeave(sidebarRoot!);
|
||||
|
||||
expect(sidebar?.classList.contains('ant-layout-sider-collapsed')).toBe(false);
|
||||
expect(sidebarRoot?.getAttribute('style')).toContain('--sider-rail: 220px');
|
||||
expect(localStorage.getItem('sidebar-pinned')).toBe('true');
|
||||
|
||||
first.unmount();
|
||||
|
||||
const second = renderSidebar();
|
||||
const restoredSidebar = second.container.querySelector('.ant-layout-sider');
|
||||
const restoredSidebarRoot = second.container.querySelector('.ant-sidebar');
|
||||
|
||||
expect(restoredSidebar?.classList.contains('ant-layout-sider-collapsed')).toBe(false);
|
||||
expect(restoredSidebarRoot?.getAttribute('style')).toContain('--sider-rail: 220px');
|
||||
expect(screen.getByRole('button', { name: 'Pin sidebar' })).not.toBeNull();
|
||||
});
|
||||
|
||||
test('returns to the compact rail after unpinning', () => {
|
||||
const view = renderSidebar();
|
||||
const sidebar = view.container.querySelector('.ant-layout-sider');
|
||||
const sidebarRoot = view.container.querySelector('.ant-sidebar');
|
||||
|
||||
fireEvent.mouseEnter(sidebarRoot!);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Pin sidebar' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Pin sidebar' }));
|
||||
fireEvent.mouseLeave(sidebarRoot!);
|
||||
|
||||
expect(sidebar?.classList.contains('ant-layout-sider-collapsed')).toBe(true);
|
||||
expect(sidebarRoot?.getAttribute('style')).toContain('--sider-rail: 72px');
|
||||
expect(localStorage.getItem('sidebar-pinned')).toBe('false');
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { renderHook, waitFor, act } from '@testing-library/react';
|
||||
import { QueryClientProvider } from '@tanstack/react-query';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { useClients } from '@/hooks/useClients';
|
||||
import { makeTestQueryClient } from '@/test/test-utils';
|
||||
import { HttpUtil, Msg } from '@/utils';
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const emptyPage = {
|
||||
items: [],
|
||||
total: 0,
|
||||
filtered: 0,
|
||||
page: 1,
|
||||
pageSize: 25,
|
||||
groups: [],
|
||||
summary: {
|
||||
total: 0,
|
||||
active: 0,
|
||||
onlineCount: 0,
|
||||
depletedCount: 0,
|
||||
expiringCount: 0,
|
||||
deactiveCount: 0,
|
||||
online: [],
|
||||
depleted: [],
|
||||
expiring: [],
|
||||
deactive: [],
|
||||
},
|
||||
};
|
||||
|
||||
function mockPanel(defaults: Record<string, unknown>) {
|
||||
const pagedUrls: string[] = [];
|
||||
vi.spyOn(HttpUtil, 'get').mockImplementation(async (url: string) => {
|
||||
if (url.includes('/clients/list/paged')) {
|
||||
pagedUrls.push(url);
|
||||
return new Msg(true, '', emptyPage);
|
||||
}
|
||||
if (url.includes('/inbounds/options')) return new Msg(true, '', []);
|
||||
return new Msg(true, '', null);
|
||||
});
|
||||
vi.spyOn(HttpUtil, 'post').mockImplementation(async (url: string) => {
|
||||
if (url.includes('/setting/defaultSettings')) return new Msg(true, '', defaults);
|
||||
if (url.includes('/clients/onlines')) return new Msg(true, '', []);
|
||||
return new Msg(true, '', null);
|
||||
});
|
||||
return pagedUrls;
|
||||
}
|
||||
|
||||
function wrapperFor() {
|
||||
const queryClient = makeTestQueryClient();
|
||||
return ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
describe('useClients query gating', () => {
|
||||
it('does not fetch the list until the page supplies a query', async () => {
|
||||
const pagedUrls = mockPanel({ pageSize: 25 });
|
||||
const { result } = renderHook(() => useClients(), { wrapper: wrapperFor() });
|
||||
|
||||
await waitFor(() => expect(result.current.settingsReady).toBe(true));
|
||||
// The page has not called setQuery yet, so nothing should have gone out —
|
||||
// this is what used to cost a thrown-away round trip on every page load.
|
||||
expect(pagedUrls).toEqual([]);
|
||||
expect(result.current.fetched).toBe(false);
|
||||
});
|
||||
|
||||
it('issues exactly one request for a page load that settles on one query', async () => {
|
||||
const pagedUrls = mockPanel({ pageSize: 50 });
|
||||
const { result } = renderHook(() => useClients(), { wrapper: wrapperFor() });
|
||||
|
||||
await waitFor(() => expect(result.current.settingsReady).toBe(true));
|
||||
act(() => {
|
||||
result.current.setQuery({ page: 1, pageSize: 50, sort: 'createdAt', order: 'ascend' });
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.fetched).toBe(true));
|
||||
expect(pagedUrls).toHaveLength(1);
|
||||
expect(pagedUrls[0]).toContain('pageSize=50');
|
||||
expect(pagedUrls[0]).toContain('sort=createdAt');
|
||||
});
|
||||
|
||||
it('fetches as soon as a query arrives, without waiting for the settings', async () => {
|
||||
// The page remembers the previous visit's page size in localStorage, so on a
|
||||
// return visit it can supply a query on the first render. The hook must not
|
||||
// hold that back behind /setting/defaultSettings, or the two round trips
|
||||
// serialise and the list lands ~160ms later than it needs to.
|
||||
const pagedUrls = mockPanel({ pageSize: 25 });
|
||||
const { result } = renderHook(() => useClients(), { wrapper: wrapperFor() });
|
||||
|
||||
act(() => {
|
||||
result.current.setQuery({ page: 1, pageSize: 25, sort: 'createdAt', order: 'ascend' });
|
||||
});
|
||||
await waitFor(() => expect(pagedUrls).toHaveLength(1));
|
||||
});
|
||||
|
||||
it('reports settingsReady even when the settings request fails, so the page can still render', async () => {
|
||||
vi.spyOn(HttpUtil, 'get').mockResolvedValue(new Msg(true, '', emptyPage));
|
||||
vi.spyOn(HttpUtil, 'post').mockResolvedValue(new Msg(false, 'boom', null));
|
||||
const { result } = renderHook(() => useClients(), { wrapper: wrapperFor() });
|
||||
|
||||
await waitFor(() => expect(result.current.settingsReady).toBe(true));
|
||||
});
|
||||
|
||||
it('skips the list, options and onlines queries for mutation-only callers', async () => {
|
||||
const pagedUrls = mockPanel({ pageSize: 25 });
|
||||
const postSpy = vi.mocked(HttpUtil.post);
|
||||
const { result } = renderHook(() => useClients({ list: false }), { wrapper: wrapperFor() });
|
||||
|
||||
await waitFor(() => expect(result.current.settingsReady).toBe(true));
|
||||
act(() => {
|
||||
result.current.setQuery({ page: 1, pageSize: 25, sort: 'createdAt', order: 'ascend' });
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.settingsReady).toBe(true));
|
||||
expect(pagedUrls).toEqual([]);
|
||||
// subSettings still needs defaultSettings; onlines must not be polled.
|
||||
const posted = postSpy.mock.calls.map((c) => String(c[0]));
|
||||
expect(posted.some((u) => u.includes('/setting/defaultSettings'))).toBe(true);
|
||||
expect(posted.some((u) => u.includes('/clients/onlines'))).toBe(false);
|
||||
expect(vi.mocked(HttpUtil.get).mock.calls.map((c) => String(c[0]))).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
import { useState } from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ClientInboundChips, ClientRowActions } from '@/pages/clients/RowCells';
|
||||
import type { InboundOption } from '@/hooks/useClients';
|
||||
|
||||
const PROTOCOL_COLORS = { vless: 'blue', trojan: 'volcano' };
|
||||
|
||||
// Counts how often the cell reads the inbound map, which happens once per chip
|
||||
// per render. A traffic push re-renders the row, so if the cell is not memoised
|
||||
// this climbs every five seconds for every visible row.
|
||||
function countingInboundMap(source: Record<number, InboundOption>) {
|
||||
const reads = { count: 0 };
|
||||
const proxy = new Proxy(source, {
|
||||
get(target, key) {
|
||||
if (typeof key === 'string' && /^\d+$/.test(key)) reads.count += 1;
|
||||
return target[key as unknown as number];
|
||||
},
|
||||
});
|
||||
return { proxy, reads };
|
||||
}
|
||||
|
||||
const INBOUNDS: Record<number, InboundOption> = {
|
||||
1: { id: 1, tag: 'in-vless', remark: 'DE', protocol: 'vless' },
|
||||
2: { id: 2, tag: 'in-trojan', remark: 'NL', protocol: 'trojan' },
|
||||
};
|
||||
|
||||
function Harness({ children }: { children: (bump: () => void) => React.ReactNode }) {
|
||||
const [, setTick] = useState(0);
|
||||
return <>{children(() => setTick((n) => n + 1))}</>;
|
||||
}
|
||||
|
||||
describe('clients table row cells', () => {
|
||||
it('does not re-render the inbound chips when the row re-renders with the same attachments', async () => {
|
||||
const { proxy, reads } = countingInboundMap(INBOUNDS);
|
||||
const ids = [1, 2];
|
||||
let bump: () => void = () => {};
|
||||
|
||||
render(
|
||||
<Harness>
|
||||
{(doBump) => {
|
||||
bump = doBump;
|
||||
return (
|
||||
<ClientInboundChips ids={ids} inboundsById={proxy} protocolColors={PROTOCOL_COLORS} chipLimit={1} />
|
||||
);
|
||||
}}
|
||||
</Harness>,
|
||||
);
|
||||
|
||||
const afterFirstRender = reads.count;
|
||||
expect(afterFirstRender).toBeGreaterThan(0);
|
||||
|
||||
// Three simulated traffic pushes: the parent re-renders, the props do not change.
|
||||
for (let i = 0; i < 3; i++) bump();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(reads.count).toBe(afterFirstRender);
|
||||
});
|
||||
|
||||
it('re-renders the chips when the attachments actually change', async () => {
|
||||
const { proxy, reads } = countingInboundMap(INBOUNDS);
|
||||
|
||||
function Swapper() {
|
||||
const [ids, setIds] = useState<number[]>([1]);
|
||||
return (
|
||||
<>
|
||||
<button type="button" onClick={() => setIds([1, 2])}>swap</button>
|
||||
<ClientInboundChips ids={ids} inboundsById={proxy} protocolColors={PROTOCOL_COLORS} chipLimit={1} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
render(<Swapper />);
|
||||
const before = reads.count;
|
||||
await userEvent.click(screen.getByRole('button', { name: 'swap' }));
|
||||
expect(reads.count).toBeGreaterThan(before);
|
||||
});
|
||||
|
||||
it('keeps the row actions wired to the right client across re-renders', async () => {
|
||||
const onShowQr = vi.fn();
|
||||
const onEdit = vi.fn();
|
||||
const noop = vi.fn();
|
||||
let bump: () => void = () => {};
|
||||
|
||||
render(
|
||||
<Harness>
|
||||
{(doBump) => {
|
||||
bump = doBump;
|
||||
return (
|
||||
<ClientRowActions
|
||||
email="alice@x"
|
||||
onShowQr={onShowQr}
|
||||
onShowInfo={noop}
|
||||
onResetTraffic={noop}
|
||||
onEdit={onEdit}
|
||||
onDelete={noop}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</Harness>,
|
||||
);
|
||||
|
||||
for (let i = 0; i < 3; i++) bump();
|
||||
|
||||
// Queried by position rather than label: the suite loads the real en-US
|
||||
// bundle, so the aria-labels are translated strings, not keys. Order is
|
||||
// QR, info, reset traffic, edit, delete.
|
||||
const buttons = screen.getAllByRole('button');
|
||||
expect(buttons).toHaveLength(5);
|
||||
await userEvent.click(buttons[0]);
|
||||
await userEvent.click(buttons[3]);
|
||||
|
||||
expect(onShowQr).toHaveBeenCalledExactlyOnceWith('alice@x');
|
||||
expect(onEdit).toHaveBeenCalledExactlyOnceWith('alice@x');
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { computeClientsSummary } from '@/hooks/useClients';
|
||||
import type { ClientTraffic } from '@/schemas/client';
|
||||
import { computeClientsSummary, pickClientsSummary, sameSpeedMap, sameSummaryInputs } from '@/hooks/useClients';
|
||||
import type { ClientTraffic, ClientsSummary } from '@/schemas/client';
|
||||
|
||||
// Parity with web/service/client.go buildClientsSummary: the same client must
|
||||
// land in the same bucket whether the count comes from the server (list fetch)
|
||||
@@ -42,6 +42,24 @@ describe('computeClientsSummary', () => {
|
||||
expect(s.active).toBe(2); // online@x + offline@x
|
||||
});
|
||||
|
||||
it('reports a counter alongside every bucket list', () => {
|
||||
const stats: Row[] = [
|
||||
row({ email: 'online@x', enable: true }),
|
||||
row({ email: 'disabled@x', enable: false }),
|
||||
row({ email: 'exhausted@x', enable: true, total: 1 * GB, up: 1 * GB }),
|
||||
row({ email: 'nearlimit@x', enable: true, total: 10 * GB, up: 9.9 * GB }),
|
||||
];
|
||||
const s = computeClientsSummary(stats, new Set(['online@x']), 3 * DAY, 1 * GB);
|
||||
|
||||
// The server caps its lists but never its counters; the live recompute has
|
||||
// both, so the summary card reads the same either way.
|
||||
expect(s.onlineCount).toBe(s.online.length);
|
||||
expect(s.depletedCount).toBe(s.depleted.length);
|
||||
expect(s.expiringCount).toBe(s.expiring.length);
|
||||
expect(s.deactiveCount).toBe(s.deactive.length);
|
||||
expect(s.active + s.depletedCount + s.expiringCount + s.deactiveCount).toBe(s.total);
|
||||
});
|
||||
|
||||
it('depleted wins over disabled and over online', () => {
|
||||
const stats: Row[] = [
|
||||
row({ email: 'a@x', enable: false, total: 1 * GB, up: 2 * GB }),
|
||||
@@ -60,3 +78,65 @@ describe('computeClientsSummary', () => {
|
||||
expect(s.depleted).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pickClientsSummary', () => {
|
||||
const serverSummary: ClientsSummary = {
|
||||
total: 67, active: 58,
|
||||
onlineCount: 0, depletedCount: 4, expiringCount: 3, deactiveCount: 2,
|
||||
online: [], depleted: [], expiring: [], deactive: [],
|
||||
};
|
||||
|
||||
it('keeps the server summary when the snapshot is short of the server total (#6102)', () => {
|
||||
const shortSnapshot: Row[] = Array.from({ length: 58 }, (_, i) => row({ email: `c${i}@x`, enable: true }));
|
||||
const s = pickClientsSummary(serverSummary, shortSnapshot, new Set(), 3 * DAY, 1 * GB);
|
||||
expect(s).toEqual(serverSummary);
|
||||
});
|
||||
|
||||
it('uses the live recompute when the snapshot covers every client', () => {
|
||||
const fullSnapshot: Row[] = Array.from({ length: 67 }, (_, i) => row({ email: `c${i}@x`, enable: true }));
|
||||
const s = pickClientsSummary(serverSummary, fullSnapshot, new Set(), 3 * DAY, 1 * GB);
|
||||
expect(s.total).toBe(67);
|
||||
expect(s.active).toBe(67);
|
||||
});
|
||||
|
||||
it('falls back to the server summary before the first WS snapshot arrives', () => {
|
||||
const s = pickClientsSummary(serverSummary, [], new Set(), 3 * DAY, 1 * GB);
|
||||
expect(s).toEqual(serverSummary);
|
||||
});
|
||||
});
|
||||
|
||||
describe('websocket payload identity preservation', () => {
|
||||
const speed = (up: number, down: number) => ({ up, down });
|
||||
|
||||
it('treats an unchanged speed map as unchanged', () => {
|
||||
const a = { 'a@x': speed(1, 2), 'b@x': speed(3, 4) };
|
||||
expect(sameSpeedMap(a, { 'a@x': speed(1, 2), 'b@x': speed(3, 4) })).toBe(true);
|
||||
expect(sameSpeedMap(a, { 'a@x': speed(1, 2) })).toBe(false);
|
||||
expect(sameSpeedMap(a, { 'a@x': speed(1, 2), 'b@x': speed(3, 5) })).toBe(false);
|
||||
expect(sameSpeedMap(a, { 'a@x': speed(1, 2), 'c@x': speed(3, 4) })).toBe(false);
|
||||
expect(sameSpeedMap({}, {})).toBe(true);
|
||||
});
|
||||
|
||||
it('compares exactly the fields the summary reads, and ignores lastOnline', () => {
|
||||
const base: Row[] = [row({ email: 'a@x', up: 1, down: 2, total: 10, expiryTime: 99 })];
|
||||
|
||||
// lastOnline moves for every online client on every push and no counter
|
||||
// depends on it, so it must not force a new snapshot.
|
||||
const onlyLastOnlineMoved: Row[] = [
|
||||
row({ email: 'a@x', up: 1, down: 2, total: 10, expiryTime: 99, lastOnline: 12345 }),
|
||||
];
|
||||
expect(sameSummaryInputs(base, onlyLastOnlineMoved)).toBe(true);
|
||||
|
||||
for (const changed of [
|
||||
row({ email: 'b@x', up: 1, down: 2, total: 10, expiryTime: 99 }),
|
||||
row({ email: 'a@x', up: 2, down: 2, total: 10, expiryTime: 99 }),
|
||||
row({ email: 'a@x', up: 1, down: 3, total: 10, expiryTime: 99 }),
|
||||
row({ email: 'a@x', up: 1, down: 2, total: 11, expiryTime: 99 }),
|
||||
row({ email: 'a@x', up: 1, down: 2, total: 10, expiryTime: 100 }),
|
||||
row({ email: 'a@x', up: 1, down: 2, total: 10, expiryTime: 99, enable: false }),
|
||||
]) {
|
||||
expect(sameSummaryInputs(base, [changed])).toBe(false);
|
||||
}
|
||||
expect(sameSummaryInputs(base, [])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { fireEvent } from '@testing-library/react';
|
||||
import dayjs from 'dayjs';
|
||||
import type { Dayjs } from 'dayjs';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import DateTimePicker from '@/components/form/DateTimePicker';
|
||||
import { renderWithProviders } from './test-utils';
|
||||
|
||||
function openPicker(): void {
|
||||
const input = document.querySelector('.ant-picker input');
|
||||
if (!input) throw new Error('picker input not rendered');
|
||||
fireEvent.mouseDown(input);
|
||||
fireEvent.click(input);
|
||||
}
|
||||
|
||||
function clickDayCell(title: string): void {
|
||||
const cell = document.querySelector(`.ant-picker-cell[title="${title}"] .ant-picker-cell-inner`);
|
||||
if (!cell) throw new Error(`day cell ${title} not rendered`);
|
||||
fireEvent.click(cell);
|
||||
}
|
||||
|
||||
describe('DateTimePicker', () => {
|
||||
it('commits a clicked calendar date without an OK press', () => {
|
||||
const onChange = vi.fn<(next: Dayjs | null) => void>();
|
||||
renderWithProviders(<DateTimePicker value={null} onChange={onChange} />);
|
||||
|
||||
openPicker();
|
||||
const tomorrow = dayjs().add(1, 'day').format('YYYY-MM-DD');
|
||||
clickDayCell(tomorrow);
|
||||
|
||||
expect(onChange).toHaveBeenCalled();
|
||||
const committed = onChange.mock.calls.at(-1)?.[0];
|
||||
expect(committed?.format('YYYY-MM-DD HH:mm:ss')).toBe(`${tomorrow} 00:00:00`);
|
||||
});
|
||||
|
||||
it('renders no OK confirm button in the picker footer', () => {
|
||||
renderWithProviders(<DateTimePicker value={null} onChange={vi.fn()} />);
|
||||
|
||||
openPicker();
|
||||
|
||||
expect(document.querySelector('.ant-picker-ok')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { screen } from '@testing-library/react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { keys } from '@/api/queryKeys';
|
||||
import DefaultSettingTag, { matchesFactoryDefault } from '@/components/ui/DefaultSettingTag';
|
||||
import { makeTestQueryClient, renderWithProviders } from './test-utils';
|
||||
|
||||
function clientWithDefaults(defaults: Record<string, string>) {
|
||||
const queryClient = makeTestQueryClient();
|
||||
queryClient.setQueryData(keys.settings.factoryDefaults(), defaults);
|
||||
return queryClient;
|
||||
}
|
||||
|
||||
describe('matchesFactoryDefault', () => {
|
||||
it('compares by value with type-aware coercion', () => {
|
||||
expect(matchesFactoryDefault(2096, '2096')).toBe(true);
|
||||
expect(matchesFactoryDefault(8443, '2096')).toBe(false);
|
||||
expect(matchesFactoryDefault(true, 'true')).toBe(true);
|
||||
expect(matchesFactoryDefault(false, 'true')).toBe(false);
|
||||
expect(matchesFactoryDefault('/sub/', '/sub/')).toBe(true);
|
||||
expect(matchesFactoryDefault('/other/', '/sub/')).toBe(false);
|
||||
});
|
||||
|
||||
it('never matches when the key has no shipped default', () => {
|
||||
expect(matchesFactoryDefault(2096, undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects blank or unparsable defaults instead of coercing them', () => {
|
||||
expect(matchesFactoryDefault(0, '')).toBe(false);
|
||||
expect(matchesFactoryDefault(0, ' ')).toBe(false);
|
||||
expect(matchesFactoryDefault(0, 'none')).toBe(false);
|
||||
expect(matchesFactoryDefault(false, '')).toBe(false);
|
||||
expect(matchesFactoryDefault(false, 'no')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DefaultSettingTag', () => {
|
||||
it('shows the tag when the current value equals the shipped default, however it got there', () => {
|
||||
renderWithProviders(
|
||||
<DefaultSettingTag settingKey="subPort" value={2096} />,
|
||||
{ queryClient: clientWithDefaults({ subPort: '2096' }) },
|
||||
);
|
||||
|
||||
expect(screen.getByText('Default')).toBeDefined();
|
||||
});
|
||||
|
||||
it('renders nothing when the value differs from the default', () => {
|
||||
renderWithProviders(
|
||||
<DefaultSettingTag settingKey="subPort" value={8443} />,
|
||||
{ queryClient: clientWithDefaults({ subPort: '2096' }) },
|
||||
);
|
||||
|
||||
expect(screen.queryByText('Default')).toBeNull();
|
||||
});
|
||||
|
||||
it('renders nothing while defaults are unknown', () => {
|
||||
renderWithProviders(
|
||||
<DefaultSettingTag settingKey="subPort" value={2096} />,
|
||||
{ queryClient: makeTestQueryClient() },
|
||||
);
|
||||
|
||||
expect(screen.queryByText('Default')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
import { useState } from 'react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { fireEvent, screen } from '@testing-library/react';
|
||||
|
||||
import DnsTab from '@/pages/xray/dns/DnsTab';
|
||||
import type { SetTemplate, XraySettingsValue } from '@/hooks/useXraySetting';
|
||||
import { renderWithProviders } from './test-utils';
|
||||
|
||||
function withHosts(hosts: Record<string, string>): XraySettingsValue {
|
||||
return {
|
||||
dns: {
|
||||
hosts,
|
||||
servers: [],
|
||||
},
|
||||
} as unknown as XraySettingsValue;
|
||||
}
|
||||
|
||||
describe('DnsTab', () => {
|
||||
it('keeps an empty row after adding a host', () => {
|
||||
function Harness() {
|
||||
const [templateSettings, setTemplateSettings] = useState<XraySettingsValue | null>(withHosts({ 'first.example': '1.1.1.1' }));
|
||||
const updateTemplate: SetTemplate = (next) => {
|
||||
setTemplateSettings((current) => (typeof next === 'function' ? next(current) : next));
|
||||
};
|
||||
|
||||
return <DnsTab templateSettings={templateSettings} setTemplateSettings={updateTemplate} />;
|
||||
}
|
||||
|
||||
renderWithProviders(
|
||||
<Harness />,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: /Hosts$/ }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add Host$/ }));
|
||||
|
||||
expect(screen.getAllByLabelText('Domain (e.g. domain:example.com)')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('keeps a row visible while its domain is incomplete', () => {
|
||||
function Harness() {
|
||||
const [templateSettings, setTemplateSettings] = useState<XraySettingsValue | null>(withHosts({ 'first.example': '1.1.1.1' }));
|
||||
const updateTemplate: SetTemplate = (next) => {
|
||||
setTemplateSettings((current) => (typeof next === 'function' ? next(current) : next));
|
||||
};
|
||||
|
||||
return <DnsTab templateSettings={templateSettings} setTemplateSettings={updateTemplate} />;
|
||||
}
|
||||
|
||||
renderWithProviders(<Harness />);
|
||||
fireEvent.click(screen.getByRole('tab', { name: /Hosts$/ }));
|
||||
fireEvent.change(screen.getByLabelText('Domain (e.g. domain:example.com)'), { target: { value: '' } });
|
||||
|
||||
expect((screen.getByLabelText('Domain (e.g. domain:example.com)') as HTMLInputElement).value).toBe('');
|
||||
});
|
||||
|
||||
it('shows hosts from an externally refreshed configuration', () => {
|
||||
function Harness() {
|
||||
const [templateSettings, setTemplateSettings] = useState<XraySettingsValue | null>(withHosts({ 'first.example': '1.1.1.1' }));
|
||||
const updateTemplate: SetTemplate = (next) => {
|
||||
setTemplateSettings((current) => (typeof next === 'function' ? next(current) : next));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button type="button" onClick={() => setTemplateSettings(withHosts({ 'second.example': '2.2.2.2' }))}>
|
||||
Refresh hosts
|
||||
</button>
|
||||
<DnsTab templateSettings={templateSettings} setTemplateSettings={updateTemplate} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
renderWithProviders(<Harness />);
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: /Hosts$/ }));
|
||||
expect((screen.getByLabelText('Domain (e.g. domain:example.com)') as HTMLInputElement).value).toBe('first.example');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Refresh hosts' }));
|
||||
expect((screen.getByLabelText('Domain (e.g. domain:example.com)') as HTMLInputElement).value).toBe('second.example');
|
||||
});
|
||||
|
||||
it('clears an incomplete host draft when DNS is disabled', () => {
|
||||
function Harness() {
|
||||
const [templateSettings, setTemplateSettings] = useState<XraySettingsValue | null>(withHosts({ 'first.example': '1.1.1.1' }));
|
||||
const updateTemplate: SetTemplate = (next) => {
|
||||
setTemplateSettings((current) => (typeof next === 'function' ? next(current) : next));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button type="button" onClick={() => setTemplateSettings({})}>Disable DNS</button>
|
||||
<button type="button" onClick={() => setTemplateSettings(withHosts({}))}>Enable DNS</button>
|
||||
<DnsTab templateSettings={templateSettings} setTemplateSettings={updateTemplate} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
renderWithProviders(<Harness />);
|
||||
fireEvent.click(screen.getByRole('tab', { name: /Hosts$/ }));
|
||||
fireEvent.change(screen.getByLabelText('Domain (e.g. domain:example.com)'), { target: { value: '' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Disable DNS' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Enable DNS' }));
|
||||
fireEvent.click(screen.getByRole('tab', { name: /Hosts$/ }));
|
||||
|
||||
expect(screen.queryByLabelText('Domain (e.g. domain:example.com)')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { matchesFactoryDefault } from '@/components/ui/DefaultSettingTag';
|
||||
import { AllSetting } from '@/models/setting';
|
||||
|
||||
/*
|
||||
* Contract test between the three homes of a setting's default value: the Go
|
||||
* defaultValueMap (authoritative, served by /setting/factoryDefaults), the
|
||||
* frontend AllSetting class defaults (what the form shows when the API omits
|
||||
* a value), and the tag's own comparison. If someone bumps a default on one
|
||||
* side only, the Default tag would start calling a different value "Default"
|
||||
* than the one the form displays — this test fails instead.
|
||||
*/
|
||||
|
||||
function goDefaultLiterals(): Record<string, string> {
|
||||
const source = readFileSync(
|
||||
resolve(process.cwd(), '..', 'internal', 'web', 'service', 'setting.go'),
|
||||
'utf8',
|
||||
);
|
||||
const start = source.indexOf('var defaultValueMap = map[string]string{');
|
||||
const end = source.indexOf('\n}', start);
|
||||
const block = source.slice(start, end);
|
||||
const literals: Record<string, string> = {};
|
||||
for (const match of block.matchAll(/"([A-Za-z0-9]+)":\s+"((?:[^"\\]|\\.)*)"\s*,/g)) {
|
||||
literals[match[1]] = JSON.parse(`"${match[2]}"`);
|
||||
}
|
||||
return literals;
|
||||
}
|
||||
|
||||
describe('factory defaults contract', () => {
|
||||
const goDefaults = goDefaultLiterals();
|
||||
const frontend = new AllSetting() as unknown as Record<string, unknown>;
|
||||
const sharedKeys = Object.keys(goDefaults).filter((key) => {
|
||||
const value = frontend[key];
|
||||
return typeof value === 'number' || typeof value === 'boolean' || typeof value === 'string';
|
||||
});
|
||||
|
||||
it('parses a plausible slice of the Go map', () => {
|
||||
expect(goDefaults.webPort).toBe('2053');
|
||||
expect(goDefaults.subPort).toBe('2096');
|
||||
expect(sharedKeys.length).toBeGreaterThan(20);
|
||||
});
|
||||
|
||||
it.each(sharedKeys)('frontend default for %s matches the shipped default', (key) => {
|
||||
expect(
|
||||
matchesFactoryDefault(frontend[key], goDefaults[key]),
|
||||
`AllSetting.${key} = ${JSON.stringify(frontend[key])} vs defaultValueMap ${JSON.stringify(goDefaults[key])}`,
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
/// <reference types="vite/client" />
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { parseGeckoPacketSize } from '@/lib/xray/forms/transport/FinalMaskForm';
|
||||
import { migrateXmcSettings, parseGeckoPacketSize } from '@/lib/xray/forms/transport/FinalMaskForm';
|
||||
import { FinalMaskStreamSettingsSchema } from '@/schemas/protocols/stream';
|
||||
|
||||
const fixtures = import.meta.glob<unknown>(
|
||||
@@ -26,6 +26,50 @@ describe('FinalMaskStreamSettingsSchema fixtures', () => {
|
||||
}
|
||||
});
|
||||
|
||||
describe('migrateXmcSettings', () => {
|
||||
it('carries legacy usernames into profile stubs and drops the dead key', () => {
|
||||
const { next, changed } = migrateXmcSettings({
|
||||
hostname: 'mc.example.com',
|
||||
usernames: ['Dream', 'Notch'],
|
||||
password: 'pw',
|
||||
});
|
||||
|
||||
expect(changed).toBe(true);
|
||||
expect(next.usernames).toBeUndefined();
|
||||
expect(next.hostname).toBe('mc.example.com');
|
||||
expect(next.password).toBe('pw');
|
||||
expect(next.profiles).toEqual([
|
||||
{ username: 'Dream', uuid: '', texturesValue: '', texturesSignature: '' },
|
||||
{ username: 'Notch', uuid: '', texturesValue: '', texturesSignature: '' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('gives a mask with neither key an empty profiles list', () => {
|
||||
const { next, changed } = migrateXmcSettings({ hostname: '', password: 'pw' });
|
||||
|
||||
expect(changed).toBe(true);
|
||||
expect(next.profiles).toEqual([]);
|
||||
});
|
||||
|
||||
it('leaves an already migrated mask untouched', () => {
|
||||
const profiles = [
|
||||
{ username: 'Notch', uuid: '069a79f4-44e9-4726-a5be-fca90e38aaf5', texturesValue: 'dmFsdWU=', texturesSignature: 'c2ln' },
|
||||
];
|
||||
const { next, changed } = migrateXmcSettings({ hostname: '', password: 'pw', profiles });
|
||||
|
||||
expect(changed).toBe(false);
|
||||
expect(next.profiles).toEqual(profiles);
|
||||
});
|
||||
|
||||
it('discards blank legacy usernames rather than seeding unfixable stubs', () => {
|
||||
const { next } = migrateXmcSettings({ usernames: ['Dream', '', ' '], password: 'pw' });
|
||||
|
||||
expect(next.profiles).toEqual([
|
||||
{ username: 'Dream', uuid: '', texturesValue: '', texturesSignature: '' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseGeckoPacketSize', () => {
|
||||
it('accepts positive ordered packet size ranges', () => {
|
||||
expect(parseGeckoPacketSize('512-1200')).toEqual({ min: 512, max: 1200 });
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { fireEvent, screen } from '@testing-library/react';
|
||||
import { MemoryRouter } from 'react-router';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { AllSetting } from '@/models/setting';
|
||||
import GeneralTab from '@/pages/settings/GeneralTab';
|
||||
import { renderWithProviders } from './test-utils';
|
||||
|
||||
describe('GeneralTab', () => {
|
||||
it('keeps the stored page size when the field is cleared', () => {
|
||||
const updateSetting = vi.fn();
|
||||
|
||||
renderWithProviders(
|
||||
<MemoryRouter initialEntries={['/settings']}>
|
||||
<GeneralTab allSetting={new AllSetting({ pageSize: 25 })} updateSetting={updateSetting} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
const pageSizeInput = screen.getByDisplayValue('25');
|
||||
fireEvent.change(pageSizeInput, { target: { value: '' } });
|
||||
fireEvent.blur(pageSizeInput);
|
||||
|
||||
expect(updateSetting).not.toHaveBeenCalled();
|
||||
expect((pageSizeInput as HTMLInputElement).value).toBe('25');
|
||||
});
|
||||
|
||||
it('forwards typed page sizes unchanged, zero included', () => {
|
||||
const updateSetting = vi.fn();
|
||||
|
||||
renderWithProviders(
|
||||
<MemoryRouter initialEntries={['/settings']}>
|
||||
<GeneralTab allSetting={new AllSetting({ pageSize: 25 })} updateSetting={updateSetting} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByDisplayValue('25'), { target: { value: '0' } });
|
||||
|
||||
expect(updateSetting).toHaveBeenCalledWith({ pageSize: 0 });
|
||||
});
|
||||
});
|
||||
@@ -1,15 +1,35 @@
|
||||
{
|
||||
"tcp": [
|
||||
{ "type": "fragment", "settings": { "packets": "1-3" } }
|
||||
{
|
||||
"type": "fragment",
|
||||
"settings": {
|
||||
"packets": "1-3",
|
||||
"length": "10-20"
|
||||
}
|
||||
}
|
||||
],
|
||||
"udp": [
|
||||
{ "type": "salamander", "settings": { "password": "swordfish" } },
|
||||
{ "type": "mkcp-legacy", "settings": { "header": "wireguard", "value": "" } }
|
||||
{
|
||||
"type": "salamander",
|
||||
"settings": {
|
||||
"password": "swordfish"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "mkcp-legacy",
|
||||
"settings": {
|
||||
"header": "wireguard",
|
||||
"value": ""
|
||||
}
|
||||
}
|
||||
],
|
||||
"quicParams": {
|
||||
"congestion": "brutal",
|
||||
"brutalUp": "100 mbps",
|
||||
"brutalDown": "200 mbps",
|
||||
"udpHop": { "ports": "10000-20000", "interval": "5-10" }
|
||||
"udpHop": {
|
||||
"ports": "10000-20000",
|
||||
"interval": "5-10"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,18 +9,28 @@
|
||||
"maxSplit": "0"
|
||||
}
|
||||
},
|
||||
{ "type": "sudoku" },
|
||||
{
|
||||
"type": "sudoku"
|
||||
},
|
||||
{
|
||||
"type": "header-custom",
|
||||
"settings": {
|
||||
"clients": [
|
||||
[
|
||||
{ "type": "str", "packet": ["GET / HTTP/1.1"], "delay": 0 }
|
||||
{
|
||||
"type": "str",
|
||||
"packet": "GET / HTTP/1.1",
|
||||
"delay": 0
|
||||
}
|
||||
]
|
||||
],
|
||||
"servers": [
|
||||
[
|
||||
{ "type": "str", "packet": ["HTTP/1.1 200 OK"], "delay": 0 }
|
||||
{
|
||||
"type": "str",
|
||||
"packet": "HTTP/1.1 200 OK",
|
||||
"delay": 0
|
||||
}
|
||||
]
|
||||
],
|
||||
"errors": []
|
||||
@@ -30,8 +40,15 @@
|
||||
"type": "xmc",
|
||||
"settings": {
|
||||
"hostname": "mc.example.com",
|
||||
"usernames": ["Dream"],
|
||||
"password": "s3cr3t"
|
||||
"password": "s3cr3t",
|
||||
"profiles": [
|
||||
{
|
||||
"username": "Dream",
|
||||
"uuid": "ec70bcaf-702f-4bb8-b48d-276fa52a780c",
|
||||
"texturesValue": "eyJ0aW1lc3RhbXAiOjE3MDAwMDAwMDAwMDAsInByb2ZpbGVJZCI6ImVjNzBiY2FmNzAyZjRiYjhiNDhkMjc2ZmE1MmE3ODBjIn0=",
|
||||
"texturesSignature": "Zm9yLWZpeHR1cmUtdXNlLW9ubHktbm90LWEtcmVhbC1tb2phbmctc2lnbmF0dXJl"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,35 +1,77 @@
|
||||
{
|
||||
"udp": [
|
||||
{ "type": "salamander", "settings": { "password": "swordfish" } },
|
||||
{ "type": "mkcp-legacy", "settings": { "header": "", "value": "abcdef0123456789" } },
|
||||
{ "type": "mkcp-legacy", "settings": { "header": "dns", "value": "cloudflare.com" } },
|
||||
{ "type": "mkcp-legacy", "settings": { "header": "wireguard", "value": "" } },
|
||||
{
|
||||
"type": "salamander",
|
||||
"settings": {
|
||||
"password": "swordfish"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "mkcp-legacy",
|
||||
"settings": {
|
||||
"header": "",
|
||||
"value": "abcdef0123456789"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "mkcp-legacy",
|
||||
"settings": {
|
||||
"header": "dns",
|
||||
"value": "cloudflare.com"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "mkcp-legacy",
|
||||
"settings": {
|
||||
"header": "wireguard",
|
||||
"value": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "noise",
|
||||
"settings": {
|
||||
"reset": "60",
|
||||
"noise": [
|
||||
{ "type": "rand", "rand": "10-20", "delay": "10-16" },
|
||||
{ "type": "str", "packet": ["ping"], "delay": "5" }
|
||||
{
|
||||
"type": "array",
|
||||
"rand": "10-20",
|
||||
"delay": "10-16"
|
||||
},
|
||||
{
|
||||
"type": "str",
|
||||
"packet": "ping",
|
||||
"delay": "5"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "xdns",
|
||||
"settings": {
|
||||
"domains": ["example.com:txt", "example.org:a"],
|
||||
"resolvers": ["example.com:txt+udp://1.1.1.1:53"]
|
||||
"domains": [
|
||||
"example.com:txt",
|
||||
"example.org:a"
|
||||
],
|
||||
"resolvers": [
|
||||
"example.com:txt+udp://1.1.1.1:53"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "xicmp",
|
||||
"settings": { "dgram": false, "ips": [] }
|
||||
"settings": {
|
||||
"dgram": false,
|
||||
"ips": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "realm",
|
||||
"settings": {
|
||||
"url": "realm://public@example.com/my-realm",
|
||||
"stunServers": ["stun.l.google.com:19302", "global.stun.twilio.com:3478"]
|
||||
"stunServers": [
|
||||
"stun.l.google.com:19302",
|
||||
"global.stun.twilio.com:3478"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
+12
-5
@@ -3,15 +3,20 @@
|
||||
"up": 0,
|
||||
"down": 0,
|
||||
"total": 0,
|
||||
"remark": "gina-hysteria-v1",
|
||||
"remark": "gina-hysteria",
|
||||
"enable": true,
|
||||
"expiryTime": 0,
|
||||
"listen": "",
|
||||
"port": 36715,
|
||||
"tag": "inbound-hysteria-v1",
|
||||
"tag": "inbound-hysteria",
|
||||
"sniffing": {
|
||||
"enabled": false,
|
||||
"destOverride": ["http", "tls", "quic", "fakedns"],
|
||||
"destOverride": [
|
||||
"http",
|
||||
"tls",
|
||||
"quic",
|
||||
"fakedns"
|
||||
],
|
||||
"metadataOnly": false,
|
||||
"routeOnly": false,
|
||||
"ipsExcluded": [],
|
||||
@@ -19,7 +24,7 @@
|
||||
},
|
||||
"protocol": "hysteria",
|
||||
"settings": {
|
||||
"version": 1,
|
||||
"version": 2,
|
||||
"clients": [
|
||||
{
|
||||
"auth": "hyst-v1-auth-XYZ",
|
||||
@@ -56,7 +61,9 @@
|
||||
"buildChain": false
|
||||
}
|
||||
],
|
||||
"alpn": ["h3"],
|
||||
"alpn": [
|
||||
"h3"
|
||||
],
|
||||
"echServerKeys": "",
|
||||
"settings": {
|
||||
"fingerprint": "chrome",
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"protocol": "hysteria",
|
||||
"settings": {
|
||||
"version": 1,
|
||||
"version": 2,
|
||||
"clients": [
|
||||
{
|
||||
"auth": "hyst3ria-v1-token-XYZ",
|
||||
|
||||
@@ -9,6 +9,6 @@
|
||||
"xPaddingKey": "secret-key",
|
||||
"xPaddingHeader": "X-Pad",
|
||||
"xPaddingPlacement": "header",
|
||||
"xPaddingMethod": "random"
|
||||
"xPaddingMethod": "tokenish"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
"xhttpSettings": {
|
||||
"path": "/sp",
|
||||
"host": "edge.example.test",
|
||||
"mode": "auto",
|
||||
"mode": "packet-up",
|
||||
"sessionIDPlacement": "header",
|
||||
"sessionIDKey": "X-Session",
|
||||
"seqPlacement": "cookie",
|
||||
"seqKey": "X-Seq",
|
||||
"uplinkDataPlacement": "query",
|
||||
"uplinkDataPlacement": "cookie",
|
||||
"uplinkDataKey": "u"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,6 @@
|
||||
},
|
||||
"xmux": {
|
||||
"maxConcurrency": "16-32",
|
||||
"maxConnections": 4,
|
||||
"cMaxReuseTimes": 0,
|
||||
"hMaxRequestTimes": "600-900",
|
||||
"hMaxReusableSecs": "1800-3000",
|
||||
|
||||
@@ -193,4 +193,87 @@ describe('http-init fetch wrapper', () => {
|
||||
|
||||
expect(initOf().signal).toBeInstanceOf(AbortSignal);
|
||||
});
|
||||
|
||||
it('preserves a caller cancellation signal when a timeout is set', async () => {
|
||||
http.setupHttp();
|
||||
fetchMock.mockResolvedValue(okEnvelope());
|
||||
const controller = new AbortController();
|
||||
|
||||
await http.httpRequest('GET', '/x', undefined, { timeout: 1_000, signal: controller.signal });
|
||||
controller.abort();
|
||||
|
||||
expect(initOf().signal?.aborted).toBe(true);
|
||||
});
|
||||
|
||||
it('preserves both cancellation paths when AbortSignal.any is unavailable', async () => {
|
||||
const timeout = AbortSignal.timeout.bind(AbortSignal);
|
||||
vi.resetModules();
|
||||
vi.stubGlobal('AbortSignal', { timeout });
|
||||
http = await import('@/api/http-init');
|
||||
http.setupHttp();
|
||||
fetchMock.mockResolvedValue(okEnvelope());
|
||||
const controller = new AbortController();
|
||||
|
||||
await http.httpRequest('GET', '/x', undefined, { timeout: 1_000, signal: controller.signal });
|
||||
controller.abort();
|
||||
|
||||
expect(initOf().signal?.aborted).toBe(true);
|
||||
});
|
||||
|
||||
it('times out when AbortSignal.any is unavailable', async () => {
|
||||
const timeout = AbortSignal.timeout.bind(AbortSignal);
|
||||
vi.resetModules();
|
||||
vi.stubGlobal('AbortSignal', { timeout });
|
||||
http = await import('@/api/http-init');
|
||||
http.setupHttp();
|
||||
fetchMock.mockResolvedValue(okEnvelope());
|
||||
const controller = new AbortController();
|
||||
|
||||
await http.httpRequest('GET', '/x', undefined, { timeout: 20, signal: controller.signal });
|
||||
const signal = initOf().signal as AbortSignal;
|
||||
await new Promise<void>((resolve) => signal.addEventListener('abort', () => resolve(), { once: true }));
|
||||
|
||||
expect(signal.aborted).toBe(true);
|
||||
});
|
||||
|
||||
it('aborts on the timeout when a caller signal is present', async () => {
|
||||
http.setupHttp();
|
||||
fetchMock.mockResolvedValue(okEnvelope());
|
||||
const controller = new AbortController();
|
||||
|
||||
await http.httpRequest('GET', '/x', undefined, { timeout: 20, signal: controller.signal });
|
||||
const signal = initOf().signal as AbortSignal;
|
||||
await new Promise<void>((resolve) => {
|
||||
if (signal.aborted) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
signal.addEventListener('abort', () => resolve(), { once: true });
|
||||
});
|
||||
|
||||
expect(signal.aborted).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['/x?keep=1', '/x?keep=1&added=yes'],
|
||||
['/x?', '/x?added=yes'],
|
||||
['/x#frag', '/x?added=yes#frag'],
|
||||
['/x?keep=1#frag', '/x?keep=1&added=yes#frag'],
|
||||
])('appends encoded params to %s', async (url, expected) => {
|
||||
http.setupHttp();
|
||||
fetchMock.mockResolvedValue(okEnvelope());
|
||||
|
||||
await http.httpRequest('GET', url, undefined, { params: { added: 'yes' } });
|
||||
|
||||
expect(urlOf()).toBe(expected);
|
||||
});
|
||||
|
||||
it('preserves the URL when no params are supplied', async () => {
|
||||
http.setupHttp();
|
||||
fetchMock.mockResolvedValue(okEnvelope());
|
||||
|
||||
await http.httpRequest('GET', '/x?keep=1#frag');
|
||||
|
||||
expect(urlOf()).toBe('/x?keep=1#frag');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { join, resolve } from 'node:path';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
/*
|
||||
* Guards the 13-locale translation set two ways: every key in en-US must be
|
||||
* referenced somewhere in the frontend or Go sources (dead keys accumulate
|
||||
* silently — this test deleted over two hundred of them when it was
|
||||
* introduced), and every locale must carry exactly the en-US key set
|
||||
* (missing keys fall back to en-US at runtime, so nothing else fails the
|
||||
* build when a translation is forgotten).
|
||||
*
|
||||
* References are matched as whole dotted tokens, not substrings, so a dead
|
||||
* key cannot hide behind a longer live sibling. Dynamically built keys are
|
||||
* covered by harvesting the string-literal prefixes that appear next to
|
||||
* concatenation or template-literal interpolation; the prefix needs at
|
||||
* least one dot but need not end on one, so a literal that stops mid-leaf
|
||||
* right before the interpolation still keeps its subtree alive. This file
|
||||
* excludes itself from the scan so the prose above cannot whitelist
|
||||
* anything.
|
||||
*/
|
||||
|
||||
const repoRoot = resolve(process.cwd(), '..');
|
||||
const translationDir = join(repoRoot, 'internal', 'web', 'translation');
|
||||
const selfPath = fileURLToPath(import.meta.url);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function collectSources(dir: string, exts: string[], out: string[]): void {
|
||||
for (const entry of readdirSync(dir)) {
|
||||
if (['node_modules', 'dist', 'generated', '.git', '.local', 'storybook-static'].includes(entry)) continue;
|
||||
const full = join(dir, entry);
|
||||
if (statSync(full).isDirectory()) {
|
||||
collectSources(full, exts, out);
|
||||
} else if (exts.some((ext) => entry.endsWith(ext)) && resolve(full) !== selfPath) {
|
||||
out.push(readFileSync(full, 'utf8'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('i18n keys', () => {
|
||||
const enUS = JSON.parse(readFileSync(join(translationDir, 'en-US.json'), 'utf8'));
|
||||
const enKeys = flattenKeys(enUS);
|
||||
|
||||
const sources: string[] = [];
|
||||
collectSources(join(repoRoot, 'frontend', 'src'), ['.ts', '.tsx'], sources);
|
||||
collectSources(join(repoRoot, 'internal'), ['.go'], sources);
|
||||
const blob = sources.join('\n');
|
||||
|
||||
const tokens = new Set(blob.match(/[A-Za-z][A-Za-z0-9_.]*/g) ?? []);
|
||||
|
||||
const prefixes: string[] = [];
|
||||
for (const match of blob.matchAll(/['"`]([A-Za-z][A-Za-z0-9_]*(?:\.[A-Za-z0-9_]+)+\.?)['"`]\s*\+/g)) {
|
||||
prefixes.push(match[1]);
|
||||
}
|
||||
for (const match of blob.matchAll(/[`']([A-Za-z][A-Za-z0-9_]*(?:\.[A-Za-z0-9_]+)+\.?)\$\{/g)) {
|
||||
prefixes.push(match[1]);
|
||||
}
|
||||
|
||||
it('every en-US key is referenced by the frontend or Go sources', () => {
|
||||
const dead = enKeys.filter(
|
||||
(key) => !tokens.has(key) && !prefixes.some((p) => key.startsWith(p)),
|
||||
);
|
||||
expect(dead, `dead i18n keys (delete from all 13 locales):\n ${dead.join('\n ')}`).toEqual([]);
|
||||
});
|
||||
|
||||
it('every locale carries exactly the en-US key set', () => {
|
||||
const enSet = new Set(enKeys);
|
||||
for (const file of readdirSync(translationDir)) {
|
||||
if (!file.endsWith('.json') || file === 'en-US.json') continue;
|
||||
const keys = new Set(flattenKeys(JSON.parse(readFileSync(join(translationDir, file), 'utf8'))));
|
||||
const missing = enKeys.filter((k) => !keys.has(k));
|
||||
const orphans = [...keys].filter((k) => !enSet.has(k));
|
||||
expect(missing, `${file} is missing keys:\n ${missing.join('\n ')}`).toEqual([]);
|
||||
expect(orphans, `${file} has keys absent from en-US:\n ${orphans.join('\n ')}`).toEqual([]);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -32,6 +32,7 @@ const vlessRow: RawInboundRow = {
|
||||
total: 1_000_000_000,
|
||||
expiryTime: 0,
|
||||
trafficReset: 'monthly',
|
||||
trafficResetDay: 15,
|
||||
lastTrafficResetTime: 0,
|
||||
tag: 'inbound-1',
|
||||
nodeId: null,
|
||||
@@ -267,6 +268,7 @@ describe('formValuesToWirePayload', () => {
|
||||
enable: payload.enable,
|
||||
expiryTime: payload.expiryTime,
|
||||
trafficReset: payload.trafficReset,
|
||||
trafficResetDay: payload.trafficResetDay,
|
||||
lastTrafficResetTime: payload.lastTrafficResetTime,
|
||||
nodeId: payload.nodeId ?? null,
|
||||
});
|
||||
@@ -276,8 +278,13 @@ describe('formValuesToWirePayload', () => {
|
||||
expect(replay.listen).toBe(original.listen);
|
||||
expect(replay.up).toBe(original.up);
|
||||
expect(replay.down).toBe(original.down);
|
||||
expect(replay.trafficResetDay).toBe(original.trafficResetDay);
|
||||
expect(replay.streamSettings).toEqual(original.streamSettings);
|
||||
});
|
||||
|
||||
it('defaults a missing monthly reset day to the first', () => {
|
||||
expect(rawInboundToFormValues({ ...vlessRow, trafficResetDay: undefined }).trafficResetDay).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('subSortIndex', () => {
|
||||
|
||||
@@ -811,3 +811,73 @@ describe('genVlessLink flow gating (#5322)', () => {
|
||||
expect(new URL(link).searchParams.get('flow')).toBe('xtls-rprx-vision');
|
||||
});
|
||||
});
|
||||
|
||||
describe('genVlessLink XHTTP extra compatibility', () => {
|
||||
it('emits both sessionID and legacy session keys in XHTTP extra', () => {
|
||||
const typed = InboundSchema.parse({
|
||||
id: 1,
|
||||
up: 0,
|
||||
down: 0,
|
||||
total: 0,
|
||||
remark: 'xhttp-session',
|
||||
enable: true,
|
||||
expiryTime: 0,
|
||||
listen: '',
|
||||
port: 443,
|
||||
tag: 'inbound-vless-xhttp',
|
||||
sniffing: {
|
||||
enabled: false,
|
||||
destOverride: [],
|
||||
metadataOnly: false,
|
||||
routeOnly: false,
|
||||
ipsExcluded: [],
|
||||
domainsExcluded: [],
|
||||
},
|
||||
protocol: 'vless',
|
||||
settings: {
|
||||
clients: [
|
||||
{
|
||||
id: '11111111-2222-3333-4444-555555555555',
|
||||
email: 'a@example.test',
|
||||
flow: '',
|
||||
limitIp: 0,
|
||||
totalGB: 0,
|
||||
expiryTime: 0,
|
||||
enable: true,
|
||||
tgId: 0,
|
||||
subId: 's1',
|
||||
comment: '',
|
||||
reset: 0,
|
||||
},
|
||||
],
|
||||
decryption: 'none',
|
||||
encryption: 'none',
|
||||
fallbacks: [],
|
||||
},
|
||||
streamSettings: {
|
||||
network: 'xhttp',
|
||||
security: 'none',
|
||||
xhttpSettings: {
|
||||
path: '/sp',
|
||||
host: 'edge.example.test',
|
||||
mode: 'auto',
|
||||
sessionIDPlacement: 'header',
|
||||
sessionIDKey: 'X-Session',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const link = genVlessLink({
|
||||
inbound: typed,
|
||||
address: 'example.test',
|
||||
port: 443,
|
||||
clientId: '11111111-2222-3333-4444-555555555555',
|
||||
});
|
||||
const extra = JSON.parse(new URL(link).searchParams.get('extra') ?? '{}') as Record<string, unknown>;
|
||||
|
||||
expect(extra.sessionIDPlacement).toBe('header');
|
||||
expect(extra.sessionIDKey).toBe('X-Session');
|
||||
expect(extra.sessionPlacement).toBe('header');
|
||||
expect(extra.sessionKey).toBe('X-Session');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { onNumber } from '@/utils/onNumber';
|
||||
|
||||
describe('onNumber', () => {
|
||||
it('forwards numeric values, including zero and negatives', () => {
|
||||
const apply = vi.fn();
|
||||
const handler = onNumber(apply);
|
||||
handler(8443);
|
||||
handler(0);
|
||||
handler(-1);
|
||||
expect(apply.mock.calls).toEqual([[8443], [0], [-1]]);
|
||||
});
|
||||
|
||||
it('ignores cleared events instead of writing a synthetic value', () => {
|
||||
const apply = vi.fn();
|
||||
const handler = onNumber(apply);
|
||||
handler(null);
|
||||
handler(undefined);
|
||||
handler(NaN);
|
||||
expect(apply).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { AllSettingSchema } from '@/schemas/setting';
|
||||
import { AllSetting } from '@/models/setting';
|
||||
|
||||
describe('subShowIdentityOnAllLinks', () => {
|
||||
it('defaults to false on AllSetting', () => {
|
||||
expect(new AllSetting().subShowIdentityOnAllLinks).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts boolean values in the settings schema', () => {
|
||||
for (const v of [true, false]) {
|
||||
const r = AllSettingSchema.safeParse({ subShowIdentityOnAllLinks: v });
|
||||
expect(r.success, `subShowIdentityOnAllLinks=${v}`).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects non-boolean values', () => {
|
||||
for (const v of ['true', 1, null]) {
|
||||
expect(AllSettingSchema.safeParse({ subShowIdentityOnAllLinks: v }).success).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { render } from '@testing-library/react';
|
||||
import { afterEach, expect, test } from 'vitest';
|
||||
|
||||
import { withTheme } from '../../.storybook/preview';
|
||||
import { ThemeProvider } from '@/hooks/useTheme';
|
||||
|
||||
function Story() {
|
||||
return <div>Story</div>;
|
||||
}
|
||||
|
||||
function StorybookTheme({ theme }: { theme: 'light' | 'dark' }) {
|
||||
return withTheme(Story, { globals: { theme } } as Partial<Parameters<typeof withTheme>[1]> as Parameters<typeof withTheme>[1]);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
document.body.className = '';
|
||||
document.documentElement.removeAttribute('data-theme');
|
||||
});
|
||||
|
||||
test('preserves unrelated body classes when applying the Storybook theme', () => {
|
||||
document.body.className = 'storybook-fixture dark';
|
||||
document.documentElement.setAttribute('data-theme', 'ultra-dark');
|
||||
const { rerender } = render(<StorybookTheme theme="light" />);
|
||||
|
||||
expect(document.body.classList.contains('storybook-fixture')).toBe(true);
|
||||
expect(document.body.classList.contains('light')).toBe(true);
|
||||
expect(document.body.classList.contains('dark')).toBe(false);
|
||||
expect(document.documentElement.hasAttribute('data-theme')).toBe(false);
|
||||
|
||||
rerender(<StorybookTheme theme="dark" />);
|
||||
|
||||
expect(document.body.classList.contains('storybook-fixture')).toBe(true);
|
||||
expect(document.body.classList.contains('dark')).toBe(true);
|
||||
expect(document.body.classList.contains('light')).toBe(false);
|
||||
});
|
||||
|
||||
test('preserves unrelated body classes when applying the panel theme', () => {
|
||||
document.body.className = 'panel-fixture';
|
||||
const message = document.createElement('div');
|
||||
message.id = 'message';
|
||||
message.className = 'message-fixture';
|
||||
document.body.append(message);
|
||||
|
||||
render(<ThemeProvider><div>Panel</div></ThemeProvider>);
|
||||
|
||||
expect(document.body.classList.contains('panel-fixture')).toBe(true);
|
||||
expect(document.body.classList.contains('dark')).toBe(true);
|
||||
expect(document.body.classList.contains('light')).toBe(false);
|
||||
expect(message.classList.contains('message-fixture')).toBe(true);
|
||||
expect(message.classList.contains('dark')).toBe(true);
|
||||
});
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
normalizeSockoptForWire,
|
||||
normalizeStreamSettingsForWire,
|
||||
normalizeXhttpForWire,
|
||||
validateRealityClientVer,
|
||||
validateRealityMaxClientVer,
|
||||
validateRealityTarget,
|
||||
} from '@/lib/xray/stream-wire-normalize';
|
||||
import { InboundFormSchema } from '@/schemas/forms/inbound-form';
|
||||
@@ -26,6 +28,64 @@ describe('validateRealityTarget', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateRealityClientVer', () => {
|
||||
it('accepts empty (not set) and core-style versions', () => {
|
||||
expect(validateRealityClientVer('')).toBeUndefined();
|
||||
expect(validateRealityClientVer('26.3.27')).toBeUndefined();
|
||||
expect(validateRealityClientVer('1.0.0')).toBeUndefined();
|
||||
expect(validateRealityClientVer('26')).toBeUndefined();
|
||||
expect(validateRealityClientVer('26.3')).toBeUndefined();
|
||||
expect(validateRealityClientVer('0.0.255')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects untrimmed values because the save path ships them verbatim', () => {
|
||||
expect(validateRealityClientVer('26.3.27 ')).toBe('pages.inbounds.form.clientVerInvalid');
|
||||
expect(validateRealityClientVer(' 26.3.27')).toBe('pages.inbounds.form.clientVerInvalid');
|
||||
expect(validateRealityClientVer(' ')).toBe('pages.inbounds.form.clientVerInvalid');
|
||||
});
|
||||
|
||||
it('rejects what the core parser rejects', () => {
|
||||
expect(validateRealityClientVer('26.3.27.1')).toBe('pages.inbounds.form.clientVerInvalid');
|
||||
expect(validateRealityClientVer('26.3.256')).toBe('pages.inbounds.form.clientVerInvalid');
|
||||
expect(validateRealityClientVer('v26.3.27')).toBe('pages.inbounds.form.clientVerInvalid');
|
||||
expect(validateRealityClientVer('26..27')).toBe('pages.inbounds.form.clientVerInvalid');
|
||||
expect(validateRealityClientVer('26.3.')).toBe('pages.inbounds.form.clientVerInvalid');
|
||||
expect(validateRealityClientVer('-1.0.0')).toBe('pages.inbounds.form.clientVerInvalid');
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateRealityMaxClientVer', () => {
|
||||
it('accepts an empty max, an empty min, and a valid range', () => {
|
||||
expect(validateRealityMaxClientVer('', '26.3.27')).toBeUndefined();
|
||||
expect(validateRealityMaxClientVer('27.0.0', '')).toBeUndefined();
|
||||
expect(validateRealityMaxClientVer('26.3.27', '26.3.27')).toBeUndefined();
|
||||
expect(validateRealityMaxClientVer('27.1.2', '26.3.27')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects a max below the min, the stale-placeholder trap included', () => {
|
||||
expect(validateRealityMaxClientVer('25.9.11', '26.3.27')).toBe(
|
||||
'pages.inbounds.form.maxClientVerBelowMin',
|
||||
);
|
||||
expect(validateRealityMaxClientVer('26.3.26', '26.3.27')).toBe(
|
||||
'pages.inbounds.form.maxClientVerBelowMin',
|
||||
);
|
||||
});
|
||||
|
||||
it('pads short versions like the core does before comparing', () => {
|
||||
expect(validateRealityMaxClientVer('26', '26.0.0')).toBeUndefined();
|
||||
expect(validateRealityMaxClientVer('26', '26.3')).toBe(
|
||||
'pages.inbounds.form.maxClientVerBelowMin',
|
||||
);
|
||||
});
|
||||
|
||||
it('reports format errors before range errors and skips a malformed min', () => {
|
||||
expect(validateRealityMaxClientVer('25.9', 'not-a-version')).toBeUndefined();
|
||||
expect(validateRealityMaxClientVer('nope', '26.3.27')).toBe(
|
||||
'pages.inbounds.form.clientVerInvalid',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeXhttpForWire stream-one', () => {
|
||||
it('drops packet-up and stream-up-only fields on inbound', () => {
|
||||
const out = normalizeXhttpForWire({
|
||||
@@ -158,8 +218,8 @@ describe('normalizeXhttpForWire stream-one', () => {
|
||||
expect(XHttpXmuxSchema.parse({}).maxConcurrency).toBe('16-32');
|
||||
});
|
||||
|
||||
it('XMUX_FRESH_DEFAULTS seeds the anti-RKN maxConnections=6 without a competing maxConcurrency', () => {
|
||||
expect(XMUX_FRESH_DEFAULTS.maxConnections).toBe(6);
|
||||
it('XMUX_FRESH_DEFAULTS seeds the core maxConnections fallback without a competing maxConcurrency', () => {
|
||||
expect(XMUX_FRESH_DEFAULTS.maxConnections).toBe(3);
|
||||
expect(XMUX_FRESH_DEFAULTS.maxConcurrency).toBe('');
|
||||
|
||||
const out = normalizeXhttpForWire({
|
||||
@@ -170,7 +230,7 @@ describe('normalizeXhttpForWire stream-one', () => {
|
||||
}, 'outbound');
|
||||
|
||||
const xmux = out.xmux as Record<string, unknown>;
|
||||
expect(xmux.maxConnections).toBe(6);
|
||||
expect(xmux.maxConnections).toBe(3);
|
||||
expect(xmux.maxConcurrency).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,6 +12,37 @@ function LocationProbe() {
|
||||
}
|
||||
|
||||
describe('SubscriptionGeneralTab', () => {
|
||||
it('keeps the stored subscription port when the field is cleared', () => {
|
||||
const updateSetting = vi.fn();
|
||||
|
||||
renderWithProviders(
|
||||
<MemoryRouter initialEntries={['/settings#subscription']}>
|
||||
<SubscriptionGeneralTab allSetting={new AllSetting({ subPort: 2096 })} updateSetting={updateSetting} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
const portInput = screen.getByDisplayValue('2096');
|
||||
fireEvent.change(portInput, { target: { value: '' } });
|
||||
fireEvent.blur(portInput);
|
||||
|
||||
expect(updateSetting).not.toHaveBeenCalled();
|
||||
expect((portInput as HTMLInputElement).value).toBe('2096');
|
||||
});
|
||||
|
||||
it('forwards typed subscription ports unchanged', () => {
|
||||
const updateSetting = vi.fn();
|
||||
|
||||
renderWithProviders(
|
||||
<MemoryRouter initialEntries={['/settings#subscription']}>
|
||||
<SubscriptionGeneralTab allSetting={new AllSetting({ subPort: 2096 })} updateSetting={updateSetting} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByDisplayValue('2096'), { target: { value: '8443' } });
|
||||
|
||||
expect(updateSetting).toHaveBeenCalledWith({ subPort: 8443 });
|
||||
});
|
||||
|
||||
it('uses router navigation to open subscription format settings', () => {
|
||||
const allSetting = new AllSetting({ subClashEnable: true });
|
||||
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import { QueryClientProvider } from '@tanstack/react-query';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { useAllSettings } from '@/api/queries/useAllSettings';
|
||||
import { keys } from '@/api/queryKeys';
|
||||
import { makeTestQueryClient } from '@/test/test-utils';
|
||||
import { HttpUtil, Msg } from '@/utils';
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('useAllSettings', () => {
|
||||
it('keeps backend-accepted settings editable when the frontend schema is stricter', async () => {
|
||||
const subJsonUserAgentRegex = 'x'.repeat(2_049);
|
||||
vi.spyOn(HttpUtil, 'post').mockResolvedValue(new Msg(true, '', { subJsonUserAgentRegex }));
|
||||
const queryClient = makeTestQueryClient();
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useAllSettings(), { wrapper });
|
||||
|
||||
await waitFor(() => expect(result.current.fetched).toBe(true));
|
||||
expect(result.current.allSetting.subJsonUserAgentRegex).toBe(subJsonUserAgentRegex);
|
||||
});
|
||||
|
||||
it('keeps an edited setting when a refetch returns older server data', async () => {
|
||||
const values = [
|
||||
{ webPort: 2053 },
|
||||
{ webPort: 2054 },
|
||||
];
|
||||
let index = 0;
|
||||
vi.spyOn(HttpUtil, 'post').mockImplementation(async () => new Msg(true, '', values[index++]));
|
||||
const queryClient = makeTestQueryClient();
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
const { result } = renderHook(() => useAllSettings(), { wrapper });
|
||||
|
||||
await waitFor(() => expect(result.current.fetched).toBe(true));
|
||||
act(() => result.current.updateSetting({ webPort: 3000 }));
|
||||
await queryClient.invalidateQueries({ queryKey: keys.settings.all() });
|
||||
|
||||
await waitFor(() => expect(HttpUtil.post).toHaveBeenCalledTimes(2));
|
||||
expect(result.current.allSetting.webPort).toBe(3000);
|
||||
expect(result.current.saveDisabled).toBe(false);
|
||||
});
|
||||
|
||||
it('hydrates redacted secrets after a successful save', async () => {
|
||||
let fetchCount = 0;
|
||||
vi.spyOn(HttpUtil, 'post').mockImplementation(async (url) => {
|
||||
if (url === '/panel/api/setting/all') {
|
||||
fetchCount += 1;
|
||||
return new Msg(true, '', fetchCount === 1 ? { hasTgBotToken: false } : { hasTgBotToken: true, tgBotToken: '' });
|
||||
}
|
||||
return new Msg(true, '');
|
||||
});
|
||||
const queryClient = makeTestQueryClient();
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
const { result } = renderHook(() => useAllSettings(), { wrapper });
|
||||
|
||||
await waitFor(() => expect(result.current.fetched).toBe(true));
|
||||
act(() => result.current.updateSetting({ tgBotToken: 'secret' }));
|
||||
await act(async () => {
|
||||
await result.current.saveAll();
|
||||
});
|
||||
|
||||
await waitFor(() => expect(HttpUtil.post).toHaveBeenCalledTimes(3));
|
||||
expect(result.current.allSetting.tgBotToken).toBe('');
|
||||
expect(result.current.allSetting.hasTgBotToken).toBe(true);
|
||||
expect(result.current.saveDisabled).toBe(true);
|
||||
});
|
||||
|
||||
it('establishes a saved baseline for a full-payload security save', async () => {
|
||||
let fetchCount = 0;
|
||||
vi.spyOn(HttpUtil, 'post').mockImplementation(async (url) => {
|
||||
if (url === '/panel/api/setting/all') {
|
||||
fetchCount += 1;
|
||||
return new Msg(true, '', fetchCount === 1 ? { hasTgBotToken: false } : { hasTgBotToken: true, tgBotToken: '' });
|
||||
}
|
||||
return new Msg(true, '');
|
||||
});
|
||||
const queryClient = makeTestQueryClient();
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
const { result } = renderHook(() => useAllSettings(), { wrapper });
|
||||
|
||||
await waitFor(() => expect(result.current.fetched).toBe(true));
|
||||
act(() => result.current.updateSetting({ tgBotToken: 'secret' }));
|
||||
await act(async () => {
|
||||
await result.current.savePayload({ ...result.current.allSetting, twoFactorEnable: false, twoFactorToken: '' });
|
||||
});
|
||||
|
||||
await waitFor(() => expect(HttpUtil.post).toHaveBeenCalledTimes(3));
|
||||
expect(result.current.allSetting.tgBotToken).toBe('');
|
||||
expect(result.current.allSetting.hasTgBotToken).toBe(true);
|
||||
expect(result.current.saveDisabled).toBe(true);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user