V2.0.1: listen override fix, sing-box core fetch, probe settings page + fast report, spx removal, core mem env
CI / go-test (push) Waiting to run
CI / postgres-durable-first (push) Waiting to run
CI / codegen (push) Waiting to run
CI / govulncheck (push) Waiting to run
CI / race (push) Waiting to run
CI / fuzz-smoke (push) Waiting to run
CI / golangci (push) Waiting to run
CI / frontend (push) Waiting to run
Docs CI / build (push) Waiting to run
Docs Deploy (GitHub Pages) / build (push) Waiting to run
Docs Deploy (GitHub Pages) / deploy (push) Blocked by required conditions
Release 3X-UI / build (386) (push) Waiting to run
Release 3X-UI / build (amd64) (push) Waiting to run
Release 3X-UI / build (arm64) (push) Waiting to run
Release 3X-UI / build (armv5) (push) Waiting to run
Release 3X-UI / build (armv6) (push) Waiting to run
Release 3X-UI / build (armv7) (push) Waiting to run
Release 3X-UI / build (s390x) (push) Waiting to run
Release 3X-UI / Build for Windows (amd64) (push) Waiting to run
Release 3X-UI / Publish rolling dev release (push) Blocked by required conditions
CodeQL Advanced / Analyze (go) (push) Waiting to run
CodeQL Advanced / Analyze (actions) (push) Waiting to run
CodeQL Advanced / Analyze (javascript-typescript) (push) Waiting to run

- 0: sing-box gen overrides fragment listen/port from the inbound row
  (empty listen -> "::"), Update/Add recompute the stored fragment;
  non-loopback E2E against the real core binary.
- 1: agent fetches the sing-box core from the Gitea release when
  missing, self-checks it, and only then latches the switch.
- 2: node list shows the sing-box core version (heartbeat field,
  persisted on the node row), xray version as fallback.
- 3: public-page fallback returns 404 to anonymous visitors when the
  page is disabled.
- 4: settings restart hint bar removed, dead i18n keys cleaned.
- 5: two PUT calls missing JSON headers fixed (audit: only those two),
  batch probe-task creation endpoint + UI.
- 6: cf-probe retirement card and coverage pipeline removed.
- 7: generated vless links no longer carry spx (panel export + sub);
  parser still accepts spx; golden fixtures updated.
- probe batch: dedicated probe settings tab (public-page settings,
  tasks, per-node meta), retention_days editable, per-node traffic
  calibration + stat-interface pinning, runtime metrics split into a
  lightweight fast report (default 3s, 1-60s adjustable).
- 8: sing-box core processes start with GOMEMLIMIT=32MiB, GOGC=50.
- 9: dead /getTwoFactorEnable docs entries removed (frontend call and
  contract entry already gone at base; verified by grep).
