diff --git a/frontend/src/components/listing/ListingArtifactRenderer.tsx b/frontend/src/components/listing/ListingArtifactRenderer.tsx index 4e8a1837..f82ae8b8 100644 --- a/frontend/src/components/listing/ListingArtifactRenderer.tsx +++ b/frontend/src/components/listing/ListingArtifactRenderer.tsx @@ -4,6 +4,7 @@ import { Link } from "react-router-dom"; import placeholder from "@/components/listing/pics/placeholder.jpg"; import { Artifact } from "@/components/listing/types"; import ROUTES from "@/lib/types/routes"; +import { formatFileSize } from "@/lib/utils/formatters"; interface Props { artifact: Artifact; @@ -45,6 +46,11 @@ const ListingArtifactRenderer = ({ artifact }: Props) => {
{artifact.name}
Kernel Image File
+ {artifact.size && ( +
+ {formatFileSize(artifact.size)} +
+ )}
{new Date(artifact.timestamp * 1000).toLocaleString()}
diff --git a/frontend/src/components/listing/ListingImageGallery.tsx b/frontend/src/components/listing/ListingImageGallery.tsx index 21537247..6ca3f2b4 100644 --- a/frontend/src/components/listing/ListingImageGallery.tsx +++ b/frontend/src/components/listing/ListingImageGallery.tsx @@ -3,8 +3,10 @@ import { FaFileDownload, FaStar, FaTimes } from "react-icons/fa"; import { Artifact } from "@/components/listing/types"; import DeleteConfirmationModal from "@/components/modals/DeleteConfirmationModal"; +import DownloadConfirmationModal from "@/components/modals/DownloadConfirmationModal"; import { useAlertQueue } from "@/hooks/useAlertQueue"; import { useAuthentication } from "@/hooks/useAuth"; +import { formatFileSize } from "@/lib/utils/formatters"; import { Tooltip } from "../ui/ToolTip"; import { Button } from "../ui/button"; @@ -41,6 +43,7 @@ const ListingImageItem = ({ const [isDeleting, setIsDeleting] = useState(false); const [isUpdating, setIsUpdating] = useState(false); const [showDeleteModal, setShowDeleteModal] = useState(false); + const [showDownloadModal, setShowDownloadModal] = useState(false); const auth = useAuthentication(); const { addErrorAlert } = useAlertQueue(); @@ -125,8 +128,12 @@ const ListingImageItem = ({ artifact.name.toLowerCase().endsWith(".img") || artifact.artifact_type === "kernel"; - const handleDownload = (e: React.MouseEvent) => { + const initiateDownload = (e: React.MouseEvent) => { e.stopPropagation(); + setShowDownloadModal(true); + }; + + const handleDownload = () => { if (!artifact.urls.large) { addErrorAlert("Artifact URL not available."); return; @@ -168,14 +175,10 @@ const ListingImageItem = ({ )} {isKernelImage && ( - + + +
+ + + ); +}; + +export default DownloadConfirmationModal; diff --git a/frontend/src/gen/api.ts b/frontend/src/gen/api.ts index 6c5b9fc9..9761df01 100644 --- a/frontend/src/gen/api.ts +++ b/frontend/src/gen/api.ts @@ -1807,6 +1807,13 @@ export interface components { order: components["schemas"]["Order"]; product: components["schemas"]["ProductInfo"] | null; }; + /** PresignedUrlResponse */ + PresignedUrlResponse: { + /** Upload Url */ + upload_url: string; + /** Artifact Id */ + artifact_id: string; + }; /** ProcessPreorderResponse */ ProcessPreorderResponse: { /** Status */ @@ -1958,6 +1965,8 @@ export interface components { * @default false */ can_edit: boolean; + /** Size */ + size?: number | null; }; /** SingleRobotResponse */ SingleRobotResponse: { @@ -2719,7 +2728,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": Record; + "application/json": components["schemas"]["PresignedUrlResponse"]; }; }; /** @description Validation Error */ diff --git a/frontend/src/lib/utils/formatters.ts b/frontend/src/lib/utils/formatters.ts new file mode 100644 index 00000000..1d693657 --- /dev/null +++ b/frontend/src/lib/utils/formatters.ts @@ -0,0 +1,7 @@ +export const formatFileSize = (bytes: number): string => { + if (!bytes) return "0 B"; + const k = 1024; + const sizes = ["B", "KB", "MB", "GB"]; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`; +}; diff --git a/store/app/routers/artifacts.py b/store/app/routers/artifacts.py index 5c25f583..5b6d2d8b 100644 --- a/store/app/routers/artifacts.py +++ b/store/app/routers/artifacts.py @@ -7,6 +7,7 @@ from typing import Annotated, Literal, Self from boto3.dynamodb.conditions import Key +from botocore.exceptions import ClientError from fastapi import APIRouter, Depends, HTTPException, UploadFile, status from fastapi.responses import RedirectResponse from pydantic.main import BaseModel @@ -135,6 +136,7 @@ class SingleArtifactResponse(BaseModel): urls: ArtifactUrls is_main: bool = False can_edit: bool = False + size: int | None = None @classmethod async def from_artifact( @@ -187,6 +189,19 @@ async def get_listing(listing: Listing | None) -> Listing: get_listing(listing), ) + s3_filename = get_artifact_name(artifact=artifact) + size = None + if crud is not None: + try: + s3_object = await crud.s3.meta.client.head_object( + Bucket=settings.s3.bucket, Key=f"{settings.s3.prefix}{s3_filename}" + ) + size = s3_object.get("ContentLength") + except ClientError as e: + logger.error(f"Failed to get S3 object size: {e}") + except Exception as e: + logger.error(f"Unexpected error getting file size: {e}") + return cls( artifact_id=artifact.id, listing_id=artifact.listing_id, @@ -199,6 +214,7 @@ async def get_listing(listing: Listing | None) -> Listing: urls=get_artifact_url_response(artifact=artifact), is_main=artifact.is_main, can_edit=can_edit, + size=size, )