From 31b114b0ccdf9ffdbeda0758821976c21fe5face Mon Sep 17 00:00:00 2001 From: ryan Date: Thu, 18 Jun 2026 17:43:11 +0800 Subject: [PATCH] migrate frontend --- .../components/openflare-ops-utils.ts | 183 +++++ .../settings/components/openflare-ops.tsx | 757 ++++++++++++++++++ .../components/uptimekuma-site-modal.tsx | 181 +++++ .../app/(main)/admin/settings/page-client.tsx | 11 + .../components/access-log-filters.tsx | 124 +++ .../components/access-log-utils.ts | 48 ++ .../access-logs/components/cleanup-dialog.tsx | 115 +++ .../app/(main)/openflare/access-logs/page.tsx | 567 ++++++++++++- .../openflare/apply-logs/page-client.tsx | 324 ++++++++ .../app/(main)/openflare/apply-logs/page.tsx | 336 +------- .../dashboard/capacity-trend-chart.tsx | 129 +++ .../components/dashboard/dashboard-utils.ts | 21 + .../dashboard/geo-distribution-list.tsx | 54 ++ .../dashboard/node-health-table.tsx | 132 +++ .../components/dashboard/stat-cards.tsx | 93 +++ .../dashboard/traffic-trend-chart.tsx | 122 +++ .../nodes/components/install-command.tsx | 173 ++++ .../nodes/components/node-observability.tsx | 385 +++++++++ .../openflare/nodes/components/node-utils.ts | 132 ++- .../nodes/components/relay-node-detail.tsx | 394 +++++++++ .../nodes/components/tunnel-node-detail.tsx | 369 +++++++++ .../openflare/nodes/detail/page-client.tsx | 96 +++ .../(main)/openflare/nodes/detail/page.tsx | 116 +-- .../(main)/openflare/nodes/page-client.tsx | 208 +++++ .../app/(main)/openflare/nodes/page.tsx | 214 +---- .../components/origin-editor-dialog.tsx | 167 ++++ .../openflare/origins/detail/page-client.tsx | 226 ++++++ .../(main)/openflare/origins/detail/page.tsx | 23 + .../app/(main)/openflare/origins/page.tsx | 209 ++++- .../frontend/app/(main)/openflare/page.tsx | 94 ++- .../components/deployment-upload-dialog.tsx | 177 ++++ .../openflare/pages/components/pages-utils.ts | 22 + .../components/project-editor-dialog.tsx | 278 +++++++ .../pages/components/project-list-item.tsx | 61 ++ .../openflare/pages/detail/page-client.tsx | 453 +++++++++++ .../(main)/openflare/pages/detail/page.tsx | 23 + .../app/(main)/openflare/pages/page.tsx | 84 +- .../components/performance-utils.ts | 269 +++++++ .../app/(main)/openflare/performance/page.tsx | 399 ++++++++- .../proxy-routes/components/helpers.ts | 239 +++++- .../detail/components/auth-section.tsx | 340 +++++++- .../detail/components/cache-section.tsx | 202 ++++- .../detail/components/domain-list-input.tsx | 262 ++++++ .../detail/components/domain-section.tsx | 241 +++++- .../detail/components/limits-section.tsx | 154 +++- .../detail/components/proxy-section.tsx | 441 +++++++++- .../detail/components/route-header.tsx | 199 ++++- .../detail/components/section-shell.tsx | 37 + .../detail/components/waf-section.tsx | 151 +++- .../openflare/proxy-routes/detail/helpers.ts | 92 +++ .../detail/hooks/use-route-section-save.ts | 40 + .../proxy-routes/detail/page-client.tsx | 27 +- .../openflare/waf/components/helpers.ts | 268 +++++++ .../waf/components/ip-group-dialog.tsx | 453 +++++++++++ .../waf/components/ip-group-test-dialog.tsx | 70 ++ .../waf/components/ip-groups-table.tsx | 120 +++ .../waf/components/pow-config-panel.tsx | 43 + .../waf/components/rule-entry-dialog.tsx | 283 +++++++ .../waf/components/rule-group-dialog.tsx | 404 ++++++++++ .../waf/components/rule-groups-table.tsx | 114 +++ .../waf/components/rule-list-section.tsx | 78 ++ .../waf/components/site-binding-sheet.tsx | 135 ++++ .../(main)/openflare/waf/ip-groups/page.tsx | 269 +++++++ .../app/(main)/openflare/waf/page.tsx | 260 +++++- .../openflare/websites/certificates/page.tsx | 334 ++++++++ .../components/certificate-apply-dialog.tsx | 314 ++++++++ .../components/certificate-detail-dialog.tsx | 189 +++++ .../components/certificate-editor-dialog.tsx | 182 +++++ .../components/certificate-import-dialog.tsx | 264 ++++++ .../components/dns-account-create-dialog.tsx | 148 ++++ .../openflare/websites/components/schemas.ts | 99 +++ .../websites/components/status-badge.tsx | 30 + .../components/website-editor-dialog.tsx | 250 ++++++ .../websites/components/website-utils.ts | 119 +++ .../openflare/websites/detail/page-client.tsx | 527 ++++++++++++ .../(main)/openflare/websites/detail/page.tsx | 23 + .../openflare/websites/dns-accounts/page.tsx | 165 ++++ .../app/(main)/openflare/websites/page.tsx | 218 ++++- .../frontend/lib/navigation/openflare-nav.ts | 34 +- Wavelet/frontend/lib/services/index.ts | 60 +- .../services/openflare/access-log.service.ts | 76 ++ .../services/openflare/dashboard.service.ts | 242 ++++++ .../services/openflare/dns-account.service.ts | 27 + .../frontend/lib/services/openflare/index.ts | 94 +++ .../lib/services/openflare/node.service.ts | 8 + .../lib/services/openflare/option.service.ts | 35 + .../lib/services/openflare/origin.service.ts | 29 + .../lib/services/openflare/pages.service.ts | 113 +++ .../lib/services/openflare/status.service.ts | 10 + .../openflare/tls-certificate.service.ts | 91 +++ .../frontend/lib/services/openflare/types.ts | 695 ++++++++++++++++ .../lib/services/openflare/update.service.ts | 16 + .../services/openflare/uptimekuma.service.ts | 9 + .../lib/services/openflare/waf.service.ts | 85 ++ .../lib/services/openflare/website.service.ts | 31 + Wavelet/frontend/package.json | 2 +- docs/changelog/index.md | 3 + ...18-openflare-wavelet-frontend-migration.md | 2 +- .../handover-openflare-frontend-migration.md | 126 +-- docs/plan/index.md | 3 +- docs/plan/verify-frontend-build.md | 151 ++++ docs/plan/verify-frontend-routes.md | 39 + docs/plan/verify-frontend-services.md | 42 + docs/plan/verify-frontend-ui.md | 33 + 104 files changed, 16735 insertions(+), 874 deletions(-) create mode 100644 Wavelet/frontend/app/(main)/admin/settings/components/openflare-ops-utils.ts create mode 100644 Wavelet/frontend/app/(main)/admin/settings/components/openflare-ops.tsx create mode 100644 Wavelet/frontend/app/(main)/admin/settings/components/uptimekuma-site-modal.tsx create mode 100644 Wavelet/frontend/app/(main)/openflare/access-logs/components/access-log-filters.tsx create mode 100644 Wavelet/frontend/app/(main)/openflare/access-logs/components/access-log-utils.ts create mode 100644 Wavelet/frontend/app/(main)/openflare/access-logs/components/cleanup-dialog.tsx create mode 100644 Wavelet/frontend/app/(main)/openflare/apply-logs/page-client.tsx create mode 100644 Wavelet/frontend/app/(main)/openflare/components/dashboard/capacity-trend-chart.tsx create mode 100644 Wavelet/frontend/app/(main)/openflare/components/dashboard/dashboard-utils.ts create mode 100644 Wavelet/frontend/app/(main)/openflare/components/dashboard/geo-distribution-list.tsx create mode 100644 Wavelet/frontend/app/(main)/openflare/components/dashboard/node-health-table.tsx create mode 100644 Wavelet/frontend/app/(main)/openflare/components/dashboard/stat-cards.tsx create mode 100644 Wavelet/frontend/app/(main)/openflare/components/dashboard/traffic-trend-chart.tsx create mode 100644 Wavelet/frontend/app/(main)/openflare/nodes/components/install-command.tsx create mode 100644 Wavelet/frontend/app/(main)/openflare/nodes/components/node-observability.tsx create mode 100644 Wavelet/frontend/app/(main)/openflare/nodes/components/relay-node-detail.tsx create mode 100644 Wavelet/frontend/app/(main)/openflare/nodes/components/tunnel-node-detail.tsx create mode 100644 Wavelet/frontend/app/(main)/openflare/nodes/detail/page-client.tsx create mode 100644 Wavelet/frontend/app/(main)/openflare/nodes/page-client.tsx create mode 100644 Wavelet/frontend/app/(main)/openflare/origins/components/origin-editor-dialog.tsx create mode 100644 Wavelet/frontend/app/(main)/openflare/origins/detail/page-client.tsx create mode 100644 Wavelet/frontend/app/(main)/openflare/origins/detail/page.tsx create mode 100644 Wavelet/frontend/app/(main)/openflare/pages/components/deployment-upload-dialog.tsx create mode 100644 Wavelet/frontend/app/(main)/openflare/pages/components/pages-utils.ts create mode 100644 Wavelet/frontend/app/(main)/openflare/pages/components/project-editor-dialog.tsx create mode 100644 Wavelet/frontend/app/(main)/openflare/pages/components/project-list-item.tsx create mode 100644 Wavelet/frontend/app/(main)/openflare/pages/detail/page-client.tsx create mode 100644 Wavelet/frontend/app/(main)/openflare/pages/detail/page.tsx create mode 100644 Wavelet/frontend/app/(main)/openflare/performance/components/performance-utils.ts create mode 100644 Wavelet/frontend/app/(main)/openflare/proxy-routes/detail/components/domain-list-input.tsx create mode 100644 Wavelet/frontend/app/(main)/openflare/proxy-routes/detail/components/section-shell.tsx create mode 100644 Wavelet/frontend/app/(main)/openflare/proxy-routes/detail/helpers.ts create mode 100644 Wavelet/frontend/app/(main)/openflare/proxy-routes/detail/hooks/use-route-section-save.ts create mode 100644 Wavelet/frontend/app/(main)/openflare/waf/components/helpers.ts create mode 100644 Wavelet/frontend/app/(main)/openflare/waf/components/ip-group-dialog.tsx create mode 100644 Wavelet/frontend/app/(main)/openflare/waf/components/ip-group-test-dialog.tsx create mode 100644 Wavelet/frontend/app/(main)/openflare/waf/components/ip-groups-table.tsx create mode 100644 Wavelet/frontend/app/(main)/openflare/waf/components/pow-config-panel.tsx create mode 100644 Wavelet/frontend/app/(main)/openflare/waf/components/rule-entry-dialog.tsx create mode 100644 Wavelet/frontend/app/(main)/openflare/waf/components/rule-group-dialog.tsx create mode 100644 Wavelet/frontend/app/(main)/openflare/waf/components/rule-groups-table.tsx create mode 100644 Wavelet/frontend/app/(main)/openflare/waf/components/rule-list-section.tsx create mode 100644 Wavelet/frontend/app/(main)/openflare/waf/components/site-binding-sheet.tsx create mode 100644 Wavelet/frontend/app/(main)/openflare/waf/ip-groups/page.tsx create mode 100644 Wavelet/frontend/app/(main)/openflare/websites/certificates/page.tsx create mode 100644 Wavelet/frontend/app/(main)/openflare/websites/components/certificate-apply-dialog.tsx create mode 100644 Wavelet/frontend/app/(main)/openflare/websites/components/certificate-detail-dialog.tsx create mode 100644 Wavelet/frontend/app/(main)/openflare/websites/components/certificate-editor-dialog.tsx create mode 100644 Wavelet/frontend/app/(main)/openflare/websites/components/certificate-import-dialog.tsx create mode 100644 Wavelet/frontend/app/(main)/openflare/websites/components/dns-account-create-dialog.tsx create mode 100644 Wavelet/frontend/app/(main)/openflare/websites/components/schemas.ts create mode 100644 Wavelet/frontend/app/(main)/openflare/websites/components/status-badge.tsx create mode 100644 Wavelet/frontend/app/(main)/openflare/websites/components/website-editor-dialog.tsx create mode 100644 Wavelet/frontend/app/(main)/openflare/websites/components/website-utils.ts create mode 100644 Wavelet/frontend/app/(main)/openflare/websites/detail/page-client.tsx create mode 100644 Wavelet/frontend/app/(main)/openflare/websites/detail/page.tsx create mode 100644 Wavelet/frontend/app/(main)/openflare/websites/dns-accounts/page.tsx create mode 100644 Wavelet/frontend/lib/services/openflare/access-log.service.ts create mode 100644 Wavelet/frontend/lib/services/openflare/dashboard.service.ts create mode 100644 Wavelet/frontend/lib/services/openflare/dns-account.service.ts create mode 100644 Wavelet/frontend/lib/services/openflare/option.service.ts create mode 100644 Wavelet/frontend/lib/services/openflare/origin.service.ts create mode 100644 Wavelet/frontend/lib/services/openflare/pages.service.ts create mode 100644 Wavelet/frontend/lib/services/openflare/status.service.ts create mode 100644 Wavelet/frontend/lib/services/openflare/tls-certificate.service.ts create mode 100644 Wavelet/frontend/lib/services/openflare/update.service.ts create mode 100644 Wavelet/frontend/lib/services/openflare/uptimekuma.service.ts create mode 100644 Wavelet/frontend/lib/services/openflare/waf.service.ts create mode 100644 Wavelet/frontend/lib/services/openflare/website.service.ts create mode 100644 docs/plan/verify-frontend-build.md create mode 100644 docs/plan/verify-frontend-routes.md create mode 100644 docs/plan/verify-frontend-services.md create mode 100644 docs/plan/verify-frontend-ui.md 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 || "未生成"} +

+
+
+ +