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

pull/10445/head
Chaim Lev-Ari 1 year ago committed by GitHub
parent d970f0e2bc
commit 1ad9488ca7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -22,6 +22,11 @@ export default class KubeCustomTemplatesViewController {
this.validateForm = this.validateForm.bind(this);
this.confirmDelete = this.confirmDelete.bind(this);
this.selectTemplate = this.selectTemplate.bind(this);
this.isSelected = this.isSelected.bind(this);
}
isSelected(templateId) {
return this.state.selectedTemplate && this.state.selectedTemplate.Id === templateId;
}
selectTemplate(template) {

@ -13,6 +13,7 @@
on-delete-click="($ctrl.confirmDelete)"
create-path="kubernetes.templates.custom.new"
edit-path="kubernetes.templates.custom.edit"
is-selected="($ctrl.isSelected)"
></custom-templates-list>
</div>
</div>

@ -29,25 +29,15 @@
</div>
<div class="blocklist gap-y-2 !px-[20px] !pb-[20px]">
<template-item
<custom-templates-list-item
ng-repeat="template in $ctrl.templates | filter:$ctrl.state.textFilter"
model="template"
type-label="{{ $ctrl.typeLabel(template.Type) }}"
template="template"
on-select="($ctrl.onSelectClick)"
on-delete="($ctrl.onDeleteClick)"
is-selected="$ctrl.isSelected(template)"
>
<template-item-actions>
<div ng-if="$ctrl.isEditAllowed(template)" class="vertical-center">
<a ui-state="$ctrl.editPath" ui-state-params="{id: template.Id}" ng-click="$event.stopPropagation();" class="btn btn-secondary btn-sm vertical-center">
<pr-icon icon="'edit'"></pr-icon>
Edit
</a>
<button class="btn btn-dangerlight btn-sm vertical-center" ng-click="$ctrl.onDeleteClick(template.Id); $event.stopPropagation();">
<pr-icon icon="'trash-2'"></pr-icon>
Delete
</button>
</div>
</template-item-actions>
</template-item>
</custom-templates-list-item>
<div ng-if="!$ctrl.templates" class="text-muted text-center"> Loading... </div>
<div ng-if="($ctrl.templates | filter: $ctrl.state.textFilter).length === 0" class="text-muted text-center"> No templates available. </div>
</div>

@ -14,5 +14,6 @@ angular.module('portainer.app').component('customTemplatesList', {
isEditAllowed: '<',
createPath: '@',
editPath: '@',
isSelected: '<',
},
});

@ -1,9 +0,0 @@
.template-item-details {
display: flex;
justify-content: space-between;
flex-wrap: wrap;
}
.template-item-details .template-item-details-sub {
width: 100%;
}

@ -1,15 +0,0 @@
import angular from 'angular';
import './template-item.css';
angular.module('portainer.app').component('templateItem', {
templateUrl: './templateItem.html',
bindings: {
model: '<',
typeLabel: '@',
onSelect: '<',
},
transclude: {
actions: '?templateItemActions',
},
});

