mirror of
https://github.com/Buriburizaem0n/nezha-dash-v1.git
synced 2026-09-19 17:50:13 +00:00
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:
@@ -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 />);
|
||||
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
|
||||
@@ -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 />);
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user