feat: support direct network exit for runtimes
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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
@@ -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() {
|
||||
|
||||
@@ -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>)
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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 })
|
||||
|
||||
Reference in New Issue
Block a user