Files
creator-hub/web/src/BrowserList.jsx
T

395 lines
23 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
</>
)
}