mirror of
https://github.com/Buriburizaem0n/nezha-dash-v1.git
synced 2026-09-19 09:40:14 +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:
+43
-5
@@ -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>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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: () => {},
|
||||
|
||||
@@ -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,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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 />);
|
||||
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export interface NezhaWebsocketResponse {
|
||||
now: number;
|
||||
online?: number;
|
||||
servers: NezhaServer[];
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user