mirror of
https://github.com/VaalaCat/frp-panel.git
synced 2025-12-24 11:51:06 +08:00
434 lines
15 KiB
TypeScript
434 lines
15 KiB
TypeScript
import { ColumnDef, Table } from '@tanstack/react-table'
|
|
import { MoreHorizontal } from 'lucide-react'
|
|
import {
|
|
Dialog,
|
|
DialogClose,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogTrigger,
|
|
} from '@/components/ui/dialog'
|
|
|
|
import { Button } from '@/components/ui/button'
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuLabel,
|
|
DropdownMenuSeparator,
|
|
DropdownMenuTrigger,
|
|
} from '@/components/ui/dropdown-menu'
|
|
import React from 'react'
|
|
import { ClientEnvFile, ExecCommandStr, LinuxInstallCommand, WindowsInstallCommand } from '@/lib/consts'
|
|
import { useMutation, useQuery } from '@tanstack/react-query'
|
|
import { deleteServer } from '@/api/server'
|
|
import { useRouter } from 'next/router'
|
|
import { useStore } from '@nanostores/react'
|
|
import { $frontendPreference, $platformInfo } from '@/store/user'
|
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
|
import { getClientsStatus } from '@/api/platform'
|
|
import { ClientType } from '@/lib/pb/common'
|
|
import { ClientStatus, ClientStatus_Status } from '@/lib/pb/api_master'
|
|
import { Badge } from '../ui/badge'
|
|
import { ClientDetail } from '../base/client_detail'
|
|
import { useTranslation } from 'react-i18next'
|
|
import { Input } from '@/components/ui/input'
|
|
import { toast } from 'sonner'
|
|
import { $serverTableRefetchTrigger } from '@/store/refetch-trigger'
|
|
import { Checkbox } from '../ui/checkbox'
|
|
import { Label } from '../ui/label'
|
|
|
|
export type ServerTableSchema = {
|
|
id: string
|
|
status: 'invalid' | 'valid'
|
|
secret: string
|
|
stopped: boolean
|
|
info?: string
|
|
ip: string
|
|
config?: string
|
|
frpsUrls: string[]
|
|
}
|
|
|
|
export const columns: ColumnDef<ServerTableSchema>[] = [
|
|
{
|
|
accessorKey: 'id',
|
|
header: function Header() {
|
|
const { t } = useTranslation()
|
|
return t('server.id')
|
|
},
|
|
cell: ({ row }) => {
|
|
return <ServerID server={row.original} />
|
|
},
|
|
},
|
|
{
|
|
accessorKey: 'status',
|
|
header: function Header() {
|
|
const { t } = useTranslation()
|
|
return t('server.status')
|
|
},
|
|
cell: ({ row }) => {
|
|
function Cell({ server }: { server: ServerTableSchema }) {
|
|
const { t } = useTranslation()
|
|
return (
|
|
<div className={`font-medium ${server.status === 'valid' ? 'text-green-500' : 'text-red-500'} min-w-12`}>
|
|
{server.status === 'valid' ? t('server.status_configured') : t('server.status_unconfigured')}
|
|
</div>
|
|
)
|
|
}
|
|
return <Cell server={row.original} />
|
|
},
|
|
},
|
|
{
|
|
accessorKey: 'info',
|
|
header: function Header() {
|
|
const { t } = useTranslation()
|
|
return t('server.info')
|
|
},
|
|
cell: ({ row }) => {
|
|
const server = row.original
|
|
return <ServerInfo server={server} />
|
|
},
|
|
},
|
|
{
|
|
accessorKey: 'ip',
|
|
header: function Header() {
|
|
const { t } = useTranslation()
|
|
return t('server.ip')
|
|
},
|
|
cell: ({ row }) => {
|
|
return row.original.ip
|
|
},
|
|
},
|
|
{
|
|
accessorKey: 'secret',
|
|
header: function Header() {
|
|
const { t } = useTranslation()
|
|
return t('server.secret')
|
|
},
|
|
cell: ({ row }) => {
|
|
const server = row.original
|
|
return <ServerSecret server={server} />
|
|
},
|
|
},
|
|
{
|
|
id: 'action',
|
|
cell: ({ row, table }) => {
|
|
const server = row.original
|
|
return <ServerActions server={server} table={table as Table<ServerTableSchema>} />
|
|
},
|
|
},
|
|
]
|
|
|
|
export const ServerID = ({ server }: { server: ServerTableSchema }) => {
|
|
const { t } = useTranslation()
|
|
const platformInfo = useStore($platformInfo)
|
|
const frontendPreference = useStore($frontendPreference)
|
|
|
|
if (!platformInfo) {
|
|
return (
|
|
<Button variant="link" className="px-0">
|
|
{server.id}
|
|
</Button>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<Popover>
|
|
<PopoverTrigger asChild>
|
|
<Button variant="link" className="px-0 font-mono">
|
|
{server.id}
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="w-80">
|
|
<div className="grid gap-4">
|
|
<div className="space-y-2">
|
|
<h4 className="font-medium leading-none">{t('server.install.title')}</h4>
|
|
<p className="text-sm text-muted-foreground">{t('server.install.description')}</p>
|
|
</div>
|
|
<div className="grid gap-2">
|
|
<div className="grid grid-cols-2 items-center gap-4 justify-items-center">
|
|
<Label>{t('client.install.use_github_proxy_url')}</Label>
|
|
<Checkbox onCheckedChange={(checked) => {
|
|
$frontendPreference.set({ ...frontendPreference, useServerGithubProxyUrl: checked === 'indeterminate' ? false : checked })
|
|
}} defaultChecked={frontendPreference.useServerGithubProxyUrl} />
|
|
</div>
|
|
<div className="grid grid-cols-2 items-center gap-4 justify-items-center">
|
|
<Label>{t('client.install.github_proxy_url')}</Label>
|
|
<Input value={frontendPreference.githubProxyUrl} onChange={(e) =>
|
|
$frontendPreference.set({ ...frontendPreference, githubProxyUrl: e.target.value })} />
|
|
</div>
|
|
<div className="grid grid-cols-2 items-center gap-4">
|
|
<Button
|
|
onClick={() => navigator.clipboard.writeText(WindowsInstallCommand('server', server, {
|
|
...platformInfo,
|
|
githubProxyUrl: frontendPreference.useServerGithubProxyUrl && frontendPreference.githubProxyUrl ? frontendPreference.githubProxyUrl : platformInfo.githubProxyUrl,
|
|
}, frontendPreference.useServerGithubProxyUrl))}
|
|
disabled={!platformInfo}
|
|
size="sm"
|
|
variant="outline"
|
|
>
|
|
{t('server.install.windows')}
|
|
</Button>
|
|
<Input
|
|
readOnly
|
|
value={WindowsInstallCommand('server', server, {
|
|
...platformInfo,
|
|
githubProxyUrl: frontendPreference.useServerGithubProxyUrl && frontendPreference.githubProxyUrl ? frontendPreference.githubProxyUrl : platformInfo.githubProxyUrl,
|
|
}, frontendPreference.useServerGithubProxyUrl)}
|
|
className="flex-1"
|
|
/>
|
|
</div>
|
|
<div className="grid grid-cols-2 items-center gap-4">
|
|
<Button
|
|
onClick={() => navigator.clipboard.writeText(LinuxInstallCommand('server', server, {
|
|
...platformInfo,
|
|
githubProxyUrl: frontendPreference.useServerGithubProxyUrl && frontendPreference.githubProxyUrl ? frontendPreference.githubProxyUrl : platformInfo.githubProxyUrl,
|
|
}, frontendPreference.useServerGithubProxyUrl))}
|
|
disabled={!platformInfo}
|
|
size="sm"
|
|
variant="outline"
|
|
>
|
|
{t('server.install.linux')}
|
|
</Button>
|
|
<Input
|
|
readOnly
|
|
value={LinuxInstallCommand('server', server, {
|
|
...platformInfo,
|
|
githubProxyUrl: frontendPreference.useServerGithubProxyUrl && frontendPreference.githubProxyUrl ? frontendPreference.githubProxyUrl : platformInfo.githubProxyUrl,
|
|
}, frontendPreference.useServerGithubProxyUrl)}
|
|
className="flex-1"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
)
|
|
}
|
|
|
|
export const ServerInfo = ({ server }: { server: ServerTableSchema }) => {
|
|
const { t } = useTranslation()
|
|
const { data: clientsStatus } = useQuery({
|
|
queryKey: ['clientsStatus', server.id],
|
|
queryFn: async () => {
|
|
return await getClientsStatus({
|
|
clientIds: [server.id],
|
|
clientType: ClientType.FRPS,
|
|
})
|
|
},
|
|
})
|
|
|
|
const trans = (info: ClientStatus | undefined) => {
|
|
let statusText: 'server.status_online' | 'server.status_offline' |
|
|
'server.status_error' | 'server.status_pause' |
|
|
'server.status_unknown' = 'server.status_unknown'
|
|
if (info === undefined) {
|
|
return statusText
|
|
}
|
|
if (info.status === ClientStatus_Status.ONLINE) {
|
|
statusText = 'server.status_online'
|
|
if (server.stopped) {
|
|
statusText = 'server.status_pause'
|
|
}
|
|
} else if (info.status === ClientStatus_Status.OFFLINE) {
|
|
statusText = 'server.status_offline'
|
|
} else if (info.status === ClientStatus_Status.ERROR) {
|
|
statusText = 'server.status_error'
|
|
}
|
|
return statusText
|
|
}
|
|
|
|
const infoColor =
|
|
clientsStatus?.clients[server.id]?.status === ClientStatus_Status.ONLINE ? (
|
|
server.stopped ? 'text-yellow-500' : 'text-green-500') : 'text-red-500'
|
|
|
|
return (
|
|
<div className="flex items-center gap-2 flex-row">
|
|
<Badge variant={"secondary"} className={`p-2 border rounded font-mono w-fit ${infoColor} text-nowrap rounded-full h-6`}>
|
|
{`${clientsStatus?.clients[server.id]?.ping}ms,${t(trans(clientsStatus?.clients[server.id]))}`}
|
|
</Badge>
|
|
{clientsStatus?.clients[server.id]?.version &&
|
|
<ClientDetail clientStatus={clientsStatus?.clients[server.id]} />
|
|
}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export const ServerSecret = ({ server }: { server: ServerTableSchema }) => {
|
|
const { t } = useTranslation()
|
|
const platformInfo = useStore($platformInfo)
|
|
|
|
if (!platformInfo) {
|
|
return (
|
|
<Button variant="link" className="px-0">
|
|
{server.secret}
|
|
</Button>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<Popover>
|
|
<PopoverTrigger asChild>
|
|
<div className="group relative cursor-pointer inline-block font-mono text-nowrap">
|
|
<span className="opacity-0 group-hover:opacity-100 transition-opacity duration-200">
|
|
{server.secret}
|
|
</span>
|
|
<span className="absolute inset-0 opacity-100 group-hover:opacity-0 transition-opacity duration-200">
|
|
{'*'.repeat(server.secret.length)}
|
|
</span>
|
|
</div>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="w-[32rem] max-w-[95vw]">
|
|
<div className="grid gap-4">
|
|
<div className="space-y-2">
|
|
<h4 className="font-medium leading-none">{t('server.start.title')}</h4>
|
|
<p className="text-sm text-muted-foreground">
|
|
{t('server.start.description')} (<a className='text-blue-500' href='https://github.com/VaalaCat/frp-panel/releases' target="_blank" rel="noopener noreferrer">{t('common.download')}</a>)
|
|
</p>
|
|
</div>
|
|
<div className="grid gap-2">
|
|
<pre className="bg-muted p-3 rounded-md font-mono text-sm overflow-x-auto whitespace-pre-wrap break-all">
|
|
{ExecCommandStr('server', server, platformInfo)}
|
|
</pre>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
className="w-full"
|
|
onClick={() => navigator.clipboard.writeText(ExecCommandStr('server', server, platformInfo))}
|
|
disabled={!platformInfo}
|
|
>
|
|
{t('common.copy')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
)
|
|
}
|
|
|
|
export interface ServerItemProps {
|
|
server: ServerTableSchema
|
|
table: Table<ServerTableSchema>
|
|
}
|
|
|
|
export const ServerActions: React.FC<ServerItemProps> = ({ server, table }) => {
|
|
const { t } = useTranslation()
|
|
const router = useRouter()
|
|
const platformInfo = useStore($platformInfo)
|
|
|
|
const removeServer = useMutation({
|
|
mutationFn: deleteServer,
|
|
onSuccess: () => {
|
|
toast(t('server.delete.success'))
|
|
$serverTableRefetchTrigger.set(Math.random())
|
|
},
|
|
onError: (e) => {
|
|
toast(t('server.delete.failed'), {
|
|
description: e.message,
|
|
})
|
|
$serverTableRefetchTrigger.set(Math.random())
|
|
},
|
|
})
|
|
|
|
const createAndDownloadFile = (fileName: string, content: string) => {
|
|
const aTag = document.createElement('a')
|
|
const blob = new Blob([content])
|
|
aTag.download = fileName
|
|
aTag.href = URL.createObjectURL(blob)
|
|
aTag.click()
|
|
URL.revokeObjectURL(aTag.href)
|
|
}
|
|
|
|
return (
|
|
<Dialog>
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button variant="ghost" className="h-8 w-8 p-0">
|
|
<span className="sr-only">{t('server.actions_menu.open_menu')}</span>
|
|
<MoreHorizontal className="h-4 w-4" />
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end">
|
|
<DropdownMenuLabel>{t('server.actions_menu.title')}</DropdownMenuLabel>
|
|
<DropdownMenuItem
|
|
onClick={() => {
|
|
try {
|
|
if (platformInfo) {
|
|
navigator.clipboard.writeText(ExecCommandStr('server', server, platformInfo))
|
|
toast(t('server.actions_menu.copy_success'))
|
|
} else {
|
|
toast(t('server.actions_menu.copy_failed'))
|
|
}
|
|
} catch (error) {
|
|
toast(t('server.actions_menu.copy_failed'))
|
|
}
|
|
}}
|
|
>
|
|
{t('server.actions_menu.copy_command')}
|
|
</DropdownMenuItem>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem
|
|
onClick={() => {
|
|
router.push({ pathname: '/serveredit', query: { serverID: server.id } })
|
|
}}
|
|
>
|
|
{t('server.actions_menu.edit_config')}
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem
|
|
onClick={() => {
|
|
try {
|
|
if (platformInfo) {
|
|
createAndDownloadFile('.env', ClientEnvFile(server, platformInfo))
|
|
}
|
|
} catch (error) {
|
|
toast(t('server.actions_menu.download_failed'), {
|
|
description: JSON.stringify(error),
|
|
})
|
|
}
|
|
}}
|
|
>
|
|
{t('server.actions_menu.download_config')}
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem
|
|
onClick={() => {
|
|
router.push({ pathname: '/streamlog', query: { serverID: server.id, clientType: ClientType.FRPS.toString() } })
|
|
}}
|
|
>
|
|
{t('server.actions_menu.realtime_log')}
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem
|
|
onClick={() => {
|
|
router.push({ pathname: '/console', query: { serverID: server.id, clientType: ClientType.FRPS.toString() } })
|
|
}}
|
|
>
|
|
{t('server.actions_menu.remote_terminal')}
|
|
</DropdownMenuItem>
|
|
<DialogTrigger asChild>
|
|
<DropdownMenuItem className="text-destructive">{t('server.actions_menu.delete')}</DropdownMenuItem>
|
|
</DialogTrigger>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>{t('server.delete.title')}</DialogTitle>
|
|
<DialogDescription>
|
|
<p className="text-destructive">{t('server.delete.description')}</p>
|
|
<p className="text-gray-500 border-l-4 border-gray-500 pl-4 py-2">
|
|
{t('server.delete.warning')}
|
|
</p>
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<DialogFooter>
|
|
<DialogClose asChild>
|
|
<Button type="submit" onClick={() => removeServer.mutate({ serverId: server.id })}>
|
|
{t('server.delete.confirm')}
|
|
</Button>
|
|
</DialogClose>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
)
|
|
}
|