diff --git a/Wavelet/frontend/app/(main)/home/page.tsx b/Wavelet/frontend/app/(main)/home/page.tsx index c61c8796..d63ce04f 100644 --- a/Wavelet/frontend/app/(main)/home/page.tsx +++ b/Wavelet/frontend/app/(main)/home/page.tsx @@ -1,5 +1,5 @@ -import {HomeMain} from "@/components/common/home/home-main" +import {redirect} from "next/navigation" export default function HomePage() { - return + redirect("/openflare") } diff --git a/Wavelet/frontend/app/(main)/openflare/access-logs/page.tsx b/Wavelet/frontend/app/(main)/openflare/access-logs/page.tsx new file mode 100644 index 00000000..3eeb69ff --- /dev/null +++ b/Wavelet/frontend/app/(main)/openflare/access-logs/page.tsx @@ -0,0 +1,7 @@ +import {ScrollText} from 'lucide-react'; + +import {PlaceholderPage} from '../components/placeholder-page'; + +export default function AccessLogsPage() { + return ; +} \ No newline at end of file diff --git a/Wavelet/frontend/app/(main)/openflare/apply-logs/components/log-detail-sheet.tsx b/Wavelet/frontend/app/(main)/openflare/apply-logs/components/log-detail-sheet.tsx new file mode 100644 index 00000000..e78a2743 --- /dev/null +++ b/Wavelet/frontend/app/(main)/openflare/apply-logs/components/log-detail-sheet.tsx @@ -0,0 +1,108 @@ +"use client" + +import {Badge} from "@/components/ui/badge" +import {Button} from "@/components/ui/button" +import {Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle,} from "@/components/ui/sheet" +import type {ApplyLogItem} from "@/lib/services/openflare" +import {formatDateTime} from "@/lib/utils" + +interface LogDetailSheetProps { + log: ApplyLogItem | null + open: boolean + onOpenChange: (open: boolean) => void +} + +function getResultBadge(result: string) { + if (result === "success") { + return ( + + + 成功 + + ) + } + if (result === "warning") { + return ( + + + 警告 + + ) + } + return ( + + + 失败 + + ) +} + +export function LogDetailSheet({ log, open, onOpenChange }: LogDetailSheetProps) { + return ( + + + + 应用日志详情 + 查看单条应用日志的完整结果与校验信息。 + + + {log ? ( +
+
+ {getResultBadge(log.result)} + + Node: {log.node_id} + + + 版本: {log.version} + +
+ +
+
+

创建时间

+

{formatDateTime(log.created_at)}

+
+
+

目标 Checksum

+

{log.checksum || "—"}

+
+
+

主配置摘要

+

{log.main_config_checksum || "—"}

+
+
+

路由配置摘要

+

{log.route_config_checksum || "—"}

+
+
+

支持文件数

+

{log.support_file_count}

+
+
+ +
+

消息

+
{log.message || "—"}
+
+
+ ) : null} + + + + +
+
+ ) +} diff --git a/Wavelet/frontend/app/(main)/openflare/apply-logs/page.tsx b/Wavelet/frontend/app/(main)/openflare/apply-logs/page.tsx new file mode 100644 index 00000000..3649801e --- /dev/null +++ b/Wavelet/frontend/app/(main)/openflare/apply-logs/page.tsx @@ -0,0 +1,324 @@ +"use client" + +import {useCallback, useEffect, useMemo, useState} from "react" +import Link from "next/link" +import {useSearchParams} from "next/navigation" +import {ClipboardList, Eye, RefreshCw, Search} from "lucide-react" + +import {EmptyStateWithBorder} from "@/components/layout/empty" +import {ErrorInline} from "@/components/layout/error" +import {LoadingStateWithBorder} from "@/components/layout/loading" +import {Badge} from "@/components/ui/badge" +import {Button} from "@/components/ui/button" +import {Input} from "@/components/ui/input" +import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue,} from "@/components/ui/select" +import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow,} from "@/components/ui/table" +import {type ApplyLogItem, ApplyLogService,} from "@/lib/services/openflare" +import {formatDateTime} from "@/lib/utils" + +import {LogDetailSheet} from "./components/log-detail-sheet" + +const PAGE_SIZE_OPTIONS = [20, 50, 100] + +function truncateHash(value: string) { + if (!value) return "—" + return value.length > 12 ? `${value.slice(0, 12)}...` : value +} + +function getResultBadge(result: string) { + if (result === "success") { + return ( + + + 成功 + + ) + } + if (result === "warning") { + return ( + + + 警告 + + ) + } + return ( + + + 失败 + + ) +} + +export default function ApplyLogsPage() { + const searchParams = useSearchParams() + const initialNodeId = searchParams.get("node_id")?.trim() ?? "" + + const [nodeFilterInput, setNodeFilterInput] = useState(initialNodeId) + const [nodeFilter, setNodeFilter] = useState(initialNodeId) + const [pageNo, setPageNo] = useState(1) + const [pageSize, setPageSize] = useState(20) + + const [rows, setRows] = useState([]) + const [current, setCurrent] = useState(1) + const [total, setTotal] = useState(0) + const [totalPage, setTotalPage] = useState(0) + + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + const [selectedLog, setSelectedLog] = useState(null) + const [detailOpen, setDetailOpen] = useState(false) + + const summary = useMemo(() => { + const nodeIds = new Set(rows.map((item) => item.node_id)) + return [ + { label: "总记录数", value: total }, + { label: "当前页", value: current }, + { label: "总页数", value: totalPage }, + { label: "当前页节点数", value: nodeIds.size }, + ] + }, [rows, total, current, totalPage]) + + const fetchLogs = useCallback(async () => { + setLoading(true) + setError(null) + try { + const data = await ApplyLogService.list({ + node_id: nodeFilter || undefined, + pageNo, + pageSize, + }) + setRows(data.rows) + setCurrent(data.current) + setTotal(data.total) + setTotalPage(data.totalPage) + } catch (err) { + setError(err instanceof Error ? err.message : "加载失败") + } finally { + setLoading(false) + } + }, [nodeFilter, pageNo, pageSize]) + + useEffect(() => { + void fetchLogs() + }, [fetchLogs]) + + useEffect(() => { + const nodeId = searchParams.get("node_id")?.trim() + if (!nodeId) return + setNodeFilterInput(nodeId) + setNodeFilter(nodeId) + setPageNo(1) + }, [searchParams]) + + const handleSearch = () => { + setPageNo(1) + setNodeFilter(nodeFilterInput.trim()) + } + + const handleReset = () => { + setNodeFilterInput("") + setNodeFilter("") + setPageNo(1) + } + + return ( +
+
+
+ +
+

应用日志

+

+ 查看节点应用配置的成功、警告和失败记录,支持按 node_id 过滤。 +

+
+
+ +
+ + +
+
+ +
+ {summary.map((item) => ( +
+

+ {item.label} +

+

{item.value}

+
+ ))} +
+ +
+
+
+

Node ID

+
+ + setNodeFilterInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") handleSearch() + }} + placeholder="输入 node_id 过滤应用日志" + className="pl-8 h-9 text-xs" + /> +
+
+
+

每页条数

+ +
+
+
+ + +
+
+ + {error ? void fetchLogs()} /> : null} + +
+ {loading ? ( + + ) : rows.length === 0 ? ( + + ) : ( + + + + Node ID + 版本 + 结果 + Checksum + 时间 + 消息 + 操作 + + + + {rows.map((log) => ( + + {log.node_id} + {log.version} + {getResultBadge(log.result)} + + {truncateHash(log.checksum)} + + + {formatDateTime(log.created_at)} + + +
{log.message || "—"}
+
+ + + +
+ ))} +
+
+ )} +
+ + {!loading && rows.length > 0 ? ( +
+

+ 第 {current} / {Math.max(totalPage, 1)} 页,共 {total} 条记录。 +

+
+ + +
+
+ ) : null} + + +
+ ) +} diff --git a/Wavelet/frontend/app/(main)/openflare/components/placeholder-page.tsx b/Wavelet/frontend/app/(main)/openflare/components/placeholder-page.tsx new file mode 100644 index 00000000..0e091056 --- /dev/null +++ b/Wavelet/frontend/app/(main)/openflare/components/placeholder-page.tsx @@ -0,0 +1,26 @@ +import type {LucideIcon} from 'lucide-react'; + +import {Card, CardDescription, CardHeader, CardTitle} from '@/components/ui/card'; + +interface PlaceholderPageProps { + title: string; + icon: LucideIcon; +} + +export function PlaceholderPage({title, icon: Icon}: PlaceholderPageProps) { + return ( +
+
+ +

{title}

+
+ + + + 开发中 + 该功能正在迁移开发中,敬请期待。 + + +
+ ); +} \ No newline at end of file diff --git a/Wavelet/frontend/app/(main)/openflare/config-versions/components/cleanup-dialog.tsx b/Wavelet/frontend/app/(main)/openflare/config-versions/components/cleanup-dialog.tsx new file mode 100644 index 00000000..ccaef7a0 --- /dev/null +++ b/Wavelet/frontend/app/(main)/openflare/config-versions/components/cleanup-dialog.tsx @@ -0,0 +1,84 @@ +"use client" + +import {useState} from "react" +import {Loader2} from "lucide-react" + +import { + AlertDialog, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog" +import {Button} from "@/components/ui/button" +import {Input} from "@/components/ui/input" +import {Label} from "@/components/ui/label" + +interface CleanupDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + onConfirm: (keepCount: number) => void + loading: boolean +} + +export function CleanupDialog({ + open, + onOpenChange, + onConfirm, + loading, +}: CleanupDialogProps) { + const [keepCount, setKeepCount] = useState(10) + const [error, setError] = useState(null) + + const handleConfirm = () => { + if (keepCount < 3) { + setError("最少保留 3 个历史快照") + return + } + setError(null) + onConfirm(keepCount) + } + + return ( + + + + 清理历史快照 + + 删除旧的历史快照配置,系统将始终保护当前已激活的版本不被删除。 + + + +
+ + setKeepCount(Number.parseInt(e.target.value, 10) || 3)} + disabled={loading} + /> +

