feat: enhance server handling and add empty state messaging in Server component

This commit is contained in:
hamster1963
2026-07-12 00:41:56 +08:00
parent 27d8d3f572
commit 7821ce1131
12 changed files with 199 additions and 40 deletions
+29 -18
View File
@@ -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: {},
},
});
+25 -1
View File
@@ -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) => {
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -53,7 +53,9 @@
"websocketConnecting": "WebSocket подключение",
"websocketConnected": "WebSocket подключен",
"websocketDisconnected": "WebSocket отключен",
"processing": "Обработка..."
"processing": "Обработка...",
"noServers": "Нет доступных серверов",
"noMatchingServers": "Нет серверов, соответствующих текущим фильтрам"
},
"cycleTransfer": {
"nextUpdate": "следующее обновление",
+3 -1
View File
@@ -23,7 +23,9 @@
"websocketConnecting": "வெப்சாக்கெட் இணைத்தல்",
"websocketConnected": "வெப்சாக்கெட் இணைக்கப்பட்டுள்ளது",
"websocketDisconnected": "வெப்சாக்கெட் துண்டிக்கப்பட்டது",
"processing": "செயலாக்கம் ..."
"processing": "செயலாக்கம் ...",
"noServers": "சேவையகங்கள் எதுவும் இல்லை",
"noMatchingServers": "தற்போதைய வடிப்பான்களுடன் பொருந்தும் சேவையகங்கள் எதுவும் இல்லை"
},
"serverOverview": {
"totalServers": "மொத்த சேவையகங்கள்",
+3 -1
View File
@@ -11,7 +11,9 @@
"websocketConnecting": "WebSocket 连接中",
"websocketConnected": "WebSocket 连接成功",
"websocketDisconnected": "WebSocket 连接断开",
"processing": "处理中..."
"processing": "处理中...",
"noServers": "暂无服务器",
"noMatchingServers": "没有符合当前筛选条件的服务器"
},
"serverOverview": {
"totalServers": "服务器总数",
+3 -1
View File
@@ -11,7 +11,9 @@
"websocketConnecting": "WebSocket 連線中",
"websocketConnected": "WebSocket 連線成功",
"websocketDisconnected": "WebSocket 連線中斷",
"processing": "處理中..."
"processing": "處理中...",
"noServers": "暫無伺服器",
"noMatchingServers": "沒有符合目前篩選條件的伺服器"
},
"serverOverview": {
"totalServers": "總伺服器",
+37 -7
View File
@@ -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
now={nezhaWsData.now}
serverList={nezhaWsData?.servers || []}
/>
{hasServers && showMap === "1" && (
<GlobalMap now={nezhaWsData.now} serverList={nezhaWsData.servers} />
)}
{showServices === "1" && <ServiceTracker serverList={filteredServers} />}
{hasServers && showServices === "1" && (
<ServiceTracker serverList={filteredServers} />
)}
{!hasServers ? (
<ServerEmptyState />
) : filteredServers.length > 0 ? (
<VirtualServerList
inline={inline === "1"}
now={nezhaWsData.now}
servers={filteredServers}
/>
) : (
<ServerEmptyState filtered />
)}
</div>
);
}
+45 -4
View File
@@ -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 />);
+39
View File
@@ -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({