mirror of
https://github.com/quay/quay.git
synced 2025-04-16 23:03:13 +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_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
|
||||
UI_V2_FEEDBACK_FORM = "https://7qdvkuo9rkj.typeform.com/to/XH5YE79P"
|
||||
|
@ -8,8 +8,10 @@ from typing import List, Optional
|
||||
|
||||
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.oci.retriever import RepositoryContentRetriever
|
||||
from data.registry_model import registry_model
|
||||
from digest import digest_tools
|
||||
from endpoints.api import (
|
||||
@ -31,6 +33,7 @@ from endpoints.api import (
|
||||
validate_json_request,
|
||||
)
|
||||
from endpoints.exception import NotFound
|
||||
from util.parsing import truthy_bool
|
||||
from util.validation import VALID_LABEL_KEY_REGEX
|
||||
|
||||
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)
|
||||
@path_param("repository", "The full path of the repository. e.g. namespace/name")
|
||||
@path_param("manifestref", "The digest of the manifest")
|
||||
@ -107,7 +134,14 @@ class RepositoryManifest(RepositoryParamResource):
|
||||
@require_repo_read(allow_for_superuser=True)
|
||||
@nickname("getRepoManifest")
|
||||
@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)
|
||||
if repo_ref is None:
|
||||
raise NotFound()
|
||||
@ -120,7 +154,18 @@ class RepositoryManifest(RepositoryParamResource):
|
||||
if manifest is None or manifest.internal_manifest_bytes.as_unicode() == "":
|
||||
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")
|
||||
|
@ -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 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.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):
|
||||
@ -22,3 +27,85 @@ def test_repository_manifest(app):
|
||||
result = conduct_api_call(cl, RepositoryManifest, "GET", params, None, 200).json
|
||||
assert result["digest"] == manifest_digest
|
||||
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
|
||||
IMAGE_EXPIRY_TRIGGER: FeatureNameValue
|
||||
|
||||
UI_MODELCARD: FeatureNameValue
|
||||
|
@ -76,13 +76,13 @@ OCI_MANIFEST_URLS_KEY = "urls"
|
||||
OCI_MANIFEST_ANNOTATIONS_KEY = "annotations"
|
||||
OCI_MANIFEST_SUBJECT_KEY = "subject"
|
||||
OCI_MANIFEST_ARTIFACT_TYPE_KEY = "artifactType"
|
||||
OCI_MANIFEST_ANNOTATIONS_KEY = "annotations"
|
||||
|
||||
# Named tuples.
|
||||
OCIManifestConfig = namedtuple("OCIManifestConfig", ["size", "digest"])
|
||||
OCIManifestDescriptor = namedtuple("OCIManifestDescriptor", ["mediatype", "size", "digest"])
|
||||
OCIManifestLayer = namedtuple(
|
||||
"OCIManifestLayer", ["index", "digest", "is_remote", "urls", "compressed_size"]
|
||||
"OCIManifestLayer",
|
||||
["index", "digest", "is_remote", "urls", "compressed_size", "mediatype", "annotations"],
|
||||
)
|
||||
|
||||
OCIManifestImageLayer = namedtuple(
|
||||
@ -506,6 +506,7 @@ class OCIManifest(ManifestInterface):
|
||||
for index, layer in enumerate(self._parsed[OCI_MANIFEST_LAYERS_KEY]):
|
||||
content_type = layer[OCI_MANIFEST_MEDIATYPE_KEY]
|
||||
is_remote = content_type in OCI_IMAGE_NON_DISTRIBUTABLE_LAYER_CONTENT_TYPES
|
||||
layer_annotations = layer.get(OCI_MANIFEST_ANNOTATIONS_KEY, {})
|
||||
|
||||
try:
|
||||
digest = digest_tools.Digest.parse_digest(layer[OCI_MANIFEST_DIGEST_KEY])
|
||||
@ -520,6 +521,8 @@ class OCIManifest(ManifestInterface):
|
||||
digest=digest,
|
||||
is_remote=is_remote,
|
||||
urls=layer.get(OCI_MANIFEST_URLS_KEY),
|
||||
mediatype=content_type,
|
||||
annotations=layer_annotations,
|
||||
)
|
||||
|
||||
|
||||
@ -561,7 +564,7 @@ class OCIManifestBuilder(object):
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
@ -572,6 +575,8 @@ class OCIManifestBuilder(object):
|
||||
compressed_size=size,
|
||||
urls=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 = """{
|
||||
"schemaVersion": 2,
|
||||
@ -267,3 +300,16 @@ def test_validate_helm_oci_manifest():
|
||||
HELM_CHART_LAYER_TYPES = ["application/tar+gzip"]
|
||||
register_artifact_type(HELM_CHART_CONFIG_TYPE, HELM_CHART_LAYER_TYPES)
|
||||
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",
|
||||
"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": {
|
||||
"type": "string",
|
||||
"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",
|
||||
"process": "^0.11.10",
|
||||
"react": "^17.0.2",
|
||||
"react-remark": "^2.1.0",
|
||||
"react-router-dom": "^6.15.0",
|
||||
"react-scripts": "5.0.1",
|
||||
"recoil": "^0.7.7",
|
||||
"recoil-persist": "^4.2.0",
|
||||
"remark-gemoji": "^8.0.0",
|
||||
"ts-jest": "^29.1.1",
|
||||
"typescript": "^4.6.3",
|
||||
"use-react-router-breadcrumbs": "^4.0.1",
|
||||
|
@ -24,6 +24,7 @@ export interface Tag {
|
||||
manifest_list: ManifestList;
|
||||
expiration?: string;
|
||||
end_ts?: number;
|
||||
modelcard?: string;
|
||||
}
|
||||
|
||||
export interface ManifestList {
|
||||
@ -70,6 +71,7 @@ export interface ManifestByDigestResponse {
|
||||
manifest_data: string;
|
||||
config_media_type?: any;
|
||||
layers?: any;
|
||||
modelcard?: string;
|
||||
}
|
||||
|
||||
export interface SecurityDetailsResponse {
|
||||
@ -326,9 +328,10 @@ export async function getManifestByDigest(
|
||||
org: string,
|
||||
repo: string,
|
||||
digest: string,
|
||||
include_modelcard: boolean,
|
||||
) {
|
||||
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);
|
||||
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,
|
||||
parseTagNameFromUrl,
|
||||
} from '../../libs/utils';
|
||||
import {useQuayConfig} from 'src/hooks/UseQuayConfig';
|
||||
import TagArchSelect from './TagDetailsArchSelect';
|
||||
import TagTabs from './TagDetailsTabs';
|
||||
|
||||
@ -46,6 +47,8 @@ export default function TagDetails() {
|
||||
},
|
||||
});
|
||||
|
||||
const quayConfig = useQuayConfig();
|
||||
|
||||
// TODO: refactor, need more checks when parsing path
|
||||
const location = useLocation();
|
||||
|
||||
@ -71,10 +74,15 @@ export default function TagDetails() {
|
||||
}
|
||||
|
||||
const tagResp: Tag = resp.tags[0];
|
||||
if (tagResp.is_manifest_list) {
|
||||
if (tagResp.is_manifest_list || quayConfig.features.UI_MODELCARD) {
|
||||
const manifestResp: ManifestByDigestResponse =
|
||||
await getManifestByDigest(org, repo, tagResp.manifest_digest);
|
||||
tagResp.manifest_list = JSON.parse(manifestResp.manifest_data);
|
||||
await getManifestByDigest(org, repo, tagResp.manifest_digest, true);
|
||||
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
|
||||
|
@ -3,12 +3,14 @@ import {useSearchParams, useNavigate, useLocation} from 'react-router-dom';
|
||||
import {useState} from 'react';
|
||||
import Details from './Details/Details';
|
||||
import SecurityReport from './SecurityReport/SecurityReport';
|
||||
import {ModelCard} from './ModelCard/ModelCard';
|
||||
import {Tag} from 'src/resources/TagResource';
|
||||
import {TabIndex} from './Types';
|
||||
import {Packages} from './Packages/Packages';
|
||||
import ErrorBoundary from 'src/components/errors/ErrorBoundary';
|
||||
import {isErrorString} from 'src/resources/ErrorHandling';
|
||||
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
|
||||
function getTabIndex(tab: string) {
|
||||
@ -18,6 +20,8 @@ function getTabIndex(tab: string) {
|
||||
}
|
||||
|
||||
export default function TagTabs(props: TagTabsProps) {
|
||||
const quayConfig = useQuayConfig();
|
||||
|
||||
const [activeTabKey, setActiveTabKey] = useState<TabIndex>(TabIndex.Details);
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
@ -28,6 +32,7 @@ export default function TagTabs(props: TagTabsProps) {
|
||||
if (requestedTabIndex && requestedTabIndex !== activeTabKey) {
|
||||
setActiveTabKey(requestedTabIndex);
|
||||
}
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
activeKey={activeTabKey}
|
||||
@ -59,6 +64,13 @@ export default function TagTabs(props: TagTabsProps) {
|
||||
>
|
||||
<Packages />
|
||||
</Tab>
|
||||
<Tab
|
||||
eventKey={TabIndex.ModelCard}
|
||||
title={<TabTitleText>ModelCard</TabTitleText>}
|
||||
isHidden={!quayConfig?.features?.UI_MODELCARD || !props.tag.modelcard}
|
||||
>
|
||||
<ModelCard modelCard={props.tag.modelcard} />
|
||||
</Tab>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
|
@ -3,4 +3,5 @@ export enum TabIndex {
|
||||
Layers = 'layers',
|
||||
SecurityReport = 'securityreport',
|
||||
Packages = 'packages',
|
||||
ModelCard = 'modelcard',
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user