mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-05 12:44:22 +00:00
feat(frontend): adapter between raw inbound rows and InboundFormValues
Adds lib/xray/inbound-form-adapter.ts with rawInboundToFormValues and formValuesToWirePayload. The pair is the data boundary the upcoming Pattern A modal will use: it consumes the DB row shape (settings et al. as string OR object — coerced internally), hands the modal typed InboundFormValues, and on submit reverses the trip to a wire payload with the three JSON-stringified slices the Go endpoints expect. No dependency on the legacy Inbound/DBInbound classes — the coerce step is inlined so the adapter survives the eventual models/ deletion. Adds 10 Vitest cases covering string vs object inputs, the optional streamSettings/nodeId fields, trafficReset coercion, and a raw-to-payload -to-raw round-trip equality.
This commit is contained in:
parent
d2f3a7baa7
commit
629567db72
2 changed files with 306 additions and 0 deletions
135
frontend/src/lib/xray/inbound-form-adapter.ts
Normal file
135
frontend/src/lib/xray/inbound-form-adapter.ts
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
import type { InboundFormValues, TrafficReset } from '@/schemas/forms/inbound-form';
|
||||
import type { InboundSettings } from '@/schemas/protocols/inbound';
|
||||
import type { StreamSettings } from '@/schemas/api/inbound';
|
||||
import type { Sniffing } from '@/schemas/primitives';
|
||||
|
||||
// Plain-data adapter between the panel's stored inbound row shape and
|
||||
// the typed InboundFormValues that Form.useForm<T> carries inside
|
||||
// InboundFormModal. No dependency on the legacy Inbound/DBInbound
|
||||
// classes — the modal hands the raw row in, takes typed values out, and
|
||||
// on submit calls formValuesToWirePayload() to get a payload ready to
|
||||
// POST to /panel/api/inbounds/add or /update/:id.
|
||||
|
||||
export interface RawInboundRow {
|
||||
port?: number;
|
||||
listen?: string;
|
||||
protocol?: string;
|
||||
tag?: string;
|
||||
settings?: unknown;
|
||||
streamSettings?: unknown;
|
||||
sniffing?: unknown;
|
||||
up?: number;
|
||||
down?: number;
|
||||
total?: number;
|
||||
remark?: string;
|
||||
enable?: boolean;
|
||||
expiryTime?: number;
|
||||
trafficReset?: string;
|
||||
lastTrafficResetTime?: number;
|
||||
nodeId?: number | null;
|
||||
clientStats?: unknown;
|
||||
}
|
||||
|
||||
// The wire payload — settings/streamSettings/sniffing arrive as JSON
|
||||
// strings, mirroring what the Go endpoints expect (xray-core wants the
|
||||
// nested config slices as strings to round-trip through its loader).
|
||||
export interface WireInboundPayload {
|
||||
up: number;
|
||||
down: number;
|
||||
total: number;
|
||||
remark: string;
|
||||
enable: boolean;
|
||||
expiryTime: number;
|
||||
trafficReset: TrafficReset;
|
||||
lastTrafficResetTime: number;
|
||||
listen: string;
|
||||
port: number;
|
||||
protocol: string;
|
||||
settings: string;
|
||||
streamSettings: string;
|
||||
sniffing: string;
|
||||
tag: string;
|
||||
clientStats?: unknown;
|
||||
nodeId?: number;
|
||||
}
|
||||
|
||||
function coerceJsonObject(value: unknown): Record<string, unknown> {
|
||||
if (value == null) return {};
|
||||
if (typeof value === 'object' && !Array.isArray(value)) {
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
if (typeof value !== 'string') return {};
|
||||
const trimmed = value.trim();
|
||||
if (trimmed === '') return {};
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
||||
? (parsed as Record<string, unknown>)
|
||||
: {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
const TRAFFIC_RESETS: TrafficReset[] = ['never', 'hourly', 'daily', 'weekly', 'monthly'];
|
||||
|
||||
function coerceTrafficReset(v: unknown): TrafficReset {
|
||||
return typeof v === 'string' && (TRAFFIC_RESETS as string[]).includes(v)
|
||||
? (v as TrafficReset)
|
||||
: 'never';
|
||||
}
|
||||
|
||||
// Map a raw DB row (settings/streamSettings/sniffing as string OR object)
|
||||
// into the typed InboundFormValues. Does NOT validate against the schema —
|
||||
// callers that want a hard guarantee should follow up with
|
||||
// InboundFormSchema.safeParse(...).
|
||||
export function rawInboundToFormValues(row: RawInboundRow): InboundFormValues {
|
||||
const protocol = (row.protocol || 'vless') as InboundSettings['protocol'];
|
||||
const settings = coerceJsonObject(row.settings) as InboundSettings['settings'];
|
||||
const rawStream = coerceJsonObject(row.streamSettings);
|
||||
const streamSettings = Object.keys(rawStream).length > 0
|
||||
? (rawStream as StreamSettings)
|
||||
: undefined;
|
||||
const sniffing = coerceJsonObject(row.sniffing) as unknown as Sniffing;
|
||||
|
||||
return {
|
||||
remark: row.remark ?? '',
|
||||
enable: row.enable ?? true,
|
||||
port: row.port ?? 0,
|
||||
listen: row.listen ?? '',
|
||||
tag: row.tag ?? '',
|
||||
expiryTime: row.expiryTime ?? 0,
|
||||
sniffing,
|
||||
streamSettings,
|
||||
up: row.up ?? 0,
|
||||
down: row.down ?? 0,
|
||||
total: row.total ?? 0,
|
||||
trafficReset: coerceTrafficReset(row.trafficReset),
|
||||
lastTrafficResetTime: row.lastTrafficResetTime ?? 0,
|
||||
nodeId: row.nodeId ?? null,
|
||||
protocol,
|
||||
settings,
|
||||
} as InboundFormValues;
|
||||
}
|
||||
|
||||
export function formValuesToWirePayload(values: InboundFormValues): WireInboundPayload {
|
||||
const payload: WireInboundPayload = {
|
||||
up: values.up,
|
||||
down: values.down,
|
||||
total: values.total,
|
||||
remark: values.remark,
|
||||
enable: values.enable,
|
||||
expiryTime: values.expiryTime,
|
||||
trafficReset: values.trafficReset,
|
||||
lastTrafficResetTime: values.lastTrafficResetTime,
|
||||
listen: values.listen,
|
||||
port: values.port,
|
||||
protocol: values.protocol,
|
||||
settings: JSON.stringify(values.settings ?? {}),
|
||||
streamSettings: values.streamSettings ? JSON.stringify(values.streamSettings) : '',
|
||||
sniffing: JSON.stringify(values.sniffing ?? {}),
|
||||
tag: values.tag,
|
||||
};
|
||||
if (values.nodeId != null) payload.nodeId = values.nodeId;
|
||||
return payload;
|
||||
}
|
||||
171
frontend/src/test/inbound-form-adapter.test.ts
Normal file
171
frontend/src/test/inbound-form-adapter.test.ts
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
/// <reference types="vite/client" />
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
rawInboundToFormValues,
|
||||
formValuesToWirePayload,
|
||||
type RawInboundRow,
|
||||
} from '@/lib/xray/inbound-form-adapter';
|
||||
import { InboundFormSchema } from '@/schemas/forms/inbound-form';
|
||||
|
||||
// Round-trip: raw DB row → InboundFormValues → wire payload, asserting
|
||||
// that the JSON-stringified settings/streamSettings/sniffing in the
|
||||
// payload deserialize back to the same data the raw row carried.
|
||||
|
||||
interface FixtureCase {
|
||||
name: string;
|
||||
row: RawInboundRow;
|
||||
expectedProtocol: string;
|
||||
}
|
||||
|
||||
const vlessRow: RawInboundRow = {
|
||||
id: 7,
|
||||
port: 12345,
|
||||
listen: '0.0.0.0',
|
||||
protocol: 'vless',
|
||||
remark: 'edge-1',
|
||||
enable: true,
|
||||
up: 1024,
|
||||
down: 2048,
|
||||
total: 1_000_000_000,
|
||||
expiryTime: 0,
|
||||
trafficReset: 'monthly',
|
||||
lastTrafficResetTime: 0,
|
||||
tag: 'inbound-1',
|
||||
nodeId: null,
|
||||
settings: {
|
||||
clients: [{
|
||||
id: '8c14d6f7-2e3b-4a91-9d24-3f7a6b8c1e02',
|
||||
email: 'alice@example.test',
|
||||
flow: '',
|
||||
limitIp: 0,
|
||||
totalGB: 0,
|
||||
expiryTime: 0,
|
||||
enable: true,
|
||||
tgId: 0,
|
||||
subId: 'abc123def',
|
||||
comment: '',
|
||||
reset: 0,
|
||||
}],
|
||||
decryption: 'none',
|
||||
encryption: 'none',
|
||||
fallbacks: [],
|
||||
},
|
||||
streamSettings: {
|
||||
network: 'tcp',
|
||||
security: 'none',
|
||||
tcpSettings: { header: { type: 'none' } },
|
||||
},
|
||||
sniffing: {
|
||||
enabled: false,
|
||||
destOverride: ['http', 'tls', 'quic', 'fakedns'],
|
||||
metadataOnly: false,
|
||||
routeOnly: false,
|
||||
ipsExcluded: [],
|
||||
domainsExcluded: [],
|
||||
},
|
||||
} as RawInboundRow & { id: number };
|
||||
|
||||
const cases: FixtureCase[] = [
|
||||
{ name: 'vless tcp none', row: vlessRow, expectedProtocol: 'vless' },
|
||||
{
|
||||
name: 'string-coerced settings',
|
||||
row: {
|
||||
...vlessRow,
|
||||
settings: JSON.stringify(vlessRow.settings),
|
||||
streamSettings: JSON.stringify(vlessRow.streamSettings),
|
||||
sniffing: JSON.stringify(vlessRow.sniffing),
|
||||
},
|
||||
expectedProtocol: 'vless',
|
||||
},
|
||||
{
|
||||
name: 'empty stream settings drop to undefined',
|
||||
row: { ...vlessRow, streamSettings: '' },
|
||||
expectedProtocol: 'vless',
|
||||
},
|
||||
{
|
||||
name: 'unknown trafficReset coerces to never',
|
||||
row: { ...vlessRow, trafficReset: 'totally-fabricated' },
|
||||
expectedProtocol: 'vless',
|
||||
},
|
||||
];
|
||||
|
||||
describe('rawInboundToFormValues', () => {
|
||||
for (const { name, row, expectedProtocol } of cases) {
|
||||
it(`maps ${name}`, () => {
|
||||
const values = rawInboundToFormValues(row);
|
||||
expect(values.protocol).toBe(expectedProtocol);
|
||||
expect(values.port).toBe(row.port);
|
||||
expect(values.remark).toBe(row.remark ?? '');
|
||||
if (name === 'unknown trafficReset coerces to never') {
|
||||
expect(values.trafficReset).toBe('never');
|
||||
}
|
||||
if (name === 'empty stream settings drop to undefined') {
|
||||
expect(values.streamSettings).toBeUndefined();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
it('produces values that the InboundFormSchema accepts', () => {
|
||||
const values = rawInboundToFormValues(vlessRow);
|
||||
const result = InboundFormSchema.safeParse(values);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formValuesToWirePayload', () => {
|
||||
it('stringifies settings/streamSettings/sniffing', () => {
|
||||
const values = rawInboundToFormValues(vlessRow);
|
||||
const payload = formValuesToWirePayload(values);
|
||||
|
||||
expect(typeof payload.settings).toBe('string');
|
||||
expect(typeof payload.streamSettings).toBe('string');
|
||||
expect(typeof payload.sniffing).toBe('string');
|
||||
|
||||
expect(JSON.parse(payload.settings)).toEqual(vlessRow.settings);
|
||||
expect(JSON.parse(payload.streamSettings)).toEqual(vlessRow.streamSettings);
|
||||
expect(JSON.parse(payload.sniffing)).toEqual(vlessRow.sniffing);
|
||||
});
|
||||
|
||||
it('emits empty string for absent streamSettings', () => {
|
||||
const values = rawInboundToFormValues({ ...vlessRow, streamSettings: '' });
|
||||
const payload = formValuesToWirePayload(values);
|
||||
expect(payload.streamSettings).toBe('');
|
||||
});
|
||||
|
||||
it('omits nodeId when null', () => {
|
||||
const values = rawInboundToFormValues({ ...vlessRow, nodeId: null });
|
||||
const payload = formValuesToWirePayload(values);
|
||||
expect('nodeId' in payload).toBe(false);
|
||||
});
|
||||
|
||||
it('includes nodeId when set', () => {
|
||||
const values = rawInboundToFormValues({ ...vlessRow, nodeId: 42 });
|
||||
const payload = formValuesToWirePayload(values);
|
||||
expect(payload.nodeId).toBe(42);
|
||||
});
|
||||
|
||||
it('round-trips through raw → values → payload → values', () => {
|
||||
const original = rawInboundToFormValues(vlessRow);
|
||||
const payload = formValuesToWirePayload(original);
|
||||
const replay = rawInboundToFormValues({
|
||||
port: payload.port,
|
||||
listen: payload.listen,
|
||||
protocol: payload.protocol,
|
||||
tag: payload.tag,
|
||||
settings: payload.settings,
|
||||
streamSettings: payload.streamSettings,
|
||||
sniffing: payload.sniffing,
|
||||
up: payload.up,
|
||||
down: payload.down,
|
||||
total: payload.total,
|
||||
remark: payload.remark,
|
||||
enable: payload.enable,
|
||||
expiryTime: payload.expiryTime,
|
||||
trafficReset: payload.trafficReset,
|
||||
lastTrafficResetTime: payload.lastTrafficResetTime,
|
||||
nodeId: payload.nodeId ?? null,
|
||||
});
|
||||
expect(replay).toEqual(original);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue