mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-05 12:44:22 +00:00
refactor(frontend): extract InboundInfoModal helpers, types & buildInboundInfo
InboundInfoModal.tsx 1081 -> 836 lines. Moved the pure data helpers (network host/path readers, link-protocol check, copy/download/statsColor/IP formatting) plus all shared types and the buildInboundInfo data builder into info/helpers.ts and info/types.ts. The state-coupled render body is left intact (no React render tests to guard a deeper split). Code moved verbatim; no behavior change. All gates green, 337 tests pass.
This commit is contained in:
parent
a32fe94872
commit
f116b09f7c
3 changed files with 268 additions and 261 deletions
|
|
@ -1,280 +1,30 @@
|
|||
import { Fragment, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Divider, Modal, Space, Tabs, Tag, Tooltip } from 'antd';
|
||||
import { getMessage } from '@/utils/messageBus';
|
||||
import { CopyOutlined, SyncOutlined, DeleteOutlined, DownloadOutlined } from '@ant-design/icons';
|
||||
|
||||
import {
|
||||
HttpUtil,
|
||||
IntlUtil,
|
||||
SizeFormatter,
|
||||
ColorUtils,
|
||||
ClipboardManager,
|
||||
FileManager,
|
||||
} from '@/utils';
|
||||
import { HttpUtil, IntlUtil, SizeFormatter, ColorUtils } from '@/utils';
|
||||
import { Protocols } from '@/schemas/primitives';
|
||||
import { InfinityIcon } from '@/components/ui';
|
||||
import { useDatepicker } from '@/hooks/useDatepicker';
|
||||
import { coerceInboundJsonField } from '@/models/dbinbound';
|
||||
import {
|
||||
canEnableTlsFlow,
|
||||
isSS2022 as isSS2022Helper,
|
||||
isSSMultiUser as isSSMultiUserHelper,
|
||||
} from '@/lib/xray/protocol-capabilities';
|
||||
import {
|
||||
genAllLinks,
|
||||
genWireguardConfigs,
|
||||
genWireguardLinks,
|
||||
} from '@/lib/xray/inbound-link';
|
||||
import { inboundFromDb } from '@/lib/xray/inbound-from-db';
|
||||
import type { SubSettings } from '../useInbounds';
|
||||
|
||||
import {
|
||||
buildInboundInfo,
|
||||
copyText,
|
||||
downloadText,
|
||||
formatIpInfo,
|
||||
hasShareLink,
|
||||
statsColor,
|
||||
} from './helpers';
|
||||
import type { ClientSetting, ClientStats, InboundInfo, InboundInfoModalProps } from './types';
|
||||
import './InboundInfoModal.css';
|
||||
|
||||
const LINK_PROTOCOLS: ReadonlySet<string> = new Set([
|
||||
Protocols.VMESS,
|
||||
Protocols.VLESS,
|
||||
Protocols.TROJAN,
|
||||
Protocols.SHADOWSOCKS,
|
||||
Protocols.HYSTERIA,
|
||||
]);
|
||||
|
||||
function hasShareLink(protocol: string): boolean {
|
||||
return LINK_PROTOCOLS.has(protocol);
|
||||
}
|
||||
|
||||
function readHeader(headers: unknown, name: string): string {
|
||||
const needle = name.toLowerCase();
|
||||
if (Array.isArray(headers)) {
|
||||
for (const h of headers) {
|
||||
if (h && typeof h === 'object' && String((h as { name?: string }).name ?? '').toLowerCase() === needle) {
|
||||
return String((h as { value?: unknown }).value ?? '');
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
if (headers && typeof headers === 'object') {
|
||||
for (const [k, v] of Object.entries(headers as Record<string, unknown>)) {
|
||||
if (k.toLowerCase() === needle) {
|
||||
return Array.isArray(v) ? String(v[0] ?? '') : String(v ?? '');
|
||||
}
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function readNetworkHost(stream: Record<string, unknown>, network: string): string | null {
|
||||
switch (network) {
|
||||
case 'tcp': {
|
||||
const tcp = stream.tcpSettings as { header?: { request?: { headers?: unknown } } } | undefined;
|
||||
return readHeader(tcp?.header?.request?.headers, 'host');
|
||||
}
|
||||
case 'ws': {
|
||||
const ws = stream.wsSettings as { host?: string; headers?: unknown } | undefined;
|
||||
return (ws?.host && ws.host.length > 0) ? ws.host : readHeader(ws?.headers, 'host');
|
||||
}
|
||||
case 'httpupgrade': {
|
||||
const hu = stream.httpupgradeSettings as { host?: string; headers?: unknown } | undefined;
|
||||
return (hu?.host && hu.host.length > 0) ? hu.host : readHeader(hu?.headers, 'host');
|
||||
}
|
||||
case 'xhttp': {
|
||||
const xh = stream.xhttpSettings as { host?: string; headers?: unknown } | undefined;
|
||||
return (xh?.host && xh.host.length > 0) ? xh.host : readHeader(xh?.headers, 'host');
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readNetworkPath(stream: Record<string, unknown>, network: string): string | null {
|
||||
switch (network) {
|
||||
case 'tcp': {
|
||||
const tcp = stream.tcpSettings as { header?: { request?: { path?: string[] } } } | undefined;
|
||||
return tcp?.header?.request?.path?.[0] ?? null;
|
||||
}
|
||||
case 'ws':
|
||||
return (stream.wsSettings as { path?: string } | undefined)?.path ?? null;
|
||||
case 'httpupgrade':
|
||||
return (stream.httpupgradeSettings as { path?: string } | undefined)?.path ?? null;
|
||||
case 'xhttp':
|
||||
return (stream.xhttpSettings as { path?: string } | undefined)?.path ?? null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
interface ClientStats {
|
||||
email: string;
|
||||
up: number;
|
||||
down: number;
|
||||
total: number;
|
||||
expiryTime: number;
|
||||
enable?: boolean;
|
||||
}
|
||||
|
||||
interface ClientSetting {
|
||||
email?: string;
|
||||
id?: string;
|
||||
security?: string;
|
||||
password?: string;
|
||||
flow?: string;
|
||||
subId?: string;
|
||||
totalGB?: number;
|
||||
expiryTime?: number;
|
||||
comment?: string;
|
||||
tgId?: string;
|
||||
enable?: boolean;
|
||||
limitIp?: number;
|
||||
created_at?: number;
|
||||
updated_at?: number;
|
||||
}
|
||||
|
||||
interface InboundInfo {
|
||||
protocol: string;
|
||||
clients: ClientSetting[];
|
||||
settings: Record<string, unknown>;
|
||||
isTcp: boolean;
|
||||
isWs: boolean;
|
||||
isHttpupgrade: boolean;
|
||||
isXHTTP: boolean;
|
||||
isGrpc: boolean;
|
||||
isSSMultiUser: boolean;
|
||||
isSS2022: boolean;
|
||||
isVlessTlsFlow: boolean;
|
||||
host: string | null;
|
||||
path: string | null;
|
||||
serviceName: string;
|
||||
serverName: string;
|
||||
stream: {
|
||||
network: string;
|
||||
security: string;
|
||||
xhttp?: { mode?: string };
|
||||
grpc?: { multiMode?: boolean };
|
||||
};
|
||||
}
|
||||
|
||||
interface DBInboundLike {
|
||||
id: number;
|
||||
address: string;
|
||||
port: number;
|
||||
listen: string;
|
||||
protocol: string;
|
||||
remark: string;
|
||||
enable?: boolean;
|
||||
isVMess?: boolean;
|
||||
isVLess?: boolean;
|
||||
isTrojan?: boolean;
|
||||
isSS?: boolean;
|
||||
isMixed?: boolean;
|
||||
isHTTP?: boolean;
|
||||
isWireguard?: boolean;
|
||||
settings: unknown;
|
||||
streamSettings: unknown;
|
||||
sniffing: unknown;
|
||||
clientStats?: ClientStats[];
|
||||
}
|
||||
|
||||
function buildInboundInfo(dbInbound: DBInboundLike): InboundInfo {
|
||||
const settings = coerceInboundJsonField(dbInbound.settings) as Record<string, unknown>;
|
||||
const stream = coerceInboundJsonField(dbInbound.streamSettings) as Record<string, unknown>;
|
||||
const network = (stream.network as string | undefined) ?? '';
|
||||
const security = (stream.security as string | undefined) ?? 'none';
|
||||
const clients = Array.isArray(settings.clients) ? (settings.clients as ClientSetting[]) : [];
|
||||
const xhttpSettings = stream.xhttpSettings as { mode?: string } | undefined;
|
||||
const grpcSettings = stream.grpcSettings as { multiMode?: boolean; serviceName?: string } | undefined;
|
||||
let serverName = '';
|
||||
if (security === 'tls') {
|
||||
const tls = stream.tlsSettings as { sni?: string; serverName?: string } | undefined;
|
||||
serverName = tls?.sni ?? tls?.serverName ?? '';
|
||||
} else if (security === 'reality') {
|
||||
const reality = stream.realitySettings as { serverNames?: string[]; serverName?: string } | undefined;
|
||||
if (Array.isArray(reality?.serverNames)) {
|
||||
serverName = reality.serverNames.join(', ');
|
||||
} else if (reality?.serverName) {
|
||||
serverName = reality.serverName;
|
||||
}
|
||||
}
|
||||
return {
|
||||
protocol: dbInbound.protocol,
|
||||
clients,
|
||||
settings,
|
||||
isTcp: network === 'tcp',
|
||||
isWs: network === 'ws',
|
||||
isHttpupgrade: network === 'httpupgrade',
|
||||
isXHTTP: network === 'xhttp',
|
||||
isGrpc: network === 'grpc',
|
||||
isSSMultiUser: isSSMultiUserHelper({
|
||||
protocol: dbInbound.protocol,
|
||||
settings: settings as { method?: string },
|
||||
}),
|
||||
isSS2022: isSS2022Helper({
|
||||
protocol: dbInbound.protocol,
|
||||
settings: settings as { method?: string },
|
||||
}),
|
||||
isVlessTlsFlow: canEnableTlsFlow({
|
||||
protocol: dbInbound.protocol,
|
||||
streamSettings: { network, security },
|
||||
}),
|
||||
host: readNetworkHost(stream, network),
|
||||
path: readNetworkPath(stream, network),
|
||||
serviceName: grpcSettings?.serviceName ?? '',
|
||||
serverName,
|
||||
stream: {
|
||||
network,
|
||||
security,
|
||||
xhttp: xhttpSettings ? { mode: xhttpSettings.mode } : undefined,
|
||||
grpc: grpcSettings ? { multiMode: grpcSettings.multiMode } : undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
interface InboundInfoModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
dbInbound: DBInboundLike | null;
|
||||
clientIndex?: number;
|
||||
remarkModel?: string;
|
||||
expireDiff?: number;
|
||||
trafficDiff?: number;
|
||||
ipLimitEnable?: boolean;
|
||||
tgBotEnable?: boolean;
|
||||
nodeAddress?: string;
|
||||
subSettings?: SubSettings;
|
||||
lastOnlineMap?: Record<string, number>;
|
||||
}
|
||||
|
||||
function copyText(value: unknown, t: (k: string) => string) {
|
||||
ClipboardManager.copyText(String(value ?? '')).then((ok) => {
|
||||
if (ok) getMessage().success(t('copied'));
|
||||
});
|
||||
}
|
||||
|
||||
function downloadText(content: string, filename: string) {
|
||||
FileManager.downloadTextFile(content, filename);
|
||||
}
|
||||
|
||||
function statsColor(stats: ClientStats, trafficDiff: number) {
|
||||
return ColorUtils.usageColor(stats.up + stats.down, trafficDiff, stats.total);
|
||||
}
|
||||
|
||||
function formatIpInfo(record: unknown) {
|
||||
if (record == null) return '';
|
||||
if (typeof record === 'string' || typeof record === 'number') return String(record);
|
||||
const r = record as { ip?: string; IP?: string; timestamp?: number | string; Timestamp?: number | string };
|
||||
const ip = r.ip || r.IP || '';
|
||||
const ts = r.timestamp || r.Timestamp || 0;
|
||||
if (!ip) return String(record);
|
||||
if (!ts) return String(ip);
|
||||
const date = new Date(Number(ts) * 1000);
|
||||
const timeStr = date
|
||||
.toLocaleString('en-GB', {
|
||||
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
||||
hour12: false,
|
||||
})
|
||||
.replace(',', '');
|
||||
return `${ip} (${timeStr})`;
|
||||
}
|
||||
|
||||
export default function InboundInfoModal({
|
||||
open,
|
||||
onClose,
|
||||
|
|
|
|||
170
frontend/src/pages/inbounds/info/helpers.ts
Normal file
170
frontend/src/pages/inbounds/info/helpers.ts
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
import { getMessage } from '@/utils/messageBus';
|
||||
import { ColorUtils, ClipboardManager, FileManager } from '@/utils';
|
||||
import { Protocols } from '@/schemas/primitives';
|
||||
import { coerceInboundJsonField } from '@/models/dbinbound';
|
||||
import {
|
||||
canEnableTlsFlow,
|
||||
isSS2022 as isSS2022Helper,
|
||||
isSSMultiUser as isSSMultiUserHelper,
|
||||
} from '@/lib/xray/protocol-capabilities';
|
||||
|
||||
import type { ClientSetting, ClientStats, DBInboundLike, InboundInfo } from './types';
|
||||
|
||||
const LINK_PROTOCOLS: ReadonlySet<string> = new Set([
|
||||
Protocols.VMESS,
|
||||
Protocols.VLESS,
|
||||
Protocols.TROJAN,
|
||||
Protocols.SHADOWSOCKS,
|
||||
Protocols.HYSTERIA,
|
||||
]);
|
||||
|
||||
export function hasShareLink(protocol: string): boolean {
|
||||
return LINK_PROTOCOLS.has(protocol);
|
||||
}
|
||||
|
||||
function readHeader(headers: unknown, name: string): string {
|
||||
const needle = name.toLowerCase();
|
||||
if (Array.isArray(headers)) {
|
||||
for (const h of headers) {
|
||||
if (h && typeof h === 'object' && String((h as { name?: string }).name ?? '').toLowerCase() === needle) {
|
||||
return String((h as { value?: unknown }).value ?? '');
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
if (headers && typeof headers === 'object') {
|
||||
for (const [k, v] of Object.entries(headers as Record<string, unknown>)) {
|
||||
if (k.toLowerCase() === needle) {
|
||||
return Array.isArray(v) ? String(v[0] ?? '') : String(v ?? '');
|
||||
}
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function readNetworkHost(stream: Record<string, unknown>, network: string): string | null {
|
||||
switch (network) {
|
||||
case 'tcp': {
|
||||
const tcp = stream.tcpSettings as { header?: { request?: { headers?: unknown } } } | undefined;
|
||||
return readHeader(tcp?.header?.request?.headers, 'host');
|
||||
}
|
||||
case 'ws': {
|
||||
const ws = stream.wsSettings as { host?: string; headers?: unknown } | undefined;
|
||||
return (ws?.host && ws.host.length > 0) ? ws.host : readHeader(ws?.headers, 'host');
|
||||
}
|
||||
case 'httpupgrade': {
|
||||
const hu = stream.httpupgradeSettings as { host?: string; headers?: unknown } | undefined;
|
||||
return (hu?.host && hu.host.length > 0) ? hu.host : readHeader(hu?.headers, 'host');
|
||||
}
|
||||
case 'xhttp': {
|
||||
const xh = stream.xhttpSettings as { host?: string; headers?: unknown } | undefined;
|
||||
return (xh?.host && xh.host.length > 0) ? xh.host : readHeader(xh?.headers, 'host');
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readNetworkPath(stream: Record<string, unknown>, network: string): string | null {
|
||||
switch (network) {
|
||||
case 'tcp': {
|
||||
const tcp = stream.tcpSettings as { header?: { request?: { path?: string[] } } } | undefined;
|
||||
return tcp?.header?.request?.path?.[0] ?? null;
|
||||
}
|
||||
case 'ws':
|
||||
return (stream.wsSettings as { path?: string } | undefined)?.path ?? null;
|
||||
case 'httpupgrade':
|
||||
return (stream.httpupgradeSettings as { path?: string } | undefined)?.path ?? null;
|
||||
case 'xhttp':
|
||||
return (stream.xhttpSettings as { path?: string } | undefined)?.path ?? null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function buildInboundInfo(dbInbound: DBInboundLike): InboundInfo {
|
||||
const settings = coerceInboundJsonField(dbInbound.settings) as Record<string, unknown>;
|
||||
const stream = coerceInboundJsonField(dbInbound.streamSettings) as Record<string, unknown>;
|
||||
const network = (stream.network as string | undefined) ?? '';
|
||||
const security = (stream.security as string | undefined) ?? 'none';
|
||||
const clients = Array.isArray(settings.clients) ? (settings.clients as ClientSetting[]) : [];
|
||||
const xhttpSettings = stream.xhttpSettings as { mode?: string } | undefined;
|
||||
const grpcSettings = stream.grpcSettings as { multiMode?: boolean; serviceName?: string } | undefined;
|
||||
let serverName = '';
|
||||
if (security === 'tls') {
|
||||
const tls = stream.tlsSettings as { sni?: string; serverName?: string } | undefined;
|
||||
serverName = tls?.sni ?? tls?.serverName ?? '';
|
||||
} else if (security === 'reality') {
|
||||
const reality = stream.realitySettings as { serverNames?: string[]; serverName?: string } | undefined;
|
||||
if (Array.isArray(reality?.serverNames)) {
|
||||
serverName = reality.serverNames.join(', ');
|
||||
} else if (reality?.serverName) {
|
||||
serverName = reality.serverName;
|
||||
}
|
||||
}
|
||||
return {
|
||||
protocol: dbInbound.protocol,
|
||||
clients,
|
||||
settings,
|
||||
isTcp: network === 'tcp',
|
||||
isWs: network === 'ws',
|
||||
isHttpupgrade: network === 'httpupgrade',
|
||||
isXHTTP: network === 'xhttp',
|
||||
isGrpc: network === 'grpc',
|
||||
isSSMultiUser: isSSMultiUserHelper({
|
||||
protocol: dbInbound.protocol,
|
||||
settings: settings as { method?: string },
|
||||
}),
|
||||
isSS2022: isSS2022Helper({
|
||||
protocol: dbInbound.protocol,
|
||||
settings: settings as { method?: string },
|
||||
}),
|
||||
isVlessTlsFlow: canEnableTlsFlow({
|
||||
protocol: dbInbound.protocol,
|
||||
streamSettings: { network, security },
|
||||
}),
|
||||
host: readNetworkHost(stream, network),
|
||||
path: readNetworkPath(stream, network),
|
||||
serviceName: grpcSettings?.serviceName ?? '',
|
||||
serverName,
|
||||
stream: {
|
||||
network,
|
||||
security,
|
||||
xhttp: xhttpSettings ? { mode: xhttpSettings.mode } : undefined,
|
||||
grpc: grpcSettings ? { multiMode: grpcSettings.multiMode } : undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function copyText(value: unknown, t: (k: string) => string) {
|
||||
ClipboardManager.copyText(String(value ?? '')).then((ok) => {
|
||||
if (ok) getMessage().success(t('copied'));
|
||||
});
|
||||
}
|
||||
|
||||
export function downloadText(content: string, filename: string) {
|
||||
FileManager.downloadTextFile(content, filename);
|
||||
}
|
||||
|
||||
export function statsColor(stats: ClientStats, trafficDiff: number) {
|
||||
return ColorUtils.usageColor(stats.up + stats.down, trafficDiff, stats.total);
|
||||
}
|
||||
|
||||
export function formatIpInfo(record: unknown) {
|
||||
if (record == null) return '';
|
||||
if (typeof record === 'string' || typeof record === 'number') return String(record);
|
||||
const r = record as { ip?: string; IP?: string; timestamp?: number | string; Timestamp?: number | string };
|
||||
const ip = r.ip || r.IP || '';
|
||||
const ts = r.timestamp || r.Timestamp || 0;
|
||||
if (!ip) return String(record);
|
||||
if (!ts) return String(ip);
|
||||
const date = new Date(Number(ts) * 1000);
|
||||
const timeStr = date
|
||||
.toLocaleString('en-GB', {
|
||||
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
||||
hour12: false,
|
||||
})
|
||||
.replace(',', '');
|
||||
return `${ip} (${timeStr})`;
|
||||
}
|
||||
87
frontend/src/pages/inbounds/info/types.ts
Normal file
87
frontend/src/pages/inbounds/info/types.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import type { SubSettings } from '../useInbounds';
|
||||
|
||||
export interface ClientStats {
|
||||
email: string;
|
||||
up: number;
|
||||
down: number;
|
||||
total: number;
|
||||
expiryTime: number;
|
||||
enable?: boolean;
|
||||
}
|
||||
|
||||
export interface ClientSetting {
|
||||
email?: string;
|
||||
id?: string;
|
||||
security?: string;
|
||||
password?: string;
|
||||
flow?: string;
|
||||
subId?: string;
|
||||
totalGB?: number;
|
||||
expiryTime?: number;
|
||||
comment?: string;
|
||||
tgId?: string;
|
||||
enable?: boolean;
|
||||
limitIp?: number;
|
||||
created_at?: number;
|
||||
updated_at?: number;
|
||||
}
|
||||
|
||||
export interface InboundInfo {
|
||||
protocol: string;
|
||||
clients: ClientSetting[];
|
||||
settings: Record<string, unknown>;
|
||||
isTcp: boolean;
|
||||
isWs: boolean;
|
||||
isHttpupgrade: boolean;
|
||||
isXHTTP: boolean;
|
||||
isGrpc: boolean;
|
||||
isSSMultiUser: boolean;
|
||||
isSS2022: boolean;
|
||||
isVlessTlsFlow: boolean;
|
||||
host: string | null;
|
||||
path: string | null;
|
||||
serviceName: string;
|
||||
serverName: string;
|
||||
stream: {
|
||||
network: string;
|
||||
security: string;
|
||||
xhttp?: { mode?: string };
|
||||
grpc?: { multiMode?: boolean };
|
||||
};
|
||||
}
|
||||
|
||||
export interface DBInboundLike {
|
||||
id: number;
|
||||
address: string;
|
||||
port: number;
|
||||
listen: string;
|
||||
protocol: string;
|
||||
remark: string;
|
||||
enable?: boolean;
|
||||
isVMess?: boolean;
|
||||
isVLess?: boolean;
|
||||
isTrojan?: boolean;
|
||||
isSS?: boolean;
|
||||
isMixed?: boolean;
|
||||
isHTTP?: boolean;
|
||||
isWireguard?: boolean;
|
||||
settings: unknown;
|
||||
streamSettings: unknown;
|
||||
sniffing: unknown;
|
||||
clientStats?: ClientStats[];
|
||||
}
|
||||
|
||||
export interface InboundInfoModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
dbInbound: DBInboundLike | null;
|
||||
clientIndex?: number;
|
||||
remarkModel?: string;
|
||||
expireDiff?: number;
|
||||
trafficDiff?: number;
|
||||
ipLimitEnable?: boolean;
|
||||
tgBotEnable?: boolean;
|
||||
nodeAddress?: string;
|
||||
subSettings?: SubSettings;
|
||||
lastOnlineMap?: Record<string, number>;
|
||||
}
|
||||
Loading…
Reference in a new issue