diff --git a/.github/workflows/docs_deploy.yml b/.github/workflows/docs_deploy.yml
index b59964a..e4fbfba 100644
--- a/.github/workflows/docs_deploy.yml
+++ b/.github/workflows/docs_deploy.yml
@@ -55,7 +55,7 @@ jobs:
# attempt_delay: 30000
#
- name: Deploy to Github pages
- uses: JamesIves/github-pages-deploy-action@v4.6.9
+ uses: JamesIves/github-pages-deploy-action@v4.7.2
with:
branch: github_pages
folder: docs/build/html
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index eea4024..5e46fe6 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -16,7 +16,7 @@ repos:
- id: check-toml
- repo: https://github.com/astral-sh/ruff-pre-commit
- rev: 'v0.8.0'
+ rev: 'v0.8.1'
hooks:
- id: ruff
args: [--fix, --exit-non-zero-on-fix]
diff --git a/florist/app/assets/css/florist.css b/florist/app/assets/css/florist.css
index 5a5e58d..ba23331 100644
--- a/florist/app/assets/css/florist.css
+++ b/florist/app/assets/css/florist.css
@@ -81,6 +81,11 @@
margin: 0;
}
+.job-details-download-button a.btn {
+ padding: 0;
+ margin: 15px 0 0 0;
+}
+
.job-round-details {
padding-left: 40px;
}
diff --git a/florist/app/jobs/details/page.tsx b/florist/app/jobs/details/page.tsx
index 0b7f6bb..3b82fa2 100644
--- a/florist/app/jobs/details/page.tsx
+++ b/florist/app/jobs/details/page.tsx
@@ -175,10 +175,12 @@ export function JobProgressBar({
metrics,
totalEpochs,
status,
+ clientIndex,
}: {
metrics: string;
totalEpochs: number;
status: status;
+ clientIndex: number;
}): ReactElement {
const [collapsed, setCollapsed] = useState(true);
@@ -271,14 +273,16 @@ export function JobProgressBar({
-
{!collapsed ? : null}
+
+ {!collapsed ? : null}
+
);
}
-export function JobProgressDetails({ metrics }: { metrics: Object }): ReactElement {
+export function JobProgressDetails({ metrics, clientIndex }: { metrics: Object; clientIndex: number }): ReactElement {
if (!metrics) {
return null;
}
@@ -309,6 +313,9 @@ export function JobProgressDetails({ metrics }: { metrics: Object }): ReactEleme
}
}
+ 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)]));
+
return (
@@ -337,6 +344,20 @@ export function JobProgressDetails({ metrics }: { metrics: Object }): ReactEleme
{roundMetricsArray.map((roundMetrics, i) => (
))}
+
+
);
}
@@ -647,6 +668,7 @@ export function JobDetailsClientsInfoTable({
diff --git a/florist/tests/unit/app/jobs/details/page.test.tsx b/florist/tests/unit/app/jobs/details/page.test.tsx
index b9a9deb..c5ae739 100644
--- a/florist/tests/unit/app/jobs/details/page.test.tsx
+++ b/florist/tests/unit/app/jobs/details/page.test.tsx
@@ -27,6 +27,12 @@ function setupGetJobMock(data: JobData, isLoading: boolean = false, error = null
});
}
+function setupURLSpyMock(urlSpy, testURL: string = "foo") {
+ urlSpy = jest.spyOn(window, "URL");
+ urlSpy.createObjectURL = jest.fn((_) => testURL);
+ return urlSpy;
+}
+
function makeTestJob(): JobData {
return {
_id: testJobId,
@@ -309,6 +315,14 @@ describe("Job Details Page", () => {
expect(progressBar).toHaveClass("bg-danger");
});
describe("Details", () => {
+ let urlSpy;
+ afterEach(() => {
+ if (urlSpy) {
+ // making sure the mock is clear even on error,
+ // otherwise some weird errors start popping up
+ urlSpy.mockRestore();
+ }
+ });
it("Should be collapsed by default", () => {
setupGetJobMock(makeTestJob());
const { container } = render();
@@ -317,6 +331,7 @@ describe("Job Details Page", () => {
});
it("Should open when the toggle button is clicked", () => {
setupGetJobMock(makeTestJob());
+ setupURLSpyMock(urlSpy);
const { container } = render();
const toggleButton = container.querySelector(".job-details-toggle a");
expect(toggleButton).toHaveTextContent("Expand");
@@ -332,6 +347,7 @@ describe("Job Details Page", () => {
const testJob = makeTestJob();
const serverMetrics = JSON.parse(testJob.server_metrics);
setupGetJobMock(testJob);
+ setupURLSpyMock(urlSpy);
const { container } = render();
const toggleButton = container.querySelector(".job-details-toggle a");
act(() => toggleButton.click());
@@ -382,6 +398,7 @@ describe("Job Details Page", () => {
const testJob = makeTestJob();
const serverMetrics = JSON.parse(testJob.server_metrics);
setupGetJobMock(testJob);
+ setupURLSpyMock(urlSpy);
const { container } = render();
const progressToggleButton = container.querySelector(".job-details-toggle a");
act(() => progressToggleButton.click());
@@ -395,6 +412,7 @@ describe("Job Details Page", () => {
const testJob = makeTestJob();
const serverMetrics = JSON.parse(testJob.server_metrics);
setupGetJobMock(testJob);
+ setupURLSpyMock(urlSpy);
const { container } = render();
const progressToggleButton = container.querySelector(".job-details-toggle a");
act(() => progressToggleButton.click());
@@ -415,6 +433,7 @@ describe("Job Details Page", () => {
const testJob = makeTestJob();
const serverMetrics = JSON.parse(testJob.server_metrics);
setupGetJobMock(testJob);
+ setupURLSpyMock(urlSpy);
const { container } = render();
const progressToggleButton = container.querySelector(".job-details-toggle a");
act(() => progressToggleButton.click());
@@ -479,6 +498,58 @@ describe("Job Details Page", () => {
);
});
});
+ describe("Download metrics", () => {
+ it("Should render the download server metrics button correctly", async () => {
+ const testJob = makeTestJob();
+ setupGetJobMock(testJob);
+ const { container } = render();
+
+ const testURL = "test url";
+ urlSpy = setupURLSpyMock(urlSpy, testURL);
+
+ const progressToggleButton = container.querySelector(".job-details-toggle a");
+ act(() => progressToggleButton.click());
+
+ const expectedServerMetrics = JSON.stringify(JSON.parse(testJob.server_metrics), null, 4);
+ expect(urlSpy.createObjectURL).toHaveBeenCalledWith(new Blob([expectedServerMetrics]));
+
+ const jobProgressDetailsComponent = container.querySelector(".job-progress-detail");
+ const downloadMetricsButton = jobProgressDetailsComponent.querySelector(".download-metrics-button");
+ expect(downloadMetricsButton.getAttribute("href")).toBe(testURL);
+ expect(downloadMetricsButton.getAttribute("download")).toBe("server-metrics.json");
+ });
+ it("Should render the download client metrics button correctly", () => {
+ const testJob = makeTestJob();
+ setupGetJobMock(testJob);
+ const { container } = render();
+
+ const testURL = "test url";
+ urlSpy = setupURLSpyMock(urlSpy, testURL);
+
+ const testClientIndex = 1;
+ let toggleButton = container.querySelectorAll(".job-client-progress .job-details-toggle a")[
+ testClientIndex
+ ];
+ act(() => toggleButton.click());
+
+ const expectedClientMetrics = JSON.stringify(
+ JSON.parse(testJob.clients_info[testClientIndex].metrics),
+ null,
+ 4,
+ );
+ expect(urlSpy.createObjectURL).toHaveBeenCalledWith(new Blob([expectedClientMetrics]));
+
+ const clientProgressDetailsComponent = container.querySelector(
+ `#job-details-client-config-progress-${testClientIndex} .job-progress-detail`,
+ );
+ const downloadMetricsButton =
+ clientProgressDetailsComponent.querySelector(".download-metrics-button");
+ expect(downloadMetricsButton.getAttribute("href")).toBe(testURL);
+ expect(downloadMetricsButton.getAttribute("download")).toBe(
+ `client-metrics-${testClientIndex}.json`,
+ );
+ });
+ });
describe("Clients", () => {
it("Renders their progress bars correctly", () => {
const testJob = makeTestJob();
@@ -497,6 +568,7 @@ describe("Job Details Page", () => {
it("Renders the progress details correctly", () => {
const testJob = makeTestJob();
setupGetJobMock(testJob);
+ setupURLSpyMock(urlSpy);
const { container } = render();
let toggleButton = container.querySelectorAll(".job-client-progress .job-details-toggle a")[0];
diff --git a/poetry.lock b/poetry.lock
index 1886f12..cadeb0c 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -5369,18 +5369,15 @@ files = [
[[package]]
name = "python-multipart"
-version = "0.0.9"
+version = "0.0.18"
description = "A streaming multipart parser for Python"
optional = false
python-versions = ">=3.8"
files = [
- {file = "python_multipart-0.0.9-py3-none-any.whl", hash = "sha256:97ca7b8ea7b05f977dc3849c3ba99d51689822fab725c3703af7c866a0c2b215"},
- {file = "python_multipart-0.0.9.tar.gz", hash = "sha256:03f54688c663f1b7977105f021043b0793151e4cb1c1a9d4a11fc13d622c4026"},
+ {file = "python_multipart-0.0.18-py3-none-any.whl", hash = "sha256:efe91480f485f6a361427a541db4796f9e1591afc0fb8e7a4ba06bfbc6708996"},
+ {file = "python_multipart-0.0.18.tar.gz", hash = "sha256:7a68db60c8bfb82e460637fa4750727b45af1d5e2ed215593f917f64694d34fe"},
]
-[package.extras]
-dev = ["atomicwrites (==1.4.1)", "attrs (==23.2.0)", "coverage (==7.4.1)", "hatch", "invoke (==2.2.0)", "more-itertools (==10.2.0)", "pbr (==6.0.0)", "pluggy (==1.4.0)", "py (==1.11.0)", "pytest (==8.0.0)", "pytest-cov (==4.1.0)", "pytest-timeout (==2.2.0)", "pyyaml (==6.0.1)", "ruff (==0.2.1)"]
-
[[package]]
name = "pytz"
version = "2024.2"
@@ -7791,4 +7788,4 @@ propcache = ">=0.2.0"
[metadata]
lock-version = "2.0"
python-versions = ">=3.10.0,<3.11"
-content-hash = "aef6df7694d9da4efc41e5ce109ac67766a0dee7ebb3665ddedf5c66841983e5"
+content-hash = "08cf217d59efdfc3bc04f1dcf51e9083328dd5852bd0b6556978d5a9cd33ae35"
diff --git a/pyproject.toml b/pyproject.toml
index 272a726..5cace6d 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -20,7 +20,7 @@ uvicorn = {version = "^0.23.2", extras = ["standard"]}
wandb = "^0.18.0"
torchvision = "^0.18.0"
redis = "^5.0.1"
-python-multipart = "^0.0.9"
+python-multipart = "^0.0.18"
pydantic = "^1.10.15"
motor = "^3.4.0"
tqdm = "^4.66.3"