Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions frontend/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,3 +79,7 @@ export function fetchArtifacts(runId: number): Promise<ArtifactsResponse> {
export function fetchVersion(): Promise<{ version: string }> {
return request('/api/version')
}

export function getArtifactDownloadUrl(runId: number, artifactName: string): string {
return `/runs/${runId}/artifacts/${encodeURIComponent(artifactName)}/download`
}
21 changes: 15 additions & 6 deletions frontend/src/components/modals/ArtifactsModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,12 +72,21 @@ export function ArtifactsModal() {
<span>{formatFileSize(artifact.size)}</span>
</div>
</div>
<button
className="artifact-download"
onClick={() => copyPath(artifact.path)}
>
{copiedPath === artifact.path ? 'Copied!' : 'Copy Path'}
</button>
<div className="artifact-actions">
<a
className="artifact-download"
href={api.getArtifactDownloadUrl(selectedRunId!, artifact.name)}
download
>
Download
</a>
<button
className="artifact-copy"
onClick={() => copyPath(artifact.path)}
>
{copiedPath === artifact.path ? 'Copied!' : 'Copy Path'}
</button>
</div>
</div>
))
)}
Expand Down
15 changes: 13 additions & 2 deletions frontend/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -729,7 +729,14 @@ body {
letter-spacing: 0.5px;
}

.artifact-download {
.artifact-actions {
display: flex;
gap: var(--space-xs);
flex-shrink: 0;
}

.artifact-download,
.artifact-copy {
padding: var(--space-xs) var(--space-sm);
background: var(--ui-element-background);
border: 1px solid var(--ui-element-border);
Expand All @@ -739,9 +746,13 @@ body {
cursor: pointer;
transition: all 0.15s;
white-space: nowrap;
text-decoration: none;
display: inline-flex;
align-items: center;
}

.artifact-download:hover {
.artifact-download:hover,
.artifact-copy:hover {
background: var(--ui-element-background-hover);
border-color: var(--ui-element-border-hover);
}
Expand Down
51 changes: 50 additions & 1 deletion src/vespaembed/web/app.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
import io
import json
import os
import re
import subprocess
import sys
import time
import zipfile
from pathlib import Path
from typing import Optional

from fastapi import FastAPI, File, Form, HTTPException, UploadFile
from fastapi.responses import HTMLResponse
from fastapi.responses import FileResponse, HTMLResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, Field, field_validator

Expand Down Expand Up @@ -639,3 +641,50 @@ async def get_run_artifacts(run_id: int):
)

return {"artifacts": artifacts, "output_dir": str(output_dir)}


@app.get("/runs/{run_id}/artifacts/{artifact_name}/download")
async def download_artifact(run_id: int, artifact_name: str):
"""Download an artifact file or directory (as zip) for a training run."""
run = get_run(run_id)
if not run:
raise HTTPException(status_code=404, detail="Run not found")

output_dir = Path(run["output_dir"])
if not output_dir.exists():
raise HTTPException(status_code=404, detail="Output directory not found")

artifact_path = output_dir / artifact_name
if not artifact_path.exists():
raise HTTPException(status_code=404, detail="Artifact not found")

# Security: ensure the artifact is within the output directory
try:
artifact_path.resolve().relative_to(output_dir.resolve())
except ValueError:
raise HTTPException(status_code=403, detail="Access denied")

if artifact_path.is_file():
return FileResponse(
path=str(artifact_path),
filename=artifact_path.name,
media_type="application/octet-stream",
)

# Directory: stream as zip
def iter_zip():
buffer = io.BytesIO()
with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as zf:
for file_path in sorted(artifact_path.rglob("*")):
if file_path.is_file():
arcname = file_path.relative_to(artifact_path)
zf.write(file_path, arcname)
buffer.seek(0)
yield buffer.read()

zip_filename = f"{artifact_name}.zip"
return StreamingResponse(
iter_zip(),
media_type="application/zip",
headers={"Content-Disposition": f'attachment; filename="{zip_filename}"'},
)