diff --git a/florist/api/routes/server/job.py b/florist/api/routes/server/job.py index ab336b81..be9dcdad 100644 --- a/florist/api/routes/server/job.py +++ b/florist/api/routes/server/job.py @@ -1,6 +1,6 @@ """FastAPI routes for the job.""" -from typing import List +from typing import List, Union from fastapi import APIRouter, Body, Request, status from fastapi.responses import JSONResponse @@ -11,6 +11,29 @@ router = APIRouter() +@router.get( + path="/{job_id}", + response_description="Retrieves a job by ID", + status_code=status.HTTP_200_OK, + response_model=Job, +) +async def get_job(job_id: str, request: Request) -> Union[Job, JSONResponse]: + """ + Retrieve a training job by its ID. + + :param request: (fastapi.Request) the FastAPI request object. + :param job_id: (str) The ID of the job to be retrieved. + + :return: (Union[Job, JSONResponse]) The job with the given ID, or a 400 JSONResponse if it hasn't been found. + """ + job = await Job.find_by_id(job_id, request.app.database) + + if job is None: + return JSONResponse(content={"error": f"Job with ID {job_id} does not exist."}, status_code=400) + + return job + + @router.post( path="", response_description="Create a new job", @@ -36,7 +59,11 @@ async def new_job(request: Request, job: Job = Body(...)) -> Job: # noqa: B008 return job_in_db -@router.get(path="/{status}", response_description="List jobs with the specified status", response_model=List[Job]) +@router.get( + path="/status/{status}", + response_description="List jobs with the specified status", + response_model=List[Job], +) async def list_jobs_with_status(status: JobStatus, request: Request) -> List[Job]: """ List jobs with specified status. diff --git a/florist/app/assets/css/florist.css b/florist/app/assets/css/florist.css index 8fea8042..b294ffca 100644 --- a/florist/app/assets/css/florist.css +++ b/florist/app/assets/css/florist.css @@ -53,3 +53,11 @@ .save-btn { width: 100px; } + +.status-pill { + display: inline-flex; + align-items: center; + padding: 5px 10px; + border-radius: 5px; + color: white; +} diff --git a/florist/app/jobs/definitions.tsx b/florist/app/jobs/definitions.tsx new file mode 100644 index 00000000..5fd5de50 --- /dev/null +++ b/florist/app/jobs/definitions.tsx @@ -0,0 +1,26 @@ +// Must be in same order as array returned from useGetJobsByJobStatus +export const validStatuses = { + NOT_STARTED: "Not Started", + IN_PROGRESS: "In Progress", + FINISHED_SUCCESSFULLY: "Finished Successfully", + FINISHED_WITH_ERROR: "Finished with Error", +}; + +export interface JobData { + _id: string; + status: string; + model: string; + server_address: string; + server_info: string; + redis_host: string; + redis_port: string; + clients_info: Array; +} + +export interface ClientInfo { + client: string; + service_address: string; + data_path: string; + redis_host: string; + redis_port: string; +} diff --git a/florist/app/jobs/details/page.tsx b/florist/app/jobs/details/page.tsx new file mode 100644 index 00000000..f632143e --- /dev/null +++ b/florist/app/jobs/details/page.tsx @@ -0,0 +1,288 @@ +"use client"; + +import { useSearchParams } from "next/navigation"; +import Image from "next/image"; + +import { ReactElement } from "react/React"; + +import { useGetJob } from "../hooks"; +import { validStatuses, ClientInfo } from "../definitions"; +import loading_gif from "../../assets/img/loading.gif"; + +export default function JobDetails(): ReactElement { + return ( +
+ + +
+ ); +} + +export function JobDetailsHeader(): ReactElement { + return ( +
+

Job Details

+
+ ); +} + +export function JobDetailsBody(): ReactElement { + const searchParams = useSearchParams(); + const jobId = searchParams.get("id"); + + const { data: job, error, isLoading } = useGetJob(jobId); + + if (isLoading) { + return ( +
+ Loading +
+ ); + } + + if (!job || error) { + if (error) { + console.error(error); + } + return ( +
+
+ Error retrieving job. +
+
+ ); + } + + return ( +
+
+
+ Job ID: +
+
+ {job._id} +
+
+
+
+ Status: +
+
+ +
+
+
+
+ Model: +
+
+ {job.model} +
+
+
+
+ Server Address: +
+
+ {job.server_address} +
+
+
+
+ Redis Host: +
+
+ {job.redis_host} +
+
+
+
+ Redis Port: +
+
+ {job.redis_port} +
+
+ + + + +
+ ); +} + +export function JobDetailsStatus({ status }: { status: string }): ReactElement { + let pillClasses = "status-pill text-sm "; + let iconName; + let statusDescription; + switch (String(validStatuses[status])) { + case validStatuses.NOT_STARTED: + pillClasses += "alert-info"; + iconName = "radio_button_checked"; + statusDescription = validStatuses[status]; + break; + case validStatuses.IN_PROGRESS: + pillClasses += "alert-warning"; + iconName = "sync"; + statusDescription = validStatuses[status]; + break; + case validStatuses.FINISHED_SUCCESSFULLY: + pillClasses += "alert-success"; + iconName = "check_circle"; + statusDescription = validStatuses[status]; + break; + case validStatuses.FINISHED_WITH_ERROR: + pillClasses += "alert-danger"; + iconName = "error"; + statusDescription = validStatuses[status]; + break; + default: + pillClasses += "alert-secondary"; + iconName = ""; + statusDescription = status; + break; + } + return ( +
+ + {iconName} + +   + {statusDescription} +
+ ); +} + +export function JobDetailsTable({ Component, title, data }): ReactElement { + return ( +
+
+
+
+
+
{title}
+
+
+ +
+
+ +
+
+
+
+
+ ); +} + +export function JobDetailsServerConfigTable({ data }: { data: string }): ReactElement { + const emptyResponse = ( +
+ Empty. +
+ ); + + if (!data) { + return emptyResponse; + } + + const serverConfigJson = JSON.parse(data); + + if (typeof serverConfigJson != "object" || Array.isArray(serverConfigJson)) { + return ( +
+ Error parsing server configuration. +
+ ); + } + + const serverConfigNames = Object.keys(serverConfigJson); + + if (serverConfigNames.length === 0) { + return emptyResponse; + } + + return ( + + + + + + + + + {serverConfigNames.map((serverConfigName, i) => ( + + + + + ))} + +
NameValue
+
+ {serverConfigName} +
+
+
+ + {serverConfigJson[serverConfigName]} + +
+
+ ); +} + +export function JobDetailsClientsInfoTable({ data }: { data: Array }): ReactElement { + return ( + + + + + + + + + + + + {data.map((clientInfo, i) => ( + + + + + + + + ))} + +
ClientAddressData PathRedis HostRedis Port
+
+ {clientInfo.client} +
+
+
+ {clientInfo.service_address} +
+
+
+ {clientInfo.data_path} +
+
+
+ {clientInfo.redis_host} +
+
+
+ {clientInfo.redis_port} +
+
+ ); +} diff --git a/florist/app/jobs/hooks.tsx b/florist/app/jobs/hooks.tsx index c3e19663..15bd5dec 100644 --- a/florist/app/jobs/hooks.tsx +++ b/florist/app/jobs/hooks.tsx @@ -4,13 +4,17 @@ import useSWR, { mutate } from "swr"; import { fetcher } from "../client_imports"; export function useGetJobsByJobStatus(status: string) { - const endpoint = "/api/server/job/".concat(status); + const endpoint = `/api/server/job/status/${status}`; const { data, error, isLoading } = useSWR(endpoint, fetcher, { refresh_interval: 1000, }); return { data, error, isLoading }; } +export function useGetJob(jobId: string) { + return useSWR(`/api/server/job/${jobId}`, fetcher); +} + export function useGetModels() { return useSWR("/api/server/models", fetcher); } @@ -49,5 +53,5 @@ export const usePost = () => { }; export function refreshJobsByJobStatus(statuses: Array) { - statuses.forEach((status: string) => mutate(`/api/server/job/${status}`)); + statuses.forEach((status: string) => mutate(`/api/server/job/status/${status}`)); } diff --git a/florist/app/jobs/page.tsx b/florist/app/jobs/page.tsx index 97239aaf..215967b2 100644 --- a/florist/app/jobs/page.tsx +++ b/florist/app/jobs/page.tsx @@ -4,39 +4,13 @@ import { useEffect } from "react"; import { ReactElement } from "react/React"; import { refreshJobsByJobStatus, useGetJobsByJobStatus, usePost } from "./hooks"; +import { validStatuses, JobData, ClientInfo } from "./definitions"; import Link from "next/link"; import Image from "next/image"; import loading_gif from "../assets/img/loading.gif"; -// Must be in same order as array returned from useGetJobsByJobStatus -export const validStatuses = { - NOT_STARTED: "Not Started", - IN_PROGRESS: "In Progress", - FINISHED_SUCCESSFULLY: "Finished Successfully", - FINISHED_WITH_ERROR: "Finished with Error", -}; - -interface JobData { - _id: string; - status: string; - model: string; - server_address: string; - server_info: string; - redis_host: string; - redis_port: string; - clients_info: Array; -} - -interface ClientInfo { - client: string; - service_address: string; - data_path: string; - redis_host: string; - redis_port: string; -} - interface StatusProp { status: string; } @@ -138,6 +112,32 @@ export function StartJobButton({ rowId, jobId }: { rowId: number; jobId: string ); } +export function JobDetailsButton({ + rowId, + jobId, + status, +}: { + rowId: number; + jobId: string; + status: string; +}): ReactElement { + return ( +
+ + settings + +
+ ); +} + export function Status({ status, data }: { status: StatusProp; data: Object }): ReactElement { return (
@@ -174,7 +174,8 @@ export function StatusTable({ data, status }: { data: Array; status: St Client Service Addresses - + + @@ -247,7 +248,10 @@ export function TableRow({
- {validStatuses[status] == "Not Started" ? : null} + + + + {validStatuses[status] === "Not Started" ? : null} ); } diff --git a/florist/tests/unit/api/routes/server/test_job.py b/florist/tests/unit/api/routes/server/test_job.py index 3e448b56..d4d7d0ef 100644 --- a/florist/tests/unit/api/routes/server/test_job.py +++ b/florist/tests/unit/api/routes/server/test_job.py @@ -3,9 +3,45 @@ from unittest.mock import patch, Mock, AsyncMock from fastapi.responses import JSONResponse -from florist.api.routes.server.job import change_job_status +from florist.api.routes.server.job import change_job_status, get_job from florist.api.db.entities import JobStatus + +@patch("florist.api.db.entities.Job.find_by_id") +async def test_get_job_success(mock_find_by_id: Mock) -> None: + mock_job = Mock() + mock_find_by_id.return_value = mock_job + + mock_request = Mock() + mock_request.app.database = Mock() + + test_id = "test_id" + + response = await get_job(test_id, mock_request) + + mock_find_by_id.assert_called_once_with(test_id, mock_request.app.database) + + assert response == mock_job + + +@patch("florist.api.db.entities.Job.find_by_id") +async def test_get_job_fail_none_job(mock_find_by_id: Mock) -> None: + mock_find_by_id.return_value = None + + mock_request = Mock() + mock_request.app.database = Mock() + + test_id = "test_id" + + response = await get_job(test_id, mock_request) + + mock_find_by_id.assert_called_once_with(test_id, mock_request.app.database) + + assert isinstance(response, JSONResponse) + assert response.status_code == 400 + assert json.loads(response.body.decode("utf-8")) == {"error": f"Job with ID {test_id} does not exist."} + + @patch("florist.api.db.entities.Job.find_by_id") async def test_change_job_status_success(mock_find_by_id: Mock) -> None: mock_job = Mock() @@ -28,6 +64,7 @@ async def test_change_job_status_success(mock_find_by_id: Mock) -> None: assert response.status_code == 200 assert json.loads(response.body.decode("utf-8")) == {"status": "success"} + @patch("florist.api.db.entities.Job.find_by_id") async def test_change_job_status_failure_in_find_by_id(mock_find_by_id: Mock) -> None: mock_find_by_id.return_value = None diff --git a/florist/tests/unit/app/jobs/details/page.test.tsx b/florist/tests/unit/app/jobs/details/page.test.tsx new file mode 100644 index 00000000..cd55068f --- /dev/null +++ b/florist/tests/unit/app/jobs/details/page.test.tsx @@ -0,0 +1,200 @@ +import "@testing-library/jest-dom"; +import { render, cleanup } from "@testing-library/react"; +import { describe, it, expect, afterEach } from "@jest/globals"; + +import { useGetJob } from "../../../../../app/jobs/hooks"; +import { validStatuses, JobData } from "../../../../../app/jobs/definitions"; +import JobDetails from "../../../../../app/jobs/details/page"; + +const testJobId = "test-job-id"; + +jest.mock("../../../../../app/jobs/hooks"); +jest.mock("next/navigation", () => ({ + ...require("next-router-mock"), + useSearchParams: () => new Map([["id", testJobId]]), +})); + +afterEach(() => { + jest.clearAllMocks(); + cleanup(); +}); + +function setupGetJobMock(data: JobData, isLoading: boolean = false, error = null) { + useGetJob.mockImplementation((jobId: string) => { + return { data, error, isLoading }; + }); +} + +function makeTestJob(): JobData { + return { + _id: testJobId, + status: "NOT_STARTED", + model: "test-model", + server_address: "test-server-address", + redis_host: "test-redis-host", + redis_port: "test-redis-port", + server_config: JSON.stringify({ + test_attribute_1: "test-value-1", + test_attribute_2: "test-value-2", + }), + clients_info: [ + { + client: "test-client-1", + service_address: "test-service-address-1", + data_path: "test-data-path-1", + redis_host: "test-redis-host-1", + redis_port: "test-redis-port-1", + }, + { + client: "test-client-2", + service_address: "test-service-address-2", + data_path: "test-data-path-2", + redis_host: "test-redis-host-2", + redis_port: "test-redis-port-2", + }, + ], + }; +} + +describe("Job Details Page", () => { + it("Renders correctly", () => { + const testJob = makeTestJob(); + setupGetJobMock(testJob); + const { container } = render(); + + expect(useGetJob).toBeCalledWith(testJobId); + + expect(container.querySelector("h1")).toHaveTextContent("Job Details"); + expect(container.querySelector("#job-details-id")).toHaveTextContent(testJob._id); + expect(container.querySelector("#job-details-status")).toHaveTextContent(validStatuses[testJob.status]); + expect(container.querySelector("#job-details-status")).toHaveClass("status-pill"); + expect(container.querySelector("#job-details-server-address")).toHaveTextContent(testJob.server_address); + expect(container.querySelector("#job-details-redis-host")).toHaveTextContent(testJob.redis_host); + const testServerConfig = JSON.parse(testJob.server_config); + const serverConfigNames = Object.keys(testServerConfig); + for (let i = 0; i < serverConfigNames.length; i++) { + expect(container.querySelector(`#job-details-server-config-name-${i}`)).toHaveTextContent( + serverConfigNames[i], + ); + expect(container.querySelector(`#job-details-server-config-value-${i}`)).toHaveTextContent( + testServerConfig[serverConfigNames[i]], + ); + } + for (let i = 0; i < testJob.clients_info.length; i++) { + expect(container.querySelector(`#job-details-client-config-client-${i}`)).toHaveTextContent( + testJob.clients_info[i].client, + ); + expect(container.querySelector(`#job-details-client-config-service-address-${i}`)).toHaveTextContent( + testJob.clients_info[i].service_address, + ); + expect(container.querySelector(`#job-details-client-config-data-path-${i}`)).toHaveTextContent( + testJob.clients_info[i].data_path, + ); + expect(container.querySelector(`#job-details-client-config-redis-host-${i}`)).toHaveTextContent( + testJob.clients_info[i].redis_host, + ); + expect(container.querySelector(`#job-details-client-config-redis-port-${i}`)).toHaveTextContent( + testJob.clients_info[i].redis_port, + ); + } + }); + describe("Status", () => { + it("Renders NOT_STARTED correctly", () => { + const testJob = makeTestJob(); + testJob.status = "NOT_STARTED"; + setupGetJobMock(testJob); + const { container } = render(); + const statusComponent = container.querySelector("#job-details-status"); + expect(statusComponent).toHaveTextContent(validStatuses[testJob.status]); + expect(statusComponent).toHaveClass("alert-info"); + const iconComponent = statusComponent.querySelector("#job-details-status-icon"); + expect(iconComponent).toHaveTextContent("radio_button_checked"); + }); + it("Renders IN_PROGRESS correctly", () => { + const testJob = makeTestJob(); + testJob.status = "IN_PROGRESS"; + setupGetJobMock(testJob); + const { container } = render(); + const statusComponent = container.querySelector("#job-details-status"); + expect(statusComponent).toHaveTextContent(validStatuses[testJob.status]); + expect(statusComponent).toHaveClass("alert-warning"); + const iconComponent = statusComponent.querySelector("#job-details-status-icon"); + expect(iconComponent).toHaveTextContent("sync"); + }); + it("Renders FINISHED_SUCCESSFULLY correctly", () => { + const testJob = makeTestJob(); + testJob.status = "FINISHED_SUCCESSFULLY"; + setupGetJobMock(testJob); + const { container } = render(); + const statusComponent = container.querySelector("#job-details-status"); + expect(statusComponent).toHaveTextContent(validStatuses[testJob.status]); + expect(statusComponent).toHaveClass("alert-success"); + const iconComponent = statusComponent.querySelector("#job-details-status-icon"); + expect(iconComponent).toHaveTextContent("check_circle"); + }); + it("Renders FINISHED_WITH_ERROR correctly", () => { + const testJob = makeTestJob(); + testJob.status = "FINISHED_WITH_ERROR"; + setupGetJobMock(testJob); + const { container } = render(); + const statusComponent = container.querySelector("#job-details-status"); + expect(statusComponent).toHaveTextContent(validStatuses[testJob.status]); + expect(statusComponent).toHaveClass("alert-danger"); + const iconComponent = statusComponent.querySelector("#job-details-status-icon"); + expect(iconComponent).toHaveTextContent("error"); + }); + it("Renders unknown status correctly", () => { + const testJob = makeTestJob(); + testJob.status = "some inexistent status"; + setupGetJobMock(testJob); + const { container } = render(); + const statusComponent = container.querySelector("#job-details-status"); + expect(statusComponent).toHaveTextContent(testJob.status); + expect(statusComponent).toHaveClass("alert-secondary"); + const iconComponent = statusComponent.querySelector("#job-details-status-icon"); + expect(iconComponent).toHaveTextContent(""); + }); + }); + describe("Server config", () => { + it("Does not break when it's null", () => { + const testJob = makeTestJob(); + testJob.server_config = null; + setupGetJobMock(testJob); + const { container } = render(); + const serverConfigComponent = container.querySelector("#job-details-server-config-empty"); + expect(serverConfigComponent).toHaveTextContent("Empty."); + }); + it("Does not break when it's an empty dictionary", () => { + const testJob = makeTestJob(); + testJob.server_config = JSON.stringify({}); + setupGetJobMock(testJob); + const { container } = render(); + const serverConfigComponent = container.querySelector("#job-details-server-config-empty"); + expect(serverConfigComponent).toHaveTextContent("Empty."); + }); + it("Does not break when it's not a dictionary", () => { + const testJob = makeTestJob(); + testJob.server_config = JSON.stringify(["bad server config"]); + setupGetJobMock(testJob); + const { container } = render(); + const serverConfigComponent = container.querySelector("#job-details-server-config-error"); + expect(serverConfigComponent).toHaveTextContent("Error parsing server configuration."); + }); + }); + it("Renders loading gif correctly", () => { + setupGetJobMock(null, true); + const { container } = render(); + const loadingComponent = container.querySelector("img#job-details-loading"); + expect(loadingComponent.getAttribute("alt")).toBe("Loading"); + }); + it("Renders error message when job is null", () => { + setupGetJobMock(null); + const { container } = render(); + expect(container.querySelector("#job-details-error")).toHaveTextContent("Error retrieving job."); + }); + it("Renders error message when there is an error", () => { + setupGetJobMock({}, false, "error"); + const { container } = render(); + expect(container.querySelector("#job-details-error")).toHaveTextContent("Error retrieving job."); + }); +}); diff --git a/florist/tests/unit/app/jobs/page.test.tsx b/florist/tests/unit/app/jobs/page.test.tsx index 0171ba46..a3e88226 100644 --- a/florist/tests/unit/app/jobs/page.test.tsx +++ b/florist/tests/unit/app/jobs/page.test.tsx @@ -2,7 +2,8 @@ import "@testing-library/jest-dom"; import { getByText, render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react"; import { describe, it, expect, afterEach } from "@jest/globals"; -import Page, { validStatuses } from "../../../../app/jobs/page"; +import Page from "../../../../app/jobs/page"; +import { validStatuses } from "../../../../app/jobs/definitions"; import { useGetJobsByJobStatus, usePost } from "../../../../app/jobs/hooks"; import { after } from "node:test"; @@ -15,6 +16,7 @@ afterEach(() => { function mockJobData(model: string, serverAddress: string, clientServicesAddresses: Array) { const data = { + _id: "test-id", model: model, server_address: serverAddress, clients_info: clientServicesAddresses.map((clientServicesAddress) => ({ @@ -149,6 +151,7 @@ describe("List Jobs Page", () => { expect(getByText(element, "No jobs to display.")).toBeInTheDocument(); } }); + it("Renders Loading GIF only when all isLoading", () => { setupMock(["NOT_STARTED", "IN_PROGRESS", "FINISHED_SUCCESSFULLY", "FINISHED_WITH_ERROR"], [], false, true); const { getByTestId } = render(); @@ -163,6 +166,20 @@ describe("List Jobs Page", () => { expect(element).not.toBeInTheDocument(); }); + it("Details button is present on all statuses", () => { + const data = mockJobData("MNIST", "localhost:8080", ["localhost:7080"]); + const validStatusesKeys = Object.keys(validStatuses); + + setupMock(validStatusesKeys, [data], false, false); + const { queryByTestId } = render(); + + for (let status of validStatusesKeys) { + const element = queryByTestId(`job-details-button-${status}-0`); + expect(element.getAttribute("alt")).toBe("Details"); + expect(element.getAttribute("href")).toBe(`jobs/details?id=${data._id}`); + } + }); + it("Start training button present in NOT_STARTED jobs", () => { const data = [mockJobData("MNIST", "localhost:8080", ["localhost:7080"])]; const validStatusesKeys = Object.keys(validStatuses); diff --git a/package.json b/package.json index 87cf3448..bc93e864 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "@testing-library/react": "^15.0.4", "jest": "^29.7.0", "jest-environment-jsdom": "^29.7.0", + "next-router-mock": "^0.9.13", "ts-node": "^10.9.2" } } diff --git a/yarn.lock b/yarn.lock index f2c800f4..2e326d34 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3629,6 +3629,11 @@ natural-compare@^1.4.0: resolved "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz" integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw== +next-router-mock@^0.9.13: + version "0.9.13" + resolved "https://registry.yarnpkg.com/next-router-mock/-/next-router-mock-0.9.13.tgz#bdee2011ea6c09e490121c354ef917f339767f72" + integrity sha512-906n2RRaE6Y28PfYJbaz5XZeJ6Tw8Xz1S6E31GGwZ0sXB6/XjldD1/2azn1ZmBmRk5PQRkzjg+n+RHZe5xQzWA== + next@14.1.1: version "14.1.1" resolved "https://registry.yarnpkg.com/next/-/next-14.1.1.tgz#92bd603996c050422a738e90362dff758459a171"