diff --git a/app/kubernetes/custom-templates/kube-custom-templates-view/kube-custom-templates-view.controller.js b/app/kubernetes/custom-templates/kube-custom-templates-view/kube-custom-templates-view.controller.js index 74d345b4f..23f4b546a 100644 --- a/app/kubernetes/custom-templates/kube-custom-templates-view/kube-custom-templates-view.controller.js +++ b/app/kubernetes/custom-templates/kube-custom-templates-view/kube-custom-templates-view.controller.js @@ -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) { diff --git a/app/kubernetes/custom-templates/kube-custom-templates-view/kube-custom-templates-view.html b/app/kubernetes/custom-templates/kube-custom-templates-view/kube-custom-templates-view.html index c771dd4db..bc35c5194 100644 --- a/app/kubernetes/custom-templates/kube-custom-templates-view/kube-custom-templates-view.html +++ b/app/kubernetes/custom-templates/kube-custom-templates-view/kube-custom-templates-view.html @@ -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)" > diff --git a/app/portainer/components/custom-templates-list/customTemplatesList.html b/app/portainer/components/custom-templates-list/customTemplatesList.html index 96bd2ea82..97fd126f2 100644 --- a/app/portainer/components/custom-templates-list/customTemplatesList.html +++ b/app/portainer/components/custom-templates-list/customTemplatesList.html @@ -29,25 +29,15 @@
- - -
- - - Edit - - -
-
-
+ +
Loading...
No templates available.
diff --git a/app/portainer/components/custom-templates-list/index.js b/app/portainer/components/custom-templates-list/index.js index f3a4ad8ab..cf6ca2144 100644 --- a/app/portainer/components/custom-templates-list/index.js +++ b/app/portainer/components/custom-templates-list/index.js @@ -14,5 +14,6 @@ angular.module('portainer.app').component('customTemplatesList', { isEditAllowed: '<', createPath: '@', editPath: '@', + isSelected: '<', }, }); diff --git a/app/portainer/components/template-list/template-item/template-item.css b/app/portainer/components/template-list/template-item/template-item.css deleted file mode 100644 index 01b8f1a23..000000000 --- a/app/portainer/components/template-list/template-item/template-item.css +++ /dev/null @@ -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%; -} diff --git a/app/portainer/components/template-list/template-item/template-item.js b/app/portainer/components/template-list/template-item/template-item.js deleted file mode 100644 index 8b62581a3..000000000 --- a/app/portainer/components/template-list/template-item/template-item.js +++ /dev/null @@ -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', - }, -}); diff --git a/app/portainer/components/template-list/template-item/templateItem.html b/app/portainer/components/template-list/template-item/templateItem.html deleted file mode 100644 index 213c37e52..000000000 --- a/app/portainer/components/template-list/template-item/templateItem.html +++ /dev/null @@ -1,49 +0,0 @@ - -
-
- -
- -
- - -
- -
- - {{ $ctrl.model.Title }} - -
-
- - -
- -
- -
- {{ $ctrl.typeLabel }} -
-
- - - -
- - {{ $ctrl.model.Description }} - - - {{ $ctrl.model.Categories.join(', ') }} - -
- -
- -
- -
diff --git a/app/portainer/components/template-list/template-list.js b/app/portainer/components/template-list/template-list.js index 6871b65ea..ec61e2a01 100644 --- a/app/portainer/components/template-list/template-list.js +++ b/app/portainer/components/template-list/template-list.js @@ -8,5 +8,6 @@ angular.module('portainer.app').component('templateList', { tableKey: '@', selectAction: '<', showSwarmStacks: '<', + isSelected: '<', }, }); diff --git a/app/portainer/components/template-list/templateList.html b/app/portainer/components/template-list/templateList.html index 70f25e9d5..68c8ef110 100644 --- a/app/portainer/components/template-list/templateList.html +++ b/app/portainer/components/template-list/templateList.html @@ -59,16 +59,10 @@
- - - - - +
+ + +
Loading...
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; -} diff --git a/app/portainer/react/components/custom-templates/index.ts b/app/portainer/react/components/custom-templates/index.ts index 6a682b881..18c1cdfcb 100644 --- a/app/portainer/react/components/custom-templates/index.ts +++ b/app/portainer/react/components/custom-templates/index.ts @@ -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; diff --git a/app/portainer/services/api/templateService.js b/app/portainer/services/api/templateService.js index 52f0f8cf6..7259305f7 100644 --- a/app/portainer/services/api/templateService.js +++ b/app/portainer/services/api/templateService.js @@ -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); diff --git a/app/portainer/views/custom-templates/custom-templates-view/customTemplatesView.html b/app/portainer/views/custom-templates/custom-templates-view/customTemplatesView.html index d7882eb5d..cf7108a0a 100644 --- a/app/portainer/views/custom-templates/custom-templates-view/customTemplatesView.html +++ b/app/portainer/views/custom-templates/custom-templates-view/customTemplatesView.html @@ -75,6 +75,7 @@ is-edit-allowed="$ctrl.isEditAllowed" on-select-click="($ctrl.selectTemplate)" on-delete-click="($ctrl.confirmDelete)" + is-selected="($ctrl.isSelected)" >
diff --git a/app/portainer/views/custom-templates/custom-templates-view/customTemplatesViewController.js b/app/portainer/views/custom-templates/custom-templates-view/customTemplatesViewController.js index 4aeac7ac9..52b0d65a5 100644 --- a/app/portainer/views/custom-templates/custom-templates-view/customTemplatesViewController.js +++ b/app/portainer/views/custom-templates/custom-templates-view/customTemplatesViewController.js @@ -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); diff --git a/app/portainer/views/templates/templates.html b/app/portainer/views/templates/templates.html index 5fc6c9ccf..a32a5f655 100644 --- a/app/portainer/views/templates/templates.html +++ b/app/portainer/views/templates/templates.html @@ -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" > diff --git a/app/portainer/views/templates/templatesController.js b/app/portainer/views/templates/templatesController.js index a19124a7e..6edde966c 100644 --- a/app/portainer/views/templates/templatesController.js +++ b/app/portainer/views/templates/templatesController.js @@ -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) { diff --git a/app/react/components/Blocklist/BlocklistItem.tsx b/app/react/components/Blocklist/BlocklistItem.tsx new file mode 100644 index 000000000..d3f2a4e26 --- /dev/null +++ b/app/react/components/Blocklist/BlocklistItem.tsx @@ -0,0 +1,37 @@ +import clsx from 'clsx'; +import { ComponentProps, ComponentType, ElementType } from 'react'; + +export type AsComponentProps = + ComponentProps & { + as?: E; + }; + +export function BlocklistItem({ + className, + isSelected, + children, + as = 'button', + ...props +}: AsComponentProps & { + isSelected?: boolean; + as?: ComponentType; +}) { + const Component = as as 'button'; + + return ( + + {children} + + ); +} diff --git a/app/react/components/FallbackImage.tsx b/app/react/components/FallbackImage.tsx index 0cd64d2eb..e251b29dd 100644 --- a/app/react/components/FallbackImage.tsx +++ b/app/react/components/FallbackImage.tsx @@ -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; diff --git a/app/react/portainer/HomeView/EnvironmentList/EnvironmentItem/EnvironmentItem.tsx b/app/react/portainer/HomeView/EnvironmentList/EnvironmentItem/EnvironmentItem.tsx index 5638423bc..a09713c8f 100644 --- a/app/react/portainer/HomeView/EnvironmentList/EnvironmentItem/EnvironmentItem.tsx +++ b/app/react/portainer/HomeView/EnvironmentList/EnvironmentItem/EnvironmentItem.tsx @@ -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 (
- - - + +
+ {/* 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 diff --git a/app/react/portainer/custom-templates/types.ts b/app/react/portainer/custom-templates/types.ts new file mode 100644 index 000000000..788604443 --- /dev/null +++ b/app/react/portainer/custom-templates/types.ts @@ -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 custom 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, +} diff --git a/app/react/portainer/templates/app-templates/AppTemplatesListItem.tsx b/app/react/portainer/templates/app-templates/AppTemplatesListItem.tsx new file mode 100644 index 000000000..236cc287c --- /dev/null +++ b/app/react/portainer/templates/app-templates/AppTemplatesListItem.tsx @@ -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 ( + onSelect(template)} + isSelected={isSelected} + renderActions={ + template.Type === TemplateType.SwarmStack || + (template.Type === TemplateType.ComposeStack && ( +
+ +
+ )) + } + /> + ); +} diff --git a/app/react/portainer/templates/app-templates/template.ts b/app/react/portainer/templates/app-templates/template.ts new file mode 100644 index 000000000..f787509e5 --- /dev/null +++ b/app/react/portainer/templates/app-templates/template.ts @@ -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; + + 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; + } +} diff --git a/app/react/portainer/templates/app-templates/types.ts b/app/react/portainer/templates/app-templates/types.ts new file mode 100644 index 000000000..6d74e2009 --- /dev/null +++ b/app/react/portainer/templates/app-templates/types.ts @@ -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 custom 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; +} diff --git a/app/react/portainer/templates/components/TemplateItem.tsx b/app/react/portainer/templates/components/TemplateItem.tsx new file mode 100644 index 000000000..02b99314b --- /dev/null +++ b/app/react/portainer/templates/components/TemplateItem.tsx @@ -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 ( +
+ onSelect()}> +
+ +
+
+
+ {template.Title} +
+
+ {(template.Platform === Platform.LINUX || + !template.Platform) && ( + + )} + {(template.Platform === Platform.WINDOWS || + !template.Platform) && ( + + )} +
+ {typeLabel === 'manifest' && ( +
+ +
+ )} + {typeLabel} +
+
+
+ {template.Description} + {template.Categories && template.Categories.length > 0 && ( + + {template.Categories.join(', ')} + + )} +
+
+
+ + {renderActions} + +
+ ); +} diff --git a/app/react/portainer/templates/custom-templates/ListView/CustomTemplatesListItem.tsx b/app/react/portainer/templates/custom-templates/ListView/CustomTemplatesListItem.tsx new file mode 100644 index 000000000..2a1a701b1 --- /dev/null +++ b/app/react/portainer/templates/custom-templates/ListView/CustomTemplatesListItem.tsx @@ -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 ( + onSelect(template.Id)} + isSelected={isSelected} + renderActions={ +
+ {isEditAllowed && ( +
+ + +
+ )} +
+ } + /> + ); +} + +function getTypeLabel(type: StackType) { + switch (type) { + case StackType.DockerSwarm: + return 'swarm'; + case StackType.Kubernetes: + return 'manifest'; + case StackType.DockerCompose: + default: + return 'standalone'; + } +}