mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-29 05:56:38 +08:00
migrate frontend
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
+124
@@ -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}`;
|
||||
}
|
||||
+234
@@ -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>
|
||||
);
|
||||
}
|
||||
+25
@@ -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>
|
||||
);
|
||||
}
|
||||
+26
@@ -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>
|
||||
);
|
||||
}
|
||||
+26
@@ -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>
|
||||
);
|
||||
}
|
||||
+25
@@ -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}§ion=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,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},
|
||||
];
|
||||
@@ -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`(代码变更时)
|
||||
@@ -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 适配规范、子智能体文件所有权与验收标准 |
|
||||
|
||||
## 使用建议
|
||||
|
||||
|
||||
Reference in New Issue
Block a user