mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-04 15:06:37 +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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user