diff --git a/.github/workflows/static_code_checks.yaml b/.github/workflows/static_code_checks.yaml index 97872942..dd7e9229 100644 --- a/.github/workflows/static_code_checks.yaml +++ b/.github/workflows/static_code_checks.yaml @@ -62,12 +62,6 @@ jobs: uses: pypa/gh-action-pip-audit@v1.1.0 with: virtual-environment: .venv/ - # Skipping 3 cryptography issues that can't be patched because of FL4Health # Skipping 1 cryptography issue that can't be patched because of Flower - # Skipping 1 torch issue that can't be ugraded because of torchvision ignore-vulns: | - GHSA-3ww4-gg4f-jr7f - GHSA-9v9h-cgj8-h64p - GHSA-6vqw-3v5j-54x4 GHSA-h4gh-qq45-vh27 - GHSA-pg7h-5qx3-wjr3 diff --git a/florist/app/assets/css/florist.css b/florist/app/assets/css/florist.css index ba233310..8f475b18 100644 --- a/florist/app/assets/css/florist.css +++ b/florist/app/assets/css/florist.css @@ -76,7 +76,7 @@ background-color: lightgray !important; } -.job-expand-button a.btn { +.job-details-button a.btn { padding: 0; margin: 0; } @@ -153,3 +153,44 @@ .job-client-progress .job-progress-bar .progress .progress-bar { height: max-content; } + +.log-viewer.show { + display: block; + z-index: 10000; + background-color: rgba(0, 0, 0, 0.5); +} + +.log-viewer .modal-dialog { + max-width: 90%; +} + +.log-viewer .modal-content { + height: 100%; +} + +.log-viewer .btn-close { + color: black; + font-size: 30px; + margin-top: -25px; +} + +.log-viewer a.refresh-button, +.log-viewer a.download-button { + cursor: pointer; + margin-left: 15px; + margin-top: 10px; +} + +.log-viewer .modal-body { + white-space: pre; + font-family: monospace; + font-size: 14px; +} + +.log-viewer .loading-container { + height: 100%; + width: 100%; + display: inline-grid; + align-content: center; + justify-content: center; +} diff --git a/florist/app/assets/js/material-dashboard.js b/florist/app/assets/js/material-dashboard.js index f201147c..2d75bfed 100644 --- a/florist/app/assets/js/material-dashboard.js +++ b/florist/app/assets/js/material-dashboard.js @@ -617,11 +617,12 @@ const observer = new MutationObserver((mutationList) => { if (mutationList[i].type === "childList") { for (var j = 0; j < mutationList[i].addedNodes.length; j++) { const addedNode = mutationList[i].addedNodes[j]; - const inputs = addedNode.querySelectorAll("select"); - const selects = addedNode.querySelectorAll("input"); - const btns = addedNode.querySelectorAll(".btn"); - - hasTargetElementTypes = inputs.length > 0 || selects.length > 0 || btns.length > 0; + if (addedNode.querySelectorAll) { + const inputs = addedNode.querySelectorAll("select"); + const selects = addedNode.querySelectorAll("input"); + const btns = addedNode.querySelectorAll(".btn"); + hasTargetElementTypes = inputs.length > 0 || selects.length > 0 || btns.length > 0; + } } } } diff --git a/florist/app/jobs/details/page.tsx b/florist/app/jobs/details/page.tsx index 3b82fa26..c3bbd0ef 100644 --- a/florist/app/jobs/details/page.tsx +++ b/florist/app/jobs/details/page.tsx @@ -6,7 +6,7 @@ import Image from "next/image"; import { useState } from "react"; import { ReactElement } from "react/React"; -import { useGetJob } from "../hooks"; +import { useGetJob, getServerLogsKey, getClientLogsKey, useSWRWithKey } from "../hooks"; import { validStatuses, ClientInfo } from "../definitions"; import loading_gif from "../../assets/img/loading.gif"; @@ -111,7 +111,13 @@ export function JobDetailsBody(): ReactElement { - + ); @@ -175,11 +181,13 @@ export function JobProgressBar({ metrics, totalEpochs, status, + jobId, clientIndex, }: { metrics: string; totalEpochs: number; status: status; + jobId: string; clientIndex: number; }): ReactElement { const [collapsed, setCollapsed] = useState(true); @@ -257,7 +265,7 @@ export function JobProgressBar({ {Math.floor(progressPercent)}% -
+
setCollapsed(!collapsed)}> {collapsed ? ( @@ -274,7 +282,9 @@ export function JobProgressBar({
- {!collapsed ? : null} + {!collapsed ? ( + + ) : null}
@@ -282,7 +292,17 @@ export function JobProgressBar({ ); } -export function JobProgressDetails({ metrics, clientIndex }: { metrics: Object; clientIndex: number }): ReactElement { +export function JobProgressDetails({ + metrics, + jobId, + clientIndex, +}: { + metrics: Object; + jobId: string; + clientIndex: number; +}): ReactElement { + const [showLogs, setShowLogs] = useState(false); + if (!metrics) { return null; } @@ -313,8 +333,13 @@ export function JobProgressDetails({ metrics, clientIndex }: { metrics: Object; } } - let metricsFileName = metrics.host_type === "server" ? "server-metrics.json" : `client-metrics-${clientIndex}.json`; - let metricsFileURL = window.URL.createObjectURL(new Blob([JSON.stringify(metrics, null, 4)])); + const metricsFileName = + metrics.host_type === "server" ? "server-metrics.json" : `client-metrics-${clientIndex}.json`; + let metricsFileURL = null; + if (window.URL.createObjectURL) { + // adding this check here to avoid overly complicated mocking in tests + metricsFileURL = window.URL.createObjectURL(new Blob([JSON.stringify(metrics, null, 4)])); + } return (
@@ -345,6 +370,16 @@ export function JobProgressDetails({ metrics, clientIndex }: { metrics: Object; ))} +
+
+ Logs: +
+
+
+ + {showLogs ? ( + + ) : null}
); } @@ -375,7 +419,7 @@ export function JobProgressRound({ roundMetrics, index }: { roundMetrics: Object
Round {index + 1}
-
+
setCollapsed(!collapsed)}> {collapsed ? ( @@ -668,6 +712,7 @@ export function JobDetailsClientsInfoTable({ @@ -681,6 +726,69 @@ export function JobDetailsClientsInfoTable({ ); } +export function JobLogsModal({ + hostType, + jobId, + clientIndex, + showLogs, + setShowLogs, +}: { + type: string; + jobId: string; + clientIndex: number; + setShowLogs: Callable; +}): ReactElement { + let apiKey, fileName; + if (hostType === "server") { + apiKey = getServerLogsKey(jobId); + fileName = "server.log"; + } + if (hostType === "client") { + apiKey = getClientLogsKey(jobId, clientIndex); + fileName = `client-${clientIndex}.log`; + } + + const { data, error, isLoading, isValidating, mutate } = useSWRWithKey(apiKey); + + let dataURL = null; + if (data) { + dataURL = window.URL.createObjectURL(new Blob([data])); + } + + return ( +
+
+
+ + +
+ {isLoading || isValidating ? ( +
+ Loading Logs +
+ ) : error ? ( + "Error loading logs" + ) : ( + data + )} +
+
+
+
+ ); +} + export function getTimeString(timeInMiliseconds: number): string { const hours = Math.floor(timeInMiliseconds / 1000 / 60 / 60); const minutes = Math.floor((timeInMiliseconds / 1000 / 60 / 60 - hours) * 60); diff --git a/florist/app/jobs/hooks.tsx b/florist/app/jobs/hooks.tsx index 15bd5dec..a525f00b 100644 --- a/florist/app/jobs/hooks.tsx +++ b/florist/app/jobs/hooks.tsx @@ -23,6 +23,18 @@ export function useGetClients() { return useSWR("/api/server/clients", fetcher); } +export function getServerLogsKey(jobId: string) { + return `/api/server/job/get_server_log/${jobId}`; +} + +export function getClientLogsKey(jobId: string, clientIndex: number) { + return `/api/server/job/get_client_log/${jobId}/${clientIndex}`; +} + +export function useSWRWithKey(key: string) { + return useSWR(key, fetcher); +} + export const usePost = () => { const [error, setError] = useState(null); const [isLoading, setIsLoading] = useState(null); diff --git a/florist/tests/unit/app/jobs/details/page.test.tsx b/florist/tests/unit/app/jobs/details/page.test.tsx index c5ae739b..a6d5f36f 100644 --- a/florist/tests/unit/app/jobs/details/page.test.tsx +++ b/florist/tests/unit/app/jobs/details/page.test.tsx @@ -3,7 +3,7 @@ import { render, cleanup } from "@testing-library/react"; import { describe, it, expect, afterEach } from "@jest/globals"; import { act } from "react-dom/test-utils"; -import { useGetJob } from "../../../../../app/jobs/hooks"; +import { useGetJob, useSWRWithKey, getServerLogsKey, getClientLogsKey } from "../../../../../app/jobs/hooks"; import { validStatuses, JobData } from "../../../../../app/jobs/definitions"; import JobDetails, { getTimeString } from "../../../../../app/jobs/details/page"; @@ -27,6 +27,17 @@ function setupGetJobMock(data: JobData, isLoading: boolean = false, error = null }); } +function setupUseSWRWithKeyMock({ data, isLoading = false, error = null, isValidating = false }) { + getServerLogsKey.mockImplementation((jobId) => `test server logs key: ${jobId}`); + getClientLogsKey.mockImplementation((jobId, clientIndex) => `test client logs key: ${jobId}, ${clientIndex}`); + + const mutateMock = jest.fn(); + useSWRWithKey.mockImplementation((apiKey: string) => { + return { data, error, isLoading, isValidating, mutate: mutateMock }; + }); + return mutateMock; +} + function setupURLSpyMock(urlSpy, testURL: string = "foo") { urlSpy = jest.spyOn(window, "URL"); urlSpy.createObjectURL = jest.fn((_) => testURL); @@ -392,6 +403,13 @@ describe("Job Details Page", () => { const round3 = jobProgressDetailsComponent.children[8].children[0]; expect(round3.children[0]).toHaveTextContent("Round 3"); expect(round3.children[1]).toHaveClass("job-round-toggle-2"); + + // show logs + const showLogs = jobProgressDetailsComponent.children[9]; + expect(showLogs.children[0]).toHaveTextContent("Logs:"); + const showLogsButton = showLogs.children[1].children[0]; + expect(showLogsButton.tagName.toLowerCase()).toBe("a"); + expect(showLogsButton).toHaveTextContent("Show Logs"); }); describe("Rounds", () => { it("Should be collapsed by default", () => { @@ -498,6 +516,195 @@ describe("Job Details Page", () => { ); }); }); + describe("Logs Modal", () => { + it("Should be hidden by default", () => { + const testJob = makeTestJob(); + setupGetJobMock(testJob); + const { container } = render(); + + const progressToggleButton = container.querySelector(".job-details-toggle a"); + act(() => progressToggleButton.click()); + + const jobProgressDetailsComponent = container.querySelector(".job-progress-detail"); + expect(jobProgressDetailsComponent.querySelector(".log-viewer")).toBeNull(); + }); + it("Should render the server logs modal correctly when clicked", () => { + const testJob = makeTestJob(); + setupGetJobMock(testJob); + setupURLSpyMock(urlSpy, "test url"); + const { container } = render(); + + const progressToggleButton = container.querySelector(".job-details-toggle a"); + act(() => progressToggleButton.click()); + + const testLogContents = "[INFO] test log contents\n[INFO] second line"; + setupUseSWRWithKeyMock({ data: testLogContents }); + const testURL = "test url"; + urlSpy = setupURLSpyMock(urlSpy, testURL); + + const jobProgressDetailsComponent = container.querySelector(".job-progress-detail"); + const showLogsButton = jobProgressDetailsComponent.querySelector(".show-logs-button"); + act(() => showLogsButton.click()); + + expect(useSWRWithKey).toHaveBeenCalledWith(getServerLogsKey(testJob._id)); + expect(urlSpy.createObjectURL).toHaveBeenCalledWith(new Blob([testLogContents])); + + const logViewerComponent = jobProgressDetailsComponent.querySelector(".log-viewer"); + expect(logViewerComponent).toHaveClass("modal", "show"); + + const downloadButton = logViewerComponent.querySelector(".download-button"); + expect(downloadButton.getAttribute("href")).toBe(testURL); + expect(downloadButton.getAttribute("download")).toBe("server.log"); + + const modalBody = logViewerComponent.querySelector(".modal-body"); + expect(modalBody).toHaveTextContent(testLogContents.replace("\n", " ")); + }); + it("Should render the client logs modal correctly when clicked", () => { + const testJob = makeTestJob(); + setupGetJobMock(testJob); + setupURLSpyMock(urlSpy, "test url"); + const { container } = render(); + + const testClientIndex = 1; + let toggleButton = container.querySelectorAll(".job-client-progress .job-details-toggle a")[ + testClientIndex + ]; + act(() => toggleButton.click()); + + const testLogContents = "[INFO] test log contents\n[INFO] second line"; + setupUseSWRWithKeyMock({ data: testLogContents }); + const testURL = "test url"; + urlSpy = setupURLSpyMock(urlSpy, testURL); + + const jobProgressDetailsComponent = container.querySelector( + `#job-details-client-config-progress-${testClientIndex} .job-progress-detail`, + ); + const showLogsButton = jobProgressDetailsComponent.querySelector(".show-logs-button"); + act(() => showLogsButton.click()); + + expect(useSWRWithKey).toHaveBeenCalledWith(getClientLogsKey(testJob._id, testClientIndex)); + expect(urlSpy.createObjectURL).toHaveBeenCalledWith(new Blob([testLogContents])); + + const logViewerComponent = jobProgressDetailsComponent.querySelector(".log-viewer"); + expect(logViewerComponent).toHaveClass("modal", "show"); + + const downloadButton = logViewerComponent.querySelector(".download-button"); + expect(downloadButton.getAttribute("href")).toBe(testURL); + expect(downloadButton.getAttribute("download")).toBe(`client-${testClientIndex}.log`); + + const modalBody = logViewerComponent.querySelector(".modal-body"); + expect(modalBody).toHaveTextContent(testLogContents.replace("\n", " ")); + }); + it("Should display spinner when loading", () => { + const testJob = makeTestJob(); + setupGetJobMock(testJob); + const { container } = render(); + + const progressToggleButton = container.querySelector(".job-details-toggle a"); + act(() => progressToggleButton.click()); + + setupUseSWRWithKeyMock({ data: null, isLoading: true }); + + const jobProgressDetailsComponent = container.querySelector(".job-progress-detail"); + const showLogsButton = jobProgressDetailsComponent.querySelector(".show-logs-button"); + act(() => showLogsButton.click()); + + expect(useSWRWithKey).toHaveBeenCalledWith(getServerLogsKey(testJob._id)); + + const logViewerComponent = jobProgressDetailsComponent.querySelector(".log-viewer"); + const modalBody = logViewerComponent.querySelector(".modal-body"); + const loadingComponent = modalBody.querySelector("div.loading-container > img"); + expect(loadingComponent.getAttribute("alt")).toBe("Loading Logs"); + }); + it("Should display spinner when validating", () => { + const testJob = makeTestJob(); + setupGetJobMock(testJob); + const { container } = render(); + + const progressToggleButton = container.querySelector(".job-details-toggle a"); + act(() => progressToggleButton.click()); + + setupUseSWRWithKeyMock({ data: null, isValidating: true }); + + const jobProgressDetailsComponent = container.querySelector(".job-progress-detail"); + const showLogsButton = jobProgressDetailsComponent.querySelector(".show-logs-button"); + act(() => showLogsButton.click()); + + expect(useSWRWithKey).toHaveBeenCalledWith(getServerLogsKey(testJob._id)); + + const logViewerComponent = jobProgressDetailsComponent.querySelector(".log-viewer"); + const modalBody = logViewerComponent.querySelector(".modal-body"); + const loadingComponent = modalBody.querySelector("div.loading-container > img"); + expect(loadingComponent.getAttribute("alt")).toBe("Loading Logs"); + }); + it("Should display error message", () => { + const testJob = makeTestJob(); + setupGetJobMock(testJob); + setupURLSpyMock(urlSpy, "test url"); + const { container } = render(); + + const progressToggleButton = container.querySelector(".job-details-toggle a"); + act(() => progressToggleButton.click()); + + setupUseSWRWithKeyMock({ data: null, error: "error!" }); + + const jobProgressDetailsComponent = container.querySelector(".job-progress-detail"); + const showLogsButton = jobProgressDetailsComponent.querySelector(".show-logs-button"); + act(() => showLogsButton.click()); + + expect(useSWRWithKey).toHaveBeenCalledWith(getServerLogsKey(testJob._id)); + + const logViewerComponent = jobProgressDetailsComponent.querySelector(".log-viewer"); + const modalBody = logViewerComponent.querySelector(".modal-body"); + expect(modalBody).toHaveTextContent("Error loading logs"); + }); + it("Clicking refresh should call mutate", () => { + const testJob = makeTestJob(); + setupGetJobMock(testJob); + setupURLSpyMock(urlSpy, "test url"); + const { container } = render(); + + const progressToggleButton = container.querySelector(".job-details-toggle a"); + act(() => progressToggleButton.click()); + + const mutateMock = setupUseSWRWithKeyMock({ data: null }); + + const jobProgressDetailsComponent = container.querySelector(".job-progress-detail"); + const showLogsButton = jobProgressDetailsComponent.querySelector(".show-logs-button"); + act(() => showLogsButton.click()); + + expect(useSWRWithKey).toHaveBeenCalledWith(getServerLogsKey(testJob._id)); + + const logViewerComponent = jobProgressDetailsComponent.querySelector(".log-viewer"); + const refreshButton = logViewerComponent.querySelector(".refresh-button"); + act(() => refreshButton.click()); + + expect(mutateMock).toHaveBeenCalledWith(getServerLogsKey(testJob._id)); + }); + it("Clicking close should close the modal", () => { + const testJob = makeTestJob(); + setupGetJobMock(testJob); + setupURLSpyMock(urlSpy, "test url"); + const { container } = render(); + + const progressToggleButton = container.querySelector(".job-details-toggle a"); + act(() => progressToggleButton.click()); + + const mutateMock = setupUseSWRWithKeyMock({ data: null }); + + const jobProgressDetailsComponent = container.querySelector(".job-progress-detail"); + const showLogsButton = jobProgressDetailsComponent.querySelector(".show-logs-button"); + act(() => showLogsButton.click()); + + expect(useSWRWithKey).toHaveBeenCalledWith(getServerLogsKey(testJob._id)); + + const logViewerComponent = jobProgressDetailsComponent.querySelector(".log-viewer"); + const closeButton = logViewerComponent.querySelector(".btn-close"); + act(() => closeButton.click()); + + expect(jobProgressDetailsComponent.querySelector(".log-viewer")).toBeNull(); + }); + }); describe("Download metrics", () => { it("Should render the download server metrics button correctly", async () => { const testJob = makeTestJob();