diff --git a/apps/admin/app/dashboard/nodes/node-form.tsx b/apps/admin/app/dashboard/nodes/node-form.tsx index 47b3a54..36185d9 100644 --- a/apps/admin/app/dashboard/nodes/node-form.tsx +++ b/apps/admin/app/dashboard/nodes/node-form.tsx @@ -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; -}; - -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 { + const { data } = await filterServerList({ page: 1, size: 1000 }); + return (data?.data?.list || []) as ServerRow[]; } -const buildSchema = (t: ReturnType) => - 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) => + 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((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({ - 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; + 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; + 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: {
- ( - - {t('name')} - - form.setValue(field.name, v as string)} - /> - - - - )} - /> - ( - - {t('tags')} - - form.setValue(field.name, v)} - /> - - - - )} - /> - ({ 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: { )} /> + ( + + {t('name')} + + form.setValue(field.name, v as string)} + /> + + + + )} + /> ( - {t('server_addr')} + {t('address')} form.setValue(field.name, Number(v))} /> @@ -334,6 +280,24 @@ export default function NodeForm(props: { )} /> + ( + + {t('tags')} + + form.setValue(field.name, v)} + /> + + {t('tags_description')} + + + )} + />
diff --git a/apps/admin/app/dashboard/nodes/page.tsx b/apps/admin/app/dashboard/nodes/page.tsx index a6d6dbb..d9389d9 100644 --- a/apps/admin/app/dashboard/nodes/page.tsx +++ b/apps/admin/app/dashboard/nodes/page.tsx @@ -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(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(); - 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 ( - + 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() { { - 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 }) => ( - - {(row.original.server_addr || '—') + ':' + (row.original.port ?? '—')} - - ), + 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)} - + {row.original.protocol || '—'} ·{' '} {getProtocolOriginPort(row.original.server_id, row.original.protocol)} @@ -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; + } }} />, { - 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; - }} /> ); } diff --git a/apps/admin/app/dashboard/product/subscribe-form.tsx b/apps/admin/app/dashboard/product/subscribe-form.tsx index c8ed90e..1845d6c 100644 --- a/apps/admin/app/dashboard/product/subscribe-form.tsx +++ b/apps/admin/app/dashboard/product/subscribe-form.tsx @@ -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>({ 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>({ }, }); - 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 ( @@ -290,7 +290,7 @@ export default function SubscribeForm>({ - {t('form.servers')} + {t('form.nodes')} @@ -798,50 +798,56 @@ export default function SubscribeForm>({ name='server_group' render={({ field }) => ( - {t('form.serverGroup')} + {t('form.nodeGroup')} - {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 ( - +
{ 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), ); }} /> - +
-
    - {server - ?.filter( - (server: API.Server) => server.group_id === group.id, - ) - ?.map((node: API.Server) => { - return ( -
  • - {node.name} - {node.server_addr} - {node.protocol} -
  • - ); - })} +
      + {(nodes as API.Node[]) + ?.filter((n) => (n.tags || []).includes(tag)) + ?.map((node) => ( +
    • + {node.name} + + {node.address}:{node.port ?? '—'} + + + {node.protocol || '—'} + + + {(node.tags || []).map((tg) => ( + + {tg} + + ))} + +
    • + ))}
    @@ -859,12 +865,12 @@ export default function SubscribeForm>({ name='server' render={({ field }) => ( - {t('form.server')} + {t('form.node')}
    - {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>({ ); }} /> -
    ); diff --git a/apps/admin/app/dashboard/server/form-schema.ts b/apps/admin/app/dashboard/server/form-schema.ts deleted file mode 100644 index f3e55c5..0000000 --- a/apps/admin/app/dashboard/server/form-schema.ts +++ /dev/null @@ -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); diff --git a/apps/admin/app/dashboard/server/group-form.tsx b/apps/admin/app/dashboard/server/group-form.tsx deleted file mode 100644 index 50f3a3b..0000000 --- a/apps/admin/app/dashboard/server/group-form.tsx +++ /dev/null @@ -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 { - onSubmit: (data: T) => Promise | boolean; - initialValues?: T; - loading?: boolean; - trigger: string; - title: string; -} - -export default function GroupForm>({ - onSubmit, - initialValues, - loading, - trigger, - title, -}: GroupFormProps) { - 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 ( - - - - - - - {title} - - -
    - - ( - - {t('groupForm.name')} - - { - form.setValue(field.name, value); - }} - /> - - - - )} - /> - ( - - {t('groupForm.description')} - - { - form.setValue(field.name, value); - }} - /> - - - - )} - /> - - -
    - - - - -
    -
    - ); -} diff --git a/apps/admin/app/dashboard/server/group-table.tsx b/apps/admin/app/dashboard/server/group-table.tsx deleted file mode 100644 index 0e72913..0000000 --- a/apps/admin/app/dashboard/server/group-table.tsx +++ /dev/null @@ -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(null); - - return ( - - action={ref} - header={{ - title: t('group.title'), - toolbar: ( - - 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 }) =>

    {row.getValue('description')}

    , - }, - { - 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) => [ - - 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; - } - }} - />, - {t('group.delete')}} - 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 [ - {t('group.delete')}} - 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')} - />, - ]; - }, - }} - /> - ); -} diff --git a/apps/admin/app/dashboard/server/node-config.tsx b/apps/admin/app/dashboard/server/node-config.tsx deleted file mode 100644 index 0f15309..0000000 --- a/apps/admin/app/dashboard/server/node-config.tsx +++ /dev/null @@ -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([]); - - 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, - ); - }, [data]); - - return ( - <> - - - - - -

    {t('communicationKeyDescription')}

    -
    - - updateConfig('node_secret', value)} - suffix={ -
    - { - 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' - /> -
    - } - /> -
    -
    - - - -

    {t('nodePullIntervalDescription')}

    -
    - - updateConfig('node_pull_interval', value)} - suffix='S' - value={data?.node_pull_interval} - placeholder={t('inputPlaceholder')} - /> - -
    - - - -

    {t('nodePushIntervalDescription')}

    -
    - - updateConfig('node_push_interval', value)} - placeholder={t('inputPlaceholder')} - /> - -
    - - - -

    {t('dynamicMultiplierDescription')}

    -
    - - - - -
    -
    -
    -
    -
    - - - - `${(multiplier || 0)?.toFixed(2)}x (${(percent * 100).toFixed(0)}%)` - } - > - {chartTimeSlots.map((entry, index) => ( - - ))} - - { - if (payload && payload.length) { - const data = payload[0]?.payload; - return ( -
    -
    -
    - - {t('timeSlot')} - - - {data.name || '其他'} - -
    -
    - - {t('multiplier')} - - {data.multiplier.toFixed(2)}x -
    -
    -
    - ); - } - return null; - }} - /> - -
    -
    -
    -
    - - 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} - /> -
    -
    - - ); -} diff --git a/apps/admin/app/dashboard/server/node-detail.tsx b/apps/admin/app/dashboard/server/node-detail.tsx deleted file mode 100644 index 9141c5f..0000000 --- a/apps/admin/app/dashboard/server/node-detail.tsx +++ /dev/null @@ -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 --; - - switch (type) { - case 'account': - if (!data.user_id) return --; - return ; - - case 'subscribeName': - if (!data.subscribe?.name) return --; - return {data.subscribe.name}; - - case 'subscribeId': - if (!data.id) return --; - return {data.id}; - - case 'trafficUsage': { - const usedTraffic = data.upload + data.download; - const totalTraffic = data.traffic || 0; - return ( -
    -
    - {formatBytes(usedTraffic)} / {totalTraffic > 0 ? formatBytes(totalTraffic) : '无限制'} -
    -
    - ); - } - - case 'expireTime': { - if (!data.expire_time) return --; - const isExpired = data.expire_time < Date.now() / 1000; - return ( -
    - {formatDate(data.expire_time)} - {isExpired && ( - - 过期 - - )} -
    - ); - } - - default: - return --; - } -} - -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 ( - - - {trigger || ( - - )} - - - - - {t('nodeDetail')} - {node.name} - - -
    -

    {t('nodeStatus')}

    -
    -
    - - {isOnline ? t('normal') : t('abnormal')} - - - {t('onlineCount')}: {onlineCount} - - {isOnline && ( - - {t('lastUpdated')}: {formatDate(updated_at)} - - )} -
    - - {isOnline && ( -
    -
    -
    - CPU - {cpu?.toFixed(1)}% -
    - -
    -
    -
    - {t('memory')} - {mem?.toFixed(1)}% -
    - -
    -
    -
    - {t('disk')} - {disk?.toFixed(1)}% -
    - -
    -
    - )} -
    - {isOnline && onlineCount > 0 && ( -
    -

    {t('onlineUsers')}

    -
    - { - const ips = row.original.ips; - return ( -
    - {ips.map((ip: string, index: number) => ( -
    - {index === 0 ? ( - {ip} - ) : ( - {ip} - )} -
    - ))} -
    - ); - }, - }, - { - accessorKey: 'user', - header: t('user'), - cell: ({ row }) => ( - - ), - }, - { - accessorKey: 'subscribeName', - header: t('subscribeName'), - cell: ({ row }) => ( - - ), - }, - { - accessorKey: 'subscribeId', - header: t('subscribeId'), - cell: ({ row }) => ( - - ), - }, - { - accessorKey: 'trafficUsage', - header: t('trafficUsage'), - cell: ({ row }) => ( - - ), - }, - { - accessorKey: 'expireTime', - header: t('expireTime'), - cell: ({ row }) => ( - - ), - }, - ]} - request={async () => ({ - list: onlineUsersData, - total: onlineUsersData.length, - })} - /> -
    -
    - )} -
    -
    -
    - ); -} diff --git a/apps/admin/app/dashboard/server/node-form.tsx b/apps/admin/app/dashboard/server/node-form.tsx deleted file mode 100644 index ba01b39..0000000 --- a/apps/admin/app/dashboard/server/node-form.tsx +++ /dev/null @@ -1,1077 +0,0 @@ -'use client'; - -import { getNodeGroupList } from '@/services/admin/server'; -import { zodResolver } from '@hookform/resolvers/zod'; -import { useQuery } from '@tanstack/react-query'; -import { Button } from '@workspace/ui/components/button'; -import { Card, CardContent, CardHeader, CardTitle } from '@workspace/ui/components/card'; -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, -} from '@workspace/ui/components/form'; -import { ScrollArea } from '@workspace/ui/components/scroll-area'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@workspace/ui/components/select'; -import { - Sheet, - SheetContent, - SheetFooter, - SheetHeader, - SheetTitle, - SheetTrigger, -} from '@workspace/ui/components/sheet'; -import { Switch } from '@workspace/ui/components/switch'; -import { Tabs, TabsList, TabsTrigger } from '@workspace/ui/components/tabs'; -import { Combobox } from '@workspace/ui/custom-components/combobox'; -import { ArrayInput } from '@workspace/ui/custom-components/dynamic-Inputs'; -import { EnhancedInput } from '@workspace/ui/custom-components/enhanced-input'; -import { Icon } from '@workspace/ui/custom-components/icon'; -import TagInput from '@workspace/ui/custom-components/tag-input'; -import { cn } from '@workspace/ui/lib/utils'; -import { unitConversion } from '@workspace/ui/utils'; -import { useTranslations } from 'next-intl'; -import { useEffect, useState } from 'react'; -import { useForm } from 'react-hook-form'; -import { toast } from 'sonner'; -import { formSchema, protocols } from './form-schema'; -interface NodeFormProps { - onSubmit: (data: T) => Promise | boolean; - initialValues?: T; - loading?: boolean; - trigger: string; - title: string; -} - -export default function NodeForm({ - onSubmit, - initialValues, - loading, - trigger, - title, -}: Readonly>) { - const t = useTranslations('server'); - const tf = useTranslations('server.nodeForm'); - const trs = useTranslations('server.relayModeOptions'); - const tsc = useTranslations('server.securityConfig'); - - const [open, setOpen] = useState(false); - const form = useForm({ - resolver: zodResolver(formSchema), - defaultValues: { - tags: [], - traffic_ratio: 1, - protocol: 'shadowsocks', - ...initialValues, - config: { - security: 'none', - transport: 'tcp', - ...initialValues?.config, - }, - } as any, - }); - const protocol = form.watch('protocol'); - const transport = form.watch('config.transport'); - const security = form.watch('config.security'); - const relayMode = form.watch('relay_mode'); - const method = form.watch('config.method'); - - useEffect(() => { - form?.reset(initialValues); - }, [form, initialValues]); - - async function handleSubmit(data: { [x: string]: any }) { - const bool = await onSubmit(data as unknown as T); - if (bool) setOpen(false); - } - - const { data: groups } = useQuery({ - queryKey: ['getNodeGroupList'], - queryFn: async () => { - const { data } = await getNodeGroupList(); - return (data.data?.list || []) as API.ServerGroup[]; - }, - }); - - return ( - - - - - - - {title} - - -
    - -
    - ( - - {t('nodeForm.name')} - - { - form.setValue(field.name, value); - }} - /> - - - - )} - /> - ( - - {t('nodeForm.groupId')} - - - placeholder={t('nodeForm.selectNodeGroup')} - {...field} - options={groups?.map((item) => ({ - value: item.id, - label: item.name, - }))} - onChange={(value) => { - form.setValue(field.name, value || 0); - }} - /> - - - - )} - /> -
    -
    - ( - - {t('nodeForm.tags')} - - form.setValue(field.name, value)} - /> - - - - )} - /> - ( - - {t('nodeForm.country')} - - { - form.setValue(field.name, value); - }} - /> - - - - )} - /> - ( - - {t('nodeForm.city')} - - { - form.setValue(field.name, value); - }} - /> - - - - )} - /> -
    -
    - ( - - {t('nodeForm.serverAddr')} - - { - form.setValue(field.name, value); - }} - /> - - - - )} - /> - ( - - {t('nodeForm.speedLimit')} - - unitConversion('bitsToMb', value)} - formatOutput={(value) => unitConversion('mbToBits', value)} - onValueChange={(value) => { - form.setValue(field.name, value); - }} - suffix='Mbps' - /> - - - - )} - /> - ( - - {t('nodeForm.trafficRatio')} - - { - form.setValue(field.name, value); - }} - suffix='X' - /> - - - - )} - /> -
    - - ( - - {t('nodeForm.protocol')} - - { - form.setValue(field.name, value); - if (['trojan', 'hysteria2', 'tuic', 'anytls'].includes(value)) { - form.setValue('config.security', 'tls'); - } - }} - > - - {protocols.map((proto) => ( - - {proto.charAt(0).toUpperCase() + proto.slice(1)} - - ))} - - - - - - )} - /> - - {protocol === 'shadowsocks' && ( -
    - ( - - {t('nodeForm.encryptionMethod')} - - - - - - )} - /> - ( - - {t('nodeForm.port')} - - { - form.setValue(field.name, value); - }} - /> - - - - )} - /> - {[ - '2022-blake3-aes-128-gcm', - '2022-blake3-aes-256-gcm', - '2022-blake3-chacha20-poly1305', - ].includes(method) && ( - ( - - {t('nodeForm.serverKey')} - - { - form.setValue(field.name, value); - }} - /> - - - - )} - /> - )} -
    - )} - - {['vmess', 'vless', 'trojan', 'hysteria2', 'tuic', 'anytls'].includes(protocol) && ( -
    -
    - ( - - {t('nodeForm.port')} - - { - form.setValue(field.name, value); - }} - /> - - - - )} - /> - {protocol === 'vless' && ( - ( - - {t('nodeForm.flow')} - - - - - - )} - /> - )} - {protocol === 'hysteria2' && ( - <> - ( - - {t('nodeForm.obfsPassword')} - - { - form.setValue(field.name, value); - }} - /> - - - - )} - /> - ( - - {t('nodeForm.hopPorts')} - - { - form.setValue(field.name, value); - }} - /> - - - - )} - /> - ( - - {t('nodeForm.hopInterval')} - - { - form.setValue(field.name, value); - }} - suffix='S' - /> - - - - )} - /> - - )} - {protocol === 'tuic' && ( - <> - ( - - {t('nodeForm.udpRelayMode')} - - - - - - )} - /> - ( - - {t('nodeForm.congestionController')} - - - - - - )} - /> -
    - ( - - {t('nodeForm.disableSni')} - -
    - { - form.setValue(field.name, checked); - }} - /> -
    -
    - -
    - )} - /> - ( - - {t('nodeForm.reduceRtt')} - -
    - { - form.setValue(field.name, checked); - }} - /> -
    -
    - -
    - )} - /> -
    - - )} -
    - {['vmess', 'vless', 'trojan'].includes(protocol) && ( - - - {t('nodeForm.transportConfig')} - ( - - - - - - - )} - /> - - {transport !== 'tcp' && ( - - {['websocket', 'http2', 'httpupgrade'].includes(transport) && ( - <> - ( - - PATH - - { - form.setValue(field.name, value); - }} - /> - - - - )} - /> - ( - - HOST - - { - form.setValue(field.name, value); - }} - /> - - - - )} - /> - - )} - {['grpc'].includes(transport) && ( - ( - - Service Name - - { - form.setValue(field.name, value); - }} - /> - - - - )} - /> - )} - - )} - - )} - {(['vmess', 'vless', 'trojan'].includes(protocol) || - ['anytls', 'tuic', 'hysteria2'].includes(protocol)) && ( - - - {t('nodeForm.securityConfig')} - {['vmess', 'vless', 'trojan'].includes(protocol) && ( - ( - - - - - )} - /> - )} - - {(['anytls', 'tuic', 'hysteria2'].includes(protocol) || - (['vmess', 'vless', 'trojan'].includes(protocol) && - security !== 'none')) && ( - - ( - - Server Name(SNI) - - { - form.setValue(field.name, value); - }} - /> - - - - )} - /> - - {/* Reality 特殊配置只在 vless + reality 时显示 */} - {protocol === 'vless' && security === 'reality' && ( - <> - ( - - {t('securityConfig.serverAddress')} - - { - form.setValue(field.name, value); - }} - /> - - - - )} - /> - ( - - {t('securityConfig.serverPort')} - - { - form.setValue(field.name, value); - }} - /> - - - - )} - /> - ( - - {t('securityConfig.privateKey')} - - { - form.setValue(field.name, value); - }} - /> - - - - )} - /> - ( - - {t('securityConfig.publicKey')} - - { - form.setValue(field.name, value); - }} - /> - - - - )} - /> - ( - - {t('securityConfig.shortId')} - - { - form.setValue(field.name, value); - }} - /> - - - - )} - /> - - )} - - {protocol === 'vless' && ( - ( - - {t('securityConfig.fingerprint')} - - - - )} - /> - )} - - ( - - Allow Insecure - -
    - { - form.setValue(field.name, checked); - }} - /> -
    -
    - -
    - )} - /> -
    - )} -
    - )} -
    - )} - - - - {t('nodeForm.relayMode')} - ( - - - - - - - )} - /> - - {relayMode !== 'none' && ( - - ( - - - { - form.setValue(field.name, value); - }} - /> - - - - )} - /> - - )} - - - -
    - - - - -
    -
    - ); -} diff --git a/apps/admin/app/dashboard/server/node-status.tsx b/apps/admin/app/dashboard/server/node-status.tsx deleted file mode 100644 index ca1cd91..0000000 --- a/apps/admin/app/dashboard/server/node-status.tsx +++ /dev/null @@ -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 --; - - switch (type) { - case 'account': - if (!data.user_id) return --; - return ; - - case 'subscribeName': - if (!data.subscribe?.name) return --; - return {data.subscribe.name}; - - case 'subscribeId': - if (!data.id) return --; - return {data.id}; - - case 'trafficUsage': { - const usedTraffic = data.upload + data.download; - const totalTraffic = data.traffic || 0; - return ( -
    -
    - {formatBytes(usedTraffic)} / {totalTraffic > 0 ? formatBytes(totalTraffic) : '无限制'} -
    -
    - ); - } - - case 'expireTime': { - if (!data.expire_time) return --; - const isExpired = data.expire_time < Date.now() / 1000; - return ( -
    - {formatDate(data.expire_time)} - {isExpired && ( - - 过期 - - )} -
    - ); - } - - default: - return --; - } -} - -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 ( - <> - - - - - {node && ( - - - - {t('nodeDetail')} - {node.name} - - -
    -

    {t('nodeStatus')}

    -
    -
    - - {isOnline ? t('normal') : t('abnormal')} - - - {t('onlineCount')}: {onlineCount} - - {isOnline && ( - - {t('lastUpdated')}: {formatDate(updated_at)} - - )} -
    - - {isOnline && ( -
    -
    -
    - CPU - {cpu?.toFixed(1)}% -
    - -
    -
    -
    - {t('memory')} - {mem?.toFixed(1)}% -
    - -
    -
    -
    - {t('disk')} - {disk?.toFixed(1)}% -
    - -
    -
    - )} -
    - {isOnline && onlineCount > 0 && ( -
    -

    {t('onlineUsers')}

    -
    - { - const ips = row.original.ips; - return ( -
    - {ips.map((ip: string, index: number) => ( -
    - {index === 0 ? ( - - ) : ( - - )} -
    - ))} -
    - ); - }, - }, - { - accessorKey: 'user', - header: t('user'), - cell: ({ row }) => ( - - ), - }, - { - accessorKey: 'subscribeName', - header: t('subscribeName'), - cell: ({ row }) => ( - - ), - }, - { - accessorKey: 'subscribeId', - header: t('subscribeId'), - cell: ({ row }) => ( - - ), - }, - { - accessorKey: 'trafficUsage', - header: t('trafficUsage'), - cell: ({ row }) => ( - - ), - }, - { - accessorKey: 'expireTime', - header: t('expireTime'), - cell: ({ row }) => ( - - ), - }, - ]} - request={async () => ({ - list: onlineUsersData, - total: onlineUsersData.length, - })} - /> -
    -
    - )} -
    -
    - )} -
    - - ); -} diff --git a/apps/admin/app/dashboard/server/node-table.tsx b/apps/admin/app/dashboard/server/node-table.tsx deleted file mode 100644 index d21d6d0..0000000 --- a/apps/admin/app/dashboard/server/node-table.tsx +++ /dev/null @@ -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(null); - - return ( - - action={ref} - header={{ - toolbar: ( - - 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 }) => ( - - - - - {row.getValue('id')} - - - {row.original.protocol} - - - ), - }, - { - accessorKey: 'enable', - header: t('enable'), - cell: ({ row }) => { - return ( - { - 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 ( -
    - - {row.original.country} - {row.original.city} - - {row.getValue('server_addr')} -
    - ); - }, - }, - { - accessorKey: 'status', - header: t('status'), - cell: ({ row }) => { - return ; - }, - }, - { - accessorKey: 'speed_limit', - header: t('speedLimit'), - cell: ({ row }) => ( - - ), - }, - { - accessorKey: 'traffic_ratio', - header: t('trafficRatio'), - cell: ({ row }) => {row.getValue('traffic_ratio')} X, - }, - - { - accessorKey: 'group_id', - header: t('nodeGroup'), - cell: ({ row }) => { - const name = groups?.find((group) => group.id === row.getValue('group_id'))?.name; - return name ? {name} : t('noData'); - }, - }, - { - accessorKey: 'tags', - header: t('tags'), - cell: ({ row }) => { - const tags = (row.getValue('tags') as string[]) || []; - return tags.length > 0 ? ( -
    - {tags.map((tag) => ( - - {tag} - - ))} -
    - ) : ( - 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) => [ - - 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; - } - }} - />, - {t('delete')}} - 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')} - />, - , - ], - batchRender(rows) { - return [ - {t('delete')}} - 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; - }} - /> - ); -} diff --git a/apps/admin/app/dashboard/server/page.tsx b/apps/admin/app/dashboard/server/page.tsx deleted file mode 100644 index 6bea9fd..0000000 --- a/apps/admin/app/dashboard/server/page.tsx +++ /dev/null @@ -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 ( - - - {t('tabs.node')} - {t('tabs.nodeGroup')} - {t('tabs.nodeConfig')} - - - - - - - - - - - - ); -} diff --git a/apps/admin/app/dashboard/servers/form-scheme.ts b/apps/admin/app/dashboard/servers/form-scheme.ts index 5631d2f..f617d99 100644 --- a/apps/admin/app/dashboard/servers/form-scheme.ts +++ b/apps/admin/app/dashboard/servers/form-scheme.ts @@ -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)[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; } diff --git a/apps/admin/app/dashboard/servers/online-users-cell.tsx b/apps/admin/app/dashboard/servers/online-users-cell.tsx new file mode 100644 index 0000000..2cbff23 --- /dev/null +++ b/apps/admin/app/dashboard/servers/online-users-cell.tsx @@ -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; +}) { + 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 ( + + + + + + + {t('onlineUsers')} + +
    + + > + header={{ hidden: true }} + columns={[ + { + accessorKey: 'ips', + header: t('ipAddresses'), + cell: ({ row }) => { + const ips = row.original.ips; + return ( +
    + {ips.map((ip, i) => ( +
    + {i === 0 ? ( + + ) : ( + + )} +
    + ))} +
    + ); + }, + }, + { + accessorKey: 'user', + header: t('user'), + cell: ({ row }) => , + }, + { + accessorKey: 'subscription', + header: t('subscription'), + cell: ({ row }) => ( + {row.original.subscribe || '--'} + ), + }, + { + accessorKey: 'subscribeId', + header: t('subscribeId'), + cell: ({ row }) => ( + {row.original.subscribe_id || '--'} + ), + }, + { + accessorKey: 'traffic', + header: t('traffic'), + cell: ({ row }) => { + const v = Number(row.original.traffic || 0); + return {(v / 1024 ** 3).toFixed(2)} GB; + }, + }, + { + accessorKey: 'expireTime', + header: t('expireTime'), + cell: ({ row }) => { + const ts = Number(row.original.expired_at || 0); + if (!ts) return --; + const expired = ts < Date.now() / 1000; + return ( +
    + {new Date(ts * 1000).toLocaleString()} + {expired && ( + + {t('expired')} + + )} +
    + ); + }, + }, + ]} + request={async () => ({ list: rows, total: rows.length })} + /> +
    +
    +
    + ); +} diff --git a/apps/admin/app/dashboard/servers/page.tsx b/apps/admin/app/dashboard/servers/page.tsx index 8dbceaa..77840f9 100644 --- a/apps/admin/app/dashboard/servers/page.tsx +++ b/apps/admin/app/dashboard/servers/page.tsx @@ -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 }; - -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) => { - mockData.push({ - id: Date.now(), - name: '', - server_addr: '', - protocols: [], - ...values, - }); - return true; -}; -const updateServer = async (id: number, values: Omit) => { - 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 = { shadowsocks: 'bg-green-500', @@ -162,42 +31,8 @@ const PROTOCOL_COLORS: Record = { 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 ( - - - - - {label} - - - {tooltip || t('notAvailable')} - - - ); -} - function PctBar({ value }: { value: number }) { - const v = Math.max(0, Math.min(100, Math.round(value))); + const v = value.toFixed(2); return (
    {v}%
    @@ -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 (
    {region} - {ip} + {ip || t('notAvailable')}
    ); } -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 --; - if (type === 'account') - return data.user_id ? ( - - ) : ( - -- - ); - if (type === 'subscribeName') - return data.subscribe?.name ? ( - {data.subscribe.name} - ) : ( - -- - ); - if (type === 'subscribeId') - return data.id ? ( - {data.id} - ) : ( - -- - ); - if (type === 'traffic') { - const used = (data.upload || 0) + (data.download || 0); - const total = data.traffic || 0; - return ( -
    {`${(used / 1024 ** 3).toFixed(2)} GB / ${total > 0 ? (total / 1024 ** 3).toFixed(2) + ' GB' : t('unlimited')}`}
    - ); - } - if (type === 'expireTime') { - if (!data.expire_time) return --; - const expired = data.expire_time < Date.now() / 1000; - return ( -
    - {new Date((data.expire_time || 0) * 1000).toLocaleString()} - {expired && ( - - {t('expired')} - - )} -
    - ); - } - return --; -} - -function normalizeOnlineMap(online: unknown): { uid: string; ips: string[] }[] { - if (!online || typeof online !== 'object' || Array.isArray(online)) return []; - const m = online as Record; - 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; - 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 ( - - - - - - - {t('onlineUsers')} - -
    - - > - header={{ hidden: true }} - columns={[ - { - accessorKey: 'ips', - header: t('ipAddresses'), - cell: ({ row }) => { - const ips = row.original.ips; - return ( -
    - {ips.map((ip, i) => ( -
    - {i === 0 ? ( - - ) : ( - - )} -
    - ))} -
    - ); - }, - }, - { - accessorKey: 'user', - header: t('user'), - cell: ({ row }) => ( - - ), - }, - { - accessorKey: 'subscription', - header: t('subscription'), - cell: ({ row }) => ( - - ), - }, - { - accessorKey: 'subscribeId', - header: t('subscribeId'), - cell: ({ row }) => ( - - ), - }, - { - accessorKey: 'traffic', - header: t('traffic'), - cell: ({ row }) => ( - - ), - }, - { - accessorKey: 'expireTime', - header: t('expireTime'), - cell: ({ row }) => ( - - ), - }, - ]} - request={async () => ({ list: rows, total: rows.length })} - /> -
    -
    -
    - ); -} +// OnlineUsersCell is now a standalone component export default function ServersPage() { const t = useTranslations('servers'); @@ -407,7 +78,7 @@ export default function ServersPage() { - + 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 }) => ( ), @@ -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 (
    - {enabled.map((p, idx) => ( - - ))} + {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 s = (row.original.status ?? {}) as ServerStatus; - const on = !!( - s.online && - typeof s.online === 'object' && - !Array.isArray(s.online) && - Object.keys(s.online as Record).length - ); + const s = (row.original.status ?? {}) as API.ServerStatus; + const on = !!(Array.isArray(s.online) && s.online.length > 0); return (
    , + cell: ({ row }) => ( + + ), }, { id: 'mem', header: t('memory'), - cell: ({ row }) => , + cell: ({ row }) => ( + + ), }, { id: 'disk', header: t('disk'), - cell: ({ row }) => , + 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 { 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), + }); + toast.success(t('updated')); + ref.current?.refresh(); + setLoading(false); + return true; + } catch (e) { + setLoading(false); + return false; + } }} />, { - 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); diff --git a/apps/admin/app/dashboard/servers/server-config.tsx b/apps/admin/app/dashboard/servers/server-config.tsx index 4439d24..3473ca5 100644 --- a/apps/admin/app/dashboard/servers/server-config.tsx +++ b/apps/admin/app/dashboard/servers/server-config.tsx @@ -188,7 +188,7 @@ export default function ServerConfig() {
    - +

    {t('config.title')}

    @@ -274,6 +274,7 @@ export default function ServerConfig() { { onSubmit: (data: T) => Promise | boolean; @@ -51,29 +63,14 @@ function titleCase(s: string) { } function normalizeValues(raw: any) { - const map = new Map(); - const given = Array.isArray(raw?.protocols) ? raw.protocols : []; - for (const it of given) map.set(it?.protocol, it); - - const normalized = { + return { name: raw?.name ?? '', - server_addr: raw?.server_addr ?? '', + address: raw?.address ?? '', country: raw?.country ?? '', city: raw?.city ?? '', - protocols: PROTOCOLS.map((p) => { - const incoming = map.get(p); - const def = getProtocolDefaultConfig(p as any); - if (incoming) { - return { - protocol: p, - enabled: !!incoming.enabled, - config: { ...def, ...(incoming.config ?? {}) }, - }; - } - return { protocol: p, enabled: false, config: def }; - }), + ratio: Number(raw?.ratio ?? 1), + protocols: Array.isArray(raw?.protocols) ? raw.protocols : [], }; - return normalized; } export default function ServerForm({ @@ -85,664 +82,65 @@ export default function ServerForm({ }: Readonly>) { const t = useTranslations('servers'); const [open, setOpen] = useState(false); + const [activeType, setActiveType] = useState<(typeof PROTOCOLS)[number]>('shadowsocks'); const defaultValues = useMemo( () => normalizeValues({ name: '', - server_addr: '', + address: '', country: '', city: '', + ratio: 1, protocols: [], }), [], ); - const form = useForm({ - resolver: zodResolver(formScheme), - defaultValues, - }); + const form = useForm({ resolver: zodResolver(formScheme), defaultValues }); const { control } = form; - useFieldArray({ control, name: 'protocols' }); + const { fields, append, remove } = useFieldArray({ control, name: 'protocols' }); - const [activeProto, setActiveProto] = useState(PROTOCOLS[0]); - const activeIndex = useMemo(() => PROTOCOLS.findIndex((p) => p === activeProto), [activeProto]); + const protocolsValues = useWatch({ control, name: 'protocols' }); useEffect(() => { - if (initialValues) { - const normalized = normalizeValues(initialValues); - form.reset(normalized); - const enabledFirst = normalized.protocols.find((p: any) => p.enabled)?.protocol; - setActiveProto((enabledFirst as any) || PROTOCOLS[0]); - } + const normalized = normalizeValues(initialValues || {}); + const byType = new Map(); + (Array.isArray(normalized.protocols) ? normalized.protocols : []).forEach((p: any) => { + if (p && p.type) byType.set(String(p.type), p); + }); + const full = PROTOCOLS.map((t) => byType.get(t) || getProtocolDefaultConfig(t)); + form.reset({ ...normalized, protocols: full }); + setActiveType('shadowsocks'); // eslint-disable-next-line react-hooks/exhaustive-deps }, [initialValues]); async function handleSubmit(data: { [x: string]: any }) { - const ok = await onSubmit(data as unknown as T); + const all = Array.isArray(data?.protocols) ? data.protocols : []; + const filtered = all + .filter((p: any) => { + const v = (p ?? {}).port; + const n = Number(v); + return Number.isFinite(n) && n > 0 && n <= 65535; + }) + .map((p: any) => ({ ...p, port: Number(p.port) })); + if (filtered.length === 0) { + toast.error(t('validation_failed')); + return; + } + const body = { + name: data?.name, + country: data?.country, + city: data?.city, + ratio: Number(data?.ratio ?? 1), + address: data?.address, + protocols: filtered, + } as unknown as T; + const ok = await onSubmit(body as unknown as T); if (ok) setOpen(false); } - function ProtocolEditor({ idx, proto }: { idx: number; proto: string }) { - const transport = useWatch({ control, name: `protocols.${idx}.config.transport` as const }); - const security = useWatch({ control, name: `protocols.${idx}.config.security` as const }); - const method = useWatch({ control, name: `protocols.${idx}.config.method` as const }); - const enabled = useWatch({ control, name: `protocols.${idx}.enabled` as const }); - - return ( -
    - ( -
    - {t('enabled')} - { - field.onChange(checked); - if (checked) { - form.setValue( - `protocols.${idx}.config` as const, - getProtocolDefaultConfig(proto as any), - ); - if (['trojan', 'hysteria2'].includes(proto)) { - form.setValue(`protocols.${idx}.config.security` as const, 'tls'); - } - } - }} - /> -
    - )} - /> - - {enabled && ( - <> - {['shadowsocks'].includes(proto) && ( -
    - ( - - {t('encryption_method')} - - - - - - )} - /> - ( - - {t('port')} - - field.onChange(v)} - /> - - - - )} - /> - {[ - '2022-blake3-aes-128-gcm', - '2022-blake3-aes-256-gcm', - '2022-blake3-chacha20-poly1305', - ].includes(method as any) && ( - ( - - {t('server_key')} - - field.onChange(v)} /> - - - - )} - /> - )} -
    - )} - - {['vmess', 'vless', 'trojan', 'hysteria2', 'tuic', 'anytls'].includes(proto) && ( -
    - ( - - {t('port')} - - field.onChange(v)} - /> - - - - )} - /> - - {proto === 'vless' && ( - ( - - {t('flow')} - - - - - - )} - /> - )} - - {proto === 'hysteria2' && ( - <> - ( - - {t('obfs_password')} - - field.onChange(v)} - /> - - - - )} - /> - ( - - {t('hop_ports')} - - field.onChange(v)} - /> - - - - )} - /> - ( - - {t('hop_interval')} - - field.onChange(v)} - suffix={t('unitSecondsShort')} - /> - - - - )} - /> - - )} - - {proto === 'tuic' && ( - <> - ( - - {t('udp_relay_mode')} - - - - - - )} - /> - ( - - {t('congestion_controller')} - - - - - - )} - /> -
    - ( - - {t('disable_sni')} - -
    - field.onChange(checked)} - /> -
    -
    - -
    - )} - /> - ( - - {t('reduce_rtt')} - -
    - field.onChange(checked)} - /> -
    -
    - -
    - )} - /> -
    - - )} -
    - )} - - {['vmess', 'vless', 'trojan'].includes(proto) && ( - - - {t('transport_title')} - ( - - - - - - - )} - /> - - {transport !== 'tcp' && ( - - {['websocket', 'http2', 'httpupgrade'].includes(transport as any) && ( - <> - ( - - {t('path')} - - field.onChange(v)} - /> - - - - )} - /> - ( - - {t('host')} - - field.onChange(v)} - /> - - - - )} - /> - - )} - {transport === 'grpc' && ( - ( - - {t('service_name')} - - field.onChange(v)} /> - - - - )} - /> - )} - - )} - - )} - - {['vmess', 'vless', 'trojan', 'anytls', 'tuic', 'hysteria2'].includes(proto) && ( - - - {t('security_title')} - {['vmess', 'vless', 'trojan'].includes(proto) && ( - ( - - - - - )} - /> - )} - - - {(['anytls', 'tuic', 'hysteria2'].includes(proto) || - (['vmess', 'vless', 'trojan'].includes(proto) && security !== 'none')) && ( - - ( - - {t('security_sni')} - - field.onChange(v)} /> - - - - )} - /> - - {proto === 'vless' && security === 'reality' && ( - <> - ( - - {t('security_server_address')} - - field.onChange(v)} - /> - - - - )} - /> - ( - - {t('security_server_port')} - - field.onChange(v)} - /> - - - - )} - /> - ( - - {t('security_private_key')} - - field.onChange(v)} - /> - - - - )} - /> - ( - - {t('security_public_key')} - - field.onChange(v)} - /> - - - - )} - /> - ( - - {t('security_short_id')} - - field.onChange(v)} - /> - - - - )} - /> - - )} - - {proto === 'vless' && ( - ( - - {t('security_fingerprint')} - - - - )} - /> - )} - - ( - - {t('security_allow_insecure')} - -
    - field.onChange(checked)} - /> -
    -
    - -
    - )} - /> -
    - )} -
    - )} - - )} -
    - ); - } + // inlined protocol editor below in TabsContent return ( @@ -750,8 +148,8 @@ export default function ServerForm({