默认为 10 个,最少需保留 3 个。

+ {error ?

{error}

: null} +
+ + + 取消 + + +
+
+ ) +} diff --git a/Wavelet/frontend/app/(main)/openflare/config-versions/components/diff-dialog.tsx b/Wavelet/frontend/app/(main)/openflare/config-versions/components/diff-dialog.tsx new file mode 100644 index 00000000..889bedd6 --- /dev/null +++ b/Wavelet/frontend/app/(main)/openflare/config-versions/components/diff-dialog.tsx @@ -0,0 +1,142 @@ +"use client" + +import {Loader2} from "lucide-react" + +import {Badge} from "@/components/ui/badge" +import {Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle,} from "@/components/ui/dialog" +import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow,} from "@/components/ui/table" +import type {ConfigDiffResult} from "@/lib/services/openflare" + +interface DiffDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + diff: ConfigDiffResult | null + loading: boolean + error: string | null +} + +function DiffChipList({ title, items }: { title: string; items: string[] }) { + return ( +
+
+

{title}

+ + {items.length} 项 + +
+ {items.length > 0 ? ( +
+ {items.map((item) => ( + + {item} + + ))} +
+ ) : ( +

无变更

+ )} +
+ ) +} + +function renderOptionValue(value: string) { + return value === "" ? "空" : value +} + +export function DiffDialog({ + open, + onOpenChange, + diff, + loading, + error, +}: DiffDialogProps) { + return ( + + + + 配置差异 + + 对比当前待发布配置与已激活版本之间的差异。 + + + + {loading ? ( +
+ + 正在加载差异数据... +
+ ) : error ? ( +

{error}

+ ) : diff ? ( +
+
+
+

激活版本

+

{diff.active_version || "无"}

+
+
+

新增域名

+

{diff.added_domains.length}

+
+
+

删除域名

+

{diff.removed_domains.length}

+
+
+

修改域名

+

{diff.modified_domains.length}

+
+
+ +
+ + + +
+ +
+ + 主配置 {diff.main_config_changed ? "已变化" : "无变化"} + + + WAF {diff.waf_config_changed ? "已变化" : "无变化"} + + + 网站数 {diff.active_website_count} → {diff.current_website_count} + +
+ + {diff.changed_option_details.length > 0 ? ( +
+ + + + 参数 + 激活值 + 待发布值 + + + + {diff.changed_option_details.map((item) => ( + + {item.key} + + {renderOptionValue(item.previous_value)} + + + {renderOptionValue(item.current_value)} + + + ))} + +
+
+ ) : ( +

当前无 OpenResty 参数变化。

+ )} +
+ ) : null} +
+
+ ) +} diff --git a/Wavelet/frontend/app/(main)/openflare/config-versions/components/preview-sheet.tsx b/Wavelet/frontend/app/(main)/openflare/config-versions/components/preview-sheet.tsx new file mode 100644 index 00000000..a6dc67a5 --- /dev/null +++ b/Wavelet/frontend/app/(main)/openflare/config-versions/components/preview-sheet.tsx @@ -0,0 +1,119 @@ +"use client" + +import {Loader2} from "lucide-react" + +import {Badge} from "@/components/ui/badge" +import {Button} from "@/components/ui/button" +import {CodeBlock} from "@/components/ui/code-block" +import {ScrollArea} from "@/components/ui/scroll-area" +import {Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle,} from "@/components/ui/sheet" +import type {ConfigPreviewResult} from "@/lib/services/openflare" + +interface PreviewSheetProps { + open: boolean + onOpenChange: (open: boolean) => void + preview: ConfigPreviewResult | null + loading: boolean + error: string | null + publishing: boolean + canPublish: boolean + onPublish: () => void +} + +export function PreviewSheet({ + open, + onOpenChange, + preview, + loading, + error, + publishing, + canPublish, + onPublish, +}: PreviewSheetProps) { + return ( + + + + 发布预览 + + 查看待发布配置的渲染结果与支持文件。 + + + + + {loading ? ( +
+ + 正在加载预览... +
+ ) : error ? ( +

{error}

+ ) : preview ? ( +
+
+ + 规则 {preview.route_count} 条 + + + 网站 {preview.website_count} 个 + + + {preview.checksum.slice(0, 16)}... + +
+ +
+

主配置

+ +
+ +
+

路由配置

+ +
+ + {preview.support_files.length > 0 ? ( +
+

支持文件 ({preview.support_files.length})

+ {preview.support_files.map((file) => ( +
+ {file.path} + +
+ ))} +
+ ) : ( +

当前发布不需要额外支持文件。

+ )} + + {!canPublish ? ( +

+ 当前规则与已激活版本一致,无法重复发布。 +

+ ) : null} +
+ ) : null} +
+ + + + + +
+
+ ) +} diff --git a/Wavelet/frontend/app/(main)/openflare/config-versions/components/version-snapshot-sheet.tsx b/Wavelet/frontend/app/(main)/openflare/config-versions/components/version-snapshot-sheet.tsx new file mode 100644 index 00000000..ecd70002 --- /dev/null +++ b/Wavelet/frontend/app/(main)/openflare/config-versions/components/version-snapshot-sheet.tsx @@ -0,0 +1,124 @@ +"use client" + +import {useEffect, useState} from "react" +import {Loader2} from "lucide-react" +import {toast} from "sonner" + +import {Badge} from "@/components/ui/badge" +import {Button} from "@/components/ui/button" +import {CodeBlock} from "@/components/ui/code-block" +import {ScrollArea} from "@/components/ui/scroll-area" +import {Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle,} from "@/components/ui/sheet" +import {type ConfigVersionDetail, ConfigVersionService, type ConfigVersionSummary,} from "@/lib/services/openflare" +import {formatDateTime} from "@/lib/utils" + +interface VersionSnapshotSheetProps { + version: ConfigVersionSummary | null + open: boolean + onOpenChange: (open: boolean) => void +} + +export function VersionSnapshotSheet({ + version, + open, + onOpenChange, +}: VersionSnapshotSheetProps) { + const [detail, setDetail] = useState(null) + const [loading, setLoading] = useState(false) + + useEffect(() => { + if (!open || !version) { + setDetail(null) + return + } + + let cancelled = false + setLoading(true) + + ConfigVersionService.getById(version.id) + .then((data) => { + if (!cancelled) setDetail(data) + }) + .catch((err) => { + if (!cancelled) { + toast.error("加载版本快照失败", { + description: err instanceof Error ? err.message : "未知错误", + }) + } + }) + .finally(() => { + if (!cancelled) setLoading(false) + }) + + return () => { + cancelled = true + } + }, [open, version]) + + return ( + + + + + {version ? `版本 ${version.version}` : "版本快照"} + + 查看历史配置版本的完整快照内容。 + + + + {loading ? ( +
+ + 正在加载快照... +
+ ) : detail ? ( +
+
+ {detail.is_active ? ( + 当前激活 + ) : ( + 历史版本 + )} + + {detail.checksum} + +
+ +
+
+

创建人

+

{detail.created_by || "系统"}

+
+
+

创建时间

+

{formatDateTime(detail.created_at)}

+
+
+ +
+

快照 JSON

+ +
+ +
+

主配置

+ +
+ +
+

路由配置

+ +
+
+ ) : null} +
+ + + + +
+
+ ) +} diff --git a/Wavelet/frontend/app/(main)/openflare/config-versions/page.tsx b/Wavelet/frontend/app/(main)/openflare/config-versions/page.tsx new file mode 100644 index 00000000..022fc379 --- /dev/null +++ b/Wavelet/frontend/app/(main)/openflare/config-versions/page.tsx @@ -0,0 +1,425 @@ +"use client" + +import {useCallback, useEffect, useMemo, useState} from "react" +import {Eye, GitCompare, History, Loader2, Play, RefreshCw, Trash2,} from "lucide-react" +import {toast} from "sonner" + +import {EmptyStateWithBorder} from "@/components/layout/empty" +import {ErrorInline} from "@/components/layout/error" +import {LoadingStateWithBorder} from "@/components/layout/loading" +import { + AlertDialog, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog" +import {Badge} from "@/components/ui/badge" +import {Button} from "@/components/ui/button" +import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow,} from "@/components/ui/table" +import { + type ConfigDiffResult, + type ConfigPreviewResult, + ConfigVersionService, + type ConfigVersionSummary, +} from "@/lib/services/openflare" +import {formatDateTime} from "@/lib/utils" + +import {CleanupDialog} from "./components/cleanup-dialog" +import {DiffDialog} from "./components/diff-dialog" +import {PreviewSheet} from "./components/preview-sheet" +import {VersionSnapshotSheet} from "./components/version-snapshot-sheet" + +function truncateChecksum(checksum: string) { + if (!checksum) return "—" + return checksum.length > 16 ? `${checksum.slice(0, 16)}...` : checksum +} + +function hasConfigDiff(diff: ConfigDiffResult) { + return ( + diff.added_domains.length > 0 || + diff.removed_domains.length > 0 || + diff.modified_domains.length > 0 || + diff.main_config_changed || + diff.changed_option_keys.length > 0 || + !diff.active_version + ) +} + +export default function ConfigVersionsPage() { + const [versions, setVersions] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + const [previewOpen, setPreviewOpen] = useState(false) + const [preview, setPreview] = useState(null) + const [previewLoading, setPreviewLoading] = useState(false) + const [previewError, setPreviewError] = useState(null) + + const [diffOpen, setDiffOpen] = useState(false) + const [diff, setDiff] = useState(null) + const [diffLoading, setDiffLoading] = useState(false) + const [diffError, setDiffError] = useState(null) + + const [snapshotVersion, setSnapshotVersion] = useState(null) + const [snapshotOpen, setSnapshotOpen] = useState(false) + + const [publishConfirmOpen, setPublishConfirmOpen] = useState(false) + const [forcePublishConfirmOpen, setForcePublishConfirmOpen] = useState(false) + const [activateTarget, setActivateTarget] = useState(null) + const [cleanupOpen, setCleanupOpen] = useState(false) + + const [publishing, setPublishing] = useState(false) + const [activating, setActivating] = useState(false) + const [cleaning, setCleaning] = useState(false) + + const canPublish = useMemo( + () => Boolean(preview && preview.route_count > 0 && diff && hasConfigDiff(diff)), + [preview, diff], + ) + + const fetchVersions = useCallback(async () => { + setLoading(true) + setError(null) + try { + const data = await ConfigVersionService.list() + setVersions(data) + } catch (err) { + setError(err instanceof Error ? err.message : "加载失败") + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + void fetchVersions() + }, [fetchVersions]) + + const loadPreviewData = async () => { + setPreviewLoading(true) + setPreviewError(null) + setDiffLoading(true) + setDiffError(null) + + try { + const [previewData, diffData] = await Promise.all([ + ConfigVersionService.preview(), + ConfigVersionService.diff(), + ]) + setPreview(previewData) + setDiff(diffData) + } catch (err) { + const message = err instanceof Error ? err.message : "加载预览失败" + setPreviewError(message) + setDiffError(message) + } finally { + setPreviewLoading(false) + setDiffLoading(false) + } + } + + const handleOpenPreview = async () => { + setPreviewOpen(true) + if (!preview) { + await loadPreviewData() + } + } + + const handleOpenDiff = async () => { + setDiffOpen(true) + if (!diff) { + setDiffLoading(true) + setDiffError(null) + try { + const diffData = await ConfigVersionService.diff() + setDiff(diffData) + } catch (err) { + setDiffError(err instanceof Error ? err.message : "加载差异失败") + } finally { + setDiffLoading(false) + } + } + } + + const handlePublish = async (force = false) => { + setPublishing(true) + try { + const version = await ConfigVersionService.publish(force) + toast.success("发布成功", { description: `版本 ${version.version}` }) + setPreviewOpen(false) + setPublishConfirmOpen(false) + setForcePublishConfirmOpen(false) + setPreview(null) + setDiff(null) + await fetchVersions() + } catch (err) { + toast.error("发布失败", { + description: err instanceof Error ? err.message : "未知错误", + }) + } finally { + setPublishing(false) + } + } + + const handleActivate = async () => { + if (!activateTarget) return + + setActivating(true) + try { + const version = await ConfigVersionService.activate(activateTarget.id) + toast.success("激活成功", { description: `版本 ${version.version}` }) + setActivateTarget(null) + await fetchVersions() + } catch (err) { + toast.error("激活失败", { + description: err instanceof Error ? err.message : "未知错误", + }) + } finally { + setActivating(false) + } + } + + const handleCleanup = async (keepCount: number) => { + setCleaning(true) + try { + const result = await ConfigVersionService.cleanup({ keep_count: keepCount }) + toast.success("清理完成", { + description: `已删除 ${result.deleted_count} 个历史快照`, + }) + setCleanupOpen(false) + await fetchVersions() + } catch (err) { + toast.error("清理失败", { + description: err instanceof Error ? err.message : "未知错误", + }) + } finally { + setCleaning(false) + } + } + + return ( +
+
+
+ +
+

配置版本

+

+ 查看历史快照、预览待发布配置差异,并在需要时重新激活旧版本。 +

+
+
+ +
+ + + + + +
+
+ + {error ? void fetchVersions()} /> : null} + +
+ {loading ? ( + + ) : versions.length === 0 ? ( + + ) : ( + + + + 版本号 + 状态 + 创建人 + Checksum + 创建时间 + 操作 + + + + {versions.map((version) => ( + + + {version.version} + + + {version.is_active ? ( + + + 当前激活 + + ) : ( + + 历史版本 + + )} + + + {version.created_by || "系统"} + + + {truncateChecksum(version.checksum)} + + + {formatDateTime(version.created_at)} + + +
+ + {!version.is_active ? ( + + ) : null} +
+
+
+ ))} +
+
+ )} +
+ + setPublishConfirmOpen(true)} + /> + + + + + + void handleCleanup(keepCount)} + loading={cleaning} + /> + + + + + 确认发布配置 + + 将把当前待发布配置生成新版本并设为激活版本,节点将随后拉取更新。 + + + + 取消 + + + + + + + + + 确认强制发布 + + 将忽略配置变化检查并立即生成一个新版本,请确认你确实需要重新发布。 + + + + 取消 + + + + + + { + if (!open) setActivateTarget(null) + }} + > + + + 确认激活版本 + + {activateTarget + ? `确认将版本 ${activateTarget.version} 设为当前激活版本吗?` + : ""} + + + + 取消 + + + + +
+ ) +} diff --git a/Wavelet/frontend/app/(main)/openflare/layout.tsx b/Wavelet/frontend/app/(main)/openflare/layout.tsx new file mode 100644 index 00000000..04066cda --- /dev/null +++ b/Wavelet/frontend/app/(main)/openflare/layout.tsx @@ -0,0 +1,7 @@ +export default function OpenFlareLayout({ + children, +}: { + children: React.ReactNode; +}) { + return children; +} \ No newline at end of file diff --git a/Wavelet/frontend/app/(main)/openflare/nodes/components/agent-update-dialog.tsx b/Wavelet/frontend/app/(main)/openflare/nodes/components/agent-update-dialog.tsx new file mode 100644 index 00000000..894e1d24 --- /dev/null +++ b/Wavelet/frontend/app/(main)/openflare/nodes/components/agent-update-dialog.tsx @@ -0,0 +1,170 @@ +'use client'; + +import {useState} from 'react'; +import {useQuery} from '@tanstack/react-query'; +import {ExternalLink, Loader2} from 'lucide-react'; + +import {Button} from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import {formatDateTime} from '@/lib/utils'; +import type {NodeAgentReleaseInfo, NodeItem, ReleaseChannel} from '@/lib/services/openflare'; +import {NodeService} from '@/lib/services/openflare'; + +import {NodeStatusBadge} from './node-status-badge'; +import {formatRelativeTime, getErrorMessage} from './node-utils'; + +export function AgentUpdateDialog({ + open, + node, + submitting, + onClose, + onConfirm, +}: { + open: boolean; + node: NodeItem; + submitting: boolean; + onClose: () => void; + onConfirm: (release: NodeAgentReleaseInfo | null, channel: ReleaseChannel) => Promise; +}) { + const [channel, setChannel] = useState('stable'); + + const releaseQuery = useQuery({ + queryKey: ['openflare', 'node-agent-release', node.id, channel], + queryFn: () => NodeService.getAgentRelease(node.id, channel), + enabled: open, + }); + + const release = releaseQuery.data ?? null; + + return ( + !next && onClose()}> + + + Agent 升级 + + 检查 GitHub 发布并向节点下发升级指令,节点将在下一次心跳后执行。 + + + +
+
+
+

当前版本

+

{node.version || 'unknown'}

+
+
+

检查通道

+

+ {channel === 'preview' ? '预览版' : '正式版'} +

+
+
+

更新状态

+
+ +
+
+
+ + {releaseQuery.isFetching ? ( +
+ + 检查发布版本中... +
+ ) : releaseQuery.isError ? ( +

{getErrorMessage(releaseQuery.error)}

+ ) : release ? ( +
+
+ + {release.prerelease ? ( + + ) : ( + + )} +
+
+
+

目标版本

+

{release.tag_name || '未找到'}

+
+
+

发布时间

+

+ {release.published_at + ? `${formatRelativeTime(release.published_at)} · ${formatDateTime(release.published_at)}` + : '—'} +

+
+
+

+ {release.body || '暂无更新说明'} +

+ {release.html_url ? ( + + 查看发布详情 + + + ) : null} +
+ ) : null} +
+ + + + + + +
+
+ ); +} diff --git a/Wavelet/frontend/app/(main)/openflare/nodes/components/edge-node-detail.tsx b/Wavelet/frontend/app/(main)/openflare/nodes/components/edge-node-detail.tsx new file mode 100644 index 00000000..cfcfb97e --- /dev/null +++ b/Wavelet/frontend/app/(main)/openflare/nodes/components/edge-node-detail.tsx @@ -0,0 +1,394 @@ +'use client'; + +import Link from 'next/link'; +import {useRouter} from 'next/navigation'; +import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'; +import {useState} from 'react'; +import {ArrowLeft, FileText, Loader2, RefreshCw, RotateCcw, Trash2, Upload,} 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 {formatDateTime} from '@/lib/utils'; +import type {NodeAgentReleaseInfo, NodeItem, ReleaseChannel} from '@/lib/services/openflare'; +import {NodeService} from '@/lib/services/openflare'; + +import {AgentUpdateDialog} from './agent-update-dialog'; +import {NodeEditorDialog} from './node-editor-dialog'; +import {NodeStatusBadge} from './node-status-badge'; +import { + formatRelativeTime, + getApplyLabel, + getApplyTone, + getErrorMessage, + getNodeStatusLabel, + getNodeStatusTone, + getOpenrestyStatusLabel, + getOpenrestyStatusTone, + isMeaningfulTime, +} from './node-utils'; + +const nodesQueryKey = ['openflare', 'nodes']; + +export function EdgeNodeDetail({ node }: { node: NodeItem }) { + const router = useRouter(); + const queryClient = useQueryClient(); + const [editorOpen, setEditorOpen] = useState(false); + const [upgradeOpen, setUpgradeOpen] = useState(false); + const [deleteOpen, setDeleteOpen] = useState(false); + + const observabilityQuery = useQuery({ + queryKey: ['openflare', 'node-observability', node.id], + queryFn: () => NodeService.getObservability(node.id, { hours: 24, limit: 48 }), + refetchInterval: 10000, + }); + + const saveMutation = useMutation({ + mutationFn: (payload: Parameters[1]) => + NodeService.updateNode(node.id, payload), + onSuccess: async () => { + toast.success('节点已更新'); + setEditorOpen(false); + await queryClient.invalidateQueries({ queryKey: nodesQueryKey }); + }, + onError: (error) => toast.error(getErrorMessage(error)), + }); + + const forceSyncMutation = useMutation({ + mutationFn: () => NodeService.requestForceSync(node.id), + onSuccess: async (updated) => { + toast.success(`已向节点 ${updated.name} 下发强制同步指令`); + await queryClient.invalidateQueries({ queryKey: nodesQueryKey }); + }, + onError: (error) => toast.error(getErrorMessage(error)), + }); + + const restartMutation = useMutation({ + mutationFn: () => NodeService.requestOpenrestyRestart(node.id), + onSuccess: async (updated) => { + toast.success(`已向节点 ${updated.name} 下发 OpenResty 重启指令`); + await queryClient.invalidateQueries({ queryKey: nodesQueryKey }); + }, + onError: (error) => toast.error(getErrorMessage(error)), + }); + + const upgradeMutation = useMutation({ + mutationFn: ({ + release, + channel, + }: { + release: NodeAgentReleaseInfo | null; + channel: ReleaseChannel; + }) => + NodeService.requestAgentUpdate(node.id, { + channel: release?.channel ?? channel, + tag_name: release?.channel === 'preview' ? release.tag_name || undefined : undefined, + }), + onSuccess: async (updated) => { + toast.success( + `已向节点 ${updated.name} 下发${updated.update_channel === 'preview' ? '预览版' : '正式版'}升级指令`, + ); + setUpgradeOpen(false); + await queryClient.invalidateQueries({ queryKey: nodesQueryKey }); + }, + onError: (error) => toast.error(getErrorMessage(error)), + }); + + const deleteMutation = useMutation({ + mutationFn: () => NodeService.deleteNode(node.id), + onSuccess: async () => { + toast.success('节点已删除'); + await queryClient.invalidateQueries({ queryKey: nodesQueryKey }); + router.push('/openflare/nodes'); + }, + onError: (error) => toast.error(getErrorMessage(error)), + }); + + const profile = observabilityQuery.data?.profile ?? null; + const latestMetric = observabilityQuery.data?.metric_snapshots?.[0] ?? null; + const activeHealthEvents = + observabilityQuery.data?.health_events.filter((event) => event.status === 'active') ?? []; + + const handleRefresh = () => { + void Promise.all([ + queryClient.invalidateQueries({ queryKey: nodesQueryKey }), + queryClient.invalidateQueries({ queryKey: ['openflare', 'node-observability', node.id] }), + ]); + }; + + const isRefreshing = observabilityQuery.isFetching; + + return ( +
+
+
+ +

