Files
planet/frontend/src/pages/Dashboard/Dashboard.tsx
2026-04-29 17:27:44 +08:00

578 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useState } from 'react'
import { Card, Row, Col, Statistic, Typography, Button, Tag, Spin, Space, Modal, Alert, Select } from 'antd'
import {
DatabaseOutlined,
BarChartOutlined,
AlertOutlined,
GlobalOutlined,
PoweroffOutlined,
WifiOutlined,
DisconnectOutlined,
ReloadOutlined,
} from '@ant-design/icons'
import { Link } from 'react-router-dom'
import axios from 'axios'
import { useAuthStore } from '../../stores/auth'
import AppLayout from '../../components/AppLayout/AppLayout'
import Scrollbar from '../../components/Scrollbar/Scrollbar'
import { useWebSocket } from '../../hooks/useWebSocket'
import { formatDateTimeZhCN } from '../../utils/datetime'
const { Title, Text } = Typography
interface Stats {
total_datasources: number
active_datasources: number
tasks_today: number
success_rate: number
last_updated: string
alerts: {
critical: number
warning: number
info: number
}
}
interface RestartTask {
task_id: string
action: string
status: string
stage: string
message: string
created_at: string
updated_at: string
}
interface RestartTaskLogs {
task_id: string
lines: string[]
}
type RestartAction = 'restart-backend' | 'restart-frontend' | 'restart-ai-provider' | 'restart-database' | 'restart-system'
type RestartStage = 'confirming' | 'waiting_for_shutdown' | 'waiting_for_recovery' | 'recovered' | 'failed' | 'timeout'
const RESTART_ACTION_OPTIONS: Array<{ value: RestartAction; label: string; description: string; command: string }> = [
{
value: 'restart-backend',
label: '重启后端',
description: '只重启后端服务,页面通常会短暂失联后自动恢复。',
command: './planet.sh restart -b',
},
{
value: 'restart-frontend',
label: '重启前端',
description: '只重启前端开发服务,页面会短暂不可用,恢复后自动刷新。',
command: './planet.sh restart -f',
},
{
value: 'restart-ai-provider',
label: '重启 AI Provider',
description: '只重启 AI Provider 适配服务,前端页面通常保持在线。',
command: './planet.sh restart -a',
},
{
value: 'restart-database',
label: '重启数据库',
description: '重启 PostgreSQL 和 Redis 容器,前端页面保持在线。',
command: './planet.sh restart -d',
},
{
value: 'restart-system',
label: '完全重启',
description: '重启前后端和相关服务,页面会短暂不可用,恢复后自动刷新。',
command: './planet.sh restart',
},
]
const RESTART_GUIDE_LINES: Record<RestartAction, string[]> = {
'restart-backend': [
'[ctl] preparing backend restart task',
'[ctl] handing restart to detached runner',
'[ctl] waiting for backend health recovery',
],
'restart-frontend': [
'[ctl] preparing frontend restart task',
'[ctl] handing restart to detached runner',
'[ctl] waiting for frontend entrypoint recovery',
],
'restart-ai-provider': [
'[ctl] preparing ai provider restart task',
'[ctl] handing restart to detached runner',
'[ctl] waiting for ai provider health recovery',
],
'restart-database': [
'[ctl] preparing database restart task',
'[ctl] restarting PostgreSQL and Redis containers',
'[ctl] waiting for containers to settle',
],
'restart-system': [
'[ctl] preparing full system restart',
'[ctl] notifying operator that frontend may disconnect',
'[ctl] stopping frontend and backend services',
'[ctl] restarting platform services',
'[ctl] polling for frontend re-entry window',
],
}
let cachedDashboardStats: Stats | null = null
function getRestartConfirmMessage(action: RestartAction): string {
if (action === 'restart-frontend') {
return '将重启前端开发服务,页面会短暂不可用,恢复后会自动刷新。'
}
if (action === 'restart-ai-provider') {
return '将重启 AI Provider 适配服务,页面通常保持在线,但 AI 分析请求会短暂不可用。'
}
if (action === 'restart-database') {
return '将重启 PostgreSQL 和 Redis页面通常保持在线但相关请求可能短暂波动。'
}
if (action === 'restart-system') {
return '将完全重启前后端和相关服务,页面会短暂不可用,恢复后会自动刷新。'
}
return '将重启后端服务,页面会短暂不可用。'
}
function Dashboard() {
const { token, clearAuth, user } = useAuthStore()
const [stats, setStats] = useState<Stats | null>(cachedDashboardStats)
const [loading, setLoading] = useState(cachedDashboardStats === null)
const [wsConnected, setWsConnected] = useState(false)
const [error, setError] = useState<string | null>(null)
const [restartModalOpen, setRestartModalOpen] = useState(false)
const [restartSubmitting, setRestartSubmitting] = useState(false)
const [restartAction, setRestartAction] = useState<RestartAction>('restart-backend')
const [restartTaskId, setRestartTaskId] = useState<string | null>(null)
const [restartMessage, setRestartMessage] = useState(getRestartConfirmMessage('restart-backend'))
const [restartStage, setRestartStage] = useState<RestartStage>('confirming')
const [restartLogs, setRestartLogs] = useState<string[]>([])
const [restartStartedAt, setRestartStartedAt] = useState<number | null>(null)
const isSuperAdmin = user?.role === 'super_admin'
const selectedRestartAction = RESTART_ACTION_OPTIONS.find((item) => item.value === restartAction) ?? RESTART_ACTION_OPTIONS[0]
useEffect(() => {
if (!token) return
const fetchStats = async () => {
try {
if (!cachedDashboardStats) {
setLoading(true)
}
const res = await fetch('/api/v1/dashboard/stats', {
headers: { Authorization: `Bearer ${token}` },
})
if (res.status === 401) {
clearAuth()
window.location.href = '/'
return
}
const data = await res.json()
cachedDashboardStats = data
setStats(data)
setError(null)
} catch (err) {
setError('获取数据失败')
console.error(err)
} finally {
setLoading(false)
}
}
fetchStats()
}, [token, clearAuth])
const { connected: dashboardSocketConnected } = useWebSocket({
autoConnect: true,
autoSubscribe: ['dashboard'],
onMessage: (message) => {
if (message.type === 'data_frame' && message.channel === 'dashboard' && message.payload?.stats) {
const nextStats = message.payload.stats as Stats
cachedDashboardStats = nextStats
setStats(nextStats)
}
},
})
useEffect(() => {
setWsConnected(dashboardSocketConnected)
}, [dashboardSocketConnected])
const handleRetry = () => {
window.location.reload()
}
const openRestartModal = () => {
setRestartAction('restart-backend')
setRestartTaskId(null)
setRestartLogs([])
setRestartSubmitting(false)
setRestartStartedAt(null)
setRestartStage('confirming')
setRestartMessage(getRestartConfirmMessage('restart-backend'))
setRestartModalOpen(true)
}
const closeRestartModal = () => {
if (restartSubmitting || restartStage === 'waiting_for_shutdown' || restartStage === 'waiting_for_recovery') {
return
}
setRestartModalOpen(false)
}
const handleRestartAction = async () => {
setRestartSubmitting(true)
setRestartLogs(RESTART_GUIDE_LINES[restartAction].slice(0, restartAction === 'restart-system' ? 3 : 1))
try {
const res = await axios.post<RestartTask>('/api/v1/system/restart-tasks', { action: restartAction })
setRestartTaskId(res.data.task_id)
setRestartStartedAt(Date.now())
setRestartStage('waiting_for_shutdown')
setRestartMessage(
restartAction === 'restart-system'
? '已发送完全重启指令,页面可能暂时失联,恢复后会自动刷新。'
: restartAction === 'restart-frontend'
? '已发送前端重启指令,正在等待页面入口恢复。'
: restartAction === 'restart-ai-provider'
? '已发送 AI Provider 重启指令,正在等待 AI 服务恢复。'
: '已发送重启指令,正在等待服务进入重启流程。'
)
setRestartLogs((current) => [...current, `任务已创建: ${res.data.task_id}`])
} catch (restartError: unknown) {
const err = restartError as { response?: { data?: { detail?: string } } }
setRestartStage('failed')
setRestartMessage(err.response?.data?.detail || '提交重启任务失败')
setRestartLogs((current) => [...current, '提交重启任务失败'])
} finally {
setRestartSubmitting(false)
}
}
useEffect(() => {
if (!restartModalOpen || restartStage !== 'confirming') return
setRestartMessage(getRestartConfirmMessage(restartAction))
}, [restartAction, restartModalOpen, restartStage])
useEffect(() => {
if (!restartModalOpen || !restartTaskId || restartStartedAt === null) return
let cancelled = false
let sawUnhealthy = false
let healthyStreak = 0
let frontendHealthyStreak = 0
let pollTimer: number | null = null
const appendLog = (line: string) => {
setRestartLogs((current) => (current[current.length - 1] === line ? current : [...current, line].slice(-8)))
}
const poll = async () => {
if (cancelled) return
const elapsed = Date.now() - restartStartedAt
if (elapsed > 90_000) {
setRestartStage('timeout')
setRestartMessage('恢复超时,请手动检查后端服务状态。')
appendLog('恢复超时,请手动检查服务状态')
return
}
try {
const taskRes = await axios.get<RestartTask>(`/api/v1/system/restart-tasks/${restartTaskId}`, { timeout: 1500 })
const task = taskRes.data
if (!cancelled && task?.message) {
setRestartMessage(task.message)
}
if (!cancelled && restartAction !== 'restart-system') {
const logsRes = await axios.get<RestartTaskLogs>(`/api/v1/system/restart-tasks/${restartTaskId}/logs`, { timeout: 1500 })
if (logsRes.data.lines.length > 0) {
setRestartLogs(logsRes.data.lines.slice(-8))
}
}
if (!cancelled && typeof task?.stage === 'string') {
if (task.stage === 'healthy') {
setRestartStage('recovered')
setRestartMessage('服务已恢复,正在刷新页面。')
appendLog('后端已恢复,正在刷新页面')
window.setTimeout(() => window.location.reload(), 600)
return
}
if (task.status === 'failed' || task.status === 'timeout') {
setRestartStage(task.status === 'timeout' ? 'timeout' : 'failed')
setRestartMessage(task.message || '重启任务失败')
appendLog(task.message || '重启任务失败')
return
}
}
} catch {
// Backend may be temporarily down during restart; handled by health polling below.
}
if (restartAction === 'restart-system' || restartAction === 'restart-frontend') {
try {
const rootRes = await fetch(`/?restart_probe=${Date.now()}`, { cache: 'no-store' })
if (rootRes.ok) {
frontendHealthyStreak += 1
if (sawUnhealthy && frontendHealthyStreak >= 2) {
setRestartStage('recovered')
setRestartMessage(
restartAction === 'restart-frontend'
? '前端已恢复,正在刷新页面。'
: '系统已恢复,正在刷新页面。'
)
appendLog('[ctl] frontend entrypoint reachable again')
window.setTimeout(() => window.location.reload(), 600)
return
}
} else {
sawUnhealthy = true
frontendHealthyStreak = 0
setRestartStage('waiting_for_recovery')
setRestartMessage(
restartAction === 'restart-frontend'
? '前端正在重启,正在等待页面入口恢复访问。'
: '系统正在完全重启,正在等待前端恢复访问。'
)
appendLog('[ctl] frontend is temporarily unavailable')
}
} catch {
sawUnhealthy = true
frontendHealthyStreak = 0
setRestartStage('waiting_for_recovery')
setRestartMessage(
restartAction === 'restart-frontend'
? '前端正在重启,正在等待页面入口恢复访问。'
: '系统正在完全重启,正在等待前端恢复访问。'
)
appendLog('[ctl] frontend is temporarily unavailable')
}
pollTimer = window.setTimeout(poll, 1500)
return
}
try {
const healthRes = await fetch('/health', { cache: 'no-store' })
if (healthRes.ok) {
healthyStreak += 1
if (sawUnhealthy && healthyStreak >= 2) {
setRestartStage('recovered')
setRestartMessage('服务已恢复,正在刷新页面。')
appendLog('健康检查已恢复,正在刷新页面')
window.setTimeout(() => window.location.reload(), 600)
return
}
} else {
sawUnhealthy = true
healthyStreak = 0
setRestartStage('waiting_for_recovery')
setRestartMessage('后端已停止响应,正在等待服务恢复。')
appendLog('检测到后端已停止响应')
}
} catch {
sawUnhealthy = true
healthyStreak = 0
setRestartStage('waiting_for_recovery')
setRestartMessage('后端已停止响应,正在等待服务恢复。')
appendLog('检测到后端已停止响应')
}
pollTimer = window.setTimeout(poll, 1500)
}
pollTimer = window.setTimeout(poll, 1200)
return () => {
cancelled = true
if (pollTimer !== null) {
window.clearTimeout(pollTimer)
}
}
}, [restartAction, restartModalOpen, restartStartedAt, restartTaskId])
if (loading && !stats) {
return (
<div style={{ height: '100vh' }}>
<Spin size="large" tip="加载中..." fullscreen />
</div>
)
}
return (
<AppLayout>
<div className="dashboard-page">
<div className="dashboard-page__header">
<div>
<Title level={4} style={{ margin: 0 }}></Title>
<Text type="secondary"></Text>
</div>
<Space wrap className="dashboard-page__actions">
{wsConnected ? (
<Tag className="dashboard-status-tag" icon={<WifiOutlined />} color="success"></Tag>
) : (
<Tag className="dashboard-status-tag" icon={<DisconnectOutlined />} color="default">线</Tag>
)}
{isSuperAdmin ? (
<Button
className="dashboard-action-button dashboard-restart-button"
icon={<PoweroffOutlined />}
danger
onClick={openRestartModal}
>
</Button>
) : null}
<Button className="dashboard-action-button dashboard-refresh-button" icon={<ReloadOutlined />} onClick={handleRetry}></Button>
</Space>
</div>
{error && (
<Card style={{ borderColor: '#ff4d4f' }}>
<Text style={{ color: '#ff4d4f' }}>{error}</Text>
</Card>
)}
<Row gutter={[16, 16]}>
<Col xs={24} sm={12} xl={6}>
<Card>
<Statistic title="数据源总数" value={stats?.total_datasources || 0} prefix={<DatabaseOutlined />} />
</Card>
</Col>
<Col xs={24} sm={12} xl={6}>
<Card>
<Statistic title="活跃数据源" value={stats?.active_datasources || 0} valueStyle={{ color: '#52c41a' }} />
</Card>
</Col>
<Col xs={24} sm={12} xl={6}>
<Card>
<Statistic title="今日任务" value={stats?.tasks_today || 0} prefix={<BarChartOutlined />} />
</Card>
</Col>
<Col xs={24} sm={12} xl={6}>
<Card>
<Statistic title="成功率" value={stats?.success_rate || 0} suffix="%" valueStyle={{ color: '#1890ff' }} />
</Card>
</Col>
</Row>
<Row gutter={[16, 16]}>
<Col xs={24}>
<Card>
<Space direction="vertical" size={12} className="dashboard-quick-entry">
<div className="dashboard-quick-entry__copy">
<Title level={5} className="dashboard-quick-entry__title"></Title>
<Text type="secondary">访</Text>
</div>
<Link to="/earth" className="dashboard-quick-entry__link">
<Button type="primary" icon={<GlobalOutlined />} className="dashboard-quick-entry__button">
访 Earth
</Button>
</Link>
</Space>
</Card>
</Col>
<Col xs={24} md={8}>
<Card>
<Statistic title="严重告警" value={stats?.alerts?.critical || 0} valueStyle={{ color: '#ff4d4f' }} prefix={<AlertOutlined />} />
</Card>
</Col>
<Col xs={24} md={8}>
<Card>
<Statistic title="警告" value={stats?.alerts?.warning || 0} valueStyle={{ color: '#faad14' }} prefix={<AlertOutlined />} />
</Card>
</Col>
<Col xs={24} md={8}>
<Card>
<Statistic title="提示" value={stats?.alerts?.info || 0} valueStyle={{ color: '#1890ff' }} prefix={<AlertOutlined />} />
</Card>
</Col>
</Row>
{stats?.last_updated && (
<div style={{ textAlign: 'center', color: '#8c8c8c' }}>
: {formatDateTimeZhCN(stats.last_updated)}
{wsConnected && <Tag className="dashboard-status-tag" color="green" style={{ marginLeft: 8 }}></Tag>}
</div>
)}
<Modal
title="重启服务"
open={restartModalOpen}
onCancel={closeRestartModal}
maskClosable={false}
closable={!restartSubmitting && restartStage !== 'waiting_for_shutdown' && restartStage !== 'waiting_for_recovery'}
footer={[
<Button
key="close"
onClick={closeRestartModal}
disabled={restartSubmitting || restartStage === 'waiting_for_shutdown' || restartStage === 'waiting_for_recovery'}
>
{restartStage === 'confirming' ? '取消' : '关闭'}
</Button>,
<Button
key="submit"
type="primary"
danger
loading={restartSubmitting}
disabled={restartSubmitting || restartStage !== 'confirming'}
onClick={handleRestartAction}
>
</Button>,
]}
>
<div className="dashboard-restart-modal">
<div className="dashboard-restart-toolbar">
<div className="dashboard-restart-toolbar__field">
<Text className="dashboard-restart-section__label"></Text>
<Select
value={restartAction}
onChange={(value) => setRestartAction(value)}
disabled={restartSubmitting || restartStage !== 'confirming'}
options={RESTART_ACTION_OPTIONS.map((item) => ({
value: item.value,
label: item.label,
}))}
/>
</div>
<div className="dashboard-restart-toolbar__meta">
<div className="dashboard-restart-toolbar__item">
<Text type="secondary"></Text>
<Text code>{selectedRestartAction.command}</Text>
</div>
<div className="dashboard-restart-toolbar__item">
<Text type="secondary"> ID</Text>
<Text>{restartTaskId || '等待创建'}</Text>
</div>
</div>
</div>
<div className="dashboard-restart-section">
<Text className="dashboard-restart-section__label"></Text>
<Alert
type={
restartStage === 'failed' || restartStage === 'timeout'
? 'error'
: restartStage === 'recovered'
? 'success'
: 'info'
}
message={restartMessage}
showIcon
/>
</div>
<div className="dashboard-restart-section">
<Text className="dashboard-restart-section__label"></Text>
<Scrollbar className="dashboard-restart-log">
{restartLogs.length > 0 ? restartLogs.map((line, index) => (
<div key={`${line}-${index}`}>{line}</div>
)) : <div></div>}
</Scrollbar>
</div>
</div>
</Modal>
</div>
</AppLayout>
)
}
export default Dashboard