diff --git a/src/components/DashCommand.tsx b/src/components/DashCommand.tsx index 9981ff2..6668365 100644 --- a/src/components/DashCommand.tsx +++ b/src/components/DashCommand.tsx @@ -16,6 +16,7 @@ import { import { useCommand } from "@/hooks/use-command"; import { useTheme } from "@/hooks/use-theme"; import { useWebSocketContext } from "@/hooks/use-websocket-context"; +import { saveMainPageScrollPosition } from "@/lib/navigation"; import { formatNezhaInfo } from "@/lib/utils"; export function DashCommand() { @@ -89,6 +90,7 @@ export function DashCommand() { key={server.id} value={server.name} onSelect={() => { + saveMainPageScrollPosition(); navigate(`/server/${server.id}`); closeCommand(); }} diff --git a/src/components/GroupSwitch.tsx b/src/components/GroupSwitch.tsx index 3f6e4a4..9af2a00 100644 --- a/src/components/GroupSwitch.tsx +++ b/src/components/GroupSwitch.tsx @@ -59,13 +59,22 @@ export default function GroupSwitch({ useEffect(() => { const currentTagRef = itemRefs.current[tabs.indexOf(currentTab)]; + const scrollContainer = scrollRef.current; - if (currentTagRef) { - currentTagRef.scrollIntoView({ - behavior: "smooth", - block: "nearest", - inline: "center", - }); + if (currentTagRef && scrollContainer) { + const nextScrollLeft = + currentTagRef.offsetLeft - + scrollContainer.clientWidth / 2 + + currentTagRef.offsetWidth / 2; + + if (typeof scrollContainer.scrollTo === "function") { + scrollContainer.scrollTo({ + behavior: "smooth", + left: Math.max(0, nextScrollLeft), + }); + } else { + scrollContainer.scrollLeft = Math.max(0, nextScrollLeft); + } } }, [currentTab, itemRefs, tabs]); diff --git a/src/components/Header.tsx b/src/components/Header.tsx index d67efbf..8a5db4f 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -207,11 +207,30 @@ type links = { name: string; }; +function parseCustomLinks(customLinks: string | undefined): links[] | null { + if (!customLinks) return null; + + try { + const parsedLinks = JSON.parse(customLinks); + + if (!Array.isArray(parsedLinks)) { + return null; + } + + return parsedLinks.filter( + (link): link is links => + typeof link?.link === "string" && typeof link?.name === "string", + ); + } catch { + return null; + } +} + function Links() { // @ts-expect-error CustomLinks is a global variable const customLinks = window.CustomLinks as string; - const links: links[] | null = customLinks ? JSON.parse(customLinks) : null; + const links = parseCustomLinks(customLinks); if (!links) return null; diff --git a/src/components/MapTooltip.tsx b/src/components/MapTooltip.tsx index bae2259..d8da965 100644 --- a/src/components/MapTooltip.tsx +++ b/src/components/MapTooltip.tsx @@ -2,6 +2,7 @@ import { memo } from "react"; import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; import useTooltip from "@/hooks/use-tooltip"; +import { saveMainPageScrollPosition } from "@/lib/navigation"; const MapTooltip = memo(function MapTooltip() { const { t } = useTranslation(); @@ -46,7 +47,7 @@ const MapTooltip = memo(function MapTooltip() { type="button" className="flex items-center gap-1.5 py-0.5 text-neutral-500 transition-colors hover:text-black dark:text-neutral-400 dark:hover:text-white" onClick={() => { - sessionStorage.setItem("fromMainPage", "true"); + saveMainPageScrollPosition(); navigate(`/server/${server.id}`); }} > diff --git a/src/components/ServerCard.tsx b/src/components/ServerCard.tsx index f3f6e4b..3f95b2f 100644 --- a/src/components/ServerCard.tsx +++ b/src/components/ServerCard.tsx @@ -8,6 +8,7 @@ import { GetOsName, MageMicrosoftWindows, } from "@/lib/logo-class"; +import { saveMainPageScrollPosition } from "@/lib/navigation"; import { cn, formatNezhaInfo, parsePublicNote } from "@/lib/utils"; import type { NezhaServer } from "@/types/nezha-api"; import BillingInfo from "./billingInfo"; @@ -40,7 +41,7 @@ export default function ServerCard({ } = formatNezhaInfo(now, serverInfo); const cardClick = () => { - sessionStorage.setItem("fromMainPage", "true"); + saveMainPageScrollPosition(); navigate(`/server/${serverInfo.id}`); }; diff --git a/src/components/ServerCardInline.tsx b/src/components/ServerCardInline.tsx index d4f1271..c6b2a1a 100644 --- a/src/components/ServerCardInline.tsx +++ b/src/components/ServerCardInline.tsx @@ -8,6 +8,7 @@ import { GetOsName, MageMicrosoftWindows, } from "@/lib/logo-class"; +import { saveMainPageScrollPosition } from "@/lib/navigation"; import { cn, formatNezhaInfo, parsePublicNote } from "@/lib/utils"; import type { NezhaServer } from "@/types/nezha-api"; import BillingInfo from "./billingInfo"; @@ -41,7 +42,7 @@ export default function ServerCardInline({ } = formatNezhaInfo(now, serverInfo); const cardClick = () => { - sessionStorage.setItem("fromMainPage", "true"); + saveMainPageScrollPosition(); navigate(`/server/${serverInfo.id}`); }; diff --git a/src/components/ServerDetailOverview.tsx b/src/components/ServerDetailOverview.tsx index f5c3168..66688d6 100644 --- a/src/components/ServerDetailOverview.tsx +++ b/src/components/ServerDetailOverview.tsx @@ -40,6 +40,12 @@ export default function ServerDetailOverview({ if (previousPath) { setHasHistory(true); } + + return () => { + if (previousPath) { + sessionStorage.removeItem("fromMainPage"); + } + }; }, []); const { lastData, connected } = useWebSocketContext(); diff --git a/src/components/ServiceTracker.tsx b/src/components/ServiceTracker.tsx index ff53a17..8500c2d 100644 --- a/src/components/ServiceTracker.tsx +++ b/src/components/ServiceTracker.tsx @@ -34,7 +34,7 @@ export function ServiceTracker({ serverList }: { serverList: NezhaServer[] }) { const totalChecks = serviceData.up.reduce((a, b) => a + b, 0) + serviceData.down.reduce((a, b) => a + b, 0); - const uptime = (totalUp / totalChecks) * 100; + const uptime = totalChecks > 0 ? (totalUp / totalChecks) * 100 : 0; const avgDelay = serviceData.delay.length > 0 diff --git a/src/lib/navigation.ts b/src/lib/navigation.ts new file mode 100644 index 0000000..9a36d6a --- /dev/null +++ b/src/lib/navigation.ts @@ -0,0 +1,10 @@ +export function saveMainPageScrollPosition() { + const scrollPosition = + window.scrollY || + document.documentElement.scrollTop || + document.body.scrollTop || + 0; + + sessionStorage.setItem("fromMainPage", "true"); + sessionStorage.setItem("scrollPosition", String(scrollPosition)); +} diff --git a/src/lib/nezha-api.ts b/src/lib/nezha-api.ts index 8e73d03..175d1ce 100644 --- a/src/lib/nezha-api.ts +++ b/src/lib/nezha-api.ts @@ -51,7 +51,6 @@ export const fetchLoginUser = async (): Promise => { ) { lastestRefreshTokenAt = Date.now(); const csrfToken = getCsrfToken(); - console.log("Refreshing token with CSRF token:", csrfToken); fetch("/api/v1/refresh-token", { method: "POST", headers: { diff --git a/src/pages/Server.tsx b/src/pages/Server.tsx index 104d9c0..218e8f3 100644 --- a/src/pages/Server.tsx +++ b/src/pages/Server.tsx @@ -46,8 +46,9 @@ export default function Servers() { const [showServices, setShowServices] = useState("0"); const [showMap, setShowMap] = useState("0"); const [inline, setInline] = useState("0"); - const containerRef = useRef(null); + const hasRestoredScroll = useRef(false); const [currentGroup, setCurrentGroup] = useState("All"); + const nezhaWsData = lastData; const customBackgroundImage = (window.CustomBackgroundImage as string) !== "" @@ -55,19 +56,30 @@ export default function Servers() { : undefined; const restoreScrollPosition = useCallback(() => { + const isFromMainPage = sessionStorage.getItem("fromMainPage") === "true"; const savedPosition = sessionStorage.getItem("scrollPosition"); - if (savedPosition && containerRef.current) { - containerRef.current.scrollTop = Number(savedPosition); + const scrollTop = savedPosition ? Number(savedPosition) : Number.NaN; + + if ( + hasRestoredScroll.current || + !isFromMainPage || + !Number.isFinite(scrollTop) + ) { + return; } + + hasRestoredScroll.current = true; + requestAnimationFrame(() => { + requestAnimationFrame(() => { + window.scrollTo({ top: scrollTop, left: 0, behavior: "auto" }); + }); + }); }, []); const handleTagChange = (newGroup: string) => { setCurrentGroup(newGroup); sessionStorage.setItem("selectedGroup", newGroup); - sessionStorage.setItem( - "scrollPosition", - String(containerRef.current?.scrollTop || 0), - ); + sessionStorage.setItem("scrollPosition", String(window.scrollY || 0)); }; useEffect(() => { @@ -120,11 +132,13 @@ export default function Servers() { useEffect(() => { const savedGroup = sessionStorage.getItem("selectedGroup") || "All"; setCurrentGroup(savedGroup); + }, []); - restoreScrollPosition(); - }, [restoreScrollPosition]); - - const nezhaWsData = lastData; + useEffect(() => { + if (nezhaWsData) { + restoreScrollPosition(); + } + }, [nezhaWsData, restoreScrollPosition]); const groupTabs = [ "All", @@ -250,7 +264,9 @@ export default function Servers() { comparison = (a.state?.uptime ?? 0) - (b.state?.uptime ?? 0); break; case "system": - comparison = a.host.platform.localeCompare(b.host.platform); + comparison = (a.host?.platform ?? "").localeCompare( + b.host?.platform ?? "", + ); break; case "cpu": comparison = (a.state?.cpu ?? 0) - (b.state?.cpu ?? 0); @@ -433,10 +449,7 @@ export default function Servers() { )} {showServices === "1" && } {inline === "1" && ( -
+
{filteredServers.map((serverInfo) => ( )} {inline === "0" && ( -
+
{filteredServers.map((serverInfo) => ( { expect(screen.getByText("edge-7")).toBeInTheDocument(); expect(screen.getByText("ToggleDarkMode")).toBeInTheDocument(); + Object.defineProperty(window, "scrollY", { + configurable: true, + value: 256, + }); await user.click(screen.getByText("edge-7")); expect(dashMocks.navigate).toHaveBeenCalledWith("/server/7"); + expect(sessionStorage.getItem("fromMainPage")).toBe("true"); + expect(sessionStorage.getItem("scrollPosition")).toBe("256"); expect(dashMocks.closeCommand).toHaveBeenCalled(); await user.click(screen.getByText("ToggleDarkMode")); diff --git a/src/test/components/header.test.tsx b/src/test/components/header.test.tsx index b97c071..97a8649 100644 --- a/src/test/components/header.test.tsx +++ b/src/test/components/header.test.tsx @@ -179,6 +179,19 @@ describe("Header", () => { ).toBeInTheDocument(); }); + it("ignores invalid custom links instead of crashing", async () => { + Object.assign(window, { + CustomLinks: "{bad-json", + }); + + renderHeader(); + + expect(await screen.findByText("Nezha")).toBeInTheDocument(); + expect( + screen.queryByRole("link", { name: "Docs" }), + ).not.toBeInTheDocument(); + }); + it("stores and removes the active custom background", async () => { const user = userEvent.setup(); headerMocks.backgroundImage = "/desktop.png"; diff --git a/src/test/components/navigation-and-footer.test.tsx b/src/test/components/navigation-and-footer.test.tsx index ef18832..86c32eb 100644 --- a/src/test/components/navigation-and-footer.test.tsx +++ b/src/test/components/navigation-and-footer.test.tsx @@ -104,6 +104,7 @@ describe("Tab and group switches", () => { it("scrolls overflowing group tabs and applies custom background styling", async () => { const user = userEvent.setup(); const setCurrentTab = vi.fn(); + const scrollIntoView = vi.spyOn(HTMLElement.prototype, "scrollIntoView"); vi.spyOn(HTMLElement.prototype, "scrollWidth", "get").mockReturnValue(320); vi.spyOn(HTMLElement.prototype, "clientWidth", "get").mockReturnValue(120); window.CustomBackgroundImage = "/background.jpg"; @@ -125,6 +126,7 @@ describe("Tab and group switches", () => { expect(scrollContainer.scrollLeft).toBe(42); expect(setCurrentTab).toHaveBeenCalledWith("Asia"); + expect(scrollIntoView).not.toHaveBeenCalled(); expect( container.querySelector(".relative.flex.items-center")?.className, ).toContain("bg-stone-100/70"); diff --git a/src/test/components/server-cards.test.tsx b/src/test/components/server-cards.test.tsx index 45de958..caafb63 100644 --- a/src/test/components/server-cards.test.tsx +++ b/src/test/components/server-cards.test.tsx @@ -72,9 +72,14 @@ describe("ServerCard", () => { screen.getAllByText(/billingInfo.remaining: 16/).length, ).toBeGreaterThan(0); + Object.defineProperty(window, "scrollY", { + configurable: true, + value: 432, + }); fireEvent.click(screen.getByText("edge-online")); expect(sessionStorage.getItem("fromMainPage")).toBe("true"); + expect(sessionStorage.getItem("scrollPosition")).toBe("432"); expect(screen.getByText("/server/7")).toBeInTheDocument(); }); diff --git a/src/test/components/server-detail-components.test.tsx b/src/test/components/server-detail-components.test.tsx index 5983446..9c9f2a7 100644 --- a/src/test/components/server-detail-components.test.tsx +++ b/src/test/components/server-detail-components.test.tsx @@ -99,6 +99,7 @@ describe("ServerDetailOverview", () => { it("renders server identity, hardware, traffic, and temperature details", async () => { const user = userEvent.setup(); + sessionStorage.setItem("fromMainPage", "true"); seedWebSocketData({ server: createServer({ id: 7, @@ -113,8 +114,8 @@ describe("ServerDetailOverview", () => { }), }); - render( - + const { unmount } = render( + , @@ -139,5 +140,8 @@ describe("ServerDetailOverview", () => { await user.click(screen.getByText("edge-detail")); expect(screen.getByText("/")).toBeInTheDocument(); + + unmount(); + expect(sessionStorage.getItem("fromMainPage")).toBeNull(); }); }); diff --git a/src/test/components/service-tracker.test.tsx b/src/test/components/service-tracker.test.tsx index 87c8aee..49098b7 100644 --- a/src/test/components/service-tracker.test.tsx +++ b/src/test/components/service-tracker.test.tsx @@ -93,6 +93,31 @@ describe("ServiceTracker", () => { expect(screen.getByText("Monthly")).toBeInTheDocument(); expect(screen.queryByText("hidden-server")).not.toBeInTheDocument(); }); + + it("falls back to zero uptime when a service has no checks", async () => { + apiMocks.fetchService.mockResolvedValue({ + success: true, + data: { + services: { + http: { + service_name: "HTTP Ping", + current_up: 0, + current_down: 0, + total_up: 0, + total_down: 0, + delay: [], + up: [0, 0], + down: [0, 0], + }, + }, + }, + }); + + renderWithQuery(); + + expect(await screen.findByText("HTTP Ping")).toBeInTheDocument(); + expect(screen.getByText("0.0% serviceTracker.uptime")).toBeInTheDocument(); + }); }); describe("CycleTransferStatsCard", () => { diff --git a/src/test/lib/nezha-api.test.ts b/src/test/lib/nezha-api.test.ts index 7b772a5..6e17fe3 100644 --- a/src/test/lib/nezha-api.test.ts +++ b/src/test/lib/nezha-api.test.ts @@ -81,6 +81,9 @@ describe("nezha api fetchers", () => { }); it("refreshes the token when a logged-in browser session has cookies", async () => { + const consoleLog = vi + .spyOn(console, "log") + .mockImplementation(() => undefined); Object.defineProperty(document, "cookie", { configurable: true, value: "nezha_token=token; nz-csrf=test-csrf-token", @@ -108,5 +111,6 @@ describe("nezha api fetchers", () => { "X-CSRF-Token": "test-csrf-token", }, }); + expect(consoleLog).not.toHaveBeenCalled(); }); }); diff --git a/src/test/pages/Server.test.tsx b/src/test/pages/Server.test.tsx index ab7a7b5..5c0a783 100644 --- a/src/test/pages/Server.test.tsx +++ b/src/test/pages/Server.test.tsx @@ -275,6 +275,73 @@ describe("Servers page", () => { expect(screen.getAllByTestId("server-card")[0]).toHaveTextContent("alpha"); }); + it("sorts by system even when a platform value is missing", async () => { + const missingPlatform = createServer({ + id: 1, + name: "alpha", + host: { platform: undefined }, + }); + const linux = createServer({ + id: 2, + name: "beta", + host: { platform: "linux" }, + }); + const user = userEvent.setup(); + + renderServerPage({ + connected: true, + lastData: websocketPayload([missingPlatform, linux]), + }); + + await user.selectOptions(screen.getByLabelText("Sort metric"), "system"); + + const cards = screen.getAllByTestId("server-card"); + expect(cards).toHaveLength(2); + expect(cards[0]).toHaveTextContent("beta"); + expect(cards[1]).toHaveTextContent("alpha"); + }); + + it("restores the saved main page scroll position after data is ready", async () => { + const scrollTo = vi.fn(); + vi.stubGlobal("scrollTo", scrollTo); + vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => { + callback(0); + return 0; + }); + sessionStorage.setItem("fromMainPage", "true"); + sessionStorage.setItem("scrollPosition", "345"); + + renderServerPage({ + connected: true, + lastData: websocketPayload([createServer({ id: 1, name: "alpha" })]), + }); + + await waitFor(() => { + expect(scrollTo).toHaveBeenCalledWith({ + top: 345, + left: 0, + behavior: "auto", + }); + }); + }); + + it("does not restore stale scroll positions without a main page origin", () => { + const scrollTo = vi.fn(); + vi.stubGlobal("scrollTo", scrollTo); + vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => { + callback(0); + return 0; + }); + sessionStorage.setItem("scrollPosition", "345"); + + renderServerPage({ + connected: true, + lastData: websocketPayload([createServer({ id: 1, name: "alpha" })]), + }); + + expect(scrollTo).not.toHaveBeenCalled(); + }); + it("toggles map and service tracker controls when service data exists", async () => { apiMocks.fetchService.mockResolvedValue({ success: true,