diff --git a/web-react/src/app/App.tsx b/web-react/src/app/App.tsx index 46c09ef..ff0878e 100644 --- a/web-react/src/app/App.tsx +++ b/web-react/src/app/App.tsx @@ -56,6 +56,7 @@ const ClassroomRepairsPage = lazy(() => import('../pages/ClassroomRepairsPage')) const CourseGradeStatisticsPage = lazy(() => import('../pages/CourseGradeStatisticsPage')) const EventAnalyticsPage = lazy(() => import('../pages/EventAnalyticsPage')) const PluginsPage = lazy(() => import('../pages/PluginsPage')) +const OperationsPage = lazy(() => import('../pages/OperationsPage')) const AccountActivationPage = lazy(() => import('../pages/AccountActivationPage')) const OfficialDocumentVerifyPage = lazy(() => import('../pages/OfficialDocumentVerifyPage')) const ClassroomRepairReportPage = lazy(() => import('../pages/ClassroomRepairReportPage')) @@ -160,6 +161,7 @@ function AppRoutes() { } /> } /> } /> + } /> } /> } /> diff --git a/web-react/src/layout/AppShell.tsx b/web-react/src/layout/AppShell.tsx index 1c28068..1c5a4f0 100644 --- a/web-react/src/layout/AppShell.tsx +++ b/web-react/src/layout/AppShell.tsx @@ -141,7 +141,7 @@ export default function AppShell() { { key: '/students', icon: , label: '学生档案' }, ] : []), ...(user?.roles.includes('SuperAdmin') - ? [{ key: '/users', icon: , label: '用户与权限' }, { key: '/plugins', icon: , label: '插件平台' }] + ? [{ key: '/users', icon: , label: '用户与权限' }, { key: '/plugins', icon: , label: '插件平台' }, { key: '/operations', icon: , label: '运维与审计' }] : []), { key: 'legacy', icon: , label: '返回现有系统' }, ], [isStudentHome, isTeacherHome, user?.roles]) diff --git a/web-react/src/pages/OperationsPage.tsx b/web-react/src/pages/OperationsPage.tsx new file mode 100644 index 0000000..3a117ee --- /dev/null +++ b/web-react/src/pages/OperationsPage.tsx @@ -0,0 +1,20 @@ +import { App, Alert, Button, Skeleton, Switch, Table, Tag } from 'antd' +import { ReloadOutlined } from '@ant-design/icons' +import { useQuery } from '@tanstack/react-query' +import http, { apiErrorMessage } from '../api/http' +import PageHeader from '../components/PageHeader' + +type Health = 'healthy' | 'warning' | 'unhealthy' +type Backup = { id: string; fileName: string; provider: string; createdAt: string; sizeBytes: number; lastDrillSucceeded?: boolean } +type Summary = { generatedAt: string; health: { components: { key: string; label: string; status: Health; detail: string; latencyMilliseconds?: number }[] }; counters: { auditEvents24Hours: number; serverErrors24Hours: number; failedJobs7Days: number; criticalAlerts: number }; alerts: { id: string; severity: 'critical' | 'warning'; title: string; detail: string }[] } +const stamp = (value: string) => new Date(value).toLocaleString('zh-CN', { hour12: false }) +export default function OperationsPage() { + const { message, modal } = App.useApp(); const summary = useQuery({ queryKey: ['operations-summary'], queryFn: async () => (await http.get('/operations/summary')).data }); const backups = useQuery({ queryKey: ['operations-backups'], queryFn: async () => (await http.get('/operations/backups')).data }); const swagger = useQuery({ queryKey: ['operations-swagger'], queryFn: async () => (await http.get<{ isEnabled: boolean }>('/operations/swagger')).data }) + async function refresh() { await Promise.all([summary.refetch(), backups.refetch(), swagger.refetch()]) } + async function setSwagger(isEnabled: boolean) { try { await http.put('/operations/swagger', { isEnabled }); await swagger.refetch(); message.success(isEnabled ? 'Swagger 已开放。' : 'Swagger 已关闭。') } catch (error) { message.error(apiErrorMessage(error)) } } + function drill(item: Backup) { modal.confirm({ title: '运行恢复演练', content: `将把“${item.fileName}”恢复到隔离数据库,业务数据库不会被覆盖。`, okText: '开始隔离演练', onOk: async () => { try { const result = await http.post<{ succeeded: boolean; detail: string }>(`/operations/backups/${item.id}/restore-drill`, { confirmation: 'RESTORE_DRILL' }, { timeout: 1800000 }); result.data.succeeded ? message.success(result.data.detail) : message.error(result.data.detail); await refresh() } catch (error) { message.error(apiErrorMessage(error)); throw error } } }) } + if (summary.isLoading || backups.isLoading || swagger.isLoading) return
+ if (summary.isError) return
+ const data = summary.data! + return
} onClick={() => void refresh()}>刷新状态} />} />
{data.health.components.map((item) =>
{item.status}{item.label}{item.detail}{item.latencyMilliseconds != null && ` · ${item.latencyMilliseconds} ms`}
)}
{data.alerts.map((item) => )}

备份与恢复演练

rowKey="id" dataSource={backups.data} pagination={false} columns={[{ title: '备份', dataIndex: 'fileName' }, { title: '提供方', dataIndex: 'provider' }, { title: '创建时间', render: (_, item) => stamp(item.createdAt) }, { title: '演练', render: (_, item) => }]} />
+}