'use client'; // Online users detail moved to separate component import { ProTable, ProTableActions } from '@/components/pro-table'; import { createServer, deleteServer, filterServerList, hasMigrateSeverNode, migrateServerNode, resetSortWithServer, updateServer, } from '@/services/admin/server'; 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 { 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'; const PROTOCOL_COLORS: Record = { 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 PctBar({ value }: { value: number }) { const v = value.toFixed(2); return (
{v}%
); } 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 (
{region} {ip || t('notAvailable')}
); } export default function ServersPage() { const t = useTranslations('servers'); const [loading, setLoading] = useState(false); const [migrating, setMigrating] = useState(false); const ref = useRef(null); const { data: hasMigrate, refetch: refetchHasMigrate } = useQuery({ queryKey: ['hasMigrateSeverNode'], queryFn: async () => { const { data } = await hasMigrateSeverNode(); return data.data?.has_migrate; }, }); const handleMigrate = async () => { setMigrating(true); try { const { data } = await migrateServerNode(); const fail = data.data?.fail || 0; if (fail > 0) { toast.error(data.data?.message); } else { toast.success(t('migrated')); } refetchHasMigrate(); ref.current?.refresh(); } catch (error) { toast.error(t('migrateFailed')); } finally { setMigrating(false); } }; return (
action={ref} header={{ title: t('pageTitle'), toolbar: (
{hasMigrate && ( )} { setLoading(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; } }} />
), }} columns={[ { accessorKey: 'id', header: t('id'), cell: ({ row }) => {row.getValue('id')}, }, { accessorKey: 'name', header: t('name') }, { id: 'region_ip', header: t('address'), cell: ({ row }) => ( ), }, { accessorKey: 'protocols', header: t('protocols'), cell: ({ row }) => { const list = (row.original.protocols || []) as API.Protocol[]; if (!list.length) return t('noData'); return (
{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 ( {label} ); })}
); }, }, { id: 'status', header: t('status'), cell: ({ row }) => { const offline = row.original.status.status === 'offline'; return (
{offline ? t('offline') : t('online')}
); }, }, { id: 'cpu', header: t('cpu'), cell: ({ row }) => ( ), }, { id: 'mem', header: t('memory'), cell: ({ row }) => ( ), }, { id: 'disk', header: t('disk'), cell: ({ row }) => ( ), }, { id: 'online_users', header: t('onlineUsers'), cell: ({ row }) => , }, { id: 'traffic_ratio', header: t('traffic_ratio'), cell: ({ row }) => { const raw = row.original.ratio as unknown; const ratio = Number(raw ?? 1) || 1; return {ratio.toFixed(2)}x; }, }, ]} params={[{ key: 'search' }]} 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) => [ { setLoading(true); try { // ServerForm already returns API-shaped body; add id for update await updateServer({ id: row.id, ...(values as unknown as Omit), }); toast.success(t('updated')); ref.current?.refresh(); setLoading(false); return true; } catch (e) { setLoading(false); return false; } }} />, {t('delete')}} title={t('confirmDeleteTitle')} description={t('confirmDeleteDesc')} onConfirm={async () => { await deleteServer({ id: row.id } as any); toast.success(t('deleted')); ref.current?.refresh(); }} cancelText={t('cancel')} confirmText={t('confirm')} />, , ], batchRender(rows) { return [ {t('delete')}} title={t('confirmDeleteTitle')} description={t('confirmDeleteDesc')} onConfirm={async () => { await Promise.all(rows.map((r) => deleteServer({ id: r.id }))); toast.success(t('deleted')); ref.current?.refresh(); }} cancelText={t('cancel')} confirmText={t('confirm')} />, ]; }, }} onSort={async (source, target, items) => { const sourceIndex = items.findIndex((item) => String(item.id) === source); const targetIndex = items.findIndex((item) => String(item.id) === target); const originalSorts = items.map((item) => item.sort); const [movedItem] = items.splice(sourceIndex, 1); items.splice(targetIndex, 0, movedItem!); const updatedItems = items.map((item, index) => { const originalSort = originalSorts[index]; const newSort = originalSort !== undefined ? originalSort : item.sort; return { ...item, sort: newSort }; }); const changedItems = updatedItems.filter((item, index) => { return item.sort !== items[index]?.sort; }); if (changedItems.length > 0) { resetSortWithServer({ sort: changedItems.map((item) => ({ id: item.id, sort: item.sort, })) as API.SortItem[], }); } return updatedItems; }} />
); }