mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-13 02:26:36 +08:00
feat: 增强更新功能,支持开发版本的自我升级检查;优化仪表板和应用日志页面的用户界面
This commit is contained in:
@@ -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 {
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user