1
0
Fork 0
mirror of https://github.com/portainer/portainer.git synced 2025-08-05 13:55:21 +02:00

refactor(templates): migrate template item to react [EE-6203] (#10429)

This commit is contained in:
Chaim Lev-Ari 2023-10-19 21:09:15 +02:00 committed by GitHub
parent d970f0e2bc
commit 1ad9488ca7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 932 additions and 289 deletions

View file

@ -0,0 +1,37 @@
import clsx from 'clsx';
import { ComponentProps, ComponentType, ElementType } from 'react';
export type AsComponentProps<E extends ElementType = ElementType> =
ComponentProps<E> & {
as?: E;
};
export function BlocklistItem<T extends ElementType>({
className,
isSelected,
children,
as = 'button',
...props
}: AsComponentProps & {
isSelected?: boolean;
as?: ComponentType<T>;
}) {
const Component = as as 'button';
return (
<Component
type="button"
className={clsx(
className,
'blocklist-item flex items-stretch overflow-hidden bg-transparent w-full !ml-0',
{
'blocklist-item--selected': isSelected,
}
)}
// eslint-disable-next-line react/jsx-props-no-spreading
{...props}
>
{children}
</Component>
);
}

View file

@ -4,7 +4,7 @@ import { BadgeIcon, BadgeSize } from './BadgeIcon/BadgeIcon';
interface Props {
// props for the image to load
src: string; // a link to an external image
src?: string; // a link to an external image
fallbackIcon: string;
alt?: string;
size?: BadgeSize;

View file

@ -1,5 +1,6 @@
import _ from 'lodash';
import { Tag, Activity } from 'lucide-react';
import clsx from 'clsx';
import {
isoDateFromTimestamp,
@ -20,6 +21,7 @@ import { useTags } from '@/portainer/tags/queries';
import { EdgeIndicator } from '@@/EdgeIndicator';
import { EnvironmentStatusBadge } from '@@/EnvironmentStatusBadge';
import { Link } from '@@/Link';
import { BlocklistItem } from '@@/Blocklist/BlocklistItem';
import { EnvironmentIcon } from './EnvironmentIcon';
import { EnvironmentStats } from './EnvironmentStats';
@ -53,63 +55,62 @@ export function EnvironmentItem({
return (
<div className="relative">
<Link
<BlocklistItem
as={dashboardRoute.to ? Link : 'button'}
className={clsx('!m-0 min-h-[110px] !pr-56', {
'cursor-default': !dashboardRoute.to,
'no-link': dashboardRoute.to,
})}
onClick={onClickBrowse}
to={dashboardRoute.to}
params={dashboardRoute.params}
className="no-link"
>
<button
className="blocklist-item !m-0 flex min-h-[110px] w-full items-stretch overflow-hidden bg-transparent !pr-56"
onClick={onClickBrowse}
type="button"
>
<div className="ml-2 flex justify-center self-center">
<EnvironmentIcon type={environment.Type} />
</div>
<div className="ml-3 mr-auto flex flex-col items-start justify-center gap-3">
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
<span className="font-bold">{environment.Name}</span>
{isEdge ? (
<EdgeIndicator environment={environment} showLastCheckInDate />
) : (
<>
<EnvironmentStatusBadge status={environment.Status} />
{snapshotTime && (
<span
className="small text-muted vertical-center gap-1"
title="Last snapshot time"
>
<Activity className="icon icon-sm" aria-hidden="true" />
{snapshotTime}
</span>
)}
</>
)}
<EngineVersion environment={environment} />
{!isEdge && (
<span className="text-muted small vertical-center">
{stripProtocol(environment.URL)}
</span>
)}
</div>
<div className="small text-muted flex flex-wrap items-center gap-x-4 gap-y-2">
{groupName && (
<span className="font-semibold">
<span>Group: </span>
<span>{groupName}</span>
</span>
)}
<span className="vertical-center gap-1">
<Tag className="icon icon-sm" aria-hidden="true" />
{tags}
<div className="ml-2 flex justify-center self-center">
<EnvironmentIcon type={environment.Type} />
</div>
<div className="ml-3 mr-auto flex flex-col items-start justify-center gap-3">
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
<span className="font-bold">{environment.Name}</span>
{isEdge ? (
<EdgeIndicator environment={environment} showLastCheckInDate />
) : (
<>
<EnvironmentStatusBadge status={environment.Status} />
{snapshotTime && (
<span
className="small text-muted vertical-center gap-1"
title="Last snapshot time"
>
<Activity className="icon icon-sm" aria-hidden="true" />
{snapshotTime}
</span>
)}
</>
)}
<EngineVersion environment={environment} />
{!isEdge && (
<span className="text-muted small vertical-center">
{stripProtocol(environment.URL)}
</span>
<EnvironmentTypeTag environment={environment} />
<AgentDetails environment={environment} />
</div>
<EnvironmentStats environment={environment} />
)}
</div>
</button>
</Link>
<div className="small text-muted flex flex-wrap items-center gap-x-4 gap-y-2">
{groupName && (
<span className="font-semibold">
<span>Group: </span>
<span>{groupName}</span>
</span>
)}
<span className="vertical-center gap-1">
<Tag className="icon icon-sm" aria-hidden="true" />
{tags}
</span>
<EnvironmentTypeTag environment={environment} />
<AgentDetails environment={environment} />
</div>
<EnvironmentStats environment={environment} />
</div>
</BlocklistItem>
{/*
Buttons are extracted out of the main button because it causes errors with react and accessibility issues
see https://stackoverflow.com/questions/66409964/warning-validatedomnesting-a-cannot-appear-as-a-descendant-of-a

View file

@ -0,0 +1,107 @@
import { UserId } from '@/portainer/users/types';
import { StackType } from '@/react/common/stacks/types';
import { ResourceControlResponse } from '../access-control/types';
import { RepoConfigResponse } from '../gitops/types';
export enum Platform {
LINUX = 1,
WINDOWS,
}
export /**
* CustomTemplate represents a custom template.
*/
interface CustomTemplate {
/**
* CustomTemplate Identifier.
* @example 1
*/
Id: number;
/**
* Title of the template.
* @example "Nginx"
*/
Title: string;
/**
* Description of the template.
* @example "High performance web server"
*/
Description: string;
/**
* Path on disk to the repository hosting the Stack file.
* @example "/data/custom_template/3"
*/
ProjectPath: string;
/**
* Path to the Stack file.
* @example "docker-compose.yml"
*/
EntryPoint: string;
/**
* User identifier who created this template.
* @example 3
*/
CreatedByUserId: UserId;
/**
* A note that will be displayed in the UI. Supports HTML content.
* @example "This is my <b>custom</b> template"
*/
Note: string;
/**
* Platform associated with the template.
* Valid values are: 1 - 'linux', 2 - 'windows'.
* @example 1
*/
Platform: Platform;
/**
* URL of the template's logo.
* @example "https://portainer.io/img/logo.svg"
*/
Logo: string;
/**
* Type of created stack:
* - 1: swarm
* - 2: compose
* - 3: kubernetes
* @example 1
*/
Type: StackType;
/**
* ResourceControl associated with the template.
*/
ResourceControl?: ResourceControlResponse;
/**
* GitConfig for the template.
*/
GitConfig?: RepoConfigResponse;
/**
* Indicates if the Kubernetes template is created from a Docker Compose file.
* @example false
*/
IsComposeFormat: boolean;
}
export type CustomTemplateFileContent = {
FileContent: string;
};
export const CustomTemplateKubernetesType = 3;
export enum Types {
SWARM = 1,
STANDALONE,
KUBERNETES,
}

View file

@ -0,0 +1,45 @@
import { Button } from '@@/buttons';
import { TemplateItem } from '../components/TemplateItem';
import { TemplateViewModel } from './template';
import { TemplateType } from './types';
export function AppTemplatesListItem({
template,
onSelect,
onDuplicate,
isSelected,
}: {
template: TemplateViewModel;
onSelect: (template: TemplateViewModel) => void;
onDuplicate: (template: TemplateViewModel) => void;
isSelected: boolean;
}) {
return (
<TemplateItem
template={template}
typeLabel={
template.Type === TemplateType.Container ? 'container' : 'stack'
}
onSelect={() => onSelect(template)}
isSelected={isSelected}
renderActions={
template.Type === TemplateType.SwarmStack ||
(template.Type === TemplateType.ComposeStack && (
<div className="mr-5 mt-3">
<Button
size="xsmall"
onClick={(e) => {
e.stopPropagation();
onDuplicate(template);
}}
>
Copy as Custom
</Button>
</div>
))
}
/>
);
}

View file

@ -0,0 +1,184 @@
import _ from 'lodash';
import { PorImageRegistryModel } from 'Docker/models/porImageRegistry';
import { Pair } from '../../settings/types';
import { Platform } from '../../custom-templates/types';
import {
AppTemplate,
TemplateEnv,
TemplateRepository,
TemplateType,
} from './types';
export class TemplateViewModel {
Id!: string;
Title!: string;
Type!: TemplateType;
Description!: string;
AdministratorOnly!: boolean;
Name: string | undefined;
Note: string | undefined;
Categories!: string[];
Platform!: Platform | undefined;
Logo: string | undefined;
Repository!: TemplateRepository;
Hostname: string | undefined;
RegistryModel!: PorImageRegistryModel;
Command!: string;
Network!: string;
Privileged!: boolean;
Interactive!: boolean;
RestartPolicy!: string;
Labels!: Pair[];
Env!: Array<TemplateEnv & { type: EnvVarType; value: string }>;
Volumes!: {
container: string;
readonly: boolean;
type: string;
bind: string | null;
}[];
Ports!: {
hostPort: string | undefined;
containerPort: string;
protocol: string;
}[];
constructor(data: AppTemplate, version: string) {
switch (version) {
case '2':
this.setTemplatesV2(data);
break;
default:
throw new Error('Unsupported template version');
}
}
setTemplatesV2(template: AppTemplate) {
this.Id = _.uniqueId();
this.Title = template.title;
this.Type = template.type;
this.Description = template.description;
this.AdministratorOnly = template.administrator_only;
this.Name = template.name;
this.Note = template.note;
this.Categories = template.categories ? template.categories : [];
this.Platform = getPlatform(template.platform);
this.Logo = template.logo;
this.Repository = template.repository;
this.Hostname = template.hostname;
this.RegistryModel = new PorImageRegistryModel();
this.RegistryModel.Image = template.image;
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
this.RegistryModel.Registry.URL = template.registry || '';
this.Command = template.command ? template.command : '';
this.Network = template.network ? template.network : '';
this.Privileged = template.privileged ? template.privileged : false;
this.Interactive = template.interactive ? template.interactive : false;
this.RestartPolicy = template.restart_policy
? template.restart_policy
: 'always';
this.Labels = template.labels ? template.labels : [];
this.Env = templateEnv(template);
this.Volumes = templateVolumes(template);
this.Ports = templatePorts(template);
}
}
function templatePorts(data: AppTemplate) {
return (
data.ports?.map((p) => {
const portAndProtocol = _.split(p, '/');
const hostAndContainerPort = _.split(portAndProtocol[0], ':');
return {
hostPort:
hostAndContainerPort.length > 1 ? hostAndContainerPort[0] : undefined,
containerPort:
hostAndContainerPort.length > 1
? hostAndContainerPort[1]
: hostAndContainerPort[0],
protocol: portAndProtocol[1],
};
}) || []
);
}
function templateVolumes(data: AppTemplate) {
return (
data.volumes?.map((v) => ({
container: v.container,
readonly: v.readonly || false,
type: v.bind ? 'bind' : 'auto',
bind: v.bind ? v.bind : null,
})) || []
);
}
enum EnvVarType {
PreSelected = 1,
Text = 2,
Select = 3,
}
function templateEnv(data: AppTemplate) {
return (
data.env?.map((envvar) => ({
name: envvar.name,
label: envvar.label,
description: envvar.description,
default: envvar.default,
preset: envvar.preset,
select: envvar.select,
...getEnvVarTypeAndValue(envvar),
})) || []
);
function getEnvVarTypeAndValue(envvar: TemplateEnv) {
if (envvar.select) {
return {
type: EnvVarType.Select,
value: envvar.select.find((v) => v.default)?.value || '',
};
}
return {
type: envvar.preset ? EnvVarType.PreSelected : EnvVarType.Text,
value: envvar.default || '',
};
}
}
function getPlatform(platform?: 'linux' | 'windows' | '') {
switch (platform) {
case 'linux':
return Platform.LINUX;
case 'windows':
return Platform.WINDOWS;
default:
return undefined;
}
}

View file

@ -0,0 +1,250 @@
import { Pair } from '../../settings/types';
export enum TemplateType {
Container = 1,
SwarmStack = 2,
ComposeStack = 3,
ComposeEdgeStack = 4,
}
/**
* Template represents an application template that can be used as an App Template or an Edge template.
*/
export interface AppTemplate {
/**
* Template type. Valid values are: 1 (container), 2 (Swarm stack), 3 (Compose stack), 4 (Compose edge stack).
* @example 1
*/
type: TemplateType;
/**
* Title of the template.
* @example "Nginx"
*/
title: string;
/**
* Description of the template.
* @example "High performance web server"
*/
description: string;
/**
* Whether the template should be available to administrators only.
* @example true
*/
administrator_only: boolean;
/**
* Image associated with a container template. Mandatory for a container template.
* @example "nginx:latest"
*/
image: string;
/**
* Repository associated with the template.
*/
repository: TemplateRepository;
/**
* Stack file used for this template (Mandatory for Edge stack).
*/
stackFile?: string;
/**
* Default name for the stack/container to be used on deployment.
* @example "mystackname"
*/
name?: string;
/**
* URL of the template's logo.
* @example "https://portainer.io/img/logo.svg"
*/
logo?: string;
/**
* A list of environment (endpoint) variables used during the template deployment.
*/
env?: TemplateEnv[];
/**
* A note that will be displayed in the UI. Supports HTML content.
* @example "This is my <b>custom</b> template"
*/
note?: string;
/**
* Platform associated with the template.
* Valid values are: 'linux', 'windows' or leave empty for multi-platform.
* @example "linux"
*/
platform?: 'linux' | 'windows' | '';
/**
* A list of categories associated with the template.
* @example ["database"]
*/
categories?: string[];
/**
* The URL of a registry associated with the image for a container template.
* @example "quay.io"
*/
registry?: string;
/**
* The command that will be executed in a container template.
* @example "ls -lah"
*/
command?: string;
/**
* Name of a network that will be used on container deployment if it exists inside the environment (endpoint).
* @example "mynet"
*/
network?: string;
/**
* A list of volumes used during the container template deployment.
*/
volumes?: TemplateVolume[];
/**
* A list of ports exposed by the container.
* @example ["8080:80/tcp"]
*/
ports?: string[];
/**
* Container labels.
*/
labels?: Pair[];
/**
* Whether the container should be started in privileged mode.
* @example true
*/
privileged?: boolean;
/**
* Whether the container should be started in interactive mode (-i -t equivalent on the CLI).
* @example true
*/
interactive?: boolean;
/**
* Container restart policy.
* @example "on-failure"
*/
restart_policy?: string;
/**
* Container hostname.
* @example "mycontainer"
*/
hostname?: string;
}
/**
* TemplateRepository represents the git repository configuration for a template.
*/
export interface TemplateRepository {
/**
* URL of a git repository used to deploy a stack template. Mandatory for a Swarm/Compose stack template.
* @example "https://github.com/portainer/portainer-compose"
*/
url: string;
/**
* Path to the stack file inside the git repository.
* @example "./subfolder/docker-compose.yml"
*/
stackfile: string;
}
/**
* TemplateVolume represents a template volume configuration.
*/
interface TemplateVolume {
/**
* Path inside the container.
* @example "/data"
*/
container: string;
/**
* Path on the host.
* @example "/tmp"
*/
bind?: string;
/**
* Whether the volume used should be readonly.
* @example true
*/
readonly?: boolean;
}
/**
* TemplateEnv represents an environment (endpoint) variable for a template.
*/
export interface TemplateEnv {
/**
* Name of the environment (endpoint) variable.
* @example "MYSQL_ROOT_PASSWORD"
*/
name: string;
/**
* Text for the label that will be generated in the UI.
* @example "Root password"
*/
label?: string;
/**
* Content of the tooltip that will be generated in the UI.
* @example "MySQL root account password"
*/
description?: string;
/**
* Default value that will be set for the variable.
* @example "default_value"
*/
default?: string;
/**
* If set to true, will not generate any input for this variable in the UI.
* @example false
*/
preset?: boolean;
/**
* A list of name/value pairs that will be used to generate a dropdown in the UI.
*/
select?: TemplateEnvSelect[];
}
/**
* TemplateEnvSelect represents a text/value pair that will be displayed as a choice for the template user.
*/
interface TemplateEnvSelect {
/**
* Some text that will be displayed as a choice.
* @example "text value"
*/
text: string;
/**
* A value that will be associated with the choice.
* @example "value"
*/
value: string;
/**
* Will set this choice as the default choice.
* @example false
*/
default: boolean;
}

View file

@ -0,0 +1,91 @@
import { ReactNode } from 'react';
import LinuxIcon from '@/assets/ico/linux.svg?c';
import MicrosoftIcon from '@/assets/ico/vendor/microsoft.svg?c';
import KubernetesIcon from '@/assets/ico/vendor/kubernetes.svg?c';
import { Icon } from '@@/Icon';
import { FallbackImage } from '@@/FallbackImage';
import { BlocklistItem } from '@@/Blocklist/BlocklistItem';
import { Platform } from '../../custom-templates/types';
type Value = {
Id: number | string;
Logo?: string;
Title: string;
Platform?: Platform;
Description: string;
Categories?: string[];
};
export function TemplateItem({
template,
typeLabel,
onSelect,
renderActions,
isSelected,
}: {
template: Value;
typeLabel: string;
onSelect: () => void;
renderActions: ReactNode;
isSelected: boolean;
}) {
return (
<div className="relative">
<BlocklistItem isSelected={isSelected} onClick={() => onSelect()}>
<div className="vertical-center min-w-[56px] justify-center">
<FallbackImage
src={template.Logo}
fallbackIcon="rocket"
className="blocklist-item-logo"
size="3xl"
/>
</div>
<div className="col-sm-12 flex justify-between flex-wrap">
<div className="blocklist-item-line gap-2">
<span className="blocklist-item-title">{template.Title}</span>
<div className="space-left blocklist-item-subtitle inline-flex items-center">
<div className="vertical-center gap-1">
{(template.Platform === Platform.LINUX ||
!template.Platform) && (
<Icon icon={LinuxIcon} className="mr-1" />
)}
{(template.Platform === Platform.WINDOWS ||
!template.Platform) && (
<Icon
icon={MicrosoftIcon}
className="[&>*]:flex [&>*]:items-center"
size="lg"
/>
)}
</div>
{typeLabel === 'manifest' && (
<div className="vertical-center">
<Icon
icon={KubernetesIcon}
size="lg"
className="align-bottom [&>*]:flex [&>*]:items-center"
/>
</div>
)}
{typeLabel}
</div>
</div>
<div className="blocklist-item-line w-full">
<span className="blocklist-item-desc">{template.Description}</span>
{template.Categories && template.Categories.length > 0 && (
<span className="small text-muted">
{template.Categories.join(', ')}
</span>
)}
</div>
</div>
</BlocklistItem>
<span className="absolute inset-y-0 right-0 justify-end">
{renderActions}
</span>
</div>
);
}

View file

@ -0,0 +1,80 @@
import { Edit, Trash2 } from 'lucide-react';
import { useCurrentUser } from '@/react/hooks/useUser';
import { StackType } from '@/react/common/stacks/types';
import { CustomTemplate } from '@/react/portainer/custom-templates/types';
import { Button } from '@@/buttons';
import { Link } from '@@/Link';
import { TemplateItem } from '../../components/TemplateItem';
export function CustomTemplatesListItem({
template,
onSelect,
onDelete,
isSelected,
}: {
template: CustomTemplate;
onSelect: (templateId: CustomTemplate['Id']) => void;
onDelete: (templateId: CustomTemplate['Id']) => void;
isSelected: boolean;
}) {
const { isAdmin, user } = useCurrentUser();
const isEditAllowed = isAdmin || template.CreatedByUserId === user.Id;
return (
<TemplateItem
template={template}
typeLabel={getTypeLabel(template.Type)}
onSelect={() => onSelect(template.Id)}
isSelected={isSelected}
renderActions={
<div className="mr-4 mt-3">
{isEditAllowed && (
<div className="vertical-center">
<Button
as={Link}
onClick={(e) => {
e.stopPropagation();
}}
color="secondary"
props={{
to: '.edit',
params: {
id: template.Id,
},
}}
icon={Edit}
>
Edit
</Button>
<Button
onClick={(e) => {
onDelete(template.Id);
e.stopPropagation();
}}
color="dangerlight"
icon={Trash2}
>
Delete
</Button>
</div>
)}
</div>
}
/>
);
}
function getTypeLabel(type: StackType) {
switch (type) {
case StackType.DockerSwarm:
return 'swarm';
case StackType.Kubernetes:
return 'manifest';
case StackType.DockerCompose:
default:
return 'standalone';
}
}