140 lines
7.7 KiB
React
140 lines
7.7 KiB
React
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>
|
||
</>
|
||
)
|
||
}
|