mirror of
https://github.com/gethomepage/homepage.git
synced 2026-10-15 23:19:57 -07:00
Chore: handle cloudflare breaking API change dropping connections (#7217)
This commit is contained in:
@@ -6,9 +6,10 @@ export default function Component({ service }) {
|
||||
const { widget } = service;
|
||||
|
||||
const { data: statsData, error: statsError } = useWidgetAPI(widget, "cfd_tunnel");
|
||||
const { data: connectionsData, error: connectionsError } = useWidgetAPI(widget, "connections");
|
||||
|
||||
if (statsError) {
|
||||
return <Container service={service} error={statsError} />;
|
||||
if (statsError || connectionsError) {
|
||||
return <Container service={service} error={statsError ?? connectionsError} />;
|
||||
}
|
||||
|
||||
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 (
|
||||
<Container service={service}>
|
||||
@@ -28,7 +31,7 @@ export default function Component({ service }) {
|
||||
label="cloudflared.status"
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,59 +4,93 @@ import { screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
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() }));
|
||||
|
||||
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
|
||||
|
||||
import Component from "./component";
|
||||
|
||||
describe("widgets/cloudflared/component", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
beforeEach(() => vi.resetAllMocks());
|
||||
|
||||
it("renders placeholders while loading", () => {
|
||||
useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });
|
||||
function mockResponses(stats, connections) {
|
||||
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 },
|
||||
});
|
||||
}
|
||||
|
||||
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.origin_ip")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders status capitalization and origin_ip from nested connections", () => {
|
||||
useWidgetAPI.mockReturnValue({
|
||||
data: {
|
||||
result: { status: "healthy", connections: { origin_ip: "1.2.3.4" } },
|
||||
},
|
||||
error: undefined,
|
||||
});
|
||||
|
||||
const { container } = renderWithProviders(<Component service={{ widget: { type: "cloudflared" } }} />, {
|
||||
settings: { hideErrors: false },
|
||||
});
|
||||
|
||||
it("renders status without the retired connections field and reads connector IPs", () => {
|
||||
mockResponses(
|
||||
{ data: { result: { status: "healthy" } } },
|
||||
{ data: { result: [{ conns: [{ origin_ip: "1.2.3.4" }] }] } },
|
||||
);
|
||||
const { container } = renderWidget();
|
||||
expectBlockValue(container, "cloudflared.status", "Healthy");
|
||||
expectBlockValue(container, "cloudflared.origin_ip", "1.2.3.4");
|
||||
});
|
||||
|
||||
it("falls back to origin_ip from first connection entry", () => {
|
||||
useWidgetAPI.mockReturnValue({
|
||||
data: {
|
||||
result: { status: "down", connections: [{ origin_ip: "5.6.7.8" }] },
|
||||
},
|
||||
error: undefined,
|
||||
});
|
||||
|
||||
const { container } = renderWithProviders(<Component service={{ widget: { type: "cloudflared" } }} />, {
|
||||
settings: { hideErrors: false },
|
||||
});
|
||||
|
||||
it("finds the first available IP across connectors and connections", () => {
|
||||
mockResponses(
|
||||
{ data: { result: { status: "degraded", connections: [{ origin_ip: "old-ip" }] } } },
|
||||
{ data: { result: [{}, { conns: [] }, { conns: [{}, { origin_ip: "5.6.7.8" }, { origin_ip: "9.10.11.12" }] }] } },
|
||||
);
|
||||
const { container } = renderWidget();
|
||||
expectBlockValue(container, "cloudflared.status", "Degraded");
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import credentialedProxyHandler from "utils/proxy/handlers/credentialed";
|
||||
|
||||
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,
|
||||
|
||||
mappings: {
|
||||
cfd_tunnel: {
|
||||
endpoint: "cfd_tunnel",
|
||||
endpoint: "cfd_tunnel/{tunnelid}",
|
||||
validate: ["success", "result"],
|
||||
},
|
||||
connections: {
|
||||
endpoint: "cfd_tunnel/{tunnelid}/connections",
|
||||
validate: ["success", "result"],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, it } from "vitest";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { expectWidgetConfigShape } from "test-utils/widget-config";
|
||||
import { formatApiCall } from "utils/proxy/api-helpers";
|
||||
|
||||
import widget from "./widget";
|
||||
|
||||
@@ -8,4 +9,15 @@ describe("cloudflared widget config", () => {
|
||||
it("exports a valid widget config", () => {
|
||||
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"]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user