From 8f660c02763442ae8983cf7082a5da37378f76f3 Mon Sep 17 00:00:00 2001 From: Andras Bacsai Date: Mon, 17 Oct 2022 15:43:57 +0200 Subject: [PATCH] work-work --- .../migration.sql | 13 ++ apps/api/prisma/schema.prisma | 13 ++ apps/api/src/lib/common.ts | 3 +- apps/api/src/lib/services/common.ts | 2 + apps/api/src/lib/services/handlers.ts | 27 +-- apps/api/src/lib/templates.ts | 114 ++++++++----- .../src/routes/api/v1/services/handlers.ts | 154 +++++++++++++++--- apps/api/src/routes/api/v1/services/index.ts | 1 + .../Services/PlausibleAnalytics.svelte | 4 +- .../src/routes/services/[id]/__layout.svelte | 3 +- .../services/[id]/configuration/type.svelte | 4 +- apps/ui/src/routes/services/[id]/index.svelte | 89 +++++++--- 12 files changed, 322 insertions(+), 105 deletions(-) create mode 100644 apps/api/prisma/migrations/20221017134342_standardized_service_configs/migration.sql diff --git a/apps/api/prisma/migrations/20221017134342_standardized_service_configs/migration.sql b/apps/api/prisma/migrations/20221017134342_standardized_service_configs/migration.sql new file mode 100644 index 000000000..837017435 --- /dev/null +++ b/apps/api/prisma/migrations/20221017134342_standardized_service_configs/migration.sql @@ -0,0 +1,13 @@ +-- CreateTable +CREATE TABLE "ServiceSetting" ( + "id" TEXT NOT NULL PRIMARY KEY, + "serviceId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "value" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "ServiceSetting_serviceId_fkey" FOREIGN KEY ("serviceId") REFERENCES "Service" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); + +-- CreateIndex +CREATE UNIQUE INDEX "ServiceSetting_serviceId_name_key" ON "ServiceSetting"("serviceId", "name"); diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index d782bceae..28e2d9a3b 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -398,6 +398,7 @@ model Service { destinationDocker DestinationDocker? @relation(fields: [destinationDockerId], references: [id]) persistentStorage ServicePersistentStorage[] serviceSecret ServiceSecret[] + serviceSetting ServiceSetting[] teams Team[] fider Fider? @@ -417,6 +418,18 @@ model Service { taiga Taiga? } +model ServiceSetting { + id String @id @default(cuid()) + serviceId String + name String + value String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + service Service @relation(fields: [serviceId], references: [id]) + + @@unique([serviceId, name]) +} + model PlausibleAnalytics { id String @id @default(cuid()) email String? diff --git a/apps/api/src/lib/common.ts b/apps/api/src/lib/common.ts index 32ed080dc..1bc1d13b3 100644 --- a/apps/api/src/lib/common.ts +++ b/apps/api/src/lib/common.ts @@ -1454,7 +1454,6 @@ export async function getServiceFromDB({ } let { type } = body; type = fixType(type); - if (body?.serviceSecret.length > 0) { body.serviceSecret = body.serviceSecret.map((s) => { s.value = decrypt(s.value); @@ -1462,7 +1461,7 @@ export async function getServiceFromDB({ }); } - body[type] = { ...body[type], ...getUpdateableFields(type, body[type]) }; + // body[type] = { ...body[type], ...getUpdateableFields(type, body[type]) }; return { ...body, settings }; } diff --git a/apps/api/src/lib/services/common.ts b/apps/api/src/lib/services/common.ts index 716dc44c0..08924cf81 100644 --- a/apps/api/src/lib/services/common.ts +++ b/apps/api/src/lib/services/common.ts @@ -6,6 +6,7 @@ export const includeServices: any = { destinationDocker: true, persistentStorage: true, serviceSecret: true, + serviceSetting: true, minio: true, plausibleAnalytics: true, vscodeserver: true, @@ -362,6 +363,7 @@ export async function configureServiceType({ export async function removeService({ id }: { id: string }): Promise { await prisma.serviceSecret.deleteMany({ where: { serviceId: id } }); + await prisma.serviceSetting.deleteMany({ where: { serviceId: id } }); await prisma.servicePersistentStorage.deleteMany({ where: { serviceId: id } }); await prisma.meiliSearch.deleteMany({ where: { serviceId: id } }); await prisma.fider.deleteMany({ where: { serviceId: id } }); diff --git a/apps/api/src/lib/services/handlers.ts b/apps/api/src/lib/services/handlers.ts index 408da0000..07079e5f1 100644 --- a/apps/api/src/lib/services/handlers.ts +++ b/apps/api/src/lib/services/handlers.ts @@ -7,6 +7,7 @@ import { asyncSleep, ComposeFile, createDirectories, defaultComposeConfiguration import { defaultServiceConfigurations } from '../services'; import { OnlyId } from '../../types'; import templates from '../templates' +import { parseAndFindServiceTemplates } from '../../routes/api/v1/services/handlers'; // export async function startService(request: FastifyRequest) { // try { @@ -691,36 +692,36 @@ export async function startService(request: FastifyRequest) { const teamId = request.user.teamId; const service = await getServiceFromDB({ id, teamId }); - const { type, version, destinationDockerId, destinationDocker, serviceSecret, exposePort, persistentStorage } = + const { type, version, destinationDockerId, destinationDocker, serviceSecret,serviceSetting, exposePort, persistentStorage } = service; - let template = templates.find((template) => template.name === type); - - template = JSON.parse(JSON.stringify(template).replaceAll('$$id', id).replaceAll('$$fqdn', service.fqdn)) - + const { workdir } = await createDirectories({ repository: type, buildId: id }); + const template: any = await parseAndFindServiceTemplates(service, workdir, true) const network = destinationDockerId && destinationDocker.network; const config = {}; for (const service in template.services) { + console.log(template.services[service]) config[service] = { - container_name: id, - image: template.services[service].image.replace('$$core_version', version), + container_name: service, + image: template.services[service].image, expose: template.services[service].ports, // ...(exposePort ? { ports: [`${exposePort}:${port}`] } : {}), volumes: template.services[service].volumes, - environment: {}, + environment: template.services[service].environment, depends_on: template.services[service].depends_on, ulimits: template.services[service].ulimits, labels: makeLabelForServices(type), ...defaultComposeConfiguration(network), } - if (serviceSecret.length > 0) { - serviceSecret.forEach((secret) => { - config[service].environment[secret.name] = secret.value; - }); + + // Generate files for builds + if (template.services[service].build) { + if (template.services[service]?.extras?.files?.length > 0) { + console.log(template.services[service]?.extras?.files) + } } } - const { workdir } = await createDirectories({ repository: type, buildId: id }); const { volumeMounts } = persistentVolumes(id, persistentStorage, config) const composeFile: ComposeFile = { diff --git a/apps/api/src/lib/templates.ts b/apps/api/src/lib/templates.ts index b0312dafd..f7493fdb7 100644 --- a/apps/api/src/lib/templates.ts +++ b/apps/api/src/lib/templates.ts @@ -4,10 +4,10 @@ export default [ "serviceDefaultVersion": "0.198.1", "name": "n8n", "displayName": "n8n.io", - "isOfficial": true, "description": "n8n is a free and open node based Workflow Automation Tool.", "services": { "$$id": { + "name": "N8n", "documentation": "Taken from https://hub.docker.com/r/n8nio/n8n", "depends_on": [], "image": "n8nio/n8n:$$core_version", @@ -17,24 +17,31 @@ export default [ "/var/run/docker.sock:/var/run/docker.sock" ], "environment": [ - "WEBHOOK_URL=$$fqdn" + "WEBHOOK_URL=$$config_webhook_url" ], "ports": [ "5678" ] } }, - "variables": [] + "variables": [ + { + "id": "$$config_webhook_url", + "name": "WEBHOOK_URL", + "label": "Webhook URL", + "defaultValue": "$$generate_fqdn", + "description": "", + }] }, { "templateVersion": "1.0.0", "serviceDefaultVersion": "stable", "name": "plausibleanalytics", "displayName": "PlausibleAnalytics", - "isOfficial": true, "description": "Plausible is a lightweight and open-source website analytics tool.", "services": { "$$id": { + "name": "Plausible Analytics", "documentation": "Taken from https://plausible.io/", "command": ['sh -c "sleep 10 && /entrypoint.sh db createdb && /entrypoint.sh db migrate && /entrypoint.sh db init-admin && /entrypoint.sh run"'], "depends_on": [ @@ -43,31 +50,33 @@ export default [ ], "image": "plausible/analytics:$$core_version", "environment": [ - "ADMIN_USER_EMAIL=$$secret_email", - "ADMIN_USER_NAME=$$secret_name", - "ADMIN_USER_PASSWORD=$$secret_password", - "BASE_URL=$$fqdn", - "SECRET_KEY_BASE=$$secret_key_base", - "DISABLE_AUTH=$$secret_disable_auth", - "DISABLE_REGISTRATION=$$secret_disable_registration", - "DATABASE_URL=postgresql://$$secret_postgresql_username:$$secret_postgresql_password@$$id-postgresql:5432/$$secret_postgresql_database", - "CLICKHOUSE_DATABASE_URL=http://$$id-clickhouse:8123/plausible", + "ADMIN_USER_EMAIL=$$config_admin_user_email", + "ADMIN_USER_NAME=$$config_admin_user_name", + "ADMIN_USER_PASSWORD=$$secret_admin_user_password", + "BASE_URL=$$config_base_url", + "SECRET_KEY_BASE=$$secret_secret_key_base", + "DISABLE_AUTH=$$config_disable_auth", + "DISABLE_REGISTRATION=$$config_disable_registration", + "DATABASE_URL=$$secret_database_url", + "CLICKHOUSE_DATABASE_URL=$$secret_clickhouse_database_url", ], "ports": [ "8000" ], }, "$$id-postgresql": { + "name": "PostgreSQL", "documentation": "Taken from https://plausible.io/", "image": "bitnami/postgresql:13.2.0", "environment": [ "POSTGRESQL_PASSWORD=$$secret_postgresql_password", - "POSTGRESQL_USERNAME=$$secret_postgresql_username", - "POSTGRESQL_DATABASE=$$secret_postgresql_database", + "POSTGRESQL_USERNAME=$$config_postgresql_username", + "POSTGRESQL_DATABASE=$$config_postgresql_database", ], }, "$$id-clickhouse": { + "name": "Clickhouse", "documentation": "Taken from https://plausible.io/", "build": "$$workdir", "image": "yandex/clickhouse-server:21.3.2.5", @@ -102,69 +111,98 @@ export default [ }, "variables": [ { - "id": "$$secret_email", - "label": "Admin Email", + "id": "$$config_base_url", + "name": "BASE_URL", + "label": "Base URL", + "defaultValue": "$$generate_fqdn", + "description": "You must set this to the FQDN of the Plausible Analytics instance. This is used to generate the links to the Plausible Analytics instance.", + }, + { + "id": "$$secret_database_url", + "name": "DATABASE_URL", + "label": "Database URL for PostgreSQL", + "defaultValue": "postgresql://$$config_postgresql_username:$$secret_postgresql_password@$$id-postgresql:5432/$$config_postgresql_database", + "description": "", + }, + { + "id": "$$secret_clickhouse_database_url", + "name": "CLICKHOUSE_DATABASE_URL", + "label": "Database URL for Clickhouse", + "defaultValue": "http://$$id-clickhouse:8123/plausible", + "description": "", + }, + { + "id": "$$config_admin_user_email", + "name": "ADMIN_USER_EMAIL", + "label": "Admin Email Address", "defaultValue": "admin@example.com", "description": "This is the admin email. Please change it.", - "validRegex": /^([^\s^\/])+$/ }, { - "id": "$$secret_name", - "label": "Admin Name", + "id": "$$config_admin_user_name", + "name": "ADMIN_USER_NAME", + "label": "Admin User Name", "defaultValue": "$$generate_username", "description": "This is the admin username. Please change it.", - "validRegex": /^([^\s^\/])+$/ }, { - "id": "$$secret_password", - "label": "Admin Password", - "defaultValue":"$$generate_password", + "id": "$$secret_admin_user_password", + "name": "ADMIN_USER_PASSWORD", + "showAsConfiguration": true, + "label": "Admin User Password", + "defaultValue": "$$generate_password", "description": "This is the admin password. Please change it.", - "validRegex": /^([^\s^\/])+$/ }, { "id": "$$secret_secret_key_base", + "name": "SECRET_KEY_BASE", "label": "Secret Key Base", - "defaultValue":"$$generate_passphrase", + "defaultValue": "$$generate_passphrase", "description": "", - "validRegex": /^([^\s^\/])+$/ }, { - "id": "$$secret_disable_auth", - "label": "Disable Auth", + "id": "$$config_disable_auth", + "name": "DISABLE_AUTH", + "label": "Disable Authentication", "defaultValue": "false", "description": "", - "validRegex": /^([^\s^\/])+$/ }, { - "id": "$$secret_disable_registration", + "id": "$$config_disable_registration", + "name": "DISABLE_REGISTRATION", "label": "Disable Registration", "defaultValue": "true", "description": "", - "validRegex": /^([^\s^\/])+$/ }, { - "id": "$$secret_postgresql_username", + "id": "$$config_postgresql_username", + "name": "POSTGRESQL_USERNAME", "label": "PostgreSQL Username", "defaultValue": "postgresql", "description": "", - "validRegex": /^([^\s^\/])+$/ }, { "id": "$$secret_postgresql_password", + "name": "POSTGRESQL_PASSWORD", "label": "PostgreSQL Password", "defaultValue": "$$generate_password", "description": "", - "validRegex": /^([^\s^\/])+$/ } , { - "id": "$$secret_postgresql_database", + "id": "$$config_postgresql_database", + "name": "POSTGRESQL_DATABASE", "label": "PostgreSQL Database", "defaultValue": "plausible", "description": "", - "validRegex": /^([^\s^\/])+$/ - } + }, + { + "id": "$$config_scriptName", + "name": "SCRIPT_NAME", + "label": "Custom Script Name", + "defaultValue": "plausible.js", + "description": "This is the default script name.", + }, ] } ] diff --git a/apps/api/src/routes/api/v1/services/handlers.ts b/apps/api/src/routes/api/v1/services/handlers.ts index 10f4f40fd..0dc3a8cb2 100644 --- a/apps/api/src/routes/api/v1/services/handlers.ts +++ b/apps/api/src/routes/api/v1/services/handlers.ts @@ -114,13 +114,70 @@ export async function getServiceStatus(request: FastifyRequest) { return errorHandler({ status, message }) } } -function parseAndFindServiceTemplates(service: any) { +export async function parseAndFindServiceTemplates(service: any, workdir?: string, isDeploy: boolean = false) { const foundTemplate = templates.find(t => t.name === service.type) + let parsedTemplate = {} if (foundTemplate) { - return JSON.parse(JSON.stringify(foundTemplate).replaceAll('$$id', service.id).replaceAll('$$fqdn', service.fqdn)) + if (!isDeploy) { + for (const [key, value] of Object.entries(foundTemplate.services)) { + const realKey = key.replace('$$id', service.id) + parsedTemplate[realKey] = { + name: value.name, + image: value.image, + environment: [] + } + if (value.environment?.length > 0) { + for (const env of value.environment) { + const [envKey, envValue] = env.split('=') + const label = foundTemplate.variables.find(v => v.name === envKey)?.label + const description = foundTemplate.variables.find(v => v.name === envKey)?.description + const defaultValue = foundTemplate.variables.find(v => v.name === envKey)?.defaultValue + const showAsConfiguration = foundTemplate.variables.find(v => v.name === envKey)?.showAsConfiguration + if (envValue.startsWith('$$config') || showAsConfiguration) { + parsedTemplate[realKey].environment.push( + { name: envKey, value: envValue, label, description, defaultValue } + ) + } + + } + } + } + } else { + parsedTemplate = foundTemplate + } + // replace $$id and $$workdir + parsedTemplate = JSON.parse(JSON.stringify(parsedTemplate).replaceAll('$$id', service.id).replaceAll('$$core_version', foundTemplate.serviceDefaultVersion)) + + // replace $$fqdn + if (workdir) { + parsedTemplate = JSON.parse(JSON.stringify(parsedTemplate).replaceAll('$$workdir', workdir)) + } + + // replace $$config + if (service.serviceSetting.length > 0) { + for (const setting of service.serviceSetting) { + const { name, value } = setting + if (service.fqdn && value === '$$generate_fqdn') { + parsedTemplate = JSON.parse(JSON.stringify(parsedTemplate).replaceAll(`$$config_${name.toLowerCase()}`, service.fqdn)) + } else { + parsedTemplate = JSON.parse(JSON.stringify(parsedTemplate).replaceAll(`$$config_${name.toLowerCase()}`, value)) + + } + } + } + + // replace $$secret + if (service.serviceSecret.length > 0) { + for (const secret of service.serviceSecret) { + const { name, value } = secret + parsedTemplate = JSON.parse(JSON.stringify(parsedTemplate).replaceAll(`$$secret_${name.toLowerCase()}`, value)) + } + } } + return parsedTemplate } + export async function getService(request: FastifyRequest) { try { const teamId = request.user.teamId; @@ -129,10 +186,11 @@ export async function getService(request: FastifyRequest) { if (!service) { throw { status: 404, message: 'Service not found.' } } + const template = await parseAndFindServiceTemplates(service) return { settings: await listSettings(), service, - template: parseAndFindServiceTemplates(service) + template, } } catch ({ status, message }) { return errorHandler({ status, message }) @@ -150,30 +208,69 @@ export async function getServiceType(request: FastifyRequest) { export async function saveServiceType(request: FastifyRequest, reply: FastifyReply) { try { const { id } = request.params; - const { name, variables = [], serviceDefaultVersion = 'latest' } = request.body; - if (variables.length > 0) { - for (const variable of variables) { - const { id: variableId, defaultValue, value = null } = variable; - if (variableId.startsWith('$$secret_')) { - const secretName = variableId.replace('$$secret_', ''); - let secretValue = defaultValue || value || null; - if (defaultValue === '$$generate_password') { - secretValue = generatePassword({}); + const { type } = request.body; + let foundTemplate = templates.find(t => t.name === type) + if (foundTemplate) { + let generatedVariables = new Set() + let missingVariables = new Set() + + foundTemplate = JSON.parse(JSON.stringify(foundTemplate).replaceAll('$$id', id)) + + if (foundTemplate.variables.length > 0) { + foundTemplate.variables = foundTemplate.variables.map(variable => { + let { id: variableId } = variable; + if (variableId.startsWith('$$secret_')) { + if (variable.defaultValue === '$$generate_password') { + variable.value = generatePassword({}); + } else if (variable.defaultValue === '$$generate_passphrase') { + variable.value = cuid(); + } } - if (defaultValue === '$$generate_username') { - secretValue = cuid(); + if (variableId.startsWith('$$config_')) { + if (variable.defaultValue === '$$generate_username') { + variable.value = cuid(); + } else { + variable.value = variable.defaultValue + } } - if (defaultValue === '$$generate_passphrase') { - secretValue = cuid(); + if (variable.value) { + generatedVariables.add(`${variableId}=${variable.value}`) + } else { + missingVariables.add(variableId) } - await prisma.serviceSecret.create({ - data: { name: secretName, value: encrypt(secretValue), service: { connect: { id } } } + return variable + }) + if (missingVariables.size > 0) { + foundTemplate.variables = foundTemplate.variables.map(variable => { + if (missingVariables.has(variable.id)) { + variable.value = variable.defaultValue + for (const generatedVariable of generatedVariables) { + let [id, value] = generatedVariable.split('=') + variable.value = variable.value.replaceAll(id, value) + } + } + return variable }) } + for (const variable of foundTemplate.variables) { + if (variable.id.startsWith('$$secret_')) { + await prisma.serviceSecret.create({ + data: { name: variable.name, value: encrypt(variable.value), service: { connect: { id } } } + }) + } + if (variable.id.startsWith('$$config_')) { + await prisma.serviceSetting.create({ + data: { name: variable.name, value: variable.value, service: { connect: { id } } } + }) + } + } } + await prisma.service.update({ where: { id }, data: { type, version: foundTemplate.serviceDefaultVersion } }) + return reply.code(201).send() + } else { + throw { status: 404, message: 'Service type not found.' } } - await prisma.service.update({ where: { id }, data: { type: name, version: serviceDefaultVersion } }) - return reply.code(201).send() + } catch ({ status, message }) { return errorHandler({ status, message }) } @@ -344,23 +441,30 @@ export async function checkService(request: FastifyRequest) { export async function saveService(request: FastifyRequest, reply: FastifyReply) { try { const { id } = request.params; - let { name, fqdn, exposePort, type } = request.body; - + let { name, fqdn, exposePort, type, serviceSetting } = request.body; if (fqdn) fqdn = fqdn.toLowerCase(); if (exposePort) exposePort = Number(exposePort); type = fixType(type) - const update = saveUpdateableFields(type, request.body[type]) + // const update = saveUpdateableFields(type, request.body[type]) const data = { fqdn, name, exposePort, } - if (Object.keys(update).length > 0) { - data[type] = { update: update } + // if (Object.keys(update).length > 0) { + // data[type] = { update: update } + // } + for (const setting of serviceSetting) { + const { id: settingId, value, changed = false } = setting + if (setting.changed) { + await prisma.serviceSetting.update({ where: { id: settingId }, data: { value } }) + + } } await prisma.service.update({ where: { id }, data + }); return reply.code(201).send() } catch ({ status, message }) { diff --git a/apps/api/src/routes/api/v1/services/index.ts b/apps/api/src/routes/api/v1/services/index.ts index bd0311b4d..546c7fd5c 100644 --- a/apps/api/src/routes/api/v1/services/index.ts +++ b/apps/api/src/routes/api/v1/services/index.ts @@ -72,6 +72,7 @@ const root: FastifyPluginAsync = async (fastify): Promise => { fastify.get('/:id/usage', async (request) => await getServiceUsage(request)); fastify.get('/:id/logs', async (request) => await getServiceLogs(request)); + fastify.post('/:id/start', async (request) => await startService(request)); fastify.post('/:id/:type/start', async (request) => await startService(request)); fastify.post('/:id/:type/stop', async (request) => await stopService(request)); fastify.post('/:id/:type/settings', async (request, reply) => await setSettingsService(request, reply)); diff --git a/apps/ui/src/lib/components/Services/PlausibleAnalytics.svelte b/apps/ui/src/lib/components/Services/PlausibleAnalytics.svelte index f8ddbbd88..013db6fcb 100644 --- a/apps/ui/src/lib/components/Services/PlausibleAnalytics.svelte +++ b/apps/ui/src/lib/components/Services/PlausibleAnalytics.svelte @@ -23,7 +23,7 @@ } -
+ \ No newline at end of file diff --git a/apps/ui/src/routes/services/[id]/__layout.svelte b/apps/ui/src/routes/services/[id]/__layout.svelte index 870248969..b44b096ac 100644 --- a/apps/ui/src/routes/services/[id]/__layout.svelte +++ b/apps/ui/src/routes/services/[id]/__layout.svelte @@ -41,6 +41,7 @@ }, stuff: { service, + template, readOnly, settings } @@ -111,7 +112,7 @@ $status.service.initialLoading = true; $status.service.loading = true; try { - await post(`/services/${service.id}/${service.type}/start`, {}); + await post(`/services/${service.id}/start`, {}); } catch (error) { return errorNotification(error); } finally { diff --git a/apps/ui/src/routes/services/[id]/configuration/type.svelte b/apps/ui/src/routes/services/[id]/configuration/type.svelte index eb5831702..0e61a8c91 100644 --- a/apps/ui/src/routes/services/[id]/configuration/type.svelte +++ b/apps/ui/src/routes/services/[id]/configuration/type.svelte @@ -26,7 +26,7 @@
-
handleSubmit()}> +
General
@@ -213,7 +223,7 @@ : $t('forms.save')} {/if} - {#if service.type === 'plausibleanalytics' && $status.service.isRunning} + {#if service.type === 'appwrite' && $status.service.isRunning}
- +
+
+ {#each Object.keys(template) as oneService} +
+
{template[oneService].name}
+ +
+ +
+ {#if template[oneService].environment.length > 0} + {#each template[oneService].environment as variable} +
+ + {#if variable.defaultValue === '$$generate_fqdn'} + + {:else} + + {/if} +
+ {/each} + {/if} +
+ {/each} +