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