HH-803: add stable network exit orchestration (#19)
This commit is contained in:
+33
-11
@@ -17,7 +17,6 @@ import {
|
||||
MenuItem,
|
||||
Paper,
|
||||
Stack,
|
||||
Switch,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
@@ -54,6 +53,8 @@ const emptyForm = {
|
||||
alias: '',
|
||||
gateway: '',
|
||||
image_version: '',
|
||||
account_id: '',
|
||||
network_exit_id: '',
|
||||
seed: '1000',
|
||||
platform: '',
|
||||
platform_version: '',
|
||||
@@ -63,8 +64,6 @@ const emptyForm = {
|
||||
lang: '',
|
||||
accept_lang: '',
|
||||
timezone: '',
|
||||
proxy_server: '',
|
||||
disable_non_proxied_udp: false,
|
||||
disable_spoofing: [],
|
||||
}
|
||||
|
||||
@@ -78,13 +77,11 @@ function buildFingerprint(form) {
|
||||
lang: form.lang,
|
||||
accept_lang: form.accept_lang,
|
||||
timezone: form.timezone,
|
||||
proxy_server: form.proxy_server,
|
||||
}
|
||||
for (const [key, value] of Object.entries(optionalText)) {
|
||||
if (value !== '') fingerprint[key] = value
|
||||
}
|
||||
if (form.hardware_concurrency !== '') fingerprint.hardware_concurrency = Number(form.hardware_concurrency)
|
||||
if (form.disable_non_proxied_udp) fingerprint.disable_non_proxied_udp = true
|
||||
if (form.disable_spoofing.length > 0) fingerprint.disable_spoofing = form.disable_spoofing.join(',')
|
||||
return fingerprint
|
||||
}
|
||||
@@ -117,10 +114,14 @@ function Copyable({ value }) {
|
||||
)
|
||||
}
|
||||
|
||||
function CreateForm({ gateways, images, onSubmit, busy }) {
|
||||
function CreateForm({ gateways, images, accounts, networkExits, onSubmit, busy }) {
|
||||
const [form, setForm] = useState(emptyForm)
|
||||
const [advanced, setAdvanced] = useState(false)
|
||||
const enabledImages = images.filter(image => image.enabled)
|
||||
const availableAccounts = accounts.filter(account => account.authorization_status === 'authorized' && account.runtime_status === 'paused')
|
||||
const healthyExits = networkExits.filter(exit => exit.health_status === 'healthy')
|
||||
const defaultAccountID = availableAccounts[0]?.id ?? ''
|
||||
const defaultExitID = healthyExits[0]?.id ?? ''
|
||||
const update = (key, value) => setForm(current => ({ ...current, [key]: value }))
|
||||
const toggleSpoofing = option => setForm(current => ({
|
||||
...current,
|
||||
@@ -135,10 +136,17 @@ function CreateForm({ gateways, images, onSubmit, busy }) {
|
||||
useEffect(() => {
|
||||
if (form.image_version === '' && enabledImages.length > 0) update('image_version', enabledImages[0].version)
|
||||
}, [enabledImages, form.image_version])
|
||||
useEffect(() => {
|
||||
if (form.account_id === '' && defaultAccountID !== '') update('account_id', defaultAccountID)
|
||||
}, [defaultAccountID, form.account_id])
|
||||
useEffect(() => {
|
||||
if (form.network_exit_id === '' && defaultExitID !== '') update('network_exit_id', defaultExitID)
|
||||
}, [defaultExitID, form.network_exit_id])
|
||||
|
||||
const seedNumber = Number(form.seed)
|
||||
const valid = form.name.trim() !== '' && aliasPattern.test(form.alias) && form.gateway !== '' &&
|
||||
form.image_version !== '' && Number.isInteger(seedNumber) && seedNumber >= 1 && seedNumber <= 2147483647
|
||||
form.image_version !== '' && form.account_id !== '' && form.network_exit_id !== '' &&
|
||||
Number.isInteger(seedNumber) && seedNumber >= 1 && seedNumber <= 2147483647
|
||||
|
||||
const submit = event => {
|
||||
event.preventDefault()
|
||||
@@ -148,9 +156,11 @@ function CreateForm({ gateways, images, onSubmit, busy }) {
|
||||
name: form.name.trim(),
|
||||
gateway: form.gateway,
|
||||
image_version: form.image_version,
|
||||
account_id: form.account_id,
|
||||
network_exit_id: form.network_exit_id,
|
||||
fingerprint: buildFingerprint(form),
|
||||
})
|
||||
setForm(current => ({ ...emptyForm, gateway: current.gateway, image_version: current.image_version }))
|
||||
setForm(current => ({ ...emptyForm, gateway: current.gateway, image_version: current.image_version, account_id: current.account_id, network_exit_id: current.network_exit_id }))
|
||||
}
|
||||
|
||||
const label = (htmlFor, text, required = false) => (
|
||||
@@ -185,6 +195,18 @@ function CreateForm({ gateways, images, onSubmit, busy }) {
|
||||
{label('env-seed', 'Fingerprint Seed', true)}
|
||||
<TextField id="env-seed" required type="number" slotProps={{ htmlInput: { 'aria-label': 'Fingerprint Seed', min: 1, max: 2147483647 } }} value={form.seed} onChange={event => update('seed', event.target.value)} />
|
||||
</Stack>
|
||||
<Stack spacing={1}>
|
||||
{label('env-account', '社媒账号', true)}
|
||||
<TextField id="env-account" select required slotProps={{ htmlInput: { 'aria-label': '社媒账号' } }} value={form.account_id} onChange={event => update('account_id', event.target.value)} disabled={availableAccounts.length === 0} helperText={availableAccounts.length === 0 ? '请先准备已授权且暂停的账号' : ' '}>
|
||||
{availableAccounts.map(account => <MenuItem key={account.id} value={account.id}>{account.id} · {account.platform}</MenuItem>)}
|
||||
</TextField>
|
||||
</Stack>
|
||||
<Stack spacing={1}>
|
||||
{label('env-exit', '网络出口', true)}
|
||||
<TextField id="env-exit" select required slotProps={{ htmlInput: { 'aria-label': '网络出口' } }} value={form.network_exit_id} onChange={event => update('network_exit_id', event.target.value)} disabled={healthyExits.length === 0} helperText={healthyExits.length === 0 ? '请先检查并启用健康出口' : ' '}>
|
||||
{healthyExits.map(exit => <MenuItem key={exit.id} value={exit.id}>{exit.id} · {exit.protocol}://{exit.host}:{exit.port}</MenuItem>)}
|
||||
</TextField>
|
||||
</Stack>
|
||||
|
||||
<Stack spacing={1} sx={{ gridColumn: { md: '1 / -1' } }}>
|
||||
<Button type="button" variant="text" onClick={() => setAdvanced(value => !value)} endIcon={<ExpandMoreOutlined sx={{ transform: advanced ? 'rotate(180deg)' : 'none', transition: theme => theme.transitions.create('transform') }} />} sx={{ justifySelf: 'start' }}>
|
||||
@@ -206,8 +228,6 @@ function CreateForm({ gateways, images, onSubmit, busy }) {
|
||||
<TextField label="语言 lang" value={form.lang} onChange={event => update('lang', event.target.value)} slotProps={{ htmlInput: { 'aria-label': '语言 lang' } }} placeholder="如 zh-CN" />
|
||||
<TextField label="接受语言 accept-lang" value={form.accept_lang} onChange={event => update('accept_lang', event.target.value)} slotProps={{ htmlInput: { 'aria-label': '接受语言 accept-lang' } }} placeholder="如 zh-CN,en-US" />
|
||||
<TextField label="时区 timezone" value={form.timezone} onChange={event => update('timezone', event.target.value)} slotProps={{ htmlInput: { 'aria-label': '时区 timezone' } }} placeholder="如 Asia/Shanghai" />
|
||||
<TextField label="代理 proxy-server" value={form.proxy_server} onChange={event => update('proxy_server', event.target.value)} slotProps={{ htmlInput: { 'aria-label': '代理 proxy-server' } }} placeholder="如 socks5://127.0.0.1:1080" />
|
||||
<FormControlLabel control={<Switch checked={form.disable_non_proxied_udp} onChange={event => update('disable_non_proxied_udp', event.target.checked)} slotProps={{ input: { 'aria-label': '禁用非代理 UDP' } }} />} label="禁用非代理 UDP(WebRTC)" sx={{ gridColumn: { md: 'span 2' } }} />
|
||||
<Stack spacing={0.5}>
|
||||
<Typography variant="body2" fontWeight={650}>禁用指纹伪装 disable-spoofing</Typography>
|
||||
<Stack direction="row" spacing={0.5} useFlexGap sx={{ flexWrap: 'wrap' }}>
|
||||
@@ -283,6 +303,8 @@ export function BrowserList() {
|
||||
const { data: runtimes = [], error, isPending, refetch } = useGetList('browsers', {}, { refetchInterval: 3000 })
|
||||
const { data: gateways = [] } = useGetList('gateways')
|
||||
const { data: images = [] } = useGetList('browser-images')
|
||||
const { data: accounts = [] } = useGetList('accounts')
|
||||
const { data: networkExits = [] } = useGetList('network-exits')
|
||||
|
||||
useEffect(() => {
|
||||
document.title = 'CreatorHub · 运行环境'
|
||||
@@ -335,7 +357,7 @@ export function BrowserList() {
|
||||
<>
|
||||
<Box component="header" sx={{ mb: 4 }}><Typography variant="h1">运行环境</Typography><Typography color="text.secondary" sx={{ mt: 1, fontSize: '1.05rem' }}>启动、停止、升级并回收隔离的指纹浏览器</Typography></Box>
|
||||
|
||||
<CreateForm gateways={gateways} images={images} onSubmit={createRuntime} busy={busy === 'create'} />
|
||||
<CreateForm gateways={gateways} images={images} accounts={accounts} networkExits={networkExits} onSubmit={createRuntime} busy={busy === 'create'} />
|
||||
|
||||
{message ? <Alert severity="error" action={<Button color="inherit" size="small" onClick={() => { setLocalError(''); refetch() }}>重试</Button>} sx={{ mb: 2.5 }}>{message}</Alert> : null}
|
||||
{isPending ? <Box sx={{ display: 'grid', placeItems: 'center', minHeight: 220 }}><CircularProgress aria-label="正在加载运行环境" /></Box> : null}
|
||||
|
||||
@@ -16,6 +16,8 @@ const images = [
|
||||
{ id: '144.0.0.1', version: '144.0.0.1', image_ref: 'reg/img:144', enabled: true },
|
||||
{ id: '139.0.0.1', version: '139.0.0.1', image_ref: 'reg/img:139', enabled: false },
|
||||
]
|
||||
const accounts = [{ id: 'social-a', platform: 'douyin', authorization_status: 'authorized', runtime_status: 'paused' }]
|
||||
const networkExits = [{ id: 'exit-1', protocol: 'socks5', host: 'proxy.example', port: 1080, health_status: 'healthy' }]
|
||||
|
||||
function provider(overrides = {}) {
|
||||
return {
|
||||
@@ -23,6 +25,8 @@ function provider(overrides = {}) {
|
||||
if (resource === 'browsers') return Promise.resolve({ data: runtimes, total: runtimes.length })
|
||||
if (resource === 'gateways') return Promise.resolve({ data: gateways, total: gateways.length })
|
||||
if (resource === 'browser-images') return Promise.resolve({ data: images, total: images.length })
|
||||
if (resource === 'accounts') return Promise.resolve({ data: accounts, total: accounts.length })
|
||||
if (resource === 'network-exits') return Promise.resolve({ data: networkExits, total: networkExits.length })
|
||||
return Promise.reject(new Error(`unsupported ${resource}`))
|
||||
}),
|
||||
create: vi.fn().mockResolvedValue({ data: { id: 'account-a', alias: 'account-a' } }),
|
||||
@@ -77,7 +81,7 @@ describe('BrowserList', () => {
|
||||
await waitFor(() => expect(dataProvider.browserAction).toHaveBeenCalledWith('account-a', 'stop', undefined))
|
||||
})
|
||||
|
||||
it('creates an env with chinese name, alias, gateway and enabled image version', async () => {
|
||||
it('creates an env with an account and healthy network exit without legacy proxy fields', async () => {
|
||||
const dataProvider = provider()
|
||||
render(<CoreAdminContext dataProvider={dataProvider}><BrowserList /></CoreAdminContext>)
|
||||
await screen.findAllByText('店铺一号')
|
||||
@@ -91,8 +95,12 @@ describe('BrowserList', () => {
|
||||
name: '店铺三号',
|
||||
gateway: 'gw-1',
|
||||
image_version: '148.0.0.1',
|
||||
account_id: 'social-a',
|
||||
network_exit_id: 'exit-1',
|
||||
fingerprint: { seed: 1000 },
|
||||
} }))
|
||||
expect(screen.queryByLabelText('代理 proxy-server')).toBeNull()
|
||||
expect(screen.queryByLabelText('禁用非代理 UDP')).toBeNull()
|
||||
})
|
||||
|
||||
it('upgrades an env through the version dialog', async () => {
|
||||
|
||||
@@ -21,6 +21,8 @@ const resourcePaths = {
|
||||
browsers: '/browsers',
|
||||
'browser-images': '/browser-images',
|
||||
gateways: '/gateways',
|
||||
accounts: '/phase-a/accounts',
|
||||
'network-exits': '/network-exits',
|
||||
}
|
||||
|
||||
export const dataProvider = {
|
||||
@@ -28,7 +30,7 @@ export const dataProvider = {
|
||||
const path = resourcePaths[resource]
|
||||
if (!path) return unsupported(resource, 'getList')
|
||||
const records = await request(path)
|
||||
return { data: records.map(record => ({ ...record, id: record.alias ?? record.version ?? record.name })), total: records.length }
|
||||
return { data: records.map(record => ({ ...record, id: record.id ?? record.alias ?? record.version ?? record.name })), total: records.length }
|
||||
},
|
||||
async create(resource, { data }) {
|
||||
const path = resourcePaths[resource]
|
||||
|
||||
@@ -15,6 +15,17 @@ describe('dataProvider', () => {
|
||||
expect(fetch).toHaveBeenCalledWith('/api/browsers', undefined)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['accounts', '/api/phase-a/accounts'],
|
||||
['network-exits', '/api/network-exits'],
|
||||
])('loads %s for the environment create contract', async (resource, path) => {
|
||||
const fetch = vi.fn().mockResolvedValue(new Response('[{"id":"record-1"}]', { status: 200 }))
|
||||
vi.stubGlobal('fetch', fetch)
|
||||
|
||||
await expect(dataProvider.getList(resource)).resolves.toMatchObject({ data: [{ id: 'record-1' }] })
|
||||
expect(fetch).toHaveBeenCalledWith(path, undefined)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['start', '/api/browsers/account-a/start', 'POST'],
|
||||
['stop', '/api/browsers/account-a/stop', 'POST'],
|
||||
|
||||
Reference in New Issue
Block a user