mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-06 23:56:37 +08:00
feat: add support for release channels in version upgrade and node agent updates
- Introduced ReleaseChannel type to manage stable and preview releases. - Updated DashboardTopbar to handle version upgrades based on selected release channel. - Enhanced node detail page to allow manual checks for agent updates on stable and preview channels. - Modified API endpoints to support fetching and upgrading based on release channels. - Updated UI components to reflect changes in version checking and upgrade processes. - Added tests for new functionality related to preview releases and agent updates.
This commit is contained in:
@@ -14,7 +14,10 @@ import {
|
||||
uploadServerBinary,
|
||||
} from '@/features/update/api/update';
|
||||
import { VersionUpgradeModal } from '@/features/update/components/version-upgrade-modal';
|
||||
import type { UploadedServerBinaryInfo } from '@/features/update/types';
|
||||
import type {
|
||||
ReleaseChannel,
|
||||
UploadedServerBinaryInfo,
|
||||
} from '@/features/update/types';
|
||||
import { publicEnv } from '@/lib/env/public-env';
|
||||
import { useAppShellStore } from '@/store/app-shell';
|
||||
|
||||
@@ -31,6 +34,8 @@ export function DashboardTopbar() {
|
||||
const [isLoggingOut, setIsLoggingOut] = useState(false);
|
||||
const [isUserMenuOpen, setIsUserMenuOpen] = useState(false);
|
||||
const [isVersionModalOpen, setIsVersionModalOpen] = useState(false);
|
||||
const [selectedReleaseChannel, setSelectedReleaseChannel] =
|
||||
useState<ReleaseChannel>('stable');
|
||||
const [versionFeedback, setVersionFeedback] = useState<string | null>(null);
|
||||
const [manualUpgradeStatus, setManualUpgradeStatus] = useState<string | null>(
|
||||
null,
|
||||
@@ -48,23 +53,32 @@ export function DashboardTopbar() {
|
||||
queryFn: getPublicStatus,
|
||||
});
|
||||
|
||||
const latestReleaseQuery = useQuery({
|
||||
queryKey: ['update', 'latest-release'],
|
||||
queryFn: getLatestRelease,
|
||||
const stableReleaseQuery = useQuery({
|
||||
queryKey: ['update', 'latest-release', 'stable'],
|
||||
queryFn: () => getLatestRelease('stable'),
|
||||
enabled: isRoot,
|
||||
refetchInterval: 60 * 60 * 1000,
|
||||
});
|
||||
|
||||
const previewReleaseQuery = useQuery({
|
||||
queryKey: ['update', 'latest-release', 'preview'],
|
||||
queryFn: () => getLatestRelease('preview'),
|
||||
enabled: false,
|
||||
});
|
||||
|
||||
const upgradeMutation = useMutation({
|
||||
mutationFn: upgradeServer,
|
||||
mutationFn: (channel: ReleaseChannel) => upgradeServer(channel),
|
||||
onSuccess: (release) => {
|
||||
setUploadedBinary(null);
|
||||
setManualUpgradeStatus(null);
|
||||
setManualUpgradeError(null);
|
||||
setVersionFeedback(
|
||||
`服务升级任务已启动,目标版本 ${release.tag_name}。页面可能短暂不可用。`,
|
||||
`服务升级任务已启动,目标版本 ${release.tag_name}(${release.channel === 'preview' ? '预览版' : '正式版'})。页面可能短暂不可用。`,
|
||||
);
|
||||
void latestReleaseQuery.refetch();
|
||||
void stableReleaseQuery.refetch();
|
||||
if (release.channel === 'preview') {
|
||||
void previewReleaseQuery.refetch();
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
setVersionFeedback(
|
||||
@@ -99,7 +113,8 @@ export function DashboardTopbar() {
|
||||
setManualUpgradeStatus(
|
||||
`手动升级任务已启动,目标版本 ${candidate.detected_version}。页面可能短暂不可用。`,
|
||||
);
|
||||
void latestReleaseQuery.refetch();
|
||||
void stableReleaseQuery.refetch();
|
||||
void previewReleaseQuery.refetch();
|
||||
},
|
||||
onError: (error) => {
|
||||
setManualUpgradeStatus(null);
|
||||
@@ -154,12 +169,13 @@ export function DashboardTopbar() {
|
||||
};
|
||||
|
||||
const handleOpenVersionModal = () => {
|
||||
setSelectedReleaseChannel('stable');
|
||||
setVersionFeedback(null);
|
||||
setManualUpgradeStatus(null);
|
||||
setManualUpgradeError(null);
|
||||
setIsVersionModalOpen(true);
|
||||
if (isRoot) {
|
||||
void latestReleaseQuery.refetch();
|
||||
void stableReleaseQuery.refetch();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -167,7 +183,23 @@ export function DashboardTopbar() {
|
||||
setVersionFeedback(null);
|
||||
setManualUpgradeStatus(null);
|
||||
setManualUpgradeError(null);
|
||||
upgradeMutation.mutate();
|
||||
upgradeMutation.mutate(selectedReleaseChannel);
|
||||
};
|
||||
|
||||
const handleCheckStableRelease = () => {
|
||||
setSelectedReleaseChannel('stable');
|
||||
setVersionFeedback(null);
|
||||
if (isRoot) {
|
||||
void stableReleaseQuery.refetch();
|
||||
}
|
||||
};
|
||||
|
||||
const handleCheckPreviewRelease = () => {
|
||||
setSelectedReleaseChannel('preview');
|
||||
setVersionFeedback(null);
|
||||
if (isRoot) {
|
||||
void previewReleaseQuery.refetch();
|
||||
}
|
||||
};
|
||||
|
||||
const handleUploadBinary = (binary: File) => {
|
||||
@@ -188,8 +220,19 @@ export function DashboardTopbar() {
|
||||
confirmManualUpgradeMutation.mutate(uploadedBinary.upload_token);
|
||||
};
|
||||
|
||||
const release = latestReleaseQuery.data;
|
||||
const hasUpdate = Boolean(isRoot && release?.has_update);
|
||||
const selectedRelease =
|
||||
selectedReleaseChannel === 'preview'
|
||||
? previewReleaseQuery.data
|
||||
: stableReleaseQuery.data;
|
||||
const selectedReleaseError =
|
||||
selectedReleaseChannel === 'preview'
|
||||
? previewReleaseQuery.error
|
||||
: stableReleaseQuery.error;
|
||||
const isSelectedReleaseError =
|
||||
selectedReleaseChannel === 'preview'
|
||||
? previewReleaseQuery.isError
|
||||
: stableReleaseQuery.isError;
|
||||
const hasUpdate = Boolean(isRoot && stableReleaseQuery.data?.has_update);
|
||||
const currentVersion = publicStatusQuery.data?.version || 'unknown';
|
||||
const versionLabel = hasUpdate
|
||||
? `版本 ${publicEnv.appVersion} · 可升级`
|
||||
@@ -199,9 +242,9 @@ export function DashboardTopbar() {
|
||||
: 'border-[var(--border-default)]';
|
||||
const versionErrorMessage =
|
||||
versionFeedback ||
|
||||
(latestReleaseQuery.isError
|
||||
? latestReleaseQuery.error instanceof Error
|
||||
? latestReleaseQuery.error.message
|
||||
(isSelectedReleaseError
|
||||
? selectedReleaseError instanceof Error
|
||||
? selectedReleaseError.message
|
||||
: '版本检查失败,请稍后重试。'
|
||||
: undefined);
|
||||
const manualUpgradeErrorMessage = manualUpgradeError ?? undefined;
|
||||
@@ -283,23 +326,29 @@ export function DashboardTopbar() {
|
||||
currentVersion={currentVersion}
|
||||
frontendVersion={publicEnv.appVersion}
|
||||
startTime={publicStatusQuery.data?.start_time}
|
||||
release={release}
|
||||
release={selectedRelease}
|
||||
selectedChannel={selectedReleaseChannel}
|
||||
uploadedBinary={uploadedBinary}
|
||||
isLoading={latestReleaseQuery.isLoading && !release && isRoot}
|
||||
isLoading={
|
||||
(selectedReleaseChannel === 'preview'
|
||||
? previewReleaseQuery.isLoading && !previewReleaseQuery.data
|
||||
: stableReleaseQuery.isLoading && !stableReleaseQuery.data) &&
|
||||
isRoot
|
||||
}
|
||||
releaseErrorMessage={versionErrorMessage}
|
||||
manualStatusMessage={manualUpgradeStatus ?? undefined}
|
||||
manualErrorMessage={manualUpgradeErrorMessage}
|
||||
canUpgrade={isRoot}
|
||||
isChecking={latestReleaseQuery.isFetching}
|
||||
isChecking={
|
||||
selectedReleaseChannel === 'preview'
|
||||
? previewReleaseQuery.isFetching
|
||||
: stableReleaseQuery.isFetching
|
||||
}
|
||||
isUpgrading={upgradeMutation.isPending}
|
||||
isUploadingBinary={uploadBinaryMutation.isPending}
|
||||
isConfirmingManualUpgrade={confirmManualUpgradeMutation.isPending}
|
||||
onRefresh={() => {
|
||||
setVersionFeedback(null);
|
||||
if (isRoot) {
|
||||
void latestReleaseQuery.refetch();
|
||||
}
|
||||
}}
|
||||
onCheckStable={handleCheckStableRelease}
|
||||
onCheckPreview={handleCheckPreviewRelease}
|
||||
onUpgrade={handleUpgrade}
|
||||
onUploadBinary={handleUploadBinary}
|
||||
onConfirmManualUpgrade={handleConfirmManualUpgrade}
|
||||
|
||||
@@ -1,47 +1,63 @@
|
||||
import { apiRequest } from '@/lib/api/client';
|
||||
|
||||
import type {
|
||||
NodeBootstrapToken,
|
||||
NodeItem,
|
||||
NodeMutationPayload,
|
||||
} from '@/features/nodes/types';
|
||||
|
||||
export function getNodes() {
|
||||
return apiRequest<NodeItem[]>('/nodes/');
|
||||
}
|
||||
|
||||
export function createNode(payload: NodeMutationPayload) {
|
||||
return apiRequest<NodeItem>('/nodes/', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function updateNode(id: number, payload: NodeMutationPayload) {
|
||||
return apiRequest<NodeItem>(`/nodes/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteNode(id: number) {
|
||||
return apiRequest<void>(`/nodes/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
export function getNodeBootstrapToken() {
|
||||
return apiRequest<NodeBootstrapToken>('/nodes/bootstrap-token');
|
||||
}
|
||||
|
||||
export function rotateNodeBootstrapToken() {
|
||||
return apiRequest<NodeBootstrapToken>('/nodes/bootstrap-token/rotate', {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
export function requestNodeAgentUpdate(id: number) {
|
||||
return apiRequest<NodeItem>(`/nodes/${id}/agent-update`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
import { apiRequest } from '@/lib/api/client';
|
||||
|
||||
import type {
|
||||
NodeAgentReleaseInfo,
|
||||
NodeAgentUpdatePayload,
|
||||
NodeBootstrapToken,
|
||||
NodeItem,
|
||||
NodeMutationPayload,
|
||||
} from '@/features/nodes/types';
|
||||
import type { ReleaseChannel } from '@/features/update/types';
|
||||
|
||||
export function getNodes() {
|
||||
return apiRequest<NodeItem[]>('/nodes/');
|
||||
}
|
||||
|
||||
export function createNode(payload: NodeMutationPayload) {
|
||||
return apiRequest<NodeItem>('/nodes/', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function updateNode(id: number, payload: NodeMutationPayload) {
|
||||
return apiRequest<NodeItem>(`/nodes/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteNode(id: number) {
|
||||
return apiRequest<void>(`/nodes/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
export function getNodeBootstrapToken() {
|
||||
return apiRequest<NodeBootstrapToken>('/nodes/bootstrap-token');
|
||||
}
|
||||
|
||||
export function rotateNodeBootstrapToken() {
|
||||
return apiRequest<NodeBootstrapToken>('/nodes/bootstrap-token/rotate', {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
export function getNodeAgentRelease(
|
||||
id: number,
|
||||
channel: ReleaseChannel = 'stable',
|
||||
) {
|
||||
return apiRequest<NodeAgentReleaseInfo>(
|
||||
`/nodes/${id}/agent-release?channel=${channel}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function requestNodeAgentUpdate(
|
||||
id: number,
|
||||
payload?: NodeAgentUpdatePayload,
|
||||
) {
|
||||
return apiRequest<NodeItem>(`/nodes/${id}/agent-update`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload ?? {}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -16,15 +16,18 @@ import { PageHeader } from '@/components/layout/page-header';
|
||||
import { AppModal } from '@/components/ui/app-modal';
|
||||
import { AppCard } from '@/components/ui/app-card';
|
||||
import { StatusBadge } from '@/components/ui/status-badge';
|
||||
import { getPublicStatus } from '@/features/auth/api/public';
|
||||
import { getApplyLogs } from '@/features/apply-logs/api/apply-logs';
|
||||
import {
|
||||
deleteNode,
|
||||
getNodeAgentRelease,
|
||||
getNodes,
|
||||
requestNodeAgentUpdate,
|
||||
updateNode,
|
||||
} from '@/features/nodes/api/nodes';
|
||||
import type { NodeMutationPayload } from '@/features/nodes/types';
|
||||
import type {
|
||||
NodeAgentReleaseInfo,
|
||||
NodeMutationPayload,
|
||||
} from '@/features/nodes/types';
|
||||
import {
|
||||
CodeBlock,
|
||||
DangerButton,
|
||||
@@ -34,6 +37,7 @@ import {
|
||||
SecondaryButton,
|
||||
ToggleField,
|
||||
} from '@/features/shared/components/resource-primitives';
|
||||
import type { ReleaseChannel } from '@/features/update/types';
|
||||
import { formatDateTime, formatRelativeTime } from '@/lib/utils/date';
|
||||
import {
|
||||
buildNodeInstallCommand,
|
||||
@@ -44,7 +48,6 @@ import {
|
||||
getServerUrl,
|
||||
getUpdateMode,
|
||||
isMeaningfulTime,
|
||||
shouldShowManualUpdate,
|
||||
} from '@/features/nodes/utils';
|
||||
|
||||
const nodesQueryKey = ['nodes'];
|
||||
@@ -90,6 +93,11 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
|
||||
const queryClient = useQueryClient();
|
||||
const [feedback, setFeedback] = useState<FeedbackState | null>(null);
|
||||
const [isEditorOpen, setIsEditorOpen] = useState(false);
|
||||
const [isAgentUpdateModalOpen, setIsAgentUpdateModalOpen] = useState(false);
|
||||
const [selectedReleaseChannel, setSelectedReleaseChannel] =
|
||||
useState<ReleaseChannel>('stable');
|
||||
const [agentUpdateFeedback, setAgentUpdateFeedback] =
|
||||
useState<FeedbackState | null>(null);
|
||||
const [serverUrl, setServerUrl] = useState('');
|
||||
|
||||
const form = useForm<NodeFormValues>({
|
||||
@@ -108,9 +116,16 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
|
||||
refetchInterval: 5000,
|
||||
});
|
||||
|
||||
const publicStatusQuery = useQuery({
|
||||
queryKey: ['public-status'],
|
||||
queryFn: getPublicStatus,
|
||||
const stableAgentReleaseQuery = useQuery({
|
||||
queryKey: ['node-agent-release', nodeId, 'stable'],
|
||||
queryFn: () => getNodeAgentRelease(Number(nodeId), 'stable'),
|
||||
enabled: false,
|
||||
});
|
||||
|
||||
const previewAgentReleaseQuery = useQuery({
|
||||
queryKey: ['node-agent-release', nodeId, 'preview'],
|
||||
queryFn: () => getNodeAgentRelease(Number(nodeId), 'preview'),
|
||||
enabled: false,
|
||||
});
|
||||
|
||||
const node = useMemo(() => {
|
||||
@@ -161,16 +176,29 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
|
||||
});
|
||||
|
||||
const updateAgentMutation = useMutation({
|
||||
mutationFn: () => requestNodeAgentUpdate(Number(nodeId)),
|
||||
mutationFn: (release: NodeAgentReleaseInfo | null) =>
|
||||
requestNodeAgentUpdate(Number(nodeId), {
|
||||
channel: release?.channel ?? selectedReleaseChannel,
|
||||
tag_name:
|
||||
release?.channel === 'preview'
|
||||
? release.tag_name || undefined
|
||||
: undefined,
|
||||
}),
|
||||
onSuccess: async (updatedNode) => {
|
||||
setFeedback({
|
||||
tone: 'success',
|
||||
message: `已向节点 ${updatedNode.name} 下发更新指令。`,
|
||||
message: `已向节点 ${updatedNode.name} 下发${updatedNode.update_channel === 'preview' ? '预览版' : '正式版'}更新指令。`,
|
||||
});
|
||||
setAgentUpdateFeedback({
|
||||
tone: 'success',
|
||||
message: `节点将在下一次心跳后执行${updatedNode.update_channel === 'preview' ? '预览版' : '正式版'} Agent 更新。`,
|
||||
});
|
||||
await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
|
||||
},
|
||||
onError: (error) => {
|
||||
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
|
||||
const message = getErrorMessage(error);
|
||||
setFeedback({ tone: 'danger', message });
|
||||
setAgentUpdateFeedback({ tone: 'danger', message });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -245,13 +273,43 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
|
||||
? buildNodeInstallCommand(normalizedServerUrl, node.agent_token)
|
||||
: '';
|
||||
const updateMode = getUpdateMode(node);
|
||||
const serverVersion = publicStatusQuery.data?.version ?? '';
|
||||
const showManualUpdate = shouldShowManualUpdate(
|
||||
node.agent_version || '',
|
||||
serverVersion,
|
||||
);
|
||||
const selectedAgentRelease =
|
||||
selectedReleaseChannel === 'preview'
|
||||
? previewAgentReleaseQuery.data
|
||||
: stableAgentReleaseQuery.data;
|
||||
const selectedAgentReleaseError =
|
||||
selectedReleaseChannel === 'preview'
|
||||
? previewAgentReleaseQuery.error
|
||||
: stableAgentReleaseQuery.error;
|
||||
const isCheckingAgentRelease =
|
||||
selectedReleaseChannel === 'preview'
|
||||
? previewAgentReleaseQuery.isFetching
|
||||
: stableAgentReleaseQuery.isFetching;
|
||||
const applyLogs = applyLogsQuery.data ?? [];
|
||||
|
||||
const handleOpenAgentUpdateModal = () => {
|
||||
setAgentUpdateFeedback(null);
|
||||
setSelectedReleaseChannel('stable');
|
||||
setIsAgentUpdateModalOpen(true);
|
||||
void stableAgentReleaseQuery.refetch();
|
||||
};
|
||||
|
||||
const handleCheckStableAgentRelease = () => {
|
||||
setAgentUpdateFeedback(null);
|
||||
setSelectedReleaseChannel('stable');
|
||||
void stableAgentReleaseQuery.refetch();
|
||||
};
|
||||
|
||||
const handleCheckPreviewAgentRelease = () => {
|
||||
setAgentUpdateFeedback(null);
|
||||
setSelectedReleaseChannel('preview');
|
||||
void previewAgentReleaseQuery.refetch();
|
||||
};
|
||||
|
||||
const handleRequestAgentUpdate = () => {
|
||||
updateAgentMutation.mutate(selectedAgentRelease ?? null);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-6">
|
||||
@@ -272,17 +330,13 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
|
||||
>
|
||||
编辑节点
|
||||
</SecondaryButton>
|
||||
{showManualUpdate ? (
|
||||
<PrimaryButton
|
||||
type="button"
|
||||
onClick={() => updateAgentMutation.mutate()}
|
||||
disabled={
|
||||
updateAgentMutation.isPending || node.update_requested
|
||||
}
|
||||
>
|
||||
{node.update_requested ? '等待更新中...' : '手动升级 Agent'}
|
||||
</PrimaryButton>
|
||||
) : null}
|
||||
<PrimaryButton
|
||||
type="button"
|
||||
onClick={handleOpenAgentUpdateModal}
|
||||
disabled={updateAgentMutation.isPending}
|
||||
>
|
||||
{node.update_requested ? '查看 Agent 更新' : 'Agent 更新'}
|
||||
</PrimaryButton>
|
||||
<DangerButton
|
||||
type="button"
|
||||
onClick={handleDelete}
|
||||
@@ -332,9 +386,9 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
|
||||
/>
|
||||
<p className="text-sm text-[var(--foreground-secondary)]">
|
||||
{node.update_requested
|
||||
? '已等待节点在下一次心跳后执行更新。'
|
||||
? `已等待节点在下一次心跳后执行${node.update_channel === 'preview' ? '预览版' : '正式版'}更新。`
|
||||
: node.auto_update_enabled
|
||||
? '节点已启用自动更新。'
|
||||
? '节点已启用正式版自动更新。'
|
||||
: '当前仅支持手动触发更新。'}
|
||||
</p>
|
||||
</div>
|
||||
@@ -567,6 +621,184 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
|
||||
/>
|
||||
</form>
|
||||
</AppModal>
|
||||
|
||||
<AppModal
|
||||
isOpen={isAgentUpdateModalOpen}
|
||||
onClose={() => setIsAgentUpdateModalOpen(false)}
|
||||
title="Agent 更新"
|
||||
description="默认检查正式版;你也可以手动检查 preview 发布,并选择向当前节点下发对应版本的升级指令。"
|
||||
footer={
|
||||
<div className="flex flex-wrap justify-end gap-3">
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
onClick={handleCheckStableAgentRelease}
|
||||
disabled={isCheckingAgentRelease || updateAgentMutation.isPending}
|
||||
>
|
||||
{isCheckingAgentRelease && selectedReleaseChannel === 'stable'
|
||||
? '检查中...'
|
||||
: '检查正式版'}
|
||||
</SecondaryButton>
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
onClick={handleCheckPreviewAgentRelease}
|
||||
disabled={isCheckingAgentRelease || updateAgentMutation.isPending}
|
||||
>
|
||||
{isCheckingAgentRelease && selectedReleaseChannel === 'preview'
|
||||
? '检查中...'
|
||||
: '检查预览版'}
|
||||
</SecondaryButton>
|
||||
<PrimaryButton
|
||||
type="button"
|
||||
onClick={handleRequestAgentUpdate}
|
||||
disabled={
|
||||
!selectedAgentRelease?.has_update ||
|
||||
updateAgentMutation.isPending ||
|
||||
isCheckingAgentRelease ||
|
||||
node.update_requested
|
||||
}
|
||||
>
|
||||
{updateAgentMutation.isPending
|
||||
? '下发中...'
|
||||
: selectedReleaseChannel === 'preview'
|
||||
? '升级到预览版'
|
||||
: '升级到正式版'}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-6">
|
||||
{agentUpdateFeedback ? (
|
||||
<InlineMessage
|
||||
tone={agentUpdateFeedback.tone}
|
||||
message={agentUpdateFeedback.message}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<AppCard title="当前 Agent 版本">
|
||||
<p className="text-sm font-medium text-[var(--foreground-primary)]">
|
||||
{node.agent_version || 'unknown'}
|
||||
</p>
|
||||
</AppCard>
|
||||
<AppCard title="检查通道">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<p className="text-sm font-medium text-[var(--foreground-primary)]">
|
||||
{selectedReleaseChannel === 'preview' ? '预览版' : '正式版'}
|
||||
</p>
|
||||
<StatusBadge
|
||||
label={
|
||||
selectedReleaseChannel === 'preview' ? 'Preview' : 'Stable'
|
||||
}
|
||||
variant={
|
||||
selectedReleaseChannel === 'preview' ? 'warning' : 'info'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</AppCard>
|
||||
<AppCard title="更新状态">
|
||||
<StatusBadge
|
||||
label={
|
||||
node.update_requested
|
||||
? node.update_channel === 'preview'
|
||||
? '等待预览更新'
|
||||
: '等待更新'
|
||||
: '未下发'
|
||||
}
|
||||
variant={node.update_requested ? 'warning' : 'info'}
|
||||
/>
|
||||
</AppCard>
|
||||
</div>
|
||||
|
||||
{isCheckingAgentRelease && !selectedAgentRelease ? (
|
||||
<LoadingState />
|
||||
) : null}
|
||||
{!isCheckingAgentRelease && selectedAgentReleaseError ? (
|
||||
<ErrorState
|
||||
title="Agent 版本检查失败"
|
||||
description={getErrorMessage(selectedAgentReleaseError)}
|
||||
/>
|
||||
) : null}
|
||||
{!isCheckingAgentRelease &&
|
||||
!selectedAgentReleaseError &&
|
||||
!selectedAgentRelease ? (
|
||||
<EmptyState
|
||||
title="尚未检查 Agent 更新"
|
||||
description="点击“检查正式版”或“检查预览版”后,会在这里显示对应发布信息。"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{selectedAgentRelease ? (
|
||||
<AppCard
|
||||
title={`GitHub ${selectedReleaseChannel === 'preview' ? '预览版' : '正式版'} · ${selectedAgentRelease.tag_name || '未找到版本'}`}
|
||||
description={
|
||||
selectedAgentRelease.published_at
|
||||
? `发布时间:${formatRelativeTime(selectedAgentRelease.published_at)} · ${formatDateTime(selectedAgentRelease.published_at)}`
|
||||
: '未提供发布时间'
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<StatusBadge
|
||||
label={
|
||||
selectedAgentRelease.has_update
|
||||
? '发现可升级版本'
|
||||
: '当前已是最新版本'
|
||||
}
|
||||
variant={
|
||||
selectedAgentRelease.has_update ? 'warning' : 'success'
|
||||
}
|
||||
/>
|
||||
{selectedAgentRelease.prerelease ? (
|
||||
<StatusBadge label="Preview 发布" variant="warning" />
|
||||
) : (
|
||||
<StatusBadge label="正式发布" variant="info" />
|
||||
)}
|
||||
{node.update_requested ? (
|
||||
<StatusBadge
|
||||
label={`已下发${node.update_channel === 'preview' ? '预览版' : '正式版'}更新`}
|
||||
variant="warning"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<p className="text-xs text-[var(--foreground-secondary)]">
|
||||
当前版本
|
||||
</p>
|
||||
<p className="mt-1 text-sm font-medium text-[var(--foreground-primary)]">
|
||||
{selectedAgentRelease.current_version || 'unknown'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-[var(--foreground-secondary)]">
|
||||
目标版本
|
||||
</p>
|
||||
<p className="mt-1 text-sm font-medium text-[var(--foreground-primary)]">
|
||||
{selectedAgentRelease.tag_name || '未找到'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4 text-sm leading-6 whitespace-pre-wrap text-[var(--foreground-secondary)]">
|
||||
{selectedAgentRelease.body || '暂无更新说明'}
|
||||
</div>
|
||||
|
||||
{selectedAgentRelease.html_url ? (
|
||||
<a
|
||||
href={selectedAgentRelease.html_url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-sm font-medium text-[var(--brand-primary)] transition hover:opacity-80"
|
||||
>
|
||||
查看发布详情
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
</AppCard>
|
||||
) : null}
|
||||
</div>
|
||||
</AppModal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,29 +1,52 @@
|
||||
export interface NodeItem {
|
||||
id: number;
|
||||
node_id: string;
|
||||
name: string;
|
||||
ip: string;
|
||||
agent_token: string;
|
||||
auto_update_enabled: boolean;
|
||||
update_requested: boolean;
|
||||
agent_version: string;
|
||||
nginx_version: string;
|
||||
status: 'online' | 'offline' | 'pending';
|
||||
current_version: string;
|
||||
last_seen_at: string;
|
||||
last_error: string;
|
||||
latest_apply_result: 'success' | 'failed' | '';
|
||||
latest_apply_message: string;
|
||||
latest_apply_at?: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface NodeBootstrapToken {
|
||||
discovery_token: string;
|
||||
}
|
||||
|
||||
export interface NodeMutationPayload {
|
||||
name: string;
|
||||
auto_update_enabled: boolean;
|
||||
}
|
||||
import type { ReleaseChannel } from '@/features/update/types';
|
||||
|
||||
export interface NodeItem {
|
||||
id: number;
|
||||
node_id: string;
|
||||
name: string;
|
||||
ip: string;
|
||||
agent_token: string;
|
||||
auto_update_enabled: boolean;
|
||||
update_requested: boolean;
|
||||
update_channel: ReleaseChannel;
|
||||
update_tag: string;
|
||||
agent_version: string;
|
||||
nginx_version: string;
|
||||
status: 'online' | 'offline' | 'pending';
|
||||
current_version: string;
|
||||
last_seen_at: string;
|
||||
last_error: string;
|
||||
latest_apply_result: 'success' | 'failed' | '';
|
||||
latest_apply_message: string;
|
||||
latest_apply_at?: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface NodeBootstrapToken {
|
||||
discovery_token: string;
|
||||
}
|
||||
|
||||
export interface NodeMutationPayload {
|
||||
name: string;
|
||||
auto_update_enabled: boolean;
|
||||
}
|
||||
|
||||
export interface NodeAgentReleaseInfo {
|
||||
tag_name: string;
|
||||
body: string;
|
||||
html_url: string;
|
||||
published_at: string;
|
||||
current_version: string;
|
||||
has_update: boolean;
|
||||
channel: ReleaseChannel;
|
||||
prerelease: boolean;
|
||||
update_requested: boolean;
|
||||
requested_channel: ReleaseChannel;
|
||||
requested_tag: string;
|
||||
}
|
||||
|
||||
export interface NodeAgentUpdatePayload {
|
||||
channel?: ReleaseChannel;
|
||||
tag_name?: string;
|
||||
}
|
||||
|
||||
@@ -54,6 +54,10 @@ export function getApplyLabel(result: NodeItem['latest_apply_result']) {
|
||||
|
||||
export function getUpdateMode(node: NodeItem) {
|
||||
if (node.update_requested) {
|
||||
if (node.update_channel === 'preview') {
|
||||
return { label: '等待预览更新', variant: 'warning' as const };
|
||||
}
|
||||
|
||||
return { label: '等待更新', variant: 'warning' as const };
|
||||
}
|
||||
|
||||
|
||||
@@ -2,16 +2,20 @@ import { apiRequest } from '@/lib/api/client';
|
||||
|
||||
import type {
|
||||
LatestReleaseInfo,
|
||||
ReleaseChannel,
|
||||
UploadedServerBinaryInfo,
|
||||
} from '@/features/update/types';
|
||||
|
||||
export function getLatestRelease() {
|
||||
return apiRequest<LatestReleaseInfo>('/update/latest-release');
|
||||
export function getLatestRelease(channel: ReleaseChannel = 'stable') {
|
||||
return apiRequest<LatestReleaseInfo>(
|
||||
`/update/latest-release?channel=${channel}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function upgradeServer() {
|
||||
export function upgradeServer(channel: ReleaseChannel = 'stable') {
|
||||
return apiRequest<LatestReleaseInfo>('/update/upgrade', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ channel }),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import { AppModal } from '@/components/ui/app-modal';
|
||||
import { StatusBadge } from '@/components/ui/status-badge';
|
||||
import type {
|
||||
LatestReleaseInfo,
|
||||
ReleaseChannel,
|
||||
UploadedServerBinaryInfo,
|
||||
} from '@/features/update/types';
|
||||
import {
|
||||
@@ -28,6 +29,7 @@ interface VersionUpgradeModalProps {
|
||||
frontendVersion: string;
|
||||
startTime?: number;
|
||||
release: LatestReleaseInfo | null | undefined;
|
||||
selectedChannel: ReleaseChannel;
|
||||
uploadedBinary: UploadedServerBinaryInfo | null;
|
||||
isLoading: boolean;
|
||||
releaseErrorMessage?: string;
|
||||
@@ -38,7 +40,8 @@ interface VersionUpgradeModalProps {
|
||||
isUpgrading: boolean;
|
||||
isUploadingBinary: boolean;
|
||||
isConfirmingManualUpgrade: boolean;
|
||||
onRefresh: () => void;
|
||||
onCheckStable: () => void;
|
||||
onCheckPreview: () => void;
|
||||
onUpgrade: () => void;
|
||||
onUploadBinary: (file: File) => void;
|
||||
onConfirmManualUpgrade: () => void;
|
||||
@@ -64,6 +67,7 @@ export function VersionUpgradeModal({
|
||||
frontendVersion,
|
||||
startTime,
|
||||
release,
|
||||
selectedChannel,
|
||||
uploadedBinary,
|
||||
isLoading,
|
||||
releaseErrorMessage,
|
||||
@@ -74,13 +78,16 @@ export function VersionUpgradeModal({
|
||||
isUpgrading,
|
||||
isUploadingBinary,
|
||||
isConfirmingManualUpgrade,
|
||||
onRefresh,
|
||||
onCheckStable,
|
||||
onCheckPreview,
|
||||
onUpgrade,
|
||||
onUploadBinary,
|
||||
onConfirmManualUpgrade,
|
||||
}: VersionUpgradeModalProps) {
|
||||
const upgradeBadge = getUpgradeBadge(release);
|
||||
const [selectedBinary, setSelectedBinary] = useState<File | null>(null);
|
||||
const selectedChannelLabel =
|
||||
selectedChannel === 'preview' ? '预览版' : '正式版';
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
@@ -93,17 +100,28 @@ export function VersionUpgradeModal({
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title="版本"
|
||||
description="在这里检查 GitHub 最新版本,或手动上传 Server 二进制并确认升级。升级开始后服务会短暂重启。"
|
||||
description="默认检查正式版更新;你也可以手动检查 preview 发布并选择升级,或上传 Server 二进制确认升级。升级开始后服务会短暂重启。"
|
||||
size="lg"
|
||||
footer={
|
||||
canUpgrade ? (
|
||||
<div className="flex flex-wrap justify-end gap-3">
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
onClick={onRefresh}
|
||||
onClick={onCheckStable}
|
||||
disabled={isChecking || isUpgrading || isUploadingBinary}
|
||||
>
|
||||
{isChecking ? '检查中...' : '检查更新'}
|
||||
{isChecking && selectedChannel === 'stable'
|
||||
? '检查中...'
|
||||
: '检查正式版'}
|
||||
</SecondaryButton>
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
onClick={onCheckPreview}
|
||||
disabled={isChecking || isUpgrading || isUploadingBinary}
|
||||
>
|
||||
{isChecking && selectedChannel === 'preview'
|
||||
? '检查中...'
|
||||
: '检查预览版'}
|
||||
</SecondaryButton>
|
||||
<PrimaryButton
|
||||
type="button"
|
||||
@@ -149,7 +167,9 @@ export function VersionUpgradeModal({
|
||||
? '升级中...'
|
||||
: release?.in_progress
|
||||
? '升级中...'
|
||||
: '立即升级'}
|
||||
: selectedChannel === 'preview'
|
||||
? '升级到预览版'
|
||||
: '升级到正式版'}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
) : undefined
|
||||
@@ -174,9 +194,15 @@ export function VersionUpgradeModal({
|
||||
</div>
|
||||
</AppCard>
|
||||
<AppCard title="最新版本">
|
||||
<p className="text-sm font-medium text-[var(--foreground-primary)]">
|
||||
{release?.tag_name || '未检查'}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<p className="text-sm font-medium text-[var(--foreground-primary)]">
|
||||
{release?.tag_name || '未检查'}
|
||||
</p>
|
||||
<StatusBadge
|
||||
label={selectedChannelLabel}
|
||||
variant={selectedChannel === 'preview' ? 'warning' : 'info'}
|
||||
/>
|
||||
</div>
|
||||
</AppCard>
|
||||
<AppCard title="启动时间">
|
||||
<p className="text-sm font-medium text-[var(--foreground-primary)]">
|
||||
@@ -191,13 +217,13 @@ export function VersionUpgradeModal({
|
||||
) : null}
|
||||
{!isLoading && !releaseErrorMessage && !release ? (
|
||||
<EmptyState
|
||||
title="尚未检查更新"
|
||||
description="点击“检查更新”后会在这里展示最新 GitHub Release 信息。"
|
||||
title={`尚未检查${selectedChannelLabel}`}
|
||||
description={`点击“检查${selectedChannelLabel}”后会在这里展示对应 GitHub Release 信息。`}
|
||||
/>
|
||||
) : null}
|
||||
{!isLoading && !releaseErrorMessage && release ? (
|
||||
<AppCard
|
||||
title={`GitHub Release · ${release.tag_name}`}
|
||||
title={`GitHub ${selectedChannelLabel} · ${release.tag_name}`}
|
||||
description={
|
||||
release.published_at
|
||||
? `发布时间:${formatRelativeTime(release.published_at)} · ${formatDateTime(release.published_at)}`
|
||||
@@ -210,6 +236,11 @@ export function VersionUpgradeModal({
|
||||
label={release.has_update ? '发现新版本' : '已经是最新版本'}
|
||||
variant={release.has_update ? 'warning' : 'success'}
|
||||
/>
|
||||
{release.prerelease ? (
|
||||
<StatusBadge label="Preview 发布" variant="warning" />
|
||||
) : (
|
||||
<StatusBadge label="正式发布" variant="info" />
|
||||
)}
|
||||
{!release.upgrade_supported ? (
|
||||
<StatusBadge
|
||||
label="当前平台不支持自动升级"
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
export type ReleaseChannel = 'stable' | 'preview';
|
||||
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user