diff --git a/package-lock.json b/package-lock.json index e6ddb095..856f8846 100644 --- a/package-lock.json +++ b/package-lock.json @@ -92,7 +92,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -1992,7 +1991,6 @@ "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", @@ -2036,7 +2034,6 @@ "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz", "integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", @@ -3401,7 +3398,6 @@ "resolved": "https://registry.npmjs.org/@mui/material/-/material-6.5.0.tgz", "integrity": "sha512-yjvtXoFcrPLGtgKRxFaH6OQPtcLPhkloC0BML6rBG5UeldR0nPULR/2E2BfXdo5JNV7j7lOzrrLX2Qf/iSidow==", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.26.0", "@mui/core-downloads-tracker": "^6.5.0", @@ -3552,6 +3548,7 @@ "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-7.3.8.tgz", "integrity": "sha512-JHAeXQzS0tJ+Fq3C6J4TVDsW+yKhO4uuxuiLaopNStJeQYBIUCXpKYyUCcgXym4AmhbznQnv9RlHywSH6b0FOg==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.28.6", "@emotion/cache": "^11.14.0", @@ -3627,6 +3624,7 @@ "resolved": "https://registry.npmjs.org/@mui/system/-/system-7.3.8.tgz", "integrity": "sha512-hoFRj4Zw2Km8DPWZp/nKG+ao5Jw5LSk2m/e4EGc6M3RRwXKEkMSG4TgtfVJg7dS2homRwtdXSMW+iRO0ZJ4+IA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.28.6", "@mui/private-theming": "^7.3.8", @@ -3667,6 +3665,7 @@ "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-7.3.8.tgz", "integrity": "sha512-du5dlPZ9XL3xW2apHoGDXBI+QLtyVJGrXNCfcNYfP/ojkz1RQ0rRV6VG9Rkm1DqEFRG8mjjTL7zmE1Bvn1eR4A==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.28.6", "@mui/utils": "^7.3.8", @@ -3694,6 +3693,7 @@ "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.4.11.tgz", "integrity": "sha512-fZ2xO9D08IKOxO2oUBi1nnVKH6oJUD+64cnv4YAaFoC0E5+i1+S5AHbNqqvZlYYsbPEQ6qEVwuBqY3jl5W4G+Q==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.28.6" }, @@ -3711,6 +3711,7 @@ "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-7.3.8.tgz", "integrity": "sha512-kZRcE2620CBGr+XI8YMmwPj6WIPwSF7uMJjvSfqd8zXVvlz0MCJbzRRUGNf8NgflCLthdji2DdS643TeyJ3+nA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.28.6", "@mui/types": "^7.4.11", @@ -4295,7 +4296,6 @@ "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -4657,7 +4657,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5215,7 +5214,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -6258,7 +6256,6 @@ "integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -6319,7 +6316,6 @@ "integrity": "sha512-iI1f+D2ViGn+uvv5HuHVUamg8ll4tN+JRHGc6IJi4TP9Kl976C57fzPXgseXNs8v0iA8aSJpHsTWjDb9QJamGQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -9197,7 +9193,6 @@ "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" } @@ -9917,7 +9912,6 @@ "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -10068,7 +10062,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -10081,7 +10074,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -11071,7 +11063,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -11401,7 +11392,6 @@ "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -11495,7 +11485,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, diff --git a/src/__tests__/TagPage/HistoryLayers.test.jsx b/src/__tests__/TagPage/HistoryLayers.test.jsx index 8baea02e..d2196e37 100644 --- a/src/__tests__/TagPage/HistoryLayers.test.jsx +++ b/src/__tests__/TagPage/HistoryLayers.test.jsx @@ -25,6 +25,21 @@ const mockLayersList = [ } ]; +const mockArtifactLayers = [ + { + mediaType: 'text/plain', + size: 12, + digest: 'sha256:a948904f2f0f479b8f8197694b30184b0d2ed1c1cd2a1ec0fb85d299a192a447', + annotations: [{ key: 'org.opencontainers.image.title', value: 'artifact.txt' }] + }, + { + mediaType: 'application/octet-stream', + size: 1024, + digest: 'sha256:b948904f2f0f479b8f8197694b30184b0d2ed1c1cd2a1ec0fb85d299a192a448', + annotations: [] + } +]; + afterEach(() => { // restore the spy created with spyOn jest.restoreAllMocks(); @@ -49,3 +64,70 @@ describe('Layers page', () => { expect(await screen.findAllByText(/DIGEST/i)).toHaveLength(1); }); }); + +describe('Artifact files display', () => { + it('renders "Artifact Files" title for artifact manifests', async () => { + render( + + ); + expect(await screen.findByText('Artifact Files')).toBeInTheDocument(); + }); + + it('renders artifact file cards for artifact manifests', async () => { + render( + + ); + expect(await screen.findAllByTestId('artifact-file-card')).toHaveLength(2); + }); + + it('shows artifact file title from org.opencontainers.image.title annotation', async () => { + render( + + ); + expect(await screen.findByText('artifact.txt')).toBeInTheDocument(); + }); + + it('shows media type for each artifact file', async () => { + render( + + ); + expect(await screen.findByText('text/plain')).toBeInTheDocument(); + }); + + it('shows "No artifact files available" when artifact has no layers', async () => { + render( + + ); + expect(await screen.findByText(/No artifact files available/i)).toBeInTheDocument(); + }); + + it('renders "Layers" title for regular (non-artifact) manifests', async () => { + render(); + expect(await screen.findByText('Layers')).toBeInTheDocument(); + }); +}); diff --git a/src/__tests__/TagPage/TagDetails.test.jsx b/src/__tests__/TagPage/TagDetails.test.jsx index 0c436079..8cc3d11b 100644 --- a/src/__tests__/TagPage/TagDetails.test.jsx +++ b/src/__tests__/TagPage/TagDetails.test.jsx @@ -1111,3 +1111,76 @@ describe('Tags details', () => { await waitFor(() => expect(screen.queryAllByTestId('successPulled-buton')).toHaveLength(0), { timeout: 4500 }); }); }); + +const mockArtifactImage = { + Image: { + RepoName: 'hello-artifact', + Tag: 'v1', + TaggedTimestamp: '2026-05-05T16:40:57Z', + Manifests: [ + { + Digest: 'sha256:3f41230db4272fb8909e21c431d8e589682e3d743ed47d98e76a3925cff06976', + LastUpdated: '2026-05-05T16:40:57Z', + Size: '574', + ArtifactType: 'application/vnd.acme.rocket.config', + Platform: {}, + Layers: [ + { + MediaType: 'text/plain', + Size: '12', + Digest: 'sha256:a948904f2f0f479b8f8197694b30184b0d2ed1c1cd2a1ec0fb85d299a192a447', + Annotations: [{ Key: 'org.opencontainers.image.title', Value: 'artifact.txt' }] + } + ], + History: [] + } + ], + Vulnerabilities: { MaxSeverity: 'NONE', Count: 0 } + } +}; + +describe('Artifact tag details', () => { + it('should show ORAS tab in pull dropdown for artifact manifests', async () => { + jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: { data: mockArtifactImage } }); + render(); + const dropdown = await screen.findByText( + `Pull ${mockArtifactImage.Image.RepoName}:${mockArtifactImage.Image.Tag}` + ); + userEvent.click(dropdown); + await waitFor(() => expect(screen.queryAllByTestId('pull-menuItem')).toHaveLength(1)); + expect(await screen.findByText('ORAS')).toBeInTheDocument(); + }); + + it('should copy the oras pull string to clipboard for artifact manifests', async () => { + jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: { data: mockArtifactImage } }); + render(); + const dropdown = await screen.findByText( + `Pull ${mockArtifactImage.Image.RepoName}:${mockArtifactImage.Image.Tag}` + ); + userEvent.click(dropdown); + await waitFor(() => expect(screen.queryAllByTestId('pull-menuItem')).toHaveLength(1)); + fireEvent.click(await screen.findByTestId('orasPullcopy-btn')); + await waitFor(() => + expect(mockCopyToClipboard).toHaveBeenCalledWith( + `oras pull localhost/${mockArtifactImage.Image.RepoName}:${mockArtifactImage.Image.Tag}` + ) + ); + }); + + it('should show artifact type in metadata for artifact manifests', async () => { + jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: { data: mockArtifactImage } }); + render(); + expect(await screen.findByText('Artifact Type')).toBeInTheDocument(); + expect(await screen.findByTestId('artifact-type')).toHaveTextContent( + 'application/vnd.acme.rocket.config' + ); + }); + + it('should show "Artifact Files" tab content for artifact manifests', async () => { + jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: { data: mockArtifactImage } }); + render(); + expect(await screen.findByText('Artifact Files')).toBeInTheDocument(); + expect(await screen.findByText('artifact.txt')).toBeInTheDocument(); + }); +}); + diff --git a/src/api.js b/src/api.js index aad1d59b..66096b4e 100644 --- a/src/api.js +++ b/src/api.js @@ -92,7 +92,7 @@ const endpoints = { detailedRepoInfo: (name) => `/v2/_zot/ext/search?query={ExpandedRepoInfo(repo:"${name}"){Images {Manifests {Digest Platform {Os Arch} Size} Vulnerabilities {MaxSeverity Count} Tag LastUpdated Vendor IsDeletable } Summary {Name LastUpdated Size Platforms {Os Arch} Vendors IsStarred IsBookmarked NewestImage {RepoName IsSigned SignatureInfo { Tool IsTrusted Author } Vulnerabilities {MaxSeverity Count} Manifests {Digest} Tag Vendor Title Documentation DownloadCount Source Description Licenses}}}}`, detailedImageInfo: (name, tag) => - `/v2/_zot/ext/search?query={Image(image: "${name}:${tag}"){RepoName IsSigned SignatureInfo { Tool IsTrusted Author } Vulnerabilities {MaxSeverity Count} Referrers {MediaType ArtifactType Size Digest Annotations{Key Value}} Tag TaggedTimestamp Manifests {History {Layer {Size Digest} HistoryDescription {CreatedBy EmptyLayer}} Digest ConfigDigest LastUpdated Size Platform {Os Arch} Referrers {MediaType ArtifactType Size Digest Annotations{Key Value}}} Vendor Licenses }}`, + `/v2/_zot/ext/search?query={Image(image: "${name}:${tag}"){RepoName IsSigned SignatureInfo { Tool IsTrusted Author } Vulnerabilities {MaxSeverity Count} Referrers {MediaType ArtifactType Size Digest Annotations{Key Value}} Tag TaggedTimestamp Manifests {ArtifactType Layers {MediaType Size Digest Annotations{Key Value}} History {Layer {Size Digest} HistoryDescription {CreatedBy EmptyLayer}} Digest ConfigDigest LastUpdated Size Platform {Os Arch} Referrers {MediaType ArtifactType Size Digest Annotations{Key Value}}} Vendor Licenses }}`, vulnerabilitiesForRepo: ( name, { pageNumber = 1, pageSize = 15 }, diff --git a/src/components/Shared/ArtifactFileCard.jsx b/src/components/Shared/ArtifactFileCard.jsx new file mode 100644 index 00000000..99d8b56a --- /dev/null +++ b/src/components/Shared/ArtifactFileCard.jsx @@ -0,0 +1,150 @@ +import React, { useState } from 'react'; + +import transform from 'utilities/transform'; + +import { Card, CardContent, Typography, Grid, Divider, Stack, Collapse, Tooltip } from '@mui/material'; +import { KeyboardArrowDown, KeyboardArrowRight } from '@mui/icons-material'; + +import makeStyles from '@mui/styles/makeStyles'; + +const useStyles = makeStyles(() => ({ + card: { + marginBottom: 2, + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + backgroundColor: '#FFFFFF', + border: '1px solid #E0E5EB', + borderRadius: '0.75rem', + alignSelf: 'stretch', + flexGrow: 0, + order: 0, + width: '100%' + }, + content: { + textAlign: 'left', + color: '#52637A', + width: '100%', + boxSizing: 'border-box', + padding: '1rem', + backgroundColor: '#FFFFFF', + '&:hover': { + backgroundColor: '#FFFFFF' + }, + '&:last-child': { + paddingBottom: '1rem' + } + }, + fileName: { + fontSize: '1rem', + fontWeight: '400', + paddingRight: '0.5rem', + paddingBottom: '0.5rem', + paddingTop: '0.5rem', + textAlign: 'left', + width: '100%', + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis' + }, + mediaType: { + fontSize: '0.875rem', + fontWeight: '400', + paddingBottom: '0.25rem', + color: '#52637A' + }, + values: { + fontSize: '1rem', + fontWeight: '400', + paddingBottom: '0.5rem', + paddingTop: '0.5rem', + textAlign: 'right' + }, + dropdownText: { + color: '#1479FF', + paddingTop: '1rem', + fontSize: '1rem', + fontWeight: '600', + cursor: 'pointer', + textAlign: 'center' + }, + dropdownButton: { + color: '#1479FF', + paddingTop: '1rem', + fontSize: '0.8125rem', + fontWeight: '600', + cursor: 'pointer' + }, + dropdownContentBox: { + boxSizing: 'border-box', + color: '#52637A', + fontSize: '1rem', + fontWeight: '400', + padding: '0.75rem', + backgroundColor: '#F7F7F7', + borderRadius: '0.9rem', + overflowWrap: 'break-word' + }, + divider: { + margin: '1rem 0' + } +})); + +const TITLE_ANNOTATION = 'org.opencontainers.image.title'; + +function ArtifactFileCard(props) { + const classes = useStyles(); + const { layer } = props; + const [open, setOpen] = useState(false); + + const title = layer?.annotations?.find((a) => a.key === TITLE_ANNOTATION)?.value || layer?.digest || ''; + + return ( + + + + + + + {title} + + + {layer?.mediaType && ( + + {layer.mediaType} + + )} + + + + {transform.formatBytes(layer?.size)} + + + + + + + setOpen((prev) => !prev)}> + {!open ? ( + + ) : ( + + )} + DETAILS + + + + DIGEST + + {layer?.digest} + + + + + + + + ); +} + +export default ArtifactFileCard; diff --git a/src/components/Shared/PullCommandButton.jsx b/src/components/Shared/PullCommandButton.jsx index aff8fb40..c05b572a 100644 --- a/src/components/Shared/PullCommandButton.jsx +++ b/src/components/Shared/PullCommandButton.jsx @@ -3,7 +3,7 @@ import React, { useState, useEffect, useRef } from 'react'; import makeStyles from '@mui/styles/makeStyles'; import { Grid, Button, FormControl, Menu, MenuItem, Box, Tab, InputBase, IconButton, ButtonBase } from '@mui/material'; import { TabContext, TabList, TabPanel } from '@mui/lab'; -import { dockerPull, podmanPull, skopeoPull } from 'utilities/pullStrings'; +import { dockerPull, podmanPull, skopeoPull, orasPull } from 'utilities/pullStrings'; import CheckCircleIcon from '@mui/icons-material/CheckCircle'; import ContentCopyIcon from '@mui/icons-material/ContentCopy'; @@ -111,13 +111,14 @@ const useStyles = makeStyles((theme) => ({ function PullCommandButton(props) { const classes = useStyles(); - const { imageName } = props; + const { imageName, isArtifact } = props; + const defaultPull = isArtifact ? orasPull(imageName) : dockerPull(imageName); const [anchor, setAnchor] = useState(); const open = Boolean(anchor); - const [pullString, setPullString] = useState(dockerPull(imageName)); + const [pullString, setPullString] = useState(defaultPull); const [isCopied, setIsCopied] = useState(false); - const [selectedPullTab, setSelectedPullTab] = useState(dockerPull(imageName)); + const [selectedPullTab, setSelectedPullTab] = useState(defaultPull); const mounted = useRef(false); @@ -193,12 +194,35 @@ function PullCommandButton(props) { TabIndicatorProps={{ className: classes.selectedPullTab }} sx={{ '& button.Mui-selected': { color: '#14191F', fontWeight: '600' } }} > + {isArtifact && } + {isArtifact && ( + + + + + e.preventDefault()} + className={classes.textEllipsis} + defaultValue={orasPull(imageName)} + data-testid="oras-input" + /> + + + + + + + + + + )} diff --git a/src/components/Tag/Tabs/HistoryLayers.jsx b/src/components/Tag/Tabs/HistoryLayers.jsx index c37146e6..9077ba11 100644 --- a/src/components/Tag/Tabs/HistoryLayers.jsx +++ b/src/components/Tag/Tabs/HistoryLayers.jsx @@ -3,6 +3,7 @@ import React, { useEffect, useMemo, useState } from 'react'; // components import { Stack, Typography } from '@mui/material'; import LayerCard from '../../Shared/LayerCard.jsx'; +import ArtifactFileCard from '../../Shared/ArtifactFileCard.jsx'; import makeStyles from '@mui/styles/makeStyles'; import Loading from '../../Shared/Loading'; @@ -25,7 +26,8 @@ function HistoryLayers(props) { const [historyData, setHistoryData] = useState([]); const [isLoading, setIsLoading] = useState(true); const abortController = useMemo(() => new AbortController(), []); - const { name, history } = props; + const { name, history, artifactType, layers } = props; + const isArtifact = Boolean(artifactType); useEffect(() => { setHistoryData(history); @@ -38,10 +40,22 @@ function HistoryLayers(props) { return ( <> - Layers + {isArtifact ? 'Artifact Files' : 'Layers'} {isLoading ? ( + ) : isArtifact ? ( + + {layers?.length > 0 ? ( + layers.map((layer, index) => ( + + )) + ) : ( + + No artifact files available + + )} + ) : ( {historyData?.length > 0 ? ( diff --git a/src/components/Tag/TagDetails.jsx b/src/components/Tag/TagDetails.jsx index 87c00dde..734da77b 100644 --- a/src/components/Tag/TagDetails.jsx +++ b/src/components/Tag/TagDetails.jsx @@ -226,7 +226,14 @@ function TagDetails() { return ; default: - return ; + return ( + + ); } }; @@ -369,6 +376,7 @@ function TagDetails() { lastTagged={imageDetailData?.lastTagged} license={imageDetailData?.license} imageName={imageDetailData?.name} + artifactType={selectedManifest?.artifactType} /> diff --git a/src/components/Tag/TagDetailsMetadata.jsx b/src/components/Tag/TagDetailsMetadata.jsx index 20ff922a..d83662a3 100644 --- a/src/components/Tag/TagDetailsMetadata.jsx +++ b/src/components/Tag/TagDetailsMetadata.jsx @@ -52,7 +52,9 @@ const useStyles = makeStyles((theme) => ({ function TagDetailsMetadata(props) { const classes = useStyles(); - const { platform, lastUpdated, lastTagged, size, license, imageName } = props; + const { platform, lastUpdated, lastTagged, size, license, imageName, artifactType } = props; + + const isArtifact = Boolean(artifactType); const lastDate = lastUpdated ? DateTime.fromISO(lastUpdated).toRelative({ unit: ['weeks', 'days', 'hours', 'minutes'] }) @@ -67,7 +69,7 @@ function TagDetailsMetadata(props) { - + @@ -83,6 +85,22 @@ function TagDetailsMetadata(props) { + {isArtifact && ( + + + + + Artifact Type + + + + {artifactType} + + + + + + )} diff --git a/src/utilities/objectModels.js b/src/utilities/objectModels.js index 4843a6f8..2a7eb0ef 100644 --- a/src/utilities/objectModels.js +++ b/src/utilities/objectModels.js @@ -85,7 +85,13 @@ const mapToManifest = (responseManifest) => { platform: responseManifest.Platform, downloadCount: responseManifest.DownloadCount, starCount: responseManifest.StarCount, - layers: responseManifest.Layers, + artifactType: responseManifest.ArtifactType, + layers: responseManifest.Layers?.map((layer) => ({ + mediaType: layer.MediaType, + size: layer.Size, + digest: layer.Digest, + annotations: layer.Annotations?.map((a) => ({ key: a.Key, value: a.Value })) + })), history: responseManifest.History, vulnerabilities: responseManifest.Vulnerabilities, referrers: responseManifest.Referrers diff --git a/src/utilities/pullStrings.js b/src/utilities/pullStrings.js index 4cae0e7b..e2ced35c 100644 --- a/src/utilities/pullStrings.js +++ b/src/utilities/pullStrings.js @@ -3,5 +3,6 @@ import { hostRoot } from 'host'; const dockerPull = (imageName) => `docker pull ${hostRoot()}/${imageName}`; const podmanPull = (imageName) => `podman pull ${hostRoot()}/${imageName}`; const skopeoPull = (imageName) => `skopeo copy docker://${hostRoot()}/${imageName}`; +const orasPull = (imageName) => `oras pull ${hostRoot()}/${imageName}`; -export { dockerPull, podmanPull, skopeoPull }; +export { dockerPull, podmanPull, skopeoPull, orasPull };