Chore: handle cloudflare breaking API change dropping connections (#7217)

This commit is contained in:
shamoon
2026-10-05 20:56:01 -07:00
committed by GitHub
parent d41f56feb5
commit 8ec79250e6
4 changed files with 93 additions and 40 deletions
+7 -4
View File
@@ -6,9 +6,10 @@ export default function Component({ service }) {
const { widget } = service; const { widget } = service;
const { data: statsData, error: statsError } = useWidgetAPI(widget, "cfd_tunnel"); const { data: statsData, error: statsError } = useWidgetAPI(widget, "cfd_tunnel");
const { data: connectionsData, error: connectionsError } = useWidgetAPI(widget, "connections");
if (statsError) { if (statsError || connectionsError) {
return <Container service={service} error={statsError} />; return <Container service={service} error={statsError ?? connectionsError} />;
} }
if (!statsData) { if (!statsData) {
@@ -20,7 +21,9 @@ export default function Component({ service }) {
); );
} }
const originIP = statsData.result.connections?.origin_ip ?? statsData.result.connections[0]?.origin_ip; const originIP = connectionsData?.result
.flatMap((connector) => connector.conns ?? [])
.find((connection) => connection.origin_ip)?.origin_ip;
return ( return (
<Container service={service}> <Container service={service}>
@@ -28,7 +31,7 @@ export default function Component({ service }) {
label="cloudflared.status" label="cloudflared.status"
value={statsData.result.status.charAt(0).toUpperCase() + statsData.result.status.slice(1)} value={statsData.result.status.charAt(0).toUpperCase() + statsData.result.status.slice(1)}
/> />
<Block label="cloudflared.origin_ip" value={originIP} /> <Block label="cloudflared.origin_ip" value={connectionsData ? (originIP ?? "-") : undefined} />
</Container> </Container>
); );
} }
+67 -33
View File
@@ -4,59 +4,93 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers"; import { renderWithProviders } from "test-utils/render-with-providers";
import { expectBlockValue } from "test-utils/widget-assertions"; import { expectBlockValue, findServiceBlockByLabel } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() })); const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI })); vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component"; import Component from "./component";
describe("widgets/cloudflared/component", () => { describe("widgets/cloudflared/component", () => {
beforeEach(() => { beforeEach(() => vi.resetAllMocks());
vi.clearAllMocks();
});
it("renders placeholders while loading", () => { function mockResponses(stats, connections) {
useWidgetAPI.mockReturnValue({ data: undefined, error: undefined }); useWidgetAPI.mockImplementation((_widget, endpoint) => {
if (endpoint === "cfd_tunnel") return stats;
if (endpoint === "connections") return connections;
throw new Error(`Unexpected endpoint: ${endpoint}`);
});
}
const { container } = renderWithProviders(<Component service={{ widget: { type: "cloudflared" } }} />, { function renderWidget() {
return renderWithProviders(<Component service={{ widget: { type: "cloudflared" } }} />, {
settings: { hideErrors: false }, settings: { hideErrors: false },
}); });
}
expect(container.querySelectorAll(".service-block")).toHaveLength(2); it("renders placeholders while both requests are loading", () => {
mockResponses({ data: undefined }, { data: undefined });
const { container } = renderWidget();
expect(container.querySelectorAll(".service-block.animate-pulse")).toHaveLength(2);
expect(screen.getByText("cloudflared.status")).toBeInTheDocument(); expect(screen.getByText("cloudflared.status")).toBeInTheDocument();
expect(screen.getByText("cloudflared.origin_ip")).toBeInTheDocument(); expect(screen.getByText("cloudflared.origin_ip")).toBeInTheDocument();
}); });
it("renders status capitalization and origin_ip from nested connections", () => { it("renders status without the retired connections field and reads connector IPs", () => {
useWidgetAPI.mockReturnValue({ mockResponses(
data: { { data: { result: { status: "healthy" } } },
result: { status: "healthy", connections: { origin_ip: "1.2.3.4" } }, { data: { result: [{ conns: [{ origin_ip: "1.2.3.4" }] }] } },
}, );
error: undefined, const { container } = renderWidget();
});
const { container } = renderWithProviders(<Component service={{ widget: { type: "cloudflared" } }} />, {
settings: { hideErrors: false },
});
expectBlockValue(container, "cloudflared.status", "Healthy"); expectBlockValue(container, "cloudflared.status", "Healthy");
expectBlockValue(container, "cloudflared.origin_ip", "1.2.3.4"); expectBlockValue(container, "cloudflared.origin_ip", "1.2.3.4");
}); });
it("falls back to origin_ip from first connection entry", () => { it("finds the first available IP across connectors and connections", () => {
useWidgetAPI.mockReturnValue({ mockResponses(
data: { { data: { result: { status: "degraded", connections: [{ origin_ip: "old-ip" }] } } },
result: { status: "down", connections: [{ origin_ip: "5.6.7.8" }] }, { data: { result: [{}, { conns: [] }, { conns: [{}, { origin_ip: "5.6.7.8" }, { origin_ip: "9.10.11.12" }] }] } },
}, );
error: undefined, const { container } = renderWidget();
}); expectBlockValue(container, "cloudflared.status", "Degraded");
const { container } = renderWithProviders(<Component service={{ widget: { type: "cloudflared" } }} />, {
settings: { hideErrors: false },
});
expectBlockValue(container, "cloudflared.origin_ip", "5.6.7.8"); expectBlockValue(container, "cloudflared.origin_ip", "5.6.7.8");
expect(container.textContent).not.toContain("old-ip");
});
it.each([
{ connectors: [] },
{ connectors: [{}] },
{ connectors: [{ conns: [] }] },
{ connectors: [{ conns: [{}] }] },
])("renders a settled placeholder when no connector has an IP ($connectors)", ({ connectors }) => {
mockResponses({ data: { result: { status: "down" } } }, { data: { result: connectors } });
const { container } = renderWidget();
expectBlockValue(container, "cloudflared.status", "Down");
expectBlockValue(container, "cloudflared.origin_ip", "-");
expect(findServiceBlockByLabel(container, "cloudflared.origin_ip")).not.toHaveClass("animate-pulse");
});
it("renders status while connector details are loading", () => {
mockResponses({ data: { result: { status: "healthy" } } }, { data: undefined });
const { container } = renderWidget();
expectBlockValue(container, "cloudflared.status", "Healthy");
expect(findServiceBlockByLabel(container, "cloudflared.origin_ip")).toHaveClass("animate-pulse");
});
it("keeps placeholders when connectors load before tunnel status", () => {
mockResponses({ data: undefined }, { data: { result: [{ conns: [{ origin_ip: "1.2.3.4" }] }] } });
const { container } = renderWidget();
expect(container.querySelectorAll(".service-block.animate-pulse")).toHaveLength(2);
});
it.each(["cfd_tunnel", "connections"])("shows errors from the %s request", (failedEndpoint) => {
useWidgetAPI.mockImplementation((_widget, endpoint) =>
endpoint === failedEndpoint
? { error: { message: "Cloudflare request failed" } }
: { data: endpoint === "cfd_tunnel" ? { result: { status: "healthy" } } : { result: [] } },
);
const { container } = renderWidget();
expect(container.querySelectorAll(".service-block")).toHaveLength(0);
expect(screen.getByText(/Cloudflare request failed/)).toBeInTheDocument();
}); });
}); });
+6 -2
View File
@@ -1,12 +1,16 @@
import credentialedProxyHandler from "utils/proxy/handlers/credentialed"; import credentialedProxyHandler from "utils/proxy/handlers/credentialed";
const widget = { const widget = {
api: "https://api.cloudflare.com/client/v4/accounts/{accountid}/{endpoint}/{tunnelid}", api: "https://api.cloudflare.com/client/v4/accounts/{accountid}/{endpoint}",
proxyHandler: credentialedProxyHandler, proxyHandler: credentialedProxyHandler,
mappings: { mappings: {
cfd_tunnel: { cfd_tunnel: {
endpoint: "cfd_tunnel", endpoint: "cfd_tunnel/{tunnelid}",
validate: ["success", "result"],
},
connections: {
endpoint: "cfd_tunnel/{tunnelid}/connections",
validate: ["success", "result"], validate: ["success", "result"],
}, },
}, },
+13 -1
View File
@@ -1,6 +1,7 @@
import { describe, it } from "vitest"; import { describe, expect, it } from "vitest";
import { expectWidgetConfigShape } from "test-utils/widget-config"; import { expectWidgetConfigShape } from "test-utils/widget-config";
import { formatApiCall } from "utils/proxy/api-helpers";
import widget from "./widget"; import widget from "./widget";
@@ -8,4 +9,15 @@ describe("cloudflared widget config", () => {
it("exports a valid widget config", () => { it("exports a valid widget config", () => {
expectWidgetConfigShape(widget); expectWidgetConfigShape(widget);
}); });
it.each([
["cfd_tunnel", "cfd_tunnel/tunnel-id"],
["connections", "cfd_tunnel/tunnel-id/connections"],
])("routes %s to the account and tunnel", (endpoint, path) => {
const mapping = widget.mappings[endpoint];
expect(
formatApiCall(widget.api, { accountid: "account-id", tunnelid: "tunnel-id", endpoint: mapping.endpoint }),
).toBe(`https://api.cloudflare.com/client/v4/accounts/account-id/${path}`);
expect(mapping.validate).toEqual(["success", "result"]);
});
}); });