Show an error with retry, not an empty list, when packets, channels, messages or adverts fail to load (#178)

This commit is contained in:
MrAlders0n authored and GitHub committed 2026-10-20 02:32:11 -04:00
1 parent b8d404b63b
commit 008b2afad8
9 files changed
+74 -9

No files matched your search

+1 -1
View File
@@ -253,7 +253,7 @@ export function ChannelList({ wsManager, onAnalyze }: ChannelListProps) {
onSelect={handleSelect}
/>
)}
{!keyedLoading && filteredChannels.length === 0 && (
{!keyedLoading && filteredChannels.length === 0 && !keyed.isError && !others.isError && (
<p className="px-3 py-2 text-xs font-mono text-text-muted">{t("channels.noMatches")}</p>
)}
{(keyed.isError || others.isError) && <p role="alert" className="px-3 py-2 text-xs text-danger">{t("channels.loadError")}</p>}
+1 -1
View File
@@ -223,7 +223,7 @@ export function MessagePanel({ channel, heardCounts, iatas, regionKey, onAnalyze
<MessageRow key={msg.packetHash || msg.id} msg={msg} heardCount={heardCounts[msg.packetHash]} onAnalyze={onAnalyze} />
))}
</div>
) : (
) : isError ? null : (
<div className="flex items-center justify-center h-32 text-text-muted text-xs font-mono">
{t(!channel.keyKnown ? "channelMessages.emptyNoKey" : scope ? "channelMessages.emptyScope" : "channelMessages.empty", { scope })}
</div>
+7 -2
View File
@@ -31,7 +31,7 @@ function AdvertRow({ advert, onClick }: { advert: AdvertObservation; onClick: ()
export function ObserverAdverts({ observerId, onAnalyzePacket }: { observerId: string; onAnalyzePacket: (hash: string, observationId: number) => void }) {
const { t } = useTranslation();
const { data: adverts, isPending } = useQuery({
const { data: adverts, isPending, isError, isFetching, refetch } = useQuery({
queryKey: ["observer-adverts", observerId],
queryFn: () => getObserverAdverts(observerId, { limit: 50 }),
staleTime: 30_000,
@@ -41,7 +41,12 @@ export function ObserverAdverts({ observerId, onAnalyzePacket }: { observerId: s
<summary className="cursor-pointer font-mono text-[11px] font-semibold uppercase tracking-wider text-text-normal">{t("observerPage.advertsHeard")}</summary>
<div className="mt-2.5">
{isPending ? <p role="status" className="text-sm text-text-muted">{t("common.loading")}</p>
: adverts?.items.length ? (
: isError && !adverts?.items.length ? (
<p role="alert" className="font-mono text-[13px] text-danger">
{t("common.loadFailed")}{" · "}
<button type="button" disabled={isFetching} className="cursor-pointer underline disabled:opacity-40" onClick={() => void refetch()}>{t("observerPage.retry")}</button>
</p>
) : adverts?.items.length ? (
<div className="grid gap-1.5 md:grid-cols-2">
{adverts.items.map(a => <AdvertRow key={a.id} advert={a} onClick={() => onAnalyzePacket(a.packetHash, a.id)} />)}
</div>
+8 -1
View File
@@ -70,6 +70,7 @@ export function PacketList({ wsManager, onAnalyze, onViewPath, selectedObservati
isFetchingNextPage,
isLoading,
isError,
refetch,
observersByHash,
handlePacketObservation,
handleLagged,
@@ -232,6 +233,12 @@ export function PacketList({ wsManager, onAnalyze, onViewPath, selectedObservati
{isLoading && packets.length === 0 ? (
<SkeletonRows />
) : isError && allPackets.length === 0 ? (
// a failed history load is not an empty region: say so and offer a retry
<p role="alert" className="py-4 text-center text-xs font-mono text-danger">
{t("loadingPill.packets.failed")}{" · "}
<button type="button" disabled={isFetching} className="underline cursor-pointer disabled:opacity-40" onClick={() => void refetch()}>{t("packetList.retry")}</button>
</p>
) : (
<PacketVirtualList
key={listResetKey}
@@ -251,7 +258,7 @@ export function PacketList({ wsManager, onAnalyze, onViewPath, selectedObservati
)}
<LoadingPill
loading={isLoading || isFetchingNextPage}
error={isError}
error={isError && allPackets.length > 0}
count={packets.length}
noun="packets"
position="bottom-3 right-3"
+2
View File
@@ -215,6 +215,7 @@ export function usePackets(frozen: boolean = false, serverFilter: PacketServerFi
isFetchingNextPage,
isLoading,
isError,
refetch,
} = useInfiniteQuery({
// The unfiltered key must stay 2-element so its cached entry survives filter toggling; the
// lagged/mount resets above match both shapes by prefix.
@@ -262,6 +263,7 @@ export function usePackets(frozen: boolean = false, serverFilter: PacketServerFi
isFetchingNextPage,
isLoading: isLoading || pending,
isError,
refetch,
observersByHash,
handlePacketObservation,
handleLagged,
@@ -117,6 +117,14 @@ describe("channel directory", () => {
expect(screen.queryByText("#Channel 1")).not.toBeInTheDocument();
});
it("shows the load error, not an empty list, when the first page fails", async () => {
keyed(new Error("unavailable"));
show();
expect(await screen.findByRole("alert")).toHaveTextContent("Could not load channels.");
expect(screen.queryByText("No matching channels loaded.")).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Retry loading channels" })).toBeInTheDocument();
});
it("can retry an initial failure", async () => {
keyed(new Error("temporary failure"), page([channel(1)]));
show();
+19 -1
View File
@@ -1,9 +1,10 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { act, render, screen } from "@testing-library/react";
import { act, fireEvent, render, screen } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { MessagePanel } from "../../../src/features/channels/MessagePanel";
import type { ChannelMessage, ChannelSummary } from "../../../src/features/channels/types";
import i18n from "../../../src/i18n";
import { getChannelMessagesPage } from "../../../src/api/client";
// Older live WS messages have no id — preserve that compatible runtime shape.
const restMsg: ChannelMessage = {
@@ -149,3 +150,20 @@ describe("MessagePanel live follow", () => {
expect(scrollTo).toHaveBeenCalled();
});
});
describe("MessagePanel load failure", () => {
it("shows the error and retry instead of the empty state", async () => {
await i18n.changeLanguage("en");
vi.mocked(getChannelMessagesPage).mockRejectedValueOnce(Object.assign(new Error("unavailable"), { status: 503 }));
render(
<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>
<MessagePanel channel={channel} heardCounts={{}} regionKey="*" scope="#ops" />
</QueryClientProvider>,
);
expect(await screen.findByRole("alert")).toHaveTextContent("Could not load messages.");
expect(screen.queryByText(/No messages/)).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
expect(await screen.findByText("live two")).toBeInTheDocument();
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
});
});
@@ -42,4 +42,15 @@ describe("ObserverAdverts", () => {
renderAdverts();
expect(await screen.findByText("No adverts heard")).toBeInTheDocument();
});
it("shows an error with a retry instead of the empty state when the request fails", async () => {
mockGetObserverAdverts.mockRejectedValueOnce(Object.assign(new Error("unavailable"), { status: 503 }));
mockGetObserverAdverts.mockResolvedValueOnce({ items: [advert(1, "Node Alpha")], nextCursor: null, hasMore: false });
renderAdverts();
expect(await screen.findByRole("alert")).toHaveTextContent("Failed to load");
expect(screen.queryByText("No adverts heard")).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
expect(await screen.findByText("Node Alpha")).toBeInTheDocument();
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
});
});
+17 -3
View File
@@ -19,6 +19,7 @@ const basePackets = () => ({
isFetchingNextPage: false,
isLoading: false,
isError: false,
refetch: vi.fn(),
observersByHash: new Map(),
handlePacketObservation: () => {},
handleLagged: () => {},
@@ -180,11 +181,24 @@ describe("PacketList loading feedback", () => {
expect(screen.getByRole("status").textContent).toContain("Loading packets… (1)");
});
it("surfaces a failed history fetch through the pill", () => {
usePackets.mockImplementation(() => ({ ...basePackets(), isError: true }));
it("shows an error with a retry, not an empty list, when history fails with nothing loaded", () => {
const refetch = vi.fn();
usePackets.mockImplementation(() => ({ ...basePackets(), isError: true, refetch }));
renderList();
expect(screen.getByRole("status").textContent).toContain("Failed to load packets");
expect(screen.getByRole("alert")).toHaveTextContent("Failed to load packets");
expect(screen.queryByTestId("expanded")).toBeNull();
expect(screen.queryByRole("status")).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
expect(refetch).toHaveBeenCalledTimes(1);
});
it("keeps loaded packets and surfaces a later history failure through the pill", () => {
usePackets.mockImplementation(() => ({ ...basePackets(), allPackets: [packet("h1")], isError: true }));
renderList();
expect(screen.getByTestId("expanded")).toBeTruthy();
expect(screen.getByRole("status").textContent).toContain("Some packets failed to load (1 shown)");
});
it("renders neither pill nor skeletons when idle", () => {