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 {label || '未知状态'}{label ? null : {state}}
}
function RuntimeActions({ runtime, busy, onAction }) {
const running = runtime.state === 'running'
const startBlockReason = runtime.schedule_block_reason === 'runtime_missing' ? '' : scheduleBlockLabel(runtime)
return (
} onClick={() => onAction(runtime, 'start')}>启动
} onClick={() => onAction(runtime, 'stop')}>停止
} onClick={() => onAction(runtime, 'upgrade')}>升级
} onClick={() => onAction(runtime, 'recycle')}>回收
)
}
function Copyable({ value }) {
const copy = () => navigator.clipboard?.writeText(value)
return (
{value}
)
}
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) => (
{text} {required ? * : null}
)
return (
{label('env-name', '环境名称', true)}
update('name', event.target.value)} placeholder="支持中文,如:店铺一号" />
{label('env-alias', '别名(容器标识)', true)}
update('alias', event.target.value)} placeholder="如:shop-01" helperText="用于容器名、数据卷与 API 路径" />
{label('env-gateway', '网关', true)}
update('gateway', event.target.value)} disabled={gateways.length === 0} helperText={gateways.length === 0 ? '请先在「网关管理」注册网关' : ' '}>
{gateways.map(gateway => )}
{label('env-version', '镜像版本', true)}
update('image_version', event.target.value)} disabled={enabledImages.length === 0} helperText={enabledImages.length === 0 ? '请先在「镜像版本」添加并启用' : ' '}>
{enabledImages.map(image => )}
{label('env-seed', 'Fingerprint Seed', true)}
update('seed', event.target.value)} />
{label('env-account', '社媒账号', true)}
update('account_id', event.target.value)} disabled={availableAccounts.length === 0} helperText={availableAccounts.length === 0 ? '请先准备已授权且暂停的账号' : ' '}>
{availableAccounts.map(account => )}
{label('env-exit', '网络出口', true)}
update('network_exit_id', event.target.value)} disabled={healthyExits.length === 0} helperText={healthyExits.length === 0 ? '请先检查并启用健康出口' : ' '}>
{healthyExits.map(exit => )}
update('platform', event.target.value)} slotProps={{ htmlInput: { 'aria-label': '操作系统 platform' } }}>
{platformOptions.map(option => )}
update('platform_version', event.target.value)} slotProps={{ htmlInput: { 'aria-label': '系统版本 platform-version' } }} placeholder="如 11.0.0" />
update('brand', event.target.value)} slotProps={{ htmlInput: { 'aria-label': '浏览器品牌 brand' } }}>
{brandOptions.map(option => )}
update('brand_version', event.target.value)} slotProps={{ htmlInput: { 'aria-label': '品牌版本 brand-version' } }} placeholder="如 132.0.6834.159" />
update('hardware_concurrency', event.target.value)} slotProps={{ htmlInput: { 'aria-label': 'CPU 核心数 hardware-concurrency', min: 1, max: 128 } }} placeholder="留空由种子生成" />
update('lang', event.target.value)} slotProps={{ htmlInput: { 'aria-label': '语言 lang' } }} placeholder="如 zh-CN" />
update('accept_lang', event.target.value)} slotProps={{ htmlInput: { 'aria-label': '接受语言 accept-lang' } }} placeholder="如 zh-CN,en-US" />
update('timezone', event.target.value)} slotProps={{ htmlInput: { 'aria-label': '时区 timezone' } }} placeholder="如 Asia/Shanghai" />
禁用指纹伪装 disable-spoofing
{spoofingOptions.map(option => (
toggleSpoofing(option)} slotProps={{ input: { 'aria-label': `禁用伪装 ${option}` } }} />} label={option} />
))}
)
}
function BindingReadiness({ runtime }) {
const blockReason = scheduleBlockLabel(runtime)
return {blockReason ? : }{blockReason || '可调度'}
}
function UpgradeDialog({ target, images, busy, onClose, onConfirm }) {
const [version, setVersion] = useState('')
const enabledImages = images.filter(image => image.enabled)
useEffect(() => {
setVersion('')
}, [target])
return (
)
}
function RuntimeTable({ runtimes, busy, onAction }) {
return (
环境状态固定绑定CDP操作
{runtimes.map(runtime => (
{runtime.name}{runtime.alias} · seed {runtime.fingerprint?.seed}
{runtime.account_id || '未绑定账号'}{runtime.network_exit_id || '未绑定出口'} · {runtime.image_version}
{runtime.endpoint ? : —}
))}
)
}
function RuntimeCards({ runtimes, busy, onAction }) {
return {runtimes.map(runtime => {runtime.name}{runtime.alias} · {runtime.image_version} · seed {runtime.fingerprint?.seed}账号:{runtime.account_id || '未绑定'} · 固定出口:{runtime.network_exit_id || '未绑定'}{runtime.endpoint ? : null})}
}
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 (
<>
运行环境启动、停止、升级并回收隔离的指纹浏览器
{message ? { setLocalError(''); refetch() }}>重试} sx={{ mb: 2.5 }}>{message} : null}
{isPending ? : null}
{!isPending && runtimes.length === 0 ? 暂无运行环境,创建第一个隔离 Profile。 : null}
{!isPending && runtimes.length > 0 ? <>> : null}
setUpgradeTarget(null)} onConfirm={confirmUpgrade} />
Docker socket 仅由受限网关访问,镜像与参数由平台下发
>
)
}