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

351 lines
19 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,
Switch,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
TextField,
Typography,
} from '@mui/material'
import ContentCopyOutlined from '@mui/icons-material/ContentCopyOutlined'
import DeleteOutlined from '@mui/icons-material/DeleteOutlined'
import ExpandMoreOutlined from '@mui/icons-material/ExpandMoreOutlined'
import PlayArrowOutlined from '@mui/icons-material/PlayArrowOutlined'
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 emptyForm = {
name: '',
alias: '',
gateway: '',
image_version: '',
seed: '1000',
platform: '',
platform_version: '',
brand: '',
brand_version: '',
hardware_concurrency: '',
lang: '',
accept_lang: '',
timezone: '',
proxy_server: '',
disable_non_proxied_udp: false,
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,
proxy_server: form.proxy_server,
}
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_non_proxied_udp) fingerprint.disable_non_proxied_udp = true
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'
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}`} 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, onSubmit, busy }) {
const [form, setForm] = useState(emptyForm)
const [advanced, setAdvanced] = useState(false)
const enabledImages = images.filter(image => image.enabled)
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])
const seedNumber = Number(form.seed)
const valid = form.name.trim() !== '' && aliasPattern.test(form.alias) && form.gateway !== '' &&
form.image_version !== '' && Number.isInteger(seedNumber) && seedNumber >= 1 && seedNumber <= 2147483647
const submit = event => {
event.preventDefault()
if (!valid) return
onSubmit({
alias: form.alias,
name: form.name.trim(),
gateway: form.gateway,
image_version: form.image_version,
fingerprint: buildFingerprint(form),
})
setForm(current => ({ ...emptyForm, gateway: current.gateway, image_version: current.image_version }))
}
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} 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" />
<TextField label="代理 proxy-server" value={form.proxy_server} onChange={event => update('proxy_server', event.target.value)} slotProps={{ htmlInput: { 'aria-label': '代理 proxy-server' } }} placeholder="如 socks5://127.0.0.1:1080" />
<FormControlLabel control={<Switch checked={form.disable_non_proxied_udp} onChange={event => update('disable_non_proxied_udp', event.target.checked)} slotProps={{ input: { 'aria-label': '禁用非代理 UDP' } }} />} label="禁用非代理 UDP(WebRTC)" sx={{ gridColumn: { md: 'span 2' } }} />
<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 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="18%">环境</TableCell><TableCell width="11%">状态</TableCell><TableCell width="13%">镜像版本</TableCell><TableCell width="22%">CDP</TableCell><TableCell width="36%">操作</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.image_version}</Typography></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} />{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')
useEffect(() => {
document.title = 'CreatorHub · 运行环境'
}, [])
async function createRuntime(data) {
setBusy('create')
setLocalError('')
try {
await dataProvider.create('browsers', { data })
await refetch()
} catch (reason) {
setLocalError(reason.message)
} 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} 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>
</>
)
}