release: bump version to 0.44.0

This commit is contained in:
linkong
2026-04-29 17:27:44 +08:00
parent 2da25376bd
commit 87594a95ff
54 changed files with 3665 additions and 2154 deletions

View File

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

View File

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