550 lines
20 KiB
TypeScript
550 lines
20 KiB
TypeScript
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-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-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-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-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-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') {
|
||
try {
|
||
const rootRes = await fetch(`/?restart_probe=${Date.now()}`, { cache: 'no-store' })
|
||
if (rootRes.ok) {
|
||
frontendHealthyStreak += 1
|
||
if (sawUnhealthy && frontendHealthyStreak >= 2) {
|
||
setRestartStage('recovered')
|
||
setRestartMessage('系统已恢复,正在刷新页面。')
|
||
appendLog('[ctl] frontend entrypoint reachable again')
|
||
window.setTimeout(() => window.location.reload(), 600)
|
||
return
|
||
}
|
||
} else {
|
||
sawUnhealthy = true
|
||
frontendHealthyStreak = 0
|
||
setRestartStage('waiting_for_recovery')
|
||
setRestartMessage('系统正在完全重启,正在等待前端恢复访问。')
|
||
appendLog('[ctl] frontend is temporarily unavailable')
|
||
}
|
||
} catch {
|
||
sawUnhealthy = true
|
||
frontendHealthyStreak = 0
|
||
setRestartStage('waiting_for_recovery')
|
||
setRestartMessage('系统正在完全重启,正在等待前端恢复访问。')
|
||
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
|