feat: support direct network exit for runtimes

This commit is contained in:
2026-09-01 19:12:48 +08:00
parent 5674cfe8c3
commit 3729ce910f
15 changed files with 259 additions and 144 deletions
+3 -3
View File
@@ -63,8 +63,8 @@ export function accountReadiness(account, binding, bindingError = false) {
if (!binding) return { label: '未绑定运行环境', reason: 'binding_missing', canResume: false, ready: false }
const resourceReason = binding.cleanup_pending
? 'runtime_stop_pending'
: binding.network_exit_health !== 'healthy'
? (binding.network_exit_id ? 'network_exit_unhealthy' : 'network_exit_missing')
: binding.network_exit_id && binding.network_exit_health !== 'healthy'
? 'network_exit_unhealthy'
: ''
if (resourceReason) return { label: reasonLabels[resourceReason], reason: resourceReason, canResume: false, ready: false }
if (account.runtime_status === 'paused') {
@@ -234,7 +234,7 @@ export function AccountDetail() {
<Alert severity="warning" icon={<PauseCircleOutlined />} sx={{ mb: 2.5 }}>暂停会把待领取任务置为 hold恢复账号只恢复账号可用性不会自动恢复既有 hold</Alert>
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: 'minmax(0, 1fr)', lg: 'repeat(2, minmax(0, 1fr))' }, gap: 2.5 }}>
<Paper variant="outlined" sx={{ p: 3, minWidth: 0 }}><Stack spacing={2} sx={{ minWidth: 0 }}><Typography variant="h6">账号状态</Typography><AccountState account={account} binding={binding} bindingError={browsersError} /><Typography>授权{account.authorization_status === 'authorized' ? '已授权' : '已撤销'}{account.authorization_kind}</Typography><Typography>运行{account.runtime_status === 'active' ? '启用' : '暂停'} · 版本 {account.version}</Typography><Typography sx={wrapAnywhere}>凭据引用{account.credential_reference?.id} · {account.credential_reference?.provider}</Typography><Stack direction={{ xs: 'column', sm: 'row' }} spacing={1.5}><Button variant="outlined" color="warning" disabled={busy || account.runtime_status === 'paused'} onClick={() => act('pause')}>暂停账号</Button><Button variant="contained" disabled={busy || !readiness.canResume} onClick={() => act('resume')}>恢复账号</Button></Stack></Stack></Paper>
<Paper variant="outlined" sx={{ p: 3, minWidth: 0 }}><Stack spacing={2} sx={{ minWidth: 0 }}><Typography variant="h6">固定资源</Typography>{browsersError ? <Typography color="text.secondary">运行环境固定出口与 readiness 状态未知重试成功后再执行依赖资源状态的操作</Typography> : binding ? <><Typography sx={wrapAnywhere}>运行环境{binding.name}{binding.alias}</Typography><Typography sx={wrapAnywhere}>固定出口{binding.network_exit_id || '未绑定'} · {binding.network_exit_health || '未知状态'}</Typography><Typography>绑定版本{binding.binding_version}</Typography><Typography sx={wrapAnywhere}>不可调度原因{binding.schedule_block_reason ? (reasonLabels[binding.schedule_block_reason] || binding.schedule_block_reason) : '无'}</Typography><Button component={RouterLink} to="/browsers" variant="outlined">查看运行环境</Button></> : <><Typography color="text.secondary">尚未绑定运行环境与固定出口因此不能恢复或排队</Typography><Button component={RouterLink} to="/browsers" variant="contained">绑定运行环境与出口</Button></>}</Stack></Paper>
<Paper variant="outlined" sx={{ p: 3, minWidth: 0 }}><Stack spacing={2} sx={{ minWidth: 0 }}><Typography variant="h6">固定资源</Typography>{browsersError ? <Typography color="text.secondary">运行环境与网络出口状态未知重试成功后再执行依赖资源状态的操作</Typography> : binding ? <><Typography sx={wrapAnywhere}>运行环境{binding.name}{binding.alias}</Typography><Typography sx={wrapAnywhere}>网络出口{binding.network_exit_id ? `${binding.network_exit_id} · ${binding.network_exit_health || '未知状态'}` : '当前机器直连'}</Typography><Typography>绑定版本{binding.binding_version}</Typography><Typography sx={wrapAnywhere}>不可调度原因{binding.schedule_block_reason ? (reasonLabels[binding.schedule_block_reason] || binding.schedule_block_reason) : '无'}</Typography><Button component={RouterLink} to="/browsers" variant="outlined">查看运行环境</Button></> : <><Typography color="text.secondary">尚未绑定运行环境因此不能恢复或排队</Typography><Button component={RouterLink} to="/browsers" variant="contained">绑定运行环境</Button></>}</Stack></Paper>
</Box>
<Paper component="section" variant="outlined" sx={{ p: { xs: 2.5, md: 3 }, mt: 2.5, minWidth: 0 }}>
<Typography variant="h6">文本草稿</Typography>
+3 -2
View File
@@ -68,6 +68,7 @@ describe('AccountList', () => {
it('distinguishes resumable bindings from missing resources', () => {
expect(accountReadiness(account, binding)).toMatchObject({ label: '资源就绪,可恢复', canResume: true })
expect(accountReadiness(account, { ...binding, network_exit_id: '', network_exit_health: 'unchecked' })).toMatchObject({ label: '资源就绪,可恢复', canResume: true })
expect(accountReadiness(account, undefined)).toMatchObject({ label: '未绑定运行环境', canResume: false })
expect(accountReadiness(account, undefined, true)).toMatchObject({ label: '环境状态未知', canResume: false })
})
@@ -96,8 +97,8 @@ describe('AccountDetail', () => {
render(<MemoryRouter initialEntries={['/accounts/account-a']}><CoreAdminContext dataProvider={dataProvider}><Routes><Route path="/accounts/:id" element={<AccountDetail />} /></Routes></CoreAdminContext></MemoryRouter>)
expect(await screen.findByText(/环境不可用(502/)).toBeTruthy()
expect(screen.getByText(/运行环境、固定出口与 readiness 状态未知/)).toBeTruthy()
expect(screen.queryByText(/尚未绑定运行环境与固定出口/)).toBeNull()
expect(screen.getByText(/运行环境与网络出口状态未知/)).toBeTruthy()
expect(screen.queryByText(/尚未绑定运行环境/)).toBeNull()
expect(screen.getByRole('button', { name: '恢复账号' }).disabled).toBe(true)
expect(screen.getByRole('button', { name: '暂停账号' }).disabled).toBe(false)
fireEvent.click(screen.getByRole('button', { name: '重试环境状态' }))
+6 -11
View File
@@ -13,7 +13,6 @@ import {
DialogTitle,
FormControlLabel,
IconButton,
InputAdornment,
MenuItem,
Paper,
Stack,
@@ -136,7 +135,6 @@ function CreateForm({ gateways, images, accounts, networkExits, onSubmit, busy }
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,
@@ -154,13 +152,9 @@ function CreateForm({ gateways, images, accounts, networkExits, onSubmit, busy }
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 !== '' && form.account_id !== '' && form.network_exit_id !== '' &&
form.image_version !== '' && form.account_id !== '' &&
Number.isInteger(seedNumber) && seedNumber >= 1 && seedNumber <= 2147483647
const submit = async event => {
@@ -217,8 +211,9 @@ function CreateForm({ gateways, images, accounts, networkExits, onSubmit, busy }
</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 ? '请先检查并启用健康出口' : ' '}>
{label('env-exit', '网络出口')}
<TextField id="env-exit" select slotProps={{ htmlInput: { 'aria-label': '网络出口' } }} value={form.network_exit_id} onChange={event => update('network_exit_id', event.target.value)} helperText="留空时使用当前机器网络出口直连">
<MenuItem value="">当前机器直连</MenuItem>
{healthyExits.map(exit => <MenuItem key={exit.id} value={exit.id}>{exit.id} · {exit.protocol}://{exit.host}:{exit.port}</MenuItem>)}
</TextField>
</Stack>
@@ -300,7 +295,7 @@ function RuntimeTable({ runtimes, busy, onAction }) {
<TableRow key={runtime.alias} sx={{ '&:last-child td': { borderBottom: 0 } }}>
<TableCell><Typography fontWeight={650}>{runtime.name}</Typography><Typography variant="caption" color="text.secondary">{runtime.alias} · seed {runtime.fingerprint?.seed}</Typography></TableCell>
<TableCell><Status state={runtime.state} /></TableCell>
<TableCell><Typography variant="body2">{runtime.account_id || '未绑定账号'}</Typography><Typography variant="caption" color="text.secondary">{runtime.network_exit_id || '未绑定出口'} · {runtime.image_version}</Typography><BindingReadiness runtime={runtime} /></TableCell>
<TableCell><Typography variant="body2">{runtime.account_id || '未绑定账号'}</Typography><Typography variant="caption" color="text.secondary">{runtime.network_exit_id || '当前机器直连'} · {runtime.image_version}</Typography><BindingReadiness runtime={runtime} /></TableCell>
<TableCell>{runtime.endpoint ? <Copyable value={runtime.endpoint} /> : <Typography variant="body2" color="text.secondary"></Typography>}</TableCell>
<TableCell><RuntimeActions runtime={runtime} busy={busy === runtime.alias} onAction={onAction} /></TableCell>
</TableRow>
@@ -312,7 +307,7 @@ function RuntimeTable({ runtimes, busy, onAction }) {
}
function RuntimeCards({ runtimes, busy, onAction }) {
return <Stack spacing={2} sx={{ display: { xs: 'flex', md: 'none' } }}>{runtimes.map(runtime => <Paper key={runtime.alias} variant="outlined" sx={{ p: 2.5 }}><Stack spacing={1.5}><Box><Typography fontWeight={700}>{runtime.name}</Typography><Typography variant="caption" color="text.secondary">{runtime.alias} · {runtime.image_version} · seed {runtime.fingerprint?.seed}</Typography></Box><Status state={runtime.state} /><Typography variant="body2">账号{runtime.account_id || '未绑定'} · 固定出口{runtime.network_exit_id || '未绑定'}</Typography><BindingReadiness runtime={runtime} />{runtime.endpoint ? <Copyable value={runtime.endpoint} /> : null}<RuntimeActions runtime={runtime} busy={busy === runtime.alias} onAction={onAction} /></Stack></Paper>)}</Stack>
return <Stack spacing={2} sx={{ display: { xs: 'flex', md: 'none' } }}>{runtimes.map(runtime => <Paper key={runtime.alias} variant="outlined" sx={{ p: 2.5 }}><Stack spacing={1.5}><Box><Typography fontWeight={700}>{runtime.name}</Typography><Typography variant="caption" color="text.secondary">{runtime.alias} · {runtime.image_version} · seed {runtime.fingerprint?.seed}</Typography></Box><Status state={runtime.state} /><Typography variant="body2">账号{runtime.account_id || '未绑定'} · 网络出口{runtime.network_exit_id || '当前机器直连'}</Typography><BindingReadiness runtime={runtime} />{runtime.endpoint ? <Copyable value={runtime.endpoint} /> : null}<RuntimeActions runtime={runtime} busy={busy === runtime.alias} onAction={onAction} /></Stack></Paper>)}</Stack>
}
export function BrowserList() {
+21
View File
@@ -126,6 +126,8 @@ describe('BrowserList', () => {
fireEvent.change(screen.getByRole('textbox', { name: /环境名称/ }), { target: { value: '店铺三号' } })
fireEvent.change(screen.getByRole('textbox', { name: /别名/ }), { target: { value: 'shop-03' } })
fireEvent.mouseDown(screen.getByRole('combobox', { name: '网络出口' }))
fireEvent.click(await screen.findByRole('option', { name: /exit-1/ }))
fireEvent.click(screen.getByRole('button', { name: '创建环境' }))
await waitFor(() => expect(dataProvider.create).toHaveBeenCalledWith('browsers', { data: {
@@ -141,6 +143,25 @@ describe('BrowserList', () => {
expect(screen.queryByLabelText('禁用非代理 UDP')).toBeNull()
})
it('creates an env with the current machine exit when network exit is empty', async () => {
const dataProvider = provider({ getList: vi.fn(resource => {
if (resource === 'network-exits') return Promise.resolve({ data: [], total: 0 })
return provider().getList(resource)
}) })
render(<CoreAdminContext dataProvider={dataProvider}><BrowserList /></CoreAdminContext>)
await screen.findAllByText('店铺一号')
fireEvent.change(screen.getByRole('textbox', { name: /环境名称/ }), { target: { value: '直连环境' } })
fireEvent.change(screen.getByRole('textbox', { name: /别名/ }), { target: { value: 'direct-env' } })
fireEvent.click(screen.getByRole('button', { name: '创建环境' }))
await waitFor(() => expect(dataProvider.create).toHaveBeenCalledWith('browsers', { data: expect.objectContaining({
alias: 'direct-env',
network_exit_id: '',
}) }))
expect(screen.getByText('留空时使用当前机器网络出口直连')).toBeTruthy()
})
it('keeps binding input after a 409 conflict', async () => {
const dataProvider = provider({ create: vi.fn().mockRejectedValue(new HttpError('conflict', 409)) })
render(<CoreAdminContext dataProvider={dataProvider}><BrowserList /></CoreAdminContext>)
+7 -7
View File
@@ -66,7 +66,7 @@ export function DraftDetail() {
confirmation.account_version === draft.account.version && confirmation.draft_version === draft.version)
const binding = browsersError ? undefined : browsers.find(browser => browser.account_id === draft.account_id)
const readiness = accountReadiness(draft.account, binding, Boolean(browsersError))
const canConfirm = snapshotCurrent && Boolean(binding?.network_exit_id) && !browsersError
const canConfirm = snapshotCurrent && Boolean(binding) && !browsersError
const canEnqueue = Boolean(currentConfirmation) && snapshotCurrent && readiness.ready
function openDialog() {
@@ -114,8 +114,8 @@ export function DraftDetail() {
</Box>
{message ? <Alert severity={message.severity} aria-live="polite" sx={{ mb: 2.5 }}>{message.text}</Alert> : null}
{browsersError ? <Alert severity="error" action={<Button color="inherit" size="small" onClick={() => refetchBrowsers()}>重试环境状态</Button>} sx={{ mb: 2.5 }}>环境不可用当前运行环境与固定出口状态未知不能确认或排队</Alert> : null}
{!snapshotCurrent ? <Alert severity="warning" action={<Button component={RouterLink} to={`/drafts/${versions[0]?.id}`}>打开最新版本</Button>} sx={{ mb: 2.5 }}>当前只读快照已不是最新草稿版本请刷新到版本 {latestVersion} 后重新核对</Alert> : null}
{browsersError ? <Alert severity="error" action={<Button color="inherit" size="small" onClick={() => refetchBrowsers()}>重试环境状态</Button>} sx={{ mb: 2.5 }}>环境不可用当前运行环境与网络出口状态未知不能确认或排队</Alert> : null}
{snapshotCurrent ? null : <Alert severity="warning" action={<Button component={RouterLink} to={`/drafts/${versions[0]?.id}`}>打开最新版本</Button>} sx={{ mb: 2.5 }}>当前只读快照已不是最新草稿版本请刷新到版本 {latestVersion} 后重新核对</Alert>}
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: 'minmax(0, 1fr)', lg: 'minmax(0, 1.5fr) minmax(0, 1fr)' }, gap: 2.5 }}>
<Paper component="section" variant="outlined" sx={{ p: { xs: 2.5, md: 3 }, minWidth: 0 }}>
@@ -129,7 +129,7 @@ export function DraftDetail() {
<Typography>账号版本{draft.account.version}</Typography>
<Typography>草稿版本{draft.version}</Typography>
<Typography sx={wrapAnywhere}>运行环境{browsersError ? '状态未知' : (binding?.name || '未绑定')}</Typography>
<Typography sx={wrapAnywhere}>固定出口{browsersError ? '状态未知' : (binding?.network_exit_id || '未绑定')}</Typography>
<Typography sx={wrapAnywhere}>网络出口{browsersError ? '状态未知' : (binding?.network_exit_id || '当前机器直连')}</Typography>
<Stack direction="row" spacing={1} sx={{ alignItems: 'center', color: readiness.ready ? 'success.main' : 'warning.main' }}>{readiness.ready ? <CheckCircleOutlined /> : <ReportProblemOutlined />}<Typography fontWeight={650}>{readiness.label}</Typography></Stack>
</Stack>
</Paper>
@@ -144,12 +144,12 @@ export function DraftDetail() {
<Paper component="section" variant="outlined" sx={{ p: { xs: 2.5, md: 3 }, mt: 2.5, minWidth: 0 }}>
<Typography variant="h6">显式核对与入队</Typography>
<FormControlLabel sx={{ mt: 1.5, alignItems: 'flex-start' }} control={<Checkbox checked={checked} onChange={event => setChecked(event.target.checked)} />} label="我已核对当前账号、草稿内容、运行环境和固定出口" />
<FormControlLabel sx={{ mt: 1.5, alignItems: 'flex-start' }} control={<Checkbox checked={checked} onChange={event => setChecked(event.target.checked)} />} label="我已核对当前账号、草稿内容、运行环境和网络出口" />
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1.5} sx={{ mt: 2 }}>
<Button ref={confirmTrigger} variant="outlined" disabled={!checked || !canConfirm || busy} onClick={openDialog}>确认当前快照</Button>
<Button variant="contained" disabled={!canEnqueue || busy} onClick={enqueue}>{busy ? '处理中…' : '加入队列'}</Button>
</Stack>
{!currentConfirmation ? <Typography color="text.secondary" sx={{ mt: 1.5 }}>保存有效确认后才可加入队列</Typography> : null}
{currentConfirmation ? null : <Typography color="text.secondary" sx={{ mt: 1.5 }}>保存有效确认后才可加入队列</Typography>}
{currentConfirmation ? <Paper variant="outlined" sx={{ p: 2, mt: 2.5, minWidth: 0 }}><Typography fontWeight={700}>确认快照 v{currentConfirmation.version}</Typography><Typography variant="body2">账号版本 {currentConfirmation.account_version} · 草稿版本 {currentConfirmation.draft_version}</Typography><Typography variant="body2" sx={wrapAnywhere}>环境{currentConfirmation.browser_env_alias || '未记录'} · 出口{currentConfirmation.network_exit_id || '未记录'} · 绑定版本{currentConfirmation.binding_version || '未记录'}</Typography></Paper> : null}
</Paper>
@@ -160,7 +160,7 @@ export function DraftDetail() {
<Dialog open={dialogOpen} disableRestoreFocus onClose={() => busy ? undefined : closeDialog()} aria-labelledby="confirm-draft-title" slotProps={{ transition: { onExited: () => confirmTrigger.current?.focus() } }}>
<DialogTitle id="confirm-draft-title">确认草稿版本</DialogTitle>
<DialogContent><DialogContentText>将保存账号版本 {draft.account.version}草稿版本 {draft.version}运行环境 {binding?.alias} 固定出口 {binding?.network_exit_id} 的只读确认快照版本变化后必须重新确认</DialogContentText></DialogContent>
<DialogContent><DialogContentText>将保存账号版本 {draft.account.version}草稿版本 {draft.version}运行环境 {binding?.alias} 网络出口 {binding?.network_exit_id || '当前机器直连'} 的只读确认快照版本变化后必须重新确认</DialogContentText></DialogContent>
<DialogActions><Button autoFocus disabled={busy} onClick={closeDialog}>返回核对</Button><Button variant="contained" disabled={busy} onClick={confirm}>{busy ? '确认中…' : '保存确认'}</Button></DialogActions>
</Dialog>
</>
+10
View File
@@ -46,6 +46,16 @@ describe('DraftDetail', () => {
expect(screen.getByRole('button', { name: '确认当前快照' })).toBe(document.activeElement)
})
it('allows confirming a direct-machine environment', async () => {
const direct = { ...binding, network_exit_id: '', network_exit_health: 'unchecked' }
const dataProvider = provider(draft, { getList: vi.fn().mockResolvedValue({ data: [direct], total: 1 }) })
renderDraft(dataProvider)
fireEvent.click(await screen.findByRole('checkbox', { name: /我已核对当前账号/ }))
expect(screen.getByText(/网络出口:当前机器直连/)).toBeTruthy()
expect(screen.getByRole('button', { name: '确认当前快照' }).disabled).toBe(false)
})
it('submits only once while a repeated enqueue click is in flight', async () => {
let finish
const pending = new Promise(resolve => { finish = resolve })