'use client'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useEffect, useMemo, useState } from 'react'; import { EmptyState } from '@/components/feedback/empty-state'; import { ErrorState } from '@/components/feedback/error-state'; import { InlineMessage } from '@/components/feedback/inline-message'; import { LoadingState } from '@/components/feedback/loading-state'; import { AppModal } from '@/components/ui/app-modal'; import { useAuth } from '@/components/providers/auth-provider'; import { PageHeader } from '@/components/layout/page-header'; import { AppCard } from '@/components/ui/app-card'; import { StatusBadge } from '@/components/ui/status-badge'; import { getOAuthAuthorizeUrl, sendEmailVerification, } from '@/features/auth/api/auth'; import { getPublicStatus } from '@/features/auth/api/public'; import { bindEmail, cleanupDatabaseObservability, deleteExternalAccountBinding, generateAccessToken, getAuthSources, getBootstrapToken, getExternalAccountBindings, getOptions, getSettingsProfile, lookupGeoIP, rotateBootstrapToken, updateOptions, updateSelf, } from '@/features/settings/api/settings'; import { AuthSourceModal } from '@/features/settings/components/auth-source-modal'; import type { BootstrapTokenPayload, DatabaseCleanupResult, DatabaseCleanupTarget, GeoIPLookupResult, OptionItem, UpdateSelfPayload, } from '@/features/settings/types'; import { CodeBlock, DangerButton, PrimaryButton, ResourceField, ResourceInput, ResourceSelect, ResourceTextarea, SecondaryButton, ToggleField, } from '@/features/shared/components/resource-primitives'; import { formatDateTime } from '@/lib/utils/date'; const settingsQueryKey = ['settings', 'options'] as const; const authSourcesQueryKey = ['settings', 'auth-sources'] as const; const externalAccountBindingsQueryKey = [ 'settings', 'external-accounts', ] as const; const installerScriptUrl = 'https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/install-agent.sh'; const defaultSystemFields = { ServerAddress: '', PasswordLoginEnabled: true, PasswordRegisterEnabled: true, EmailVerificationEnabled: false, GitHubOAuthEnabled: false, WeChatAuthEnabled: false, SMTPServer: '', SMTPPort: '587', SMTPAccount: '', SMTPToken: '', GitHubClientId: '', GitHubClientSecret: '', WeChatServerAddress: '', WeChatServerToken: '', WeChatAccountQRCodeImageURL: '', }; const defaultOperationFields = { AgentHeartbeatInterval: '10000', AgentWebsocketUpgradeEnabled: true, NodeOfflineThreshold: '120000', AgentUpdateRepo: 'Rain-kl/OpenFlare', GeoIPProvider: 'ipinfo', OpenRestyWorkerProcesses: 'auto', OpenRestyWorkerConnections: '4096', OpenRestyWorkerRlimitNofile: '65535', OpenRestyEventsUse: 'epoll', OpenRestyEventsMultiAcceptEnabled: true, OpenRestyKeepaliveTimeout: '20', OpenRestyKeepaliveRequests: '1000', OpenRestyClientHeaderTimeout: '15', OpenRestyClientBodyTimeout: '15', OpenRestySendTimeout: '30', OpenRestyProxyConnectTimeout: '3', OpenRestyProxySendTimeout: '60', OpenRestyProxyReadTimeout: '60', OpenRestyProxyBufferingEnabled: true, OpenRestyProxyBuffers: '16 16k', OpenRestyProxyBufferSize: '8k', OpenRestyProxyBusyBuffersSize: '64k', OpenRestyGzipEnabled: true, OpenRestyGzipMinLength: '1024', OpenRestyGzipCompLevel: '5', OpenRestyCacheEnabled: false, OpenRestyCachePath: '', OpenRestyCacheLevels: '1:2', OpenRestyCacheInactive: '30m', OpenRestyCacheMaxSize: '1g', OpenRestyCacheKeyTemplate: '$scheme$host$request_uri', OpenRestyCacheLockEnabled: true, OpenRestyCacheLockTimeout: '5s', OpenRestyCacheUseStale: 'error timeout updating http_500 http_502 http_503 http_504', GlobalApiRateLimitNum: '300', GlobalApiRateLimitDuration: '180', GlobalWebRateLimitNum: '300', GlobalWebRateLimitDuration: '180', UploadRateLimitNum: '50', UploadRateLimitDuration: '60', DownloadRateLimitNum: '50', DownloadRateLimitDuration: '60', CriticalRateLimitNum: '100', CriticalRateLimitDuration: '1200', ServerAddress: '', }; const defaultOtherFields = { Notice: '', SystemName: '', HomePageLink: '', About: '', Footer: '', }; const defaultDatabaseFields = { DatabaseAutoCleanupEnabled: false, DatabaseAutoCleanupRetentionDays: '30', }; const defaultProfileFields: UpdateSelfPayload = { username: '', display_name: '', password: '', }; type FeedbackState = { tone: 'info' | 'success' | 'danger'; message: string; }; type CleanupModalState = { target: DatabaseCleanupTarget; label: string; }; type SettingsTab = 'personal' | 'operation' | 'database' | 'system' | 'other'; function getErrorMessage(error: unknown) { return error instanceof Error ? error.message : '请求失败,请稍后重试。'; } function optionsToMap(options: OptionItem[] | undefined) { return (options ?? []).reduce>( (accumulator, option) => { accumulator[option.key] = option.value; return accumulator; }, {}, ); } function toBoolean(value: string | undefined, fallback: boolean) { if (value === undefined) { return fallback; } return value === 'true'; } function normalizeServerUrl(value: string) { return value.trim().replace(/\/+$/, ''); } function getBrowserOrigin() { if (typeof window === 'undefined') { return ''; } return normalizeServerUrl(window.location.origin); } function formatDurationLabel(value: string) { const milliseconds = Number.parseInt(value, 10); if (Number.isNaN(milliseconds)) { return value; } if (milliseconds >= 60000) { return `${milliseconds / 60000} 分钟`; } return `${milliseconds / 1000} 秒`; } function formatSecondsLabel(value: string) { const seconds = Number.parseInt(value, 10); if (Number.isNaN(seconds)) { return value; } if (seconds >= 3600 && seconds % 3600 === 0) { return `${seconds / 3600} 小时`; } if (seconds >= 60 && seconds % 60 === 0) { return `${seconds / 60} 分钟`; } return `${seconds} 秒`; } function buildDiscoveryCommand(serverUrl: string, discoveryToken: string) { return [ `curl -fsSL ${installerScriptUrl} | bash -s -- \\`, ` --server-url ${normalizeServerUrl(serverUrl)} \\`, ` --discovery-token ${discoveryToken}`, ].join('\n'); } async function copyToClipboard(value: string) { await navigator.clipboard.writeText(value); } export function SettingsPage() { const queryClient = useQueryClient(); const { refreshUser, user } = useAuth(); const [activeTab, setActiveTab] = useState('personal'); const [feedback, setFeedback] = useState(null); const [busyKey, setBusyKey] = useState(null); const [profileFields, setProfileFields] = useState(defaultProfileFields); const [systemFields, setSystemFields] = useState(defaultSystemFields); const [operationFields, setOperationFields] = useState( defaultOperationFields, ); const [otherFields, setOtherFields] = useState(defaultOtherFields); const [databaseFields, setDatabaseFields] = useState(defaultDatabaseFields); const [accessToken, setAccessToken] = useState(''); const [emailAddress, setEmailAddress] = useState(''); const [emailCode, setEmailCode] = useState(''); const [authSourceModalOpen, setAuthSourceModalOpen] = useState(false); const [geoIPTestIP, setGeoIPTestIP] = useState('8.8.8.8'); const [cleanupModalState, setCleanupModalState] = useState(null); const [cleanupRetentionDays, setCleanupRetentionDays] = useState(''); const isRoot = (user?.role ?? 0) >= 100; const publicStatusQuery = useQuery({ queryKey: ['public-status'], queryFn: getPublicStatus, }); const profileQuery = useQuery({ queryKey: ['settings', 'profile'], queryFn: getSettingsProfile, }); const externalAccountsQuery = useQuery({ queryKey: externalAccountBindingsQueryKey, queryFn: getExternalAccountBindings, }); const optionsQuery = useQuery({ queryKey: settingsQueryKey, queryFn: getOptions, enabled: isRoot, }); const authSourcesQuery = useQuery({ queryKey: authSourcesQueryKey, queryFn: getAuthSources, enabled: isRoot, }); const bootstrapQuery = useQuery({ queryKey: ['settings', 'bootstrap-token'], queryFn: getBootstrapToken, enabled: isRoot, }); useEffect(() => { if (profileQuery.data) { setProfileFields({ username: profileQuery.data.username, display_name: profileQuery.data.display_name || '', password: '', }); setEmailAddress(profileQuery.data.email || ''); } }, [profileQuery.data]); useEffect(() => { const publicStatus = publicStatusQuery.data; if (!publicStatus) { return; } const resolvedServerAddress = publicStatus.server_address || getBrowserOrigin(); setSystemFields((previous) => ({ ...previous, ServerAddress: resolvedServerAddress || previous.ServerAddress, GitHubClientId: publicStatus.github_client_id || previous.GitHubClientId, WeChatAccountQRCodeImageURL: publicStatus.wechat_qrcode || previous.WeChatAccountQRCodeImageURL, })); setOtherFields((previous) => ({ ...previous, SystemName: publicStatus.system_name || previous.SystemName, HomePageLink: publicStatus.home_page_link || previous.HomePageLink, Footer: publicStatus.footer_html || previous.Footer, })); setOperationFields((previous) => ({ ...previous, ServerAddress: resolvedServerAddress || previous.ServerAddress, })); }, [publicStatusQuery.data]); useEffect(() => { if (!optionsQuery.data) { return; } const optionMap = optionsToMap(optionsQuery.data); const resolvedServerAddress = optionMap.ServerAddress || publicStatusQuery.data?.server_address || getBrowserOrigin(); setSystemFields({ ServerAddress: resolvedServerAddress, PasswordLoginEnabled: toBoolean(optionMap.PasswordLoginEnabled, true), PasswordRegisterEnabled: toBoolean( optionMap.PasswordRegisterEnabled, true, ), EmailVerificationEnabled: toBoolean( optionMap.EmailVerificationEnabled, false, ), GitHubOAuthEnabled: toBoolean(optionMap.GitHubOAuthEnabled, false), WeChatAuthEnabled: toBoolean(optionMap.WeChatAuthEnabled, false), SMTPServer: optionMap.SMTPServer ?? '', SMTPPort: optionMap.SMTPPort ?? '587', SMTPAccount: optionMap.SMTPAccount ?? '', SMTPToken: '', GitHubClientId: optionMap.GitHubClientId ?? '', GitHubClientSecret: '', WeChatServerAddress: optionMap.WeChatServerAddress ?? '', WeChatServerToken: '', WeChatAccountQRCodeImageURL: optionMap.WeChatAccountQRCodeImageURL ?? '', }); setOperationFields({ AgentHeartbeatInterval: optionMap.AgentHeartbeatInterval ?? '10000', AgentWebsocketUpgradeEnabled: toBoolean( optionMap.AgentWebsocketUpgradeEnabled, true, ), NodeOfflineThreshold: optionMap.NodeOfflineThreshold ?? '120000', AgentUpdateRepo: optionMap.AgentUpdateRepo ?? 'Rain-kl/OpenFlare', GeoIPProvider: optionMap.GeoIPProvider ?? 'ipinfo', OpenRestyWorkerProcesses: optionMap.OpenRestyWorkerProcesses ?? 'auto', OpenRestyWorkerConnections: optionMap.OpenRestyWorkerConnections ?? '4096', OpenRestyWorkerRlimitNofile: optionMap.OpenRestyWorkerRlimitNofile ?? '65535', OpenRestyEventsUse: optionMap.OpenRestyEventsUse ?? 'epoll', OpenRestyEventsMultiAcceptEnabled: toBoolean( optionMap.OpenRestyEventsMultiAcceptEnabled, true, ), OpenRestyKeepaliveTimeout: optionMap.OpenRestyKeepaliveTimeout ?? '20', OpenRestyKeepaliveRequests: optionMap.OpenRestyKeepaliveRequests ?? '1000', OpenRestyClientHeaderTimeout: optionMap.OpenRestyClientHeaderTimeout ?? '15', OpenRestyClientBodyTimeout: optionMap.OpenRestyClientBodyTimeout ?? '15', OpenRestySendTimeout: optionMap.OpenRestySendTimeout ?? '30', OpenRestyProxyConnectTimeout: optionMap.OpenRestyProxyConnectTimeout ?? '3', OpenRestyProxySendTimeout: optionMap.OpenRestyProxySendTimeout ?? '60', OpenRestyProxyReadTimeout: optionMap.OpenRestyProxyReadTimeout ?? '60', OpenRestyProxyBufferingEnabled: toBoolean( optionMap.OpenRestyProxyBufferingEnabled, true, ), OpenRestyProxyBuffers: optionMap.OpenRestyProxyBuffers ?? '16 16k', OpenRestyProxyBufferSize: optionMap.OpenRestyProxyBufferSize ?? '8k', OpenRestyProxyBusyBuffersSize: optionMap.OpenRestyProxyBusyBuffersSize ?? '64k', OpenRestyGzipEnabled: toBoolean(optionMap.OpenRestyGzipEnabled, true), OpenRestyGzipMinLength: optionMap.OpenRestyGzipMinLength ?? '1024', OpenRestyGzipCompLevel: optionMap.OpenRestyGzipCompLevel ?? '5', OpenRestyCacheEnabled: toBoolean(optionMap.OpenRestyCacheEnabled, false), OpenRestyCachePath: optionMap.OpenRestyCachePath ?? '', OpenRestyCacheLevels: optionMap.OpenRestyCacheLevels ?? '1:2', OpenRestyCacheInactive: optionMap.OpenRestyCacheInactive ?? '30m', OpenRestyCacheMaxSize: optionMap.OpenRestyCacheMaxSize ?? '1g', OpenRestyCacheKeyTemplate: optionMap.OpenRestyCacheKeyTemplate ?? '$scheme$host$request_uri', OpenRestyCacheLockEnabled: toBoolean( optionMap.OpenRestyCacheLockEnabled, true, ), OpenRestyCacheLockTimeout: optionMap.OpenRestyCacheLockTimeout ?? '5s', OpenRestyCacheUseStale: optionMap.OpenRestyCacheUseStale ?? 'error timeout updating http_500 http_502 http_503 http_504', GlobalApiRateLimitNum: optionMap.GlobalApiRateLimitNum ?? '300', GlobalApiRateLimitDuration: optionMap.GlobalApiRateLimitDuration ?? '180', GlobalWebRateLimitNum: optionMap.GlobalWebRateLimitNum ?? '300', GlobalWebRateLimitDuration: optionMap.GlobalWebRateLimitDuration ?? '180', UploadRateLimitNum: optionMap.UploadRateLimitNum ?? '50', UploadRateLimitDuration: optionMap.UploadRateLimitDuration ?? '60', DownloadRateLimitNum: optionMap.DownloadRateLimitNum ?? '50', DownloadRateLimitDuration: optionMap.DownloadRateLimitDuration ?? '60', CriticalRateLimitNum: optionMap.CriticalRateLimitNum ?? '100', CriticalRateLimitDuration: optionMap.CriticalRateLimitDuration ?? '1200', ServerAddress: resolvedServerAddress, }); setOtherFields({ Notice: optionMap.Notice ?? '', SystemName: optionMap.SystemName ?? '', HomePageLink: optionMap.HomePageLink ?? '', About: optionMap.About ?? '', Footer: optionMap.Footer ?? '', }); setDatabaseFields({ DatabaseAutoCleanupEnabled: toBoolean( optionMap.DatabaseAutoCleanupEnabled, false, ), DatabaseAutoCleanupRetentionDays: optionMap.DatabaseAutoCleanupRetentionDays ?? '30', }); }, [optionsQuery.data, publicStatusQuery.data?.server_address]); const rotateTokenMutation = useMutation({ mutationFn: rotateBootstrapToken, onSuccess: async (data: BootstrapTokenPayload) => { setFeedback({ tone: 'success', message: 'Discovery Token 已重新生成。' }); await queryClient.invalidateQueries({ queryKey: ['settings', 'bootstrap-token'], }); if (data.discovery_token) { try { await copyToClipboard(data.discovery_token); } catch { // ignore clipboard errors } } }, onError: (error) => { setFeedback({ tone: 'danger', message: getErrorMessage(error) }); }, }); const accessTokenMutation = useMutation({ mutationFn: generateAccessToken, onSuccess: (token) => { setAccessToken(token); setFeedback({ tone: 'success', message: '访问令牌已重置,并已在当前页面展示。', }); }, onError: (error) => { setFeedback({ tone: 'danger', message: getErrorMessage(error) }); }, }); const geoIPLookupMutation = useMutation({ mutationFn: ({ provider, ip }: { provider: string; ip: string }) => lookupGeoIP(provider, ip), }); const databaseCleanupMutation = useMutation({ mutationFn: cleanupDatabaseObservability, onSuccess: (result: DatabaseCleanupResult) => { setCleanupModalState(null); setCleanupRetentionDays(''); setFeedback({ tone: 'success', message: result.delete_all ? `已清空${result.target_label}数据,共删除 ${result.deleted_count} 条。` : `已清理${result.target_label}中超出保留期的数据,共删除 ${result.deleted_count} 条。`, }); }, onError: (error) => { setFeedback({ tone: 'danger', message: getErrorMessage(error) }); }, }); const discoveryToken = bootstrapQuery.data?.discovery_token ?? ''; const discoveryCommand = isRoot && operationFields.ServerAddress && discoveryToken ? buildDiscoveryCommand(operationFields.ServerAddress, discoveryToken) : ''; const tabs = useMemo( () => [ { key: 'personal' as const, label: '个人设置', description: '更新个人资料、绑定账号与访问令牌。', }, ...(isRoot ? [ { key: 'operation' as const, label: '运维设置', description: 'Agent 参数、Discovery Token 与部署命令。', }, { key: 'system' as const, label: '系统设置', description: '登录注册、SMTP、认证源、限流与风控开关。', }, { key: 'database' as const, label: '数据库', description: '观测数据清理与每日自动保留策略。', }, { key: 'other' as const, label: '其他设置', description: '公告、关于与品牌信息。', }, ] : []), ], [isRoot], ); const runBusyAction = async (key: string, action: () => Promise) => { setBusyKey(key); setFeedback(null); try { await action(); } catch (error) { setFeedback({ tone: 'danger', message: getErrorMessage(error) }); } finally { setBusyKey(null); } }; const saveOptionEntries = async ( entries: Array<[string, string]>, successMessage: string, ) => { await updateOptions(entries.map(([key, value]) => ({ key, value }))); await queryClient.invalidateQueries({ queryKey: settingsQueryKey }); await queryClient.invalidateQueries({ queryKey: ['public-status'] }); setFeedback({ tone: 'success', message: successMessage }); }; const handleGeoIPLookup = () => { geoIPLookupMutation.reset(); geoIPLookupMutation.mutate({ provider: operationFields.GeoIPProvider, ip: geoIPTestIP.trim(), }); }; const geoIPLookupResult: GeoIPLookupResult | undefined = geoIPLookupMutation.data; const handleProfileSave = () => { void runBusyAction('profile', async () => { await updateSelf({ username: profileFields.username.trim(), display_name: profileFields.display_name.trim(), password: profileFields.password, }); await Promise.all([ queryClient.invalidateQueries({ queryKey: ['settings', 'profile'] }), refreshUser(), ]); setProfileFields((previous) => ({ ...previous, password: '' })); setFeedback({ tone: 'success', message: '个人资料已更新。' }); }); }; const handleEmailVerification = () => { if (!emailAddress.trim()) { setFeedback({ tone: 'danger', message: '请输入要绑定的邮箱地址。' }); return; } void runBusyAction('email-send', async () => { await sendEmailVerification(emailAddress.trim()); setFeedback({ tone: 'success', message: '验证码已发送,请检查邮箱。' }); }); }; const handleBindEmail = () => { if (!emailAddress.trim() || !emailCode.trim()) { setFeedback({ tone: 'danger', message: '请输入邮箱地址和验证码。' }); return; } void runBusyAction('email-bind', async () => { await bindEmail(emailAddress.trim(), emailCode.trim()); setEmailCode(''); await Promise.all([ queryClient.invalidateQueries({ queryKey: ['settings', 'profile'] }), refreshUser(), ]); setFeedback({ tone: 'success', message: '邮箱已绑定。' }); }); }; const handleBindAuthSource = (sourceName: string) => { void runBusyAction(`auth-source-bind-${sourceName}`, async () => { const result = await getOAuthAuthorizeUrl(sourceName); window.location.href = result.authorize_url; }); }; const handleUnbindAuthSource = (id: number, label: string) => { if (!window.confirm(`确定解绑「${label}」吗?`)) { return; } void runBusyAction(`auth-source-unbind-${id}`, async () => { await deleteExternalAccountBinding(id); await queryClient.invalidateQueries({ queryKey: externalAccountBindingsQueryKey, }); setFeedback({ tone: 'success', message: '第三方账号已解绑。' }); }); }; const handleToggleOption = ( key: keyof typeof systemFields, nextValue: boolean, ) => { setSystemFields((previous) => ({ ...previous, [key]: nextValue })); void runBusyAction(`toggle-${key}`, async () => { await saveOptionEntries([[key, String(nextValue)]], '系统开关已更新。'); }); }; const renderTabContent = () => { if (profileQuery.isLoading || publicStatusQuery.isLoading) { return ; } if (profileQuery.isError) { return ( ); } if (publicStatusQuery.isError) { return ( ); } if (externalAccountsQuery.isError) { return ( ); } const publicStatus = publicStatusQuery.data; const profile = profileQuery.data; const externalAccounts = externalAccountsQuery.data ?? []; const externalAccountMap = new Map( externalAccounts.map((account) => [account.auth_source_name, account]), ); if (!publicStatus || !profile) { return ( ); } if (activeTab === 'personal') { return (
{busyKey === 'profile' ? '保存中...' : '保存资料'} } >
setProfileFields((previous) => ({ ...previous, username: event.target.value, })) } placeholder="请输入用户名" /> setProfileFields((previous) => ({ ...previous, display_name: event.target.value, })) } placeholder="请输入显示名称" /> setProfileFields((previous) => ({ ...previous, password: event.target.value, })) } placeholder="请输入新密码" />
accessTokenMutation.mutate()} disabled={accessTokenMutation.isPending} > {accessTokenMutation.isPending ? '生成中...' : '重置令牌'} } >

