Files
3x-ui-agent/frontend/src/models/dbinbound.ts
T
Muse d867b15f4a fix(inbounds): forwarding echo lost in DBInbound — add the missing field
The edit modal seeds from the list row wrapped in DBInbound, whose
cloneProps only copies keys the instance already owns. DBInbound had
no forwarding field, so the stored ForwardingSpec JSON was silently
dropped on every row wrap and rawInboundToFormValues fell back to
direct — saved forwarding compiled and worked, but never echoed back
into the form. Backend/API carried the field correctly all along.

Regression test covers managed/manual/direct through the exact
row -> DBInbound -> form-values chain.
2026-10-21 20:59:36 +00:00

238 lines
5.3 KiB
TypeScript

import dayjs, { type Dayjs } from 'dayjs';
import { ObjectUtil, NumberFormatter, SizeFormatter } from '@/utils';
import { Protocols } from '@/schemas/primitives';
export type RawJsonField = string | Record<string, unknown> | unknown[];
export interface ClientStats {
email: string;
up: number;
down: number;
total: number;
expiryTime: number;
enable?: boolean;
inboundId?: number;
reset?: number;
}
export interface FallbackParentRef {
masterId: number;
path: string;
}
export type DBInboundInit = Partial<{
id: number;
userId: number;
up: number;
down: number;
total: number;
remark: string;
enable: boolean;
expiryTime: number;
trafficReset: string;
trafficResetDay: number;
lastTrafficResetTime: number;
listen: string;
port: number;
protocol: string;
settings: RawJsonField;
streamSettings: RawJsonField;
tag: string;
sniffing: RawJsonField;
forwarding: string;
clientStats: ClientStats[];
nodeId: number | null;
shareAddrStrategy: string;
shareAddr: string;
subSortIndex: number;
disableFlow: boolean;
originNodeGuid: string;
fallbackParent: FallbackParentRef | null;
}>;
export function coerceInboundJsonField(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);
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
return parsed as Record<string, unknown>;
}
return {};
} catch {
return {};
}
}
export class DBInbound {
id: number;
userId: number;
up: number;
down: number;
total: number;
remark: string;
enable: boolean;
expiryTime: number;
trafficReset: string;
trafficResetDay: number;
lastTrafficResetTime: number;
listen: string;
port: number;
protocol: string;
settings: RawJsonField;
streamSettings: RawJsonField;
tag: string;
sniffing: RawJsonField;
forwarding: string;
clientStats: ClientStats[];
nodeId: number | null;
shareAddrStrategy: string;
shareAddr: string;
subSortIndex: number;
disableFlow: boolean;
originNodeGuid: string;
fallbackParent: FallbackParentRef | null;
private _clientStatsMap: Map<string, ClientStats> | null = null;
constructor(data?: DBInboundInit) {
this.id = 0;
this.userId = 0;
this.up = 0;
this.down = 0;
this.total = 0;
this.remark = '';
this.enable = true;
this.expiryTime = 0;
this.trafficReset = 'never';
this.trafficResetDay = 1;
this.lastTrafficResetTime = 0;
this.listen = '';
this.port = 0;
this.protocol = '';
this.settings = '';
this.streamSettings = '';
this.tag = '';
this.sniffing = '';
this.forwarding = '';
this.clientStats = [];
this.nodeId = null;
this.shareAddrStrategy = 'node';
this.shareAddr = '';
this.subSortIndex = 1;
this.disableFlow = false;
this.originNodeGuid = '';
this.fallbackParent = null;
if (data == null) {
return;
}
ObjectUtil.cloneProps(this, data);
}
get totalGB(): number {
return NumberFormatter.toFixed(this.total / SizeFormatter.ONE_GB, 2);
}
set totalGB(gb: number) {
this.total = NumberFormatter.toFixed(gb * SizeFormatter.ONE_GB, 0);
}
get isVMess() {
return this.protocol === Protocols.VMESS;
}
get isVLess() {
return this.protocol === Protocols.VLESS;
}
get isTrojan() {
return this.protocol === Protocols.TROJAN;
}
get isSS() {
return this.protocol === Protocols.SHADOWSOCKS;
}
get isMixed() {
return this.protocol === Protocols.MIXED;
}
get isHTTP() {
return this.protocol === Protocols.HTTP;
}
get isWireguard() {
return this.protocol === Protocols.WIREGUARD;
}
get isAmneziawg() {
return this.protocol === Protocols.AMNEZIAWG;
}
get isHysteria() {
return this.protocol === Protocols.HYSTERIA;
}
get isTunnel() {
return this.protocol === Protocols.TUNNEL;
}
get address(): string {
let address = location.hostname;
if (!ObjectUtil.isEmpty(this.listen) && this.listen !== '0.0.0.0') {
address = this.listen;
}
return address;
}
get _expiryTime(): Dayjs | null {
if (this.expiryTime === 0) {
return null;
}
return dayjs(this.expiryTime);
}
set _expiryTime(t: Dayjs | null | undefined) {
if (t == null) {
this.expiryTime = 0;
} else {
this.expiryTime = t.valueOf();
}
}
get isExpiry(): boolean {
return this.expiryTime < new Date().getTime();
}
invalidateCache(): void {
this._clientStatsMap = null;
}
toJSON(): Record<string, unknown> {
const out: Record<string, unknown> = { ...(this as unknown as Record<string, unknown>) };
delete out._clientStatsMap;
return out;
}
getClientStats(email: string): ClientStats | undefined {
if (!this._clientStatsMap) {
this._clientStatsMap = new Map();
if (Array.isArray(this.clientStats)) {
for (const stats of this.clientStats) {
if (stats && stats.email) {
this._clientStatsMap.set(stats.email, stats);
}
}
}
}
return this._clientStatsMap.get(email);
}
}