mirror of
https://github.com/quay/quay.git
synced 2025-04-18 10:44:06 +03:00
ui: implement change to render modelcard stored in layers (PROJQUAY-8642) (#3692)
* ui: implement change to render modelcard stored in layers (PROJQUAY-8412) When a manifest has certain annotations or artifactTypes, render the applicable modelcard markdown in a new tags detail tab. * removing untar when fetching model card * removing extra api calls * Add modelcar check tests --------- Co-authored-by: bcaton <bcaton@redhat.com>
This commit is contained in:
parent
ad3423e223
commit
5f8ca041e7
@ -843,6 +843,12 @@ class DefaultConfig(ImmutableConfig):
|
|||||||
|
|
||||||
# Feature Flag: Enables user to try the beta UI Environment
|
# Feature Flag: Enables user to try the beta UI Environment
|
||||||
FEATURE_UI_V2 = False
|
FEATURE_UI_V2 = False
|
||||||
|
FEATURE_UI_MODELCARD = True
|
||||||
|
UI_MODELCARD_ARTIFACT_TYPE = "application/x-mlmodel"
|
||||||
|
UI_MODELCARD_ANNOTATION: Optional[Dict[str, str]] = {}
|
||||||
|
UI_MODELCARD_LAYER_ANNOTATION: Optional[Dict[str, str]] = {
|
||||||
|
"org.opencontainers.image.title": "README.md"
|
||||||
|
}
|
||||||
|
|
||||||
# User feedback form for UI-V2
|
# User feedback form for UI-V2
|
||||||
UI_V2_FEEDBACK_FORM = "https://7qdvkuo9rkj.typeform.com/to/XH5YE79P"
|
UI_V2_FEEDBACK_FORM = "https://7qdvkuo9rkj.typeform.com/to/XH5YE79P"
|
||||||
|
@ -8,8 +8,10 @@ from typing import List, Optional
|
|||||||
|
|
||||||
from flask import request
|
from flask import request
|
||||||
|
|
||||||
from app import label_validator, storage
|
import features
|
||||||
|
from app import app, label_validator, storage
|
||||||
from data.model import InvalidLabelKeyException, InvalidMediaTypeException
|
from data.model import InvalidLabelKeyException, InvalidMediaTypeException
|
||||||
|
from data.model.oci.retriever import RepositoryContentRetriever
|
||||||
from data.registry_model import registry_model
|
from data.registry_model import registry_model
|
||||||
from digest import digest_tools
|
from digest import digest_tools
|
||||||
from endpoints.api import (
|
from endpoints.api import (
|
||||||
@ -31,6 +33,7 @@ from endpoints.api import (
|
|||||||
validate_json_request,
|
validate_json_request,
|
||||||
)
|
)
|
||||||
from endpoints.exception import NotFound
|
from endpoints.exception import NotFound
|
||||||
|
from util.parsing import truthy_bool
|
||||||
from util.validation import VALID_LABEL_KEY_REGEX
|
from util.validation import VALID_LABEL_KEY_REGEX
|
||||||
|
|
||||||
BASE_MANIFEST_ROUTE = '/v1/repository/<apirepopath:repository>/manifest/<regex("{0}"):manifestref>'
|
BASE_MANIFEST_ROUTE = '/v1/repository/<apirepopath:repository>/manifest/<regex("{0}"):manifestref>'
|
||||||
@ -96,6 +99,30 @@ def _manifest_dict(manifest):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _get_modelcard_layer_digest(parsed):
|
||||||
|
manifest_modelcard_annotation = app.config["UI_MODELCARD_ANNOTATION"]
|
||||||
|
manifest_layer_modelcard_annotation = app.config["UI_MODELCARD_LAYER_ANNOTATION"]
|
||||||
|
|
||||||
|
layer_digest = None
|
||||||
|
if parsed.artifact_type and parsed.artifact_type == app.config["UI_MODELCARD_ARTIFACT_TYPE"]:
|
||||||
|
layer_digest = str(parsed.filesystem_layers[-1].digest)
|
||||||
|
|
||||||
|
elif (
|
||||||
|
manifest_modelcard_annotation
|
||||||
|
and hasattr(parsed, "annotations")
|
||||||
|
and manifest_modelcard_annotation.items() <= parsed.annotations.items()
|
||||||
|
):
|
||||||
|
for layer in parsed.filesystem_layers:
|
||||||
|
if (
|
||||||
|
hasattr(layer, "annotations")
|
||||||
|
and manifest_layer_modelcard_annotation.items() <= layer.annotations.items()
|
||||||
|
):
|
||||||
|
layer_digest = str(layer.digest)
|
||||||
|
break
|
||||||
|
|
||||||
|
return layer_digest
|
||||||
|
|
||||||
|
|
||||||
@resource(MANIFEST_DIGEST_ROUTE)
|
@resource(MANIFEST_DIGEST_ROUTE)
|
||||||
@path_param("repository", "The full path of the repository. e.g. namespace/name")
|
@path_param("repository", "The full path of the repository. e.g. namespace/name")
|
||||||
@path_param("manifestref", "The digest of the manifest")
|
@path_param("manifestref", "The digest of the manifest")
|
||||||
@ -107,7 +134,14 @@ class RepositoryManifest(RepositoryParamResource):
|
|||||||
@require_repo_read(allow_for_superuser=True)
|
@require_repo_read(allow_for_superuser=True)
|
||||||
@nickname("getRepoManifest")
|
@nickname("getRepoManifest")
|
||||||
@disallow_for_app_repositories
|
@disallow_for_app_repositories
|
||||||
def get(self, namespace_name, repository_name, manifestref):
|
@parse_args()
|
||||||
|
@query_param(
|
||||||
|
"include_modelcard",
|
||||||
|
"If specified, include modelcard markdown from image, if any",
|
||||||
|
type=truthy_bool,
|
||||||
|
default=False,
|
||||||
|
)
|
||||||
|
def get(self, namespace_name, repository_name, manifestref, parsed_args):
|
||||||
repo_ref = registry_model.lookup_repository(namespace_name, repository_name)
|
repo_ref = registry_model.lookup_repository(namespace_name, repository_name)
|
||||||
if repo_ref is None:
|
if repo_ref is None:
|
||||||
raise NotFound()
|
raise NotFound()
|
||||||
@ -120,7 +154,18 @@ class RepositoryManifest(RepositoryParamResource):
|
|||||||
if manifest is None or manifest.internal_manifest_bytes.as_unicode() == "":
|
if manifest is None or manifest.internal_manifest_bytes.as_unicode() == "":
|
||||||
raise NotFound()
|
raise NotFound()
|
||||||
|
|
||||||
return _manifest_dict(manifest)
|
manifest_dict = _manifest_dict(manifest)
|
||||||
|
|
||||||
|
if features.UI_MODELCARD and parsed_args["include_modelcard"]:
|
||||||
|
parsed = manifest.get_parsed_manifest()
|
||||||
|
layer_digest = _get_modelcard_layer_digest(parsed)
|
||||||
|
|
||||||
|
if layer_digest:
|
||||||
|
retriever = RepositoryContentRetriever(repo_ref._db_id, storage)
|
||||||
|
content = retriever.get_blob_bytes_with_digest(layer_digest)
|
||||||
|
manifest_dict["modelcard"] = content.decode("utf-8")
|
||||||
|
|
||||||
|
return manifest_dict
|
||||||
|
|
||||||
|
|
||||||
@resource(MANIFEST_DIGEST_ROUTE + "/labels")
|
@resource(MANIFEST_DIGEST_ROUTE + "/labels")
|
||||||
|
@ -1,9 +1,14 @@
|
|||||||
from test.fixtures import *
|
from mock import patch
|
||||||
|
|
||||||
|
from app import app as realapp
|
||||||
from data.registry_model import registry_model
|
from data.registry_model import registry_model
|
||||||
from endpoints.api.manifest import RepositoryManifest
|
from endpoints.api.manifest import RepositoryManifest, _get_modelcard_layer_digest
|
||||||
from endpoints.api.test.shared import conduct_api_call
|
from endpoints.api.test.shared import conduct_api_call
|
||||||
from endpoints.test.shared import client_with_identity
|
from endpoints.test.shared import client_with_identity
|
||||||
|
from features import FeatureNameValue
|
||||||
|
from image.oci.manifest import OCIManifest
|
||||||
|
from test.fixtures import *
|
||||||
|
from util.bytes import Bytes
|
||||||
|
|
||||||
|
|
||||||
def test_repository_manifest(app):
|
def test_repository_manifest(app):
|
||||||
@ -22,3 +27,85 @@ def test_repository_manifest(app):
|
|||||||
result = conduct_api_call(cl, RepositoryManifest, "GET", params, None, 200).json
|
result = conduct_api_call(cl, RepositoryManifest, "GET", params, None, 200).json
|
||||||
assert result["digest"] == manifest_digest
|
assert result["digest"] == manifest_digest
|
||||||
assert result["manifest_data"]
|
assert result["manifest_data"]
|
||||||
|
|
||||||
|
|
||||||
|
ARTIFACT_MANIFEST = """{
|
||||||
|
"schemaVersion": 2,
|
||||||
|
"mediaType": "application/vnd.oci.image.manifest.v1+json",
|
||||||
|
"artifactType": "application/vnd.example+type",
|
||||||
|
"config": {
|
||||||
|
"mediaType": "application/vnd.oci.empty.v1+json",
|
||||||
|
"digest": "sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a",
|
||||||
|
"size": 2
|
||||||
|
},
|
||||||
|
"layers": [
|
||||||
|
{
|
||||||
|
"mediaType": "application/vnd.oci.image.layer.v1.tar",
|
||||||
|
"digest": "sha256:d2a84f4b8b650937ec8f73cd8be2c74add5a911ba64df27458ed8229da804a26",
|
||||||
|
"size": 12,
|
||||||
|
"annotations": {
|
||||||
|
"org.opencontainers.image.title": "hello.txt"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"annotations": {
|
||||||
|
"org.opencontainers.image.created": "2023-08-03T00:21:51Z"
|
||||||
|
}
|
||||||
|
}"""
|
||||||
|
|
||||||
|
|
||||||
|
IMAGE_MANIFEST = """{
|
||||||
|
"schemaVersion": 2,
|
||||||
|
"config": {
|
||||||
|
"mediaType": "application/vnd.oci.image.config.v1+json",
|
||||||
|
"digest": "sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a",
|
||||||
|
"size": 2,
|
||||||
|
"annotations": {
|
||||||
|
"hello": "world"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"layers": [
|
||||||
|
{
|
||||||
|
"mediaType": "application/vnd.oci.image.layer.v1.tar",
|
||||||
|
"digest": "sha256:22af0898315a239117308d39acd80636326c4987510b0ec6848e58eb584ba82e",
|
||||||
|
"size": 6,
|
||||||
|
"annotations": {
|
||||||
|
"fun": "more cream",
|
||||||
|
"org.opencontainers.image.title": "cake.txt"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mediaType": "application/vnd.oci.image.layer.v1.tar",
|
||||||
|
"digest": "sha256:be6fe11876282442bead98e8b24aca07f8972a763cd366c56b4b5f7bcdd23eac",
|
||||||
|
"size": 7,
|
||||||
|
"annotations": {
|
||||||
|
"org.opencontainers.image.title": "juice.txt"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"annotations": {
|
||||||
|
"foo": "bar"
|
||||||
|
}
|
||||||
|
}"""
|
||||||
|
|
||||||
|
|
||||||
|
def test_modelcar_layer(app):
|
||||||
|
manifest1 = OCIManifest(Bytes.for_string_or_unicode(ARTIFACT_MANIFEST))
|
||||||
|
manifest2 = OCIManifest(Bytes.for_string_or_unicode(IMAGE_MANIFEST))
|
||||||
|
|
||||||
|
realapp.config["UI_MODELCARD_ANNOTATION"] = {"foo": "bar"}
|
||||||
|
realapp.config["UI_MODELCARD_LAYER_ANNOTATION"] = {"org.opencontainers.image.title": "cake.txt"}
|
||||||
|
realapp.config["UI_MODELCARD_ARTIFACT_TYPE"] = "application/vnd.example+type"
|
||||||
|
|
||||||
|
with patch("features.UI_MODELCARD", FeatureNameValue("UI_MODELCARD", True)):
|
||||||
|
layer_digest1 = _get_modelcard_layer_digest(manifest1)
|
||||||
|
assert (
|
||||||
|
layer_digest1
|
||||||
|
== "sha256:d2a84f4b8b650937ec8f73cd8be2c74add5a911ba64df27458ed8229da804a26"
|
||||||
|
)
|
||||||
|
|
||||||
|
layer_digest2 = _get_modelcard_layer_digest(manifest2)
|
||||||
|
assert (
|
||||||
|
layer_digest2
|
||||||
|
== "sha256:22af0898315a239117308d39acd80636326c4987510b0ec6848e58eb584ba82e"
|
||||||
|
)
|
||||||
|
@ -222,3 +222,5 @@ ASSIGN_OAUTH_TOKEN: FeatureNameValue
|
|||||||
|
|
||||||
# Feature Flag: If set to true, supports setting notifications on image expiry
|
# Feature Flag: If set to true, supports setting notifications on image expiry
|
||||||
IMAGE_EXPIRY_TRIGGER: FeatureNameValue
|
IMAGE_EXPIRY_TRIGGER: FeatureNameValue
|
||||||
|
|
||||||
|
UI_MODELCARD: FeatureNameValue
|
||||||
|
@ -76,13 +76,13 @@ OCI_MANIFEST_URLS_KEY = "urls"
|
|||||||
OCI_MANIFEST_ANNOTATIONS_KEY = "annotations"
|
OCI_MANIFEST_ANNOTATIONS_KEY = "annotations"
|
||||||
OCI_MANIFEST_SUBJECT_KEY = "subject"
|
OCI_MANIFEST_SUBJECT_KEY = "subject"
|
||||||
OCI_MANIFEST_ARTIFACT_TYPE_KEY = "artifactType"
|
OCI_MANIFEST_ARTIFACT_TYPE_KEY = "artifactType"
|
||||||
OCI_MANIFEST_ANNOTATIONS_KEY = "annotations"
|
|
||||||
|
|
||||||
# Named tuples.
|
# Named tuples.
|
||||||
OCIManifestConfig = namedtuple("OCIManifestConfig", ["size", "digest"])
|
OCIManifestConfig = namedtuple("OCIManifestConfig", ["size", "digest"])
|
||||||
OCIManifestDescriptor = namedtuple("OCIManifestDescriptor", ["mediatype", "size", "digest"])
|
OCIManifestDescriptor = namedtuple("OCIManifestDescriptor", ["mediatype", "size", "digest"])
|
||||||
OCIManifestLayer = namedtuple(
|
OCIManifestLayer = namedtuple(
|
||||||
"OCIManifestLayer", ["index", "digest", "is_remote", "urls", "compressed_size"]
|
"OCIManifestLayer",
|
||||||
|
["index", "digest", "is_remote", "urls", "compressed_size", "mediatype", "annotations"],
|
||||||
)
|
)
|
||||||
|
|
||||||
OCIManifestImageLayer = namedtuple(
|
OCIManifestImageLayer = namedtuple(
|
||||||
@ -506,6 +506,7 @@ class OCIManifest(ManifestInterface):
|
|||||||
for index, layer in enumerate(self._parsed[OCI_MANIFEST_LAYERS_KEY]):
|
for index, layer in enumerate(self._parsed[OCI_MANIFEST_LAYERS_KEY]):
|
||||||
content_type = layer[OCI_MANIFEST_MEDIATYPE_KEY]
|
content_type = layer[OCI_MANIFEST_MEDIATYPE_KEY]
|
||||||
is_remote = content_type in OCI_IMAGE_NON_DISTRIBUTABLE_LAYER_CONTENT_TYPES
|
is_remote = content_type in OCI_IMAGE_NON_DISTRIBUTABLE_LAYER_CONTENT_TYPES
|
||||||
|
layer_annotations = layer.get(OCI_MANIFEST_ANNOTATIONS_KEY, {})
|
||||||
|
|
||||||
try:
|
try:
|
||||||
digest = digest_tools.Digest.parse_digest(layer[OCI_MANIFEST_DIGEST_KEY])
|
digest = digest_tools.Digest.parse_digest(layer[OCI_MANIFEST_DIGEST_KEY])
|
||||||
@ -520,6 +521,8 @@ class OCIManifest(ManifestInterface):
|
|||||||
digest=digest,
|
digest=digest,
|
||||||
is_remote=is_remote,
|
is_remote=is_remote,
|
||||||
urls=layer.get(OCI_MANIFEST_URLS_KEY),
|
urls=layer.get(OCI_MANIFEST_URLS_KEY),
|
||||||
|
mediatype=content_type,
|
||||||
|
annotations=layer_annotations,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -561,7 +564,7 @@ class OCIManifestBuilder(object):
|
|||||||
"""
|
"""
|
||||||
self.annotations[key] = value
|
self.annotations[key] = value
|
||||||
|
|
||||||
def add_layer(self, digest, size, urls=None):
|
def add_layer(self, digest, size, urls=None, annotations=None, mediatype=None):
|
||||||
"""
|
"""
|
||||||
Adds a filesystem layer to the manifest.
|
Adds a filesystem layer to the manifest.
|
||||||
"""
|
"""
|
||||||
@ -572,6 +575,8 @@ class OCIManifestBuilder(object):
|
|||||||
compressed_size=size,
|
compressed_size=size,
|
||||||
urls=urls,
|
urls=urls,
|
||||||
is_remote=bool(urls),
|
is_remote=bool(urls),
|
||||||
|
annotations=None,
|
||||||
|
mediatype=None,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -38,6 +38,39 @@ SAMPLE_MANIFEST = """{
|
|||||||
}
|
}
|
||||||
}"""
|
}"""
|
||||||
|
|
||||||
|
SAMPLE_MANIFEST2 = """{
|
||||||
|
"schemaVersion": 2,
|
||||||
|
"config": {
|
||||||
|
"mediaType": "application/vnd.oci.image.config.v1+json",
|
||||||
|
"size": 7023,
|
||||||
|
"digest": "sha256:b5b2b2c507a0944348e0303114d8d93aaaa081732b86451d9bce1f432a537bc7"
|
||||||
|
},
|
||||||
|
"layers": [
|
||||||
|
{
|
||||||
|
"mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
|
||||||
|
"size": 32654,
|
||||||
|
"digest": "sha256:9834876dcfb05cb167a5c24953eba58c4ac89b1adf57f28f2f9d09af107ee8f0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
|
||||||
|
"size": 16724,
|
||||||
|
"digest": "sha256:3c3a4604a545cdc127456d94e421cd355bca5b528f4a9c1905b15da2eb4a4c6b"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
|
||||||
|
"size": 73109,
|
||||||
|
"digest": "sha256:ec4b8955958665577945c89419d1af06b5f7636b4ac3da7f12184802ad867736",
|
||||||
|
"annotations": {
|
||||||
|
"com.example.layerkey1": "value1",
|
||||||
|
"com.example.layerkey2": "value2"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"annotations": {
|
||||||
|
"com.example.key1": "value1",
|
||||||
|
"com.example.key2": "value2"
|
||||||
|
}
|
||||||
|
}"""
|
||||||
|
|
||||||
SAMPLE_REMOTE_MANIFEST = """{
|
SAMPLE_REMOTE_MANIFEST = """{
|
||||||
"schemaVersion": 2,
|
"schemaVersion": 2,
|
||||||
@ -267,3 +300,16 @@ def test_validate_helm_oci_manifest():
|
|||||||
HELM_CHART_LAYER_TYPES = ["application/tar+gzip"]
|
HELM_CHART_LAYER_TYPES = ["application/tar+gzip"]
|
||||||
register_artifact_type(HELM_CHART_CONFIG_TYPE, HELM_CHART_LAYER_TYPES)
|
register_artifact_type(HELM_CHART_CONFIG_TYPE, HELM_CHART_LAYER_TYPES)
|
||||||
manifest = OCIManifest(Bytes.for_string_or_unicode(manifest_bytes))
|
manifest = OCIManifest(Bytes.for_string_or_unicode(manifest_bytes))
|
||||||
|
|
||||||
|
|
||||||
|
def test_manifest_layer_annotations():
|
||||||
|
manifest = OCIManifest(Bytes.for_string_or_unicode(SAMPLE_MANIFEST2))
|
||||||
|
assert manifest.annotations == {"com.example.key1": "value1", "com.example.key2": "value2"}
|
||||||
|
|
||||||
|
for layer in manifest.filesystem_layers:
|
||||||
|
assert hasattr(layer, "annotations")
|
||||||
|
if layer.annotations:
|
||||||
|
assert layer.annotations == {
|
||||||
|
"com.example.layerkey1": "value1",
|
||||||
|
"com.example.layerkey2": "value2",
|
||||||
|
}
|
||||||
|
@ -1425,6 +1425,26 @@ CONFIG_SCHEMA = {
|
|||||||
"description": "Enables user to try the beta UI Environment",
|
"description": "Enables user to try the beta UI Environment",
|
||||||
"x-example": False,
|
"x-example": False,
|
||||||
},
|
},
|
||||||
|
"FEATURE_UI_MODELCARD": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Enables modelcard image tab in UI",
|
||||||
|
"x-example": False,
|
||||||
|
},
|
||||||
|
"UI_MODELCARD_ARTIFACT_TYPE": {
|
||||||
|
"type": "str",
|
||||||
|
"description": "Defines the modelcard artifact type",
|
||||||
|
"x-example": "application/x-mlmodel",
|
||||||
|
},
|
||||||
|
"UI_MODELCARD_ANNOTATION": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Defines the layer annotation of the modelcard stored in an OCI image",
|
||||||
|
"x-example": {},
|
||||||
|
},
|
||||||
|
"UI_MODELCARD_LAYER_ANNOTATION": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Defines the layer annotation of the modelcard stored in an OCI image",
|
||||||
|
"x-example": {"org.opencontainers.image.title": "README.md"},
|
||||||
|
},
|
||||||
"EXPORT_COMPLIANCE_ENDPOINT": {
|
"EXPORT_COMPLIANCE_ENDPOINT": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "The Red Hat Export Compliance Service Endpoint (only used in Quay.io)",
|
"description": "The Red Hat Export Compliance Service Endpoint (only used in Quay.io)",
|
||||||
|
1128
web/package-lock.json
generated
1128
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -27,10 +27,12 @@
|
|||||||
"null-loader": "^4.0.1",
|
"null-loader": "^4.0.1",
|
||||||
"process": "^0.11.10",
|
"process": "^0.11.10",
|
||||||
"react": "^17.0.2",
|
"react": "^17.0.2",
|
||||||
|
"react-remark": "^2.1.0",
|
||||||
"react-router-dom": "^6.15.0",
|
"react-router-dom": "^6.15.0",
|
||||||
"react-scripts": "5.0.1",
|
"react-scripts": "5.0.1",
|
||||||
"recoil": "^0.7.7",
|
"recoil": "^0.7.7",
|
||||||
"recoil-persist": "^4.2.0",
|
"recoil-persist": "^4.2.0",
|
||||||
|
"remark-gemoji": "^8.0.0",
|
||||||
"ts-jest": "^29.1.1",
|
"ts-jest": "^29.1.1",
|
||||||
"typescript": "^4.6.3",
|
"typescript": "^4.6.3",
|
||||||
"use-react-router-breadcrumbs": "^4.0.1",
|
"use-react-router-breadcrumbs": "^4.0.1",
|
||||||
|
@ -24,6 +24,7 @@ export interface Tag {
|
|||||||
manifest_list: ManifestList;
|
manifest_list: ManifestList;
|
||||||
expiration?: string;
|
expiration?: string;
|
||||||
end_ts?: number;
|
end_ts?: number;
|
||||||
|
modelcard?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ManifestList {
|
export interface ManifestList {
|
||||||
@ -70,6 +71,7 @@ export interface ManifestByDigestResponse {
|
|||||||
manifest_data: string;
|
manifest_data: string;
|
||||||
config_media_type?: any;
|
config_media_type?: any;
|
||||||
layers?: any;
|
layers?: any;
|
||||||
|
modelcard?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SecurityDetailsResponse {
|
export interface SecurityDetailsResponse {
|
||||||
@ -326,9 +328,10 @@ export async function getManifestByDigest(
|
|||||||
org: string,
|
org: string,
|
||||||
repo: string,
|
repo: string,
|
||||||
digest: string,
|
digest: string,
|
||||||
|
include_modelcard: boolean,
|
||||||
) {
|
) {
|
||||||
const response: AxiosResponse<ManifestByDigestResponse> = await axios.get(
|
const response: AxiosResponse<ManifestByDigestResponse> = await axios.get(
|
||||||
`/api/v1/repository/${org}/${repo}/manifest/${digest}`,
|
`/api/v1/repository/${org}/${repo}/manifest/${digest}?include_modelcard=true`,
|
||||||
);
|
);
|
||||||
assertHttpCode(response.status, 200);
|
assertHttpCode(response.status, 200);
|
||||||
return response.data;
|
return response.data;
|
||||||
|
20
web/src/routes/TagDetails/ModelCard/ModelCard.tsx
Normal file
20
web/src/routes/TagDetails/ModelCard/ModelCard.tsx
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import {PageSection, Divider, TextContent} from '@patternfly/react-core';
|
||||||
|
|
||||||
|
import {Remark} from 'react-remark';
|
||||||
|
|
||||||
|
export function ModelCard(props: ModelCardProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Divider />
|
||||||
|
<PageSection>
|
||||||
|
<TextContent>
|
||||||
|
<Remark>{props.modelCard}</Remark>
|
||||||
|
</TextContent>
|
||||||
|
</PageSection>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ModelCardProps {
|
||||||
|
modelCard: string;
|
||||||
|
}
|
@ -22,6 +22,7 @@ import {
|
|||||||
parseRepoNameFromUrl,
|
parseRepoNameFromUrl,
|
||||||
parseTagNameFromUrl,
|
parseTagNameFromUrl,
|
||||||
} from '../../libs/utils';
|
} from '../../libs/utils';
|
||||||
|
import {useQuayConfig} from 'src/hooks/UseQuayConfig';
|
||||||
import TagArchSelect from './TagDetailsArchSelect';
|
import TagArchSelect from './TagDetailsArchSelect';
|
||||||
import TagTabs from './TagDetailsTabs';
|
import TagTabs from './TagDetailsTabs';
|
||||||
|
|
||||||
@ -46,6 +47,8 @@ export default function TagDetails() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const quayConfig = useQuayConfig();
|
||||||
|
|
||||||
// TODO: refactor, need more checks when parsing path
|
// TODO: refactor, need more checks when parsing path
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
@ -71,10 +74,15 @@ export default function TagDetails() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const tagResp: Tag = resp.tags[0];
|
const tagResp: Tag = resp.tags[0];
|
||||||
if (tagResp.is_manifest_list) {
|
if (tagResp.is_manifest_list || quayConfig.features.UI_MODELCARD) {
|
||||||
const manifestResp: ManifestByDigestResponse =
|
const manifestResp: ManifestByDigestResponse =
|
||||||
await getManifestByDigest(org, repo, tagResp.manifest_digest);
|
await getManifestByDigest(org, repo, tagResp.manifest_digest, true);
|
||||||
tagResp.manifest_list = JSON.parse(manifestResp.manifest_data);
|
if (tagResp.is_manifest_list) {
|
||||||
|
tagResp.manifest_list = JSON.parse(manifestResp.manifest_data);
|
||||||
|
}
|
||||||
|
if (manifestResp.modelcard) {
|
||||||
|
tagResp.modelcard = manifestResp.modelcard;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Confirm requested digest exists for this tag
|
// Confirm requested digest exists for this tag
|
||||||
|
@ -3,12 +3,14 @@ import {useSearchParams, useNavigate, useLocation} from 'react-router-dom';
|
|||||||
import {useState} from 'react';
|
import {useState} from 'react';
|
||||||
import Details from './Details/Details';
|
import Details from './Details/Details';
|
||||||
import SecurityReport from './SecurityReport/SecurityReport';
|
import SecurityReport from './SecurityReport/SecurityReport';
|
||||||
|
import {ModelCard} from './ModelCard/ModelCard';
|
||||||
import {Tag} from 'src/resources/TagResource';
|
import {Tag} from 'src/resources/TagResource';
|
||||||
import {TabIndex} from './Types';
|
import {TabIndex} from './Types';
|
||||||
import {Packages} from './Packages/Packages';
|
import {Packages} from './Packages/Packages';
|
||||||
import ErrorBoundary from 'src/components/errors/ErrorBoundary';
|
import ErrorBoundary from 'src/components/errors/ErrorBoundary';
|
||||||
import {isErrorString} from 'src/resources/ErrorHandling';
|
import {isErrorString} from 'src/resources/ErrorHandling';
|
||||||
import RequestError from 'src/components/errors/RequestError';
|
import RequestError from 'src/components/errors/RequestError';
|
||||||
|
import {useQuayConfig} from 'src/hooks/UseQuayConfig';
|
||||||
|
|
||||||
// Return the tab as an enum or null if it does not exist
|
// Return the tab as an enum or null if it does not exist
|
||||||
function getTabIndex(tab: string) {
|
function getTabIndex(tab: string) {
|
||||||
@ -18,6 +20,8 @@ function getTabIndex(tab: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function TagTabs(props: TagTabsProps) {
|
export default function TagTabs(props: TagTabsProps) {
|
||||||
|
const quayConfig = useQuayConfig();
|
||||||
|
|
||||||
const [activeTabKey, setActiveTabKey] = useState<TabIndex>(TabIndex.Details);
|
const [activeTabKey, setActiveTabKey] = useState<TabIndex>(TabIndex.Details);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
@ -28,6 +32,7 @@ export default function TagTabs(props: TagTabsProps) {
|
|||||||
if (requestedTabIndex && requestedTabIndex !== activeTabKey) {
|
if (requestedTabIndex && requestedTabIndex !== activeTabKey) {
|
||||||
setActiveTabKey(requestedTabIndex);
|
setActiveTabKey(requestedTabIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tabs
|
<Tabs
|
||||||
activeKey={activeTabKey}
|
activeKey={activeTabKey}
|
||||||
@ -59,6 +64,13 @@ export default function TagTabs(props: TagTabsProps) {
|
|||||||
>
|
>
|
||||||
<Packages />
|
<Packages />
|
||||||
</Tab>
|
</Tab>
|
||||||
|
<Tab
|
||||||
|
eventKey={TabIndex.ModelCard}
|
||||||
|
title={<TabTitleText>ModelCard</TabTitleText>}
|
||||||
|
isHidden={!quayConfig?.features?.UI_MODELCARD || !props.tag.modelcard}
|
||||||
|
>
|
||||||
|
<ModelCard modelCard={props.tag.modelcard} />
|
||||||
|
</Tab>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -3,4 +3,5 @@ export enum TabIndex {
|
|||||||
Layers = 'layers',
|
Layers = 'layers',
|
||||||
SecurityReport = 'securityreport',
|
SecurityReport = 'securityreport',
|
||||||
Packages = 'packages',
|
Packages = 'packages',
|
||||||
|
ModelCard = 'modelcard',
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user