mirror of
https://github.com/Buriburizaem0n/nezha-dash-v1.git
synced 2026-09-19 09:40:14 +00:00
feat: enhance server handling and add empty state messaging in Server component
This commit is contained in:
@@ -114,11 +114,16 @@ function updateServerMetrics() {
|
||||
}
|
||||
|
||||
function websocketPayload() {
|
||||
return JSON.stringify({
|
||||
now: Date.now(),
|
||||
online: onlineCount,
|
||||
servers,
|
||||
});
|
||||
const payload = { now: Date.now() };
|
||||
|
||||
if (onlineCount > 0) {
|
||||
payload.online = onlineCount;
|
||||
}
|
||||
if (servers.length > 0) {
|
||||
payload.servers = servers;
|
||||
}
|
||||
|
||||
return JSON.stringify(payload);
|
||||
}
|
||||
|
||||
function jsonResponse(response, data) {
|
||||
@@ -130,7 +135,7 @@ function jsonResponse(response, data) {
|
||||
response.end(JSON.stringify(data));
|
||||
}
|
||||
|
||||
const serverGroups = [
|
||||
const populatedServerGroups = [
|
||||
{
|
||||
group: {
|
||||
id: 1,
|
||||
@@ -166,6 +171,7 @@ const serverGroups = [
|
||||
.map((server) => server.id),
|
||||
},
|
||||
];
|
||||
const serverGroups = serverCount === 0 ? [] : populatedServerGroups;
|
||||
|
||||
const serviceHistory = Array.from({ length: 30 }, (_, index) => ({
|
||||
up: 95 + (index % 6),
|
||||
@@ -173,6 +179,22 @@ const serviceHistory = Array.from({ length: 30 }, (_, index) => ({
|
||||
delay: 20 + index * 3,
|
||||
}));
|
||||
|
||||
const services =
|
||||
serverCount === 0
|
||||
? {}
|
||||
: {
|
||||
mock_http: {
|
||||
service_name: "Mock HTTP",
|
||||
current_up: 1,
|
||||
current_down: 0,
|
||||
total_up: 2999,
|
||||
total_down: 1,
|
||||
delay: serviceHistory.map((item) => item.delay),
|
||||
up: serviceHistory.map((item) => item.up),
|
||||
down: serviceHistory.map((item) => item.down),
|
||||
},
|
||||
};
|
||||
|
||||
const httpServer = http.createServer((request, response) => {
|
||||
const requestUrl = new URL(
|
||||
request.url || "/",
|
||||
@@ -213,18 +235,7 @@ const httpServer = http.createServer((request, response) => {
|
||||
jsonResponse(response, {
|
||||
success: true,
|
||||
data: {
|
||||
services: {
|
||||
mock_http: {
|
||||
service_name: "Mock HTTP",
|
||||
current_up: 1,
|
||||
current_down: 0,
|
||||
total_up: 2999,
|
||||
total_down: 1,
|
||||
delay: serviceHistory.map((item) => item.delay),
|
||||
up: serviceHistory.map((item) => item.up),
|
||||
down: serviceHistory.map((item) => item.down),
|
||||
},
|
||||
},
|
||||
services,
|
||||
cycle_transfer_stats: {},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -11,6 +11,30 @@ interface WebSocketProviderProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
type RawNezhaWebsocketResponse = Omit<
|
||||
Partial<NezhaWebsocketResponse>,
|
||||
"servers"
|
||||
> & {
|
||||
servers?: unknown;
|
||||
};
|
||||
|
||||
function normalizeWebSocketResponse(data: unknown): NezhaWebsocketResponse {
|
||||
if (!data || typeof data !== "object" || Array.isArray(data)) {
|
||||
throw new TypeError("WebSocket message must be an object");
|
||||
}
|
||||
|
||||
const response = data as RawNezhaWebsocketResponse;
|
||||
if (typeof response.now !== "number" || !Number.isFinite(response.now)) {
|
||||
throw new TypeError("WebSocket message must include a finite now value");
|
||||
}
|
||||
|
||||
return {
|
||||
...response,
|
||||
now: response.now,
|
||||
servers: Array.isArray(response.servers) ? response.servers : [],
|
||||
};
|
||||
}
|
||||
|
||||
export const WebSocketProvider: React.FC<WebSocketProviderProps> = ({
|
||||
url,
|
||||
children,
|
||||
@@ -92,7 +116,7 @@ export const WebSocketProvider: React.FC<WebSocketProviderProps> = ({
|
||||
throw new Error("WebSocket message data must be a string");
|
||||
}
|
||||
|
||||
const newData = JSON.parse(event.data) as NezhaWebsocketResponse;
|
||||
const newData = normalizeWebSocketResponse(JSON.parse(event.data));
|
||||
setLastData(newData);
|
||||
// 更新历史消息,保持最新的30条记录
|
||||
setMessageHistory((prev) => {
|
||||
|
||||
@@ -83,7 +83,9 @@
|
||||
"websocketConnecting": "WebSocket verbindet",
|
||||
"websocketConnected": "WebSocket verbunden",
|
||||
"websocketDisconnected": "WebSocket getrennt",
|
||||
"processing": "Verarbeiten..."
|
||||
"processing": "Verarbeiten...",
|
||||
"noServers": "Keine Server verfügbar",
|
||||
"noMatchingServers": "Keine Server entsprechen den aktuellen Filtern"
|
||||
},
|
||||
"tabSwitch": {
|
||||
"Network": "Netzwerk",
|
||||
|
||||
@@ -11,7 +11,9 @@
|
||||
"websocketConnecting": "WebSocket connecting",
|
||||
"websocketConnected": "WebSocket connected",
|
||||
"websocketDisconnected": "WebSocket disconnected",
|
||||
"processing": "Processing..."
|
||||
"processing": "Processing...",
|
||||
"noServers": "No servers available",
|
||||
"noMatchingServers": "No servers match the current filters"
|
||||
},
|
||||
"serverOverview": {
|
||||
"totalServers": "Total Servers",
|
||||
|
||||
@@ -127,7 +127,9 @@
|
||||
"websocketConnecting": "Conexión WebSocket",
|
||||
"websocketDisconnected": "WebSocket desconectado",
|
||||
"websocketConnected": "WebSocket conectado",
|
||||
"processing": "Procesando..."
|
||||
"processing": "Procesando...",
|
||||
"noServers": "No hay servidores disponibles",
|
||||
"noMatchingServers": "Ningún servidor coincide con los filtros actuales"
|
||||
},
|
||||
"NoResults": "No se encontraron resultados.",
|
||||
"refreshing": "Actualizando",
|
||||
|
||||
@@ -53,7 +53,9 @@
|
||||
"websocketConnecting": "WebSocket подключение",
|
||||
"websocketConnected": "WebSocket подключен",
|
||||
"websocketDisconnected": "WebSocket отключен",
|
||||
"processing": "Обработка..."
|
||||
"processing": "Обработка...",
|
||||
"noServers": "Нет доступных серверов",
|
||||
"noMatchingServers": "Нет серверов, соответствующих текущим фильтрам"
|
||||
},
|
||||
"cycleTransfer": {
|
||||
"nextUpdate": "следующее обновление",
|
||||
|
||||
@@ -23,7 +23,9 @@
|
||||
"websocketConnecting": "வெப்சாக்கெட் இணைத்தல்",
|
||||
"websocketConnected": "வெப்சாக்கெட் இணைக்கப்பட்டுள்ளது",
|
||||
"websocketDisconnected": "வெப்சாக்கெட் துண்டிக்கப்பட்டது",
|
||||
"processing": "செயலாக்கம் ..."
|
||||
"processing": "செயலாக்கம் ...",
|
||||
"noServers": "சேவையகங்கள் எதுவும் இல்லை",
|
||||
"noMatchingServers": "தற்போதைய வடிப்பான்களுடன் பொருந்தும் சேவையகங்கள் எதுவும் இல்லை"
|
||||
},
|
||||
"serverOverview": {
|
||||
"totalServers": "மொத்த சேவையகங்கள்",
|
||||
|
||||
@@ -11,7 +11,9 @@
|
||||
"websocketConnecting": "WebSocket 连接中",
|
||||
"websocketConnected": "WebSocket 连接成功",
|
||||
"websocketDisconnected": "WebSocket 连接断开",
|
||||
"processing": "处理中..."
|
||||
"processing": "处理中...",
|
||||
"noServers": "暂无服务器",
|
||||
"noMatchingServers": "没有符合当前筛选条件的服务器"
|
||||
},
|
||||
"serverOverview": {
|
||||
"totalServers": "服务器总数",
|
||||
|
||||
@@ -11,7 +11,9 @@
|
||||
"websocketConnecting": "WebSocket 連線中",
|
||||
"websocketConnected": "WebSocket 連線成功",
|
||||
"websocketDisconnected": "WebSocket 連線中斷",
|
||||
"processing": "處理中..."
|
||||
"processing": "處理中...",
|
||||
"noServers": "暫無伺服器",
|
||||
"noMatchingServers": "沒有符合目前篩選條件的伺服器"
|
||||
},
|
||||
"serverOverview": {
|
||||
"totalServers": "總伺服器",
|
||||
|
||||
+40
-10
@@ -4,6 +4,7 @@ import {
|
||||
ArrowUpIcon,
|
||||
ChartBarSquareIcon,
|
||||
MapIcon,
|
||||
ServerStackIcon,
|
||||
ViewColumnsIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
@@ -72,6 +73,25 @@ function BackendErrorState({ error }: { error: unknown }) {
|
||||
);
|
||||
}
|
||||
|
||||
function ServerEmptyState({ filtered = false }: { filtered?: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div
|
||||
className="server-empty-state mt-6 flex min-h-52 flex-col items-center justify-center gap-2 px-4 text-center"
|
||||
role="status"
|
||||
>
|
||||
<ServerStackIcon
|
||||
aria-hidden="true"
|
||||
className="size-7 text-stone-300 dark:text-stone-600"
|
||||
/>
|
||||
<p className="text-sm font-medium text-stone-500 dark:text-stone-400">
|
||||
{t(filtered ? "info.noMatchingServers" : "info.noServers")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Servers({
|
||||
backendError,
|
||||
}: {
|
||||
@@ -409,6 +429,8 @@ export default function Servers({
|
||||
);
|
||||
}
|
||||
|
||||
const hasServers = nezhaWsData.servers.length > 0;
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-5xl px-0">
|
||||
<ServerOverview
|
||||
@@ -420,7 +442,10 @@ export default function Servers({
|
||||
upSpeed={upSpeed}
|
||||
downSpeed={downSpeed}
|
||||
/>
|
||||
<div className="flex mt-6 items-center justify-between gap-2 server-overview-controls">
|
||||
<div
|
||||
hidden={!hasServers}
|
||||
className="flex mt-6 items-center justify-between gap-2 server-overview-controls"
|
||||
>
|
||||
<section className="flex items-center gap-2 w-full overflow-hidden">
|
||||
<button
|
||||
onClick={() => {
|
||||
@@ -546,18 +571,23 @@ export default function Servers({
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{showMap === "1" && (
|
||||
<GlobalMap
|
||||
{hasServers && showMap === "1" && (
|
||||
<GlobalMap now={nezhaWsData.now} serverList={nezhaWsData.servers} />
|
||||
)}
|
||||
{hasServers && showServices === "1" && (
|
||||
<ServiceTracker serverList={filteredServers} />
|
||||
)}
|
||||
{!hasServers ? (
|
||||
<ServerEmptyState />
|
||||
) : filteredServers.length > 0 ? (
|
||||
<VirtualServerList
|
||||
inline={inline === "1"}
|
||||
now={nezhaWsData.now}
|
||||
serverList={nezhaWsData?.servers || []}
|
||||
servers={filteredServers}
|
||||
/>
|
||||
) : (
|
||||
<ServerEmptyState filtered />
|
||||
)}
|
||||
{showServices === "1" && <ServiceTracker serverList={filteredServers} />}
|
||||
<VirtualServerList
|
||||
inline={inline === "1"}
|
||||
now={nezhaWsData.now}
|
||||
servers={filteredServers}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -132,7 +132,10 @@ function WebSocketProbe() {
|
||||
<div>
|
||||
<p>{connected ? "connected" : "disconnected"}</p>
|
||||
<p>{lastData?.servers[0]?.name ?? "none"}</p>
|
||||
<p>{messageHistory.length}</p>
|
||||
<p data-testid="message-count">{messageHistory.length}</p>
|
||||
<p data-testid="history-server-count">
|
||||
{messageHistory.reduce((total, item) => total + item.servers.length, 0)}
|
||||
</p>
|
||||
<p>{needReconnect ? "needs-reconnect" : "stable"}</p>
|
||||
<button type="button" onClick={() => setNeedReconnect(true)}>
|
||||
mark
|
||||
@@ -268,7 +271,24 @@ describe("WebSocketProvider", () => {
|
||||
});
|
||||
|
||||
expect(screen.getByText("second")).toBeInTheDocument();
|
||||
expect(screen.getByText("2")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("message-count")).toHaveTextContent("2");
|
||||
});
|
||||
|
||||
it("normalizes missing and non-array server collections", () => {
|
||||
renderWebSocketProvider(<WebSocketProbe />);
|
||||
const socket = FakeWebSocket.instances[0];
|
||||
const now = Date.parse("2025-01-01T00:00:20.000Z");
|
||||
|
||||
act(() => {
|
||||
socket.open();
|
||||
socket.message(JSON.stringify({ now }));
|
||||
socket.message(JSON.stringify({ now, servers: null }));
|
||||
socket.message(JSON.stringify({ now, servers: { invalid: true } }));
|
||||
});
|
||||
|
||||
expect(screen.getByText("none")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("message-count")).toHaveTextContent("3");
|
||||
expect(screen.getByTestId("history-server-count")).toHaveTextContent("0");
|
||||
});
|
||||
|
||||
it("keeps only the latest thirty websocket messages", () => {
|
||||
@@ -283,7 +303,7 @@ describe("WebSocketProvider", () => {
|
||||
});
|
||||
|
||||
expect(screen.getByText("message-30")).toBeInTheDocument();
|
||||
expect(screen.getByText("30")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("message-count")).toHaveTextContent("30");
|
||||
});
|
||||
|
||||
it("ignores malformed websocket messages without disconnecting", () => {
|
||||
@@ -300,13 +320,34 @@ describe("WebSocketProvider", () => {
|
||||
|
||||
expect(screen.getByText("connected")).toBeInTheDocument();
|
||||
expect(screen.getByText("none")).toBeInTheDocument();
|
||||
expect(screen.getByText("0")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("message-count")).toHaveTextContent("0");
|
||||
expect(consoleError).toHaveBeenCalledWith(
|
||||
"Failed to parse WebSocket message:",
|
||||
expect.any(SyntaxError),
|
||||
);
|
||||
});
|
||||
|
||||
it("ignores websocket messages without a valid response shape", () => {
|
||||
const consoleError = vi
|
||||
.spyOn(console, "error")
|
||||
.mockImplementation(() => undefined);
|
||||
renderWebSocketProvider(<WebSocketProbe />);
|
||||
const socket = FakeWebSocket.instances[0];
|
||||
|
||||
act(() => {
|
||||
socket.open();
|
||||
socket.message("null");
|
||||
socket.message(JSON.stringify({ servers: [] }));
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("message-count")).toHaveTextContent("0");
|
||||
expect(consoleError).toHaveBeenCalledTimes(2);
|
||||
expect(consoleError).toHaveBeenCalledWith(
|
||||
"Failed to parse WebSocket message:",
|
||||
expect.any(TypeError),
|
||||
);
|
||||
});
|
||||
|
||||
it("exposes manual reconnect state separately from socket state", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWebSocketProvider(<WebSocketProbe />);
|
||||
|
||||
@@ -241,6 +241,45 @@ describe("Servers page", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("shows an empty state and hides controls when there are no servers", () => {
|
||||
const { container } = renderServerPage({
|
||||
connected: true,
|
||||
lastData: websocketPayload([]),
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("server-overview")).toHaveTextContent("0:0:0");
|
||||
expect(screen.getByText("info.noServers")).toBeInTheDocument();
|
||||
expect(
|
||||
container.querySelector(".server-overview-controls"),
|
||||
).not.toBeVisible();
|
||||
expect(screen.queryByTestId("server-card")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("global-map")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("service-tracker")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows a filtered empty state while keeping controls available", async () => {
|
||||
const offline = createServer({
|
||||
id: 1,
|
||||
name: "offline",
|
||||
last_active: "2024-12-31T23:00:00.000Z",
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
|
||||
const { container } = renderServerPage(
|
||||
{
|
||||
connected: true,
|
||||
lastData: websocketPayload([offline]),
|
||||
},
|
||||
{ withStatusControl: true },
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "online-only" }));
|
||||
|
||||
expect(screen.getByText("info.noMatchingServers")).toBeInTheDocument();
|
||||
expect(container.querySelector(".server-overview-controls")).not.toBeNull();
|
||||
expect(screen.queryByTestId("server-card")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("filters servers by selected group", async () => {
|
||||
const online = createServer({ id: 1, name: "alpha" });
|
||||
const offline = createServer({
|
||||
|
||||
Reference in New Issue
Block a user