当前角色

已绑定邮箱

{profile.email || '未绑定'}

{accessToken ? (
{accessToken} void copyToClipboard(accessToken)} > 复制令牌
) : ( )}

第三方认证源

登录状态下发起授权会直接绑定到当前账号。

{(publicStatus.auth_sources ?? []).length > 0 ? ( publicStatus.auth_sources.map((source) => { const binding = externalAccountMap.get(source.name); const label = source.display_name || source.name; return (

{label}

{binding ? ( <>

已绑定: {binding.external_username || binding.email || '第三方账号'}

{binding.email ? (

邮箱:{binding.email}

) : null}

绑定时间: {formatDateTime(binding.created_at)}

) : (

未绑定

)}
{binding ? ( handleUnbindAuthSource(binding.id, label) } disabled={ busyKey === `auth-source-unbind-${binding.id}` } > 解绑 ) : ( handleBindAuthSource(source.name) } disabled={ busyKey === `auth-source-bind-${source.name}` } > 绑定 {label} )}
); }) ) : ( 当前未启用认证源。 )}

邮箱地址

当前状态: {profile.email ? `已绑定 ${profile.email}` : '未绑定'}

setEmailAddress(event.target.value)} placeholder="请输入邮箱地址" /> setEmailCode(event.target.value)} placeholder="请输入邮箱验证码" />
{busyKey === 'email-send' ? '发送中...' : '发送验证码'} {busyKey === 'email-bind' ? '绑定中...' : '绑定邮箱'}
); } if (!isRoot) { return ( ); } if (optionsQuery.isLoading) { return ; } if (optionsQuery.isError) { return ( ); } if (activeTab === 'operation') { return (
void runBusyAction('operation-settings', async () => { const heartbeat = Number.parseInt( operationFields.AgentHeartbeatInterval, 10, ); const offline = Number.parseInt( operationFields.NodeOfflineThreshold, 10, ); if (Number.isNaN(heartbeat) || heartbeat < 5000) { throw new Error('心跳间隔不能小于 5000 毫秒。'); } if (Number.isNaN(offline) || offline < 10000) { throw new Error('离线阈值不能小于 10000 毫秒。'); } await saveOptionEntries( [ ['AgentHeartbeatInterval', String(heartbeat)], [ 'AgentWebsocketUpgradeEnabled', String( operationFields.AgentWebsocketUpgradeEnabled, ), ], ['NodeOfflineThreshold', String(offline)], [ 'AgentUpdateRepo', operationFields.AgentUpdateRepo.trim(), ], ['GeoIPProvider', operationFields.GeoIPProvider], ], '运维设置已保存。', ); }) } > {busyKey === 'operation-settings' ? '保存中...' : '保存设置'} } >