@ -1,49 +0,0 @@
<!-- template -->
<div ng-class="{ 'blocklist-item--selected': $ctrl.model.Selected }" class="blocklist-item template-item !my-0 !mr-0" ng-click="$ctrl.onSelect($ctrl.model)">
<div class="blocklist-item-box">
<!-- template-image -->
<div class="vertical-center min-w-[56px] justify-center">
<fallback-image src="$ctrl.model.Logo" fallback-icon="'rocket'" class-name="'blocklist-item-logo'" size="'3xl'"></fallback-image>
</div>
<!-- !template-image -->
<!-- template-details -->
<div class="col-sm-12 template-item-details">
<!-- blocklist-item-line1 -->
<div class="blocklist-item-line gap-2">
<span class="blocklist-item-title">
{{ $ctrl.model.Title }}
</span>
<div class="space-left blocklist-item-subtitle inline-flex items-center">
<div ng-if="$ctrl.typeLabel !== 'manifest'" class="vertical-center gap-1">
<pr-icon ng-if="$ctrl.model.Platform === 1 || $ctrl.model.Platform === 'linux' || !$ctrl.model.Platform" icon="'svg-linux'" class="mr-1"></pr-icon>
<pr-icon
icon="'svg-microsoft'"
ng-if="$ctrl.model.Platform === 2 || $ctrl.model.Platform === 'windows' || !$ctrl.model.Platform"
class-name="'[&>*]:flex [&>*]:items-center'"
size="'lg'"
></pr-icon>
</div>
<!-- currently only kubernetes uses the typeLabel of 'manifest' -->
<div ng-if="$ctrl.typeLabel === 'manifest'" class="vertical-center">
<pr-icon icon="'svg-kubernetes'" size="'lg'" class="align-bottom" class-name="'[&>*]:flex [&>*]:items-center'"></pr-icon>
</div>
{{ $ctrl.typeLabel }}
</div>
</div>
<!-- !blocklist-item-line1 -->
<span class="blocklist-item-actions" ng-transclude="actions"></span>
<!-- blocklist-item-line2 -->
<div class="blocklist-item-line template-item-details-sub">
<span class="blocklist-item-desc">
{{ $ctrl.model.Description }}
</span>
<span class="small text-muted" ng-if="$ctrl.model.Categories.length > 0">
{{ $ctrl.model.Categories.join(', ') }}
</span>
</div>
<!-- !blocklist-item-line2 -->
</div>
<!-- !template-details -->
</div>
<!-- !template -->
</div>

@ -8,5 +8,6 @@ angular.module('portainer.app').component('templateList', {
tableKey: '@',
selectAction: '<',
showSwarmStacks: '<',
isSelected: '<',
},
});

@ -59,16 +59,10 @@
</div>
</div>
<div class="blocklist gap-y-2 !px-[20px] !pb-[20px]">
<template-item
ng-repeat="template in $ctrl.templates | filter: $ctrl.filterByTemplateType | filter:$ctrl.filterByCategory | filter:$ctrl.state.textFilter"
model="template"
type-label="{{ template.Type === 1 ? 'container' : 'stack' }}"
on-select="($ctrl.selectAction)"
>
<template-item-actions ng-if="template.Type === 2 || template.Type === 3">
<button ng-click="$event.stopPropagation(); $ctrl.duplicateTemplate(template)" class="btn btn-primary btn-xs"> Copy as Custom </button>
</template-item-actions>
</template-item>
<div ng-repeat="template in $ctrl.templates | filter: $ctrl.filterByTemplateType | filter:$ctrl.filterByCategory | filter:$ctrl.state.textFilter">
<app-templates-list-item template="template" on-select="($ctrl.selectAction)" on-duplicate="($ctrl.duplicateTemplate)" is-selected="$ctrl.isSelected(template)">
</app-templates-list-item>
</div>
<div ng-if="!$ctrl.templates" class="text-muted text-center"> Loading... </div>
<div
ng-if="($ctrl.templates | filter: $ctrl.filterByTemplateType | filter: $ctrl.filterByCategory | filter: $ctrl.state.textFilter).length === 0"

