refactor(registries): migrate tags table to react [EE-6452] (#10990)
parent
8913e75484
commit
bd271ec5a1
|
@ -4,10 +4,20 @@ import { r2a } from '@/react-tools/react2angular';
|
|||
import { withReactQuery } from '@/react-tools/withReactQuery';
|
||||
import { withUIRouter } from '@/react-tools/withUIRouter';
|
||||
import { RepositoriesDatatable } from '@/react/portainer/registries/repositories/ListView/RepositoriesDatatable';
|
||||
import { TagsDatatable } from '@/react/portainer/registries/repositories/ItemView/TagsDatatable/TagsDatatable';
|
||||
|
||||
export const registriesModule = angular
|
||||
.module('portainer.app.react.components.registries', [])
|
||||
.component(
|
||||
'registryRepositoriesDatatable',
|
||||
r2a(withUIRouter(withReactQuery(RepositoriesDatatable)), ['dataset'])
|
||||
)
|
||||
.component(
|
||||
'registriesRepositoryTagsDatatable',
|
||||
r2a(withUIRouter(withReactQuery(TagsDatatable)), [
|
||||
'dataset',
|
||||
'advancedFeaturesAvailable',
|
||||
'onRemove',
|
||||
'onRetag',
|
||||
])
|
||||
).name;
|
||||
|
|
|
@ -0,0 +1,57 @@
|
|||
import { TagIcon } from 'lucide-react';
|
||||
import { useStore } from 'zustand';
|
||||
|
||||
import { Datatable } from '@@/datatables';
|
||||
import { useTableStateWithStorage } from '@@/datatables/useTableState';
|
||||
import { DeleteButton } from '@@/buttons/DeleteButton';
|
||||
import { withMeta } from '@@/datatables/extend-options/withMeta';
|
||||
|
||||
import { Tag } from './types';
|
||||
import { useColumns } from './columns/useColumns';
|
||||
import { newNamesStore } from './useRetagState';
|
||||
import { RepositoryTagViewModel } from './view-model';
|
||||
|
||||
export function TagsDatatable({
|
||||
dataset,
|
||||
advancedFeaturesAvailable,
|
||||
onRemove,
|
||||
onRetag,
|
||||
}: {
|
||||
dataset?: Tag[];
|
||||
advancedFeaturesAvailable: boolean;
|
||||
onRemove: (tags: Tag[]) => void;
|
||||
onRetag: (tags: Record<string, RepositoryTagViewModel>) => Promise<void>;
|
||||
}) {
|
||||
const updatesState = useStore(newNamesStore);
|
||||
|
||||
const tableState = useTableStateWithStorage('registryRepositoryTags', 'name');
|
||||
const columns = useColumns(advancedFeaturesAvailable);
|
||||
|
||||
return (
|
||||
<Datatable
|
||||
title="Tags"
|
||||
titleIcon={TagIcon}
|
||||
columns={columns}
|
||||
dataset={dataset || []}
|
||||
isLoading={!dataset}
|
||||
settingsManager={tableState}
|
||||
emptyContentLabel="No tags available."
|
||||
renderTableActions={(selectedItems) =>
|
||||
advancedFeaturesAvailable && (
|
||||
<DeleteButton
|
||||
confirmMessage="Are you sure you want to remove the selected tags?"
|
||||
onConfirmed={() => onRemove(selectedItems)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
getRowId={(tag) => tag.Name}
|
||||
extendTableOptions={withMeta({
|
||||
onUpdate: async () => {
|
||||
await onRetag(updatesState.updates);
|
||||
updatesState.clear();
|
||||
},
|
||||
table: 'registry-repository-tags',
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,114 @@
|
|||
import { CellContext } from '@tanstack/react-table';
|
||||
import { X, Check, TagIcon } from 'lucide-react';
|
||||
import { Form, Formik } from 'formik';
|
||||
import { useStore } from 'zustand';
|
||||
import { object, string } from 'yup';
|
||||
|
||||
import { Button } from '@@/buttons';
|
||||
import { Tooltip } from '@@/Tip/Tooltip';
|
||||
import { Input } from '@@/form-components/Input';
|
||||
import { FormError } from '@@/form-components/FormError';
|
||||
|
||||
import { Tag } from '../types';
|
||||
import { newNamesStore } from '../useRetagState';
|
||||
import { getTableMeta } from '../meta';
|
||||
|
||||
import { helper } from './helper';
|
||||
import { useDetails } from './buildCell';
|
||||
|
||||
export const actions = helper.display({
|
||||
header: 'Actions',
|
||||
cell: ActionsCell,
|
||||
});
|
||||
|
||||
function ActionsCell({
|
||||
table,
|
||||
row: { original: item },
|
||||
}: CellContext<Tag, unknown>) {
|
||||
const meta = getTableMeta(table.options.meta);
|
||||
const detailsQuery = useDetails(item.Name);
|
||||
const state = useStore(newNamesStore);
|
||||
|
||||
const isEdit = state.updates[item.Name] !== undefined;
|
||||
|
||||
if (!detailsQuery.data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tagDetails = detailsQuery.data;
|
||||
|
||||
if (!isEdit) {
|
||||
return (
|
||||
<Button
|
||||
color="link"
|
||||
icon={TagIcon}
|
||||
onClick={() => state.setName(item.Name, tagDetails)}
|
||||
>
|
||||
Retag
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<EditTag
|
||||
initialName={item.Name}
|
||||
onCancel={() => state.setName(item.Name)}
|
||||
onChange={(name) =>
|
||||
state.setName(item.Name, {
|
||||
...tagDetails,
|
||||
Name: name,
|
||||
})
|
||||
}
|
||||
onSubmit={() => meta.onUpdate()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const schema = object().shape({
|
||||
name: string()
|
||||
.required()
|
||||
.matches(/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/),
|
||||
});
|
||||
|
||||
function EditTag({
|
||||
initialName,
|
||||
onCancel,
|
||||
onChange,
|
||||
onSubmit,
|
||||
}: {
|
||||
initialName: string;
|
||||
onChange: (name: string) => void;
|
||||
onCancel(): void;
|
||||
onSubmit(): void;
|
||||
}) {
|
||||
return (
|
||||
<Formik
|
||||
initialValues={{ name: initialName }}
|
||||
onSubmit={onSubmit}
|
||||
validationSchema={schema}
|
||||
>
|
||||
{({ values, errors, setFieldValue }) => (
|
||||
<Form className="vertical-center">
|
||||
<Tooltip message="'Tag can only contain alphanumeric (a-zA-Z0-9) and special _ . - characters. Tag must not start with . - characters.'" />
|
||||
|
||||
<Input
|
||||
className="input-sm"
|
||||
type="text"
|
||||
value={values.name}
|
||||
onChange={(e) => {
|
||||
setFieldValue('name', e.target.value);
|
||||
onChange(e.target.value);
|
||||
}}
|
||||
autoFocus
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
|
||||
{errors.name && <FormError>{errors.name}</FormError>}
|
||||
|
||||
<Button color="none" icon={X} onClick={onCancel} />
|
||||
<Button type="submit" color="none" icon={Check} />
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,57 @@
|
|||
import { CellContext } from '@tanstack/react-table';
|
||||
import { useCurrentStateAndParams } from '@uirouter/react';
|
||||
|
||||
import { useTagDetails } from '../../../queries/useTagDetails';
|
||||
import { Tag } from '../types';
|
||||
import { RepositoryTagViewModel } from '../view-model';
|
||||
|
||||
function useParams() {
|
||||
const {
|
||||
params: { endpointId, id, repository },
|
||||
} = useCurrentStateAndParams();
|
||||
|
||||
const registryId = number(id);
|
||||
|
||||
if (!registryId) {
|
||||
throw new Error('Missing registry id');
|
||||
}
|
||||
|
||||
if (!repository || typeof repository !== 'string') {
|
||||
throw new Error('Missing repository name');
|
||||
}
|
||||
|
||||
return {
|
||||
environmentId: number(endpointId),
|
||||
registryId,
|
||||
repository,
|
||||
};
|
||||
}
|
||||
|
||||
export function useDetails<T = RepositoryTagViewModel>(
|
||||
tag: string,
|
||||
select?: (data: RepositoryTagViewModel) => T
|
||||
) {
|
||||
const params = useParams();
|
||||
return useTagDetails(
|
||||
{
|
||||
tag,
|
||||
...params,
|
||||
},
|
||||
{ staleTime: 60 * 1000, select }
|
||||
);
|
||||
}
|
||||
|
||||
export function buildCell<T = RepositoryTagViewModel>(
|
||||
select: (data: RepositoryTagViewModel) => T
|
||||
) {
|
||||
return function Cell({ row }: CellContext<Tag, unknown>) {
|
||||
const detailsQuery = useDetails(row.original.Name, select);
|
||||
|
||||
return detailsQuery.data || '';
|
||||
};
|
||||
}
|
||||
|
||||
function number(value: string | undefined) {
|
||||
const num = parseInt(value || '', 10);
|
||||
return Number.isNaN(num) ? undefined : num;
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
import { createColumnHelper } from '@tanstack/react-table';
|
||||
|
||||
import { Tag } from '../types';
|
||||
|
||||
export const helper = createColumnHelper<Tag>();
|
|
@ -0,0 +1,41 @@
|
|||
import { useMemo } from 'react';
|
||||
import _ from 'lodash';
|
||||
|
||||
import { humanize } from '@/portainer/filters/filters';
|
||||
import { trimSHA } from '@/docker/filters/utils';
|
||||
|
||||
import { buildNameColumn } from '@@/datatables/buildNameColumn';
|
||||
|
||||
import { Tag } from '../types';
|
||||
|
||||
import { helper } from './helper';
|
||||
import { buildCell } from './buildCell';
|
||||
import { actions } from './actions';
|
||||
|
||||
const columns = [
|
||||
buildNameColumn<Tag>(
|
||||
'Name',
|
||||
'portainer.registries.registry.repository.tag',
|
||||
'tag',
|
||||
(item) => item.Name
|
||||
),
|
||||
helper.display({
|
||||
header: 'OS/Architecture',
|
||||
cell: buildCell((model) => `${model.Os}/${model.Architecture}`),
|
||||
}),
|
||||
helper.display({
|
||||
header: 'Image ID',
|
||||
cell: buildCell((model) => trimSHA(model.ImageId)),
|
||||
}),
|
||||
helper.display({
|
||||
header: 'Compressed Size',
|
||||
cell: buildCell((model) => humanize(model.Size)),
|
||||
}),
|
||||
];
|
||||
|
||||
export function useColumns(advancedFeaturesAvailable: boolean) {
|
||||
return useMemo(
|
||||
() => _.compact([...columns, advancedFeaturesAvailable && actions]),
|
||||
[advancedFeaturesAvailable]
|
||||
);
|
||||
}
|
|
@ -0,0 +1,74 @@
|
|||
import _ from 'lodash';
|
||||
|
||||
import { ManifestV1, ManifestV2 } from '../../queries/manifest.service';
|
||||
import { ImageConfigs } from '../../queries/getRegistryBlobs';
|
||||
|
||||
import { RepositoryTagViewModel } from './view-model';
|
||||
|
||||
function parseV1History(history: { v1Compatibility: string }[]) {
|
||||
return _.map(history, (item) => JSON.parse(item.v1Compatibility));
|
||||
}
|
||||
|
||||
// convert image configs blob history to manifest v1 history
|
||||
function parseImageConfigsHistory(imageConfigs: ImageConfigs, v2: ManifestV2) {
|
||||
return _.map(imageConfigs.history.reverse(), (item) => ({
|
||||
...item,
|
||||
CreatedBy: item.created_by,
|
||||
// below fields exist in manifest v1 history but not image configs blob
|
||||
id: v2.config.digest,
|
||||
created: imageConfigs.created,
|
||||
docker_version: imageConfigs.docker_version,
|
||||
os: imageConfigs.os,
|
||||
architecture: imageConfigs.architecture,
|
||||
config: imageConfigs.config,
|
||||
container_config: imageConfigs.container_config,
|
||||
}));
|
||||
}
|
||||
|
||||
export function manifestsToTag({
|
||||
v1,
|
||||
v2,
|
||||
imageConfigs,
|
||||
}: {
|
||||
v1?: ManifestV1;
|
||||
v2: ManifestV2 & { digest: string };
|
||||
imageConfigs?: ImageConfigs;
|
||||
}) {
|
||||
let history = [];
|
||||
let name = '';
|
||||
let os = '';
|
||||
let arch = '';
|
||||
|
||||
if (imageConfigs) {
|
||||
// use info from image configs blob when manifest v1 is not provided by registry
|
||||
os = imageConfigs.os || '';
|
||||
arch = imageConfigs.architecture || '';
|
||||
history = parseImageConfigsHistory(imageConfigs, v2);
|
||||
} else if (v1) {
|
||||
// use info from manifest v1
|
||||
history = parseV1History(v1.history);
|
||||
name = v1.tag;
|
||||
os = _.get(history, '[0].os', '');
|
||||
arch = v1.architecture;
|
||||
}
|
||||
|
||||
const size = v2.layers.reduce((size, b) => size + b.size, 0);
|
||||
|
||||
const imageId = v2.config.digest;
|
||||
|
||||
// v2.digest comes from
|
||||
// 1. Docker-Content-Digest header from the v2 response, or
|
||||
// 2. Calculated locally by sha256(v2-response-body)
|
||||
const imageDigest = v2.digest;
|
||||
|
||||
return new RepositoryTagViewModel(
|
||||
name,
|
||||
os,
|
||||
arch,
|
||||
size,
|
||||
imageDigest,
|
||||
imageId,
|
||||
v2,
|
||||
history
|
||||
);
|
||||
}
|
|
@ -0,0 +1,21 @@
|
|||
interface TableMeta {
|
||||
table: 'registry-repository-tags';
|
||||
onUpdate(): void;
|
||||
}
|
||||
|
||||
function isTableMeta(meta: unknown): meta is TableMeta {
|
||||
return (
|
||||
!!meta &&
|
||||
typeof meta === 'object' &&
|
||||
'table' in meta &&
|
||||
meta.table === 'registry-repository-tags'
|
||||
);
|
||||
}
|
||||
|
||||
export function getTableMeta(meta: unknown): TableMeta {
|
||||
if (!isTableMeta(meta)) {
|
||||
throw new Error('Invalid table meta');
|
||||
}
|
||||
|
||||
return meta;
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
export interface Tag {
|
||||
Name: string;
|
||||
}
|
|
@ -0,0 +1,33 @@
|
|||
import { createStore } from 'zustand';
|
||||
|
||||
import { RepositoryTagViewModel } from './view-model';
|
||||
|
||||
interface Store {
|
||||
updates: Record<string, RepositoryTagViewModel>;
|
||||
setName(originalName: string, value?: RepositoryTagViewModel): void;
|
||||
count: number;
|
||||
getUpdate(originalName: string): RepositoryTagViewModel | undefined;
|
||||
clear(): void;
|
||||
}
|
||||
|
||||
export const newNamesStore = createStore<Store>()((set, get) => ({
|
||||
updates: {},
|
||||
count: 0,
|
||||
setName(originalName: string, value?: RepositoryTagViewModel) {
|
||||
const { updates } = get();
|
||||
if (typeof value === 'undefined') {
|
||||
delete updates[originalName];
|
||||
} else {
|
||||
updates[originalName] = value;
|
||||
}
|
||||
|
||||
set({ updates, count: Object.keys(updates).length });
|
||||
},
|
||||
getUpdate(originalName: string) {
|
||||
const { updates } = get();
|
||||
return updates[originalName];
|
||||
},
|
||||
clear() {
|
||||
set({ updates: {}, count: 0 });
|
||||
},
|
||||
}));
|
|
@ -0,0 +1,41 @@
|
|||
import { ManifestV2 } from '../../queries/manifest.service';
|
||||
|
||||
export class RepositoryTagViewModel {
|
||||
Name: string;
|
||||
|
||||
Os: string;
|
||||
|
||||
Architecture: string;
|
||||
|
||||
Size: number;
|
||||
|
||||
ImageDigest: string;
|
||||
|
||||
ImageId: string;
|
||||
|
||||
ManifestV2: ManifestV2 & { digest: string };
|
||||
|
||||
History: unknown[];
|
||||
|
||||
constructor(
|
||||
name: string,
|
||||
os: string,
|
||||
arch: string,
|
||||
size: number,
|
||||
imageDigest: string,
|
||||
imageId: string,
|
||||
v2: ManifestV2 & {
|
||||
digest: string;
|
||||
},
|
||||
history: unknown[]
|
||||
) {
|
||||
this.Name = name;
|
||||
this.Os = os || '';
|
||||
this.Architecture = arch || '';
|
||||
this.Size = size || 0;
|
||||
this.ImageDigest = imageDigest || '';
|
||||
this.ImageId = imageId || '';
|
||||
this.ManifestV2 = v2 || {};
|
||||
this.History = history || [];
|
||||
}
|
||||
}
|
|
@ -0,0 +1,31 @@
|
|||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
|
||||
import { queryKeys as registriesQueryKeys } from '../../queries/query-keys';
|
||||
import { RegistryId } from '../../types/registry';
|
||||
|
||||
export const queryKeys = {
|
||||
base: (registryId: RegistryId) =>
|
||||
[...registriesQueryKeys.item(registryId), 'repositories'] as const,
|
||||
repository: (registryId: RegistryId, repository: string) =>
|
||||
[...queryKeys.base(registryId), repository] as const,
|
||||
repositoryTags: (registryId: RegistryId, repository: string) =>
|
||||
[...queryKeys.repository(registryId, repository), 'tags'] as const,
|
||||
repositoryTag: (registryId: RegistryId, repository: string, tag: string) =>
|
||||
[...queryKeys.repository(registryId, repository), 'tags', tag] as const,
|
||||
tagDetails: ({
|
||||
registryId,
|
||||
repository,
|
||||
tag,
|
||||
...params
|
||||
}: {
|
||||
repository: string;
|
||||
environmentId?: EnvironmentId;
|
||||
registryId: RegistryId;
|
||||
tag: string;
|
||||
}) =>
|
||||
[
|
||||
...queryKeys.repositoryTag(registryId, repository, tag),
|
||||
'details',
|
||||
params,
|
||||
] as const,
|
||||
};
|
|
@ -0,0 +1,82 @@
|
|||
import { useQuery } from 'react-query';
|
||||
|
||||
import { Environment } from '@/react/portainer/environments/types';
|
||||
import { Registry } from '@/react/portainer/registries/types/registry';
|
||||
|
||||
import { manifestsToTag } from '../ItemView/TagsDatatable/manifestsToTag';
|
||||
import { RepositoryTagViewModel } from '../ItemView/TagsDatatable/view-model';
|
||||
|
||||
import { getTagManifestV1, getTagManifestV2 } from './manifest.service';
|
||||
import { ImageConfigs, getRegistryBlob } from './getRegistryBlobs';
|
||||
import { queryKeys } from './queryKeys';
|
||||
|
||||
interface Params {
|
||||
registryId: Registry['Id'];
|
||||
repository: string;
|
||||
environmentId?: Environment['Id'];
|
||||
tag: string;
|
||||
}
|
||||
|
||||
export function useTagDetails<T = RepositoryTagViewModel>(
|
||||
params: Params,
|
||||
{
|
||||
staleTime = 0,
|
||||
select,
|
||||
}: { select?: (model: RepositoryTagViewModel) => T; staleTime?: number } = {}
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.tagDetails(params),
|
||||
queryFn: () => getTagDetails(params),
|
||||
staleTime,
|
||||
select,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getTagDetails({
|
||||
registryId,
|
||||
environmentId,
|
||||
repository,
|
||||
tag,
|
||||
}: Params) {
|
||||
const params = {
|
||||
id: registryId,
|
||||
endpointId: environmentId,
|
||||
repository,
|
||||
tag,
|
||||
};
|
||||
const [v1, v2] = await Promise.all([
|
||||
getTagManifestV1(params),
|
||||
getTagManifestV2(params),
|
||||
]);
|
||||
|
||||
let useV1 = true;
|
||||
let imageConfigs: ImageConfigs | undefined;
|
||||
try {
|
||||
imageConfigs = await getRegistryBlob({
|
||||
digest: v2.config.digest,
|
||||
...params,
|
||||
});
|
||||
|
||||
// prefer image configs than manifest v1
|
||||
useV1 = false;
|
||||
} catch (e) {
|
||||
// empty
|
||||
}
|
||||
|
||||
if (v1 && v1.schemaVersion === 2) {
|
||||
// Registry returns manifest v2 while we request manifest v1
|
||||
useV1 = false;
|
||||
}
|
||||
|
||||
const data: { v2: typeof v2; v1?: typeof v1; imageConfigs?: ImageConfigs } = {
|
||||
v2,
|
||||
imageConfigs,
|
||||
};
|
||||
if (useV1) {
|
||||
data.v1 = v1;
|
||||
}
|
||||
|
||||
const tagDetails = manifestsToTag(data);
|
||||
tagDetails.Name = tagDetails.Name || tag;
|
||||
return tagDetails;
|
||||
}
|
Loading…
Reference in New Issue