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 ? (
-
- ) : null}
-
+ return (
+
+
+
+ AF
+
+ {showLabel ? (
+
+ ) : 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 {
|