@ -1,107 +0,0 @@
import _ from 'lodash-es';
import { PorImageRegistryModel } from 'Docker/models/porImageRegistry';
export class TemplateViewModel {
constructor(data, version) {
switch (version) {
case '2':
this.setTemplatesV2(data);
break;
default:
throw new Error('Unsupported template version');
}
}
setTemplatesV2(data) {
this.Id = data.Id;
this.Title = data.title;
this.Type = data.type;
this.Description = data.description;
this.AdministratorOnly = data.AdministratorOnly;
this.Name = data.name;
this.Note = data.note;
this.Categories = data.categories ? data.categories : [];
this.Platform = data.platform ? data.platform : '';
this.Logo = data.logo;
this.Repository = data.repository;
this.Hostname = data.hostname;
this.RegistryModel = new PorImageRegistryModel();
this.RegistryModel.Image = data.image;
this.RegistryModel.Registry.URL = data.registry || '';
this.Command = data.command ? data.command : '';
this.Network = data.network ? data.network : '';
this.Privileged = data.privileged ? data.privileged : false;
this.Interactive = data.interactive ? data.interactive : false;
this.RestartPolicy = data.restart_policy ? data.restart_policy : 'always';
this.Labels = data.labels ? data.labels : [];
this.Hosts = data.hosts ? data.hosts : [];
this.Env = templateEnv(data);
this.Volumes = templateVolumes(data);
this.Ports = templatePorts(data);
}
}
function templatePorts(data) {
var ports = [];
if (data.ports) {
ports = data.ports.map(function (p) {
var portAndProtocol = _.split(p, '/');
var hostAndContainerPort = _.split(portAndProtocol[0], ':');
return {
hostPort: hostAndContainerPort.length > 1 ? hostAndContainerPort[0] : undefined,
containerPort: hostAndContainerPort.length > 1 ? hostAndContainerPort[1] : hostAndContainerPort[0],
protocol: portAndProtocol[1],
};
});
}
return ports;
}
function templateVolumes(data) {
var volumes = [];
if (data.volumes) {
volumes = data.volumes.map(function (v) {
return {
container: v.container,
readonly: v.readonly || false,
type: v.bind ? 'bind' : 'auto',
bind: v.bind ? v.bind : null,
};
});
}
return volumes;
}
function templateEnv(data) {
var env = [];
if (data.env) {
env = data.env.map(function (envvar) {
envvar.type = 2;
envvar.value = envvar.default ? envvar.default : '';
if (envvar.preset) {
envvar.type = 1;
}
if (envvar.select) {
envvar.type = 3;
for (var i = 0; i < envvar.select.length; i++) {
var allowedValue = envvar.select[i];
if (allowedValue.default) {
envvar.value = allowedValue.value;
break;
}
}
}
return envvar;
});
}
return env;
}

@ -4,6 +4,10 @@ import { r2a } from '@/react-tools/react2angular';
import { CustomTemplatesVariablesDefinitionField } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesDefinitionField';
import { CustomTemplatesVariablesField } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesField';
import { withControlledInput } from '@/react-tools/withControlledInput';
import { CustomTemplatesListItem } from '@/react/portainer/templates/custom-templates/ListView/CustomTemplatesListItem';
import { withCurrentUser } from '@/react-tools/withCurrentUser';
import { withUIRouter } from '@/react-tools/withUIRouter';
import { AppTemplatesListItem } from '@/react/portainer/templates/app-templates/AppTemplatesListItem';
import { VariablesFieldAngular } from './variables-field';
@ -26,4 +30,22 @@ export const customTemplatesModule = angular
'errors',
'isVariablesNamesFromParent',
])
)
.component(
'customTemplatesListItem',
r2a(withUIRouter(withCurrentUser(CustomTemplatesListItem)), [
'onDelete',
'onSelect',
'template',
'isSelected',
])
)
.component(
'appTemplatesListItem',
r2a(withUIRouter(withCurrentUser(AppTemplatesListItem)), [
'onSelect',
'template',
'isSelected',
'onDuplicate',
])
).name;

@ -1,6 +1,6 @@
import { commandStringToArray } from '@/docker/helpers/containers';
import { TemplateViewModel } from '@/react/portainer/templates/app-templates/template';
import { DockerHubViewModel } from 'Portainer/models/dockerhub';
import { TemplateViewModel } from '../../models/template';
angular.module('portainer.app').factory('TemplateService', TemplateServiceFactory);

@ -75,6 +75,7 @@
is-edit-allowed="$ctrl.isEditAllowed"
on-select-click="($ctrl.selectTemplate)"
on-delete-click="($ctrl.confirmDelete)"
is-selected="($ctrl.isSelected)"
></custom-templates-list>
</div>
</div>

