♻️ refactor: Refactor server management API endpoints and typings
This commit is contained in:
@@ -10,179 +10,223 @@ export const protocols = [
|
||||
'anytls',
|
||||
] as const;
|
||||
|
||||
// Global label map for display; fallback to raw value if missing
|
||||
export const LABELS = {
|
||||
// transport
|
||||
'tcp': 'TCP',
|
||||
'websocket': 'WebSocket',
|
||||
'http2': 'HTTP/2',
|
||||
'httpupgrade': 'HTTP Upgrade',
|
||||
'grpc': 'gRPC',
|
||||
'xtls-rprx-vision': 'XTLS-RPRX-Vision',
|
||||
// security
|
||||
'none': 'NONE',
|
||||
'tls': 'TLS',
|
||||
'reality': 'Reality',
|
||||
// fingerprint
|
||||
'chrome': 'Chrome',
|
||||
'firefox': 'Firefox',
|
||||
'safari': 'Safari',
|
||||
'ios': 'IOS',
|
||||
'android': 'Android',
|
||||
'edge': 'edge',
|
||||
'360': '360',
|
||||
'qq': 'QQ',
|
||||
} as const;
|
||||
|
||||
// Flat arrays for enum-like sets
|
||||
export const SS_CIPHERS = [
|
||||
'aes-128-gcm',
|
||||
'aes-192-gcm',
|
||||
'aes-256-gcm',
|
||||
'chacha20-ietf-poly1305',
|
||||
'2022-blake3-aes-128-gcm',
|
||||
'2022-blake3-aes-256-gcm',
|
||||
'2022-blake3-chacha20-poly1305',
|
||||
] as const;
|
||||
|
||||
export const TRANSPORTS = {
|
||||
vmess: ['tcp', 'websocket', 'grpc', 'httpupgrade'] as const,
|
||||
vless: ['tcp', 'websocket', 'grpc', 'httpupgrade', 'http2'] as const,
|
||||
trojan: ['tcp', 'websocket', 'grpc'] as const,
|
||||
} as const;
|
||||
|
||||
export const SECURITY = {
|
||||
vmess: ['none', 'tls'] as const,
|
||||
vless: ['none', 'tls', 'reality'] as const,
|
||||
trojan: ['tls'] as const,
|
||||
hysteria2: ['tls'] as const,
|
||||
} as const;
|
||||
|
||||
export const FLOWS = {
|
||||
vless: ['none', 'xtls-rprx-vision'] as const,
|
||||
} as const;
|
||||
|
||||
export const TUIC_UDP_RELAY_MODES = ['native', 'quic', 'none'] as const;
|
||||
export const TUIC_CONGESTION = ['bbr', 'cubic', 'new_reno'] as const;
|
||||
export const FINGERPRINTS = [
|
||||
'chrome',
|
||||
'firefox',
|
||||
'safari',
|
||||
'ios',
|
||||
'android',
|
||||
'edge',
|
||||
'360',
|
||||
'qq',
|
||||
] as const;
|
||||
|
||||
export function getLabel(value: string): string {
|
||||
return (LABELS as Record<string, string>)[value] ?? value;
|
||||
}
|
||||
|
||||
const nullableString = z.string().nullish();
|
||||
const portScheme = z.number().max(65535).nullish();
|
||||
const nullableBool = z.boolean().nullish();
|
||||
const nullablePort = z.number().int().min(0).max(65535).nullish();
|
||||
|
||||
const securityConfigScheme = z
|
||||
.object({
|
||||
sni: nullableString,
|
||||
allow_insecure: z.boolean().nullable().default(false),
|
||||
fingerprint: nullableString,
|
||||
reality_private_key: nullableString,
|
||||
reality_public_key: nullableString,
|
||||
reality_short_id: nullableString,
|
||||
reality_server_addr: nullableString,
|
||||
reality_server_port: portScheme,
|
||||
})
|
||||
.nullish();
|
||||
|
||||
const transportConfigScheme = z
|
||||
.object({
|
||||
path: nullableString,
|
||||
host: nullableString,
|
||||
service_name: nullableString,
|
||||
})
|
||||
.nullish();
|
||||
|
||||
const shadowsocksScheme = z.object({
|
||||
method: z.string(),
|
||||
port: portScheme,
|
||||
const ss = z.object({
|
||||
type: z.literal('shadowsocks'),
|
||||
host: nullableString,
|
||||
port: nullablePort,
|
||||
cipher: z.enum(SS_CIPHERS as any).nullish(),
|
||||
server_key: nullableString,
|
||||
});
|
||||
|
||||
const vmessScheme = z.object({
|
||||
port: portScheme,
|
||||
transport: z.string(),
|
||||
transport_config: transportConfigScheme,
|
||||
security: z.string(),
|
||||
security_config: securityConfigScheme,
|
||||
const vmess = z.object({
|
||||
type: z.literal('vmess'),
|
||||
host: nullableString,
|
||||
port: nullablePort,
|
||||
transport: z.enum(TRANSPORTS.vmess as any).nullish(),
|
||||
security: z.enum(SECURITY.vmess as any).nullish(),
|
||||
path: nullableString,
|
||||
service_name: nullableString,
|
||||
sni: nullableString,
|
||||
allow_insecure: nullableBool,
|
||||
fingerprint: nullableString,
|
||||
});
|
||||
|
||||
const vlessScheme = z.object({
|
||||
port: portScheme,
|
||||
transport: z.string(),
|
||||
transport_config: transportConfigScheme,
|
||||
security: z.string(),
|
||||
security_config: securityConfigScheme,
|
||||
flow: nullableString,
|
||||
const vless = z.object({
|
||||
type: z.literal('vless'),
|
||||
host: nullableString,
|
||||
port: nullablePort,
|
||||
transport: z.enum(TRANSPORTS.vless as any).nullish(),
|
||||
security: z.enum(SECURITY.vless as any).nullish(),
|
||||
path: nullableString,
|
||||
service_name: nullableString,
|
||||
flow: z.enum(FLOWS.vless as any).nullish(),
|
||||
sni: nullableString,
|
||||
allow_insecure: nullableBool,
|
||||
fingerprint: nullableString,
|
||||
reality_server_addr: nullableString,
|
||||
reality_server_port: nullablePort,
|
||||
reality_private_key: nullableString,
|
||||
reality_public_key: nullableString,
|
||||
reality_short_id: nullableString,
|
||||
});
|
||||
|
||||
const trojanScheme = z.object({
|
||||
port: portScheme,
|
||||
transport: z.string(),
|
||||
transport_config: transportConfigScheme,
|
||||
security: z.string(),
|
||||
security_config: securityConfigScheme,
|
||||
const trojan = z.object({
|
||||
type: z.literal('trojan'),
|
||||
host: nullableString,
|
||||
port: nullablePort,
|
||||
transport: z.enum(TRANSPORTS.trojan as any).nullish(),
|
||||
security: z.enum(SECURITY.trojan as any).nullish(),
|
||||
path: nullableString,
|
||||
service_name: nullableString,
|
||||
sni: nullableString,
|
||||
allow_insecure: nullableBool,
|
||||
fingerprint: nullableString,
|
||||
});
|
||||
|
||||
const hysteria2Scheme = z.object({
|
||||
port: portScheme,
|
||||
const hysteria2 = z.object({
|
||||
type: z.literal('hysteria2'),
|
||||
hop_ports: nullableString,
|
||||
hop_interval: z.number().nullish(),
|
||||
obfs_password: nullableString,
|
||||
security: z.string(),
|
||||
security_config: securityConfigScheme,
|
||||
host: nullableString,
|
||||
port: nullablePort,
|
||||
security: z.enum(SECURITY.hysteria2 as any).nullish(),
|
||||
sni: nullableString,
|
||||
allow_insecure: nullableBool,
|
||||
fingerprint: nullableString,
|
||||
});
|
||||
|
||||
const tuicScheme = z.object({
|
||||
port: portScheme,
|
||||
disable_sni: z.boolean().default(false),
|
||||
reduce_rtt: z.boolean().default(false),
|
||||
udp_relay_mode: z.string().default('native'),
|
||||
congestion_controller: z.string().default('bbr'),
|
||||
security_config: securityConfigScheme,
|
||||
const tuic = z.object({
|
||||
type: z.literal('tuic'),
|
||||
host: nullableString,
|
||||
port: nullablePort,
|
||||
disable_sni: z.boolean().nullish(),
|
||||
reduce_rtt: z.boolean().nullish(),
|
||||
udp_relay_mode: z.enum(TUIC_UDP_RELAY_MODES as any).nullish(),
|
||||
congestion_controller: z.enum(TUIC_CONGESTION as any).nullish(),
|
||||
sni: nullableString,
|
||||
allow_insecure: nullableBool,
|
||||
fingerprint: nullableString,
|
||||
});
|
||||
|
||||
const anytlsScheme = z.object({
|
||||
port: portScheme,
|
||||
security_config: securityConfigScheme,
|
||||
const anytls = z.object({
|
||||
type: z.literal('anytls'),
|
||||
host: nullableString,
|
||||
port: nullablePort,
|
||||
sni: nullableString,
|
||||
allow_insecure: nullableBool,
|
||||
fingerprint: nullableString,
|
||||
});
|
||||
|
||||
export const protocolConfigScheme = z.discriminatedUnion('protocol', [
|
||||
z.object({
|
||||
protocol: z.literal('shadowsocks'),
|
||||
enabled: z.boolean().default(false),
|
||||
config: shadowsocksScheme,
|
||||
}),
|
||||
z.object({
|
||||
protocol: z.literal('vmess'),
|
||||
enabled: z.boolean().default(false),
|
||||
config: vmessScheme,
|
||||
}),
|
||||
z.object({
|
||||
protocol: z.literal('vless'),
|
||||
enabled: z.boolean().default(false),
|
||||
config: vlessScheme,
|
||||
}),
|
||||
z.object({
|
||||
protocol: z.literal('trojan'),
|
||||
enabled: z.boolean().default(false),
|
||||
config: trojanScheme,
|
||||
}),
|
||||
z.object({
|
||||
protocol: z.literal('hysteria2'),
|
||||
enabled: z.boolean().default(false),
|
||||
config: hysteria2Scheme,
|
||||
}),
|
||||
z.object({
|
||||
protocol: z.literal('tuic'),
|
||||
enabled: z.boolean().default(false),
|
||||
config: tuicScheme,
|
||||
}),
|
||||
z.object({
|
||||
protocol: z.literal('anytls'),
|
||||
enabled: z.boolean().default(false),
|
||||
config: anytlsScheme,
|
||||
}),
|
||||
export const protocolApiScheme = z.discriminatedUnion('type', [
|
||||
ss,
|
||||
vmess,
|
||||
vless,
|
||||
trojan,
|
||||
hysteria2,
|
||||
tuic,
|
||||
anytls,
|
||||
]);
|
||||
|
||||
export const formScheme = z.object({
|
||||
name: z.string(),
|
||||
server_addr: z.string(),
|
||||
name: z.string().min(1),
|
||||
address: z.string().min(1),
|
||||
country: z.string().optional(),
|
||||
city: z.string().optional(),
|
||||
protocols: z.array(protocolConfigScheme).min(1),
|
||||
ratio: z.number().default(1),
|
||||
protocols: z.array(protocolApiScheme),
|
||||
});
|
||||
|
||||
export function getProtocolDefaultConfig(proto: (typeof protocols)[number]) {
|
||||
export type ProtocolType = (typeof protocols)[number];
|
||||
|
||||
export function getProtocolDefaultConfig(proto: ProtocolType) {
|
||||
switch (proto) {
|
||||
case 'shadowsocks':
|
||||
return { method: 'chacha20-ietf-poly1305', port: null, server_key: null };
|
||||
return {
|
||||
type: 'shadowsocks',
|
||||
port: null,
|
||||
cipher: 'chacha20-ietf-poly1305',
|
||||
server_key: null,
|
||||
} as any;
|
||||
case 'vmess':
|
||||
return {
|
||||
port: null,
|
||||
transport: 'tcp',
|
||||
transport_config: null,
|
||||
security: 'none',
|
||||
security_config: null,
|
||||
};
|
||||
return { type: 'vmess', port: null, transport: 'tcp', security: 'none' } as any;
|
||||
case 'vless':
|
||||
return {
|
||||
port: null,
|
||||
transport: 'tcp',
|
||||
transport_config: null,
|
||||
security: 'none',
|
||||
security_config: null,
|
||||
flow: null,
|
||||
};
|
||||
return { type: 'vless', port: null, transport: 'tcp', security: 'none', flow: 'none' } as any;
|
||||
case 'trojan':
|
||||
return {
|
||||
port: null,
|
||||
transport: 'tcp',
|
||||
transport_config: null,
|
||||
security: 'tls',
|
||||
security_config: {},
|
||||
};
|
||||
return { type: 'trojan', port: null, transport: 'tcp', security: 'tls' } as any;
|
||||
case 'hysteria2':
|
||||
return {
|
||||
type: 'hysteria2',
|
||||
port: null,
|
||||
hop_ports: null,
|
||||
hop_interval: null,
|
||||
obfs_password: null,
|
||||
security: 'tls',
|
||||
security_config: {},
|
||||
};
|
||||
} as any;
|
||||
case 'tuic':
|
||||
return {
|
||||
type: 'tuic',
|
||||
port: null,
|
||||
disable_sni: false,
|
||||
reduce_rtt: false,
|
||||
udp_relay_mode: 'native',
|
||||
congestion_controller: 'bbr',
|
||||
security_config: {},
|
||||
};
|
||||
} as any;
|
||||
case 'anytls':
|
||||
return { port: null, security_config: {} };
|
||||
return { type: 'anytls', port: null } as any;
|
||||
default:
|
||||
return {} as any;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
'use client';
|
||||
|
||||
import { UserDetail } from '@/app/dashboard/user/user-detail';
|
||||
import { IpLink } from '@/components/ip-link';
|
||||
import { ProTable } from '@/components/pro-table';
|
||||
import { filterServerList } from '@/services/admin/server';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Badge } from '@workspace/ui/components/badge';
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from '@workspace/ui/components/sheet';
|
||||
import type { useTranslations } from 'next-intl';
|
||||
import { useState } from 'react';
|
||||
|
||||
function mapOnlineUsers(online: API.ServerStatus['online'] = []): {
|
||||
uid: string;
|
||||
ips: string[];
|
||||
subscribe?: string;
|
||||
subscribe_id?: number;
|
||||
traffic?: number;
|
||||
expired_at?: number;
|
||||
}[] {
|
||||
return (online || []).map((u) => ({
|
||||
uid: String(u.user_id || ''),
|
||||
ips: Array.isArray(u.ip) ? u.ip.map(String) : [],
|
||||
subscribe: (u as any).subscribe,
|
||||
subscribe_id: (u as any).subscribe_id,
|
||||
traffic: (u as any).traffic,
|
||||
expired_at: (u as any).expired_at,
|
||||
}));
|
||||
}
|
||||
|
||||
export default function OnlineUsersCell({
|
||||
serverId,
|
||||
status,
|
||||
t,
|
||||
}: {
|
||||
serverId?: number;
|
||||
status?: API.ServerStatus;
|
||||
t: ReturnType<typeof useTranslations>;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const { data: latest } = useQuery({
|
||||
queryKey: ['serverStatusById', serverId, open],
|
||||
enabled: !!serverId && open,
|
||||
queryFn: async () => {
|
||||
const { data } = await filterServerList({ page: 1, size: 1, search: String(serverId) });
|
||||
const list = (data?.data?.list || []) as API.Server[];
|
||||
return list[0]?.status as API.ServerStatus | undefined;
|
||||
},
|
||||
});
|
||||
|
||||
const rows = mapOnlineUsers((latest || status)?.online);
|
||||
const count = rows.length;
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
<SheetTrigger asChild>
|
||||
<button className='hover:text-foreground text-muted-foreground flex items-center gap-2 bg-transparent p-0 text-sm'>
|
||||
<Badge variant='secondary'>{count}</Badge>
|
||||
<span>{t('onlineUsers')}</span>
|
||||
</button>
|
||||
</SheetTrigger>
|
||||
<SheetContent className='h-screen w-screen max-w-none sm:h-auto sm:w-[900px] sm:max-w-[90vw]'>
|
||||
<SheetHeader>
|
||||
<SheetTitle>{t('onlineUsers')}</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div className='-mx-6 h-[calc(100vh-48px-16px)] overflow-y-auto px-6 py-4 sm:h-[calc(100dvh-48px-16px-env(safe-area-inset-top))]'>
|
||||
<ProTable<
|
||||
{
|
||||
uid: string;
|
||||
ips: string[];
|
||||
subscribe?: string;
|
||||
subscribe_id?: number;
|
||||
traffic?: number;
|
||||
expired_at?: number;
|
||||
},
|
||||
Record<string, unknown>
|
||||
>
|
||||
header={{ hidden: true }}
|
||||
columns={[
|
||||
{
|
||||
accessorKey: 'ips',
|
||||
header: t('ipAddresses'),
|
||||
cell: ({ row }) => {
|
||||
const ips = row.original.ips;
|
||||
return (
|
||||
<div className='flex min-w-0 flex-col gap-1'>
|
||||
{ips.map((ip, i) => (
|
||||
<div
|
||||
key={`${row.original.uid}-${ip}`}
|
||||
className='whitespace-nowrap text-sm'
|
||||
>
|
||||
{i === 0 ? (
|
||||
<IpLink ip={ip} className='font-medium' />
|
||||
) : (
|
||||
<IpLink ip={ip} className='text-muted-foreground' />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'user',
|
||||
header: t('user'),
|
||||
cell: ({ row }) => <UserDetail id={Number(row.original.uid)} />,
|
||||
},
|
||||
{
|
||||
accessorKey: 'subscription',
|
||||
header: t('subscription'),
|
||||
cell: ({ row }) => (
|
||||
<span className='text-sm'>{row.original.subscribe || '--'}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'subscribeId',
|
||||
header: t('subscribeId'),
|
||||
cell: ({ row }) => (
|
||||
<span className='font-mono text-sm'>{row.original.subscribe_id || '--'}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'traffic',
|
||||
header: t('traffic'),
|
||||
cell: ({ row }) => {
|
||||
const v = Number(row.original.traffic || 0);
|
||||
return <span className='text-sm'>{(v / 1024 ** 3).toFixed(2)} GB</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'expireTime',
|
||||
header: t('expireTime'),
|
||||
cell: ({ row }) => {
|
||||
const ts = Number(row.original.expired_at || 0);
|
||||
if (!ts) return <span className='text-muted-foreground'>--</span>;
|
||||
const expired = ts < Date.now() / 1000;
|
||||
return (
|
||||
<div className='flex items-center gap-2'>
|
||||
<span className='text-sm'>{new Date(ts * 1000).toLocaleString()}</span>
|
||||
{expired && (
|
||||
<Badge variant='destructive' className='w-fit px-1 py-0 text-xs'>
|
||||
{t('expired')}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
]}
|
||||
request={async () => ({ list: rows, total: rows.length })}
|
||||
/>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -1,156 +1,25 @@
|
||||
'use client';
|
||||
import { UserDetail } from '@/app/dashboard/user/user-detail';
|
||||
import { IpLink } from '@/components/ip-link';
|
||||
// Online users detail moved to separate component
|
||||
import { ProTable, ProTableActions } from '@/components/pro-table';
|
||||
import { getUserSubscribeById } from '@/services/admin/user';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
createServer,
|
||||
deleteServer,
|
||||
filterServerList,
|
||||
updateServer,
|
||||
} from '@/services/admin/server';
|
||||
import { Badge } from '@workspace/ui/components/badge';
|
||||
import { Button } from '@workspace/ui/components/button';
|
||||
import { Card, CardContent } from '@workspace/ui/components/card';
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from '@workspace/ui/components/sheet';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@workspace/ui/components/tooltip';
|
||||
import { ConfirmButton } from '@workspace/ui/custom-components/confirm-button';
|
||||
import { cn } from '@workspace/ui/lib/utils';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useRef, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import OnlineUsersCell from './online-users-cell';
|
||||
import ServerConfig from './server-config';
|
||||
import ServerForm from './server-form';
|
||||
|
||||
type ProtocolName = 'shadowsocks' | 'vmess' | 'vless' | 'trojan' | 'hysteria2' | 'tuic' | 'anytls';
|
||||
type ProtocolEntry = { protocol: ProtocolName; enabled: boolean; config: Record<string, unknown> };
|
||||
|
||||
interface ServerFormFields {
|
||||
name: string;
|
||||
server_addr: string;
|
||||
country?: string;
|
||||
city?: string;
|
||||
protocols: ProtocolEntry[];
|
||||
}
|
||||
|
||||
type ServerStatus = {
|
||||
online?: unknown;
|
||||
cpu?: number;
|
||||
mem?: number;
|
||||
disk?: number;
|
||||
updated_at?: number;
|
||||
};
|
||||
|
||||
type ServerItem = ServerFormFields & { id: number; status?: ServerStatus; [key: string]: unknown };
|
||||
|
||||
const mockList: ServerItem[] = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Server A',
|
||||
server_addr: '1.1.1.1',
|
||||
country: 'US',
|
||||
city: 'SFO',
|
||||
protocols: [
|
||||
{
|
||||
protocol: 'shadowsocks',
|
||||
enabled: true,
|
||||
config: { method: 'aes-128-gcm', port: 443, server_key: null },
|
||||
},
|
||||
{
|
||||
protocol: 'trojan',
|
||||
enabled: true,
|
||||
config: { port: 8443, transport: 'tcp', security: 'tls' },
|
||||
},
|
||||
{
|
||||
protocol: 'vmess',
|
||||
enabled: false,
|
||||
config: {
|
||||
port: 1443,
|
||||
transport: 'websocket',
|
||||
transport_config: { path: '/ws', host: 'example.com' },
|
||||
security: 'tls',
|
||||
},
|
||||
},
|
||||
],
|
||||
status: {
|
||||
online: { 1001: ['1.2.3.4'], 1002: ['5.6.7.8', '9.9.9.9'] },
|
||||
cpu: 34,
|
||||
mem: 62,
|
||||
disk: 48,
|
||||
updated_at: Date.now() / 1000,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Server B',
|
||||
server_addr: '2.2.2.2',
|
||||
country: 'JP',
|
||||
city: 'Tokyo',
|
||||
protocols: [
|
||||
{
|
||||
protocol: 'vmess',
|
||||
enabled: true,
|
||||
config: { port: 2443, transport: 'tcp', security: 'none' },
|
||||
},
|
||||
{
|
||||
protocol: 'hysteria2',
|
||||
enabled: true,
|
||||
config: { port: 3443, hop_ports: '443,8443,10443', hop_interval: 15, security: 'tls' },
|
||||
},
|
||||
{ protocol: 'tuic', enabled: false, config: { port: 4443 } },
|
||||
],
|
||||
status: {
|
||||
online: { 2001: ['10.0.0.1'] },
|
||||
cpu: 72,
|
||||
mem: 81,
|
||||
disk: 67,
|
||||
updated_at: Date.now() / 1000,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'Server C',
|
||||
server_addr: '3.3.3.3',
|
||||
country: 'DE',
|
||||
city: 'FRA',
|
||||
protocols: [
|
||||
{ protocol: 'anytls', enabled: true, config: { port: 80 } },
|
||||
{
|
||||
protocol: 'shadowsocks',
|
||||
enabled: false,
|
||||
config: { method: 'chacha20-ietf-poly1305', port: 8080 },
|
||||
},
|
||||
],
|
||||
status: { online: {}, cpu: 0, mem: 0, disk: 0, updated_at: 0 },
|
||||
},
|
||||
];
|
||||
|
||||
let mockData: ServerItem[] = [...mockList];
|
||||
const getServerList = async () => ({ list: mockData, total: mockData.length });
|
||||
const createServer = async (values: Omit<ServerItem, 'id'>) => {
|
||||
mockData.push({
|
||||
id: Date.now(),
|
||||
name: '',
|
||||
server_addr: '',
|
||||
protocols: [],
|
||||
...values,
|
||||
});
|
||||
return true;
|
||||
};
|
||||
const updateServer = async (id: number, values: Omit<ServerItem, 'id'>) => {
|
||||
mockData = mockData.map((i) => (i.id === id ? { ...i, ...values } : i));
|
||||
return true;
|
||||
};
|
||||
const deleteServer = async (id: number) => {
|
||||
mockData = mockData.filter((i) => i.id !== id);
|
||||
return true;
|
||||
};
|
||||
|
||||
const PROTOCOL_COLORS: Record<ProtocolName, string> = {
|
||||
shadowsocks: 'bg-green-500',
|
||||
@@ -162,42 +31,8 @@ const PROTOCOL_COLORS: Record<ProtocolName, string> = {
|
||||
anytls: 'bg-gray-500',
|
||||
};
|
||||
|
||||
function getEnabledProtocols(p: ServerItem['protocols']) {
|
||||
return Array.isArray(p) ? p.filter((x) => x.enabled) : [];
|
||||
}
|
||||
|
||||
function ProtocolBadge({
|
||||
item,
|
||||
t,
|
||||
}: {
|
||||
item: ServerItem['protocols'][number];
|
||||
t: (key: string) => string;
|
||||
}) {
|
||||
const color = PROTOCOL_COLORS[item.protocol];
|
||||
const port = (item?.config as any)?.port as number | undefined;
|
||||
const extra: string[] = [];
|
||||
if ((item.config as any)?.transport) extra.push(String((item.config as any).transport));
|
||||
if ((item.config as any)?.security && (item.config as any).security !== 'none')
|
||||
extra.push(String((item.config as any).security));
|
||||
const label = `${item.protocol}${port ? ` (${port})` : ''}`;
|
||||
const tipParts = [label, extra.length ? `· ${extra.join(' / ')}` : ''].filter(Boolean);
|
||||
const tooltip = tipParts.join(' ');
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge variant='outline' className={cn('text-primary-foreground', color)}>
|
||||
{label}
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{tooltip || t('notAvailable')}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function PctBar({ value }: { value: number }) {
|
||||
const v = Math.max(0, Math.min(100, Math.round(value)));
|
||||
const v = value.toFixed(2);
|
||||
return (
|
||||
<div className='min-w-24'>
|
||||
<div className='text-xs leading-none'>{v}%</div>
|
||||
@@ -216,183 +51,19 @@ function RegionIpCell({
|
||||
}: {
|
||||
country?: string;
|
||||
city?: string;
|
||||
ip: string;
|
||||
ip?: string;
|
||||
t: (key: string) => string;
|
||||
}) {
|
||||
const region = [country, city].filter(Boolean).join(' / ') || t('notAvailable');
|
||||
return (
|
||||
<div className='flex items-center gap-1'>
|
||||
<Badge variant='outline'>{region}</Badge>
|
||||
<Badge variant='outline'>{ip}</Badge>
|
||||
<Badge variant='outline'>{ip || t('notAvailable')}</Badge>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UserSubscribeInfo({
|
||||
userId,
|
||||
type,
|
||||
t,
|
||||
}: {
|
||||
userId: number;
|
||||
type: 'account' | 'subscribeName' | 'subscribeId' | 'traffic' | 'expireTime';
|
||||
t: (key: string) => string;
|
||||
}) {
|
||||
const { data } = useQuery({
|
||||
enabled: userId !== 0,
|
||||
queryKey: ['getUserSubscribeById', userId],
|
||||
queryFn: async () => {
|
||||
const { data } = await getUserSubscribeById({ id: userId });
|
||||
return data.data;
|
||||
},
|
||||
});
|
||||
if (!data) return <span className='text-muted-foreground'>--</span>;
|
||||
if (type === 'account')
|
||||
return data.user_id ? (
|
||||
<UserDetail id={data.user_id} />
|
||||
) : (
|
||||
<span className='text-muted-foreground'>--</span>
|
||||
);
|
||||
if (type === 'subscribeName')
|
||||
return data.subscribe?.name ? (
|
||||
<span className='text-sm'>{data.subscribe.name}</span>
|
||||
) : (
|
||||
<span className='text-muted-foreground'>--</span>
|
||||
);
|
||||
if (type === 'subscribeId')
|
||||
return data.id ? (
|
||||
<span className='font-mono text-sm'>{data.id}</span>
|
||||
) : (
|
||||
<span className='text-muted-foreground'>--</span>
|
||||
);
|
||||
if (type === 'traffic') {
|
||||
const used = (data.upload || 0) + (data.download || 0);
|
||||
const total = data.traffic || 0;
|
||||
return (
|
||||
<div className='min-w-0 text-sm'>{`${(used / 1024 ** 3).toFixed(2)} GB / ${total > 0 ? (total / 1024 ** 3).toFixed(2) + ' GB' : t('unlimited')}`}</div>
|
||||
);
|
||||
}
|
||||
if (type === 'expireTime') {
|
||||
if (!data.expire_time) return <span className='text-muted-foreground'>--</span>;
|
||||
const expired = data.expire_time < Date.now() / 1000;
|
||||
return (
|
||||
<div className='flex items-center gap-2'>
|
||||
<span className='text-sm'>{new Date((data.expire_time || 0) * 1000).toLocaleString()}</span>
|
||||
{expired && (
|
||||
<Badge variant='destructive' className='w-fit px-1 py-0 text-xs'>
|
||||
{t('expired')}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <span className='text-muted-foreground'>--</span>;
|
||||
}
|
||||
|
||||
function normalizeOnlineMap(online: unknown): { uid: string; ips: string[] }[] {
|
||||
if (!online || typeof online !== 'object' || Array.isArray(online)) return [];
|
||||
const m = online as Record<string, unknown>;
|
||||
const rows = Object.entries(m).map(([uid, ips]) => {
|
||||
if (Array.isArray(ips)) return { uid, ips: (ips as unknown[]).map(String) };
|
||||
if (typeof ips === 'string') return { uid, ips: [ips] };
|
||||
const o = ips as Record<string, unknown>;
|
||||
if (Array.isArray(o?.ips)) return { uid, ips: (o.ips as unknown[]).map(String) };
|
||||
return { uid, ips: [] };
|
||||
});
|
||||
return rows.filter((r) => r.ips.length > 0);
|
||||
}
|
||||
|
||||
function OnlineUsersCell({ status, t }: { status?: ServerStatus; t: (key: string) => string }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const rows = normalizeOnlineMap(status?.online);
|
||||
const count = rows.length;
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
<SheetTrigger asChild>
|
||||
<button className='hover:text-foreground text-muted-foreground flex items-center gap-2 bg-transparent p-0 text-sm'>
|
||||
<Badge variant='secondary'>{count}</Badge>
|
||||
<span>{t('onlineUsers')}</span>
|
||||
</button>
|
||||
</SheetTrigger>
|
||||
<SheetContent className='sm:w=[900px] h-screen w-screen max-w-none sm:h-auto sm:max-w-[90vw]'>
|
||||
<SheetHeader>
|
||||
<SheetTitle>{t('onlineUsers')}</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div className='-mx-6 h-[calc(100vh-48px-16px)] overflow-y-auto px-6 py-4 sm:h-[calc(100dvh-48px-16px-env(safe-area-inset-top))]'>
|
||||
<ProTable<
|
||||
{
|
||||
uid: string;
|
||||
ips: string[];
|
||||
},
|
||||
Record<string, unknown>
|
||||
>
|
||||
header={{ hidden: true }}
|
||||
columns={[
|
||||
{
|
||||
accessorKey: 'ips',
|
||||
header: t('ipAddresses'),
|
||||
cell: ({ row }) => {
|
||||
const ips = row.original.ips;
|
||||
return (
|
||||
<div className='flex min-w-0 flex-col gap-1'>
|
||||
{ips.map((ip, i) => (
|
||||
<div
|
||||
key={`${row.original.uid}-${ip}`}
|
||||
className='whitespace-nowrap text-sm'
|
||||
>
|
||||
{i === 0 ? (
|
||||
<IpLink ip={ip} className='font-medium' />
|
||||
) : (
|
||||
<IpLink ip={ip} className='text-muted-foreground' />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'user',
|
||||
header: t('user'),
|
||||
cell: ({ row }) => (
|
||||
<UserSubscribeInfo userId={Number(row.original.uid)} type='account' t={t} />
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'subscription',
|
||||
header: t('subscription'),
|
||||
cell: ({ row }) => (
|
||||
<UserSubscribeInfo userId={Number(row.original.uid)} type='subscribeName' t={t} />
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'subscribeId',
|
||||
header: t('subscribeId'),
|
||||
cell: ({ row }) => (
|
||||
<UserSubscribeInfo userId={Number(row.original.uid)} type='subscribeId' t={t} />
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'traffic',
|
||||
header: t('traffic'),
|
||||
cell: ({ row }) => (
|
||||
<UserSubscribeInfo userId={Number(row.original.uid)} type='traffic' t={t} />
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'expireTime',
|
||||
header: t('expireTime'),
|
||||
cell: ({ row }) => (
|
||||
<UserSubscribeInfo userId={Number(row.original.uid)} type='expireTime' t={t} />
|
||||
),
|
||||
},
|
||||
]}
|
||||
request={async () => ({ list: rows, total: rows.length })}
|
||||
/>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
// OnlineUsersCell is now a standalone component
|
||||
|
||||
export default function ServersPage() {
|
||||
const t = useTranslations('servers');
|
||||
@@ -407,7 +78,7 @@ export default function ServersPage() {
|
||||
<ServerConfig />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<ProTable<ServerItem, { search: string }>
|
||||
<ProTable<API.Server, { search: string }>
|
||||
action={ref}
|
||||
header={{
|
||||
title: t('pageTitle'),
|
||||
@@ -418,11 +89,16 @@ export default function ServersPage() {
|
||||
loading={loading}
|
||||
onSubmit={async (values) => {
|
||||
setLoading(true);
|
||||
await createServer(values as any);
|
||||
toast.success(t('created'));
|
||||
ref.current?.refresh();
|
||||
setLoading(false);
|
||||
return true;
|
||||
try {
|
||||
await createServer(values as unknown as API.CreateServerRequest);
|
||||
toast.success(t('created'));
|
||||
ref.current?.refresh();
|
||||
setLoading(false);
|
||||
return true;
|
||||
} catch (e) {
|
||||
setLoading(false);
|
||||
return false;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
),
|
||||
@@ -436,12 +112,12 @@ export default function ServersPage() {
|
||||
{ accessorKey: 'name', header: t('name') },
|
||||
{
|
||||
id: 'region_ip',
|
||||
header: t('serverAddress'),
|
||||
header: t('address'),
|
||||
cell: ({ row }) => (
|
||||
<RegionIpCell
|
||||
country={row.original.country as string}
|
||||
city={row.original.city as string}
|
||||
ip={row.original.server_addr as string}
|
||||
country={row.original.country as unknown as string}
|
||||
city={row.original.city as unknown as string}
|
||||
ip={row.original.address as unknown as string}
|
||||
t={t}
|
||||
/>
|
||||
),
|
||||
@@ -450,28 +126,37 @@ export default function ServersPage() {
|
||||
accessorKey: 'protocols',
|
||||
header: t('protocols'),
|
||||
cell: ({ row }) => {
|
||||
const enabled = getEnabledProtocols(row.original.protocols);
|
||||
if (!enabled.length) return t('noData');
|
||||
const list = (row.original.protocols || []) as API.Protocol[];
|
||||
if (!list.length) return t('noData');
|
||||
return (
|
||||
<div className='flex flex-wrap gap-1'>
|
||||
{enabled.map((p, idx) => (
|
||||
<ProtocolBadge key={idx} item={p} t={t} />
|
||||
))}
|
||||
{list.map((p, idx) => {
|
||||
const proto = ((p as any)?.type || '') as ProtocolName | '';
|
||||
if (!proto) return null;
|
||||
const color = PROTOCOL_COLORS[proto as ProtocolName];
|
||||
const port = (p as any)?.port as number | undefined;
|
||||
const label = `${proto}${port ? ` (${port})` : ''}`;
|
||||
return (
|
||||
<Badge
|
||||
key={idx}
|
||||
variant='outline'
|
||||
className={cn('text-primary-foreground', color)}
|
||||
>
|
||||
{label}
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
id: 'status',
|
||||
header: t('status'),
|
||||
cell: ({ row }) => {
|
||||
const s = (row.original.status ?? {}) as ServerStatus;
|
||||
const on = !!(
|
||||
s.online &&
|
||||
typeof s.online === 'object' &&
|
||||
!Array.isArray(s.online) &&
|
||||
Object.keys(s.online as Record<string, unknown>).length
|
||||
);
|
||||
const s = (row.original.status ?? {}) as API.ServerStatus;
|
||||
const on = !!(Array.isArray(s.online) && s.online.length > 0);
|
||||
return (
|
||||
<div className='flex items-center gap-2'>
|
||||
<span
|
||||
@@ -488,38 +173,56 @@ export default function ServersPage() {
|
||||
{
|
||||
id: 'cpu',
|
||||
header: t('cpu'),
|
||||
cell: ({ row }) => <PctBar value={(row.original.status?.cpu as number) ?? 0} />,
|
||||
cell: ({ row }) => (
|
||||
<PctBar value={(row.original.status?.cpu as unknown as number) ?? 0} />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'mem',
|
||||
header: t('memory'),
|
||||
cell: ({ row }) => <PctBar value={(row.original.status?.mem as number) ?? 0} />,
|
||||
cell: ({ row }) => (
|
||||
<PctBar value={(row.original.status?.mem as unknown as number) ?? 0} />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'disk',
|
||||
header: t('disk'),
|
||||
cell: ({ row }) => <PctBar value={(row.original.status?.disk as number) ?? 0} />,
|
||||
cell: ({ row }) => (
|
||||
<PctBar value={(row.original.status?.disk as unknown as number) ?? 0} />
|
||||
),
|
||||
},
|
||||
|
||||
{
|
||||
id: 'online_users',
|
||||
header: t('onlineUsers'),
|
||||
cell: ({ row }) => (
|
||||
<OnlineUsersCell status={row.original.status as ServerStatus} t={t} />
|
||||
<OnlineUsersCell
|
||||
serverId={row.original.id}
|
||||
status={row.original.status as API.ServerStatus}
|
||||
t={t}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'traffic_ratio',
|
||||
header: t('traffic_ratio'),
|
||||
cell: ({ row }) => {
|
||||
const raw = row.original.ratio as unknown;
|
||||
const ratio = Number(raw ?? 1) || 1;
|
||||
return <span className='text-sm'>{ratio.toFixed(2)}x</span>;
|
||||
},
|
||||
},
|
||||
]}
|
||||
params={[{ key: 'search' }]}
|
||||
request={async (_pagination, filter) => {
|
||||
const { list } = await getServerList();
|
||||
const keyword = (filter?.search || '').toLowerCase().trim();
|
||||
const filtered = keyword
|
||||
? list.filter((item) =>
|
||||
[item.name, item.server_addr, item.country, item.city]
|
||||
.filter(Boolean)
|
||||
.some((v) => String(v).toLowerCase().includes(keyword)),
|
||||
)
|
||||
: list;
|
||||
return { list: filtered, total: filtered.length };
|
||||
request={async (pagination, filter) => {
|
||||
const { data } = await filterServerList({
|
||||
page: pagination.page,
|
||||
size: pagination.size,
|
||||
search: filter?.search || undefined,
|
||||
});
|
||||
const list = (data?.data?.list || []) as API.Server[];
|
||||
const total = (data?.data?.total ?? list.length) as number;
|
||||
return { list, total };
|
||||
}}
|
||||
actions={{
|
||||
render: (row) => [
|
||||
@@ -527,21 +230,24 @@ export default function ServersPage() {
|
||||
key='edit'
|
||||
trigger={t('edit')}
|
||||
title={t('drawerEditTitle')}
|
||||
initialValues={{
|
||||
name: row.name as string,
|
||||
server_addr: row.server_addr as string,
|
||||
country: (row as any).country,
|
||||
city: (row as any).city,
|
||||
protocols: (row as ServerItem).protocols,
|
||||
}}
|
||||
initialValues={row as any}
|
||||
loading={loading}
|
||||
onSubmit={async (values) => {
|
||||
setLoading(true);
|
||||
await updateServer(row.id as number, values as any);
|
||||
toast.success(t('updated'));
|
||||
ref.current?.refresh();
|
||||
setLoading(false);
|
||||
return true;
|
||||
try {
|
||||
// ServerForm already returns API-shaped body; add id for update
|
||||
await updateServer({
|
||||
id: row.id,
|
||||
...(values as unknown as Omit<API.UpdateServerRequest, 'id'>),
|
||||
});
|
||||
toast.success(t('updated'));
|
||||
ref.current?.refresh();
|
||||
setLoading(false);
|
||||
return true;
|
||||
} catch (e) {
|
||||
setLoading(false);
|
||||
return false;
|
||||
}
|
||||
}}
|
||||
/>,
|
||||
<ConfirmButton
|
||||
@@ -550,7 +256,7 @@ export default function ServersPage() {
|
||||
title={t('confirmDeleteTitle')}
|
||||
description={t('confirmDeleteDesc')}
|
||||
onConfirm={async () => {
|
||||
await deleteServer(row.id as number);
|
||||
await deleteServer({ id: row.id } as any);
|
||||
toast.success(t('deleted'));
|
||||
ref.current?.refresh();
|
||||
}}
|
||||
@@ -562,8 +268,17 @@ export default function ServersPage() {
|
||||
variant='outline'
|
||||
onClick={async () => {
|
||||
setLoading(true);
|
||||
const { id, ...others } = row as ServerItem;
|
||||
await createServer(others as any);
|
||||
const { id, created_at, updated_at, last_reported_at, status, ...others } =
|
||||
row as any;
|
||||
const body: API.CreateServerRequest = {
|
||||
name: others.name,
|
||||
country: others.country,
|
||||
city: others.city,
|
||||
ratio: others.ratio,
|
||||
address: others.address,
|
||||
protocols: others.protocols || [],
|
||||
};
|
||||
await createServer(body);
|
||||
toast.success(t('copied'));
|
||||
ref.current?.refresh();
|
||||
setLoading(false);
|
||||
|
||||
@@ -188,7 +188,7 @@ export default function ServerConfig() {
|
||||
<div className='flex cursor-pointer items-center justify-between'>
|
||||
<div className='flex items-center gap-3'>
|
||||
<div className='bg-primary/10 flex h-10 w-10 items-center justify-center rounded-lg'>
|
||||
<Icon icon='mdi:server-cog' className='text-primary h-5 w-5' />
|
||||
<Icon icon='mdi:resistor-nodes' className='text-primary h-5 w-5' />
|
||||
</div>
|
||||
<div className='flex-1'>
|
||||
<p className='font-medium'>{t('config.title')}</p>
|
||||
@@ -274,6 +274,7 @@ export default function ServerConfig() {
|
||||
<EnhancedInput
|
||||
type='number'
|
||||
min={0}
|
||||
suffix='S'
|
||||
step={0.1}
|
||||
value={field.value as any}
|
||||
onValueChange={field.onChange}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user