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) => ( ))} + +
+
+ + download + Download Metrics as JSON + +
+
); } @@ -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"