'use client'; import Link from 'next/link'; import { useEffect, useMemo, useState } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { Copy, ExternalLink, Loader2, RefreshCw, RotateCw, Save, Server, Trash2, } from 'lucide-react'; import { toast } from 'sonner'; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from '@/components/ui/alert-dialog'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from '@/components/ui/card'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select'; import { Switch } from '@/components/ui/switch'; import { Textarea } from '@/components/ui/textarea'; import { ErrorInline } from '@/components/layout/error'; import { LoadingStateWithBorder } from '@/components/layout/loading'; import type { DatabaseCleanupTarget } from '@/lib/services/openflare'; import { NodeService, OptionService, StatusService, UptimeKumaService, } from '@/lib/services/openflare'; import { agentOptionEntries, buildDiscoveryCommand, databaseAutoCleanupEntries, defaultOpenFlareOpsFields, formatDurationLabel, getBrowserOrigin, mapOptionsToOpsFields, type OpenFlareOpsFields, optionsToMap, uptimeKumaOptionEntries, } from './openflare-ops-utils'; import { UptimeKumaSiteSelectModal } from './uptimekuma-site-modal'; const optionsQueryKey = ['openflare', 'options'] as const; const openflarePublicStatusQueryKey = ['openflare', 'public-status'] as const; const cleanupTargets: Array<{ target: DatabaseCleanupTarget; label: string; description: string; }> = [ { target: 'node_access_logs', label: '访问日志', description: '清理 node_access_logs,影响访问明细与 IP 汇总;表 TTL 为 90 天。', }, { target: 'node_metric_snapshots', label: '性能快照', description: '清理 node_metric_snapshots,影响节点资源趋势;表 TTL 为 30 天。', }, { target: 'node_request_reports', label: '请求聚合', description: '清理 node_request_reports,影响请求量与错误量统计;表 TTL 为 30 天。', }, ]; async function copyText(value: string) { await navigator.clipboard.writeText(value); } export function OpenFlareOpsSettings() { const queryClient = useQueryClient(); const [fields, setFields] = useState( defaultOpenFlareOpsFields, ); const [savingSection, setSavingSection] = useState(null); const [geoIPTestIP, setGeoIPTestIP] = useState('8.8.8.8'); const [uptimeKumaModalOpen, setUptimeKumaModalOpen] = useState(false); const [cleanupTarget, setCleanupTarget] = useState<{ target: DatabaseCleanupTarget; label: string; } | null>(null); const [cleanupRetentionDays, setCleanupRetentionDays] = useState(''); const optionsQuery = useQuery({ queryKey: optionsQueryKey, queryFn: () => OptionService.list(), }); const statusQuery = useQuery({ queryKey: openflarePublicStatusQueryKey, queryFn: () => StatusService.getPublicStatus(), }); const bootstrapQuery = useQuery({ queryKey: ['openflare', 'bootstrap-token'], queryFn: () => NodeService.getBootstrapToken(), }); useEffect(() => { if (!optionsQuery.data) return; const optionMap = optionsToMap(optionsQuery.data); const serverAddress = optionMap.server_address || statusQuery.data?.server_address || getBrowserOrigin(); setFields(mapOptionsToOpsFields(optionMap, serverAddress)); }, [optionsQuery.data, statusQuery.data?.server_address]); const geoIPMutation = useMutation({ mutationFn: () => OptionService.lookupGeoIP(fields.geoip_provider, geoIPTestIP.trim()), }); const saveMutation = useMutation({ mutationFn: async ({ section, entries, }: { section: string; entries: Array<{ key: string; value: string }>; }) => { setSavingSection(section); await OptionService.updateBatch(entries); }, onSuccess: async () => { toast.success('OpenFlare 运维设置已保存'); await queryClient.invalidateQueries({ queryKey: optionsQueryKey }); setSavingSection(null); }, onError: (error) => { setSavingSection(null); toast.error(error instanceof Error ? error.message : '保存失败'); }, }); const rotateTokenMutation = useMutation({ mutationFn: () => NodeService.rotateBootstrapToken(), onSuccess: async (data) => { toast.success('Discovery Token 已重新生成'); await queryClient.invalidateQueries({ queryKey: ['openflare', 'bootstrap-token'], }); if (data.discovery_token) { try { await copyText(data.discovery_token); } catch { // ignore clipboard errors } } }, onError: (error) => { toast.error(error instanceof Error ? error.message : 'Token 轮换失败'); }, }); const syncUptimeKumaMutation = useMutation({ mutationFn: () => UptimeKumaService.sync(), onSuccess: () => toast.success('Uptime Kuma 同步任务已执行'), onError: (error) => toast.error(error instanceof Error ? error.message : '同步失败'), }); const cleanupMutation = useMutation({ mutationFn: (payload: { target: DatabaseCleanupTarget; retention_days?: number; }) => OptionService.cleanupDatabase(payload), onSuccess: (result) => { setCleanupTarget(null); setCleanupRetentionDays(''); toast.success( result.delete_all ? `已清空${result.target_label},共删除 ${result.deleted_count} 条。` : `已清理${result.target_label},共删除 ${result.deleted_count} 条。`, ); }, onError: (error) => { toast.error(error instanceof Error ? error.message : '清理失败'); }, }); const discoveryToken = bootstrapQuery.data?.discovery_token ?? ''; const discoveryCommand = useMemo(() => { if (!fields.server_address || !discoveryToken) return ''; return buildDiscoveryCommand(fields.server_address, discoveryToken); }, [discoveryToken, fields.server_address]); const updateField = ( key: K, value: OpenFlareOpsFields[K], ) => { setFields((previous) => ({ ...previous, [key]: value })); }; const saveAgentSettings = () => { try { saveMutation.mutate({ section: 'agent', entries: agentOptionEntries(fields), }); } catch (error) { toast.error(error instanceof Error ? error.message : '参数校验失败'); } }; const saveUptimeKumaSettings = () => { try { saveMutation.mutate({ section: 'uptimekuma', entries: uptimeKumaOptionEntries(fields), }); } catch (error) { toast.error(error instanceof Error ? error.message : '参数校验失败'); } }; const saveDatabaseAutoCleanup = () => { try { saveMutation.mutate({ section: 'database-auto', entries: databaseAutoCleanupEntries(fields), }); } catch (error) { toast.error(error instanceof Error ? error.message : '参数校验失败'); } }; if (optionsQuery.isLoading) { return ( ); } if (optionsQuery.isError) { return ( void optionsQuery.refetch()} /> ); } return (
Agent 运行参数 心跳间隔与离线阈值会在下个心跳周期同步到节点。
updateField('agent_heartbeat_interval', value) } /> updateField('node_offline_threshold', value) } />
updateField('agent_websocket_upgrade_enabled', value) } /> updateField('agent_update_repo', value)} />
IP 归属解析 控制节点地图等场景的 GeoIP 来源;访客访问记录归属地固定使用 MaxMind mmdb。
{geoIPMutation.data ? (
) : null}
Discovery Token 与部署 新节点首次接入使用 Discovery Token;轮换请前往节点管理或使用下方操作。
updateField('server_address', value)} placeholder='https://yourdomain.com' />

Discovery Token(只读)

{bootstrapQuery.isLoading ? '加载中...' : discoveryToken || '未生成'}