@ -82,6 +82,11 @@ class CustomTemplatesViewController {
this.isEditAllowed = this.isEditAllowed.bind(this);
this.onChangeFormValues = this.onChangeFormValues.bind(this);
this.onChangeTemplateVariables = this.onChangeTemplateVariables.bind(this);
this.isSelected = this.isSelected.bind(this);
}
isSelected(templateId) {
return this.state.selectedTemplate && this.state.selectedTemplate.Id === templateId;
}
isEditAllowed(template) {
@ -177,12 +182,11 @@ class CustomTemplatesViewController {
}
}
unselectTemplate(template) {
unselectTemplate() {
// wrapping unselect with async to make a digest cycle run between unselect to select
return this.$async(this.unselectTemplateAsync, template);
return this.$async(this.unselectTemplateAsync);
}
async unselectTemplateAsync(template) {
template.Selected = false;
async unselectTemplateAsync() {
this.state.selectedTemplate = null;
this.formValues = {
@ -194,15 +198,15 @@ class CustomTemplatesViewController {
};
}
selectTemplate(template) {
return this.$async(this.selectTemplateAsync, template);
selectTemplate(templateId) {
return this.$async(this.selectTemplateAsync, templateId);
}
async selectTemplateAsync(template) {
async selectTemplateAsync(templateId) {
if (this.state.selectedTemplate) {
await this.unselectTemplate(this.state.selectedTemplate);
}
template.Selected = true;
const template = _.find(this.templates, { Id: templateId });
try {
this.state.templateContent = this.formValues.fileContent = await this.CustomTemplateService.customTemplateFile(template.Id, template.GitConfig !== null);

@ -279,6 +279,7 @@
templates="templates"
table-key="templates"
select-action="selectTemplate"
is-selected="isSelected"
show-swarm-stacks="applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE' && applicationState.endpoint.mode.role === 'MANAGER' && applicationState.endpoint.apiVersion >= 1.25"
>
</template-list>

@ -18,6 +18,7 @@ angular.module('portainer.app').controller('TemplatesController', [
'FormValidator',
'StackService',
'endpoint',
'$async',
function (
$scope,
$q,
@ -34,7 +35,8 @@ angular.module('portainer.app').controller('TemplatesController', [
Authentication,
FormValidator,
StackService,
endpoint
endpoint,
$async
) {
const DOCKER_STANDALONE = 'DOCKER_STANDALONE';
const DOCKER_SWARM_MODE = 'DOCKER_SWARM_MODE';
@ -222,31 +224,37 @@ angular.module('portainer.app').controller('TemplatesController', [
}
};
$scope.unselectTemplate = function (template) {
template.Selected = false;
$scope.state.selectedTemplate = null;
$scope.isSelected = function (template) {
return $scope.state.selectedTemplate && $scope.state.selectedTemplate.Id === template.Id;
};
$scope.unselectTemplate = function () {
return $async(async () => {
$scope.state.selectedTemplate = null;
});
};
$scope.selectTemplate = function (template) {
if ($scope.state.selectedTemplate) {
$scope.unselectTemplate($scope.state.selectedTemplate);
}
return $async(async () => {
if ($scope.state.selectedTemplate) {
$scope.unselectTemplate($scope.state.selectedTemplate);
}
template.Selected = true;
if (template.Network) {
$scope.formValues.network = _.find($scope.availableNetworks, function (o) {
return o.Name === template.Network;
});
} else {
$scope.formValues.network = _.find($scope.availableNetworks, function (o) {
return o.Name === 'bridge';
});
}
if (template.Network) {
$scope.formValues.network = _.find($scope.availableNetworks, function (o) {
return o.Name === template.Network;
});
} else {
$scope.formValues.network = _.find($scope.availableNetworks, function (o) {
return o.Name === 'bridge';
});
}
$scope.formValues.name = template.Name ? template.Name : '';
$scope.state.selectedTemplate = template;
$scope.state.deployable = isDeployable($scope.applicationState.endpoint, template.Type);
$anchorScroll('view-top');
$scope.formValues.name = template.Name ? template.Name : '';
$scope.state.selectedTemplate = template;
$scope.state.deployable = isDeployable($scope.applicationState.endpoint, template.Type);
$anchorScroll('view-top');
});
};
function isDeployable(endpoint, templateType) {

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

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

@ -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 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="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="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>
<EnvironmentTypeTag environment={environment} />
<AgentDetails environment={environment} />
</div>
<EnvironmentStats environment={environment} />
)}
<span className="vertical-center gap-1">
<Tag className="icon icon-sm" aria-hidden="true" />
{tags}
</span>
<EnvironmentTypeTag environment={environment} />
<AgentDetails environment={environment} />
</div>
</button>
</Link>
<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

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

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

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

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

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

@ -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';
}
}
Loading…
Cancel
Save