diff --git a/Dockerfile b/Dockerfile index 7cee7422..b8684688 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # build stage 1: frontend -FROM node:16 AS frontend +FROM node:24 AS frontend WORKDIR /build COPY ./frontend/package.json ./frontend/package-lock.json /build/ diff --git a/frontend/__tests__/App.test.tsx b/frontend/__tests__/App.test.tsx index e43f88a2..6ba9256e 100644 --- a/frontend/__tests__/App.test.tsx +++ b/frontend/__tests__/App.test.tsx @@ -1,3 +1,4 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { act, fireEvent, @@ -11,6 +12,16 @@ import nock from "nock"; import React from "react"; import App from "../components/App"; +const renderWithClient = (ui: React.ReactElement) => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + const Wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + return render(ui, { wrapper: Wrapper }); +}; + jest.mock("next/router", () => require("next-router-mock")); // -- Mock GraphVisualization @@ -82,53 +93,66 @@ describe("Streams Explorer", () => { describe("renders", () => { mockBackendGraph(true); it("without crashing", async () => { - const { findByTestId } = render(); + const { findByTestId } = renderWithClient(); await findByTestId("graph"); }); }); describe("handles url parameters", () => { - // -- Mock backend endpoints - const nockGraph = mockBackendGraph(true); - const nockPipelineGraph = mockBackendGraph(true, "test-pipeline"); - - nock("http://localhost") - .persist() - .get("/api/pipelines") - .reply(200, { - pipelines: ["test-pipeline"], - }); + let nockGraph: ReturnType; + let nockPipelineGraph: ReturnType; - nock("http://localhost") - .persist() - .get("/api/metrics") - .reply(200, [ - { - node_id: "test-app", - messages_in: null, - messages_out: null, - consumer_lag: null, - consumer_read_rate: null, - topic_size: null, - replicas: null, - connector_tasks: null, - }, - { - node_id: "test-topic", - messages_in: null, - messages_out: null, - consumer_lag: null, - consumer_read_rate: null, - topic_size: null, - replicas: null, - connector_tasks: null, - }, - ]); + beforeEach(() => { + mockRouter.setCurrentUrl("/"); + + // Mock backend endpoints + nockGraph = mockBackendGraph(true); + nockPipelineGraph = mockBackendGraph(true, "test-pipeline"); + + nock("http://localhost") + .persist() + .get("/api/pipelines") + .reply(200, { + pipelines: ["test-pipeline"], + }); + + nock("http://localhost") + .persist() + .get("/api/metrics") + .reply(200, [ + { + node_id: "test-app", + messages_in: null, + messages_out: null, + consumer_lag: null, + consumer_read_rate: null, + topic_size: null, + replicas: null, + connector_tasks: null, + }, + { + node_id: "test-topic", + messages_in: null, + messages_out: null, + consumer_lag: null, + consumer_read_rate: null, + topic_size: null, + replicas: null, + connector_tasks: null, + }, + ]); + }); + + afterEach(() => { + nock.cleanAll(); + }); it("should set pipeline from url parameter", async () => { mockRouter.setCurrentUrl("/?pipeline=test-pipeline"); - const { getByTestId, findByTestId, asFragment } = render(); + const { getByTestId, findByTestId, asFragment } = renderWithClient( + + ); expect(singletonRouter).toMatchObject({ asPath: "/?pipeline=test-pipeline", @@ -161,7 +185,7 @@ describe("Streams Explorer", () => { info: [], }); - const { findByTestId, getByTestId } = render(); + const { findByTestId, getByTestId } = renderWithClient(); expect(singletonRouter).toMatchObject({ asPath: "/?focus-node=test-app", @@ -178,14 +202,14 @@ describe("Streams Explorer", () => { const input = within(nodeSelect).getByRole( "combobox" ) as HTMLInputElement; - expect(input).toHaveValue("test-app-name"); // shows label + await waitFor(() => expect(input).toHaveValue("test-app-name")); expect(nockAppNode.isDone()).toBeTruthy(); }); it("should render without url parameters", async () => { mockRouter.setCurrentUrl("/"); - const { getByTestId, findByTestId } = render(); + const { getByTestId, findByTestId } = renderWithClient(); expect(singletonRouter).toMatchObject({ asPath: "/", @@ -209,9 +233,8 @@ describe("Streams Explorer", () => { mockRouter.setCurrentUrl("/?pipeline=test-pipeline"); // render App - const { getByTestId, getByText, findByTestId, findAllByTestId } = render( - - ); + const { getByTestId, getByText, findByTestId, findAllByTestId } = + renderWithClient(); await findByTestId("graph"); const nodeSelect = getByTestId("node-select"); @@ -307,11 +330,9 @@ describe("Streams Explorer", () => { .post(`/api/update`) .reply(200); - mockBackendGraph(true); - mockRouter.setCurrentUrl("/?pipeline=doesnt-exist"); - const { findByTestId } = render(); + const { findByTestId } = renderWithClient(); expect(singletonRouter).toMatchObject({ asPath: "/?pipeline=doesnt-exist", @@ -338,33 +359,29 @@ describe("Streams Explorer", () => { }); it("should update and retry if pipeline is not found", async () => { - let nockPipeline = nock("http://localhost") + const nockPipeline404 = nock("http://localhost") .get(`/api/graph?pipeline_name=avail-after-scrape`) .reply(404); + const nockPipeline200 = mockBackendGraph(false, "avail-after-scrape"); + const nockUpdate = nock("http://localhost") .post(`/api/update`) .reply(200); - mockBackendGraph(true); - mockRouter.setCurrentUrl("/?pipeline=avail-after-scrape"); - const { getByTestId, findByTestId } = render(); + const { getByTestId, findByTestId } = renderWithClient(); expect(singletonRouter.asPath).toBe("/?pipeline=avail-after-scrape"); - await waitFor(() => { - // wait for the first pipeline request to fail - expect(nockPipeline.isDone()).toBeTruthy(); - // pipeline becomes available - nockPipeline = mockBackendGraph(true, "avail-after-scrape"); - }); - await findByTestId("graph"); - expect(nockUpdate.isDone()).toBeTruthy(); - expect(nockPipeline.isDone()).toBeTruthy(); + await waitFor(() => { + expect(nockUpdate.isDone()).toBeTruthy(); + expect(nockPipeline404.isDone()).toBeTruthy(); + expect(nockPipeline200.isDone()).toBeTruthy(); + }); await waitFor(() => { const currentPipeline = getByTestId("pipeline-current"); expect( @@ -376,9 +393,7 @@ describe("Streams Explorer", () => { }); it("should persist metrics refresh interval across page reloads", async () => { - mockBackendGraph(true); - - const { findByText, findByTestId, rerender } = render(); + const { findByText, findByTestId, rerender } = renderWithClient(); await findByTestId("graph"); @@ -406,7 +421,6 @@ describe("Streams Explorer", () => { }); it("should not fetch metrics if interval is set to 'off'", async () => { - mockBackendGraph(true); const nockMetrics = nock("http://localhost") .get("/api/metrics") .reply(200, []); @@ -414,7 +428,7 @@ describe("Streams Explorer", () => { // set metrics refresh interval to 'off' window.localStorage.setItem("metrics-interval", "0"); - const { findByTestId, getByText } = render(); + const { findByTestId, getByText } = renderWithClient(); await findByTestId("graph"); @@ -424,6 +438,19 @@ describe("Streams Explorer", () => { // verify metrics haven't been refreshed expect(nockMetrics.isDone()).toBeFalsy(); }); + + it("should not show metrics loading spinner if interval is set to 'off'", async () => { + // set metrics refresh interval to 'off' + window.localStorage.setItem("metrics-interval", "0"); + + const { findByTestId, queryByTestId } = renderWithClient(); + + await findByTestId("graph"); + + // The metrics spinner should not be spinning + const metricsSpinner = queryByTestId("metrics-spinner"); + expect(metricsSpinner).not.toBeInTheDocument(); + }); }); }); diff --git a/frontend/__tests__/Details.test.tsx b/frontend/__tests__/Details.test.tsx index 9120461a..39374b1c 100644 --- a/frontend/__tests__/Details.test.tsx +++ b/frontend/__tests__/Details.test.tsx @@ -1,6 +1,6 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import nock from "nock"; import React from "react"; -import { RestfulProvider } from "restful-react"; import { fireEvent, @@ -10,6 +10,15 @@ import { } from "@testing-library/react"; import Details from "../components/Details"; +const renderWithClient = (ui: React.ReactElement) => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return render( + {ui} + ); +}; + describe("display node information", () => { beforeAll(() => { Object.defineProperty(window, "matchMedia", { @@ -32,10 +41,8 @@ describe("display node information", () => { detail: 'Could not find information for node with name "fake-node"', }); - const { findByTestId, asFragment } = render( - -
- + const { findByTestId, asFragment } = renderWithClient( +
); await findByTestId("no-node-info"); @@ -49,10 +56,8 @@ describe("display node information", () => { info: [], }); - const { findByText, asFragment } = render( - -
- + const { findByText, asFragment } = renderWithClient( +
); await findByText("connector"); @@ -66,10 +71,8 @@ describe("display node information", () => { info: [], }); - const { findByText, asFragment } = render( - -
- + const { findByText, asFragment } = renderWithClient( +
); await findByText("connector"); @@ -103,12 +106,12 @@ describe("display node information", () => { .query({ link_type: "kibana" }) .reply( 200, - "http://localhost:5601/app/kibana#/discover?_a=(columns:!(_source),query:(language:lucene,query:'kubernetes.labels.app:%20%22atm-fraud-transactionavroproducer%22'))" + JSON.stringify( + "http://localhost:5601/app/kibana#/discover?_a=(columns:!(_source),query:(language:lucene,query:'kubernetes.labels.app:%20%22atm-fraud-transactionavroproducer%22'))" + ) ); - const { findByText, asFragment, queryByText } = render( - -
- + const { findByText, asFragment, queryByText } = renderWithClient( +
); await findByText("streaming-app"); @@ -196,17 +199,16 @@ describe("display node information", () => { ) .reply( 200, - "http://localhost:3000/d/path/to/dashboard?var-topics=atm-fraud-incoming-transactions-topic" + JSON.stringify( + "http://localhost:3000/d/path/to/dashboard?var-topics=atm-fraud-incoming-transactions-topic" + ) ); - const { getByText, findByText, getByTestId } = render( - -
- + const { getByText, findByText, getByTestId } = renderWithClient( +
); await findByText("v2"); // get dropdown menu for schema version - let schemaVersion = getByText("v2"); expect(nockSchema2.isDone()).toBeTruthy(); expect(nockSchema1.isDone()).toBeFalsy(); const schema2 = getByTestId("schema"); @@ -221,7 +223,7 @@ describe("display node information", () => { }); await waitFor(() => { - expect(schemaVersion).toHaveTextContent("v1"); + expect(getByTestId("schema-version")).toHaveTextContent("v1"); expect(nockSchema1.isDone()).toBeTruthy(); }); @@ -251,10 +253,8 @@ describe("display node information", () => { .get("/api/node/atm-fraud-incoming-transactions-topic/schema") .reply(404); - const { findByTestId } = render( - -
- + const { findByTestId } = renderWithClient( +
); await findByTestId("no-schema-versions"); @@ -279,10 +279,8 @@ describe("display node information", () => { .get("/api/node/atm-fraud-incoming-transactions-topic/schema") .reply(200, []); - const { findByTestId } = render( - -
- + const { findByTestId } = renderWithClient( +
); await findByTestId("no-schema-versions"); @@ -311,10 +309,8 @@ describe("display node information", () => { .get("/api/node/atm-fraud-incoming-transactions-topic/schema/1") .reply(404); - const { findByTestId } = render( - -
- + const { findByTestId } = renderWithClient( +
); await findByTestId("no-schema"); diff --git a/frontend/__tests__/DetailsCard.test.tsx b/frontend/__tests__/DetailsCard.test.tsx index 065e5c77..a07b8009 100644 --- a/frontend/__tests__/DetailsCard.test.tsx +++ b/frontend/__tests__/DetailsCard.test.tsx @@ -1,10 +1,19 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { render } from "@testing-library/react"; import nock from "nock"; import React from "react"; -import { RestfulProvider } from "restful-react"; import DetailsCard from "../components/DetailsCard"; import Node from "../components/graph/Node"; +const renderWithClient = (ui: React.ReactElement) => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return render( + {ui} + ); +}; + describe("display card for node details", () => { beforeAll(() => { Object.defineProperty(window, "matchMedia", { @@ -37,11 +46,7 @@ describe("display card for node details", () => { info: [], }); - const { queryByText } = render( - - - - ); + const { queryByText } = renderWithClient(); expect(queryByText("test-app-name - Details")).toBeInTheDocument(); }); diff --git a/frontend/__tests__/Search.test.tsx b/frontend/__tests__/Search.test.tsx index 809fdd7a..295d6d98 100644 --- a/frontend/__tests__/Search.test.tsx +++ b/frontend/__tests__/Search.test.tsx @@ -1,8 +1,19 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { fireEvent, render, within } from "@testing-library/react"; import nock from "nock"; import React from "react"; import App from "../components/App"; +const renderWithClient = (ui: React.ReactElement) => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + const Wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + return render(ui, { wrapper: Wrapper }); +}; + jest.mock("next/router", () => require("next-router-mock")); // -- Mock GraphVisualization @@ -81,7 +92,9 @@ describe("Search", () => { it("node icons", async () => { // render App - const { getByTestId, findByTestId, findAllByTestId } = render(); + const { getByTestId, findByTestId, findAllByTestId } = renderWithClient( + + ); await findByTestId("graph"); const nodeSelect = getByTestId("node-select"); diff --git a/frontend/__tests__/__snapshots__/App.test.tsx.snap b/frontend/__tests__/__snapshots__/App.test.tsx.snap index db8ee231..0cb5450b 100644 --- a/frontend/__tests__/__snapshots__/App.test.tsx.snap +++ b/frontend/__tests__/__snapshots__/App.test.tsx.snap @@ -220,30 +220,6 @@ exports[`Streams Explorer handles url parameters should set pipeline from url pa aria-hidden="true" style="display: none;" /> -
- - - -
diff --git a/frontend/components/App.tsx b/frontend/components/App.tsx index eeec143a..f3e6776c 100644 --- a/frontend/components/App.tsx +++ b/frontend/components/App.tsx @@ -12,13 +12,13 @@ import { import { useRouter } from "next/router"; import React, { useCallback, useEffect, useRef, useState } from "react"; import { useResizeDetector } from "react-resize-detector"; -import { useMutate } from "restful-react"; import { - HTTPValidationError, useGetMetricsApiMetricsGet, useGetPipelinesApiPipelinesGet, useGetPositionedGraphApiGraphGet, -} from "./api/fetchers"; + useUpdateApiUpdatePost, +} from "../lib/api/fetchers"; +import { HTTPValidationError } from "../lib/api/model"; import DetailsCard from "./DetailsCard"; import Node from "./graph/Node"; import GraphVisualization from "./graph/Visualization"; @@ -59,59 +59,54 @@ const App: React.FC = () => { localStorage.getItem(REFRESH_INTERVAL) || DEFAULT_REFRESH_INTERVAL ); setRefreshInterval(storedRefreshInterval); - if (storedRefreshInterval) { - refetchMetrics(); - } }, []); // eslint-disable-line react-hooks/exhaustive-deps useEffect(() => { localStorage.setItem(REFRESH_INTERVAL, refreshInterval.toString()); }, [refreshInterval]); - const { mutate: update, loading: isUpdating } = useMutate({ - verb: "POST", - path: "/api/update", - }); + const { + mutate: updateMutate, + mutateAsync: updateMutateAsync, + isLoading: isUpdating, + } = useUpdateApiUpdatePost(); + + const update = () => updateMutate(); const { data: graph, - loading: isLoadingGraph, + isLoading: isLoadingGraph, error: graphError, - refetch: graphRefetch, - } = useGetPositionedGraphApiGraphGet({ - queryParams: currentPipeline !== ALL_PIPELINES + } = useGetPositionedGraphApiGraphGet( + currentPipeline !== ALL_PIPELINES ? { pipeline_name: currentPipeline } - : undefined, - }); + : undefined + ); const { refetch: retryPipelineGraph, - error: retryPipelineGraphError, - data: retryPipelineGraphData, - } = useGetPositionedGraphApiGraphGet({ - queryParams: { pipeline_name: currentPipeline }, - lazy: true, - }); + } = useGetPositionedGraphApiGraphGet( + { pipeline_name: currentPipeline }, + { query: { enabled: false } } + ); const { data: pipelines, - loading: isLoadingPipelines, + isLoading: isLoadingPipelines, error: pipelineError, - } = useGetPipelinesApiPipelinesGet({}); + } = useGetPipelinesApiPipelinesGet(); const { data: metrics, - loading: isLoadingMetrics, + isFetching: isFetchingMetrics, refetch: refetchMetrics, error: metricsError, - } = useGetMetricsApiMetricsGet({ lazy: true }); - - useEffect(() => { - if (refreshInterval && refreshInterval > 0) { - const interval = setInterval(refetchMetrics, refreshInterval * 1000); - return () => clearInterval(interval); - } - }, [refreshInterval]); // eslint-disable-line react-hooks/exhaustive-deps + } = useGetMetricsApiMetricsGet({ + query: { + enabled: refreshInterval > 0, + refetchInterval: refreshInterval > 0 ? refreshInterval * 1000 : false, + }, + }); useEffect(() => { if (!graph) return; @@ -132,47 +127,34 @@ const App: React.FC = () => { }, [graph, query]); useEffect(() => { - if (graphError) { - let errorMessage: string | undefined; - if ("data" in graphError) { - // specific pipeline was not found - const data = graphError["data"] as HTTPValidationError; - if (data.detail) { - errorMessage = data.detail.toString(); - } - } - message.error(errorMessage || "Failed loading graph", 5); + if (!graphError) return; - if (graphError.status === 404 && currentPipeline !== ALL_PIPELINES) { - // check if a re-scrape solves it - const hideMessage = message.warning("Refreshing pipelines", 0); - update({}) - .then(() => { - retryPipelineGraph(); - }) - .catch(() => { - redirectAllPipelines(); - }) - .finally(() => { - hideMessage(); - }); + let errorMessage: string | undefined; + const err = graphError as any; + if (err?.data) { + const data = err.data as HTTPValidationError; + if (data.detail) { + errorMessage = data.detail.toString(); } } - }, [graphError]); // eslint-disable-line react-hooks/exhaustive-deps + message.error(errorMessage || "Failed loading graph", 5); - useEffect(() => { - if ( - retryPipelineGraphError - && retryPipelineGraphError.status === 404 - && currentPipeline !== ALL_PIPELINES - ) { - // pipeline still not found - redirectAllPipelines(); - } else if (retryPipelineGraphData) { - message.success("Found pipeline!"); - graphRefetch(); - } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [retryPipelineGraphError, retryPipelineGraphData]); + if (err?.status !== 404 || currentPipeline === ALL_PIPELINES) return; + + // Pipeline not found — scrape then retry, sequentially. + const hideMessage = message.warning("Refreshing pipelines", 0); + (async () => { + try { + await updateMutateAsync(); + await retryPipelineGraph(); + message.success("Found pipeline!"); + } catch { + redirectAllPipelines(); + } finally { + hideMessage(); + } + })(); + }, [graphError]); // eslint-disable-line react-hooks/exhaustive-deps const redirectAllPipelines = () => { message.info("Redirecting to all pipelines"); @@ -261,9 +243,10 @@ const App: React.FC = () => { key="3" style={{ float: "right", marginLeft: "auto" }} onClick={() => { - update({}) - .then(() => router.reload()) - .catch(() => message.error("Failed to update!")); + updateMutate(undefined, { + onSuccess: () => router.reload(), + onError: () => message.error("Failed to update!"), + }); }} >