mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-09 09:06:36 +08:00
migrate frontend
This commit is contained in:
@@ -28,6 +28,8 @@ import {ErrorInline} from "@/components/layout/error"
|
|||||||
import {LoadingStateWithBorder} from "@/components/layout/loading"
|
import {LoadingStateWithBorder} from "@/components/layout/loading"
|
||||||
import type {DatabaseCleanupTarget} from "@/lib/services/openflare"
|
import type {DatabaseCleanupTarget} from "@/lib/services/openflare"
|
||||||
import {NodeService, OptionService, StatusService, UpdateService, UptimeKumaService,} from "@/lib/services/openflare"
|
import {NodeService, OptionService, StatusService, UpdateService, UptimeKumaService,} from "@/lib/services/openflare"
|
||||||
|
import {VersionUpgradeDialog} from "@/app/(main)/openflare/components/version-upgrade-dialog"
|
||||||
|
import {openflareLatestReleaseQueryKey, openflarePublicStatusQueryKey,} from "@/lib/hooks/use-openflare-server-upgrade"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
agentOptionEntries,
|
agentOptionEntries,
|
||||||
@@ -82,6 +84,7 @@ export function OpenFlareOpsSettings() {
|
|||||||
label: string
|
label: string
|
||||||
} | null>(null)
|
} | null>(null)
|
||||||
const [cleanupRetentionDays, setCleanupRetentionDays] = useState("")
|
const [cleanupRetentionDays, setCleanupRetentionDays] = useState("")
|
||||||
|
const [versionDialogOpen, setVersionDialogOpen] = useState(false)
|
||||||
|
|
||||||
const optionsQuery = useQuery({
|
const optionsQuery = useQuery({
|
||||||
queryKey: optionsQueryKey,
|
queryKey: optionsQueryKey,
|
||||||
@@ -89,7 +92,7 @@ export function OpenFlareOpsSettings() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const statusQuery = useQuery({
|
const statusQuery = useQuery({
|
||||||
queryKey: ["openflare", "public-status"],
|
queryKey: openflarePublicStatusQueryKey,
|
||||||
queryFn: () => StatusService.getPublicStatus(),
|
queryFn: () => StatusService.getPublicStatus(),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -99,7 +102,7 @@ export function OpenFlareOpsSettings() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const releaseQuery = useQuery({
|
const releaseQuery = useQuery({
|
||||||
queryKey: ["openflare", "latest-release"],
|
queryKey: openflareLatestReleaseQueryKey("stable"),
|
||||||
queryFn: () => UpdateService.getLatestRelease("stable"),
|
queryFn: () => UpdateService.getLatestRelease("stable"),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -602,11 +605,16 @@ export function OpenFlareOpsSettings() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card className="border-dashed shadow-none">
|
<Card className="border-dashed shadow-none">
|
||||||
<CardHeader>
|
<CardHeader className="flex flex-row items-center justify-between gap-4">
|
||||||
<CardTitle className="text-base">版本信息</CardTitle>
|
<div>
|
||||||
<CardDescription>
|
<CardTitle className="text-base">版本信息</CardTitle>
|
||||||
可通过 UpdateService 对接顶栏升级入口;完整升级流程可复用 Wavelet admin/updater。
|
<CardDescription>
|
||||||
</CardDescription>
|
检查 GitHub Release、在线升级或手动上传服务端二进制包。
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Button type="button" size="sm" onClick={() => setVersionDialogOpen(true)}>
|
||||||
|
管理升级
|
||||||
|
</Button>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
<CardContent className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
<InfoCell label="当前版本" value={statusQuery.data?.version ?? releaseQuery.data?.current_version ?? "—"} />
|
<InfoCell label="当前版本" value={statusQuery.data?.version ?? releaseQuery.data?.current_version ?? "—"} />
|
||||||
@@ -635,6 +643,12 @@ export function OpenFlareOpsSettings() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<VersionUpgradeDialog
|
||||||
|
open={versionDialogOpen}
|
||||||
|
onOpenChange={setVersionDialogOpen}
|
||||||
|
canUpgrade
|
||||||
|
/>
|
||||||
|
|
||||||
<UptimeKumaSiteSelectModal
|
<UptimeKumaSiteSelectModal
|
||||||
open={uptimeKumaModalOpen}
|
open={uptimeKumaModalOpen}
|
||||||
selectedSites={
|
selectedSites={
|
||||||
|
|||||||
@@ -0,0 +1,203 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import Link from "next/link"
|
||||||
|
import {useQuery} from "@tanstack/react-query"
|
||||||
|
import {Info} from "lucide-react"
|
||||||
|
import ReactMarkdown from "react-markdown"
|
||||||
|
import remarkGfm from "remark-gfm"
|
||||||
|
|
||||||
|
import {EmptyStateWithBorder} from "@/components/layout/empty"
|
||||||
|
import {ErrorInline} from "@/components/layout/error"
|
||||||
|
import {Badge} from "@/components/ui/badge"
|
||||||
|
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card"
|
||||||
|
import {Skeleton} from "@/components/ui/skeleton"
|
||||||
|
import {AboutService, StatusService} from "@/lib/services/openflare"
|
||||||
|
import {formatDateTime} from "@/lib/utils"
|
||||||
|
|
||||||
|
const aboutQueryKey = ["openflare", "about"] as const
|
||||||
|
const publicStatusQueryKey = ["openflare", "public-status"] as const
|
||||||
|
|
||||||
|
function AboutPageSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="py-6 px-1 space-y-6">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Skeleton className="size-5 rounded-md" />
|
||||||
|
<Skeleton className="h-8 w-48" />
|
||||||
|
</div>
|
||||||
|
<Card className="border-dashed shadow-none">
|
||||||
|
<CardHeader>
|
||||||
|
<Skeleton className="h-5 w-40" />
|
||||||
|
<Skeleton className="h-4 w-72" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="grid gap-3 md:grid-cols-3">
|
||||||
|
<Skeleton className="h-20 w-full" />
|
||||||
|
<Skeleton className="h-20 w-full" />
|
||||||
|
<Skeleton className="h-20 w-full" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card className="border-dashed shadow-none">
|
||||||
|
<CardHeader>
|
||||||
|
<Skeleton className="h-5 w-32" />
|
||||||
|
<Skeleton className="h-4 w-64" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<Skeleton className="h-4 w-full" />
|
||||||
|
<Skeleton className="h-4 w-full" />
|
||||||
|
<Skeleton className="h-4 w-3/4" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AboutPage() {
|
||||||
|
const aboutQuery = useQuery({
|
||||||
|
queryKey: aboutQueryKey,
|
||||||
|
queryFn: () => AboutService.getAboutContent(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const statusQuery = useQuery({
|
||||||
|
queryKey: publicStatusQueryKey,
|
||||||
|
queryFn: () => StatusService.getPublicStatus(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const isLoading = aboutQuery.isLoading || statusQuery.isLoading
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <AboutPageSkeleton />
|
||||||
|
}
|
||||||
|
|
||||||
|
if (aboutQuery.isError) {
|
||||||
|
return (
|
||||||
|
<div className="py-6 px-1">
|
||||||
|
<ErrorInline
|
||||||
|
message={
|
||||||
|
aboutQuery.error instanceof Error
|
||||||
|
? aboutQuery.error.message
|
||||||
|
: "关于内容加载失败"
|
||||||
|
}
|
||||||
|
onRetry={() => void aboutQuery.refetch()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (statusQuery.isError) {
|
||||||
|
return (
|
||||||
|
<div className="py-6 px-1">
|
||||||
|
<ErrorInline
|
||||||
|
message={
|
||||||
|
statusQuery.error instanceof Error
|
||||||
|
? statusQuery.error.message
|
||||||
|
: "系统状态加载失败"
|
||||||
|
}
|
||||||
|
onRetry={() => void statusQuery.refetch()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const aboutContent = aboutQuery.data?.trim() ?? ""
|
||||||
|
const status = statusQuery.data
|
||||||
|
|
||||||
|
if (!status) {
|
||||||
|
return (
|
||||||
|
<div className="py-6 px-1">
|
||||||
|
<EmptyStateWithBorder
|
||||||
|
title="暂无系统信息"
|
||||||
|
description="未能获取 OpenFlare 的公开状态信息。"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="py-6 px-1 space-y-6">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Info className="size-5 text-primary" />
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">关于 OpenFlare</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
公开展示当前系统简介、版本信息与项目入口。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="border-dashed shadow-none">
|
||||||
|
<CardHeader className="flex flex-row items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-base">系统信息</CardTitle>
|
||||||
|
<CardDescription>当前运行实例的公开状态与项目链接。</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Badge variant="secondary">{status.version || "dev"}</Badge>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="grid gap-3 md:grid-cols-3">
|
||||||
|
<div className="rounded-lg border border-dashed px-4 py-3">
|
||||||
|
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">
|
||||||
|
系统名称
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-sm font-medium">
|
||||||
|
{status.system_name || "OpenFlare"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border border-dashed px-4 py-3">
|
||||||
|
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">
|
||||||
|
Server 启动时间
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-sm font-medium">
|
||||||
|
{formatDateTime(new Date(status.start_time * 1000))}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border border-dashed px-4 py-3">
|
||||||
|
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">
|
||||||
|
项目仓库
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href="https://github.com/Rain-kl/OpenFlare"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="mt-2 block text-sm font-medium text-primary transition hover:opacity-80"
|
||||||
|
>
|
||||||
|
github.com/Rain-kl/OpenFlare
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{aboutContent ? (
|
||||||
|
<Card className="border-dashed shadow-none">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">项目介绍</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
以下内容由系统设置中的「关于内容」维护。
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div
|
||||||
|
className={[
|
||||||
|
"prose prose-sm max-w-none dark:prose-invert",
|
||||||
|
"prose-p:text-foreground prose-p:leading-7",
|
||||||
|
"prose-li:text-foreground",
|
||||||
|
"prose-strong:text-foreground",
|
||||||
|
"prose-a:text-primary",
|
||||||
|
].join(" ")}
|
||||||
|
>
|
||||||
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>{aboutContent}</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<EmptyStateWithBorder
|
||||||
|
title="尚未配置关于内容"
|
||||||
|
description="可在设置页的「其他设置」标签中编写 Markdown / HTML 内容,这里会自动同步展示。"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-3 text-sm">
|
||||||
|
<Link href="/openflare" className="text-primary transition hover:opacity-80">
|
||||||
|
返回总览
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -394,7 +394,18 @@ export default function AccessLogsPage() {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
{foldsQuery.isLoading ? (
|
{foldsQuery.isError ? (
|
||||||
|
<div className="p-4">
|
||||||
|
<ErrorInline
|
||||||
|
message={
|
||||||
|
foldsQuery.error instanceof Error
|
||||||
|
? foldsQuery.error.message
|
||||||
|
: "加载失败"
|
||||||
|
}
|
||||||
|
onRetry={() => void foldsQuery.refetch()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : foldsQuery.isLoading ? (
|
||||||
<LoadingStateWithBorder />
|
<LoadingStateWithBorder />
|
||||||
) : (foldsQuery.data?.items ?? []).length === 0 ? (
|
) : (foldsQuery.data?.items ?? []).length === 0 ? (
|
||||||
<EmptyStateWithBorder title="暂无折叠数据" />
|
<EmptyStateWithBorder title="暂无折叠数据" />
|
||||||
@@ -455,7 +466,18 @@ export default function AccessLogsPage() {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
{ipSummaryQuery.isLoading ? (
|
{ipSummaryQuery.isError ? (
|
||||||
|
<div className="p-4">
|
||||||
|
<ErrorInline
|
||||||
|
message={
|
||||||
|
ipSummaryQuery.error instanceof Error
|
||||||
|
? ipSummaryQuery.error.message
|
||||||
|
: "加载失败"
|
||||||
|
}
|
||||||
|
onRetry={() => void ipSummaryQuery.refetch()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : ipSummaryQuery.isLoading ? (
|
||||||
<LoadingStateWithBorder />
|
<LoadingStateWithBorder />
|
||||||
) : (ipSummaryQuery.data?.items ?? []).length === 0 ? (
|
) : (ipSummaryQuery.data?.items ?? []).length === 0 ? (
|
||||||
<EmptyStateWithBorder title="暂无 IP 汇总数据" />
|
<EmptyStateWithBorder title="暂无 IP 汇总数据" />
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import {Badge} from '@/components/ui/badge';
|
||||||
|
import type {LatestReleaseInfo} from '@/lib/services/openflare';
|
||||||
|
|
||||||
|
function formatLogTimestamp(value: string) {
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return '0000-00-00 00:00:00.000';
|
||||||
|
}
|
||||||
|
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = `${date.getMonth() + 1}`.padStart(2, '0');
|
||||||
|
const day = `${date.getDate()}`.padStart(2, '0');
|
||||||
|
const hour = `${date.getHours()}`.padStart(2, '0');
|
||||||
|
const minute = `${date.getMinutes()}`.padStart(2, '0');
|
||||||
|
const second = `${date.getSeconds()}`.padStart(2, '0');
|
||||||
|
const millisecond = `${date.getMilliseconds()}`.padStart(3, '0');
|
||||||
|
|
||||||
|
return `${year}-${month}-${day} ${hour}:${minute}:${second}.${millisecond}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatLogLevel(level: string) {
|
||||||
|
return (level || 'info').toUpperCase().padEnd(8, ' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUpgradeStatusBadge(release: LatestReleaseInfo) {
|
||||||
|
if (release.upgrade_status === 'failed') {
|
||||||
|
return { label: '升级失败', variant: 'destructive' as const };
|
||||||
|
}
|
||||||
|
if (release.upgrade_status === 'succeeded') {
|
||||||
|
return { label: '准备重启', variant: 'default' as const };
|
||||||
|
}
|
||||||
|
if (release.in_progress) {
|
||||||
|
return { label: '升级中', variant: 'secondary' as const };
|
||||||
|
}
|
||||||
|
return { label: '空闲', variant: 'outline' as const };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UpgradeLogPanel({release}: {release: LatestReleaseInfo}) {
|
||||||
|
const statusBadge = getUpgradeStatusBadge(release);
|
||||||
|
const upgradeLogs = release.upgrade_logs ?? [];
|
||||||
|
|
||||||
|
if (upgradeLogs.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-dashed px-4 py-8 text-center text-sm text-muted-foreground">
|
||||||
|
暂无升级日志
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Badge variant={statusBadge.variant}>{statusBadge.label}</Badge>
|
||||||
|
<div className="max-h-72 overflow-y-auto rounded-lg border bg-slate-950 px-4 py-3">
|
||||||
|
{upgradeLogs.map((log, index) => (
|
||||||
|
<pre
|
||||||
|
key={`${log.created_at}-${index}`}
|
||||||
|
className="overflow-x-auto border-b border-white/10 py-2 font-mono text-[12px] leading-6 text-slate-200 last:border-b-0"
|
||||||
|
>
|
||||||
|
<span className="text-slate-400">{formatLogTimestamp(log.created_at)}</span>
|
||||||
|
<span className="text-slate-500"> | </span>
|
||||||
|
<span className="text-cyan-300">{formatLogLevel(log.level)}</span>
|
||||||
|
<span className="text-slate-500"> | </span>
|
||||||
|
<span className="break-all whitespace-pre-wrap text-slate-100">{log.message}</span>
|
||||||
|
</pre>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,401 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import {useEffect, useState} from 'react';
|
||||||
|
import {ExternalLink, Loader2} from 'lucide-react';
|
||||||
|
import ReactMarkdown from 'react-markdown';
|
||||||
|
import remarkGfm from 'remark-gfm';
|
||||||
|
|
||||||
|
import {Badge} from '@/components/ui/badge';
|
||||||
|
import {Button} from '@/components/ui/button';
|
||||||
|
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from '@/components/ui/card';
|
||||||
|
import {Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle,} from '@/components/ui/dialog';
|
||||||
|
import {Input} from '@/components/ui/input';
|
||||||
|
import {Label} from '@/components/ui/label';
|
||||||
|
import {Progress} from '@/components/ui/progress';
|
||||||
|
import {Tabs, TabsContent, TabsList, TabsTrigger} from '@/components/ui/tabs';
|
||||||
|
import {useOpenFlareServerUpgrade} from '@/lib/hooks/use-openflare-server-upgrade';
|
||||||
|
import type {LatestReleaseInfo, ReleaseChannel} from '@/lib/services/openflare';
|
||||||
|
import {formatDateTime} from '@/lib/utils';
|
||||||
|
import {formatRelativeTime} from '@/app/(main)/openflare/nodes/components/node-utils';
|
||||||
|
|
||||||
|
import {UpgradeLogPanel} from './upgrade-log-panel';
|
||||||
|
|
||||||
|
function getUpgradeBadge(release: LatestReleaseInfo | null | undefined) {
|
||||||
|
if (!release) {
|
||||||
|
return { label: '未检查', variant: 'outline' as const };
|
||||||
|
}
|
||||||
|
if (release.in_progress) {
|
||||||
|
return { label: '升级中', variant: 'secondary' as const };
|
||||||
|
}
|
||||||
|
if (release.has_update) {
|
||||||
|
return { label: '可升级', variant: 'secondary' as const };
|
||||||
|
}
|
||||||
|
return { label: '最新', variant: 'default' as const };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VersionUpgradeDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
canUpgrade = true,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
canUpgrade?: boolean;
|
||||||
|
}) {
|
||||||
|
const {
|
||||||
|
currentVersion,
|
||||||
|
selectedChannel,
|
||||||
|
release,
|
||||||
|
uploadedBinary,
|
||||||
|
releaseErrorMessage,
|
||||||
|
manualStatusMessage,
|
||||||
|
manualErrorMessage,
|
||||||
|
isInitialLoading,
|
||||||
|
isChecking,
|
||||||
|
isUpgrading,
|
||||||
|
isUploadingBinary,
|
||||||
|
uploadProgress,
|
||||||
|
isConfirmingManualUpgrade,
|
||||||
|
handleOpen,
|
||||||
|
handleCheckRelease,
|
||||||
|
handleChannelChange,
|
||||||
|
handleUpgrade,
|
||||||
|
handleUploadBinary,
|
||||||
|
handleConfirmManualUpgrade,
|
||||||
|
} = useOpenFlareServerUpgrade({ open, canUpgrade });
|
||||||
|
const [selectedBinary, setSelectedBinary] = useState<File | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
handleOpen();
|
||||||
|
}
|
||||||
|
}, [open, handleOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
setSelectedBinary(null);
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const upgradeBadge = getUpgradeBadge(release);
|
||||||
|
const selectedChannelLabel = selectedChannel === 'preview' ? '预览版' : '正式版';
|
||||||
|
const uploadPhaseLabel =
|
||||||
|
uploadProgress >= 100 ? '已上传,正在服务端校验版本...' : '上传中...';
|
||||||
|
const canConfirmManualUpgrade = Boolean(
|
||||||
|
uploadedBinary?.ready_to_upgrade && uploadedBinary.upload_token,
|
||||||
|
);
|
||||||
|
|
||||||
|
const isBusy =
|
||||||
|
isChecking || isUpgrading || isUploadingBinary || isConfirmingManualUpgrade;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-3xl max-h-[90vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>服务端版本</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
默认检查正式版更新;也可检查 preview 发布或手动上传二进制包。升级开始后服务会短暂重启。
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<Card className="border-dashed shadow-none py-4 gap-3">
|
||||||
|
<CardHeader className="px-4 pb-0">
|
||||||
|
<CardTitle className="text-sm">当前版本</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="px-4">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<p className="text-sm font-medium">{currentVersion}</p>
|
||||||
|
<Badge variant={upgradeBadge.variant}>{upgradeBadge.label}</Badge>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="border-dashed shadow-none py-4 gap-3">
|
||||||
|
<CardHeader className="px-4 pb-0">
|
||||||
|
<CardTitle className="text-sm">最新版本</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="px-4 space-y-3">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<p className="text-sm font-medium">{release?.tag_name || '未检查'}</p>
|
||||||
|
{canUpgrade ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 text-xs"
|
||||||
|
disabled={isBusy}
|
||||||
|
onClick={() =>
|
||||||
|
handleChannelChange(selectedChannel === 'preview' ? 'stable' : 'preview')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{selectedChannelLabel}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{canUpgrade ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={isBusy}
|
||||||
|
onClick={handleCheckRelease}
|
||||||
|
>
|
||||||
|
{isChecking ? '检查中...' : `检查${selectedChannelLabel}`}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isInitialLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
|
||||||
|
<Loader2 className="size-4 mr-2 animate-spin" />
|
||||||
|
加载版本信息...
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!isInitialLoading && releaseErrorMessage ? (
|
||||||
|
<div className="rounded-lg border border-destructive/30 bg-destructive/5 px-4 py-3 text-sm text-destructive">
|
||||||
|
{releaseErrorMessage}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Tabs defaultValue="online">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="online">在线升级</TabsTrigger>
|
||||||
|
{canUpgrade ? <TabsTrigger value="manual">手动升级</TabsTrigger> : null}
|
||||||
|
{release ? <TabsTrigger value="logs">升级日志</TabsTrigger> : null}
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="online" className="space-y-4">
|
||||||
|
{!isInitialLoading && !releaseErrorMessage && !release ? (
|
||||||
|
<div className="rounded-lg border border-dashed px-4 py-8 text-center text-sm text-muted-foreground">
|
||||||
|
尚未检查{selectedChannelLabel},点击「检查{selectedChannelLabel}」后展示 GitHub Release 信息。
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{release ? (
|
||||||
|
<Card className="border-dashed shadow-none py-4 gap-3">
|
||||||
|
<CardHeader className="px-4 pb-0">
|
||||||
|
<CardTitle className="text-sm">
|
||||||
|
GitHub {selectedChannelLabel} · {release.tag_name}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{release.published_at
|
||||||
|
? `发布时间:${formatRelativeTime(release.published_at)} · ${formatDateTime(release.published_at)}`
|
||||||
|
: '未提供发布时间'}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="px-4 space-y-4">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Badge variant={release.has_update ? 'secondary' : 'default'}>
|
||||||
|
{release.has_update ? '发现新版本' : '已经是最新版本'}
|
||||||
|
</Badge>
|
||||||
|
{release.prerelease ? (
|
||||||
|
<Badge variant="secondary">Preview 发布</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="outline">正式发布</Badge>
|
||||||
|
)}
|
||||||
|
{!release.upgrade_supported ? (
|
||||||
|
<Badge variant="destructive">当前平台不支持自动升级</Badge>
|
||||||
|
) : null}
|
||||||
|
{release.in_progress ? (
|
||||||
|
<Badge variant="secondary">升级任务执行中</Badge>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="prose prose-sm dark:prose-invert max-w-none text-sm">
|
||||||
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||||
|
{release.body || '暂无更新说明'}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{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}
|
||||||
|
|
||||||
|
{canUpgrade ? (
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
disabled={
|
||||||
|
!release.has_update ||
|
||||||
|
release.in_progress ||
|
||||||
|
isUpgrading ||
|
||||||
|
!release.upgrade_supported ||
|
||||||
|
isUploadingBinary ||
|
||||||
|
isConfirmingManualUpgrade
|
||||||
|
}
|
||||||
|
onClick={handleUpgrade}
|
||||||
|
>
|
||||||
|
{isUpgrading || release.in_progress
|
||||||
|
? '升级中...'
|
||||||
|
: selectedChannel === 'preview'
|
||||||
|
? '升级预览版'
|
||||||
|
: '升级正式版'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : null}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{canUpgrade ? (
|
||||||
|
<TabsContent value="manual" className="space-y-4">
|
||||||
|
<Card className="border-dashed shadow-none py-4 gap-3">
|
||||||
|
<CardHeader className="px-4 pb-0">
|
||||||
|
<CardTitle className="text-sm">手动升级</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
支持上传已编译好的当前平台 Server 可执行文件。
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="px-4 space-y-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="server-binary">服务端二进制</Label>
|
||||||
|
<Input
|
||||||
|
id="server-binary"
|
||||||
|
type="file"
|
||||||
|
disabled={isUploadingBinary || isConfirmingManualUpgrade}
|
||||||
|
onChange={(event) => {
|
||||||
|
setSelectedBinary(event.target.files?.[0] ?? null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end">
|
||||||
|
{canConfirmManualUpgrade ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
disabled={
|
||||||
|
!canConfirmManualUpgrade ||
|
||||||
|
isConfirmingManualUpgrade ||
|
||||||
|
isUploadingBinary ||
|
||||||
|
isUpgrading
|
||||||
|
}
|
||||||
|
onClick={handleConfirmManualUpgrade}
|
||||||
|
>
|
||||||
|
{isConfirmingManualUpgrade ? '升级中...' : '确认升级'}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
disabled={
|
||||||
|
!selectedBinary ||
|
||||||
|
isUploadingBinary ||
|
||||||
|
isConfirmingManualUpgrade
|
||||||
|
}
|
||||||
|
onClick={() => {
|
||||||
|
if (selectedBinary) {
|
||||||
|
handleUploadBinary(selectedBinary);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isUploadingBinary
|
||||||
|
? uploadProgress >= 100
|
||||||
|
? '服务端校验中...'
|
||||||
|
: '上传中...'
|
||||||
|
: '上传并检查'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isUploadingBinary ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||||
|
<span>{uploadPhaseLabel}</span>
|
||||||
|
<span>{uploadProgress}%</span>
|
||||||
|
</div>
|
||||||
|
<Progress value={uploadProgress} />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{manualErrorMessage ? (
|
||||||
|
<div className="rounded-lg border border-destructive/30 bg-destructive/5 px-4 py-3 text-sm text-destructive">
|
||||||
|
{manualErrorMessage}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!manualErrorMessage && manualStatusMessage ? (
|
||||||
|
<div className="rounded-lg border border-amber-500/30 bg-amber-500/10 px-4 py-3 text-sm text-amber-600 dark:text-amber-400">
|
||||||
|
{manualStatusMessage}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{uploadedBinary ? (
|
||||||
|
<div className="space-y-4 rounded-lg border border-dashed p-4">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Badge
|
||||||
|
variant={uploadedBinary.ready_to_upgrade ? 'secondary' : 'outline'}
|
||||||
|
>
|
||||||
|
{uploadedBinary.ready_to_upgrade
|
||||||
|
? '可确认升级'
|
||||||
|
: uploadedBinary.has_update
|
||||||
|
? '待确认'
|
||||||
|
: '不可升级'}
|
||||||
|
</Badge>
|
||||||
|
{!uploadedBinary.upgrade_supported ? (
|
||||||
|
<Badge variant="destructive">当前版本不支持手动升级</Badge>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2 text-sm">
|
||||||
|
<InfoCell label="文件名" value={uploadedBinary.file_name} />
|
||||||
|
<InfoCell
|
||||||
|
label="上传时间"
|
||||||
|
value={
|
||||||
|
uploadedBinary.uploaded_at
|
||||||
|
? formatDateTime(uploadedBinary.uploaded_at)
|
||||||
|
: '未知'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<InfoCell label="当前版本" value={uploadedBinary.current_version} />
|
||||||
|
<InfoCell label="上传版本" value={uploadedBinary.detected_version} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{uploadedBinary.comparison_message}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-lg border border-dashed px-4 py-8 text-center text-sm text-muted-foreground">
|
||||||
|
尚未上传升级包
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{release ? (
|
||||||
|
<TabsContent value="logs">
|
||||||
|
<UpgradeLogPanel release={release} />
|
||||||
|
</TabsContent>
|
||||||
|
) : null}
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InfoCell({label, value}: {label: string; value: string}) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground">{label}</p>
|
||||||
|
<p className="mt-1 font-medium break-all">{value}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type {ReleaseChannel};
|
||||||
+27
-14
@@ -1,7 +1,10 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import {useState} from "react"
|
import {useEffect} from "react"
|
||||||
|
import {zodResolver} from "@hookform/resolvers/zod"
|
||||||
import {Loader2} from "lucide-react"
|
import {Loader2} from "lucide-react"
|
||||||
|
import {useForm} from "react-hook-form"
|
||||||
|
import {z} from "zod"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
@@ -16,6 +19,12 @@ import {Button} from "@/components/ui/button"
|
|||||||
import {Input} from "@/components/ui/input"
|
import {Input} from "@/components/ui/input"
|
||||||
import {Label} from "@/components/ui/label"
|
import {Label} from "@/components/ui/label"
|
||||||
|
|
||||||
|
const cleanupSchema = z.object({
|
||||||
|
keepCount: z.number().int().min(3, "最少保留 3 个历史快照"),
|
||||||
|
})
|
||||||
|
|
||||||
|
type CleanupFormValues = z.infer<typeof cleanupSchema>
|
||||||
|
|
||||||
interface CleanupDialogProps {
|
interface CleanupDialogProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
onOpenChange: (open: boolean) => void
|
onOpenChange: (open: boolean) => void
|
||||||
@@ -29,17 +38,20 @@ export function CleanupDialog({
|
|||||||
onConfirm,
|
onConfirm,
|
||||||
loading,
|
loading,
|
||||||
}: CleanupDialogProps) {
|
}: CleanupDialogProps) {
|
||||||
const [keepCount, setKeepCount] = useState(10)
|
const form = useForm<CleanupFormValues>({
|
||||||
const [error, setError] = useState<string | null>(null)
|
resolver: zodResolver(cleanupSchema),
|
||||||
|
defaultValues: { keepCount: 10 },
|
||||||
|
})
|
||||||
|
|
||||||
const handleConfirm = () => {
|
useEffect(() => {
|
||||||
if (keepCount < 3) {
|
if (open) {
|
||||||
setError("最少保留 3 个历史快照")
|
form.reset({ keepCount: 10 })
|
||||||
return
|
|
||||||
}
|
}
|
||||||
setError(null)
|
}, [form, open])
|
||||||
onConfirm(keepCount)
|
|
||||||
}
|
const handleConfirm = form.handleSubmit((values) => {
|
||||||
|
onConfirm(values.keepCount)
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||||
@@ -57,17 +69,18 @@ export function CleanupDialog({
|
|||||||
id="keepCount"
|
id="keepCount"
|
||||||
type="number"
|
type="number"
|
||||||
min={3}
|
min={3}
|
||||||
value={keepCount}
|
|
||||||
onChange={(e) => setKeepCount(Number.parseInt(e.target.value, 10) || 3)}
|
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
|
{...form.register("keepCount", { valueAsNumber: true })}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">默认为 10 个,最少需保留 3 个。</p>
|
<p className="text-xs text-muted-foreground">默认为 10 个,最少需保留 3 个。</p>
|
||||||
{error ? <p className="text-xs text-destructive">{error}</p> : null}
|
{form.formState.errors.keepCount ? (
|
||||||
|
<p className="text-xs text-destructive">{form.formState.errors.keepCount.message}</p>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel disabled={loading}>取消</AlertDialogCancel>
|
<AlertDialogCancel disabled={loading}>取消</AlertDialogCancel>
|
||||||
<Button variant="destructive" onClick={handleConfirm} disabled={loading}>
|
<Button variant="destructive" onClick={() => void handleConfirm()} disabled={loading}>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="size-4 animate-spin mr-1" />
|
<Loader2 className="size-4 animate-spin mr-1" />
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import {useEffect, useState} from 'react';
|
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 {Button} from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -16,21 +20,51 @@ import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue,} from '@/
|
|||||||
import {Switch} from '@/components/ui/switch';
|
import {Switch} from '@/components/ui/switch';
|
||||||
import type {NodeItem, NodeMutationPayload, NodeType} from '@/lib/services/openflare';
|
import type {NodeItem, NodeMutationPayload, NodeType} from '@/lib/services/openflare';
|
||||||
|
|
||||||
type FormState = {
|
const nodeSchema = z
|
||||||
node_type: NodeType;
|
.object({
|
||||||
name: string;
|
node_type: z.enum(['edge_node', 'tunnel_relay', 'tunnel_client']),
|
||||||
ip: string;
|
name: z.string().trim().min(1, '请输入节点名称').max(255),
|
||||||
ip_manual_override: boolean;
|
ip: z.string(),
|
||||||
auto_update_enabled: boolean;
|
ip_manual_override: z.boolean(),
|
||||||
geo_manual_override: boolean;
|
auto_update_enabled: z.boolean(),
|
||||||
geo_name: string;
|
geo_manual_override: z.boolean(),
|
||||||
geo_latitude: string;
|
geo_name: z.string(),
|
||||||
geo_longitude: string;
|
geo_latitude: z.string(),
|
||||||
relay_bind_port: string;
|
geo_longitude: z.string(),
|
||||||
relay_vhost_http_port: string;
|
relay_bind_port: z.string(),
|
||||||
};
|
relay_vhost_http_port: z.string(),
|
||||||
|
})
|
||||||
|
.superRefine((value, context) => {
|
||||||
|
if (value.ip_manual_override && !value.ip.trim()) {
|
||||||
|
context.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ['ip'],
|
||||||
|
message: '锁定 IP 时必须填写节点 IP',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (value.node_type === 'tunnel_relay') {
|
||||||
|
const bindPort = Number(value.relay_bind_port);
|
||||||
|
const vhostPort = Number(value.relay_vhost_http_port);
|
||||||
|
if (!Number.isFinite(bindPort) || bindPort <= 0) {
|
||||||
|
context.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ['relay_bind_port'],
|
||||||
|
message: '请输入有效的绑定端口',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(vhostPort) || vhostPort <= 0) {
|
||||||
|
context.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ['relay_vhost_http_port'],
|
||||||
|
message: '请输入有效的 VHost 端口',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const defaultForm: FormState = {
|
type NodeFormValues = z.infer<typeof nodeSchema>;
|
||||||
|
|
||||||
|
const defaultForm: NodeFormValues = {
|
||||||
node_type: 'edge_node',
|
node_type: 'edge_node',
|
||||||
name: '',
|
name: '',
|
||||||
ip: '',
|
ip: '',
|
||||||
@@ -44,7 +78,7 @@ const defaultForm: FormState = {
|
|||||||
relay_vhost_http_port: '8080',
|
relay_vhost_http_port: '8080',
|
||||||
};
|
};
|
||||||
|
|
||||||
function buildFormState(node?: NodeItem | null): FormState {
|
function buildFormValues(node?: NodeItem | null): NodeFormValues {
|
||||||
if (!node) return defaultForm;
|
if (!node) return defaultForm;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -68,7 +102,7 @@ function buildFormState(node?: NodeItem | null): FormState {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function toPayload(form: FormState): NodeMutationPayload {
|
function toPayload(form: NodeFormValues): NodeMutationPayload {
|
||||||
const base: NodeMutationPayload = {
|
const base: NodeMutationPayload = {
|
||||||
node_type: form.node_type,
|
node_type: form.node_type,
|
||||||
name: form.name.trim(),
|
name: form.name.trim(),
|
||||||
@@ -110,30 +144,24 @@ export function NodeEditorDialog({
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSubmit: (payload: NodeMutationPayload) => Promise<void>;
|
onSubmit: (payload: NodeMutationPayload) => Promise<void>;
|
||||||
}) {
|
}) {
|
||||||
const [form, setForm] = useState<FormState>(defaultForm);
|
const form = useForm<NodeFormValues>({
|
||||||
const [error, setError] = useState('');
|
resolver: zodResolver(nodeSchema),
|
||||||
|
defaultValues: defaultForm,
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
setForm(buildFormState(node));
|
form.reset(buildFormValues(node));
|
||||||
setError('');
|
|
||||||
}
|
}
|
||||||
}, [open, node]);
|
}, [form, open, node]);
|
||||||
|
|
||||||
const handleSubmit = async (event: React.FormEvent) => {
|
const nodeType = form.watch('node_type');
|
||||||
event.preventDefault();
|
const ipManualOverride = form.watch('ip_manual_override');
|
||||||
if (!form.name.trim()) {
|
const geoManualOverride = form.watch('geo_manual_override');
|
||||||
setError('请输入节点名称');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (form.ip_manual_override && !form.ip.trim()) {
|
|
||||||
setError('锁定 IP 时必须填写节点 IP');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setError('');
|
const handleSubmit = form.handleSubmit(async (values) => {
|
||||||
await onSubmit(toPayload(form));
|
await onSubmit(toPayload(values));
|
||||||
};
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={(next) => !next && onClose()}>
|
<Dialog open={open} onOpenChange={(next) => !next && onClose()}>
|
||||||
@@ -149,10 +177,10 @@ export function NodeEditorDialog({
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>节点类型</Label>
|
<Label>节点类型</Label>
|
||||||
<Select
|
<Select
|
||||||
value={form.node_type}
|
value={nodeType}
|
||||||
disabled={Boolean(node)}
|
disabled={Boolean(node)}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
setForm((prev) => ({ ...prev, node_type: value as NodeType }))
|
form.setValue('node_type', value as NodeType)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
@@ -170,20 +198,28 @@ export function NodeEditorDialog({
|
|||||||
<Label htmlFor="node-name">节点名称</Label>
|
<Label htmlFor="node-name">节点名称</Label>
|
||||||
<Input
|
<Input
|
||||||
id="node-name"
|
id="node-name"
|
||||||
value={form.name}
|
|
||||||
onChange={(event) => setForm((prev) => ({ ...prev, name: event.target.value }))}
|
|
||||||
placeholder="例如 edge-hk-01"
|
placeholder="例如 edge-hk-01"
|
||||||
|
{...form.register('name')}
|
||||||
/>
|
/>
|
||||||
|
{form.formState.errors.name ? (
|
||||||
|
<p className="text-xs text-destructive">
|
||||||
|
{form.formState.errors.name.message}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="node-ip">节点 IP</Label>
|
<Label htmlFor="node-ip">节点 IP</Label>
|
||||||
<Input
|
<Input
|
||||||
id="node-ip"
|
id="node-ip"
|
||||||
value={form.ip}
|
|
||||||
onChange={(event) => setForm((prev) => ({ ...prev, ip: event.target.value }))}
|
|
||||||
placeholder="可选,接入后自动上报"
|
placeholder="可选,接入后自动上报"
|
||||||
|
{...form.register('ip')}
|
||||||
/>
|
/>
|
||||||
|
{form.formState.errors.ip ? (
|
||||||
|
<p className="text-xs text-destructive">
|
||||||
|
{form.formState.errors.ip.message}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between rounded-lg border px-3 py-2">
|
<div className="flex items-center justify-between rounded-lg border px-3 py-2">
|
||||||
@@ -192,9 +228,9 @@ export function NodeEditorDialog({
|
|||||||
<p className="text-xs text-muted-foreground">启用后管理端不再自动覆盖 IP</p>
|
<p className="text-xs text-muted-foreground">启用后管理端不再自动覆盖 IP</p>
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<Switch
|
||||||
checked={form.ip_manual_override}
|
checked={ipManualOverride}
|
||||||
onCheckedChange={(checked) =>
|
onCheckedChange={(checked) =>
|
||||||
setForm((prev) => ({ ...prev, ip_manual_override: checked }))
|
form.setValue('ip_manual_override', checked)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -205,34 +241,38 @@ export function NodeEditorDialog({
|
|||||||
<p className="text-xs text-muted-foreground">启用后节点将自动拉取正式版更新</p>
|
<p className="text-xs text-muted-foreground">启用后节点将自动拉取正式版更新</p>
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<Switch
|
||||||
checked={form.auto_update_enabled}
|
checked={form.watch('auto_update_enabled')}
|
||||||
onCheckedChange={(checked) =>
|
onCheckedChange={(checked) =>
|
||||||
setForm((prev) => ({ ...prev, auto_update_enabled: checked }))
|
form.setValue('auto_update_enabled', checked)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{form.node_type === 'tunnel_relay' ? (
|
{nodeType === 'tunnel_relay' ? (
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="relay-bind-port">绑定端口</Label>
|
<Label htmlFor="relay-bind-port">绑定端口</Label>
|
||||||
<Input
|
<Input
|
||||||
id="relay-bind-port"
|
id="relay-bind-port"
|
||||||
value={form.relay_bind_port}
|
{...form.register('relay_bind_port')}
|
||||||
onChange={(event) =>
|
|
||||||
setForm((prev) => ({ ...prev, relay_bind_port: event.target.value }))
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
|
{form.formState.errors.relay_bind_port ? (
|
||||||
|
<p className="text-xs text-destructive">
|
||||||
|
{form.formState.errors.relay_bind_port.message}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="relay-vhost-port">VHost 端口</Label>
|
<Label htmlFor="relay-vhost-port">VHost 端口</Label>
|
||||||
<Input
|
<Input
|
||||||
id="relay-vhost-port"
|
id="relay-vhost-port"
|
||||||
value={form.relay_vhost_http_port}
|
{...form.register('relay_vhost_http_port')}
|
||||||
onChange={(event) =>
|
|
||||||
setForm((prev) => ({ ...prev, relay_vhost_http_port: event.target.value }))
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
|
{form.formState.errors.relay_vhost_http_port ? (
|
||||||
|
<p className="text-xs text-destructive">
|
||||||
|
{form.formState.errors.relay_vhost_http_port.message}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -243,50 +283,30 @@ export function NodeEditorDialog({
|
|||||||
<p className="text-xs text-muted-foreground">用于总览地图展示</p>
|
<p className="text-xs text-muted-foreground">用于总览地图展示</p>
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<Switch
|
||||||
checked={form.geo_manual_override}
|
checked={geoManualOverride}
|
||||||
onCheckedChange={(checked) =>
|
onCheckedChange={(checked) =>
|
||||||
setForm((prev) => ({ ...prev, geo_manual_override: checked }))
|
form.setValue('geo_manual_override', checked)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{form.geo_manual_override ? (
|
{geoManualOverride ? (
|
||||||
<div className="grid gap-3 sm:grid-cols-3">
|
<div className="grid gap-3 sm:grid-cols-3">
|
||||||
<div className="space-y-2 sm:col-span-3">
|
<div className="space-y-2 sm:col-span-3">
|
||||||
<Label htmlFor="geo-name">位置名称</Label>
|
<Label htmlFor="geo-name">位置名称</Label>
|
||||||
<Input
|
<Input id="geo-name" {...form.register('geo_name')} />
|
||||||
id="geo-name"
|
|
||||||
value={form.geo_name}
|
|
||||||
onChange={(event) =>
|
|
||||||
setForm((prev) => ({ ...prev, geo_name: event.target.value }))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="geo-lat">纬度</Label>
|
<Label htmlFor="geo-lat">纬度</Label>
|
||||||
<Input
|
<Input id="geo-lat" {...form.register('geo_latitude')} />
|
||||||
id="geo-lat"
|
|
||||||
value={form.geo_latitude}
|
|
||||||
onChange={(event) =>
|
|
||||||
setForm((prev) => ({ ...prev, geo_latitude: event.target.value }))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2 sm:col-span-2">
|
<div className="space-y-2 sm:col-span-2">
|
||||||
<Label htmlFor="geo-lng">经度</Label>
|
<Label htmlFor="geo-lng">经度</Label>
|
||||||
<Input
|
<Input id="geo-lng" {...form.register('geo_longitude')} />
|
||||||
id="geo-lng"
|
|
||||||
value={form.geo_longitude}
|
|
||||||
onChange={(event) =>
|
|
||||||
setForm((prev) => ({ ...prev, geo_longitude: event.target.value }))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{error ? <p className="text-sm text-destructive">{error}</p> : null}
|
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button type="button" variant="outline" onClick={onClose} disabled={submitting}>
|
<Button type="button" variant="outline" onClick={onClose} disabled={submitting}>
|
||||||
取消
|
取消
|
||||||
|
|||||||
@@ -2,8 +2,9 @@
|
|||||||
|
|
||||||
import {useCallback, useEffect, useState} from 'react';
|
import {useCallback, useEffect, useState} from 'react';
|
||||||
import {useRouter, useSearchParams} from 'next/navigation';
|
import {useRouter, useSearchParams} from 'next/navigation';
|
||||||
import {toast} from 'sonner';
|
|
||||||
|
|
||||||
|
import {EmptyStateWithBorder} from '@/components/layout/empty';
|
||||||
|
import {ErrorInline} from '@/components/layout/error';
|
||||||
import {Skeleton} from '@/components/ui/skeleton';
|
import {Skeleton} from '@/components/ui/skeleton';
|
||||||
import {Tabs, TabsContent, TabsList, TabsTrigger} from '@/components/ui/tabs';
|
import {Tabs, TabsContent, TabsList, TabsTrigger} from '@/components/ui/tabs';
|
||||||
import type {ProxyRouteConfigSection, ProxyRouteItem} from '@/lib/services/openflare';
|
import type {ProxyRouteConfigSection, ProxyRouteItem} from '@/lib/services/openflare';
|
||||||
@@ -23,9 +24,11 @@ export function ProxyRouteDetailPageClient() {
|
|||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const routeId = Number(searchParams.get('id'));
|
const routeId = Number(searchParams.get('id'));
|
||||||
const activeSection = getProxyRouteConfigSection(searchParams.get('section'));
|
const activeSection = getProxyRouteConfigSection(searchParams.get('section'));
|
||||||
|
const hasValidId = Number.isFinite(routeId) && routeId > 0;
|
||||||
|
|
||||||
const [route, setRoute] = useState<ProxyRouteItem | null>(null);
|
const [route, setRoute] = useState<ProxyRouteItem | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [loadError, setLoadError] = useState<string | null>(null);
|
||||||
const [sectionSaving, setSectionSaving] = useState(false);
|
const [sectionSaving, setSectionSaving] = useState(false);
|
||||||
|
|
||||||
const handleSectionChange = useCallback(
|
const handleSectionChange = useCallback(
|
||||||
@@ -41,47 +44,36 @@ export function ProxyRouteDetailPageClient() {
|
|||||||
setRoute(updatedRoute);
|
setRoute(updatedRoute);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
const fetchRoute = useCallback(async () => {
|
||||||
if (!Number.isFinite(routeId) || routeId <= 0) {
|
if (!hasValidId) {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
setRoute(null);
|
setRoute(null);
|
||||||
|
setLoadError(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let cancelled = false;
|
setLoading(true);
|
||||||
|
setLoadError(null);
|
||||||
|
|
||||||
const fetchRoute = async () => {
|
try {
|
||||||
setLoading(true);
|
const data = await ProxyRouteService.getById(routeId);
|
||||||
try {
|
setRoute(data);
|
||||||
const data = await ProxyRouteService.getById(routeId);
|
} catch (error) {
|
||||||
if (!cancelled) {
|
setRoute(null);
|
||||||
setRoute(data);
|
setLoadError(error instanceof Error ? error.message : '未知错误');
|
||||||
}
|
} finally {
|
||||||
} catch (error) {
|
setLoading(false);
|
||||||
if (!cancelled) {
|
}
|
||||||
setRoute(null);
|
}, [hasValidId, routeId]);
|
||||||
toast.error('规则详情加载失败', {
|
|
||||||
description: error instanceof Error ? error.message : '未知错误',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
if (!cancelled) {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
void fetchRoute();
|
void fetchRoute();
|
||||||
|
}, [fetchRoute]);
|
||||||
|
|
||||||
return () => {
|
if (!hasValidId) {
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [routeId]);
|
|
||||||
|
|
||||||
if (!Number.isFinite(routeId) || routeId <= 0) {
|
|
||||||
return (
|
return (
|
||||||
<div className="py-6 px-1">
|
<div className="py-6 px-1">
|
||||||
<p className="text-sm text-muted-foreground">缺少有效的规则 ID。</p>
|
<ErrorInline message="缺少有效的规则 ID。" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -96,10 +88,24 @@ export function ProxyRouteDetailPageClient() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (loadError) {
|
||||||
|
return (
|
||||||
|
<div className="py-6 px-1">
|
||||||
|
<ErrorInline
|
||||||
|
message={loadError}
|
||||||
|
onRetry={() => void fetchRoute()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (!route) {
|
if (!route) {
|
||||||
return (
|
return (
|
||||||
<div className="py-6 px-1">
|
<div className="py-6 px-1">
|
||||||
<p className="text-sm text-muted-foreground">未找到对应规则,请返回列表重试。</p>
|
<EmptyStateWithBorder
|
||||||
|
title="未找到对应规则"
|
||||||
|
description="请返回列表重试。"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import {useEffect, useMemo, useState} from 'react';
|
import {useEffect, useMemo, useState} from 'react';
|
||||||
|
import {zodResolver} from '@hookform/resolvers/zod';
|
||||||
import {Search} from 'lucide-react';
|
import {Search} from 'lucide-react';
|
||||||
|
import {useForm} from 'react-hook-form';
|
||||||
|
import {z} from 'zod';
|
||||||
|
|
||||||
import {Button} from '@/components/ui/button';
|
import {Button} from '@/components/ui/button';
|
||||||
import {Checkbox} from '@/components/ui/checkbox';
|
import {Checkbox} from '@/components/ui/checkbox';
|
||||||
@@ -19,45 +22,101 @@ import {Textarea} from '@/components/ui/textarea';
|
|||||||
import {cn} from '@/lib/utils';
|
import {cn} from '@/lib/utils';
|
||||||
import type {WAFIPGroup} from '@/lib/services/openflare';
|
import type {WAFIPGroup} from '@/lib/services/openflare';
|
||||||
|
|
||||||
import {
|
import {type CountryOption, normalizeItems, type RuleDimension, type RuleListType, textToList,} from './helpers';
|
||||||
type CountryOption,
|
|
||||||
normalizeItems,
|
const ruleEntrySchema = z
|
||||||
type RuleDimension,
|
.object({
|
||||||
type RuleListType,
|
listType: z.enum(['whitelist', 'blacklist']),
|
||||||
type RuleModalState,
|
dimension: z.enum(['ip', 'ip_group', 'country']),
|
||||||
} from './helpers';
|
ipValue: z.string(),
|
||||||
|
ipGroupIDs: z.array(z.number()),
|
||||||
|
countryValues: z.array(z.string()),
|
||||||
|
})
|
||||||
|
.superRefine((value, context) => {
|
||||||
|
if (value.dimension === 'ip') {
|
||||||
|
if (textToList(value.ipValue).length === 0) {
|
||||||
|
context.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ['ipValue'],
|
||||||
|
message: '请输入至少一个 IP 或 IP 段',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (value.dimension === 'ip_group') {
|
||||||
|
if (value.ipGroupIDs.length === 0) {
|
||||||
|
context.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ['ipGroupIDs'],
|
||||||
|
message: '请选择至少一个 IP 组',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (normalizeItems(value.countryValues).length === 0) {
|
||||||
|
context.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ['countryValues'],
|
||||||
|
message: '请选择至少一个国家/地区',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export type RuleEntryFormValues = z.infer<typeof ruleEntrySchema>;
|
||||||
|
|
||||||
|
const defaultRuleEntryValues: RuleEntryFormValues = {
|
||||||
|
listType: 'blacklist',
|
||||||
|
dimension: 'ip',
|
||||||
|
ipValue: '',
|
||||||
|
ipGroupIDs: [],
|
||||||
|
countryValues: [],
|
||||||
|
};
|
||||||
|
|
||||||
interface RuleEntryDialogProps {
|
interface RuleEntryDialogProps {
|
||||||
state: RuleModalState;
|
open: boolean;
|
||||||
countryOptions: CountryOption[];
|
countryOptions: CountryOption[];
|
||||||
ipGroups: WAFIPGroup[];
|
ipGroups: WAFIPGroup[];
|
||||||
onClose: () => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
onChange: (patch: Partial<RuleModalState>) => void;
|
onSubmit: (values: RuleEntryFormValues) => void;
|
||||||
onSubmit: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RuleEntryDialog({
|
export function RuleEntryDialog({
|
||||||
state,
|
open,
|
||||||
countryOptions,
|
countryOptions,
|
||||||
ipGroups,
|
ipGroups,
|
||||||
onClose,
|
onOpenChange,
|
||||||
onChange,
|
|
||||||
onSubmit,
|
onSubmit,
|
||||||
}: RuleEntryDialogProps) {
|
}: RuleEntryDialogProps) {
|
||||||
const [keyword, setKeyword] = useState('');
|
const [keyword, setKeyword] = useState('');
|
||||||
|
const form = useForm<RuleEntryFormValues>({
|
||||||
|
resolver: zodResolver(ruleEntrySchema),
|
||||||
|
defaultValues: defaultRuleEntryValues,
|
||||||
|
});
|
||||||
|
|
||||||
|
const listType = form.watch('listType');
|
||||||
|
const dimension = form.watch('dimension');
|
||||||
|
const ipValue = form.watch('ipValue');
|
||||||
|
const ipGroupIDs = form.watch('ipGroupIDs');
|
||||||
|
const countryValues = form.watch('countryValues');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!state.open) return;
|
if (!open) return;
|
||||||
|
form.reset(defaultRuleEntryValues);
|
||||||
setKeyword('');
|
setKeyword('');
|
||||||
}, [state.dimension, state.open]);
|
}, [form, open]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
setKeyword('');
|
||||||
|
}, [dimension, open]);
|
||||||
|
|
||||||
const selectedCountrySet = useMemo(
|
const selectedCountrySet = useMemo(
|
||||||
() => new Set(state.countryValues),
|
() => new Set(countryValues),
|
||||||
[state.countryValues],
|
[countryValues],
|
||||||
);
|
);
|
||||||
const selectedIPGroupSet = useMemo(
|
const selectedIPGroupSet = useMemo(
|
||||||
() => new Set(state.ipGroupIDs),
|
() => new Set(ipGroupIDs),
|
||||||
[state.ipGroupIDs],
|
[ipGroupIDs],
|
||||||
);
|
);
|
||||||
|
|
||||||
const filteredCountries = useMemo(() => {
|
const filteredCountries = useMemo(() => {
|
||||||
@@ -73,28 +132,32 @@ export function RuleEntryDialog({
|
|||||||
|
|
||||||
const toggleCountry = (code: string) => {
|
const toggleCountry = (code: string) => {
|
||||||
const values = selectedCountrySet.has(code)
|
const values = selectedCountrySet.has(code)
|
||||||
? state.countryValues.filter((item) => item !== code)
|
? countryValues.filter((item) => item !== code)
|
||||||
: normalizeItems([...state.countryValues, code]);
|
: normalizeItems([...countryValues, code]);
|
||||||
onChange({ countryValues: values });
|
form.setValue('countryValues', values, { shouldValidate: true });
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleIPGroup = (id: number) => {
|
const toggleIPGroup = (id: number) => {
|
||||||
const values = selectedIPGroupSet.has(id)
|
const values = selectedIPGroupSet.has(id)
|
||||||
? state.ipGroupIDs.filter((item) => item !== id)
|
? ipGroupIDs.filter((item) => item !== id)
|
||||||
: [...state.ipGroupIDs, id].sort((left, right) => left - right);
|
: [...ipGroupIDs, id].sort((left, right) => left - right);
|
||||||
onChange({ ipGroupIDs: values });
|
form.setValue('ipGroupIDs', values, { shouldValidate: true });
|
||||||
};
|
};
|
||||||
|
|
||||||
const typeLabel = state.listType === 'blacklist' ? '黑名单' : '白名单';
|
const typeLabel = listType === 'blacklist' ? '黑名单' : '白名单';
|
||||||
const dimensionLabel =
|
const dimensionLabel =
|
||||||
state.dimension === 'ip'
|
dimension === 'ip'
|
||||||
? 'IP'
|
? 'IP'
|
||||||
: state.dimension === 'ip_group'
|
: dimension === 'ip_group'
|
||||||
? 'IP 组'
|
? 'IP 组'
|
||||||
: '地域';
|
: '地域';
|
||||||
|
|
||||||
|
const handleSubmit = form.handleSubmit((values) => {
|
||||||
|
onSubmit(values);
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={state.open} onOpenChange={(open) => !open && onClose()}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>添加{typeLabel}规则</DialogTitle>
|
<DialogTitle>添加{typeLabel}规则</DialogTitle>
|
||||||
@@ -115,9 +178,9 @@ export function RuleEntryDialog({
|
|||||||
<Button
|
<Button
|
||||||
key={option.value}
|
key={option.value}
|
||||||
type="button"
|
type="button"
|
||||||
variant={state.listType === option.value ? 'default' : 'outline'}
|
variant={listType === option.value ? 'default' : 'outline'}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
onChange({ listType: option.value as RuleListType })
|
form.setValue('listType', option.value as RuleListType)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{option.label}
|
{option.label}
|
||||||
@@ -137,9 +200,9 @@ export function RuleEntryDialog({
|
|||||||
key={option.value}
|
key={option.value}
|
||||||
type="button"
|
type="button"
|
||||||
size="sm"
|
size="sm"
|
||||||
variant={state.dimension === option.value ? 'default' : 'outline'}
|
variant={dimension === option.value ? 'default' : 'outline'}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
onChange({ dimension: option.value as RuleDimension })
|
form.setValue('dimension', option.value as RuleDimension)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{option.label}
|
{option.label}
|
||||||
@@ -149,21 +212,28 @@ export function RuleEntryDialog({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{state.dimension === 'ip' ? (
|
{dimension === 'ip' ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>IP / IP 段</Label>
|
<Label>IP / IP 段</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
value={state.ipValue}
|
value={ipValue}
|
||||||
placeholder="例如 1.1.1.1 或 192.168.0.0/24"
|
placeholder="例如 1.1.1.1 或 192.168.0.0/24"
|
||||||
onChange={(event) => onChange({ ipValue: event.target.value })}
|
onChange={(event) =>
|
||||||
|
form.setValue('ipValue', event.target.value, { shouldValidate: true })
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
支持单个 IP、CIDR,或使用换行/逗号一次添加多个。
|
支持单个 IP、CIDR,或使用换行/逗号一次添加多个。
|
||||||
</p>
|
</p>
|
||||||
|
{form.formState.errors.ipValue ? (
|
||||||
|
<p className="text-xs text-destructive">
|
||||||
|
{form.formState.errors.ipValue.message}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{state.dimension === 'ip_group' ? (
|
{dimension === 'ip_group' ? (
|
||||||
<div className="space-y-3 rounded-lg border border-dashed p-4">
|
<div className="space-y-3 rounded-lg border border-dashed p-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
@@ -173,7 +243,7 @@ export function RuleEntryDialog({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
已选 {state.ipGroupIDs.length}
|
已选 {ipGroupIDs.length}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="max-h-64 space-y-2 overflow-y-auto">
|
<div className="max-h-64 space-y-2 overflow-y-auto">
|
||||||
@@ -208,10 +278,15 @@ export function RuleEntryDialog({
|
|||||||
<p className="text-sm text-muted-foreground">暂无 IP 组,请先创建。</p>
|
<p className="text-sm text-muted-foreground">暂无 IP 组,请先创建。</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{form.formState.errors.ipGroupIDs ? (
|
||||||
|
<p className="text-xs text-destructive">
|
||||||
|
{form.formState.errors.ipGroupIDs.message}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{state.dimension === 'country' ? (
|
{dimension === 'country' ? (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center gap-2 rounded-md border px-3 py-2">
|
<div className="flex items-center gap-2 rounded-md border px-3 py-2">
|
||||||
<Search className="size-4 text-muted-foreground" />
|
<Search className="size-4 text-muted-foreground" />
|
||||||
@@ -226,12 +301,14 @@ export function RuleEntryDialog({
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
onChange({
|
form.setValue(
|
||||||
countryValues: normalizeItems([
|
'countryValues',
|
||||||
...state.countryValues,
|
normalizeItems([
|
||||||
|
...countryValues,
|
||||||
...filteredCountries.map((option) => option.code),
|
...filteredCountries.map((option) => option.code),
|
||||||
]),
|
]),
|
||||||
})
|
{ shouldValidate: true },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
全选当前
|
全选当前
|
||||||
@@ -240,7 +317,9 @@ export function RuleEntryDialog({
|
|||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => onChange({ countryValues: [] })}
|
onClick={() =>
|
||||||
|
form.setValue('countryValues', [], { shouldValidate: true })
|
||||||
|
}
|
||||||
>
|
>
|
||||||
清空
|
清空
|
||||||
</Button>
|
</Button>
|
||||||
@@ -265,15 +344,20 @@ export function RuleEntryDialog({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
{form.formState.errors.countryValues ? (
|
||||||
|
<p className="text-xs text-destructive">
|
||||||
|
{form.formState.errors.countryValues.message}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button type="button" variant="outline" onClick={onClose}>
|
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
取消
|
取消
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="button" onClick={onSubmit}>
|
<Button type="button" onClick={() => void handleSubmit()}>
|
||||||
添加
|
添加
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
|||||||
@@ -26,18 +26,16 @@ import {
|
|||||||
buildCountryOptions,
|
buildCountryOptions,
|
||||||
buildRuleGroupDraft,
|
buildRuleGroupDraft,
|
||||||
countRuleEntries,
|
countRuleEntries,
|
||||||
defaultRuleModalState,
|
|
||||||
emptyRuleGroupDraft,
|
emptyRuleGroupDraft,
|
||||||
formatCountryItem,
|
formatCountryItem,
|
||||||
getListFieldKey,
|
getListFieldKey,
|
||||||
type ListFieldKey,
|
type ListFieldKey,
|
||||||
normalizeItems,
|
normalizeItems,
|
||||||
type RuleModalState,
|
|
||||||
textToList,
|
textToList,
|
||||||
updateDraftList,
|
updateDraftList,
|
||||||
} from './helpers';
|
} from './helpers';
|
||||||
import {PowConfigPanel} from './pow-config-panel';
|
import {PowConfigPanel} from './pow-config-panel';
|
||||||
import {RuleEntryDialog} from './rule-entry-dialog';
|
import {RuleEntryDialog, type RuleEntryFormValues} from './rule-entry-dialog';
|
||||||
import {RuleListSection} from './rule-list-section';
|
import {RuleListSection} from './rule-list-section';
|
||||||
|
|
||||||
const ruleGroupSchema = z.object({
|
const ruleGroupSchema = z.object({
|
||||||
@@ -69,7 +67,7 @@ export function RuleGroupDialog({
|
|||||||
onSubmit,
|
onSubmit,
|
||||||
}: RuleGroupDialogProps) {
|
}: RuleGroupDialogProps) {
|
||||||
const [listsDraft, setListsDraft] = useState<WAFRuleGroupPayload>(emptyRuleGroupDraft);
|
const [listsDraft, setListsDraft] = useState<WAFRuleGroupPayload>(emptyRuleGroupDraft);
|
||||||
const [ruleModal, setRuleModal] = useState<RuleModalState>(defaultRuleModalState);
|
const [ruleEntryOpen, setRuleEntryOpen] = useState(false);
|
||||||
|
|
||||||
const countryOptions = useMemo(() => buildCountryOptions(), []);
|
const countryOptions = useMemo(() => buildCountryOptions(), []);
|
||||||
const countryLabelMap = useMemo(
|
const countryLabelMap = useMemo(
|
||||||
@@ -101,7 +99,7 @@ export function RuleGroupDialog({
|
|||||||
remark: draft.remark,
|
remark: draft.remark,
|
||||||
pow_enabled: draft.pow_enabled,
|
pow_enabled: draft.pow_enabled,
|
||||||
});
|
});
|
||||||
setRuleModal(defaultRuleModalState);
|
setRuleEntryOpen(false);
|
||||||
}, [form, group, open]);
|
}, [form, group, open]);
|
||||||
|
|
||||||
const ipGroupByID = new Map(ipGroups.map((item) => [item.id, item]));
|
const ipGroupByID = new Map(ipGroups.map((item) => [item.id, item]));
|
||||||
@@ -126,23 +124,21 @@ export function RuleGroupDialog({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const applyRuleModal = () => {
|
const applyRuleEntry = (entry: RuleEntryFormValues) => {
|
||||||
const values =
|
const values =
|
||||||
ruleModal.dimension === 'ip'
|
entry.dimension === 'ip'
|
||||||
? textToList(ruleModal.ipValue)
|
? textToList(entry.ipValue)
|
||||||
: ruleModal.dimension === 'ip_group'
|
: entry.dimension === 'ip_group'
|
||||||
? ruleModal.ipGroupIDs.map(String)
|
? entry.ipGroupIDs.map(String)
|
||||||
: normalizeItems(ruleModal.countryValues);
|
: normalizeItems(entry.countryValues);
|
||||||
|
|
||||||
if (values.length === 0) return;
|
const listKey = getListFieldKey(entry.listType, entry.dimension);
|
||||||
|
|
||||||
const listKey = getListFieldKey(ruleModal.listType, ruleModal.dimension);
|
|
||||||
setListsDraft((current) =>
|
setListsDraft((current) =>
|
||||||
updateDraftList(current, listKey, (items) =>
|
updateDraftList(current, listKey, (items) =>
|
||||||
normalizeItems([...items, ...values]),
|
normalizeItems([...items, ...values]),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
setRuleModal(defaultRuleModalState);
|
setRuleEntryOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = form.handleSubmit(async (values) => {
|
const handleSubmit = form.handleSubmit(async (values) => {
|
||||||
@@ -244,7 +240,7 @@ export function RuleGroupDialog({
|
|||||||
type="button"
|
type="button"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
setRuleModal({ ...defaultRuleModalState, open: true })
|
setRuleEntryOpen(true)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Plus className="size-3.5 mr-1" />
|
<Plus className="size-3.5 mr-1" />
|
||||||
@@ -392,12 +388,11 @@ export function RuleGroupDialog({
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<RuleEntryDialog
|
<RuleEntryDialog
|
||||||
state={ruleModal}
|
open={ruleEntryOpen}
|
||||||
countryOptions={countryOptions}
|
countryOptions={countryOptions}
|
||||||
ipGroups={ipGroups}
|
ipGroups={ipGroups}
|
||||||
onClose={() => setRuleModal(defaultRuleModalState)}
|
onOpenChange={setRuleEntryOpen}
|
||||||
onChange={(patch) => setRuleModal((current) => ({ ...current, ...patch, open: true }))}
|
onSubmit={applyRuleEntry}
|
||||||
onSubmit={applyRuleModal}
|
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
+24
-20
@@ -1,9 +1,11 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import {useMutation, useQueryClient} from '@tanstack/react-query';
|
import {useMutation, useQueryClient} from '@tanstack/react-query';
|
||||||
|
import {zodResolver} from '@hookform/resolvers/zod';
|
||||||
import {useState} from 'react';
|
import {useState} from 'react';
|
||||||
import {useForm} from 'react-hook-form';
|
import {useForm} from 'react-hook-form';
|
||||||
import {Loader2} from 'lucide-react';
|
import {Loader2} from 'lucide-react';
|
||||||
|
import {z} from 'zod';
|
||||||
|
|
||||||
import {Button} from '@/components/ui/button';
|
import {Button} from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
@@ -23,11 +25,13 @@ import {getErrorMessage} from './website-utils';
|
|||||||
|
|
||||||
const dnsAccountsQueryKey = ['openflare', 'dns-accounts'];
|
const dnsAccountsQueryKey = ['openflare', 'dns-accounts'];
|
||||||
|
|
||||||
type DnsAccountFormValues = {
|
const dnsAccountSchema = z.object({
|
||||||
name: string;
|
name: z.string().trim().min(1, '请输入名称').max(255),
|
||||||
type: string;
|
type: z.string().min(1),
|
||||||
authorization: string;
|
authorization: z.string().trim().min(1, '请输入 Token'),
|
||||||
};
|
});
|
||||||
|
|
||||||
|
type DnsAccountFormValues = z.infer<typeof dnsAccountSchema>;
|
||||||
|
|
||||||
interface DnsAccountCreateDialogProps {
|
interface DnsAccountCreateDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -42,16 +46,16 @@ export function DnsAccountCreateDialog({
|
|||||||
}: DnsAccountCreateDialogProps) {
|
}: DnsAccountCreateDialogProps) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const {register, handleSubmit, setValue, watch, formState, reset} =
|
const form = useForm<DnsAccountFormValues>({
|
||||||
useForm<DnsAccountFormValues>({
|
resolver: zodResolver(dnsAccountSchema),
|
||||||
defaultValues: {name: '', type: 'cloudflare', authorization: ''},
|
defaultValues: {name: '', type: 'cloudflare', authorization: ''},
|
||||||
});
|
});
|
||||||
|
|
||||||
const createMutation = useMutation({
|
const createMutation = useMutation({
|
||||||
mutationFn: DnsAccountService.create,
|
mutationFn: DnsAccountService.create,
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
await queryClient.invalidateQueries({queryKey: dnsAccountsQueryKey});
|
await queryClient.invalidateQueries({queryKey: dnsAccountsQueryKey});
|
||||||
reset();
|
form.reset();
|
||||||
setError('');
|
setError('');
|
||||||
onCreated?.();
|
onCreated?.();
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
@@ -59,7 +63,7 @@ export function DnsAccountCreateDialog({
|
|||||||
onError: (err) => setError(getErrorMessage(err)),
|
onError: (err) => setError(getErrorMessage(err)),
|
||||||
});
|
});
|
||||||
|
|
||||||
const onSubmit = handleSubmit((values) => {
|
const onSubmit = form.handleSubmit((values) => {
|
||||||
setError('');
|
setError('');
|
||||||
let auth = values.authorization.trim();
|
let auth = values.authorization.trim();
|
||||||
if (!auth.startsWith('{')) {
|
if (!auth.startsWith('{')) {
|
||||||
@@ -69,7 +73,7 @@ export function DnsAccountCreateDialog({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
reset();
|
form.reset();
|
||||||
setError('');
|
setError('');
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
};
|
};
|
||||||
@@ -91,18 +95,18 @@ export function DnsAccountCreateDialog({
|
|||||||
<Label>账号名称</Label>
|
<Label>账号名称</Label>
|
||||||
<Input
|
<Input
|
||||||
placeholder="Cloudflare 邮箱账号"
|
placeholder="Cloudflare 邮箱账号"
|
||||||
{...register('name', {required: '请输入名称'})}
|
{...form.register('name')}
|
||||||
/>
|
/>
|
||||||
{formState.errors.name ? (
|
{form.formState.errors.name ? (
|
||||||
<p className="text-xs text-destructive">{formState.errors.name.message}</p>
|
<p className="text-xs text-destructive">{form.formState.errors.name.message}</p>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>DNS 服务商</Label>
|
<Label>DNS 服务商</Label>
|
||||||
<Select
|
<Select
|
||||||
value={watch('type')}
|
value={form.watch('type')}
|
||||||
onValueChange={(value) => setValue('type', value)}
|
onValueChange={(value) => form.setValue('type', value)}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
@@ -116,12 +120,12 @@ export function DnsAccountCreateDialog({
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>API Token</Label>
|
<Label>API Token</Label>
|
||||||
<Input
|
<Input
|
||||||
{...register('authorization', {required: '请输入 Token'})}
|
{...form.register('authorization')}
|
||||||
placeholder="请勿使用 Global API Key"
|
placeholder="请勿使用 Global API Key"
|
||||||
/>
|
/>
|
||||||
{formState.errors.authorization ? (
|
{form.formState.errors.authorization ? (
|
||||||
<p className="text-xs text-destructive">
|
<p className="text-xs text-destructive">
|
||||||
{formState.errors.authorization.message}
|
{form.formState.errors.authorization.message}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {useNotificationSettings} from "@/contexts/notification-settings-context"
|
|||||||
import {SidebarTrigger} from "@/components/ui/sidebar"
|
import {SidebarTrigger} from "@/components/ui/sidebar"
|
||||||
import {useTheme} from "next-themes"
|
import {useTheme} from "next-themes"
|
||||||
import {useRouter} from "next/navigation"
|
import {useRouter} from "next/navigation"
|
||||||
|
import {OpenFlareVersionEntry} from "@/components/layout/openflare-version-entry"
|
||||||
import {SearchDialog} from "@/components/layout/search-dialog"
|
import {SearchDialog} from "@/components/layout/search-dialog"
|
||||||
import {Kbd} from "@/components/ui/kbd"
|
import {Kbd} from "@/components/ui/kbd"
|
||||||
|
|
||||||
@@ -100,6 +101,7 @@ export function SiteHeader({ isFullWidth = false, onToggleFullWidth }: { isFullW
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
|
<OpenFlareVersionEntry />
|
||||||
<Button variant="ghost" size="icon" className="size-9 text-muted-foreground hover:text-foreground" onClick={() => setSearchOpen(true)}>
|
<Button variant="ghost" size="icon" className="size-9 text-muted-foreground hover:text-foreground" onClick={() => setSearchOpen(true)}>
|
||||||
<Search className="size-[18px]" />
|
<Search className="size-[18px]" />
|
||||||
<span className="sr-only">搜索</span>
|
<span className="sr-only">搜索</span>
|
||||||
@@ -125,6 +127,7 @@ export function SiteHeader({ isFullWidth = false, onToggleFullWidth }: { isFullW
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="ml-auto flex items-center gap-1">
|
<div className="ml-auto flex items-center gap-1">
|
||||||
|
<OpenFlareVersionEntry />
|
||||||
<BellButton />
|
<BellButton />
|
||||||
<Button variant="ghost" size="icon" className="size-9 text-muted-foreground hover:text-foreground" onClick={() => router.push('/settings')}>
|
<Button variant="ghost" size="icon" className="size-9 text-muted-foreground hover:text-foreground" onClick={() => router.push('/settings')}>
|
||||||
<Settings className="size-[18px]" />
|
<Settings className="size-[18px]" />
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import {useState} from 'react';
|
||||||
|
import {useQuery} from '@tanstack/react-query';
|
||||||
|
import {usePathname} from 'next/navigation';
|
||||||
|
|
||||||
|
import {VersionUpgradeDialog} from '@/app/(main)/openflare/components/version-upgrade-dialog';
|
||||||
|
import {Badge} from '@/components/ui/badge';
|
||||||
|
import {Button} from '@/components/ui/button';
|
||||||
|
import {useUser} from '@/contexts/user-context';
|
||||||
|
import {
|
||||||
|
openflareLatestReleaseQueryKey,
|
||||||
|
openflarePublicStatusQueryKey,
|
||||||
|
} from '@/lib/hooks/use-openflare-server-upgrade';
|
||||||
|
import {StatusService, UpdateService} from '@/lib/services/openflare';
|
||||||
|
|
||||||
|
export function OpenFlareVersionEntry() {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const {user} = useUser();
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
|
||||||
|
const isOpenFlareRoute = pathname.startsWith('/openflare');
|
||||||
|
const canUpgrade = Boolean(user?.is_admin);
|
||||||
|
|
||||||
|
const statusQuery = useQuery({
|
||||||
|
queryKey: openflarePublicStatusQueryKey,
|
||||||
|
queryFn: () => StatusService.getPublicStatus(),
|
||||||
|
enabled: isOpenFlareRoute,
|
||||||
|
staleTime: 60_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const releaseQuery = useQuery({
|
||||||
|
queryKey: openflareLatestReleaseQueryKey('stable'),
|
||||||
|
queryFn: () => UpdateService.getLatestRelease('stable'),
|
||||||
|
enabled: isOpenFlareRoute && canUpgrade,
|
||||||
|
staleTime: 60 * 60 * 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!isOpenFlareRoute) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const version = statusQuery.data?.version || releaseQuery.data?.current_version || 'unknown';
|
||||||
|
const hasUpdate = Boolean(canUpgrade && releaseQuery.data?.has_update);
|
||||||
|
const versionLabel = hasUpdate ? `OpenFlare ${version} · 可升级` : `OpenFlare ${version}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="hidden h-8 gap-2 px-2.5 text-xs md:inline-flex"
|
||||||
|
onClick={() => setDialogOpen(true)}
|
||||||
|
>
|
||||||
|
<span className="max-w-[180px] truncate">{versionLabel}</span>
|
||||||
|
{hasUpdate ? <Badge variant="secondary" className="h-5 px-1.5 text-[10px]">新</Badge> : null}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="size-9 md:hidden"
|
||||||
|
onClick={() => setDialogOpen(true)}
|
||||||
|
aria-label="服务端版本"
|
||||||
|
>
|
||||||
|
<Badge
|
||||||
|
variant={hasUpdate ? 'secondary' : 'outline'}
|
||||||
|
className="h-6 px-2 text-[10px] font-medium"
|
||||||
|
>
|
||||||
|
版本
|
||||||
|
</Badge>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<VersionUpgradeDialog
|
||||||
|
open={dialogOpen}
|
||||||
|
onOpenChange={setDialogOpen}
|
||||||
|
canUpgrade={canUpgrade}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,348 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import {useMutation, useQuery} from '@tanstack/react-query';
|
||||||
|
import {useCallback, useEffect, useRef, useState} from 'react';
|
||||||
|
|
||||||
|
import type {
|
||||||
|
LatestReleaseInfo,
|
||||||
|
ReleaseChannel,
|
||||||
|
UpgradeStreamSnapshot,
|
||||||
|
UploadedServerBinaryInfo,
|
||||||
|
} from '@/lib/services/openflare';
|
||||||
|
import {StatusService, UpdateService} from '@/lib/services/openflare';
|
||||||
|
|
||||||
|
const UPGRADE_STATUS_POLL_INTERVAL = 3000;
|
||||||
|
|
||||||
|
export const openflarePublicStatusQueryKey = ['openflare', 'public-status'] as const;
|
||||||
|
|
||||||
|
export const openflareLatestReleaseQueryKey = (channel: ReleaseChannel = 'stable') =>
|
||||||
|
['openflare', 'latest-release', channel] as const;
|
||||||
|
|
||||||
|
export function mergeReleaseWithUpgradeStream(
|
||||||
|
release: LatestReleaseInfo | null | undefined,
|
||||||
|
stream: UpgradeStreamSnapshot | null,
|
||||||
|
) {
|
||||||
|
if (!release || !stream) {
|
||||||
|
return release;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...release,
|
||||||
|
in_progress: stream.in_progress,
|
||||||
|
upgrade_status: stream.upgrade_status,
|
||||||
|
upgrade_logs: stream.upgrade_logs,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useOpenFlareServerUpgrade({
|
||||||
|
open,
|
||||||
|
canUpgrade,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
canUpgrade: boolean;
|
||||||
|
}) {
|
||||||
|
const [selectedChannel, setSelectedChannel] = useState<ReleaseChannel>('stable');
|
||||||
|
const [feedback, setFeedback] = useState<string | null>(null);
|
||||||
|
const [manualStatusMessage, setManualStatusMessage] = useState<string | null>(null);
|
||||||
|
const [manualErrorMessage, setManualErrorMessage] = useState<string | null>(null);
|
||||||
|
const [uploadedBinary, setUploadedBinary] = useState<UploadedServerBinaryInfo | null>(null);
|
||||||
|
const [uploadProgress, setUploadProgress] = useState(0);
|
||||||
|
const [upgradeStream, setUpgradeStream] = useState<UpgradeStreamSnapshot | null>(null);
|
||||||
|
|
||||||
|
const upgradeRefreshPendingRef = useRef(false);
|
||||||
|
const upgradeReloadStartedRef = useRef(false);
|
||||||
|
const upgradeReloadTimerRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
const statusQuery = useQuery({
|
||||||
|
queryKey: openflarePublicStatusQueryKey,
|
||||||
|
queryFn: () => StatusService.getPublicStatus(),
|
||||||
|
enabled: open,
|
||||||
|
});
|
||||||
|
|
||||||
|
const stableReleaseQuery = useQuery({
|
||||||
|
queryKey: openflareLatestReleaseQueryKey('stable'),
|
||||||
|
queryFn: () => UpdateService.getLatestRelease('stable'),
|
||||||
|
enabled: open && canUpgrade,
|
||||||
|
refetchInterval: (query) => {
|
||||||
|
const release = query.state.data;
|
||||||
|
if (open && release?.in_progress) {
|
||||||
|
return UPGRADE_STATUS_POLL_INTERVAL;
|
||||||
|
}
|
||||||
|
return 60 * 60 * 1000;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const previewReleaseQuery = useQuery({
|
||||||
|
queryKey: openflareLatestReleaseQueryKey('preview'),
|
||||||
|
queryFn: () => UpdateService.getLatestRelease('preview'),
|
||||||
|
enabled: false,
|
||||||
|
refetchInterval: (query) => {
|
||||||
|
const release = query.state.data;
|
||||||
|
if (open && release?.in_progress) {
|
||||||
|
return UPGRADE_STATUS_POLL_INTERVAL;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const scheduleUpgradePageReload = useCallback(() => {
|
||||||
|
if (upgradeReloadStartedRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
upgradeReloadStartedRef.current = true;
|
||||||
|
setFeedback('服务升级已进入重启阶段,页面将在服务恢复后自动刷新。');
|
||||||
|
|
||||||
|
const reloadWhenServerReady = async () => {
|
||||||
|
try {
|
||||||
|
await StatusService.getPublicStatus();
|
||||||
|
window.location.reload();
|
||||||
|
} catch {
|
||||||
|
upgradeReloadTimerRef.current = window.setTimeout(reloadWhenServerReady, 1500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
upgradeReloadTimerRef.current = window.setTimeout(reloadWhenServerReady, 1200);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (upgradeReloadTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(upgradeReloadTimerRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !canUpgrade) {
|
||||||
|
setUpgradeStream(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let closed = false;
|
||||||
|
let reconnectTimer: number | null = null;
|
||||||
|
let socket: WebSocket | null = null;
|
||||||
|
|
||||||
|
const connect = () => {
|
||||||
|
if (closed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
socket = UpdateService.createUpgradeLogsWebSocket();
|
||||||
|
if (!socket) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.onmessage = (event) => {
|
||||||
|
const snapshot = UpdateService.parseUpgradeStreamSnapshot(String(event.data));
|
||||||
|
if (snapshot) {
|
||||||
|
if (snapshot.in_progress || snapshot.upgrade_status === 'succeeded') {
|
||||||
|
upgradeRefreshPendingRef.current = true;
|
||||||
|
}
|
||||||
|
if (snapshot.upgrade_status === 'failed') {
|
||||||
|
upgradeRefreshPendingRef.current = false;
|
||||||
|
}
|
||||||
|
setUpgradeStream(snapshot);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.onclose = () => {
|
||||||
|
if (closed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (upgradeRefreshPendingRef.current) {
|
||||||
|
scheduleUpgradePageReload();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
reconnectTimer = window.setTimeout(connect, 1500);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
connect();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
closed = true;
|
||||||
|
if (reconnectTimer !== null) {
|
||||||
|
window.clearTimeout(reconnectTimer);
|
||||||
|
}
|
||||||
|
socket?.close();
|
||||||
|
};
|
||||||
|
}, [canUpgrade, open, scheduleUpgradePageReload]);
|
||||||
|
|
||||||
|
const upgradeMutation = useMutation({
|
||||||
|
mutationFn: (channel: ReleaseChannel) => UpdateService.upgradeServer(channel),
|
||||||
|
onSuccess: (release) => {
|
||||||
|
upgradeRefreshPendingRef.current = true;
|
||||||
|
setUploadedBinary(null);
|
||||||
|
setManualStatusMessage(null);
|
||||||
|
setManualErrorMessage(null);
|
||||||
|
setFeedback(
|
||||||
|
`服务升级任务已启动,目标版本 ${release.tag_name}(${release.channel === 'preview' ? '预览版' : '正式版'})。页面可能短暂不可用。`,
|
||||||
|
);
|
||||||
|
void stableReleaseQuery.refetch();
|
||||||
|
if (release.channel === 'preview') {
|
||||||
|
void previewReleaseQuery.refetch();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
setFeedback(error instanceof Error ? error.message : '升级失败,请稍后重试。');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const uploadBinaryMutation = useMutation({
|
||||||
|
mutationFn: (binary: File) =>
|
||||||
|
UpdateService.uploadServerBinary(binary, (progress) => {
|
||||||
|
setUploadProgress(progress);
|
||||||
|
}),
|
||||||
|
onSuccess: (candidate) => {
|
||||||
|
setUploadProgress(0);
|
||||||
|
setFeedback(null);
|
||||||
|
setManualErrorMessage(null);
|
||||||
|
setUploadedBinary(candidate);
|
||||||
|
setManualStatusMessage(candidate.comparison_message);
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
setUploadProgress(0);
|
||||||
|
setUploadedBinary(null);
|
||||||
|
setManualStatusMessage(null);
|
||||||
|
setManualErrorMessage(
|
||||||
|
error instanceof Error ? error.message : '上传升级包失败,请稍后重试。',
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const confirmManualUpgradeMutation = useMutation({
|
||||||
|
mutationFn: (uploadToken: string) => UpdateService.confirmManualServerUpgrade(uploadToken),
|
||||||
|
onSuccess: (candidate) => {
|
||||||
|
upgradeRefreshPendingRef.current = true;
|
||||||
|
setFeedback(null);
|
||||||
|
setManualErrorMessage(null);
|
||||||
|
setUploadedBinary(candidate);
|
||||||
|
setManualStatusMessage(
|
||||||
|
`手动升级任务已启动,目标版本 ${candidate.detected_version}。页面可能短暂不可用。`,
|
||||||
|
);
|
||||||
|
void stableReleaseQuery.refetch();
|
||||||
|
void previewReleaseQuery.refetch();
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
setManualStatusMessage(null);
|
||||||
|
setManualErrorMessage(
|
||||||
|
error instanceof Error ? error.message : '确认手动升级失败,请稍后重试。',
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const resetTransientState = useCallback(() => {
|
||||||
|
setSelectedChannel('stable');
|
||||||
|
setFeedback(null);
|
||||||
|
setManualStatusMessage(null);
|
||||||
|
setManualErrorMessage(null);
|
||||||
|
setUploadedBinary(null);
|
||||||
|
setUploadProgress(0);
|
||||||
|
setUpgradeStream(null);
|
||||||
|
upgradeRefreshPendingRef.current = false;
|
||||||
|
upgradeReloadStartedRef.current = false;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleOpen = useCallback(() => {
|
||||||
|
resetTransientState();
|
||||||
|
if (canUpgrade) {
|
||||||
|
void stableReleaseQuery.refetch();
|
||||||
|
}
|
||||||
|
}, [canUpgrade, resetTransientState, stableReleaseQuery]);
|
||||||
|
|
||||||
|
const handleCheckRelease = useCallback(() => {
|
||||||
|
setFeedback(null);
|
||||||
|
if (!canUpgrade) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (selectedChannel === 'preview') {
|
||||||
|
void previewReleaseQuery.refetch();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void stableReleaseQuery.refetch();
|
||||||
|
}, [canUpgrade, previewReleaseQuery, selectedChannel, stableReleaseQuery]);
|
||||||
|
|
||||||
|
const handleChannelChange = useCallback((channel: ReleaseChannel) => {
|
||||||
|
setSelectedChannel(channel);
|
||||||
|
setFeedback(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleUpgrade = useCallback(() => {
|
||||||
|
setFeedback(null);
|
||||||
|
setManualStatusMessage(null);
|
||||||
|
setManualErrorMessage(null);
|
||||||
|
upgradeMutation.mutate(selectedChannel);
|
||||||
|
}, [selectedChannel, upgradeMutation]);
|
||||||
|
|
||||||
|
const handleUploadBinary = useCallback(
|
||||||
|
(binary: File) => {
|
||||||
|
setUploadProgress(0);
|
||||||
|
setManualStatusMessage(null);
|
||||||
|
setManualErrorMessage(null);
|
||||||
|
uploadBinaryMutation.mutate(binary);
|
||||||
|
},
|
||||||
|
[uploadBinaryMutation],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleConfirmManualUpgrade = useCallback(() => {
|
||||||
|
if (!uploadedBinary?.upload_token) {
|
||||||
|
setManualStatusMessage(null);
|
||||||
|
setManualErrorMessage('请先上传并检查升级包。');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setFeedback(null);
|
||||||
|
setManualStatusMessage(null);
|
||||||
|
setManualErrorMessage(null);
|
||||||
|
confirmManualUpgradeMutation.mutate(uploadedBinary.upload_token);
|
||||||
|
}, [confirmManualUpgradeMutation, uploadedBinary?.upload_token]);
|
||||||
|
|
||||||
|
const selectedRelease =
|
||||||
|
selectedChannel === 'preview' ? previewReleaseQuery.data : stableReleaseQuery.data;
|
||||||
|
const releaseWithStream = mergeReleaseWithUpgradeStream(selectedRelease, upgradeStream);
|
||||||
|
const selectedReleaseError =
|
||||||
|
selectedChannel === 'preview' ? previewReleaseQuery.error : stableReleaseQuery.error;
|
||||||
|
const isSelectedReleaseError =
|
||||||
|
selectedChannel === 'preview' ? previewReleaseQuery.isError : stableReleaseQuery.isError;
|
||||||
|
const isChecking =
|
||||||
|
selectedChannel === 'preview'
|
||||||
|
? previewReleaseQuery.isFetching
|
||||||
|
: stableReleaseQuery.isFetching;
|
||||||
|
const isInitialLoading =
|
||||||
|
(selectedChannel === 'preview'
|
||||||
|
? previewReleaseQuery.isLoading && !previewReleaseQuery.data
|
||||||
|
: stableReleaseQuery.isLoading && !stableReleaseQuery.data) && canUpgrade;
|
||||||
|
|
||||||
|
const releaseErrorMessage =
|
||||||
|
feedback ||
|
||||||
|
(isSelectedReleaseError
|
||||||
|
? selectedReleaseError instanceof Error
|
||||||
|
? selectedReleaseError.message
|
||||||
|
: '版本检查失败,请稍后重试。'
|
||||||
|
: undefined);
|
||||||
|
|
||||||
|
const currentVersion = statusQuery.data?.version || releaseWithStream?.current_version || 'unknown';
|
||||||
|
|
||||||
|
return {
|
||||||
|
currentVersion,
|
||||||
|
selectedChannel,
|
||||||
|
release: releaseWithStream,
|
||||||
|
uploadedBinary,
|
||||||
|
releaseErrorMessage,
|
||||||
|
manualStatusMessage,
|
||||||
|
manualErrorMessage,
|
||||||
|
isInitialLoading,
|
||||||
|
isChecking,
|
||||||
|
isUpgrading: upgradeMutation.isPending,
|
||||||
|
isUploadingBinary: uploadBinaryMutation.isPending,
|
||||||
|
uploadProgress,
|
||||||
|
isConfirmingManualUpgrade: confirmManualUpgradeMutation.isPending,
|
||||||
|
stableRelease: stableReleaseQuery.data,
|
||||||
|
handleOpen,
|
||||||
|
handleCheckRelease,
|
||||||
|
handleChannelChange,
|
||||||
|
handleUpgrade,
|
||||||
|
handleUploadBinary,
|
||||||
|
handleConfirmManualUpgrade,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
Gauge,
|
Gauge,
|
||||||
GitBranch,
|
GitBranch,
|
||||||
Globe,
|
Globe,
|
||||||
|
Info,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
MapPin,
|
MapPin,
|
||||||
Route,
|
Route,
|
||||||
@@ -43,6 +44,7 @@ export const openflareNavItems: OpenFlareNavItem[] = [
|
|||||||
{title: '访问日志', url: '/openflare/access-logs', icon: ScrollText},
|
{title: '访问日志', url: '/openflare/access-logs', icon: ScrollText},
|
||||||
{title: '应用日志', url: '/openflare/apply-logs', icon: ClipboardList},
|
{title: '应用日志', url: '/openflare/apply-logs', icon: ClipboardList},
|
||||||
{title: '性能', url: '/openflare/performance', icon: Gauge},
|
{title: '性能', url: '/openflare/performance', icon: Gauge},
|
||||||
|
{title: '关于', url: '/openflare/about', icon: Info},
|
||||||
];
|
];
|
||||||
|
|
||||||
/** 网站模块页内二级导航 */
|
/** 网站模块页内二级导航 */
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import {PushService} from './push';
|
|||||||
import {AdminUploadService, UploadService} from './upload';
|
import {AdminUploadService, UploadService} from './upload';
|
||||||
import {UserService} from './user';
|
import {UserService} from './user';
|
||||||
import {
|
import {
|
||||||
|
AboutService,
|
||||||
AccessLogService,
|
AccessLogService,
|
||||||
ApplyLogService,
|
ApplyLogService,
|
||||||
ConfigVersionService,
|
ConfigVersionService,
|
||||||
@@ -61,6 +62,7 @@ const services = {
|
|||||||
adminUpload: AdminUploadService,
|
adminUpload: AdminUploadService,
|
||||||
dbManage: DbManageService,
|
dbManage: DbManageService,
|
||||||
push: PushService,
|
push: PushService,
|
||||||
|
openflareAbout: AboutService,
|
||||||
openflareNode: NodeService,
|
openflareNode: NodeService,
|
||||||
openflareProxyRoute: ProxyRouteService,
|
openflareProxyRoute: ProxyRouteService,
|
||||||
openflareConfigVersion: ConfigVersionService,
|
openflareConfigVersion: ConfigVersionService,
|
||||||
@@ -181,6 +183,7 @@ export type { PushEvent, PushHistory, PushChannelConfig, ListPushHistoriesReques
|
|||||||
// ==================== OpenFlare 业务服务导出 ====================
|
// ==================== OpenFlare 业务服务导出 ====================
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
AboutService,
|
||||||
LegacyOpenFlareBaseService,
|
LegacyOpenFlareBaseService,
|
||||||
NodeService,
|
NodeService,
|
||||||
ProxyRouteService,
|
ProxyRouteService,
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import {LegacyOpenFlareBaseService} from './legacy-base.service';
|
||||||
|
|
||||||
|
export class AboutService extends LegacyOpenFlareBaseService {
|
||||||
|
protected static override readonly basePath = '/api';
|
||||||
|
|
||||||
|
static getAboutContent(): Promise<string> {
|
||||||
|
return this.legacyGet<string>('/about');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
export { AboutService } from './about.service';
|
||||||
export { LegacyOpenFlareBaseService } from './legacy-base.service';
|
export { LegacyOpenFlareBaseService } from './legacy-base.service';
|
||||||
export type { LegacyApiResponse } from './legacy-base.service';
|
export type { LegacyApiResponse } from './legacy-base.service';
|
||||||
|
|
||||||
@@ -87,6 +88,9 @@ export type {
|
|||||||
DatabaseCleanupTarget,
|
DatabaseCleanupTarget,
|
||||||
LatestReleaseInfo,
|
LatestReleaseInfo,
|
||||||
OpenFlarePublicStatus,
|
OpenFlarePublicStatus,
|
||||||
|
UpgradeLogItem,
|
||||||
|
UpgradeStreamSnapshot,
|
||||||
|
UploadedServerBinaryInfo,
|
||||||
OriginDetail,
|
OriginDetail,
|
||||||
OriginItem,
|
OriginItem,
|
||||||
OriginMutationPayload,
|
OriginMutationPayload,
|
||||||
@@ -108,6 +112,7 @@ export type {
|
|||||||
TlsCertificateMutationPayload,
|
TlsCertificateMutationPayload,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
|
import {AboutService} from './about.service';
|
||||||
import {AccessLogService} from './access-log.service';
|
import {AccessLogService} from './access-log.service';
|
||||||
import {ApplyLogService} from './apply-log.service';
|
import {ApplyLogService} from './apply-log.service';
|
||||||
import {ConfigVersionService} from './config-version.service';
|
import {ConfigVersionService} from './config-version.service';
|
||||||
@@ -126,6 +131,7 @@ import {WafService} from './waf.service';
|
|||||||
import {WebsiteService} from './website.service';
|
import {WebsiteService} from './website.service';
|
||||||
|
|
||||||
export const openflareServices = {
|
export const openflareServices = {
|
||||||
|
about: AboutService,
|
||||||
node: NodeService,
|
node: NodeService,
|
||||||
proxyRoute: ProxyRouteService,
|
proxyRoute: ProxyRouteService,
|
||||||
configVersion: ConfigVersionService,
|
configVersion: ConfigVersionService,
|
||||||
|
|||||||
@@ -669,10 +669,28 @@ export interface LatestReleaseInfo {
|
|||||||
has_update: boolean;
|
has_update: boolean;
|
||||||
upgrade_supported: boolean;
|
upgrade_supported: boolean;
|
||||||
in_progress: boolean;
|
in_progress: boolean;
|
||||||
upgrade_status: string;
|
upgrade_status: 'idle' | 'running' | 'succeeded' | 'failed' | string;
|
||||||
upgrade_logs: UpgradeLogItem[];
|
upgrade_logs: UpgradeLogItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UpgradeStreamSnapshot {
|
||||||
|
in_progress: boolean;
|
||||||
|
upgrade_status: 'idle' | 'running' | 'succeeded' | 'failed' | string;
|
||||||
|
upgrade_logs: UpgradeLogItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UploadedServerBinaryInfo {
|
||||||
|
upload_token: string;
|
||||||
|
file_name: string;
|
||||||
|
detected_version: string;
|
||||||
|
current_version: string;
|
||||||
|
has_update: boolean;
|
||||||
|
upgrade_supported: boolean;
|
||||||
|
ready_to_upgrade: boolean;
|
||||||
|
comparison_message: string;
|
||||||
|
uploaded_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface WAFRuleGroup {
|
export interface WAFRuleGroup {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
@@ -1,16 +1,135 @@
|
|||||||
|
import type {InternalAxiosRequestConfig} from 'axios';
|
||||||
|
|
||||||
|
import {getApiBaseUrl} from '@/lib/services/core/config';
|
||||||
|
import {ApiErrorBase} from '@/lib/services/core/errors';
|
||||||
|
|
||||||
|
import type {LegacyApiResponse} from './legacy-base.service';
|
||||||
import {LegacyOpenFlareBaseService} from './legacy-base.service';
|
import {LegacyOpenFlareBaseService} from './legacy-base.service';
|
||||||
import type {LatestReleaseInfo, ReleaseChannel} from './types';
|
import type {LatestReleaseInfo, ReleaseChannel, UpgradeStreamSnapshot, UploadedServerBinaryInfo,} from './types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* OpenFlare 服务端升级 API(遗留 `/api/update/*`)。
|
* OpenFlare 服务端升级 API(遗留 `/api/update/*`)。
|
||||||
*
|
|
||||||
* 可选集成:在顶栏或 Admin 升级入口调用 `getLatestRelease()` 展示版本与更新状态;
|
|
||||||
* 完整升级流程(upgrade / manual-upload / logs WebSocket)可对接 Wavelet `admin/updater`。
|
|
||||||
*/
|
*/
|
||||||
export class UpdateService extends LegacyOpenFlareBaseService {
|
export class UpdateService extends LegacyOpenFlareBaseService {
|
||||||
protected static override readonly basePath = '/api/update';
|
protected static override readonly basePath = '/api/update';
|
||||||
|
|
||||||
|
private static getLegacyApiUrl(path: string): string {
|
||||||
|
const baseURL = getApiBaseUrl();
|
||||||
|
const fullPath = this.getFullPath(path);
|
||||||
|
if (!baseURL) {
|
||||||
|
return fullPath;
|
||||||
|
}
|
||||||
|
return `${baseURL.replace(/\/$/, '')}${fullPath}`;
|
||||||
|
}
|
||||||
|
|
||||||
static getLatestRelease(channel: ReleaseChannel = 'stable'): Promise<LatestReleaseInfo> {
|
static getLatestRelease(channel: ReleaseChannel = 'stable'): Promise<LatestReleaseInfo> {
|
||||||
return this.legacyGet<LatestReleaseInfo>('/latest-release', { channel });
|
return this.legacyGet<LatestReleaseInfo>('/latest-release', { channel });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static upgradeServer(channel: ReleaseChannel = 'stable'): Promise<LatestReleaseInfo> {
|
||||||
|
return this.legacyPost<LatestReleaseInfo>('/upgrade', { channel });
|
||||||
|
}
|
||||||
|
|
||||||
|
static uploadServerBinary(
|
||||||
|
binary: File,
|
||||||
|
onProgress?: (progress: number) => void,
|
||||||
|
): Promise<UploadedServerBinaryInfo> {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('binary', binary);
|
||||||
|
|
||||||
|
if (!onProgress) {
|
||||||
|
return this.legacyPost<UploadedServerBinaryInfo>('/manual-upload', formData, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
} as InternalAxiosRequestConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const xhr = new XMLHttpRequest();
|
||||||
|
xhr.open('POST', this.getLegacyApiUrl('/manual-upload'));
|
||||||
|
xhr.withCredentials = true;
|
||||||
|
|
||||||
|
xhr.upload.addEventListener('progress', (event) => {
|
||||||
|
if (event.lengthComputable) {
|
||||||
|
onProgress(Math.round((event.loaded / event.total) * 100));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
xhr.addEventListener('load', () => {
|
||||||
|
let payload: LegacyApiResponse<UploadedServerBinaryInfo> | null = null;
|
||||||
|
try {
|
||||||
|
payload = JSON.parse(
|
||||||
|
xhr.responseText,
|
||||||
|
) as LegacyApiResponse<UploadedServerBinaryInfo>;
|
||||||
|
} catch {
|
||||||
|
payload = null;
|
||||||
|
}
|
||||||
|
if (xhr.status < 200 || xhr.status >= 300) {
|
||||||
|
reject(
|
||||||
|
new ApiErrorBase(
|
||||||
|
payload?.message || `请求失败(${xhr.status})`,
|
||||||
|
undefined,
|
||||||
|
xhr.status,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!payload) {
|
||||||
|
reject(new ApiErrorBase('响应格式无效', undefined, xhr.status));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!payload.success) {
|
||||||
|
reject(new ApiErrorBase(payload.message || '请求失败', undefined, xhr.status));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve(payload.data);
|
||||||
|
});
|
||||||
|
|
||||||
|
xhr.addEventListener('error', () => {
|
||||||
|
reject(new ApiErrorBase('上传过程中网络连接中断,请检查网络后重试', undefined, 0));
|
||||||
|
});
|
||||||
|
|
||||||
|
xhr.send(formData);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
static confirmManualServerUpgrade(uploadToken: string): Promise<UploadedServerBinaryInfo> {
|
||||||
|
return this.legacyPost<UploadedServerBinaryInfo>('/manual-upgrade', {
|
||||||
|
upload_token: uploadToken,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
static createUpgradeLogsWebSocket(): WebSocket | null {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiUrl = this.getLegacyApiUrl('/logs/ws');
|
||||||
|
const resolvedUrl = apiUrl.startsWith('http://')
|
||||||
|
? `ws://${apiUrl.slice('http://'.length)}`
|
||||||
|
: apiUrl.startsWith('https://')
|
||||||
|
? `wss://${apiUrl.slice('https://'.length)}`
|
||||||
|
: `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}${apiUrl}`;
|
||||||
|
|
||||||
|
return new WebSocket(resolvedUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
static parseUpgradeStreamSnapshot(rawMessage: string): UpgradeStreamSnapshot | null {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(rawMessage) as Partial<UpgradeStreamSnapshot>;
|
||||||
|
if (
|
||||||
|
typeof parsed.in_progress !== 'boolean' ||
|
||||||
|
typeof parsed.upgrade_status !== 'string' ||
|
||||||
|
!Array.isArray(parsed.upgrade_logs)
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
in_progress: parsed.in_progress,
|
||||||
|
upgrade_status: parsed.upgrade_status,
|
||||||
|
upgrade_logs: parsed.upgrade_logs,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ sidebar: false
|
|||||||
- 将 OpenFlare 管理控制台从 `openflare-server/web` 迁移至 `Wavelet/frontend/app/(main)/openflare/`,复用 Wavelet shadcn 组件与 Session 鉴权;新增 `LegacyOpenFlareBaseService` 对接 `/api/*` 业务 API。
|
- 将 OpenFlare 管理控制台从 `openflare-server/web` 迁移至 `Wavelet/frontend/app/(main)/openflare/`,复用 Wavelet shadcn 组件与 Session 鉴权;新增 `LegacyOpenFlareBaseService` 对接 `/api/*` 业务 API。
|
||||||
- 实装节点、代理规则(6 Section)、配置发布、WAF、网站/证书/DNS、Pages、源站、访问/应用日志、仪表盘、性能调优等业务页面;Admin 设置新增 OpenFlare 运维扩展 Tab。
|
- 实装节点、代理规则(6 Section)、配置发布、WAF、网站/证书/DNS、Pages、源站、访问/应用日志、仪表盘、性能调优等业务页面;Admin 设置新增 OpenFlare 运维扩展 Tab。
|
||||||
- 修复静态导出构建中 `useSearchParams` 未包裹 Suspense 的问题;`pnpm build:embed` 全量 46 页通过。
|
- 修复静态导出构建中 `useSearchParams` 未包裹 Suspense 的问题;`pnpm build:embed` 全量 46 页通过。
|
||||||
|
- 补全前端迁移收尾:新增 `/openflare/about` 关于页与 `AboutService`;补全 `UpdateService` 升级流程(在线升级、手动上传、WebSocket 日志流)及顶栏版本升级入口。
|
||||||
|
- 前端 UI 打磨:节点/WAF/DNS/配置清理等 Dialog 补 RHF+Zod;代理规则详情与访问日志 Tab 补 error 态;`pnpm build:embed` 增至 47 静态页。
|
||||||
|
|
||||||
## [v2.3.4] - 2026-06-17
|
## [v2.3.4] - 2026-06-17
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
> **文档类型**:实现计划(Implementation Plan)
|
> **文档类型**:实现计划(Implementation Plan)
|
||||||
> **创建日期**:2026-06-18
|
> **创建日期**:2026-06-18
|
||||||
> **状态**:基本完成(FC-1~19 已实装;验证见 `docs/plan/verify-frontend-*.md`)
|
> **状态**:✅ 迁移完成(FC-1~20 已实装;验证见 `docs/plan/verify-frontend-*.md`)
|
||||||
> **前置阅读**:[`Wavelet/AGENTS.md`](../../Wavelet/AGENTS.md)、[`docs/plan/20260618-openflare-wavelet-backend-migration.md`](./20260618-openflare-wavelet-backend-migration.md)
|
> **前置阅读**:[`Wavelet/AGENTS.md`](../../Wavelet/AGENTS.md)、[`docs/plan/20260618-openflare-wavelet-backend-migration.md`](./20260618-openflare-wavelet-backend-migration.md)
|
||||||
> **依赖**:后端阶段一(旧路径 `/api/*` 兼容)完成后启动;阶段二可与后端阶段五并行
|
> **依赖**:后端阶段一(旧路径 `/api/*` 兼容)完成后启动;阶段二可与后端阶段五并行
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# OpenFlare 前端迁移 — 任务拆分与 AI 委派
|
# OpenFlare 前端迁移 — 任务拆分与 AI 委派
|
||||||
|
|
||||||
> **状态**:基本完成(验证通过,少量 P1 打磨待办)
|
> **状态**:✅ 迁移完成(多角度验证通过;P2 可选打磨待办)
|
||||||
> **目标前端**:`Wavelet/frontend/`(Next.js 16 + shadcn/ui + Session 鉴权)
|
> **目标前端**:`Wavelet/frontend/`(Next.js 16 + shadcn/ui + Session 鉴权)
|
||||||
> **验证报告**:[`verify-frontend-routes.md`](./verify-frontend-routes.md)、[`verify-frontend-services.md`](./verify-frontend-services.md)、[`verify-frontend-ui.md`](./verify-frontend-ui.md)、[`verify-frontend-build.md`](./verify-frontend-build.md)
|
> **验证报告**:[`verify-frontend-routes.md`](./verify-frontend-routes.md)、[`verify-frontend-services.md`](./verify-frontend-services.md)、[`verify-frontend-ui.md`](./verify-frontend-ui.md)、[`verify-frontend-build.md`](./verify-frontend-build.md)
|
||||||
|
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
| F-NODE | 节点(含 Relay/Tunnel 详情) | ✅ |
|
| F-NODE | 节点(含 Relay/Tunnel 详情) | ✅ |
|
||||||
| F-PROXY | 代理规则(含 6 Section 实装) | ✅ |
|
| F-PROXY | 代理规则(含 6 Section 实装) | ✅ |
|
||||||
| F-CFG | 配置发布 + 应用日志 | ✅ |
|
| F-CFG | 配置发布 + 应用日志 | ✅ |
|
||||||
| F-DASH | 总览仪表盘 | ✅(缺世界地图) |
|
| F-DASH | 总览仪表盘 | ✅(缺世界地图,P2) |
|
||||||
| F-WAF | WAF 规则组 + IP 组 | ✅ |
|
| F-WAF | WAF 规则组 + IP 组 | ✅ |
|
||||||
| F-WEB | 网站/证书/DNS | ✅ |
|
| F-WEB | 网站/证书/DNS | ✅ |
|
||||||
| F-PAGES | Pages 托管 | ✅ |
|
| F-PAGES | Pages 托管 | ✅ |
|
||||||
@@ -21,6 +21,8 @@
|
|||||||
| F-PERF | 性能调优 | ✅ |
|
| F-PERF | 性能调优 | ✅ |
|
||||||
| F-ADMIN | Admin 运维设置扩展 | ✅ |
|
| F-ADMIN | Admin 运维设置扩展 | ✅ |
|
||||||
| F-AUTH | Wavelet 登录/用户复用 | ✅(原生,未改登录页) |
|
| F-AUTH | Wavelet 登录/用户复用 | ✅(原生,未改登录页) |
|
||||||
|
| F-ABOUT | About 页(FC-20) | ✅ |
|
||||||
|
| F-UPDATE | 服务升级 UI + UpdateService | ✅ |
|
||||||
|
|
||||||
## 验证结果摘要
|
## 验证结果摘要
|
||||||
|
|
||||||
@@ -28,16 +30,17 @@
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `tsc --noEmit` | ✅ |
|
| `tsc --noEmit` | ✅ |
|
||||||
| `pnpm lint` | ✅ |
|
| `pnpm lint` | ✅ |
|
||||||
| `pnpm build:embed` | ✅(46 静态页;已修 Suspense) |
|
| `pnpm build:embed` | ✅(47 静态页) |
|
||||||
| 路由覆盖 FC-1~19 | ✅ 18 完整 + 1 部分 |
|
| 路由覆盖 FC-1~20 | ✅ 20/20 文件(FC-1 世界地图 P2) |
|
||||||
| Service 覆盖 | ⚠️ `update` 升级流程待补 |
|
| Service 覆盖 | ✅ 100% |
|
||||||
|
| UI 规范 | ✅ PASS |
|
||||||
|
|
||||||
## 剩余 P1 待办
|
## P2 可选待办
|
||||||
|
|
||||||
1. **FC-20** About 页或链到 `/docs`
|
1. **FC-1** 世界地图(当前用 geo 列表)
|
||||||
2. **UpdateService** 补全 upgrade/manual-upload/WS 日志
|
2. **UI 打磨** — proxy 列表 error 态、access-logs cleanup Zod、node-observability error
|
||||||
3. **UI 打磨**:部分 Dialog 补 Zod;access-logs/proxy-detail error 态
|
3. **废弃旧前端** — 移除 `openflare-server/web` 引用(阶段五)
|
||||||
4. **FC-1** 世界地图(可选,当前用 geo 列表)
|
4. **E2E** — Playwright 核心路径
|
||||||
|
|
||||||
## 验收命令
|
## 验收命令
|
||||||
|
|
||||||
|
|||||||
@@ -10,17 +10,16 @@
|
|||||||
|------|---------|-----------|--------|
|
|------|---------|-----------|--------|
|
||||||
| TypeScript | `pnpm exec tsc --noEmit` | **0** | ✅ Pass |
|
| TypeScript | `pnpm exec tsc --noEmit` | **0** | ✅ Pass |
|
||||||
| Lint | `pnpm lint` | **0** | ✅ Pass |
|
| Lint | `pnpm lint` | **0** | ✅ Pass |
|
||||||
| Production build | `pnpm build:embed` | **1** | ❌ Fail |
|
| Production build | `pnpm build:embed` | **0** | ✅ Pass |
|
||||||
| Standard build | `pnpm build` | **1** | ❌ Fail (same error) |
|
|
||||||
|
|
||||||
**Overall:** TypeScript and lint are clean. Both build targets fail during static page generation due to a missing React `Suspense` boundary around `useSearchParams()`.
|
**Overall:** All three gates pass. Static export completed with **47/47** pages generated.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. TypeScript (`pnpm exec tsc --noEmit`)
|
## 1. TypeScript (`pnpm exec tsc --noEmit`)
|
||||||
|
|
||||||
- **Exit code:** `0`
|
- **Exit code:** `0`
|
||||||
- **Duration:** ~6.2s
|
- **Duration:** ~16.2s
|
||||||
- **Errors:** None
|
- **Errors:** None
|
||||||
- **Warnings:** None
|
- **Warnings:** None
|
||||||
|
|
||||||
@@ -29,7 +28,7 @@
|
|||||||
## 2. ESLint (`pnpm lint`)
|
## 2. ESLint (`pnpm lint`)
|
||||||
|
|
||||||
- **Exit code:** `0`
|
- **Exit code:** `0`
|
||||||
- **Duration:** ~5.6s
|
- **Duration:** ~24.4s
|
||||||
- **Command:** `eslint` (no extra args)
|
- **Command:** `eslint` (no extra args)
|
||||||
- **Errors:** None
|
- **Errors:** None
|
||||||
- **Warnings:** None
|
- **Warnings:** None
|
||||||
@@ -40,112 +39,100 @@
|
|||||||
|
|
||||||
**Production target:** `build:embed` — sets `NEXT_STANDALONE_EXPORT=true`, which enables `output: 'export'` in `next.config.ts` for static export embedded in the Go backend.
|
**Production target:** `build:embed` — sets `NEXT_STANDALONE_EXPORT=true`, which enables `output: 'export'` in `next.config.ts` for static export embedded in the Go backend.
|
||||||
|
|
||||||
- **Exit code:** `1`
|
- **Exit code:** `0`
|
||||||
- **Duration:** ~57s (compile ~29.5s, TypeScript check ~23.9s)
|
- **Duration:** ~72s (compile ~44s, TypeScript check ~20.1s, static generation ~2.2s)
|
||||||
|
|
||||||
### Build progress
|
### Build progress
|
||||||
|
|
||||||
- ✅ Compiled successfully
|
- ✅ Compiled successfully
|
||||||
- ✅ TypeScript check passed during build
|
- ✅ TypeScript check passed during build
|
||||||
- ❌ Static page generation failed at **0/46** pages
|
- ✅ Static page generation completed at **47/47** pages
|
||||||
|
|
||||||
### Error
|
### Warnings
|
||||||
|
|
||||||
```
|
```
|
||||||
⨯ useSearchParams() should be wrapped in a suspense boundary at page "/openflare/nodes".
|
⚠ Statically exporting a Next.js application via `next export` disables API routes and middleware.
|
||||||
Read more: https://nextjs.org/docs/messages/missing-suspense-with-csr-bailout
|
This command is meant for static-only hosts, and is not necessary to make your application static.
|
||||||
|
Pages in your application without server-side data dependencies will be automatically statically exported by `next build`, including pages powered by `getStaticProps`.
|
||||||
Error occurred prerendering page "/openflare/nodes".
|
Learn more: https://nextjs.org/docs/messages/api-routes-static-export
|
||||||
Export encountered an error on /(main)/openflare/nodes/page: /openflare/nodes, exiting the build.
|
|
||||||
⨯ Next.js build worker exited with code: 1 and signal: null
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Affected files
|
### Static pages (47 routes)
|
||||||
|
|
||||||
| File | Issue |
|
| Route |
|
||||||
|------|-------|
|
|-------|
|
||||||
| `app/(main)/openflare/nodes/page.tsx` | `useSearchParams()` at line 36 (page default export) |
|
| `/` |
|
||||||
| `app/(main)/openflare/nodes/components/node-type-filter.tsx` | `useSearchParams()` at line 57 (rendered inside nodes page) |
|
| `/_not-found` |
|
||||||
|
| `/admin/database` |
|
||||||
|
| `/admin/demo` |
|
||||||
|
| `/admin/files` |
|
||||||
|
| `/admin/logs` |
|
||||||
|
| `/admin/push` |
|
||||||
|
| `/admin/settings` |
|
||||||
|
| `/admin/system` |
|
||||||
|
| `/admin/tasks` |
|
||||||
|
| `/admin/users` |
|
||||||
|
| `/docs/api` |
|
||||||
|
| `/docs/how-to-use` |
|
||||||
|
| `/docs/privacy-policy` |
|
||||||
|
| `/docs/terms-of-service` |
|
||||||
|
| `/files` |
|
||||||
|
| `/home` |
|
||||||
|
| `/icon` |
|
||||||
|
| `/login` |
|
||||||
|
| `/openflare` |
|
||||||
|
| `/openflare/about` |
|
||||||
|
| `/openflare/access-logs` |
|
||||||
|
| `/openflare/apply-logs` |
|
||||||
|
| `/openflare/config-versions` |
|
||||||
|
| `/openflare/nodes` |
|
||||||
|
| `/openflare/nodes/detail` |
|
||||||
|
| `/openflare/origins` |
|
||||||
|
| `/openflare/origins/detail` |
|
||||||
|
| `/openflare/pages` |
|
||||||
|
| `/openflare/pages/detail` |
|
||||||
|
| `/openflare/performance` |
|
||||||
|
| `/openflare/proxy-routes` |
|
||||||
|
| `/openflare/proxy-routes/detail` |
|
||||||
|
| `/openflare/waf` |
|
||||||
|
| `/openflare/waf/ip-groups` |
|
||||||
|
| `/openflare/websites` |
|
||||||
|
| `/openflare/websites/certificates` |
|
||||||
|
| `/openflare/websites/detail` |
|
||||||
|
| `/openflare/websites/dns-accounts` |
|
||||||
|
| `/register` |
|
||||||
|
| `/settings` |
|
||||||
|
| `/settings/access-token` |
|
||||||
|
| `/settings/appearance` |
|
||||||
|
| `/settings/notifications` |
|
||||||
|
| `/settings/profile` |
|
||||||
|
| `/settings/security` |
|
||||||
|
|
||||||
### Build output size
|
All routes prerendered as static content (`○`). Middleware proxy is present but not exported in static mode (expected).
|
||||||
|
|
||||||
Not available — build aborted before artifact generation completed.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. Standard Build (`pnpm build`)
|
## Notes
|
||||||
|
|
||||||
Ran for comparison (uses rewrites instead of static export).
|
- Initial `pnpm build:embed` attempt failed with "Another next build process is already running" due to a stale `.next/lock` from a concurrent build. Lock cleared and build re-run successfully.
|
||||||
|
- Previous verification on this date documented a `useSearchParams()` / missing `Suspense` failure on `/openflare/nodes`. That issue is no longer present in the current tree.
|
||||||
- **Exit code:** `1`
|
|
||||||
- **Duration:** ~65s
|
|
||||||
- **Error:** Identical `useSearchParams()` / missing `Suspense` failure on `/openflare/nodes`
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Root Cause
|
## CI Gate (recommended)
|
||||||
|
|
||||||
Next.js 16 requires `useSearchParams()` to be used inside a `<Suspense>` boundary when pages are statically prerendered/exported. The nodes page is a client component that calls `useSearchParams()` directly in both the page and a child component (`NodeTypeFilter`).
|
Run all three checks before merge:
|
||||||
|
|
||||||
Other pages in the codebase already follow the correct pattern:
|
|
||||||
|
|
||||||
- `app/(auth)/login/page.tsx` — wraps content in `<Suspense>`
|
|
||||||
- `app/(auth)/register/page.tsx` — wraps content in `<Suspense>`
|
|
||||||
- `app/(main)/admin/tasks/page.tsx` — wraps content in `<Suspense fallback={...}>`
|
|
||||||
- `app/(main)/openflare/proxy-routes/detail/page.tsx` — splits into `page.tsx` + `page-client.tsx` with `<Suspense>`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Other `useSearchParams()` Usages (likely to fail after nodes fix)
|
|
||||||
|
|
||||||
These files also use `useSearchParams()` without a visible `Suspense` wrapper at the page level. They may fail once `/openflare/nodes` is fixed and generation continues:
|
|
||||||
|
|
||||||
| File |
|
|
||||||
|------|
|
|
||||||
| `app/(main)/openflare/nodes/detail/page.tsx` |
|
|
||||||
| `app/(main)/openflare/websites/detail/page.tsx` |
|
|
||||||
| `app/(main)/openflare/origins/detail/page.tsx` |
|
|
||||||
| `app/(main)/openflare/apply-logs/page.tsx` |
|
|
||||||
| `app/(main)/openflare/pages/detail/page.tsx` |
|
|
||||||
| `components/auth/login-form.tsx` |
|
|
||||||
| `components/auth/login-page.tsx` |
|
|
||||||
| `components/auth/register-form.tsx` |
|
|
||||||
|
|
||||||
(Auth pages are likely safe because `login/page.tsx` and `register/page.tsx` already wrap them in `<Suspense>`.)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Recommendations
|
|
||||||
|
|
||||||
### Priority 1 — Fix `/openflare/nodes` build blocker
|
|
||||||
|
|
||||||
Refactor using the existing `proxy-routes/detail` pattern:
|
|
||||||
|
|
||||||
1. Create `app/(main)/openflare/nodes/page-client.tsx` with the current page logic.
|
|
||||||
2. Change `page.tsx` to a server component that wraps the client component in `<Suspense fallback={<LoadingState />}>`.
|
|
||||||
|
|
||||||
Alternatively, wrap `<NodeTypeFilter />` and the `useSearchParams()` usage in a single child component inside `<Suspense>`.
|
|
||||||
|
|
||||||
### Priority 2 — Audit remaining pages
|
|
||||||
|
|
||||||
Apply the same `Suspense` pattern to all detail/list pages using `useSearchParams()` (see table above) to avoid repeated build failures at 1/46, 2/46, etc.
|
|
||||||
|
|
||||||
### Priority 3 — Add a CI gate
|
|
||||||
|
|
||||||
Run all three checks in CI before merge:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
cd Wavelet/frontend
|
||||||
pnpm exec tsc --noEmit
|
pnpm exec tsc --noEmit
|
||||||
pnpm lint
|
pnpm lint
|
||||||
pnpm build:embed
|
pnpm build:embed
|
||||||
```
|
```
|
||||||
|
|
||||||
### Priority 4 — Optional lint rule
|
|
||||||
|
|
||||||
Consider an ESLint rule or codemod to flag `useSearchParams()` usage outside `Suspense` boundaries, since `tsc` and `eslint` pass even when the production build fails.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Actions Taken
|
## Actions Taken
|
||||||
|
|
||||||
- No code fixes applied. The failure is a structural Next.js `Suspense` requirement, not a trivial one-line lint fix.
|
- Ran all three verification commands on 2026-06-18.
|
||||||
|
- No code changes applied; documentation updated with results.
|
||||||
@@ -1,39 +1,50 @@
|
|||||||
# OpenFlare 前端路由验证报告
|
# OpenFlare 前端路由验证报告
|
||||||
|
|
||||||
> **验证日期**:2026-06-18
|
> **验证日期**:2026-06-18(复验)
|
||||||
> **对照**:`20260618-openflare-wavelet-frontend-migration.md` §6.1(FC-1 ~ FC-20)
|
> **对照**:`20260618-openflare-wavelet-frontend-migration.md` §6.1(FC-1 ~ FC-20)
|
||||||
|
> **扫描路径**:`Wavelet/frontend/app/(main)/openflare/**/page.tsx`
|
||||||
|
|
||||||
|
## 摘要
|
||||||
|
|
||||||
|
| 指标 | 结果 |
|
||||||
|
|---|---|
|
||||||
|
| 计划路由数(FC-1 ~ FC-20) | 20 |
|
||||||
|
| `page.tsx` 文件数 | 20 |
|
||||||
|
| **路由文件覆盖率** | **100%** |
|
||||||
|
| **严格完成**(路由存在且非占位) | **19/20(95%)** |
|
||||||
|
| **加权完成**(FC-1 计 0.5) | **97.5%** |
|
||||||
|
| FC-20 `/openflare/about` | ✅ 已实现 |
|
||||||
|
| 缺失路由 | **无** |
|
||||||
|
|
||||||
## 路由对照
|
## 路由对照
|
||||||
|
|
||||||
| # | 计划路由 | 状态 | 备注 |
|
| # | 计划路由 | 页面文件 | 状态 | 备注 |
|
||||||
|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| FC-1 | `/openflare` | ⚠️ partial | 仪表盘已实现;世界地图以国家列表替代 |
|
| FC-1 | `/openflare` | `openflare/page.tsx` | ⚠️ partial | 仪表盘已实现;世界地图以 `GeoDistributionList` 国家列表替代 |
|
||||||
| FC-2 | `/openflare/nodes` | ✅ | |
|
| FC-2 | `/openflare/nodes` | `openflare/nodes/page.tsx` | ✅ | |
|
||||||
| FC-3 | `/openflare/nodes/detail` | ✅ | Edge/Relay/Tunnel 三种详情 |
|
| FC-3 | `/openflare/nodes/detail` | `openflare/nodes/detail/page.tsx` | ✅ | Edge / Relay / Tunnel |
|
||||||
| FC-4 | `/openflare/proxy-routes` | ✅ | |
|
| FC-4 | `/openflare/proxy-routes` | `openflare/proxy-routes/page.tsx` | ✅ | |
|
||||||
| FC-5 | `/openflare/proxy-routes/detail` | ✅ | 6 Section + 发布 |
|
| FC-5 | `/openflare/proxy-routes/detail` | `openflare/proxy-routes/detail/page.tsx` | ✅ | 6 Section + 发布 |
|
||||||
| FC-6 | `/openflare/config-versions` | ✅ | |
|
| FC-6 | `/openflare/config-versions` | `openflare/config-versions/page.tsx` | ✅ | |
|
||||||
| FC-7 | `/openflare/waf` | ✅ | |
|
| FC-7 | `/openflare/waf` | `openflare/waf/page.tsx` | ✅ | |
|
||||||
| FC-8 | `/openflare/waf/ip-groups` | ✅ | 页内链接 |
|
| FC-8 | `/openflare/waf/ip-groups` | `openflare/waf/ip-groups/page.tsx` | ✅ | |
|
||||||
| FC-9 | `/openflare/websites` | ✅ | |
|
| FC-9 | `/openflare/websites` | `openflare/websites/page.tsx` | ✅ | |
|
||||||
| FC-10 | `/openflare/websites/detail` | ✅ | |
|
| FC-10 | `/openflare/websites/detail` | `openflare/websites/detail/page.tsx` | ✅ | |
|
||||||
| FC-11 | `/openflare/websites/certificates` | ✅ | |
|
| FC-11 | `/openflare/websites/certificates` | `openflare/websites/certificates/page.tsx` | ✅ | |
|
||||||
| FC-12 | `/openflare/websites/dns-accounts` | ✅ | |
|
| FC-12 | `/openflare/websites/dns-accounts` | `openflare/websites/dns-accounts/page.tsx` | ✅ | |
|
||||||
| FC-13 | `/openflare/pages` | ✅ | |
|
| FC-13 | `/openflare/pages` | `openflare/pages/page.tsx` | ✅ | |
|
||||||
| FC-14 | `/openflare/pages/detail` | ✅ | |
|
| FC-14 | `/openflare/pages/detail` | `openflare/pages/detail/page.tsx` | ✅ | |
|
||||||
| FC-15 | `/openflare/origins` | ✅ | |
|
| FC-15 | `/openflare/origins` | `openflare/origins/page.tsx` | ✅ | |
|
||||||
| FC-16 | `/openflare/origins/detail` | ✅ | |
|
| FC-16 | `/openflare/origins/detail` | `openflare/origins/detail/page.tsx` | ✅ | |
|
||||||
| FC-17 | `/openflare/access-logs` | ✅ | 4 Tab |
|
| FC-17 | `/openflare/access-logs` | `openflare/access-logs/page.tsx` | ✅ | 4 Tab |
|
||||||
| FC-18 | `/openflare/apply-logs` | ✅ | |
|
| FC-18 | `/openflare/apply-logs` | `openflare/apply-logs/page.tsx` | ✅ | |
|
||||||
| FC-19 | `/openflare/performance` | ✅ | |
|
| FC-19 | `/openflare/performance` | `openflare/performance/page.tsx` | ✅ | |
|
||||||
| FC-20 | `/openflare/about` | ❌ | 待实现或复用 `/docs` |
|
| FC-20 | `/openflare/about` | `openflare/about/page.tsx` | ✅ | AboutService + 侧栏导航 |
|
||||||
|
|
||||||
## 覆盖率
|
|
||||||
|
|
||||||
- **严格完成**:18/20(90%)
|
|
||||||
- **加权**:92.5%(FC-1 计 0.5)
|
|
||||||
- **PlaceholderPage 使用**:0(组件仍存在但未引用)
|
|
||||||
|
|
||||||
## 子路由(不在侧栏顶级)
|
## 子路由(不在侧栏顶级)
|
||||||
|
|
||||||
`/openflare/nodes/detail`、`proxy-routes/detail`、`pages/detail`、`websites/detail`、`websites/certificates`、`websites/dns-accounts`、`waf/ip-groups`、`origins/detail`
|
`/openflare/nodes/detail`、`proxy-routes/detail`、`pages/detail`、`websites/detail`、`websites/certificates`、`websites/dns-accounts`、`waf/ip-groups`、`origins/detail`
|
||||||
|
|
||||||
|
## 结论
|
||||||
|
|
||||||
|
§6.1 路由映射 **基本完成**(20/20 文件)。唯一路由级缺口为 FC-1 世界地图(可选 P2);无缺失 `page.tsx`。
|
||||||
@@ -1,8 +1,16 @@
|
|||||||
# OpenFlare 前端 Service 层验证报告
|
# OpenFlare 前端 Service 层验证报告
|
||||||
|
|
||||||
> **验证日期**:2026-06-18
|
> **验证日期**:2026-06-18(复验)
|
||||||
> **目录**:`Wavelet/frontend/lib/services/openflare/`
|
> **目录**:`Wavelet/frontend/lib/services/openflare/`
|
||||||
|
|
||||||
|
## 覆盖率摘要
|
||||||
|
|
||||||
|
| 维度 | 结果 |
|
||||||
|
|---|---|
|
||||||
|
| §7.1 计划服务文件 | **15 / 15 = 100%** |
|
||||||
|
| 业务 `/api/*` 端点 | **100%** |
|
||||||
|
| 待补 API | **0** |
|
||||||
|
|
||||||
## §7.1 对照结果
|
## §7.1 对照结果
|
||||||
|
|
||||||
| 计划服务 | 实际文件 | 注册 | 方法覆盖 |
|
| 计划服务 | 实际文件 | 注册 | 方法覆盖 |
|
||||||
@@ -21,22 +29,33 @@
|
|||||||
| access-log | ✅ | `openflareAccessLog` | ✅ |
|
| access-log | ✅ | `openflareAccessLog` | ✅ |
|
||||||
| apply-log | ✅ | `openflareApplyLog` | ✅ |
|
| apply-log | ✅ | `openflareApplyLog` | ✅ |
|
||||||
| option | ✅ | `openflareOption` | ✅ |
|
| option | ✅ | `openflareOption` | ✅ |
|
||||||
| update | ⚠️ partial | `openflareUpdate` | 仅 `getLatestRelease` |
|
| update | ✅ | `openflareUpdate` | ✅ 6 方法 |
|
||||||
|
| about | ✅ | `openflareAbout` | `getAboutContent()` |
|
||||||
|
|
||||||
|
## update.service.ts 复验
|
||||||
|
|
||||||
|
| 方法 | 端点 |
|
||||||
|
|---|---|
|
||||||
|
| `getLatestRelease(channel?)` | `GET /api/update/latest-release` |
|
||||||
|
| `upgradeServer(channel?)` | `POST /api/update/upgrade` |
|
||||||
|
| `uploadServerBinary(file, onProgress?)` | `POST /api/update/manual-upload` |
|
||||||
|
| `confirmManualServerUpgrade(token)` | `POST /api/update/manual-upgrade` |
|
||||||
|
| `createUpgradeLogsWebSocket()` | `WS /api/update/logs/ws` |
|
||||||
|
| `parseUpgradeStreamSnapshot(raw)` | WS 消息解析 |
|
||||||
|
|
||||||
|
已接入:`use-openflare-server-upgrade.ts`、`openflare-version-entry.tsx`、`openflare-ops.tsx`。
|
||||||
|
|
||||||
## 计划外(已实现)
|
## 计划外(已实现)
|
||||||
|
|
||||||
- `status.service.ts` → `openflareStatus`
|
- `status.service.ts` → `openflareStatus`
|
||||||
- `uptimekuma.service.ts` → `openflareUptimeKuma`
|
- `uptimekuma.service.ts` → `openflareUptimeKuma`
|
||||||
|
- `about.service.ts` → `openflareAbout`
|
||||||
- `legacy-base.service.ts`(基类)
|
- `legacy-base.service.ts`(基类)
|
||||||
|
|
||||||
## 待补 API
|
## 待补 API
|
||||||
|
|
||||||
| 端点 | 说明 |
|
无。
|
||||||
|---|---|
|
|
||||||
| `POST /api/update/upgrade` | 顶栏升级流程 |
|
|
||||||
| `POST /api/update/manual-upload` | 手动上传 |
|
|
||||||
| `POST /api/update/manual-upgrade` | 手动确认升级 |
|
|
||||||
| `WS /api/update/logs/ws` | 升级日志流 |
|
|
||||||
| `GET /api/about` | About 页(FC-20) |
|
|
||||||
|
|
||||||
业务 `/api/*` 其余端点均已由 Service 覆盖。
|
## 结论
|
||||||
|
|
||||||
|
Service 层迁移 **完成**。所有计划内业务 API 均已由 Service 覆盖。
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# OpenFlare 前端 UI 规范验收报告
|
# OpenFlare 前端 UI 规范验收报告
|
||||||
|
|
||||||
> **验证日期**:2026-06-18
|
> **验证日期**:2026-06-18(polish pass 复验)
|
||||||
> **范围**:`Wavelet/frontend/app/(main)/openflare/`
|
> **范围**:`Wavelet/frontend/app/(main)/openflare/`
|
||||||
|
|
||||||
## 总体
|
## 总体
|
||||||
@@ -9,25 +9,32 @@
|
|||||||
|---|---|
|
|---|---|
|
||||||
| 标题 `h1 text-2xl font-semibold tracking-tight` | ✅ |
|
| 标题 `h1 text-2xl font-semibold tracking-tight` | ✅ |
|
||||||
| 外壳 `py-6 px-1` | ✅ |
|
| 外壳 `py-6 px-1` | ✅ |
|
||||||
| shadcn 组件(无旧 UI) | ✅ |
|
| shadcn 组件(无旧 UI / HeroUI) | ✅ |
|
||||||
| 单文件 ≤600 行 | ✅(最大 access-logs 584 行) |
|
| 单文件 ≤600 行 | ✅(最大 `access-logs/page.tsx` 558 行) |
|
||||||
| loading/empty/error | ⚠️ 部分 Tab 缺 error |
|
| loading/empty/error | ✅ |
|
||||||
| RHF + Zod 表单 | ⚠️ 部分 Dialog 待补 |
|
| RHF + Zod 表单(P1 Dialog) | ✅ |
|
||||||
|
|
||||||
|
**总体结论:PASS**
|
||||||
|
|
||||||
## 分模块
|
## 分模块
|
||||||
|
|
||||||
| 模块 | 结论 |
|
| 模块 | 结论 | 说明 |
|
||||||
|---|---|
|
|---|---|---|
|
||||||
| Dashboard | ✅ PASS |
|
| Dashboard | ✅ PASS | 标准壳与三态组件 |
|
||||||
| Nodes | ⚠️ `node-editor-dialog` 缺 Zod |
|
| Nodes | ✅ PASS | `node-editor-dialog` Zod 已补 |
|
||||||
| Proxy detail | ⚠️ 无效 ID/error 态待统一组件 |
|
| Proxy detail | ✅ PASS | `ErrorInline` / `EmptyStateWithBorder` 已统一 |
|
||||||
| WAF | ⚠️ `rule-entry-dialog` 缺 RHF+Zod |
|
| Proxy list | ⚠️ PASS* | 列表 error 仅 toast(P2) |
|
||||||
| Websites/Certificates | ⚠️ `dns-account-create-dialog` 缺 Zod |
|
| WAF | ✅ PASS | `rule-entry-dialog` RHF+Zod 已补 |
|
||||||
| Access logs | ⚠️ folds/ip-summary Tab 缺 error |
|
| Websites/Certificates | ✅ PASS | `dns-account-create-dialog` Zod 已补 |
|
||||||
| Config versions | ⚠️ cleanup dialog 缺 Zod |
|
| Access logs | ✅ PASS | folds/ip-summary/ip-trend error 已补 |
|
||||||
|
| Config versions | ✅ PASS | `cleanup-dialog` Zod 已补 |
|
||||||
|
| About | ✅ PASS | 页面惯例完整 |
|
||||||
|
| Version upgrade dialog | ✅ PASS | Card/Badge/Markdown 惯例合规 |
|
||||||
|
|
||||||
## P1 修复建议
|
## P2 可选改进
|
||||||
|
|
||||||
1. `proxy-routes/detail/page-client.tsx` — `EmptyStateWithBorder` / `ErrorInline`
|
1. `proxy-routes/page-client.tsx` — 列表 error 改用 `ErrorInline`
|
||||||
2. `access-logs/page.tsx` — folds、ip-summary 的 `isError` 分支
|
2. `access-logs/components/cleanup-dialog.tsx` — 补 RHF+Zod
|
||||||
3. `node-editor-dialog.tsx`、`rule-entry-dialog.tsx` — 补 Zod schema
|
3. `access-logs/page.tsx` ip-trend — 改用 shadcn `Input`
|
||||||
|
4. `nodes/components/node-observability.tsx` — 改用 `ErrorInline`+重试
|
||||||
|
5. FC-1 世界地图 — 当前以国家列表替代
|
||||||
Reference in New Issue
Block a user