diff --git a/Wavelet/frontend/app/(main)/admin/settings/components/openflare-ops-utils.ts b/Wavelet/frontend/app/(main)/admin/settings/components/openflare-ops-utils.ts new file mode 100644 index 00000000..0855744e --- /dev/null +++ b/Wavelet/frontend/app/(main)/admin/settings/components/openflare-ops-utils.ts @@ -0,0 +1,183 @@ +import type {OptionItem} from "@/lib/services/openflare" + +export type OpenFlareOpsFields = { + AgentHeartbeatInterval: string + AgentWebsocketUpgradeEnabled: boolean + NodeOfflineThreshold: string + AgentUpdateRepo: string + GeoIPProvider: string + ServerAddress: string + UptimeKumaEnabled: boolean + UptimeKumaUrl: string + UptimeKumaUsername: string + UptimeKumaPassword: string + UptimeKumaMonitorScope: string + UptimeKumaSelectedSites: string + UptimeKumaSyncInterval: string + UptimeKumaInterval: string + UptimeKumaRetry: string + UptimeKumaRetryInterval: string + UptimeKumaTimeout: string + DatabaseAutoCleanupEnabled: boolean + DatabaseAutoCleanupRetentionDays: string +} + +export const defaultOpenFlareOpsFields: OpenFlareOpsFields = { + AgentHeartbeatInterval: "10000", + AgentWebsocketUpgradeEnabled: true, + NodeOfflineThreshold: "120000", + AgentUpdateRepo: "Rain-kl/OpenFlare", + GeoIPProvider: "ipinfo", + ServerAddress: "", + UptimeKumaEnabled: false, + UptimeKumaUrl: "", + UptimeKumaUsername: "", + UptimeKumaPassword: "", + UptimeKumaMonitorScope: "all", + UptimeKumaSelectedSites: "", + UptimeKumaSyncInterval: "5", + UptimeKumaInterval: "60", + UptimeKumaRetry: "0", + UptimeKumaRetryInterval: "60", + UptimeKumaTimeout: "48", + DatabaseAutoCleanupEnabled: false, + DatabaseAutoCleanupRetentionDays: "30", +} + +export const INSTALLER_SCRIPT_URL = + "https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/install-agent.sh" + +export function optionsToMap(options: OptionItem[]) { + 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" +} + +export function mapOptionsToOpsFields( + optionMap: Record, + serverAddress = "", +): OpenFlareOpsFields { + return { + AgentHeartbeatInterval: optionMap.AgentHeartbeatInterval ?? "10000", + AgentWebsocketUpgradeEnabled: toBoolean(optionMap.AgentWebsocketUpgradeEnabled, true), + NodeOfflineThreshold: optionMap.NodeOfflineThreshold ?? "120000", + AgentUpdateRepo: optionMap.AgentUpdateRepo ?? "Rain-kl/OpenFlare", + GeoIPProvider: optionMap.GeoIPProvider ?? "ipinfo", + ServerAddress: optionMap.ServerAddress || serverAddress, + UptimeKumaEnabled: toBoolean(optionMap.UptimeKumaEnabled, false), + UptimeKumaUrl: optionMap.UptimeKumaUrl ?? "", + UptimeKumaUsername: optionMap.UptimeKumaUsername ?? "", + UptimeKumaPassword: "", + UptimeKumaMonitorScope: optionMap.UptimeKumaMonitorScope ?? "all", + UptimeKumaSelectedSites: optionMap.UptimeKumaSelectedSites ?? "", + UptimeKumaSyncInterval: optionMap.UptimeKumaSyncInterval ?? "5", + UptimeKumaInterval: optionMap.UptimeKumaInterval ?? "60", + UptimeKumaRetry: optionMap.UptimeKumaRetry ?? "0", + UptimeKumaRetryInterval: optionMap.UptimeKumaRetryInterval ?? "60", + UptimeKumaTimeout: optionMap.UptimeKumaTimeout ?? "48", + DatabaseAutoCleanupEnabled: toBoolean(optionMap.DatabaseAutoCleanupEnabled, false), + DatabaseAutoCleanupRetentionDays: optionMap.DatabaseAutoCleanupRetentionDays ?? "30", + } +} + +export 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} 秒` +} + +export function normalizeServerUrl(value: string) { + return value.trim().replace(/\/+$/, "") +} + +export function getBrowserOrigin() { + if (typeof window === "undefined") return "" + return normalizeServerUrl(window.location.origin) +} + +export function buildDiscoveryCommand(serverUrl: string, discoveryToken: string) { + return [ + `curl -fsSL ${INSTALLER_SCRIPT_URL} | bash -s -- \\`, + ` --server-url ${normalizeServerUrl(serverUrl)} \\`, + ` --discovery-token ${discoveryToken}`, + ].join("\n") +} + +export function validateAgentFields(fields: OpenFlareOpsFields) { + const heartbeat = Number.parseInt(fields.AgentHeartbeatInterval, 10) + const offline = Number.parseInt(fields.NodeOfflineThreshold, 10) + if (Number.isNaN(heartbeat) || heartbeat < 5000) { + throw new Error("心跳间隔不能小于 5000 毫秒。") + } + if (Number.isNaN(offline) || offline < 10000) { + throw new Error("离线阈值不能小于 10000 毫秒。") + } +} + +export function validateUptimeKumaFields(fields: OpenFlareOpsFields) { + const syncInt = Number.parseInt(fields.UptimeKumaSyncInterval, 10) + const interval = Number.parseInt(fields.UptimeKumaInterval, 10) + const retry = Number.parseInt(fields.UptimeKumaRetry, 10) + const retryInt = Number.parseInt(fields.UptimeKumaRetryInterval, 10) + const timeout = Number.parseInt(fields.UptimeKumaTimeout, 10) + + if (fields.UptimeKumaEnabled) { + if (!fields.UptimeKumaUrl.trim()) throw new Error("请输入 Uptime Kuma 地址。") + if (!fields.UptimeKumaUsername.trim()) throw new Error("请输入 Uptime Kuma 用户名。") + } + if (Number.isNaN(syncInt) || syncInt <= 0) throw new Error("同步间隔必须为正整数。") + if (Number.isNaN(interval) || interval <= 0) throw new Error("心跳间隔必须为正整数。") + if (Number.isNaN(retry) || retry < 0) throw new Error("重试次数必须为非负整数。") + if (Number.isNaN(retryInt) || retryInt <= 0) throw new Error("心跳重试间隔必须为正整数。") + if (Number.isNaN(timeout) || timeout <= 0) throw new Error("请求超时必须为正整数。") +} + +export function validateDatabaseAutoCleanup(fields: OpenFlareOpsFields) { + const retentionDays = Number.parseInt(fields.DatabaseAutoCleanupRetentionDays, 10) + if (Number.isNaN(retentionDays) || retentionDays < 1) { + throw new Error("自动清理保留天数至少为 1 天。") + } +} + +export function agentOptionEntries(fields: OpenFlareOpsFields): OptionItem[] { + validateAgentFields(fields) + return [ + { key: "AgentHeartbeatInterval", value: fields.AgentHeartbeatInterval }, + { key: "AgentWebsocketUpgradeEnabled", value: String(fields.AgentWebsocketUpgradeEnabled) }, + { key: "NodeOfflineThreshold", value: fields.NodeOfflineThreshold }, + { key: "AgentUpdateRepo", value: fields.AgentUpdateRepo.trim() }, + { key: "GeoIPProvider", value: fields.GeoIPProvider }, + ] +} + +export function uptimeKumaOptionEntries(fields: OpenFlareOpsFields): OptionItem[] { + validateUptimeKumaFields(fields) + return [ + { key: "UptimeKumaEnabled", value: String(fields.UptimeKumaEnabled) }, + { key: "UptimeKumaUrl", value: fields.UptimeKumaUrl.trim() }, + { key: "UptimeKumaUsername", value: fields.UptimeKumaUsername.trim() }, + { key: "UptimeKumaPassword", value: fields.UptimeKumaPassword }, + { key: "UptimeKumaMonitorScope", value: fields.UptimeKumaMonitorScope }, + { key: "UptimeKumaSelectedSites", value: fields.UptimeKumaSelectedSites }, + { key: "UptimeKumaSyncInterval", value: fields.UptimeKumaSyncInterval }, + { key: "UptimeKumaInterval", value: fields.UptimeKumaInterval }, + { key: "UptimeKumaRetry", value: fields.UptimeKumaRetry }, + { key: "UptimeKumaRetryInterval", value: fields.UptimeKumaRetryInterval }, + { key: "UptimeKumaTimeout", value: fields.UptimeKumaTimeout }, + ] +} + +export function databaseAutoCleanupEntries(fields: OpenFlareOpsFields): OptionItem[] { + validateDatabaseAutoCleanup(fields) + return [ + { key: "DatabaseAutoCleanupEnabled", value: String(fields.DatabaseAutoCleanupEnabled) }, + { key: "DatabaseAutoCleanupRetentionDays", value: fields.DatabaseAutoCleanupRetentionDays }, + ] +} diff --git a/Wavelet/frontend/app/(main)/admin/settings/components/openflare-ops.tsx b/Wavelet/frontend/app/(main)/admin/settings/components/openflare-ops.tsx new file mode 100644 index 00000000..6dbb9eaa --- /dev/null +++ b/Wavelet/frontend/app/(main)/admin/settings/components/openflare-ops.tsx @@ -0,0 +1,757 @@ +"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 {NodeService, OptionService, StatusService, UpdateService, 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 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 optionsQuery = useQuery({ + queryKey: optionsQueryKey, + queryFn: () => OptionService.list(), + }) + + const statusQuery = useQuery({ + queryKey: ["openflare", "public-status"], + queryFn: () => StatusService.getPublicStatus(), + }) + + const bootstrapQuery = useQuery({ + queryKey: ["openflare", "bootstrap-token"], + queryFn: () => NodeService.getBootstrapToken(), + }) + + const releaseQuery = useQuery({ + queryKey: ["openflare", "latest-release"], + queryFn: () => UpdateService.getLatestRelease("stable"), + }) + + 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 || "未生成"} +

+
+
+ +