From c240838692651d181a8154f2730f3388edd2bc19 Mon Sep 17 00:00:00 2001 From: ryan Date: Wed, 11 Mar 2026 22:19:27 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=BC=BA=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=EF=BC=8C=E6=94=AF=E6=8C=81=E5=BC=80=E5=8F=91?= =?UTF-8?q?=E7=89=88=E6=9C=AC=E7=9A=84=E8=87=AA=E6=88=91=E5=8D=87=E7=BA=A7?= =?UTF-8?q?=E6=A3=80=E6=9F=A5=EF=BC=9B=E4=BC=98=E5=8C=96=E4=BB=AA=E8=A1=A8?= =?UTF-8?q?=E6=9D=BF=E5=92=8C=E5=BA=94=E7=94=A8=E6=97=A5=E5=BF=97=E9=A1=B5?= =?UTF-8?q?=E9=9D=A2=E7=9A=84=E7=94=A8=E6=88=B7=E7=95=8C=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- atsf_server/service/update.go | 5 +- atsf_server/service/update_test.go | 22 + atsf_server/web/app/(dashboard)/page.tsx | 8 +- .../components/layout/dashboard-sidebar.tsx | 454 +++++++++--------- .../apply-logs/components/apply-logs-page.tsx | 9 + .../features/nodes/components/nodes-page.tsx | 90 +++- atsf_server/web/lib/utils/navigation.ts | 20 +- 7 files changed, 359 insertions(+), 249 deletions(-) diff --git a/atsf_server/service/update.go b/atsf_server/service/update.go index f9758e36..e524f7ab 100644 --- a/atsf_server/service/update.go +++ b/atsf_server/service/update.go @@ -124,8 +124,9 @@ func fetchLatestRelease(ctx context.Context) (*githubReleaseResponse, error) { func buildLatestServerReleaseView(release *githubReleaseResponse) *LatestServerRelease { currentVersion := strings.TrimSpace(common.Version) + isDevBuild := currentVersion == "" || strings.EqualFold(currentVersion, "dev") hasUpdate := false - if release != nil { + if release != nil && !isDevBuild { hasUpdate = isVersionNewer(currentVersion, release.TagName) } @@ -136,7 +137,7 @@ func buildLatestServerReleaseView(release *githubReleaseResponse) *LatestServerR view := &LatestServerRelease{ CurrentVersion: currentVersion, HasUpdate: hasUpdate, - UpgradeSupported: runtime.GOOS != "windows", + UpgradeSupported: !isDevBuild && runtime.GOOS != "windows", InProgress: inProgress, } if release != nil { diff --git a/atsf_server/service/update_test.go b/atsf_server/service/update_test.go index cc0e46aa..9f625e61 100644 --- a/atsf_server/service/update_test.go +++ b/atsf_server/service/update_test.go @@ -63,3 +63,25 @@ func TestBuildLatestServerReleaseView(t *testing.T) { 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") + } +} diff --git a/atsf_server/web/app/(dashboard)/page.tsx b/atsf_server/web/app/(dashboard)/page.tsx index c020e83b..9588a834 100644 --- a/atsf_server/web/app/(dashboard)/page.tsx +++ b/atsf_server/web/app/(dashboard)/page.tsx @@ -1,12 +1,12 @@ -import { DashboardOverview } from '@/features/dashboard/components/dashboard-overview'; -import { PageHeader } from '@/components/layout/page-header'; - +import { DashboardOverview } from '@/features/dashboard/components/dashboard-overview'; +import { PageHeader } from '@/components/layout/page-header'; + export default function DashboardPage() { return (
diff --git a/atsf_server/web/components/layout/dashboard-sidebar.tsx b/atsf_server/web/components/layout/dashboard-sidebar.tsx index b3849076..2f45ae6c 100644 --- a/atsf_server/web/components/layout/dashboard-sidebar.tsx +++ b/atsf_server/web/components/layout/dashboard-sidebar.tsx @@ -1,253 +1,259 @@ 'use client'; -import { useEffect } from 'react'; +import {useEffect} from 'react'; import Link from 'next/link'; -import { usePathname } from 'next/navigation'; +import {usePathname} from 'next/navigation'; -import { dashboardNavigation } from '@/lib/constants/navigation'; -import { cn } from '@/lib/utils/cn'; -import { isNavigationItemActive } from '@/lib/utils/navigation'; -import { useAppShellStore } from '@/store/app-shell'; -import type { NavigationIconKey, NavigationItem } from '@/types/navigation'; +import {dashboardNavigation} from '@/lib/constants/navigation'; +import {cn} from '@/lib/utils/cn'; +import {isNavigationItemActive} from '@/lib/utils/navigation'; +import {useAppShellStore} from '@/store/app-shell'; +import type {NavigationIconKey, NavigationItem} from '@/types/navigation'; -function SidebarIcon({ icon }: { icon: NavigationIconKey }) { - const commonProps = { - className: 'h-[18px] w-[18px]', - fill: 'none', - stroke: 'currentColor', - strokeWidth: 1.8, - strokeLinecap: 'round' as const, - strokeLinejoin: 'round' as const, - viewBox: '0 0 24 24', - }; +function SidebarIcon({icon}: { icon: NavigationIconKey }) { + const commonProps = { + className: 'h-[18px] w-[18px]', + fill: 'none', + stroke: 'currentColor', + strokeWidth: 1.8, + strokeLinecap: 'round' as const, + strokeLinejoin: 'round' as const, + viewBox: '0 0 24 24', + }; - switch (icon) { - case 'home': - return ( - - - - - - ); - case 'node': - return ( - - - - - - - ); - case 'website': - return ( - - - - - - ); - case 'domain': - return ( - - - - - - ); - case 'certificate': - return ( - - - - - - ); - case 'proxy': - return ( - - - - - - - ); - case 'release': - return ( - - - - - - ); - case 'user': - return ( - - - - - ); - case 'setting': - return ( - - - - - ); - } + switch (icon) { + case 'home': + return ( + + + + + + ); + case 'node': + return ( + + + + + + + ); + case 'website': + return ( + + + + + + ); + case 'domain': + return ( + + + + + + ); + case 'certificate': + return ( + + + + + + ); + case 'proxy': + return ( + + + + + + + ); + case 'release': + return ( + + + + + + ); + case 'user': + return ( + + + + + ); + case 'setting': + return ( + + + + + ); + } } function SidebarNavItem({ - item, - currentPath, - isSidebarCollapsed, - forceExpanded, - onNavigate, - depth = 0, -}: { - item: NavigationItem; - currentPath: string; - isSidebarCollapsed: boolean; - forceExpanded?: boolean; - onNavigate?: () => void; - depth?: number; + item, + currentPath, + isSidebarCollapsed, + forceExpanded, + onNavigate, + depth = 0, + }: { + item: NavigationItem; + currentPath: string; + isSidebarCollapsed: boolean; + forceExpanded?: boolean; + onNavigate?: () => void; + depth?: number; }) { - const active = isNavigationItemActive(currentPath, item); - const hasChildren = Boolean(item.children?.length); - const showLabel = forceExpanded || !isSidebarCollapsed; + const active = isNavigationItemActive(currentPath, item); + const hasChildren = Boolean(item.children?.length); + const showLabel = forceExpanded || !isSidebarCollapsed; - return ( -
- 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)]', - )} - > - - - - {showLabel ? {item.label} : null} - - {showLabel && hasChildren ? ( + return (
- {item.children?.map((child) => ( - - ))} + 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)]', + )} + > + + + + {showLabel ? {item.label} : null} + + {showLabel && hasChildren ? ( +
+ {item.children?.map((child) => ( + + ))} +
+ ) : null}
- ) : null} -
- ); + ); } function SidebarContent({ - currentPath, - isSidebarCollapsed, - forceExpanded = false, - onNavigate, -}: { - currentPath: string; - isSidebarCollapsed: boolean; - forceExpanded?: boolean; - onNavigate?: () => void; + currentPath, + isSidebarCollapsed, + forceExpanded = false, + onNavigate, + }: { + currentPath: string; + isSidebarCollapsed: boolean; + forceExpanded?: boolean; + onNavigate?: () => void; }) { - const showLabel = forceExpanded || !isSidebarCollapsed; + const showLabel = forceExpanded || !isSidebarCollapsed; - return ( -
-
-
- AF -
- {showLabel ? ( -
-

ATSFlare

-

控制面

-
- ) : null} -
+ return ( +
+
+
+ AF +
+ {showLabel ? ( +
+

ATSFlare

+

控制面

+
+ ) : null} +
- + - {showLabel ? ( -
- 总览页现在承接各模块摘要,侧栏只保留稳定导航。 + {showLabel ? ( +
+ 已连接 ATSFlare Server +
+ ) : null}
- ) : null} -
- ); + ); } export function DashboardSidebar() { - const pathname = usePathname(); - const currentPath = pathname ?? '/'; - const isSidebarCollapsed = useAppShellStore((state) => state.isSidebarCollapsed); - const isMobileSidebarOpen = useAppShellStore((state) => state.isMobileSidebarOpen); - const setMobileSidebarOpen = useAppShellStore((state) => state.setMobileSidebarOpen); + const pathname = usePathname(); + const currentPath = pathname ?? '/'; + const isSidebarCollapsed = useAppShellStore((state) => state.isSidebarCollapsed); + const isMobileSidebarOpen = useAppShellStore((state) => state.isMobileSidebarOpen); + const setMobileSidebarOpen = useAppShellStore((state) => state.setMobileSidebarOpen); - useEffect(() => { - setMobileSidebarOpen(false); - }, [currentPath, setMobileSidebarOpen]); + useEffect(() => { + setMobileSidebarOpen(false); + }, [currentPath, setMobileSidebarOpen]); - return ( - <> -
setMobileSidebarOpen(false)} - aria-hidden='true' - /> + return ( + <> +
setMobileSidebarOpen(false)} + aria-hidden='true' + /> - + - - - ); + + + ); } diff --git a/atsf_server/web/features/apply-logs/components/apply-logs-page.tsx b/atsf_server/web/features/apply-logs/components/apply-logs-page.tsx index fa613cb1..cabdd6cc 100644 --- a/atsf_server/web/features/apply-logs/components/apply-logs-page.tsx +++ b/atsf_server/web/features/apply-logs/components/apply-logs-page.tsx @@ -1,5 +1,6 @@ 'use client'; +import Link from 'next/link'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useEffect, useMemo, useState } from 'react'; @@ -88,6 +89,14 @@ export function ApplyLogsPage() { + 返回节点页 + + } /> {feedback ? : null} diff --git a/atsf_server/web/features/nodes/components/nodes-page.tsx b/atsf_server/web/features/nodes/components/nodes-page.tsx index 3a282303..7f0f843c 100644 --- a/atsf_server/web/features/nodes/components/nodes-page.tsx +++ b/atsf_server/web/features/nodes/components/nodes-page.tsx @@ -15,6 +15,7 @@ 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 { createNode, deleteNode, @@ -134,6 +135,56 @@ function getUpdateMode(node: NodeItem) { 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) { return value.trim().replace(/\/+$/, ''); } @@ -175,6 +226,11 @@ export function NodesPage() { queryFn: getNodes, }); + const publicStatusQuery = useQuery({ + queryKey: ['public-status'], + queryFn: getPublicStatus, + }); + useEffect(() => { if (typeof window !== 'undefined' && !serverUrl) { setServerUrl(window.location.origin); @@ -246,6 +302,7 @@ export function NodesPage() { const nodes = useMemo(() => nodesQuery.data ?? [], [nodesQuery.data]); const normalizedServerUrl = getServerUrl(serverUrl); + const serverVersion = publicStatusQuery.data?.version ?? ''; const selectedNodeFromList = useMemo(() => { if (!selectedNode) { @@ -348,7 +405,6 @@ export function NodesPage() { {nodes.map((node) => { const updateMode = getUpdateMode(node); + const showManualUpdate = shouldShowManualUpdate( + node.agent_version || '', + serverVersion, + ); return ( @@ -465,19 +525,21 @@ export function NodesPage() {
- - updateAgentMutation.mutate(node.id) - } - disabled={ - updateAgentMutation.isPending || - node.update_requested - } - className="px-3 py-2 text-xs" - > - 立即更新 - + {showManualUpdate ? ( + + updateAgentMutation.mutate(node.id) + } + disabled={ + updateAgentMutation.isPending || + node.update_requested + } + className="px-3 py-2 text-xs" + > + 升级 + + ) : null} handleOpenDeployModal(node)} diff --git a/atsf_server/web/lib/utils/navigation.ts b/atsf_server/web/lib/utils/navigation.ts index 7f2a178b..7d5588ae 100644 --- a/atsf_server/web/lib/utils/navigation.ts +++ b/atsf_server/web/lib/utils/navigation.ts @@ -1,12 +1,22 @@ import { dashboardNavigation } from '@/lib/constants/navigation'; import type { NavigationItem } from '@/types/navigation'; +const navigationPathAliases: Record = { + '/apply-log': '/node', +}; + +function normalizeNavigationPath(pathname: string) { + return navigationPathAliases[pathname] ?? pathname; +} + export function isPathActive(pathname: string, href: string) { - if (href === '/') { - return pathname === '/'; - } - - return pathname === href || pathname.startsWith(`${href}/`); + const normalizedPathname = normalizeNavigationPath(pathname); + + if (href === '/') { + return normalizedPathname === '/'; + } + + return normalizedPathname === href || normalizedPathname.startsWith(`${href}/`); } export function isNavigationItemActive(pathname: string, item: NavigationItem): boolean {