HH-803: add stable network exit orchestration (#19)

This commit is contained in:
2026-08-29 07:59:55 +08:00
parent 884505bb55
commit f1ccfa0096
25 changed files with 6082 additions and 368 deletions
+33 -11
View File
@@ -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}
+9 -1
View File
@@ -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 () => {
+3 -1
View File
@@ -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]
+11
View File
@@ -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'],