{node.name}

+ +
+ +
+ + + + + + +
+
+ +
+ + + 节点 ID + {node.node_id} + + + + + Agent 版本 + {node.version || 'unknown'} + + + + + 当前配置版本 + {node.current_version || '未应用'} + + + + + 最近心跳 + + {isMeaningfulTime(node.last_seen_at) + ? formatRelativeTime(node.last_seen_at) + : '暂无'} + + + +
+ +
+ + + 基本信息 + 边缘代理节点 (edge_node) + + +
+ 运行状态 + +
+
+ OpenResty 健康 + +
+
+ 最近应用 + +
+
+ IP 地址 + + {node.ip || '—'} + {node.ip_manual_override ? '(已锁定)' : ''} + +
+
+ 地图点位 + {node.geo_name || '未配置'} +
+
+ 创建时间 + {formatDateTime(node.created_at)} +
+
+ 更新时间 + {formatDateTime(node.updated_at)} +
+ {node.last_error ? ( +
+ {node.last_error} +
+ ) : null} +
+
+ + + +
+ 运行快照 + 来自 observability 接口的最近上报 +
+ +
+ + {observabilityQuery.isLoading ? ( +
+ + 加载运行快照中... +
+ ) : observabilityQuery.isError ? ( +

{getErrorMessage(observabilityQuery.error)}

+ ) : ( + <> +
+ 活动健康事件 + {activeHealthEvents.length} +
+
+ 主机名 + {profile?.hostname || '—'} +
+
+ 操作系统 + + {profile + ? `${profile.os_name || 'unknown'} ${profile.os_version || ''}`.trim() + : '—'} + +
+
+ CPU 使用率 + + {latestMetric ? `${latestMetric.cpu_usage_percent.toFixed(1)}%` : '—'} + +
+
+ OpenResty 连接数 + {latestMetric?.openresty_connections ?? '—'} +
+
+ OpenResty 状态消息 + + {node.openresty_message || '无'} + +
+ + )} +
+
+
+ + setEditorOpen(false)} + onSubmit={async (payload) => { + await saveMutation.mutateAsync(payload); + }} + /> + + setUpgradeOpen(false)} + onConfirm={async (release, channel) => { + await upgradeMutation.mutateAsync({ release, channel }); + }} + /> + + + + + 确认删除节点 + + 确认删除节点「{node.name}」吗?删除后该节点需要重新创建并重新接入。 + + + + 取消 + deleteMutation.mutate()} + > + {deleteMutation.isPending ? '删除中...' : '确认删除'} + + + + +
+ ); +} diff --git a/Wavelet/frontend/app/(main)/openflare/nodes/components/node-editor-dialog.tsx b/Wavelet/frontend/app/(main)/openflare/nodes/components/node-editor-dialog.tsx new file mode 100644 index 00000000..423c7c4f --- /dev/null +++ b/Wavelet/frontend/app/(main)/openflare/nodes/components/node-editor-dialog.tsx @@ -0,0 +1,302 @@ +'use client'; + +import {useEffect, useState} from 'react'; +import {Button} from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +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 type {NodeItem, NodeMutationPayload, NodeType} from '@/lib/services/openflare'; + +type FormState = { + node_type: NodeType; + name: string; + ip: string; + ip_manual_override: boolean; + auto_update_enabled: boolean; + geo_manual_override: boolean; + geo_name: string; + geo_latitude: string; + geo_longitude: string; + relay_bind_port: string; + relay_vhost_http_port: string; +}; + +const defaultForm: FormState = { + node_type: 'edge_node', + name: '', + ip: '', + ip_manual_override: false, + auto_update_enabled: false, + geo_manual_override: false, + geo_name: '', + geo_latitude: '', + geo_longitude: '', + relay_bind_port: '7000', + relay_vhost_http_port: '8080', +}; + +function buildFormState(node?: NodeItem | null): FormState { + if (!node) return defaultForm; + + return { + node_type: node.node_type, + name: node.name, + ip: node.ip, + ip_manual_override: node.ip_manual_override, + auto_update_enabled: node.auto_update_enabled, + geo_manual_override: node.geo_manual_override, + geo_name: node.geo_name, + geo_latitude: + node.geo_latitude === undefined || node.geo_latitude === null + ? '' + : String(node.geo_latitude), + geo_longitude: + node.geo_longitude === undefined || node.geo_longitude === null + ? '' + : String(node.geo_longitude), + relay_bind_port: String(node.relay_bind_port || 7000), + relay_vhost_http_port: String(node.relay_vhost_http_port || 8080), + }; +} + +function toPayload(form: FormState): NodeMutationPayload { + const base: NodeMutationPayload = { + node_type: form.node_type, + name: form.name.trim(), + ip: form.ip.trim(), + ip_manual_override: form.ip_manual_override, + auto_update_enabled: form.auto_update_enabled, + geo_manual_override: form.geo_manual_override, + geo_name: form.geo_manual_override ? form.geo_name.trim() : '', + geo_latitude: form.geo_manual_override && form.geo_latitude + ? Number(form.geo_latitude) + : null, + geo_longitude: form.geo_manual_override && form.geo_longitude + ? Number(form.geo_longitude) + : null, + }; + + if (form.node_type === 'tunnel_relay') { + return { + ...base, + relay_bind_port: Number(form.relay_bind_port), + relay_vhost_http_port: Number(form.relay_vhost_http_port), + relay_web_server_enabled: true, + }; + } + + return base; +} + +export function NodeEditorDialog({ + open, + node, + submitting, + onClose, + onSubmit, +}: { + open: boolean; + node: NodeItem | null; + submitting: boolean; + onClose: () => void; + onSubmit: (payload: NodeMutationPayload) => Promise; +}) { + const [form, setForm] = useState(defaultForm); + const [error, setError] = useState(''); + + useEffect(() => { + if (open) { + setForm(buildFormState(node)); + setError(''); + } + }, [open, node]); + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + if (!form.name.trim()) { + setError('请输入节点名称'); + return; + } + if (form.ip_manual_override && !form.ip.trim()) { + setError('锁定 IP 时必须填写节点 IP'); + return; + } + + setError(''); + await onSubmit(toPayload(form)); + }; + + return ( + !next && onClose()}> + + + {node ? '编辑节点' : '新增节点'} + + 预创建节点后可在详情页查看专属 Token 与部署命令。 + + + +
void handleSubmit(event)} className="space-y-4"> +
+ + +
+ +
+ + setForm((prev) => ({ ...prev, name: event.target.value }))} + placeholder="例如 edge-hk-01" + /> +
+ +
+ + setForm((prev) => ({ ...prev, ip: event.target.value }))} + placeholder="可选,接入后自动上报" + /> +
+ +
+
+

