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)}%
-
- {!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;
))}
+
+
+ {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 ? (
+
+
+
+ ) : 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();