Agent 运行参数

修改后会在下个心跳周期同步到节点。

setOperationFields((previous) => ({ ...previous, AgentHeartbeatInterval: event.target.value, })) } /> setOperationFields((previous) => ({ ...previous, NodeOfflineThreshold: event.target.value, })) } />
setOperationFields((previous) => ({ ...previous, AgentWebsocketUpgradeEnabled: checked, })) } />

Agent 更新仓库

上游更新仓库

setOperationFields((previous) => ({ ...previous, AgentUpdateRepo: event.target.value, })) } placeholder="Rain-kl/OpenFlare" />

IP 归属方式

控制节点地图等场景使用的 IP 归属解析来源。访客访问记录归属地入库固定使用 MaxMind mmdb。

setOperationFields((previous) => ({ ...previous, GeoIPProvider: event.target.value, })) } >
setGeoIPTestIP(event.target.value) } placeholder="例如 8.8.8.8" /> {geoIPLookupMutation.isPending ? '查询中...' : '查询归属'}
{geoIPLookupMutation.isError ? ( ) : null} {geoIPLookupResult ? (

查询 IP

{geoIPLookupResult.ip}

国家 / 地区

{geoIPLookupResult.name || '—'}

ISO Code

经纬度

{geoIPLookupResult.latitude !== undefined && geoIPLookupResult.latitude !== null && geoIPLookupResult.longitude !== undefined && geoIPLookupResult.longitude !== null ? `${geoIPLookupResult.latitude.toFixed(4)}, ${geoIPLookupResult.longitude.toFixed(4)}` : '—'}

) : null}
rotateTokenMutation.mutate()} disabled={rotateTokenMutation.isPending} > {rotateTokenMutation.isPending ? '生成中...' : '重新生成 Token'} {discoveryCommand ? ( void copyToClipboard(discoveryCommand)} > 复制命令 ) : null}
} > {bootstrapQuery.isLoading ? ( ) : bootstrapQuery.isError ? ( ) : (
setOperationFields((previous) => ({ ...previous, ServerAddress: event.target.value, })) } />

Discovery Token

{discoveryToken || '未生成'}

{discoveryCommand || '请先填写可访问的 Server URL。'}
)}

