release: bump version to 0.47.0

This commit is contained in:
linkong
2026-04-30 16:56:37 +08:00
parent f22079d33a
commit 421234301a
42 changed files with 2501 additions and 454 deletions

View File

@@ -26,6 +26,7 @@ import {
SyncOutlined,
} from '@ant-design/icons'
import axios, { type AxiosResponse } from 'axios'
import { useNavigate } from 'react-router-dom'
import AppLayout from '../../components/AppLayout/AppLayout'
import ScrollbarOverlay from '../../components/Scrollbar/ScrollbarOverlay'
@@ -224,6 +225,7 @@ function normalizeCustom(source: CustomDataSource): UnifiedDataSource {
function DataSources() {
const [messageApi, contextHolder] = message.useMessage()
const navigate = useNavigate()
const [modal, modalContextHolder] = Modal.useModal()
const [builtInSources, setBuiltInSources] = useState<BuiltInDataSource[]>([])
const [customSources, setCustomSources] = useState<CustomDataSource[]>([])
@@ -736,6 +738,15 @@ function DataSources() {
showIcon
message="需要采集器凭证"
description={viewingSource.credential_status === 'supported' ? '请在设置中心的采集器设置中维护该采集器凭证。' : '该采集器需要凭证,配置入口待接入。'}
action={viewingSource.credential_status === 'supported' ? (
<Button
size="small"
type="primary"
onClick={() => navigate(`/settings?tab=collector_credentials&collector=${encodeURIComponent(viewingSource.source)}`)}
>
</Button>
) : undefined}
/>
) : null}

View File

@@ -82,6 +82,18 @@ interface CollectorSettings {
requires_credentials?: boolean
credential_provider?: string | null
credential_status?: string
ais_health?: AISSourceHealth | null
}
interface AISSourceHealth {
source: string
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 TVStreamSource {
@@ -166,12 +178,59 @@ interface CollectorConfigOption {
is_active: boolean
source_type: string
auth_type: string
auth_config?: Record<string, any>
auth_configured?: Record<string, boolean>
headers: Record<string, string>
config: Record<string, any>
config_id: number | null
description: string
}
const AISSTREAM_BBOX_PRESETS = [
{
value: 'global',
label: '全球',
boxes: [[[-90, -180], [90, 180]]],
},
{
value: 'norway_north_sea',
label: '挪威 / 北海',
boxes: [[[50, -8], [72, 32]]],
},
{
value: 'europe_coast',
label: '欧洲近海',
boxes: [[[35, -12], [72, 32]]],
},
{
value: 'east_asia',
label: '东亚',
boxes: [[[18, 105], [46, 146]]],
},
{
value: 'north_america_coasts',
label: '北美东西海岸',
boxes: [
[[24, -126], [50, -66]],
[[18, -98], [31, -80]],
],
},
]
const stringifyBoundingBoxes = (boxes: unknown) => JSON.stringify(boxes, null, 2)
const matchAisstreamBboxPreset = (boxes: unknown) => {
const serialized = JSON.stringify(boxes)
return AISSTREAM_BBOX_PRESETS.find((preset) => JSON.stringify(preset.boxes) === serialized)?.value || 'custom'
}
const formatLagSeconds = (value: number | null | undefined) => {
if (value == null) return '未知'
if (value < 60) return `${Math.round(value)}`
if (value < 3600) return `${Math.round(value / 60)} 分钟`
return `${Math.round(value / 3600)} 小时`
}
function PlugConnectIcon() {
return (
<svg width="1em" height="1em" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
@@ -247,6 +306,7 @@ function SettingsPanel({
function Settings() {
const [searchParams, setSearchParams] = useSearchParams()
const requestedTab = searchParams.get('tab') || 'display'
const requestedCollector = searchParams.get('collector') || ''
const [loading, setLoading] = useState(true)
const [savingCollectorId, setSavingCollectorId] = useState<number | null>(null)
const [collectors, setCollectors] = useState<CollectorSettings[]>([])
@@ -285,6 +345,7 @@ function Settings() {
const selectedCollectorHealth = selectedCollector
? collectorHealthStatus[selectedCollector.source]
: undefined
const selectedAisRuntimeHealth = selectedCollector?.ais_health || null
const settingsTabKeys = new Set([
'display',
'notifications',
@@ -386,16 +447,31 @@ function Settings() {
useEffect(() => {
if (loading || !selectedCollectorConfig) return
const config = selectedCollectorConfig.config || {}
const boundingBoxes = config.bounding_boxes ?? [[[-90, -180], [90, 180]]]
collectorConfigForm.setFieldsValue({
endpoint: selectedCollectorConfig.endpoint,
auth_config: {
api_key: selectedCollectorConfig.auth_configured?.api_key ? '••••••••' : '',
},
headers: Object.entries(selectedCollectorConfig.headers || {}).map(([key, value]) => ({ key, value })),
config: {
timeout: selectedCollectorConfig.config?.timeout ?? 30,
retry: selectedCollectorConfig.config?.retry ?? 3,
timeout: config.timeout ?? 30,
retry: config.retry ?? 3,
max_messages: config.max_messages ?? 500,
receive_timeout_seconds: config.receive_timeout_seconds ?? 30,
message_types: config.message_types ?? ['PositionReport', 'ShipStaticData'],
bounding_box_preset: matchAisstreamBboxPreset(boundingBoxes),
bounding_boxes_json: stringifyBoundingBoxes(boundingBoxes),
},
})
}, [collectorConfigForm, loading, selectedCollectorConfig])
useEffect(() => {
if (!requestedCollector || !collectors.some((collector) => collector.source === requestedCollector)) return
setSelectedCollectorSource(requestedCollector)
}, [collectors, requestedCollector])
useEffect(() => {
const updateTableHeight = () => {
const regionHeight = collectorTableRegionRef.current?.offsetHeight || 0
@@ -440,6 +516,12 @@ function Settings() {
}, {})
)
const applyAisstreamBboxPreset = (presetValue: string) => {
const preset = AISSTREAM_BBOX_PRESETS.find((item) => item.value === presetValue)
if (!preset) return
collectorConfigForm.setFieldValue(['config', 'bounding_boxes_json'], stringifyBoundingBoxes(preset.boxes))
}
const saveCollector = async (collector: CollectorSettings) => {
try {
setSavingCollectorId(collector.id)
@@ -501,15 +583,39 @@ function Settings() {
return
}
const payload = {
const configValues = { ...(baseValues.config || {}) }
if (selectedCollector.source === 'aisstream_vessels') {
try {
configValues.bounding_boxes = JSON.parse(configValues.bounding_boxes_json || '[[[-90,-180],[90,180]]]')
} catch {
message.error('AISStream Bounding Boxes 必须是合法 JSON')
return
}
delete configValues.bounding_boxes_json
delete configValues.bounding_box_preset
}
const payload: Record<string, any> = {
name: selectedCollector.source,
description: `内置采集器覆盖配置:${selectedCollector.name}`,
source_type: 'http',
source_type: selectedCollector.source === 'aisstream_vessels' ? 'websocket' : 'http',
endpoint: baseValues.endpoint,
auth_type: 'none',
auth_config: {},
auth_type: selectedCollector.source === 'aisstream_vessels' ? 'api_key' : 'none',
headers,
config: baseValues.config || {},
config: configValues,
}
if (selectedCollector.source === 'aisstream_vessels') {
const apiKey = String(baseValues.auth_config?.api_key || '').trim()
if (apiKey && !apiKey.startsWith('••••')) {
payload.auth_config = {
api_key: apiKey,
in: 'payload',
}
} else if (!selectedCollectorConfig.config_id) {
payload.auth_config = {}
}
} else {
payload.auth_config = {}
}
if (selectedCollectorConfig.config_id) {
await axios.put(`/api/v1/datasources/configs/${selectedCollectorConfig.config_id}`, payload)
@@ -589,13 +695,16 @@ function Settings() {
try {
const values = collectorConfigForm.getFieldsValue(true)
setTestingCredentialProvider(selectedCollector.source)
const draftApiKey = String(values.auth_config?.api_key || '').trim()
const response = await axios.post('/api/v1/datasources/configs/builtin/connect', {
name: selectedCollector.source,
description: `内置采集器连接验证:${selectedCollector.name}`,
source_type: 'http',
source_type: selectedCollector.source === 'aisstream_vessels' ? 'websocket' : 'http',
endpoint: values.endpoint || selectedCollectorConfig?.endpoint || selectedCollectorConfig?.default_url || '',
auth_type: 'none',
auth_config: {},
auth_type: selectedCollector.source === 'aisstream_vessels' ? 'api_key' : 'none',
auth_config: selectedCollector.source === 'aisstream_vessels' && draftApiKey && !draftApiKey.startsWith('••••')
? { api_key: draftApiKey }
: {},
headers: headersListToMap(values.headers),
config: values.config || {},
})
@@ -611,6 +720,9 @@ function Settings() {
[selectedCollector.source]: { ok: false, message: response.data.message || '不可用' },
}))
message.error(response.data.message || '采集器健康检查失败')
if (selectedCollector.credential_provider) {
await loadCredentialGuide(selectedCollector.credential_provider, true)
}
}
} catch (error: unknown) {
const err = error as { response?: { data?: { detail?: string; message?: string } } }
@@ -620,6 +732,9 @@ function Settings() {
[selectedCollector.source]: { ok: false, message: errorMessage },
}))
message.error(errorMessage)
if (selectedCollector.credential_provider) {
await loadCredentialGuide(selectedCollector.credential_provider, true)
}
} finally {
setTestingCredentialProvider(null)
}
@@ -1383,6 +1498,13 @@ function Settings() {
) : (
<Tag></Tag>
)}
{selectedAisRuntimeHealth ? (
<Tooltip title={selectedAisRuntimeHealth.last_error || '采集器运行状态'}>
<Tag color={selectedAisRuntimeHealth.connection_state === 'connected' ? 'success' : 'default'}>
{selectedAisRuntimeHealth.connection_state}
</Tag>
</Tooltip>
) : null}
{selectedCollectorConfig?.is_overridden ? <Tag color="blue"> endpoint</Tag> : null}
</Space>
) : null}
@@ -1391,11 +1513,13 @@ function Settings() {
{selectedCollector?.requires_credentials && selectedCollector.source !== 'barentswatch_vessels' ? (
<Alert
showIcon
type="warning"
type={selectedCollector.source === 'aisstream_vessels' ? 'info' : 'warning'}
message="该采集器需要凭证"
description={selectedCollector.credential_status === 'supported'
? '该凭证类型已支持,但当前页面还没有专用表单。'
: '该凭证配置入口待接入。'}
description={selectedCollector.source === 'aisstream_vessels'
? '请在下方 AISStream 凭证中填写 API Key并保存采集器设置。'
: selectedCollector.credential_status === 'supported'
? '该凭证类型已支持,但当前页面还没有专用表单。'
: '该凭证配置入口待接入。'}
/>
) : null}
@@ -1435,6 +1559,87 @@ function Settings() {
</Card>
) : null}
{selectedCollector?.source === 'aisstream_vessels' ? (
<Card
size="small"
title={<Space><ApiOutlined />AISStream </Space>}
extra={(
<Space>
<Tooltip title="查看凭证获取教程">
<Button
size="small"
icon={<BookOutlined />}
onClick={() => { void loadCredentialGuide('aisstream', true) }}
/>
</Tooltip>
</Space>
)}
>
<Form form={collectorConfigForm} layout="vertical">
<Form.Item name={['auth_config', 'api_key']} label="API Key">
<Input.Password
autoComplete="new-password"
placeholder="输入 AISStream API Key"
/>
</Form.Item>
<Alert
showIcon
type="info"
message="AISStream 使用 WebSocket 实时流"
description="API Key 会保存在采集器覆盖配置中;保存后可用连接测试按钮验证凭证是否已配置。"
/>
</Form>
</Card>
) : null}
{selectedCollector?.source === 'aisstream_vessels' ? (
<Card size="small" title="AISStream 运行状态">
{selectedAisRuntimeHealth ? (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, minmax(160px, 1fr))', gap: 12 }}>
<div>
<Text type="secondary"></Text>
<div>
<Tag color={selectedAisRuntimeHealth.connection_state === 'connected' ? 'success' : 'default'}>
{selectedAisRuntimeHealth.connection_state}
</Tag>
</div>
</div>
<div>
<Text type="secondary"></Text>
<div>{selectedAisRuntimeHealth.message_rate ?? '未知'}</div>
</div>
<div>
<Text type="secondary"></Text>
<div>{formatDateTimeZhCN(selectedAisRuntimeHealth.last_seen_at)}</div>
</div>
<div>
<Text type="secondary"></Text>
<div>{formatDateTimeZhCN(selectedAisRuntimeHealth.last_success_at)}</div>
</div>
<div>
<Text type="secondary"></Text>
<div>{formatLagSeconds(selectedAisRuntimeHealth.lag_seconds)}</div>
</div>
<div>
<Text type="secondary"></Text>
<div>{formatDateTimeZhCN(selectedAisRuntimeHealth.updated_at)}</div>
</div>
</div>
) : (
<Alert showIcon type="warning" message="尚无运行状态" description="保存配置并触发一次 AISStream 采集后,这里会显示最近连接和消息统计。" />
)}
{selectedAisRuntimeHealth?.last_error ? (
<Alert
showIcon
type="error"
style={{ marginTop: 12 }}
message="最近错误"
description={selectedAisRuntimeHealth.last_error}
/>
) : null}
</Card>
) : null}
<Card size="small" title="基础配置">
<Form form={collectorConfigForm} layout="vertical">
<Form.Item name="endpoint" label="Endpoint" rules={[{ required: true, message: '请输入 Endpoint' }]}>
@@ -1471,6 +1676,36 @@ function Settings() {
<InputNumber min={0} max={10} style={{ width: '100%' }} />
</Form.Item>
</div>
{selectedCollector?.source === 'aisstream_vessels' ? (
<>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0 16px' }}>
<Form.Item name={['config', 'max_messages']} label="单次最大消息数">
<InputNumber min={1} max={10000} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name={['config', 'receive_timeout_seconds']} label="接收超时(秒)">
<InputNumber min={1} max={300} style={{ width: '100%' }} />
</Form.Item>
</div>
<Form.Item name={['config', 'message_types']} label="消息类型">
<Select mode="tags" options={[
{ value: 'PositionReport', label: 'PositionReport' },
{ value: 'ShipStaticData', label: 'ShipStaticData' },
]} />
</Form.Item>
<Form.Item name={['config', 'bounding_box_preset']} label="采集范围">
<Select
options={[
...AISSTREAM_BBOX_PRESETS.map((preset) => ({ value: preset.value, label: preset.label })),
{ value: 'custom', label: '自定义 JSON' },
]}
onChange={applyAisstreamBboxPreset}
/>
</Form.Item>
<Form.Item name={['config', 'bounding_boxes_json']} label="Bounding Boxes JSON">
<Input.TextArea rows={4} />
</Form.Item>
</>
) : null}
</Form>
</Card>