锁定 IP

+

启用后管理端不再自动覆盖 IP

+
+ + setForm((prev) => ({ ...prev, ip_manual_override: checked })) + } + /> +
+ +
+
+

自动更新 Agent

+

启用后节点将自动拉取正式版更新

+
+ + setForm((prev) => ({ ...prev, auto_update_enabled: checked })) + } + /> +
+ + {form.node_type === 'tunnel_relay' ? ( +
+
+ + + setForm((prev) => ({ ...prev, relay_bind_port: event.target.value })) + } + /> +
+
+ + + setForm((prev) => ({ ...prev, relay_vhost_http_port: event.target.value })) + } + /> +
+
+ ) : null} + +
+
+

手动地图点位

+

用于总览地图展示

+
+ + setForm((prev) => ({ ...prev, geo_manual_override: checked })) + } + /> +
+ + {form.geo_manual_override ? ( +
+
+ + + setForm((prev) => ({ ...prev, geo_name: event.target.value })) + } + /> +
+
+ + + setForm((prev) => ({ ...prev, geo_latitude: event.target.value })) + } + /> +
+
+ + + setForm((prev) => ({ ...prev, geo_longitude: event.target.value })) + } + /> +
+
+ ) : null} + + {error ?

{error}

: null} + + + + + +
+
+
+ ); +} diff --git a/Wavelet/frontend/app/(main)/openflare/nodes/components/node-status-badge.tsx b/Wavelet/frontend/app/(main)/openflare/nodes/components/node-status-badge.tsx new file mode 100644 index 00000000..17482640 --- /dev/null +++ b/Wavelet/frontend/app/(main)/openflare/nodes/components/node-status-badge.tsx @@ -0,0 +1,30 @@ +import {Badge} from '@/components/ui/badge'; +import {cn} from '@/lib/utils'; + +import type {StatusTone} from './node-utils'; + +const toneClassName: Record = { + success: 'bg-emerald-500/10 border-emerald-500/20 text-emerald-600', + warning: 'bg-amber-500/10 border-amber-500/20 text-amber-600', + danger: 'bg-destructive/10 border-destructive/20 text-destructive', + info: 'bg-blue-500/10 border-blue-500/20 text-blue-600', +}; + +export function NodeStatusBadge({ + label, + tone, + className, +}: { + label: string; + tone: StatusTone; + className?: string; +}) { + return ( + + {label} + + ); +} diff --git a/Wavelet/frontend/app/(main)/openflare/nodes/components/node-type-filter.tsx b/Wavelet/frontend/app/(main)/openflare/nodes/components/node-type-filter.tsx new file mode 100644 index 00000000..982aace7 --- /dev/null +++ b/Wavelet/frontend/app/(main)/openflare/nodes/components/node-type-filter.tsx @@ -0,0 +1,78 @@ +'use client'; + +import Link from 'next/link'; +import {useSearchParams} from 'next/navigation'; + +import {cn} from '@/lib/utils'; + +export type NodeFilter = 'all' | 'edge' | 'relay' | 'tunnel'; + +const filters: Array<{ key: NodeFilter; label: string; href: string }> = [ + { key: 'all', label: '全部节点', href: '/openflare/nodes' }, + { key: 'edge', label: 'Edge', href: '/openflare/nodes?filter=edge' }, + { key: 'relay', label: 'Relay', href: '/openflare/nodes?filter=relay' }, + { key: 'tunnel', label: 'Tunnel', href: '/openflare/nodes?filter=tunnel' }, +]; + +export function getNodeFilter(searchParams: URLSearchParams): NodeFilter { + const current = searchParams.get('filter')?.trim().toLowerCase() ?? ''; + if (current === 'relay' || current === 'tunnel' || current === 'edge' || current === 'all') { + return current; + } + return 'all'; +} + +export function filterNodesByType( + nodes: T[], + filter: NodeFilter, +): T[] { + switch (filter) { + case 'relay': + return nodes.filter((node) => node.node_type === 'tunnel_relay'); + case 'tunnel': + return nodes.filter((node) => node.node_type === 'tunnel_client'); + case 'edge': + return nodes.filter((node) => node.node_type === 'edge_node'); + case 'all': + default: + return nodes; + } +} + +export function getFilterDescription(filter: NodeFilter) { + switch (filter) { + case 'relay': + return '当前仅展示 Relay 节点。'; + case 'tunnel': + return '当前仅展示 Tunnel 节点。'; + case 'edge': + return '当前仅展示 Edge 节点。'; + case 'all': + default: + return '当前展示全部节点。'; + } +} + +export function NodeTypeFilter() { + const searchParams = useSearchParams(); + const activeFilter = getNodeFilter(searchParams); + + return ( +
+ {filters.map((item) => ( + + {item.label} + + ))} +
+ ); +} diff --git a/Wavelet/frontend/app/(main)/openflare/nodes/components/node-utils.ts b/Wavelet/frontend/app/(main)/openflare/nodes/components/node-utils.ts new file mode 100644 index 00000000..3085c883 --- /dev/null +++ b/Wavelet/frontend/app/(main)/openflare/nodes/components/node-utils.ts @@ -0,0 +1,98 @@ +import type {ApplyResult, NodeItem, NodeStatus, OpenrestyStatus} from '@/lib/services/openflare'; + +export const WS_CONNECTED_LAST_SEEN = '__OPENFLARE_WS_CONNECTED__'; +export const FLARED_WS_CONNECTED_LAST_SEEN = '__OPENFLARE_FLARED_WS_CONNECTED__'; + +export type StatusTone = 'success' | 'warning' | 'danger' | 'info'; + +export function isWSConnectedLastSeen(value: string | null | undefined) { + return value === WS_CONNECTED_LAST_SEEN || value === FLARED_WS_CONNECTED_LAST_SEEN; +} + +export function isMeaningfulTime(value: string | null | undefined) { + return ( + Boolean(value) && + !isWSConnectedLastSeen(value) && + !String(value).startsWith('0001-01-01') + ); +} + +export function formatRelativeTime(value: string) { + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return value; + } + + const diffMs = Date.now() - date.getTime(); + const diffMinutes = Math.floor(diffMs / 60_000); + if (diffMinutes < 1) return '刚刚'; + if (diffMinutes < 60) return `${diffMinutes} 分钟前`; + + const diffHours = Math.floor(diffMinutes / 60); + if (diffHours < 24) return `${diffHours} 小时前`; + + const diffDays = Math.floor(diffHours / 24); + if (diffDays < 30) return `${diffDays} 天前`; + + return `${Math.floor(diffDays / 30)} 个月前`; +} + +export function getNodeStatusTone(status: NodeStatus): StatusTone { + if (status === 'online') return 'success'; + if (status === 'pending') return 'warning'; + return 'danger'; +} + +export function getNodeStatusLabel(status: NodeStatus) { + if (status === 'online') return '在线'; + if (status === 'pending') return '待接入'; + return '离线'; +} + +export function getApplyTone(result: ApplyResult): StatusTone { + if (result === 'success') return 'success'; + if (result === 'warning') return 'warning'; + if (result === 'failed') return 'danger'; + return 'warning'; +} + +export function getApplyLabel(result: ApplyResult) { + if (result === 'success') return '成功'; + if (result === 'warning') return '警告'; + if (result === 'failed') return '失败'; + return '暂无'; +} + +export function getOpenrestyStatusTone(status: OpenrestyStatus): StatusTone { + if (status === 'healthy') return 'success'; + if (status === 'unhealthy') return 'danger'; + return 'warning'; +} + +export function getOpenrestyStatusLabel(status: OpenrestyStatus) { + if (status === 'healthy') return '健康'; + if (status === 'unhealthy') return '异常'; + return '未知'; +} + +export function getRelayStatusTone(status: string | null | undefined): StatusTone { + if (status === 'healthy') return 'success'; + if (status === 'unhealthy') return 'danger'; + return 'warning'; +} + +export function getRelayStatusLabel(status: string | null | undefined) { + if (status === 'healthy') return '健康'; + if (status === 'unhealthy') return '异常'; + return '未知'; +} + +export function getNodeTypeLabel(nodeType: NodeItem['node_type']) { + if (nodeType === 'tunnel_relay') return 'Relay'; + if (nodeType === 'tunnel_client') return 'Tunnel'; + return 'Edge'; +} + +export function getErrorMessage(error: unknown) { + return error instanceof Error ? error.message : '请求失败,请稍后重试。'; +} diff --git a/Wavelet/frontend/app/(main)/openflare/nodes/components/nodes-table.tsx b/Wavelet/frontend/app/(main)/openflare/nodes/components/nodes-table.tsx new file mode 100644 index 00000000..4b3d7e0f --- /dev/null +++ b/Wavelet/frontend/app/(main)/openflare/nodes/components/nodes-table.tsx @@ -0,0 +1,151 @@ +'use client'; + +import Link from 'next/link'; +import {Pencil, Trash2} from 'lucide-react'; + +import {Button} from '@/components/ui/button'; +import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow,} from '@/components/ui/table'; +import {formatDateTime} from '@/lib/utils'; +import type {NodeItem} from '@/lib/services/openflare'; + +import {NodeStatusBadge} from './node-status-badge'; +import { + formatRelativeTime, + getApplyLabel, + getApplyTone, + getNodeStatusLabel, + getNodeStatusTone, + getNodeTypeLabel, + getOpenrestyStatusLabel, + getOpenrestyStatusTone, + getRelayStatusLabel, + getRelayStatusTone, + isMeaningfulTime, + isWSConnectedLastSeen, +} from './node-utils'; + +export function NodesTable({ + nodes, + deletingId, + onEdit, + onDelete, +}: { + nodes: NodeItem[]; + deletingId: number | null; + onEdit: (node: NodeItem) => void; + onDelete: (node: NodeItem) => void; +}) { + return ( +
+ + + + 节点 + 状态 + Version + 运行健康 + 当前版本 + 最近应用 + 最近心跳 + 操作 + + + + {nodes.map((node) => ( + + +
+
+

{node.name}

+ +
+

+ IP:{node.ip || '—'} + {node.ip_manual_override ? '(已锁定)' : ''} +

+

+ 位置:{node.geo_name || '未配置地图点位'} +

+
+
+ + + + + {node.version || 'unknown'} + + + {node.node_type === 'tunnel_relay' ? ( + + ) : node.node_type === 'tunnel_client' ? ( + + ) : ( + + )} + + + {node.current_version || + (node.node_type === 'tunnel_relay' ? '实时配置' : '未应用')} + + + {node.node_type === 'tunnel_relay' ? ( + — + ) : ( + + )} + + + {isWSConnectedLastSeen(node.last_seen_at) + ? 'WS 已连接' + : isMeaningfulTime(node.last_seen_at) + ? `${formatRelativeTime(node.last_seen_at)} · ${formatDateTime(node.last_seen_at)}` + : '暂无'} + + +
+ + + +
+
+
+ ))} +
+
+
+ ); +} diff --git a/Wavelet/frontend/app/(main)/openflare/nodes/detail/page.tsx b/Wavelet/frontend/app/(main)/openflare/nodes/detail/page.tsx new file mode 100644 index 00000000..27ee1490 --- /dev/null +++ b/Wavelet/frontend/app/(main)/openflare/nodes/detail/page.tsx @@ -0,0 +1,103 @@ +'use client'; + +import Link from 'next/link'; +import {useMemo} from 'react'; +import {useSearchParams} from 'next/navigation'; +import {useQuery} from '@tanstack/react-query'; +import {ArrowLeft, Server} from 'lucide-react'; + +import {Button} from '@/components/ui/button'; +import {EmptyStateWithBorder} from '@/components/layout/empty'; +import {ErrorInline} from '@/components/layout/error'; +import {LoadingStateWithBorder} from '@/components/layout/loading'; +import {NodeService} from '@/lib/services/openflare'; + +import {EdgeNodeDetail} from '../components/edge-node-detail'; +import {getErrorMessage} from '../components/node-utils'; + +const nodesQueryKey = ['openflare', 'nodes']; + +export default function NodeDetailPage() { + const searchParams = useSearchParams(); + const nodeId = searchParams.get('id')?.trim() ?? ''; + + const nodesQuery = useQuery({ + queryKey: nodesQueryKey, + queryFn: () => NodeService.listNodes(), + refetchInterval: 5000, + }); + + const node = useMemo(() => { + if (!nodeId) return null; + return (nodesQuery.data ?? []).find((item) => String(item.id) === nodeId) ?? null; + }, [nodeId, nodesQuery.data]); + + if (!nodeId) { + return ( +
+ +
+ ); + } + + if (nodesQuery.isLoading) { + return ( +
+ +
+ ); + } + + if (nodesQuery.isError) { + return ( +
+
+ void nodesQuery.refetch()} + className="justify-center" + /> +
+
+ ); + } + + if (!node) { + return ( +
+ + +
+ ); + } + + if (node.node_type !== 'edge_node') { + return ( +
+ + +
+ ); + } + + return ; +} diff --git a/Wavelet/frontend/app/(main)/openflare/nodes/page.tsx b/Wavelet/frontend/app/(main)/openflare/nodes/page.tsx new file mode 100644 index 00000000..93dbcb1e --- /dev/null +++ b/Wavelet/frontend/app/(main)/openflare/nodes/page.tsx @@ -0,0 +1,208 @@ +'use client'; + +import Link from 'next/link'; +import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'; +import {useMemo, useState} from 'react'; +import {useSearchParams} from 'next/navigation'; +import {Loader2, Plus, RefreshCw, Server} 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 {EmptyStateWithBorder} from '@/components/layout/empty'; +import {ErrorInline} from '@/components/layout/error'; +import {LoadingStateWithBorder} from '@/components/layout/loading'; +import type {NodeItem, NodeMutationPayload} from '@/lib/services/openflare'; +import {NodeService} from '@/lib/services/openflare'; + +import {NodeEditorDialog} from './components/node-editor-dialog'; +import {filterNodesByType, getFilterDescription, getNodeFilter, NodeTypeFilter,} from './components/node-type-filter'; +import {NodesTable} from './components/nodes-table'; +import {getErrorMessage} from './components/node-utils'; + +const nodesQueryKey = ['openflare', 'nodes']; + +export default function NodesPage() { + const searchParams = useSearchParams(); + const queryClient = useQueryClient(); + const [editingNode, setEditingNode] = useState(null); + const [editorOpen, setEditorOpen] = useState(false); + const [deleteTarget, setDeleteTarget] = useState(null); + + const nodeFilter = useMemo( + () => getNodeFilter(new URLSearchParams(searchParams.toString())), + [searchParams], + ); + + const nodesQuery = useQuery({ + queryKey: nodesQueryKey, + queryFn: () => NodeService.listNodes(), + refetchInterval: 5000, + }); + + const nodes = useMemo(() => nodesQuery.data ?? [], [nodesQuery.data]); + const filteredNodes = useMemo( + () => filterNodesByType(nodes, nodeFilter), + [nodeFilter, nodes], + ); + + const saveMutation = useMutation({ + mutationFn: async (payload: NodeMutationPayload) => { + if (editingNode) { + return NodeService.updateNode(editingNode.id, payload); + } + return NodeService.createNode(payload); + }, + onSuccess: async () => { + toast.success(editingNode ? '节点已更新' : '节点已创建'); + setEditingNode(null); + setEditorOpen(false); + await queryClient.invalidateQueries({ queryKey: nodesQueryKey }); + }, + onError: (error) => { + toast.error(getErrorMessage(error)); + }, + }); + + const deleteMutation = useMutation({ + mutationFn: (id: number) => NodeService.deleteNode(id), + onSuccess: async () => { + toast.success('节点已删除'); + setDeleteTarget(null); + await queryClient.invalidateQueries({ queryKey: nodesQueryKey }); + }, + onError: (error) => { + toast.error(getErrorMessage(error)); + }, + }); + + const handleCreate = () => { + setEditingNode(null); + setEditorOpen(true); + }; + + const handleEdit = (node: NodeItem) => { + setEditingNode(node); + setEditorOpen(true); + }; + + const handleRefresh = () => { + void queryClient.invalidateQueries({ queryKey: nodesQueryKey }); + }; + + return ( +
+
+
+ +

