feat: 增强更新功能,支持开发版本的自我升级检查;优化仪表板和应用日志页面的用户界面

This commit is contained in:
ryan
2026-03-11 22:19:27 +08:00
parent 6140ed1718
commit c240838692
7 changed files with 359 additions and 249 deletions
+3 -2
View File
@@ -124,8 +124,9 @@ func fetchLatestRelease(ctx context.Context) (*githubReleaseResponse, error) {
func buildLatestServerReleaseView(release *githubReleaseResponse) *LatestServerRelease { func buildLatestServerReleaseView(release *githubReleaseResponse) *LatestServerRelease {
currentVersion := strings.TrimSpace(common.Version) currentVersion := strings.TrimSpace(common.Version)
isDevBuild := currentVersion == "" || strings.EqualFold(currentVersion, "dev")
hasUpdate := false hasUpdate := false
if release != nil { if release != nil && !isDevBuild {
hasUpdate = isVersionNewer(currentVersion, release.TagName) hasUpdate = isVersionNewer(currentVersion, release.TagName)
} }
@@ -136,7 +137,7 @@ func buildLatestServerReleaseView(release *githubReleaseResponse) *LatestServerR
view := &LatestServerRelease{ view := &LatestServerRelease{
CurrentVersion: currentVersion, CurrentVersion: currentVersion,
HasUpdate: hasUpdate, HasUpdate: hasUpdate,
UpgradeSupported: runtime.GOOS != "windows", UpgradeSupported: !isDevBuild && runtime.GOOS != "windows",
InProgress: inProgress, InProgress: inProgress,
} }
if release != nil { if release != nil {
+22
View File
@@ -63,3 +63,25 @@ func TestBuildLatestServerReleaseView(t *testing.T) {
t.Fatalf("unexpected tag name: %s", view.TagName) t.Fatalf("unexpected tag name: %s", view.TagName)
} }
} }
func TestBuildLatestServerReleaseViewDevBuild(t *testing.T) {
originalVersion := common.Version
common.Version = "dev"
t.Cleanup(func() {
common.Version = originalVersion
serverUpgradeState.Lock()
serverUpgradeState.inProgress = false
serverUpgradeState.Unlock()
})
view := buildLatestServerReleaseView(&githubReleaseResponse{
TagName: "v0.5.0",
})
if view.HasUpdate {
t.Fatal("expected dev build not to report update availability")
}
if view.UpgradeSupported {
t.Fatal("expected dev build not to support self-upgrade")
}
}
+4 -4
View File
@@ -1,12 +1,12 @@
import { DashboardOverview } from '@/features/dashboard/components/dashboard-overview'; import { DashboardOverview } from '@/features/dashboard/components/dashboard-overview';
import { PageHeader } from '@/components/layout/page-header'; import { PageHeader } from '@/components/layout/page-header';
export default function DashboardPage() { export default function DashboardPage() {
return ( return (
<div className='space-y-6'> <div className='space-y-6'>
<PageHeader <PageHeader
title='ATSFlare 管理端' title='ATSFlare 管理端'
description='新版前端已统一承接全部页面入口和迁移期兼容路由,当前可继续推进联调与回归。' description='集中展示系统核心数据概览,快速导航至各功能模块进行管理和配置。'
/> />
<DashboardOverview /> <DashboardOverview />
</div> </div>
@@ -1,253 +1,259 @@
'use client'; 'use client';
import { useEffect } from 'react'; import {useEffect} from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { usePathname } from 'next/navigation'; import {usePathname} from 'next/navigation';
import { dashboardNavigation } from '@/lib/constants/navigation'; import {dashboardNavigation} from '@/lib/constants/navigation';
import { cn } from '@/lib/utils/cn'; import {cn} from '@/lib/utils/cn';
import { isNavigationItemActive } from '@/lib/utils/navigation'; import {isNavigationItemActive} from '@/lib/utils/navigation';
import { useAppShellStore } from '@/store/app-shell'; import {useAppShellStore} from '@/store/app-shell';
import type { NavigationIconKey, NavigationItem } from '@/types/navigation'; import type {NavigationIconKey, NavigationItem} from '@/types/navigation';
function SidebarIcon({ icon }: { icon: NavigationIconKey }) { function SidebarIcon({icon}: { icon: NavigationIconKey }) {
const commonProps = { const commonProps = {
className: 'h-[18px] w-[18px]', className: 'h-[18px] w-[18px]',
fill: 'none', fill: 'none',
stroke: 'currentColor', stroke: 'currentColor',
strokeWidth: 1.8, strokeWidth: 1.8,
strokeLinecap: 'round' as const, strokeLinecap: 'round' as const,
strokeLinejoin: 'round' as const, strokeLinejoin: 'round' as const,
viewBox: '0 0 24 24', viewBox: '0 0 24 24',
}; };
switch (icon) { switch (icon) {
case 'home': case 'home':
return ( return (
<svg {...commonProps}> <svg {...commonProps}>
<path d='M3 10.5 12 3l9 7.5' /> <path d='M3 10.5 12 3l9 7.5'/>
<path d='M5.5 9.5V21h13V9.5' /> <path d='M5.5 9.5V21h13V9.5'/>
<path d='M9.5 21v-6h5v6' /> <path d='M9.5 21v-6h5v6'/>
</svg> </svg>
); );
case 'node': case 'node':
return ( return (
<svg {...commonProps}> <svg {...commonProps}>
<rect x='4' y='4' width='6' height='6' rx='1.5' /> <rect x='4' y='4' width='6' height='6' rx='1.5'/>
<rect x='14' y='4' width='6' height='6' rx='1.5' /> <rect x='14' y='4' width='6' height='6' rx='1.5'/>
<rect x='9' y='14' width='6' height='6' rx='1.5' /> <rect x='9' y='14' width='6' height='6' rx='1.5'/>
<path d='M10 7h4M12 10v4' /> <path d='M10 7h4M12 10v4'/>
</svg> </svg>
); );
case 'website': case 'website':
return ( return (
<svg {...commonProps}> <svg {...commonProps}>
<circle cx='12' cy='12' r='8.5' /> <circle cx='12' cy='12' r='8.5'/>
<path d='M3.5 12h17' /> <path d='M3.5 12h17'/>
<path d='M12 3.5c2.4 2.2 3.8 5.2 3.8 8.5S14.4 18.3 12 20.5C9.6 18.3 8.2 15.3 8.2 12S9.6 5.7 12 3.5Z' /> <path
</svg> d='M12 3.5c2.4 2.2 3.8 5.2 3.8 8.5S14.4 18.3 12 20.5C9.6 18.3 8.2 15.3 8.2 12S9.6 5.7 12 3.5Z'/>
); </svg>
case 'domain': );
return ( case 'domain':
<svg {...commonProps}> return (
<path d='M4 8.5h16' /> <svg {...commonProps}>
<path d='M4 15.5h16' /> <path d='M4 8.5h16'/>
<rect x='3.5' y='5' width='17' height='14' rx='3' /> <path d='M4 15.5h16'/>
</svg> <rect x='3.5' y='5' width='17' height='14' rx='3'/>
); </svg>
case 'certificate': );
return ( case 'certificate':
<svg {...commonProps}> return (
<path d='M7 4.5h10l2 2V14a7 7 0 1 1-14 0V4.5Z' /> <svg {...commonProps}>
<path d='M9 10h6' /> <path d='M7 4.5h10l2 2V14a7 7 0 1 1-14 0V4.5Z'/>
<path d='M10 14h4' /> <path d='M9 10h6'/>
</svg> <path d='M10 14h4'/>
); </svg>
case 'proxy': );
return ( case 'proxy':
<svg {...commonProps}> return (
<path d='M7 7h10' /> <svg {...commonProps}>
<path d='M7 12h10' /> <path d='M7 7h10'/>
<path d='M7 17h6' /> <path d='M7 12h10'/>
<path d='m14 15 3 2-3 2' /> <path d='M7 17h6'/>
</svg> <path d='m14 15 3 2-3 2'/>
); </svg>
case 'release': );
return ( case 'release':
<svg {...commonProps}> return (
<path d='M12 4v10' /> <svg {...commonProps}>
<path d='m8.5 7.5 3.5-3.5 3.5 3.5' /> <path d='M12 4v10'/>
<path d='M5 19.5h14' /> <path d='m8.5 7.5 3.5-3.5 3.5 3.5'/>
</svg> <path d='M5 19.5h14'/>
); </svg>
case 'user': );
return ( case 'user':
<svg {...commonProps}> return (
<circle cx='12' cy='8' r='3.25' /> <svg {...commonProps}>
<path d='M5 19.5c1.7-3 4.1-4.5 7-4.5s5.3 1.5 7 4.5' /> <circle cx='12' cy='8' r='3.25'/>
</svg> <path d='M5 19.5c1.7-3 4.1-4.5 7-4.5s5.3 1.5 7 4.5'/>
); </svg>
case 'setting': );
return ( case 'setting':
<svg {...commonProps}> return (
<circle cx='12' cy='12' r='3.5' /> <svg {...commonProps}>
<path d='M12 3.5v2.2M12 18.3v2.2M20.5 12h-2.2M5.7 12H3.5M18.1 5.9l-1.6 1.6M7.5 16.5l-1.6 1.6M18.1 18.1l-1.6-1.6M7.5 7.5 5.9 5.9' /> <circle cx='12' cy='12' r='3.5'/>
</svg> <path
); d='M12 3.5v2.2M12 18.3v2.2M20.5 12h-2.2M5.7 12H3.5M18.1 5.9l-1.6 1.6M7.5 16.5l-1.6 1.6M18.1 18.1l-1.6-1.6M7.5 7.5 5.9 5.9'/>
} </svg>
);
}
} }
function SidebarNavItem({ function SidebarNavItem({
item, item,
currentPath, currentPath,
isSidebarCollapsed, isSidebarCollapsed,
forceExpanded, forceExpanded,
onNavigate, onNavigate,
depth = 0, depth = 0,
}: { }: {
item: NavigationItem; item: NavigationItem;
currentPath: string; currentPath: string;
isSidebarCollapsed: boolean; isSidebarCollapsed: boolean;
forceExpanded?: boolean; forceExpanded?: boolean;
onNavigate?: () => void; onNavigate?: () => void;
depth?: number; depth?: number;
}) { }) {
const active = isNavigationItemActive(currentPath, item); const active = isNavigationItemActive(currentPath, item);
const hasChildren = Boolean(item.children?.length); const hasChildren = Boolean(item.children?.length);
const showLabel = forceExpanded || !isSidebarCollapsed; const showLabel = forceExpanded || !isSidebarCollapsed;
return ( return (
<div className='space-y-2'>
<Link
href={item.href}
onClick={onNavigate}
className={cn(
'flex min-h-[50px] items-center gap-3 rounded-2xl border px-3 py-2.5 transition-colors',
depth > 0 && 'ml-3 rounded-xl',
active
? 'border-[var(--border-strong)] bg-[var(--accent-soft)] text-[var(--foreground-primary)]'
: 'border-transparent text-[var(--foreground-secondary)] hover:border-[var(--border-default)] hover:bg-[var(--surface-muted)] hover:text-[var(--foreground-primary)]',
)}
>
<span className='flex h-8 w-8 shrink-0 items-center justify-center rounded-xl bg-[var(--control-background)] text-[var(--foreground-primary)]'>
<SidebarIcon icon={item.icon} />
</span>
{showLabel ? <span className='min-w-0 flex-1 text-sm font-medium'>{item.label}</span> : null}
</Link>
{showLabel && hasChildren ? (
<div className='space-y-2'> <div className='space-y-2'>
{item.children?.map((child) => ( <Link
<SidebarNavItem href={item.href}
key={child.href} onClick={onNavigate}
item={child} className={cn(
currentPath={currentPath} 'flex min-h-[50px] items-center gap-3 rounded-2xl border px-3 py-2.5 transition-colors',
isSidebarCollapsed={isSidebarCollapsed} depth > 0 && 'ml-3 rounded-xl',
forceExpanded={forceExpanded} active
onNavigate={onNavigate} ? 'border-[var(--border-strong)] bg-[var(--accent-soft)] text-[var(--foreground-primary)]'
depth={depth + 1} : 'border-transparent text-[var(--foreground-secondary)] hover:border-[var(--border-default)] hover:bg-[var(--surface-muted)] hover:text-[var(--foreground-primary)]',
/> )}
))} >
<span
className='flex h-8 w-8 shrink-0 items-center justify-center rounded-xl bg-[var(--control-background)] text-[var(--foreground-primary)]'>
<SidebarIcon icon={item.icon}/>
</span>
{showLabel ? <span className='min-w-0 flex-1 text-sm font-medium'>{item.label}</span> : null}
</Link>
{showLabel && hasChildren ? (
<div className='space-y-2'>
{item.children?.map((child) => (
<SidebarNavItem
key={child.href}
item={child}
currentPath={currentPath}
isSidebarCollapsed={isSidebarCollapsed}
forceExpanded={forceExpanded}
onNavigate={onNavigate}
depth={depth + 1}
/>
))}
</div>
) : null}
</div> </div>
) : null} );
</div>
);
} }
function SidebarContent({ function SidebarContent({
currentPath, currentPath,
isSidebarCollapsed, isSidebarCollapsed,
forceExpanded = false, forceExpanded = false,
onNavigate, onNavigate,
}: { }: {
currentPath: string; currentPath: string;
isSidebarCollapsed: boolean; isSidebarCollapsed: boolean;
forceExpanded?: boolean; forceExpanded?: boolean;
onNavigate?: () => void; onNavigate?: () => void;
}) { }) {
const showLabel = forceExpanded || !isSidebarCollapsed; const showLabel = forceExpanded || !isSidebarCollapsed;
return ( return (
<div className='flex h-full flex-col gap-5'> <div className='flex h-full flex-col gap-5'>
<div className='flex items-center gap-3 rounded-2xl border border-[var(--border-default)] bg-[var(--surface-muted)] px-3 py-3'> <div
<div className='flex h-10 w-10 items-center justify-center rounded-2xl bg-[var(--brand-primary-soft)] text-sm font-semibold text-[var(--brand-primary)]'> className='flex items-center gap-3 rounded-2xl border border-[var(--border-default)] bg-[var(--surface-muted)] px-3 py-3'>
AF <div
</div> className='flex h-10 w-10 items-center justify-center rounded-2xl bg-[var(--brand-primary-soft)] text-sm font-semibold text-[var(--brand-primary)]'>
{showLabel ? ( AF
<div> </div>
<p className='text-sm font-semibold text-[var(--foreground-primary)]'>ATSFlare</p> {showLabel ? (
<p className='text-xs text-[var(--foreground-secondary)]'>控制面</p> <div>
</div> <p className='text-sm font-semibold text-[var(--foreground-primary)]'>ATSFlare</p>
) : null} <p className='text-xs text-[var(--foreground-secondary)]'>控制面</p>
</div> </div>
) : null}
</div>
<nav className='flex-1 space-y-2'> <nav className='flex-1 space-y-2'>
<div className='flex max-h-full min-h-0 flex-col gap-2 overflow-y-auto pr-1'> <div className='flex max-h-full min-h-0 flex-col gap-2 overflow-y-auto pr-1'>
{dashboardNavigation.map((item) => ( {dashboardNavigation.map((item) => (
<SidebarNavItem <SidebarNavItem
key={item.href} key={item.href}
item={item} item={item}
currentPath={currentPath} currentPath={currentPath}
isSidebarCollapsed={isSidebarCollapsed} isSidebarCollapsed={isSidebarCollapsed}
forceExpanded={forceExpanded} forceExpanded={forceExpanded}
onNavigate={onNavigate} onNavigate={onNavigate}
/> />
))} ))}
</div> </div>
</nav> </nav>
{showLabel ? ( {showLabel ? (
<div className='rounded-2xl border border-[var(--status-success-border)] bg-[var(--status-success-soft)] px-3 py-3 text-xs leading-6 text-[var(--status-success-foreground)]'> <div
总览页现在承接各模块摘要,侧栏只保留稳定导航。 className='rounded-2xl border border-[var(--status-success-border)] bg-[var(--status-success-soft)] px-3 py-3 text-xs leading-6 text-[var(--status-success-foreground)]'>
已连接 ATSFlare Server
</div>
) : null}
</div> </div>
) : null} );
</div>
);
} }
export function DashboardSidebar() { export function DashboardSidebar() {
const pathname = usePathname(); const pathname = usePathname();
const currentPath = pathname ?? '/'; const currentPath = pathname ?? '/';
const isSidebarCollapsed = useAppShellStore((state) => state.isSidebarCollapsed); const isSidebarCollapsed = useAppShellStore((state) => state.isSidebarCollapsed);
const isMobileSidebarOpen = useAppShellStore((state) => state.isMobileSidebarOpen); const isMobileSidebarOpen = useAppShellStore((state) => state.isMobileSidebarOpen);
const setMobileSidebarOpen = useAppShellStore((state) => state.setMobileSidebarOpen); const setMobileSidebarOpen = useAppShellStore((state) => state.setMobileSidebarOpen);
useEffect(() => { useEffect(() => {
setMobileSidebarOpen(false); setMobileSidebarOpen(false);
}, [currentPath, setMobileSidebarOpen]); }, [currentPath, setMobileSidebarOpen]);
return ( return (
<> <>
<div <div
className={cn( className={cn(
'fixed inset-0 z-30 bg-black/35 transition-opacity duration-200 min-[1000px]:hidden', 'fixed inset-0 z-30 bg-black/35 transition-opacity duration-200 min-[1000px]:hidden',
isMobileSidebarOpen ? 'opacity-100' : 'pointer-events-none opacity-0', isMobileSidebarOpen ? 'opacity-100' : 'pointer-events-none opacity-0',
)} )}
onClick={() => setMobileSidebarOpen(false)} onClick={() => setMobileSidebarOpen(false)}
aria-hidden='true' aria-hidden='true'
/> />
<aside <aside
className={cn( className={cn(
'fixed left-0 top-0 z-40 h-screen w-[200px] overflow-hidden border-r border-[var(--border-default)] bg-[var(--surface-panel)]/95 px-3 py-5 backdrop-blur transition-transform duration-200 min-[1000px]:hidden', 'fixed left-0 top-0 z-40 h-screen w-[200px] overflow-hidden border-r border-[var(--border-default)] bg-[var(--surface-panel)]/95 px-3 py-5 backdrop-blur transition-transform duration-200 min-[1000px]:hidden',
isMobileSidebarOpen ? 'translate-x-0' : '-translate-x-full', isMobileSidebarOpen ? 'translate-x-0' : '-translate-x-full',
)} )}
> >
<SidebarContent <SidebarContent
currentPath={currentPath} currentPath={currentPath}
isSidebarCollapsed={false} isSidebarCollapsed={false}
forceExpanded forceExpanded
onNavigate={() => setMobileSidebarOpen(false)} onNavigate={() => setMobileSidebarOpen(false)}
/> />
</aside> </aside>
<aside <aside
className={cn( className={cn(
'sticky top-0 z-10 hidden h-screen shrink-0 overflow-hidden border-r border-[var(--border-default)] bg-[var(--surface-panel)]/95 px-3 py-5 backdrop-blur min-[1000px]:block', 'sticky top-0 z-10 hidden h-screen shrink-0 overflow-hidden border-r border-[var(--border-default)] bg-[var(--surface-panel)]/95 px-3 py-5 backdrop-blur min-[1000px]:block',
isSidebarCollapsed ? 'w-[76px]' : 'w-[200px]', isSidebarCollapsed ? 'w-[76px]' : 'w-[200px]',
)} )}
> >
<SidebarContent currentPath={currentPath} isSidebarCollapsed={isSidebarCollapsed} /> <SidebarContent currentPath={currentPath} isSidebarCollapsed={isSidebarCollapsed}/>
</aside> </aside>
</> </>
); );
} }
@@ -1,5 +1,6 @@
'use client'; 'use client';
import Link from 'next/link';
import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
@@ -88,6 +89,14 @@ export function ApplyLogsPage() {
<PageHeader <PageHeader
title='应用记录' title='应用记录'
description='查看节点应用版本的成功或失败记录,支持按 node_id 过滤并查看单条详情。' description='查看节点应用版本的成功或失败记录,支持按 node_id 过滤并查看单条详情。'
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>
}
/> />
{feedback ? <InlineMessage tone='info' message={feedback} /> : null} {feedback ? <InlineMessage tone='info' message={feedback} /> : null}
@@ -15,6 +15,7 @@ import { PageHeader } from '@/components/layout/page-header';
import { AppModal } from '@/components/ui/app-modal'; import { AppModal } from '@/components/ui/app-modal';
import { AppCard } from '@/components/ui/app-card'; import { AppCard } from '@/components/ui/app-card';
import { StatusBadge } from '@/components/ui/status-badge'; import { StatusBadge } from '@/components/ui/status-badge';
import { getPublicStatus } from '@/features/auth/api/public';
import { import {
createNode, createNode,
deleteNode, deleteNode,
@@ -134,6 +135,56 @@ function getUpdateMode(node: NodeItem) {
return { label: '手动更新', variant: 'info' as const }; return { label: '手动更新', variant: 'info' as const };
} }
function parseVersionParts(version: string) {
const normalized = version.trim().replace(/^v/i, '');
if (!normalized || normalized.toLowerCase() === 'unknown') {
return null;
}
return normalized.split('.').map((segment) => {
const matched = segment.trim().match(/^\d+/);
return matched ? Number.parseInt(matched[0], 10) : 0;
});
}
function isOlderVersion(current: string, target: string) {
const currentParts = parseVersionParts(current);
const targetParts = parseVersionParts(target);
if (!currentParts || !targetParts) {
return false;
}
const maxLength = Math.max(currentParts.length, targetParts.length);
for (let index = 0; index < maxLength; index += 1) {
const currentPart = currentParts[index] ?? 0;
const targetPart = targetParts[index] ?? 0;
if (currentPart < targetPart) {
return true;
}
if (currentPart > targetPart) {
return false;
}
}
return false;
}
function shouldShowManualUpdate(agentVersion: string, serverVersion: string) {
const normalizedServerVersion = serverVersion.trim();
const normalizedAgentVersion = agentVersion.trim();
if (
!normalizedServerVersion ||
normalizedServerVersion.toLowerCase() === 'dev' ||
!normalizedAgentVersion ||
normalizedAgentVersion.toLowerCase() === 'unknown'
) {
return false;
}
return isOlderVersion(normalizedAgentVersion, normalizedServerVersion);
}
function getServerUrl(value: string) { function getServerUrl(value: string) {
return value.trim().replace(/\/+$/, ''); return value.trim().replace(/\/+$/, '');
} }
@@ -175,6 +226,11 @@ export function NodesPage() {
queryFn: getNodes, queryFn: getNodes,
}); });
const publicStatusQuery = useQuery({
queryKey: ['public-status'],
queryFn: getPublicStatus,
});
useEffect(() => { useEffect(() => {
if (typeof window !== 'undefined' && !serverUrl) { if (typeof window !== 'undefined' && !serverUrl) {
setServerUrl(window.location.origin); setServerUrl(window.location.origin);
@@ -246,6 +302,7 @@ export function NodesPage() {
const nodes = useMemo(() => nodesQuery.data ?? [], [nodesQuery.data]); const nodes = useMemo(() => nodesQuery.data ?? [], [nodesQuery.data]);
const normalizedServerUrl = getServerUrl(serverUrl); const normalizedServerUrl = getServerUrl(serverUrl);
const serverVersion = publicStatusQuery.data?.version ?? '';
const selectedNodeFromList = useMemo(() => { const selectedNodeFromList = useMemo(() => {
if (!selectedNode) { if (!selectedNode) {
@@ -348,7 +405,6 @@ export function NodesPage() {
<AppCard <AppCard
title="节点列表" title="节点列表"
description="展示心跳、最近应用结果、版本信息与更新状态,可直接执行管理动作。"
action={ action={
<SecondaryButton <SecondaryButton
type="button" type="button"
@@ -391,6 +447,10 @@ export function NodesPage() {
<tbody className="divide-y divide-[var(--border-default)]"> <tbody className="divide-y divide-[var(--border-default)]">
{nodes.map((node) => { {nodes.map((node) => {
const updateMode = getUpdateMode(node); const updateMode = getUpdateMode(node);
const showManualUpdate = shouldShowManualUpdate(
node.agent_version || '',
serverVersion,
);
return ( return (
<tr key={node.id} className="align-top"> <tr key={node.id} className="align-top">
@@ -465,19 +525,21 @@ export function NodesPage() {
</td> </td>
<td className="px-3 py-4"> <td className="px-3 py-4">
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<PrimaryButton {showManualUpdate ? (
type="button" <PrimaryButton
onClick={() => type="button"
updateAgentMutation.mutate(node.id) onClick={() =>
} updateAgentMutation.mutate(node.id)
disabled={ }
updateAgentMutation.isPending || disabled={
node.update_requested updateAgentMutation.isPending ||
} node.update_requested
className="px-3 py-2 text-xs" }
> className="px-3 py-2 text-xs"
立即更新 >
</PrimaryButton> 升级
</PrimaryButton>
) : null}
<SecondaryButton <SecondaryButton
type="button" type="button"
onClick={() => handleOpenDeployModal(node)} onClick={() => handleOpenDeployModal(node)}
+15 -5
View File
@@ -1,12 +1,22 @@
import { dashboardNavigation } from '@/lib/constants/navigation'; import { dashboardNavigation } from '@/lib/constants/navigation';
import type { NavigationItem } from '@/types/navigation'; import type { NavigationItem } from '@/types/navigation';
const navigationPathAliases: Record<string, string> = {
'/apply-log': '/node',
};
function normalizeNavigationPath(pathname: string) {
return navigationPathAliases[pathname] ?? pathname;
}
export function isPathActive(pathname: string, href: string) { export function isPathActive(pathname: string, href: string) {
if (href === '/') { const normalizedPathname = normalizeNavigationPath(pathname);
return pathname === '/';
} if (href === '/') {
return normalizedPathname === '/';
return pathname === href || pathname.startsWith(`${href}/`); }
return normalizedPathname === href || normalizedPathname.startsWith(`${href}/`);
} }
export function isNavigationItemActive(pathname: string, item: NavigationItem): boolean { export function isNavigationItemActive(pathname: string, item: NavigationItem): boolean {