395 lines
23 KiB
React
395 lines
23 KiB
React
import { useEffect, useState } from 'react'
|
||
import { useDataProvider, useGetList } from 'ra-core'
|
||
import {
|
||
Alert,
|
||
Box,
|
||
Button,
|
||
Checkbox,
|
||
CircularProgress,
|
||
Collapse,
|
||
Dialog,
|
||
DialogActions,
|
||
DialogContent,
|
||
DialogTitle,
|
||
FormControlLabel,
|
||
IconButton,
|
||
InputAdornment,
|
||
MenuItem,
|
||
Paper,
|
||
Stack,
|
||
Table,
|
||
TableBody,
|
||
TableCell,
|
||
TableContainer,
|
||
TableHead,
|
||
TableRow,
|
||
TextField,
|
||
Typography,
|
||
} from '@mui/material'
|
||
import ContentCopyOutlined from '@mui/icons-material/ContentCopyOutlined'
|
||
import CheckCircleOutlined from '@mui/icons-material/CheckCircleOutlined'
|
||
import DeleteOutlined from '@mui/icons-material/DeleteOutlined'
|
||
import ExpandMoreOutlined from '@mui/icons-material/ExpandMoreOutlined'
|
||
import PlayArrowOutlined from '@mui/icons-material/PlayArrowOutlined'
|
||
import ReportProblemOutlined from '@mui/icons-material/ReportProblemOutlined'
|
||
import SecurityOutlined from '@mui/icons-material/SecurityOutlined'
|
||
import StopOutlined from '@mui/icons-material/StopOutlined'
|
||
import UpgradeOutlined from '@mui/icons-material/UpgradeOutlined'
|
||
|
||
const aliasPattern = /^[a-z0-9][a-z0-9-]{0,31}$/
|
||
const platformOptions = ['windows', 'linux', 'macos']
|
||
const brandOptions = ['Chrome', 'Edge', 'Opera', 'Vivaldi']
|
||
const spoofingOptions = ['font', 'audio', 'canvas', 'clientrects', 'gpu']
|
||
|
||
const statusLabels = {
|
||
created: '已创建',
|
||
running: '运行中',
|
||
exited: '已停止',
|
||
restarting: '重启中',
|
||
paused: '已暂停',
|
||
missing: '未部署',
|
||
}
|
||
|
||
const scheduleReasonLabels = {
|
||
account_revoked: '授权已撤销',
|
||
account_paused: '账号已暂停',
|
||
binding_missing: '绑定缺失',
|
||
network_exit_missing: '固定出口缺失',
|
||
network_exit_unhealthy: '固定出口不健康',
|
||
runtime_stop_pending: '停止结果待确认',
|
||
runtime_missing: '运行实例缺失',
|
||
}
|
||
|
||
const scheduleBlockLabel = runtime => runtime.schedule_status === 'ready' ? '' : (scheduleReasonLabels[runtime.schedule_block_reason] || runtime.schedule_block_reason || '不可调度')
|
||
|
||
const emptyForm = {
|
||
name: '',
|
||
alias: '',
|
||
gateway: '',
|
||
image_version: '',
|
||
account_id: '',
|
||
network_exit_id: '',
|
||
seed: '1000',
|
||
platform: '',
|
||
platform_version: '',
|
||
brand: '',
|
||
brand_version: '',
|
||
hardware_concurrency: '',
|
||
lang: '',
|
||
accept_lang: '',
|
||
timezone: '',
|
||
disable_spoofing: [],
|
||
}
|
||
|
||
function buildFingerprint(form) {
|
||
const fingerprint = { seed: Number(form.seed) }
|
||
const optionalText = {
|
||
platform: form.platform,
|
||
platform_version: form.platform_version,
|
||
brand: form.brand,
|
||
brand_version: form.brand_version,
|
||
lang: form.lang,
|
||
accept_lang: form.accept_lang,
|
||
timezone: form.timezone,
|
||
}
|
||
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_spoofing.length > 0) fingerprint.disable_spoofing = form.disable_spoofing.join(',')
|
||
return fingerprint
|
||
}
|
||
|
||
function Status({ state }) {
|
||
const label = Object.hasOwn(statusLabels, state) ? statusLabels[state] : undefined
|
||
const color = state === 'running' ? 'success.main' : state === 'exited' ? 'warning.main' : 'text.secondary'
|
||
return <Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 1, color, fontWeight: 650 }}><Box sx={{ width: 8, height: 8, borderRadius: '50%', bgcolor: 'currentColor' }} /><Box><Typography component="span" fontWeight="inherit">{label || '未知状态'}</Typography>{label ? null : <Typography component="span" variant="caption" color="text.secondary" sx={{ display: 'block' }}>{state}</Typography>}</Box></Box>
|
||
}
|
||
|
||
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}${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>
|
||
</Stack>
|
||
)
|
||
}
|
||
|
||
function Copyable({ value }) {
|
||
const copy = () => navigator.clipboard?.writeText(value)
|
||
return (
|
||
<Typography component="code" variant="body2" sx={{ overflowWrap: 'anywhere', display: 'inline-flex', alignItems: 'center', gap: 0.5 }}>
|
||
{value}
|
||
<IconButton aria-label={`复制 ${value}`} size="small" onClick={copy}><ContentCopyOutlined sx={{ fontSize: 15 }} /></IconButton>
|
||
</Typography>
|
||
)
|
||
}
|
||
|
||
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,
|
||
disable_spoofing: current.disable_spoofing.includes(option)
|
||
? current.disable_spoofing.filter(item => item !== option)
|
||
: [...current.disable_spoofing, option],
|
||
}))
|
||
|
||
useEffect(() => {
|
||
if (form.gateway === '' && gateways.length > 0) update('gateway', gateways[0].name)
|
||
}, [gateways, form.gateway])
|
||
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 !== '' && form.account_id !== '' && form.network_exit_id !== '' &&
|
||
Number.isInteger(seedNumber) && seedNumber >= 1 && seedNumber <= 2147483647
|
||
|
||
const submit = async event => {
|
||
event.preventDefault()
|
||
if (!valid) return
|
||
const created = await onSubmit({
|
||
alias: form.alias,
|
||
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),
|
||
})
|
||
if (created) 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) => (
|
||
<Typography component="label" htmlFor={htmlFor} variant="body2" fontWeight={650}>
|
||
{text} {required ? <Box component="span" aria-hidden="true" color="error.main">*</Box> : null}
|
||
</Typography>
|
||
)
|
||
|
||
return (
|
||
<Paper component="form" onSubmit={submit} variant="outlined" sx={{ display: 'grid', gridTemplateColumns: { xs: 'minmax(0, 1fr)', md: 'repeat(2, minmax(0, 1fr))', lg: 'repeat(3, minmax(0, 1fr))' }, gap: 3, alignItems: 'end', p: { xs: 2.5, md: 3 }, mb: 3.5 }}>
|
||
<Stack spacing={1}>
|
||
{label('env-name', '环境名称', true)}
|
||
<TextField id="env-name" required slotProps={{ htmlInput: { 'aria-label': '环境名称', maxLength: 64 } }} value={form.name} onChange={event => update('name', event.target.value)} placeholder="支持中文,如:店铺一号" />
|
||
</Stack>
|
||
<Stack spacing={1}>
|
||
{label('env-alias', '别名(容器标识)', true)}
|
||
<TextField id="env-alias" required slotProps={{ htmlInput: { 'aria-label': '别名', pattern: '[a-z0-9][a-z0-9-]{0,31}' } }} value={form.alias} onChange={event => update('alias', event.target.value)} placeholder="如:shop-01" helperText="用于容器名、数据卷与 API 路径" />
|
||
</Stack>
|
||
<Stack spacing={1}>
|
||
{label('env-gateway', '网关', true)}
|
||
<TextField id="env-gateway" select required slotProps={{ htmlInput: { 'aria-label': '网关' } }} value={form.gateway} onChange={event => update('gateway', event.target.value)} disabled={gateways.length === 0} helperText={gateways.length === 0 ? '请先在「网关管理」注册网关' : ' '}>
|
||
{gateways.map(gateway => <MenuItem key={gateway.name} value={gateway.name}>{gateway.name}</MenuItem>)}
|
||
</TextField>
|
||
</Stack>
|
||
<Stack spacing={1}>
|
||
{label('env-version', '镜像版本', true)}
|
||
<TextField id="env-version" select required slotProps={{ htmlInput: { 'aria-label': '镜像版本' } }} value={form.image_version} onChange={event => update('image_version', event.target.value)} disabled={enabledImages.length === 0} helperText={enabledImages.length === 0 ? '请先在「镜像版本」添加并启用' : ' '}>
|
||
{enabledImages.map(image => <MenuItem key={image.version} value={image.version}>{image.version}</MenuItem>)}
|
||
</TextField>
|
||
</Stack>
|
||
<Stack spacing={1}>
|
||
{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' }}>
|
||
高级指纹参数{advanced ? '(收起)' : '(展开)'}
|
||
</Button>
|
||
<Collapse in={advanced} sx={{ width: 1 }}>
|
||
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: 'minmax(0, 1fr)', md: 'repeat(3, minmax(0, 1fr))' }, gap: 3, pt: 1 }}>
|
||
<TextField label="操作系统 platform" select value={form.platform} onChange={event => update('platform', event.target.value)} slotProps={{ htmlInput: { 'aria-label': '操作系统 platform' } }}>
|
||
<MenuItem value="">默认</MenuItem>
|
||
{platformOptions.map(option => <MenuItem key={option} value={option}>{option}</MenuItem>)}
|
||
</TextField>
|
||
<TextField label="系统版本 platform-version" value={form.platform_version} onChange={event => update('platform_version', event.target.value)} slotProps={{ htmlInput: { 'aria-label': '系统版本 platform-version' } }} placeholder="如 11.0.0" />
|
||
<TextField label="浏览器品牌 brand" select value={form.brand} onChange={event => update('brand', event.target.value)} slotProps={{ htmlInput: { 'aria-label': '浏览器品牌 brand' } }}>
|
||
<MenuItem value="">默认 Chromium</MenuItem>
|
||
{brandOptions.map(option => <MenuItem key={option} value={option}>{option}</MenuItem>)}
|
||
</TextField>
|
||
<TextField label="品牌版本 brand-version" value={form.brand_version} onChange={event => update('brand_version', event.target.value)} slotProps={{ htmlInput: { 'aria-label': '品牌版本 brand-version' } }} placeholder="如 132.0.6834.159" />
|
||
<TextField label="CPU 核心数 hardware-concurrency" type="number" value={form.hardware_concurrency} onChange={event => update('hardware_concurrency', event.target.value)} slotProps={{ htmlInput: { 'aria-label': 'CPU 核心数 hardware-concurrency', min: 1, max: 128 } }} placeholder="留空由种子生成" />
|
||
<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" />
|
||
<Stack spacing={0.5}>
|
||
<Typography variant="body2" fontWeight={650}>禁用指纹伪装 disable-spoofing</Typography>
|
||
<Stack direction="row" spacing={0.5} useFlexGap sx={{ flexWrap: 'wrap' }}>
|
||
{spoofingOptions.map(option => (
|
||
<FormControlLabel key={option} control={<Checkbox size="small" checked={form.disable_spoofing.includes(option)} onChange={() => toggleSpoofing(option)} slotProps={{ input: { 'aria-label': `禁用伪装 ${option}` } }} />} label={option} />
|
||
))}
|
||
</Stack>
|
||
</Stack>
|
||
</Box>
|
||
</Collapse>
|
||
</Stack>
|
||
|
||
<Button type="submit" variant="contained" size="large" disabled={!valid || busy} sx={{ minHeight: 56, gridColumn: { md: '1 / -1', lg: 'auto' } }}>{busy ? '创建中…' : '创建环境'}</Button>
|
||
</Paper>
|
||
)
|
||
}
|
||
|
||
function BindingReadiness({ runtime }) {
|
||
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 }) {
|
||
const [version, setVersion] = useState('')
|
||
const enabledImages = images.filter(image => image.enabled)
|
||
useEffect(() => {
|
||
setVersion('')
|
||
}, [target])
|
||
return (
|
||
<Dialog open={target !== null} onClose={busy ? undefined : onClose} aria-label="升级镜像版本">
|
||
<DialogTitle>升级 {target ? `${target.name}(${target.alias})` : ''}</DialogTitle>
|
||
<DialogContent sx={{ minWidth: { xs: 300, sm: 420 } }}>
|
||
<Stack spacing={2} sx={{ pt: 1 }}>
|
||
<Typography variant="body2" color="text.secondary">容器将按新镜像重建,Profile 数据卷与指纹参数保持不变。</Typography>
|
||
<TextField select fullWidth label="目标镜像版本" value={version} onChange={event => setVersion(event.target.value)} slotProps={{ htmlInput: { 'aria-label': '目标镜像版本' } }}>
|
||
{enabledImages.map(image => <MenuItem key={image.version} value={image.version}>{image.version}{image.version === target?.image_version ? '(当前)' : ''}</MenuItem>)}
|
||
</TextField>
|
||
</Stack>
|
||
</DialogContent>
|
||
<DialogActions>
|
||
<Button onClick={onClose} disabled={busy}>取消</Button>
|
||
<Button variant="contained" onClick={() => onConfirm(version)} disabled={busy || version === '' || version === target?.image_version}>{busy ? '升级中…' : '确认升级'}</Button>
|
||
</DialogActions>
|
||
</Dialog>
|
||
)
|
||
}
|
||
|
||
function RuntimeTable({ runtimes, busy, onAction }) {
|
||
return (
|
||
<TableContainer component={Paper} variant="outlined" sx={{ borderRadius: 1, display: { xs: 'none', md: 'block' } }}>
|
||
<Table sx={{ tableLayout: 'fixed', '& th, & td': { py: 2.25 } }}>
|
||
<TableHead><TableRow><TableCell width="19%">环境</TableCell><TableCell width="12%">状态</TableCell><TableCell width="23%">固定绑定</TableCell><TableCell width="16%">CDP</TableCell><TableCell width="30%">操作</TableCell></TableRow></TableHead>
|
||
<TableBody>
|
||
{runtimes.map(runtime => (
|
||
<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>{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>
|
||
))}
|
||
</TableBody>
|
||
</Table>
|
||
</TableContainer>
|
||
)
|
||
}
|
||
|
||
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>
|
||
}
|
||
|
||
export function BrowserList() {
|
||
const dataProvider = useDataProvider()
|
||
const [busy, setBusy] = useState('')
|
||
const [localError, setLocalError] = useState('')
|
||
const [upgradeTarget, setUpgradeTarget] = useState(null)
|
||
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 · 运行环境'
|
||
}, [])
|
||
|
||
async function createRuntime(data) {
|
||
setBusy('create')
|
||
setLocalError('')
|
||
try {
|
||
await dataProvider.create('browsers', { data })
|
||
await refetch()
|
||
return true
|
||
} catch (reason) {
|
||
setLocalError(reason.status === 409 ? '冲突(409):账号或运行环境已有活动绑定;表单内容已保留。' : reason.status === 503 ? `环境不可用(503):${reason.message}` : reason.message)
|
||
return false
|
||
} finally {
|
||
setBusy('')
|
||
}
|
||
}
|
||
|
||
async function act(runtime, action) {
|
||
if (action === 'recycle' && !window.confirm(`回收 ${runtime.name}(${runtime.alias})?容器将被删除,Profile 数据卷保留。`)) return
|
||
if (action === 'upgrade') {
|
||
setUpgradeTarget(runtime)
|
||
return
|
||
}
|
||
await runAction(runtime, action)
|
||
}
|
||
|
||
async function runAction(runtime, action, extra) {
|
||
setBusy(runtime.alias)
|
||
setLocalError('')
|
||
try {
|
||
await dataProvider.browserAction(runtime.alias, action, extra)
|
||
await refetch()
|
||
} catch (reason) {
|
||
setLocalError(reason.message)
|
||
return reason
|
||
} finally {
|
||
setBusy('')
|
||
}
|
||
}
|
||
|
||
async function confirmUpgrade(version) {
|
||
const target = upgradeTarget
|
||
const failure = await runAction(target, 'upgrade', { version })
|
||
if (!failure) setUpgradeTarget(null)
|
||
}
|
||
|
||
const message = localError || error?.message
|
||
return (
|
||
<>
|
||
<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} 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}
|
||
{!isPending && runtimes.length === 0 ? <Paper variant="outlined" sx={{ py: 7, textAlign: 'center', color: 'text.secondary' }}>暂无运行环境,创建第一个隔离 Profile。</Paper> : null}
|
||
{!isPending && runtimes.length > 0 ? <><RuntimeTable runtimes={runtimes} busy={busy} onAction={act} /><RuntimeCards runtimes={runtimes} busy={busy} onAction={act} /></> : null}
|
||
|
||
<UpgradeDialog target={upgradeTarget} images={images} busy={busy === upgradeTarget?.alias} onClose={() => setUpgradeTarget(null)} onConfirm={confirmUpgrade} />
|
||
|
||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.25, mt: 4, color: '#475467' }}><SecurityOutlined color="primary" /><Typography>Docker socket 仅由受限网关访问,镜像与参数由平台下发</Typography></Box>
|
||
</>
|
||
)
|
||
}
|