refactor(openflare): remove legacy update API, use admin updater

Drop the migrated /api/v1/openflare/update compatibility layer and point
the OpenFlare upgrade UI at Wavelet's /api/v1/admin/update endpoints.
This commit is contained in:
ryan
2026-06-19 11:17:45 +08:00
parent c69378f0de
commit 53bd451450
16 changed files with 142 additions and 5606 deletions
-1663
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -27,9 +27,10 @@ import {Textarea} from "@/components/ui/textarea"
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 {AdminStatusService} from "@/lib/services/admin"
import {NodeService, OptionService, StatusService, UptimeKumaService,} from "@/lib/services/openflare"
import {VersionUpgradeDialog} from "@/app/(main)/components/version-upgrade-dialog"
import {openflareLatestReleaseQueryKey, openflarePublicStatusQueryKey,} from "@/lib/hooks/use-openflare-server-upgrade"
import {adminUpdateStatusQueryKey, openflarePublicStatusQueryKey,} from "@/lib/hooks/use-openflare-server-upgrade"
import {
agentOptionEntries,
@@ -102,8 +103,8 @@ export function OpenFlareOpsSettings() {
})
const releaseQuery = useQuery({
queryKey: openflareLatestReleaseQueryKey("stable"),
queryFn: () => UpdateService.getLatestRelease("stable"),
queryKey: adminUpdateStatusQueryKey,
queryFn: () => AdminStatusService.getUpdateStatus(),
})
useEffect(() => {
@@ -609,7 +610,7 @@ export function OpenFlareOpsSettings() {
<div>
<CardTitle className="text-base">版本信息</CardTitle>
<CardDescription>
检查 GitHub Release、在线升级或手动上传服务端二进制包。
检查上游 GitHub Release 并升级当前服务。
</CardDescription>
</div>
<Button type="button" size="sm" onClick={() => setVersionDialogOpen(true)}>
@@ -620,12 +621,12 @@ export function OpenFlareOpsSettings() {
<InfoCell label="当前版本" value={statusQuery.data?.version ?? releaseQuery.data?.current_version ?? "—"} />
<InfoCell
label="最新 Release"
value={releaseQuery.data?.tag_name ?? "—"}
value={releaseQuery.data?.latest_version ?? "—"}
/>
<div className="rounded-lg border border-dashed px-3 py-2">
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">更新状态</p>
<div className="mt-2">
{releaseQuery.data?.has_update ? (
{releaseQuery.data?.update_available ? (
<Badge variant="secondary">有新版本</Badge>
) : (
<Badge variant="outline">已是最新</Badge>
@@ -1,71 +0,0 @@
'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>
);
}
@@ -1,6 +1,6 @@
'use client';
import {useEffect, useState} from 'react';
import {useEffect} from 'react';
import {ExternalLink, Loader2} from 'lucide-react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
@@ -8,26 +8,28 @@ 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 {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog';
import {Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle} from '@/components/ui/dialog';
import {useOpenFlareServerUpgrade} from '@/lib/hooks/use-openflare-server-upgrade';
import type {LatestReleaseInfo, ReleaseChannel} from '@/lib/services/openflare';
import type {AppUpdateStatus} from '@/lib/services/admin/types';
import {formatDateTime} from '@/lib/utils';
import {formatRelativeTime} from '@/app/(main)/nodes/components/node-utils';
import {UpgradeLogPanel} from './upgrade-log-panel';
function getUpgradeBadge(release: LatestReleaseInfo | null | undefined) {
if (!release) {
function getUpgradeBadge(update: AppUpdateStatus | null | undefined) {
if (!update) {
return { label: '未检查', variant: 'outline' as const };
}
if (release.in_progress) {
return { label: '升级中', variant: 'secondary' as const };
}
if (release.has_update) {
if (update.update_available) {
return { label: '可升级', variant: 'secondary' as const };
}
return { label: '最新', variant: 'default' as const };
@@ -44,26 +46,15 @@ export function VersionUpgradeDialog({
}) {
const {
currentVersion,
selectedChannel,
release,
uploadedBinary,
update,
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) {
@@ -71,22 +62,8 @@ export function VersionUpgradeDialog({
}
}, [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;
const upgradeBadge = getUpgradeBadge(update);
const isBusy = isChecking || isUpgrading;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
@@ -94,7 +71,7 @@ export function VersionUpgradeDialog({
<DialogHeader>
<DialogTitle>服务端版本</DialogTitle>
<DialogDescription>
默认检查正式版更新;也可检查 preview 发布或手动上传二进制包。升级开始后服务会短暂重启。
检查上游 GitHub Release 并升级当前服务。升级开始后服务会短暂重启。
</DialogDescription>
</DialogHeader>
@@ -117,23 +94,7 @@ export function VersionUpgradeDialog({
<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>
<p className="text-sm font-medium">{update?.latest_version || '未检查'}</p>
{canUpgrade ? (
<Button
type="button"
@@ -142,7 +103,7 @@ export function VersionUpgradeDialog({
disabled={isBusy}
onClick={handleCheckRelease}
>
{isChecking ? '检查中...' : `检查${selectedChannelLabel}`}
{isChecking ? '检查中...' : '检查更新'}
</Button>
) : null}
</CardContent>
@@ -162,240 +123,101 @@ export function VersionUpgradeDialog({
</div>
) : null}
<Tabs defaultValue="online">
<TabsList>
<TabsTrigger value="online">在线升级</TabsTrigger>
{canUpgrade ? <TabsTrigger value="manual">手动升级</TabsTrigger> : null}
{release ? <TabsTrigger value="logs">升级日志</TabsTrigger> : null}
</TabsList>
{!isInitialLoading && !releaseErrorMessage && !update ? (
<div className="rounded-lg border border-dashed px-4 py-8 text-center text-sm text-muted-foreground">
尚未检查更新,点击「检查更新」后展示 GitHub Release 信息。
</div>
) : null}
<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 信息。
{update ? (
<Card className="border-dashed shadow-none py-4 gap-3">
<CardHeader className="px-4 pb-0">
<CardTitle className="text-sm">GitHub Release · {update.latest_version}</CardTitle>
<CardDescription>
{update.published_at
? `发布时间:${formatRelativeTime(update.published_at)} · ${formatDateTime(update.published_at)}`
: '未提供发布时间'}
</CardDescription>
</CardHeader>
<CardContent className="px-4 space-y-4">
<div className="flex flex-wrap items-center gap-2">
<Badge variant={update.update_available ? 'secondary' : 'default'}>
{update.update_available ? '发现新版本' : '已经是最新版本'}
</Badge>
{update.prerelease ? (
<Badge variant="secondary">Preview 发布</Badge>
) : (
<Badge variant="outline">正式发布</Badge>
)}
{!update.can_upgrade ? (
<Badge variant="destructive">当前平台不支持自动升级</Badge>
) : null}
</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]}>
{update.release_notes || '暂无更新说明'}
</ReactMarkdown>
</div>
<div className="prose prose-sm dark:prose-invert max-w-none text-sm">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{release.body || '暂无更新说明'}
</ReactMarkdown>
</div>
{update.release_url ? (
<a
href={update.release_url}
target="_blank"
rel="noreferrer"
className="inline-flex items-center text-sm text-primary hover:underline"
>
查看发布详情
<ExternalLink className="size-3 ml-1" />
</a>
) : null}
{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">
{canUpgrade ? (
<div className="flex justify-end">
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
type="button"
disabled={
!release.has_update ||
release.in_progress ||
!update.update_available ||
isUpgrading ||
!release.upgrade_supported ||
isUploadingBinary ||
isConfirmingManualUpgrade
!update.can_upgrade ||
isBusy
}
onClick={handleUpgrade}
>
{isUpgrading || release.in_progress
? '升级中...'
: selectedChannel === 'preview'
? '升级预览版'
: '升级正式版'}
{isUpgrading ? '升级中...' : '立即升级'}
</Button>
</div>
) : null}
</CardContent>
</Card>
) : null}
</TabsContent>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>升级到 {update.latest_version}?</AlertDialogTitle>
<AlertDialogDescription>
服务将下载并校验 {update.asset_name},随后替换当前二进制并重启。请确保安装目录可写,且服务允许原地重启。
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogAction onClick={handleUpgrade}>确认升级</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
) : null}
{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>
{!update.can_upgrade ? (
<p className="text-sm text-muted-foreground">
{update.current_version === 'dev'
? '开发构建没有可比较的 Release 版本,不能执行自动升级。'
: update.update_available
? '当前平台暂不支持自动替换二进制,请从 Release 页面手动升级。'
: '当前版本无需升级。'}
</p>
) : null}
</CardContent>
</Card>
) : null}
</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};
}
@@ -3,36 +3,13 @@
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;
import {AdminStatusService} from '@/lib/services/admin';
import type {AppUpdateStatus} from '@/lib/services/admin/types';
import {StatusService} from '@/lib/services/openflare';
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 const adminUpdateStatusQueryKey = ['admin', 'update'] as const;
export function useOpenFlareServerUpgrade({
open,
@@ -41,15 +18,8 @@ export function useOpenFlareServerUpgrade({
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);
@@ -59,30 +29,11 @@ export function useOpenFlareServerUpgrade({
enabled: open,
});
const stableReleaseQuery = useQuery({
queryKey: openflareLatestReleaseQueryKey('stable'),
queryFn: () => UpdateService.getLatestRelease('stable'),
const updateQuery = useQuery({
queryKey: adminUpdateStatusQueryKey,
queryFn: () => AdminStatusService.getUpdateStatus(),
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;
},
staleTime: 5 * 60 * 1000,
});
const scheduleUpgradePageReload = useCallback(() => {
@@ -113,236 +64,64 @@ export function useOpenFlareServerUpgrade({
};
}, []);
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();
}
mutationFn: () => AdminStatusService.applyUpdate(),
onSuccess: () => {
scheduleUpgradePageReload();
setFeedback('升级包已校验完成,服务正在重启。');
},
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();
void updateQuery.refetch();
}
}, [canUpgrade, resetTransientState, stableReleaseQuery]);
}, [canUpgrade, resetTransientState, updateQuery]);
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);
}, []);
void updateQuery.refetch();
}, [canUpgrade, updateQuery]);
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;
upgradeMutation.mutate();
}, [upgradeMutation]);
const update = updateQuery.data;
const releaseErrorMessage =
feedback ||
(isSelectedReleaseError
? selectedReleaseError instanceof Error
? selectedReleaseError.message
(updateQuery.isError
? updateQuery.error instanceof Error
? updateQuery.error.message
: '版本检查失败,请稍后重试。'
: undefined);
const currentVersion = statusQuery.data?.version || releaseWithStream?.current_version || 'unknown';
const currentVersion = statusQuery.data?.version || update?.current_version || 'unknown';
return {
currentVersion,
selectedChannel,
release: releaseWithStream,
uploadedBinary,
update,
releaseErrorMessage,
manualStatusMessage,
manualErrorMessage,
isInitialLoading,
isChecking,
isInitialLoading: updateQuery.isLoading && !updateQuery.data && canUpgrade,
isChecking: updateQuery.isFetching,
isUpgrading: upgradeMutation.isPending,
isUploadingBinary: uploadBinaryMutation.isPending,
uploadProgress,
isConfirmingManualUpgrade: confirmManualUpgradeMutation.isPending,
stableRelease: stableReleaseQuery.data,
handleOpen,
handleCheckRelease,
handleChannelChange,
handleUpgrade,
handleUploadBinary,
handleConfirmManualUpgrade,
};
}
}
export type {AppUpdateStatus};
-4
View File
@@ -40,7 +40,6 @@ import {
ProxyRouteService,
StatusService,
TlsCertificateService,
UpdateService,
UptimeKumaService,
WafService,
WebsiteService,
@@ -77,7 +76,6 @@ const services = {
openflareAccessLog: AccessLogService,
openflareOption: OptionService,
openflareUptimeKuma: UptimeKumaService,
openflareUpdate: UpdateService,
openflareStatus: StatusService,
} as const;
@@ -199,7 +197,6 @@ export {
AccessLogService,
OptionService,
StatusService,
UpdateService,
UptimeKumaService,
} from './openflare';
@@ -227,6 +224,5 @@ export type {
OptionItem,
GeoIPLookupResult,
DatabaseCleanupResult,
LatestReleaseInfo,
OpenFlarePublicStatus,
} from './openflare';
@@ -15,7 +15,6 @@ export { OriginService } from './origin.service';
export { AccessLogService } from './access-log.service';
export { OptionService } from './option.service';
export { UptimeKumaService } from './uptimekuma.service';
export { UpdateService } from './update.service';
export { StatusService } from './status.service';
export type {
@@ -93,11 +92,7 @@ export type {
DatabaseCleanupPayload,
DatabaseCleanupResult,
DatabaseCleanupTarget,
LatestReleaseInfo,
OpenFlarePublicStatus,
UpgradeLogItem,
UpgradeStreamSnapshot,
UploadedServerBinaryInfo,
OriginDetail,
OriginItem,
OriginMutationPayload,
@@ -128,7 +123,6 @@ import {DnsAccountService} from './dns-account.service';
import {NodeService} from './node.service';
import {OptionService} from './option.service';
import {StatusService} from './status.service';
import {UpdateService} from './update.service';
import {UptimeKumaService} from './uptimekuma.service';
import {OriginService} from './origin.service';
import {PagesService} from './pages.service';
@@ -153,6 +147,5 @@ export const openflareServices = {
accessLog: AccessLogService,
option: OptionService,
uptimeKuma: UptimeKumaService,
update: UpdateService,
status: StatusService,
} as const;
@@ -696,45 +696,6 @@ export interface OpenFlarePublicStatus {
system_name: string;
}
export interface UpgradeLogItem {
level: string;
message: string;
created_at: string;
}
export interface LatestReleaseInfo {
tag_name: string;
body: string;
html_url: string;
published_at: string;
channel: ReleaseChannel;
prerelease: boolean;
current_version: string;
has_update: boolean;
upgrade_supported: boolean;
in_progress: boolean;
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,131 +0,0 @@
import type {InternalAxiosRequestConfig} from 'axios';
import {getApiBaseUrl} from '@/lib/services/core/config';
import {ApiErrorBase} from '@/lib/services/core/errors';
import type {ApiResponse} from '@/lib/services/core';
import {OpenFlareBaseService} from './base.service';
import type {LatestReleaseInfo, ReleaseChannel, UpgradeStreamSnapshot, UploadedServerBinaryInfo} from './types';
/**
* OpenFlare 服务端升级 API(`/api/v1/openflare/update/*`)。
*/
export class UpdateService extends OpenFlareBaseService {
protected static override readonly basePath: string = '/api/v1/openflare/update';
private static getApiUrl(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.get<LatestReleaseInfo>('/latest-release', { channel });
}
static upgradeServer(channel: ReleaseChannel = 'stable'): Promise<LatestReleaseInfo> {
return this.post<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.post<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.getApiUrl('/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: ApiResponse<UploadedServerBinaryInfo> | null = null;
try {
payload = JSON.parse(
xhr.responseText,
) as ApiResponse<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;
}
resolve(payload.data);
});
xhr.addEventListener('error', () => {
reject(new ApiErrorBase('上传过程中网络连接中断,请检查网络后重试', undefined, 0));
});
xhr.send(formData);
});
}
static confirmManualServerUpgrade(uploadToken: string): Promise<UploadedServerBinaryInfo> {
return this.post<UploadedServerBinaryInfo>('/manual-upgrade', {
upload_token: uploadToken,
});
}
static createUpgradeLogsWebSocket(): WebSocket | null {
if (typeof window === 'undefined') {
return null;
}
const apiUrl = this.getApiUrl('/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;
}
}
}
@@ -1,262 +0,0 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package update
import (
"context"
"errors"
"fmt"
"runtime"
"strings"
"sync"
"time"
"github.com/Rain-kl/Wavelet/internal/apps/admin/updater"
"github.com/Rain-kl/Wavelet/pkg/logger"
)
const (
channelStable = "stable"
channelPreview = "preview"
)
// UpgradeLogRecord is a single upgrade log entry for the legacy update API.
type UpgradeLogRecord struct {
Level string `json:"level"`
Message string `json:"message"`
CreatedAt time.Time `json:"created_at"`
}
// LatestReleaseView mirrors the legacy OpenFlare latest-release payload.
type LatestReleaseView struct {
TagName string `json:"tag_name"`
Body string `json:"body"`
HTMLURL string `json:"html_url"`
PublishedAt string `json:"published_at"`
Channel string `json:"channel"`
Prerelease bool `json:"prerelease"`
CurrentVersion string `json:"current_version"`
HasUpdate bool `json:"has_update"`
UpgradeSupported bool `json:"upgrade_supported"`
InProgress bool `json:"in_progress"`
UpgradeStatus string `json:"upgrade_status"`
UpgradeLogs []UpgradeLogRecord `json:"upgrade_logs"`
}
// StreamSnapshot is pushed over the upgrade logs websocket.
type StreamSnapshot struct {
InProgress bool `json:"in_progress"`
UpgradeStatus string `json:"upgrade_status"`
UpgradeLogs []UpgradeLogRecord `json:"upgrade_logs"`
}
type upgradeRequest struct {
Channel string `json:"channel"`
}
var upgradeState struct {
sync.Mutex
inProgress bool
status string
logs []UpgradeLogRecord
}
var upgradeSubscribers struct {
sync.Mutex
nextID int
listeners map[int]chan StreamSnapshot
}
func init() {
upgradeSubscribers.listeners = make(map[int]chan StreamSnapshot)
upgradeState.status = "idle"
}
func normalizeChannel(channel string) string {
switch strings.ToLower(strings.TrimSpace(channel)) {
case channelPreview:
return channelPreview
default:
return channelStable
}
}
func isDevBuild(version string) bool {
version = strings.TrimSpace(version)
return version == "" || strings.EqualFold(version, "dev")
}
func mapStatusToLatestRelease(status updater.Status, channel string) *LatestReleaseView {
inProgress, upgradeStatus, logs := snapshotUpgradeState()
view := &LatestReleaseView{
TagName: status.LatestVersion,
Body: status.ReleaseNotes,
HTMLURL: status.ReleaseURL,
PublishedAt: status.PublishedAt,
Channel: channel,
Prerelease: status.Prerelease,
CurrentVersion: status.CurrentVersion,
HasUpdate: status.UpdateAvailable,
UpgradeSupported: !isDevBuild(status.CurrentVersion) && runtime.GOOS != "windows",
InProgress: inProgress || updater.IsUpgrading(),
UpgradeStatus: upgradeStatus,
UpgradeLogs: logs,
}
if channel == channelPreview && status.Prerelease {
view.HasUpdate = !isDevBuild(status.CurrentVersion)
}
return view
}
// GetLatestRelease returns the newest upstream release for the requested channel.
func GetLatestRelease(ctx context.Context, channel string) (*LatestReleaseView, error) {
normalizedChannel := normalizeChannel(channel)
status, err := updater.GetStatus(ctx)
if err != nil {
return nil, err
}
return mapStatusToLatestRelease(status, normalizedChannel), nil
}
// ScheduleUpgrade downloads the latest release and restarts with the staged binary.
func ScheduleUpgrade(ctx context.Context, channel string) (*LatestReleaseView, error) {
normalizedChannel := normalizeChannel(channel)
upgradeState.Lock()
if upgradeState.inProgress || updater.IsUpgrading() {
upgradeState.Unlock()
return nil, errors.New("服务升级正在执行中,请稍后再试")
}
resetUpgradeLogsLocked()
upgradeState.inProgress = true
upgradeState.status = "running"
appendUpgradeLogLocked("info", fmt.Sprintf("Automatic upgrade scheduled for channel: %s.", normalizedChannel))
upgradeState.Unlock()
broadcastUpgradeSnapshot()
executable, stagedBinary, status, err := updater.PrepareUpgrade(ctx)
if err != nil {
recordUpgradeFailure(err)
return nil, err
}
view := mapStatusToLatestRelease(status, normalizedChannel)
view.InProgress = true
view.UpgradeStatus = "running"
view.UpgradeLogs = snapshotUpgradeLogs()
appendUpgradeLogLocked("info", fmt.Sprintf("Upgrade package prepared: %s.", status.LatestVersion))
broadcastUpgradeSnapshot()
go func() {
time.Sleep(time.Second)
if err := updater.ApplyPreparedUpgrade(executable, stagedBinary); err != nil {
updater.FinishUpgrade()
recordUpgradeFailure(err)
logger.ErrorF(context.Background(), "[Update] replace and restart failed: %v", err)
}
}()
return view, nil
}
// UploadManualBinary is disabled in the legacy OpenFlare server.
func UploadManualBinary() error {
return errors.New("手动升级功能已禁用")
}
// ConfirmManualUpgrade is disabled in the legacy OpenFlare server.
func ConfirmManualUpgrade() error {
return errors.New("手动升级功能已禁用")
}
// SubscribeUpgradeStream registers a listener for upgrade websocket snapshots.
func SubscribeUpgradeStream() (<-chan StreamSnapshot, func()) {
upgradeSubscribers.Lock()
defer upgradeSubscribers.Unlock()
id := upgradeSubscribers.nextID
upgradeSubscribers.nextID++
ch := make(chan StreamSnapshot, 1)
upgradeSubscribers.listeners[id] = ch
unsubscribe := func() {
upgradeSubscribers.Lock()
defer upgradeSubscribers.Unlock()
if listener, ok := upgradeSubscribers.listeners[id]; ok {
delete(upgradeSubscribers.listeners, id)
close(listener)
}
}
select {
case ch <- currentUpgradeSnapshot():
default:
}
return ch, unsubscribe
}
func snapshotUpgradeState() (bool, string, []UpgradeLogRecord) {
upgradeState.Lock()
defer upgradeState.Unlock()
return upgradeState.inProgress || updater.IsUpgrading(), upgradeState.status, cloneUpgradeLogsLocked()
}
func snapshotUpgradeLogs() []UpgradeLogRecord {
upgradeState.Lock()
defer upgradeState.Unlock()
return cloneUpgradeLogsLocked()
}
func currentUpgradeSnapshot() StreamSnapshot {
inProgress, status, logs := snapshotUpgradeState()
return StreamSnapshot{
InProgress: inProgress,
UpgradeStatus: status,
UpgradeLogs: logs,
}
}
func resetUpgradeLogsLocked() {
upgradeState.logs = nil
}
func appendUpgradeLogLocked(level, message string) {
upgradeState.logs = append(upgradeState.logs, UpgradeLogRecord{
Level: level,
Message: message,
CreatedAt: time.Now().UTC(),
})
}
func cloneUpgradeLogsLocked() []UpgradeLogRecord {
if len(upgradeState.logs) == 0 {
return []UpgradeLogRecord{}
}
cloned := make([]UpgradeLogRecord, len(upgradeState.logs))
copy(cloned, upgradeState.logs)
return cloned
}
func recordUpgradeFailure(err error) {
upgradeState.Lock()
upgradeState.inProgress = false
upgradeState.status = "failed"
appendUpgradeLogLocked("error", err.Error())
upgradeState.Unlock()
broadcastUpgradeSnapshot()
}
func broadcastUpgradeSnapshot() {
snapshot := currentUpgradeSnapshot()
upgradeSubscribers.Lock()
defer upgradeSubscribers.Unlock()
for _, listener := range upgradeSubscribers.listeners {
select {
case listener <- snapshot:
default:
}
}
}
@@ -1,157 +0,0 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package update
import (
"encoding/json"
"errors"
"io"
"net/http"
"time"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/apiutil"
"github.com/Rain-kl/Wavelet/internal/common/response"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
)
var upgradeLogsUpgrader = websocket.Upgrader{
CheckOrigin: func(_ *http.Request) bool { return true },
}
// GetLatestReleaseHandler 获取最新 GitHub 发布版本。
// @Summary 获取最新服务端发布版本
// @Description 查询 OpenFlare 服务端最新 GitHub Release 及升级状态,需要管理员权限
// @Tags openflare-update
// @Produce json
// @Security SessionCookie
// @Param channel query string false "发布渠道(stable/preview)"
// @Success 200 {object} response.Any{data=update.LatestReleaseView} "最新发布信息"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 403 {object} response.Any "无管理员权限"
// @Failure 500 {object} response.Any "内部错误"
// @Router /api/v1/openflare/update/latest-release [get]
func GetLatestReleaseHandler(c *gin.Context) {
release, err := GetLatestRelease(c.Request.Context(), c.Query("channel"))
if apiutil.AbortBadRequestOnError(c, err) {
return
}
c.JSON(http.StatusOK, response.OK(release))
}
// UpgradeServerHandler 调度自动升级任务。
// @Summary 触发服务端自动升级
// @Description 从最新 Release 调度 OpenFlare 服务端自动升级,需要管理员权限
// @Tags openflare-update
// @Accept json
// @Produce json
// @Security SessionCookie
// @Param request body update.upgradeRequest false "升级参数"
// @Success 200 {object} response.Any{data=update.LatestReleaseView} "升级任务已调度"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 403 {object} response.Any "无管理员权限"
// @Failure 500 {object} response.Any "内部错误"
// @Router /api/v1/openflare/update/upgrade [post]
func UpgradeServerHandler(c *gin.Context) {
var request upgradeRequest
if err := bindOptionalJSON(c.Request.Body, &request); err != nil {
response.AbortBadRequest(c, "无效的参数")
return
}
release, err := ScheduleUpgrade(c.Request.Context(), request.Channel)
if apiutil.AbortBadRequestOnError(c, err) {
return
}
c.JSON(http.StatusOK, response.OK(release))
}
// UploadManualServerBinaryHandler 上传手动升级二进制(已禁用)。
// @Summary 上传手动升级二进制
// @Description 上传服务端二进制以进行手动升级(当前功能已禁用),需要管理员权限
// @Tags openflare-update
// @Accept multipart/form-data
// @Produce json
// @Security SessionCookie
// @Param binary formData file true "服务端二进制文件"
// @Failure 400 {object} response.Any "功能已禁用或参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 403 {object} response.Any "无管理员权限"
// @Router /api/v1/openflare/update/manual-upload [post]
func UploadManualServerBinaryHandler(c *gin.Context) {
if apiutil.AbortBadRequestOnError(c, UploadManualBinary()) {
return
}
}
// ConfirmManualServerUpgradeHandler 确认手动升级(已禁用)。
// @Summary 确认手动服务端升级
// @Description 确认并执行手动上传的服务端升级(当前功能已禁用),需要管理员权限
// @Tags openflare-update
// @Accept json
// @Produce json
// @Security SessionCookie
// @Failure 400 {object} response.Any "功能已禁用或参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 403 {object} response.Any "无管理员权限"
// @Router /api/v1/openflare/update/manual-upgrade [post]
func ConfirmManualServerUpgradeHandler(c *gin.Context) {
if apiutil.AbortBadRequestOnError(c, ConfirmManualUpgrade()) {
return
}
}
// StreamServerUpgradeLogsHandler 通过 WebSocket 推送升级日志。
// @Summary 流式获取服务端升级日志
// @Description 通过 WebSocket 推送升级进度快照,需要管理员权限
// @Tags openflare-update
// @Security SessionCookie
// @Success 101 {object} update.StreamSnapshot "WebSocket 升级日志流"
// @Failure 401 {object} response.Any "未登录"
// @Failure 403 {object} response.Any "无管理员权限"
// @Router /api/v1/openflare/update/logs/ws [get]
func StreamServerUpgradeLogsHandler(c *gin.Context) {
conn, err := upgradeLogsUpgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
return
}
defer func() {
_ = conn.Close()
}()
updates, unsubscribe := SubscribeUpgradeStream()
defer unsubscribe()
heartbeatTicker := time.NewTicker(15 * time.Second)
defer heartbeatTicker.Stop()
for {
select {
case snapshot, ok := <-updates:
if !ok {
return
}
if err := conn.WriteJSON(snapshot); err != nil {
return
}
case <-heartbeatTicker.C:
if err := conn.WriteJSON(StreamSnapshot{}); err != nil {
return
}
case <-c.Request.Context().Done():
return
}
}
}
func bindOptionalJSON(body io.Reader, target any) error {
if err := json.NewDecoder(body).Decode(target); err != nil && !errors.Is(err, io.EOF) {
return err
}
return nil
}
@@ -1,22 +0,0 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package openflare
import (
"github.com/Rain-kl/Wavelet/internal/apps/openflare/apiutil"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/update"
"github.com/gin-gonic/gin"
)
func registerUpdateRoutes(apiGroup *gin.RouterGroup) {
updateRoute := apiGroup.Group("/update")
updateRoute.Use(apiutil.AdminRequired())
{
updateRoute.GET("/latest-release", update.GetLatestReleaseHandler)
updateRoute.GET("/logs/ws", update.StreamServerUpgradeLogsHandler)
updateRoute.POST("/manual-upload", update.UploadManualServerBinaryHandler)
updateRoute.POST("/manual-upgrade", update.ConfirmManualServerUpgradeHandler)
updateRoute.POST("/upgrade", update.UpgradeServerHandler)
}
}
@@ -22,5 +22,4 @@ func RegisterV1Routes(apiV1Router *gin.RouterGroup) {
registerPagesRoutes(group)
registerDashboardRoutes(group)
registerObservabilityRoutes(group)
registerUpdateRoutes(group)
}
+1
View File
@@ -19,6 +19,7 @@ sidebar: false
### 移除
- 移除 Wavelet 中从旧系统迁移但未实装的全局 API / Web / 敏感接口限流选项(`GlobalApiRateLimit*`、`GlobalWebRateLimit*`、`CriticalRateLimit*`)及相关校验与数据库种子。
- 移除旧 OpenFlare 服务升级兼容层(`/api/v1/openflare/update/*`);统一使用 Wavelet 框架 `admin/updater`(`/api/v1/admin/update`)。
### 新增