refactor(registries): migrate tags table to react [EE-6452] (#10990)

pull/10792/head
Chaim Lev-Ari 2024-04-09 08:08:14 +03:00 committed by GitHub
parent 8913e75484
commit bd271ec5a1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 569 additions and 0 deletions

View File

@ -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;

View File

@ -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',
})}
/>
);
}

View File

@ -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>
);
}

View File

@ -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;
}

View File

@ -0,0 +1,5 @@
import { createColumnHelper } from '@tanstack/react-table';
import { Tag } from '../types';
export const helper = createColumnHelper<Tag>();

View File

@ -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]
);
}

View File

@ -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
);
}

View File

@ -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;
}

View File

@ -0,0 +1,3 @@
export interface Tag {
Name: string;
}

View File

@ -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 });
},
}));

View File

@ -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 || [];
}
}

View File

@ -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,
};

View File

@ -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;
}