服务端版本

{publicStatus.version}

Server 启动时间

{formatDateTime(new Date(publicStatus.start_time * 1000))}

运行模式

静态导出 + Go Server 托管

版本入口

点击顶栏“版本”可检查更新、查看 Release Notes,并直接触发服务端升级。

); } if (activeTab === 'database') { return (
void runBusyAction('database-auto-cleanup', async () => { const retentionDays = Number.parseInt( databaseFields.DatabaseAutoCleanupRetentionDays, 10, ); if (Number.isNaN(retentionDays) || retentionDays < 1) { throw new Error('自动清理保留天数至少为 1 天。'); } await saveOptionEntries( [ [ 'DatabaseAutoCleanupEnabled', String(databaseFields.DatabaseAutoCleanupEnabled), ], [ 'DatabaseAutoCleanupRetentionDays', String(retentionDays), ], ], '数据库自动清理设置已保存。', ); }) } disabled={busyKey === 'database-auto-cleanup'} > {busyKey === 'database-auto-cleanup' ? '保存中...' : '保存自动清理'} } >
setDatabaseFields((previous) => ({ ...previous, DatabaseAutoCleanupEnabled: checked, })) } />
setDatabaseFields((previous) => ({ ...previous, DatabaseAutoCleanupRetentionDays: event.target.value, })) } placeholder="例如 30" />

