migrate frontend

This commit is contained in:
ryan
2026-06-18 17:26:57 +08:00
parent d7fe4ef8be
commit a9fd0bd128
53 changed files with 4765 additions and 19 deletions
+2 -2
View File
@@ -1,5 +1,5 @@
import {HomeMain} from "@/components/common/home/home-main"
import {redirect} from "next/navigation"
export default function HomePage() {
return <HomeMain />
redirect("/openflare")
}
@@ -0,0 +1,7 @@
import {ScrollText} from 'lucide-react';
import {PlaceholderPage} from '../components/placeholder-page';
export default function AccessLogsPage() {
return <PlaceholderPage title="访问日志" icon={ScrollText} />;
}
@@ -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 (
<Badge
variant="outline"
className="text-[10px] bg-emerald-500/10 border-emerald-500/20 text-emerald-600 rounded-full py-0 px-2"
>
<span className="size-1 bg-emerald-500 rounded-full mr-1.5 shrink-0" />
成功
</Badge>
)
}
if (result === "warning") {
return (
<Badge
variant="outline"
className="text-[10px] bg-amber-500/10 border-amber-500/20 text-amber-600 rounded-full py-0 px-2"
>
<span className="size-1 bg-amber-500 rounded-full mr-1.5 shrink-0" />
警告
</Badge>
)
}
return (
<Badge
variant="outline"
className="text-[10px] bg-destructive/10 border-destructive/20 text-destructive rounded-full py-0 px-2"
>
<span className="size-1 bg-destructive rounded-full mr-1.5 shrink-0" />
失败
</Badge>
)
}
export function LogDetailSheet({ log, open, onOpenChange }: LogDetailSheetProps) {
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="sm:max-w-md w-full p-0 flex flex-col gap-0">
<SheetHeader className="px-5 py-4 border-b">
<SheetTitle>应用日志详情</SheetTitle>
<SheetDescription>查看单条应用日志的完整结果与校验信息。</SheetDescription>
</SheetHeader>
{log ? (
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-4">
<div className="flex flex-wrap gap-2">
{getResultBadge(log.result)}
<Badge variant="outline" className="text-[10px] rounded-full">
Node: {log.node_id}
</Badge>
<Badge variant="outline" className="text-[10px] rounded-full">
版本: {log.version}
</Badge>
</div>
<div className="grid gap-3 text-xs">
<div>
<p className="text-muted-foreground">创建时间</p>
<p className="font-medium">{formatDateTime(log.created_at)}</p>
</div>
<div>
<p className="text-muted-foreground">目标 Checksum</p>
<p className="font-mono break-all">{log.checksum || "—"}</p>
</div>
<div>
<p className="text-muted-foreground">主配置摘要</p>
<p className="font-mono break-all">{log.main_config_checksum || "—"}</p>
</div>
<div>
<p className="text-muted-foreground">路由配置摘要</p>
<p className="font-mono break-all">{log.route_config_checksum || "—"}</p>
</div>
<div>
<p className="text-muted-foreground">支持文件数</p>
<p className="font-medium">{log.support_file_count}</p>
</div>
</div>
<div className="rounded-lg border border-dashed p-3">
<p className="text-[10px] uppercase tracking-wider text-muted-foreground mb-2">消息</p>
<pre className="text-xs whitespace-pre-wrap break-words">{log.message || "—"}</pre>
</div>
</div>
) : null}
<SheetFooter className="px-5 py-4 border-t">
<Button variant="outline" onClick={() => onOpenChange(false)}>
关闭
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
)
}
@@ -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 (
<Badge
variant="outline"
className="text-[10px] bg-emerald-500/10 border-emerald-500/20 text-emerald-600 rounded-full py-0 px-2"
>
<span className="size-1 bg-emerald-500 rounded-full mr-1.5 shrink-0" />
成功
</Badge>
)
}
if (result === "warning") {
return (
<Badge
variant="outline"
className="text-[10px] bg-amber-500/10 border-amber-500/20 text-amber-600 rounded-full py-0 px-2"
>
<span className="size-1 bg-amber-500 rounded-full mr-1.5 shrink-0" />
警告
</Badge>
)
}
return (
<Badge
variant="outline"
className="text-[10px] bg-destructive/10 border-destructive/20 text-destructive rounded-full py-0 px-2"
>
<span className="size-1 bg-destructive rounded-full mr-1.5 shrink-0" />
失败
</Badge>
)
}
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<ApplyLogItem[]>([])
const [current, setCurrent] = useState(1)
const [total, setTotal] = useState(0)
const [totalPage, setTotalPage] = useState(0)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [selectedLog, setSelectedLog] = useState<ApplyLogItem | null>(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 (
<div className="py-6 px-1 space-y-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-2">
<ClipboardList className="size-5 text-primary" />
<div>
<h1 className="text-2xl font-semibold tracking-tight">应用日志</h1>
<p className="text-sm text-muted-foreground">
查看节点应用配置的成功、警告和失败记录,支持按 node_id 过滤。
</p>
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" onClick={() => void fetchLogs()} disabled={loading}>
<RefreshCw className={`size-3.5 mr-1 ${loading ? "animate-spin" : ""}`} />
刷新
</Button>
<Button variant="outline" size="sm" asChild>
<Link href="/openflare/nodes">返回节点</Link>
</Button>
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
{summary.map((item) => (
<div
key={item.label}
className="rounded-lg border border-dashed px-4 py-3 bg-background"
>
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">
{item.label}
</p>
<p className="mt-1 text-lg font-semibold">{item.value}</p>
</div>
))}
</div>
<div className="flex flex-col gap-3 xl:flex-row xl:items-end xl:justify-between">
<div className="grid flex-1 gap-3 md:grid-cols-[minmax(0,1fr)_160px]">
<div className="space-y-1.5">
<p className="text-xs font-medium text-muted-foreground">Node ID</p>
<div className="relative">
<Search className="absolute left-2.5 top-2.5 size-3.5 text-muted-foreground" />
<Input
value={nodeFilterInput}
onChange={(e) => setNodeFilterInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleSearch()
}}
placeholder="输入 node_id 过滤应用日志"
className="pl-8 h-9 text-xs"
/>
</div>
</div>
<div className="space-y-1.5">
<p className="text-xs font-medium text-muted-foreground">每页条数</p>
<Select
value={String(pageSize)}
onValueChange={(value) => {
setPageSize(Number.parseInt(value, 10))
setPageNo(1)
}}
>
<SelectTrigger className="h-9 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PAGE_SIZE_OPTIONS.map((option) => (
<SelectItem key={option} value={String(option)}>
{option} 条
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button size="sm" onClick={handleSearch}>
筛选
</Button>
<Button variant="outline" size="sm" onClick={handleReset}>
清空
</Button>
</div>
</div>
{error ? <ErrorInline message={error} onRetry={() => void fetchLogs()} /> : null}
<div className="border border-dashed shadow-none rounded-lg overflow-hidden bg-background">
{loading ? (
<LoadingStateWithBorder />
) : rows.length === 0 ? (
<EmptyStateWithBorder
title="暂无应用日志"
description="当前筛选条件下没有可展示的应用记录。"
/>
) : (
<Table>
<TableHeader className="bg-muted/40">
<TableRow className="border-dashed hover:bg-transparent">
<TableHead className="text-xs font-semibold">Node ID</TableHead>
<TableHead className="text-xs font-semibold">版本</TableHead>
<TableHead className="text-xs font-semibold">结果</TableHead>
<TableHead className="text-xs font-semibold">Checksum</TableHead>
<TableHead className="text-xs font-semibold">时间</TableHead>
<TableHead className="text-xs font-semibold">消息</TableHead>
<TableHead className="text-xs font-semibold text-right">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((log) => (
<TableRow
key={log.id}
className="border-dashed hover:bg-muted/10 transition-colors align-top"
>
<TableCell className="text-xs font-medium">{log.node_id}</TableCell>
<TableCell className="text-xs text-muted-foreground">{log.version}</TableCell>
<TableCell>{getResultBadge(log.result)}</TableCell>
<TableCell
className="text-xs font-mono text-muted-foreground"
title={log.checksum}
>
{truncateHash(log.checksum)}
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{formatDateTime(log.created_at)}
</TableCell>
<TableCell className="text-xs text-muted-foreground max-w-56">
<div className="line-clamp-2 break-words">{log.message || "—"}</div>
</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="sm"
className="h-7 text-xs"
onClick={() => {
setSelectedLog(log)
setDetailOpen(true)
}}
>
<Eye className="size-3 mr-1" />
详情
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
{!loading && rows.length > 0 ? (
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<p className="text-xs text-muted-foreground">
第 {current} / {Math.max(totalPage, 1)} 页,共 {total} 条记录。
</p>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
disabled={current <= 1}
onClick={() => setPageNo((prev) => Math.max(1, prev - 1))}
>
上一页
</Button>
<Button
variant="outline"
size="sm"
disabled={totalPage === 0 || current >= totalPage}
onClick={() =>
setPageNo((prev) =>
totalPage > 0 ? Math.min(totalPage, prev + 1) : prev,
)
}
>
下一页
</Button>
</div>
</div>
) : null}
<LogDetailSheet
log={selectedLog}
open={detailOpen}
onOpenChange={setDetailOpen}
/>
</div>
)
}
@@ -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 (
<div className="py-6 px-1 space-y-6">
<div className="flex items-center gap-2">
<Icon className="size-5 text-primary" />
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
</div>
<Card>
<CardHeader>
<CardTitle className="text-base">开发中</CardTitle>
<CardDescription>该功能正在迁移开发中,敬请期待。</CardDescription>
</CardHeader>
</Card>
</div>
);
}
@@ -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<string | null>(null)
const handleConfirm = () => {
if (keepCount < 3) {
setError("最少保留 3 个历史快照")
return
}
setError(null)
onConfirm(keepCount)
}
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>清理历史快照</AlertDialogTitle>
<AlertDialogDescription>
删除旧的历史快照配置,系统将始终保护当前已激活的版本不被删除。
</AlertDialogDescription>
</AlertDialogHeader>
<div className="space-y-2">
<Label htmlFor="keepCount">保留最近快照个数</Label>
<Input
id="keepCount"
type="number"
min={3}
value={keepCount}
onChange={(e) => setKeepCount(Number.parseInt(e.target.value, 10) || 3)}
disabled={loading}
/>
<p className="text-xs text-muted-foreground">默认为 10 个,最少需保留 3 个。</p>
{error ? <p className="text-xs text-destructive">{error}</p> : null}
</div>
<AlertDialogFooter>
<AlertDialogCancel disabled={loading}>取消</AlertDialogCancel>
<Button variant="destructive" onClick={handleConfirm} disabled={loading}>
{loading ? (
<>
<Loader2 className="size-4 animate-spin mr-1" />
清理中...
</>
) : (
"确认清理"
)}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}
@@ -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 (
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<p className="text-xs font-semibold">{title}</p>
<Badge variant="outline" className="text-[10px] rounded-full">
{items.length} 项
</Badge>
</div>
{items.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{items.map((item) => (
<Badge key={item} variant="secondary" className="text-[10px] font-normal">
{item}
</Badge>
))}
</div>
) : (
<p className="text-xs text-muted-foreground">无变更</p>
)}
</div>
)
}
function renderOptionValue(value: string) {
return value === "" ? "空" : value
}
export function DiffDialog({
open,
onOpenChange,
diff,
loading,
error,
}: DiffDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-3xl max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>配置差异</DialogTitle>
<DialogDescription>
对比当前待发布配置与已激活版本之间的差异。
</DialogDescription>
</DialogHeader>
{loading ? (
<div className="flex items-center justify-center gap-2 py-12 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
正在加载差异数据...
</div>
) : error ? (
<p className="text-sm text-destructive">{error}</p>
) : diff ? (
<div className="space-y-5">
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<div className="rounded-lg border border-dashed p-3">
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">激活版本</p>
<p className="mt-1 text-sm font-medium">{diff.active_version || "无"}</p>
</div>
<div className="rounded-lg border border-dashed p-3">
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">新增域名</p>
<p className="mt-1 text-lg font-semibold text-emerald-600">{diff.added_domains.length}</p>
</div>
<div className="rounded-lg border border-dashed p-3">
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">删除域名</p>
<p className="mt-1 text-lg font-semibold text-amber-600">{diff.removed_domains.length}</p>
</div>
<div className="rounded-lg border border-dashed p-3">
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">修改域名</p>
<p className="mt-1 text-lg font-semibold text-blue-600">{diff.modified_domains.length}</p>
</div>
</div>
<div className="grid gap-4 md:grid-cols-3">
<DiffChipList title="新增域名" items={diff.added_domains} />
<DiffChipList title="删除域名" items={diff.removed_domains} />
<DiffChipList title="修改域名" items={diff.modified_domains} />
</div>
<div className="flex flex-wrap gap-2">
<Badge variant={diff.main_config_changed ? "destructive" : "outline"}>
主配置 {diff.main_config_changed ? "已变化" : "无变化"}
</Badge>
<Badge variant={diff.waf_config_changed ? "destructive" : "outline"}>
WAF {diff.waf_config_changed ? "已变化" : "无变化"}
</Badge>
<Badge variant="outline">
网站数 {diff.active_website_count} → {diff.current_website_count}
</Badge>
</div>
{diff.changed_option_details.length > 0 ? (
<div className="border border-dashed rounded-lg overflow-hidden">
<Table>
<TableHeader className="bg-muted/40">
<TableRow className="border-dashed hover:bg-transparent">
<TableHead className="text-xs font-semibold">参数</TableHead>
<TableHead className="text-xs font-semibold">激活值</TableHead>
<TableHead className="text-xs font-semibold">待发布值</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{diff.changed_option_details.map((item) => (
<TableRow key={item.key} className="border-dashed">
<TableCell className="text-xs font-medium">{item.key}</TableCell>
<TableCell className="text-xs font-mono text-muted-foreground">
{renderOptionValue(item.previous_value)}
</TableCell>
<TableCell className="text-xs font-mono text-muted-foreground">
{renderOptionValue(item.current_value)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
) : (
<p className="text-xs text-muted-foreground">当前无 OpenResty 参数变化。</p>
)}
</div>
) : null}
</DialogContent>
</Dialog>
)
}
@@ -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 (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="sm:max-w-2xl w-full p-0 flex flex-col gap-0">
<SheetHeader className="px-5 py-4 border-b">
<SheetTitle>发布预览</SheetTitle>
<SheetDescription>
查看待发布配置的渲染结果与支持文件。
</SheetDescription>
</SheetHeader>
<ScrollArea className="flex-1 px-5 py-4">
{loading ? (
<div className="flex items-center justify-center gap-2 py-16 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
正在加载预览...
</div>
) : error ? (
<p className="text-sm text-destructive">{error}</p>
) : preview ? (
<div className="space-y-5 pb-4">
<div className="flex flex-wrap gap-2">
<Badge variant="outline" className="rounded-full text-[10px]">
规则 {preview.route_count} 条
</Badge>
<Badge variant="outline" className="rounded-full text-[10px]">
网站 {preview.website_count} 个
</Badge>
<Badge variant="outline" className="rounded-full text-[10px] font-mono">
{preview.checksum.slice(0, 16)}...
</Badge>
</div>
<div className="space-y-2">
<p className="text-xs font-semibold">主配置</p>
<CodeBlock code={preview.main_config} language="nginx" className="my-0 text-xs" />
</div>
<div className="space-y-2">
<p className="text-xs font-semibold">路由配置</p>
<CodeBlock code={preview.rendered_config} language="nginx" className="my-0 text-xs" />
</div>
{preview.support_files.length > 0 ? (
<div className="space-y-2">
<p className="text-xs font-semibold">支持文件 ({preview.support_files.length})</p>
{preview.support_files.map((file) => (
<details
key={file.path}
className="rounded-lg border border-dashed px-3 py-2"
>
<summary className="cursor-pointer text-xs font-medium">{file.path}</summary>
<CodeBlock code={file.content} language="text" className="my-2 text-xs" />
</details>
))}
</div>
) : (
<p className="text-xs text-muted-foreground">当前发布不需要额外支持文件。</p>
)}
{!canPublish ? (
<p className="text-xs text-muted-foreground">
当前规则与已激活版本一致,无法重复发布。
</p>
) : null}
</div>
) : null}
</ScrollArea>
<SheetFooter className="px-5 py-4 border-t">
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={publishing}>
关闭
</Button>
<Button onClick={onPublish} disabled={!canPublish || publishing || loading}>
{publishing ? (
<>
<Loader2 className="size-4 animate-spin mr-1" />
发布中...
</>
) : (
"确认发布"
)}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
)
}
@@ -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<ConfigVersionDetail | null>(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 (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="sm:max-w-2xl w-full p-0 flex flex-col gap-0">
<SheetHeader className="px-5 py-4 border-b">
<SheetTitle>
{version ? `版本 ${version.version}` : "版本快照"}
</SheetTitle>
<SheetDescription>查看历史配置版本的完整快照内容。</SheetDescription>
</SheetHeader>
<ScrollArea className="flex-1 px-5 py-4">
{loading ? (
<div className="flex items-center justify-center gap-2 py-16 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
正在加载快照...
</div>
) : detail ? (
<div className="space-y-5 pb-4">
<div className="flex flex-wrap gap-2">
{detail.is_active ? (
<Badge className="rounded-full text-[10px]">当前激活</Badge>
) : (
<Badge variant="outline" className="rounded-full text-[10px]">历史版本</Badge>
)}
<Badge variant="outline" className="rounded-full text-[10px] font-mono">
{detail.checksum}
</Badge>
</div>
<div className="grid gap-3 sm:grid-cols-2 text-xs">
<div>
<p className="text-muted-foreground">创建人</p>
<p className="font-medium">{detail.created_by || "系统"}</p>
</div>
<div>
<p className="text-muted-foreground">创建时间</p>
<p className="font-medium">{formatDateTime(detail.created_at)}</p>
</div>
</div>
<div className="space-y-2">
<p className="text-xs font-semibold">快照 JSON</p>
<CodeBlock code={detail.snapshot_json} language="json" className="my-0 text-xs" />
</div>
<div className="space-y-2">
<p className="text-xs font-semibold">主配置</p>
<CodeBlock code={detail.main_config} language="nginx" className="my-0 text-xs" />
</div>
<div className="space-y-2">
<p className="text-xs font-semibold">路由配置</p>
<CodeBlock code={detail.rendered_config} language="nginx" className="my-0 text-xs" />
</div>
</div>
) : null}
</ScrollArea>
<SheetFooter className="px-5 py-4 border-t">
<Button variant="outline" onClick={() => onOpenChange(false)}>
关闭
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
)
}
@@ -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<ConfigVersionSummary[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [previewOpen, setPreviewOpen] = useState(false)
const [preview, setPreview] = useState<ConfigPreviewResult | null>(null)
const [previewLoading, setPreviewLoading] = useState(false)
const [previewError, setPreviewError] = useState<string | null>(null)
const [diffOpen, setDiffOpen] = useState(false)
const [diff, setDiff] = useState<ConfigDiffResult | null>(null)
const [diffLoading, setDiffLoading] = useState(false)
const [diffError, setDiffError] = useState<string | null>(null)
const [snapshotVersion, setSnapshotVersion] = useState<ConfigVersionSummary | null>(null)
const [snapshotOpen, setSnapshotOpen] = useState(false)
const [publishConfirmOpen, setPublishConfirmOpen] = useState(false)
const [forcePublishConfirmOpen, setForcePublishConfirmOpen] = useState(false)
const [activateTarget, setActivateTarget] = useState<ConfigVersionSummary | null>(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 (
<div className="py-6 px-1 space-y-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-2">
<History className="size-5 text-primary" />
<div>
<h1 className="text-2xl font-semibold tracking-tight">配置版本</h1>
<p className="text-sm text-muted-foreground">
查看历史快照、预览待发布配置差异,并在需要时重新激活旧版本。
</p>
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" onClick={() => void fetchVersions()} disabled={loading}>
<RefreshCw className={`size-3.5 mr-1 ${loading ? "animate-spin" : ""}`} />
刷新
</Button>
<Button variant="outline" size="sm" onClick={() => setCleanupOpen(true)}>
<Trash2 className="size-3.5 mr-1" />
清理旧版本
</Button>
<Button variant="outline" size="sm" onClick={() => void handleOpenDiff()}>
<GitCompare className="size-3.5 mr-1" />
查看差异
</Button>
<Button variant="outline" size="sm" onClick={() => setForcePublishConfirmOpen(true)}>
强制发布
</Button>
<Button size="sm" onClick={() => void handleOpenPreview()}>
<Eye className="size-3.5 mr-1" />
预览并发布
</Button>
</div>
</div>
{error ? <ErrorInline message={error} onRetry={() => void fetchVersions()} /> : null}
<div className="border border-dashed shadow-none rounded-lg overflow-hidden bg-background">
{loading ? (
<LoadingStateWithBorder />
) : versions.length === 0 ? (
<EmptyStateWithBorder
title="暂无历史版本"
description="当前还没有可查看的发布记录,请先触发一次配置发布。"
/>
) : (
<Table>
<TableHeader className="bg-muted/40">
<TableRow className="border-dashed hover:bg-transparent">
<TableHead className="text-xs font-semibold">版本号</TableHead>
<TableHead className="text-xs font-semibold">状态</TableHead>
<TableHead className="text-xs font-semibold">创建人</TableHead>
<TableHead className="text-xs font-semibold">Checksum</TableHead>
<TableHead className="text-xs font-semibold">创建时间</TableHead>
<TableHead className="text-xs font-semibold text-right">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{versions.map((version) => (
<TableRow
key={version.id}
className="border-dashed hover:bg-muted/10 transition-colors"
>
<TableCell className="font-mono text-xs font-semibold">
{version.version}
</TableCell>
<TableCell>
{version.is_active ? (
<Badge
variant="outline"
className="text-[10px] bg-emerald-500/10 border-emerald-500/20 text-emerald-600 rounded-full py-0 px-2"
>
<span className="size-1 bg-emerald-500 rounded-full mr-1.5 shrink-0" />
当前激活
</Badge>
) : (
<Badge variant="outline" className="text-[10px] rounded-full py-0 px-2">
历史版本
</Badge>
)}
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{version.created_by || "系统"}
</TableCell>
<TableCell
className="text-xs font-mono text-muted-foreground"
title={version.checksum}
>
{truncateChecksum(version.checksum)}
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{formatDateTime(version.created_at)}
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1.5">
<Button
variant="ghost"
size="sm"
className="h-7 text-xs"
onClick={() => {
setSnapshotVersion(version)
setSnapshotOpen(true)
}}
>
<Eye className="size-3 mr-1" />
快照
</Button>
{!version.is_active ? (
<Button
variant="ghost"
size="sm"
className="h-7 text-xs"
onClick={() => setActivateTarget(version)}
>
<Play className="size-3 mr-1" />
激活
</Button>
) : null}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
<PreviewSheet
open={previewOpen}
onOpenChange={setPreviewOpen}
preview={preview}
loading={previewLoading}
error={previewError}
publishing={publishing}
canPublish={canPublish}
onPublish={() => setPublishConfirmOpen(true)}
/>
<DiffDialog
open={diffOpen}
onOpenChange={setDiffOpen}
diff={diff}
loading={diffLoading}
error={diffError}
/>
<VersionSnapshotSheet
version={snapshotVersion}
open={snapshotOpen}
onOpenChange={setSnapshotOpen}
/>
<CleanupDialog
open={cleanupOpen}
onOpenChange={setCleanupOpen}
onConfirm={(keepCount) => void handleCleanup(keepCount)}
loading={cleaning}
/>
<AlertDialog open={publishConfirmOpen} onOpenChange={setPublishConfirmOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认发布配置</AlertDialogTitle>
<AlertDialogDescription>
将把当前待发布配置生成新版本并设为激活版本,节点将随后拉取更新。
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={publishing}>取消</AlertDialogCancel>
<Button onClick={() => void handlePublish(false)} disabled={publishing}>
{publishing ? <Loader2 className="size-4 animate-spin" /> : "确认发布"}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AlertDialog open={forcePublishConfirmOpen} onOpenChange={setForcePublishConfirmOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认强制发布</AlertDialogTitle>
<AlertDialogDescription>
将忽略配置变化检查并立即生成一个新版本,请确认你确实需要重新发布。
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={publishing}>取消</AlertDialogCancel>
<Button
variant="destructive"
onClick={() => void handlePublish(true)}
disabled={publishing}
>
{publishing ? <Loader2 className="size-4 animate-spin" /> : "强制发布"}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AlertDialog
open={activateTarget !== null}
onOpenChange={(open) => {
if (!open) setActivateTarget(null)
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认激活版本</AlertDialogTitle>
<AlertDialogDescription>
{activateTarget
? `确认将版本 ${activateTarget.version} 设为当前激活版本吗?`
: ""}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={activating}>取消</AlertDialogCancel>
<Button onClick={() => void handleActivate()} disabled={activating}>
{activating ? <Loader2 className="size-4 animate-spin" /> : "确认激活"}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}
@@ -0,0 +1,7 @@
export default function OpenFlareLayout({
children,
}: {
children: React.ReactNode;
}) {
return children;
}
@@ -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<void>;
}) {
const [channel, setChannel] = useState<ReleaseChannel>('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 (
<Dialog open={open} onOpenChange={(next) => !next && onClose()}>
<DialogContent className="sm:max-w-xl">
<DialogHeader>
<DialogTitle>Agent 升级</DialogTitle>
<DialogDescription>
检查 GitHub 发布并向节点下发升级指令,节点将在下一次心跳后执行。
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="grid gap-3 sm:grid-cols-3">
<div className="rounded-lg border p-3">
<p className="text-xs text-muted-foreground">当前版本</p>
<p className="mt-1 text-sm font-medium">{node.version || 'unknown'}</p>
</div>
<div className="rounded-lg border p-3">
<p className="text-xs text-muted-foreground">检查通道</p>
<p className="mt-1 text-sm font-medium">
{channel === 'preview' ? '预览版' : '正式版'}
</p>
</div>
<div className="rounded-lg border p-3">
<p className="text-xs text-muted-foreground">更新状态</p>
<div className="mt-1">
<NodeStatusBadge
label={
node.update_requested
? node.update_channel === 'preview'
? '等待预览更新'
: '等待更新'
: '未下发'
}
tone={node.update_requested ? 'warning' : 'info'}
/>
</div>
</div>
</div>
{releaseQuery.isFetching ? (
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
<Loader2 className="size-4 mr-2 animate-spin" />
检查发布版本中...
</div>
) : releaseQuery.isError ? (
<p className="text-sm text-destructive">{getErrorMessage(releaseQuery.error)}</p>
) : release ? (
<div className="space-y-3 rounded-lg border p-4">
<div className="flex flex-wrap items-center gap-2">
<NodeStatusBadge
label={release.has_update ? '发现可升级版本' : '当前已是最新版本'}
tone={release.has_update ? 'warning' : 'success'}
/>
{release.prerelease ? (
<NodeStatusBadge label="Preview 发布" tone="warning" />
) : (
<NodeStatusBadge label="正式发布" tone="info" />
)}
</div>
<div className="grid gap-3 sm:grid-cols-2 text-sm">
<div>
<p className="text-xs text-muted-foreground">目标版本</p>
<p className="mt-1 font-medium">{release.tag_name || '未找到'}</p>
</div>
<div>
<p className="text-xs text-muted-foreground">发布时间</p>
<p className="mt-1">
{release.published_at
? `${formatRelativeTime(release.published_at)} · ${formatDateTime(release.published_at)}`
: '—'}
</p>
</div>
</div>
<p className="text-sm text-muted-foreground whitespace-pre-wrap">
{release.body || '暂无更新说明'}
</p>
{release.html_url ? (
<a
href={release.html_url}
target="_blank"
rel="noreferrer"
className="inline-flex items-center text-sm text-primary hover:underline"
>
查看发布详情
<ExternalLink className="size-3 ml-1" />
</a>
) : null}
</div>
) : null}
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
type="button"
variant="outline"
disabled={submitting || releaseQuery.isFetching}
onClick={() => setChannel('stable')}
>
检查正式版
</Button>
<Button
type="button"
variant="outline"
disabled={submitting || releaseQuery.isFetching}
onClick={() => setChannel('preview')}
>
检查预览版
</Button>
<Button
type="button"
disabled={
submitting ||
releaseQuery.isFetching ||
!release?.has_update ||
node.update_requested
}
onClick={() => void onConfirm(release, channel)}
>
{submitting ? '下发中...' : channel === 'preview' ? '升级到预览版' : '升级到正式版'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -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<typeof NodeService.updateNode>[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 (
<div className="py-6 px-1 space-y-6">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2">
<Button variant="ghost" size="sm" className="h-8 px-2" asChild>
<Link href="/openflare/nodes">
<ArrowLeft className="size-4" />
</Link>
</Button>
<h1 className="text-2xl font-semibold tracking-tight">{node.name}</h1>
<NodeStatusBadge
label={getNodeStatusLabel(node.status)}
tone={getNodeStatusTone(node.status)}
/>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={() => setEditorOpen(true)}>
编辑
</Button>
<Button
variant="outline"
size="sm"
className="h-7 text-xs"
onClick={handleRefresh}
disabled={isRefreshing}
>
{isRefreshing ? (
<Loader2 className="size-3.5 mr-1 animate-spin" />
) : (
<RefreshCw className="size-3.5 mr-1" />
)}
刷新
</Button>
<Button
variant="outline"
size="sm"
className="h-7 text-xs"
disabled={forceSyncMutation.isPending}
onClick={() => forceSyncMutation.mutate()}
>
<RotateCcw className="size-3.5 mr-1" />
{forceSyncMutation.isPending ? '同步中...' : '强制同步'}
</Button>
<Button
variant="outline"
size="sm"
className="h-7 text-xs"
disabled={restartMutation.isPending}
onClick={() => restartMutation.mutate()}
>
{restartMutation.isPending ? '下发中...' : '重启 OpenResty'}
</Button>
<Button
variant="secondary"
size="sm"
className="h-7 text-xs"
onClick={() => setUpgradeOpen(true)}
>
<Upload className="size-3.5 mr-1" />
{node.update_requested ? '查看升级' : '升级 Agent'}
</Button>
<Button
variant="outline"
size="sm"
className="h-7 text-xs text-destructive hover:text-destructive"
onClick={() => setDeleteOpen(true)}
>
<Trash2 className="size-3.5 mr-1" />
删除
</Button>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<Card className="border-dashed shadow-none">
<CardHeader className="pb-2">
<CardDescription>节点 ID</CardDescription>
<CardTitle className="text-sm font-medium break-all">{node.node_id}</CardTitle>
</CardHeader>
</Card>
<Card className="border-dashed shadow-none">
<CardHeader className="pb-2">
<CardDescription>Agent 版本</CardDescription>
<CardTitle className="text-sm font-medium">{node.version || 'unknown'}</CardTitle>
</CardHeader>
</Card>
<Card className="border-dashed shadow-none">
<CardHeader className="pb-2">
<CardDescription>当前配置版本</CardDescription>
<CardTitle className="text-sm font-medium">{node.current_version || '未应用'}</CardTitle>
</CardHeader>
</Card>
<Card className="border-dashed shadow-none">
<CardHeader className="pb-2">
<CardDescription>最近心跳</CardDescription>
<CardTitle className="text-sm font-medium">
{isMeaningfulTime(node.last_seen_at)
? formatRelativeTime(node.last_seen_at)
: '暂无'}
</CardTitle>
</CardHeader>
</Card>
</div>
<div className="grid gap-6 xl:grid-cols-2">
<Card className="border-dashed shadow-none">
<CardHeader>
<CardTitle className="text-base font-semibold">基本信息</CardTitle>
<CardDescription>边缘代理节点 (edge_node)</CardDescription>
</CardHeader>
<CardContent className="space-y-3 text-sm">
<div className="flex items-center justify-between gap-3">
<span className="text-muted-foreground">运行状态</span>
<NodeStatusBadge
label={getNodeStatusLabel(node.status)}
tone={getNodeStatusTone(node.status)}
/>
</div>
<div className="flex items-center justify-between gap-3">
<span className="text-muted-foreground">OpenResty 健康</span>
<NodeStatusBadge
label={getOpenrestyStatusLabel(node.openresty_status)}
tone={getOpenrestyStatusTone(node.openresty_status)}
/>
</div>
<div className="flex items-center justify-between gap-3">
<span className="text-muted-foreground">最近应用</span>
<NodeStatusBadge
label={getApplyLabel(node.latest_apply_result)}
tone={getApplyTone(node.latest_apply_result)}
/>
</div>
<div className="flex items-center justify-between gap-3">
<span className="text-muted-foreground">IP 地址</span>
<span>
{node.ip || '—'}
{node.ip_manual_override ? '(已锁定)' : ''}
</span>
</div>
<div className="flex items-center justify-between gap-3">
<span className="text-muted-foreground">地图点位</span>
<span>{node.geo_name || '未配置'}</span>
</div>
<div className="flex items-center justify-between gap-3">
<span className="text-muted-foreground">创建时间</span>
<span>{formatDateTime(node.created_at)}</span>
</div>
<div className="flex items-center justify-between gap-3">
<span className="text-muted-foreground">更新时间</span>
<span>{formatDateTime(node.updated_at)}</span>
</div>
{node.last_error ? (
<div className="rounded-lg border border-destructive/20 bg-destructive/5 p-3 text-destructive">
{node.last_error}
</div>
) : null}
</CardContent>
</Card>
<Card className="border-dashed shadow-none">
<CardHeader className="flex-row items-center justify-between space-y-0">
<div>
<CardTitle className="text-base font-semibold">运行快照</CardTitle>
<CardDescription>来自 observability 接口的最近上报</CardDescription>
</div>
<Button variant="outline" size="sm" className="h-7 text-xs" asChild>
<Link href={`/openflare/apply-logs?node_id=${encodeURIComponent(node.node_id)}`}>
<FileText className="size-3.5 mr-1" />
应用记录
</Link>
</Button>
</CardHeader>
<CardContent className="space-y-3 text-sm">
{observabilityQuery.isLoading ? (
<div className="flex items-center text-muted-foreground py-6 justify-center">
<Loader2 className="size-4 mr-2 animate-spin" />
加载运行快照中...
</div>
) : observabilityQuery.isError ? (
<p className="text-destructive">{getErrorMessage(observabilityQuery.error)}</p>
) : (
<>
<div className="flex items-center justify-between gap-3">
<span className="text-muted-foreground">活动健康事件</span>
<span>{activeHealthEvents.length}</span>
</div>
<div className="flex items-center justify-between gap-3">
<span className="text-muted-foreground">主机名</span>
<span>{profile?.hostname || '—'}</span>
</div>
<div className="flex items-center justify-between gap-3">
<span className="text-muted-foreground">操作系统</span>
<span>
{profile
? `${profile.os_name || 'unknown'} ${profile.os_version || ''}`.trim()
: '—'}
</span>
</div>
<div className="flex items-center justify-between gap-3">
<span className="text-muted-foreground">CPU 使用率</span>
<span>
{latestMetric ? `${latestMetric.cpu_usage_percent.toFixed(1)}%` : '—'}
</span>
</div>
<div className="flex items-center justify-between gap-3">
<span className="text-muted-foreground">OpenResty 连接数</span>
<span>{latestMetric?.openresty_connections ?? '—'}</span>
</div>
<div className="flex items-center justify-between gap-3">
<span className="text-muted-foreground">OpenResty 状态消息</span>
<span className="text-right max-w-[60%] break-words">
{node.openresty_message || '无'}
</span>
</div>
</>
)}
</CardContent>
</Card>
</div>
<NodeEditorDialog
open={editorOpen}
node={node}
submitting={saveMutation.isPending}
onClose={() => setEditorOpen(false)}
onSubmit={async (payload) => {
await saveMutation.mutateAsync(payload);
}}
/>
<AgentUpdateDialog
open={upgradeOpen}
node={node}
submitting={upgradeMutation.isPending}
onClose={() => setUpgradeOpen(false)}
onConfirm={async (release, channel) => {
await upgradeMutation.mutateAsync({ release, channel });
}}
/>
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认删除节点</AlertDialogTitle>
<AlertDialogDescription>
确认删除节点「{node.name}」吗?删除后该节点需要重新创建并重新接入。
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleteMutation.isPending}>取消</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-white hover:bg-destructive/90"
disabled={deleteMutation.isPending}
onClick={() => deleteMutation.mutate()}
>
{deleteMutation.isPending ? '删除中...' : '确认删除'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
@@ -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<void>;
}) {
const [form, setForm] = useState<FormState>(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 (
<Dialog open={open} onOpenChange={(next) => !next && onClose()}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{node ? '编辑节点' : '新增节点'}</DialogTitle>
<DialogDescription>
预创建节点后可在详情页查看专属 Token 与部署命令。
</DialogDescription>
</DialogHeader>
<form onSubmit={(event) => void handleSubmit(event)} className="space-y-4">
<div className="space-y-2">
<Label>节点类型</Label>
<Select
value={form.node_type}
disabled={Boolean(node)}
onValueChange={(value) =>
setForm((prev) => ({ ...prev, node_type: value as NodeType }))
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="edge_node">Edge 节点</SelectItem>
<SelectItem value="tunnel_relay">Relay 节点</SelectItem>
<SelectItem value="tunnel_client">Tunnel 节点</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="node-name">节点名称</Label>
<Input
id="node-name"
value={form.name}
onChange={(event) => setForm((prev) => ({ ...prev, name: event.target.value }))}
placeholder="例如 edge-hk-01"
/>
</div>
<div className="space-y-2">
<Label htmlFor="node-ip">节点 IP</Label>
<Input
id="node-ip"
value={form.ip}
onChange={(event) => setForm((prev) => ({ ...prev, ip: event.target.value }))}
placeholder="可选,接入后自动上报"
/>
</div>
<div className="flex items-center justify-between rounded-lg border px-3 py-2">
<div>
<p className="text-sm font-medium">锁定 IP</p>
<p className="text-xs text-muted-foreground">启用后管理端不再自动覆盖 IP</p>
</div>
<Switch
checked={form.ip_manual_override}
onCheckedChange={(checked) =>
setForm((prev) => ({ ...prev, ip_manual_override: checked }))
}
/>
</div>
<div className="flex items-center justify-between rounded-lg border px-3 py-2">
<div>
<p className="text-sm font-medium">自动更新 Agent</p>
<p className="text-xs text-muted-foreground">启用后节点将自动拉取正式版更新</p>
</div>
<Switch
checked={form.auto_update_enabled}
onCheckedChange={(checked) =>
setForm((prev) => ({ ...prev, auto_update_enabled: checked }))
}
/>
</div>
{form.node_type === 'tunnel_relay' ? (
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="relay-bind-port">绑定端口</Label>
<Input
id="relay-bind-port"
value={form.relay_bind_port}
onChange={(event) =>
setForm((prev) => ({ ...prev, relay_bind_port: event.target.value }))
}
/>
</div>
<div className="space-y-2">
<Label htmlFor="relay-vhost-port">VHost 端口</Label>
<Input
id="relay-vhost-port"
value={form.relay_vhost_http_port}
onChange={(event) =>
setForm((prev) => ({ ...prev, relay_vhost_http_port: event.target.value }))
}
/>
</div>
</div>
) : null}
<div className="flex items-center justify-between rounded-lg border px-3 py-2">
<div>
<p className="text-sm font-medium">手动地图点位</p>
<p className="text-xs text-muted-foreground">用于总览地图展示</p>
</div>
<Switch
checked={form.geo_manual_override}
onCheckedChange={(checked) =>
setForm((prev) => ({ ...prev, geo_manual_override: checked }))
}
/>
</div>
{form.geo_manual_override ? (
<div className="grid gap-3 sm:grid-cols-3">
<div className="space-y-2 sm:col-span-3">
<Label htmlFor="geo-name">位置名称</Label>
<Input
id="geo-name"
value={form.geo_name}
onChange={(event) =>
setForm((prev) => ({ ...prev, geo_name: event.target.value }))
}
/>
</div>
<div className="space-y-2">
<Label htmlFor="geo-lat">纬度</Label>
<Input
id="geo-lat"
value={form.geo_latitude}
onChange={(event) =>
setForm((prev) => ({ ...prev, geo_latitude: event.target.value }))
}
/>
</div>
<div className="space-y-2 sm:col-span-2">
<Label htmlFor="geo-lng">经度</Label>
<Input
id="geo-lng"
value={form.geo_longitude}
onChange={(event) =>
setForm((prev) => ({ ...prev, geo_longitude: event.target.value }))
}
/>
</div>
</div>
) : null}
{error ? <p className="text-sm text-destructive">{error}</p> : null}
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose} disabled={submitting}>
取消
</Button>
<Button type="submit" disabled={submitting}>
{submitting ? '保存中...' : node ? '保存修改' : '新增节点'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,30 @@
import {Badge} from '@/components/ui/badge';
import {cn} from '@/lib/utils';
import type {StatusTone} from './node-utils';
const toneClassName: Record<StatusTone, string> = {
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 (
<Badge
variant="outline"
className={cn('text-[10px] rounded-full py-0 px-2 font-medium', toneClassName[tone], className)}
>
{label}
</Badge>
);
}
@@ -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<T extends { node_type: string }>(
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 (
<div className="flex flex-wrap gap-2">
{filters.map((item) => (
<Link
key={item.key}
href={item.href}
className={cn(
'inline-flex items-center rounded-full border px-3 py-1.5 text-xs transition',
activeFilter === item.key
? 'border-foreground/30 bg-accent text-foreground'
: 'border-border text-muted-foreground hover:bg-muted/50',
)}
>
{item.label}
</Link>
))}
</div>
);
}
@@ -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 : '请求失败,请稍后重试。';
}
@@ -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 (
<div className="border border-dashed rounded-lg overflow-hidden">
<Table>
<TableHeader>
<TableRow className="border-dashed hover:bg-transparent">
<TableHead className="py-2 h-8">节点</TableHead>
<TableHead className="py-2 h-8">状态</TableHead>
<TableHead className="py-2 h-8">Version</TableHead>
<TableHead className="py-2 h-8">运行健康</TableHead>
<TableHead className="py-2 h-8">当前版本</TableHead>
<TableHead className="py-2 h-8">最近应用</TableHead>
<TableHead className="py-2 h-8">最近心跳</TableHead>
<TableHead className="py-2 h-8 text-right">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{nodes.map((node) => (
<TableRow key={node.id} className="border-dashed align-top">
<TableCell className="py-3">
<div className="space-y-1">
<div className="flex items-center gap-2">
<p className="font-medium">{node.name}</p>
<NodeStatusBadge label={getNodeTypeLabel(node.node_type)} tone="info" />
</div>
<p className="text-xs text-muted-foreground">
IP:{node.ip || '—'}
{node.ip_manual_override ? '(已锁定)' : ''}
</p>
<p className="text-xs text-muted-foreground">
位置:{node.geo_name || '未配置地图点位'}
</p>
</div>
</TableCell>
<TableCell className="py-3">
<NodeStatusBadge
label={getNodeStatusLabel(node.status)}
tone={getNodeStatusTone(node.status)}
/>
</TableCell>
<TableCell className="py-3 text-muted-foreground">
{node.version || 'unknown'}
</TableCell>
<TableCell className="py-3">
{node.node_type === 'tunnel_relay' ? (
<NodeStatusBadge
label={getRelayStatusLabel(node.relay_status)}
tone={getRelayStatusTone(node.relay_status)}
/>
) : node.node_type === 'tunnel_client' ? (
<NodeStatusBadge
label={node.status === 'online' ? '运行中' : '未知'}
tone={node.status === 'online' ? 'success' : 'warning'}
/>
) : (
<NodeStatusBadge
label={getOpenrestyStatusLabel(node.openresty_status)}
tone={getOpenrestyStatusTone(node.openresty_status)}
/>
)}
</TableCell>
<TableCell className="py-3 text-muted-foreground">
{node.current_version ||
(node.node_type === 'tunnel_relay' ? '实时配置' : '未应用')}
</TableCell>
<TableCell className="py-3">
{node.node_type === 'tunnel_relay' ? (
<span className="text-sm text-muted-foreground">—</span>
) : (
<NodeStatusBadge
label={getApplyLabel(node.latest_apply_result)}
tone={getApplyTone(node.latest_apply_result)}
/>
)}
</TableCell>
<TableCell className="py-3 text-muted-foreground">
{isWSConnectedLastSeen(node.last_seen_at)
? 'WS 已连接'
: isMeaningfulTime(node.last_seen_at)
? `${formatRelativeTime(node.last_seen_at)} · ${formatDateTime(node.last_seen_at)}`
: '暂无'}
</TableCell>
<TableCell className="py-3">
<div className="flex flex-wrap justify-end gap-1">
<Button variant="outline" size="sm" className="h-7 text-xs" asChild>
<Link href={`/openflare/nodes/detail?id=${node.id}`}>详情</Link>
</Button>
<Button
variant="outline"
size="sm"
className="h-7 text-xs"
onClick={() => onEdit(node)}
>
<Pencil className="size-3 mr-1" />
编辑
</Button>
<Button
variant="outline"
size="sm"
className="h-7 text-xs text-destructive hover:text-destructive"
disabled={deletingId === node.id}
onClick={() => onDelete(node)}
>
<Trash2 className="size-3 mr-1" />
删除
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}
@@ -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 (
<div className="py-6 px-1">
<EmptyStateWithBorder
icon={Server}
description="缺少节点 ID,请从节点列表进入详情页。"
/>
</div>
);
}
if (nodesQuery.isLoading) {
return (
<div className="py-6 px-1">
<LoadingStateWithBorder icon={Server} description="加载节点详情中..." />
</div>
);
}
if (nodesQuery.isError) {
return (
<div className="py-6 px-1">
<div className="p-8 border border-dashed rounded-lg">
<ErrorInline
message={getErrorMessage(nodesQuery.error)}
onRetry={() => void nodesQuery.refetch()}
className="justify-center"
/>
</div>
</div>
);
}
if (!node) {
return (
<div className="py-6 px-1 space-y-4">
<Button variant="ghost" size="sm" className="h-8 px-2" asChild>
<Link href="/openflare/nodes">
<ArrowLeft className="size-4 mr-1" />
返回节点列表
</Link>
</Button>
<EmptyStateWithBorder
icon={Server}
description="节点不存在,可能已被删除或 ID 无效。"
/>
</div>
);
}
if (node.node_type !== 'edge_node') {
return (
<div className="py-6 px-1 space-y-4">
<Button variant="ghost" size="sm" className="h-8 px-2" asChild>
<Link href="/openflare/nodes">
<ArrowLeft className="size-4 mr-1" />
返回节点列表
</Link>
</Button>
<EmptyStateWithBorder
icon={Server}
description={`当前仅实现 Edge 节点详情骨架,${node.node_type} 详情将在后续任务中补齐。`}
/>
</div>
);
}
return <EdgeNodeDetail node={node} />;
}
@@ -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<NodeItem | null>(null);
const [editorOpen, setEditorOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<NodeItem | null>(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 (
<div className="py-6 px-1 space-y-6">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<Server className="size-5 text-primary" />
<h1 className="text-2xl font-semibold tracking-tight">节点管理</h1>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" className="h-7 text-xs" asChild>
<Link href="/openflare/apply-logs">应用记录</Link>
</Button>
<Button variant="secondary" size="sm" className="h-7 text-xs" onClick={handleCreate}>
<Plus className="size-3.5 mr-1" />
新增节点
</Button>
</div>
</div>
<Card className="border-dashed shadow-none">
<CardHeader className="pb-3">
<div className="flex items-center justify-between gap-3">
<div>
<CardTitle className="text-base font-semibold">节点列表</CardTitle>
<CardDescription>{getFilterDescription(nodeFilter)}</CardDescription>
</div>
<Button
variant="outline"
size="sm"
className="h-7 text-xs"
onClick={handleRefresh}
disabled={nodesQuery.isFetching}
>
{nodesQuery.isFetching ? (
<Loader2 className="size-3.5 mr-1 animate-spin" />
) : (
<RefreshCw className="size-3.5 mr-1" />
)}
立即刷新
</Button>
</div>
</CardHeader>
<CardContent className="space-y-4">
<NodeTypeFilter />
{nodesQuery.isLoading ? (
<LoadingStateWithBorder icon={Server} description="加载节点列表中..." />
) : nodesQuery.isError ? (
<div className="p-8 border border-dashed rounded-lg">
<ErrorInline
message={getErrorMessage(nodesQuery.error)}
onRetry={handleRefresh}
className="justify-center"
/>
</div>
) : filteredNodes.length === 0 ? (
<EmptyStateWithBorder
icon={Server}
description={nodes.length === 0 ? '暂无节点,请先创建一个节点。' : '当前筛选无结果'}
/>
) : (
<NodesTable
nodes={filteredNodes}
deletingId={deleteMutation.isPending ? deleteTarget?.id ?? null : null}
onEdit={handleEdit}
onDelete={setDeleteTarget}
/>
)}
</CardContent>
</Card>
<NodeEditorDialog
open={editorOpen}
node={editingNode}
submitting={saveMutation.isPending}
onClose={() => {
setEditorOpen(false);
setEditingNode(null);
}}
onSubmit={async (payload) => {
await saveMutation.mutateAsync(payload);
}}
/>
<AlertDialog open={Boolean(deleteTarget)} onOpenChange={(open) => !open && setDeleteTarget(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认删除节点</AlertDialogTitle>
<AlertDialogDescription>
确认删除节点「{deleteTarget?.name}」吗?删除后该节点需要重新创建并重新接入。
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleteMutation.isPending}>取消</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-white hover:bg-destructive/90"
disabled={deleteMutation.isPending}
onClick={() => deleteTarget && deleteMutation.mutate(deleteTarget.id)}
>
{deleteMutation.isPending ? '删除中...' : '确认删除'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
@@ -0,0 +1,7 @@
import {MapPin} from 'lucide-react';
import {PlaceholderPage} from '../components/placeholder-page';
export default function OriginsPage() {
return <PlaceholderPage title="源站" icon={MapPin} />;
}
@@ -0,0 +1,7 @@
import {LayoutDashboard} from 'lucide-react';
import {PlaceholderPage} from './components/placeholder-page';
export default function OpenFlareDashboardPage() {
return <PlaceholderPage title="总览" icon={LayoutDashboard} />;
}
@@ -0,0 +1,7 @@
import {FileText} from 'lucide-react';
import {PlaceholderPage} from '../components/placeholder-page';
export default function PagesPage() {
return <PlaceholderPage title="Pages" icon={FileText} />;
}
@@ -0,0 +1,7 @@
import {Gauge} from 'lucide-react';
import {PlaceholderPage} from '../components/placeholder-page';
export default function PerformancePage() {
return <PlaceholderPage title="性能" icon={Gauge} />;
}
@@ -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}`;
}
@@ -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<typeof createProxyRouteSchema>;
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<CreateProxyRouteFormValues>({
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 (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="right" className="w-full sm:max-w-lg overflow-y-auto">
<SheetHeader>
<SheetTitle>新建规则</SheetTitle>
<SheetDescription>
创建网站后可进入详情页继续配置 HTTPS、缓存和限流等高级选项。
</SheetDescription>
</SheetHeader>
<Form {...form}>
<form onSubmit={handleSubmit} className="space-y-4 px-4 pb-4">
<FormField
control={form.control}
name="site_name"
render={({ field }) => (
<FormItem>
<FormLabel>站点标识</FormLabel>
<FormControl>
<Input placeholder="marketing-site" {...field} />
</FormControl>
<FormDescription>可选,留空时会自动使用域名。</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="domain"
render={({ field }) => (
<FormItem>
<FormLabel>主域名</FormLabel>
<FormControl>
<Input placeholder="www.example.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="origin_url"
render={({ field }) => (
<FormItem>
<FormLabel>上游地址</FormLabel>
<FormControl>
<Input placeholder="https://origin.internal:443" {...field} />
</FormControl>
<FormDescription>主回源完整 URL,创建后可在详情页添加多上游。</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="enabled"
render={({ field }) => (
<FormItem className="flex items-center justify-between rounded-lg border p-3">
<div className="space-y-0.5">
<FormLabel>创建后立即启用</FormLabel>
<FormDescription>关闭后站点会以草稿保存。</FormDescription>
</div>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="remark"
render={({ field }) => (
<FormItem>
<FormLabel>备注</FormLabel>
<FormControl>
<Textarea rows={3} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{form.formState.errors.root?.message ? (
<p className="text-sm text-destructive">{form.formState.errors.root.message}</p>
) : null}
<SheetFooter className="px-0">
<Button type="submit" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? '创建中...' : '创建'}
</Button>
</SheetFooter>
</form>
</Form>
</SheetContent>
</Sheet>
);
}
@@ -0,0 +1,25 @@
'use client';
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from '@/components/ui/card';
import type {ProxyRouteItem} from '@/lib/services/openflare';
interface AuthSectionProps {
route: ProxyRouteItem;
}
export function AuthSection({ route }: AuthSectionProps) {
return (
<Card className="border-dashed shadow-none">
<CardHeader>
<CardTitle className="text-sm font-semibold">认证配置</CardTitle>
<CardDescription>配置基础鉴权访问,需要输入账号密码才能访问网站。</CardDescription>
</CardHeader>
<CardContent className="space-y-3 text-sm text-muted-foreground">
<p>待实现</p>
<p className="text-xs">
基础认证:{route.basic_auth_enabled ? '已启用' : '未启用'};PoW:{route.pow_enabled ? '已启用' : '未启用'}
</p>
</CardContent>
</Card>
);
}
@@ -0,0 +1,25 @@
'use client';
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from '@/components/ui/card';
import type {ProxyRouteItem} from '@/lib/services/openflare';
interface CacheSectionProps {
route: ProxyRouteItem;
}
export function CacheSection({ route }: CacheSectionProps) {
return (
<Card className="border-dashed shadow-none">
<CardHeader>
<CardTitle className="text-sm font-semibold">缓存</CardTitle>
<CardDescription>配置站点缓存策略。</CardDescription>
</CardHeader>
<CardContent className="space-y-3 text-sm text-muted-foreground">
<p>待实现</p>
<p className="text-xs">
缓存状态:{route.cache_enabled ? '已启用' : '未启用'};策略:{route.cache_policy || 'url'}
</p>
</CardContent>
</Card>
);
}
@@ -0,0 +1,26 @@
'use client';
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from '@/components/ui/card';
import type {ProxyRouteItem} from '@/lib/services/openflare';
interface DomainSectionProps {
route: ProxyRouteItem;
}
export function DomainSection({ route }: DomainSectionProps) {
return (
<Card className="border-dashed shadow-none">
<CardHeader>
<CardTitle className="text-sm font-semibold">域名设置</CardTitle>
<CardDescription>维护站点标识、域名列表和证书绑定。</CardDescription>
</CardHeader>
<CardContent className="space-y-3 text-sm text-muted-foreground">
<p>待实现</p>
<p className="text-xs">
当前主域名:{route.primary_domain || route.domain}
{route.domains.length > 1 ? `(共 ${route.domains.length} 个域名)` : ''}
</p>
</CardContent>
</Card>
);
}
@@ -0,0 +1,26 @@
'use client';
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from '@/components/ui/card';
import type {ProxyRouteItem} from '@/lib/services/openflare';
interface LimitsSectionProps {
route: ProxyRouteItem;
}
export function LimitsSection({ route }: LimitsSectionProps) {
return (
<Card className="border-dashed shadow-none">
<CardHeader>
<CardTitle className="text-sm font-semibold">流量限制</CardTitle>
<CardDescription>设置连接数和限速。</CardDescription>
</CardHeader>
<CardContent className="space-y-3 text-sm text-muted-foreground">
<p>待实现</p>
<p className="text-xs">
当前限速:{route.limit_rate || '未配置'};单 IP 连接数:{route.limit_conn_per_ip};单 Server
连接数:{route.limit_conn_per_server}
</p>
</CardContent>
</Card>
);
}
@@ -0,0 +1,25 @@
'use client';
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from '@/components/ui/card';
import type {ProxyRouteItem} from '@/lib/services/openflare';
import {getUpstreamSummary} from '../../components/helpers';
interface ProxySectionProps {
route: ProxyRouteItem;
}
export function ProxySection({ route }: ProxySectionProps) {
return (
<Card className="border-dashed shadow-none">
<CardHeader>
<CardTitle className="text-sm font-semibold">反向代理</CardTitle>
<CardDescription>配置主回源和上游地址。</CardDescription>
</CardHeader>
<CardContent className="space-y-3 text-sm text-muted-foreground">
<p>待实现</p>
<p className="text-xs">当前上游:{getUpstreamSummary(route)}</p>
</CardContent>
</Card>
);
}
@@ -0,0 +1,48 @@
'use client';
import Link from 'next/link';
import {ArrowLeft, Route, Save, Upload} from 'lucide-react';
import {Badge} from '@/components/ui/badge';
import {Button} from '@/components/ui/button';
import type {ProxyRouteItem} from '@/lib/services/openflare';
interface RouteHeaderProps {
route: ProxyRouteItem;
}
export function RouteHeader({ route }: RouteHeaderProps) {
return (
<div className="space-y-4">
<Button variant="ghost" size="sm" className="h-8 gap-1.5 px-0 text-xs" asChild>
<Link href="/openflare/proxy-routes">
<ArrowLeft className="size-3.5" />
返回规则列表
</Link>
</Button>
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-2">
<Route className="size-5 text-primary" />
<div className="flex flex-wrap items-center gap-2">
<h1 className="text-2xl font-semibold tracking-tight">{route.site_name}</h1>
<Badge variant={route.enabled ? 'default' : 'secondary'}>
{route.enabled ? '已启用' : '已停用'}
</Badge>
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button size="sm" variant="secondary" className="h-8 gap-1.5 text-xs" disabled>
<Save className="size-3.5" />
保存(待实现)
</Button>
<Button size="sm" className="h-8 gap-1.5 text-xs" disabled>
<Upload className="size-3.5" />
发布配置(待实现)
</Button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,23 @@
'use client';
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from '@/components/ui/card';
import type {ProxyRouteItem} from '@/lib/services/openflare';
interface WafSectionProps {
route: ProxyRouteItem;
}
export function WafSection({ route }: WafSectionProps) {
return (
<Card className="border-dashed shadow-none">
<CardHeader>
<CardTitle className="text-sm font-semibold">WAF</CardTitle>
<CardDescription>绑定 WAF 规则组,并查看当前站点生效策略。</CardDescription>
</CardHeader>
<CardContent className="space-y-3 text-sm text-muted-foreground">
<p>待实现</p>
<p className="text-xs">站点 ID:{route.id}</p>
</CardContent>
</Card>
);
}
@@ -0,0 +1,144 @@
'use client';
import {useCallback, useEffect, useState} from 'react';
import {useRouter, useSearchParams} from 'next/navigation';
import {toast} from 'sonner';
import {Skeleton} from '@/components/ui/skeleton';
import {Tabs, TabsContent, TabsList, TabsTrigger} from '@/components/ui/tabs';
import type {ProxyRouteConfigSection, ProxyRouteItem} from '@/lib/services/openflare';
import {ProxyRouteService} from '@/lib/services/openflare';
import {getProxyRouteConfigSection, proxyRouteConfigSections} from '../components/helpers';
import {AuthSection} from './components/auth-section';
import {CacheSection} from './components/cache-section';
import {DomainSection} from './components/domain-section';
import {LimitsSection} from './components/limits-section';
import {ProxySection} from './components/proxy-section';
import {RouteHeader} from './components/route-header';
import {WafSection} from './components/waf-section';
export function ProxyRouteDetailPageClient() {
const router = useRouter();
const searchParams = useSearchParams();
const routeId = Number(searchParams.get('id'));
const activeSection = getProxyRouteConfigSection(searchParams.get('section'));
const [route, setRoute] = useState<ProxyRouteItem | null>(null);
const [loading, setLoading] = useState(true);
const handleSectionChange = useCallback(
(section: ProxyRouteConfigSection) => {
const params = new URLSearchParams(searchParams.toString());
params.set('section', section);
router.replace(`/openflare/proxy-routes/detail?${params.toString()}`);
},
[router, searchParams],
);
useEffect(() => {
if (!Number.isFinite(routeId) || routeId <= 0) {
setLoading(false);
setRoute(null);
return;
}
let cancelled = false;
const fetchRoute = async () => {
setLoading(true);
try {
const data = await ProxyRouteService.getById(routeId);
if (!cancelled) {
setRoute(data);
}
} catch (error) {
if (!cancelled) {
setRoute(null);
toast.error('规则详情加载失败', {
description: error instanceof Error ? error.message : '未知错误',
});
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
};
void fetchRoute();
return () => {
cancelled = true;
};
}, [routeId]);
if (!Number.isFinite(routeId) || routeId <= 0) {
return (
<div className="py-6 px-1">
<p className="text-sm text-muted-foreground">缺少有效的规则 ID。</p>
</div>
);
}
if (loading) {
return (
<div className="py-6 px-1 space-y-6">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-10 w-full max-w-xl" />
<Skeleton className="h-64 w-full" />
</div>
);
}
if (!route) {
return (
<div className="py-6 px-1">
<p className="text-sm text-muted-foreground">未找到对应规则,请返回列表重试。</p>
</div>
);
}
return (
<div className="py-6 px-1 space-y-6">
<RouteHeader route={route} />
<Tabs
value={activeSection}
onValueChange={(value) => handleSectionChange(value as ProxyRouteConfigSection)}
className="w-full"
>
<TabsList variant="line" className="w-fit inline-flex gap-8 mb-6">
{proxyRouteConfigSections.map((section) => (
<TabsTrigger
key={section.key}
value={section.key}
className="px-0 pb-2 text-xs font-semibold"
>
{section.label}
</TabsTrigger>
))}
</TabsList>
<TabsContent value="domains" className="focus-visible:outline-none">
<DomainSection route={route} />
</TabsContent>
<TabsContent value="limits" className="focus-visible:outline-none">
<LimitsSection route={route} />
</TabsContent>
<TabsContent value="proxy" className="focus-visible:outline-none">
<ProxySection route={route} />
</TabsContent>
<TabsContent value="cache" className="focus-visible:outline-none">
<CacheSection route={route} />
</TabsContent>
<TabsContent value="waf" className="focus-visible:outline-none">
<WafSection route={route} />
</TabsContent>
<TabsContent value="auth" className="focus-visible:outline-none">
<AuthSection route={route} />
</TabsContent>
</Tabs>
</div>
);
}
@@ -0,0 +1,23 @@
import {Suspense} from 'react';
import {Skeleton} from '@/components/ui/skeleton';
import {ProxyRouteDetailPageClient} from './page-client';
function DetailPageFallback() {
return (
<div className="py-6 px-1 space-y-6">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-10 w-full max-w-xl" />
<Skeleton className="h-64 w-full" />
</div>
);
}
export default function ProxyRouteDetailPage() {
return (
<Suspense fallback={<DetailPageFallback />}>
<ProxyRouteDetailPageClient />
</Suspense>
);
}
@@ -0,0 +1,253 @@
'use client';
import Link from 'next/link';
import {useRouter} from 'next/navigation';
import {useCallback, useEffect, useMemo, useState} from 'react';
import {Plus, RefreshCw, Route, 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, CardHeader, CardTitle} from '@/components/ui/card';
import {Input} from '@/components/ui/input';
import {Skeleton} from '@/components/ui/skeleton';
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow,} from '@/components/ui/table';
import type {ProxyRouteItem} from '@/lib/services/openflare';
import {ProxyRouteService} from '@/lib/services/openflare';
import {getUpstreamSummary} from './components/helpers';
import {ProxyRouteCreateSheet} from './components/proxy-route-create-sheet';
export function ProxyRoutesPageClient() {
const router = useRouter();
const [routes, setRoutes] = useState<ProxyRouteItem[]>([]);
const [loading, setLoading] = useState(true);
const [keyword, setKeyword] = useState('');
const [isCreateOpen, setIsCreateOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<ProxyRouteItem | null>(null);
const [deleting, setDeleting] = useState(false);
const fetchRoutes = useCallback(async () => {
setLoading(true);
try {
const data = await ProxyRouteService.list();
setRoutes(data);
} catch (error) {
toast.error('规则列表加载失败', {
description: error instanceof Error ? error.message : '未知错误',
});
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void fetchRoutes();
}, [fetchRoutes]);
const filteredRoutes = useMemo(() => {
const normalizedKeyword = keyword.trim().toLowerCase();
if (!normalizedKeyword) {
return routes;
}
return routes.filter((route) => {
const haystack = [
route.site_name,
route.primary_domain,
...route.domains,
route.origin_url,
route.remark,
]
.join(' ')
.toLowerCase();
return haystack.includes(normalizedKeyword);
});
}, [keyword, routes]);
const handleDelete = async () => {
if (!deleteTarget) {
return;
}
setDeleting(true);
try {
await ProxyRouteService.delete(deleteTarget.id);
toast.success('网站已删除');
setDeleteTarget(null);
await fetchRoutes();
} catch (error) {
toast.error('删除失败', {
description: error instanceof Error ? error.message : '未知错误',
});
} finally {
setDeleting(false);
}
};
return (
<div className="py-6 px-1 space-y-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-2">
<Route className="size-5 text-primary" />
<h1 className="text-2xl font-semibold tracking-tight">规则配置</h1>
</div>
<div className="flex flex-wrap gap-2">
<Button
size="sm"
variant="secondary"
className="h-8 gap-1.5 text-xs"
onClick={() => void fetchRoutes()}
disabled={loading}
>
<RefreshCw className={`size-3 ${loading ? 'animate-spin' : ''}`} />
刷新
</Button>
<Button
size="sm"
className="h-8 gap-1.5 text-xs"
onClick={() => setIsCreateOpen(true)}
>
<Plus className="size-3.5" />
新建规则
</Button>
</div>
</div>
<Card className="border-border/40 shadow-sm">
<CardHeader className="pb-3">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<CardTitle className="text-sm font-semibold">规则列表</CardTitle>
<Input
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
placeholder="搜索站点、域名或上游..."
className="h-8 max-w-sm text-xs"
/>
</div>
</CardHeader>
<CardContent>
{loading ? (
<div className="space-y-2">
{Array.from({ length: 5 }).map((_, index) => (
<Skeleton key={index} className="h-10 w-full" />
))}
</div>
) : filteredRoutes.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-2 py-12 text-center">
<p className="text-sm font-medium">
{routes.length === 0 ? '暂无网站' : '没有匹配结果'}
</p>
<p className="text-xs text-muted-foreground">
{routes.length === 0
? '先创建一个网站,再进入配置子页面继续补齐 HTTPS、缓存和限流。'
: '试试调整搜索词,或者清空筛选条件。'}
</p>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>站点名称</TableHead>
<TableHead>域名</TableHead>
<TableHead>状态</TableHead>
<TableHead>上游</TableHead>
<TableHead className="text-right">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredRoutes.map((route) => (
<TableRow key={route.id}>
<TableCell className="font-medium">{route.site_name}</TableCell>
<TableCell className="max-w-[220px] truncate" title={route.domains.join(', ')}>
{route.primary_domain || route.domain}
</TableCell>
<TableCell>
<Badge variant={route.enabled ? 'default' : 'secondary'}>
{route.enabled ? '已启用' : '已停用'}
</Badge>
</TableCell>
<TableCell className="max-w-[280px] truncate" title={getUpstreamSummary(route)}>
{getUpstreamSummary(route)}
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button variant="outline" size="sm" className="h-7 text-xs" asChild>
<Link href={`/openflare/proxy-routes/detail?id=${route.id}`}>
配置
</Link>
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 text-xs text-destructive hover:text-destructive"
onClick={() => setDeleteTarget(route)}
>
<Trash2 className="size-3.5" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
<ProxyRouteCreateSheet
open={isCreateOpen}
onOpenChange={setIsCreateOpen}
onCreated={(route) => {
toast.success('网站已创建');
void fetchRoutes();
router.push(`/openflare/proxy-routes/detail?id=${route.id}&section=domains`);
}}
/>
<AlertDialog
open={deleteTarget !== null}
onOpenChange={(open) => {
if (!open) {
setDeleteTarget(null);
}
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认删除网站</AlertDialogTitle>
<AlertDialogDescription>
{deleteTarget
? `确认删除网站 ${deleteTarget.site_name} 吗?这会删除该站点下的全部域名与配置。`
: null}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleting}>取消</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-white hover:bg-destructive/90"
disabled={deleting}
onClick={(event) => {
event.preventDefault();
void handleDelete();
}}
>
{deleting ? '删除中...' : '删除'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
@@ -0,0 +1,5 @@
import {ProxyRoutesPageClient} from './page-client';
export default function ProxyRoutesPage() {
return <ProxyRoutesPageClient />;
}
@@ -0,0 +1,7 @@
import {Shield} from 'lucide-react';
import {PlaceholderPage} from '../components/placeholder-page';
export default function WafPage() {
return <PlaceholderPage title="WAF" icon={Shield} />;
}
@@ -0,0 +1,7 @@
import {Globe} from 'lucide-react';
import {PlaceholderPage} from '../components/placeholder-page';
export default function WebsitesPage() {
return <PlaceholderPage title="网站" icon={Globe} />;
}
@@ -64,6 +64,7 @@ import {
import {useUser} from "@/contexts/user-context"
import {usePublicConfig} from "@/hooks/use-public-config"
import {openflareNavItems} from "@/lib/navigation/openflare-nav"
/* 导航数据 */
const data = {
@@ -326,6 +327,36 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
</SidebarGroup>
)}
{openflareNavItems.length > 0 && (
<SidebarGroup className="py-0 pt-4">
<SidebarGroupLabel className="text-xs font-normal text-muted-foreground">
OpenFlare
</SidebarGroupLabel>
<SidebarGroupContent className="py-1">
<SidebarMenu className="gap-1">
{openflareNavItems.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton
tooltip={item.title}
isActive={
item.url === "/openflare"
? pathname === "/openflare"
: pathname === item.url || pathname.startsWith(`${item.url}/`)
}
asChild
>
<Link href={item.url} onClick={handleCloseSidebar}>
<item.icon />
<span>{item.title}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
)}
{user?.is_admin && adminFiltered.length > 0 && (
<SidebarGroup className="py-0 pt-4">
<SidebarGroupLabel className="text-xs font-normal text-muted-foreground">
+1 -1
View File
@@ -1,6 +1,6 @@
"use client"
import {createContext, useCallback, useContext, useEffect, useRef, useState} from 'react'
import {createContext, type ReactNode, useCallback, useContext, useEffect, useRef, useState} from 'react'
import {AuthService} from '@/lib/services/auth'
import {User} from '@/lib/services/auth/types'
@@ -0,0 +1,35 @@
import type {LucideIcon} from 'lucide-react';
import {
ClipboardList,
FileText,
Gauge,
GitBranch,
Globe,
LayoutDashboard,
MapPin,
Route,
ScrollText,
Server,
Shield,
} from 'lucide-react';
export interface OpenFlareNavItem {
title: string;
url: string;
icon: LucideIcon;
}
/** OpenFlare 业务控制台侧栏导航 */
export const openflareNavItems: OpenFlareNavItem[] = [
{title: '总览', url: '/openflare', icon: LayoutDashboard},
{title: '节点', url: '/openflare/nodes', icon: Server},
{title: '规则', url: '/openflare/proxy-routes', icon: Route},
{title: 'Pages', url: '/openflare/pages', icon: FileText},
{title: '网站', url: '/openflare/websites', icon: Globe},
{title: 'WAF', url: '/openflare/waf', icon: Shield},
{title: '源站', url: '/openflare/origins', icon: MapPin},
{title: '发布', url: '/openflare/config-versions', icon: GitBranch},
{title: '访问日志', url: '/openflare/access-logs', icon: ScrollText},
{title: '应用日志', url: '/openflare/apply-logs', icon: ClipboardList},
{title: '性能', url: '/openflare/performance', icon: Gauge},
];
+28 -1
View File
@@ -26,6 +26,7 @@ import {DbManageService} from './db-manage';
import {PushService} from './push';
import {AdminUploadService, UploadService} from './upload';
import {UserService} from './user';
import {ApplyLogService, ConfigVersionService, NodeService, ProxyRouteService,} from './openflare';
const services = {
auth: AuthService,
@@ -43,6 +44,10 @@ const services = {
adminUpload: AdminUploadService,
dbManage: DbManageService,
push: PushService,
openflareNode: NodeService,
openflareProxyRoute: ProxyRouteService,
openflareConfigVersion: ConfigVersionService,
openflareApplyLog: ApplyLogService,
} as const;
export default services;
@@ -142,4 +147,26 @@ export { DbManageService } from './db-manage';
export type { DBOverview, TableDataResponse, ExecuteSQLResponse } from './db-manage';
export { PushService } from './push';
export type { PushEvent, PushHistory, PushChannelConfig, ListPushHistoriesRequest, ListPushHistoriesResponse, UpdatePushEventRequest, TestPushRequest } from './push';
export type { PushEvent, PushHistory, PushChannelConfig, ListPushHistoriesRequest, ListPushHistoriesResponse, UpdatePushEventRequest, TestPushRequest } from './push';
// ==================== OpenFlare 业务服务导出 ====================
export {
LegacyOpenFlareBaseService,
NodeService,
ProxyRouteService,
ConfigVersionService,
ApplyLogService,
} from './openflare';
export type {
LegacyApiResponse,
NodeItem,
ProxyRouteItem,
ProxyRouteConfigSection,
ConfigVersionSummary,
ConfigVersionDetail,
ConfigDiffResult,
ConfigPreviewResult,
ApplyLogList,
} from './openflare';
@@ -0,0 +1,27 @@
import {LegacyOpenFlareBaseService} from './legacy-base.service';
import type {ApplyLogCleanupPayload, ApplyLogCleanupResult, ApplyLogList, ApplyLogListQuery,} from './types';
export class ApplyLogService extends LegacyOpenFlareBaseService {
protected static override readonly basePath = '/api/apply-logs';
static list(query: ApplyLogListQuery = {}): Promise<ApplyLogList> {
const params: Record<string, unknown> = {};
const normalizedNodeId = query.node_id?.trim();
if (normalizedNodeId) {
params.node_id = normalizedNodeId;
}
if (query.pageNo) {
params.pageNo = query.pageNo;
}
if (query.pageSize) {
params.pageSize = query.pageSize;
}
return this.legacyGet<ApplyLogList>('/', params);
}
static cleanup(payload: ApplyLogCleanupPayload): Promise<ApplyLogCleanupResult> {
return this.legacyPost<ApplyLogCleanupResult>('/cleanup', payload);
}
}
@@ -0,0 +1,55 @@
import type {InternalAxiosRequestConfig} from 'axios';
import {LegacyOpenFlareBaseService} from './legacy-base.service';
import type {
ConfigDiffResult,
ConfigPreviewResult,
ConfigVersionCleanupPayload,
ConfigVersionCleanupResult,
ConfigVersionDetail,
ConfigVersionSummary,
} from './types';
export class ConfigVersionService extends LegacyOpenFlareBaseService {
protected static override readonly basePath = '/api/config-versions';
static list(): Promise<ConfigVersionSummary[]> {
return this.legacyGet<ConfigVersionSummary[]>('/');
}
static getActive(): Promise<ConfigVersionDetail> {
return this.legacyGet<ConfigVersionDetail>('/active');
}
static preview(): Promise<ConfigPreviewResult> {
return this.legacyGet<ConfigPreviewResult>('/preview');
}
static diff(): Promise<ConfigDiffResult> {
return this.legacyGet<ConfigDiffResult>('/diff');
}
static getById(id: number): Promise<ConfigVersionDetail> {
return this.legacyGet<ConfigVersionDetail>(`/${id}`);
}
static publish(force?: boolean): Promise<ConfigVersionDetail> {
return this.legacyPost<ConfigVersionDetail>(
'/publish',
undefined,
force
? ({ params: { force: true } } as InternalAxiosRequestConfig)
: undefined,
);
}
static activate(id: number): Promise<ConfigVersionDetail> {
return this.legacyPost<ConfigVersionDetail>(`/${id}/activate`);
}
static cleanup(
payload: ConfigVersionCleanupPayload,
): Promise<ConfigVersionCleanupResult> {
return this.legacyPost<ConfigVersionCleanupResult>('/cleanup', payload);
}
}
@@ -0,0 +1,51 @@
export { LegacyOpenFlareBaseService } from './legacy-base.service';
export type { LegacyApiResponse } from './legacy-base.service';
export { NodeService } from './node.service';
export { ProxyRouteService } from './proxy-route.service';
export { ConfigVersionService } from './config-version.service';
export { ApplyLogService } from './apply-log.service';
export type {
ApplyLogCleanupPayload,
ApplyLogCleanupResult,
ApplyLogItem,
ApplyLogList,
ApplyLogListQuery,
ApplyResult,
ConfigDiffResult,
ConfigOptionDiffItem,
ConfigPreviewResult,
ConfigVersionCleanupPayload,
ConfigVersionCleanupResult,
ConfigVersionDetail,
ConfigVersionSummary,
NodeAgentReleaseInfo,
NodeAgentUpdatePayload,
NodeBootstrapToken,
NodeItem,
NodeMutationPayload,
NodeObservability,
NodeStatus,
NodeType,
OpenrestyStatus,
ProxyRouteConfigSection,
ProxyRouteCustomHeader,
ProxyRouteItem,
ProxyRouteMutationPayload,
ProxyRoutePoWConfig,
ReleaseChannel,
SupportFile,
} from './types';
import {ApplyLogService} from './apply-log.service';
import {ConfigVersionService} from './config-version.service';
import {NodeService} from './node.service';
import {ProxyRouteService} from './proxy-route.service';
export const openflareServices = {
node: NodeService,
proxyRoute: ProxyRouteService,
configVersion: ConfigVersionService,
applyLog: ApplyLogService,
} as const;
@@ -0,0 +1,53 @@
import apiClient from '@/lib/services/core/api-client';
import {ApiErrorBase} from '@/lib/services/core/errors';
import type {InternalAxiosRequestConfig} from 'axios';
export interface LegacyApiResponse<T> {
success: boolean;
message: string;
data: T;
}
/**
* OpenFlare legacy 业务 API 基类
* 解析 { success, message, data } 响应格式
*/
export class LegacyOpenFlareBaseService {
protected static readonly basePath: string = '';
protected static getFullPath(path: string): string {
return `${this.basePath}${path}`;
}
protected static parseLegacyResponse<T>(body: LegacyApiResponse<T>): T {
if (!body.success) {
throw new ApiErrorBase(body.message || '请求失败');
}
return body.data;
}
protected static async legacyGet<T>(
path: string,
params?: Record<string, unknown>,
config?: InternalAxiosRequestConfig,
): Promise<T> {
const response = await apiClient.get<LegacyApiResponse<T>>(
this.getFullPath(path),
{ ...config, params } as InternalAxiosRequestConfig,
);
return this.parseLegacyResponse(response.data);
}
protected static async legacyPost<T>(
path: string,
data?: unknown,
config?: InternalAxiosRequestConfig,
): Promise<T> {
const response = await apiClient.post<LegacyApiResponse<T>>(
this.getFullPath(path),
data,
config,
);
return this.parseLegacyResponse(response.data);
}
}
@@ -0,0 +1,70 @@
import {LegacyOpenFlareBaseService} from './legacy-base.service';
import type {
NodeAgentReleaseInfo,
NodeAgentUpdatePayload,
NodeBootstrapToken,
NodeItem,
NodeMutationPayload,
NodeObservability,
ReleaseChannel,
} from './types';
export class NodeService extends LegacyOpenFlareBaseService {
protected static readonly basePath = '/api/nodes';
static async listNodes(): Promise<NodeItem[]> {
return this.legacyGet<NodeItem[]>('/');
}
static async createNode(payload: NodeMutationPayload): Promise<NodeItem> {
return this.legacyPost<NodeItem>('/', payload);
}
static async updateNode(id: number, payload: NodeMutationPayload): Promise<NodeItem> {
return this.legacyPost<NodeItem>(`/${id}/update`, payload);
}
static async deleteNode(id: number): Promise<void> {
return this.legacyPost<void>(`/${id}/delete`);
}
static async getBootstrapToken(): Promise<NodeBootstrapToken> {
return this.legacyGet<NodeBootstrapToken>('/bootstrap-token');
}
static async rotateBootstrapToken(): Promise<NodeBootstrapToken> {
return this.legacyPost<NodeBootstrapToken>('/bootstrap-token/rotate');
}
static async requestAgentUpdate(
id: number,
payload?: NodeAgentUpdatePayload,
): Promise<NodeItem> {
return this.legacyPost<NodeItem>(`/${id}/agent-update`, payload ?? {});
}
static async requestForceSync(id: number): Promise<NodeItem> {
return this.legacyPost<NodeItem>(`/${id}/force-sync`);
}
static async requestOpenrestyRestart(id: number): Promise<NodeItem> {
return this.legacyPost<NodeItem>(`/${id}/openresty-restart`);
}
static async getAgentRelease(
id: number,
channel: ReleaseChannel = 'stable',
): Promise<NodeAgentReleaseInfo> {
return this.legacyGet<NodeAgentReleaseInfo>(`/${id}/agent-release`, { channel });
}
static async getObservability(
id: number,
options?: { hours?: number; limit?: number },
): Promise<NodeObservability> {
return this.legacyGet<NodeObservability>(`/${id}/observability`, {
hours: options?.hours,
limit: options?.limit,
});
}
}
@@ -0,0 +1,29 @@
import {LegacyOpenFlareBaseService} from './legacy-base.service';
import type {ProxyRouteItem, ProxyRouteMutationPayload} from './types';
export class ProxyRouteService extends LegacyOpenFlareBaseService {
protected static readonly basePath = '/api/proxy-routes';
static async list(): Promise<ProxyRouteItem[]> {
return this.legacyGet<ProxyRouteItem[]>('/');
}
static async getById(id: number): Promise<ProxyRouteItem> {
return this.legacyGet<ProxyRouteItem>(`/${id}`);
}
static async create(payload: ProxyRouteMutationPayload): Promise<ProxyRouteItem> {
return this.legacyPost<ProxyRouteItem>('/', payload);
}
static async update(
id: number,
payload: ProxyRouteMutationPayload,
): Promise<ProxyRouteItem> {
return this.legacyPost<ProxyRouteItem>(`/${id}/update`, payload);
}
static async delete(id: number): Promise<void> {
return this.legacyPost<void>(`/${id}/delete`);
}
}
@@ -0,0 +1,367 @@
/**
* OpenFlare 遗留业务 API 响应信封
* 阶段一 `/api/*` 端点使用此格式,与 Wavelet `/api/v1/*` 的 `{error_msg,data}` 不同
*/
export interface LegacyApiResponse<T = unknown> {
success: boolean;
message: string;
data: T;
}
export type ReleaseChannel = 'stable' | 'preview';
export type NodeType = 'edge_node' | 'tunnel_relay' | 'tunnel_client';
export type NodeStatus = 'online' | 'offline' | 'pending';
export type OpenrestyStatus = 'healthy' | 'unhealthy' | 'unknown';
export type ApplyResult = 'success' | 'warning' | 'failed' | '';
export interface NodeItem {
id: number;
node_id: string;
node_type: NodeType;
name: string;
ip: string;
ip_manual_override: boolean;
relay_bind_port: number;
relay_vhost_http_port: number;
relay_client_access_addr: string;
relay_agent_access_addr: string;
relay_client_proxy_url: string;
relay_auth_token: string;
relay_status: string;
relay_web_server_enabled: boolean;
relay_frps_connections: number;
relay_frps_proxy_count: number;
geo_name: string;
geo_latitude?: number | null;
geo_longitude?: number | null;
geo_manual_override: boolean;
access_token: string;
auto_update_enabled: boolean;
update_requested: boolean;
update_channel: ReleaseChannel;
update_tag: string;
restart_openresty_requested: boolean;
version: string;
ext_version: string;
openresty_status: OpenrestyStatus;
openresty_message: string;
status: NodeStatus;
current_version: string;
last_seen_at: string;
last_error: string;
latest_apply_result: ApplyResult;
latest_apply_message: string;
latest_apply_checksum: string;
latest_main_config_checksum: string;
latest_route_config_checksum: string;
latest_support_file_count: number;
latest_apply_at?: string | null;
created_at: string;
updated_at: string;
}
export interface NodeBootstrapToken {
discovery_token: string;
}
export interface NodeMutationPayload {
node_type: NodeType;
name: string;
ip: string;
ip_manual_override: boolean;
relay_bind_port?: number;
relay_vhost_http_port?: number;
relay_client_access_addr?: string;
relay_agent_access_addr?: string;
relay_client_proxy_url?: string;
relay_web_server_enabled?: boolean;
auto_update_enabled: boolean;
geo_name: string;
geo_latitude?: number | null;
geo_longitude?: number | null;
geo_manual_override: boolean;
}
export interface NodeAgentReleaseInfo {
tag_name: string;
body: string;
html_url: string;
published_at: string;
current_version: string;
has_update: boolean;
channel: ReleaseChannel;
prerelease: boolean;
update_requested: boolean;
requested_channel: ReleaseChannel;
requested_tag: string;
}
export interface NodeAgentUpdatePayload {
channel?: ReleaseChannel;
tag_name?: string;
}
export interface NodeSystemProfile {
hostname: string;
os_name: string;
os_version: string;
kernel_version: string;
architecture: string;
cpu_model: string;
cpu_cores: number;
total_memory_bytes: number;
total_disk_bytes: number;
uptime_seconds: number;
reported_at: string;
}
export interface NodeMetricSnapshot {
captured_at: string;
cpu_usage_percent: number;
memory_used_bytes: number;
memory_total_bytes: number;
storage_used_bytes: number;
storage_total_bytes: number;
disk_read_bytes: number;
disk_write_bytes: number;
network_rx_bytes: number;
network_tx_bytes: number;
openresty_rx_bytes: number;
openresty_tx_bytes: number;
openresty_connections: number;
}
export interface NodeHealthEvent {
event_type: string;
severity: string;
status: string;
message: string;
metadata_json?: string;
first_triggered_at: string;
last_triggered_at: string;
reported_at: string;
resolved_at?: string | null;
}
export interface NodeObservability {
node_id: string;
profile: NodeSystemProfile | null;
metric_snapshots: NodeMetricSnapshot[];
health_events: NodeHealthEvent[];
}
export type ProxyRouteConfigSection =
| 'domains'
| 'limits'
| 'proxy'
| 'cache'
| 'waf'
| 'auth';
export interface ProxyRouteCustomHeader {
key: string;
value: string;
}
export interface ProxyRoutePoWListConfig {
ips: string[];
ip_cidrs: string[];
paths: string[];
path_regexes: string[];
user_agents: string[];
}
export interface ProxyRoutePoWConfig {
difficulty: number;
algorithm: 'fast' | 'slow';
session_ttl: number;
challenge_ttl: number;
whitelist: ProxyRoutePoWListConfig;
blacklist: ProxyRoutePoWListConfig;
}
export interface ProxyRouteItem {
id: number;
site_name: string;
domain: string;
domains: string[];
primary_domain: string;
domain_count: number;
origin_id: number | null;
origin_url: string;
origin_host: string;
upstreams: string;
upstream_list: string[];
enabled: boolean;
enable_https: boolean;
cert_id: number | null;
cert_ids: number[];
domain_cert_ids: number[];
redirect_http: boolean;
limit_conn_per_server: number;
limit_conn_per_ip: number;
limit_rate: string;
cache_enabled: boolean;
cache_policy: string;
cache_rules: string;
cache_rule_list: string[];
custom_headers: string;
custom_header_list: ProxyRouteCustomHeader[];
pow_enabled: boolean;
pow_config: ProxyRoutePoWConfig;
basic_auth_enabled: boolean;
basic_auth_username: string;
basic_auth_password: string;
remark: string;
upstream_type: 'direct' | 'tunnel' | 'pages';
tunnel_node_id?: number | null;
tunnel_id?: number | null;
tunnel_target_addr?: string;
tunnel_target_protocol?: string;
pages_project_id?: number | null;
created_at: string;
updated_at: string;
}
export interface ProxyRouteMutationPayload {
site_name?: string;
domain: string;
domains?: string[];
origin_id: number | null;
origin_url: string;
origin_scheme: 'http' | 'https';
origin_address: string;
origin_port: string;
origin_uri: string;
origin_host: string;
upstreams: string[];
enabled: boolean;
enable_https: boolean;
cert_id: number | null;
cert_ids?: number[];
domain_cert_ids?: number[];
redirect_http: boolean;
limit_conn_per_server?: number;
limit_conn_per_ip?: number;
limit_rate?: string;
cache_enabled: boolean;
cache_policy: string;
cache_rules: string[];
custom_headers: ProxyRouteCustomHeader[];
pow_enabled: boolean;
pow_config: string;
basic_auth_enabled: boolean;
basic_auth_username?: string;
basic_auth_password?: string;
remark: string;
upstream_type?: 'direct' | 'tunnel' | 'pages';
tunnel_node_id?: number | null;
tunnel_id?: number | null;
tunnel_target_addr?: string;
tunnel_target_protocol?: string;
pages_project_id?: number | null;
}
export interface ConfigVersionSummary {
id: number;
version: string;
checksum: string;
is_active: boolean;
created_by: string;
created_at: string;
}
export interface ConfigVersionDetail extends ConfigVersionSummary {
snapshot_json: string;
main_config: string;
rendered_config: string;
support_files_json: string;
}
export interface SupportFile {
path: string;
content: string;
}
export interface ConfigPreviewResult {
snapshot_json: string;
main_config: string;
route_config: string;
rendered_config: string;
support_files: SupportFile[];
checksum: string;
route_count: number;
website_count: number;
}
export interface ConfigOptionDiffItem {
key: string;
previous_value: string;
current_value: string;
}
export interface ConfigDiffResult {
active_version?: string;
added_sites: string[];
removed_sites: string[];
modified_sites: string[];
added_domains: string[];
removed_domains: string[];
modified_domains: string[];
main_config_changed: boolean;
waf_config_changed: boolean;
changed_option_keys: string[];
changed_option_details: ConfigOptionDiffItem[];
current_website_count: number;
active_website_count: number;
}
export interface ConfigVersionCleanupPayload {
keep_count: number;
}
export interface ConfigVersionCleanupResult {
deleted_count: number;
}
export interface ApplyLogItem {
id: number;
node_id: string;
version: string;
result: 'success' | 'failed' | string;
message: string;
checksum: string;
main_config_checksum: string;
route_config_checksum: string;
support_file_count: number;
created_at: string;
}
export interface ApplyLogList {
rows: ApplyLogItem[];
current: number;
total: number;
totalPage: number;
}
export interface ApplyLogListQuery {
node_id?: string;
pageNo?: number;
pageSize?: number;
}
export interface ApplyLogCleanupPayload {
delete_all?: boolean;
retention_days?: number;
}
export interface ApplyLogCleanupResult {
delete_all: boolean;
retention_days: number;
deleted_count: number;
cutoff?: string;
}
@@ -2,7 +2,7 @@
> **文档类型**:实现计划(Implementation Plan)
> **创建日期**:2026-06-18
> **状态**:规划中
> **状态**:实施中(阶段 0 基建 + 阶段 2 核心链路并行)
> **前置阅读**:[`Wavelet/AGENTS.md`](../../Wavelet/AGENTS.md)、[`docs/plan/20260618-openflare-wavelet-backend-migration.md`](./20260618-openflare-wavelet-backend-migration.md)
> **依赖**:后端阶段一(旧路径 `/api/*` 兼容)完成后启动;阶段二可与后端阶段五并行
@@ -510,15 +510,15 @@ OpenFlare
## 9. 分阶段实施计划
### 阶段 0:基建(1 周)
### 阶段 0:基建(1 周)— ✅ 已完成
| 任务 ID | 任务 | 产出 |
| 任务 ID | 任务 | 状态 |
|---|---|---|
| F0-1 | 创建 `lib/services/openflare/` 骨架 | types + base |
| F0-2 | 创建 `lib/navigation/openflare-nav.ts` | 导航配置 |
| F0-3 | 扩展 `app-sidebar.tsx` | OpenFlare 导航组 |
| F0-4 | 确认 embed 构建方案 | `Makefile` / `next.config.ts` |
| F0-5 | 移除旧前端入口引用准备 | 文档记录 |
| F0-1 | 创建 `lib/services/openflare/` 骨架 | ✅ `legacy-base.service.ts` + types |
| F0-2 | 创建 `lib/navigation/openflare-nav.ts` | ✅ |
| F0-3 | 扩展 `app-sidebar.tsx` | ✅ OpenFlare 导航组 |
| F0-4 | 确认 embed 构建方案 | ⏳ |
| F0-5 | 移除旧前端入口引用准备 | ⏳ |
### 阶段 1:认证与布局(1 周)
@@ -532,15 +532,16 @@ OpenFlare
**验收**:Wavelet 登录后可看到 OpenFlare 空导航骨架。
### 阶段 2:核心链路 UI(2–3 周)
### 阶段 2:核心链路 UI(2–3 周)— 进行中
| 任务 ID | 页面 | 依赖 API |
| 任务 ID | 页面 | 状态 |
|---|---|---|
| F2-1 | 节点列表 + 详情(edge) | 后端 B2-5 |
| F2-2 | 代理规则列表 + 详情 | 后端 B2-3 |
| F2-3 | 配置发布 | 后端 B2-4 |
| F2-4 | 应用日志 | 后端 B2-7 |
| F2-5 | relay/tunnel 详情页 | 后端 B4-2 |
| F2-1 | 节点列表 + 详情(edge) | ✅ 列表 + Edge 详情 |
| F2-2 | 代理规则列表 + 详情 | ✅ 列表 + 6 Tab 骨架 |
| F2-3 | 配置发布 | ✅ |
| F2-4 | 应用日志 | ✅ |
| F2-5 | relay/tunnel 详情页 | ⏳ |
| F2-6 | 代理规则详情各 Section 表单实装 | ⏳ |
**验收**:UI 完成节点→规则→发布闭环操作。
@@ -0,0 +1,110 @@
# OpenFlare 前端迁移 — 任务拆分与 AI 委派
> **状态**:进行中(阶段 0 基建 + 阶段 2 核心链路并行)
> **目标前端**:`Wavelet/frontend/`(Next.js 16 + shadcn/ui + Session 鉴权)
> **业务 API**:阶段一仍调用 `/api/*` legacy 兼容层(响应 `{success,message,data}`)
> **平台 API**:认证/用户/Admin 使用 `/api/v1/*`(响应 `{error_msg,data}`)
## 任务隔离规则
| 规则 | 说明 |
|---|---|
| 文件所有权 | 每个任务 **仅修改** 自己的 `app/(main)/openflare/<module>/`、`lib/services/openflare/<module>.service.ts` |
| 共享文件 | `F0` 独占:`lib/navigation/`、`lib/services/openflare/legacy-base.service.ts`、`lib/services/openflare/index.ts`(注册入口)、`components/layout/sidebar.tsx`(仅新增 OpenFlare 导航组)、`app/(main)/openflare/layout.tsx` |
| 禁止修改 | Wavelet 框架核心:`app/(auth)/*` 逻辑、`lib/services/core/*`、`components/ui/*` 源码 |
| UI 规范 | 标题 `h1 text-2xl font-semibold tracking-tight`;单文件 ≤600 行;参考 `/admin/demo`、`/admin/database` 拆分模式 |
| 组件 | 仅用 `@/components/ui/*`(shadcn),**禁止**复制旧 `openflare-server/web/components/ui` |
| 服务层 | 业务 API 继承 `LegacyOpenFlareBaseService`;平台 API 继续用 `BaseService` |
| 鉴权 | Session Cookie(`withCredentials`),**禁止** `OpenFlare-Token` localStorage |
| 质量门禁 | `cd Wavelet/frontend && pnpm typecheck && pnpm lint` |
## 任务队列
| ID | 板块 | 状态 | 负责目录/文件 |
|---|---|---|---|
| F0 | 基建:Legacy 服务基类 + 导航 + 侧栏 + 路由骨架 | ✅ 完成 | `legacy-base.service.ts`、`openflare-nav.ts`、`sidebar.tsx`、`/home` → `/openflare` |
| F-NODE | 节点列表 + Edge 详情 | ✅ 完成(Relay/Tunnel 详情待补) | `node.service.ts`、`openflare/nodes/` |
| F-PROXY | 代理规则列表 + 详情骨架 | ✅ 骨架完成(6 Tab 待实装) | `proxy-route.service.ts`、`openflare/proxy-routes/` |
| F-CFG | 配置发布 + 应用日志 | ✅ 完成 | `config-version.service.ts`、`apply-log.service.ts` |
| F-DASH | 总览仪表盘 | ⏳ | `dashboard.service.ts`、`openflare/page.tsx` |
| F-WAF | WAF 规则组 + IP 组 | ⏳ | `waf.service.ts`、`openflare/waf/` |
| F-WEB | 网站/证书/DNS | ⏳ | `website.service.ts`、`tls-certificate.service.ts`、`openflare/websites/` |
| F-PAGES | Pages 托管 | ⏳ | `pages.service.ts`、`openflare/pages/` |
| F-ORIGIN | 源站 | ⏳ | `origin.service.ts`、`openflare/origins/` |
| F-LOGS | 访问日志 | ⏳ | `access-log.service.ts`、`openflare/access-logs/` |
| F-ADMIN | 运维设置扩展 Admin | ⏳ | `option.service.ts`、`admin/settings` 扩展块 |
| F-AUTH | 认证复用验证(Wavelet 原生) | ⏳ | 仅导航/回调 URL,不改登录页 |
## Legacy API 适配要点
旧 OpenFlare 业务 API 响应:
```json
{ "success": true, "message": "", "data": { ... } }
```
Wavelet `BaseService` 期望 `{ "error_msg": "", "data": ... }`。业务服务必须使用 `LegacyOpenFlareBaseService`:
```typescript
// lib/services/openflare/legacy-base.service.ts
protected static async legacyGet<T>(path: string, params?: Record<string, unknown>): Promise<T>
protected static async legacyPost<T>(path: string, data?: unknown): Promise<T>
// 解析 success/message/data;success=false 时 throw ApiErrorBase(message)
```
阶段一 `basePath` 示例:`/api/nodes`、`/api/proxy-routes`(无 `/v1` 前缀)。
## 页面规范速查
```tsx
// 标准页面外壳(参考 admin/demo)
<div className="py-6 px-1 space-y-6">
<div className="flex items-center gap-2">
<Icon className="size-5 text-primary" />
<h1 className="text-2xl font-semibold tracking-tight">页面标题</h1>
</div>
{/* 内容 */}
</div>
```
| 旧组件 | Wavelet 替代 |
|---|---|
| `app-modal` | `Dialog` / `Sheet` |
| `app-table` | `Table` + `@tanstack/react-table` 或简易 Table |
| `status-badge` | `Badge` variant |
| `primary-button` | `Button` |
| ECharts 趋势图 | Recharts(世界地图可暂保留 ECharts) |
## 源代码参考
| 类型 | 路径 |
|---|---|
| 旧前端业务 | `openflare-server/web/features/<module>/` |
| 新前端标杆 | `Wavelet/frontend/app/(main)/admin/database/`、`admin/demo/` |
| 服务层范例 | `Wavelet/frontend/lib/services/admin/user.service.ts` |
| 迁移计划 | `docs/plan/20260618-openflare-wavelet-frontend-migration.md` |
| 后端 API | `docs/plan/20260618-openflare-wavelet-backend-migration.md` §12 |
## 验收命令
```bash
cd Wavelet/frontend
pnpm typecheck
pnpm lint
pnpm dev # 联调 wavelet api :3000
```
## 阶段验收标准
| 阶段 | 验收 |
|---|---|
| F0 完成 | 登录后侧栏可见 OpenFlare 导航;`/home` → `/openflare`;各路由有占位或骨架页 |
| F-NODE + F-PROXY + F-CFG | 节点 CRUD、规则 CRUD、配置发布/激活、应用日志列表可联调 |
| 全量 | 旧前端 18 个控制台路由功能对等;E2E 核心路径通过 |
## 下一步(F0 完成后)
1. 启动 **F-DASH**(仪表盘,依赖 dashboard API)
2. 启动 **F-WAF** + **F-WEB**(阶段 3 并行)
3. **F-ADMIN** 合并运维设置到 `admin/settings`
4. 更新 `docs/changelog/index.md`(代码变更时)
+1
View File
@@ -16,6 +16,7 @@
| [OpenFlare → Wavelet 后端迁移计划](./20260618-openflare-wavelet-backend-migration.md) | 将 `openflare-server` 后端迁移至 Wavelet 框架,保留 `/api/*` 路径,复用用户/认证等平台能力 |
| [OpenFlare 后端迁移 — AI 接手](./handover-openflare-backend-migration.md) | 后端迁移当前进度、任务队列、定时任务、goose 版本与下一步行动(阶段 5 收尾) |
| [OpenFlare → Wavelet 前端迁移计划](./20260618-openflare-wavelet-frontend-migration.md) | 将 `openflare-server/web` 业务 UI 按 Wavelet 设计风格重写,复用框架组件与 Admin 基建 |
| [OpenFlare 前端迁移 — AI 委派](./handover-openflare-frontend-migration.md) | 前端迁移任务队列、Legacy API 适配规范、子智能体文件所有权与验收标准 |
## 使用建议