"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 {Badge} from "@/components/ui/badge" 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 {AdminStatusService} from "@/lib/services/admin" import {NodeService, OptionService, StatusService, UptimeKumaService,} from "@/lib/services/openflare" import {VersionUpgradeDialog} from "@/app/(main)/components/version-upgrade-dialog" import {adminUpdateStatusQueryKey, openflarePublicStatusQueryKey,} from "@/lib/hooks/use-openflare-server-upgrade" 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 cleanupTargets: Array<{ target: DatabaseCleanupTarget label: string description: string }> = [ { target: "node_access_logs", label: "访问日志", description: "清理 node_access_logs,影响访问明细与 IP 汇总。", }, { target: "node_metric_snapshots", label: "性能快照", description: "清理 node_metric_snapshots,影响节点资源趋势。", }, { target: "node_request_reports", label: "请求聚合", description: "清理 node_request_reports,影响请求量与错误量统计。", }, ] 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 [versionDialogOpen, setVersionDialogOpen] = useState(false) 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(), }) const releaseQuery = useQuery({ queryKey: adminUpdateStatusQueryKey, queryFn: () => AdminStatusService.getUpdateStatus(), }) useEffect(() => { if (!optionsQuery.data) return const optionMap = optionsToMap(optionsQuery.data) const serverAddress = optionMap.ServerAddress || statusQuery.data?.server_address || getBrowserOrigin() setFields(mapOptionsToOpsFields(optionMap, serverAddress)) }, [optionsQuery.data, statusQuery.data?.server_address]) const geoIPMutation = useMutation({ mutationFn: () => OptionService.lookupGeoIP(fields.GeoIPProvider, 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.ServerAddress || !discoveryToken) return "" return buildDiscoveryCommand(fields.ServerAddress, discoveryToken) }, [discoveryToken, fields.ServerAddress]) 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("AgentHeartbeatInterval", value)} /> updateField("NodeOfflineThreshold", value)} />
updateField("AgentWebsocketUpgradeEnabled", value)} /> updateField("AgentUpdateRepo", value)} />
IP 归属解析 控制节点地图等场景的 GeoIP 来源;访客访问记录归属地固定使用 MaxMind mmdb。
{geoIPMutation.data ? (
) : null}
Discovery Token 与部署 新节点首次接入使用 Discovery Token;轮换请前往节点管理或使用下方操作。
updateField("ServerAddress", value)} placeholder="https://yourdomain.com" />

Discovery Token(只读)

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