♻️ 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
+99 -135
View File
@@ -1,11 +1,13 @@
'use client';
import { filterServerList } from '@/services/admin/server';
import { zodResolver } from '@hookform/resolvers/zod';
import { useQuery } from '@tanstack/react-query';
import { Button } from '@workspace/ui/components/button';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
@@ -38,82 +40,40 @@ export type ProtocolName =
| 'tuic'
| 'anytls';
type ServerProtocolItem = {
protocol: ProtocolName;
enabled: boolean;
config?: { port?: number } & Record<string, unknown>;
};
type ServerRow = {
id: number;
name: string;
server_addr: string;
protocols: ServerProtocolItem[];
};
type ServerRow = API.Server;
export type NodeFormValues = {
name: string;
server_id?: number;
protocol: ProtocolName | '';
server_addr: string;
port?: number;
address: string;
port: number;
tags: string[];
};
async function getServerListMock(): Promise<{ data: { list: ServerRow[] } }> {
return {
data: {
list: [
{
id: 101,
name: 'Tokyo-1',
server_addr: 'jp-1.example.com',
protocols: [
{ protocol: 'shadowsocks', enabled: true, config: { port: 443 } },
{ protocol: 'vless', enabled: true, config: { port: 8443 } },
{ protocol: 'trojan', enabled: false, config: { port: 443 } },
],
},
{
id: 102,
name: 'HK-Edge',
server_addr: 'hk-edge.example.com',
protocols: [
{ protocol: 'vmess', enabled: true, config: { port: 443 } },
{ protocol: 'vless', enabled: true, config: { port: 443 } },
{ protocol: 'hysteria2', enabled: true, config: { port: 60000 } },
],
},
{
id: 103,
name: 'AnyTLS Lab',
server_addr: 'lab.example.com',
protocols: [
{ protocol: 'anytls', enabled: true, config: { port: 443 } },
{ protocol: 'tuic', enabled: false, config: { port: 4443 } },
],
},
],
},
};
async function getServers(): Promise<ServerRow[]> {
const { data } = await filterServerList({ page: 1, size: 1000 });
return (data?.data?.list || []) as ServerRow[];
}
const buildSchema = (t: ReturnType<typeof useTranslations>) =>
z
.object({
name: z.string().min(1, t('errors.nameRequired')),
server_id: z.number({ invalid_type_error: t('errors.serverRequired') }).optional(),
protocol: z.string().min(1, t('errors.protocolRequired')),
server_addr: z.string().min(1, t('errors.serverAddrRequired')),
port: z
.number()
.int()
.min(1, t('errors.portRange'))
.max(65535, t('errors.portRange'))
.optional(),
tags: z.array(z.string()),
})
.refine((v) => !!v.server_id, { path: ['server_id'], message: t('errors.serverRequired') });
const buildScheme = (t: ReturnType<typeof useTranslations>) =>
z.object({
name: z.string().trim().min(1, t('errors.nameRequired')),
server_id: z.coerce
.number({ invalid_type_error: t('errors.serverRequired') })
.int()
.gt(0, t('errors.serverRequired')),
protocol: z.custom<ProtocolName>((v) => typeof v === 'string' && v.length > 0, {
message: t('errors.protocolRequired'),
}),
address: z.string().trim().min(1, t('errors.serverAddrRequired')),
port: z.coerce
.number({ invalid_type_error: t('errors.portRange') })
.int()
.min(1, t('errors.portRange'))
.max(65535, t('errors.portRange')),
tags: z.array(z.string()).default([]),
});
export default function NodeForm(props: {
trigger: string;
@@ -124,16 +84,16 @@ export default function NodeForm(props: {
}) {
const { trigger, title, loading, initialValues, onSubmit } = props;
const t = useTranslations('nodes');
const schema = useMemo(() => buildSchema(t), [t]);
const Scheme = useMemo(() => buildScheme(t), [t]);
const form = useForm<NodeFormValues>({
resolver: zodResolver(schema),
resolver: zodResolver(Scheme),
defaultValues: {
name: '',
server_id: undefined,
protocol: '',
server_addr: '',
port: undefined,
address: '',
port: 0,
tags: [],
...initialValues,
},
@@ -141,22 +101,19 @@ export default function NodeForm(props: {
const serverId = form.watch('server_id');
const { data } = useQuery({ queryKey: ['getServerListMock'], queryFn: getServerListMock });
// eslint-disable-next-line react-hooks/exhaustive-deps
const servers: ServerRow[] = data?.data?.list ?? [];
const { data } = useQuery({ queryKey: ['filterServerListAll'], queryFn: getServers });
const servers: ServerRow[] = data as ServerRow[];
const currentServer = useMemo(() => servers.find((s) => s.id === serverId), [servers, serverId]);
const currentServer = useMemo(() => servers?.find((s) => s.id === serverId), [servers, serverId]);
const availableProtocols = useMemo(
() =>
(currentServer?.protocols || [])
.filter((p) => p.enabled)
.map((p) => ({
protocol: p.protocol,
port: p.config?.port,
})),
[currentServer],
);
const availableProtocols = useMemo(() => {
return (currentServer?.protocols || [])
.map((p) => ({
protocol: (p as any).type as ProtocolName,
port: (p as any).port as number | undefined,
}))
.filter((p) => !!p.protocol);
}, [currentServer]);
useEffect(() => {
if (initialValues) {
@@ -164,8 +121,8 @@ export default function NodeForm(props: {
name: '',
server_id: undefined,
protocol: '',
server_addr: '',
port: undefined,
address: '',
port: 0,
tags: [],
...initialValues,
});
@@ -178,26 +135,33 @@ export default function NodeForm(props: {
form.setValue('server_id', id);
const sel = servers.find((s) => s.id === id);
if (!form.getValues('server_addr') && sel?.server_addr) {
form.setValue('server_addr', sel.server_addr);
const dirty = form.formState.dirtyFields as Record<string, any>;
if (!dirty.name) {
form.setValue('name', (sel?.name as string) || '', { shouldDirty: false });
}
const allowed = (sel?.protocols || []).filter((p) => p.enabled).map((p) => p.protocol);
if (!dirty.address) {
form.setValue('address', (sel?.address as string) || '', { shouldDirty: false });
}
const allowed = (sel?.protocols || [])
.map((p) => (p as any).type as ProtocolName)
.filter(Boolean);
if (!allowed.includes(form.getValues('protocol') as ProtocolName)) {
form.setValue('protocol', '' as any);
}
// Do not auto-fill port here; handled in handleProtocolChange
}
function handleProtocolChange(nextProto?: ProtocolName | null) {
const p = (nextProto || '') as ProtocolName | '';
form.setValue('protocol', p);
if (!p || !currentServer) return;
const curPort = Number(form.getValues('port') || 0);
if (!curPort) {
const hit = currentServer.protocols.find((x) => x.protocol === p);
const port = hit?.config?.port;
if (typeof port === 'number' && port > 0) {
form.setValue('port', port);
}
const dirty = form.formState.dirtyFields as Record<string, any>;
if (!dirty.port) {
const hit = (currentServer.protocols as any[]).find((x) => (x as any).type === p);
const port = (hit as any)?.port as number | undefined;
form.setValue('port', typeof port === 'number' && port > 0 ? port : 0, {
shouldDirty: false,
});
}
}
@@ -220,40 +184,6 @@ export default function NodeForm(props: {
<ScrollArea className='-mx-6 h-[calc(100dvh-48px-36px-36px-env(safe-area-inset-top))] px-6 pt-4'>
<Form {...form}>
<form className='grid grid-cols-1 gap-4'>
<FormField
control={form.control}
name='name'
render={({ field }) => (
<FormItem>
<FormLabel>{t('name')}</FormLabel>
<FormControl>
<EnhancedInput
{...field}
onValueChange={(v) => form.setValue(field.name, v as string)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='tags'
render={({ field }) => (
<FormItem>
<FormLabel>{t('tags')}</FormLabel>
<FormControl>
<TagInput
placeholder={t('tags_placeholder')}
value={field.value || []}
onChange={(v) => form.setValue(field.name, v)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='server_id'
@@ -266,7 +196,7 @@ export default function NodeForm(props: {
value={field.value}
options={servers.map((s) => ({
value: s.id,
label: `${s.name} (${s.server_addr})`,
label: `${s.name} (${(s.address as any) || ''})`,
}))}
onChange={(v) => handleServerChange(v)}
/>
@@ -287,7 +217,7 @@ export default function NodeForm(props: {
value={field.value}
options={availableProtocols.map((p) => ({
value: p.protocol,
label: `${p.protocol} (${p.port})`,
label: `${p.protocol}${p.port ? ` (${p.port})` : ''}`,
}))}
onChange={(v) => handleProtocolChange((v as ProtocolName) || null)}
/>
@@ -296,13 +226,29 @@ export default function NodeForm(props: {
</FormItem>
)}
/>
<FormField
control={form.control}
name='name'
render={({ field }) => (
<FormItem>
<FormLabel>{t('name')}</FormLabel>
<FormControl>
<EnhancedInput
{...field}
onValueChange={(v) => form.setValue(field.name, v as string)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='server_addr'
name='address'
render={({ field }) => (
<FormItem>
<FormLabel>{t('server_addr')}</FormLabel>
<FormLabel>{t('address')}</FormLabel>
<FormControl>
<EnhancedInput
{...field}
@@ -326,7 +272,7 @@ export default function NodeForm(props: {
type='number'
min={1}
max={65535}
placeholder='1 - 65535'
placeholder='1-65535'
onValueChange={(v) => form.setValue(field.name, Number(v))}
/>
</FormControl>
@@ -334,6 +280,24 @@ export default function NodeForm(props: {
</FormItem>
)}
/>
<FormField
control={form.control}
name='tags'
render={({ field }) => (
<FormItem>
<FormLabel>{t('tags')}</FormLabel>
<FormControl>
<TagInput
placeholder={t('tags_placeholder')}
value={field.value || []}
onChange={(v) => form.setValue(field.name, v)}
/>
</FormControl>
<FormDescription>{t('tags_description')}</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</ScrollArea>
+96 -165
View File
@@ -1,124 +1,50 @@
'use client';
import { ProTable, ProTableActions } from '@/components/pro-table';
import {
createNode,
deleteNode,
filterNodeList,
filterServerList,
toggleNodeStatus,
updateNode,
} 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 { Switch } from '@workspace/ui/components/switch';
import { ConfirmButton } from '@workspace/ui/custom-components/confirm-button';
import { useTranslations } from 'next-intl';
import { useMemo, useRef, useState } from 'react';
import { useRef, useState } from 'react';
import { toast } from 'sonner';
import NodeForm, { type NodeFormValues } from './node-form';
type NodeItem = NodeFormValues & { id: number; enabled: boolean; sort: number };
let mock: NodeItem[] = [
{
id: 1,
enabled: false,
name: 'Node A',
server_id: 101,
protocol: 'shadowsocks',
server_addr: 'jp-1.example.com',
port: 443,
tags: ['hk', 'premium'],
sort: 1,
},
{
id: 2,
enabled: true,
name: 'Node B',
server_id: 102,
protocol: 'vless',
server_addr: 'hk-edge.example.com',
port: 8443,
tags: ['jp'],
sort: 2,
},
];
const list = async () => ({ list: mock, total: mock.length });
const create = async (v: NodeFormValues) => {
mock.push({
id: Date.now(),
enabled: false,
sort: 0,
...v,
});
return true;
};
const update = async (id: number, v: NodeFormValues) => {
mock = mock.map((x) => (x.id === id ? { ...x, ...v } : x));
return true;
};
const remove = async (id: number) => {
mock = mock.filter((x) => x.id !== id);
return true;
};
const setState = async (id: number, en: boolean) => {
mock = mock.map((x) => (x.id === id ? { ...x, enabled: en } : x));
return true;
};
type ProtocolName = 'shadowsocks' | 'vmess' | 'vless' | 'trojan' | 'hysteria2' | 'tuic' | 'anytls';
type ServerProtocolItem = { protocol: ProtocolName; enabled: boolean; config?: { port?: number } };
type ServerRow = { id: number; name: string; server_addr: string; protocols: ServerProtocolItem[] };
async function getServerListMock(): Promise<{ data: { list: ServerRow[] } }> {
return {
data: {
list: [
{
id: 101,
name: 'Tokyo-1',
server_addr: 'jp-1.example.com',
protocols: [
{ protocol: 'shadowsocks', enabled: true, config: { port: 443 } },
{ protocol: 'vless', enabled: true, config: { port: 8443 } },
],
},
{
id: 102,
name: 'HK-Edge',
server_addr: 'hk-edge.example.com',
protocols: [
{ protocol: 'vmess', enabled: true, config: { port: 443 } },
{ protocol: 'vless', enabled: true, config: { port: 443 } },
],
},
],
},
};
}
import NodeForm from './node-form';
export default function NodesPage() {
const t = useTranslations('nodes');
const ref = useRef<ProTableActions>(null);
const [loading, setLoading] = useState(false);
const { data: serversResp } = useQuery({
queryKey: ['getServerListMock'],
queryFn: getServerListMock,
const { data: servers = [] } = useQuery({
queryKey: ['filterServerListAll', { page: 1, size: 1000 }],
queryFn: async () => {
const { data } = await filterServerList({ page: 1, size: 1000 });
return data?.data?.list || [];
},
});
const servers: ServerRow[] = serversResp?.data?.list ?? [];
const serverMap = useMemo(() => {
const m = new Map<number, ServerRow>();
servers.forEach((s) => m.set(s.id, s));
return m;
}, [servers]);
const getServerName = (id?: number) => (id ? (serverMap.get(id)?.name ?? `#${id}`) : '—');
const getServerOriginAddr = (id?: number) => (id ? (serverMap.get(id)?.server_addr ?? '—') : '—');
const getServerName = (id?: number) =>
id ? (servers.find((s) => s.id === id)?.name ?? `#${id}`) : '—';
const getServerOriginAddr = (id?: number) =>
id ? (servers.find((s) => s.id === id)?.address ?? '—') : '—';
const getProtocolOriginPort = (id?: number, proto?: string) => {
if (!id || !proto) return '—';
const hit = serverMap.get(id)?.protocols?.find((p) => p.protocol === proto);
const p = hit?.config?.port;
const hit = servers.find((s) => s.id === id)?.protocols?.find((p) => (p as any).type === proto);
const p = (hit as any)?.port as number | undefined;
return typeof p === 'number' ? String(p) : '—';
};
return (
<ProTable<NodeItem, { search: string }>
<ProTable<API.Node, { search: string }>
action={ref}
header={{
title: t('pageTitle'),
@@ -129,11 +55,25 @@ export default function NodesPage() {
loading={loading}
onSubmit={async (values) => {
setLoading(true);
await create(values);
toast.success(t('created'));
ref.current?.refresh();
setLoading(false);
return true;
try {
const body: API.CreateNodeRequest = {
name: values.name,
server_id: Number(values.server_id!),
protocol: values.protocol,
address: values.address,
port: Number(values.port!),
tags: values.tags || [],
enabled: false,
};
await createNode(body);
toast.success(t('created'));
ref.current?.refresh();
setLoading(false);
return true;
} catch (e) {
setLoading(false);
return false;
}
}}
/>
),
@@ -146,7 +86,7 @@ export default function NodesPage() {
<Switch
checked={row.original.enabled}
onCheckedChange={async (v) => {
await setState(row.original.id, v);
await toggleNodeStatus({ id: row.original.id, enable: v });
toast.success(v ? t('enabled_on') : t('enabled_off'));
ref.current?.refresh();
}}
@@ -156,13 +96,9 @@ export default function NodesPage() {
{ accessorKey: 'name', header: t('name') },
{
id: 'server_addr_port',
header: t('server_addr_port'),
cell: ({ row }) => (
<Badge variant='outline'>
{(row.original.server_addr || '—') + ':' + (row.original.port ?? '—')}
</Badge>
),
id: 'address_port',
header: `${t('address')}:${t('port')}`,
cell: ({ row }) => (row.original.address || '—') + ':' + (row.original.port ?? '—'),
},
{
@@ -174,7 +110,7 @@ export default function NodesPage() {
{getServerName(row.original.server_id)} ·{' '}
{getServerOriginAddr(row.original.server_id)}
</Badge>
<Badge>
<Badge variant='outline'>
{row.original.protocol || '—'} ·{' '}
{getProtocolOriginPort(row.original.server_id, row.original.protocol)}
</Badge>
@@ -198,24 +134,15 @@ export default function NodesPage() {
},
]}
params={[{ key: 'search' }]}
request={async (_pagination, filter) => {
const { list: items } = await list();
const kw = (filter?.search || '').toLowerCase().trim();
const filtered = kw
? items.filter((i) =>
[
i.name,
getServerName(i.server_id),
getServerOriginAddr(i.server_id),
`${i.server_addr}:${i.port ?? ''}`,
`${i.protocol}:${getProtocolOriginPort(i.server_id, i.protocol)}`,
...(i.tags || []),
]
.filter(Boolean)
.some((v) => String(v).toLowerCase().includes(kw)),
)
: items;
return { list: filtered, total: filtered.length };
request={async (pagination, filter) => {
const { data } = await filterNodeList({
page: pagination.page,
size: pagination.size,
search: filter?.search || undefined,
});
const list = (data?.data?.list || []) as API.Node[];
const total = Number(data?.data?.total || list.length);
return { list, total };
}}
actions={{
render: (row) => [
@@ -224,14 +151,36 @@ export default function NodesPage() {
trigger={t('edit')}
title={t('drawerEditTitle')}
loading={loading}
initialValues={row}
initialValues={{
name: row.name,
server_id: row.server_id,
protocol: row.protocol as any,
address: row.address as any,
port: row.port as any,
tags: (row.tags as any) || [],
}}
onSubmit={async (values) => {
setLoading(true);
await update(row.id, values);
toast.success(t('updated'));
ref.current?.refresh();
setLoading(false);
return true;
try {
const body: API.UpdateNodeRequest = {
id: row.id,
name: values.name,
server_id: Number(values.server_id!),
protocol: values.protocol,
address: values.address,
port: Number(values.port!),
tags: values.tags || [],
enabled: row.enabled,
} as any;
await updateNode(body);
toast.success(t('updated'));
ref.current?.refresh();
setLoading(false);
return true;
} catch (e) {
setLoading(false);
return false;
}
}}
/>,
<ConfirmButton
@@ -240,7 +189,7 @@ export default function NodesPage() {
title={t('confirmDeleteTitle')}
description={t('confirmDeleteDesc')}
onConfirm={async () => {
await remove(row.id);
await deleteNode({ id: row.id } as any);
toast.success(t('deleted'));
ref.current?.refresh();
}}
@@ -251,8 +200,16 @@ export default function NodesPage() {
key='copy'
variant='outline'
onClick={async () => {
const { id, enabled, ...rest } = row;
await create(rest);
const { id, enabled, created_at, updated_at, ...rest } = row as any;
await createNode({
name: rest.name,
server_id: rest.server_id,
protocol: rest.protocol,
address: rest.address,
port: rest.port,
tags: rest.tags || [],
enabled: false,
} as any);
toast.success(t('copied'));
ref.current?.refresh();
}}
@@ -268,6 +225,7 @@ export default function NodesPage() {
title={t('confirmDeleteTitle')}
description={t('confirmDeleteDesc')}
onConfirm={async () => {
await Promise.all(rows.map((r) => deleteNode({ id: r.id } as any)));
toast.success(t('deleted'));
ref.current?.refresh();
}}
@@ -277,33 +235,6 @@ export default function NodesPage() {
];
},
}}
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) {
// nodeSort({
// sort: changedItems.map((item) => ({ id: item.id, sort: item.sort })),
// });
}
return updatedItems;
}}
/>
);
}
@@ -1,6 +1,6 @@
'use client';
import { getNodeGroupList, getNodeList } from '@/services/admin/server';
import { filterNodeList } from '@/services/admin/server';
import { getSubscribeGroupList } from '@/services/admin/subscribe';
import { zodResolver } from '@hookform/resolvers/zod';
import { useQuery } from '@tanstack/react-query';
@@ -10,6 +10,7 @@ import {
AccordionItem,
AccordionTrigger,
} from '@workspace/ui/components/accordion';
import { Badge } from '@workspace/ui/components/badge';
import { Button } from '@workspace/ui/components/button';
import { Checkbox } from '@workspace/ui/components/checkbox';
import {
@@ -103,7 +104,11 @@ export default function SubscribeForm<T extends Record<string, any>>({
traffic: z.number().optional().default(0),
quota: z.number().optional().default(0),
group_id: z.number().optional().nullish(),
server_group: z.array(z.number()).optional().default([]),
// Use tags as group identifiers; accept string (tag) or number (legacy id)
server_group: z
.array(z.union([z.number(), z.string()]) as any)
.optional()
.default([]),
server: z.array(z.number()).optional().default([]),
deduction_ratio: z.number().optional().default(0),
allow_deduction: z.boolean().optional().default(false),
@@ -237,27 +242,22 @@ export default function SubscribeForm<T extends Record<string, any>>({
},
});
const { data: server } = useQuery({
queryKey: ['getNodeList', 'all'],
const { data: nodes } = useQuery({
queryKey: ['filterNodeListAll'],
queryFn: async () => {
const { data } = await getNodeList({
page: 1,
size: 9999,
});
return data.data?.list;
},
});
const { data: server_groups } = useQuery({
queryKey: ['getNodeGroupList'],
queryFn: async () => {
const { data } = await getNodeGroupList();
return (data.data?.list || []) as API.ServerGroup[];
const { data } = await filterNodeList({ page: 1, size: 9999 });
return (data.data?.list || []) as API.Node[];
},
});
const tagGroups = Array.from(
new Set(
((nodes as API.Node[]) || [])
.flatMap((n) => (Array.isArray(n.tags) ? n.tags : []))
.filter(Boolean),
),
) as string[];
const unit_time = form.watch('unit_time');
const unit_price = form.watch('unit_price');
return (
<Sheet open={open} onOpenChange={setOpen}>
@@ -290,7 +290,7 @@ export default function SubscribeForm<T extends Record<string, any>>({
</TabsTrigger>
<TabsTrigger value='servers' className='flex items-center gap-2'>
<Server className='h-4 w-4' />
{t('form.servers')}
{t('form.nodes')}
</TabsTrigger>
</TabsList>
@@ -798,50 +798,56 @@ export default function SubscribeForm<T extends Record<string, any>>({
name='server_group'
render={({ field }) => (
<FormItem>
<FormLabel>{t('form.serverGroup')}</FormLabel>
<FormLabel>{t('form.nodeGroup')}</FormLabel>
<FormControl>
<Accordion type='single' collapsible className='w-full'>
{server_groups?.map((group: API.ServerGroup) => {
{tagGroups.map((tag) => {
const value = field.value || [];
// Use a synthetic ID for tag grouping selection by name
const tagId = tag;
return (
<AccordionItem key={group.id} value={String(group.id)}>
<AccordionItem key={tag} value={String(tag)}>
<AccordionTrigger>
<div className='flex items-center gap-2'>
<Checkbox
checked={value.includes(group.id!)}
checked={value.includes(tagId as any)}
onCheckedChange={(checked) => {
return checked
? form.setValue(field.name, [...value, group.id])
? form.setValue(field.name, [...value, tagId] as any)
: form.setValue(
field.name,
value.filter(
(value: number) => value !== group.id,
),
value.filter((v: any) => v !== tagId),
);
}}
/>
<Label>{group.name}</Label>
<Label>{tag}</Label>
</div>
</AccordionTrigger>
<AccordionContent>
<ul className='list-disc [&>li]:mt-2'>
{server
?.filter(
(server: API.Server) => server.group_id === group.id,
)
?.map((node: API.Server) => {
return (
<li
key={node.id}
className='flex items-center justify-between *:flex-1'
>
<span>{node.name}</span>
<span>{node.server_addr}</span>
<span className='text-right'>{node.protocol}</span>
</li>
);
})}
<ul className='space-y-1'>
{(nodes as API.Node[])
?.filter((n) => (n.tags || []).includes(tag))
?.map((node) => (
<li
key={node.id}
className='flex items-center justify-between gap-3'
>
<span className='font-medium'>{node.name}</span>
<span className='text-muted-foreground'>
{node.address}:{node.port ?? '—'}
</span>
<span className='font-mono text-xs uppercase'>
{node.protocol || '—'}
</span>
<span className='flex flex-wrap justify-end gap-1'>
{(node.tags || []).map((tg) => (
<Badge key={tg} variant='outline'>
{tg}
</Badge>
))}
</span>
</li>
))}
</ul>
</AccordionContent>
</AccordionItem>
@@ -859,12 +865,12 @@ export default function SubscribeForm<T extends Record<string, any>>({
name='server'
render={({ field }) => (
<FormItem>
<FormLabel>{t('form.server')}</FormLabel>
<FormLabel>{t('form.node')}</FormLabel>
<FormControl>
<div className='flex flex-col gap-2'>
{server
?.filter((item: API.Server) => !item.group_id)
?.map((item: API.Server) => {
{(nodes as API.Node[])
?.filter((item) => (item.tags || []).length === 0)
?.map((item) => {
const value = field.value || [];
return (
@@ -880,10 +886,12 @@ export default function SubscribeForm<T extends Record<string, any>>({
);
}}
/>
<Label className='flex w-full items-center justify-between *:flex-1'>
<Label className='flex w-full items-center justify-between gap-3'>
<span>{item.name}</span>
<span>{item.server_addr}</span>
<span className='text-right'>{item.protocol}</span>
<span>
{item.address}:{item.port}
</span>
<span>{item.protocol}</span>
</Label>
</div>
);
@@ -1,126 +0,0 @@
import { z } from 'zod';
export const protocols = ['shadowsocks', 'vmess', 'vless', 'trojan', 'hysteria2', 'tuic', 'anytls'];
const nullableString = z.string().nullish();
const portSchema = z.number().max(65535).nullish();
const securityConfigSchema = 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: portSchema,
})
.nullish();
const transportConfigSchema = z
.object({
path: nullableString,
host: nullableString,
service_name: nullableString,
})
.nullish();
const baseProtocolSchema = z.object({
port: portSchema,
transport: z.string(),
transport_config: transportConfigSchema,
security: z.string(),
security_config: securityConfigSchema,
});
const shadowsocksSchema = z.object({
method: z.string(),
port: portSchema,
server_key: nullableString,
});
const vmessSchema = baseProtocolSchema;
const vlessSchema = baseProtocolSchema.extend({
flow: nullableString,
});
const trojanSchema = baseProtocolSchema;
const hysteria2Schema = z.object({
port: portSchema,
hop_ports: nullableString,
hop_interval: z.number().nullish(),
obfs_password: nullableString,
security: z.string(),
security_config: securityConfigSchema,
});
const tuicSchema = z.object({
port: portSchema,
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: securityConfigSchema,
});
const anytlsSchema = z.object({
port: portSchema,
security_config: securityConfigSchema,
});
const protocolConfigSchema = z.discriminatedUnion('protocol', [
z.object({
protocol: z.literal('shadowsocks'),
config: shadowsocksSchema,
}),
z.object({
protocol: z.literal('vmess'),
config: vmessSchema,
}),
z.object({
protocol: z.literal('vless'),
config: vlessSchema,
}),
z.object({
protocol: z.literal('trojan'),
config: trojanSchema,
}),
z.object({
protocol: z.literal('hysteria2'),
config: hysteria2Schema,
}),
z.object({
protocol: z.literal('tuic'),
config: tuicSchema,
}),
z.object({
protocol: z.literal('anytls'),
config: anytlsSchema,
}),
]);
const baseFormSchema = z.object({
name: z.string(),
tags: z.array(z.string()).nullish().default([]),
country: z.string().nullish(),
city: z.string().nullish(),
server_addr: z.string(),
speed_limit: z.number().nullish(),
traffic_ratio: z.number().default(1),
group_id: z.number().nullish(),
relay_mode: z.string().nullish().default('none'),
relay_node: z
.array(
z.object({
host: z.string(),
port: portSchema,
prefix: z.string().nullish(),
}),
)
.nullish()
.default([]),
});
export const formSchema = z.intersection(baseFormSchema, protocolConfigSchema);
@@ -1,145 +0,0 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { Button } from '@workspace/ui/components/button';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@workspace/ui/components/form';
import { ScrollArea } from '@workspace/ui/components/scroll-area';
import {
Sheet,
SheetContent,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
} from '@workspace/ui/components/sheet';
import { EnhancedInput } from '@workspace/ui/custom-components/enhanced-input';
import { Icon } from '@workspace/ui/custom-components/icon';
import { useTranslations } from 'next-intl';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
const formSchema = z.object({
name: z.string(),
description: z.string().optional(),
});
interface GroupFormProps<T> {
onSubmit: (data: T) => Promise<boolean> | boolean;
initialValues?: T;
loading?: boolean;
trigger: string;
title: string;
}
export default function GroupForm<T extends Record<string, any>>({
onSubmit,
initialValues,
loading,
trigger,
title,
}: GroupFormProps<T>) {
const t = useTranslations('server');
const [open, setOpen] = useState(false);
const form = useForm({
resolver: zodResolver(formSchema),
defaultValues: {
...initialValues,
},
});
useEffect(() => {
form?.reset(initialValues);
}, [form, initialValues]);
async function handleSubmit(data: { [x: string]: any }) {
const bool = await onSubmit(data as T);
if (bool) setOpen(false);
}
return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
<Button
onClick={() => {
form.reset();
setOpen(true);
}}
>
{trigger}
</Button>
</SheetTrigger>
<SheetContent className='w-[500px] max-w-full md:max-w-screen-md'>
<SheetHeader>
<SheetTitle>{title}</SheetTitle>
</SheetHeader>
<ScrollArea className='-mx-6 h-[calc(100dvh-48px-36px-36px-env(safe-area-inset-top))]'>
<Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)} className='space-y-4 px-6 pt-4'>
<FormField
control={form.control}
name='name'
render={({ field }) => (
<FormItem>
<FormLabel>{t('groupForm.name')}</FormLabel>
<FormControl>
<EnhancedInput
{...field}
onValueChange={(value) => {
form.setValue(field.name, value);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='description'
render={({ field }) => (
<FormItem>
<FormLabel>{t('groupForm.description')}</FormLabel>
<FormControl>
<EnhancedInput
{...field}
onValueChange={(value) => {
form.setValue(field.name, value);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</ScrollArea>
<SheetFooter className='flex-row justify-end gap-2 pt-3'>
<Button
variant='outline'
disabled={loading}
onClick={() => {
setOpen(false);
}}
>
{t('groupForm.cancel')}
</Button>
<Button disabled={loading} onClick={form.handleSubmit(handleSubmit)}>
{loading && <Icon icon='mdi:loading' className='mr-2 animate-spin' />}{' '}
{t('groupForm.confirm')}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
}
@@ -1,140 +0,0 @@
'use client';
import { ProTable, ProTableActions } from '@/components/pro-table';
import {
batchDeleteNodeGroup,
createNodeGroup,
deleteNodeGroup,
getNodeGroupList,
updateNodeGroup,
} from '@/services/admin/server';
import { Button } from '@workspace/ui/components/button';
import { ConfirmButton } from '@workspace/ui/custom-components/confirm-button';
import { formatDate } from '@workspace/ui/utils';
import { useTranslations } from 'next-intl';
import { useRef, useState } from 'react';
import { toast } from 'sonner';
import GroupForm from './group-form';
export default function GroupTable() {
const t = useTranslations('server');
const [loading, setLoading] = useState(false);
const ref = useRef<ProTableActions>(null);
return (
<ProTable<API.ServerGroup, any>
action={ref}
header={{
title: t('group.title'),
toolbar: (
<GroupForm<API.CreateNodeGroupRequest>
trigger={t('group.create')}
title={t('group.createNodeGroup')}
loading={loading}
onSubmit={async (values) => {
setLoading(true);
try {
await createNodeGroup(values);
toast.success(t('group.createdSuccessfully'));
ref.current?.refresh();
setLoading(false);
return true;
} catch (error) {
setLoading(false);
return false;
}
}}
/>
),
}}
columns={[
{
accessorKey: 'name',
header: t('group.name'),
},
{
accessorKey: 'description',
header: t('group.description'),
cell: ({ row }) => <p className='line-clamp-3'>{row.getValue('description')}</p>,
},
{
accessorKey: 'updated_at',
header: t('group.updatedAt'),
cell: ({ row }) => formatDate(row.getValue('updated_at')),
},
]}
request={async () => {
const { data } = await getNodeGroupList();
return {
list: data.data?.list || [],
total: data.data?.total || 0,
};
}}
actions={{
render: (row) => [
<GroupForm<API.ServerGroup>
key='edit'
trigger={t('group.edit')}
title={t('group.editNodeGroup')}
loading={loading}
initialValues={row}
onSubmit={async (values) => {
setLoading(true);
try {
await updateNodeGroup({
...row,
...values,
});
toast.success(t('group.createdSuccessfully'));
ref.current?.refresh();
setLoading(false);
return true;
} catch (error) {
setLoading(false);
return false;
}
}}
/>,
<ConfirmButton
key='delete'
trigger={<Button variant='destructive'>{t('group.delete')}</Button>}
title={t('group.confirmDelete')}
description={t('group.deleteWarning')}
onConfirm={async () => {
await deleteNodeGroup({
id: row.id!,
});
toast.success(t('group.deletedSuccessfully'));
ref.current?.refresh();
}}
cancelText={t('group.cancel')}
confirmText={t('group.confirm')}
/>,
],
batchRender(rows) {
return [
<ConfirmButton
key='delete'
trigger={<Button variant='destructive'>{t('group.delete')}</Button>}
title={t('group.confirmDelete')}
description={t('group.deleteWarning')}
onConfirm={async () => {
await batchDeleteNodeGroup({
ids: rows.map((item) => item.id),
});
toast.success(t('group.deleteSuccess'));
ref.current?.refresh();
}}
cancelText={t('group.cancel')}
confirmText={t('group.confirm')}
/>,
];
},
}}
/>
);
}
@@ -1,296 +0,0 @@
'use client';
import {
getNodeConfig,
getNodeMultiplier,
setNodeMultiplier,
updateNodeConfig,
} from '@/services/admin/system';
import { useQuery } from '@tanstack/react-query';
import { Button } from '@workspace/ui/components/button';
import { ChartContainer, ChartTooltip } from '@workspace/ui/components/chart';
import { Label } from '@workspace/ui/components/label';
import { Table, TableBody, TableCell, TableRow } from '@workspace/ui/components/table';
import { ArrayInput } from '@workspace/ui/custom-components/dynamic-Inputs';
import { EnhancedInput } from '@workspace/ui/custom-components/enhanced-input';
import { DicesIcon } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { uid } from 'radash';
import { useMemo, useState } from 'react';
import { Cell, Legend, Pie, PieChart } from 'recharts';
import { toast } from 'sonner';
const COLORS = [
'hsl(var(--chart-1))',
'hsl(var(--chart-2))',
'hsl(var(--chart-3))',
'hsl(var(--chart-4))',
'hsl(var(--chart-5))',
];
const MINUTES_IN_DAY = 1440; // 24 * 60
function getTimeRangeData(slots: API.TimePeriod[]) {
const timePoints = slots
.filter((slot) => slot.start_time && slot.end_time)
.flatMap((slot) => {
const [startH = 0, startM = 0] = slot.start_time.split(':').map(Number);
const [endH = 0, endM = 0] = slot?.end_time.split(':').map(Number);
const start = startH * 60 + startM;
let end = endH * 60 + endM;
if (end < start) end += MINUTES_IN_DAY;
return { start, end, multiplier: slot.multiplier };
})
.sort((a, b) => a.start - b.start);
const result = [];
let currentMinute = 0;
timePoints.forEach((point) => {
if (point.start > currentMinute) {
result.push({
name: `${Math.floor(currentMinute / 60)}:${String(currentMinute % 60).padStart(2, '0')} - ${Math.floor(point.start / 60)}:${String(point.start % 60).padStart(2, '0')}`,
value: point.start - currentMinute,
multiplier: 1,
});
}
result.push({
name: `${Math.floor(point.start / 60)}:${String(point.start % 60).padStart(2, '0')} - ${Math.floor((point.end / 60) % 24)}:${String(point.end % 60).padStart(2, '0')}`,
value: point.end - point.start,
multiplier: point.multiplier,
});
currentMinute = point.end % MINUTES_IN_DAY;
});
if (currentMinute < MINUTES_IN_DAY) {
result.push({
name: `${Math.floor(currentMinute / 60)}:${String(currentMinute % 60).padStart(2, '0')} - 24:00`,
value: MINUTES_IN_DAY - currentMinute,
multiplier: 1,
});
}
return result;
}
export default function NodeConfig() {
const t = useTranslations('server.config');
const { data, refetch } = useQuery({
queryKey: ['getNodeConfig'],
queryFn: async () => {
const { data } = await getNodeConfig();
return data.data;
},
});
async function updateConfig(key: string, value: unknown) {
if (data?.[key] === value) return;
try {
await updateNodeConfig({
...data,
[key]: value,
} as API.NodeConfig);
toast.success(t('saveSuccess'));
refetch();
} catch (error) {
/* empty */
}
}
const [timeSlots, setTimeSlots] = useState<API.TimePeriod[]>([]);
const { data: NodeMultiplier, refetch: refetchNodeMultiplier } = useQuery({
queryKey: ['getNodeMultiplier'],
queryFn: async () => {
const { data } = await getNodeMultiplier();
if (timeSlots.length === 0) {
setTimeSlots(data.data?.periods || []);
}
return data.data?.periods || [];
},
});
const chartTimeSlots = useMemo(() => {
return getTimeRangeData(timeSlots);
}, [timeSlots]);
const chartConfig = useMemo(() => {
return chartTimeSlots?.reduce(
(acc, item, index) => {
acc[item.name] = {
label: item.name,
color: COLORS[index % COLORS.length] || 'hsl(var(--default-chart-color))',
};
return acc;
},
{} as Record<string, { label: string; color: string }>,
);
}, [data]);
return (
<>
<Table>
<TableBody>
<TableRow>
<TableCell>
<Label>{t('communicationKey')}</Label>
<p className='text-muted-foreground text-xs'>{t('communicationKeyDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
placeholder={t('inputPlaceholder')}
value={data?.node_secret}
onValueBlur={(value) => updateConfig('node_secret', value)}
suffix={
<div className='bg-muted flex h-9 items-center text-nowrap px-3'>
<DicesIcon
onClick={() => {
const id = uid(32).toLowerCase();
const formatted = `${id.slice(0, 8)}-${id.slice(8, 12)}-${id.slice(12, 16)}-${id.slice(16, 20)}-${id.slice(20)}`;
updateConfig('node_secret', formatted);
}}
className='cursor-pointer'
/>
</div>
}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('nodePullInterval')}</Label>
<p className='text-muted-foreground text-xs'>{t('nodePullIntervalDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
type='number'
min={0}
onValueBlur={(value) => updateConfig('node_pull_interval', value)}
suffix='S'
value={data?.node_pull_interval}
placeholder={t('inputPlaceholder')}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('nodePushInterval')}</Label>
<p className='text-muted-foreground text-xs'>{t('nodePushIntervalDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
type='number'
min={0}
step={0.1}
value={data?.node_push_interval}
onValueBlur={(value) => updateConfig('node_push_interval', value)}
placeholder={t('inputPlaceholder')}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('dynamicMultiplier')}</Label>
<p className='text-muted-foreground text-xs'>{t('dynamicMultiplierDescription')}</p>
</TableCell>
<TableCell className='flex justify-end gap-2'>
<Button
size='sm'
variant='outline'
onClick={() => {
setTimeSlots(NodeMultiplier || []);
}}
>
{t('reset')}
</Button>
<Button
size='sm'
onClick={() => {
setNodeMultiplier({
periods: timeSlots,
}).then(async () => {
const result = await refetchNodeMultiplier();
if (result.data) setTimeSlots(result.data);
toast.success(t('saveSuccess'));
});
}}
>
{t('save')}
</Button>
</TableCell>
</TableRow>
</TableBody>
</Table>
<div className='flex flex-col-reverse gap-8 px-4 pt-6 md:flex-row md:items-start'>
<div className='w-full md:w-1/2'>
<ChartContainer config={chartConfig} className='mx-auto aspect-[4/3] max-w-[400px]'>
<PieChart>
<Pie
data={chartTimeSlots}
cx='50%'
cy='50%'
labelLine={false}
outerRadius='80%'
fill='#8884d8'
dataKey='value'
label={({ name, percent, multiplier }) =>
`${(multiplier || 0)?.toFixed(2)}x (${(percent * 100).toFixed(0)}%)`
}
>
{chartTimeSlots.map((entry, index) => (
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
))}
</Pie>
<ChartTooltip
content={({ payload }) => {
if (payload && payload.length) {
const data = payload[0]?.payload;
return (
<div className='bg-background rounded-lg border p-2 shadow-sm'>
<div className='grid grid-cols-2 gap-2'>
<div className='flex flex-col'>
<span className='text-muted-foreground text-[0.70rem] uppercase'>
{t('timeSlot')}
</span>
<span className='text-muted-foreground font-bold'>
{data.name || '其他'}
</span>
</div>
<div className='flex flex-col'>
<span className='text-muted-foreground text-[0.70rem] uppercase'>
{t('multiplier')}
</span>
<span className='font-bold'>{data.multiplier.toFixed(2)}x</span>
</div>
</div>
</div>
);
}
return null;
}}
/>
<Legend />
</PieChart>
</ChartContainer>
</div>
<div className='w-full md:w-1/2'>
<ArrayInput<API.TimePeriod>
fields={[
{
name: 'start_time',
prefix: t('startTime'),
type: 'time',
},
{ name: 'end_time', prefix: t('endTime'), type: 'time' },
{ name: 'multiplier', prefix: t('multiplier'), type: 'number', placeholder: '0' },
]}
value={timeSlots}
onChange={setTimeSlots}
/>
</div>
</div>
</>
);
}
@@ -1,251 +0,0 @@
'use client';
import { UserDetail } from '@/app/dashboard/user/user-detail';
import { ProTable } 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 { Progress } from '@workspace/ui/components/progress';
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetTrigger,
} from '@workspace/ui/components/sheet';
import { formatBytes, formatDate } from '@workspace/ui/utils';
import { useTranslations } from 'next-intl';
import { useState } from 'react';
interface NodeDetailDialogProps {
node: API.Server;
children?: React.ReactNode;
trigger?: React.ReactNode;
}
// 统一的用户订阅信息组件
function UserSubscribeInfo({
userId,
type,
}: {
userId: number;
type: 'account' | 'subscribeName' | 'subscribeId' | 'trafficUsage' | 'expireTime';
}) {
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>;
switch (type) {
case 'account':
if (!data.user_id) return <span className='text-muted-foreground'>--</span>;
return <UserDetail id={data.user_id} />;
case 'subscribeName':
if (!data.subscribe?.name) return <span className='text-muted-foreground'>--</span>;
return <span className='text-sm'>{data.subscribe.name}</span>;
case 'subscribeId':
if (!data.id) return <span className='text-muted-foreground'>--</span>;
return <span className='font-mono text-sm'>{data.id}</span>;
case 'trafficUsage': {
const usedTraffic = data.upload + data.download;
const totalTraffic = data.traffic || 0;
return (
<div className='min-w-0 text-sm'>
<div className='break-words'>
{formatBytes(usedTraffic)} / {totalTraffic > 0 ? formatBytes(totalTraffic) : '无限制'}
</div>
</div>
);
}
case 'expireTime': {
if (!data.expire_time) return <span className='text-muted-foreground'>--</span>;
const isExpired = data.expire_time < Date.now() / 1000;
return (
<div className='flex flex-col gap-1 sm:flex-row sm:items-center sm:gap-2'>
<span className='text-sm'>{formatDate(data.expire_time)}</span>
{isExpired && (
<Badge variant='destructive' className='w-fit px-1 py-0 text-xs'>
</Badge>
)}
</div>
);
}
default:
return <span className='text-muted-foreground'>--</span>;
}
}
export function NodeDetailDialog({ node, children, trigger }: NodeDetailDialogProps) {
const t = useTranslations('server.node');
const [open, setOpen] = useState(false);
const { status } = node;
const { online, cpu, mem, disk, updated_at } = status || {
online: {},
cpu: 0,
mem: 0,
disk: 0,
updated_at: 0,
};
const isOnline = updated_at > 0;
const onlineCount = (online && Object.keys(online).length) || 0;
// 转换在线用户数据为ProTable需要的格式
const onlineUsersData = Object.entries(online || {}).map(([uid, ips]) => ({
uid,
ips: ips as string[],
primaryIp: ips[0] || '',
allIps: (ips as string[]).join(', '),
}));
return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
{trigger || (
<Button variant='outline' size='sm'>
{t('detail')}
</Button>
)}
</SheetTrigger>
<SheetContent className='w-full max-w-full sm:w-[600px] sm:max-w-screen-md'>
<SheetHeader>
<SheetTitle>
{t('nodeDetail')} - {node.name}
</SheetTitle>
</SheetHeader>
<div className='-mx-6 h-[calc(100dvh-48px-16px-env(safe-area-inset-top))] space-y-2 overflow-y-auto px-6 py-4'>
<h3 className='text-base font-medium'>{t('nodeStatus')}</h3>
<div className='space-y-3'>
<div className='flex w-full flex-col gap-2 text-sm sm:flex-row sm:items-center sm:gap-3'>
<Badge variant={isOnline ? 'default' : 'destructive'} className='w-fit text-xs'>
{isOnline ? t('normal') : t('abnormal')}
</Badge>
<span className='text-muted-foreground'>
{t('onlineCount')}: {onlineCount}
</span>
{isOnline && (
<span className='text-muted-foreground text-xs sm:text-sm'>
{t('lastUpdated')}: {formatDate(updated_at)}
</span>
)}
</div>
{isOnline && (
<div className='grid grid-cols-1 gap-3 sm:grid-cols-3'>
<div className='space-y-1'>
<div className='flex justify-between text-xs'>
<span>CPU</span>
<span>{cpu?.toFixed(1)}%</span>
</div>
<Progress value={cpu ?? 0} className='h-1.5' max={100} />
</div>
<div className='space-y-1'>
<div className='flex justify-between text-xs'>
<span>{t('memory')}</span>
<span>{mem?.toFixed(1)}%</span>
</div>
<Progress value={mem ?? 0} className='h-1.5' max={100} />
</div>
<div className='space-y-1'>
<div className='flex justify-between text-xs'>
<span>{t('disk')}</span>
<span>{disk?.toFixed(1)}%</span>
</div>
<Progress value={disk ?? 0} className='h-1.5' max={100} />
</div>
</div>
)}
</div>
{isOnline && onlineCount > 0 && (
<div>
<h3 className='mb-3 text-lg font-medium'>{t('onlineUsers')}</h3>
<div className='overflow-x-auto'>
<ProTable
header={{
hidden: true,
}}
columns={[
{
accessorKey: 'allIps',
header: t('ipAddresses'),
cell: ({ row }) => {
const ips = row.original.ips;
return (
<div className='flex min-w-0 flex-col gap-1'>
{ips.map((ip: string, index: number) => (
<div key={ip} className='whitespace-nowrap text-sm'>
{index === 0 ? (
<span className='font-medium'>{ip}</span>
) : (
<span className='text-muted-foreground'>{ip}</span>
)}
</div>
))}
</div>
);
},
},
{
accessorKey: 'user',
header: t('user'),
cell: ({ row }) => (
<UserSubscribeInfo userId={Number(row.original.uid)} type='account' />
),
},
{
accessorKey: 'subscribeName',
header: t('subscribeName'),
cell: ({ row }) => (
<UserSubscribeInfo userId={Number(row.original.uid)} type='subscribeName' />
),
},
{
accessorKey: 'subscribeId',
header: t('subscribeId'),
cell: ({ row }) => (
<UserSubscribeInfo userId={Number(row.original.uid)} type='subscribeId' />
),
},
{
accessorKey: 'trafficUsage',
header: t('trafficUsage'),
cell: ({ row }) => (
<UserSubscribeInfo userId={Number(row.original.uid)} type='trafficUsage' />
),
},
{
accessorKey: 'expireTime',
header: t('expireTime'),
cell: ({ row }) => (
<UserSubscribeInfo userId={Number(row.original.uid)} type='expireTime' />
),
},
]}
request={async () => ({
list: onlineUsersData,
total: onlineUsersData.length,
})}
/>
</div>
</div>
)}
</div>
</SheetContent>
</Sheet>
);
}
File diff suppressed because it is too large Load Diff
@@ -1,266 +0,0 @@
'use client';
import { UserDetail } from '@/app/dashboard/user/user-detail';
import { IpLink } from '@/components/ip-link';
import { ProTable } from '@/components/pro-table';
import { getUserSubscribeById } from '@/services/admin/user';
import { useQuery } from '@tanstack/react-query';
import { Badge } from '@workspace/ui/components/badge';
import { Progress } from '@workspace/ui/components/progress';
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetTrigger,
} from '@workspace/ui/components/sheet';
import { formatBytes, formatDate } from '@workspace/ui/utils';
import { useTranslations } from 'next-intl';
import { useState } from 'react';
export function formatPercentage(value: number): string {
return `${value.toFixed(1)}%`;
}
// 统一的用户订阅信息组件
function UserSubscribeInfo({
userId,
type,
}: {
userId: number;
type: 'account' | 'subscribeName' | 'subscribeId' | 'trafficUsage' | 'expireTime';
}) {
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>;
switch (type) {
case 'account':
if (!data.user_id) return <span className='text-muted-foreground'>--</span>;
return <UserDetail id={data.user_id} />;
case 'subscribeName':
if (!data.subscribe?.name) return <span className='text-muted-foreground'>--</span>;
return <span className='text-sm'>{data.subscribe.name}</span>;
case 'subscribeId':
if (!data.id) return <span className='text-muted-foreground'>--</span>;
return <span className='font-mono text-sm'>{data.id}</span>;
case 'trafficUsage': {
const usedTraffic = data.upload + data.download;
const totalTraffic = data.traffic || 0;
return (
<div className='min-w-0 text-sm'>
<div className='break-words'>
{formatBytes(usedTraffic)} / {totalTraffic > 0 ? formatBytes(totalTraffic) : '无限制'}
</div>
</div>
);
}
case 'expireTime': {
if (!data.expire_time) return <span className='text-muted-foreground'>--</span>;
const isExpired = data.expire_time < Date.now() / 1000;
return (
<div className='flex flex-col gap-1 sm:flex-row sm:items-center sm:gap-2'>
<span className='text-sm'>{formatDate(data.expire_time)}</span>
{isExpired && (
<Badge variant='destructive' className='w-fit px-1 py-0 text-xs'>
</Badge>
)}
</div>
);
}
default:
return <span className='text-muted-foreground'>--</span>;
}
}
export function NodeStatusCell({ status, node }: { status: API.NodeStatus; node?: API.Server }) {
const t = useTranslations('server.node');
const [open, setOpen] = useState(false);
const { online, cpu, mem, disk, updated_at } = status || {
online: {},
cpu: 0,
mem: 0,
disk: 0,
updated_at: 0,
};
const isOnline = updated_at > 0;
const badgeVariant = isOnline ? 'default' : 'destructive';
const badgeText = isOnline ? t('normal') : t('abnormal');
const onlineCount = (online && Object.keys(online).length) || 0;
// 转换在线用户数据为ProTable需要的格式
const onlineUsersData = Object.entries(online || {}).map(([uid, ips]) => ({
uid,
ips: ips as string[],
primaryIp: ips[0] || '',
allIps: (ips as string[]).join(', '),
}));
return (
<>
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
<button className='hover:text-foreground flex cursor-pointer items-center gap-2 border-none bg-transparent p-0 text-left text-sm transition-colors'>
<Badge variant={badgeVariant}>{badgeText}</Badge>
<span className='text-muted-foreground'>
{t('onlineCount')}: {onlineCount}
</span>
</button>
</SheetTrigger>
{node && (
<SheetContent className='h-screen w-screen max-w-none sm:h-auto sm:w-[800px] sm:max-w-[90vw]'>
<SheetHeader>
<SheetTitle>
{t('nodeDetail')} - {node.name}
</SheetTitle>
</SheetHeader>
<div className='-mx-6 h-[calc(100vh-48px-16px)] space-y-2 overflow-y-auto px-6 py-4 sm:h-[calc(100dvh-48px-16px-env(safe-area-inset-top))]'>
<h3 className='text-base font-medium'>{t('nodeStatus')}</h3>
<div className='space-y-3'>
<div className='flex w-full flex-col gap-2 text-sm sm:flex-row sm:items-center sm:gap-3'>
<Badge variant={isOnline ? 'default' : 'destructive'} className='w-fit text-xs'>
{isOnline ? t('normal') : t('abnormal')}
</Badge>
<span className='text-muted-foreground'>
{t('onlineCount')}: {onlineCount}
</span>
{isOnline && (
<span className='text-muted-foreground text-xs sm:text-sm'>
{t('lastUpdated')}: {formatDate(updated_at)}
</span>
)}
</div>
{isOnline && (
<div className='grid grid-cols-1 gap-3 sm:grid-cols-3'>
<div className='space-y-1'>
<div className='flex justify-between text-xs'>
<span>CPU</span>
<span>{cpu?.toFixed(1)}%</span>
</div>
<Progress value={cpu ?? 0} className='h-1.5' max={100} />
</div>
<div className='space-y-1'>
<div className='flex justify-between text-xs'>
<span>{t('memory')}</span>
<span>{mem?.toFixed(1)}%</span>
</div>
<Progress value={mem ?? 0} className='h-1.5' max={100} />
</div>
<div className='space-y-1'>
<div className='flex justify-between text-xs'>
<span>{t('disk')}</span>
<span>{disk?.toFixed(1)}%</span>
</div>
<Progress value={disk ?? 0} className='h-1.5' max={100} />
</div>
</div>
)}
</div>
{isOnline && onlineCount > 0 && (
<div>
<h3 className='mb-3 text-lg font-medium'>{t('onlineUsers')}</h3>
<div className='overflow-x-auto'>
<ProTable
header={{
hidden: true,
}}
columns={[
{
accessorKey: 'allIps',
header: t('ipAddresses'),
cell: ({ row }) => {
const ips = row.original.ips;
return (
<div className='flex min-w-0 flex-col gap-1'>
{ips.map((ip: string, index: number) => (
<div key={ip} className='whitespace-nowrap text-sm'>
{index === 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' />
),
},
{
accessorKey: 'subscribeName',
header: t('subscribeName'),
cell: ({ row }) => (
<UserSubscribeInfo
userId={Number(row.original.uid)}
type='subscribeName'
/>
),
},
{
accessorKey: 'subscribeId',
header: t('subscribeId'),
cell: ({ row }) => (
<UserSubscribeInfo
userId={Number(row.original.uid)}
type='subscribeId'
/>
),
},
{
accessorKey: 'trafficUsage',
header: t('trafficUsage'),
cell: ({ row }) => (
<UserSubscribeInfo
userId={Number(row.original.uid)}
type='trafficUsage'
/>
),
},
{
accessorKey: 'expireTime',
header: t('expireTime'),
cell: ({ row }) => (
<UserSubscribeInfo
userId={Number(row.original.uid)}
type='expireTime'
/>
),
},
]}
request={async () => ({
list: onlineUsersData,
total: onlineUsersData.length,
})}
/>
</div>
</div>
)}
</div>
</SheetContent>
)}
</Sheet>
</>
);
}
@@ -1,320 +0,0 @@
'use client';
import { Display } from '@/components/display';
import { ProTable, ProTableActions } from '@/components/pro-table';
import {
batchDeleteNode,
createNode,
deleteNode,
getNodeGroupList,
getNodeList,
nodeSort,
updateNode,
} 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 { Switch } from '@workspace/ui/components/switch';
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 NodeForm from './node-form';
import { NodeStatusCell } from './node-status';
export default function NodeTable() {
const t = useTranslations('server.node');
const [loading, setLoading] = useState(false);
const { data: groups } = useQuery({
queryKey: ['getNodeGroupList'],
queryFn: async () => {
const { data } = await getNodeGroupList();
return (data.data?.list || []) as API.ServerGroup[];
},
});
const ref = useRef<ProTableActions>(null);
return (
<ProTable<API.Server, { groupId: number; search: string }>
action={ref}
header={{
toolbar: (
<NodeForm<API.CreateNodeRequest>
trigger={t('create')}
title={t('createNode')}
loading={loading}
onSubmit={async (values) => {
setLoading(true);
try {
await createNode({ ...values, enable: false });
toast.success(t('createSuccess'));
ref.current?.refresh();
setLoading(false);
return true;
} catch (error) {
setLoading(false);
return false;
}
}}
/>
),
}}
columns={[
{
accessorKey: 'id',
header: t('id'),
cell: ({ row }) => (
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<Badge
variant='outline'
className={cn('text-primary-foreground', {
'bg-green-500': row.original.protocol === 'shadowsocks',
'bg-rose-500': row.original.protocol === 'vmess',
'bg-blue-500': row.original.protocol === 'vless',
'bg-yellow-500': row.original.protocol === 'trojan',
'bg-purple-500': row.original.protocol === 'hysteria2',
'bg-cyan-500': row.original.protocol === 'tuic',
'bg-gray-500': row.original.protocol === 'anytls',
})}
>
{row.getValue('id')}
</Badge>
</TooltipTrigger>
<TooltipContent>{row.original.protocol}</TooltipContent>
</Tooltip>
</TooltipProvider>
),
},
{
accessorKey: 'enable',
header: t('enable'),
cell: ({ row }) => {
return (
<Switch
checked={row.getValue('enable')}
onCheckedChange={async (checked) => {
await updateNode({
...row.original,
id: row.original.id!,
enable: checked,
} as API.UpdateNodeRequest);
ref.current?.refresh();
}}
/>
);
},
},
{
accessorKey: 'name',
header: t('name'),
},
{
accessorKey: 'server_addr',
header: t('serverAddr'),
cell: ({ row }) => {
return (
<div className='flex gap-1'>
<Badge variant='outline'>
{row.original.country} - {row.original.city}
</Badge>
<Badge variant='outline'>{row.getValue('server_addr')}</Badge>
</div>
);
},
},
{
accessorKey: 'status',
header: t('status'),
cell: ({ row }) => {
return <NodeStatusCell status={row.original?.status} node={row.original} />;
},
},
{
accessorKey: 'speed_limit',
header: t('speedLimit'),
cell: ({ row }) => (
<Display type='trafficSpeed' value={row.getValue('speed_limit')} unlimited />
),
},
{
accessorKey: 'traffic_ratio',
header: t('trafficRatio'),
cell: ({ row }) => <Badge variant='outline'>{row.getValue('traffic_ratio')} X</Badge>,
},
{
accessorKey: 'group_id',
header: t('nodeGroup'),
cell: ({ row }) => {
const name = groups?.find((group) => group.id === row.getValue('group_id'))?.name;
return name ? <Badge variant='outline'>{name}</Badge> : t('noData');
},
},
{
accessorKey: 'tags',
header: t('tags'),
cell: ({ row }) => {
const tags = (row.getValue('tags') as string[]) || [];
return tags.length > 0 ? (
<div className='flex gap-1'>
{tags.map((tag) => (
<Badge key={tag} variant='outline'>
{tag}
</Badge>
))}
</div>
) : (
t('noData')
);
},
},
]}
params={[
{
key: 'group_id',
placeholder: t('nodeGroup'),
options: groups?.map((item) => ({
label: item.name,
value: String(item.id),
})),
},
{
key: 'search',
},
]}
request={async (pagination, filter) => {
const { data } = await getNodeList({
...pagination,
...filter,
});
return {
list: data.data?.list || [],
total: data.data?.total || 0,
};
}}
actions={{
render: (row) => [
<NodeForm<API.Server>
key='edit'
trigger={t('edit')}
title={t('editNode')}
loading={loading}
initialValues={row}
onSubmit={async (values) => {
setLoading(true);
try {
await updateNode({ ...row, ...values } as API.UpdateNodeRequest);
toast.success(t('updateSuccess'));
ref.current?.refresh();
setLoading(false);
return true;
} catch (error) {
setLoading(false);
return false;
}
}}
/>,
<ConfirmButton
key='delete'
trigger={<Button variant='destructive'>{t('delete')}</Button>}
title={t('confirmDelete')}
description={t('deleteWarning')}
onConfirm={async () => {
await deleteNode({
id: row.id,
});
toast.success(t('deleteSuccess'));
ref.current?.refresh();
}}
cancelText={t('cancel')}
confirmText={t('confirm')}
/>,
<Button
key='copy'
variant='outline'
onClick={async () => {
setLoading(true);
try {
const { id, sort, enable, updated_at, created_at, status, ...params } = row;
await createNode({
...params,
enable: false,
} as API.CreateNodeRequest);
toast.success(t('copySuccess'));
ref.current?.refresh();
setLoading(false);
return true;
} catch (error) {
setLoading(false);
return false;
}
}}
>
{t('copy')}
</Button>,
],
batchRender(rows) {
return [
<ConfirmButton
key='delete'
trigger={<Button variant='destructive'>{t('delete')}</Button>}
title={t('confirmDelete')}
description={t('deleteWarning')}
onConfirm={async () => {
await batchDeleteNode({
ids: rows.map((item) => item.id),
});
toast.success(t('deleteSuccess'));
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) {
nodeSort({
sort: changedItems.map((item) => ({ id: item.id, sort: item.sort })),
});
}
return updatedItems;
}}
/>
);
}
-29
View File
@@ -1,29 +0,0 @@
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@workspace/ui/components/tabs';
import { getTranslations } from 'next-intl/server';
import GroupTable from './group-table';
import NodeConfig from './node-config';
import NodeTable from './node-table';
export default async function Page() {
const t = await getTranslations('server');
return (
<Tabs defaultValue='node'>
<TabsList>
<TabsTrigger value='node'>{t('tabs.node')}</TabsTrigger>
<TabsTrigger value='group'>{t('tabs.nodeGroup')}</TabsTrigger>
<TabsTrigger value='config'>{t('tabs.nodeConfig')}</TabsTrigger>
</TabsList>
<TabsContent value='node'>
<NodeTable />
</TabsContent>
<TabsContent value='group'>
<GroupTable />
</TabsContent>
<TabsContent value='config'>
<NodeConfig />
</TabsContent>
</Tabs>
);
}
+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