feat: 多镜像版本管理与网关化环境编排 (#14)
This commit is contained in:
@@ -0,0 +1,176 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useDataProvider, useGetList } from 'ra-core'
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
CircularProgress,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
FormControlLabel,
|
||||
IconButton,
|
||||
MenuItem,
|
||||
Paper,
|
||||
Stack,
|
||||
Switch,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TextField,
|
||||
Typography,
|
||||
} from '@mui/material'
|
||||
import DeleteOutlined from '@mui/icons-material/DeleteOutlined'
|
||||
import EditOutlined from '@mui/icons-material/EditOutlined'
|
||||
|
||||
const versionPattern = /^[0-9][A-Za-z0-9.+~-]{0,63}$/
|
||||
const refPattern = /^[A-Za-z0-9][A-Za-z0-9._:/@-]{0,300}$/
|
||||
|
||||
function ImageTable({ images, busyVersion, onEdit, onToggle, onDelete }) {
|
||||
return (
|
||||
<TableContainer component={Paper} variant="outlined" sx={{ borderRadius: 1 }}>
|
||||
<Table sx={{ tableLayout: 'fixed', '& th, & td': { py: 2 } }}>
|
||||
<TableHead><TableRow><TableCell width="22%">版本</TableCell><TableCell width="38%">镜像引用</TableCell><TableCell width="20%">备注</TableCell><TableCell width="8%">启用</TableCell><TableCell width="12%" align="right">操作</TableCell></TableRow></TableHead>
|
||||
<TableBody>
|
||||
{images.map(image => (
|
||||
<TableRow key={image.version} sx={{ '&:last-child td': { borderBottom: 0 } }}>
|
||||
<TableCell><Typography fontWeight={650}>{image.version}</Typography></TableCell>
|
||||
<TableCell><Typography component="code" variant="body2" sx={{ overflowWrap: 'anywhere' }}>{image.image_ref}</Typography></TableCell>
|
||||
<TableCell><Typography variant="body2" color={image.note ? 'text.primary' : 'text.disabled'}>{image.note || '—'}</Typography></TableCell>
|
||||
<TableCell>
|
||||
<Switch checked={image.enabled} disabled={busyVersion === image.version} onChange={() => onToggle(image)} slotProps={{ input: { 'aria-label': `启用 ${image.version}` } }} />
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<IconButton aria-label={`编辑 ${image.version}`} disabled={busyVersion === image.version} onClick={() => onEdit(image)}><EditOutlined /></IconButton>
|
||||
<IconButton aria-label={`删除 ${image.version}`} color="error" disabled={busyVersion === image.version} onClick={() => onDelete(image)}><DeleteOutlined /></IconButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
)
|
||||
}
|
||||
|
||||
export function BrowserImageList() {
|
||||
const dataProvider = useDataProvider()
|
||||
const { data: images = [], error, isPending, refetch } = useGetList('browser-images')
|
||||
const [form, setForm] = useState({ version: '', image_ref: '', note: '' })
|
||||
const [editing, setEditing] = useState(null)
|
||||
const [busy, setBusy] = useState('')
|
||||
const [localError, setLocalError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
document.title = 'CreatorHub · 镜像版本'
|
||||
}, [])
|
||||
|
||||
const createValid = versionPattern.test(form.version) && refPattern.test(form.image_ref)
|
||||
|
||||
async function createImage(event) {
|
||||
event.preventDefault()
|
||||
if (!createValid) return
|
||||
setBusy('create')
|
||||
setLocalError('')
|
||||
try {
|
||||
await dataProvider.create('browser-images', { data: { version: form.version, image_ref: form.image_ref, note: form.note, enabled: true } })
|
||||
setForm({ version: '', image_ref: '', note: '' })
|
||||
await refetch()
|
||||
} catch (reason) {
|
||||
setLocalError(reason.message)
|
||||
} finally {
|
||||
setBusy('')
|
||||
}
|
||||
}
|
||||
|
||||
async function toggle(image) {
|
||||
setBusy(image.version)
|
||||
setLocalError('')
|
||||
try {
|
||||
await dataProvider.update('browser-images', { id: image.version, data: { image_ref: image.image_ref, note: image.note, enabled: !image.enabled } })
|
||||
await refetch()
|
||||
} catch (reason) {
|
||||
setLocalError(reason.message)
|
||||
} finally {
|
||||
setBusy('')
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(image) {
|
||||
if (!window.confirm(`删除镜像版本 ${image.version}?被环境引用时删除会被拒绝。`)) return
|
||||
setBusy(image.version)
|
||||
setLocalError('')
|
||||
try {
|
||||
await dataProvider.delete('browser-images', { id: image.version })
|
||||
await refetch()
|
||||
} catch (reason) {
|
||||
setLocalError(reason.message)
|
||||
} finally {
|
||||
setBusy('')
|
||||
}
|
||||
}
|
||||
|
||||
async function saveEditing() {
|
||||
if (!refPattern.test(editing.image_ref)) {
|
||||
setEditing(current => ({ ...current, error: '镜像引用格式不正确' }))
|
||||
return
|
||||
}
|
||||
setBusy(editing.version)
|
||||
setLocalError('')
|
||||
try {
|
||||
await dataProvider.update('browser-images', { id: editing.version, data: { image_ref: editing.image_ref, note: editing.note, enabled: editing.enabled } })
|
||||
setEditing(null)
|
||||
await refetch()
|
||||
} catch (reason) {
|
||||
setLocalError(reason.message)
|
||||
} finally {
|
||||
setBusy('')
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
<Paper component="form" onSubmit={createImage} variant="outlined" sx={{ display: 'grid', gridTemplateColumns: { xs: 'minmax(0, 1fr)', md: 'minmax(0, 1fr) minmax(0, 1.6fr) minmax(0, 1fr) auto' }, gap: 3, alignItems: 'end', p: { xs: 2.5, md: 3 }, mb: 3.5 }}>
|
||||
<Stack spacing={1}>
|
||||
<Typography component="label" htmlFor="image-version" variant="body2" fontWeight={650}>版本 <Box component="span" aria-hidden="true" color="error.main">*</Box></Typography>
|
||||
<TextField id="image-version" required slotProps={{ htmlInput: { 'aria-label': '版本', pattern: '[0-9][A-Za-z0-9.+~-]{0,63}' } }} value={form.version} onChange={event => setForm(current => ({ ...current, version: event.target.value }))} placeholder="如 148.0.7778.215" />
|
||||
</Stack>
|
||||
<Stack spacing={1}>
|
||||
<Typography component="label" htmlFor="image-ref" variant="body2" fontWeight={650}>镜像引用 <Box component="span" aria-hidden="true" color="error.main">*</Box></Typography>
|
||||
<TextField id="image-ref" required slotProps={{ htmlInput: { 'aria-label': '镜像引用' } }} value={form.image_ref} onChange={event => setForm(current => ({ ...current, image_ref: event.target.value }))} placeholder="registry/repo:tag 或 @sha256 摘要" />
|
||||
</Stack>
|
||||
<Stack spacing={1}>
|
||||
<Typography component="label" htmlFor="image-note" variant="body2" fontWeight={650}>备注</Typography>
|
||||
<TextField id="image-note" slotProps={{ htmlInput: { 'aria-label': '备注', maxLength: 200 } }} value={form.note} onChange={event => setForm(current => ({ ...current, note: event.target.value }))} />
|
||||
</Stack>
|
||||
<Button type="submit" variant="contained" size="large" disabled={!createValid || busy === 'create'} sx={{ minHeight: 56 }}>{busy === 'create' ? '保存中…' : '添加版本'}</Button>
|
||||
</Paper>
|
||||
|
||||
{message ? <Alert severity="error" sx={{ mb: 2.5 }}>{message}</Alert> : null}
|
||||
{isPending ? <Box sx={{ display: 'grid', placeItems: 'center', minHeight: 220 }}><CircularProgress aria-label="正在加载镜像版本" /></Box> : null}
|
||||
{!isPending && images.length === 0 ? <Paper variant="outlined" sx={{ py: 7, textAlign: 'center', color: 'text.secondary' }}>暂无镜像版本;网关会在镜像缺失时按引用自动拉取。</Paper> : null}
|
||||
{!isPending && images.length > 0 ? <ImageTable images={images} busyVersion={busy} onEdit={image => setEditing({ ...image, error: '' })} onToggle={toggle} onDelete={remove} /> : null}
|
||||
|
||||
<Dialog open={editing !== null} onClose={() => setEditing(null)} aria-label="编辑镜像版本">
|
||||
<DialogTitle>编辑 {editing?.version}</DialogTitle>
|
||||
<DialogContent sx={{ minWidth: { xs: 300, sm: 480 } }}>
|
||||
<Stack spacing={2.5} sx={{ pt: 1 }}>
|
||||
<TextField label="镜像引用" value={editing?.image_ref ?? ''} onChange={event => setEditing(current => ({ ...current, image_ref: event.target.value, error: '' }))} slotProps={{ htmlInput: { 'aria-label': '镜像引用' } }} error={Boolean(editing?.error)} helperText={editing?.error || ' '} />
|
||||
<TextField label="备注" value={editing?.note ?? ''} onChange={event => setEditing(current => ({ ...current, note: event.target.value }))} slotProps={{ htmlInput: { 'aria-label': '备注', maxLength: 200 } }} />
|
||||
<FormControlLabel control={<Switch checked={editing?.enabled ?? false} onChange={event => setEditing(current => ({ ...current, enabled: event.target.checked }))} slotProps={{ input: { 'aria-label': '启用' } }} />} label="启用(可用于创建与升级)" />
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setEditing(null)} disabled={busy === editing?.version}>取消</Button>
|
||||
<Button variant="contained" onClick={saveEditing} disabled={busy === editing?.version}>{busy === editing?.version ? '保存中…' : '保存'}</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { CoreAdminContext } from 'ra-core'
|
||||
import { BrowserImageList } from './BrowserImageList'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
const images = [
|
||||
{ id: '148.0.0.1', version: '148.0.0.1', image_ref: 'reg/img:148', note: '', enabled: true },
|
||||
{ id: '144.0.0.1', version: '144.0.0.1', image_ref: 'reg/img:144', note: '旧版', enabled: false },
|
||||
]
|
||||
|
||||
function provider(overrides = {}) {
|
||||
return {
|
||||
getList: vi.fn(() => Promise.resolve({ data: images, total: images.length })),
|
||||
create: vi.fn().mockResolvedValue({ data: images[0] }),
|
||||
update: vi.fn().mockResolvedValue({ data: images[0] }),
|
||||
delete: vi.fn().mockResolvedValue({ data: { id: '144.0.0.1' } }),
|
||||
getOne: vi.fn(), getMany: vi.fn(), getManyReference: vi.fn(), updateMany: vi.fn(), deleteMany: vi.fn(), browserAction: vi.fn(),
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('BrowserImageList', () => {
|
||||
it('adds an image version', async () => {
|
||||
const dataProvider = provider()
|
||||
render(<CoreAdminContext dataProvider={dataProvider}><BrowserImageList /></CoreAdminContext>)
|
||||
|
||||
await screen.findAllByText('reg/img:148')
|
||||
fireEvent.change(screen.getByRole('textbox', { name: '版本' }), { target: { value: '150.0.0.1' } })
|
||||
fireEvent.change(screen.getByRole('textbox', { name: '镜像引用' }), { target: { value: 'reg/img:150' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: '添加版本' }))
|
||||
|
||||
await waitFor(() => expect(dataProvider.create).toHaveBeenCalledWith('browser-images', { data: { version: '150.0.0.1', image_ref: 'reg/img:150', note: '', enabled: true } }))
|
||||
})
|
||||
|
||||
it('toggles enabled through update', async () => {
|
||||
const dataProvider = provider()
|
||||
render(<CoreAdminContext dataProvider={dataProvider}><BrowserImageList /></CoreAdminContext>)
|
||||
|
||||
fireEvent.click(await screen.findByRole('switch', { name: '启用 144.0.0.1' }))
|
||||
|
||||
await waitFor(() => expect(dataProvider.update).toHaveBeenCalledWith('browser-images', { id: '144.0.0.1', data: { image_ref: 'reg/img:144', note: '旧版', enabled: true } }))
|
||||
})
|
||||
|
||||
it('blocks invalid input before submit', async () => {
|
||||
const dataProvider = provider()
|
||||
render(<CoreAdminContext dataProvider={dataProvider}><BrowserImageList /></CoreAdminContext>)
|
||||
|
||||
await screen.findAllByText('reg/img:148')
|
||||
fireEvent.change(screen.getByRole('textbox', { name: '版本' }), { target: { value: 'v1!' } })
|
||||
expect(screen.getByRole('button', { name: '添加版本' }).disabled).toBe(true)
|
||||
expect(dataProvider.create).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('surfaces delete rejection (referenced by env)', async () => {
|
||||
const dataProvider = provider({ delete: vi.fn().mockRejectedValue(new Error('该版本正被环境引用')) })
|
||||
render(<CoreAdminContext dataProvider={dataProvider}><BrowserImageList /></CoreAdminContext>)
|
||||
|
||||
fireEvent.click(await screen.findByLabelText('删除 148.0.0.1'))
|
||||
expect(window.confirm).toBeTruthy()
|
||||
window.confirm = vi.fn(() => true)
|
||||
fireEvent.click(screen.getByLabelText('删除 148.0.0.1'))
|
||||
|
||||
expect((await screen.findByRole('alert')).textContent).toContain('该版本正被环境引用')
|
||||
})
|
||||
})
|
||||
+243
-33
@@ -4,9 +4,20 @@ import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Checkbox,
|
||||
CircularProgress,
|
||||
Collapse,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
FormControlLabel,
|
||||
IconButton,
|
||||
InputAdornment,
|
||||
MenuItem,
|
||||
Paper,
|
||||
Stack,
|
||||
Switch,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
@@ -16,10 +27,18 @@ import {
|
||||
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: '已创建',
|
||||
@@ -27,6 +46,47 @@ const statusLabels = {
|
||||
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 }) {
|
||||
@@ -39,26 +99,170 @@ 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.name, 'start')}>启动</Button>
|
||||
<Button aria-label={`停止 ${runtime.name}`} variant="outlined" color="warning" size="small" disabled={busy || !running} startIcon={<StopOutlined />} onClick={() => onAction(runtime.name, 'stop')}>停止</Button>
|
||||
<Button aria-label={`回收 ${runtime.name}`} variant="outlined" color="error" size="small" disabled={busy} startIcon={<DeleteOutlined />} onClick={() => onAction(runtime.name, 'recycle')}>回收</Button>
|
||||
<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="12%">状态</TableCell><TableCell width="18%">Profile</TableCell><TableCell width="25%">CDP</TableCell><TableCell width="27%">操作</TableCell></TableRow></TableHead>
|
||||
<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.id} sx={{ '&:last-child td': { borderBottom: 0 } }}>
|
||||
<TableCell><Typography fontWeight={650}>{runtime.name}</Typography><Typography variant="caption" color="text.secondary">{runtime.status}</Typography></TableCell>
|
||||
<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 component="code" variant="body2" sx={{ overflowWrap: 'anywhere' }}>{runtime.profile}</Typography></TableCell>
|
||||
<TableCell><Typography component="code" variant="body2" sx={{ overflowWrap: 'anywhere' }}>{runtime.endpoint}</Typography></TableCell>
|
||||
<TableCell><RuntimeActions runtime={runtime} busy={busy === runtime.name} onAction={onAction} /></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>
|
||||
@@ -68,28 +272,27 @@ function RuntimeTable({ runtimes, busy, onAction }) {
|
||||
}
|
||||
|
||||
function RuntimeCards({ runtimes, busy, onAction }) {
|
||||
return <Stack spacing={2} sx={{ display: { xs: 'flex', md: 'none' } }}>{runtimes.map(runtime => <Paper key={runtime.id} variant="outlined" sx={{ p: 2.5 }}><Stack spacing={1.5}><Box><Typography fontWeight={700}>{runtime.name}</Typography><Typography variant="caption" color="text.secondary">{runtime.status}</Typography></Box><Status state={runtime.state} /><Typography component="code" variant="body2" sx={{ overflowWrap: 'anywhere' }}>{runtime.profile}</Typography><Box><Typography variant="caption" color="text.secondary">CDP</Typography><Typography component="code" variant="body2" sx={{ display: 'block', overflowWrap: 'anywhere' }}>{runtime.endpoint}</Typography></Box><RuntimeActions runtime={runtime} busy={busy === runtime.name} onAction={onAction} /></Stack></Paper>)}</Stack>
|
||||
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 [name, setName] = useState('')
|
||||
const [seed, setSeed] = useState('1000')
|
||||
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(event) {
|
||||
event.preventDefault()
|
||||
async function createRuntime(data) {
|
||||
setBusy('create')
|
||||
setLocalError('')
|
||||
try {
|
||||
await dataProvider.create('browsers', { data: { name, seed: Number(seed) } })
|
||||
setName('')
|
||||
await dataProvider.create('browsers', { data })
|
||||
await refetch()
|
||||
} catch (reason) {
|
||||
setLocalError(reason.message)
|
||||
@@ -98,43 +301,50 @@ export function BrowserList() {
|
||||
}
|
||||
}
|
||||
|
||||
async function act(runtimeName, action) {
|
||||
if (action === 'recycle' && !window.confirm(`回收 ${runtimeName}?Profile 数据卷将保留。`)) return
|
||||
setBusy(runtimeName)
|
||||
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(runtimeName, action)
|
||||
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' }}>启动、停止并回收隔离的浏览器 Profile</Typography></Box>
|
||||
<Box component="header" sx={{ mb: 4 }}><Typography variant="h1">运行环境</Typography><Typography color="text.secondary" sx={{ mt: 1, fontSize: '1.05rem' }}>启动、停止、升级并回收隔离的指纹浏览器</Typography></Box>
|
||||
|
||||
<Paper component="form" onSubmit={createRuntime} 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}>
|
||||
<Typography component="label" htmlFor="runtime-name" variant="body2" fontWeight={650}>环境名称 <Box component="span" aria-hidden="true" color="error.main">*</Box></Typography>
|
||||
<TextField id="runtime-name" required slotProps={{ htmlInput: { 'aria-label': '环境名称', pattern: '[a-z0-9](?:[a-z0-9]|-){0,31}' } }} value={name} onChange={event => setName(event.target.value)} placeholder="例如:account-a" />
|
||||
</Stack>
|
||||
<Stack spacing={1}>
|
||||
<Typography component="label" htmlFor="runtime-seed" variant="body2" fontWeight={650}>Fingerprint Seed <Box component="span" aria-hidden="true" color="error.main">*</Box></Typography>
|
||||
<TextField id="runtime-seed" required type="number" slotProps={{ htmlInput: { 'aria-label': 'Fingerprint Seed', min: 1, max: 2147483647 } }} value={seed} onChange={event => setSeed(event.target.value)} />
|
||||
</Stack>
|
||||
<Button type="submit" variant="contained" size="large" disabled={busy === 'create'} sx={{ minHeight: 56, gridColumn: { md: '1 / -1', lg: 'auto' } }}>{busy === 'create' ? '创建中…' : '创建环境'}</Button>
|
||||
</Paper>
|
||||
<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}
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.25, mt: 4, color: '#475467' }}><SecurityOutlined color="primary" /><Typography>Docker socket 仅由受限网关访问</Typography></Box>
|
||||
<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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { CoreAdminContext } from 'ra-core'
|
||||
import { BrowserList } from './BrowserList'
|
||||
import { theme } from './theme'
|
||||
@@ -7,14 +7,25 @@ import { theme } from './theme'
|
||||
afterEach(cleanup)
|
||||
|
||||
const runtimes = [
|
||||
{ id: 'container-a', name: 'account-a', state: 'running', status: 'Up', profile: 'creatorhub-profile-account-a', endpoint: 'http://account-a:9222' },
|
||||
{ id: 'container-b', name: 'account-b', state: 'exited', status: 'Exited', profile: 'creatorhub-profile-account-b', endpoint: 'http://account-b:9222' },
|
||||
{ id: 'account-a', alias: 'account-a', name: '店铺一号', state: 'running', status: 'Up', image_version: '148.0.0.1', fingerprint: { seed: 1000 }, endpoint: 'http://account-a:9222' },
|
||||
{ id: 'account-b', alias: 'account-b', name: '店铺二号', state: 'exited', status: 'Exited', image_version: '148.0.0.1', fingerprint: { seed: 2000 }, endpoint: 'http://account-b:9222' },
|
||||
]
|
||||
const gateways = [{ id: 'gw-1', name: 'gw-1', endpoint: 'http://gw:8081', token: 't' }]
|
||||
const images = [
|
||||
{ id: '148.0.0.1', version: '148.0.0.1', image_ref: 'reg/img:148', enabled: true },
|
||||
{ id: '144.0.0.1', version: '144.0.0.1', image_ref: 'reg/img:144', enabled: true },
|
||||
{ id: '139.0.0.1', version: '139.0.0.1', image_ref: 'reg/img:139', enabled: false },
|
||||
]
|
||||
|
||||
function provider(overrides = {}) {
|
||||
return {
|
||||
getList: vi.fn().mockResolvedValue({ data: runtimes, total: runtimes.length }),
|
||||
create: vi.fn().mockResolvedValue({ data: { id: 'new-runtime', name: 'new-runtime' } }),
|
||||
getList: vi.fn((resource) => {
|
||||
if (resource === 'browsers') return Promise.resolve({ data: runtimes, total: runtimes.length })
|
||||
if (resource === 'gateways') return Promise.resolve({ data: gateways, total: gateways.length })
|
||||
if (resource === 'browser-images') return Promise.resolve({ data: images, total: images.length })
|
||||
return Promise.reject(new Error(`unsupported ${resource}`))
|
||||
}),
|
||||
create: vi.fn().mockResolvedValue({ data: { id: 'account-a', alias: 'account-a' } }),
|
||||
browserAction: vi.fn().mockResolvedValue(undefined),
|
||||
getOne: vi.fn(), getMany: vi.fn(), getManyReference: vi.fn(), update: vi.fn(), updateMany: vi.fn(), delete: vi.fn(), deleteMany: vi.fn(),
|
||||
...overrides,
|
||||
@@ -33,7 +44,7 @@ describe('BrowserList', () => {
|
||||
render(<CoreAdminContext dataProvider={provider()}><BrowserList /></CoreAdminContext>)
|
||||
|
||||
expect(await screen.findAllByText('http://account-a:9222')).toHaveLength(2)
|
||||
expect(screen.getAllByText('CDP')).toHaveLength(3)
|
||||
expect(screen.getAllByText('CDP')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('shows unknown states with their raw value as secondary evidence', async () => {
|
||||
@@ -58,31 +69,52 @@ describe('BrowserList', () => {
|
||||
const dataProvider = provider()
|
||||
render(<CoreAdminContext dataProvider={dataProvider}><BrowserList /></CoreAdminContext>)
|
||||
|
||||
await screen.findAllByText('account-a')
|
||||
expect(screen.getAllByLabelText('启动 account-a')[0].disabled).toBe(true)
|
||||
expect(screen.getAllByLabelText('停止 account-b')[0].disabled).toBe(true)
|
||||
fireEvent.click(screen.getAllByLabelText('停止 account-a')[0])
|
||||
await screen.findAllByText('店铺一号')
|
||||
expect(screen.getAllByLabelText('启动 店铺一号')[0].disabled).toBe(true)
|
||||
expect(screen.getAllByLabelText('停止 店铺二号')[0].disabled).toBe(true)
|
||||
fireEvent.click(screen.getAllByLabelText('停止 店铺一号')[0])
|
||||
|
||||
await waitFor(() => expect(dataProvider.browserAction).toHaveBeenCalledWith('account-a', 'stop'))
|
||||
await waitFor(() => expect(dataProvider.browserAction).toHaveBeenCalledWith('account-a', 'stop', undefined))
|
||||
})
|
||||
|
||||
it('creates a runtime through the data provider', async () => {
|
||||
it('creates an env with chinese name, alias, gateway and enabled image version', async () => {
|
||||
const dataProvider = provider()
|
||||
render(<CoreAdminContext dataProvider={dataProvider}><BrowserList /></CoreAdminContext>)
|
||||
await screen.findAllByText('account-a')
|
||||
await screen.findAllByText('店铺一号')
|
||||
|
||||
fireEvent.change(screen.getByRole('textbox', { name: /环境名称/ }), { target: { value: 'new-runtime' } })
|
||||
fireEvent.change(screen.getByRole('textbox', { name: /环境名称/ }), { target: { value: '店铺三号' } })
|
||||
fireEvent.change(screen.getByRole('textbox', { name: /别名/ }), { target: { value: 'shop-03' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: '创建环境' }))
|
||||
|
||||
await waitFor(() => expect(dataProvider.create).toHaveBeenCalledWith('browsers', { data: { name: 'new-runtime', seed: 1000 } }))
|
||||
await waitFor(() => expect(dataProvider.create).toHaveBeenCalledWith('browsers', { data: {
|
||||
alias: 'shop-03',
|
||||
name: '店铺三号',
|
||||
gateway: 'gw-1',
|
||||
image_version: '148.0.0.1',
|
||||
fingerprint: { seed: 1000 },
|
||||
} }))
|
||||
})
|
||||
|
||||
it('upgrades an env through the version dialog', async () => {
|
||||
const dataProvider = provider()
|
||||
render(<CoreAdminContext dataProvider={dataProvider}><BrowserList /></CoreAdminContext>)
|
||||
await screen.findAllByText('店铺一号')
|
||||
|
||||
fireEvent.click(screen.getAllByLabelText('升级 店铺一号')[0])
|
||||
const dialog = await screen.findByRole('dialog')
|
||||
fireEvent.mouseDown(within(dialog).getByRole('combobox', { name: /目标镜像版本/ }))
|
||||
fireEvent.click(await within(document.body).findByRole('option', { name: '144.0.0.1' }))
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '确认升级' }))
|
||||
|
||||
await waitFor(() => expect(dataProvider.browserAction).toHaveBeenCalledWith('account-a', 'upgrade', { version: '144.0.0.1' }))
|
||||
})
|
||||
|
||||
it('shows action errors and keeps a retry control', async () => {
|
||||
const dataProvider = provider({ browserAction: vi.fn().mockRejectedValue(new Error('停止失败')) })
|
||||
render(<CoreAdminContext dataProvider={dataProvider}><BrowserList /></CoreAdminContext>)
|
||||
await screen.findAllByText('account-a')
|
||||
await screen.findAllByText('店铺一号')
|
||||
|
||||
fireEvent.click(screen.getAllByLabelText('停止 account-a')[0])
|
||||
fireEvent.click(screen.getAllByLabelText('停止 店铺一号')[0])
|
||||
|
||||
expect((await screen.findByRole('alert')).textContent).toContain('停止失败')
|
||||
expect(screen.getByRole('button', { name: '重试' })).toBeTruthy()
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useDataProvider, useGetList } from 'ra-core'
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
CircularProgress,
|
||||
IconButton,
|
||||
InputAdornment,
|
||||
Paper,
|
||||
Stack,
|
||||
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 VisibilityOutlined from '@mui/icons-material/VisibilityOutlined'
|
||||
import VisibilityOffOutlined from '@mui/icons-material/VisibilityOffOutlined'
|
||||
|
||||
const namePattern = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/
|
||||
const tokenPattern = /^[A-Za-z0-9][A-Za-z0-9._-]{15,127}$/
|
||||
|
||||
function CopyButton({ value }) {
|
||||
return <IconButton aria-label={`复制 ${value}`} size="small" onClick={() => navigator.clipboard?.writeText(value)}><ContentCopyOutlined sx={{ fontSize: 16 }} /></IconButton>
|
||||
}
|
||||
|
||||
function TokenCell({ token, name }) {
|
||||
const [revealed, setRevealed] = useState(false)
|
||||
return (
|
||||
<Stack direction="row" spacing={0.5} alignItems="center">
|
||||
<Typography component="code" variant="body2" sx={{ overflowWrap: 'anywhere' }}>{revealed ? token : '•'.repeat(12)}</Typography>
|
||||
<IconButton aria-label={revealed ? `隐藏 ${name} 的令牌` : `显示 ${name} 的令牌`} size="small" onClick={() => setRevealed(value => !value)}>
|
||||
{revealed ? <VisibilityOffOutlined sx={{ fontSize: 16 }} /> : <VisibilityOutlined sx={{ fontSize: 16 }} />}
|
||||
</IconButton>
|
||||
{revealed ? <CopyButton value={token} /> : null}
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
|
||||
export function GatewayList() {
|
||||
const dataProvider = useDataProvider()
|
||||
const { data: gateways = [], error, isPending, refetch } = useGetList('gateways')
|
||||
const [form, setForm] = useState({ name: '', endpoint: '', token: '' })
|
||||
const [busy, setBusy] = useState('')
|
||||
const [localError, setLocalError] = useState('')
|
||||
const [created, setCreated] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
document.title = 'CreatorHub · 网关管理'
|
||||
}, [])
|
||||
|
||||
const token = form.token.trim()
|
||||
const createValid = namePattern.test(form.name) && /^https?:\/\/\S+$/.test(form.endpoint) && (token === '' || tokenPattern.test(token))
|
||||
|
||||
async function registerGateway(event) {
|
||||
event.preventDefault()
|
||||
if (!createValid) return
|
||||
setBusy('create')
|
||||
setLocalError('')
|
||||
try {
|
||||
const { data } = await dataProvider.create('gateways', { data: { name: form.name, endpoint: form.endpoint, token } })
|
||||
setCreated(data)
|
||||
setForm({ name: '', endpoint: '', token: '' })
|
||||
await refetch()
|
||||
} catch (reason) {
|
||||
setLocalError(reason.message)
|
||||
} finally {
|
||||
setBusy('')
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(gateway) {
|
||||
if (!window.confirm(`删除网关 ${gateway.name}?仍被环境引用时删除会被拒绝。`)) return
|
||||
setBusy(gateway.name)
|
||||
setLocalError('')
|
||||
try {
|
||||
await dataProvider.delete('gateways', { id: gateway.name })
|
||||
await refetch()
|
||||
} catch (reason) {
|
||||
setLocalError(reason.message)
|
||||
} finally {
|
||||
setBusy('')
|
||||
}
|
||||
}
|
||||
|
||||
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' }}>注册 Docker 网关;令牌须与网关进程的 GATEWAY_TOKEN 一致</Typography></Box>
|
||||
|
||||
{created ? (
|
||||
<Alert severity="success" sx={{ mb: 3 }} action={<Button color="inherit" size="small" onClick={() => setCreated(null)}>知道了</Button>}>
|
||||
网关 {created.name} 已注册。<Box component="span" sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.5, ml: 1 }}>
|
||||
令牌 <Typography component="code">{created.token}</Typography> <CopyButton value={created.token} />
|
||||
</Box>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<Paper component="form" onSubmit={registerGateway} variant="outlined" sx={{ display: 'grid', gridTemplateColumns: { xs: 'minmax(0, 1fr)', md: 'minmax(0, 0.8fr) minmax(0, 1.4fr) minmax(0, 1.2fr) auto' }, gap: 3, alignItems: 'end', p: { xs: 2.5, md: 3 }, mb: 3.5 }}>
|
||||
<Stack spacing={1}>
|
||||
<Typography component="label" htmlFor="gateway-name" variant="body2" fontWeight={650}>名称 <Box component="span" aria-hidden="true" color="error.main">*</Box></Typography>
|
||||
<TextField id="gateway-name" required slotProps={{ htmlInput: { 'aria-label': '网关名称', pattern: '[A-Za-z0-9][A-Za-z0-9._-]{0,63}' } }} value={form.name} onChange={event => setForm(current => ({ ...current, name: event.target.value }))} placeholder="如 gw-main" />
|
||||
</Stack>
|
||||
<Stack spacing={1}>
|
||||
<Typography component="label" htmlFor="gateway-endpoint" variant="body2" fontWeight={650}>Endpoint <Box component="span" aria-hidden="true" color="error.main">*</Box></Typography>
|
||||
<TextField id="gateway-endpoint" required slotProps={{ htmlInput: { 'aria-label': 'Endpoint' } }} value={form.endpoint} onChange={event => setForm(current => ({ ...current, endpoint: event.target.value }))} placeholder="http://docker-gateway:8081" />
|
||||
</Stack>
|
||||
<Stack spacing={1}>
|
||||
<Typography component="label" htmlFor="gateway-token" variant="body2" fontWeight={650}>令牌(可选)</Typography>
|
||||
<TextField id="gateway-token" slotProps={{ htmlInput: { 'aria-label': '网关令牌' } }} value={form.token} onChange={event => setForm(current => ({ ...current, token: event.target.value }))} placeholder="留空由平台生成" helperText="填写则须与网关 GATEWAY_TOKEN 一致" />
|
||||
</Stack>
|
||||
<Button type="submit" variant="contained" size="large" disabled={!createValid || busy === 'create'} sx={{ minHeight: 56 }}>{busy === 'create' ? '注册中…' : '注册网关'}</Button>
|
||||
</Paper>
|
||||
|
||||
{message ? <Alert severity="error" sx={{ mb: 2.5 }}>{message}</Alert> : null}
|
||||
{isPending ? <Box sx={{ display: 'grid', placeItems: 'center', minHeight: 220 }}><CircularProgress aria-label="正在加载网关" /></Box> : null}
|
||||
{!isPending && gateways.length === 0 ? <Paper variant="outlined" sx={{ py: 7, textAlign: 'center', color: 'text.secondary' }}>暂无网关;注册后环境即可调度到该网关。</Paper> : null}
|
||||
{!isPending && gateways.length > 0 ? (
|
||||
<TableContainer component={Paper} variant="outlined" sx={{ borderRadius: 1 }}>
|
||||
<Table sx={{ tableLayout: 'fixed', '& th, & td': { py: 2 } }}>
|
||||
<TableHead><TableRow><TableCell width="18%">名称</TableCell><TableCell width="34%">Endpoint</TableCell><TableCell width="34%">令牌</TableCell><TableCell width="14%" align="right">操作</TableCell></TableRow></TableHead>
|
||||
<TableBody>
|
||||
{gateways.map(gateway => (
|
||||
<TableRow key={gateway.name} sx={{ '&:last-child td': { borderBottom: 0 } }}>
|
||||
<TableCell><Typography fontWeight={650}>{gateway.name}</Typography></TableCell>
|
||||
<TableCell><Typography component="code" variant="body2" sx={{ overflowWrap: 'anywhere' }}>{gateway.endpoint}</Typography></TableCell>
|
||||
<TableCell><TokenCell token={gateway.token} name={gateway.name} /></TableCell>
|
||||
<TableCell align="right">
|
||||
<IconButton aria-label={`删除 ${gateway.name}`} color="error" disabled={busy === gateway.name} onClick={() => remove(gateway)}><DeleteOutlined /></IconButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { CoreAdminContext } from 'ra-core'
|
||||
import { GatewayList } from './GatewayList'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
const gateways = [{ id: 'gw-1', name: 'gw-1', endpoint: 'http://docker-gateway:8081', token: 'unit-test-gateway-token' }]
|
||||
|
||||
function provider(overrides = {}) {
|
||||
return {
|
||||
getList: vi.fn(() => Promise.resolve({ data: gateways, total: gateways.length })),
|
||||
create: vi.fn().mockResolvedValue({ data: { name: 'gw-2', endpoint: 'http://gw2:8081', token: 'generated-token-abcdef' } }),
|
||||
delete: vi.fn().mockResolvedValue({ data: { id: 'gw-1' } }),
|
||||
getOne: vi.fn(), getMany: vi.fn(), getManyReference: vi.fn(), update: vi.fn(), updateMany: vi.fn(), deleteMany: vi.fn(), browserAction: vi.fn(),
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('GatewayList', () => {
|
||||
it('registers a gateway and reveals the generated token', async () => {
|
||||
const dataProvider = provider()
|
||||
render(<CoreAdminContext dataProvider={dataProvider}><GatewayList /></CoreAdminContext>)
|
||||
|
||||
await screen.findAllByText('gw-1')
|
||||
fireEvent.change(screen.getByRole('textbox', { name: '网关名称' }), { target: { value: 'gw-2' } })
|
||||
fireEvent.change(screen.getByRole('textbox', { name: 'Endpoint' }), { target: { value: 'http://gw2:8081' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: '注册网关' }))
|
||||
|
||||
await waitFor(() => expect(dataProvider.create).toHaveBeenCalledWith('gateways', { data: { name: 'gw-2', endpoint: 'http://gw2:8081', token: '' } }))
|
||||
expect((await screen.findByRole('alert')).textContent).toContain('generated-token-abcdef')
|
||||
})
|
||||
|
||||
it('rejects an invalid endpoint before submit', async () => {
|
||||
const dataProvider = provider()
|
||||
render(<CoreAdminContext dataProvider={dataProvider}><GatewayList /></CoreAdminContext>)
|
||||
|
||||
await screen.findAllByText('gw-1')
|
||||
fireEvent.change(screen.getByRole('textbox', { name: '网关名称' }), { target: { value: 'gw-2' } })
|
||||
fireEvent.change(screen.getByRole('textbox', { name: 'Endpoint' }), { target: { value: 'ftp://gw2:8081' } })
|
||||
expect(screen.getByRole('button', { name: '注册网关' }).disabled).toBe(true)
|
||||
expect(dataProvider.create).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('deletes a gateway after confirm', async () => {
|
||||
const dataProvider = provider()
|
||||
window.confirm = vi.fn(() => true)
|
||||
render(<CoreAdminContext dataProvider={dataProvider}><GatewayList /></CoreAdminContext>)
|
||||
|
||||
fireEvent.click(await screen.findByLabelText('删除 gw-1'))
|
||||
|
||||
await waitFor(() => expect(dataProvider.delete).toHaveBeenCalledWith('gateways', { id: 'gw-1' }))
|
||||
})
|
||||
})
|
||||
+50
-26
@@ -1,7 +1,7 @@
|
||||
import { HttpError } from 'ra-core'
|
||||
|
||||
async function request(path = '', options) {
|
||||
const response = await fetch(`/api/browsers${path}`, options)
|
||||
const response = await fetch(`/api${path}`, options)
|
||||
if (!response.ok) {
|
||||
const body = await response.json().catch(() => ({}))
|
||||
throw new HttpError(body.error || `请求失败 (${response.status})`, response.status, body)
|
||||
@@ -9,38 +9,62 @@ async function request(path = '', options) {
|
||||
return response.status === 204 ? null : response.json()
|
||||
}
|
||||
|
||||
const unsupported = operation => Promise.reject(new Error(`browsers 不支持 ${operation}`))
|
||||
const jsonOptions = (method, data) => ({
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
|
||||
const unsupported = (resource, operation) => Promise.reject(new Error(`${resource} 不支持 ${operation}`))
|
||||
|
||||
const resourcePaths = {
|
||||
browsers: '/browsers',
|
||||
'browser-images': '/browser-images',
|
||||
gateways: '/gateways',
|
||||
}
|
||||
|
||||
export const dataProvider = {
|
||||
async getList(resource) {
|
||||
if (resource !== 'browsers') return unsupported('getList')
|
||||
const browsers = await request()
|
||||
return {
|
||||
data: browsers.map(browser => ({ ...browser, id: browser.id || browser.name })),
|
||||
total: browsers.length,
|
||||
}
|
||||
const path = resourcePaths[resource]
|
||||
if (!path) return unsupported(resource, 'getList')
|
||||
const records = await request(path)
|
||||
return { data: records.map(record => ({ ...record, id: record.alias ?? record.version ?? record.name })), total: records.length }
|
||||
},
|
||||
async create(resource, { data }) {
|
||||
if (resource !== 'browsers') return unsupported('create')
|
||||
const created = await request('', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
return { data: { ...data, ...created, id: created.id || created.name } }
|
||||
const path = resourcePaths[resource]
|
||||
if (!path) return unsupported(resource, 'create')
|
||||
const created = await request(path, jsonOptions('POST', data))
|
||||
return { data: { ...data, ...created, id: created?.alias ?? created?.version ?? created?.name ?? data.alias } }
|
||||
},
|
||||
getOne: () => unsupported('getOne'),
|
||||
getMany: () => unsupported('getMany'),
|
||||
getManyReference: () => unsupported('getManyReference'),
|
||||
update: () => unsupported('update'),
|
||||
updateMany: () => unsupported('updateMany'),
|
||||
delete: () => unsupported('delete'),
|
||||
deleteMany: () => unsupported('deleteMany'),
|
||||
async browserAction(name, action) {
|
||||
async update(resource, { id, data }) {
|
||||
const path = resourcePaths[resource]
|
||||
if (!path || resource === 'browsers') return unsupported(resource, 'update')
|
||||
// browser-images 的 PUT 不接受 version 字段(路径已携带),透传其余字段。
|
||||
const { version: _ignored, ...rest } = data ?? {}
|
||||
await request(`${path}/${encodeURIComponent(id)}`, jsonOptions('PUT', rest))
|
||||
return { data: { ...rest, id } }
|
||||
},
|
||||
async delete(resource, { id }) {
|
||||
const path = resourcePaths[resource]
|
||||
if (!path) return unsupported(resource, 'delete')
|
||||
await request(`${path}/${encodeURIComponent(id)}`, { method: 'DELETE' })
|
||||
return { data: { id } }
|
||||
},
|
||||
getOne: resource => unsupported(resource, 'getOne'),
|
||||
getMany: resource => unsupported(resource, 'getMany'),
|
||||
getManyReference: resource => unsupported(resource, 'getManyReference'),
|
||||
updateMany: resource => unsupported(resource, 'updateMany'),
|
||||
deleteMany: resource => unsupported(resource, 'deleteMany'),
|
||||
// 环境的领域动作保持显式动词,不伪装成 CRUD update。
|
||||
async browserAction(alias, action, data) {
|
||||
if (action === 'upgrade') {
|
||||
await request(`/browsers/${encodeURIComponent(alias)}/upgrade`, jsonOptions('POST', data))
|
||||
return
|
||||
}
|
||||
const paths = {
|
||||
start: [`/${encodeURIComponent(name)}/start`, 'POST'],
|
||||
stop: [`/${encodeURIComponent(name)}/stop`, 'POST'],
|
||||
recycle: [`/${encodeURIComponent(name)}`, 'DELETE'],
|
||||
start: [`/browsers/${encodeURIComponent(alias)}/start`, 'POST'],
|
||||
stop: [`/browsers/${encodeURIComponent(alias)}/stop`, 'POST'],
|
||||
recycle: [`/browsers/${encodeURIComponent(alias)}`, 'DELETE'],
|
||||
}
|
||||
const target = paths[action]
|
||||
if (!target) throw new Error(`未知运行环境操作: ${action}`)
|
||||
|
||||
@@ -16,6 +16,8 @@ import ChevronLeft from '@mui/icons-material/ChevronLeft'
|
||||
import ChevronRight from '@mui/icons-material/ChevronRight'
|
||||
import DnsOutlined from '@mui/icons-material/DnsOutlined'
|
||||
import HistoryOutlined from '@mui/icons-material/HistoryOutlined'
|
||||
import HubOutlined from '@mui/icons-material/HubOutlined'
|
||||
import Inventory2Outlined from '@mui/icons-material/Inventory2Outlined'
|
||||
import MenuIcon from '@mui/icons-material/Menu'
|
||||
import WidgetsOutlined from '@mui/icons-material/WidgetsOutlined'
|
||||
import { useTheme } from '@mui/material/styles'
|
||||
@@ -35,6 +37,14 @@ export function CreatorHubMenu({ collapsed, onNavigate }) {
|
||||
<ListItemIcon sx={{ minWidth: collapsed ? 0 : 42, color: 'inherit' }}><DnsOutlined /></ListItemIcon>
|
||||
{collapsed ? null : <ListItemText primary="运行环境" />}
|
||||
</ListItemButton>
|
||||
<ListItemButton component={NavLink} to="/browser-images" aria-label="镜像版本" onClick={onNavigate} sx={{ minHeight: 52, mb: 1.25, borderRadius: 1, color: '#a9b7cc', justifyContent: collapsed ? 'center' : 'flex-start', '&.active': { color: '#fff', bgcolor: '#0866ef' }, '&:hover': { bgcolor: '#0b2a54' }, '&.active:hover': { bgcolor: '#0866ef' } }}>
|
||||
<ListItemIcon sx={{ minWidth: collapsed ? 0 : 42, color: 'inherit' }}><Inventory2Outlined /></ListItemIcon>
|
||||
{collapsed ? null : <ListItemText primary="镜像版本" />}
|
||||
</ListItemButton>
|
||||
<ListItemButton component={NavLink} to="/gateways" aria-label="网关管理" onClick={onNavigate} sx={{ minHeight: 52, mb: 1.25, borderRadius: 1, color: '#a9b7cc', justifyContent: collapsed ? 'center' : 'flex-start', '&.active': { color: '#fff', bgcolor: '#0866ef' }, '&:hover': { bgcolor: '#0b2a54' }, '&.active:hover': { bgcolor: '#0866ef' } }}>
|
||||
<ListItemIcon sx={{ minWidth: collapsed ? 0 : 42, color: 'inherit' }}><HubOutlined /></ListItemIcon>
|
||||
{collapsed ? null : <ListItemText primary="网关管理" />}
|
||||
</ListItemButton>
|
||||
<ListItemButton disabled aria-label="审计记录" sx={{ minHeight: 52, borderRadius: 1, color: '#a9b7cc', justifyContent: collapsed ? 'center' : 'flex-start', '&.Mui-disabled': { opacity: 0.58 } }}>
|
||||
<ListItemIcon sx={{ minWidth: collapsed ? 0 : 42, color: 'inherit' }}><HistoryOutlined /></ListItemIcon>
|
||||
{collapsed ? null : <ListItemText primary="审计记录" />}
|
||||
|
||||
@@ -3,8 +3,10 @@ import { createRoot } from 'react-dom/client'
|
||||
// react-admin 5.15.1 publishes an incomplete MUI barrel; ra-core is its supported headless entry point.
|
||||
import { CoreAdmin, Resource } from 'ra-core'
|
||||
import { CssBaseline, ThemeProvider } from '@mui/material'
|
||||
import { BrowserImageList } from './BrowserImageList'
|
||||
import { BrowserList } from './BrowserList'
|
||||
import { dataProvider } from './dataProvider'
|
||||
import { GatewayList } from './GatewayList'
|
||||
import { CreatorHubLayout } from './layout'
|
||||
import { theme } from './theme'
|
||||
import './styles.css'
|
||||
@@ -15,6 +17,8 @@ createRoot(document.getElementById('root')).render(
|
||||
<CssBaseline />
|
||||
<CoreAdmin dataProvider={dataProvider} layout={CreatorHubLayout} title="CreatorHub" disableTelemetry>
|
||||
<Resource name="browsers" list={BrowserList} options={{ label: '运行环境' }} />
|
||||
<Resource name="browser-images" list={BrowserImageList} options={{ label: '镜像版本' }} />
|
||||
<Resource name="gateways" list={GatewayList} options={{ label: '网关管理' }} />
|
||||
</CoreAdmin>
|
||||
</ThemeProvider>
|
||||
</StrictMode>,
|
||||
|
||||
Reference in New Issue
Block a user