1
0
Fork 0
mirror of https://github.com/portainer/portainer.git synced 2025-08-09 07:45:22 +02:00

feat(edge/templates): introduce edge app templates [EE-6209] (#10480)

This commit is contained in:
Chaim Lev-Ari 2023-11-14 14:54:44 +02:00 committed by GitHub
parent 95d96e1164
commit e1e90c9c1d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
58 changed files with 1142 additions and 365 deletions

View file

@ -23,12 +23,11 @@ export function TemplatesUrlSection() {
</span>
</div>
<FormControl label="URL" inputId="templates_url" required errors={error}>
<FormControl label="URL" inputId="templates_url" errors={error}>
<Field
as={Input}
id="templates_url"
placeholder="https://myserver.mydomain/templates.json"
required
data-cy="settings-templateUrl"
name={name}
/>

View file

@ -7,7 +7,7 @@ import { Values } from './types';
export function validation(): SchemaOf<Values> {
return object({
edgeAgentCheckinInterval: number().required(),
enableTelemetry: bool().required(),
enableTelemetry: bool().default(false),
loginBannerEnabled: boolean().default(false),
loginBanner: string()
.default('')
@ -30,7 +30,11 @@ export function validation(): SchemaOf<Values> {
}),
snapshotInterval: string().required('Snapshot interval is required'),
templatesUrl: string()
.required('Templates URL is required')
.test('valid-url', 'Must be a valid URL', (value) => isValidUrl(value)),
.default('')
.test(
'valid-url',
'Must be a valid URL',
(value) => !value || isValidUrl(value)
),
});
}

View file

@ -1,7 +1,6 @@
import { Edit } from 'lucide-react';
import _ from 'lodash';
import { useState } from 'react';
import { useRouter } from '@uirouter/react';
import { DatatableHeader } from '@@/datatables/DatatableHeader';
import { Table } from '@@/datatables';
@ -11,39 +10,41 @@ import { DatatableFooter } from '@@/datatables/DatatableFooter';
import { AppTemplatesListItem } from './AppTemplatesListItem';
import { TemplateViewModel } from './view-model';
import { ListState } from './types';
import { ListState, TemplateType } from './types';
import { useSortAndFilterTemplates } from './useSortAndFilter';
import { Filters } from './Filters';
import { useFetchTemplateInfoMutation } from './useFetchTemplateInfoMutation';
const tableKey = 'app-templates-list';
const store = createPersistedStore<ListState>(tableKey, undefined, (set) => ({
category: null,
setCategory: (category: ListState['category']) => set({ category }),
type: null,
setType: (type: ListState['type']) => set({ type }),
types: [],
setTypes: (types: ListState['types']) => set({ types }),
}));
export function AppTemplatesList({
templates,
onSelect,
selectedId,
showSwarmStacks,
disabledTypes,
fixedCategories,
hideDuplicate,
}: {
templates?: TemplateViewModel[];
onSelect: (template: TemplateViewModel) => void;
selectedId?: TemplateViewModel['Id'];
showSwarmStacks?: boolean;
disabledTypes?: Array<TemplateType>;
fixedCategories?: Array<string>;
hideDuplicate?: boolean;
}) {
const fetchTemplateInfoMutation = useFetchTemplateInfoMutation();
const router = useRouter();
const [page, setPage] = useState(0);
const listState = useTableState(store, tableKey);
const filteredTemplates = useSortAndFilterTemplates(
templates || [],
listState,
showSwarmStacks
disabledTypes,
fixedCategories
);
const pagedTemplates =
@ -59,8 +60,10 @@ export function AppTemplatesList({
description={
<Filters
listState={listState}
templates={templates || []}
templates={filteredTemplates || []}
onChange={() => setPage(0)}
disabledTypes={disabledTypes}
fixedCategories={fixedCategories}
/>
}
/>
@ -71,8 +74,8 @@ export function AppTemplatesList({
key={template.Id}
template={template}
onSelect={onSelect}
onDuplicate={onDuplicate}
isSelected={selectedId === template.Id}
hideDuplicate={hideDuplicate}
/>
))}
{!templates && <div className="text-muted text-center">Loading...</div>}
@ -96,15 +99,4 @@ export function AppTemplatesList({
listState.setSearch(search);
setPage(0);
}
function onDuplicate(template: TemplateViewModel) {
fetchTemplateInfoMutation.mutate(template, {
onSuccess({ fileContent, type }) {
router.stateService.go('.custom.new', {
fileContent,
type,
});
},
});
}
}

View file

@ -1,4 +1,7 @@
import { StackType } from '@/react/common/stacks/types';
import { Button } from '@@/buttons';
import { Link } from '@@/Link';
import { TemplateItem } from '../components/TemplateItem';
@ -8,14 +11,16 @@ import { TemplateType } from './types';
export function AppTemplatesListItem({
template,
onSelect,
onDuplicate,
isSelected,
hideDuplicate = false,
}: {
template: TemplateViewModel;
onSelect: (template: TemplateViewModel) => void;
onDuplicate: (template: TemplateViewModel) => void;
isSelected: boolean;
hideDuplicate?: boolean;
}) {
const duplicateCustomTemplateType = getCustomTemplateType(template.Type);
return (
<TemplateItem
template={template}
@ -25,21 +30,39 @@ export function AppTemplatesListItem({
onSelect={() => onSelect(template)}
isSelected={isSelected}
renderActions={
template.Type === TemplateType.SwarmStack ||
(template.Type === TemplateType.ComposeStack && (
!hideDuplicate &&
duplicateCustomTemplateType && (
<div className="mr-5 mt-3">
<Button
as={Link}
size="xsmall"
onClick={(e) => {
e.stopPropagation();
onDuplicate(template);
}}
props={{
to: '.custom.new',
params: {
appTemplateId: template.Id,
type: duplicateCustomTemplateType,
},
}}
>
Copy as Custom
</Button>
</div>
))
)
}
/>
);
}
function getCustomTemplateType(type: TemplateType): StackType | null {
switch (type) {
case TemplateType.SwarmStack:
return StackType.DockerSwarm;
case TemplateType.ComposeStack:
return StackType.DockerCompose;
default:
return null;
}
}

View file

@ -1,58 +1,79 @@
import _ from 'lodash';
import { PortainerSelect } from '@@/form-components/PortainerSelect';
import { Option, PortainerSelect } from '@@/form-components/PortainerSelect';
import { ListState, TemplateType } from './types';
import { TemplateViewModel } from './view-model';
import { TemplateListSort } from './TemplateListSort';
const orderByFields = ['Title', 'Categories', 'Description'] as const;
const typeFilters = [
const typeFilters: ReadonlyArray<Option<TemplateType>> = [
{ label: 'Container', value: TemplateType.Container },
{ label: 'Stack', value: TemplateType.SwarmStack },
{ label: 'Swarm Stack', value: TemplateType.SwarmStack },
{ label: 'Compose Stack', value: TemplateType.ComposeStack },
] as const;
export function Filters({
templates,
listState,
onChange,
disabledTypes = [],
fixedCategories = [],
}: {
templates: TemplateViewModel[];
listState: ListState & { search: string };
onChange(): void;
disabledTypes?: Array<TemplateType>;
fixedCategories?: Array<string>;
}) {
const categories = _.sortBy(
_.uniq(templates?.flatMap((template) => template.Categories))
).map((category) => ({ label: category, value: category }));
)
.filter((category) => !fixedCategories.includes(category))
.map((category) => ({ label: category, value: category }));
const templatesTypes = _.uniq(
templates?.flatMap((template) => template.Type)
);
const typeFiltersEnabled = typeFilters.filter(
(type) =>
!disabledTypes.includes(type.value) && templatesTypes.includes(type.value)
);
return (
<div className="flex gap-4 w-full">
<div className="w-1/4">
<PortainerSelect
options={categories}
onChange={(category) => {
listState.setCategory(category);
onChange();
}}
placeholder="Category"
value={listState.category}
bindToBody
isClearable
/>
</div>
<div className="w-1/4">
<PortainerSelect
options={typeFilters}
onChange={(type) => {
listState.setType(type);
onChange();
}}
placeholder="Type"
value={listState.type}
bindToBody
isClearable
/>
</div>
{categories.length > 0 && (
<div className="w-1/4">
<PortainerSelect
options={categories}
onChange={(category) => {
listState.setCategory(category);
onChange();
}}
placeholder="Category"
value={listState.category}
bindToBody
isClearable
/>
</div>
)}
{typeFiltersEnabled.length > 1 && (
<div className="w-1/4">
<PortainerSelect<TemplateType>
isMulti
options={typeFiltersEnabled}
onChange={(types) => {
listState.setTypes(types);
onChange();
}}
placeholder="Type"
value={listState.types}
bindToBody
isClearable
/>
</div>
)}
<div className="w-1/4 ml-auto">
<TemplateListSort
onChange={(value) => {

View file

@ -0,0 +1,18 @@
import { AppTemplate } from '../types';
export function buildUrl({
id,
action,
}: { id?: AppTemplate['id']; action?: string } = {}) {
let baseUrl = '/templates';
if (id) {
baseUrl += `/${id}`;
}
if (action) {
baseUrl += `/${action}`;
}
return baseUrl;
}

View file

@ -0,0 +1,54 @@
import { useQuery } from 'react-query';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { useRegistries } from '@/react/portainer/registries/queries/useRegistries';
import { DockerHubViewModel } from '@/portainer/models/dockerhub';
import { Registry } from '@/react/portainer/registries/types/registry';
import { AppTemplate } from '../types';
import { TemplateViewModel } from '../view-model';
import { buildUrl } from './build-url';
export function useAppTemplates() {
const registriesQuery = useRegistries();
return useQuery(
'templates',
() => getTemplatesWithRegistry(registriesQuery.data),
{
enabled: !!registriesQuery.data,
}
);
}
async function getTemplatesWithRegistry(
registries: Array<Registry> | undefined
) {
if (!registries) {
return [];
}
const { templates, version } = await getTemplates();
return templates.map((item) => {
const template = new TemplateViewModel(item, version);
const registryURL = item.registry;
const registry = registryURL
? registries.find((reg) => reg.URL === registryURL)
: new DockerHubViewModel();
template.RegistryModel.Registry = registry || new DockerHubViewModel();
return template;
});
}
async function getTemplates() {
try {
const { data } = await axios.get<{
version: string;
templates: Array<AppTemplate>;
}>(buildUrl());
return data;
} catch (err) {
throw parseAxiosError(err);
}
}

View file

@ -0,0 +1,16 @@
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { AppTemplate } from '../types';
import { buildUrl } from './build-url';
export async function fetchFilePreview(id: AppTemplate['id']) {
try {
const { data } = await axios.post<{ FileContent: string }>(
buildUrl({ id, action: 'file' })
);
return data.FileContent;
} catch (err) {
throw parseAxiosError(err);
}
}

View file

@ -5,15 +5,14 @@ import { Pair } from '../../settings/types';
export interface ListState extends BasicTableSettings {
category: string | null;
setCategory: (category: string | null) => void;
type: TemplateType | null;
setType: (type: TemplateType | null) => void;
types: ReadonlyArray<TemplateType>;
setTypes: (value: ReadonlyArray<TemplateType>) => void;
}
export enum TemplateType {
Container = 1,
SwarmStack = 2,
ComposeStack = 3,
EdgeStack = 4,
}
/**
@ -21,7 +20,12 @@ export enum TemplateType {
*/
export interface AppTemplate {
/**
* Template type. Valid values are: 1 (container), 2 (Swarm stack), 3 (Compose stack), 4 (Compose edge stack).
* Unique identifier of the template.
*/
id: number;
/**
* Template type. Valid values are: 1 (container), 2 (Swarm stack), 3 (Compose stack)
* @example 1
*/
type: TemplateType;

View file

@ -1,52 +0,0 @@
import { useMutation } from 'react-query';
import { StackType } from '@/react/common/stacks/types';
import { mutationOptions, withGlobalError } from '@/react-tools/react-query';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { TemplateType } from './types';
import { TemplateViewModel } from './view-model';
export function useFetchTemplateInfoMutation() {
return useMutation(
getTemplateInfo,
mutationOptions(withGlobalError('Unable to fetch template info'))
);
}
async function getTemplateInfo(template: TemplateViewModel) {
const fileContent = await fetchFilePreview({
url: template.Repository.url,
file: template.Repository.stackfile,
});
const type = getCustomTemplateType(template.Type);
return {
type,
fileContent,
};
}
function getCustomTemplateType(type: TemplateType): StackType {
switch (type) {
case TemplateType.SwarmStack:
return StackType.DockerSwarm;
case TemplateType.ComposeStack:
return StackType.DockerCompose;
default:
throw new Error(`Unknown supported template type: ${type}`);
}
}
async function fetchFilePreview({ url, file }: { url: string; file: string }) {
try {
const { data } = await axios.post<{ FileContent: string }>(
'/templates/file',
{ repositoryUrl: url, composeFilePathInRepository: file }
);
return data.FileContent;
} catch (err) {
throw parseAxiosError(err);
}
}

View file

@ -1,22 +1,26 @@
import { useCallback, useMemo } from 'react';
import _ from 'lodash';
import { TemplateViewModel } from './view-model';
import { ListState, TemplateType } from './types';
import { ListState } from './types';
export function useSortAndFilterTemplates(
templates: Array<TemplateViewModel>,
listState: ListState & { search: string },
showSwarmStacks?: boolean
disabledTypes: Array<TemplateViewModel['Type']> = [],
fixedCategories: Array<string> = []
) {
const filterByCategory = useCallback(
(item: TemplateViewModel) => {
if (!listState.category) {
if (!listState.category && !fixedCategories.length) {
return true;
}
return item.Categories.includes(listState.category);
return _.compact([...fixedCategories, listState.category]).every(
(category) => item.Categories.includes(category)
);
},
[listState.category]
[fixedCategories, listState.category]
);
const filterBySearch = useCallback(
@ -37,29 +41,20 @@ export function useSortAndFilterTemplates(
const filterByTemplateType = useCallback(
(item: TemplateViewModel) => {
switch (item.Type) {
case TemplateType.Container:
return (
listState.type === TemplateType.Container || listState.type === null
);
case TemplateType.SwarmStack:
return (
showSwarmStacks &&
(listState.type === TemplateType.SwarmStack ||
listState.type === null)
);
case TemplateType.ComposeStack:
return (
listState.type === TemplateType.SwarmStack ||
listState.type === null
);
case TemplateType.EdgeStack:
return listState.type === TemplateType.EdgeStack;
default:
return false;
if (listState.types.length === 0 && disabledTypes.length === 0) {
return true;
}
if (listState.types.length === 0) {
return !disabledTypes.includes(item.Type);
}
return (
listState.types.includes(item.Type) &&
!disabledTypes.includes(item.Type)
);
},
[listState.type, showSwarmStacks]
[disabledTypes, listState.types]
);
const sort = useCallback(

View file

@ -12,7 +12,7 @@ import {
} from './types';
export class TemplateViewModel {
Id!: string;
Id!: number;
Title!: string;
@ -65,46 +65,56 @@ export class TemplateViewModel {
protocol: string;
}[];
constructor(data: AppTemplate, version: string) {
constructor(template: AppTemplate, version: string) {
switch (version) {
case '2':
this.setTemplatesV2(data);
setTemplatesV2.call(this, template);
break;
case '3':
setTemplatesV3.call(this, template);
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 setTemplatesV3(this: TemplateViewModel, template: AppTemplate) {
setTemplatesV2.call(this, template);
this.Id = template.id;
}
let templateV2ID = 0;
function setTemplatesV2(this: TemplateViewModel, template: AppTemplate) {
this.Id = templateV2ID++;
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) {

View file

@ -1,4 +1,5 @@
import { ReactNode } from 'react';
import { Rocket } from 'lucide-react';
import LinuxIcon from '@/assets/ico/linux.svg?c';
import MicrosoftIcon from '@/assets/ico/vendor/microsoft.svg?c';
@ -7,6 +8,7 @@ import KubernetesIcon from '@/assets/ico/vendor/kubernetes.svg?c';
import { Icon } from '@@/Icon';
import { FallbackImage } from '@@/FallbackImage';
import { BlocklistItem } from '@@/Blocklist/BlocklistItem';
import { BadgeIcon } from '@@/BadgeIcon';
import { Platform } from '../../custom-templates/types';
@ -38,9 +40,8 @@ export function TemplateItem({
<div className="vertical-center min-w-[56px] justify-center">
<FallbackImage
src={template.Logo}
fallbackIcon="rocket"
fallbackIcon={<BadgeIcon icon={Rocket} size="3xl" />}
className="blocklist-item-logo"
size="3xl"
/>
</div>
<div className="col-sm-12 flex justify-between flex-wrap">