release: bump version to 0.44.0
This commit is contained in:
@@ -48,7 +48,7 @@ interface RestartTaskLogs {
|
||||
lines: string[]
|
||||
}
|
||||
|
||||
type RestartAction = 'restart-backend' | 'restart-ai-provider' | 'restart-database' | 'restart-system'
|
||||
type RestartAction = 'restart-backend' | 'restart-frontend' | 'restart-ai-provider' | 'restart-database' | 'restart-system'
|
||||
type RestartStage = 'confirming' | 'waiting_for_shutdown' | 'waiting_for_recovery' | 'recovered' | 'failed' | 'timeout'
|
||||
|
||||
const RESTART_ACTION_OPTIONS: Array<{ value: RestartAction; label: string; description: string; command: string }> = [
|
||||
@@ -58,6 +58,12 @@ const RESTART_ACTION_OPTIONS: Array<{ value: RestartAction; label: string; descr
|
||||
description: '只重启后端服务,页面通常会短暂失联后自动恢复。',
|
||||
command: './planet.sh restart -b',
|
||||
},
|
||||
{
|
||||
value: 'restart-frontend',
|
||||
label: '重启前端',
|
||||
description: '只重启前端开发服务,页面会短暂不可用,恢复后自动刷新。',
|
||||
command: './planet.sh restart -f',
|
||||
},
|
||||
{
|
||||
value: 'restart-ai-provider',
|
||||
label: '重启 AI Provider',
|
||||
@@ -84,6 +90,11 @@ const RESTART_GUIDE_LINES: Record<RestartAction, string[]> = {
|
||||
'[ctl] handing restart to detached runner',
|
||||
'[ctl] waiting for backend health recovery',
|
||||
],
|
||||
'restart-frontend': [
|
||||
'[ctl] preparing frontend restart task',
|
||||
'[ctl] handing restart to detached runner',
|
||||
'[ctl] waiting for frontend entrypoint recovery',
|
||||
],
|
||||
'restart-ai-provider': [
|
||||
'[ctl] preparing ai provider restart task',
|
||||
'[ctl] handing restart to detached runner',
|
||||
@@ -106,6 +117,9 @@ const RESTART_GUIDE_LINES: Record<RestartAction, string[]> = {
|
||||
let cachedDashboardStats: Stats | null = null
|
||||
|
||||
function getRestartConfirmMessage(action: RestartAction): string {
|
||||
if (action === 'restart-frontend') {
|
||||
return '将重启前端开发服务,页面会短暂不可用,恢复后会自动刷新。'
|
||||
}
|
||||
if (action === 'restart-ai-provider') {
|
||||
return '将重启 AI Provider 适配服务,页面通常保持在线,但 AI 分析请求会短暂不可用。'
|
||||
}
|
||||
@@ -215,6 +229,8 @@ function Dashboard() {
|
||||
setRestartMessage(
|
||||
restartAction === 'restart-system'
|
||||
? '已发送完全重启指令,页面可能暂时失联,恢复后会自动刷新。'
|
||||
: restartAction === 'restart-frontend'
|
||||
? '已发送前端重启指令,正在等待页面入口恢复。'
|
||||
: restartAction === 'restart-ai-provider'
|
||||
? '已发送 AI Provider 重启指令,正在等待 AI 服务恢复。'
|
||||
: '已发送重启指令,正在等待服务进入重启流程。'
|
||||
@@ -290,14 +306,18 @@ function Dashboard() {
|
||||
// Backend may be temporarily down during restart; handled by health polling below.
|
||||
}
|
||||
|
||||
if (restartAction === 'restart-system') {
|
||||
if (restartAction === 'restart-system' || restartAction === 'restart-frontend') {
|
||||
try {
|
||||
const rootRes = await fetch(`/?restart_probe=${Date.now()}`, { cache: 'no-store' })
|
||||
if (rootRes.ok) {
|
||||
frontendHealthyStreak += 1
|
||||
if (sawUnhealthy && frontendHealthyStreak >= 2) {
|
||||
setRestartStage('recovered')
|
||||
setRestartMessage('系统已恢复,正在刷新页面。')
|
||||
setRestartMessage(
|
||||
restartAction === 'restart-frontend'
|
||||
? '前端已恢复,正在刷新页面。'
|
||||
: '系统已恢复,正在刷新页面。'
|
||||
)
|
||||
appendLog('[ctl] frontend entrypoint reachable again')
|
||||
window.setTimeout(() => window.location.reload(), 600)
|
||||
return
|
||||
@@ -306,14 +326,22 @@ function Dashboard() {
|
||||
sawUnhealthy = true
|
||||
frontendHealthyStreak = 0
|
||||
setRestartStage('waiting_for_recovery')
|
||||
setRestartMessage('系统正在完全重启,正在等待前端恢复访问。')
|
||||
setRestartMessage(
|
||||
restartAction === 'restart-frontend'
|
||||
? '前端正在重启,正在等待页面入口恢复访问。'
|
||||
: '系统正在完全重启,正在等待前端恢复访问。'
|
||||
)
|
||||
appendLog('[ctl] frontend is temporarily unavailable')
|
||||
}
|
||||
} catch {
|
||||
sawUnhealthy = true
|
||||
frontendHealthyStreak = 0
|
||||
setRestartStage('waiting_for_recovery')
|
||||
setRestartMessage('系统正在完全重启,正在等待前端恢复访问。')
|
||||
setRestartMessage(
|
||||
restartAction === 'restart-frontend'
|
||||
? '前端正在重启,正在等待页面入口恢复访问。'
|
||||
: '系统正在完全重启,正在等待前端恢复访问。'
|
||||
)
|
||||
appendLog('[ctl] frontend is temporarily unavailable')
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,19 @@
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import { useCollapsedActions } from '../../hooks'
|
||||
import { TableActions, actionCellProps } from '../../components/TableActions/TableActions'
|
||||
import { ApiOutlined, CheckCircleOutlined, DeleteOutlined, EditOutlined, PlusOutlined, SyncOutlined } from '@ant-design/icons'
|
||||
import {
|
||||
ApiOutlined,
|
||||
BookOutlined,
|
||||
CheckCircleOutlined,
|
||||
DeleteOutlined,
|
||||
EditOutlined,
|
||||
PlusOutlined,
|
||||
ReloadOutlined,
|
||||
RobotOutlined,
|
||||
SyncOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
Checkbox,
|
||||
@@ -24,6 +35,7 @@ import axios from 'axios'
|
||||
import AppLayout from '../../components/AppLayout/AppLayout'
|
||||
import Scrollbar from '../../components/Scrollbar/Scrollbar'
|
||||
import TableScrollRegion from '../../components/Scrollbar/TableScrollRegion'
|
||||
import MarkdownRenderer from '../../components/MarkdownRenderer/MarkdownRenderer'
|
||||
import { formatDateTimeZhCN } from '../../utils/datetime'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
|
||||
@@ -138,6 +150,84 @@ interface AIProviderPreset {
|
||||
refresh_error?: string
|
||||
}
|
||||
|
||||
interface CredentialGuide {
|
||||
provider: string
|
||||
title: string
|
||||
markdown: string
|
||||
prompt: string
|
||||
source: string
|
||||
}
|
||||
|
||||
interface CollectorConfigOption {
|
||||
name: string
|
||||
default_url: string
|
||||
endpoint: string
|
||||
is_overridden: boolean
|
||||
is_active: boolean
|
||||
source_type: string
|
||||
auth_type: string
|
||||
headers: Record<string, string>
|
||||
config: Record<string, any>
|
||||
config_id: number | null
|
||||
description: string
|
||||
}
|
||||
|
||||
function PlugConnectIcon() {
|
||||
return (
|
||||
<svg width="1em" height="1em" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<path
|
||||
d="M7 12l5 5l-1.5 1.5a3.536 3.536 0 1 1 -5 -5l1.5 -1.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M17 12l-5 -5l1.5 -1.5a3.536 3.536 0 1 1 5 5l-1.5 1.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M3 21l2.5 -2.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M18.5 5.5l2.5 -2.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M10 11l-2 2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M13 14l-2 2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function SettingsPanel({
|
||||
loading,
|
||||
children,
|
||||
@@ -166,9 +256,19 @@ function Settings() {
|
||||
const [tvSettings, setTvSettings] = useState<TVSettings | null>(null)
|
||||
const [integrations, setIntegrations] = useState<ExternalIntegrations | null>(null)
|
||||
const [aiProviderPresets, setAiProviderPresets] = useState<AIProviderPreset[]>([])
|
||||
const [collectorConfigs, setCollectorConfigs] = useState<CollectorConfigOption[]>([])
|
||||
const [selectedCollectorSource, setSelectedCollectorSource] = useState<string>('barentswatch_vessels')
|
||||
const [savingCollectorConfig, setSavingCollectorConfig] = useState(false)
|
||||
const [collectorHealthStatus, setCollectorHealthStatus] = useState<Record<string, { ok: boolean; message: string }>>({})
|
||||
const [refreshingAiPreset, setRefreshingAiPreset] = useState(false)
|
||||
const [savingTvSettings, setSavingTvSettings] = useState(false)
|
||||
const [savingIntegrations, setSavingIntegrations] = useState(false)
|
||||
const [testingCredentialProvider, setTestingCredentialProvider] = useState<string | null>(null)
|
||||
const [credentialGuide, setCredentialGuide] = useState<CredentialGuide | null>(null)
|
||||
const [credentialGuideOpen, setCredentialGuideOpen] = useState(false)
|
||||
const [credentialGuideLoading, setCredentialGuideLoading] = useState(false)
|
||||
const [credentialGuideGenerating, setCredentialGuideGenerating] = useState(false)
|
||||
const [credentialGuideResetting, setCredentialGuideResetting] = useState(false)
|
||||
const [editingSource, setEditingSource] = useState<TVStreamSource | null>(null)
|
||||
const [tvActionsCollapsed, tvTableRef] = useCollapsedActions(780)
|
||||
const collectorTableRegionRef = useRef<HTMLDivElement | null>(null)
|
||||
@@ -177,9 +277,14 @@ function Settings() {
|
||||
const [notificationForm] = Form.useForm<NotificationSettings>()
|
||||
const [securityForm] = Form.useForm<SecuritySettings>()
|
||||
const [integrationForm] = Form.useForm()
|
||||
const [collectorConfigForm] = Form.useForm()
|
||||
const [tvEditForm] = Form.useForm<TVStreamSource>()
|
||||
const selectedAiProvider = Form.useWatch(['ai_provider', 'provider'], integrationForm)
|
||||
const credentialCollectors = collectors.filter((collector) => collector.requires_credentials)
|
||||
const selectedCollector = collectors.find((collector) => collector.source === selectedCollectorSource)
|
||||
const selectedCollectorConfig = collectorConfigs.find((config) => config.name === selectedCollectorSource)
|
||||
const selectedCollectorHealth = selectedCollector
|
||||
? collectorHealthStatus[selectedCollector.source]
|
||||
: undefined
|
||||
const settingsTabKeys = new Set([
|
||||
'display',
|
||||
'notifications',
|
||||
@@ -208,9 +313,10 @@ function Settings() {
|
||||
const fetchSettings = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const [response, presetsResponse] = await Promise.all([
|
||||
const [response, presetsResponse, collectorConfigsResponse] = await Promise.all([
|
||||
axios.get('/api/v1/settings'),
|
||||
axios.get('/api/v1/settings/integrations/ai-provider/presets'),
|
||||
axios.get('/api/v1/datasources/configs/all'),
|
||||
])
|
||||
setSystemSettings(response.data.system)
|
||||
setNotificationSettings(response.data.notifications)
|
||||
@@ -219,6 +325,7 @@ function Settings() {
|
||||
setIntegrations(response.data.integrations || null)
|
||||
setCollectors(response.data.collectors || [])
|
||||
setAiProviderPresets(presetsResponse.data.data || [])
|
||||
setCollectorConfigs(collectorConfigsResponse.data.data || [])
|
||||
} catch (error) {
|
||||
message.error('获取系统配置失败')
|
||||
console.error(error)
|
||||
@@ -270,12 +377,25 @@ function Settings() {
|
||||
barentswatch: {
|
||||
endpoint: integrations.barentswatch.endpoint,
|
||||
client_id: integrations.barentswatch.client_id,
|
||||
client_secret: '',
|
||||
clear_client_secret: false,
|
||||
client_secret: integrations.barentswatch.client_secret.configured
|
||||
? integrations.barentswatch.client_secret.preview
|
||||
: '',
|
||||
},
|
||||
})
|
||||
}, [integrationForm, integrations, loading])
|
||||
|
||||
useEffect(() => {
|
||||
if (loading || !selectedCollectorConfig) return
|
||||
collectorConfigForm.setFieldsValue({
|
||||
endpoint: selectedCollectorConfig.endpoint,
|
||||
headers: Object.entries(selectedCollectorConfig.headers || {}).map(([key, value]) => ({ key, value })),
|
||||
config: {
|
||||
timeout: selectedCollectorConfig.config?.timeout ?? 30,
|
||||
retry: selectedCollectorConfig.config?.retry ?? 3,
|
||||
},
|
||||
})
|
||||
}, [collectorConfigForm, loading, selectedCollectorConfig])
|
||||
|
||||
useEffect(() => {
|
||||
const updateTableHeight = () => {
|
||||
const regionHeight = collectorTableRegionRef.current?.offsetHeight || 0
|
||||
@@ -311,6 +431,15 @@ function Settings() {
|
||||
)
|
||||
}
|
||||
|
||||
const headersListToMap = (headers?: Array<{ key?: string; value?: string }>) => (
|
||||
(headers || []).reduce<Record<string, string>>((acc, item) => {
|
||||
const key = item?.key?.trim()
|
||||
const value = item?.value?.trim() ?? ''
|
||||
if (key) acc[key] = value
|
||||
return acc
|
||||
}, {})
|
||||
)
|
||||
|
||||
const saveCollector = async (collector: CollectorSettings) => {
|
||||
try {
|
||||
setSavingCollectorId(collector.id)
|
||||
@@ -332,7 +461,17 @@ function Settings() {
|
||||
const saveIntegrations = async (values: any) => {
|
||||
try {
|
||||
setSavingIntegrations(true)
|
||||
const response = await axios.put('/api/v1/settings/integrations', values)
|
||||
const payload = {
|
||||
...values,
|
||||
barentswatch: {
|
||||
...values.barentswatch,
|
||||
client_secret:
|
||||
values.barentswatch?.client_secret === integrations?.barentswatch.client_secret.preview
|
||||
? ''
|
||||
: values.barentswatch?.client_secret,
|
||||
},
|
||||
}
|
||||
const response = await axios.put('/api/v1/settings/integrations', payload)
|
||||
setIntegrations(response.data.integrations)
|
||||
message.success('外部集成配置已保存')
|
||||
await fetchSettings()
|
||||
@@ -343,6 +482,177 @@ function Settings() {
|
||||
}
|
||||
}
|
||||
|
||||
const saveSelectedCollectorSettings = async () => {
|
||||
if (!selectedCollector || !selectedCollectorConfig) return
|
||||
try {
|
||||
setSavingCollectorConfig(true)
|
||||
const baseValues = collectorConfigForm.getFieldsValue(true)
|
||||
const headers = headersListToMap(baseValues.headers)
|
||||
|
||||
if (selectedCollector.source === 'barentswatch_vessels') {
|
||||
const integrationValues = integrationForm.getFieldsValue(true)
|
||||
await saveIntegrations({
|
||||
...integrationValues,
|
||||
barentswatch: {
|
||||
...integrationValues.barentswatch,
|
||||
endpoint: baseValues.endpoint,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const payload = {
|
||||
name: selectedCollector.source,
|
||||
description: `内置采集器覆盖配置:${selectedCollector.name}`,
|
||||
source_type: 'http',
|
||||
endpoint: baseValues.endpoint,
|
||||
auth_type: 'none',
|
||||
auth_config: {},
|
||||
headers,
|
||||
config: baseValues.config || {},
|
||||
}
|
||||
if (selectedCollectorConfig.config_id) {
|
||||
await axios.put(`/api/v1/datasources/configs/${selectedCollectorConfig.config_id}`, payload)
|
||||
} else {
|
||||
await axios.post('/api/v1/datasources/configs', payload)
|
||||
}
|
||||
message.success('采集器设置已保存')
|
||||
await fetchSettings()
|
||||
} catch (error: unknown) {
|
||||
const err = error as { response?: { data?: { detail?: string; message?: string } } }
|
||||
message.error(err.response?.data?.message || err.response?.data?.detail || '采集器设置保存失败')
|
||||
} finally {
|
||||
setSavingCollectorConfig(false)
|
||||
}
|
||||
}
|
||||
|
||||
const loadCredentialGuide = async (provider: string, open = true) => {
|
||||
try {
|
||||
setCredentialGuideLoading(true)
|
||||
const response = await axios.get(`/api/v1/settings/credential-guides/${provider}`)
|
||||
setCredentialGuide(response.data.guide)
|
||||
if (open) setCredentialGuideOpen(true)
|
||||
} catch {
|
||||
message.error('获取凭证教程失败')
|
||||
} finally {
|
||||
setCredentialGuideLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const testBarentsWatchCredentials = async () => {
|
||||
try {
|
||||
const values = integrationForm.getFieldsValue(true)
|
||||
setTestingCredentialProvider('barentswatch')
|
||||
const payload = {
|
||||
...values.barentswatch,
|
||||
client_secret:
|
||||
values.barentswatch?.client_secret === integrations?.barentswatch.client_secret.preview
|
||||
? ''
|
||||
: values.barentswatch?.client_secret,
|
||||
}
|
||||
const response = await axios.post('/api/v1/settings/integrations/barentswatch/connect', payload)
|
||||
if (response.data.success) {
|
||||
setCollectorHealthStatus((prev) => ({
|
||||
...prev,
|
||||
barentswatch_vessels: { ok: true, message: response.data.message || '可用' },
|
||||
}))
|
||||
message.success(response.data.message || 'BarentsWatch AIS 连接成功')
|
||||
} else {
|
||||
setCollectorHealthStatus((prev) => ({
|
||||
...prev,
|
||||
barentswatch_vessels: { ok: false, message: response.data.message || '不可用' },
|
||||
}))
|
||||
message.error(response.data.message || 'BarentsWatch AIS 连接失败')
|
||||
await loadCredentialGuide('barentswatch', true)
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const err = error as { response?: { data?: { detail?: string } } }
|
||||
const errorMessage = err.response?.data?.detail || 'BarentsWatch AIS 连接失败'
|
||||
setCollectorHealthStatus((prev) => ({
|
||||
...prev,
|
||||
barentswatch_vessels: { ok: false, message: errorMessage },
|
||||
}))
|
||||
message.error(errorMessage)
|
||||
await loadCredentialGuide('barentswatch', true)
|
||||
} finally {
|
||||
setTestingCredentialProvider(null)
|
||||
}
|
||||
}
|
||||
|
||||
const testSelectedCollectorConnectivity = async () => {
|
||||
if (!selectedCollector) return
|
||||
if (selectedCollector.source === 'barentswatch_vessels') {
|
||||
await testBarentsWatchCredentials()
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const values = collectorConfigForm.getFieldsValue(true)
|
||||
setTestingCredentialProvider(selectedCollector.source)
|
||||
const response = await axios.post('/api/v1/datasources/configs/builtin/connect', {
|
||||
name: selectedCollector.source,
|
||||
description: `内置采集器连接验证:${selectedCollector.name}`,
|
||||
source_type: 'http',
|
||||
endpoint: values.endpoint || selectedCollectorConfig?.endpoint || selectedCollectorConfig?.default_url || '',
|
||||
auth_type: 'none',
|
||||
auth_config: {},
|
||||
headers: headersListToMap(values.headers),
|
||||
config: values.config || {},
|
||||
})
|
||||
if (response.data.success || response.data.connected) {
|
||||
setCollectorHealthStatus((prev) => ({
|
||||
...prev,
|
||||
[selectedCollector.source]: { ok: true, message: response.data.message || '可用' },
|
||||
}))
|
||||
message.success(response.data.message || '采集器健康检查通过')
|
||||
} else {
|
||||
setCollectorHealthStatus((prev) => ({
|
||||
...prev,
|
||||
[selectedCollector.source]: { ok: false, message: response.data.message || '不可用' },
|
||||
}))
|
||||
message.error(response.data.message || '采集器健康检查失败')
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const err = error as { response?: { data?: { detail?: string; message?: string } } }
|
||||
const errorMessage = err.response?.data?.message || err.response?.data?.detail || '采集器健康检查失败'
|
||||
setCollectorHealthStatus((prev) => ({
|
||||
...prev,
|
||||
[selectedCollector.source]: { ok: false, message: errorMessage },
|
||||
}))
|
||||
message.error(errorMessage)
|
||||
} finally {
|
||||
setTestingCredentialProvider(null)
|
||||
}
|
||||
}
|
||||
|
||||
const generateCredentialGuide = async () => {
|
||||
if (!credentialGuide) return
|
||||
try {
|
||||
setCredentialGuideGenerating(true)
|
||||
const response = await axios.post(`/api/v1/settings/credential-guides/${credentialGuide.provider}/generate`)
|
||||
setCredentialGuide(response.data.guide)
|
||||
message.success('已生成新的凭证教程')
|
||||
} catch {
|
||||
message.error('生成教程失败,请检查 AI 配置')
|
||||
} finally {
|
||||
setCredentialGuideGenerating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const resetCredentialGuide = async () => {
|
||||
if (!credentialGuide) return
|
||||
try {
|
||||
setCredentialGuideResetting(true)
|
||||
const response = await axios.post(`/api/v1/settings/credential-guides/${credentialGuide.provider}/reset`)
|
||||
setCredentialGuide(response.data.guide)
|
||||
message.success('已恢复默认教程')
|
||||
} catch {
|
||||
message.error('重置教程失败')
|
||||
} finally {
|
||||
setCredentialGuideResetting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const applyAiProviderPreset = (preset: AIProviderPreset) => {
|
||||
integrationForm.setFieldsValue({
|
||||
ai_provider: {
|
||||
@@ -1025,65 +1335,153 @@ function Settings() {
|
||||
},
|
||||
{
|
||||
key: 'collector_credentials',
|
||||
label: '采集器凭证',
|
||||
label: '采集器设置',
|
||||
forceRender: true,
|
||||
children: (
|
||||
<SettingsPanel loading={loading}>
|
||||
<Form form={integrationForm} layout="vertical" onFinish={saveIntegrations}>
|
||||
<Card size="small" title="需要凭证的采集器" style={{ marginBottom: 16 }}>
|
||||
<Space size={[6, 6]} wrap>
|
||||
{credentialCollectors.length ? credentialCollectors.map((collector) => (
|
||||
<Tag
|
||||
key={collector.source}
|
||||
color={collector.credential_status === 'supported' ? 'blue' : 'orange'}
|
||||
>
|
||||
{collector.display_name || collector.name}
|
||||
{collector.credential_status === 'supported' ? ' · 已支持配置' : ' · 待接入'}
|
||||
</Tag>
|
||||
)) : (
|
||||
<Text type="secondary">当前没有需要凭证的内置采集器。</Text>
|
||||
)}
|
||||
</Space>
|
||||
</Card>
|
||||
<Space direction="vertical" size={16} style={{ width: '100%' }}>
|
||||
<Card
|
||||
size="small"
|
||||
title={<Space><ApiOutlined />BarentsWatch AIS</Space>}
|
||||
title="选择采集器"
|
||||
>
|
||||
<Form.Item name={['barentswatch', 'endpoint']} label="AIS Endpoint">
|
||||
<Input placeholder="https://live.ais.barentswatch.no/v1/latest/combined" />
|
||||
</Form.Item>
|
||||
<Form.Item name={['barentswatch', 'client_id']} label="Client ID">
|
||||
<Input autoComplete="off" />
|
||||
</Form.Item>
|
||||
<Form.Item label="Client Secret">
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<Space>
|
||||
<Tag color={integrations?.barentswatch.client_secret.configured ? 'green' : 'default'}>
|
||||
{integrations?.barentswatch.client_secret.configured
|
||||
? `已配置 ${integrations.barentswatch.client_secret.preview}`
|
||||
: '未配置'}
|
||||
</Tag>
|
||||
<Text type="secondary">留空表示保留现有 secret。</Text>
|
||||
</Space>
|
||||
<Form.Item name={['barentswatch', 'client_secret']} noStyle>
|
||||
<Input.Password autoComplete="new-password" placeholder="输入新 client secret" />
|
||||
</Form.Item>
|
||||
<Space.Compact style={{ width: '100%' }}>
|
||||
<Select
|
||||
showSearch
|
||||
value={selectedCollectorSource}
|
||||
style={{ width: '100%' }}
|
||||
optionFilterProp="label"
|
||||
onChange={setSelectedCollectorSource}
|
||||
options={collectors.map((collector) => ({
|
||||
value: collector.source,
|
||||
label: `${collector.display_name || collector.name} · ${collector.source}`,
|
||||
}))}
|
||||
/>
|
||||
<Tooltip title="健康检查">
|
||||
<Button
|
||||
icon={<PlugConnectIcon />}
|
||||
loading={testingCredentialProvider === (selectedCollector?.credential_provider || selectedCollector?.source)}
|
||||
disabled={!selectedCollector}
|
||||
onClick={() => { void testSelectedCollectorConnectivity() }}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Space.Compact>
|
||||
{selectedCollector ? (
|
||||
<Space size={[6, 6]} wrap style={{ marginTop: 12 }}>
|
||||
<Tag color={selectedCollector.requires_credentials ? 'orange' : 'green'}>
|
||||
{selectedCollector.requires_credentials ? '需要凭证' : '无需凭证'}
|
||||
</Tag>
|
||||
<Tag>{selectedCollector.module}</Tag>
|
||||
<Tag color={selectedCollector.is_active ? 'success' : 'default'}>
|
||||
{selectedCollector.is_active ? '启用' : '禁用'}
|
||||
</Tag>
|
||||
{selectedCollectorHealth ? (
|
||||
<Tooltip title={selectedCollectorHealth.message}>
|
||||
<Tag color={selectedCollectorHealth.ok ? 'success' : 'error'}>
|
||||
{selectedCollectorHealth.ok ? '可用' : '不可用'}
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Tag>未检查</Tag>
|
||||
)}
|
||||
{selectedCollectorConfig?.is_overridden ? <Tag color="blue">已覆盖 endpoint</Tag> : null}
|
||||
</Space>
|
||||
</Form.Item>
|
||||
<Form.Item name={['barentswatch', 'clear_client_secret']} valuePropName="checked">
|
||||
<Checkbox>清除当前 BarentsWatch client secret</Checkbox>
|
||||
</Form.Item>
|
||||
) : null}
|
||||
</Card>
|
||||
|
||||
{selectedCollector?.requires_credentials && selectedCollector.source !== 'barentswatch_vessels' ? (
|
||||
<Alert
|
||||
showIcon
|
||||
type="warning"
|
||||
message="该采集器需要凭证"
|
||||
description={selectedCollector.credential_status === 'supported'
|
||||
? '该凭证类型已支持,但当前页面还没有专用表单。'
|
||||
: '该凭证配置入口待接入。'}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{selectedCollector?.source === 'barentswatch_vessels' ? (
|
||||
<Card
|
||||
size="small"
|
||||
title={<Space><ApiOutlined />BarentsWatch AIS 凭证</Space>}
|
||||
extra={(
|
||||
<Space>
|
||||
<Tooltip title="查看凭证获取教程">
|
||||
<Button
|
||||
size="small"
|
||||
icon={<BookOutlined />}
|
||||
onClick={() => { void loadCredentialGuide('barentswatch', true) }}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Space>
|
||||
)}
|
||||
>
|
||||
<Form form={integrationForm} layout="vertical">
|
||||
<Form.Item name={['barentswatch', 'client_id']} label="Client ID">
|
||||
<Input autoComplete="off" />
|
||||
</Form.Item>
|
||||
<Form.Item name={['barentswatch', 'client_secret']} label="Client Secret">
|
||||
<Input.Password
|
||||
autoComplete="new-password"
|
||||
placeholder="输入 client secret"
|
||||
onFocus={() => {
|
||||
const preview = integrations?.barentswatch.client_secret.preview
|
||||
if (preview && integrationForm.getFieldValue(['barentswatch', 'client_secret']) === preview) {
|
||||
integrationForm.setFieldValue(['barentswatch', 'client_secret'], '')
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<Card size="small" title="基础配置">
|
||||
<Form form={collectorConfigForm} layout="vertical">
|
||||
<Form.Item name="endpoint" label="Endpoint" rules={[{ required: true, message: '请输入 Endpoint' }]}>
|
||||
<Input placeholder={selectedCollectorConfig?.default_url || 'https://api.example.com'} />
|
||||
</Form.Item>
|
||||
<Form.Item label="默认 Endpoint">
|
||||
<Input value={selectedCollectorConfig?.default_url || '-'} disabled />
|
||||
</Form.Item>
|
||||
<Form.List name="headers">
|
||||
{(fields, { add, remove }) => (
|
||||
<Form.Item label="请求头">
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
{fields.map(({ key, name, ...restField }) => (
|
||||
<Space key={key} style={{ display: 'flex' }} align="baseline">
|
||||
<Form.Item {...restField} name={[name, 'key']} noStyle>
|
||||
<Input placeholder="Header" />
|
||||
</Form.Item>
|
||||
<Form.Item {...restField} name={[name, 'value']} noStyle>
|
||||
<Input placeholder="Value" />
|
||||
</Form.Item>
|
||||
<Button type="link" danger onClick={() => remove(name)}>删除</Button>
|
||||
</Space>
|
||||
))}
|
||||
<Button type="dashed" onClick={() => add()} block>添加请求头</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
)}
|
||||
</Form.List>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0 16px' }}>
|
||||
<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>
|
||||
</div>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
loading={savingIntegrations}
|
||||
style={{ marginTop: 16 }}
|
||||
loading={savingCollectorConfig || savingIntegrations}
|
||||
onClick={() => { void saveSelectedCollectorSettings() }}
|
||||
>
|
||||
保存采集器凭证
|
||||
保存采集器设置
|
||||
</Button>
|
||||
</Form>
|
||||
</Space>
|
||||
</SettingsPanel>
|
||||
),
|
||||
},
|
||||
@@ -1133,6 +1531,50 @@ function Settings() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Modal
|
||||
title={credentialGuide?.title || '凭证教程'}
|
||||
open={credentialGuideOpen}
|
||||
onCancel={() => setCredentialGuideOpen(false)}
|
||||
footer={[
|
||||
<Button
|
||||
key="reset"
|
||||
icon={<ReloadOutlined />}
|
||||
loading={credentialGuideResetting}
|
||||
onClick={() => { void resetCredentialGuide() }}
|
||||
>
|
||||
重置
|
||||
</Button>,
|
||||
<Button
|
||||
key="generate"
|
||||
icon={<RobotOutlined />}
|
||||
loading={credentialGuideGenerating}
|
||||
onClick={() => { void generateCredentialGuide() }}
|
||||
>
|
||||
教程不好用
|
||||
</Button>,
|
||||
<Button key="close" type="primary" onClick={() => setCredentialGuideOpen(false)}>
|
||||
关闭
|
||||
</Button>,
|
||||
]}
|
||||
width={760}
|
||||
centered
|
||||
>
|
||||
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
||||
{credentialGuide?.source === 'ai' ? (
|
||||
<Alert showIcon type="success" message="当前显示 AI 生成教程,可随时重置为默认教程。" />
|
||||
) : (
|
||||
<Alert showIcon type="info" message="当前显示默认教程;如果不适用,可以点击右下角“教程不好用”让 AI 重新生成。" />
|
||||
)}
|
||||
<Card loading={credentialGuideLoading} size="small" style={{ maxHeight: 520, overflow: 'auto' }}>
|
||||
<MarkdownRenderer markdown={credentialGuide?.markdown || ''} />
|
||||
</Card>
|
||||
{credentialGuide?.prompt ? (
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
生成提示词:{credentialGuide.prompt}
|
||||
</Text>
|
||||
) : null}
|
||||
</Space>
|
||||
</Modal>
|
||||
</AppLayout>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user