first commit
This commit is contained in:
219
frontend/src/pages/Alerts/Alerts.tsx
Normal file
219
frontend/src/pages/Alerts/Alerts.tsx
Normal file
@@ -0,0 +1,219 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Table, Tag, Card, Row, Col, Statistic, Button, Modal, Space, Descriptions } from 'antd'
|
||||
import { AlertOutlined, InfoCircleOutlined, ReloadOutlined } from '@ant-design/icons'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
|
||||
interface Alert {
|
||||
id: number
|
||||
severity: 'critical' | 'warning' | 'info'
|
||||
status: 'active' | 'acknowledged' | 'resolved'
|
||||
datasource_name: string
|
||||
message: string
|
||||
created_at: string
|
||||
acknowledged_at?: string
|
||||
resolved_at?: string
|
||||
}
|
||||
|
||||
function Alerts() {
|
||||
const { token } = useAuthStore()
|
||||
const [alerts, setAlerts] = useState<Alert[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [selectedAlert, setSelectedAlert] = useState<Alert | null>(null)
|
||||
const [detailVisible, setDetailVisible] = useState(false)
|
||||
|
||||
const fetchAlerts = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetch('/api/v1/alerts', {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
const data = await res.json()
|
||||
setAlerts(data.data || [])
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch alerts:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchAlerts()
|
||||
}, [token])
|
||||
|
||||
const handleAcknowledge = async (alertId: number) => {
|
||||
try {
|
||||
await fetch(`/api/v1/alerts/${alertId}/acknowledge`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
fetchAlerts()
|
||||
} catch (error) {
|
||||
console.error('Failed to acknowledge alert:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleResolve = async (alertId: number) => {
|
||||
try {
|
||||
await fetch(`/api/v1/alerts/${alertId}/resolve`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({ resolution: '已处理' }),
|
||||
})
|
||||
fetchAlerts()
|
||||
} catch (error) {
|
||||
console.error('Failed to resolve alert:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{ title: 'ID', dataIndex: 'id', key: 'id', width: 60 },
|
||||
{
|
||||
title: '级别',
|
||||
dataIndex: 'severity',
|
||||
key: 'severity',
|
||||
render: (s: string) => {
|
||||
const colors: Record<string, string> = { critical: 'error', warning: 'warning', info: 'blue' }
|
||||
const icons: Record<string, JSX.Element> = {
|
||||
critical: <AlertOutlined />,
|
||||
warning: <AlertOutlined />,
|
||||
info: <InfoCircleOutlined />,
|
||||
}
|
||||
return (
|
||||
<Tag color={colors[s]} icon={icons[s]}>
|
||||
{s === 'critical' ? '严重' : s === 'warning' ? '警告' : '信息'}
|
||||
</Tag>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
render: (s: string) => {
|
||||
const colors: Record<string, string> = { active: 'red', acknowledged: 'orange', resolved: 'green' }
|
||||
return (
|
||||
<Tag color={colors[s]}>
|
||||
{s === 'active' ? '待处理' : s === 'acknowledged' ? '已确认' : '已解决'}
|
||||
</Tag>
|
||||
)
|
||||
},
|
||||
},
|
||||
{ title: '数据源', dataIndex: 'datasource_name', key: 'datasource_name' },
|
||||
{ title: '消息', dataIndex: 'message', key: 'message', ellipsis: true },
|
||||
{
|
||||
title: '时间',
|
||||
dataIndex: 'created_at',
|
||||
key: 'created_at',
|
||||
render: (t: string) => new Date(t).toLocaleString('zh-CN'),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
render: (_: unknown, record: Alert) => (
|
||||
<Space>
|
||||
{record.status === 'active' && (
|
||||
<Button type="link" size="small" onClick={() => handleAcknowledge(record.id)}>
|
||||
确认
|
||||
</Button>
|
||||
)}
|
||||
{record.status !== 'resolved' && (
|
||||
<Button type="link" size="small" onClick={() => handleResolve(record.id)}>
|
||||
解决
|
||||
</Button>
|
||||
)}
|
||||
<Button type="link" size="small" onClick={() => { setSelectedAlert(record); setDetailVisible(true); }}>
|
||||
详情
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
const stats = alerts.reduce(
|
||||
(acc, alert) => {
|
||||
if (alert.status === 'active') {
|
||||
acc[alert.severity]++
|
||||
}
|
||||
return acc
|
||||
},
|
||||
{ critical: 0, warning: 0, info: 0 } as Record<string, number>
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
|
||||
<Col span={8}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="严重告警"
|
||||
value={stats.critical}
|
||||
valueStyle={{ color: '#ff4d4f' }}
|
||||
prefix={<AlertOutlined />}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="警告"
|
||||
value={stats.warning}
|
||||
valueStyle={{ color: '#faad14' }}
|
||||
prefix={<AlertOutlined />}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Card>
|
||||
<Statistic title="信息" value={stats.info} valueStyle={{ color: '#1890ff' }} prefix={<InfoCircleOutlined />} />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Card
|
||||
title="告警列表"
|
||||
extra={<Button icon={<ReloadOutlined />} onClick={fetchAlerts}>刷新</Button>}
|
||||
>
|
||||
<Table columns={columns} dataSource={alerts} rowKey="id" loading={loading} pagination={{ pageSize: 10 }} />
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title="告警详情"
|
||||
open={detailVisible}
|
||||
onCancel={() => setDetailVisible(false)}
|
||||
footer={null}
|
||||
width={600}
|
||||
>
|
||||
{selectedAlert && (
|
||||
<Descriptions column={1} bordered>
|
||||
<Descriptions.Item label="ID">{selectedAlert.id}</Descriptions.Item>
|
||||
<Descriptions.Item label="级别">
|
||||
<Tag color={selectedAlert.severity === 'critical' ? 'error' : selectedAlert.severity === 'warning' ? 'warning' : 'blue'}>
|
||||
{selectedAlert.severity}
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">
|
||||
<Tag color={selectedAlert.status === 'active' ? 'red' : selectedAlert.status === 'acknowledged' ? 'orange' : 'green'}>
|
||||
{selectedAlert.status}
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="数据源">{selectedAlert.datasource_name}</Descriptions.Item>
|
||||
<Descriptions.Item label="消息">{selectedAlert.message}</Descriptions.Item>
|
||||
<Descriptions.Item label="创建时间">{new Date(selectedAlert.created_at).toLocaleString('zh-CN')}</Descriptions.Item>
|
||||
{selectedAlert.acknowledged_at && (
|
||||
<Descriptions.Item label="确认时间">
|
||||
{new Date(selectedAlert.acknowledged_at).toLocaleString('zh-CN')}
|
||||
</Descriptions.Item>
|
||||
)}
|
||||
{selectedAlert.resolved_at && (
|
||||
<Descriptions.Item label="解决时间">
|
||||
{new Date(selectedAlert.resolved_at).toLocaleString('zh-CN')}
|
||||
</Descriptions.Item>
|
||||
)}
|
||||
</Descriptions>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Alerts
|
||||
224
frontend/src/pages/Dashboard/Dashboard.tsx
Normal file
224
frontend/src/pages/Dashboard/Dashboard.tsx
Normal file
@@ -0,0 +1,224 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Layout, Menu, Card, Row, Col, Statistic, Typography, Button, Tag, Spin } from 'antd'
|
||||
import {
|
||||
DashboardOutlined,
|
||||
DatabaseOutlined,
|
||||
UserOutlined,
|
||||
SettingOutlined,
|
||||
BarChartOutlined,
|
||||
AlertOutlined,
|
||||
WifiOutlined,
|
||||
DisconnectOutlined,
|
||||
ReloadOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
|
||||
const { Header, Sider, Content } = Layout
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
function Dashboard() {
|
||||
const { user, logout, token, clearAuth } = useAuthStore()
|
||||
const [stats, setStats] = useState<Stats | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [wsConnected, setWsConnected] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) return
|
||||
|
||||
const fetchStats = async () => {
|
||||
try {
|
||||
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()
|
||||
setStats(data)
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
setError('获取数据失败')
|
||||
console.error(err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchStats()
|
||||
}, [token])
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) return
|
||||
|
||||
let ws: WebSocket | null = null
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const connectWs = () => {
|
||||
try {
|
||||
ws = new WebSocket(`ws://localhost:8000/ws?token=${token}`)
|
||||
|
||||
ws.onopen = () => {
|
||||
setWsConnected(true)
|
||||
ws?.send(JSON.stringify({ type: 'subscribe', data: { channels: ['dashboard'] } }))
|
||||
}
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data)
|
||||
if (msg.type === 'data_frame' && msg.channel === 'dashboard') {
|
||||
setStats(msg.payload?.stats as Stats)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Parse WS message error:', e)
|
||||
}
|
||||
}
|
||||
|
||||
ws.onclose = () => {
|
||||
setWsConnected(false)
|
||||
reconnectTimer = setTimeout(connectWs, 3000)
|
||||
}
|
||||
|
||||
ws.onerror = () => {
|
||||
setWsConnected(false)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('WS connect error:', e)
|
||||
}
|
||||
}
|
||||
|
||||
connectWs()
|
||||
|
||||
return () => {
|
||||
ws?.close()
|
||||
if (reconnectTimer) clearTimeout(reconnectTimer)
|
||||
}
|
||||
}, [token])
|
||||
|
||||
const handleLogout = () => {
|
||||
logout()
|
||||
window.location.href = '/'
|
||||
}
|
||||
|
||||
const handleClearAuth = () => {
|
||||
clearAuth()
|
||||
window.location.href = '/'
|
||||
}
|
||||
|
||||
const handleRetry = () => {
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
const menuItems = [
|
||||
{ key: '/', icon: <DashboardOutlined />, label: <Link to="/">仪表盘</Link> },
|
||||
{ key: '/datasources', icon: <DatabaseOutlined />, label: <Link to="/datasources">数据源</Link> },
|
||||
{ key: '/data', icon: <BarChartOutlined />, label: <Link to="/data">采集数据</Link> },
|
||||
{ key: '/users', icon: <UserOutlined />, label: <Link to="/users">用户管理</Link> },
|
||||
{ key: '/settings', icon: <SettingOutlined />, label: '系统配置' },
|
||||
]
|
||||
|
||||
if (loading && !stats) {
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
|
||||
<Spin size="large" tip="加载中..." />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout className="dashboard-layout">
|
||||
<Sider width={240} className="dashboard-sider">
|
||||
<div style={{ height: 64, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Title level={4} style={{ color: 'white', margin: 0 }}>智能星球</Title>
|
||||
</div>
|
||||
<Menu theme="dark" mode="inline" defaultSelectedKeys={['/']} items={menuItems} />
|
||||
</Sider>
|
||||
<Layout>
|
||||
<Header className="dashboard-header" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Text strong>欢迎, {user?.username}</Text>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
{wsConnected ? (
|
||||
<Tag icon={<WifiOutlined />} color="success">实时连接</Tag>
|
||||
) : (
|
||||
<Tag icon={<DisconnectOutlined />} color="default">离线</Tag>
|
||||
)}
|
||||
<Button type="link" danger onClick={handleLogout}>退出登录</Button>
|
||||
<Button type="link" onClick={handleClearAuth}>清除认证</Button>
|
||||
<Button type="link" icon={<ReloadOutlined />} onClick={handleRetry}>刷新</Button>
|
||||
</div>
|
||||
</Header>
|
||||
<Content className="dashboard-content">
|
||||
{error && (
|
||||
<Card style={{ marginBottom: 16, borderColor: '#ff4d4f' }}>
|
||||
<Text style={{ color: '#ff4d4f' }}>{error}</Text>
|
||||
</Card>
|
||||
)}
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col span={6}>
|
||||
<Card>
|
||||
<Statistic title="数据源总数" value={stats?.total_datasources || 0} prefix={<DatabaseOutlined />} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card>
|
||||
<Statistic title="活跃数据源" value={stats?.active_datasources || 0} valueStyle={{ color: '#52c41a' }} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card>
|
||||
<Statistic title="今日任务" value={stats?.tasks_today || 0} prefix={<BarChartOutlined />} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card>
|
||||
<Statistic title="成功率" value={stats?.success_rate || 0} suffix="%" valueStyle={{ color: '#1890ff' }} />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={[16, 16]} style={{ marginTop: 16 }}>
|
||||
<Col span={8}>
|
||||
<Card>
|
||||
<Statistic title="严重告警" value={stats?.alerts?.critical || 0} valueStyle={{ color: '#ff4d4f' }} prefix={<AlertOutlined />} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Card>
|
||||
<Statistic title="警告" value={stats?.alerts?.warning || 0} valueStyle={{ color: '#faad14' }} prefix={<AlertOutlined />} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Card>
|
||||
<Statistic title="提示" value={stats?.alerts?.info || 0} valueStyle={{ color: '#1890ff' }} prefix={<AlertOutlined />} />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
{stats?.last_updated && (
|
||||
<div style={{ marginTop: 16, textAlign: 'center', color: '#8c8c8c' }}>
|
||||
最后更新: {new Date(stats.last_updated).toLocaleString('zh-CN')}
|
||||
{wsConnected && <Tag color="green" style={{ marginLeft: 8 }}>实时同步中</Tag>}
|
||||
</div>
|
||||
)}
|
||||
</Content>
|
||||
</Layout>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
export default Dashboard
|
||||
368
frontend/src/pages/DataList/DataList.tsx
Normal file
368
frontend/src/pages/DataList/DataList.tsx
Normal file
@@ -0,0 +1,368 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
Table, Tag, Space, Card, Row, Col, Select, Input, Button,
|
||||
Statistic, Modal, Descriptions, Spin, Empty, Tooltip
|
||||
} from 'antd'
|
||||
import {
|
||||
DatabaseOutlined, GlobalOutlined, CloudServerOutlined,
|
||||
AppstoreOutlined, EyeOutlined, SearchOutlined
|
||||
} from '@ant-design/icons'
|
||||
import axios from 'axios'
|
||||
|
||||
interface CollectedData {
|
||||
id: number
|
||||
source: string
|
||||
source_id: string
|
||||
data_type: string
|
||||
name: string
|
||||
title: string | null
|
||||
description: string | null
|
||||
country: string | null
|
||||
city: string | null
|
||||
latitude: string | null
|
||||
longitude: string | null
|
||||
value: string | null
|
||||
unit: string | null
|
||||
metadata: Record<string, any> | null
|
||||
collected_at: string
|
||||
reference_date: string | null
|
||||
is_valid: number
|
||||
}
|
||||
|
||||
interface Summary {
|
||||
total_records: number
|
||||
by_source: Record<string, Record<string, number>>
|
||||
source_totals: Array<{ source: string; count: number }>
|
||||
}
|
||||
|
||||
function DataList() {
|
||||
const [data, setData] = useState<CollectedData[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [summary, setSummary] = useState<Summary | null>(null)
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(20)
|
||||
const [sourceFilter, setSourceFilter] = useState<string | undefined>()
|
||||
const [typeFilter, setTypeFilter] = useState<string | undefined>()
|
||||
const [countryFilter, setCountryFilter] = useState<string | undefined>()
|
||||
const [searchText, setSearchText] = useState('')
|
||||
const [sources, setSources] = useState<string[]>([])
|
||||
const [types, setTypes] = useState<string[]>([])
|
||||
const [countries, setCountries] = useState<string[]>([])
|
||||
const [detailVisible, setDetailVisible] = useState(false)
|
||||
const [detailData, setDetailData] = useState<CollectedData | null>(null)
|
||||
const [detailLoading, setDetailLoading] = useState(false)
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
page: page.toString(),
|
||||
page_size: pageSize.toString(),
|
||||
})
|
||||
if (sourceFilter) params.append('source', sourceFilter)
|
||||
if (typeFilter) params.append('data_type', typeFilter)
|
||||
if (countryFilter) params.append('country', countryFilter)
|
||||
if (searchText) params.append('search', searchText)
|
||||
|
||||
const res = await axios.get(`/api/v1/collected?${params}`)
|
||||
setData(res.data.data)
|
||||
setTotal(res.data.total)
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch data:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchSummary = async () => {
|
||||
try {
|
||||
const res = await axios.get('/api/v1/collected/summary')
|
||||
setSummary(res.data)
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch summary:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchFilters = async () => {
|
||||
try {
|
||||
const [sourcesRes, typesRes, countriesRes] = await Promise.all([
|
||||
axios.get('/api/v1/collected/sources'),
|
||||
axios.get('/api/v1/collected/types'),
|
||||
axios.get('/api/v1/collected/countries'),
|
||||
])
|
||||
setSources(sourcesRes.data.sources || [])
|
||||
setTypes(typesRes.data.data_types || [])
|
||||
setCountries(countriesRes.data.countries || [])
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch filters:', error)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchSummary()
|
||||
fetchFilters()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchData()
|
||||
}, [page, pageSize, sourceFilter, typeFilter, countryFilter])
|
||||
|
||||
const handleSearch = () => {
|
||||
setPage(1)
|
||||
fetchData()
|
||||
}
|
||||
|
||||
const handleViewDetail = async (id: number) => {
|
||||
setDetailVisible(true)
|
||||
setDetailLoading(true)
|
||||
try {
|
||||
const res = await axios.get(`/api/v1/collected/${id}`)
|
||||
setDetailData(res.data)
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch detail:', error)
|
||||
} finally {
|
||||
setDetailLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const getSourceIcon = (source: string) => {
|
||||
const iconMap: Record<string, React.ReactNode> = {
|
||||
'top500': <CloudServerOutlined />,
|
||||
'huggingface_models': <AppstoreOutlined />,
|
||||
'huggingface_datasets': <DatabaseOutlined />,
|
||||
'huggingface_spaces': <AppstoreOutlined />,
|
||||
'telegeography_cables': <GlobalOutlined />,
|
||||
'epoch_ai_gpu': <CloudServerOutlined />,
|
||||
}
|
||||
return iconMap[source] || <DatabaseOutlined />
|
||||
}
|
||||
|
||||
const getTypeColor = (type: string) => {
|
||||
const colors: Record<string, string> = {
|
||||
'supercomputer': 'red',
|
||||
'model': 'blue',
|
||||
'dataset': 'green',
|
||||
'space': 'purple',
|
||||
'submarine_cable': 'cyan',
|
||||
'gpu_cluster': 'orange',
|
||||
'ixp': 'magenta',
|
||||
'network': 'gold',
|
||||
'facility': 'lime',
|
||||
}
|
||||
return colors[type] || 'default'
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: 'ID',
|
||||
dataIndex: 'id',
|
||||
key: 'id',
|
||||
width: 80,
|
||||
},
|
||||
{
|
||||
title: '名称',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
ellipsis: true,
|
||||
render: (name: string, record: CollectedData) => (
|
||||
<Tooltip title={name}>
|
||||
<Button type="link" onClick={() => handleViewDetail(record.id)}>
|
||||
{name}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '数据源',
|
||||
dataIndex: 'source',
|
||||
key: 'source',
|
||||
width: 150,
|
||||
render: (source: string) => (
|
||||
<Tag icon={getSourceIcon(source)}>{source}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '类型',
|
||||
dataIndex: 'data_type',
|
||||
key: 'data_type',
|
||||
width: 120,
|
||||
render: (type: string) => (
|
||||
<Tag color={getTypeColor(type)}>{type}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '国家/地区',
|
||||
dataIndex: 'country',
|
||||
key: 'country',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: '数值',
|
||||
dataIndex: 'value',
|
||||
key: 'value',
|
||||
width: 120,
|
||||
render: (value: string | null, record: CollectedData) => (
|
||||
value ? `${value} ${record.unit || ''}` : '-'
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '采集时间',
|
||||
dataIndex: 'collected_at',
|
||||
key: 'collected_at',
|
||||
width: 180,
|
||||
render: (time: string) => new Date(time).toLocaleString('zh-CN'),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 80,
|
||||
render: (_: unknown, record: CollectedData) => (
|
||||
<Button
|
||||
type="link"
|
||||
icon={<EyeOutlined />}
|
||||
onClick={() => handleViewDetail(record.id)}
|
||||
>
|
||||
详情
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2>采集数据管理</h2>
|
||||
|
||||
{/* Summary Cards */}
|
||||
{summary && (
|
||||
<Row gutter={16} style={{ marginBottom: 24 }}>
|
||||
<Col span={6}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="总记录数"
|
||||
value={summary.total_records}
|
||||
prefix={<DatabaseOutlined />}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
{summary.source_totals.slice(0, 4).map((item) => (
|
||||
<Col span={6} key={item.source}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title={item.source}
|
||||
value={item.count}
|
||||
prefix={getSourceIcon(item.source)}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{/* Filters */}
|
||||
<Card style={{ marginBottom: 16 }}>
|
||||
<Space wrap>
|
||||
<Select
|
||||
placeholder="数据源"
|
||||
allowClear
|
||||
style={{ width: 180 }}
|
||||
value={sourceFilter}
|
||||
onChange={(v) => { setSourceFilter(v); setPage(1); }}
|
||||
options={sources.map(s => ({ label: s, value: s }))}
|
||||
/>
|
||||
<Select
|
||||
placeholder="数据类型"
|
||||
allowClear
|
||||
style={{ width: 150 }}
|
||||
value={typeFilter}
|
||||
onChange={(v) => { setTypeFilter(v); setPage(1); }}
|
||||
options={types.map(t => ({ label: t, value: t }))}
|
||||
/>
|
||||
<Select
|
||||
placeholder="国家"
|
||||
allowClear
|
||||
style={{ width: 150 }}
|
||||
value={countryFilter}
|
||||
onChange={(v) => { setCountryFilter(v); setPage(1); }}
|
||||
options={countries.map(c => ({ label: c, value: c }))}
|
||||
/>
|
||||
<Input
|
||||
placeholder="搜索名称"
|
||||
style={{ width: 200 }}
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
onPressEnter={handleSearch}
|
||||
/>
|
||||
<Button type="primary" icon={<SearchOutlined />} onClick={handleSearch}>
|
||||
搜索
|
||||
</Button>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
{/* Data Table */}
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total,
|
||||
onChange: (p, ps) => { setPage(p); setPageSize(ps); },
|
||||
showSizeChanger: true,
|
||||
showTotal: (t) => `共 ${t} 条`,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Detail Modal */}
|
||||
<Modal
|
||||
title="数据详情"
|
||||
open={detailVisible}
|
||||
onCancel={() => setDetailVisible(false)}
|
||||
footer={[
|
||||
<Button key="close" onClick={() => setDetailVisible(false)}>
|
||||
关闭
|
||||
</Button>,
|
||||
]}
|
||||
width={700}
|
||||
>
|
||||
{detailLoading ? (
|
||||
<div style={{ textAlign: 'center', padding: 40 }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
) : detailData ? (
|
||||
<Descriptions column={2} bordered>
|
||||
<Descriptions.Item label="ID">{detailData.id}</Descriptions.Item>
|
||||
<Descriptions.Item label="数据源">{detailData.source}</Descriptions.Item>
|
||||
<Descriptions.Item label="数据类型">{detailData.data_type}</Descriptions.Item>
|
||||
<Descriptions.Item label="原始ID">{detailData.source_id || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="名称" span={2}>{detailData.name}</Descriptions.Item>
|
||||
<Descriptions.Item label="标题" span={2}>{detailData.title || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="描述" span={2}>{detailData.description || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="国家">{detailData.country || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="城市">{detailData.city || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="经度">{detailData.longitude || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="纬度">{detailData.latitude || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="数值">{detailData.value} {detailData.unit || ''}</Descriptions.Item>
|
||||
<Descriptions.Item label="采集时间">
|
||||
{new Date(detailData.collected_at).toLocaleString('zh-CN')}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="参考日期">
|
||||
{detailData.reference_date ? new Date(detailData.reference_date).toLocaleDateString('zh-CN') : '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="元数据" span={2}>
|
||||
<pre style={{ margin: 0, maxHeight: 200, overflow: 'auto' }}>
|
||||
{JSON.stringify(detailData.metadata || {}, null, 2)}
|
||||
</pre>
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
) : (
|
||||
<Empty description="暂无数据" />
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default DataList
|
||||
672
frontend/src/pages/DataSources/DataSources.tsx
Normal file
672
frontend/src/pages/DataSources/DataSources.tsx
Normal file
@@ -0,0 +1,672 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
Table, Tag, Space, message, Button, Form, Input, Select,
|
||||
Drawer, Tabs, Empty, Tooltip, Popconfirm, Collapse, InputNumber
|
||||
} from 'antd'
|
||||
import {
|
||||
PlayCircleOutlined, PauseCircleOutlined, PlusOutlined,
|
||||
EditOutlined, DeleteOutlined, ApiOutlined,
|
||||
CheckCircleOutlined, CloseCircleOutlined, ExperimentOutlined,
|
||||
SyncOutlined
|
||||
} from '@ant-design/icons'
|
||||
import axios from 'axios'
|
||||
|
||||
interface BuiltInDataSource {
|
||||
id: number
|
||||
name: string
|
||||
module: string
|
||||
priority: string
|
||||
frequency: string
|
||||
is_active: boolean
|
||||
collector_class: string
|
||||
}
|
||||
|
||||
interface CustomDataSource {
|
||||
id: number
|
||||
name: string
|
||||
description: string | null
|
||||
source_type: string
|
||||
endpoint: string
|
||||
auth_type: string
|
||||
is_active: boolean
|
||||
created_at: string
|
||||
updated_at: string | null
|
||||
}
|
||||
|
||||
interface ViewDataSource {
|
||||
id: number
|
||||
name: string
|
||||
description: string | null
|
||||
source_type: string
|
||||
endpoint: string
|
||||
auth_type: string
|
||||
headers: Record<string, string>
|
||||
config: Record<string, any>
|
||||
collector_class: string
|
||||
module: string
|
||||
priority: string
|
||||
frequency: string
|
||||
}
|
||||
|
||||
function DataSources() {
|
||||
const [activeTab, setActiveTab] = useState('builtin')
|
||||
const [builtInSources, setBuiltInSources] = useState<BuiltInDataSource[]>([])
|
||||
const [customSources, setCustomSources] = useState<CustomDataSource[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [drawerVisible, setDrawerVisible] = useState(false)
|
||||
const [viewDrawerVisible, setViewDrawerVisible] = useState(false)
|
||||
const [editingConfig, setEditingConfig] = useState<CustomDataSource | null>(null)
|
||||
const [viewingSource, setViewingSource] = useState<ViewDataSource | null>(null)
|
||||
const [testing, setTesting] = useState(false)
|
||||
const [testResult, setTestResult] = useState<any>(null)
|
||||
const [form] = Form.useForm()
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [builtinRes, customRes] = await Promise.all([
|
||||
axios.get('/api/v1/datasources'),
|
||||
axios.get('/api/v1/datasources/configs')
|
||||
])
|
||||
setBuiltInSources(builtinRes.data.data || [])
|
||||
setCustomSources(customRes.data.data || [])
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch data:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchData()
|
||||
}, [])
|
||||
|
||||
const handleTrigger = async (id: number) => {
|
||||
try {
|
||||
await axios.post(`/api/v1/datasources/${id}/trigger`)
|
||||
message.success('任务已触发')
|
||||
} catch (error: unknown) {
|
||||
const err = error as { response?: { data?: { detail?: string } } }
|
||||
message.error(err.response?.data?.detail || '触发失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggle = async (id: number, current: boolean) => {
|
||||
const endpoint = current ? 'disable' : 'enable'
|
||||
try {
|
||||
await axios.post(`/api/v1/datasources/${id}/${endpoint}`)
|
||||
message.success(`${current ? '已禁用' : '已启用'}`)
|
||||
fetchData()
|
||||
} catch (error: unknown) {
|
||||
const err = error as { response?: { data?: { detail?: string } } }
|
||||
message.error(err.response?.data?.detail || '操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleViewSource = async (source: BuiltInDataSource) => {
|
||||
try {
|
||||
const res = await axios.get(`/api/v1/datasources/${source.id}`)
|
||||
const data = res.data
|
||||
setViewingSource({
|
||||
id: data.id,
|
||||
name: data.name,
|
||||
description: null,
|
||||
source_type: data.collector_class,
|
||||
endpoint: '',
|
||||
auth_type: 'none',
|
||||
headers: {},
|
||||
config: {},
|
||||
collector_class: data.collector_class,
|
||||
module: data.module,
|
||||
priority: data.priority,
|
||||
frequency: data.frequency,
|
||||
})
|
||||
setViewDrawerVisible(true)
|
||||
} catch (error) {
|
||||
message.error('获取数据源信息失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpdateSource = async () => {
|
||||
if (!viewingSource) return
|
||||
try {
|
||||
await axios.post(`/api/v1/datasources/${viewingSource.id}/trigger`)
|
||||
message.success('已触发更新')
|
||||
setViewDrawerVisible(false)
|
||||
} catch (error: unknown) {
|
||||
const err = error as { response?: { data?: { detail?: string } } }
|
||||
message.error(err.response?.data?.detail || '更新失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleTest = async () => {
|
||||
try {
|
||||
const values = await form.validateFields()
|
||||
setTesting(true)
|
||||
setTestResult(null)
|
||||
const res = await axios.post('/api/v1/datasources/configs/test', values)
|
||||
setTestResult(res.data)
|
||||
if (res.data.success) {
|
||||
message.success('连接测试成功')
|
||||
} else {
|
||||
message.error('连接测试失败')
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const err = error as { response?: { data?: { detail?: string; message?: string } } }
|
||||
message.error(err.response?.data?.message || err.response?.data?.detail || '测试失败')
|
||||
} finally {
|
||||
setTesting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
const values = await form.validateFields()
|
||||
if (editingConfig) {
|
||||
await axios.put(`/api/v1/datasources/configs/${editingConfig.id}`, values)
|
||||
message.success('配置已更新')
|
||||
} else {
|
||||
await axios.post('/api/v1/datasources/configs', values)
|
||||
message.success('配置已创建')
|
||||
}
|
||||
setDrawerVisible(false)
|
||||
form.resetFields()
|
||||
setEditingConfig(null)
|
||||
setTestResult(null)
|
||||
fetchData()
|
||||
} catch (error: unknown) {
|
||||
const err = error as { response?: { data?: { detail?: string; message?: string } } }
|
||||
message.error(err.response?.data?.message || err.response?.data?.detail || '保存失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
try {
|
||||
await axios.delete(`/api/v1/datasources/configs/${id}`)
|
||||
message.success('配置已删除')
|
||||
fetchData()
|
||||
} catch (error: unknown) {
|
||||
const err = error as { response?: { data?: { detail?: string } } }
|
||||
message.error(err.response?.data?.detail || '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggleCustom = async (id: number, current: boolean) => {
|
||||
try {
|
||||
await axios.put(`/api/v1/datasources/configs/${id}`, { is_active: !current })
|
||||
message.success(`${current ? '已禁用' : '已启用'}`)
|
||||
fetchData()
|
||||
} catch (error: unknown) {
|
||||
const err = error as { response?: { data?: { detail?: string } } }
|
||||
message.error(err.response?.data?.detail || '操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
const openDrawer = (config?: CustomDataSource) => {
|
||||
setEditingConfig(config || null)
|
||||
if (config) {
|
||||
form.setFieldsValue({
|
||||
...config,
|
||||
auth_config: {},
|
||||
})
|
||||
} else {
|
||||
form.resetFields()
|
||||
form.setFieldsValue({
|
||||
source_type: 'http',
|
||||
auth_type: 'none',
|
||||
config: { timeout: 30, retry: 3 },
|
||||
headers: {},
|
||||
})
|
||||
}
|
||||
setDrawerVisible(true)
|
||||
setTestResult(null)
|
||||
}
|
||||
|
||||
const builtinColumns = [
|
||||
{ title: 'ID', dataIndex: 'id', key: 'id', width: 60 },
|
||||
{
|
||||
title: '名称',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
render: (name: string, record: BuiltInDataSource) => (
|
||||
<Button type="link" onClick={() => handleViewSource(record)}>
|
||||
{name}
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
{ title: '模块', dataIndex: 'module', key: 'module' },
|
||||
{
|
||||
title: '优先级',
|
||||
dataIndex: 'priority',
|
||||
key: 'priority',
|
||||
render: (p: string) => <Tag color={p === 'P0' ? 'red' : 'orange'}>{p}</Tag>,
|
||||
},
|
||||
{ title: '频率', dataIndex: 'frequency', key: 'frequency' },
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'is_active',
|
||||
key: 'is_active',
|
||||
render: (active: boolean) => (
|
||||
<Tag color={active ? 'green' : 'red'}>{active ? '运行中' : '已暂停'}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
render: (_: unknown, record: BuiltInDataSource) => (
|
||||
<Space>
|
||||
<Button
|
||||
type="link"
|
||||
icon={<SyncOutlined />}
|
||||
onClick={() => handleTrigger(record.id)}
|
||||
>
|
||||
触发
|
||||
</Button>
|
||||
<Button
|
||||
type="link"
|
||||
icon={record.is_active ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
|
||||
onClick={() => handleToggle(record.id, record.is_active)}
|
||||
>
|
||||
{record.is_active ? '禁用' : '启用'}
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
const customColumns = [
|
||||
{ title: 'ID', dataIndex: 'id', key: 'id', width: 60 },
|
||||
{ title: '名称', dataIndex: 'name', key: 'name' },
|
||||
{ title: '类型', dataIndex: 'source_type', key: 'source_type' },
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'is_active',
|
||||
key: 'is_active',
|
||||
render: (active: boolean) => (
|
||||
<Tag color={active ? 'green' : 'red'}>{active ? '启用' : '禁用'}</Tag>
|
||||
),
|
||||
},
|
||||
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at' },
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
render: (_: unknown, record: CustomDataSource) => (
|
||||
<Space>
|
||||
<Tooltip title="编辑">
|
||||
<Button type="link" icon={<EditOutlined />} onClick={() => openDrawer(record)} />
|
||||
</Tooltip>
|
||||
<Tooltip title={record.is_active ? '禁用' : '启用'}>
|
||||
<Button
|
||||
type="link"
|
||||
icon={record.is_active ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
|
||||
onClick={() => handleToggleCustom(record.id, record.is_active)}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Popconfirm
|
||||
title="确定删除此配置?"
|
||||
onConfirm={() => handleDelete(record.id)}
|
||||
>
|
||||
<Tooltip title="删除">
|
||||
<Button type="link" danger icon={<DeleteOutlined />} />
|
||||
</Tooltip>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
const tabItems = [
|
||||
{
|
||||
key: 'builtin',
|
||||
label: '内置数据源',
|
||||
children: (
|
||||
<Table
|
||||
columns={builtinColumns}
|
||||
dataSource={builtInSources}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'custom',
|
||||
label: (
|
||||
<span>
|
||||
<ApiOutlined /> 自定义数据源
|
||||
</span>
|
||||
),
|
||||
children: (
|
||||
<>
|
||||
<div style={{ marginBottom: 16, textAlign: 'right' }}>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => openDrawer()}>
|
||||
添加数据源
|
||||
</Button>
|
||||
</div>
|
||||
{customSources.length === 0 ? (
|
||||
<Empty description="暂无自定义数据源" />
|
||||
) : (
|
||||
<Table
|
||||
columns={customColumns}
|
||||
dataSource={customSources}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2>数据源管理</h2>
|
||||
<Tabs activeKey={activeTab} onChange={setActiveTab} items={tabItems} />
|
||||
|
||||
<Drawer
|
||||
title={editingConfig ? '编辑数据源' : '添加数据源'}
|
||||
width={600}
|
||||
open={drawerVisible}
|
||||
onClose={() => {
|
||||
setDrawerVisible(false)
|
||||
form.resetFields()
|
||||
setEditingConfig(null)
|
||||
setTestResult(null)
|
||||
}}
|
||||
footer={
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<Button
|
||||
icon={<ExperimentOutlined />}
|
||||
loading={testing}
|
||||
onClick={handleTest}
|
||||
>
|
||||
测试连接
|
||||
</Button>
|
||||
<Space>
|
||||
<Button onClick={() => setDrawerVisible(false)}>取消</Button>
|
||||
<Button type="primary" onClick={handleSave}>
|
||||
保存
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item
|
||||
name="name"
|
||||
label="名称"
|
||||
rules={[{ required: true, message: '请输入名称' }]}
|
||||
>
|
||||
<Input placeholder="My API Data Source" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="description" label="描述">
|
||||
<Input.TextArea rows={2} placeholder="数据源描述" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="source_type"
|
||||
label="数据源类型"
|
||||
rules={[{ required: true, message: '请选择类型' }]}
|
||||
>
|
||||
<Select>
|
||||
<Select.Option value="http">HTTP API</Select.Option>
|
||||
<Select.Option value="api">REST API</Select.Option>
|
||||
<Select.Option value="database">数据库</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="endpoint"
|
||||
label="接口地址"
|
||||
rules={[{ required: true, message: '请输入接口地址' }]}
|
||||
>
|
||||
<Input placeholder="https://api.example.com/data" />
|
||||
</Form.Item>
|
||||
|
||||
<Collapse
|
||||
items={[
|
||||
{
|
||||
key: 'auth',
|
||||
label: '认证配置',
|
||||
children: (
|
||||
<>
|
||||
<Form.Item name="auth_type" label="认证方式">
|
||||
<Select>
|
||||
<Select.Option value="none">无</Select.Option>
|
||||
<Select.Option value="bearer">Bearer Token</Select.Option>
|
||||
<Select.Option value="api_key">API Key</Select.Option>
|
||||
<Select.Option value="basic">Basic Auth</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<div>
|
||||
<Form.Item noStyle shouldUpdate={(_, { auth_type }) => auth_type === 'bearer'}>
|
||||
{({ getFieldValue }) => {
|
||||
if (getFieldValue('auth_type') === 'bearer') {
|
||||
return (
|
||||
<Form.Item name={['auth_config', 'token']} label="Token">
|
||||
<Input.Password placeholder="Bearer Token" />
|
||||
</Form.Item>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}}
|
||||
</Form.Item>
|
||||
<Form.Item noStyle shouldUpdate={(_, { auth_type }) => auth_type === 'api_key'}>
|
||||
{({ getFieldValue }) => {
|
||||
if (getFieldValue('auth_type') === 'api_key') {
|
||||
return (
|
||||
<>
|
||||
<Form.Item name={['auth_config', 'key_name']} label="Header名称" initialValue="X-API-Key">
|
||||
<Input placeholder="X-API-Key" />
|
||||
</Form.Item>
|
||||
<Form.Item name={['auth_config', 'api_key']} label="API Key">
|
||||
<Input.Password placeholder="API Key" />
|
||||
</Form.Item>
|
||||
</>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}}
|
||||
</Form.Item>
|
||||
<Form.Item noStyle shouldUpdate={(_, { auth_type }) => auth_type === 'basic'}>
|
||||
{({ getFieldValue }) => {
|
||||
if (getFieldValue('auth_type') === 'basic') {
|
||||
return (
|
||||
<>
|
||||
<Form.Item name={['auth_config', 'username']} label="用户名">
|
||||
<Input placeholder="Username" />
|
||||
</Form.Item>
|
||||
<Form.Item name={['auth_config', 'password']} label="密码">
|
||||
<Input.Password placeholder="Password" />
|
||||
</Form.Item>
|
||||
</>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}}
|
||||
</Form.Item>
|
||||
</div>
|
||||
</>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<Collapse
|
||||
items={[
|
||||
{
|
||||
key: 'headers',
|
||||
label: '请求头',
|
||||
children: (
|
||||
<Form.List name="headers">
|
||||
{(fields, { add, remove }) => (
|
||||
<>
|
||||
{fields.map(({ key, name, ...restField }) => (
|
||||
<Space key={key} style={{ display: 'flex', marginBottom: 8 }} align="baseline">
|
||||
<Form.Item {...restField} name={[name, 'key']} rules={[{ required: true, message: 'Header键' }]}>
|
||||
<Input placeholder="Content-Type" />
|
||||
</Form.Item>
|
||||
<Form.Item {...restField} name={[name, 'value']} rules={[{ required: true, message: 'Header值' }]}>
|
||||
<Input placeholder="application/json" />
|
||||
</Form.Item>
|
||||
<Button type="link" danger onClick={() => remove(name)}>删除</Button>
|
||||
</Space>
|
||||
))}
|
||||
<Button type="dashed" onClick={() => add()} block>
|
||||
添加请求头
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Form.List>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<Collapse
|
||||
items={[
|
||||
{
|
||||
key: 'config',
|
||||
label: '高级配置',
|
||||
children: (
|
||||
<>
|
||||
<Form.Item name={['config', 'timeout']} label="超时时间(秒)">
|
||||
<InputNumber min={1} max={300} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name={['config', 'retry']} label="重试次数">
|
||||
<InputNumber min={0} max={10} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{testResult && (
|
||||
<div style={{
|
||||
marginTop: 16,
|
||||
padding: 16,
|
||||
background: testResult.success ? '#f6ffed' : '#fff2f0',
|
||||
border: `1px solid ${testResult.success ? '#b7eb8f' : '#ffa39e'}`,
|
||||
borderRadius: 4,
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
|
||||
{testResult.success ? (
|
||||
<CheckCircleOutlined style={{ color: '#52c41a', fontSize: 18 }} />
|
||||
) : (
|
||||
<CloseCircleOutlined style={{ color: '#ff4d4f', fontSize: 18 }} />
|
||||
)}
|
||||
<span style={{ fontWeight: 'bold' }}>
|
||||
{testResult.success ? '连接成功' : '连接失败'}
|
||||
</span>
|
||||
</div>
|
||||
{testResult.status_code && <div>状态码: {testResult.status_code}</div>}
|
||||
{testResult.response_time_ms && <div>响应时间: {testResult.response_time_ms.toFixed(0)}ms</div>}
|
||||
{testResult.error && <div style={{ color: '#ff4d4f' }}>错误: {testResult.error}</div>}
|
||||
{testResult.data_preview && (
|
||||
<div style={{ marginTop: 8, fontSize: 12, color: '#666' }}>
|
||||
预览: {testResult.data_preview}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Form>
|
||||
</Drawer>
|
||||
|
||||
<Drawer
|
||||
title="查看数据源"
|
||||
width={600}
|
||||
open={viewDrawerVisible}
|
||||
onClose={() => {
|
||||
setViewDrawerVisible(false)
|
||||
setViewingSource(null)
|
||||
}}
|
||||
footer={
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<Button
|
||||
icon={<ExperimentOutlined />}
|
||||
loading={testing}
|
||||
onClick={handleTest}
|
||||
>
|
||||
测试连接
|
||||
</Button>
|
||||
<Space>
|
||||
<Button onClick={() => setViewDrawerVisible(false)}>关闭</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SyncOutlined />}
|
||||
onClick={handleUpdateSource}
|
||||
>
|
||||
更新
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{viewingSource && (
|
||||
<Form layout="vertical">
|
||||
<Form.Item label="名称">
|
||||
<Input value={viewingSource.name} disabled />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="采集器">
|
||||
<Input value={viewingSource.collector_class} disabled />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="模块">
|
||||
<Input value={viewingSource.module} disabled />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="优先级">
|
||||
<Input value={viewingSource.priority} disabled />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="频率">
|
||||
<Input value={viewingSource.frequency} disabled />
|
||||
</Form.Item>
|
||||
|
||||
<Collapse
|
||||
items={[
|
||||
{
|
||||
key: 'auth',
|
||||
label: '认证配置',
|
||||
children: (
|
||||
<Form.Item label="认证方式">
|
||||
<Input value={viewingSource.auth_type || 'none'} disabled />
|
||||
</Form.Item>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'headers',
|
||||
label: '请求头',
|
||||
children: viewingSource.headers && Object.keys(viewingSource.headers).length > 0 ? (
|
||||
<pre style={{ background: '#f5f5f5', padding: 12, borderRadius: 4, overflow: 'auto' }}>
|
||||
{JSON.stringify(viewingSource.headers, null, 2)}
|
||||
</pre>
|
||||
) : (
|
||||
<div style={{ color: '#999' }}>无</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'config',
|
||||
label: '高级配置',
|
||||
children: viewingSource.config && Object.keys(viewingSource.config).length > 0 ? (
|
||||
<pre style={{ background: '#f5f5f5', padding: 12, borderRadius: 4, overflow: 'auto' }}>
|
||||
{JSON.stringify(viewingSource.config, null, 2)}
|
||||
</pre>
|
||||
) : (
|
||||
<div style={{ color: '#999' }}>无</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Form>
|
||||
)}
|
||||
</Drawer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default DataSources
|
||||
65
frontend/src/pages/Login/Login.tsx
Normal file
65
frontend/src/pages/Login/Login.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { useState } from 'react'
|
||||
import { Input, Button, Form, message } from 'antd'
|
||||
import { UserOutlined, LockOutlined } from '@ant-design/icons'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
|
||||
function Login() {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const { login } = useAuthStore()
|
||||
|
||||
const onFinish = async (values: { username: string; password: string }) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
await login(values.username, values.password)
|
||||
message.success('登录成功')
|
||||
} catch (error: unknown) {
|
||||
const err = error as { response?: { data?: { detail?: string } } }
|
||||
message.error(err.response?.data?.detail || '登录失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="login-container">
|
||||
<div className="login-box">
|
||||
<h1 style={{ textAlign: 'center', marginBottom: 24 }}>智能星球计划</h1>
|
||||
<Form name="login" onFinish={onFinish}>
|
||||
<Form.Item
|
||||
name="username"
|
||||
rules={[{ required: true, message: '请输入用户名' }]}
|
||||
>
|
||||
<Input
|
||||
prefix={<UserOutlined />}
|
||||
placeholder="用户名"
|
||||
size="large"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="password"
|
||||
rules={[{ required: true, message: '请输入密码' }]}
|
||||
>
|
||||
<Input.Password
|
||||
prefix={<LockOutlined />}
|
||||
placeholder="密码"
|
||||
size="large"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
size="large"
|
||||
block
|
||||
loading={loading}
|
||||
>
|
||||
登录
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Login
|
||||
421
frontend/src/pages/Settings/Settings.tsx
Normal file
421
frontend/src/pages/Settings/Settings.tsx
Normal file
@@ -0,0 +1,421 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import {
|
||||
Layout,
|
||||
Menu,
|
||||
Card,
|
||||
Row,
|
||||
Col,
|
||||
Typography,
|
||||
Button,
|
||||
Form,
|
||||
Input,
|
||||
Switch,
|
||||
Select,
|
||||
Divider,
|
||||
message,
|
||||
Spin,
|
||||
Tabs,
|
||||
InputNumber,
|
||||
} from 'antd'
|
||||
import {
|
||||
SettingOutlined,
|
||||
DashboardOutlined,
|
||||
DatabaseOutlined,
|
||||
UserOutlined,
|
||||
BellOutlined,
|
||||
SafetyOutlined,
|
||||
SaveOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
|
||||
const { Header, Sider, Content } = Layout
|
||||
const { Title, Text } = Typography
|
||||
const { TabPane } = Tabs
|
||||
|
||||
interface SystemSettings {
|
||||
system_name: string
|
||||
refresh_interval: number
|
||||
auto_refresh: boolean
|
||||
data_retention_days: number
|
||||
max_concurrent_tasks: number
|
||||
}
|
||||
|
||||
interface NotificationSettings {
|
||||
email_enabled: boolean
|
||||
email_address: string
|
||||
critical_alerts: boolean
|
||||
warning_alerts: boolean
|
||||
daily_summary: boolean
|
||||
}
|
||||
|
||||
interface SecuritySettings {
|
||||
session_timeout: number
|
||||
max_login_attempts: number
|
||||
password_policy: string
|
||||
}
|
||||
|
||||
function Settings() {
|
||||
const { user, logout, token, clearAuth } = useAuthStore()
|
||||
const navigate = useNavigate()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [systemSettings, setSystemSettings] = useState<SystemSettings>({
|
||||
system_name: '智能星球',
|
||||
refresh_interval: 60,
|
||||
auto_refresh: true,
|
||||
data_retention_days: 30,
|
||||
max_concurrent_tasks: 5,
|
||||
})
|
||||
const [notificationSettings, setNotificationSettings] = useState<NotificationSettings>({
|
||||
email_enabled: false,
|
||||
email_address: '',
|
||||
critical_alerts: true,
|
||||
warning_alerts: true,
|
||||
daily_summary: false,
|
||||
})
|
||||
const [securitySettings, setSecuritySettings] = useState<SecuritySettings>({
|
||||
session_timeout: 60,
|
||||
max_login_attempts: 5,
|
||||
password_policy: 'medium',
|
||||
})
|
||||
const [form] = Form.useForm()
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
navigate('/')
|
||||
return
|
||||
}
|
||||
fetchSettings()
|
||||
}, [token, navigate])
|
||||
|
||||
const fetchSettings = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const res = await fetch('/api/v1/settings/system', {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
if (res.status === 401) {
|
||||
clearAuth()
|
||||
navigate('/')
|
||||
return
|
||||
}
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setSystemSettings(data.system || systemSettings)
|
||||
setNotificationSettings(data.notifications || notificationSettings)
|
||||
setSecuritySettings(data.security || securitySettings)
|
||||
form.setFieldsValue({
|
||||
...data.system,
|
||||
...data.notifications,
|
||||
...data.security,
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
message.error('获取设置失败')
|
||||
console.error(err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSaveSystem = async (values: any) => {
|
||||
try {
|
||||
setSaving(true)
|
||||
const res = await fetch('/api/v1/settings/system', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify(values),
|
||||
})
|
||||
if (res.ok) {
|
||||
message.success('系统设置已保存')
|
||||
setSystemSettings(values)
|
||||
} else {
|
||||
message.error('保存失败')
|
||||
}
|
||||
} catch (err) {
|
||||
message.error('保存设置失败')
|
||||
console.error(err)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSaveNotifications = async (values: any) => {
|
||||
try {
|
||||
setSaving(true)
|
||||
const res = await fetch('/api/v1/settings/notifications', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify(values),
|
||||
})
|
||||
if (res.ok) {
|
||||
message.success('通知设置已保存')
|
||||
setNotificationSettings(values)
|
||||
} else {
|
||||
message.error('保存失败')
|
||||
}
|
||||
} catch (err) {
|
||||
message.error('保存设置失败')
|
||||
console.error(err)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSaveSecurity = async (values: any) => {
|
||||
try {
|
||||
setSaving(true)
|
||||
const res = await fetch('/api/v1/settings/security', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify(values),
|
||||
})
|
||||
if (res.ok) {
|
||||
message.success('安全设置已保存')
|
||||
setSecuritySettings(values)
|
||||
} else {
|
||||
message.error('保存失败')
|
||||
}
|
||||
} catch (err) {
|
||||
message.error('保存设置失败')
|
||||
console.error(err)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleLogout = () => {
|
||||
logout()
|
||||
navigate('/')
|
||||
}
|
||||
|
||||
const menuItems = [
|
||||
{ key: '/', icon: <DashboardOutlined />, label: <Link to="/">仪表盘</Link> },
|
||||
{ key: '/datasources', icon: <DatabaseOutlined />, label: <Link to="/datasources">数据源</Link> },
|
||||
{ key: '/users', icon: <UserOutlined />, label: <Link to="/users">用户管理</Link> },
|
||||
{ key: '/settings', icon: <SettingOutlined />, label: '系统配置' },
|
||||
]
|
||||
|
||||
if (loading && !token) {
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
|
||||
<Spin size="large" tip="加载中..." />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout className="dashboard-layout">
|
||||
<Sider width={240} className="dashboard-sider">
|
||||
<div style={{ height: 64, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Title level={4} style={{ color: 'white', margin: 0 }}>智能星球</Title>
|
||||
</div>
|
||||
<Menu theme="dark" mode="inline" defaultSelectedKeys={['/settings']} items={menuItems} />
|
||||
</Sider>
|
||||
<Layout>
|
||||
<Header className="dashboard-header" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Text strong>欢迎, {user?.username}</Text>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<Button type="link" danger onClick={handleLogout}>退出登录</Button>
|
||||
</div>
|
||||
</Header>
|
||||
<Content className="dashboard-content">
|
||||
<Title level={3}><SettingOutlined /> 系统设置</Title>
|
||||
<Tabs defaultActiveKey="system" tabPosition="left">
|
||||
<TabPane
|
||||
tab={<span><SettingOutlined /> 系统配置</span>}
|
||||
key="system"
|
||||
>
|
||||
<Card title="基本设置">
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
onFinish={handleSaveSystem}
|
||||
initialValues={systemSettings}
|
||||
>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
name="system_name"
|
||||
label="系统名称"
|
||||
rules={[{ required: true, message: '请输入系统名称' }]}
|
||||
>
|
||||
<Input placeholder="智能星球" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
name="refresh_interval"
|
||||
label="数据刷新间隔 (秒)"
|
||||
>
|
||||
<InputNumber min={10} max={3600} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
name="data_retention_days"
|
||||
label="数据保留天数"
|
||||
>
|
||||
<InputNumber min={1} max={365} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
name="max_concurrent_tasks"
|
||||
label="最大并发任务数"
|
||||
>
|
||||
<InputNumber min={1} max={20} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Form.Item
|
||||
name="auto_refresh"
|
||||
label="自动刷新"
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit" icon={<SaveOutlined />} loading={saving}>
|
||||
保存设置
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
</TabPane>
|
||||
<TabPane
|
||||
tab={<span><BellOutlined /> 通知设置</span>}
|
||||
key="notifications"
|
||||
>
|
||||
<Card title="通知配置">
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
onFinish={handleSaveNotifications}
|
||||
initialValues={notificationSettings}
|
||||
>
|
||||
<Divider orientation="left">邮件通知</Divider>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
name="email_enabled"
|
||||
label="启用邮件通知"
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
name="email_address"
|
||||
label="通知邮箱"
|
||||
rules={[{ type: 'email', message: '请输入有效的邮箱地址' }]}
|
||||
>
|
||||
<Input placeholder="admin@example.com" disabled={!notificationSettings.email_enabled} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Divider orientation="left">告警通知</Divider>
|
||||
<Row gutter={16}>
|
||||
<Col span={8}>
|
||||
<Form.Item
|
||||
name="critical_alerts"
|
||||
label="严重告警"
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Form.Item
|
||||
name="warning_alerts"
|
||||
label="警告告警"
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Form.Item
|
||||
name="daily_summary"
|
||||
label="每日摘要"
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit" icon={<SaveOutlined />} loading={saving}>
|
||||
保存设置
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
</TabPane>
|
||||
<TabPane
|
||||
tab={<span><SafetyOutlined /> 安全设置</span>}
|
||||
key="security"
|
||||
>
|
||||
<Card title="安全配置">
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
onFinish={handleSaveSecurity}
|
||||
initialValues={securitySettings}
|
||||
>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
name="session_timeout"
|
||||
label="会话超时 (分钟)"
|
||||
>
|
||||
<InputNumber min={5} max={1440} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
name="max_login_attempts"
|
||||
label="最大登录尝试次数"
|
||||
>
|
||||
<InputNumber min={1} max={10} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Form.Item
|
||||
name="password_policy"
|
||||
label="密码策略"
|
||||
>
|
||||
<Select>
|
||||
<Select.Option value="low">简单 (最低6位)</Select.Option>
|
||||
<Select.Option value="medium">中等 (8位以上,含数字字母)</Select.Option>
|
||||
<Select.Option value="high">严格 (12位以上,含大小写数字特殊字符)</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Divider />
|
||||
<Button type="primary" htmlType="submit" icon={<SaveOutlined />} loading={saving}>
|
||||
保存设置
|
||||
</Button>
|
||||
</Form>
|
||||
</Card>
|
||||
</TabPane>
|
||||
</Tabs>
|
||||
</Content>
|
||||
</Layout>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
export default Settings
|
||||
153
frontend/src/pages/Tasks/Tasks.tsx
Normal file
153
frontend/src/pages/Tasks/Tasks.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Table, Tag, Card, Row, Col, Statistic, Button } from 'antd'
|
||||
import { ReloadOutlined, CheckCircleOutlined, CloseCircleOutlined, SyncOutlined } from '@ant-design/icons'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
|
||||
interface Task {
|
||||
id: number
|
||||
collector: string
|
||||
status: 'success' | 'failed' | 'running' | 'pending'
|
||||
records_processed: number
|
||||
started_at: string
|
||||
completed_at: string
|
||||
duration_seconds: number
|
||||
}
|
||||
|
||||
function Tasks() {
|
||||
const { token } = useAuthStore()
|
||||
const [tasks, setTasks] = useState<Task[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [stats, setStats] = useState({
|
||||
total_today: 0,
|
||||
success: 0,
|
||||
failed: 0,
|
||||
running: 0,
|
||||
})
|
||||
|
||||
const fetchTasks = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetch('/api/v1/tasks', {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
const data = await res.json()
|
||||
setTasks(data.data || [])
|
||||
setStats(data.stats || stats)
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch tasks:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchTasks()
|
||||
}, [token])
|
||||
|
||||
const columns = [
|
||||
{ title: 'ID', dataIndex: 'id', key: 'id', width: 80 },
|
||||
{
|
||||
title: '收集器',
|
||||
dataIndex: 'collector',
|
||||
key: 'collector',
|
||||
render: (c: string) => <Tag color="blue">{c}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
render: (status: string) => {
|
||||
const colors: Record<string, string> = {
|
||||
success: 'success',
|
||||
failed: 'error',
|
||||
running: 'processing',
|
||||
pending: 'default',
|
||||
}
|
||||
const icons: Record<string, JSX.Element> = {
|
||||
success: <CheckCircleOutlined />,
|
||||
failed: <CloseCircleOutlined />,
|
||||
running: <SyncOutlined spin />,
|
||||
pending: <SyncOutlined />,
|
||||
}
|
||||
return (
|
||||
<Tag color={colors[status] || 'default'} icon={icons[status]}>
|
||||
{status === 'success' ? '成功' : status === 'failed' ? '失败' : status === 'running' ? '运行中' : '等待中'}
|
||||
</Tag>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '处理记录',
|
||||
dataIndex: 'records_processed',
|
||||
key: 'records_processed',
|
||||
render: (n: number) => n.toLocaleString(),
|
||||
},
|
||||
{
|
||||
title: '耗时',
|
||||
dataIndex: 'duration_seconds',
|
||||
key: 'duration_seconds',
|
||||
render: (s: number) => s ? `${s.toFixed(2)}s` : '-',
|
||||
},
|
||||
{
|
||||
title: '开始时间',
|
||||
dataIndex: 'started_at',
|
||||
key: 'started_at',
|
||||
render: (t: string) => t ? new Date(t).toLocaleString('zh-CN') : '-',
|
||||
},
|
||||
]
|
||||
|
||||
const statusCounts = tasks.reduce(
|
||||
(acc, task) => {
|
||||
acc[task.status]++
|
||||
return acc
|
||||
},
|
||||
{ success: 0, failed: 0, running: 0, pending: 0 } as Record<string, number>
|
||||
)
|
||||
|
||||
const successRate = tasks.length > 0 ? (statusCounts.success / tasks.length) * 100 : 0
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
|
||||
<Col span={6}>
|
||||
<Card>
|
||||
<Statistic title="今日任务" value={tasks.length} prefix={<ReloadOutlined />} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="成功率"
|
||||
value={successRate.toFixed(1)}
|
||||
suffix="%"
|
||||
valueStyle={{ color: successRate >= 90 ? '#52c41a' : '#faad14' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card>
|
||||
<Statistic title="成功" value={statusCounts.success} valueStyle={{ color: '#52c41a' }} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card>
|
||||
<Statistic title="失败" value={statusCounts.failed} valueStyle={{ color: '#ff4d4f' }} />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Card
|
||||
title="任务历史"
|
||||
extra={
|
||||
<Button type="primary" icon={<ReloadOutlined />} onClick={fetchTasks}>
|
||||
刷新
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Table columns={columns} dataSource={tasks} rowKey="id" loading={loading} pagination={{ pageSize: 10 }} />
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Tasks
|
||||
157
frontend/src/pages/Users/Users.tsx
Normal file
157
frontend/src/pages/Users/Users.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Table, Button, Tag, Space, message, Modal, Form, Input, Select } from 'antd'
|
||||
import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons'
|
||||
import axios from 'axios'
|
||||
|
||||
interface User {
|
||||
id: number
|
||||
username: string
|
||||
email: string
|
||||
role: string
|
||||
is_active: boolean
|
||||
created_at: string
|
||||
}
|
||||
|
||||
function Users() {
|
||||
const [users, setUsers] = useState<User[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [modalVisible, setModalVisible] = useState(false)
|
||||
const [editingUser, setEditingUser] = useState<User | null>(null)
|
||||
const [form] = Form.useForm()
|
||||
|
||||
const fetchUsers = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await axios.get('/api/v1/users')
|
||||
setUsers(res.data.data || [])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers()
|
||||
}, [])
|
||||
|
||||
const handleAdd = () => {
|
||||
setEditingUser(null)
|
||||
form.resetFields()
|
||||
setModalVisible(true)
|
||||
}
|
||||
|
||||
const handleEdit = (user: User) => {
|
||||
setEditingUser(user)
|
||||
form.setFieldsValue(user)
|
||||
setModalVisible(true)
|
||||
}
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
Modal.confirm({
|
||||
title: '确认删除',
|
||||
content: '确定要删除此用户吗?',
|
||||
onOk: async () => {
|
||||
await axios.delete(`/api/v1/users/${id}`)
|
||||
message.success('删除成功')
|
||||
fetchUsers()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const handleSubmit = async (values: Record<string, unknown>) => {
|
||||
try {
|
||||
if (editingUser) {
|
||||
await axios.put(`/api/v1/users/${editingUser.id}`, values)
|
||||
message.success('更新成功')
|
||||
} else {
|
||||
await axios.post('/api/v1/users', values)
|
||||
message.success('创建成功')
|
||||
}
|
||||
setModalVisible(false)
|
||||
fetchUsers()
|
||||
} catch (error: unknown) {
|
||||
const err = error as { response?: { data?: { detail?: string } } }
|
||||
message.error(err.response?.data?.detail || '操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{ title: 'ID', dataIndex: 'id', key: 'id', width: 80 },
|
||||
{ title: '用户名', dataIndex: 'username', key: 'username' },
|
||||
{ title: '邮箱', dataIndex: 'email', key: 'email' },
|
||||
{
|
||||
title: '角色',
|
||||
dataIndex: 'role',
|
||||
key: 'role',
|
||||
render: (role: string) => {
|
||||
const colors: Record<string, string> = {
|
||||
super_admin: 'red',
|
||||
admin: 'orange',
|
||||
operator: 'blue',
|
||||
viewer: 'green',
|
||||
}
|
||||
return <Tag color={colors[role] || 'default'}>{role}</Tag>
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'is_active',
|
||||
key: 'is_active',
|
||||
render: (active: boolean) => (
|
||||
<Tag color={active ? 'green' : 'red'}>{active ? '活跃' : '禁用'}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
render: (_: unknown, record: User) => (
|
||||
<Space>
|
||||
<Button type="link" icon={<EditOutlined />} onClick={() => handleEdit(record)}>编辑</Button>
|
||||
<Button type="link" danger icon={<DeleteOutlined />} onClick={() => handleDelete(record.id)}>删除</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between' }}>
|
||||
<h2>用户管理</h2>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}>添加用户</Button>
|
||||
</div>
|
||||
<Table columns={columns} dataSource={users} rowKey="id" loading={loading} />
|
||||
<Modal
|
||||
title={editingUser ? '编辑用户' : '添加用户'}
|
||||
open={modalVisible}
|
||||
onCancel={() => setModalVisible(false)}
|
||||
footer={null}
|
||||
>
|
||||
<Form form={form} onFinish={handleSubmit} layout="vertical">
|
||||
<Form.Item name="username" label="用户名" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="email" label="邮箱" rules={[{ required: true, type: 'email' }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
{!editingUser && (
|
||||
<Form.Item name="password" label="密码" rules={[{ required: true, min: 8 }]}>
|
||||
<Input.Password />
|
||||
</Form.Item>
|
||||
)}
|
||||
<Form.Item name="role" label="角色" rules={[{ required: true }]}>
|
||||
<Select>
|
||||
<Select.Option value="super_admin">超级管理员</Select.Option>
|
||||
<Select.Option value="admin">管理员</Select.Option>
|
||||
<Select.Option value="operator">操作员</Select.Option>
|
||||
<Select.Option value="viewer">只读用户</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit" block>提交</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Users
|
||||
Reference in New Issue
Block a user