release: bump version to 0.47.0
This commit is contained in:
@@ -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}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user