mirror of
https://github.com/gethomepage/homepage.git
synced 2026-10-15 15:09:56 -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 { 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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"]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user