mirror of
https://github.com/woodchen-ink/clash-and-dashboard.git
synced 2025-07-18 05:51:56 +08:00
Feature: add basic multi hosts support
This commit is contained in:
parent
e463b878b4
commit
45818c1273
@ -8,19 +8,19 @@ import './style.scss'
|
|||||||
interface ButtonProps extends BaseComponentProps {
|
interface ButtonProps extends BaseComponentProps {
|
||||||
type?: 'primary' | 'normal' | 'danger' | 'success' | 'warning'
|
type?: 'primary' | 'normal' | 'danger' | 'success' | 'warning'
|
||||||
onClick?: MouseEventHandler<HTMLButtonElement>
|
onClick?: MouseEventHandler<HTMLButtonElement>
|
||||||
disiabled?: boolean
|
disabled?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Button (props: ButtonProps) {
|
export function Button (props: ButtonProps) {
|
||||||
const { type = 'normal', onClick = noop, children, className, style, disiabled } = props
|
const { type = 'normal', onClick = noop, children, className, style, disabled } = props
|
||||||
const classname = classnames('button', `button-${type}`, className, { 'button-disabled': disiabled })
|
const classname = classnames('button', `button-${type}`, className, { 'button-disabled': disabled })
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
className={classname}
|
className={classname}
|
||||||
style={style}
|
style={style}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
disabled={disiabled}
|
disabled={disabled}
|
||||||
>{children}</button>
|
>{children}</button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -7,6 +7,7 @@
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 150ms ease;
|
transition: all 150ms ease;
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
&:focus {
|
&:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import classnames from 'classnames'
|
import classnames from 'classnames'
|
||||||
import { useLayoutEffect } from 'react'
|
import { useLayoutEffect } from 'react'
|
||||||
import { unmountComponentAtNode, render } from 'react-dom'
|
import { unmountComponentAtNode } from 'react-dom'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
|
||||||
import { Icon } from '@components'
|
import { Icon } from '@components'
|
||||||
import { noop } from '@lib/helper'
|
import { noop } from '@lib/helper'
|
||||||
@ -89,7 +90,7 @@ export function showMessage (args: ArgsProps) {
|
|||||||
onClose,
|
onClose,
|
||||||
}
|
}
|
||||||
|
|
||||||
render(<Message {...props} />, container)
|
createRoot(container).render(<Message {...props} />)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const info = (
|
export const info = (
|
||||||
|
@ -27,6 +27,9 @@ interface ModalProps extends BaseComponentProps {
|
|||||||
// show footer
|
// show footer
|
||||||
footer?: boolean
|
footer?: boolean
|
||||||
|
|
||||||
|
// footer extra
|
||||||
|
footerExtra?: React.ReactNode
|
||||||
|
|
||||||
// on click ok
|
// on click ok
|
||||||
onOk?: typeof noop
|
onOk?: typeof noop
|
||||||
|
|
||||||
@ -45,6 +48,7 @@ export function Modal (props: ModalProps) {
|
|||||||
bodyClassName,
|
bodyClassName,
|
||||||
bodyStyle,
|
bodyStyle,
|
||||||
className,
|
className,
|
||||||
|
footerExtra,
|
||||||
style,
|
style,
|
||||||
children,
|
children,
|
||||||
} = props
|
} = props
|
||||||
@ -84,9 +88,12 @@ export function Modal (props: ModalProps) {
|
|||||||
>{children}</div>
|
>{children}</div>
|
||||||
{
|
{
|
||||||
footer && (
|
footer && (
|
||||||
<div className="footer">
|
<div className="flex items-center justify-between">
|
||||||
<Button onClick={() => onClose()}>{ t('cancel') }</Button>
|
{footerExtra}
|
||||||
<Button type="primary" onClick={() => onOk()}>{ t('ok') }</Button>
|
<div className="flex flex-1 justify-end space-x-3">
|
||||||
|
<Button onClick={() => onClose()}>{ t('cancel') }</Button>
|
||||||
|
<Button type="primary" onClick={() => onOk()}>{ t('ok') }</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -47,18 +47,6 @@ $mobileBigWidth: 480px;
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: $color-primary-darken;
|
color: $color-primary-darken;
|
||||||
}
|
}
|
||||||
|
|
||||||
.footer {
|
|
||||||
width: 100%;
|
|
||||||
margin: 5px 0;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: flex-end;
|
|
||||||
|
|
||||||
.button {
|
|
||||||
margin-left: 10px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-small {
|
.modal-small {
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import classnames from 'classnames'
|
import classnames from 'classnames'
|
||||||
import { useRef, useState, useMemo, useLayoutEffect } from 'react'
|
import { useRef, useState, useMemo, useLayoutEffect, ReactNode } from 'react'
|
||||||
import { createPortal } from 'react-dom'
|
import { createPortal } from 'react-dom'
|
||||||
|
|
||||||
import { Icon } from '@components'
|
import { Icon } from '@components'
|
||||||
@ -9,7 +9,7 @@ import { BaseComponentProps } from '@models'
|
|||||||
import './style.scss'
|
import './style.scss'
|
||||||
|
|
||||||
export interface SelectOptions<T extends string | number> {
|
export interface SelectOptions<T extends string | number> {
|
||||||
label: string
|
label: ReactNode
|
||||||
value: T
|
value: T
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
key?: React.Key
|
key?: React.Key
|
||||||
@ -37,13 +37,6 @@ export function Select<T extends string | number> (props: SelectProps<T>) {
|
|||||||
|
|
||||||
const [showDropDownList, setShowDropDownList] = useState(false)
|
const [showDropDownList, setShowDropDownList] = useState(false)
|
||||||
const [dropdownListStyles, setDropdownListStyles] = useState<React.CSSProperties>({})
|
const [dropdownListStyles, setDropdownListStyles] = useState<React.CSSProperties>({})
|
||||||
useLayoutEffect(() => {
|
|
||||||
const targetRectInfo = targetRef.current!.getBoundingClientRect()
|
|
||||||
setDropdownListStyles({
|
|
||||||
top: Math.floor(targetRectInfo.top + targetRectInfo.height) + 6,
|
|
||||||
left: Math.floor(targetRectInfo.left) - 10,
|
|
||||||
})
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const current = portalRef.current
|
const current = portalRef.current
|
||||||
@ -57,6 +50,14 @@ export function Select<T extends string | number> (props: SelectProps<T>) {
|
|||||||
if (disabled) {
|
if (disabled) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!showDropDownList) {
|
||||||
|
const targetRectInfo = targetRef.current!.getBoundingClientRect()
|
||||||
|
setDropdownListStyles({
|
||||||
|
top: Math.floor(targetRectInfo.top + targetRectInfo.height) + 6,
|
||||||
|
left: Math.floor(targetRectInfo.left) - 10,
|
||||||
|
})
|
||||||
|
}
|
||||||
setShowDropDownList(!showDropDownList)
|
setShowDropDownList(!showDropDownList)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -98,7 +99,7 @@ export function Select<T extends string | number> (props: SelectProps<T>) {
|
|||||||
ref={targetRef}
|
ref={targetRef}
|
||||||
onClick={handleShowDropList}
|
onClick={handleShowDropList}
|
||||||
>
|
>
|
||||||
{matchChild?.label}
|
<span className="select-none">{matchChild?.label}</span>
|
||||||
<Icon type="triangle-down" />
|
<Icon type="triangle-down" />
|
||||||
</div>
|
</div>
|
||||||
{createPortal(dropDownList, portalRef.current)}
|
{createPortal(dropDownList, portalRef.current)}
|
||||||
|
@ -30,6 +30,7 @@
|
|||||||
background: $color-white;
|
background: $color-white;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
transition: all 200ms ease;
|
transition: all 200ms ease;
|
||||||
|
border-radius: 4px;
|
||||||
|
|
||||||
> .option {
|
> .option {
|
||||||
color: $color-primary-darken;
|
color: $color-primary-darken;
|
||||||
|
@ -302,7 +302,7 @@ export default function Connections () {
|
|||||||
</div>
|
</div>
|
||||||
<ConnectionInfo className="mt-3 px-5" connection={drawerState.connection} />
|
<ConnectionInfo className="mt-3 px-5" connection={drawerState.connection} />
|
||||||
<div className="mt-3 flex justify-end pr-3">
|
<div className="mt-3 flex justify-end pr-3">
|
||||||
<Button type="danger" disiabled={drawerState.connection.completed} onClick={() => handleConnectionClosed()}>{ t('info.closeConnection') }</Button>
|
<Button type="danger" disabled={drawerState.connection.completed} onClick={() => handleConnectionClosed()}>{ t('info.closeConnection') }</Button>
|
||||||
</div>
|
</div>
|
||||||
</Drawer>
|
</Drawer>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,11 +1,10 @@
|
|||||||
import { useAtom } from 'jotai'
|
import { useAtom, useSetAtom } from 'jotai'
|
||||||
import { useUpdateAtom } from 'jotai/utils'
|
|
||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
|
|
||||||
import { Modal, Input, Alert } from '@components'
|
import { Modal, Input, Alert, Button, error } from '@components'
|
||||||
import { useObject } from '@lib/hook'
|
import { useObject } from '@lib/hook'
|
||||||
import { useI18n, useAPIInfo, identityAtom } from '@stores'
|
import { useI18n, useAPIInfo, identityAtom } from '@stores'
|
||||||
import { localStorageAtom } from '@stores/request'
|
import { hostSelectIdxStorageAtom, hostsStorageAtom } from '@stores/request'
|
||||||
import './style.scss'
|
import './style.scss'
|
||||||
|
|
||||||
export default function ExternalController () {
|
export default function ExternalController () {
|
||||||
@ -23,18 +22,50 @@ export default function ExternalController () {
|
|||||||
set({ hostname, port, secret })
|
set({ hostname, port, secret })
|
||||||
}, [hostname, port, secret, set])
|
}, [hostname, port, secret, set])
|
||||||
|
|
||||||
const setter = useUpdateAtom(localStorageAtom)
|
const [hosts, setter] = useAtom(hostsStorageAtom)
|
||||||
|
const [hostSelectIdx, setHostSelectIdx] = useAtom(hostSelectIdxStorageAtom)
|
||||||
|
|
||||||
function handleOk () {
|
function handleOk () {
|
||||||
const { hostname, port, secret } = value
|
const { hostname, port, secret } = value
|
||||||
setter([{ hostname, port, secret }])
|
setter([{ hostname, port, secret }])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleAdd () {
|
||||||
|
const { hostname, port, secret } = value
|
||||||
|
const nextHosts = [...hosts, { hostname, port, secret }]
|
||||||
|
setter(nextHosts)
|
||||||
|
setHostSelectIdx(nextHosts.length - 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDelete () {
|
||||||
|
const { hostname, port } = value
|
||||||
|
const idx = hosts.findIndex(h => h.hostname === hostname && h.port === port)
|
||||||
|
if (idx === -1) {
|
||||||
|
error(t('externalControllerSetting.deleteErrorText'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextHosts = [...hosts.slice(0, idx), ...hosts.slice(idx + 1)]
|
||||||
|
setter(nextHosts)
|
||||||
|
if (hostSelectIdx >= idx) {
|
||||||
|
setHostSelectIdx(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const footerExtra = (
|
||||||
|
<div className="space-x-3">
|
||||||
|
<Button type="primary" onClick={() => handleAdd()}>{ t('externalControllerSetting.addText') }</Button>
|
||||||
|
<Button type="danger" disabled={hosts.length < 2} onClick={() => handleDelete()}>{ t('externalControllerSetting.deleteText') }</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
|
className="!w-105 !<sm:w-84"
|
||||||
show={!identity}
|
show={!identity}
|
||||||
title={t('externalControllerSetting.title')}
|
title={t('externalControllerSetting.title')}
|
||||||
bodyClassName="external-controller"
|
bodyClassName="external-controller"
|
||||||
|
footerExtra={footerExtra}
|
||||||
onClose={() => setIdentity(true)}
|
onClose={() => setIdentity(true)}
|
||||||
onOk={handleOk}
|
onOk={handleOk}
|
||||||
>
|
>
|
||||||
|
@ -1,13 +1,13 @@
|
|||||||
import classnames from 'classnames'
|
import classnames from 'classnames'
|
||||||
import { useUpdateAtom } from 'jotai/utils'
|
import { useAtom, useAtomValue, useSetAtom } from 'jotai'
|
||||||
import { capitalize } from 'lodash-es'
|
import { capitalize } from 'lodash-es'
|
||||||
import { useEffect, useMemo } from 'react'
|
import { useEffect, useMemo } from 'react'
|
||||||
|
|
||||||
import { Header, Card, Switch, ButtonSelect, ButtonSelectOptions, Input } from '@components'
|
import { Header, Card, Switch, ButtonSelect, ButtonSelectOptions, Input, Select } from '@components'
|
||||||
import { Lang } from '@i18n'
|
import { Lang } from '@i18n'
|
||||||
import { useObject } from '@lib/hook'
|
import { useObject } from '@lib/hook'
|
||||||
import { jsBridge } from '@lib/jsBridge'
|
import { jsBridge } from '@lib/jsBridge'
|
||||||
import { useI18n, useClashXData, useAPIInfo, useGeneral, useVersion, useClient, identityAtom } from '@stores'
|
import { useI18n, useClashXData, useAPIInfo, useGeneral, useVersion, useClient, identityAtom, hostSelectIdxStorageAtom, hostsStorageAtom } from '@stores'
|
||||||
import './style.scss'
|
import './style.scss'
|
||||||
|
|
||||||
const languageOptions: ButtonSelectOptions[] = [{ label: '中文', value: 'zh_CN' }, { label: 'English', value: 'en_US' }]
|
const languageOptions: ButtonSelectOptions[] = [{ label: '中文', value: 'zh_CN' }, { label: 'English', value: 'en_US' }]
|
||||||
@ -16,7 +16,9 @@ export default function Settings () {
|
|||||||
const { premium } = useVersion()
|
const { premium } = useVersion()
|
||||||
const { data: clashXData, update: fetchClashXData } = useClashXData()
|
const { data: clashXData, update: fetchClashXData } = useClashXData()
|
||||||
const { general, update: fetchGeneral } = useGeneral()
|
const { general, update: fetchGeneral } = useGeneral()
|
||||||
const setIdentity = useUpdateAtom(identityAtom)
|
const setIdentity = useSetAtom(identityAtom)
|
||||||
|
const [hostSelectIdx, setHostSelectIdx] = useAtom(hostSelectIdxStorageAtom)
|
||||||
|
const hostsStorage = useAtomValue(hostsStorageAtom)
|
||||||
const apiInfo = useAPIInfo()
|
const apiInfo = useAPIInfo()
|
||||||
const { translation, setLang, lang } = useI18n()
|
const { translation, setLang, lang } = useI18n()
|
||||||
const { t } = translation('Settings')
|
const { t } = translation('Settings')
|
||||||
@ -95,6 +97,10 @@ export default function Settings () {
|
|||||||
return options
|
return options
|
||||||
}, [t, premium])
|
}, [t, premium])
|
||||||
|
|
||||||
|
const controllerOptions = hostsStorage.map(
|
||||||
|
(h, idx) => ({ value: idx, label: <span className="truncate text-right">{h.hostname}</span> }),
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page">
|
<div className="page">
|
||||||
<Header title={t('title')} />
|
<Header title={t('title')} />
|
||||||
@ -171,11 +177,19 @@ export default function Settings () {
|
|||||||
<div className="flex flex-wrap">
|
<div className="flex flex-wrap">
|
||||||
<div className="flex w-full items-center justify-between py-3 px-8 md:w-1/2">
|
<div className="flex w-full items-center justify-between py-3 px-8 md:w-1/2">
|
||||||
<span className="label font-bold">{t('labels.externalController')}</span>
|
<span className="label font-bold">{t('labels.externalController')}</span>
|
||||||
<span
|
<div className="flex items-center space-x-2">
|
||||||
className={classnames({ 'modify-btn': !isClashX }, 'external-controller')}
|
<Select
|
||||||
onClick={() => !isClashX && setIdentity(false)}>
|
disabled={hostsStorage.length < 2 && !isClashX}
|
||||||
{`${externalControllerHost}:${externalControllerPort}`}
|
options={controllerOptions}
|
||||||
</span>
|
value={hostSelectIdx}
|
||||||
|
onSelect={idx => setHostSelectIdx(idx)}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className={classnames({ 'modify-btn': !isClashX }, 'external-controller')}
|
||||||
|
onClick={() => !isClashX && setIdentity(false)}>
|
||||||
|
编辑
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-1/2 px-8"></div>
|
<div className="w-1/2 px-8"></div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -37,6 +37,9 @@ const EN = {
|
|||||||
host: 'Host',
|
host: 'Host',
|
||||||
port: 'Port',
|
port: 'Port',
|
||||||
secret: 'Secret',
|
secret: 'Secret',
|
||||||
|
addText: 'Add',
|
||||||
|
deleteText: 'Delete',
|
||||||
|
deleteErrorText: 'Host not found',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Logs: {
|
Logs: {
|
||||||
|
@ -37,6 +37,9 @@ const CN = {
|
|||||||
host: 'Host',
|
host: 'Host',
|
||||||
port: '端口',
|
port: '端口',
|
||||||
secret: '密钥',
|
secret: '密钥',
|
||||||
|
addText: '添 加',
|
||||||
|
deleteText: '删 除',
|
||||||
|
deleteErrorText: '没有找到该 Host',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Logs: {
|
Logs: {
|
||||||
|
@ -1,9 +1,9 @@
|
|||||||
import { usePreviousDistinct, useSyncedRef } from '@react-hookz/web/esm'
|
import { usePreviousDistinct, useSyncedRef } from '@react-hookz/web/esm'
|
||||||
import { AxiosError } from 'axios'
|
import { AxiosError } from 'axios'
|
||||||
import produce from 'immer'
|
import produce from 'immer'
|
||||||
import { atom, useAtom, useAtomValue } from 'jotai'
|
import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai'
|
||||||
import { atomWithImmer } from 'jotai-immer'
|
import { atomWithImmer } from 'jotai-immer'
|
||||||
import { atomWithStorage, useUpdateAtom } from 'jotai/utils'
|
import { atomWithStorage } from 'jotai/utils'
|
||||||
import { get } from 'lodash-es'
|
import { get } from 'lodash-es'
|
||||||
import { ResultAsync } from 'neverthrow'
|
import { ResultAsync } from 'neverthrow'
|
||||||
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||||
@ -52,7 +52,7 @@ export const version = atom({
|
|||||||
export function useVersion () {
|
export function useVersion () {
|
||||||
const [data, set] = useAtom(version)
|
const [data, set] = useAtom(version)
|
||||||
const client = useClient()
|
const client = useClient()
|
||||||
const setIdentity = useUpdateAtom(identityAtom)
|
const setIdentity = useSetAtom(identityAtom)
|
||||||
|
|
||||||
useSWR([client], async function () {
|
useSWR([client], async function () {
|
||||||
const result = await ResultAsync.fromPromise(client.getVersion(), e => e as AxiosError)
|
const result = await ResultAsync.fromPromise(client.getVersion(), e => e as AxiosError)
|
||||||
|
@ -19,16 +19,18 @@ const clashxConfigAtom = atom(async () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
export const localStorageAtom = atomWithStorage<Array<{
|
export const hostsStorageAtom = atomWithStorage<Array<{
|
||||||
hostname: string
|
hostname: string
|
||||||
port: string
|
port: string
|
||||||
secret: string
|
secret: string
|
||||||
}>>('externalControllers', [])
|
}>>('externalControllers', [])
|
||||||
|
export const hostSelectIdxStorageAtom = atomWithStorage<number>('externalControllerIndex', 0)
|
||||||
|
|
||||||
export function useAPIInfo () {
|
export function useAPIInfo () {
|
||||||
const clashx = useAtomValue(clashxConfigAtom)
|
const clashx = useAtomValue(clashxConfigAtom)
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const localStorage = useAtomValue(localStorageAtom)
|
const hostSelectIdxStorage = useAtomValue(hostSelectIdxStorageAtom)
|
||||||
|
const hostsStorage = useAtomValue(hostsStorageAtom)
|
||||||
|
|
||||||
if (clashx != null) {
|
if (clashx != null) {
|
||||||
return clashx
|
return clashx
|
||||||
@ -45,9 +47,9 @@ export function useAPIInfo () {
|
|||||||
|
|
||||||
const qs = new URLSearchParams(location.search)
|
const qs = new URLSearchParams(location.search)
|
||||||
|
|
||||||
const hostname = qs.get('host') ?? localStorage?.[0]?.hostname ?? url?.hostname ?? '127.0.0.1'
|
const hostname = qs.get('host') ?? hostsStorage?.[hostSelectIdxStorage]?.hostname ?? url?.hostname ?? '127.0.0.1'
|
||||||
const port = qs.get('port') ?? localStorage?.[0]?.port ?? url?.port ?? '9090'
|
const port = qs.get('port') ?? hostsStorage?.[hostSelectIdxStorage]?.port ?? url?.port ?? '9090'
|
||||||
const secret = qs.get('secret') ?? localStorage?.[0]?.secret ?? url?.username ?? ''
|
const secret = qs.get('secret') ?? hostsStorage?.[hostSelectIdxStorage]?.secret ?? url?.username ?? ''
|
||||||
const protocol = qs.get('protocol') ?? hostname === '127.0.0.1' ? 'http:' : (url?.protocol ?? window.location.protocol)
|
const protocol = qs.get('protocol') ?? hostname === '127.0.0.1' ? 'http:' : (url?.protocol ?? window.location.protocol)
|
||||||
|
|
||||||
return { hostname, port, secret, protocol }
|
return { hostname, port, secret, protocol }
|
||||||
|
Loading…
x
Reference in New Issue
Block a user