♻️ refactor: Refactor server management API endpoints and typings

This commit is contained in:
web
2025-08-26 09:37:15 -07:00
parent 217ddce60c
commit 4f7cc807af
141 changed files with 5665 additions and 10338 deletions
+170 -126
View File
@@ -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>
);
}
+108 -393
View File
@@ -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