mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-29 05:56:38 +08:00
migrate frontend
This commit is contained in:
@@ -28,6 +28,8 @@ import {ErrorInline} from "@/components/layout/error"
|
||||
import {LoadingStateWithBorder} from "@/components/layout/loading"
|
||||
import type {DatabaseCleanupTarget} from "@/lib/services/openflare"
|
||||
import {NodeService, OptionService, StatusService, UpdateService, UptimeKumaService,} from "@/lib/services/openflare"
|
||||
import {VersionUpgradeDialog} from "@/app/(main)/openflare/components/version-upgrade-dialog"
|
||||
import {openflareLatestReleaseQueryKey, openflarePublicStatusQueryKey,} from "@/lib/hooks/use-openflare-server-upgrade"
|
||||
|
||||
import {
|
||||
agentOptionEntries,
|
||||
@@ -82,6 +84,7 @@ export function OpenFlareOpsSettings() {
|
||||
label: string
|
||||
} | null>(null)
|
||||
const [cleanupRetentionDays, setCleanupRetentionDays] = useState("")
|
||||
const [versionDialogOpen, setVersionDialogOpen] = useState(false)
|
||||
|
||||
const optionsQuery = useQuery({
|
||||
queryKey: optionsQueryKey,
|
||||
@@ -89,7 +92,7 @@ export function OpenFlareOpsSettings() {
|
||||
})
|
||||
|
||||
const statusQuery = useQuery({
|
||||
queryKey: ["openflare", "public-status"],
|
||||
queryKey: openflarePublicStatusQueryKey,
|
||||
queryFn: () => StatusService.getPublicStatus(),
|
||||
})
|
||||
|
||||
@@ -99,7 +102,7 @@ export function OpenFlareOpsSettings() {
|
||||
})
|
||||
|
||||
const releaseQuery = useQuery({
|
||||
queryKey: ["openflare", "latest-release"],
|
||||
queryKey: openflareLatestReleaseQueryKey("stable"),
|
||||
queryFn: () => UpdateService.getLatestRelease("stable"),
|
||||
})
|
||||
|
||||
@@ -602,11 +605,16 @@ export function OpenFlareOpsSettings() {
|
||||
</div>
|
||||
|
||||
<Card className="border-dashed shadow-none">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">版本信息</CardTitle>
|
||||
<CardDescription>
|
||||
可通过 UpdateService 对接顶栏升级入口;完整升级流程可复用 Wavelet admin/updater。
|
||||
</CardDescription>
|
||||
<CardHeader className="flex flex-row items-center justify-between gap-4">
|
||||
<div>
|
||||
<CardTitle className="text-base">版本信息</CardTitle>
|
||||
<CardDescription>
|
||||
检查 GitHub Release、在线升级或手动上传服务端二进制包。
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button type="button" size="sm" onClick={() => setVersionDialogOpen(true)}>
|
||||
管理升级
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<InfoCell label="当前版本" value={statusQuery.data?.version ?? releaseQuery.data?.current_version ?? "—"} />
|
||||
@@ -635,6 +643,12 @@ export function OpenFlareOpsSettings() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<VersionUpgradeDialog
|
||||
open={versionDialogOpen}
|
||||
onOpenChange={setVersionDialogOpen}
|
||||
canUpgrade
|
||||
/>
|
||||
|
||||
<UptimeKumaSiteSelectModal
|
||||
open={uptimeKumaModalOpen}
|
||||
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>
|
||||
</Select>
|
||||
</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 />
|
||||
) : (foldsQuery.data?.items ?? []).length === 0 ? (
|
||||
<EmptyStateWithBorder title="暂无折叠数据" />
|
||||
@@ -455,7 +466,18 @@ export default function AccessLogsPage() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</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 />
|
||||
) : (ipSummaryQuery.data?.items ?? []).length === 0 ? (
|
||||
<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};
|
||||
+28
-15
@@ -1,7 +1,10 @@
|
||||
"use client"
|
||||
|
||||
import {useState} from "react"
|
||||
import {useEffect} from "react"
|
||||
import {zodResolver} from "@hookform/resolvers/zod"
|
||||
import {Loader2} from "lucide-react"
|
||||
import {useForm} from "react-hook-form"
|
||||
import {z} from "zod"
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -16,6 +19,12 @@ import {Button} from "@/components/ui/button"
|
||||
import {Input} from "@/components/ui/input"
|
||||
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 {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
@@ -29,17 +38,20 @@ export function CleanupDialog({
|
||||
onConfirm,
|
||||
loading,
|
||||
}: CleanupDialogProps) {
|
||||
const [keepCount, setKeepCount] = useState(10)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const form = useForm<CleanupFormValues>({
|
||||
resolver: zodResolver(cleanupSchema),
|
||||
defaultValues: { keepCount: 10 },
|
||||
})
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (keepCount < 3) {
|
||||
setError("最少保留 3 个历史快照")
|
||||
return
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
form.reset({ keepCount: 10 })
|
||||
}
|
||||
setError(null)
|
||||
onConfirm(keepCount)
|
||||
}
|
||||
}, [form, open])
|
||||
|
||||
const handleConfirm = form.handleSubmit((values) => {
|
||||
onConfirm(values.keepCount)
|
||||
})
|
||||
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||
@@ -57,17 +69,18 @@ export function CleanupDialog({
|
||||
id="keepCount"
|
||||
type="number"
|
||||
min={3}
|
||||
value={keepCount}
|
||||
onChange={(e) => setKeepCount(Number.parseInt(e.target.value, 10) || 3)}
|
||||
disabled={loading}
|
||||
{...form.register("keepCount", { valueAsNumber: true })}
|
||||
/>
|
||||
<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>
|
||||
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={loading}>取消</AlertDialogCancel>
|
||||
<Button variant="destructive" onClick={handleConfirm} disabled={loading}>
|
||||
<Button variant="destructive" onClick={() => void handleConfirm()} disabled={loading}>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="size-4 animate-spin mr-1" />
|
||||
@@ -81,4 +94,4 @@ export function CleanupDialog({
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
'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 {
|
||||
Dialog,
|
||||
@@ -16,21 +20,51 @@ import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue,} from '@/
|
||||
import {Switch} from '@/components/ui/switch';
|
||||
import type {NodeItem, NodeMutationPayload, NodeType} from '@/lib/services/openflare';
|
||||
|
||||
type FormState = {
|
||||
node_type: NodeType;
|
||||
name: string;
|
||||
ip: string;
|
||||
ip_manual_override: boolean;
|
||||
auto_update_enabled: boolean;
|
||||
geo_manual_override: boolean;
|
||||
geo_name: string;
|
||||
geo_latitude: string;
|
||||
geo_longitude: string;
|
||||
relay_bind_port: string;
|
||||
relay_vhost_http_port: string;
|
||||
};
|
||||
const nodeSchema = z
|
||||
.object({
|
||||
node_type: z.enum(['edge_node', 'tunnel_relay', 'tunnel_client']),
|
||||
name: z.string().trim().min(1, '请输入节点名称').max(255),
|
||||
ip: z.string(),
|
||||
ip_manual_override: z.boolean(),
|
||||
auto_update_enabled: z.boolean(),
|
||||
geo_manual_override: z.boolean(),
|
||||
geo_name: z.string(),
|
||||
geo_latitude: z.string(),
|
||||
geo_longitude: z.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',
|
||||
name: '',
|
||||
ip: '',
|
||||
@@ -44,7 +78,7 @@ const defaultForm: FormState = {
|
||||
relay_vhost_http_port: '8080',
|
||||
};
|
||||
|
||||
function buildFormState(node?: NodeItem | null): FormState {
|
||||
function buildFormValues(node?: NodeItem | null): NodeFormValues {
|
||||
if (!node) return defaultForm;
|
||||
|
||||
return {
|
||||
@@ -68,7 +102,7 @@ function buildFormState(node?: NodeItem | null): FormState {
|
||||
};
|
||||
}
|
||||
|
||||
function toPayload(form: FormState): NodeMutationPayload {
|
||||
function toPayload(form: NodeFormValues): NodeMutationPayload {
|
||||
const base: NodeMutationPayload = {
|
||||
node_type: form.node_type,
|
||||
name: form.name.trim(),
|
||||
@@ -110,30 +144,24 @@ export function NodeEditorDialog({
|
||||
onClose: () => void;
|
||||
onSubmit: (payload: NodeMutationPayload) => Promise<void>;
|
||||
}) {
|
||||
const [form, setForm] = useState<FormState>(defaultForm);
|
||||
const [error, setError] = useState('');
|
||||
const form = useForm<NodeFormValues>({
|
||||
resolver: zodResolver(nodeSchema),
|
||||
defaultValues: defaultForm,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setForm(buildFormState(node));
|
||||
setError('');
|
||||
form.reset(buildFormValues(node));
|
||||
}
|
||||
}, [open, node]);
|
||||
}, [form, open, node]);
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!form.name.trim()) {
|
||||
setError('请输入节点名称');
|
||||
return;
|
||||
}
|
||||
if (form.ip_manual_override && !form.ip.trim()) {
|
||||
setError('锁定 IP 时必须填写节点 IP');
|
||||
return;
|
||||
}
|
||||
const nodeType = form.watch('node_type');
|
||||
const ipManualOverride = form.watch('ip_manual_override');
|
||||
const geoManualOverride = form.watch('geo_manual_override');
|
||||
|
||||
setError('');
|
||||
await onSubmit(toPayload(form));
|
||||
};
|
||||
const handleSubmit = form.handleSubmit(async (values) => {
|
||||
await onSubmit(toPayload(values));
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(next) => !next && onClose()}>
|
||||
@@ -149,10 +177,10 @@ export function NodeEditorDialog({
|
||||
<div className="space-y-2">
|
||||
<Label>节点类型</Label>
|
||||
<Select
|
||||
value={form.node_type}
|
||||
value={nodeType}
|
||||
disabled={Boolean(node)}
|
||||
onValueChange={(value) =>
|
||||
setForm((prev) => ({ ...prev, node_type: value as NodeType }))
|
||||
form.setValue('node_type', value as NodeType)
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
@@ -170,20 +198,28 @@ export function NodeEditorDialog({
|
||||
<Label htmlFor="node-name">节点名称</Label>
|
||||
<Input
|
||||
id="node-name"
|
||||
value={form.name}
|
||||
onChange={(event) => setForm((prev) => ({ ...prev, name: event.target.value }))}
|
||||
placeholder="例如 edge-hk-01"
|
||||
{...form.register('name')}
|
||||
/>
|
||||
{form.formState.errors.name ? (
|
||||
<p className="text-xs text-destructive">
|
||||
{form.formState.errors.name.message}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="node-ip">节点 IP</Label>
|
||||
<Input
|
||||
id="node-ip"
|
||||
value={form.ip}
|
||||
onChange={(event) => setForm((prev) => ({ ...prev, ip: event.target.value }))}
|
||||
placeholder="可选,接入后自动上报"
|
||||
{...form.register('ip')}
|
||||
/>
|
||||
{form.formState.errors.ip ? (
|
||||
<p className="text-xs text-destructive">
|
||||
{form.formState.errors.ip.message}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
<Switch
|
||||
checked={form.ip_manual_override}
|
||||
checked={ipManualOverride}
|
||||
onCheckedChange={(checked) =>
|
||||
setForm((prev) => ({ ...prev, ip_manual_override: checked }))
|
||||
form.setValue('ip_manual_override', checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
@@ -205,34 +241,38 @@ export function NodeEditorDialog({
|
||||
<p className="text-xs text-muted-foreground">启用后节点将自动拉取正式版更新</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={form.auto_update_enabled}
|
||||
checked={form.watch('auto_update_enabled')}
|
||||
onCheckedChange={(checked) =>
|
||||
setForm((prev) => ({ ...prev, auto_update_enabled: checked }))
|
||||
form.setValue('auto_update_enabled', checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{form.node_type === 'tunnel_relay' ? (
|
||||
{nodeType === 'tunnel_relay' ? (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="relay-bind-port">绑定端口</Label>
|
||||
<Input
|
||||
id="relay-bind-port"
|
||||
value={form.relay_bind_port}
|
||||
onChange={(event) =>
|
||||
setForm((prev) => ({ ...prev, relay_bind_port: event.target.value }))
|
||||
}
|
||||
{...form.register('relay_bind_port')}
|
||||
/>
|
||||
{form.formState.errors.relay_bind_port ? (
|
||||
<p className="text-xs text-destructive">
|
||||
{form.formState.errors.relay_bind_port.message}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="relay-vhost-port">VHost 端口</Label>
|
||||
<Input
|
||||
id="relay-vhost-port"
|
||||
value={form.relay_vhost_http_port}
|
||||
onChange={(event) =>
|
||||
setForm((prev) => ({ ...prev, relay_vhost_http_port: event.target.value }))
|
||||
}
|
||||
{...form.register('relay_vhost_http_port')}
|
||||
/>
|
||||
{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>
|
||||
) : null}
|
||||
@@ -243,50 +283,30 @@ export function NodeEditorDialog({
|
||||
<p className="text-xs text-muted-foreground">用于总览地图展示</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={form.geo_manual_override}
|
||||
checked={geoManualOverride}
|
||||
onCheckedChange={(checked) =>
|
||||
setForm((prev) => ({ ...prev, geo_manual_override: checked }))
|
||||
form.setValue('geo_manual_override', checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{form.geo_manual_override ? (
|
||||
{geoManualOverride ? (
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<div className="space-y-2 sm:col-span-3">
|
||||
<Label htmlFor="geo-name">位置名称</Label>
|
||||
<Input
|
||||
id="geo-name"
|
||||
value={form.geo_name}
|
||||
onChange={(event) =>
|
||||
setForm((prev) => ({ ...prev, geo_name: event.target.value }))
|
||||
}
|
||||
/>
|
||||
<Input id="geo-name" {...form.register('geo_name')} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="geo-lat">纬度</Label>
|
||||
<Input
|
||||
id="geo-lat"
|
||||
value={form.geo_latitude}
|
||||
onChange={(event) =>
|
||||
setForm((prev) => ({ ...prev, geo_latitude: event.target.value }))
|
||||
}
|
||||
/>
|
||||
<Input id="geo-lat" {...form.register('geo_latitude')} />
|
||||
</div>
|
||||
<div className="space-y-2 sm:col-span-2">
|
||||
<Label htmlFor="geo-lng">经度</Label>
|
||||
<Input
|
||||
id="geo-lng"
|
||||
value={form.geo_longitude}
|
||||
onChange={(event) =>
|
||||
setForm((prev) => ({ ...prev, geo_longitude: event.target.value }))
|
||||
}
|
||||
/>
|
||||
<Input id="geo-lng" {...form.register('geo_longitude')} />
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{error ? <p className="text-sm text-destructive">{error}</p> : null}
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={onClose} disabled={submitting}>
|
||||
取消
|
||||
@@ -299,4 +319,4 @@ export function NodeEditorDialog({
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,9 @@
|
||||
|
||||
import {useCallback, useEffect, useState} from 'react';
|
||||
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 {Tabs, TabsContent, TabsList, TabsTrigger} from '@/components/ui/tabs';
|
||||
import type {ProxyRouteConfigSection, ProxyRouteItem} from '@/lib/services/openflare';
|
||||
@@ -23,9 +24,11 @@ export function ProxyRouteDetailPageClient() {
|
||||
const searchParams = useSearchParams();
|
||||
const routeId = Number(searchParams.get('id'));
|
||||
const activeSection = getProxyRouteConfigSection(searchParams.get('section'));
|
||||
const hasValidId = Number.isFinite(routeId) && routeId > 0;
|
||||
|
||||
const [route, setRoute] = useState<ProxyRouteItem | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [sectionSaving, setSectionSaving] = useState(false);
|
||||
|
||||
const handleSectionChange = useCallback(
|
||||
@@ -41,47 +44,36 @@ export function ProxyRouteDetailPageClient() {
|
||||
setRoute(updatedRoute);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!Number.isFinite(routeId) || routeId <= 0) {
|
||||
const fetchRoute = useCallback(async () => {
|
||||
if (!hasValidId) {
|
||||
setLoading(false);
|
||||
setRoute(null);
|
||||
setLoadError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setLoadError(null);
|
||||
|
||||
const fetchRoute = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await ProxyRouteService.getById(routeId);
|
||||
if (!cancelled) {
|
||||
setRoute(data);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!cancelled) {
|
||||
setRoute(null);
|
||||
toast.error('规则详情加载失败', {
|
||||
description: error instanceof Error ? error.message : '未知错误',
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
try {
|
||||
const data = await ProxyRouteService.getById(routeId);
|
||||
setRoute(data);
|
||||
} catch (error) {
|
||||
setRoute(null);
|
||||
setLoadError(error instanceof Error ? error.message : '未知错误');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [hasValidId, routeId]);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchRoute();
|
||||
}, [fetchRoute]);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [routeId]);
|
||||
|
||||
if (!Number.isFinite(routeId) || routeId <= 0) {
|
||||
if (!hasValidId) {
|
||||
return (
|
||||
<div className="py-6 px-1">
|
||||
<p className="text-sm text-muted-foreground">缺少有效的规则 ID。</p>
|
||||
<ErrorInline message="缺少有效的规则 ID。" />
|
||||
</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) {
|
||||
return (
|
||||
<div className="py-6 px-1">
|
||||
<p className="text-sm text-muted-foreground">未找到对应规则,请返回列表重试。</p>
|
||||
<EmptyStateWithBorder
|
||||
title="未找到对应规则"
|
||||
description="请返回列表重试。"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import {useEffect, useMemo, useState} from 'react';
|
||||
import {zodResolver} from '@hookform/resolvers/zod';
|
||||
import {Search} from 'lucide-react';
|
||||
import {useForm} from 'react-hook-form';
|
||||
import {z} from 'zod';
|
||||
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {Checkbox} from '@/components/ui/checkbox';
|
||||
@@ -19,45 +22,101 @@ import {Textarea} from '@/components/ui/textarea';
|
||||
import {cn} from '@/lib/utils';
|
||||
import type {WAFIPGroup} from '@/lib/services/openflare';
|
||||
|
||||
import {
|
||||
type CountryOption,
|
||||
normalizeItems,
|
||||
type RuleDimension,
|
||||
type RuleListType,
|
||||
type RuleModalState,
|
||||
} from './helpers';
|
||||
import {type CountryOption, normalizeItems, type RuleDimension, type RuleListType, textToList,} from './helpers';
|
||||
|
||||
const ruleEntrySchema = z
|
||||
.object({
|
||||
listType: z.enum(['whitelist', 'blacklist']),
|
||||
dimension: z.enum(['ip', 'ip_group', 'country']),
|
||||
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 {
|
||||
state: RuleModalState;
|
||||
open: boolean;
|
||||
countryOptions: CountryOption[];
|
||||
ipGroups: WAFIPGroup[];
|
||||
onClose: () => void;
|
||||
onChange: (patch: Partial<RuleModalState>) => void;
|
||||
onSubmit: () => void;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSubmit: (values: RuleEntryFormValues) => void;
|
||||
}
|
||||
|
||||
export function RuleEntryDialog({
|
||||
state,
|
||||
open,
|
||||
countryOptions,
|
||||
ipGroups,
|
||||
onClose,
|
||||
onChange,
|
||||
onOpenChange,
|
||||
onSubmit,
|
||||
}: RuleEntryDialogProps) {
|
||||
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(() => {
|
||||
if (!state.open) return;
|
||||
if (!open) return;
|
||||
form.reset(defaultRuleEntryValues);
|
||||
setKeyword('');
|
||||
}, [state.dimension, state.open]);
|
||||
}, [form, open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setKeyword('');
|
||||
}, [dimension, open]);
|
||||
|
||||
const selectedCountrySet = useMemo(
|
||||
() => new Set(state.countryValues),
|
||||
[state.countryValues],
|
||||
() => new Set(countryValues),
|
||||
[countryValues],
|
||||
);
|
||||
const selectedIPGroupSet = useMemo(
|
||||
() => new Set(state.ipGroupIDs),
|
||||
[state.ipGroupIDs],
|
||||
() => new Set(ipGroupIDs),
|
||||
[ipGroupIDs],
|
||||
);
|
||||
|
||||
const filteredCountries = useMemo(() => {
|
||||
@@ -73,28 +132,32 @@ export function RuleEntryDialog({
|
||||
|
||||
const toggleCountry = (code: string) => {
|
||||
const values = selectedCountrySet.has(code)
|
||||
? state.countryValues.filter((item) => item !== code)
|
||||
: normalizeItems([...state.countryValues, code]);
|
||||
onChange({ countryValues: values });
|
||||
? countryValues.filter((item) => item !== code)
|
||||
: normalizeItems([...countryValues, code]);
|
||||
form.setValue('countryValues', values, { shouldValidate: true });
|
||||
};
|
||||
|
||||
const toggleIPGroup = (id: number) => {
|
||||
const values = selectedIPGroupSet.has(id)
|
||||
? state.ipGroupIDs.filter((item) => item !== id)
|
||||
: [...state.ipGroupIDs, id].sort((left, right) => left - right);
|
||||
onChange({ ipGroupIDs: values });
|
||||
? ipGroupIDs.filter((item) => item !== id)
|
||||
: [...ipGroupIDs, id].sort((left, right) => left - right);
|
||||
form.setValue('ipGroupIDs', values, { shouldValidate: true });
|
||||
};
|
||||
|
||||
const typeLabel = state.listType === 'blacklist' ? '黑名单' : '白名单';
|
||||
const typeLabel = listType === 'blacklist' ? '黑名单' : '白名单';
|
||||
const dimensionLabel =
|
||||
state.dimension === 'ip'
|
||||
dimension === 'ip'
|
||||
? 'IP'
|
||||
: state.dimension === 'ip_group'
|
||||
: dimension === 'ip_group'
|
||||
? 'IP 组'
|
||||
: '地域';
|
||||
|
||||
const handleSubmit = form.handleSubmit((values) => {
|
||||
onSubmit(values);
|
||||
});
|
||||
|
||||
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">
|
||||
<DialogHeader>
|
||||
<DialogTitle>添加{typeLabel}规则</DialogTitle>
|
||||
@@ -115,9 +178,9 @@ export function RuleEntryDialog({
|
||||
<Button
|
||||
key={option.value}
|
||||
type="button"
|
||||
variant={state.listType === option.value ? 'default' : 'outline'}
|
||||
variant={listType === option.value ? 'default' : 'outline'}
|
||||
onClick={() =>
|
||||
onChange({ listType: option.value as RuleListType })
|
||||
form.setValue('listType', option.value as RuleListType)
|
||||
}
|
||||
>
|
||||
{option.label}
|
||||
@@ -137,9 +200,9 @@ export function RuleEntryDialog({
|
||||
key={option.value}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={state.dimension === option.value ? 'default' : 'outline'}
|
||||
variant={dimension === option.value ? 'default' : 'outline'}
|
||||
onClick={() =>
|
||||
onChange({ dimension: option.value as RuleDimension })
|
||||
form.setValue('dimension', option.value as RuleDimension)
|
||||
}
|
||||
>
|
||||
{option.label}
|
||||
@@ -149,21 +212,28 @@ export function RuleEntryDialog({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{state.dimension === 'ip' ? (
|
||||
{dimension === 'ip' ? (
|
||||
<div className="space-y-2">
|
||||
<Label>IP / IP 段</Label>
|
||||
<Textarea
|
||||
value={state.ipValue}
|
||||
value={ipValue}
|
||||
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">
|
||||
支持单个 IP、CIDR,或使用换行/逗号一次添加多个。
|
||||
</p>
|
||||
{form.formState.errors.ipValue ? (
|
||||
<p className="text-xs text-destructive">
|
||||
{form.formState.errors.ipValue.message}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{state.dimension === 'ip_group' ? (
|
||||
{dimension === 'ip_group' ? (
|
||||
<div className="space-y-3 rounded-lg border border-dashed p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
@@ -173,7 +243,7 @@ export function RuleEntryDialog({
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
已选 {state.ipGroupIDs.length}
|
||||
已选 {ipGroupIDs.length}
|
||||
</span>
|
||||
</div>
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
{form.formState.errors.ipGroupIDs ? (
|
||||
<p className="text-xs text-destructive">
|
||||
{form.formState.errors.ipGroupIDs.message}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{state.dimension === 'country' ? (
|
||||
{dimension === 'country' ? (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 rounded-md border px-3 py-2">
|
||||
<Search className="size-4 text-muted-foreground" />
|
||||
@@ -226,12 +301,14 @@ export function RuleEntryDialog({
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
onChange({
|
||||
countryValues: normalizeItems([
|
||||
...state.countryValues,
|
||||
form.setValue(
|
||||
'countryValues',
|
||||
normalizeItems([
|
||||
...countryValues,
|
||||
...filteredCountries.map((option) => option.code),
|
||||
]),
|
||||
})
|
||||
{ shouldValidate: true },
|
||||
)
|
||||
}
|
||||
>
|
||||
全选当前
|
||||
@@ -240,7 +317,9 @@ export function RuleEntryDialog({
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onChange({ countryValues: [] })}
|
||||
onClick={() =>
|
||||
form.setValue('countryValues', [], { shouldValidate: true })
|
||||
}
|
||||
>
|
||||
清空
|
||||
</Button>
|
||||
@@ -265,15 +344,20 @@ export function RuleEntryDialog({
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{form.formState.errors.countryValues ? (
|
||||
<p className="text-xs text-destructive">
|
||||
{form.formState.errors.countryValues.message}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="button" onClick={onSubmit}>
|
||||
<Button type="button" onClick={() => void handleSubmit()}>
|
||||
添加
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
@@ -26,18 +26,16 @@ import {
|
||||
buildCountryOptions,
|
||||
buildRuleGroupDraft,
|
||||
countRuleEntries,
|
||||
defaultRuleModalState,
|
||||
emptyRuleGroupDraft,
|
||||
formatCountryItem,
|
||||
getListFieldKey,
|
||||
type ListFieldKey,
|
||||
normalizeItems,
|
||||
type RuleModalState,
|
||||
textToList,
|
||||
updateDraftList,
|
||||
} from './helpers';
|
||||
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';
|
||||
|
||||
const ruleGroupSchema = z.object({
|
||||
@@ -69,7 +67,7 @@ export function RuleGroupDialog({
|
||||
onSubmit,
|
||||
}: RuleGroupDialogProps) {
|
||||
const [listsDraft, setListsDraft] = useState<WAFRuleGroupPayload>(emptyRuleGroupDraft);
|
||||
const [ruleModal, setRuleModal] = useState<RuleModalState>(defaultRuleModalState);
|
||||
const [ruleEntryOpen, setRuleEntryOpen] = useState(false);
|
||||
|
||||
const countryOptions = useMemo(() => buildCountryOptions(), []);
|
||||
const countryLabelMap = useMemo(
|
||||
@@ -101,7 +99,7 @@ export function RuleGroupDialog({
|
||||
remark: draft.remark,
|
||||
pow_enabled: draft.pow_enabled,
|
||||
});
|
||||
setRuleModal(defaultRuleModalState);
|
||||
setRuleEntryOpen(false);
|
||||
}, [form, group, open]);
|
||||
|
||||
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 =
|
||||
ruleModal.dimension === 'ip'
|
||||
? textToList(ruleModal.ipValue)
|
||||
: ruleModal.dimension === 'ip_group'
|
||||
? ruleModal.ipGroupIDs.map(String)
|
||||
: normalizeItems(ruleModal.countryValues);
|
||||
entry.dimension === 'ip'
|
||||
? textToList(entry.ipValue)
|
||||
: entry.dimension === 'ip_group'
|
||||
? entry.ipGroupIDs.map(String)
|
||||
: normalizeItems(entry.countryValues);
|
||||
|
||||
if (values.length === 0) return;
|
||||
|
||||
const listKey = getListFieldKey(ruleModal.listType, ruleModal.dimension);
|
||||
const listKey = getListFieldKey(entry.listType, entry.dimension);
|
||||
setListsDraft((current) =>
|
||||
updateDraftList(current, listKey, (items) =>
|
||||
normalizeItems([...items, ...values]),
|
||||
),
|
||||
);
|
||||
setRuleModal(defaultRuleModalState);
|
||||
setRuleEntryOpen(false);
|
||||
};
|
||||
|
||||
const handleSubmit = form.handleSubmit(async (values) => {
|
||||
@@ -244,7 +240,7 @@ export function RuleGroupDialog({
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setRuleModal({ ...defaultRuleModalState, open: true })
|
||||
setRuleEntryOpen(true)
|
||||
}
|
||||
>
|
||||
<Plus className="size-3.5 mr-1" />
|
||||
@@ -392,12 +388,11 @@ export function RuleGroupDialog({
|
||||
</Dialog>
|
||||
|
||||
<RuleEntryDialog
|
||||
state={ruleModal}
|
||||
open={ruleEntryOpen}
|
||||
countryOptions={countryOptions}
|
||||
ipGroups={ipGroups}
|
||||
onClose={() => setRuleModal(defaultRuleModalState)}
|
||||
onChange={(patch) => setRuleModal((current) => ({ ...current, ...patch, open: true }))}
|
||||
onSubmit={applyRuleModal}
|
||||
onOpenChange={setRuleEntryOpen}
|
||||
onSubmit={applyRuleEntry}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
+25
-21
@@ -1,9 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import {useMutation, useQueryClient} from '@tanstack/react-query';
|
||||
import {zodResolver} from '@hookform/resolvers/zod';
|
||||
import {useState} from 'react';
|
||||
import {useForm} from 'react-hook-form';
|
||||
import {Loader2} from 'lucide-react';
|
||||
import {z} from 'zod';
|
||||
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {
|
||||
@@ -23,11 +25,13 @@ import {getErrorMessage} from './website-utils';
|
||||
|
||||
const dnsAccountsQueryKey = ['openflare', 'dns-accounts'];
|
||||
|
||||
type DnsAccountFormValues = {
|
||||
name: string;
|
||||
type: string;
|
||||
authorization: string;
|
||||
};
|
||||
const dnsAccountSchema = z.object({
|
||||
name: z.string().trim().min(1, '请输入名称').max(255),
|
||||
type: z.string().min(1),
|
||||
authorization: z.string().trim().min(1, '请输入 Token'),
|
||||
});
|
||||
|
||||
type DnsAccountFormValues = z.infer<typeof dnsAccountSchema>;
|
||||
|
||||
interface DnsAccountCreateDialogProps {
|
||||
open: boolean;
|
||||
@@ -42,16 +46,16 @@ export function DnsAccountCreateDialog({
|
||||
}: DnsAccountCreateDialogProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [error, setError] = useState('');
|
||||
const {register, handleSubmit, setValue, watch, formState, reset} =
|
||||
useForm<DnsAccountFormValues>({
|
||||
defaultValues: {name: '', type: 'cloudflare', authorization: ''},
|
||||
});
|
||||
const form = useForm<DnsAccountFormValues>({
|
||||
resolver: zodResolver(dnsAccountSchema),
|
||||
defaultValues: {name: '', type: 'cloudflare', authorization: ''},
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: DnsAccountService.create,
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({queryKey: dnsAccountsQueryKey});
|
||||
reset();
|
||||
form.reset();
|
||||
setError('');
|
||||
onCreated?.();
|
||||
onOpenChange(false);
|
||||
@@ -59,7 +63,7 @@ export function DnsAccountCreateDialog({
|
||||
onError: (err) => setError(getErrorMessage(err)),
|
||||
});
|
||||
|
||||
const onSubmit = handleSubmit((values) => {
|
||||
const onSubmit = form.handleSubmit((values) => {
|
||||
setError('');
|
||||
let auth = values.authorization.trim();
|
||||
if (!auth.startsWith('{')) {
|
||||
@@ -69,7 +73,7 @@ export function DnsAccountCreateDialog({
|
||||
});
|
||||
|
||||
const handleClose = () => {
|
||||
reset();
|
||||
form.reset();
|
||||
setError('');
|
||||
onOpenChange(false);
|
||||
};
|
||||
@@ -91,18 +95,18 @@ export function DnsAccountCreateDialog({
|
||||
<Label>账号名称</Label>
|
||||
<Input
|
||||
placeholder="Cloudflare 邮箱账号"
|
||||
{...register('name', {required: '请输入名称'})}
|
||||
{...form.register('name')}
|
||||
/>
|
||||
{formState.errors.name ? (
|
||||
<p className="text-xs text-destructive">{formState.errors.name.message}</p>
|
||||
{form.formState.errors.name ? (
|
||||
<p className="text-xs text-destructive">{form.formState.errors.name.message}</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>DNS 服务商</Label>
|
||||
<Select
|
||||
value={watch('type')}
|
||||
onValueChange={(value) => setValue('type', value)}
|
||||
value={form.watch('type')}
|
||||
onValueChange={(value) => form.setValue('type', value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
@@ -116,12 +120,12 @@ export function DnsAccountCreateDialog({
|
||||
<div className="space-y-2">
|
||||
<Label>API Token</Label>
|
||||
<Input
|
||||
{...register('authorization', {required: '请输入 Token'})}
|
||||
{...form.register('authorization')}
|
||||
placeholder="请勿使用 Global API Key"
|
||||
/>
|
||||
{formState.errors.authorization ? (
|
||||
{form.formState.errors.authorization ? (
|
||||
<p className="text-xs text-destructive">
|
||||
{formState.errors.authorization.message}
|
||||
{form.formState.errors.authorization.message}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -145,4 +149,4 @@ export function DnsAccountCreateDialog({
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import {useNotificationSettings} from "@/contexts/notification-settings-context"
|
||||
import {SidebarTrigger} from "@/components/ui/sidebar"
|
||||
import {useTheme} from "next-themes"
|
||||
import {useRouter} from "next/navigation"
|
||||
import {OpenFlareVersionEntry} from "@/components/layout/openflare-version-entry"
|
||||
import {SearchDialog} from "@/components/layout/search-dialog"
|
||||
import {Kbd} from "@/components/ui/kbd"
|
||||
|
||||
@@ -100,6 +101,7 @@ export function SiteHeader({ isFullWidth = false, onToggleFullWidth }: { isFullW
|
||||
</span>
|
||||
</div>
|
||||
<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)}>
|
||||
<Search className="size-[18px]" />
|
||||
<span className="sr-only">搜索</span>
|
||||
@@ -125,6 +127,7 @@ export function SiteHeader({ isFullWidth = false, onToggleFullWidth }: { isFullW
|
||||
</div>
|
||||
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<OpenFlareVersionEntry />
|
||||
<BellButton />
|
||||
<Button variant="ghost" size="icon" className="size-9 text-muted-foreground hover:text-foreground" onClick={() => router.push('/settings')}>
|
||||
<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,
|
||||
GitBranch,
|
||||
Globe,
|
||||
Info,
|
||||
LayoutDashboard,
|
||||
MapPin,
|
||||
Route,
|
||||
@@ -43,6 +44,7 @@ export const openflareNavItems: OpenFlareNavItem[] = [
|
||||
{title: '访问日志', url: '/openflare/access-logs', icon: ScrollText},
|
||||
{title: '应用日志', url: '/openflare/apply-logs', icon: ClipboardList},
|
||||
{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 {UserService} from './user';
|
||||
import {
|
||||
AboutService,
|
||||
AccessLogService,
|
||||
ApplyLogService,
|
||||
ConfigVersionService,
|
||||
@@ -61,6 +62,7 @@ const services = {
|
||||
adminUpload: AdminUploadService,
|
||||
dbManage: DbManageService,
|
||||
push: PushService,
|
||||
openflareAbout: AboutService,
|
||||
openflareNode: NodeService,
|
||||
openflareProxyRoute: ProxyRouteService,
|
||||
openflareConfigVersion: ConfigVersionService,
|
||||
@@ -181,6 +183,7 @@ export type { PushEvent, PushHistory, PushChannelConfig, ListPushHistoriesReques
|
||||
// ==================== OpenFlare 业务服务导出 ====================
|
||||
|
||||
export {
|
||||
AboutService,
|
||||
LegacyOpenFlareBaseService,
|
||||
NodeService,
|
||||
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 type { LegacyApiResponse } from './legacy-base.service';
|
||||
|
||||
@@ -87,6 +88,9 @@ export type {
|
||||
DatabaseCleanupTarget,
|
||||
LatestReleaseInfo,
|
||||
OpenFlarePublicStatus,
|
||||
UpgradeLogItem,
|
||||
UpgradeStreamSnapshot,
|
||||
UploadedServerBinaryInfo,
|
||||
OriginDetail,
|
||||
OriginItem,
|
||||
OriginMutationPayload,
|
||||
@@ -108,6 +112,7 @@ export type {
|
||||
TlsCertificateMutationPayload,
|
||||
} from './types';
|
||||
|
||||
import {AboutService} from './about.service';
|
||||
import {AccessLogService} from './access-log.service';
|
||||
import {ApplyLogService} from './apply-log.service';
|
||||
import {ConfigVersionService} from './config-version.service';
|
||||
@@ -126,6 +131,7 @@ import {WafService} from './waf.service';
|
||||
import {WebsiteService} from './website.service';
|
||||
|
||||
export const openflareServices = {
|
||||
about: AboutService,
|
||||
node: NodeService,
|
||||
proxyRoute: ProxyRouteService,
|
||||
configVersion: ConfigVersionService,
|
||||
|
||||
@@ -669,10 +669,28 @@ export interface LatestReleaseInfo {
|
||||
has_update: boolean;
|
||||
upgrade_supported: boolean;
|
||||
in_progress: boolean;
|
||||
upgrade_status: string;
|
||||
upgrade_status: 'idle' | 'running' | 'succeeded' | 'failed' | string;
|
||||
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 {
|
||||
id: number;
|
||||
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 type {LatestReleaseInfo, ReleaseChannel} from './types';
|
||||
import type {LatestReleaseInfo, ReleaseChannel, UpgradeStreamSnapshot, UploadedServerBinaryInfo,} from './types';
|
||||
|
||||
/**
|
||||
* OpenFlare 服务端升级 API(遗留 `/api/update/*`)。
|
||||
*
|
||||
* 可选集成:在顶栏或 Admin 升级入口调用 `getLatestRelease()` 展示版本与更新状态;
|
||||
* 完整升级流程(upgrade / manual-upload / logs WebSocket)可对接 Wavelet `admin/updater`。
|
||||
*/
|
||||
export class UpdateService extends LegacyOpenFlareBaseService {
|
||||
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> {
|
||||
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。
|
||||
- 实装节点、代理规则(6 Section)、配置发布、WAF、网站/证书/DNS、Pages、源站、访问/应用日志、仪表盘、性能调优等业务页面;Admin 设置新增 OpenFlare 运维扩展 Tab。
|
||||
- 修复静态导出构建中 `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
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
> **文档类型**:实现计划(Implementation Plan)
|
||||
> **创建日期**: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)
|
||||
> **依赖**:后端阶段一(旧路径 `/api/*` 兼容)完成后启动;阶段二可与后端阶段五并行
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# OpenFlare 前端迁移 — 任务拆分与 AI 委派
|
||||
|
||||
> **状态**:基本完成(验证通过,少量 P1 打磨待办)
|
||||
> **状态**:✅ 迁移完成(多角度验证通过;P2 可选打磨待办)
|
||||
> **目标前端**:`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)
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
| F-NODE | 节点(含 Relay/Tunnel 详情) | ✅ |
|
||||
| F-PROXY | 代理规则(含 6 Section 实装) | ✅ |
|
||||
| F-CFG | 配置发布 + 应用日志 | ✅ |
|
||||
| F-DASH | 总览仪表盘 | ✅(缺世界地图) |
|
||||
| F-DASH | 总览仪表盘 | ✅(缺世界地图,P2) |
|
||||
| F-WAF | WAF 规则组 + IP 组 | ✅ |
|
||||
| F-WEB | 网站/证书/DNS | ✅ |
|
||||
| F-PAGES | Pages 托管 | ✅ |
|
||||
@@ -21,6 +21,8 @@
|
||||
| F-PERF | 性能调优 | ✅ |
|
||||
| F-ADMIN | Admin 运维设置扩展 | ✅ |
|
||||
| F-AUTH | Wavelet 登录/用户复用 | ✅(原生,未改登录页) |
|
||||
| F-ABOUT | About 页(FC-20) | ✅ |
|
||||
| F-UPDATE | 服务升级 UI + UpdateService | ✅ |
|
||||
|
||||
## 验证结果摘要
|
||||
|
||||
@@ -28,16 +30,17 @@
|
||||
|---|---|
|
||||
| `tsc --noEmit` | ✅ |
|
||||
| `pnpm lint` | ✅ |
|
||||
| `pnpm build:embed` | ✅(46 静态页;已修 Suspense) |
|
||||
| 路由覆盖 FC-1~19 | ✅ 18 完整 + 1 部分 |
|
||||
| Service 覆盖 | ⚠️ `update` 升级流程待补 |
|
||||
| `pnpm build:embed` | ✅(47 静态页) |
|
||||
| 路由覆盖 FC-1~20 | ✅ 20/20 文件(FC-1 世界地图 P2) |
|
||||
| Service 覆盖 | ✅ 100% |
|
||||
| UI 规范 | ✅ PASS |
|
||||
|
||||
## 剩余 P1 待办
|
||||
## P2 可选待办
|
||||
|
||||
1. **FC-20** About 页或链到 `/docs`
|
||||
2. **UpdateService** 补全 upgrade/manual-upload/WS 日志
|
||||
3. **UI 打磨**:部分 Dialog 补 Zod;access-logs/proxy-detail error 态
|
||||
4. **FC-1** 世界地图(可选,当前用 geo 列表)
|
||||
1. **FC-1** 世界地图(当前用 geo 列表)
|
||||
2. **UI 打磨** — proxy 列表 error 态、access-logs cleanup Zod、node-observability error
|
||||
3. **废弃旧前端** — 移除 `openflare-server/web` 引用(阶段五)
|
||||
4. **E2E** — Playwright 核心路径
|
||||
|
||||
## 验收命令
|
||||
|
||||
|
||||
@@ -10,17 +10,16 @@
|
||||
|------|---------|-----------|--------|
|
||||
| TypeScript | `pnpm exec tsc --noEmit` | **0** | ✅ Pass |
|
||||
| Lint | `pnpm lint` | **0** | ✅ Pass |
|
||||
| Production build | `pnpm build:embed` | **1** | ❌ Fail |
|
||||
| Standard build | `pnpm build` | **1** | ❌ Fail (same error) |
|
||||
| Production build | `pnpm build:embed` | **0** | ✅ Pass |
|
||||
|
||||
**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`)
|
||||
|
||||
- **Exit code:** `0`
|
||||
- **Duration:** ~6.2s
|
||||
- **Duration:** ~16.2s
|
||||
- **Errors:** None
|
||||
- **Warnings:** None
|
||||
|
||||
@@ -29,7 +28,7 @@
|
||||
## 2. ESLint (`pnpm lint`)
|
||||
|
||||
- **Exit code:** `0`
|
||||
- **Duration:** ~5.6s
|
||||
- **Duration:** ~24.4s
|
||||
- **Command:** `eslint` (no extra args)
|
||||
- **Errors:** 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.
|
||||
|
||||
- **Exit code:** `1`
|
||||
- **Duration:** ~57s (compile ~29.5s, TypeScript check ~23.9s)
|
||||
- **Exit code:** `0`
|
||||
- **Duration:** ~72s (compile ~44s, TypeScript check ~20.1s, static generation ~2.2s)
|
||||
|
||||
### Build progress
|
||||
|
||||
- ✅ Compiled successfully
|
||||
- ✅ 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".
|
||||
Read more: https://nextjs.org/docs/messages/missing-suspense-with-csr-bailout
|
||||
|
||||
Error occurred prerendering page "/openflare/nodes".
|
||||
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
|
||||
⚠ Statically exporting a Next.js application via `next export` disables API routes and middleware.
|
||||
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`.
|
||||
Learn more: https://nextjs.org/docs/messages/api-routes-static-export
|
||||
```
|
||||
|
||||
### Affected files
|
||||
### Static pages (47 routes)
|
||||
|
||||
| File | Issue |
|
||||
|------|-------|
|
||||
| `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) |
|
||||
| Route |
|
||||
|-------|
|
||||
| `/` |
|
||||
| `/_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
|
||||
|
||||
Not available — build aborted before artifact generation completed.
|
||||
All routes prerendered as static content (`○`). Middleware proxy is present but not exported in static mode (expected).
|
||||
|
||||
---
|
||||
|
||||
## 4. Standard Build (`pnpm build`)
|
||||
## Notes
|
||||
|
||||
Ran for comparison (uses rewrites instead of static export).
|
||||
|
||||
- **Exit code:** `1`
|
||||
- **Duration:** ~65s
|
||||
- **Error:** Identical `useSearchParams()` / missing `Suspense` failure on `/openflare/nodes`
|
||||
- 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.
|
||||
|
||||
---
|
||||
|
||||
## 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`).
|
||||
|
||||
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:
|
||||
Run all three checks before merge:
|
||||
|
||||
```bash
|
||||
cd Wavelet/frontend
|
||||
pnpm exec tsc --noEmit
|
||||
pnpm lint
|
||||
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
|
||||
|
||||
- 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 前端路由验证报告
|
||||
|
||||
> **验证日期**:2026-06-18
|
||||
> **对照**:`20260618-openflare-wavelet-frontend-migration.md` §6.1(FC-1 ~ FC-20)
|
||||
> **验证日期**:2026-06-18(复验)
|
||||
> **对照**:`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-2 | `/openflare/nodes` | ✅ | |
|
||||
| FC-3 | `/openflare/nodes/detail` | ✅ | Edge/Relay/Tunnel 三种详情 |
|
||||
| FC-4 | `/openflare/proxy-routes` | ✅ | |
|
||||
| FC-5 | `/openflare/proxy-routes/detail` | ✅ | 6 Section + 发布 |
|
||||
| FC-6 | `/openflare/config-versions` | ✅ | |
|
||||
| FC-7 | `/openflare/waf` | ✅ | |
|
||||
| FC-8 | `/openflare/waf/ip-groups` | ✅ | 页内链接 |
|
||||
| FC-9 | `/openflare/websites` | ✅ | |
|
||||
| FC-10 | `/openflare/websites/detail` | ✅ | |
|
||||
| FC-11 | `/openflare/websites/certificates` | ✅ | |
|
||||
| FC-12 | `/openflare/websites/dns-accounts` | ✅ | |
|
||||
| FC-13 | `/openflare/pages` | ✅ | |
|
||||
| FC-14 | `/openflare/pages/detail` | ✅ | |
|
||||
| FC-15 | `/openflare/origins` | ✅ | |
|
||||
| FC-16 | `/openflare/origins/detail` | ✅ | |
|
||||
| FC-17 | `/openflare/access-logs` | ✅ | 4 Tab |
|
||||
| FC-18 | `/openflare/apply-logs` | ✅ | |
|
||||
| FC-19 | `/openflare/performance` | ✅ | |
|
||||
| FC-20 | `/openflare/about` | ❌ | 待实现或复用 `/docs` |
|
||||
|
||||
## 覆盖率
|
||||
|
||||
- **严格完成**:18/20(90%)
|
||||
- **加权**:92.5%(FC-1 计 0.5)
|
||||
- **PlaceholderPage 使用**:0(组件仍存在但未引用)
|
||||
| # | 计划路由 | 页面文件 | 状态 | 备注 |
|
||||
|---|---|---|---|---|
|
||||
| FC-1 | `/openflare` | `openflare/page.tsx` | ⚠️ partial | 仪表盘已实现;世界地图以 `GeoDistributionList` 国家列表替代 |
|
||||
| FC-2 | `/openflare/nodes` | `openflare/nodes/page.tsx` | ✅ | |
|
||||
| FC-3 | `/openflare/nodes/detail` | `openflare/nodes/detail/page.tsx` | ✅ | Edge / Relay / Tunnel |
|
||||
| FC-4 | `/openflare/proxy-routes` | `openflare/proxy-routes/page.tsx` | ✅ | |
|
||||
| FC-5 | `/openflare/proxy-routes/detail` | `openflare/proxy-routes/detail/page.tsx` | ✅ | 6 Section + 发布 |
|
||||
| FC-6 | `/openflare/config-versions` | `openflare/config-versions/page.tsx` | ✅ | |
|
||||
| FC-7 | `/openflare/waf` | `openflare/waf/page.tsx` | ✅ | |
|
||||
| FC-8 | `/openflare/waf/ip-groups` | `openflare/waf/ip-groups/page.tsx` | ✅ | |
|
||||
| FC-9 | `/openflare/websites` | `openflare/websites/page.tsx` | ✅ | |
|
||||
| FC-10 | `/openflare/websites/detail` | `openflare/websites/detail/page.tsx` | ✅ | |
|
||||
| FC-11 | `/openflare/websites/certificates` | `openflare/websites/certificates/page.tsx` | ✅ | |
|
||||
| FC-12 | `/openflare/websites/dns-accounts` | `openflare/websites/dns-accounts/page.tsx` | ✅ | |
|
||||
| FC-13 | `/openflare/pages` | `openflare/pages/page.tsx` | ✅ | |
|
||||
| FC-14 | `/openflare/pages/detail` | `openflare/pages/detail/page.tsx` | ✅ | |
|
||||
| FC-15 | `/openflare/origins` | `openflare/origins/page.tsx` | ✅ | |
|
||||
| FC-16 | `/openflare/origins/detail` | `openflare/origins/detail/page.tsx` | ✅ | |
|
||||
| FC-17 | `/openflare/access-logs` | `openflare/access-logs/page.tsx` | ✅ | 4 Tab |
|
||||
| FC-18 | `/openflare/apply-logs` | `openflare/apply-logs/page.tsx` | ✅ | |
|
||||
| FC-19 | `/openflare/performance` | `openflare/performance/page.tsx` | ✅ | |
|
||||
| FC-20 | `/openflare/about` | `openflare/about/page.tsx` | ✅ | AboutService + 侧栏导航 |
|
||||
|
||||
## 子路由(不在侧栏顶级)
|
||||
|
||||
`/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 层验证报告
|
||||
|
||||
> **验证日期**:2026-06-18
|
||||
> **验证日期**:2026-06-18(复验)
|
||||
> **目录**:`Wavelet/frontend/lib/services/openflare/`
|
||||
|
||||
## 覆盖率摘要
|
||||
|
||||
| 维度 | 结果 |
|
||||
|---|---|
|
||||
| §7.1 计划服务文件 | **15 / 15 = 100%** |
|
||||
| 业务 `/api/*` 端点 | **100%** |
|
||||
| 待补 API | **0** |
|
||||
|
||||
## §7.1 对照结果
|
||||
|
||||
| 计划服务 | 实际文件 | 注册 | 方法覆盖 |
|
||||
@@ -21,22 +29,33 @@
|
||||
| access-log | ✅ | `openflareAccessLog` | ✅ |
|
||||
| apply-log | ✅ | `openflareApplyLog` | ✅ |
|
||||
| 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`
|
||||
- `uptimekuma.service.ts` → `openflareUptimeKuma`
|
||||
- `about.service.ts` → `openflareAbout`
|
||||
- `legacy-base.service.ts`(基类)
|
||||
|
||||
## 待补 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 规范验收报告
|
||||
|
||||
> **验证日期**:2026-06-18
|
||||
> **验证日期**:2026-06-18(polish pass 复验)
|
||||
> **范围**:`Wavelet/frontend/app/(main)/openflare/`
|
||||
|
||||
## 总体
|
||||
@@ -9,25 +9,32 @@
|
||||
|---|---|
|
||||
| 标题 `h1 text-2xl font-semibold tracking-tight` | ✅ |
|
||||
| 外壳 `py-6 px-1` | ✅ |
|
||||
| shadcn 组件(无旧 UI) | ✅ |
|
||||
| 单文件 ≤600 行 | ✅(最大 access-logs 584 行) |
|
||||
| loading/empty/error | ⚠️ 部分 Tab 缺 error |
|
||||
| RHF + Zod 表单 | ⚠️ 部分 Dialog 待补 |
|
||||
| shadcn 组件(无旧 UI / HeroUI) | ✅ |
|
||||
| 单文件 ≤600 行 | ✅(最大 `access-logs/page.tsx` 558 行) |
|
||||
| loading/empty/error | ✅ |
|
||||
| RHF + Zod 表单(P1 Dialog) | ✅ |
|
||||
|
||||
**总体结论:PASS**
|
||||
|
||||
## 分模块
|
||||
|
||||
| 模块 | 结论 |
|
||||
|---|---|
|
||||
| Dashboard | ✅ PASS |
|
||||
| Nodes | ⚠️ `node-editor-dialog` 缺 Zod |
|
||||
| Proxy detail | ⚠️ 无效 ID/error 态待统一组件 |
|
||||
| WAF | ⚠️ `rule-entry-dialog` 缺 RHF+Zod |
|
||||
| Websites/Certificates | ⚠️ `dns-account-create-dialog` 缺 Zod |
|
||||
| Access logs | ⚠️ folds/ip-summary Tab 缺 error |
|
||||
| Config versions | ⚠️ cleanup dialog 缺 Zod |
|
||||
| 模块 | 结论 | 说明 |
|
||||
|---|---|---|
|
||||
| Dashboard | ✅ PASS | 标准壳与三态组件 |
|
||||
| Nodes | ✅ PASS | `node-editor-dialog` Zod 已补 |
|
||||
| Proxy detail | ✅ PASS | `ErrorInline` / `EmptyStateWithBorder` 已统一 |
|
||||
| Proxy list | ⚠️ PASS* | 列表 error 仅 toast(P2) |
|
||||
| WAF | ✅ PASS | `rule-entry-dialog` RHF+Zod 已补 |
|
||||
| Websites/Certificates | ✅ PASS | `dns-account-create-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`
|
||||
2. `access-logs/page.tsx` — folds、ip-summary 的 `isError` 分支
|
||||
3. `node-editor-dialog.tsx`、`rule-entry-dialog.tsx` — 补 Zod schema
|
||||
1. `proxy-routes/page-client.tsx` — 列表 error 改用 `ErrorInline`
|
||||
2. `access-logs/components/cleanup-dialog.tsx` — 补 RHF+Zod
|
||||
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