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 { 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>
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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,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;
|
||||||
|
|||||||
@@ -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: () => {},
|
||||||
|
|||||||
@@ -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,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
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 />);
|
||||||
|
|
||||||
|
|||||||
@@ -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 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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 />);
|
||||||
|
|||||||
@@ -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]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 },
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,5 +1,6 @@
|
|||||||
export interface NezhaWebsocketResponse {
|
export interface NezhaWebsocketResponse {
|
||||||
now: number;
|
now: number;
|
||||||
|
online?: number;
|
||||||
servers: NezhaServer[];
|
servers: NezhaServer[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user