Skip to content

Commit 4a0e263

Browse files
shreyaskarnikclaude
andcommitted
Add download button for training artifacts
Add a backend endpoint to download artifact files and directories (zipped on-the-fly). The frontend artifacts modal now shows both Download and Copy Path buttons for each artifact. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 1f4f07a commit 4a0e263

File tree

4 files changed

+82
-9
lines changed

4 files changed

+82
-9
lines changed

frontend/src/api/client.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,3 +79,7 @@ export function fetchArtifacts(runId: number): Promise<ArtifactsResponse> {
7979
export function fetchVersion(): Promise<{ version: string }> {
8080
return request('/api/version')
8181
}
82+
83+
export function getArtifactDownloadUrl(runId: number, artifactName: string): string {
84+
return `/runs/${runId}/artifacts/${encodeURIComponent(artifactName)}/download`
85+
}

frontend/src/components/modals/ArtifactsModal.tsx

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -72,12 +72,21 @@ export function ArtifactsModal() {
7272
<span>{formatFileSize(artifact.size)}</span>
7373
</div>
7474
</div>
75-
<button
76-
className="artifact-download"
77-
onClick={() => copyPath(artifact.path)}
78-
>
79-
{copiedPath === artifact.path ? 'Copied!' : 'Copy Path'}
80-
</button>
75+
<div className="artifact-actions">
76+
<a
77+
className="artifact-download"
78+
href={api.getArtifactDownloadUrl(selectedRunId!, artifact.name)}
79+
download
80+
>
81+
Download
82+
</a>
83+
<button
84+
className="artifact-copy"
85+
onClick={() => copyPath(artifact.path)}
86+
>
87+
{copiedPath === artifact.path ? 'Copied!' : 'Copy Path'}
88+
</button>
89+
</div>
8190
</div>
8291
))
8392
)}

frontend/src/styles.css

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -729,7 +729,14 @@ body {
729729
letter-spacing: 0.5px;
730730
}
731731

732-
.artifact-download {
732+
.artifact-actions {
733+
display: flex;
734+
gap: var(--space-xs);
735+
flex-shrink: 0;
736+
}
737+
738+
.artifact-download,
739+
.artifact-copy {
733740
padding: var(--space-xs) var(--space-sm);
734741
background: var(--ui-element-background);
735742
border: 1px solid var(--ui-element-border);
@@ -739,9 +746,13 @@ body {
739746
cursor: pointer;
740747
transition: all 0.15s;
741748
white-space: nowrap;
749+
text-decoration: none;
750+
display: inline-flex;
751+
align-items: center;
742752
}
743753

744-
.artifact-download:hover {
754+
.artifact-download:hover,
755+
.artifact-copy:hover {
745756
background: var(--ui-element-background-hover);
746757
border-color: var(--ui-element-border-hover);
747758
}

src/vespaembed/web/app.py

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
1+
import io
12
import json
23
import os
34
import re
45
import subprocess
56
import sys
67
import time
8+
import zipfile
79
from pathlib import Path
810
from typing import Optional
911

1012
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
11-
from fastapi.responses import HTMLResponse
13+
from fastapi.responses import FileResponse, HTMLResponse, StreamingResponse
1214
from fastapi.staticfiles import StaticFiles
1315
from pydantic import BaseModel, Field, field_validator
1416

@@ -639,3 +641,50 @@ async def get_run_artifacts(run_id: int):
639641
)
640642

641643
return {"artifacts": artifacts, "output_dir": str(output_dir)}
644+
645+
646+
@app.get("/runs/{run_id}/artifacts/{artifact_name}/download")
647+
async def download_artifact(run_id: int, artifact_name: str):
648+
"""Download an artifact file or directory (as zip) for a training run."""
649+
run = get_run(run_id)
650+
if not run:
651+
raise HTTPException(status_code=404, detail="Run not found")
652+
653+
output_dir = Path(run["output_dir"])
654+
if not output_dir.exists():
655+
raise HTTPException(status_code=404, detail="Output directory not found")
656+
657+
artifact_path = output_dir / artifact_name
658+
if not artifact_path.exists():
659+
raise HTTPException(status_code=404, detail="Artifact not found")
660+
661+
# Security: ensure the artifact is within the output directory
662+
try:
663+
artifact_path.resolve().relative_to(output_dir.resolve())
664+
except ValueError:
665+
raise HTTPException(status_code=403, detail="Access denied")
666+
667+
if artifact_path.is_file():
668+
return FileResponse(
669+
path=str(artifact_path),
670+
filename=artifact_path.name,
671+
media_type="application/octet-stream",
672+
)
673+
674+
# Directory: stream as zip
675+
def iter_zip():
676+
buffer = io.BytesIO()
677+
with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as zf:
678+
for file_path in sorted(artifact_path.rglob("*")):
679+
if file_path.is_file():
680+
arcname = file_path.relative_to(artifact_path)
681+
zf.write(file_path, arcname)
682+
buffer.seek(0)
683+
yield buffer.read()
684+
685+
zip_filename = f"{artifact_name}.zip"
686+
return StreamingResponse(
687+
iter_zip(),
688+
media_type="application/zip",
689+
headers={"Content-Disposition": f'attachment; filename="{zip_filename}"'},
690+
)

0 commit comments

Comments
 (0)