触发频率

每天一次

默认执行时间

凌晨 3:00

生效范围

三类观测表

{[ { target: 'node_access_logs' as const, label: '访问日志', description: '清理 node_access_logs,影响访问明细、IP 汇总与相关趋势查询。', }, { target: 'node_metric_snapshots' as const, label: '性能快照', description: '清理 node_metric_snapshots,影响节点资源趋势和总览资源统计。', }, { target: 'node_request_reports' as const, label: '请求聚合', description: '清理 node_request_reports,影响请求量、错误量与来源聚合展示。', }, ].map((item) => (

{item.label}

{item.description}

{ setCleanupRetentionDays(''); setCleanupModalState({ target: item.target, label: item.label, }); }} > 清理数据
))}
); } if (activeTab === 'system') { return (
setAuthSourceModalOpen(true)} > 配置认证源 } >
handleToggleOption('PasswordLoginEnabled', checked) } disabled={busyKey === 'toggle-PasswordLoginEnabled'} /> handleToggleOption('PasswordRegisterEnabled', checked) } disabled={busyKey === 'toggle-PasswordRegisterEnabled'} /> handleToggleOption('EmailVerificationEnabled', checked) } disabled={busyKey === 'toggle-EmailVerificationEnabled'} />
当前已配置 {authSourcesQuery.data?.length ?? 0} 个认证源。
window.open( '/swagger/index.html', '_blank', 'noopener,noreferrer', ) } > 打开接口文档 void runBusyAction('system-general', async () => { await saveOptionEntries( [ [ 'ServerAddress', normalizeServerUrl(systemFields.ServerAddress), ], ], '通用设置已保存。', ); }) } disabled={busyKey === 'system-general'} > {busyKey === 'system-general' ? '保存中...' : '保存通用设置'}
} > setSystemFields((previous) => ({ ...previous, ServerAddress: event.target.value, })) } placeholder="https://yourdomain.com" /> void runBusyAction('system-smtp', async () => { await saveOptionEntries( [ ['SMTPServer', systemFields.SMTPServer.trim()], ['SMTPPort', systemFields.SMTPPort.trim()], ['SMTPAccount', systemFields.SMTPAccount.trim()], ['SMTPToken', systemFields.SMTPToken.trim()], ], 'SMTP 设置已保存。', ); }) } disabled={busyKey === 'system-smtp'} > {busyKey === 'system-smtp' ? '保存中...' : '保存 SMTP 设置'} } >
setSystemFields((previous) => ({ ...previous, SMTPServer: event.target.value, })) } placeholder="smtp.qq.com" /> setSystemFields((previous) => ({ ...previous, SMTPPort: event.target.value, })) } placeholder="587" /> setSystemFields((previous) => ({ ...previous, SMTPAccount: event.target.value, })) } placeholder="name@example.com" /> setSystemFields((previous) => ({ ...previous, SMTPToken: event.target.value, })) } placeholder="请输入新的 SMTP 凭证" />
void runBusyAction('operation-rate-limit', async () => { const entries = [ [ 'GlobalApiRateLimitNum', operationFields.GlobalApiRateLimitNum, ], [ 'GlobalApiRateLimitDuration', operationFields.GlobalApiRateLimitDuration, ], [ 'GlobalWebRateLimitNum', operationFields.GlobalWebRateLimitNum, ], [ 'GlobalWebRateLimitDuration', operationFields.GlobalWebRateLimitDuration, ], [ 'UploadRateLimitNum', operationFields.UploadRateLimitNum, ], [ 'UploadRateLimitDuration', operationFields.UploadRateLimitDuration, ], [ 'DownloadRateLimitNum', operationFields.DownloadRateLimitNum, ], [ 'DownloadRateLimitDuration', operationFields.DownloadRateLimitDuration, ], [ 'CriticalRateLimitNum', operationFields.CriticalRateLimitNum, ], [ 'CriticalRateLimitDuration', operationFields.CriticalRateLimitDuration, ], ] as const; for (const [key, rawValue] of entries) { const parsedValue = Number.parseInt(rawValue, 10); if (Number.isNaN(parsedValue) || parsedValue <= 0) { throw new Error(`${key} 必须为大于 0 的整数。`); } if (key.endsWith('Duration') && parsedValue > 1200) { throw new Error(`${key} 不能超过 1200 秒。`); } } await saveOptionEntries( entries.map(([key, value]) => [ key, String(Number.parseInt(value, 10)), ]), '限流设置已保存。', ); }) } disabled={busyKey === 'operation-rate-limit'} > {busyKey === 'operation-rate-limit' ? '保存中...' : '保存限流设置'} } >

