diff --git a/frontend/src/components/common/icon/icons.jsx b/frontend/src/components/common/icon/icons.jsx
index 16240425cd..ce80dadf39 100644
--- a/frontend/src/components/common/icon/icons.jsx
+++ b/frontend/src/components/common/icon/icons.jsx
@@ -7,6 +7,7 @@ import {
MdComment,
MdFileDownload,
} from "react-icons/md";
+import { FaRegStopCircle } from "react-icons/fa";
// These function are needed in IconButton because it expects Icon as a function
@@ -53,3 +54,12 @@ export function downloadReportIcon() {
export function SpinnerIcon() {
return ;
}
+
+export function killJobIcon() {
+ return (
+
+
+ Kill job
+
+ );
+}
diff --git a/frontend/src/components/jobs/result/CustomJobPipelineNode.jsx b/frontend/src/components/jobs/result/CustomJobPipelineNode.jsx
new file mode 100644
index 0000000000..29fa2b4eef
--- /dev/null
+++ b/frontend/src/components/jobs/result/CustomJobPipelineNode.jsx
@@ -0,0 +1,62 @@
+import React from "react";
+import PropTypes from "prop-types";
+import { Handle, Position } from "reactflow";
+import "reactflow/dist/style.css";
+import { StatusIcon } from "../../common/icon/StatusIcon";
+
+function CustomJobPipelineNode({ data }) {
+ let statusIcon = "pending";
+ if (data.completed) statusIcon = "success";
+ else if (data.running) statusIcon = "running";
+
+ return (
+ <>
+
+
+
+
+ {data?.label} {data.running && "RUNNING"}
+ {data.completed && "COMPLETED"}{" "}
+
+ Reported {data.report}
+
+
+
+
+ >
+ );
+}
+
+CustomJobPipelineNode.propTypes = {
+ data: PropTypes.object.isRequired,
+};
+
+export default React.memo(CustomJobPipelineNode);
diff --git a/frontend/src/components/jobs/result/JobIsRunningAlert.jsx b/frontend/src/components/jobs/result/JobIsRunningAlert.jsx
index 5221160214..fea41d5e11 100644
--- a/frontend/src/components/jobs/result/JobIsRunningAlert.jsx
+++ b/frontend/src/components/jobs/result/JobIsRunningAlert.jsx
@@ -1,17 +1,33 @@
+/* eslint-disable id-length */
import React from "react";
import PropTypes from "prop-types";
-import { Fade } from "reactstrap";
-import { MdPauseCircleOutline } from "react-icons/md";
+import ReactFlow, { MarkerType } from "reactflow";
+import "reactflow/dist/style.css";
+import { IconButton } from "@certego/certego-ui";
-import { IconAlert, IconButton } from "@certego/certego-ui";
-
-import { killJob } from "./jobApi";
+import CustomJobPipelineNode from "./CustomJobPipelineNode";
import { JobStatuses } from "../../../constants/jobConst";
+import { areYouSureConfirmDialog } from "../../common/areYouSureConfirmDialog";
import {
reportedPluginNumber,
reportedVisualizerNumber,
} from "./utils/reportedPlugins";
+import { killJob } from "./jobApi";
+import { killJobIcon } from "../../common/icon/icons";
+
+// Important! This must be defined outside of the component
+const nodeTypes = {
+ jobPipelineNode: CustomJobPipelineNode,
+};
+
+const defaultEdgeOptions = {
+ style: { strokeWidth: 3 },
+ type: "step",
+ markerEnd: {
+ type: MarkerType.ArrowClosed,
+ },
+};
export function JobIsRunningAlert({ job }) {
// number of analyzers/connectors/visualizers reported (status: killed/succes/failed)
@@ -41,75 +57,125 @@ export function JobIsRunningAlert({ job }) {
.slice(9)
.includes(job.status);
- const alertElements = [
+ const nodes = [
+ {
+ id: `isRunningJob-analyzers`,
+ position: { x: 0, y: 0 },
+ data: {
+ id: "step-1",
+ label: "ANALYZERS",
+ running: job.status === JobStatuses.ANALYZERS_RUNNING,
+ completed:
+ analizersReported === job.analyzers_to_execute.length &&
+ analyzersCompleted,
+ report: `${analizersReported}/${job.analyzers_to_execute.length}`,
+ },
+ type: "jobPipelineNode",
+ draggable: false,
+ },
+ {
+ id: `isRunningJob-connectors`,
+ position: { x: 450, y: 0 },
+ data: {
+ id: "step-2",
+ label: "CONNECTORS",
+ running: job.status === JobStatuses.CONNECTORS_RUNNING,
+ completed:
+ connectorsReported === job.connectors_to_execute.length &&
+ connectorsCompleted,
+ report: `${connectorsReported}/${job.connectors_to_execute.length}`,
+ },
+ type: "jobPipelineNode",
+ draggable: false,
+ },
+ {
+ id: `isRunningJob-pivots`,
+ position: { x: 900, y: 0 },
+ data: {
+ id: "step-3",
+ label: "PIVOTS",
+ running: job.status === JobStatuses.PIVOTS_RUNNING,
+ completed:
+ pivotsReported === job.pivots_to_execute.length && pivotsCompleted,
+ report: `${pivotsReported}/${job.pivots_to_execute.length}`,
+ },
+ type: "jobPipelineNode",
+ draggable: false,
+ },
{
- step: 1,
- type: "ANALYZERS",
- completed:
- analizersReported === job.analyzers_to_execute.length &&
- analyzersCompleted,
- report: `${analizersReported}/${job.analyzers_to_execute.length}`,
+ id: `isRunningJob-visualizers`,
+ position: { x: 1350, y: 0 },
+ data: {
+ id: "step-4",
+ label: "VISUALIZERS",
+ running: job.status === JobStatuses.VISUALIZERS_RUNNING,
+ completed:
+ visualizersReported === job.visualizers_to_execute.length &&
+ visualizersCompleted,
+ report: `${visualizersReported}/${job.visualizers_to_execute.length}`,
+ },
+ type: "jobPipelineNode",
+ draggable: false,
},
+ ];
+
+ const edges = [
{
- step: 2,
- type: "CONNECTORS",
- completed:
- connectorsReported === job.connectors_to_execute.length &&
- connectorsCompleted,
- report: `${connectorsReported}/${job.connectors_to_execute.length}`,
+ id: `edge-analyzers-connectors`,
+ source: `isRunningJob-analyzers`,
+ target: `isRunningJob-connectors`,
},
{
- step: 3,
- type: "PIVOTS",
- completed:
- pivotsReported === job.pivots_to_execute.length && pivotsCompleted,
- report: `${pivotsReported}/${job.pivots_to_execute.length}`,
+ id: `edge-connectors-pivots`,
+ source: `isRunningJob-connectors`,
+ target: `isRunningJob-pivots`,
},
{
- step: 4,
- type: "VISUALIZERS",
- completed:
- visualizersReported === job.visualizers_to_execute.length &&
- visualizersCompleted,
- report: `${visualizersReported}/${job.visualizers_to_execute.length}`,
+ id: `edge-pivots-visualizers`,
+ source: `isRunningJob-pivots`,
+ target: `isRunningJob-visualizers`,
},
];
+ const onKillJobBtnClick = async () => {
+ const sure = await areYouSureConfirmDialog(`Kill Job #${job.id}`);
+ if (!sure) return null;
+ await killJob(job.id);
+ return null;
+ };
+
return (
-
-
-
- This job is currently running
- .
-
- {alertElements.map((element) => (
-
- STEP {element.step}: {element.type} RUNNING -
-
- reported {element.report}
-
-
- ))}
+ <>
+
+
+
+
{job.permissions?.kill && (
killJob(job.id)}
- className="mt-2"
+ onClick={onKillJobBtnClick}
+ className="mt-0"
/>
)}
-
-
+
+ >
);
}
diff --git a/frontend/src/components/jobs/result/jobApi.jsx b/frontend/src/components/jobs/result/jobApi.jsx
index bdd68a2e11..f6d3666620 100644
--- a/frontend/src/components/jobs/result/jobApi.jsx
+++ b/frontend/src/components/jobs/result/jobApi.jsx
@@ -20,8 +20,6 @@ export async function downloadJobSample(jobId) {
}
export async function killJob(jobId) {
- const sure = await areYouSureConfirmDialog(`kill job #${jobId}`);
- if (!sure) return Promise.reject();
let success = false;
try {
const response = await axios.patch(`${JOB_BASE_URI}/${jobId}/kill`);
diff --git a/frontend/tests/components/jobs/result/JobIsRunningAlert.test.jsx b/frontend/tests/components/jobs/result/JobIsRunningAlert.test.jsx
new file mode 100644
index 0000000000..8b2558b6a4
--- /dev/null
+++ b/frontend/tests/components/jobs/result/JobIsRunningAlert.test.jsx
@@ -0,0 +1,704 @@
+/* eslint-disable id-length */
+import React from "react";
+import axios from "axios";
+import "@testing-library/jest-dom";
+import { render, screen, waitFor } from "@testing-library/react";
+import { BrowserRouter } from "react-router-dom";
+import userEvent from "@testing-library/user-event";
+import { JobIsRunningAlert } from "../../../../src/components/jobs/result/JobIsRunningAlert";
+import { JOB_BASE_URI } from "../../../../src/constants/apiURLs";
+
+jest.mock("reactflow/dist/style.css", () => {});
+jest.mock("axios");
+
+describe("test JobIsRunningAlert", () => {
+ // mock needed for testing flow https://reactflow.dev/learn/advanced-use/testing#using-jest
+ beforeEach(() => {
+ let MockObserverInstance = typeof ResizeObserver;
+ MockObserverInstance = {
+ observe: jest.fn(),
+ unobserve: jest.fn(),
+ disconnect: jest.fn(),
+ };
+ global.ResizeObserver = jest
+ .fn()
+ .mockImplementation(() => MockObserverInstance);
+
+ let MockDOMMatrixInstance = typeof DOMMatrixReadOnly;
+ const mockDOMMatrix = (transform) => {
+ const scale = transform?.match(/scale\(([1-9.])\)/)?.[1];
+ MockDOMMatrixInstance = {
+ m22: scale !== undefined ? +scale : 1,
+ };
+ return MockDOMMatrixInstance;
+ };
+ global.DOMMatrixReadOnly = jest
+ .fn()
+ .mockImplementation((transform) => mockDOMMatrix(transform));
+
+ Object.defineProperties(global.HTMLElement.prototype, {
+ offsetHeight: {
+ get() {
+ return parseFloat(this.style.height) || 1;
+ },
+ },
+ offsetWidth: {
+ get() {
+ return parseFloat(this.style.width) || 1;
+ },
+ },
+ });
+
+ global.SVGElement.prototype.getBBox = () => ({
+ x: 0,
+ y: 0,
+ width: 0,
+ height: 0,
+ });
+ });
+
+ test("JobIsRunningAlert - analyzers running", () => {
+ const { container } = render(
+
+
+ ,
+ );
+
+ // analyzers node
+ const analyzersNode = container.querySelector("#jobPipeline-step-1");
+ expect(analyzersNode).toBeInTheDocument();
+ expect(analyzersNode.textContent).toContain("ANALYZERS RUNNING");
+ expect(analyzersNode.textContent).toContain("Reported 0/1");
+ // connectors node
+ const connectorsNode = container.querySelector("#jobPipeline-step-2");
+ expect(connectorsNode).toBeInTheDocument();
+ expect(connectorsNode.textContent).toContain("CONNECTORS");
+ expect(connectorsNode.textContent).toContain("Reported 0/1");
+ // pivots node
+ const pivotsNode = container.querySelector("#jobPipeline-step-3");
+ expect(pivotsNode).toBeInTheDocument();
+ expect(pivotsNode.textContent).toContain("PIVOTS");
+ expect(pivotsNode.textContent).toContain("Reported 0/1");
+ // visualizers node
+ const visualizersNode = container.querySelector("#jobPipeline-step-4");
+ expect(visualizersNode).toBeInTheDocument();
+ expect(visualizersNode.textContent).toContain("VISUALIZERS");
+ expect(visualizersNode.textContent).toContain("Reported 0/1");
+ });
+
+ test("JobIsRunningAlert - connectors running", () => {
+ const { container } = render(
+
+
+ ,
+ );
+
+ // analyzers node
+ const analyzersNode = container.querySelector("#jobPipeline-step-1");
+ expect(analyzersNode).toBeInTheDocument();
+ expect(analyzersNode.textContent).toContain("ANALYZERS COMPLETED");
+ expect(analyzersNode.textContent).toContain("Reported 1/1");
+ // connectors node
+ const connectorsNode = container.querySelector("#jobPipeline-step-2");
+ expect(connectorsNode).toBeInTheDocument();
+ expect(connectorsNode.textContent).toContain("CONNECTORS RUNNING");
+ expect(connectorsNode.textContent).toContain("Reported 0/1");
+ // pivots node
+ const pivotsNode = container.querySelector("#jobPipeline-step-3");
+ expect(pivotsNode).toBeInTheDocument();
+ expect(pivotsNode.textContent).toContain("PIVOTS");
+ expect(pivotsNode.textContent).toContain("Reported 0/1");
+ // visualizers node
+ const visualizersNode = container.querySelector("#jobPipeline-step-4");
+ expect(visualizersNode).toBeInTheDocument();
+ expect(visualizersNode.textContent).toContain("VISUALIZERS");
+ expect(visualizersNode.textContent).toContain("Reported 0/1");
+ });
+
+ test("JobIsRunningAlert - pivots running", () => {
+ const { container } = render(
+
+
+ ,
+ );
+
+ // analyzers node
+ const analyzersNode = container.querySelector("#jobPipeline-step-1");
+ expect(analyzersNode).toBeInTheDocument();
+ expect(analyzersNode.textContent).toContain("ANALYZERS COMPLETED");
+ expect(analyzersNode.textContent).toContain("Reported 1/1");
+ // connectors node
+ const connectorsNode = container.querySelector("#jobPipeline-step-2");
+ expect(connectorsNode).toBeInTheDocument();
+ expect(connectorsNode.textContent).toContain("CONNECTORS COMPLETED");
+ expect(connectorsNode.textContent).toContain("Reported 1/1");
+ // pivots node
+ const pivotsNode = container.querySelector("#jobPipeline-step-3");
+ expect(pivotsNode).toBeInTheDocument();
+ expect(pivotsNode.textContent).toContain("PIVOTS RUNNING");
+ expect(pivotsNode.textContent).toContain("Reported 0/1");
+ // visualizers node
+ const visualizersNode = container.querySelector("#jobPipeline-step-4");
+ expect(visualizersNode).toBeInTheDocument();
+ expect(visualizersNode.textContent).toContain("VISUALIZERS");
+ expect(visualizersNode.textContent).toContain("Reported 0/1");
+ });
+
+ test("JobIsRunningAlert - visualizers running", () => {
+ const { container } = render(
+
+
+ ,
+ );
+
+ // analyzers node
+ const analyzersNode = container.querySelector("#jobPipeline-step-1");
+ expect(analyzersNode).toBeInTheDocument();
+ expect(analyzersNode.textContent).toContain("ANALYZERS COMPLETED");
+ expect(analyzersNode.textContent).toContain("Reported 1/1");
+ // connectors node
+ const connectorsNode = container.querySelector("#jobPipeline-step-2");
+ expect(connectorsNode).toBeInTheDocument();
+ expect(connectorsNode.textContent).toContain("CONNECTORS COMPLETED");
+ expect(connectorsNode.textContent).toContain("Reported 1/1");
+ // pivots node
+ const pivotsNode = container.querySelector("#jobPipeline-step-3");
+ expect(pivotsNode).toBeInTheDocument();
+ expect(pivotsNode.textContent).toContain("PIVOTS COMPLETED");
+ expect(pivotsNode.textContent).toContain("Reported 1/1");
+ // visualizers node
+ const visualizersNode = container.querySelector("#jobPipeline-step-4");
+ expect(visualizersNode).toBeInTheDocument();
+ expect(visualizersNode.textContent).toContain("VISUALIZERS RUNNING");
+ expect(visualizersNode.textContent).toContain("Reported 0/1");
+ });
+
+ test("JobIsRunningAlert - kill job button", async () => {
+ axios.patch.mockImplementation(() =>
+ Promise.resolve({ status: 204, data: {} }),
+ );
+
+ const { container } = render(
+
+
+ ,
+ );
+
+ const user = userEvent.setup();
+
+ // kill job button
+ const killJobButton = container.querySelector("#killjob-iconbutton");
+ expect(killJobButton).toBeInTheDocument();
+ expect(killJobButton.textContent).toContain("Kill job");
+ await user.click(killJobButton);
+ // confirm dialog
+ const confirmButton = screen.getByRole("button", {
+ name: "Ok",
+ });
+ await user.click(confirmButton);
+ await waitFor(() => {
+ expect(axios.patch.mock.calls.length).toBe(1);
+ expect(axios.patch).toHaveBeenCalledWith(`${JOB_BASE_URI}/1/kill`);
+ });
+ });
+});
diff --git a/frontend/tests/components/jobs/result/JobOverview.test.jsx b/frontend/tests/components/jobs/result/JobOverview.test.jsx
index f2005b3014..8f0dea4bcf 100644
--- a/frontend/tests/components/jobs/result/JobOverview.test.jsx
+++ b/frontend/tests/components/jobs/result/JobOverview.test.jsx
@@ -5,6 +5,11 @@ import { BrowserRouter } from "react-router-dom";
import userEvent from "@testing-library/user-event";
import { JobOverview } from "../../../../src/components/jobs/result/JobOverview";
+// mock flow component
+jest.mock("../../../../src/components/jobs/result/JobIsRunningAlert", () => ({
+ JobIsRunningAlert: jest.fn((props) => ),
+}));
+
describe("test JobOverview (job report)", () => {
test("test utility bar", () => {
const { container } = render(
diff --git a/frontend/tests/components/jobs/result/visualizer/validators.test.js b/frontend/tests/components/jobs/result/visualizer/validators.test.js
index c5204807e0..ec9036254e 100644
--- a/frontend/tests/components/jobs/result/visualizer/validators.test.js
+++ b/frontend/tests/components/jobs/result/visualizer/validators.test.js
@@ -522,7 +522,7 @@ describe("visualizer data validation", () => {
},
{
type: "not existing type",
- value:[1,2,3],
+ value: [1, 2, 3],
icon: "invalid icon",
color: "#ff0000",
link: "https://google.com",
@@ -534,7 +534,7 @@ describe("visualizer data validation", () => {
},
{
type: "not existing type",
- value: {"value": "invalid"},
+ value: { value: "invalid" },
icon: "invalid icon",
color: "#ff0000",
link: "https://google.com",
@@ -629,14 +629,14 @@ describe("visualizer data validation", () => {
bold: true,
color: "bg-undefined",
description: "",
- copyText: "{\"value\":\"invalid\"}",
+ copyText: '{"value":"invalid"}',
disable: true,
icon: "invalid icon",
italic: true,
link: "https://google.com",
size: "col-auto",
type: "base",
- value: "{\"value\":\"invalid\"}",
+ value: '{"value":"invalid"}',
},
],
},