Files
creator-hub/web/src/BrowserList.jsx
T
2026-08-27 23:17:48 +08:00

140 lines
7.7 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,
CircularProgress,
Paper,
Stack,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
TextField,
Typography,
} from '@mui/material'
import DeleteOutlined from '@mui/icons-material/DeleteOutlined'
import PlayArrowOutlined from '@mui/icons-material/PlayArrowOutlined'
import SecurityOutlined from '@mui/icons-material/SecurityOutlined'
import StopOutlined from '@mui/icons-material/StopOutlined'
const statusLabels = {
created: '已创建',
running: '运行中',
exited: '已停止',
restarting: '重启中',
paused: '已暂停',
}
function Status({ state }) {
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' }} />{statusLabels[state] || state}</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.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>
</Stack>
)
}
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>
<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>
<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>
</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.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><RuntimeActions runtime={runtime} busy={busy === runtime.name} 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 { data: runtimes = [], error, isPending, refetch } = useGetList('browsers', {}, { refetchInterval: 3000 })
useEffect(() => {
document.title = 'CreatorHub · 运行环境'
}, [])
async function createRuntime(event) {
event.preventDefault()
setBusy('create')
setLocalError('')
try {
await dataProvider.create('browsers', { data: { name, seed: Number(seed) } })
setName('')
await refetch()
} catch (reason) {
setLocalError(reason.message)
} finally {
setBusy('')
}
}
async function act(runtimeName, action) {
if (action === 'recycle' && !window.confirm(`回收 ${runtimeName}?Profile 数据卷将保留。`)) return
setBusy(runtimeName)
setLocalError('')
try {
await dataProvider.browserAction(runtimeName, action)
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' }}>启动、停止并回收隔离的浏览器 Profile</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>
{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>
</>
)
}