mirror of
https://github.com/perfect-panel/ppanel-web.git
synced 2026-02-16 21:31:10 -05:00
580 lines
18 KiB
TypeScript
580 lines
18 KiB
TypeScript
'use client';
|
|
import { UserDetail } from '@/app/dashboard/user/user-detail';
|
|
import { IpLink } from '@/components/ip-link';
|
|
import { ProTable, ProTableActions } from '@/components/pro-table';
|
|
import { getUserSubscribeById } from '@/services/admin/user';
|
|
import { useQuery } from '@tanstack/react-query';
|
|
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 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',
|
|
vmess: 'bg-rose-500',
|
|
vless: 'bg-blue-500',
|
|
trojan: 'bg-yellow-500',
|
|
hysteria2: 'bg-purple-500',
|
|
tuic: 'bg-cyan-500',
|
|
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)));
|
|
return (
|
|
<div className='min-w-24'>
|
|
<div className='text-xs leading-none'>{v}%</div>
|
|
<div className='bg-muted h-1.5 w-full rounded'>
|
|
<div className='bg-primary h-1.5 rounded' style={{ width: `${v}%` }} />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function RegionIpCell({
|
|
country,
|
|
city,
|
|
ip,
|
|
t,
|
|
}: {
|
|
country?: string;
|
|
city?: 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>
|
|
</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>
|
|
);
|
|
}
|
|
|
|
export default function ServersPage() {
|
|
const t = useTranslations('servers');
|
|
|
|
const [loading, setLoading] = useState(false);
|
|
const ref = useRef<ProTableActions>(null);
|
|
|
|
return (
|
|
<div className='space-y-4'>
|
|
<Card>
|
|
<CardContent className='p-4'>
|
|
<ServerConfig />
|
|
</CardContent>
|
|
</Card>
|
|
<ProTable<ServerItem, { search: string }>
|
|
action={ref}
|
|
header={{
|
|
title: t('pageTitle'),
|
|
toolbar: (
|
|
<ServerForm
|
|
trigger={t('create')}
|
|
title={t('drawerCreateTitle')}
|
|
loading={loading}
|
|
onSubmit={async (values) => {
|
|
setLoading(true);
|
|
await createServer(values as any);
|
|
toast.success(t('created'));
|
|
ref.current?.refresh();
|
|
setLoading(false);
|
|
return true;
|
|
}}
|
|
/>
|
|
),
|
|
}}
|
|
columns={[
|
|
{
|
|
accessorKey: 'id',
|
|
header: t('id'),
|
|
cell: ({ row }) => <Badge>{row.getValue('id')}</Badge>,
|
|
},
|
|
{ accessorKey: 'name', header: t('name') },
|
|
{
|
|
id: 'region_ip',
|
|
header: t('serverAddress'),
|
|
cell: ({ row }) => (
|
|
<RegionIpCell
|
|
country={row.original.country as string}
|
|
city={row.original.city as string}
|
|
ip={row.original.server_addr as string}
|
|
t={t}
|
|
/>
|
|
),
|
|
},
|
|
{
|
|
accessorKey: 'protocols',
|
|
header: t('protocols'),
|
|
cell: ({ row }) => {
|
|
const enabled = getEnabledProtocols(row.original.protocols);
|
|
if (!enabled.length) return t('noData');
|
|
return (
|
|
<div className='flex flex-wrap gap-1'>
|
|
{enabled.map((p, idx) => (
|
|
<ProtocolBadge key={idx} item={p} t={t} />
|
|
))}
|
|
</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
|
|
);
|
|
return (
|
|
<div className='flex items-center gap-2'>
|
|
<span
|
|
className={cn(
|
|
'inline-block h-2.5 w-2.5 rounded-full',
|
|
on ? 'bg-emerald-500' : 'bg-zinc-400',
|
|
)}
|
|
/>
|
|
<span className='text-sm'>{on ? t('online') : t('offline')}</span>
|
|
</div>
|
|
);
|
|
},
|
|
},
|
|
{
|
|
id: 'cpu',
|
|
header: t('cpu'),
|
|
cell: ({ row }) => <PctBar value={(row.original.status?.cpu as number) ?? 0} />,
|
|
},
|
|
{
|
|
id: 'mem',
|
|
header: t('memory'),
|
|
cell: ({ row }) => <PctBar value={(row.original.status?.mem as number) ?? 0} />,
|
|
},
|
|
{
|
|
id: 'disk',
|
|
header: t('disk'),
|
|
cell: ({ row }) => <PctBar value={(row.original.status?.disk as number) ?? 0} />,
|
|
},
|
|
{
|
|
id: 'online_users',
|
|
header: t('onlineUsers'),
|
|
cell: ({ row }) => (
|
|
<OnlineUsersCell status={row.original.status as ServerStatus} t={t} />
|
|
),
|
|
},
|
|
]}
|
|
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 };
|
|
}}
|
|
actions={{
|
|
render: (row) => [
|
|
<ServerForm
|
|
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,
|
|
}}
|
|
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;
|
|
}}
|
|
/>,
|
|
<ConfirmButton
|
|
key='delete'
|
|
trigger={<Button variant='destructive'>{t('delete')}</Button>}
|
|
title={t('confirmDeleteTitle')}
|
|
description={t('confirmDeleteDesc')}
|
|
onConfirm={async () => {
|
|
await deleteServer(row.id as number);
|
|
toast.success(t('deleted'));
|
|
ref.current?.refresh();
|
|
}}
|
|
cancelText={t('cancel')}
|
|
confirmText={t('confirm')}
|
|
/>,
|
|
<Button
|
|
key='copy'
|
|
variant='outline'
|
|
onClick={async () => {
|
|
setLoading(true);
|
|
const { id, ...others } = row as ServerItem;
|
|
await createServer(others as any);
|
|
toast.success(t('copied'));
|
|
ref.current?.refresh();
|
|
setLoading(false);
|
|
}}
|
|
>
|
|
{t('copy')}
|
|
</Button>,
|
|
],
|
|
}}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|