HH-749: add CreatorHub react-admin frontend
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
@@ -4,6 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#061a38" />
|
||||
<link rel="icon" href="data:," />
|
||||
<title>CreatorHub · 运行环境</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
Generated
+2806
-3
File diff suppressed because it is too large
Load Diff
+18
-3
@@ -5,14 +5,29 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"dev": "vite"
|
||||
"dev": "vite",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "11.14.0",
|
||||
"@emotion/styled": "11.14.1",
|
||||
"@mui/icons-material": "9.3.1",
|
||||
"@mui/material": "9.3.1",
|
||||
"@tanstack/react-query": "5.102.7",
|
||||
"ra-core": "5.15.0",
|
||||
"react": "19.2.8",
|
||||
"react-dom": "19.2.8"
|
||||
"react-admin": "5.15.1",
|
||||
"react-dom": "19.2.8",
|
||||
"react-hook-form": "7.86.0",
|
||||
"react-router": "7.18.2",
|
||||
"react-router-dom": "7.18.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/dom": "10.4.1",
|
||||
"@testing-library/react": "16.3.2",
|
||||
"@vitejs/plugin-react": "6.1.0",
|
||||
"vite": "8.2.2"
|
||||
"jsdom": "27.0.1",
|
||||
"vite": "8.2.2",
|
||||
"vitest": "4.1.11"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
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: '1fr', md: 'minmax(220px, 1fr) minmax(220px, 1fr) 196px' }, 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 }}>{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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { CoreAdminContext } from 'ra-core'
|
||||
import { BrowserList } from './BrowserList'
|
||||
|
||||
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' },
|
||||
]
|
||||
|
||||
function provider(overrides = {}) {
|
||||
return {
|
||||
getList: vi.fn().mockResolvedValue({ data: runtimes, total: runtimes.length }),
|
||||
create: vi.fn().mockResolvedValue({ data: { id: 'new-runtime', name: 'new-runtime' } }),
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
describe('BrowserList', () => {
|
||||
it('disables invalid lifecycle actions and sends explicit domain actions', async () => {
|
||||
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 waitFor(() => expect(dataProvider.browserAction).toHaveBeenCalledWith('account-a', 'stop'))
|
||||
})
|
||||
|
||||
it('creates a runtime through the data provider', async () => {
|
||||
const dataProvider = provider()
|
||||
render(<CoreAdminContext dataProvider={dataProvider}><BrowserList /></CoreAdminContext>)
|
||||
await screen.findAllByText('account-a')
|
||||
|
||||
fireEvent.change(screen.getByRole('textbox', { name: /环境名称/ }), { target: { value: 'new-runtime' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: '创建环境' }))
|
||||
|
||||
await waitFor(() => expect(dataProvider.create).toHaveBeenCalledWith('browsers', { data: { name: 'new-runtime', seed: 1000 } }))
|
||||
})
|
||||
|
||||
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')
|
||||
|
||||
fireEvent.click(screen.getAllByLabelText('停止 account-a')[0])
|
||||
|
||||
expect((await screen.findByRole('alert')).textContent).toContain('停止失败')
|
||||
expect(screen.getByRole('button', { name: '重试' })).toBeTruthy()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,49 @@
|
||||
import { HttpError } from 'ra-core'
|
||||
|
||||
async function request(path = '', options) {
|
||||
const response = await fetch(`/api/browsers${path}`, options)
|
||||
if (!response.ok) {
|
||||
const body = await response.json().catch(() => ({}))
|
||||
throw new HttpError(body.error || `请求失败 (${response.status})`, response.status, body)
|
||||
}
|
||||
return response.status === 204 ? null : response.json()
|
||||
}
|
||||
|
||||
const unsupported = operation => Promise.reject(new Error(`browsers 不支持 ${operation}`))
|
||||
|
||||
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,
|
||||
}
|
||||
},
|
||||
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 } }
|
||||
},
|
||||
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) {
|
||||
const paths = {
|
||||
start: [`/${encodeURIComponent(name)}/start`, 'POST'],
|
||||
stop: [`/${encodeURIComponent(name)}/stop`, 'POST'],
|
||||
recycle: [`/${encodeURIComponent(name)}`, 'DELETE'],
|
||||
}
|
||||
const target = paths[action]
|
||||
if (!target) throw new Error(`未知运行环境操作: ${action}`)
|
||||
await request(target[0], { method: target[1] })
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { dataProvider } from './dataProvider'
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
|
||||
describe('dataProvider', () => {
|
||||
it('maps the existing browser list into react-admin records', async () => {
|
||||
const fetch = vi.fn().mockResolvedValue(new Response(JSON.stringify([{ name: 'account-a', state: 'running' }]), { status: 200 }))
|
||||
vi.stubGlobal('fetch', fetch)
|
||||
|
||||
await expect(dataProvider.getList('browsers')).resolves.toEqual({
|
||||
data: [{ id: 'account-a', name: 'account-a', state: 'running' }],
|
||||
total: 1,
|
||||
})
|
||||
expect(fetch).toHaveBeenCalledWith('/api/browsers', undefined)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['start', '/api/browsers/account-a/start', 'POST'],
|
||||
['stop', '/api/browsers/account-a/stop', 'POST'],
|
||||
['recycle', '/api/browsers/account-a', 'DELETE'],
|
||||
])('keeps %s as an explicit domain action', async (action, path, method) => {
|
||||
const fetch = vi.fn().mockResolvedValue(new Response(null, { status: 204 }))
|
||||
vi.stubGlobal('fetch', fetch)
|
||||
|
||||
await dataProvider.browserAction('account-a', action)
|
||||
expect(fetch).toHaveBeenCalledWith(path, { method })
|
||||
})
|
||||
|
||||
it('preserves API error messages and status codes', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({ error: '网关不可用' }), { status: 502 })))
|
||||
|
||||
await expect(dataProvider.getList('browsers')).rejects.toMatchObject({ message: '网关不可用', status: 502 })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useState } from 'react'
|
||||
import { NavLink } from 'react-router-dom'
|
||||
import {
|
||||
AppBar,
|
||||
Box,
|
||||
Drawer,
|
||||
IconButton,
|
||||
ListItemButton,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Toolbar,
|
||||
Typography,
|
||||
useMediaQuery,
|
||||
} from '@mui/material'
|
||||
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 MenuIcon from '@mui/icons-material/Menu'
|
||||
import WidgetsOutlined from '@mui/icons-material/WidgetsOutlined'
|
||||
import { useTheme } from '@mui/material/styles'
|
||||
|
||||
const expandedWidth = 240
|
||||
const collapsedWidth = 76
|
||||
|
||||
export function CreatorHubMenu({ collapsed, onNavigate }) {
|
||||
return (
|
||||
<>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.4, height: 70, px: collapsed ? 2.5 : 2.75 }}>
|
||||
<WidgetsOutlined sx={{ color: '#1680ff', fontSize: 30 }} />
|
||||
{collapsed ? null : <Typography variant="h6" sx={{ color: '#fff', fontWeight: 760 }}>CreatorHub</Typography>}
|
||||
</Box>
|
||||
<Box component="nav" aria-label="主导航" sx={{ px: 1.5, pt: 4 }}>
|
||||
<ListItemButton component={NavLink} to="/browsers" 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' }}><DnsOutlined /></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="审计记录" />}
|
||||
</ListItemButton>
|
||||
</Box>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function CreatorHubLayout({ children }) {
|
||||
const theme = useTheme()
|
||||
const desktop = useMediaQuery(theme.breakpoints.up('md'))
|
||||
const [collapsed, setCollapsed] = useState(false)
|
||||
const [mobileOpen, setMobileOpen] = useState(false)
|
||||
const width = collapsed ? collapsedWidth : expandedWidth
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', minHeight: '100vh', bgcolor: 'background.default' }}>
|
||||
<AppBar position="fixed" color="inherit" elevation={0} sx={{ width: desktop ? `calc(100% - ${width}px)` : '100%', ml: desktop ? `${width}px` : 0, borderBottom: '1px solid', borderColor: 'divider', transition: theme.transitions.create(['width', 'margin']) }}>
|
||||
<Toolbar sx={{ minHeight: '70px !important' }}>
|
||||
<IconButton aria-label={desktop ? (collapsed ? '展开导航' : '收起导航') : '打开导航'} onClick={() => desktop ? setCollapsed(value => !value) : setMobileOpen(true)} edge="start">
|
||||
<MenuIcon />
|
||||
</IconButton>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
|
||||
<Drawer variant={desktop ? 'permanent' : 'temporary'} open={desktop || mobileOpen} onClose={() => setMobileOpen(false)} ModalProps={{ keepMounted: true }} sx={{ width: desktop ? width : expandedWidth, flexShrink: 0, '& .MuiDrawer-paper': { width: desktop ? width : expandedWidth, border: 0, bgcolor: '#061a38', color: '#fff', transition: theme.transitions.create('width'), overflowX: 'hidden' } }}>
|
||||
<CreatorHubMenu collapsed={desktop && collapsed} onNavigate={() => setMobileOpen(false)} />
|
||||
{desktop ? <IconButton aria-label={collapsed ? '展开导航' : '收起导航'} onClick={() => setCollapsed(value => !value)} sx={{ position: 'absolute', right: 16, bottom: 24, color: '#a9b7cc', border: '1px solid #35506f' }}>{collapsed ? <ChevronRight /> : <ChevronLeft />}</IconButton> : null}
|
||||
</Drawer>
|
||||
|
||||
<Box component="main" sx={{ width: desktop ? `calc(100% - ${width}px)` : '100%', mt: '70px', px: { xs: 2, sm: 3, lg: 4.5 }, py: { xs: 3, lg: 4.5 }, transition: theme.transitions.create('width') }}>
|
||||
<Box sx={{ width: '100%', maxWidth: 1280 }}>{children}</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
+18
-146
@@ -1,149 +1,21 @@
|
||||
import { StrictMode, useEffect, useState } from 'react'
|
||||
import { StrictMode } from 'react'
|
||||
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 { BrowserList } from './BrowserList'
|
||||
import { dataProvider } from './dataProvider'
|
||||
import { CreatorHubLayout } from './layout'
|
||||
import { theme } from './theme'
|
||||
import './styles.css'
|
||||
|
||||
const statusLabels = {
|
||||
created: '已创建',
|
||||
running: '运行中',
|
||||
exited: '已停止',
|
||||
restarting: '重启中',
|
||||
paused: '已暂停',
|
||||
}
|
||||
|
||||
async function api(path = '', options) {
|
||||
const response = await fetch(`/api/browsers${path}`, options)
|
||||
if (!response.ok) {
|
||||
const body = await response.json().catch(() => ({}))
|
||||
throw new Error(body.error || `请求失败 (${response.status})`)
|
||||
}
|
||||
return response.status === 204 ? null : response.json()
|
||||
}
|
||||
|
||||
function Icon({ name }) {
|
||||
const paths = {
|
||||
runtime: <><rect x="3" y="4" width="18" height="16" rx="2"/><path d="m8 9 3 3-3 3M13 15h3"/></>,
|
||||
audit: <><path d="M9 5H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2h-3"/><rect x="9" y="3" width="6" height="4" rx="1"/></>,
|
||||
shield: <path d="M12 3 5 6v5c0 4.6 2.9 8.1 7 10 4.1-1.9 7-5.4 7-10V6l-7-3Z"/>,
|
||||
play: <path d="m9 7 8 5-8 5V7Z"/>,
|
||||
stop: <rect x="7" y="7" width="10" height="10" rx="1"/>,
|
||||
trash: <><path d="M4 7h16M9 7V4h6v3M7 7l1 14h8l1-14M10 11v6M14 11v6"/></>,
|
||||
}
|
||||
return <svg aria-hidden="true" viewBox="0 0 24 24" className="icon">{paths[name]}</svg>
|
||||
}
|
||||
|
||||
function RuntimeRow({ runtime, busy, onAction }) {
|
||||
const running = runtime.state === 'running'
|
||||
return (
|
||||
<tr>
|
||||
<td data-label="环境"><strong>{runtime.name}</strong><small>{runtime.status}</small></td>
|
||||
<td data-label="状态"><span className={`state state-${runtime.state}`}>{statusLabels[runtime.state] || runtime.state}</span></td>
|
||||
<td data-label="Profile"><code>{runtime.profile}</code></td>
|
||||
<td data-label="CDP"><code>{runtime.endpoint}</code></td>
|
||||
<td data-label="操作" className="actions">
|
||||
<button className="button button-outline" disabled={busy || running} onClick={() => onAction(runtime.name, 'start')}><Icon name="play" />启动</button>
|
||||
<button className="button button-neutral" disabled={busy || !running} onClick={() => onAction(runtime.name, 'stop')}><Icon name="stop" />停止</button>
|
||||
<button className="button button-danger" disabled={busy} onClick={() => onAction(runtime.name, 'delete')}><Icon name="trash" />回收</button>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [runtimes, setRuntimes] = useState([])
|
||||
const [name, setName] = useState('')
|
||||
const [seed, setSeed] = useState('1000')
|
||||
const [busy, setBusy] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
|
||||
async function refresh(signal) {
|
||||
try {
|
||||
setRuntimes(await api('', { signal }))
|
||||
setError('')
|
||||
} catch (reason) {
|
||||
if (reason.name !== 'AbortError') setError(reason.message)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
refresh(controller.signal)
|
||||
const timer = window.setInterval(() => refresh(controller.signal), 3000)
|
||||
return () => {
|
||||
window.clearInterval(timer)
|
||||
controller.abort()
|
||||
}
|
||||
}, [])
|
||||
|
||||
async function createRuntime(event) {
|
||||
event.preventDefault()
|
||||
setBusy('create')
|
||||
setError('')
|
||||
try {
|
||||
await api('', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, seed: Number(seed) }),
|
||||
})
|
||||
setName('')
|
||||
await refresh()
|
||||
} catch (reason) {
|
||||
setError(reason.message)
|
||||
} finally {
|
||||
setBusy('')
|
||||
}
|
||||
}
|
||||
|
||||
async function act(runtimeName, action) {
|
||||
if (action === 'delete' && !window.confirm(`回收 ${runtimeName}?Profile 数据卷将保留。`)) return
|
||||
setBusy(runtimeName)
|
||||
setError('')
|
||||
try {
|
||||
await api(`/${runtimeName}${action === 'delete' ? '' : `/${action}`}`, { method: action === 'delete' ? 'DELETE' : 'POST' })
|
||||
await refresh()
|
||||
} catch (reason) {
|
||||
setError(reason.message)
|
||||
} finally {
|
||||
setBusy('')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="shell">
|
||||
<aside className="sidebar">
|
||||
<a className="brand" href="/">CreatorHub</a>
|
||||
<nav aria-label="主导航">
|
||||
<a className="nav-item active" href="/"><Icon name="runtime" />运行环境</a>
|
||||
<span className="nav-item disabled"><Icon name="audit" />审计记录</span>
|
||||
</nav>
|
||||
</aside>
|
||||
<main>
|
||||
<header>
|
||||
<h1>运行环境</h1>
|
||||
<p>启动、停止并回收隔离的浏览器 Profile</p>
|
||||
</header>
|
||||
|
||||
<form className="create-form" onSubmit={createRuntime}>
|
||||
<label>环境名称<input required pattern="[a-z0-9](?:[a-z0-9]|-){0,31}" value={name} onChange={(event) => setName(event.target.value)} placeholder="例如:account-a" /></label>
|
||||
<label>Fingerprint Seed<input required min="1" max="2147483647" type="number" value={seed} onChange={(event) => setSeed(event.target.value)} /></label>
|
||||
<button className="button button-primary" disabled={busy === 'create'}>{busy === 'create' ? '创建中…' : '创建环境'}</button>
|
||||
</form>
|
||||
|
||||
<section className="runtime-list" aria-labelledby="runtime-list-title">
|
||||
<h2 id="runtime-list-title" className="sr-only">浏览器运行环境</h2>
|
||||
<table>
|
||||
<thead><tr><th>环境</th><th>状态</th><th>Profile</th><th>CDP</th><th>操作</th></tr></thead>
|
||||
<tbody>
|
||||
{runtimes.map((runtime) => <RuntimeRow key={runtime.id} runtime={runtime} busy={busy === runtime.name} onAction={act} />)}
|
||||
</tbody>
|
||||
</table>
|
||||
{runtimes.length === 0 ? <p className="empty">暂无运行环境,创建第一个隔离 Profile。</p> : null}
|
||||
</section>
|
||||
|
||||
{error ? <p className="error" role="alert">{error}</p> : null}
|
||||
<p className="security-note"><Icon name="shield" />Docker socket 仅由受限网关访问</p>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
createRoot(document.getElementById('root')).render(<StrictMode><App /></StrictMode>)
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<StrictMode>
|
||||
<ThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
<CoreAdmin dataProvider={dataProvider} layout={CreatorHubLayout} title="CreatorHub" disableTelemetry>
|
||||
<Resource name="browsers" list={BrowserList} options={{ label: '运行环境' }} />
|
||||
</CoreAdmin>
|
||||
</ThemeProvider>
|
||||
</StrictMode>,
|
||||
)
|
||||
|
||||
+8
-84
@@ -1,93 +1,17 @@
|
||||
:root {
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
color: #111827;
|
||||
background: #ffffff;
|
||||
background: #fff;
|
||||
font-synthesis: none;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; min-width: 320px; min-height: 100vh; }
|
||||
button, input { font: inherit; }
|
||||
button:focus-visible, input:focus-visible, a:focus-visible { outline: 3px solid #93c5fd; outline-offset: 2px; }
|
||||
.shell { min-height: 100vh; display: grid; grid-template-columns: 256px 1fr; }
|
||||
.sidebar { padding: 30px 12px; color: #fff; background: #061a38; }
|
||||
.brand { display: block; margin: 0 12px 38px; color: inherit; font-size: 28px; font-weight: 760; text-decoration: none; letter-spacing: -1px; }
|
||||
.sidebar nav { display: grid; gap: 12px; }
|
||||
.nav-item { display: flex; align-items: center; gap: 14px; min-height: 60px; padding: 0 18px; border-radius: 8px; color: #a9b7cc; font-size: 17px; font-weight: 650; text-decoration: none; }
|
||||
.nav-item.active { color: #fff; background: #0866ef; }
|
||||
.nav-item.disabled { opacity: .7; }
|
||||
.icon { width: 22px; height: 22px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; flex: 0 0 auto; }
|
||||
main { width: min(100%, 1280px); padding: 44px 36px 72px; }
|
||||
header { margin-bottom: 30px; }
|
||||
h1 { margin: 0 0 10px; font-size: 40px; line-height: 1.15; letter-spacing: -1.4px; }
|
||||
header p { margin: 0; color: #667085; font-size: 18px; }
|
||||
.create-form { display: grid; grid-template-columns: minmax(220px, 1fr) minmax(220px, 1fr) 210px; gap: 28px; align-items: end; padding: 30px; border: 1px solid #d7dde7; border-radius: 9px; box-shadow: 0 4px 14px rgb(16 24 40 / 6%); }
|
||||
label { display: grid; gap: 10px; font-size: 15px; font-weight: 650; }
|
||||
input { width: 100%; height: 52px; padding: 0 14px; border: 1px solid #c7cfdb; border-radius: 7px; color: #111827; background: #fff; font-size: 16px; }
|
||||
input::placeholder { color: #929daf; }
|
||||
.button { display: inline-flex; align-items: center; justify-content: center; gap: 7px; height: 44px; padding: 0 14px; border: 1px solid; border-radius: 7px; background: #fff; font-size: 14px; font-weight: 650; cursor: pointer; }
|
||||
.button .icon { width: 17px; height: 17px; }
|
||||
.button:disabled { cursor: not-allowed; opacity: .4; }
|
||||
.button-primary { height: 52px; border-color: #0866ef; color: #fff; background: #0866ef; font-size: 16px; }
|
||||
.button-outline { border-color: #0866ef; color: #0866ef; }
|
||||
.button-neutral { border-color: #aab4c3; color: #344054; }
|
||||
.button-danger { border-color: #ef4444; color: #dc2626; }
|
||||
.runtime-list { margin-top: 42px; overflow: hidden; border: 1px solid #d7dde7; border-radius: 9px; }
|
||||
table { width: 100%; border-collapse: collapse; table-layout: fixed; }
|
||||
th, td { padding: 23px 20px; border-bottom: 1px solid #d7dde7; text-align: left; vertical-align: middle; }
|
||||
th { color: #1f2937; font-size: 14px; font-weight: 700; }
|
||||
th:nth-child(1) { width: 19%; } th:nth-child(2) { width: 12%; } th:nth-child(3) { width: 17%; } th:nth-child(4) { width: 22%; } th:nth-child(5) { width: 30%; }
|
||||
tbody tr:last-child td { border-bottom: 0; }
|
||||
td strong, td small { display: block; }
|
||||
td small { margin-top: 6px; color: #7a8699; }
|
||||
code { color: #475467; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 13px; overflow-wrap: anywhere; }
|
||||
.state { display: inline-flex; align-items: center; gap: 7px; font-weight: 650; }
|
||||
.state::before { width: 8px; height: 8px; border-radius: 50%; background: #6b7280; content: ""; }
|
||||
.state-running { color: #079455; } .state-running::before { background: #12b76a; }
|
||||
.state-exited { color: #d97706; } .state-exited::before { background: #f59e0b; }
|
||||
.actions { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.empty { margin: 0; padding: 52px 20px; color: #667085; text-align: center; }
|
||||
.error { margin: 20px 0 0; padding: 14px 16px; border: 1px solid #fecaca; border-radius: 7px; color: #b42318; background: #fef2f2; }
|
||||
.security-note { display: flex; align-items: center; gap: 12px; margin: 32px 0 0; padding: 19px 24px; border: 1px solid #bfdbfe; border-radius: 8px; color: #243b5a; background: #f8fbff; }
|
||||
.security-note .icon { color: #164e85; }
|
||||
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
|
||||
html, body, #root { min-width: 320px; min-height: 100%; margin: 0; }
|
||||
|
||||
@media (max-width: 1000px) {
|
||||
.shell { grid-template-columns: 82px 1fr; }
|
||||
.brand { margin: 4px 8px 38px; font-size: 0; }
|
||||
.brand::before { font-size: 22px; content: "CH"; }
|
||||
.nav-item { justify-content: center; padding: 0; font-size: 0; }
|
||||
.create-form { grid-template-columns: 1fr 1fr; }
|
||||
.button-primary { grid-column: 1 / -1; }
|
||||
th:nth-child(4), td:nth-child(4) { display: none; }
|
||||
th:nth-child(1) { width: 24%; } th:nth-child(2) { width: 18%; } th:nth-child(3) { width: 25%; } th:nth-child(5) { width: 33%; }
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.shell { display: block; }
|
||||
.sidebar { display: flex; align-items: center; justify-content: space-between; padding: 12px 18px; }
|
||||
.brand { margin: 0; font-size: 20px; }
|
||||
.brand::before { content: none; }
|
||||
.sidebar nav { display: flex; }
|
||||
.nav-item { min-height: 44px; padding: 0 14px; font-size: 14px; }
|
||||
.nav-item.disabled { display: none; }
|
||||
main { padding: 30px 18px 48px; }
|
||||
h1 { font-size: 32px; }
|
||||
.create-form { grid-template-columns: 1fr; padding: 22px; gap: 18px; }
|
||||
.button-primary { grid-column: auto; }
|
||||
.runtime-list { overflow: visible; border: 0; }
|
||||
thead { display: none; }
|
||||
tbody { display: grid; gap: 14px; }
|
||||
tr { display: grid; gap: 12px; padding: 20px; border: 1px solid #d7dde7; border-radius: 8px; }
|
||||
td, tbody tr:last-child td { display: grid; grid-template-columns: 90px 1fr; padding: 0; border: 0; }
|
||||
td::before { color: #667085; font-size: 13px; content: attr(data-label); }
|
||||
td:nth-child(4) { display: none; }
|
||||
.actions { display: flex; padding-top: 8px; }
|
||||
.actions::before { display: none; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.button { transition: background-color .15s ease, border-color .15s ease, opacity .15s ease; }
|
||||
.button:not(:disabled):hover { background: #eff6ff; }
|
||||
.button-primary:not(:disabled):hover { background: #0759d4; }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { createTheme } from '@mui/material/styles'
|
||||
|
||||
export const theme = createTheme({
|
||||
palette: {
|
||||
mode: 'light',
|
||||
primary: { main: '#0866ef', dark: '#0759d4' },
|
||||
secondary: { main: '#061a38' },
|
||||
background: { default: '#ffffff', paper: '#ffffff' },
|
||||
text: { primary: '#111827', secondary: '#667085' },
|
||||
divider: '#d7dde7',
|
||||
success: { main: '#079455' },
|
||||
warning: { main: '#d97706' },
|
||||
error: { main: '#dc2626' },
|
||||
},
|
||||
shape: { borderRadius: 8 },
|
||||
typography: {
|
||||
fontFamily: 'Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
|
||||
h1: { fontSize: '2.25rem', fontWeight: 760, lineHeight: 1.15, letterSpacing: '-0.035em' },
|
||||
button: { fontSize: '0.875rem', fontWeight: 650, textTransform: 'none' },
|
||||
},
|
||||
components: {
|
||||
MuiButton: {
|
||||
defaultProps: { disableElevation: true },
|
||||
styleOverrides: { root: { borderRadius: 7, minHeight: 40 } },
|
||||
},
|
||||
MuiOutlinedInput: {
|
||||
styleOverrides: { root: { borderRadius: 7 } },
|
||||
},
|
||||
MuiPaper: {
|
||||
styleOverrides: { root: { backgroundImage: 'none' } },
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -6,4 +6,7 @@ export default defineConfig({
|
||||
server: {
|
||||
proxy: { '/api': 'http://127.0.0.1:8080' },
|
||||
},
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user