refactor: update websocket context to use structured data and lazy load components

- Changed websocket context to use `lastData` instead of `lastMessage` for better type safety and clarity.
- Updated components to handle the new websocket data structure, including `DashCommand`, `Header`, `ServerDetail`, and various chart components.
- Introduced lazy loading for `NotFound`, `ServerDetail`, and `NetworkChart` components to improve performance.
- Added skeleton loading states for `ServerDetail` to enhance user experience during data fetching.
- Updated tests to reflect changes in websocket data handling and component structure.
This commit is contained in:
hamster1963
2026-06-20 00:48:30 +08:00
parent f2b13da7b1
commit 03977c4b4d
20 changed files with 225 additions and 147 deletions
+43 -5
View File
@@ -1,6 +1,6 @@
import { useQuery } from "@tanstack/react-query";
import type React from "react";
import { useEffect, useState } from "react";
import { lazy, Suspense, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Route, BrowserRouter as Router, Routes } from "react-router-dom";
@@ -8,15 +8,39 @@ import { DashCommand } from "./components/DashCommand";
import ErrorBoundary from "./components/ErrorBoundary";
import Footer from "./components/Footer";
import Header, { RefreshToast } from "./components/Header";
import { Skeleton } from "./components/ui/skeleton";
import { useBackground } from "./hooks/use-background";
import { useTheme } from "./hooks/use-theme";
import { InjectContext } from "./lib/inject";
import { fetchSetting } from "./lib/nezha-api";
import { cn } from "./lib/utils";
import ErrorPage from "./pages/ErrorPage";
import NotFound from "./pages/NotFound";
import Server from "./pages/Server";
import ServerDetail from "./pages/ServerDetail";
const NotFound = lazy(() => import("./pages/NotFound"));
const ServerDetail = lazy(() => import("./pages/ServerDetail"));
function ServerDetailRouteFallback() {
return (
<div className="mx-auto w-full max-w-5xl px-0 flex flex-col gap-4">
<div>
<div className="flex flex-none items-center gap-0.5 text-xl">
<Skeleton className="h-5 w-5 rounded-[5px] bg-muted-foreground/10 animate-none" />
<Skeleton className="h-[20px] w-24 rounded-[5px] bg-muted-foreground/10 animate-none" />
</div>
<Skeleton className="flex flex-wrap gap-2 h-[81px] w-1/2 mt-3 rounded-[5px] bg-muted-foreground/10 animate-none" />
</div>
<section className="grid md:grid-cols-2 lg:grid-cols-3 grid-cols-1 gap-3">
<Skeleton className="h-[182px] w-full rounded-[5px] bg-muted-foreground/10 animate-none" />
<Skeleton className="h-[182px] w-full rounded-[5px] bg-muted-foreground/10 animate-none" />
<Skeleton className="h-[182px] w-full rounded-[5px] bg-muted-foreground/10 animate-none" />
<Skeleton className="h-[182px] w-full rounded-[5px] bg-muted-foreground/10 animate-none" />
<Skeleton className="h-[182px] w-full rounded-[5px] bg-muted-foreground/10 animate-none" />
<Skeleton className="h-[182px] w-full rounded-[5px] bg-muted-foreground/10 animate-none" />
</section>
</div>
);
}
// Route checker component
const RouteChecker: React.FC = () => {
@@ -110,9 +134,23 @@ const MainApp: React.FC = () => {
<DashCommand />
<Routes>
<Route path="/" element={<Server />} />
<Route path="/server/:id" element={<ServerDetail />} />
<Route
path="/server/:id"
element={
<Suspense fallback={<ServerDetailRouteFallback />}>
<ServerDetail />
</Suspense>
}
/>
<Route path="/error" element={<ErrorPage />} />
<Route path="*" element={<NotFound />} />
<Route
path="*"
element={
<Suspense fallback={null}>
<NotFound />
</Suspense>
}
/>
</Routes>
<Footer />
</main>
+2 -5
View File
@@ -17,7 +17,6 @@ import { useCommand } from "@/hooks/use-command";
import { useTheme } from "@/hooks/use-theme";
import { useWebSocketContext } from "@/hooks/use-websocket-context";
import { formatNezhaInfo } from "@/lib/utils";
import type { NezhaWebsocketResponse } from "@/types/nezha-api";
export function DashCommand() {
const { isOpen, closeCommand, toggleCommand } = useCommand();
@@ -26,11 +25,9 @@ export function DashCommand() {
const { t } = useTranslation();
const { setTheme } = useTheme();
const { lastMessage, connected } = useWebSocketContext();
const { lastData, connected } = useWebSocketContext();
const nezhaWsData = lastMessage
? (JSON.parse(lastMessage.data) as NezhaWebsocketResponse)
: null;
const nezhaWsData = lastData;
useEffect(() => {
const down = (e: KeyboardEvent) => {
+2 -6
View File
@@ -60,13 +60,9 @@ function Header() {
refetchOnWindowFocus: true,
});
const { lastMessage, connected } = useWebSocketContext();
const { lastData, connected } = useWebSocketContext();
const onlineCount = connected
? lastMessage
? JSON.parse(lastMessage.data).online || 0
: 0
: "...";
const onlineCount = connected ? (lastData ? lastData.online || 0 : 0) : "...";
const siteName = settingData?.data?.config?.site_name;
+17 -26
View File
@@ -211,7 +211,7 @@ export default function ServerDetailChart({
}: {
server_id: string;
}) {
const { lastMessage, connected, messageHistory } = useWebSocketContext();
const { lastData, connected, messageHistory } = useWebSocketContext();
const [selectedPeriod, setSelectedPeriod] = useState<ChartPeriod>("realtime");
// Check if user is logged in
@@ -256,13 +256,11 @@ export default function ServerDetailChart({
}
}, [isLogin, isTsdbEnabled, selectedPeriod]);
if (!connected && !lastMessage) {
if (!connected && !lastData) {
return <ServerDetailChartLoading />;
}
const nezhaWsData = lastMessage
? (JSON.parse(lastMessage.data) as NezhaWebsocketResponse)
: null;
const nezhaWsData = lastData;
if (!nezhaWsData) {
return <ServerDetailChartLoading />;
@@ -436,7 +434,7 @@ function GpuChart({
index: number;
gpuStat: number;
gpuName?: string;
messageHistory: { data: string }[];
messageHistory: NezhaWebsocketResponse[];
period: ChartPeriod;
}) {
const [gpuChartData, setGpuChartData] = useState<gpuChartData[]>([]);
@@ -467,8 +465,7 @@ function GpuChart({
messageHistory.length > 0
) {
const historyData = messageHistory
.map((msg) => {
const wsData = JSON.parse(msg.data) as NezhaWebsocketResponse;
.map((wsData) => {
const server = wsData.servers.find((s) => s.id === id);
if (!server) return null;
const { gpu } = formatNezhaInfo(wsData.now, server);
@@ -630,7 +627,7 @@ function CpuChart({
}: {
now: number;
data: NezhaServer;
messageHistory: { data: string }[];
messageHistory: NezhaWebsocketResponse[];
period: ChartPeriod;
}) {
const [cpuChartData, setCpuChartData] = useState<cpuChartData[]>([]);
@@ -663,8 +660,7 @@ function CpuChart({
messageHistory.length > 0
) {
const historyData = messageHistory
.map((msg) => {
const wsData = JSON.parse(msg.data) as NezhaWebsocketResponse;
.map((wsData) => {
const server = wsData.servers.find((s) => s.id === data.id);
if (!server) return null;
const { cpu } = formatNezhaInfo(wsData.now, server);
@@ -824,7 +820,7 @@ function ProcessChart({
}: {
now: number;
data: NezhaServer;
messageHistory: { data: string }[];
messageHistory: NezhaWebsocketResponse[];
period: ChartPeriod;
}) {
const { t } = useTranslation();
@@ -865,8 +861,7 @@ function ProcessChart({
messageHistory.length > 0
) {
const historyData = messageHistory
.map((msg) => {
const wsData = JSON.parse(msg.data) as NezhaWebsocketResponse;
.map((wsData) => {
const server = wsData.servers.find((s) => s.id === data.id);
if (!server) return null;
const { process } = formatNezhaInfo(wsData.now, server);
@@ -1020,7 +1015,7 @@ function MemChart({
}: {
now: number;
data: NezhaServer;
messageHistory: { data: string }[];
messageHistory: NezhaWebsocketResponse[];
period: ChartPeriod;
}) {
const { t } = useTranslation();
@@ -1121,8 +1116,7 @@ function MemChart({
messageHistory.length > 0
) {
const historyData = messageHistory
.map((msg) => {
const wsData = JSON.parse(msg.data) as NezhaWebsocketResponse;
.map((wsData) => {
const server = wsData.servers.find((s) => s.id === data.id);
if (!server) return null;
const { mem, swap } = formatNezhaInfo(wsData.now, server);
@@ -1339,7 +1333,7 @@ function DiskChart({
}: {
now: number;
data: NezhaServer;
messageHistory: { data: string }[];
messageHistory: NezhaWebsocketResponse[];
period: ChartPeriod;
}) {
const { t } = useTranslation();
@@ -1383,8 +1377,7 @@ function DiskChart({
messageHistory.length > 0
) {
const historyData = messageHistory
.map((msg) => {
const wsData = JSON.parse(msg.data) as NezhaWebsocketResponse;
.map((wsData) => {
const server = wsData.servers.find((s) => s.id === data.id);
if (!server) return null;
const { disk } = formatNezhaInfo(wsData.now, server);
@@ -1553,7 +1546,7 @@ function NetworkChart({
}: {
now: number;
data: NezhaServer;
messageHistory: { data: string }[];
messageHistory: NezhaWebsocketResponse[];
period: ChartPeriod;
}) {
const { t } = useTranslation();
@@ -1645,8 +1638,7 @@ function NetworkChart({
messageHistory.length > 0
) {
const historyData = messageHistory
.map((msg) => {
const wsData = JSON.parse(msg.data) as NezhaWebsocketResponse;
.map((wsData) => {
const server = wsData.servers.find((s) => s.id === data.id);
if (!server) return null;
const { up, down } = formatNezhaInfo(wsData.now, server);
@@ -1861,7 +1853,7 @@ function ConnectChart({
}: {
now: number;
data: NezhaServer;
messageHistory: { data: string }[];
messageHistory: NezhaWebsocketResponse[];
period: ChartPeriod;
}) {
const [connectChartData, setConnectChartData] = useState(
@@ -1951,8 +1943,7 @@ function ConnectChart({
messageHistory.length > 0
) {
const historyData = messageHistory
.map((msg) => {
const wsData = JSON.parse(msg.data) as NezhaWebsocketResponse;
.map((wsData) => {
const server = wsData.servers.find((s) => s.id === data.id);
if (!server) return null;
const { tcp, udp } = formatNezhaInfo(wsData.now, server);
+3 -6
View File
@@ -3,22 +3,19 @@
import { Progress } from "@/components/ui/progress";
import { useWebSocketContext } from "@/hooks/use-websocket-context";
import { formatNezhaInfo } from "@/lib/utils";
import type { NezhaWebsocketResponse } from "@/types/nezha-api";
export default function ServerDetailSummary({
server_id,
}: {
server_id: number;
}) {
const { lastMessage, connected } = useWebSocketContext();
const { lastData, connected } = useWebSocketContext();
if (!connected && !lastMessage) {
if (!connected && !lastData) {
return null;
}
const nezhaWsData = lastMessage
? (JSON.parse(lastMessage.data) as NezhaWebsocketResponse)
: null;
const nezhaWsData = lastData;
if (!nezhaWsData) {
return null;
+4 -3
View File
@@ -1,16 +1,17 @@
import { createContext } from "react";
import type { NezhaWebsocketResponse } from "@/types/nezha-api";
export interface WebSocketContextType {
lastMessage: { data: string } | null;
lastData: NezhaWebsocketResponse | null;
connected: boolean;
messageHistory: { data: string }[];
messageHistory: NezhaWebsocketResponse[];
reconnect: () => void;
needReconnect: boolean;
setNeedReconnect: (needReconnect: boolean) => void;
}
export const WebSocketContext = createContext<WebSocketContextType>({
lastMessage: null,
lastData: null,
connected: false,
messageHistory: [],
reconnect: () => {},
+17 -7
View File
@@ -1,6 +1,6 @@
import type React from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import type { NezhaWebsocketResponse } from "@/types/nezha-api";
import {
WebSocketContext,
type WebSocketContextType,
@@ -15,8 +15,10 @@ export const WebSocketProvider: React.FC<WebSocketProviderProps> = ({
url,
children,
}) => {
const [lastMessage, setLastMessage] = useState<{ data: string } | null>(null);
const [messageHistory, setMessageHistory] = useState<{ data: string }[]>([]); // 新增历史消息状态
const [lastData, setLastData] = useState<NezhaWebsocketResponse | null>(null);
const [messageHistory, setMessageHistory] = useState<
NezhaWebsocketResponse[]
>([]);
const [connected, setConnected] = useState(false);
const [needReconnect, setNeedReconnect] = useState(false);
const ws = useRef<WebSocket | null>(null);
@@ -85,13 +87,21 @@ export const WebSocketProvider: React.FC<WebSocketProviderProps> = ({
};
ws.current.onmessage = (event) => {
const newMessage = { data: event.data };
setLastMessage(newMessage);
try {
if (typeof event.data !== "string") {
throw new Error("WebSocket message data must be a string");
}
const newData = JSON.parse(event.data) as NezhaWebsocketResponse;
setLastData(newData);
// 更新历史消息,保持最新的30条记录
setMessageHistory((prev) => {
const updated = [newMessage, ...prev];
const updated = [newData, ...prev];
return updated.slice(0, 30);
});
} catch (error) {
console.error("Failed to parse WebSocket message:", error);
}
};
ws.current.onerror = (error) => {
@@ -130,7 +140,7 @@ export const WebSocketProvider: React.FC<WebSocketProviderProps> = ({
}, [cleanup, connect]);
const contextValue: WebSocketContextType = {
lastMessage,
lastData,
connected,
messageHistory,
reconnect,
+2 -3
View File
@@ -2,7 +2,7 @@ import { useEffect, useState } from "react";
import type { NezhaWebsocketResponse } from "@/types/nezha-api";
export function useChartHistory<T>(
messageHistory: { data: string }[],
messageHistory: NezhaWebsocketResponse[],
serverId: number,
formatFn: (wsData: NezhaWebsocketResponse, serverId: number) => T | null,
) {
@@ -11,8 +11,7 @@ export function useChartHistory<T>(
useEffect(() => {
if (messageHistory.length > 0 && data.length === 0) {
const historyData = messageHistory
.map((msg) => {
const wsData = JSON.parse(msg.data) as NezhaWebsocketResponse;
.map((wsData) => {
return formatFn(wsData, serverId);
})
.filter((item): item is T => item !== null)
+12 -1
View File
@@ -1,5 +1,5 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { lazy, Suspense } from "react";
import ReactDOM from "react-dom/client";
import { Toaster } from "sonner";
@@ -15,6 +15,13 @@ import "./i18n";
import "./index.css";
const queryClient = new QueryClient();
const ReactQueryDevtools = import.meta.env.DEV
? lazy(() =>
import("@tanstack/react-query-devtools").then((module) => ({
default: module.ReactQueryDevtools,
})),
)
: null;
const rootElement = document.getElementById("root");
if (!rootElement) {
@@ -42,7 +49,11 @@ ReactDOM.createRoot(rootElement).render(
position="top-center"
className={"flex items-center justify-center"}
/>
{ReactQueryDevtools ? (
<Suspense fallback={null}>
<ReactQueryDevtools />
</Suspense>
) : null}
</TooltipProvider>
</SortProvider>
</StatusProvider>
+4 -6
View File
@@ -22,7 +22,7 @@ import { useStatus } from "@/hooks/use-status";
import { useWebSocketContext } from "@/hooks/use-websocket-context";
import { fetchServerGroup, fetchService } from "@/lib/nezha-api";
import { cn, formatNezhaInfo } from "@/lib/utils";
import type { NezhaWebsocketResponse, ServerGroup } from "@/types/nezha-api";
import type { ServerGroup } from "@/types/nezha-api";
export default function Servers() {
const { t } = useTranslation();
@@ -41,7 +41,7 @@ export default function Servers() {
const hasServices =
!!serviceData?.data?.services &&
Object.keys(serviceData.data.services).length > 0;
const { lastMessage, connected } = useWebSocketContext();
const { lastData, connected } = useWebSocketContext();
const { status } = useStatus();
const [showServices, setShowServices] = useState<string>("0");
const [showMap, setShowMap] = useState<string>("0");
@@ -124,9 +124,7 @@ export default function Servers() {
restoreScrollPosition();
}, [restoreScrollPosition]);
const nezhaWsData = lastMessage
? (JSON.parse(lastMessage.data) as NezhaWebsocketResponse)
: null;
const nezhaWsData = lastData;
const groupTabs = [
"All",
@@ -142,7 +140,7 @@ export default function Servers() {
?.map((item: ServerGroup) => item.group.name) || []),
];
if (!connected && !lastMessage) {
if (!connected && !lastData) {
return (
<div className="flex flex-col items-center min-h-96 justify-center ">
<div className="font-semibold flex items-center gap-2 text-sm">
+14 -11
View File
@@ -1,11 +1,17 @@
import { useEffect, useState } from "react";
import { lazy, Suspense, useEffect, useState } from "react";
import { Navigate, useParams } from "react-router-dom";
import { NetworkChart } from "@/components/NetworkChart";
import NetworkChartLoading from "@/components/NetworkChartLoading";
import ServerDetailChart from "@/components/ServerDetailChart";
import ServerDetailOverview from "@/components/ServerDetailOverview";
import TabSwitch from "@/components/TabSwitch";
import { Separator } from "@/components/ui/separator";
const NetworkChart = lazy(() =>
import("@/components/NetworkChart").then((module) => ({
default: module.NetworkChart,
})),
);
export default function ServerDetail() {
useEffect(() => {
window.scrollTo({ top: 0, left: 0, behavior: "instant" });
@@ -39,15 +45,12 @@ export default function ServerDetail() {
<ServerDetailSummary server_id={Number(server_id)} />
</section> */}
<div style={{ display: currentTab === tabs[0] ? "block" : "none" }}>
<ServerDetailChart server_id={server_id} />
</div>
<div style={{ display: currentTab === tabs[1] ? "block" : "none" }}>
<NetworkChart
server_id={Number(server_id)}
show={currentTab === tabs[1]}
/>
</div>
{currentTab === tabs[0] && <ServerDetailChart server_id={server_id} />}
{currentTab === tabs[1] && (
<Suspense fallback={<NetworkChartLoading />}>
<NetworkChart server_id={Number(server_id)} show={true} />
</Suspense>
)}
</div>
);
}
+9 -8
View File
@@ -7,7 +7,10 @@ import { createServer } from "@/test/fixtures";
const dashMocks = vi.hoisted(() => ({
closeCommand: vi.fn(),
isOpen: true,
lastMessage: null as MessageEvent<string> | null,
lastData: null as {
now: number;
servers: ReturnType<typeof createServer>[];
} | null,
navigate: vi.fn(),
setTheme: vi.fn(),
toggleCommand: vi.fn(),
@@ -31,7 +34,7 @@ vi.mock("@/hooks/use-theme", () => ({
vi.mock("@/hooks/use-websocket-context", () => ({
useWebSocketContext: () => ({
connected: dashMocks.connected,
lastMessage: dashMocks.lastMessage,
lastData: dashMocks.lastData,
}),
}));
@@ -45,9 +48,8 @@ vi.mock("react-router-dom", async (importOriginal) => {
function seedWebSocketData() {
dashMocks.connected = true;
dashMocks.lastMessage = new MessageEvent("message", {
data: JSON.stringify({
now: Date.parse("2025-01-01T00:00:00.000Z") / 1000,
dashMocks.lastData = {
now: Date.parse("2025-01-01T00:00:20.000Z"),
servers: [
createServer({
id: 7,
@@ -55,14 +57,13 @@ function seedWebSocketData() {
last_active: "2025-01-01T00:00:00.000Z",
}),
],
}),
});
};
}
describe("DashCommand", () => {
it("does not render until websocket data is available", () => {
dashMocks.connected = false;
dashMocks.lastMessage = null;
dashMocks.lastData = null;
const { container } = render(<DashCommand />);
+7 -5
View File
@@ -15,7 +15,7 @@ const headerMocks = vi.hoisted(() => ({
connected: true,
fetchLoginUser: vi.fn(),
fetchSetting: vi.fn(),
lastMessage: null as MessageEvent<string> | null,
lastData: null as { now: number; online?: number; servers: [] } | null,
needReconnect: false,
setNeedReconnect: vi.fn(),
updateBackground: vi.fn(),
@@ -37,7 +37,7 @@ vi.mock("@/hooks/use-background", () => ({
vi.mock("@/hooks/use-websocket-context", () => ({
useWebSocketContext: () => ({
connected: headerMocks.connected,
lastMessage: headerMocks.lastMessage,
lastData: headerMocks.lastData,
needReconnect: headerMocks.needReconnect,
setNeedReconnect: headerMocks.setNeedReconnect,
}),
@@ -104,9 +104,11 @@ describe("Header", () => {
headerMocks.connected = true;
headerMocks.fetchLoginUser.mockReset();
headerMocks.fetchSetting.mockReset();
headerMocks.lastMessage = new MessageEvent("message", {
data: JSON.stringify({ online: 4 }),
});
headerMocks.lastData = {
now: Date.parse("2025-01-01T00:00:20.000Z"),
online: 4,
servers: [],
};
headerMocks.needReconnect = false;
headerMocks.setNeedReconnect.mockReset();
headerMocks.updateBackground.mockReset();
@@ -6,15 +6,15 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import ServerDetailChart from "@/components/ServerDetailChart";
import { createServer, createSettingResponse } from "@/test/fixtures";
import { createTestQueryClient } from "@/test/utils";
import type { NezhaServer } from "@/types/nezha-api";
import type { NezhaServer, NezhaWebsocketResponse } from "@/types/nezha-api";
const detailChartMocks = vi.hoisted(() => ({
connected: true,
fetchLoginUser: vi.fn(),
fetchServerMetrics: vi.fn(),
fetchSetting: vi.fn(),
lastMessage: null as { data: string } | null,
messageHistory: [] as { data: string }[],
lastData: null as NezhaWebsocketResponse | null,
messageHistory: [] as NezhaWebsocketResponse[],
}));
vi.mock("recharts", () => {
@@ -70,7 +70,7 @@ vi.mock("recharts", () => {
vi.mock("@/hooks/use-websocket-context", () => ({
useWebSocketContext: () => ({
connected: detailChartMocks.connected,
lastMessage: detailChartMocks.lastMessage,
lastData: detailChartMocks.lastData,
messageHistory: detailChartMocks.messageHistory,
}),
}));
@@ -122,10 +122,8 @@ function metricsResponse(metric: string) {
function websocketPayload(server: NezhaServer, now: number) {
return {
data: JSON.stringify({
now,
servers: [server],
}),
};
}
@@ -160,7 +158,7 @@ function seedWebSocketData() {
});
detailChartMocks.connected = true;
detailChartMocks.lastMessage = websocketPayload(server, baseNow);
detailChartMocks.lastData = websocketPayload(server, baseNow);
detailChartMocks.messageHistory = [0, 1, 2].map((index) =>
websocketPayload(
createServer({
@@ -192,7 +190,7 @@ describe("ServerDetailChart", () => {
detailChartMocks.fetchLoginUser.mockReset();
detailChartMocks.fetchServerMetrics.mockReset();
detailChartMocks.fetchSetting.mockReset();
detailChartMocks.lastMessage = null;
detailChartMocks.lastData = null;
detailChartMocks.messageHistory = [];
detailChartMocks.fetchLoginUser.mockRejectedValue(new Error("anonymous"));
detailChartMocks.fetchSetting.mockResolvedValue(settingResponse());
@@ -8,7 +8,14 @@ import { createServer } from "@/test/fixtures";
const websocketMocks = vi.hoisted(() => ({
connected: true,
lastMessage: null as MessageEvent<string> | null,
lastData: null as {
now: number;
servers: ReturnType<typeof createServer>[];
} | null,
}));
vi.mock("@numeric-text/react", () => ({
default: ({ value }: { value: string | number }) => <span>{value}</span>,
}));
vi.mock("@/hooks/use-websocket-context", () => ({
@@ -20,12 +27,10 @@ function seedWebSocketData({
now = Date.parse("2025-01-01T00:00:20.000Z"),
} = {}) {
websocketMocks.connected = true;
websocketMocks.lastMessage = new MessageEvent("message", {
data: JSON.stringify({
websocketMocks.lastData = {
now,
servers: [server],
}),
});
};
}
function LocationProbe() {
@@ -36,7 +41,7 @@ function LocationProbe() {
describe("ServerDetailSummary", () => {
beforeEach(() => {
websocketMocks.connected = true;
websocketMocks.lastMessage = null;
websocketMocks.lastData = null;
});
it("renders nothing until websocket data exists", () => {
@@ -68,7 +73,7 @@ describe("ServerDetailSummary", () => {
describe("ServerDetailOverview", () => {
beforeEach(() => {
websocketMocks.connected = true;
websocketMocks.lastMessage = null;
websocketMocks.lastData = null;
Object.assign(window, { ForceUseSvgFlag: true });
});
+34 -5
View File
@@ -12,6 +12,7 @@ import { useSort } from "@/hooks/use-sort";
import { useStatus } from "@/hooks/use-status";
import { useTooltip } from "@/hooks/use-tooltip";
import { useWebSocketContext } from "@/hooks/use-websocket-context";
import { createServer } from "@/test/fixtures";
class FakeWebSocket {
static readonly CONNECTING = 0;
@@ -121,7 +122,7 @@ function TooltipProbe() {
function WebSocketProbe() {
const {
connected,
lastMessage,
lastData,
messageHistory,
needReconnect,
setNeedReconnect,
@@ -130,7 +131,7 @@ function WebSocketProbe() {
return (
<div>
<p>{connected ? "connected" : "disconnected"}</p>
<p>{lastMessage?.data ?? "none"}</p>
<p>{lastData?.servers[0]?.name ?? "none"}</p>
<p>{messageHistory.length}</p>
<p>{needReconnect ? "needs-reconnect" : "stable"}</p>
<button type="button" onClick={() => setNeedReconnect(true)}>
@@ -243,6 +244,13 @@ describe("WebSocketProvider", () => {
);
}
function websocketPayload(serverName: string) {
return JSON.stringify({
now: Date.parse("2025-01-01T00:00:20.000Z"),
servers: [createServer({ name: serverName })],
});
}
it("connects with a ws URL and records incoming messages", () => {
renderWebSocketProvider(<WebSocketProbe />);
@@ -255,8 +263,8 @@ describe("WebSocketProvider", () => {
expect(screen.getByText("connected")).toBeInTheDocument();
act(() => {
socket.message("first");
socket.message("second");
socket.message(websocketPayload("first"));
socket.message(websocketPayload("second"));
});
expect(screen.getByText("second")).toBeInTheDocument();
@@ -270,7 +278,7 @@ describe("WebSocketProvider", () => {
act(() => {
socket.open();
for (let index = 0; index < 31; index += 1) {
socket.message(`message-${index}`);
socket.message(websocketPayload(`message-${index}`));
}
});
@@ -278,6 +286,27 @@ describe("WebSocketProvider", () => {
expect(screen.getByText("30")).toBeInTheDocument();
});
it("ignores malformed websocket messages without disconnecting", () => {
const consoleError = vi
.spyOn(console, "error")
.mockImplementation(() => undefined);
renderWebSocketProvider(<WebSocketProbe />);
const socket = FakeWebSocket.instances[0];
act(() => {
socket.open();
socket.message("not-json");
});
expect(screen.getByText("connected")).toBeInTheDocument();
expect(screen.getByText("none")).toBeInTheDocument();
expect(screen.getByText("0")).toBeInTheDocument();
expect(consoleError).toHaveBeenCalledWith(
"Failed to parse WebSocket message:",
expect.any(SyntaxError),
);
});
it("exposes manual reconnect state separately from socket state", async () => {
const user = userEvent.setup();
renderWebSocketProvider(<WebSocketProbe />);
+3 -3
View File
@@ -83,8 +83,8 @@ describe("useBackground", () => {
describe("useChartHistory", () => {
it("formats websocket message history once and keeps newest data last", () => {
const history = [
{ data: JSON.stringify({ now: 2, servers: [{ id: 1 }] }) },
{ data: JSON.stringify({ now: 1, servers: [{ id: 1 }] }) },
{ now: 2, servers: [] },
{ now: 1, servers: [] },
];
const formatFn = vi.fn((wsData: { now: number }, serverId: number) =>
serverId === 1 ? wsData.now : null,
@@ -98,7 +98,7 @@ describe("useChartHistory", () => {
expect(result.current).toEqual([1, 2]);
expect(formatFn).toHaveBeenCalledTimes(2);
rerender({ messages: [...history, { data: JSON.stringify({ now: 3 }) }] });
rerender({ messages: [...history, { now: 3, servers: [] }] });
expect(result.current).toEqual([1, 2]);
});
});
+10 -12
View File
@@ -94,7 +94,7 @@ function renderServerPage(
{ withStatusControl = false } = {},
) {
const defaultWebsocketValue: WebSocketContextType = {
lastMessage: null,
lastData: null,
connected: false,
messageHistory: [],
reconnect: vi.fn(),
@@ -118,10 +118,8 @@ function renderServerPage(
function websocketPayload(servers: NezhaServer[]) {
return {
data: JSON.stringify({
now: Date.parse("2025-01-01T00:00:20.000Z"),
servers,
}),
};
}
@@ -153,7 +151,7 @@ describe("Servers page", () => {
it("renders websocket loading and processing states", () => {
const { rerender } = renderServerPage({
connected: false,
lastMessage: null,
lastData: null,
});
expect(screen.getByText("info.websocketConnecting")).toBeInTheDocument();
@@ -163,7 +161,7 @@ describe("Servers page", () => {
<StatusProvider>
<WebSocketContext.Provider
value={{
lastMessage: null,
lastData: null,
connected: true,
messageHistory: [],
reconnect: vi.fn(),
@@ -190,7 +188,7 @@ describe("Servers page", () => {
renderServerPage({
connected: true,
lastMessage: websocketPayload([online, offline]),
lastData: websocketPayload([online, offline]),
});
expect(screen.getByTestId("server-overview")).toHaveTextContent("2:1:1");
@@ -215,7 +213,7 @@ describe("Servers page", () => {
renderServerPage({
connected: true,
lastMessage: websocketPayload([online, offline]),
lastData: websocketPayload([online, offline]),
});
await user.click(await screen.findByTestId("group-Edge"));
@@ -240,7 +238,7 @@ describe("Servers page", () => {
renderServerPage({
connected: true,
lastMessage: websocketPayload([lowCpu, highCpu]),
lastData: websocketPayload([lowCpu, highCpu]),
});
await user.selectOptions(screen.getByLabelText("Sort metric"), "cpu");
@@ -264,7 +262,7 @@ describe("Servers page", () => {
renderServerPage({
connected: true,
lastMessage: websocketPayload([onlineAlpha, offlineZeta]),
lastData: websocketPayload([onlineAlpha, offlineZeta]),
});
expect(screen.getByLabelText("Toggle sort direction")).toBeDisabled();
@@ -306,7 +304,7 @@ describe("Servers page", () => {
const { container } = renderServerPage({
connected: true,
lastMessage: websocketPayload([online, offline]),
lastData: websocketPayload([online, offline]),
});
await waitFor(() => {
@@ -340,7 +338,7 @@ describe("Servers page", () => {
renderServerPage({
connected: true,
lastMessage: websocketPayload([online]),
lastData: websocketPayload([online]),
});
expect(screen.getByTestId("server-card")).toHaveTextContent("alpha");
@@ -364,7 +362,7 @@ describe("Servers page", () => {
renderServerPage(
{
connected: true,
lastMessage: websocketPayload([online, offline]),
lastData: websocketPayload([online, offline]),
},
{ withStatusControl: true },
);
+5 -2
View File
@@ -109,10 +109,13 @@ describe("ServerDetail", () => {
expect(screen.getByTestId("detail-overview")).toHaveTextContent("7");
expect(screen.getByTestId("detail-chart")).toHaveTextContent("7");
expect(screen.getByTestId("network-chart")).toHaveTextContent("7:false");
expect(screen.queryByTestId("network-chart")).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Network" }));
expect(screen.getByTestId("network-chart")).toHaveTextContent("7:true");
expect(await screen.findByTestId("network-chart")).toHaveTextContent(
"7:true",
);
expect(screen.queryByTestId("detail-chart")).not.toBeInTheDocument();
});
it("redirects when route params are missing", async () => {
+1
View File
@@ -1,5 +1,6 @@
export interface NezhaWebsocketResponse {
now: number;
online?: number;
servers: NezhaServer[];
}