1
0
Fork 0
mirror of https://github.com/portainer/portainer.git synced 2025-07-23 15:29:42 +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

@ -22,6 +22,11 @@ export default class KubeCustomTemplatesViewController {
this.validateForm = this.validateForm.bind(this); this.validateForm = this.validateForm.bind(this);
this.confirmDelete = this.confirmDelete.bind(this); this.confirmDelete = this.confirmDelete.bind(this);
this.selectTemplate = this.selectTemplate.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) { selectTemplate(template) {

View file

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

View file

@ -29,25 +29,15 @@
</div> </div>
<div class="blocklist gap-y-2 !px-[20px] !pb-[20px]"> <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" ng-repeat="template in $ctrl.templates | filter:$ctrl.state.textFilter"
model="template" template="template"
type-label="{{ $ctrl.typeLabel(template.Type) }}"
on-select="($ctrl.onSelectClick)" on-select="($ctrl.onSelectClick)"
on-delete="($ctrl.onDeleteClick)"
is-selected="$ctrl.isSelected(template)"
> >
<template-item-actions> </custom-templates-list-item>
<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>
<div ng-if="!$ctrl.templates" class="text-muted text-center"> Loading... </div> <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 ng-if="($ctrl.templates | filter: $ctrl.state.textFilter).length === 0" class="text-muted text-center"> No templates available. </div>
</div> </div>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -59,16 +59,10 @@
</div> </div>
</div> </div>
<div class="blocklist gap-y-2 !px-[20px] !pb-[20px]"> <div class="blocklist gap-y-2 !px-[20px] !pb-[20px]">
<template-item <div ng-repeat="template in $ctrl.templates | filter: $ctrl.filterByTemplateType | filter:$ctrl.filterByCategory | filter:$ctrl.state.textFilter">
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)">
model="template" </app-templates-list-item>
type-label="{{ template.Type === 1 ? 'container' : 'stack' }}" </div>
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-if="!$ctrl.templates" class="text-muted text-center"> Loading... </div> <div ng-if="!$ctrl.templates" class="text-muted text-center"> Loading... </div>
<div <div
ng-if="($ctrl.templates | filter: $ctrl.filterByTemplateType | filter: $ctrl.filterByCategory | filter: $ctrl.state.textFilter).length === 0" ng-if="($ctrl.templates | filter: $ctrl.filterByTemplateType | filter: $ctrl.filterByCategory | filter: $ctrl.state.textFilter).length === 0"

View file

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

View file

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

View file

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

View file

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

View file

@ -82,6 +82,11 @@ class CustomTemplatesViewController {
this.isEditAllowed = this.isEditAllowed.bind(this); this.isEditAllowed = this.isEditAllowed.bind(this);
this.onChangeFormValues = this.onChangeFormValues.bind(this); this.onChangeFormValues = this.onChangeFormValues.bind(this);
this.onChangeTemplateVariables = this.onChangeTemplateVariables.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) { 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 // 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) { async unselectTemplateAsync() {
template.Selected = false;
this.state.selectedTemplate = null; this.state.selectedTemplate = null;
this.formValues = { this.formValues = {
@ -194,15 +198,15 @@ class CustomTemplatesViewController {
}; };
} }
selectTemplate(template) { selectTemplate(templateId) {
return this.$async(this.selectTemplateAsync, template); return this.$async(this.selectTemplateAsync, templateId);
} }
async selectTemplateAsync(template) { async selectTemplateAsync(templateId) {
if (this.state.selectedTemplate) { if (this.state.selectedTemplate) {
await this.unselectTemplate(this.state.selectedTemplate); await this.unselectTemplate(this.state.selectedTemplate);
} }
template.Selected = true; const template = _.find(this.templates, { Id: templateId });
try { try {
this.state.templateContent = this.formValues.fileContent = await this.CustomTemplateService.customTemplateFile(template.Id, template.GitConfig !== null); this.state.templateContent = this.formValues.fileContent = await this.CustomTemplateService.customTemplateFile(template.Id, template.GitConfig !== null);

View file

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

View file

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

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 { interface Props {
// props for the image to load // props for the image to load
src: string; // a link to an external image src?: string; // a link to an external image
fallbackIcon: string; fallbackIcon: string;
alt?: string; alt?: string;
size?: BadgeSize; size?: BadgeSize;

View file

@ -1,5 +1,6 @@
import _ from 'lodash'; import _ from 'lodash';
import { Tag, Activity } from 'lucide-react'; import { Tag, Activity } from 'lucide-react';
import clsx from 'clsx';
import { import {
isoDateFromTimestamp, isoDateFromTimestamp,
@ -20,6 +21,7 @@ import { useTags } from '@/portainer/tags/queries';
import { EdgeIndicator } from '@@/EdgeIndicator'; import { EdgeIndicator } from '@@/EdgeIndicator';
import { EnvironmentStatusBadge } from '@@/EnvironmentStatusBadge'; import { EnvironmentStatusBadge } from '@@/EnvironmentStatusBadge';
import { Link } from '@@/Link'; import { Link } from '@@/Link';
import { BlocklistItem } from '@@/Blocklist/BlocklistItem';
import { EnvironmentIcon } from './EnvironmentIcon'; import { EnvironmentIcon } from './EnvironmentIcon';
import { EnvironmentStats } from './EnvironmentStats'; import { EnvironmentStats } from './EnvironmentStats';
@ -53,63 +55,62 @@ export function EnvironmentItem({
return ( return (
<div className="relative"> <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} to={dashboardRoute.to}
params={dashboardRoute.params} params={dashboardRoute.params}
className="no-link"
> >
<button <div className="ml-2 flex justify-center self-center">
className="blocklist-item !m-0 flex min-h-[110px] w-full items-stretch overflow-hidden bg-transparent !pr-56" <EnvironmentIcon type={environment.Type} />
onClick={onClickBrowse} </div>
type="button" <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">
<div className="ml-2 flex justify-center self-center"> <span className="font-bold">{environment.Name}</span>
<EnvironmentIcon type={environment.Type} /> {isEdge ? (
</div> <EdgeIndicator environment={environment} showLastCheckInDate />
<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> <EnvironmentStatusBadge status={environment.Status} />
{isEdge ? ( {snapshotTime && (
<EdgeIndicator environment={environment} showLastCheckInDate /> <span
) : ( className="small text-muted vertical-center gap-1"
<> title="Last snapshot time"
<EnvironmentStatusBadge status={environment.Status} /> >
{snapshotTime && ( <Activity className="icon icon-sm" aria-hidden="true" />
<span {snapshotTime}
className="small text-muted vertical-center gap-1" </span>
title="Last snapshot time" )}
> </>
<Activity className="icon icon-sm" aria-hidden="true" /> )}
{snapshotTime} <EngineVersion environment={environment} />
</span> {!isEdge && (
)} <span className="text-muted small vertical-center">
</> {stripProtocol(environment.URL)}
)}
<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}
</span> </span>
<EnvironmentTypeTag environment={environment} /> )}
<AgentDetails environment={environment} />
</div>
<EnvironmentStats environment={environment} />
</div> </div>
</button> <div className="small text-muted flex flex-wrap items-center gap-x-4 gap-y-2">
</Link> {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 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 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';
}
}