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
+16 -15
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,24 +48,22 @@ 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,
servers: [
createServer({
id: 7,
name: "edge-7",
last_active: "2025-01-01T00:00:00.000Z",
}),
],
}),
});
dashMocks.lastData = {
now: Date.parse("2025-01-01T00:00:20.000Z"),
servers: [
createServer({
id: 7,
name: "edge-7",
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],
}),
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({
now,
servers: [server],
}),
});
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]);
});
});
+12 -14
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,
}),
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 () => {