全局 API 限流

作用于 `/api` 下的通用请求。

setOperationFields((previous) => ({ ...previous, GlobalApiRateLimitNum: event.target.value, })) } /> setOperationFields((previous) => ({ ...previous, GlobalApiRateLimitDuration: event.target.value, })) } />

全局 Web 限流

作用于页面和静态资源请求,过低会更容易触发 429。

setOperationFields((previous) => ({ ...previous, GlobalWebRateLimitNum: event.target.value, })) } /> setOperationFields((previous) => ({ ...previous, GlobalWebRateLimitDuration: event.target.value, })) } />

上传 / 下载限流

用于文件上传与下载接口,建议保留相对严格的阈值。

setOperationFields((previous) => ({ ...previous, UploadRateLimitNum: event.target.value, })) } /> setOperationFields((previous) => ({ ...previous, UploadRateLimitDuration: event.target.value, })) } /> setOperationFields((previous) => ({ ...previous, DownloadRateLimitNum: event.target.value, })) } /> setOperationFields((previous) => ({ ...previous, DownloadRateLimitDuration: event.target.value, })) } />

敏感接口限流

用于登录、注册、验证码、重置密码和 OAuth 等接口。

setOperationFields((previous) => ({ ...previous, CriticalRateLimitNum: event.target.value, })) } /> setOperationFields((previous) => ({ ...previous, CriticalRateLimitDuration: event.target.value, })) } />
); } return (
void runBusyAction('other-brand', async () => { await saveOptionEntries( [ ['Notice', otherFields.Notice], ['SystemName', otherFields.SystemName.trim()], ['HomePageLink', otherFields.HomePageLink.trim()], ['Footer', otherFields.Footer], ], '公告与品牌设置已保存。', ); }) } disabled={busyKey === 'other-brand'} > {busyKey === 'other-brand' ? '保存中...' : '保存基础信息'} } >
setOtherFields((previous) => ({ ...previous, SystemName: event.target.value, })) } placeholder="OpenFlare" /> setOtherFields((previous) => ({ ...previous, HomePageLink: event.target.value, })) } placeholder="https://example.com" /> setOtherFields((previous) => ({ ...previous, Notice: event.target.value, })) } placeholder="可在此编写首页公告内容" /> setOtherFields((previous) => ({ ...previous, Footer: event.target.value, })) } placeholder="留空则使用默认页脚" />
void runBusyAction('other-about', async () => { await saveOptionEntries( [['About', otherFields.About]], '关于页内容已保存。', ); }) } disabled={busyKey === 'other-about'} > {busyKey === 'other-about' ? '保存中...' : '保存关于内容'} } >
setOtherFields((previous) => ({ ...previous, About: event.target.value, })) } placeholder="在这里编写关于 OpenFlare 的介绍内容" className="min-h-48" />
); }; return (
{feedback ? ( ) : null}
{tabs.map((tab) => ( ))}
{renderTabContent()} setAuthSourceModalOpen(false)} onChanged={async () => { await Promise.all([ queryClient.invalidateQueries({ queryKey: authSourcesQueryKey }), queryClient.invalidateQueries({ queryKey: ['public-status'] }), ]); }} /> { if (databaseCleanupMutation.isPending) { return; } setCleanupModalState(null); }} footer={
setCleanupModalState(null)} disabled={databaseCleanupMutation.isPending} > 取消 { if (!cleanupModalState) { return; } const trimmed = cleanupRetentionDays.trim(); if (trimmed !== '') { const retentionDays = Number.parseInt(trimmed, 10); if (Number.isNaN(retentionDays) || retentionDays < 1) { setFeedback({ tone: 'danger', message: '手动清理保留天数至少为 1 天。', }); return; } databaseCleanupMutation.mutate({ target: cleanupModalState.target, retention_days: retentionDays, }); return; } databaseCleanupMutation.mutate({ target: cleanupModalState.target, }); }} > {databaseCleanupMutation.isPending ? '清理中...' : '确认清理'}
} >
该操作会直接删除数据库中的历史观测数据,删除后无法恢复,请确认当前选择的数据类型和保留范围无误。
setCleanupRetentionDays(event.target.value)} placeholder="例如 30;留空则全部删除" /> {databaseCleanupMutation.isError ? ( ) : null}
); }