release: bump version to 0.53.0
Some checks failed
ci / backend (push) Has been cancelled
ci / frontend (push) Has been cancelled
ci / delivery (push) Has been cancelled
release / images (push) Has been cancelled

This commit is contained in:
rayd1o
2026-05-13 08:05:43 +08:00
parent b87cb310fd
commit d9efd98d26
56 changed files with 2318 additions and 243 deletions

6
frontend/.dockerignore Normal file
View File

@@ -0,0 +1,6 @@
node_modules
dist
.vite
*.log
coverage
.DS_Store

View File

@@ -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
View 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;
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "planet-frontend",
"version": "0.52.0",
"version": "0.53.0",
"private": true,
"packageManager": "bun@1",
"dependencies": {

View File

@@ -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,
},
}));
};

View File

@@ -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