This commit is contained in:
Rogue
2026-10-23 07:53:11 +00:00
parent c2f1840535
commit 29de9761c8
67 changed files with 3081 additions and 832 deletions
+6
View File
@@ -22,6 +22,7 @@ import {
MoonOutlined,
PushpinFilled,
PushpinOutlined,
RadarChartOutlined,
ReadOutlined,
SafetyOutlined,
SettingOutlined,
@@ -249,6 +250,11 @@ export default function AppSidebar() {
icon: <GlobalOutlined />,
label: t('pages.settings.probeThemes.title'),
},
{
key: '/settings#probe',
icon: <RadarChartOutlined />,
label: t('pages.settings.probe.title'),
},
];
return children;
}, [t]);
+2 -20
View File
@@ -14,7 +14,6 @@ import type { XHttpStreamSettings } from '@/schemas/protocols/stream/xhttp';
import { getHeaderValue } from './headers';
import { canEnableTlsFlow } from './protocol-capabilities';
import { deriveSpiderX } from './spider-x';
// Share-link generators. Each per-protocol fn takes a typed inbound plus
// client overrides and returns a URL (or '' when the protocol doesn't
@@ -353,7 +352,6 @@ export interface GenVlessLinkInput {
forceTls?: ForceTls;
remark?: string;
clientId: string;
clientKey?: string;
flow?: VlessClient['flow'];
externalProxy?: ExternalProxyEntry | null;
}
@@ -374,7 +372,7 @@ export function applyVlessRoute(id: string, route: string | undefined): string {
// VLESS share link: vless://<uuid>@<host>:<port>?<query>#<remark>. The
// query carries network type, encryption, network-specific knobs, and
// security-specific knobs (TLS fingerprint/alpn/sni or Reality
// pbk/sid/spx). Returns '' if the inbound isn't vless.
// pbk/sid). Returns '' if the inbound isn't vless.
export function genVlessLink(input: GenVlessLinkInput): string {
const {
inbound,
@@ -383,7 +381,6 @@ export function genVlessLink(input: GenVlessLinkInput): string {
forceTls = 'same',
remark = '',
clientId,
clientKey = '',
flow = '',
externalProxy = null,
} = input;
@@ -462,8 +459,6 @@ export function genVlessLink(input: GenVlessLinkInput): string {
if (sni && sni.length > 0) params.set('sni', sni);
if (reality.shortIds.length > 0) params.set('sid', reality.shortIds[0]);
const spx = deriveSpiderX(reality.settings.spiderX, clientKey);
if (spx.length > 0) params.set('spx', spx);
if (reality.settings.mldsa65Verify.length > 0)
params.set('pqv', reality.settings.mldsa65Verify);
}
@@ -560,7 +555,6 @@ function writeTlsParams(
function writeRealityParams(
stream: NonNullable<Inbound['streamSettings']>,
params: URLSearchParams,
clientKey: string,
): void {
if (stream.security !== 'reality') return;
const reality = stream.realitySettings;
@@ -573,8 +567,6 @@ function writeRealityParams(
if (sni && sni.length > 0) params.set('sni', sni);
if (reality.shortIds.length > 0) params.set('sid', reality.shortIds[0]);
const spx = deriveSpiderX(reality.settings.spiderX, clientKey);
if (spx.length > 0) params.set('spx', spx);
if (reality.settings.mldsa65Verify.length > 0) params.set('pqv', reality.settings.mldsa65Verify);
}
@@ -585,7 +577,6 @@ export interface GenTrojanLinkInput {
forceTls?: ForceTls;
remark?: string;
clientPassword: string;
clientKey?: string;
externalProxy?: ExternalProxyEntry | null;
}
@@ -600,7 +591,6 @@ export function genTrojanLink(input: GenTrojanLinkInput): string {
forceTls = 'same',
remark = '',
clientPassword,
clientKey = '',
externalProxy = null,
} = input;
@@ -621,7 +611,7 @@ export function genTrojanLink(input: GenTrojanLinkInput): string {
applyExternalProxyTLSParams(externalProxy, params, security);
} else if (security === 'reality') {
params.set('security', 'reality');
writeRealityParams(stream, params, clientKey);
writeRealityParams(stream, params);
} else {
params.set('security', 'none');
}
@@ -1321,12 +1311,6 @@ type ClientShape = {
subId?: string;
};
// Mirror of the Go subKey: the stable per-client identity spx derivation
// keys on — subscription id first, unique email as the fallback.
function clientSubKey(client: ClientShape): string {
return client.subId || client.email || '';
}
export function getInboundClients(inbound: Inbound): ClientShape[] | null {
switch (inbound.protocol) {
case 'vmess':
@@ -1392,7 +1376,6 @@ export function genLink(input: GenLinkInput): string {
forceTls,
remark,
clientId: client.id ?? '',
clientKey: clientSubKey(client),
flow: client.flow,
externalProxy,
});
@@ -1416,7 +1399,6 @@ export function genLink(input: GenLinkInput): string {
forceTls,
remark,
clientPassword: client.password ?? '',
clientKey: clientSubKey(client),
externalProxy,
});
case 'hysteria':
-10
View File
@@ -1,10 +0,0 @@
import { sha256 } from '@noble/hashes/sha2.js';
import { bytesToHex, utf8ToBytes } from '@noble/hashes/utils.js';
// Mirrors deriveSpiderX in internal/sub/service.go byte-for-byte so panel
// links and subscription links agree; returns '' when there is no seed and
// no client key (the caller then omits spx, as the legacy builder did).
export function deriveSpiderX(seed: string, clientKey: string): string {
if (!seed && !clientKey) return '';
return `/${bytesToHex(sha256(utf8ToBytes(`${seed}|${clientKey}`))).slice(0, 15)}`;
}
+12 -2
View File
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router';
import {
Alert,
Button,
@@ -21,7 +22,7 @@ import { NodeFormSchema, type NodeFormValues, type ProbeResult } from '@/schemas
import { FormField, rhfZodValidate } from '@/components/form/rhf';
import { useOutboundTagGroups } from '@/api/queries/useOutboundTags';
import { currentPanelMasterUrl } from './agentMasterUrl';
import ProbeMetaSection from './ProbeMetaSection';
import './NodeFormModal.css';
type Mode = 'add' | 'edit';
@@ -70,6 +71,7 @@ export default function NodeFormModal({
onOpenChange,
}: NodeFormModalProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const methods = useForm<NodeFormValues>({ defaultValues: defaultValues() });
const [messageApi, messageContextHolder] = message.useMessage();
@@ -474,7 +476,15 @@ export default function NodeFormModal({
)}
{mode === 'edit' && isAgent && node?.id ? (
<ProbeMetaSection nodeId={node.id} />
<div style={{ marginBottom: 16 }}>
<div style={{ fontWeight: 600, marginBottom: 4 }}>
{t('pages.nodes.probe.title')}
</div>
<p style={{ marginBottom: 8 }}>{t('pages.nodes.probe.movedHint')}</p>
<Button onClick={() => navigate('/settings#probe')}>
{t('pages.nodes.probe.goSettings')}
</Button>
</div>
) : null}
{!isAgent && (
+13 -4
View File
@@ -493,10 +493,15 @@ export default function NodeList({
render: (_value, record) => formatPct(record.memPct),
},
{
title: t('pages.nodes.xrayVersion'),
// Core version (V2.0.1 ②): agent rows run sing-box and show
// its version; traditional rows keep showing the xray one.
title: t('pages.nodes.coreVersion'),
dataIndex: 'xrayVersion',
align: 'center',
render: (_value, record) => record.xrayVersion || '-',
render: (_value, record) =>
record.nodeType === 'agent'
? record.singboxVersion || record.xrayVersion || '-'
: record.xrayVersion || '-',
},
{
title: t('pages.nodes.panelVersion') || 'Panel version',
@@ -840,8 +845,12 @@ export default function NodeList({
<Tag>{formatPct(statsNode.memPct)}</Tag>
</div>
<div className="stat-row">
<span className="stat-label">{t('pages.nodes.xrayVersion')}</span>
<Tag>{statsNode.xrayVersion || '-'}</Tag>
<span className="stat-label">{t('pages.nodes.coreVersion')}</span>
<Tag>
{statsNode.nodeType === 'agent'
? statsNode.singboxVersion || statsNode.xrayVersion || '-'
: statsNode.xrayVersion || '-'}
</Tag>
</div>
<div className="stat-row">
<span className="stat-label">
+3 -9
View File
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router';
import { useQuery } from '@tanstack/react-query';
import {
Alert,
@@ -33,7 +34,6 @@ import { useNodeMutations } from '@/api/queries/useNodeMutations';
import AppSidebar from '@/layouts/AppSidebar';
import NodeList from './NodeList';
import NodeFormModal from './NodeFormModal';
import ProbeTasksModal from './ProbeTasksModal';
import { setMessageInstance } from '@/utils/messageBus';
import { HttpUtil } from '@/utils';
import type { PanelUpdateInfo } from '../index/PanelUpdateModal';
@@ -70,6 +70,7 @@ function UpdateChannelChoice({ onChange }: { onChange: (dev: boolean) => void })
export default function NodesPage() {
const { t } = useTranslation();
const navigate = useNavigate();
const { isDark, isUltra, antdThemeConfig } = useTheme();
const { isMobile } = useMediaQuery();
const [modal, modalContextHolder] = Modal.useModal();
@@ -105,7 +106,6 @@ export default function NodesPage() {
const [formNode, setFormNode] = useState<NodeRecord | null>(null);
const [selectedIds, setSelectedIds] = useState<number[]>([]);
const [mtlsOpen, setMtlsOpen] = useState(false);
const [probeTasksOpen, setProbeTasksOpen] = useState(false);
const [trustCa, setTrustCa] = useState('');
const [copyingCa, setCopyingCa] = useState(false);
const [savingTrustCa, setSavingTrustCa] = useState(false);
@@ -369,7 +369,7 @@ export default function NodesPage() {
onSelectionChange={setSelectedIds}
onAdd={onAdd}
onMtls={() => setMtlsOpen(true)}
onProbeTasks={() => setProbeTasksOpen(true)}
onProbeTasks={() => navigate('/settings#probe')}
onEdit={onEdit}
onDelete={onDelete}
onProbe={onProbe}
@@ -384,12 +384,6 @@ export default function NodesPage() {
</Layout.Content>
</Layout>
<ProbeTasksModal
open={probeTasksOpen}
onClose={() => setProbeTasksOpen(false)}
nodes={nodes}
/>
<NodeFormModal
open={formOpen}
mode={formMode}
@@ -23,8 +23,15 @@ interface ProbeMetaWire {
countryPin: string;
ipv4Pin: string;
ipv6Pin: string;
// V2.0.1: ledger corrections in GB (either sign) and the counted
// interface pin ('' = automatic selection on the agent).
trafficCalUpGb: number;
trafficCalDownGb: number;
statIface: string;
}
const JSON_HEADERS = { headers: { 'Content-Type': 'application/json' } };
const NAMED_CYCLES = [
'once',
'monthly',
@@ -113,6 +120,7 @@ export default function ProbeMetaSection({ nodeId }: { nodeId: number }) {
};
const msg = await HttpUtil.put(`/panel/api/agent/probe-meta/${nodeId}`, payload, {
silent: true,
...JSON_HEADERS,
});
if (msg?.success) {
setLoaded({ nodeId, meta: payload });
@@ -237,6 +245,38 @@ export default function ProbeMetaSection({ nodeId }: { nodeId: number }) {
</Field>
</Col>
</Row>
<Row gutter={16}>
<Col xs={24} md={8}>
<Field label={t('pages.nodes.probe.calUp')}>
<InputNumber
value={meta.trafficCalUpGb}
step={0.1}
onChange={(v) => set('trafficCalUpGb', v ?? 0)}
style={{ width: '100%' }}
/>
</Field>
</Col>
<Col xs={24} md={8}>
<Field label={t('pages.nodes.probe.calDown')}>
<InputNumber
value={meta.trafficCalDownGb}
step={0.1}
onChange={(v) => set('trafficCalDownGb', v ?? 0)}
style={{ width: '100%' }}
/>
</Field>
</Col>
<Col xs={24} md={8}>
<Field label={t('pages.nodes.probe.statIface')}>
<Input
value={meta.statIface}
placeholder="eth0"
onChange={(e) => set('statIface', e.target.value)}
/>
</Field>
</Col>
</Row>
<p style={{ marginTop: -4, marginBottom: 12 }}>{t('pages.nodes.probe.calHint')}</p>
<Row gutter={16}>
<Col xs={24} md={8}>
<Field label={t('pages.nodes.probe.countryPin')}>
@@ -0,0 +1,264 @@
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Col, Input, InputNumber, Row, Select, Space, Switch, Typography, message } from 'antd';
import { HttpUtil } from '@/utils';
import { useNodesQuery } from '@/api/queries/useNodesQuery';
import ProbeMetaSection from '../nodes/ProbeMetaSection';
import ProbeTasksPanel from './ProbeTasksPanel';
// V2.0.1 probe batch: the dedicated probe settings page. Everything
// probe-related that used to be scattered — the public-page block
// (public-page tab), the latency task manager (nodes-page modal)
// and the per-node probe metadata (node edit dialog) — is gathered
// here and lives nowhere else. Like the surfaces it replaces, each
// block loads and saves through its own endpoints and takes effect
// immediately (no panel save, no restart).
interface ProbeSettingsWire {
publicPage: boolean;
siteName: string;
site: string;
githubProxy: string;
retentionDays: number;
fastIntervalSec: number;
favicon: string;
touchIcon: string;
}
const JSON_HEADERS = { headers: { 'Content-Type': 'application/json' } };
const MAX_ICON_BYTES = 20 * 1024;
function readAsDataURL(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result));
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(blob);
});
}
// fileToIconDataURL fits a chosen image into the hub's 20 KiB site
// icon budget (moved unchanged from the public-page tab): small
// files pass through; a large raster is scaled down until it fits;
// a large SVG cannot be scaled and is refused here.
async function fileToIconDataURL(file: File): Promise<string> {
if (file.size <= MAX_ICON_BYTES) return readAsDataURL(file);
if (file.type === 'image/svg+xml' || file.type === 'image/x-icon' || file.type === 'image/gif') {
throw new Error('tooLarge');
}
const bitmap = await createImageBitmap(file);
try {
for (const size of [256, 180, 128, 96, 64]) {
const scale = Math.min(1, size / Math.max(bitmap.width, bitmap.height));
const canvas = document.createElement('canvas');
canvas.width = Math.max(1, Math.round(bitmap.width * scale));
canvas.height = Math.max(1, Math.round(bitmap.height * scale));
const ctx = canvas.getContext('2d');
if (!ctx) break;
ctx.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
const blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, 'image/png'));
if (blob && blob.size <= MAX_ICON_BYTES) return readAsDataURL(blob);
}
} finally {
bitmap.close();
}
throw new Error('tooLarge');
}
export default function ProbeSettingsTab() {
const { t } = useTranslation();
const [messageApi, messageContextHolder] = message.useMessage();
const [settings, setSettings] = useState<ProbeSettingsWire | null>(null);
const [saving, setSaving] = useState(false);
const [metaNodeId, setMetaNodeId] = useState<number | undefined>(undefined);
const faviconRef = useRef<HTMLInputElement>(null);
const touchRef = useRef<HTMLInputElement>(null);
const { nodes } = useNodesQuery();
const agentNodes = nodes.filter((n) => n.nodeType === 'agent');
useEffect(() => {
let cancelled = false;
HttpUtil.get<ProbeSettingsWire>('/panel/api/probe/settings', { silent: true }).then((msg) => {
if (!cancelled && msg?.success && msg.obj) setSettings(msg.obj);
});
return () => {
cancelled = true;
};
}, []);
async function saveSettings() {
if (!settings) return;
setSaving(true);
try {
const msg = await HttpUtil.put('/panel/api/probe/settings', settings, {
silent: true,
...JSON_HEADERS,
});
if (msg?.success) messageApi.success(t('pages.settings.probeThemes.saved'));
else messageApi.error(msg?.msg || t('pages.settings.probeThemes.saveFailed'));
} finally {
setSaving(false);
}
}
async function pickIcon(which: 'favicon' | 'touchIcon', file: File | undefined) {
if (!file || !settings) return;
try {
const url = await fileToIconDataURL(file);
setSettings({ ...settings, [which]: url });
} catch {
messageApi.error(t('pages.settings.probeThemes.iconTooLarge'));
}
}
const set = <K extends keyof ProbeSettingsWire>(key: K, value: ProbeSettingsWire[K]) =>
setSettings((s) => (s ? { ...s, [key]: value } : s));
return (
<div>
{messageContextHolder}
<Typography.Title level={5}>{t('pages.settings.probeThemes.settingsTitle')}</Typography.Title>
{settings ? (
<>
<Row gutter={[16, 12]}>
<Col xs={24} md={12}>
<div style={{ marginBottom: 4 }}>{t('pages.settings.probeThemes.publicPage')}</div>
<Switch
checked={settings.publicPage}
onChange={(v) => set('publicPage', v)}
/>
<Typography.Text type="secondary" style={{ marginLeft: 8 }}>
{t('pages.settings.probeThemes.publicPageHint')}
</Typography.Text>
</Col>
<Col xs={24} md={12}>
<div style={{ marginBottom: 4 }}>{t('pages.settings.probeThemes.siteName')}</div>
<Input
value={settings.siteName}
onChange={(e) => set('siteName', e.target.value)}
/>
</Col>
<Col xs={24} md={12}>
<div style={{ marginBottom: 4 }}>{t('pages.settings.probeThemes.site')}</div>
<Input
value={settings.site}
placeholder="https://"
onChange={(e) => set('site', e.target.value)}
/>
</Col>
<Col xs={24} md={12}>
<div style={{ marginBottom: 4 }}>{t('pages.settings.probeThemes.githubProxy')}</div>
<Input
value={settings.githubProxy}
placeholder="https://"
onChange={(e) => set('githubProxy', e.target.value)}
/>
<Typography.Text type="secondary">
{t('pages.settings.probeThemes.githubProxyHint')}
</Typography.Text>
</Col>
<Col xs={24} md={12}>
<div style={{ marginBottom: 4 }}>{t('pages.settings.probe.retentionDays')}</div>
<InputNumber
min={1}
max={365}
value={settings.retentionDays}
onChange={(v) => set('retentionDays', v ?? 7)}
style={{ width: '100%' }}
/>
<Typography.Text type="secondary">
{t('pages.settings.probe.retentionHint')}
</Typography.Text>
</Col>
<Col xs={24} md={12}>
<div style={{ marginBottom: 4 }}>{t('pages.settings.probe.fastInterval')}</div>
<InputNumber
min={1}
max={60}
value={settings.fastIntervalSec}
onChange={(v) => set('fastIntervalSec', v ?? 3)}
addonAfter="s"
style={{ width: '100%' }}
/>
<Typography.Text type="secondary">
{t('pages.settings.probe.fastIntervalHint')}
</Typography.Text>
</Col>
<Col xs={24} md={12}>
<div style={{ marginBottom: 4 }}>{t('pages.settings.probeThemes.favicon')}</div>
<Space>
{settings.favicon ? (
<img src={settings.favicon} alt="" style={{ width: 24, height: 24 }} />
) : null}
<Button size="small" onClick={() => faviconRef.current?.click()}>
{t('pages.settings.probeThemes.chooseImage')}
</Button>
{settings.favicon ? (
<Button size="small" onClick={() => set('favicon', '')}>
{t('clear')}
</Button>
) : null}
</Space>
<input
ref={faviconRef}
type="file"
accept="image/*"
hidden
onChange={(e) => pickIcon('favicon', e.target.files?.[0])}
/>
</Col>
<Col xs={24} md={12}>
<div style={{ marginBottom: 4 }}>{t('pages.settings.probeThemes.touchIcon')}</div>
<Space>
{settings.touchIcon ? (
<img src={settings.touchIcon} alt="" style={{ width: 24, height: 24 }} />
) : null}
<Button size="small" onClick={() => touchRef.current?.click()}>
{t('pages.settings.probeThemes.chooseImage')}
</Button>
{settings.touchIcon ? (
<Button size="small" onClick={() => set('touchIcon', '')}>
{t('clear')}
</Button>
) : null}
</Space>
<input
ref={touchRef}
type="file"
accept="image/*"
hidden
onChange={(e) => pickIcon('touchIcon', e.target.files?.[0])}
/>
<div>
<Typography.Text type="secondary">
{t('pages.settings.probeThemes.iconHint')}
</Typography.Text>
</div>
</Col>
</Row>
<div style={{ marginTop: 12 }}>
<Button type="primary" loading={saving} onClick={saveSettings}>
{t('save')}
</Button>
</div>
</>
) : null}
<Typography.Title level={5} style={{ marginTop: 28 }}>
{t('pages.nodes.probeTasks.title')}
</Typography.Title>
<ProbeTasksPanel nodes={nodes} />
<Typography.Title level={5} style={{ marginTop: 28 }}>
{t('pages.settings.probe.metaTitle')}
</Typography.Title>
<Select
placeholder={t('pages.settings.probe.metaNodePlaceholder')}
value={metaNodeId}
onChange={setMetaNodeId}
options={agentNodes.map((n) => ({ value: n.id, label: n.name }))}
style={{ minWidth: 260, marginBottom: 16 }}
/>
{metaNodeId ? <ProbeMetaSection key={metaNodeId} nodeId={metaNodeId} /> : null}
</div>
);
}
@@ -1,10 +1,9 @@
import { useCallback, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Button,
Input,
InputNumber,
Modal,
Popconfirm,
Select,
Space,
@@ -19,12 +18,14 @@ import type { ColumnsType } from 'antd/es/table';
import { HttpUtil } from '@/utils';
import type { NodeRecord } from '@/api/queries/useNodesQuery';
// Item 7 B4: probe (latency) task management. The operator maintains
// the target set here — grouped by the free-form region label, no
// preset list (user ruling Q4). Like ProbeMetaSection the surface is
// self-contained: it loads and saves on its own through the dedicated
// endpoints, effective immediately (the master pushes the set on each
// agent's next config poll; nothing restarts).
// Probe (latency) task management — the panel form of what used to
// be the nodes-page modal (moved to the probe settings page in
// V2.0.1; the target set lives in exactly one place now). The
// operator maintains the target set here — grouped by the free-form
// region label, no preset list (user ruling Q4). The surface is
// self-contained: it loads and saves on its own through the
// dedicated endpoints, effective immediately (the master pushes the
// set on each agent's next config poll; nothing restarts).
interface ProbeTaskWire {
id: number;
name: string;
@@ -48,22 +49,38 @@ interface TaskDraft {
}
const TASKS_URL = '/panel/api/agent/probe-tasks';
const JSON_HEADERS = { headers: { 'Content-Type': 'application/json' } };
export default function ProbeTasksModal({
open,
onClose,
nodes,
}: {
open: boolean;
onClose: () => void;
nodes: NodeRecord[];
}) {
function parseTargets(text: string): string[] {
const seen = new Set<string>();
const out: string[] = [];
for (const raw of text.split(/[\n,,]+/)) {
const v = raw.trim();
if (v && !seen.has(v)) {
seen.add(v);
out.push(v);
}
}
return out;
}
export default function ProbeTasksPanel({ nodes }: { nodes: NodeRecord[] }) {
const { t } = useTranslation();
const [messageApi, messageContextHolder] = message.useMessage();
const [tasks, setTasks] = useState<ProbeTaskWire[] | null>(null);
const [draft, setDraft] = useState<TaskDraft | null>(null);
const [saving, setSaving] = useState(false);
// Batch-add state (V2.0.1 ⑤): many targets pasted at once sharing
// every other field.
const [batchText, setBatchText] = useState('');
const [batchName, setBatchName] = useState('');
const [batchInterval, setBatchInterval] = useState(60);
const [batchAutoJoin, setBatchAutoJoin] = useState(false);
const [batchRegion, setBatchRegion] = useState('');
const [batchNodeIds, setBatchNodeIds] = useState<number[]>([]);
const [batchSaving, setBatchSaving] = useState(false);
const agentNodes = useMemo(() => nodes.filter((n) => n.nodeType === 'agent'), [nodes]);
const nodeName = useCallback(
(id: number) => nodes.find((n) => n.id === id)?.name ?? `#${id}`,
@@ -79,15 +96,9 @@ export default function ProbeTasksModal({
}
}, [messageApi, t]);
// Load on open (afterOpenChange is an event callback, so resetting
// the previous session's state here is not an effect write).
const onAfterOpenChange = (opened: boolean) => {
if (opened) {
setTasks(null);
setDraft(null);
void load();
}
};
useEffect(() => {
void load();
}, [load]);
// Group by region: region labels ascending, the ungrouped bucket
// last; inside a group the panel order (sort) rules.
@@ -147,7 +158,10 @@ export default function ProbeTasksModal({
nodeIds: draft.nodeIds,
};
if (draft.baseNodeIds) payload.baseNodeIds = draft.baseNodeIds;
const msg = await HttpUtil.put<{ id: number }>(TASKS_URL, payload, { silent: true });
const msg = await HttpUtil.put<{ id: number }>(TASKS_URL, payload, {
silent: true,
...JSON_HEADERS,
});
if (msg?.success) {
messageApi.success(t('pages.nodes.probeTasks.saved'));
setDraft(null);
@@ -160,6 +174,40 @@ export default function ProbeTasksModal({
}
}
async function onBatchAdd() {
const targets = parseTargets(batchText);
if (targets.length === 0) {
messageApi.error(t('pages.nodes.probeTasks.batchEmpty'));
return;
}
setBatchSaving(true);
try {
const msg = await HttpUtil.post<{ ids: number[] }>(
`${TASKS_URL}/batch`,
{
targets,
name: batchName,
interval: batchInterval,
autoJoin: batchAutoJoin,
region: batchRegion,
nodeIds: batchNodeIds,
},
{ silent: true, ...JSON_HEADERS },
);
if (msg?.success) {
messageApi.success(
t('pages.nodes.probeTasks.batchDone', { count: msg.obj?.ids.length ?? targets.length }),
);
setBatchText('');
await load();
} else {
messageApi.error(msg?.msg || t('pages.nodes.probeTasks.saveFailed'));
}
} finally {
setBatchSaving(false);
}
}
async function onDelete(task: ProbeTaskWire) {
const msg = await HttpUtil.delete(`${TASKS_URL}/${task.id}`, { silent: true });
if (msg?.success) {
@@ -226,20 +274,72 @@ export default function ProbeTasksModal({
setDraft((d) => (d ? { ...d, [key]: value } : d));
return (
<Modal
open={open}
title={t('pages.nodes.probeTasks.title')}
onCancel={onClose}
afterOpenChange={onAfterOpenChange}
footer={null}
width={780}
destroyOnHidden
>
<div>
{messageContextHolder}
<Typography.Paragraph type="secondary" style={{ marginTop: 0 }}>
{t('pages.nodes.probeTasks.intro')}
</Typography.Paragraph>
<div
style={{
border: '1px solid var(--ant-color-border-secondary, #f0f0f0)',
borderRadius: 8,
padding: 12,
marginBottom: 16,
}}
>
<Typography.Text strong>{t('pages.nodes.probeTasks.batchTitle')}</Typography.Text>
<Input.TextArea
rows={3}
value={batchText}
placeholder={t('pages.nodes.probeTasks.batchPlaceholder')}
onChange={(e) => setBatchText(e.target.value)}
style={{ marginTop: 8, fontFamily: 'monospace' }}
/>
<Space wrap style={{ marginTop: 8 }}>
<Input
placeholder={t('pages.nodes.probeTasks.name')}
value={batchName}
onChange={(e) => setBatchName(e.target.value)}
style={{ width: 180 }}
/>
<Input
placeholder={t('pages.nodes.probeTasks.region')}
value={batchRegion}
onChange={(e) => setBatchRegion(e.target.value)}
style={{ width: 130 }}
/>
<InputNumber
min={5}
max={3600}
value={batchInterval}
onChange={(v) => setBatchInterval(v ?? 60)}
addonAfter="s"
style={{ width: 120 }}
/>
<span>
{t('pages.nodes.probeTasks.autoJoin')}{' '}
<Switch checked={batchAutoJoin} onChange={setBatchAutoJoin} />
</span>
<Select
mode="multiple"
placeholder={t('pages.nodes.probeTasks.nodesPlaceholder')}
value={batchNodeIds}
onChange={setBatchNodeIds}
options={agentNodes.map((n) => ({ value: n.id, label: n.name }))}
style={{ minWidth: 260 }}
/>
<Button type="primary" loading={batchSaving} onClick={() => void onBatchAdd()}>
{t('pages.nodes.probeTasks.batchSubmit')}
</Button>
</Space>
<div>
<Typography.Text type="secondary">
{t('pages.nodes.probeTasks.batchHint')}
</Typography.Text>
</div>
</div>
{tasks === null ? (
<Typography.Text type="secondary">{t('pages.nodes.probeTasks.loading')}</Typography.Text>
) : tasks.length === 0 && !draft ? (
@@ -326,6 +426,6 @@ export default function ProbeTasksModal({
{t('pages.nodes.probeTasks.add')}
</Button>
)}
</Modal>
</div>
);
}
+8 -246
View File
@@ -20,11 +20,13 @@ import { DeleteOutlined, SettingOutlined, UploadOutlined } from '@ant-design/ico
import { HttpUtil } from '@/utils';
import { onNumber } from '@/utils/onNumber';
// Item 7 B5: the public status page's settings + theme store. Like
// the probe-meta section this surface is self-contained — it loads
// and saves through its own endpoints and every change takes effect
// immediately (no panel save, no restart). Theme packages are static
// files served by the panel; installing one never runs its code here.
// Item 7 B5: the public status page's theme store + notify block.
// (The public-page settings themselves moved to the probe settings
// page in V2.0.1.) Like the probe-meta section this surface is
// self-contained — it loads and saves through its own endpoints and
// every change takes effect immediately (no panel save, no
// restart). Theme packages are static files served by the panel;
// installing one never runs its code here.
interface ThemeConfigField {
key: string;
type: string;
@@ -47,18 +49,8 @@ interface ThemeWire {
preview: boolean;
}
interface ProbeSettingsWire {
publicPage: boolean;
siteName: string;
site: string;
githubProxy: string;
}
interface ThemesPayload {
themes: ThemeWire[];
settings: ProbeSettingsWire;
favicon: string;
touchIcon: string;
}
// Notify settings wire shape (B6) — mirrors probenotify.SettingsView.
@@ -78,83 +70,27 @@ interface NotifySettingsWire {
notifyWebhookHeadersSet: boolean;
}
interface CoverageRow {
name: string;
online: boolean;
hasProbe: boolean;
probeAt: number;
}
interface NotifyPayload {
settings: NotifySettingsWire;
coverage: CoverageRow[];
}
const JSON_HEADERS = { headers: { 'Content-Type': 'application/json' } };
const UPLOAD_CHUNK = 1 << 20;
const MAX_ICON_BYTES = 20 * 1024;
function basePathPrefix(): string {
const bp = window.X_UI_BASE_PATH;
return typeof bp === 'string' && bp !== '/' ? bp.replace(/\/$/, '') : '';
}
function readAsDataURL(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result));
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(blob);
});
}
// fileToIconDataURL fits a chosen image into the hub's 20 KiB site
// icon budget: small files pass through untouched (the server judges
// the bytes, not the declared type); a large raster is scaled down
// until it fits; a large SVG cannot be scaled and is refused here.
async function fileToIconDataURL(file: File): Promise<string> {
if (file.size <= MAX_ICON_BYTES) return readAsDataURL(file);
if (file.type === 'image/svg+xml' || file.type === 'image/x-icon' || file.type === 'image/gif') {
throw new Error('tooLarge');
}
const bitmap = await createImageBitmap(file);
try {
for (const size of [256, 180, 128, 96, 64]) {
const scale = Math.min(1, size / Math.max(bitmap.width, bitmap.height));
const canvas = document.createElement('canvas');
canvas.width = Math.max(1, Math.round(bitmap.width * scale));
canvas.height = Math.max(1, Math.round(bitmap.height * scale));
const ctx = canvas.getContext('2d');
if (!ctx) break;
ctx.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
const blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, 'image/png'));
if (blob && blob.size <= MAX_ICON_BYTES) return readAsDataURL(blob);
}
} finally {
bitmap.close();
}
throw new Error('tooLarge');
}
export default function PublicPageTab() {
const { t } = useTranslation();
const [messageApi, messageContextHolder] = message.useMessage();
const [themes, setThemes] = useState<ThemeWire[]>([]);
const [settings, setSettings] = useState<ProbeSettingsWire>({
publicPage: false,
siteName: 'Monitor',
site: '',
githubProxy: '',
});
const [favicon, setFavicon] = useState('');
const [touchIcon, setTouchIcon] = useState('');
const [saving, setSaving] = useState(false);
const [installURL, setInstallURL] = useState('');
const [installing, setInstalling] = useState(false);
const [uploadPct, setUploadPct] = useState<number | null>(null);
const [busyShort, setBusyShort] = useState('');
const [notify, setNotify] = useState<NotifySettingsWire | null>(null);
const [coverage, setCoverage] = useState<CoverageRow[]>([]);
const [webhookUrl, setWebhookUrl] = useState('');
const [webhookHeaders, setWebhookHeaders] = useState('');
const [urlTouched, setUrlTouched] = useState(false);
@@ -162,14 +98,9 @@ export default function PublicPageTab() {
const [notifySaving, setNotifySaving] = useState(false);
const [notifyTesting, setNotifyTesting] = useState(false);
const fileRef = useRef<HTMLInputElement>(null);
const faviconRef = useRef<HTMLInputElement>(null);
const touchRef = useRef<HTMLInputElement>(null);
const applyPayload = useCallback((payload: ThemesPayload) => {
setThemes(payload.themes ?? []);
setSettings(payload.settings);
setFavicon(payload.favicon ?? '');
setTouchIcon(payload.touchIcon ?? '');
}, []);
const reload = useCallback(async () => {
@@ -185,7 +116,6 @@ export default function PublicPageTab() {
HttpUtil.get<NotifyPayload>('/panel/api/probe/notify', { silent: true }).then((msg) => {
if (!cancelled && msg?.success && msg.obj) {
setNotify(msg.obj.settings);
setCoverage(msg.obj.coverage ?? []);
}
});
return () => {
@@ -215,7 +145,6 @@ export default function PublicPageTab() {
});
if (msg?.success && msg.obj) {
setNotify(msg.obj.settings);
setCoverage(msg.obj.coverage ?? []);
setWebhookUrl('');
setWebhookHeaders('');
setUrlTouched(false);
@@ -247,32 +176,6 @@ export default function PublicPageTab() {
}
}
async function saveSettings() {
setSaving(true);
try {
const msg = await HttpUtil.put(
'/panel/api/probe/settings',
{ ...settings, favicon, touchIcon },
{ silent: true, ...JSON_HEADERS },
);
if (msg?.success) messageApi.success(t('pages.settings.probeThemes.saved'));
else messageApi.error(msg?.msg || t('pages.settings.probeThemes.saveFailed'));
} finally {
setSaving(false);
}
}
async function pickIcon(which: 'favicon' | 'touchIcon', file: File | undefined) {
if (!file) return;
try {
const url = await fileToIconDataURL(file);
if (which === 'favicon') setFavicon(url);
else setTouchIcon(url);
} catch {
messageApi.error(t('pages.settings.probeThemes.iconTooLarge'));
}
}
async function uploadTheme(file: File | undefined) {
if (!file) return;
if (file.size === 0) {
@@ -371,101 +274,7 @@ export default function PublicPageTab() {
return (
<div>
{messageContextHolder}
<Typography.Title level={5}>{t('pages.settings.probeThemes.settingsTitle')}</Typography.Title>
<Row gutter={[16, 12]}>
<Col xs={24} md={12}>
<div style={{ marginBottom: 4 }}>{t('pages.settings.probeThemes.publicPage')}</div>
<Switch
checked={settings.publicPage}
onChange={(v) => setSettings({ ...settings, publicPage: v })}
/>
<Typography.Text type="secondary" style={{ marginLeft: 8 }}>
{t('pages.settings.probeThemes.publicPageHint')}
</Typography.Text>
</Col>
<Col xs={24} md={12}>
<div style={{ marginBottom: 4 }}>{t('pages.settings.probeThemes.siteName')}</div>
<Input
value={settings.siteName}
onChange={(e) => setSettings({ ...settings, siteName: e.target.value })}
/>
</Col>
<Col xs={24} md={12}>
<div style={{ marginBottom: 4 }}>{t('pages.settings.probeThemes.site')}</div>
<Input
value={settings.site}
placeholder="https://"
onChange={(e) => setSettings({ ...settings, site: e.target.value })}
/>
</Col>
<Col xs={24} md={12}>
<div style={{ marginBottom: 4 }}>{t('pages.settings.probeThemes.githubProxy')}</div>
<Input
value={settings.githubProxy}
placeholder="https://"
onChange={(e) => setSettings({ ...settings, githubProxy: e.target.value })}
/>
<Typography.Text type="secondary">
{t('pages.settings.probeThemes.githubProxyHint')}
</Typography.Text>
</Col>
<Col xs={24} md={12}>
<div style={{ marginBottom: 4 }}>{t('pages.settings.probeThemes.favicon')}</div>
<Space>
{favicon ? <img src={favicon} alt="" style={{ width: 24, height: 24 }} /> : null}
<Button size="small" onClick={() => faviconRef.current?.click()}>
{t('pages.settings.probeThemes.chooseImage')}
</Button>
{favicon ? (
<Button size="small" onClick={() => setFavicon('')}>
{t('clear')}
</Button>
) : null}
</Space>
<input
ref={faviconRef}
type="file"
accept="image/*"
hidden
onChange={(e) => pickIcon('favicon', e.target.files?.[0])}
/>
</Col>
<Col xs={24} md={12}>
<div style={{ marginBottom: 4 }}>{t('pages.settings.probeThemes.touchIcon')}</div>
<Space>
{touchIcon ? <img src={touchIcon} alt="" style={{ width: 24, height: 24 }} /> : null}
<Button size="small" onClick={() => touchRef.current?.click()}>
{t('pages.settings.probeThemes.chooseImage')}
</Button>
{touchIcon ? (
<Button size="small" onClick={() => setTouchIcon('')}>
{t('clear')}
</Button>
) : null}
</Space>
<input
ref={touchRef}
type="file"
accept="image/*"
hidden
onChange={(e) => pickIcon('touchIcon', e.target.files?.[0])}
/>
<div>
<Typography.Text type="secondary">
{t('pages.settings.probeThemes.iconHint')}
</Typography.Text>
</div>
</Col>
</Row>
<div style={{ marginTop: 12 }}>
<Button type="primary" loading={saving} onClick={saveSettings}>
{t('save')}
</Button>
</div>
<Typography.Title level={5} style={{ marginTop: 28 }}>
{t('pages.settings.probeThemes.title')}
</Typography.Title>
<Typography.Title level={5}>{t('pages.settings.probeThemes.title')}</Typography.Title>
<Space wrap style={{ marginBottom: 16 }}>
<Button icon={<UploadOutlined />} onClick={() => fileRef.current?.click()}>
{uploadPct === null
@@ -745,53 +554,6 @@ export default function PublicPageTab() {
</>
) : null}
<Typography.Title level={5} style={{ marginTop: 28 }}>
{t('pages.settings.probeNotify.retireTitle')}
</Typography.Title>
<Typography.Paragraph type="secondary">
{t('pages.settings.probeNotify.retireHint')}
</Typography.Paragraph>
<Space direction="vertical">
<span>
{t('pages.settings.probeNotify.retirePublicPage')}:
{settings.publicPage ? (
<Tag color="green">{t('pages.settings.probeNotify.configured')}</Tag>
) : (
<Tag color="orange">{t('pages.settings.probeNotify.retirePublicPageOff')}</Tag>
)}
</span>
{coverage.map((row) => (
<span key={row.name}>
{row.name}:
{row.hasProbe ? (
<Tag color="green">{t('pages.settings.probeNotify.retireHasProbe')}</Tag>
) : (
<Tag color="red">{t('pages.settings.probeNotify.retireNoProbe')}</Tag>
)}
{row.online ? (
<Tag color="green">{t('pages.settings.probeNotify.retireOnline')}</Tag>
) : (
<Tag>{t('pages.settings.probeNotify.retireOffline')}</Tag>
)}
{row.probeAt > 0 ? (
<Typography.Text type="secondary">
{t('pages.settings.probeNotify.retireProbeAt', {
time: new Date(row.probeAt).toLocaleString(),
})}
</Typography.Text>
) : null}
</span>
))}
{coverage.length > 0 && coverage.every((row) => row.hasProbe) ? (
<Typography.Text type="success">
{t('pages.settings.probeNotify.retireAllReady')}
</Typography.Text>
) : (
<Typography.Text type="warning">
{t('pages.settings.probeNotify.retireNotReady')}
</Typography.Text>
)}
</Space>
</div>
);
}
+4 -2
View File
@@ -28,13 +28,14 @@ import SecurityTab from './SecurityTab';
import SubscriptionGeneralTab from './SubscriptionGeneralTab';
import TelegramTab from './TelegramTab';
import PublicPageTab from './PublicPageTab';
import ProbeSettingsTab from './ProbeSettingsTab';
import './SettingsPage.css';
interface ApiMsg {
success?: boolean;
}
const tabSlugs = ['general', 'security', 'telegram', 'subscription', 'public-page'];
const tabSlugs = ['general', 'security', 'telegram', 'subscription', 'public-page', 'probe'];
function isIp(h: string): boolean {
if (typeof h !== 'string') return false;
@@ -182,6 +183,8 @@ export default function SettingsPage() {
return <SubscriptionGeneralTab allSetting={allSetting} updateSetting={updateSetting} />;
case 'public-page':
return <PublicPageTab />;
case 'probe':
return <ProbeSettingsTab />;
default:
return <GeneralTab allSetting={allSetting} updateSetting={updateSetting} />;
}
@@ -247,7 +250,6 @@ export default function SettingsPage() {
</Col>
<Col xs={24} sm={14} className="header-info">
<FloatButton.BackTop target={scrollTarget} visibilityHeight={200} />
<Alert type="warning" showIcon title={t('pages.settings.infoDesc')} />
</Col>
</Row>
</Card>
+3
View File
@@ -17,6 +17,9 @@ export const NodeRecordSchema = z
cpuPct: z.number().optional(),
memPct: z.number().optional(),
xrayVersion: z.string().optional(),
// Agent nodes run sing-box since V2: their core version is the
// sing-box one the heartbeat reports (V2.0.1 ②).
singboxVersion: z.string().optional(),
panelVersion: z.string().optional(),
uptimeSecs: z.number().optional(),
inboundCount: z.number().optional(),
@@ -8,7 +8,7 @@ exports[`genInboundLinks orchestrator > shadowsocks-tcp-2022: byte-stable 1`] =
exports[`genInboundLinks orchestrator > trojan-ws-tls: byte-stable 1`] = `"trojan://trojan-test-pw-XYZ@override.test:443?type=ws&path=%2Ftrojan&host=trojan.example.test&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&sni=trojan.example.test#parity-test"`;
exports[`genInboundLinks orchestrator > vless-tcp-reality: byte-stable 1`] = `"vless://22222222-3333-4444-9555-666666666666@override.test:443?type=tcp&encryption=none&security=reality&pbk=Tx5yj1bRcOPHkdvT2pIAQ2zh0gQ8m4OPdnzqXJxxV3o&fp=chrome&sni=yahoo.com&sid=a3f1&spx=%2Fdafd018f50a389b&flow=xtls-rprx-vision#parity-test"`;
exports[`genInboundLinks orchestrator > vless-tcp-reality: byte-stable 1`] = `"vless://22222222-3333-4444-9555-666666666666@override.test:443?type=tcp&encryption=none&security=reality&pbk=Tx5yj1bRcOPHkdvT2pIAQ2zh0gQ8m4OPdnzqXJxxV3o&fp=chrome&sni=yahoo.com&sid=a3f1&flow=xtls-rprx-vision#parity-test"`;
exports[`genInboundLinks orchestrator > vless-ws-tls: byte-stable 1`] = `"vless://8c14d6f7-2e3b-4a91-9d24-3f7a6b8c1e02@override.test:443?type=ws&encryption=none&path=%2Fws&host=cdn.example.test&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&sni=cdn.example.test#parity-test"`;
@@ -36,7 +36,7 @@ exports[`genShadowsocksLink > shadowsocks-tcp-2022: byte-stable 1`] = `"ss://202
exports[`genTrojanLink > trojan-ws-tls: byte-stable 1`] = `"trojan://trojan-test-pw-XYZ@example.test:443?type=ws&path=%2Ftrojan&host=trojan.example.test&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&sni=trojan.example.test#parity-test"`;
exports[`genVlessLink > vless-tcp-reality: byte-stable 1`] = `"vless://22222222-3333-4444-9555-666666666666@example.test:443?type=tcp&encryption=none&security=reality&pbk=Tx5yj1bRcOPHkdvT2pIAQ2zh0gQ8m4OPdnzqXJxxV3o&fp=chrome&sni=yahoo.com&sid=a3f1&spx=%2Fd08ed99bd9afc60&flow=xtls-rprx-vision#parity-test"`;
exports[`genVlessLink > vless-tcp-reality: byte-stable 1`] = `"vless://22222222-3333-4444-9555-666666666666@example.test:443?type=tcp&encryption=none&security=reality&pbk=Tx5yj1bRcOPHkdvT2pIAQ2zh0gQ8m4OPdnzqXJxxV3o&fp=chrome&sni=yahoo.com&sid=a3f1&flow=xtls-rprx-vision#parity-test"`;
exports[`genVlessLink > vless-ws-tls: byte-stable 1`] = `"vless://8c14d6f7-2e3b-4a91-9d24-3f7a6b8c1e02@example.test:443?type=ws&encryption=none&path=%2Fws&host=cdn.example.test&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&sni=cdn.example.test#parity-test"`;
-27
View File
@@ -1,27 +0,0 @@
import { describe, expect, it } from 'vitest';
import { deriveSpiderX } from '@/lib/xray/spider-x';
// Cross-language vectors shared with TestDeriveSpiderXMatchesFrontendVectors
// in internal/sub/service_sharelink_test.go: subscription links come from Go,
// panel links from this module, and the two must agree byte-for-byte.
describe('deriveSpiderX', () => {
it('matches the Go deriveSpiderX vectors', () => {
expect(deriveSpiderX('/seed', 'subAlice')).toBe('/c252fbc3ecd3e3c');
expect(deriveSpiderX('/', '')).toBe('/d08ed99bd9afc60');
});
it('is stable per client, distinct across clients, and rotates with the seed', () => {
expect(deriveSpiderX('/seed', 'subAlice')).toBe(deriveSpiderX('/seed', 'subAlice'));
expect(deriveSpiderX('/seed', 'subAlice')).not.toBe(deriveSpiderX('/seed', 'subBob'));
expect(deriveSpiderX('/seedA', 'subAlice')).not.toBe(deriveSpiderX('/seedB', 'subAlice'));
});
it('returns empty when there is nothing to derive from', () => {
expect(deriveSpiderX('', '')).toBe('');
});
it('emits a /-prefixed 15-hex-char path', () => {
expect(deriveSpiderX('/some-seed', 'client@example.com')).toMatch(/^\/[0-9a-f]{15}$/);
});
});