release: bump version to 0.53.0
This commit is contained in:
6
frontend/.dockerignore
Normal file
6
frontend/.dockerignore
Normal file
@@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
dist
|
||||
.vite
|
||||
*.log
|
||||
coverage
|
||||
.DS_Store
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM oven/bun:1-alpine
|
||||
FROM oven/bun:1-alpine AS build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -6,7 +6,16 @@ COPY package.json bun.lock ./
|
||||
RUN bun install --frozen-lockfile
|
||||
|
||||
COPY . .
|
||||
RUN bun run build
|
||||
|
||||
FROM nginx:1.27-alpine
|
||||
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["bun", "run", "dev", "--", "--host", "0.0.0.0"]
|
||||
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
|
||||
CMD wget -qO- http://127.0.0.1:3000/health >/dev/null || exit 1
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
|
||||
56
frontend/nginx.conf
Normal file
56
frontend/nginx.conf
Normal file
@@ -0,0 +1,56 @@
|
||||
server {
|
||||
listen 3000;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/javascript application/json image/svg+xml;
|
||||
|
||||
location = /health {
|
||||
add_header Content-Type text/plain;
|
||||
return 200 "ok\n";
|
||||
}
|
||||
|
||||
location = /api/health {
|
||||
proxy_pass http://planet-backend:8000/health;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://planet-backend:8000/api/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /ws {
|
||||
proxy_pass http://planet-backend:8000/ws;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location = /index.html {
|
||||
add_header Cache-Control "no-store";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location /assets/ {
|
||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "planet-frontend",
|
||||
"version": "0.52.0",
|
||||
"version": "0.53.0",
|
||||
"private": true,
|
||||
"packageManager": "bun@1",
|
||||
"dependencies": {
|
||||
|
||||
@@ -259,9 +259,10 @@ const vesselIconLayer = createInteractableLayer({
|
||||
});
|
||||
|
||||
const DEFAULT_VESSEL_VIEWPORT = {
|
||||
bbox: [-180, -90, 180, 90],
|
||||
zoom: 2,
|
||||
bbox: [-10, 50, 35, 75],
|
||||
zoom: 4,
|
||||
};
|
||||
const MAX_VESSEL_SUBSCRIPTION_BBOX_AREA = 2500;
|
||||
|
||||
export function getVesselMarkers() {
|
||||
return vesselIconLayer.getMarkers();
|
||||
@@ -353,8 +354,34 @@ export async function loadVessels(_scene, earth, options = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
export function startVesselRealtime(earth, { onUpdate } = {}) {
|
||||
function normalizeVesselViewportOptions(options = {}) {
|
||||
const bbox = Array.isArray(options.bbox) && options.bbox.length === 4
|
||||
? options.bbox.map(Number)
|
||||
: DEFAULT_VESSEL_VIEWPORT.bbox;
|
||||
const [lonA, latA, lonB, latB] = bbox;
|
||||
const normalizedBbox = [
|
||||
Math.max(-180, Math.min(lonA, lonB)),
|
||||
Math.max(-90, Math.min(latA, latB)),
|
||||
Math.min(180, Math.max(lonA, lonB)),
|
||||
Math.min(90, Math.max(latA, latB)),
|
||||
];
|
||||
const area = (normalizedBbox[2] - normalizedBbox[0]) * (normalizedBbox[3] - normalizedBbox[1]);
|
||||
const safeBbox = Number.isFinite(area) && area <= MAX_VESSEL_SUBSCRIPTION_BBOX_AREA
|
||||
? normalizedBbox
|
||||
: DEFAULT_VESSEL_VIEWPORT.bbox;
|
||||
const zoom = Number.isFinite(Number(options.zoom))
|
||||
? Number(options.zoom)
|
||||
: DEFAULT_VESSEL_VIEWPORT.zoom;
|
||||
return {
|
||||
bbox: safeBbox,
|
||||
zoom: Math.max(1, Math.min(20, Math.round(zoom))),
|
||||
limit: options.limit ?? VESSEL_CONFIG.maxRenderedMarkers,
|
||||
};
|
||||
}
|
||||
|
||||
export function startVesselRealtime(earth, { onUpdate, bbox, zoom, limit } = {}) {
|
||||
if (vesselStreamSocket || typeof WebSocket === "undefined") return;
|
||||
const subscriptionOptions = normalizeVesselViewportOptions({ bbox, zoom, limit });
|
||||
const connect = () => {
|
||||
if (!showVessels || vesselStreamSocket) return;
|
||||
const socket = new WebSocket(getVesselStreamUrl());
|
||||
@@ -369,9 +396,9 @@ export function startVesselRealtime(earth, { onUpdate } = {}) {
|
||||
type: "subscribe",
|
||||
data: {
|
||||
channel: "vessels",
|
||||
bbox: DEFAULT_VESSEL_VIEWPORT.bbox,
|
||||
zoom: DEFAULT_VESSEL_VIEWPORT.zoom,
|
||||
limit: VESSEL_CONFIG.maxRenderedMarkers,
|
||||
bbox: subscriptionOptions.bbox,
|
||||
zoom: subscriptionOptions.zoom,
|
||||
limit: subscriptionOptions.limit,
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
Select,
|
||||
Space,
|
||||
Table,
|
||||
Tabs,
|
||||
Tag,
|
||||
Tooltip,
|
||||
Typography,
|
||||
@@ -84,6 +85,50 @@ interface CustomDataSourceOverride {
|
||||
updated_at: string | null
|
||||
}
|
||||
|
||||
interface RealtimeSourceHealth {
|
||||
connection_state: string
|
||||
last_seen_at: string | null
|
||||
last_success_at: string | null
|
||||
last_error: string | null
|
||||
message_rate: number | null
|
||||
lag_seconds: number | null
|
||||
updated_at: string | null
|
||||
}
|
||||
|
||||
interface RealtimeSourceStats {
|
||||
total_observations: number
|
||||
observations_24h: number
|
||||
observations_1h: number
|
||||
unique_mmsi_total: number
|
||||
unique_mmsi_24h: number
|
||||
latest_observed_at: string | null
|
||||
latest_collected_at: string | null
|
||||
}
|
||||
|
||||
interface RealtimeSourceRuntime {
|
||||
running: boolean
|
||||
done: boolean
|
||||
runtime: string
|
||||
}
|
||||
|
||||
interface RealtimeSource {
|
||||
source: string
|
||||
name: string
|
||||
display_name: string
|
||||
kind: 'builtin' | 'custom'
|
||||
config_id?: number
|
||||
source_type: string
|
||||
endpoint?: string
|
||||
is_active: boolean
|
||||
credential_configured: boolean
|
||||
message_types: string[]
|
||||
bounding_boxes: unknown[]
|
||||
config: Record<string, any>
|
||||
runtime: RealtimeSourceRuntime
|
||||
health: RealtimeSourceHealth | null
|
||||
stats: RealtimeSourceStats
|
||||
}
|
||||
|
||||
interface EditableDataSourceConfig {
|
||||
id: number
|
||||
name: string
|
||||
@@ -200,6 +245,10 @@ const PRODUCT_TAG_COLORS: Record<string, string> = {
|
||||
other: 'default',
|
||||
}
|
||||
|
||||
function isRealtimeBuiltinSource(source: BuiltInDataSource | UnifiedDataSource) {
|
||||
return source.source === 'aisstream_vessels'
|
||||
}
|
||||
|
||||
function normalizeBuiltin(source: BuiltInDataSource): UnifiedDataSource {
|
||||
return {
|
||||
key: `builtin:${source.id}`,
|
||||
@@ -242,7 +291,11 @@ function DataSources() {
|
||||
const [modal, modalContextHolder] = Modal.useModal()
|
||||
const [builtInSources, setBuiltInSources] = useState<BuiltInDataSource[]>([])
|
||||
const [customOverrides, setCustomOverrides] = useState<CustomDataSourceOverride[]>([])
|
||||
const [realtimeSources, setRealtimeSources] = useState<RealtimeSource[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [realtimeLoading, setRealtimeLoading] = useState(false)
|
||||
const [realtimeActionSource, setRealtimeActionSource] = useState<string | null>(null)
|
||||
const [activeTab, setActiveTab] = useState('tasks')
|
||||
const [triggerAllLoading, setTriggerAllLoading] = useState(false)
|
||||
const [forceTriggerAll, setForceTriggerAll] = useState(false)
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState<Key[]>([])
|
||||
@@ -259,7 +312,11 @@ function DataSources() {
|
||||
const [tableHeight, setTableHeight] = useState(360)
|
||||
const tableRegionRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
const allSources = useMemo(() => builtInSources.map(normalizeBuiltin), [builtInSources])
|
||||
const taskBuiltInSources = useMemo(
|
||||
() => builtInSources.filter((source) => !isRealtimeBuiltinSource(source)),
|
||||
[builtInSources],
|
||||
)
|
||||
const allSources = useMemo(() => taskBuiltInSources.map(normalizeBuiltin), [taskBuiltInSources])
|
||||
const selectedSourceIds = useMemo(
|
||||
() => selectedRowKeys
|
||||
.map((key) => allSources.find((source) => source.key === key)?.id)
|
||||
@@ -267,13 +324,13 @@ function DataSources() {
|
||||
[allSources, selectedRowKeys],
|
||||
)
|
||||
|
||||
const activeBuiltInCount = builtInSources.filter((source) => source.is_active).length
|
||||
const collectedBuiltInCount = builtInSources.filter((source) => source.has_collected_data).length
|
||||
const runningBuiltInSources = builtInSources.filter((source) => source.is_running)
|
||||
const activeBuiltInCount = taskBuiltInSources.filter((source) => source.is_active).length
|
||||
const collectedBuiltInCount = taskBuiltInSources.filter((source) => source.has_collected_data).length
|
||||
const runningBuiltInSources = taskBuiltInSources.filter((source) => source.is_running)
|
||||
const runningBuiltInCount = runningBuiltInSources.length
|
||||
const aggregateProgress = runningBuiltInCount > 0
|
||||
? Math.round(
|
||||
builtInSources
|
||||
taskBuiltInSources
|
||||
.filter((source) => source.is_running)
|
||||
.reduce((sum, source) => sum + (source.progress || 0), 0) / runningBuiltInCount,
|
||||
)
|
||||
@@ -304,10 +361,27 @@ function DataSources() {
|
||||
}
|
||||
}, [activeFilter, collectedFilter, messageApi, moduleFilter, productFilter, searchQuery, statusFilter])
|
||||
|
||||
const fetchRealtimeSources = useCallback(async () => {
|
||||
setRealtimeLoading(true)
|
||||
try {
|
||||
const response = await axios.get('/api/v1/realtime-sources')
|
||||
setRealtimeSources(response.data.data || [])
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch realtime sources:', error)
|
||||
messageApi.error('获取实时流状态失败')
|
||||
} finally {
|
||||
setRealtimeLoading(false)
|
||||
}
|
||||
}, [messageApi])
|
||||
|
||||
useEffect(() => {
|
||||
void fetchData()
|
||||
}, [fetchData])
|
||||
|
||||
useEffect(() => {
|
||||
void fetchRealtimeSources()
|
||||
}, [fetchRealtimeSources])
|
||||
|
||||
useEffect(() => {
|
||||
const visibleKeys = new Set(allSources.map((source) => source.key))
|
||||
setSelectedRowKeys((keys) => keys.filter((key) => visibleKeys.has(String(key))))
|
||||
@@ -324,6 +398,20 @@ function DataSources() {
|
||||
return () => observer.disconnect()
|
||||
}, [allSources.length])
|
||||
|
||||
const runRealtimeAction = async (source: string, action: 'start' | 'stop' | 'restart') => {
|
||||
try {
|
||||
setRealtimeActionSource(`${source}:${action}`)
|
||||
await axios.post(`/api/v1/realtime-sources/${encodeURIComponent(source)}/${action}`)
|
||||
messageApi.success(action === 'start' ? '实时流已启动' : action === 'stop' ? '实时流已停止' : '实时流已重连')
|
||||
await Promise.all([fetchRealtimeSources(), fetchData()])
|
||||
} catch (error: unknown) {
|
||||
const err = error as { response?: { data?: { detail?: string } } }
|
||||
messageApi.error(err.response?.data?.detail || '实时流操作失败')
|
||||
} finally {
|
||||
setRealtimeActionSource(null)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchDatasourceTaskStatus = async (id: number) => {
|
||||
const res = await axios.get<DatasourceTaskStatus>(`/api/v1/datasources/${id}/task-status`)
|
||||
return res.data
|
||||
@@ -481,6 +569,160 @@ function DataSources() {
|
||||
}
|
||||
}
|
||||
|
||||
const formatCount = (value?: number | null) => Number(value || 0).toLocaleString()
|
||||
const formatLagSeconds = (value?: number | null) => {
|
||||
if (value === null || value === undefined || !Number.isFinite(Number(value))) return '-'
|
||||
const seconds = Number(value)
|
||||
if (seconds < 60) return `${Math.round(seconds)} 秒`
|
||||
if (seconds < 3600) return `${Math.round(seconds / 60)} 分钟`
|
||||
return `${Math.round(seconds / 3600)} 小时`
|
||||
}
|
||||
const getConnectionColor = (state?: string | null) => {
|
||||
if (state === 'connected') return 'success'
|
||||
if (state === 'connecting' || state === 'reconnecting') return 'processing'
|
||||
if (state === 'disconnected') return 'default'
|
||||
return 'warning'
|
||||
}
|
||||
const renderConfigPreview = (value: unknown) => {
|
||||
if (!Array.isArray(value) || value.length === 0) return '-'
|
||||
return JSON.stringify(value)
|
||||
}
|
||||
|
||||
const realtimeTotals = realtimeSources.reduce(
|
||||
(acc, source) => ({
|
||||
running: acc.running + (source.runtime?.running ? 1 : 0),
|
||||
total: acc.total + (source.stats?.total_observations || 0),
|
||||
recent24h: acc.recent24h + (source.stats?.observations_24h || 0),
|
||||
}),
|
||||
{ running: 0, total: 0, recent24h: 0 },
|
||||
)
|
||||
|
||||
const renderRealtimeSource = (source: RealtimeSource) => {
|
||||
const state = source.health?.connection_state || (source.runtime?.running ? 'running' : 'disconnected')
|
||||
const actionBusyPrefix = `${source.source}:`
|
||||
return (
|
||||
<Card
|
||||
key={source.source}
|
||||
size="small"
|
||||
title={(
|
||||
<Space wrap>
|
||||
<Text strong>{source.display_name || source.name}</Text>
|
||||
<Tag color={source.kind === 'builtin' ? 'blue' : 'purple'}>{source.kind === 'builtin' ? '内置' : '自定义'}</Tag>
|
||||
<Tag color={source.is_active ? 'success' : 'default'}>{source.is_active ? '启用' : '禁用'}</Tag>
|
||||
<Tag color={source.credential_configured ? 'success' : 'warning'}>
|
||||
{source.credential_configured ? '凭证已配置' : '缺少凭证'}
|
||||
</Tag>
|
||||
<Tag color={source.runtime?.running ? 'processing' : 'default'}>
|
||||
{source.runtime?.running ? '运行中' : '未运行'}
|
||||
</Tag>
|
||||
</Space>
|
||||
)}
|
||||
extra={(
|
||||
<Space size={6}>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<PlayCircleOutlined />}
|
||||
disabled={!source.is_active || source.runtime?.running}
|
||||
loading={realtimeActionSource === `${source.source}:start`}
|
||||
onClick={() => { void runRealtimeAction(source.source, 'start') }}
|
||||
>
|
||||
启动
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<PauseCircleOutlined />}
|
||||
disabled={!source.runtime?.running}
|
||||
loading={realtimeActionSource === `${source.source}:stop`}
|
||||
onClick={() => { void runRealtimeAction(source.source, 'stop') }}
|
||||
>
|
||||
停止
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<SyncOutlined />}
|
||||
loading={realtimeActionSource === `${source.source}:restart` || Boolean(realtimeActionSource?.startsWith(actionBusyPrefix))}
|
||||
onClick={() => { void runRealtimeAction(source.source, 'restart') }}
|
||||
>
|
||||
重连
|
||||
</Button>
|
||||
</Space>
|
||||
)}
|
||||
>
|
||||
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
||||
<Row gutter={[12, 12]}>
|
||||
<Col xs={12} md={6}>
|
||||
<Text type="secondary">连接状态</Text>
|
||||
<div><Tag color={getConnectionColor(source.health?.connection_state)}>{state}</Tag></div>
|
||||
</Col>
|
||||
<Col xs={12} md={6}>
|
||||
<Text type="secondary">累计入库</Text>
|
||||
<div>{formatCount(source.stats?.total_observations)}</div>
|
||||
</Col>
|
||||
<Col xs={12} md={6}>
|
||||
<Text type="secondary">近 24h</Text>
|
||||
<div>{formatCount(source.stats?.observations_24h)}</div>
|
||||
</Col>
|
||||
<Col xs={12} md={6}>
|
||||
<Text type="secondary">近 1h</Text>
|
||||
<div>{formatCount(source.stats?.observations_1h)}</div>
|
||||
</Col>
|
||||
<Col xs={12} md={6}>
|
||||
<Text type="secondary">唯一 MMSI</Text>
|
||||
<div>{formatCount(source.stats?.unique_mmsi_total)}</div>
|
||||
</Col>
|
||||
<Col xs={12} md={6}>
|
||||
<Text type="secondary">近 24h MMSI</Text>
|
||||
<div>{formatCount(source.stats?.unique_mmsi_24h)}</div>
|
||||
</Col>
|
||||
<Col xs={12} md={6}>
|
||||
<Text type="secondary">最近收到</Text>
|
||||
<div>{formatDateTimeZhCN(source.health?.last_seen_at)}</div>
|
||||
</Col>
|
||||
<Col xs={12} md={6}>
|
||||
<Text type="secondary">数据延迟</Text>
|
||||
<div>{formatLagSeconds(source.health?.lag_seconds)}</div>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={[12, 12]}>
|
||||
<Col xs={24} md={12}>
|
||||
<Text type="secondary">Endpoint</Text>
|
||||
<Input value={source.endpoint || '-'} readOnly />
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<Text type="secondary">Message Types</Text>
|
||||
<Input value={source.message_types?.length ? source.message_types.join(', ') : '-'} readOnly />
|
||||
</Col>
|
||||
<Col xs={24}>
|
||||
<Text type="secondary">Bounding Boxes</Text>
|
||||
<Input value={renderConfigPreview(source.bounding_boxes)} readOnly />
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={[12, 12]}>
|
||||
<Col xs={12} md={6}>
|
||||
<Text type="secondary">最近成功</Text>
|
||||
<div>{formatDateTimeZhCN(source.health?.last_success_at)}</div>
|
||||
</Col>
|
||||
<Col xs={12} md={6}>
|
||||
<Text type="secondary">状态更新时间</Text>
|
||||
<div>{formatDateTimeZhCN(source.health?.updated_at)}</div>
|
||||
</Col>
|
||||
<Col xs={12} md={6}>
|
||||
<Text type="secondary">最近观测</Text>
|
||||
<div>{formatDateTimeZhCN(source.stats?.latest_observed_at)}</div>
|
||||
</Col>
|
||||
<Col xs={12} md={6}>
|
||||
<Text type="secondary">最近入库</Text>
|
||||
<div>{formatDateTimeZhCN(source.stats?.latest_collected_at)}</div>
|
||||
</Col>
|
||||
</Row>
|
||||
{source.health?.last_error ? (
|
||||
<Alert showIcon type="error" message="最近错误" description={source.health.last_error} />
|
||||
) : null}
|
||||
</Space>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '名称',
|
||||
@@ -591,8 +833,16 @@ function DataSources() {
|
||||
<div className="page-shell__header">
|
||||
<h2 style={{ margin: 0 }}>数据源</h2>
|
||||
</div>
|
||||
<div className="page-shell__body data-source-builtin-tab">
|
||||
<div className="data-source-bulk-toolbar">
|
||||
<Tabs
|
||||
activeKey={activeTab}
|
||||
onChange={setActiveTab}
|
||||
items={[
|
||||
{
|
||||
key: 'tasks',
|
||||
label: '采集任务',
|
||||
children: (
|
||||
<div className="page-shell__body data-source-builtin-tab">
|
||||
<div className="data-source-bulk-toolbar">
|
||||
<div className="data-source-bulk-toolbar__meta">
|
||||
<div className="data-source-bulk-toolbar__title">采集实时进度</div>
|
||||
<div className="data-source-bulk-toolbar__progress">
|
||||
@@ -609,7 +859,7 @@ function DataSources() {
|
||||
</div>
|
||||
<div className="data-source-bulk-toolbar__stat-pill">
|
||||
<span className="data-source-bulk-toolbar__stat-label">内置</span>
|
||||
<strong>{builtInSources.length}</strong>
|
||||
<strong>{taskBuiltInSources.length}</strong>
|
||||
</div>
|
||||
<div className="data-source-bulk-toolbar__stat-pill">
|
||||
<span className="data-source-bulk-toolbar__stat-label">已启用</span>
|
||||
@@ -646,10 +896,10 @@ function DataSources() {
|
||||
<InfoCircleOutlined style={{ fontSize: 16, color: '#8c8c8c', cursor: 'default' }} />
|
||||
</Tooltip>
|
||||
<Checkbox checked={forceTriggerAll} onChange={(event) => setForceTriggerAll(event.target.checked)}>
|
||||
强制全部采集
|
||||
强制重新采集
|
||||
</Checkbox>
|
||||
<Button type="primary" size="middle" icon={<SyncOutlined />} loading={triggerAllLoading} onClick={handleTriggerAll}>
|
||||
{selectedSourceIds.length ? '采集选中项' : '采集当前筛选'}
|
||||
{selectedSourceIds.length ? '采集选中项' : '一键采集'}
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
@@ -741,7 +991,65 @@ function DataSources() {
|
||||
/>
|
||||
<ScrollbarOverlay containerRef={tableRegionRef} targetSelector=".ant-table-body" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'realtime',
|
||||
label: '实时流',
|
||||
children: (
|
||||
<div className="page-shell__body data-source-builtin-tab">
|
||||
<div className="data-source-bulk-toolbar">
|
||||
<div className="data-source-bulk-toolbar__meta">
|
||||
<div className="data-source-bulk-toolbar__title">实时流健康</div>
|
||||
<div className="data-source-bulk-toolbar__progress">
|
||||
<div className="data-source-bulk-toolbar__progress-copy">
|
||||
<span>运行中</span>
|
||||
<strong>{realtimeTotals.running}</strong>
|
||||
</div>
|
||||
<Progress
|
||||
percent={realtimeSources.length ? Math.round((realtimeTotals.running / realtimeSources.length) * 100) : 0}
|
||||
size="small"
|
||||
status={realtimeTotals.running > 0 ? 'active' : 'normal'}
|
||||
showInfo={false}
|
||||
strokeColor="#1677ff"
|
||||
/>
|
||||
</div>
|
||||
<div className="data-source-bulk-toolbar__stats">
|
||||
<div className="data-source-bulk-toolbar__stat-pill">
|
||||
<span className="data-source-bulk-toolbar__stat-label">实时源</span>
|
||||
<strong>{realtimeSources.length}</strong>
|
||||
</div>
|
||||
<div className="data-source-bulk-toolbar__stat-pill">
|
||||
<span className="data-source-bulk-toolbar__stat-label">运行中</span>
|
||||
<strong>{realtimeTotals.running}</strong>
|
||||
</div>
|
||||
<div className="data-source-bulk-toolbar__stat-pill">
|
||||
<span className="data-source-bulk-toolbar__stat-label">累计入库</span>
|
||||
<strong>{formatCount(realtimeTotals.total)}</strong>
|
||||
</div>
|
||||
<div className="data-source-bulk-toolbar__stat-pill">
|
||||
<span className="data-source-bulk-toolbar__stat-label">近 24h</span>
|
||||
<strong>{formatCount(realtimeTotals.recent24h)}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button icon={<SyncOutlined />} loading={realtimeLoading} onClick={() => { void fetchRealtimeSources() }}>
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
||||
{realtimeSources.length ? realtimeSources.map(renderRealtimeSource) : (
|
||||
<Card size="small">
|
||||
<Text type="secondary">当前没有实时流数据源。</Text>
|
||||
</Card>
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
|
||||
Reference in New Issue
Block a user