HH-846 / HH-799: add task recovery and audit (#27)
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useGetList } from 'ra-core'
|
||||
import { Link as RouterLink, useSearchParams } from 'react-router-dom'
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
CircularProgress,
|
||||
Paper,
|
||||
Stack,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TextField,
|
||||
Typography,
|
||||
} from '@mui/material'
|
||||
import HistoryOutlined from '@mui/icons-material/HistoryOutlined'
|
||||
|
||||
const wrapAnywhere = { overflowWrap: 'anywhere', minWidth: 0 }
|
||||
const dayStart = value => value ? new Date(`${value}T00:00:00`).toISOString() : ''
|
||||
const dayEnd = value => value ? new Date(`${value}T23:59:59.999`).toISOString() : ''
|
||||
|
||||
export function auditCSV(events) {
|
||||
const fields = ['id', 'event_type', 'account_id', 'task_id', 'attempt_id', 'browser_env_alias', 'network_exit_id', 'binding_version', 'reason_code', 'outcome', 'created_at']
|
||||
const quote = value => `"${String(value ?? '').replaceAll('"', '""')}"`
|
||||
return [fields, ...events.map(event => fields.map(field => event[field]))].map(row => row.map(quote).join(',')).join('\n')
|
||||
}
|
||||
|
||||
function exportPage(events) {
|
||||
const url = URL.createObjectURL(new Blob([auditCSV(events)], { type: 'text/csv;charset=utf-8' }))
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = 'creatorhub-audit.csv'
|
||||
link.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
function AuditLinks({ event }) {
|
||||
return (
|
||||
<Stack spacing={0.5}>
|
||||
{event.account_id ? <Typography variant="body2" sx={wrapAnywhere}>账号:<RouterLink to={`/accounts/${event.account_id}`}>{event.account_id}</RouterLink></Typography> : null}
|
||||
{event.task_id ? <Typography variant="body2" sx={wrapAnywhere}>任务:<RouterLink to={`/tasks/${event.task_id}`}>{event.task_id}</RouterLink></Typography> : null}
|
||||
{event.attempt_id ? <Typography variant="body2" sx={wrapAnywhere}>attempt:<RouterLink to={`/attempts/${encodeURIComponent(event.attempt_id)}`}>{event.attempt_id}</RouterLink></Typography> : null}
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
|
||||
export function AuditList() {
|
||||
const [searchParams] = useSearchParams()
|
||||
const [accountID, setAccountID] = useState(searchParams.get('account_id') || '')
|
||||
const [taskID, setTaskID] = useState(searchParams.get('task_id') || '')
|
||||
const [attemptID, setAttemptID] = useState(searchParams.get('attempt_id') || '')
|
||||
const [browserEnvAlias, setBrowserEnvAlias] = useState(searchParams.get('browser_env_alias') || '')
|
||||
const [networkExitID, setNetworkExitID] = useState(searchParams.get('network_exit_id') || '')
|
||||
const [eventType, setEventType] = useState('')
|
||||
const [from, setFrom] = useState('')
|
||||
const [to, setTo] = useState('')
|
||||
const [page, setPage] = useState(1)
|
||||
const perPage = 25
|
||||
const filter = { account_id: accountID, task_id: taskID, attempt_id: attemptID, browser_env_alias: browserEnvAlias, network_exit_id: networkExitID, event_type: eventType, from: dayStart(from), to: dayEnd(to) }
|
||||
const { data: events = [], total = 0, error, isPending } = useGetList('audit', { pagination: { page, perPage }, filter }, { retry: false })
|
||||
|
||||
useEffect(() => { document.title = 'CreatorHub · 审计记录' }, [])
|
||||
useEffect(() => { setPage(1) }, [accountID, taskID, attemptID, browserEnvAlias, networkExitID, eventType, from, to])
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box component="header" sx={{ mb: 3.5 }}><Stack direction="row" spacing={1.5} sx={{ alignItems: 'center' }}><HistoryOutlined color="primary" fontSize="large" /><Typography variant="h1">审计记录</Typography></Stack><Typography color="text.secondary" sx={{ mt: 1 }}>追加式事件回溯;页面不提供编辑或删除</Typography></Box>
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: 'minmax(0, 1fr)', sm: 'repeat(2, minmax(0, 1fr))', lg: 'repeat(4, minmax(0, 1fr))' }, gap: 2, mb: 2.5 }}>
|
||||
<TextField label="账号" size="small" value={accountID} onChange={event => setAccountID(event.target.value)} slotProps={{ htmlInput: { 'aria-label': '按账号筛选审计' } }} />
|
||||
<TextField label="任务" size="small" value={taskID} onChange={event => setTaskID(event.target.value)} slotProps={{ htmlInput: { 'aria-label': '按任务筛选审计' } }} />
|
||||
<TextField label="Attempt" size="small" value={attemptID} onChange={event => setAttemptID(event.target.value)} slotProps={{ htmlInput: { 'aria-label': '按 attempt 筛选审计' } }} />
|
||||
<TextField label="环境" size="small" value={browserEnvAlias} onChange={event => setBrowserEnvAlias(event.target.value)} slotProps={{ htmlInput: { 'aria-label': '按环境筛选审计' } }} />
|
||||
<TextField label="出口" size="small" value={networkExitID} onChange={event => setNetworkExitID(event.target.value)} slotProps={{ htmlInput: { 'aria-label': '按出口筛选审计' } }} />
|
||||
<TextField label="事件" size="small" value={eventType} onChange={event => setEventType(event.target.value)} slotProps={{ htmlInput: { 'aria-label': '按事件筛选审计' } }} />
|
||||
<TextField label="开始日期" type="date" size="small" value={from} onChange={event => setFrom(event.target.value)} slotProps={{ inputLabel: { shrink: true }, htmlInput: { 'aria-label': '审计开始日期' } }} />
|
||||
<TextField label="结束日期" type="date" size="small" value={to} onChange={event => setTo(event.target.value)} slotProps={{ inputLabel: { shrink: true }, htmlInput: { 'aria-label': '审计结束日期' } }} />
|
||||
</Box>
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1.5} sx={{ mb: 2.5 }}><Button variant="outlined" disabled={!events.length} onClick={() => exportPage(events)}>导出当前页(脱敏)</Button>{accountID || taskID || attemptID || browserEnvAlias || networkExitID || eventType || from || to ? <Button onClick={() => { setAccountID(''); setTaskID(''); setAttemptID(''); setBrowserEnvAlias(''); setNetworkExitID(''); setEventType(''); setFrom(''); setTo('') }}>清除筛选</Button> : null}</Stack>
|
||||
{error ? <Alert severity="error">{error.message}</Alert> : null}
|
||||
{isPending ? <Box sx={{ display: 'grid', placeItems: 'center', minHeight: 240 }}><CircularProgress aria-label="正在加载审计" /></Box> : null}
|
||||
{!isPending && events.length === 0 ? <Paper variant="outlined" sx={{ py: 7, textAlign: 'center' }}><Typography color="text.secondary">{accountID || taskID || attemptID || browserEnvAlias || networkExitID || eventType || from || to ? '没有符合筛选条件的审计事件' : '尚无审计事件'}</Typography></Paper> : null}
|
||||
{events.length ? <TableContainer component={Paper} variant="outlined" sx={{ display: { xs: 'none', md: 'block' } }}><Table sx={{ tableLayout: 'fixed' }}><TableHead><TableRow><TableCell width="20%">事件</TableCell><TableCell width="27%">关联</TableCell><TableCell width="24%">环境 / 出口</TableCell><TableCell width="29%">原因 / 时间</TableCell></TableRow></TableHead><TableBody>{events.map(event => <TableRow key={event.id}><TableCell><Typography fontWeight={700} sx={wrapAnywhere}>{event.event_type}</Typography><Typography variant="caption">#{event.id}</Typography></TableCell><TableCell><AuditLinks event={event} /></TableCell><TableCell><Typography variant="body2" sx={wrapAnywhere}>{event.browser_env_alias ? <RouterLink to={`/browsers/${encodeURIComponent(event.browser_env_alias)}`}>{event.browser_env_alias}</RouterLink> : '—'} / {event.network_exit_id ? <RouterLink to={`/network-exits/${encodeURIComponent(event.network_exit_id)}`}>{event.network_exit_id}</RouterLink> : '—'}</Typography><Typography variant="caption">绑定版本 {event.binding_version || '—'}</Typography></TableCell><TableCell><Typography variant="body2" sx={wrapAnywhere}>{event.reason_code || event.outcome || '—'}</Typography><Typography variant="caption" color="text.secondary">{new Date(event.created_at).toLocaleString('zh-CN')}</Typography></TableCell></TableRow>)}</TableBody></Table></TableContainer> : null}
|
||||
{events.length ? <Stack spacing={2} sx={{ display: { xs: 'flex', md: 'none' } }}>{events.map(event => <Paper key={event.id} variant="outlined" sx={{ p: 2.5 }}><Stack spacing={1}><Typography fontWeight={700} sx={wrapAnywhere}>{event.event_type} · #{event.id}</Typography><AuditLinks event={event} /><Typography variant="body2" sx={wrapAnywhere}>环境 / 出口:{event.browser_env_alias ? <RouterLink to={`/browsers/${encodeURIComponent(event.browser_env_alias)}`}>{event.browser_env_alias}</RouterLink> : '—'} / {event.network_exit_id ? <RouterLink to={`/network-exits/${encodeURIComponent(event.network_exit_id)}`}>{event.network_exit_id}</RouterLink> : '—'} · 绑定版本 {event.binding_version || '—'}</Typography><Typography variant="body2" sx={wrapAnywhere}>原因:{event.reason_code || event.outcome || '—'}</Typography><Typography variant="caption" color="text.secondary">{new Date(event.created_at).toLocaleString('zh-CN')}</Typography></Stack></Paper>)}</Stack> : null}
|
||||
{total > perPage ? <Stack direction="row" spacing={2} sx={{ mt: 2.5, alignItems: 'center', justifyContent: 'flex-end' }}><Button disabled={page === 1} onClick={() => setPage(value => value - 1)}>上一页</Button><Typography>第 {page} / {Math.ceil(total / perPage)} 页</Typography><Button disabled={page * perPage >= total} onClick={() => setPage(value => value + 1)}>下一页</Button></Stack> : null}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { CoreAdminContext } from 'ra-core'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import { AuditList, auditCSV } from './AuditList'
|
||||
|
||||
afterEach(() => { cleanup(); vi.restoreAllMocks() })
|
||||
|
||||
describe('AuditList', () => {
|
||||
it('deep-links safe correlation fields and exposes no mutation or secret details', async () => {
|
||||
const dataProvider = {
|
||||
getList: vi.fn().mockResolvedValue({ data: [{
|
||||
id: 7, event_type: 'task_verified', account_id: 'account-a', task_id: 'task-a', attempt_id: 'attempt-a',
|
||||
browser_env_alias: 'env-a', network_exit_id: 'exit-a', binding_version: 4, reason_code: 'manual_verification_recorded',
|
||||
details: { token: 'must-not-render' }, created_at: '2026-08-30T00:00:00Z',
|
||||
}], total: 1 }),
|
||||
getOne: vi.fn(), getMany: vi.fn(), getManyReference: vi.fn(), create: vi.fn(), update: vi.fn(), updateMany: vi.fn(), delete: vi.fn(), deleteMany: vi.fn(),
|
||||
}
|
||||
render(<MemoryRouter initialEntries={['/audit?task_id=task-a']}><CoreAdminContext dataProvider={dataProvider}><AuditList /></CoreAdminContext></MemoryRouter>)
|
||||
|
||||
expect((await screen.findAllByRole('link', { name: 'task-a' })).length).toBeGreaterThan(0)
|
||||
expect(screen.getAllByRole('link', { name: 'account-a' })[0].getAttribute('href')).toBe('/accounts/account-a')
|
||||
expect(screen.getAllByRole('link', { name: 'attempt-a' })[0].getAttribute('href')).toBe('/attempts/attempt-a')
|
||||
expect(screen.getAllByRole('link', { name: 'env-a' })[0].getAttribute('href')).toBe('/browsers/env-a')
|
||||
expect(screen.getAllByRole('link', { name: 'exit-a' })[0].getAttribute('href')).toBe('/network-exits/exit-a')
|
||||
expect(screen.queryByText('must-not-render')).toBeNull()
|
||||
expect(screen.queryByRole('button', { name: /编辑|删除/ })).toBeNull()
|
||||
})
|
||||
|
||||
it('exports only allowlisted correlation columns and requests the next page', async () => {
|
||||
const event = {
|
||||
id: 7, event_type: 'task_verified', task_id: 'task-a', attempt_id: 'attempt-a', browser_env_alias: 'env-a', network_exit_id: 'exit-a',
|
||||
details: { api_key: 'api-secret', private_key: 'private-secret', credential_key: 'credential-secret', authorization_header: 'auth-secret', proxy_url: 'proxy-secret' },
|
||||
created_at: '2026-08-30T00:00:00Z',
|
||||
}
|
||||
const csv = auditCSV([event])
|
||||
expect(csv).toContain('attempt-a')
|
||||
for (const secret of ['api-secret', 'private-secret', 'credential-secret', 'auth-secret', 'proxy-secret']) expect(csv).not.toContain(secret)
|
||||
|
||||
const getList = vi.fn().mockImplementation((_resource, params) => Promise.resolve({
|
||||
data: [{ ...event, id: params.pagination.page }], total: 26,
|
||||
}))
|
||||
const dataProvider = {
|
||||
getList, getOne: vi.fn(), getMany: vi.fn(), getManyReference: vi.fn(), create: vi.fn(), update: vi.fn(), updateMany: vi.fn(), delete: vi.fn(), deleteMany: vi.fn(),
|
||||
}
|
||||
render(<MemoryRouter><CoreAdminContext dataProvider={dataProvider}><AuditList /></CoreAdminContext></MemoryRouter>)
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: '下一页' }))
|
||||
await waitFor(() => expect(getList.mock.calls.some(([, params]) => params.pagination.page === 2)).toBe(true))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,148 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useDataProvider, useGetList, useGetOne } from 'ra-core'
|
||||
import { Link as RouterLink, useParams } from 'react-router-dom'
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
CircularProgress,
|
||||
MenuItem,
|
||||
Paper,
|
||||
Stack,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TextField,
|
||||
Typography,
|
||||
} from '@mui/material'
|
||||
import AssignmentTurnedInOutlined from '@mui/icons-material/AssignmentTurnedInOutlined'
|
||||
|
||||
const wrapAnywhere = { overflowWrap: 'anywhere', minWidth: 0 }
|
||||
|
||||
export const taskLabels = {
|
||||
queued: '已排队',
|
||||
executing: '执行中',
|
||||
succeeded: '已成功',
|
||||
failed: '失败',
|
||||
needs_confirmation: '需要人工确认',
|
||||
policy_hold: '策略暂停',
|
||||
cancelled: '已取消',
|
||||
}
|
||||
|
||||
export const reasonLabels = {
|
||||
account_paused: '账号已暂停',
|
||||
account_revoked: '账号授权已撤销',
|
||||
binding_missing: '运行环境绑定缺失',
|
||||
environment_missing: '原运行环境不可用',
|
||||
exit_missing: '固定出口缺失',
|
||||
exit_unhealthy: '固定出口失败或发生漂移',
|
||||
runtime_stop_pending: '停止结果未知,等待人工核验',
|
||||
runtime_missing: '原运行实例不可用',
|
||||
runtime_lease_expired: '运行实例租约已过期',
|
||||
execution_lease_expired: '执行租约过期,结果未知',
|
||||
task_result_uncertain: '执行结果不确定',
|
||||
task_policy_hold: '执行器请求策略暂停',
|
||||
account_version_changed: '账号版本已变化',
|
||||
draft_version_changed: '草稿版本已变化',
|
||||
confirmation_missing: '确认快照缺失',
|
||||
confirmation_version_changed: '确认版本已变化',
|
||||
binding_version_changed: '环境或出口版本已变化',
|
||||
legacy_confirmation_required: '历史任务结果未知,等待人工核验',
|
||||
}
|
||||
|
||||
const stateText = state => taskLabels[state] || `未知状态:${state}`
|
||||
const reasonText = reason => reason ? (reasonLabels[reason] || `未知停止原因:${reason}`) : '无'
|
||||
|
||||
function TaskSummary({ task }) {
|
||||
return (
|
||||
<>
|
||||
<Typography component={RouterLink} to={`/tasks/${task.id}`} fontWeight={700} color="primary" sx={wrapAnywhere}>{task.id}</Typography>
|
||||
<Typography variant="body2">{stateText(task.state)}</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={wrapAnywhere}>{reasonText(task.hold_reason)}</Typography>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function TaskList() {
|
||||
const [accountID, setAccountID] = useState('')
|
||||
const [state, setState] = useState('')
|
||||
const { data: tasks = [], error, isPending } = useGetList('tasks', { filter: { account_id: accountID, state } }, { retry: false })
|
||||
|
||||
useEffect(() => { document.title = 'CreatorHub · 任务中心' }, [])
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box component="header" sx={{ mb: 3.5 }}>
|
||||
<Stack direction="row" spacing={1.5} sx={{ alignItems: 'center' }}><AssignmentTurnedInOutlined color="primary" fontSize="large" /><Typography variant="h1">任务中心</Typography></Stack>
|
||||
<Typography color="text.secondary" sx={{ mt: 1 }}>逐条处理暂停、版本变化与未知执行结果;不会自动重试或更换出口</Typography>
|
||||
</Box>
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ mb: 2.5 }}>
|
||||
<TextField label="按账号筛选" size="small" value={accountID} onChange={event => setAccountID(event.target.value)} slotProps={{ htmlInput: { 'aria-label': '按账号筛选' } }} />
|
||||
<TextField select label="按状态筛选" size="small" value={state} onChange={event => setState(event.target.value)} sx={{ minWidth: 220 }} slotProps={{ htmlInput: { 'aria-label': '按状态筛选' } }}>
|
||||
<MenuItem value="">全部状态</MenuItem>
|
||||
{Object.entries(taskLabels).map(([value, label]) => <MenuItem key={value} value={value}>{label}</MenuItem>)}
|
||||
</TextField>
|
||||
</Stack>
|
||||
{error ? <Alert severity="error">{error.message}</Alert> : null}
|
||||
{isPending ? <Box sx={{ display: 'grid', placeItems: 'center', minHeight: 240 }}><CircularProgress aria-label="正在加载任务" /></Box> : null}
|
||||
{!isPending && tasks.length === 0 ? <Paper variant="outlined" sx={{ py: 7, textAlign: 'center' }}><Typography color="text.secondary">{accountID || state ? '没有符合筛选条件的任务' : '尚无任务'}</Typography>{accountID || state ? <Button onClick={() => { setAccountID(''); setState('') }} sx={{ mt: 1 }}>清除筛选</Button> : null}</Paper> : null}
|
||||
{tasks.length > 0 ? <TableContainer component={Paper} variant="outlined" sx={{ display: { xs: 'none', md: 'block' } }}><Table sx={{ tableLayout: 'fixed' }}><TableHead><TableRow><TableCell width="30%">任务</TableCell><TableCell width="20%">账号</TableCell><TableCell width="20%">确认</TableCell><TableCell width="30%">更新时间</TableCell></TableRow></TableHead><TableBody>{tasks.map(task => <TableRow key={task.id}><TableCell><TaskSummary task={task} /></TableCell><TableCell sx={wrapAnywhere}><RouterLink to={`/accounts/${task.account_id}`}>{task.account_id}</RouterLink></TableCell><TableCell sx={wrapAnywhere}>{task.confirmation_id || '未确认'}</TableCell><TableCell>{new Date(task.updated_at || task.created_at).toLocaleString('zh-CN')}</TableCell></TableRow>)}</TableBody></Table></TableContainer> : null}
|
||||
{tasks.length > 0 ? <Stack spacing={2} sx={{ display: { xs: 'flex', md: 'none' } }}>{tasks.map(task => <Paper key={task.id} variant="outlined" sx={{ p: 2.5 }}><Stack spacing={1.25}><TaskSummary task={task} /><Typography variant="body2" sx={wrapAnywhere}>账号:<RouterLink to={`/accounts/${task.account_id}`}>{task.account_id}</RouterLink></Typography><Typography variant="body2" sx={wrapAnywhere}>确认:{task.confirmation_id || '未确认'}</Typography><Typography variant="caption" color="text.secondary">{new Date(task.updated_at || task.created_at).toLocaleString('zh-CN')}</Typography></Stack></Paper>)}</Stack> : null}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
function actionError(error) {
|
||||
const reason = error?.body?.reason_code
|
||||
if (error?.status === 409) return `当前版本仍不允许恢复(409):${reasonText(reason)}`
|
||||
if (error?.status === 503) return `原资源尚未恢复(503):${reasonText(reason)}`
|
||||
return error?.message || '操作失败'
|
||||
}
|
||||
|
||||
export function TaskDetail() {
|
||||
const { id } = useParams()
|
||||
const dataProvider = useDataProvider()
|
||||
const [verification, setVerification] = useState('not_executed')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [message, setMessage] = useState(null)
|
||||
const { data: task, error, isPending, refetch } = useGetOne('tasks', { id }, { retry: false })
|
||||
|
||||
useEffect(() => { document.title = 'CreatorHub · 任务详情' }, [])
|
||||
|
||||
if (isPending) return <Box sx={{ display: 'grid', placeItems: 'center', minHeight: 300 }}><CircularProgress aria-label="正在加载任务详情" /></Box>
|
||||
if (error || !task) return <Alert severity="error">{error?.message || '任务不存在'}</Alert>
|
||||
|
||||
async function act(action, data) {
|
||||
setBusy(true)
|
||||
setMessage(null)
|
||||
try {
|
||||
await dataProvider.taskAction(task.id, action, data)
|
||||
setMessage({ severity: 'success', text: action === 'verify' ? '人工核验已记录;现在只能按该结论恢复或结束。' : action === 'resume' ? '任务已恢复排队。' : '任务已按核验结论结束。' })
|
||||
await refetch()
|
||||
} catch (reason) {
|
||||
setMessage({ severity: 'error', text: actionError(reason) })
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const confirmation = task.confirmation || {}
|
||||
const unknownReason = task.hold_reason && !reasonLabels[task.hold_reason]
|
||||
return (
|
||||
<Box>
|
||||
<Box component="header" sx={{ mb: 3.5 }}><Typography variant="h1">任务详情</Typography><Typography color="text.secondary" sx={{ mt: 1, ...wrapAnywhere }}>{task.id}</Typography></Box>
|
||||
{message ? <Alert severity={message.severity} aria-live="polite" sx={{ mb: 2.5 }}>{message.text}</Alert> : null}
|
||||
{unknownReason ? <Alert severity="warning" sx={{ mb: 2.5 }}>后端返回了未知停止原因“{task.hold_reason}”;为避免不安全操作,本页不提供恢复动作。</Alert> : null}
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: 'minmax(0, 1fr)', lg: 'repeat(2, minmax(0, 1fr))' }, gap: 2.5 }}>
|
||||
<Paper variant="outlined" sx={{ p: 3, minWidth: 0 }}><Stack spacing={1.25}><Typography variant="h6">当前状态</Typography><Typography>{stateText(task.state)}</Typography><Typography>停止原因:{reasonText(task.hold_reason)}</Typography><Typography>人工核验:{task.verification_result || '尚未记录'}</Typography><Button component={RouterLink} to={`/audit?task_id=${encodeURIComponent(task.id)}`} variant="outlined">查看关联审计</Button></Stack></Paper>
|
||||
<Paper variant="outlined" sx={{ p: 3, minWidth: 0 }}><Stack spacing={1.25}><Typography variant="h6">确认快照</Typography><Typography sx={wrapAnywhere}>确认:{confirmation.id || '未记录'} · v{confirmation.version || '—'}</Typography><Typography>账号版本 {confirmation.account_version || '—'} · 草稿版本 {confirmation.draft_version || '—'}</Typography><Typography sx={wrapAnywhere}>环境:{confirmation.browser_env_alias || '未记录'} · 出口:{confirmation.network_exit_id || '未记录'} · 绑定版本:{confirmation.binding_version || '未记录'}</Typography><Stack direction={{ xs: 'column', sm: 'row' }} spacing={1}><Button component={RouterLink} to={`/accounts/${task.account_id}`} variant="outlined">查看账号</Button><Button component={RouterLink} to={`/drafts/${task.draft_id}`} variant="outlined">查看草稿</Button></Stack></Stack></Paper>
|
||||
<Paper variant="outlined" sx={{ p: 3, minWidth: 0 }}><Stack spacing={1.25}><Typography variant="h6">执行关联</Typography><Typography sx={wrapAnywhere}>环境:{task.browser_env_alias || '未记录'}</Typography><Typography sx={wrapAnywhere}>固定出口:{task.network_exit_id || '未记录'}</Typography><Typography sx={wrapAnywhere}>运行实例:{task.runtime_instance_id || '未记录'} · 绑定版本:{task.binding_version || '未记录'}</Typography><Stack direction={{ xs: 'column', sm: 'row' }} spacing={1}>{task.browser_env_alias ? <Button component={RouterLink} to={`/browsers/${encodeURIComponent(task.browser_env_alias)}`} variant="outlined">查看原环境</Button> : null}{task.network_exit_id ? <Button component={RouterLink} to={`/network-exits/${encodeURIComponent(task.network_exit_id)}`} variant="outlined">查看原出口</Button> : null}</Stack></Stack></Paper>
|
||||
<Paper variant="outlined" sx={{ p: 3, minWidth: 0 }}><Stack spacing={2}><Typography variant="h6">唯一安全动作</Typography>{task.readiness_reason ? <Alert severity="warning">当前仍被阻断:{reasonText(task.readiness_reason)}。修复原资源并刷新后,才会开放恢复。</Alert> : null}{task.allowed_action === 'verify' ? <><Alert severity="warning">先在原环境和原出口核验真实结果。选择“未执行”不会立即重试,仍需下一步显式恢复。</Alert><TextField select label="人工核验结论" value={verification} onChange={event => setVerification(event.target.value)} slotProps={{ htmlInput: { 'aria-label': '人工核验结论' } }}><MenuItem value="not_executed">确认未执行,可评估恢复</MenuItem><MenuItem value="succeeded">确认已成功</MenuItem><MenuItem value="failed">确认已失败</MenuItem></TextField><Button variant="contained" disabled={busy} onClick={() => act('verify', { result: verification })}>记录人工核验</Button></> : null}{task.allowed_action === 'resume' ? <Button variant="contained" disabled={busy} onClick={() => act('resume')}>按核验结论恢复排队</Button> : null}{task.allowed_action === 'finish' ? <Button variant="contained" disabled={busy} onClick={() => act('finish')}>按核验结论结束任务</Button> : null}{task.allowed_action === 'reconfirm' ? <><Alert severity="warning">版本已变化,旧确认不可继续使用。</Alert><Button component={RouterLink} to={`/drafts/${task.draft_id}`} variant="contained">重新核对并确认草稿</Button></> : null}{!task.allowed_action ? <Typography color="text.secondary">当前没有可安全执行的动作。</Typography> : null}</Stack></Paper>
|
||||
</Box>
|
||||
<Box component="section" sx={{ mt: 3 }}><Typography variant="h2" sx={{ mb: 2 }}>执行尝试</Typography>{task.attempts?.length ? <Stack spacing={1.5}>{task.attempts.map(attempt => <Paper key={attempt.id} variant="outlined" sx={{ p: 2.5, minWidth: 0 }}><Typography component={RouterLink} to={`/attempts/${encodeURIComponent(attempt.id)}`} fontWeight={700} color="primary" sx={wrapAnywhere}>{attempt.id}</Typography><Typography variant="body2">{attempt.outcome || '执行中'} · {new Date(attempt.started_at).toLocaleString('zh-CN')}</Typography><Typography variant="body2">脱敏证据:{attempt.evidence?.mock_outcome || '无可公开证据'}</Typography></Paper>)}</Stack> : <Paper variant="outlined" sx={{ p: 3 }}><Typography color="text.secondary">尚未产生执行尝试。</Typography></Paper>}</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -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 { MemoryRouter, Route, Routes } from 'react-router-dom'
|
||||
import { TaskDetail } from './TaskCenter'
|
||||
|
||||
afterEach(() => { cleanup(); vi.restoreAllMocks() })
|
||||
|
||||
const task = {
|
||||
id: 'task-a', account_id: 'account-a', draft_id: 'draft-a', confirmation_id: 'confirmation-a', state: 'needs_confirmation',
|
||||
hold_reason: 'task_result_uncertain', allowed_action: 'verify', browser_env_alias: 'env-a', network_exit_id: 'exit-a', binding_version: 4,
|
||||
confirmation: { id: 'confirmation-a', version: 1, account_version: 2, draft_version: 1, browser_env_alias: 'env-a', network_exit_id: 'exit-a', binding_version: 4 },
|
||||
attempts: [{ id: 'attempt-a', started_at: '2026-08-30T00:00:00Z', outcome: 'uncertain', evidence: { mock_outcome: 'uncertain', token: 'must-not-render' } }],
|
||||
}
|
||||
|
||||
function provider(record = task) {
|
||||
return {
|
||||
getOne: vi.fn().mockResolvedValue({ data: record }),
|
||||
taskAction: vi.fn().mockResolvedValue(),
|
||||
getList: vi.fn(), getMany: vi.fn(), getManyReference: vi.fn(), create: vi.fn(), update: vi.fn(), updateMany: vi.fn(), delete: vi.fn(), deleteMany: vi.fn(),
|
||||
}
|
||||
}
|
||||
|
||||
function renderTask(dataProvider) {
|
||||
return render(<MemoryRouter initialEntries={['/tasks/task-a']}><CoreAdminContext dataProvider={dataProvider}><Routes><Route path="/tasks/:id" element={<TaskDetail />} /></Routes></CoreAdminContext></MemoryRouter>)
|
||||
}
|
||||
|
||||
describe('TaskDetail', () => {
|
||||
it('requires a structured manual verification before any resume', async () => {
|
||||
const dataProvider = provider()
|
||||
renderTask(dataProvider)
|
||||
|
||||
expect(await screen.findByText(/执行结果不确定/)).toBeTruthy()
|
||||
expect(screen.queryByRole('button', { name: /恢复排队/ })).toBeNull()
|
||||
fireEvent.click(screen.getByRole('button', { name: '记录人工核验' }))
|
||||
await waitFor(() => expect(dataProvider.taskAction).toHaveBeenCalledWith('task-a', 'verify', { result: 'not_executed' }))
|
||||
expect(screen.queryByText('must-not-render')).toBeNull()
|
||||
expect(screen.getByRole('link', { name: 'attempt-a' }).getAttribute('href')).toBe('/attempts/attempt-a')
|
||||
expect(screen.getByRole('link', { name: '查看原环境' }).getAttribute('href')).toBe('/browsers/env-a')
|
||||
expect(screen.getByRole('link', { name: '查看原出口' }).getAttribute('href')).toBe('/network-exits/exit-a')
|
||||
})
|
||||
|
||||
it('shows only reconfirm for changed versions', async () => {
|
||||
renderTask(provider({ ...task, hold_reason: 'account_version_changed', allowed_action: 'reconfirm', attempts: [] }))
|
||||
|
||||
expect((await screen.findByRole('link', { name: '重新核对并确认草稿' })).getAttribute('href')).toBe('/drafts/draft-a')
|
||||
expect(screen.queryByRole('button', { name: '记录人工核验' })).toBeNull()
|
||||
expect(screen.queryByRole('button', { name: /恢复排队/ })).toBeNull()
|
||||
})
|
||||
|
||||
it('round-trips unknown enums without offering an unsafe action', async () => {
|
||||
renderTask(provider({ ...task, state: 'future_state', hold_reason: 'future_reason', allowed_action: '', attempts: [] }))
|
||||
|
||||
expect(await screen.findByText('未知状态:future_state')).toBeTruthy()
|
||||
expect(screen.getByText(/未知停止原因“future_reason”/)).toBeTruthy()
|
||||
expect(screen.queryByRole('button', { name: '记录人工核验' })).toBeNull()
|
||||
expect(screen.queryByRole('button', { name: /恢复排队|结束任务/ })).toBeNull()
|
||||
})
|
||||
|
||||
it('renders a NULL confirmation without exposing recovery or reconfirm actions', async () => {
|
||||
renderTask(provider({ ...task, confirmation_id: '', confirmation: {}, hold_reason: 'confirmation_missing', allowed_action: '', attempts: [] }))
|
||||
|
||||
expect(await screen.findByText(/确认:未记录/)).toBeTruthy()
|
||||
expect(screen.queryByRole('link', { name: '重新核对并确认草稿' })).toBeNull()
|
||||
expect(screen.queryByRole('button', { name: /恢复排队|记录人工核验|结束任务/ })).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useGetOne } from 'ra-core'
|
||||
import { Alert, Box, Button, CircularProgress, Paper, Stack, Typography } from '@mui/material'
|
||||
import { Link as RouterLink, useParams } from 'react-router-dom'
|
||||
|
||||
const wrapAnywhere = { overflowWrap: 'anywhere', minWidth: 0 }
|
||||
|
||||
function DetailState({ pending, error, children }) {
|
||||
if (pending) return <Box sx={{ display: 'grid', placeItems: 'center', minHeight: 300 }}><CircularProgress aria-label="正在加载详情" /></Box>
|
||||
if (error) return <Alert severity="error">{error.message}</Alert>
|
||||
return children
|
||||
}
|
||||
|
||||
export function AttemptDetail() {
|
||||
const { id } = useParams()
|
||||
const { data: attempt, error, isPending } = useGetOne('attempts', { id }, { retry: false })
|
||||
useEffect(() => { document.title = 'CreatorHub · Attempt 详情' }, [])
|
||||
return <DetailState pending={isPending} error={error}>{attempt ? <Box><Typography variant="h1" sx={wrapAnywhere}>Attempt 详情</Typography><Paper variant="outlined" sx={{ p: 3, mt: 3 }}><Stack spacing={1.5}><Typography fontWeight={700} sx={wrapAnywhere}>{attempt.id}</Typography><Typography>结果:{attempt.outcome || '执行中'}</Typography><Typography>开始:{new Date(attempt.started_at).toLocaleString('zh-CN')}</Typography><Typography sx={wrapAnywhere}>环境:{attempt.browser_env_alias || '未记录'} · 出口:{attempt.network_exit_id || '未记录'} · 绑定版本:{attempt.binding_version || '未记录'}</Typography><Stack direction={{ xs: 'column', sm: 'row' }} spacing={1}><Button component={RouterLink} to={`/tasks/${encodeURIComponent(attempt.task_id)}`} variant="outlined">返回任务</Button><Button component={RouterLink} to={`/audit?attempt_id=${encodeURIComponent(attempt.id)}`} variant="outlined">查看审计</Button>{attempt.browser_env_alias ? <Button component={RouterLink} to={`/browsers/${encodeURIComponent(attempt.browser_env_alias)}`} variant="outlined">查看环境</Button> : null}{attempt.network_exit_id ? <Button component={RouterLink} to={`/network-exits/${encodeURIComponent(attempt.network_exit_id)}`} variant="outlined">查看出口</Button> : null}</Stack></Stack></Paper></Box> : null}</DetailState>
|
||||
}
|
||||
|
||||
export function BrowserDetail() {
|
||||
const { id } = useParams()
|
||||
const { data: environment, error, isPending } = useGetOne('browsers', { id }, { retry: false })
|
||||
useEffect(() => { document.title = 'CreatorHub · 环境详情' }, [])
|
||||
return <DetailState pending={isPending} error={error}>{environment ? <Box><Typography variant="h1">运行环境详情</Typography><Paper variant="outlined" sx={{ p: 3, mt: 3 }}><Stack spacing={1.5}><Typography fontWeight={700} sx={wrapAnywhere}>{environment.name}({environment.alias})</Typography><Typography sx={wrapAnywhere}>账号:{environment.account_id} · 镜像:{environment.image_version}</Typography><Typography sx={wrapAnywhere}>固定出口:{environment.network_exit?.id || '未绑定'} · 健康:{environment.network_exit?.health_status || '未知'}</Typography><Typography>绑定版本:{environment.binding_version} · 运行实例:{environment.runtime_instance_id || '无'}</Typography><Stack direction={{ xs: 'column', sm: 'row' }} spacing={1}><Button component={RouterLink} to={`/accounts/${encodeURIComponent(environment.account_id)}`} variant="outlined">查看账号</Button>{environment.network_exit?.id ? <Button component={RouterLink} to={`/network-exits/${encodeURIComponent(environment.network_exit.id)}`} variant="outlined">查看出口</Button> : null}<Button component={RouterLink} to={`/audit?browser_env_alias=${encodeURIComponent(environment.alias)}`} variant="outlined">查看审计</Button></Stack></Stack></Paper></Box> : null}</DetailState>
|
||||
}
|
||||
|
||||
export function NetworkExitDetail() {
|
||||
const { id } = useParams()
|
||||
const { data: exit, error, isPending } = useGetOne('network-exits', { id }, { retry: false })
|
||||
useEffect(() => { document.title = 'CreatorHub · 出口详情' }, [])
|
||||
return <DetailState pending={isPending} error={error}>{exit ? <Box><Typography variant="h1">网络出口详情</Typography><Paper variant="outlined" sx={{ p: 3, mt: 3 }}><Stack spacing={1.5}><Typography fontWeight={700} sx={wrapAnywhere}>{exit.id}</Typography><Typography sx={wrapAnywhere}>{exit.protocol}://{exit.host}:{exit.port}</Typography><Typography>健康:{exit.health_status} · 版本:{exit.version}</Typography><Typography sx={wrapAnywhere}>凭据引用:{exit.credential_reference?.id || '无'}</Typography><Stack direction={{ xs: 'column', sm: 'row' }} spacing={1}><Button component={RouterLink} to="/network-exits" variant="outlined">返回出口列表</Button><Button component={RouterLink} to={`/audit?network_exit_id=${encodeURIComponent(exit.id)}`} variant="outlined">查看审计</Button></Stack></Stack></Paper></Box> : null}</DetailState>
|
||||
}
|
||||
+18
-3
@@ -25,6 +25,8 @@ const resourcePaths = {
|
||||
drafts: '/phase-a/drafts',
|
||||
confirmations: '/phase-a/confirmations',
|
||||
tasks: '/phase-a/tasks',
|
||||
attempts: '/phase-a/attempts',
|
||||
audit: '/phase-a/audit',
|
||||
'network-exits': '/network-exits',
|
||||
}
|
||||
|
||||
@@ -32,10 +34,18 @@ export const dataProvider = {
|
||||
async getList(resource, params = {}) {
|
||||
const path = resourcePaths[resource]
|
||||
if (!path) return unsupported(resource, 'getList')
|
||||
const filterKeys = { drafts: ['account_id'], confirmations: ['draft_id'], tasks: ['account_id', 'draft_id'] }[resource] || []
|
||||
const filterKeys = {
|
||||
drafts: ['account_id'], confirmations: ['draft_id'], tasks: ['account_id', 'draft_id', 'state'],
|
||||
audit: ['account_id', 'task_id', 'attempt_id', 'browser_env_alias', 'network_exit_id', 'event_type', 'from', 'to'],
|
||||
}[resource] || []
|
||||
const query = new URLSearchParams(filterKeys.flatMap(key => params.filter?.[key] ? [[key, params.filter[key]]] : []))
|
||||
if (resource === 'audit') {
|
||||
query.set('page', params.pagination?.page || 1)
|
||||
query.set('page_size', params.pagination?.perPage || 25)
|
||||
}
|
||||
const records = await request(`${path}${query.size ? `?${query}` : ''}`)
|
||||
return { data: records.map(record => ({ ...record, id: record.id ?? record.alias ?? record.version ?? record.name })), total: records.length }
|
||||
const data = Array.isArray(records) ? records : records.data
|
||||
return { data: data.map(record => ({ ...record, id: record.id ?? record.alias ?? record.version ?? record.name })), total: records.total ?? data.length }
|
||||
},
|
||||
async create(resource, { data }) {
|
||||
const path = resourcePaths[resource]
|
||||
@@ -59,7 +69,7 @@ export const dataProvider = {
|
||||
},
|
||||
async getOne(resource, { id }) {
|
||||
const path = resourcePaths[resource]
|
||||
if (!path || !['accounts', 'network-exits', 'drafts', 'confirmations', 'tasks'].includes(resource)) return unsupported(resource, 'getOne')
|
||||
if (!path || !['accounts', 'network-exits', 'browsers', 'drafts', 'confirmations', 'tasks', 'attempts'].includes(resource)) return unsupported(resource, 'getOne')
|
||||
const record = await request(`${path}/${encodeURIComponent(id)}`)
|
||||
return { data: { ...record, id: record.id ?? id } }
|
||||
},
|
||||
@@ -99,6 +109,11 @@ export const dataProvider = {
|
||||
enqueueConfirmation(confirmationID) {
|
||||
return request('/phase-a/tasks', jsonOptions('POST', { confirmation_id: confirmationID }))
|
||||
},
|
||||
async taskAction(id, action, data) {
|
||||
if (!['verify', 'resume', 'finish', 'cancel'].includes(action)) throw new Error(`未知任务操作: ${action}`)
|
||||
const options = data ? jsonOptions('POST', data) : { method: 'POST' }
|
||||
await request(`/phase-a/tasks/${encodeURIComponent(id)}/${action}`, options)
|
||||
},
|
||||
async networkExitAction(id, action) {
|
||||
if (action !== 'check' && action !== 'disable') throw new Error(`未知网络出口操作: ${action}`)
|
||||
return request(`/network-exits/${encodeURIComponent(id)}/${action}`, { method: 'POST' })
|
||||
|
||||
@@ -29,9 +29,11 @@ describe('dataProvider', () => {
|
||||
it.each([
|
||||
['accounts', 'account-a', '/api/phase-a/accounts/account-a'],
|
||||
['network-exits', 'exit/one', '/api/network-exits/exit%2Fone'],
|
||||
['browsers', 'environment-one', '/api/browsers/environment-one'],
|
||||
['drafts', 'draft/one', '/api/phase-a/drafts/draft%2Fone'],
|
||||
['confirmations', 'confirmation/one', '/api/phase-a/confirmations/confirmation%2Fone'],
|
||||
['tasks', 'task/one', '/api/phase-a/tasks/task%2Fone'],
|
||||
['attempts', 'attempt/one', '/api/phase-a/attempts/attempt%2Fone'],
|
||||
])('loads %s detail through its stable API path', async (resource, id, path) => {
|
||||
const fetch = vi.fn().mockResolvedValue(new Response(JSON.stringify({ id }), { status: 200 }))
|
||||
vi.stubGlobal('fetch', fetch)
|
||||
@@ -57,6 +59,16 @@ describe('dataProvider', () => {
|
||||
expect(fetch).toHaveBeenCalledWith('/api/phase-a/drafts?account_id=account-a', undefined)
|
||||
})
|
||||
|
||||
it('maps paginated audit filters without exposing a CRUD mutation', async () => {
|
||||
const fetch = vi.fn().mockResolvedValue(new Response('{"data":[{"id":7,"event_type":"task_verified"}],"total":31}', { status: 200 }))
|
||||
vi.stubGlobal('fetch', fetch)
|
||||
|
||||
await expect(dataProvider.getList('audit', { pagination: { page: 2, perPage: 25 }, filter: { task_id: 'task-a', attempt_id: 'attempt-a', browser_env_alias: 'env-a', network_exit_id: 'exit-a' } })).resolves.toEqual({
|
||||
data: [{ id: 7, event_type: 'task_verified' }], total: 31,
|
||||
})
|
||||
expect(fetch).toHaveBeenCalledWith('/api/phase-a/audit?task_id=task-a&attempt_id=attempt-a&browser_env_alias=env-a&network_exit_id=exit-a&page=2&page_size=25', undefined)
|
||||
})
|
||||
|
||||
it('keeps generated draft, confirmation and enqueue identifiers out of user input', async () => {
|
||||
const fetch = vi.fn().mockImplementation(() => Promise.resolve(new Response('{"id":"generated"}', { status: 201 })))
|
||||
vi.stubGlobal('fetch', fetch)
|
||||
@@ -108,4 +120,17 @@ describe('dataProvider', () => {
|
||||
await dataProvider.accountAction('account-a', action)
|
||||
expect(fetch).toHaveBeenCalledWith(path, { method: 'POST' })
|
||||
})
|
||||
|
||||
it('keeps verification and recovery as separate task actions', async () => {
|
||||
const fetch = vi.fn().mockResolvedValue(new Response(null, { status: 204 }))
|
||||
vi.stubGlobal('fetch', fetch)
|
||||
|
||||
await dataProvider.taskAction('task/a', 'verify', { result: 'not_executed' })
|
||||
await dataProvider.taskAction('task/a', 'resume')
|
||||
|
||||
expect(fetch.mock.calls).toEqual([
|
||||
['/api/phase-a/tasks/task%2Fa/verify', expect.objectContaining({ method: 'POST', body: '{"result":"not_executed"}' })],
|
||||
['/api/phase-a/tasks/task%2Fa/resume', { method: 'POST' }],
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
+6
-1
@@ -15,6 +15,7 @@ import {
|
||||
import ChevronLeft from '@mui/icons-material/ChevronLeft'
|
||||
import ChevronRight from '@mui/icons-material/ChevronRight'
|
||||
import AccountCircleOutlined from '@mui/icons-material/AccountCircleOutlined'
|
||||
import AssignmentTurnedInOutlined from '@mui/icons-material/AssignmentTurnedInOutlined'
|
||||
import DnsOutlined from '@mui/icons-material/DnsOutlined'
|
||||
import HistoryOutlined from '@mui/icons-material/HistoryOutlined'
|
||||
import HubOutlined from '@mui/icons-material/HubOutlined'
|
||||
@@ -40,7 +41,11 @@ export function CreatorHubMenu({ collapsed, onNavigate }) {
|
||||
<ListItemIcon sx={{ minWidth: collapsed ? 0 : 42, color: 'inherit' }}><AccountCircleOutlined /></ListItemIcon>
|
||||
{collapsed ? null : <ListItemText primary="账号池" />}
|
||||
</ListItemButton>
|
||||
<ListItemButton disabled aria-label="审计记录" sx={{ minHeight: 52, mb: 2, borderRadius: 1, color: '#a9b7cc', justifyContent: collapsed ? 'center' : 'flex-start', '&.Mui-disabled': { opacity: 0.58 } }}>
|
||||
<ListItemButton component={NavLink} to="/tasks" 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' }}><AssignmentTurnedInOutlined /></ListItemIcon>
|
||||
{collapsed ? null : <ListItemText primary="任务中心" />}
|
||||
</ListItemButton>
|
||||
<ListItemButton component={NavLink} to="/audit" aria-label="审计记录" onClick={onNavigate} sx={{ minHeight: 52, mb: 2, 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' }}><HistoryOutlined /></ListItemIcon>
|
||||
{collapsed ? null : <ListItemText primary="审计记录" />}
|
||||
</ListItemButton>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { CoreAdmin, CustomRoutes, Resource } from 'ra-core'
|
||||
import { CssBaseline, ThemeProvider } from '@mui/material'
|
||||
import { Route } from 'react-router-dom'
|
||||
import { AccountDetail, AccountList } from './AccountList'
|
||||
import { AuditList } from './AuditList'
|
||||
import { BrowserImageList } from './BrowserImageList'
|
||||
import { BrowserList } from './BrowserList'
|
||||
import { dataProvider } from './dataProvider'
|
||||
@@ -12,6 +13,8 @@ import { DraftDetail } from './DraftDetail'
|
||||
import { GatewayList } from './GatewayList'
|
||||
import { CreatorHubLayout } from './layout'
|
||||
import { NetworkExitList } from './NetworkExitList'
|
||||
import { TaskDetail, TaskList } from './TaskCenter'
|
||||
import { AttemptDetail, BrowserDetail, NetworkExitDetail } from './TraceDetails'
|
||||
import { theme } from './theme'
|
||||
import './styles.css'
|
||||
|
||||
@@ -21,6 +24,8 @@ createRoot(document.getElementById('root')).render(
|
||||
<CssBaseline />
|
||||
<CoreAdmin dataProvider={dataProvider} layout={CreatorHubLayout} title="CreatorHub" disableTelemetry>
|
||||
<Resource name="accounts" list={AccountList} options={{ label: '账号池' }} />
|
||||
<Resource name="tasks" list={TaskList} options={{ label: '任务中心' }} />
|
||||
<Resource name="audit" list={AuditList} options={{ label: '审计记录' }} />
|
||||
<Resource name="network-exits" list={NetworkExitList} options={{ label: '网络出口' }} />
|
||||
<Resource name="browsers" list={BrowserList} options={{ label: '运行环境' }} />
|
||||
<Resource name="browser-images" list={BrowserImageList} options={{ label: '镜像版本' }} />
|
||||
@@ -28,6 +33,10 @@ createRoot(document.getElementById('root')).render(
|
||||
<CustomRoutes>
|
||||
<Route path="/accounts/:id" element={<AccountDetail />} />
|
||||
<Route path="/drafts/:id" element={<DraftDetail />} />
|
||||
<Route path="/tasks/:id" element={<TaskDetail />} />
|
||||
<Route path="/attempts/:id" element={<AttemptDetail />} />
|
||||
<Route path="/browsers/:id" element={<BrowserDetail />} />
|
||||
<Route path="/network-exits/:id" element={<NetworkExitDetail />} />
|
||||
</CustomRoutes>
|
||||
</CoreAdmin>
|
||||
</ThemeProvider>
|
||||
|
||||
Reference in New Issue
Block a user