Files
OpenFlare/atsf_server/web/features/nodes/components/node-detail-page.tsx
T
ryan f50eb9adee 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.
2026-03-12 11:25:13 +08:00

805 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect, useMemo, useState } from 'react';
import { useForm, useWatch } from 'react-hook-form';
import { z } from 'zod';
import { EmptyState } from '@/components/feedback/empty-state';
import { ErrorState } from '@/components/feedback/error-state';
import { InlineMessage } from '@/components/feedback/inline-message';
import { LoadingState } from '@/components/feedback/loading-state';
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 { getApplyLogs } from '@/features/apply-logs/api/apply-logs';
import {
deleteNode,
getNodeAgentRelease,
getNodes,
requestNodeAgentUpdate,
updateNode,
} from '@/features/nodes/api/nodes';
import type {
NodeAgentReleaseInfo,
NodeMutationPayload,
} from '@/features/nodes/types';
import {
CodeBlock,
DangerButton,
PrimaryButton,
ResourceField,
ResourceInput,
SecondaryButton,
ToggleField,
} from '@/features/shared/components/resource-primitives';
import type { ReleaseChannel } from '@/features/update/types';
import { formatDateTime, formatRelativeTime } from '@/lib/utils/date';
import {
buildNodeInstallCommand,
getApplyLabel,
getApplyVariant,
getNodeStatusLabel,
getNodeStatusVariant,
getServerUrl,
getUpdateMode,
isMeaningfulTime,
} from '@/features/nodes/utils';
const nodesQueryKey = ['nodes'];
const nodeSchema = z.object({
name: z
.string()
.trim()
.min(1, '请输入节点名')
.max(128, '节点名不能超过 128 个字符'),
auto_update_enabled: z.boolean(),
});
type NodeFormValues = z.infer<typeof nodeSchema>;
type FeedbackState = {
tone: 'info' | 'success' | 'danger';
message: string;
};
const defaultValues: NodeFormValues = {
name: '',
auto_update_enabled: false,
};
function getErrorMessage(error: unknown) {
return error instanceof Error ? error.message : '请求失败,请稍后重试。';
}
function toPayload(values: NodeFormValues): NodeMutationPayload {
return {
name: values.name.trim(),
auto_update_enabled: values.auto_update_enabled,
};
}
async function copyToClipboard(value: string) {
await navigator.clipboard.writeText(value);
}
export function NodeDetailPage({ nodeId }: { nodeId: string }) {
const router = useRouter();
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>({
resolver: zodResolver(nodeSchema),
defaultValues,
});
const watchedAutoUpdate = useWatch({
control: form.control,
name: 'auto_update_enabled',
});
const nodesQuery = useQuery({
queryKey: nodesQueryKey,
queryFn: getNodes,
refetchInterval: 5000,
});
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(() => {
return (
(nodesQuery.data ?? []).find((item) => String(item.id) === nodeId) ?? null
);
}, [nodeId, nodesQuery.data]);
const applyLogsQuery = useQuery({
queryKey: ['apply-logs', node?.node_id ?? ''],
queryFn: () => getApplyLogs(node?.node_id),
enabled: Boolean(node?.node_id),
refetchInterval: 5000,
});
useEffect(() => {
if (typeof window !== 'undefined' && !serverUrl) {
setServerUrl(window.location.origin);
}
}, [serverUrl]);
useEffect(() => {
if (!node || isEditorOpen) {
return;
}
form.reset({
name: node.name,
auto_update_enabled: node.auto_update_enabled,
});
}, [form, isEditorOpen, node]);
const saveMutation = useMutation({
mutationFn: async (values: NodeFormValues) =>
updateNode(Number(nodeId), toPayload(values)),
onSuccess: async (updatedNode) => {
setFeedback({ tone: 'success', message: '节点已更新。' });
setIsEditorOpen(false);
form.reset({
name: updatedNode.name,
auto_update_enabled: updatedNode.auto_update_enabled,
});
await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
},
onError: (error) => {
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
},
});
const updateAgentMutation = useMutation({
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} 下发${updatedNode.update_channel === 'preview' ? '预览版' : '正式版'}更新指令。`,
});
setAgentUpdateFeedback({
tone: 'success',
message: `节点将在下一次心跳后执行${updatedNode.update_channel === 'preview' ? '预览版' : '正式版'} Agent 更新。`,
});
await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
},
onError: (error) => {
const message = getErrorMessage(error);
setFeedback({ tone: 'danger', message });
setAgentUpdateFeedback({ tone: 'danger', message });
},
});
const deleteMutation = useMutation({
mutationFn: () => deleteNode(Number(nodeId)),
onSuccess: async () => {
setFeedback({ tone: 'success', message: '节点已删除。' });
await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
router.push('/node');
},
onError: (error) => {
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
},
});
const handleSubmit = form.handleSubmit((values) => {
setFeedback(null);
saveMutation.mutate(values);
});
const handleDelete = () => {
if (!node) {
return;
}
if (
!window.confirm(
`确认删除节点“${node.name}”吗?删除后该节点需要重新创建并重新接入。`,
)
) {
return;
}
setFeedback(null);
deleteMutation.mutate();
};
const handleCopy = async (value: string, message: string) => {
try {
await copyToClipboard(value);
setFeedback({ tone: 'success', message });
} catch (error) {
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
}
};
if (nodesQuery.isLoading) {
return <LoadingState />;
}
if (nodesQuery.isError) {
return (
<ErrorState
title="节点详情加载失败"
description={getErrorMessage(nodesQuery.error)}
/>
);
}
if (!node) {
return (
<EmptyState
title="节点不存在"
description="该节点可能已被删除,或当前 ID 无法匹配到节点记录。"
/>
);
}
const normalizedServerUrl = getServerUrl(serverUrl);
const nodeInstallCommand =
normalizedServerUrl && node.agent_token
? buildNodeInstallCommand(normalizedServerUrl, node.agent_token)
: '';
const updateMode = getUpdateMode(node);
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">
<PageHeader
title={node.name}
description="节点详情每 5 秒自动刷新一次。部署命令、Node ID、Token、更新模式和最近应用记录统一在这里查看。"
action={
<>
<Link
href="/node"
className="inline-flex items-center justify-center rounded-2xl border border-[var(--border-default)] bg-[var(--control-background)] px-4 py-3 text-sm font-medium text-[var(--foreground-primary)] transition hover:bg-[var(--control-background-hover)]"
>
返回列表
</Link>
<SecondaryButton
type="button"
onClick={() => setIsEditorOpen(true)}
>
编辑节点
</SecondaryButton>
<PrimaryButton
type="button"
onClick={handleOpenAgentUpdateModal}
disabled={updateAgentMutation.isPending}
>
{node.update_requested ? '查看 Agent 更新' : 'Agent 更新'}
</PrimaryButton>
<DangerButton
type="button"
onClick={handleDelete}
disabled={deleteMutation.isPending}
>
删除节点
</DangerButton>
</>
}
/>
{feedback ? (
<InlineMessage tone={feedback.tone} message={feedback.message} />
) : null}
<div className="grid gap-4 xl:grid-cols-4">
<AppCard title="连接状态">
<div className="space-y-3">
<StatusBadge
label={getNodeStatusLabel(node.status)}
variant={getNodeStatusVariant(node.status)}
/>
<p className="text-sm text-[var(--foreground-secondary)]">
最近心跳:
{isMeaningfulTime(node.last_seen_at)
? ` ${formatRelativeTime(
node.last_seen_at,
)} · ${formatDateTime(node.last_seen_at)}`
: ' 暂无'}
</p>
</div>
</AppCard>
<AppCard title="版本信息">
<div className="space-y-2 text-sm text-[var(--foreground-secondary)]">
<p>Agent:{node.agent_version || 'unknown'}</p>
<p>Nginx:{node.nginx_version || 'unknown'}</p>
<p>当前配置:{node.current_version || '未应用'}</p>
</div>
</AppCard>
<AppCard title="更新模式">
<div className="space-y-3">
<StatusBadge
label={updateMode.label}
variant={updateMode.variant}
/>
<p className="text-sm text-[var(--foreground-secondary)]">
{node.update_requested
? `已等待节点在下一次心跳后执行${node.update_channel === 'preview' ? '预览版' : '正式版'}更新。`
: node.auto_update_enabled
? '节点已启用正式版自动更新。'
: '当前仅支持手动触发更新。'}
</p>
</div>
</AppCard>
<AppCard title="最近应用">
<div className="space-y-3">
<StatusBadge
label={getApplyLabel(node.latest_apply_result)}
variant={getApplyVariant(node.latest_apply_result)}
/>
<p className="text-sm text-[var(--foreground-secondary)]">
{isMeaningfulTime(node.latest_apply_at)
? `${formatRelativeTime(
node.latest_apply_at,
)} · ${formatDateTime(node.latest_apply_at)}`
: '暂无应用记录'}
</p>
</div>
</AppCard>
</div>
<div className="grid gap-6 xl:grid-cols-[1.1fr_0.9fr]">
<AppCard
title="节点标识与部署"
description="Node ID、Token 与部署命令不再在列表中展示,统一收敛到详情页。"
action={
nodeInstallCommand ? (
<PrimaryButton
type="button"
onClick={() =>
void handleCopy(
nodeInstallCommand,
'节点专属部署命令已复制。',
)
}
>
复制部署命令
</PrimaryButton>
) : null
}
>
<div className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
Node ID
</p>
<p className="mt-2 text-sm break-all text-[var(--foreground-primary)]">
{node.node_id}
</p>
</div>
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
Agent Token
</p>
<p className="mt-2 text-sm break-all text-[var(--foreground-primary)]">
{node.agent_token || '暂无'}
</p>
</div>
</div>
<ResourceField
label="Server URL"
hint="默认使用当前控制面来源地址,可按需改为外部访问地址。"
>
<ResourceInput
value={serverUrl}
onChange={(event) => setServerUrl(event.target.value)}
/>
</ResourceField>
{nodeInstallCommand ? (
<CodeBlock className="whitespace-pre-wrap">
{nodeInstallCommand}
</CodeBlock>
) : null}
</div>
</AppCard>
<AppCard title="运行信息">
<div className="space-y-4 text-sm text-[var(--foreground-secondary)]">
<div>
<p className="font-medium text-[var(--foreground-primary)]">
IP 地址
</p>
<p className="mt-1">{node.ip || '暂无'}</p>
</div>
<div>
<p className="font-medium text-[var(--foreground-primary)]">
最近错误
</p>
<p className="mt-1 break-words whitespace-pre-wrap">
{node.last_error || '无'}
</p>
</div>
<div>
<p className="font-medium text-[var(--foreground-primary)]">
创建时间
</p>
<p className="mt-1">{formatDateTime(node.created_at)}</p>
</div>
<div>
<p className="font-medium text-[var(--foreground-primary)]">
更新时间
</p>
<p className="mt-1">{formatDateTime(node.updated_at)}</p>
</div>
</div>
</AppCard>
</div>
<AppCard
title="最近应用记录"
description="仅展示当前节点的应用历史。"
action={
<Link
href={`/apply-log?node_id=${encodeURIComponent(node.node_id)}`}
className="inline-flex items-center justify-center rounded-2xl border border-[var(--border-default)] bg-[var(--control-background)] px-4 py-3 text-sm font-medium text-[var(--foreground-primary)] transition hover:bg-[var(--control-background-hover)]"
>
查看完整记录
</Link>
}
>
{applyLogsQuery.isLoading ? (
<LoadingState />
) : applyLogsQuery.isError ? (
<ErrorState
title="应用记录加载失败"
description={getErrorMessage(applyLogsQuery.error)}
/>
) : applyLogs.length === 0 ? (
<EmptyState
title="暂无应用记录"
description="当前节点还没有上报过配置应用结果。"
/>
) : (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-[var(--border-default)] text-left text-sm">
<thead>
<tr className="text-[var(--foreground-secondary)]">
<th className="px-3 py-3 font-medium">版本</th>
<th className="px-3 py-3 font-medium">结果</th>
<th className="px-3 py-3 font-medium">时间</th>
<th className="px-3 py-3 font-medium">消息</th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--border-default)]">
{applyLogs.slice(0, 10).map((log) => (
<tr key={log.id} className="align-top">
<td className="px-3 py-4 text-[var(--foreground-primary)]">
{log.version}
</td>
<td className="px-3 py-4">
<StatusBadge
label={log.result === 'success' ? '成功' : '失败'}
variant={
log.result === 'success' ? 'success' : 'danger'
}
/>
</td>
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
{formatRelativeTime(log.created_at)} ·{' '}
{formatDateTime(log.created_at)}
</td>
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
<p className="max-w-80 break-words whitespace-pre-wrap">
{log.message || '—'}
</p>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</AppCard>
</div>
<AppModal
isOpen={isEditorOpen}
onClose={() => setIsEditorOpen(false)}
title="编辑节点"
description="更新模式和节点名都在详情页维护。"
footer={
<div className="flex flex-wrap justify-end gap-3">
<SecondaryButton
type="button"
onClick={() => setIsEditorOpen(false)}
disabled={saveMutation.isPending}
>
取消
</SecondaryButton>
<PrimaryButton
type="submit"
form="node-detail-editor-form"
disabled={saveMutation.isPending}
>
{saveMutation.isPending ? '保存中...' : '保存修改'}
</PrimaryButton>
</div>
}
>
<form
id="node-detail-editor-form"
className="space-y-5"
onSubmit={handleSubmit}
>
<ResourceField
label="节点名"
hint="示例:shanghai-edge-1"
error={form.formState.errors.name?.message}
>
<ResourceInput
placeholder="shanghai-edge-1"
{...form.register('name')}
/>
</ResourceField>
<ToggleField
label="启用自动更新"
description="开启后 Agent 心跳返回会提示节点自动执行自更新。"
checked={watchedAutoUpdate}
onChange={(checked) =>
form.setValue('auto_update_enabled', checked, {
shouldDirty: true,
shouldValidate: true,
})
}
/>
</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>
</>
);
}