节点管理

+
+
+ + +
+
+ + + +
+
+ 节点列表 + {getFilterDescription(nodeFilter)} +
+ +
+
+ + + + {nodesQuery.isLoading ? ( + + ) : nodesQuery.isError ? ( +
+ +
+ ) : filteredNodes.length === 0 ? ( + + ) : ( + + )} +
+
+ + { + setEditorOpen(false); + setEditingNode(null); + }} + onSubmit={async (payload) => { + await saveMutation.mutateAsync(payload); + }} + /> + + !open && setDeleteTarget(null)}> + + + 确认删除节点 + + 确认删除节点「{deleteTarget?.name}」吗?删除后该节点需要重新创建并重新接入。 + + + + 取消 + deleteTarget && deleteMutation.mutate(deleteTarget.id)} + > + {deleteMutation.isPending ? '删除中...' : '确认删除'} + + + + +
+ ); +} diff --git a/Wavelet/frontend/app/(main)/openflare/origins/page.tsx b/Wavelet/frontend/app/(main)/openflare/origins/page.tsx new file mode 100644 index 00000000..cca7375c --- /dev/null +++ b/Wavelet/frontend/app/(main)/openflare/origins/page.tsx @@ -0,0 +1,7 @@ +import {MapPin} from 'lucide-react'; + +import {PlaceholderPage} from '../components/placeholder-page'; + +export default function OriginsPage() { + return ; +} \ No newline at end of file diff --git a/Wavelet/frontend/app/(main)/openflare/page.tsx b/Wavelet/frontend/app/(main)/openflare/page.tsx new file mode 100644 index 00000000..3f945f38 --- /dev/null +++ b/Wavelet/frontend/app/(main)/openflare/page.tsx @@ -0,0 +1,7 @@ +import {LayoutDashboard} from 'lucide-react'; + +import {PlaceholderPage} from './components/placeholder-page'; + +export default function OpenFlareDashboardPage() { + return ; +} \ No newline at end of file diff --git a/Wavelet/frontend/app/(main)/openflare/pages/page.tsx b/Wavelet/frontend/app/(main)/openflare/pages/page.tsx new file mode 100644 index 00000000..ceed75e8 --- /dev/null +++ b/Wavelet/frontend/app/(main)/openflare/pages/page.tsx @@ -0,0 +1,7 @@ +import {FileText} from 'lucide-react'; + +import {PlaceholderPage} from '../components/placeholder-page'; + +export default function PagesPage() { + return ; +} \ No newline at end of file diff --git a/Wavelet/frontend/app/(main)/openflare/performance/page.tsx b/Wavelet/frontend/app/(main)/openflare/performance/page.tsx new file mode 100644 index 00000000..cba492a4 --- /dev/null +++ b/Wavelet/frontend/app/(main)/openflare/performance/page.tsx @@ -0,0 +1,7 @@ +import {Gauge} from 'lucide-react'; + +import {PlaceholderPage} from '../components/placeholder-page'; + +export default function PerformancePage() { + return ; +} \ No newline at end of file diff --git a/Wavelet/frontend/app/(main)/openflare/proxy-routes/components/helpers.ts b/Wavelet/frontend/app/(main)/openflare/proxy-routes/components/helpers.ts new file mode 100644 index 00000000..96501416 --- /dev/null +++ b/Wavelet/frontend/app/(main)/openflare/proxy-routes/components/helpers.ts @@ -0,0 +1,90 @@ +import type {ProxyRouteConfigSection, ProxyRouteItem} from '@/lib/services/openflare'; + +export const proxyRouteConfigSections = [ + { + key: 'domains' as const, + label: '域名设置', + description: '维护站点标识、域名列表和证书绑定。', + }, + { + key: 'limits' as const, + label: '流量限制', + description: '设置连接数和限速。', + }, + { + key: 'proxy' as const, + label: '反向代理', + description: '配置主回源和上游地址。', + }, + { + key: 'cache' as const, + label: '缓存', + description: '配置站点缓存策略。', + }, + { + key: 'waf' as const, + label: 'WAF', + description: '绑定 WAF 规则组,并查看当前站点生效策略。', + }, + { + key: 'auth' as const, + label: '认证配置', + description: '配置基础鉴权访问,需要输入账号密码才能访问网站。', + }, +] satisfies ReadonlyArray<{ + key: ProxyRouteConfigSection; + label: string; + description: string; +}>; + +const domainPattern = + /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/i; + +export function getProxyRouteConfigSection( + value: string | null | undefined, +): ProxyRouteConfigSection { + return proxyRouteConfigSections.some((section) => section.key === value) + ? (value as ProxyRouteConfigSection) + : 'domains'; +} + +export function validateDomain(domain: string): string | null { + const normalized = domain.trim().toLowerCase(); + if (!normalized) { + return '请输入域名'; + } + if (!domainPattern.test(normalized)) { + return '域名格式不合法'; + } + return null; +} + +export function parseOriginUrl(originUrl: string) { + const parsed = new URL(originUrl); + const port = parsed.port || (parsed.protocol === 'http:' ? '80' : '443'); + const path = parsed.pathname === '/' ? '' : parsed.pathname; + + return { + scheme: parsed.protocol.replace(':', '') as 'http' | 'https', + address: parsed.hostname, + port, + uri: parsed.search ? `${path}${parsed.search}` || parsed.search : path, + }; +} + +export function getUpstreamSummary(route: ProxyRouteItem): string { + if (route.upstream_type === 'pages') { + return route.pages_project_id + ? `Pages 项目 #${route.pages_project_id}` + : 'Pages 项目未绑定'; + } + if (route.upstream_type === 'tunnel') { + const protocol = route.tunnel_target_protocol || 'http'; + const target = route.tunnel_target_addr || '未配置目标'; + return `Tunnel → ${protocol}://${target}`; + } + if (route.upstream_list.length <= 1) { + return route.origin_url; + } + return `${route.upstream_list.length} 个上游,主上游 ${route.origin_url}`; +} diff --git a/Wavelet/frontend/app/(main)/openflare/proxy-routes/components/proxy-route-create-sheet.tsx b/Wavelet/frontend/app/(main)/openflare/proxy-routes/components/proxy-route-create-sheet.tsx new file mode 100644 index 00000000..41ece47c --- /dev/null +++ b/Wavelet/frontend/app/(main)/openflare/proxy-routes/components/proxy-route-create-sheet.tsx @@ -0,0 +1,234 @@ +'use client'; + +import {useEffect} from 'react'; +import {zodResolver} from '@hookform/resolvers/zod'; +import {useForm} from 'react-hook-form'; +import {z} from 'zod'; + +import {Button} from '@/components/ui/button'; +import {Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage,} from '@/components/ui/form'; +import {Input} from '@/components/ui/input'; +import {Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle,} from '@/components/ui/sheet'; +import {Switch} from '@/components/ui/switch'; +import {Textarea} from '@/components/ui/textarea'; +import type {ProxyRouteItem} from '@/lib/services/openflare'; +import {ProxyRouteService} from '@/lib/services/openflare'; + +import {parseOriginUrl, validateDomain} from './helpers'; + +const createProxyRouteSchema = z + .object({ + site_name: z.string().trim().max(255, '站点标识不能超过 255 个字符'), + domain: z.string().trim().min(1, '请输入域名'), + origin_url: z.string().trim().min(1, '请输入上游地址'), + enabled: z.boolean(), + remark: z.string().max(255, '备注不能超过 255 个字符'), + }) + .superRefine((value, context) => { + const domainError = validateDomain(value.domain); + if (domainError) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ['domain'], + message: domainError, + }); + } + + try { + const parsed = new URL(value.origin_url); + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ['origin_url'], + message: '上游地址必须以 http:// 或 https:// 开头', + }); + } + } catch { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ['origin_url'], + message: '上游地址格式不合法', + }); + } + }); + +type CreateProxyRouteFormValues = z.infer; + +const defaultValues: CreateProxyRouteFormValues = { + site_name: '', + domain: '', + origin_url: '', + enabled: true, + remark: '', +}; + +interface ProxyRouteCreateSheetProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onCreated: (route: ProxyRouteItem) => void; +} + +export function ProxyRouteCreateSheet({ + open, + onOpenChange, + onCreated, +}: ProxyRouteCreateSheetProps) { + const form = useForm({ + resolver: zodResolver(createProxyRouteSchema), + defaultValues, + }); + + useEffect(() => { + if (!open) { + form.reset(defaultValues); + } + }, [form, open]); + + const handleSubmit = form.handleSubmit(async (values) => { + const domain = values.domain.trim().toLowerCase(); + const origin = parseOriginUrl(values.origin_url.trim()); + + try { + const route = await ProxyRouteService.create({ + site_name: values.site_name.trim() || domain, + domain, + domains: [domain], + origin_id: null, + origin_url: values.origin_url.trim(), + origin_scheme: origin.scheme, + origin_address: origin.address, + origin_port: origin.port, + origin_uri: origin.uri, + origin_host: '', + upstreams: [], + enabled: values.enabled, + enable_https: false, + cert_id: null, + cert_ids: [], + domain_cert_ids: [0], + redirect_http: false, + limit_conn_per_server: 0, + limit_conn_per_ip: 0, + limit_rate: '', + cache_enabled: false, + cache_policy: 'url', + cache_rules: [], + custom_headers: [], + pow_enabled: false, + pow_config: '{}', + basic_auth_enabled: false, + remark: values.remark.trim(), + upstream_type: 'direct', + }); + + form.reset(defaultValues); + onOpenChange(false); + onCreated(route); + } catch (error) { + form.setError('root', { + message: error instanceof Error ? error.message : '创建失败,请稍后重试', + }); + } + }); + + return ( + + + + 新建规则 + + 创建网站后可进入详情页继续配置 HTTPS、缓存和限流等高级选项。 + + + +
+ + ( + + 站点标识 + + + + 可选,留空时会自动使用域名。 + + + )} + /> + + ( + + 主域名 + + + + + + )} + /> + + ( + + 上游地址 + + + + 主回源完整 URL,创建后可在详情页添加多上游。 + + + )} + /> + + ( + +
+ 创建后立即启用 + 关闭后站点会以草稿保存。 +
+ + + +
+ )} + /> + + ( + + 备注 + +