HH-849: gate browser starts on readiness (#25)

This commit is contained in:
2026-08-31 09:16:07 +08:00
parent 59c30d887c
commit f6909376e1
4 changed files with 66 additions and 5 deletions
+1 -1
View File
@@ -114,7 +114,7 @@ function AccountForm({ busy, onSubmit }) {
<TextField select label="凭据引用来源" value={form.credential_provider} onChange={event => update('credential_provider', event.target.value)} slotProps={{ htmlInput: { 'aria-label': '凭据引用来源' } }}>
<MenuItem value="os_keyring">系统密钥环</MenuItem><MenuItem value="secret_manager">Secret Manager</MenuItem>
</TextField>
<TextField required label="凭据引用键" value={form.credential_key} onChange={event => update('credential_key', event.target.value)} slotProps={{ htmlInput: { 'aria-label': '凭据引用键', maxLength: 127 } }} helperText="仅填写引用路径,不填写密码、Cookie 或 token" />
<TextField required label="凭据引用键" value={form.credential_key} onChange={event => update('credential_key', event.target.value)} slotProps={{ htmlInput: { 'aria-label': '凭据引用键', maxLength: 192 } }} helperText="仅填写引用路径,不填写密码、Cookie 或 token" />
<Button type="submit" variant="contained" size="large" disabled={!valid || busy} sx={{ minHeight: 52, gridColumn: { md: '1 / -1', lg: 'auto' } }}>{busy ? '创建中…' : '创建账号'}</Button>
</Paper>
)
+21
View File
@@ -45,6 +45,27 @@ describe('AccountList', () => {
expect(screen.queryByLabelText(/密码|Cookie|token/i)).toBeNull()
})
it('accepts and submits a 192-character credential reference key', async () => {
const dataProvider = provider()
const credentialKey = `${'a'.repeat(64)}/${'b'.repeat(127)}`
renderList(dataProvider)
await screen.findAllByText('shop-a')
fireEvent.change(screen.getByRole('textbox', { name: '平台' }), { target: { value: 'mock' } })
fireEvent.change(screen.getByRole('textbox', { name: '平台账号标识' }), { target: { value: 'shop-new' } })
fireEvent.change(screen.getByRole('textbox', { name: '凭据引用 ID' }), { target: { value: 'credential-new' } })
const input = screen.getByRole('textbox', { name: '凭据引用键' })
fireEvent.change(input, { target: { value: credentialKey } })
fireEvent.click(screen.getByRole('button', { name: '创建账号' }))
expect(credentialKey).toHaveLength(192)
expect(input.maxLength).toBe(192)
await waitFor(() => expect(dataProvider.create).toHaveBeenCalledWith('accounts', { data: {
platform: 'mock', platform_account_key: 'shop-new', authorization_kind: 'owned',
credential_reference: { id: 'credential-new', provider: 'os_keyring', key: credentialKey },
} }))
})
it('distinguishes resumable bindings from missing resources', () => {
expect(accountReadiness(account, binding)).toMatchObject({ label: '资源就绪,可恢复', canResume: true })
expect(accountReadiness(account, undefined)).toMatchObject({ label: '未绑定运行环境', canResume: false })
+6 -4
View File
@@ -60,6 +60,8 @@ const scheduleReasonLabels = {
runtime_missing: '运行实例缺失',
}
const scheduleBlockLabel = runtime => runtime.schedule_status === 'ready' ? '' : (scheduleReasonLabels[runtime.schedule_block_reason] || runtime.schedule_block_reason || '不可调度')
const emptyForm = {
name: '',
alias: '',
@@ -106,9 +108,10 @@ function Status({ state }) {
function RuntimeActions({ runtime, busy, onAction }) {
const running = runtime.state === 'running'
const startBlockReason = runtime.schedule_block_reason === 'runtime_missing' ? '' : scheduleBlockLabel(runtime)
return (
<Stack direction="row" spacing={1} useFlexGap sx={{ flexWrap: 'wrap' }}>
<Button aria-label={`启动 ${runtime.name}`} variant="outlined" size="small" disabled={busy || running} startIcon={<PlayArrowOutlined />} onClick={() => onAction(runtime, 'start')}>启动</Button>
<Button aria-label={`启动 ${runtime.name}${startBlockReason ? `${startBlockReason}` : ''}`} variant="outlined" size="small" disabled={busy || running || startBlockReason !== ''} startIcon={<PlayArrowOutlined />} onClick={() => onAction(runtime, 'start')}>启动</Button>
<Button aria-label={`停止 ${runtime.name}`} variant="outlined" color="warning" size="small" disabled={busy || !running} startIcon={<StopOutlined />} onClick={() => onAction(runtime, 'stop')}>停止</Button>
<Button aria-label={`升级 ${runtime.name}`} variant="outlined" size="small" disabled={busy} startIcon={<UpgradeOutlined />} onClick={() => onAction(runtime, 'upgrade')}>升级</Button>
<Button aria-label={`回收 ${runtime.name}`} variant="outlined" color="error" size="small" disabled={busy} startIcon={<DeleteOutlined />} onClick={() => onAction(runtime, 'recycle')}>回收</Button>
@@ -258,9 +261,8 @@ function CreateForm({ gateways, images, accounts, networkExits, onSubmit, busy }
}
function BindingReadiness({ runtime }) {
const ready = runtime.schedule_status === 'ready'
const reason = runtime.schedule_block_reason
return <Stack direction="row" spacing={0.75} sx={{ alignItems: 'center', color: ready ? 'success.main' : 'warning.main' }}>{ready ? <CheckCircleOutlined fontSize="small" /> : <ReportProblemOutlined fontSize="small" />}<Typography variant="caption" fontWeight={650}>{ready ? '可调度' : (scheduleReasonLabels[reason] || reason || '不可调度')}</Typography></Stack>
const blockReason = scheduleBlockLabel(runtime)
return <Stack direction="row" spacing={0.75} sx={{ alignItems: 'center', color: blockReason ? 'warning.main' : 'success.main' }}>{blockReason ? <ReportProblemOutlined fontSize="small" /> : <CheckCircleOutlined fontSize="small" />}<Typography variant="caption" fontWeight={650}>{blockReason || '可调度'}</Typography></Stack>
}
function UpgradeDialog({ target, images, busy, onClose, onConfirm }) {
+38
View File
@@ -81,6 +81,44 @@ describe('BrowserList', () => {
await waitFor(() => expect(dataProvider.browserAction).toHaveBeenCalledWith('account-a', 'stop', undefined))
})
it('starts ready or runtime-missing environments and preserves resource gates', async () => {
const environments = [
{ ...runtimes[1], id: 'ready', alias: 'ready', name: '就绪环境', account_id: 'social-a', network_exit_id: 'exit-1', network_exit_health: 'healthy', schedule_status: 'ready' },
{ ...runtimes[1], id: 'stopped', alias: 'stopped', name: '已停止环境', account_id: 'social-a', network_exit_id: 'exit-1', network_exit_health: 'healthy', schedule_status: 'blocked', schedule_block_reason: 'runtime_missing' },
{ ...runtimes[1], id: 'revoked', alias: 'revoked', name: '已撤权环境', schedule_status: 'blocked', schedule_block_reason: 'account_revoked' },
{ ...runtimes[1], id: 'paused', alias: 'paused', name: '账号暂停环境', schedule_status: 'blocked', schedule_block_reason: 'account_paused' },
{ ...runtimes[1], id: 'unbound', alias: 'unbound', name: '未绑定环境', schedule_status: 'blocked', schedule_block_reason: 'binding_missing' },
{ ...runtimes[1], id: 'no-exit', alias: 'no-exit', name: '无出口环境', schedule_status: 'blocked', schedule_block_reason: 'network_exit_missing' },
{ ...runtimes[1], id: 'unhealthy', alias: 'unhealthy', name: '出口异常环境', account_id: 'social-a', network_exit_id: 'exit-1', network_exit_health: 'unhealthy', schedule_status: 'blocked', schedule_block_reason: 'network_exit_unhealthy' },
{ ...runtimes[1], id: 'cleanup', alias: 'cleanup', name: '清理中环境', schedule_status: 'blocked', schedule_block_reason: 'runtime_stop_pending' },
]
const dataProvider = provider({ getList: vi.fn(resource => Promise.resolve({ data: resource === 'browsers' ? environments : [], total: resource === 'browsers' ? environments.length : 0 })) })
render(<CoreAdminContext dataProvider={dataProvider}><BrowserList /></CoreAdminContext>)
fireEvent.click((await screen.findAllByLabelText('启动 就绪环境'))[0])
fireEvent.click(screen.getAllByLabelText('启动 已停止环境')[0])
for (const [name, reason] of [['已撤权环境', '授权已撤销'], ['账号暂停环境', '账号已暂停'], ['未绑定环境', '绑定缺失'], ['无出口环境', '固定出口缺失'], ['出口异常环境', '固定出口不健康'], ['清理中环境', '停止结果待确认']]) {
expect(screen.getAllByLabelText(`启动 ${name}${reason}`).every(button => button.disabled)).toBe(true)
}
await waitFor(() => expect(dataProvider.browserAction.mock.calls).toEqual(expect.arrayContaining([
['ready', 'start', undefined],
['stopped', 'start', undefined],
])))
})
it('keeps the backend 409 fallback for stale readiness', async () => {
const stale = { ...runtimes[1], account_id: 'social-a', network_exit_id: 'exit-1', network_exit_health: 'healthy', schedule_status: 'ready' }
const dataProvider = provider({
getList: vi.fn(resource => Promise.resolve({ data: resource === 'browsers' ? [stale] : [], total: resource === 'browsers' ? 1 : 0 })),
browserAction: vi.fn().mockRejectedValue(new HttpError('readiness changed', 409)),
})
render(<CoreAdminContext dataProvider={dataProvider}><BrowserList /></CoreAdminContext>)
fireEvent.click((await screen.findAllByLabelText('启动 店铺二号'))[0])
expect((await screen.findByRole('alert')).textContent).toContain('readiness changed')
})
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>)