feat: docker compose support

This commit is contained in:
Andras Bacsai
2022-10-06 10:25:41 +02:00
parent d8206c0e3e
commit d27426fd8f
5 changed files with 263 additions and 68 deletions

View File

@@ -110,23 +110,64 @@ export async function getApplicationStatus(request: FastifyRequest<OnlyId>) {
try { try {
const { id } = request.params const { id } = request.params
const { teamId } = request.user const { teamId } = request.user
let isRunning = false; let payload = []
let isExited = false;
let isRestarting = false;
const application: any = await getApplicationFromDB(id, teamId); const application: any = await getApplicationFromDB(id, teamId);
if (application?.destinationDockerId) { if (application?.destinationDockerId) {
const status = await checkContainer({ dockerId: application.destinationDocker.id, container: id }); if (application.buildPack === 'compose') {
if (status?.found) { const { stdout: containers } = await executeDockerCmd({
isRunning = status.status.isRunning; dockerId: application.destinationDocker.id,
isExited = status.status.isExited; command:
isRestarting = status.status.isRestarting `docker ps -a --filter "label=coolify.applicationId=${id}" --format '{{json .}}'`
});
const containersArray = containers.trim().split('\n');
if (containersArray.length > 0 && containersArray[0] !== '') {
for (const container of containersArray) {
let isRunning = false;
let isExited = false;
let isRestarting = false;
const containerObj = JSON.parse(container);
const status = containerObj.State
if (status === 'running') {
isRunning = true;
}
if (status === 'exited') {
isExited = true;
}
if (status === 'restarting') {
isRestarting = true;
}
payload.push({
name: containerObj.Names,
status: {
isRunning,
isExited,
isRestarting
}
})
}
}
} else {
let isRunning = false;
let isExited = false;
let isRestarting = false;
const status = await checkContainer({ dockerId: application.destinationDocker.id, container: id });
if (status?.found) {
isRunning = status.status.isRunning;
isExited = status.status.isExited;
isRestarting = status.status.isRestarting
payload.push({
name: id,
status: {
isRunning,
isExited,
isRestarting
}
})
}
} }
} }
return { return payload
isRunning,
isRestarting,
isExited,
};
} catch ({ status, message }) { } catch ({ status, message }) {
return errorHandler({ status, message }) return errorHandler({ status, message })
} }
@@ -294,7 +335,6 @@ export async function saveApplication(request: FastifyRequest<SaveApplication>,
dockerComposeFileLocation, dockerComposeFileLocation,
dockerComposeConfiguration dockerComposeConfiguration
} = request.body } = request.body
console.log({dockerComposeConfiguration})
if (port) port = Number(port); if (port) port = Number(port);
if (exposePort) { if (exposePort) {
exposePort = Number(exposePort); exposePort = Number(exposePort);
@@ -515,6 +555,21 @@ export async function stopApplication(request: FastifyRequest<OnlyId>, reply: Fa
const application: any = await getApplicationFromDB(id, teamId); const application: any = await getApplicationFromDB(id, teamId);
if (application?.destinationDockerId) { if (application?.destinationDockerId) {
const { id: dockerId } = application.destinationDocker; const { id: dockerId } = application.destinationDocker;
if (application.buildPack === 'compose') {
const { stdout: containers } = await executeDockerCmd({
dockerId: application.destinationDocker.id,
command:
`docker ps -a --filter "label=coolify.applicationId=${id}" --format '{{json .}}'`
});
const containersArray = containers.trim().split('\n');
if (containersArray.length > 0 && containersArray[0] !== '') {
for (const container of containersArray) {
const containerObj = JSON.parse(container);
await removeContainer({ id: containerObj.ID, dockerId: application.destinationDocker.id });
}
}
return
}
const { found } = await checkContainer({ dockerId, container: id }); const { found } = await checkContainer({ dockerId, container: id });
if (found) { if (found) {
await removeContainer({ id, dockerId: application.destinationDocker.id }); await removeContainer({ id, dockerId: application.destinationDocker.id });

View File

@@ -234,6 +234,8 @@ export async function traefikConfiguration(request, reply) {
fqdn, fqdn,
id, id,
port, port,
buildPack,
dockerComposeConfiguration,
destinationDocker, destinationDocker,
destinationDockerId, destinationDockerId,
settings: { previews, dualCerts, isCustomSSL } settings: { previews, dualCerts, isCustomSSL }
@@ -241,6 +243,33 @@ export async function traefikConfiguration(request, reply) {
if (destinationDockerId) { if (destinationDockerId) {
const { network, id: dockerId } = destinationDocker; const { network, id: dockerId } = destinationDocker;
const isRunning = true; const isRunning = true;
if (buildPack === 'compose') {
const services = Object.entries(JSON.parse(dockerComposeConfiguration))
for (const service of services) {
const [key, value] = service
const { port: customPort, fqdn } = value
if (fqdn) {
const domain = getDomain(fqdn);
const nakedDomain = domain.replace(/^www\./, '');
const isHttps = fqdn.startsWith('https://');
const isWWW = fqdn.includes('www.');
data.applications.push({
id: `${id}-${key}`,
container: `${id}-${key}`,
port: customPort ? customPort : port || 3000,
domain,
nakedDomain,
isRunning,
isHttps,
isWWW,
isDualCerts: dualCerts,
isCustomSSL
});
}
}
continue;
}
if (fqdn) { if (fqdn) {
const domain = getDomain(fqdn); const domain = getDomain(fqdn);
const nakedDomain = domain.replace(/^www\./, ''); const nakedDomain = domain.replace(/^www\./, '');
@@ -604,13 +633,41 @@ export async function remoteTraefikConfiguration(request: FastifyRequest<OnlyId>
fqdn, fqdn,
id, id,
port, port,
buildPack,
dockerComposeConfiguration,
destinationDocker, destinationDocker,
destinationDockerId, destinationDockerId,
settings: { previews, dualCerts } settings: { previews, dualCerts, isCustomSSL }
} = application; } = application;
if (destinationDockerId) { if (destinationDockerId) {
const { id: dockerId, network } = destinationDocker; const { id: dockerId, network } = destinationDocker;
const isRunning = true; const isRunning = true;
if (buildPack === 'compose') {
const services = Object.entries(JSON.parse(dockerComposeConfiguration))
for (const service of services) {
const [key, value] = service
const { port: customPort, fqdn } = value
if (fqdn) {
const domain = getDomain(fqdn);
const nakedDomain = domain.replace(/^www\./, '');
const isHttps = fqdn.startsWith('https://');
const isWWW = fqdn.includes('www.');
data.applications.push({
id: `${id}-${key}`,
container: `${id}-${key}`,
port: customPort ? customPort : port || 3000,
domain,
nakedDomain,
isRunning,
isHttps,
isWWW,
isDualCerts: dualCerts,
isCustomSSL
});
}
}
continue;
}
if (fqdn) { if (fqdn) {
const domain = getDomain(fqdn); const domain = getDomain(fqdn);
const nakedDomain = domain.replace(/^www\./, ''); const nakedDomain = domain.replace(/^www\./, '');
@@ -626,7 +683,8 @@ export async function remoteTraefikConfiguration(request: FastifyRequest<OnlyId>
isRunning, isRunning,
isHttps, isHttps,
isWWW, isWWW,
isDualCerts: dualCerts isDualCerts: dualCerts,
isCustomSSL
}); });
} }
if (previews) { if (previews) {
@@ -649,7 +707,8 @@ export async function remoteTraefikConfiguration(request: FastifyRequest<OnlyId>
nakedDomain, nakedDomain,
isHttps, isHttps,
isWWW, isWWW,
isDualCerts: dualCerts isDualCerts: dualCerts,
isCustomSSL
}); });
} }
} }

View File

@@ -56,6 +56,7 @@ export const isDeploymentEnabled: Writable<boolean> = writable(false);
export function checkIfDeploymentEnabledApplications(isAdmin: boolean, application: any) { export function checkIfDeploymentEnabledApplications(isAdmin: boolean, application: any) {
return ( return (
isAdmin && isAdmin &&
(application.buildPack === 'compose') ||
(application.fqdn || application.settings.isBot) && (application.fqdn || application.settings.isBot) &&
application.gitSource && application.gitSource &&
application.repository && application.repository &&
@@ -74,9 +75,8 @@ export function checkIfDeploymentEnabledServices(isAdmin: boolean, service: any)
} }
export const status: Writable<any> = writable({ export const status: Writable<any> = writable({
application: { application: {
isRunning: false, statuses: [],
isExited: false, overallStatus: 'degraded',
isRestarting: false,
loading: false, loading: false,
initialLoading: true initialLoading: true
}, },

View File

@@ -59,7 +59,6 @@
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { onDestroy, onMount } from 'svelte'; import { onDestroy, onMount } from 'svelte';
import { t } from '$lib/translations'; import { t } from '$lib/translations';
import DeleteIcon from '$lib/components/DeleteIcon.svelte';
import { import {
appSession, appSession,
status, status,
@@ -140,13 +139,11 @@
async function stopApplication() { async function stopApplication() {
try { try {
$status.application.initialLoading = true; $status.application.initialLoading = true;
// $status.application.loading = true;
await post(`/applications/${id}/stop`, {}); await post(`/applications/${id}/stop`, {});
} catch (error) { } catch (error) {
return errorNotification(error); return errorNotification(error);
} finally { } finally {
$status.application.initialLoading = false; $status.application.initialLoading = false;
// $status.application.loading = false;
await getStatus(); await getStatus();
} }
} }
@@ -154,18 +151,45 @@
if ($status.application.loading) return; if ($status.application.loading) return;
$status.application.loading = true; $status.application.loading = true;
const data = await get(`/applications/${id}/status`); const data = await get(`/applications/${id}/status`);
$status.application.isRunning = data.isRunning;
$status.application.isExited = data.isExited; $status.application.statuses = data;
$status.application.isRestarting = data.isRestarting; const numberOfApplications =
application.buildPack === 'compose'
? Object.entries(JSON.parse(application.dockerComposeConfiguration)).length
: 1;
if ($status.application.statuses.length === 0) {
$status.application.overallStatus = 'stopped';
} else {
if ($status.application.statuses.length !== numberOfApplications) {
$status.application.overallStatus = 'degraded';
} else {
for (const oneStatus of $status.application.statuses) {
if (oneStatus.status.isExited || oneStatus.status.isRestarting) {
$status.application.overallStatus = 'degraded';
break;
}
if (oneStatus.status.isRunning) {
$status.application.overallStatus = 'healthy';
}
if (
!oneStatus.status.isExited &&
!oneStatus.status.isRestarting &&
!oneStatus.status.isRunning
) {
$status.application.overallStatus = 'stopped';
}
}
}
}
$status.application.loading = false; $status.application.loading = false;
$status.application.initialLoading = false; $status.application.initialLoading = false;
} }
onDestroy(() => { onDestroy(() => {
$status.application.initialLoading = true; $status.application.initialLoading = true;
$status.application.isRunning = false; // $status.application.isRunning = false;
$status.application.isExited = false; // $status.application.isExited = false;
$status.application.isRestarting = false; // $status.application.isRestarting = false;
$status.application.loading = false; $status.application.loading = false;
$location = null; $location = null;
$isDeploymentEnabled = false; $isDeploymentEnabled = false;
@@ -173,15 +197,11 @@
}); });
onMount(async () => { onMount(async () => {
setLocation(application, settings); setLocation(application, settings);
$status.application.isRunning = false; // $status.application.isRunning = false;
$status.application.isExited = false; // $status.application.isExited = false;
$status.application.isRestarting = false; // $status.application.isRestarting = false;
$status.application.loading = false; $status.application.loading = false;
if ( if ($isDeploymentEnabled) {
application.gitSourceId &&
application.destinationDockerId &&
(application.fqdn || application.settings.isBot)
) {
await getStatus(); await getStatus();
statusInterval = setInterval(async () => { statusInterval = setInterval(async () => {
await getStatus(); await getStatus();
@@ -208,10 +228,15 @@
<div>Configurations</div> <div>Configurations</div>
<div <div
class="badge rounded uppercase" class="badge rounded uppercase"
class:text-green-500={$status.application.isRunning} class:text-green-500={$status.application.overallStatus === 'healthy'}
class:text-red-500={!$status.application.isRunning} class:text-yellow-400={$status.application.overallStatus === 'degraded'}
class:text-red-500={$status.application.overallStatus === 'stopped'}
> >
{$status.application.isRunning ? 'Running' : 'Stopped'} {$status.application.overallStatus === 'healthy'
? 'Running'
: $status.application.overallStatus === 'degraded'
? 'Degraded'
: 'Stopped'}
</div> </div>
</div> </div>
{/if} {/if}
@@ -245,7 +270,7 @@
<div <div
class="pt-4 flex flex-row items-start justify-center lg:justify-end space-x-2 order-1 lg:order-2" class="pt-4 flex flex-row items-start justify-center lg:justify-end space-x-2 order-1 lg:order-2"
> >
{#if $status.application.isExited || $status.application.isRestarting} {#if $status.application.overallStatus === 'degraded' && application.buildPack !== 'compose'}
<a <a
id="applicationerror" id="applicationerror"
href={$isDeploymentEnabled ? `/applications/${id}/logs` : null} href={$isDeploymentEnabled ? `/applications/${id}/logs` : null}
@@ -293,7 +318,7 @@
<line x1="11" y1="19.94" x2="11" y2="19.95" /> <line x1="11" y1="19.94" x2="11" y2="19.95" />
</svg> </svg>
</button> </button>
{:else if $status.application.isRunning} {:else if $status.application.overallStatus === 'healthy'}
<button <button
id="stop" id="stop"
on:click={stopApplication} on:click={stopApplication}
@@ -385,11 +410,11 @@
<path stroke="none" d="M0 0h24v24H0z" fill="none" /> <path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M7 4v16l13 -8z" /> <path d="M7 4v16l13 -8z" />
</svg> </svg>
Deploy {$status.application.overallStatus === 'degraded' ? 'Restart Degraded Services' : 'Deploy'}
</button> </button>
{/if} {/if}
{#if $location && $status.application.isRunning} {#if $location && $status.application.overallStatus === 'healthy'}
<a id="openApplication" href={$location} target="_blank" class="icons bg-transparent " <a id="openApplication" href={$location} target="_blank" class="icons bg-transparent "
><svg ><svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"

View File

@@ -28,6 +28,8 @@
<script lang="ts"> <script lang="ts">
export let application: any; export let application: any;
export let settings: any; export let settings: any;
import yaml from 'js-yaml';
import { page } from '$app/stores'; import { page } from '$app/stores';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import Select from 'svelte-select'; import Select from 'svelte-select';
@@ -47,13 +49,16 @@
import Setting from '$lib/components/Setting.svelte'; import Setting from '$lib/components/Setting.svelte';
import Explainer from '$lib/components/Explainer.svelte'; import Explainer from '$lib/components/Explainer.svelte';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import yaml from 'js-yaml';
const { id } = $page.params; const { id } = $page.params;
$: isDisabled = $: isDisabled =
!$appSession.isAdmin || $status.application.isRunning || $status.application.initialLoading; !$appSession.isAdmin ||
$status.application.overallStatus === 'degraded' ||
$status.application.overallStatus === 'healthy' ||
$status.application.initialLoading;
let statues: any = {};
let loading = false; let loading = false;
let fqdnEl: any = null; let fqdnEl: any = null;
let forceSave = false; let forceSave = false;
@@ -176,7 +181,7 @@
isCustomSSL = !isCustomSSL; isCustomSSL = !isCustomSSL;
} }
if (name === 'isBot') { if (name === 'isBot') {
if ($status.application.isRunning) return; if ($status.application.overallStatus !== 'stopped') return;
isBot = !isBot; isBot = !isBot;
application.settings.isBot = isBot; application.settings.isBot = isBot;
application.fqdn = null; application.fqdn = null;
@@ -228,9 +233,9 @@
$isDeploymentEnabled = checkIfDeploymentEnabledApplications($appSession.isAdmin, application); $isDeploymentEnabled = checkIfDeploymentEnabledApplications($appSession.isAdmin, application);
} }
} }
async function handleSubmit() { async function handleSubmit(toast: boolean = true) {
if (loading) return; if (loading) return;
loading = true; if (toast) loading = true;
try { try {
nonWWWDomain = application.fqdn && getDomain(application.fqdn).replace(/^www\./, ''); nonWWWDomain = application.fqdn && getDomain(application.fqdn).replace(/^www\./, '');
if (application.deploymentType) if (application.deploymentType)
@@ -252,7 +257,7 @@
forceSave = false; forceSave = false;
addToast({ toast && addToast({
message: 'Configuration saved.', message: 'Configuration saved.',
type: 'success' type: 'success'
}); });
@@ -333,7 +338,7 @@
let dockerComposeFileContentJSON = JSON.parse(dockerComposeFileContent); let dockerComposeFileContentJSON = JSON.parse(dockerComposeFileContent);
dockerComposeServices = normalizeDockerServices(dockerComposeFileContentJSON?.services); dockerComposeServices = normalizeDockerServices(dockerComposeFileContentJSON?.services);
application.dockerComposeFile = dockerComposeFileContent; application.dockerComposeFile = dockerComposeFileContent;
await handleSubmit(); await handleSubmit(false);
} }
addToast({ addToast({
message: 'Compose file reloaded.', message: 'Compose file reloaded.',
@@ -343,6 +348,30 @@
errorNotification(error); errorNotification(error);
} }
} }
$: if ($status.application.statuses) {
for (const service of dockerComposeServices) {
getStatus(service);
}
}
function getStatus(service: any) {
let foundStatus = null;
const foundService = $status.application.statuses.find(
(s: any) => s.name === `${application.id}-${service.name}`
);
if (foundService) {
const statusText = foundService?.status;
if (statusText?.isRunning) {
foundStatus = 'Running';
}
if (statusText?.isExited) {
foundStatus = 'Exited';
}
if (statusText?.isRestarting) {
foundStatus = 'Restarting';
}
}
statues[service.name] = foundStatus || 'Stopped';
}
</script> </script>
<div class="w-full"> <div class="w-full">
@@ -443,7 +472,7 @@
on:click={() => changeSettings('isBot')} on:click={() => changeSettings('isBot')}
title="Is your application a bot?" title="Is your application a bot?"
description="You can deploy applications without domains or make them to listen on the <span class='text-settings font-bold'>Exposed Port</span>.<br></Setting><br>Useful to host <span class='text-settings font-bold'>Twitch bots, regular jobs, or anything that does not require an incoming HTTP connection.</span>" description="You can deploy applications without domains or make them to listen on the <span class='text-settings font-bold'>Exposed Port</span>.<br></Setting><br>Useful to host <span class='text-settings font-bold'>Twitch bots, regular jobs, or anything that does not require an incoming HTTP connection.</span>"
disabled={$status.application.isRunning} disabled={isDisabled}
/> />
</div> </div>
{/if} {/if}
@@ -510,12 +539,12 @@
<Setting <Setting
id="dualCerts" id="dualCerts"
dataTooltip={$t('forms.must_be_stopped_to_modify')} dataTooltip={$t('forms.must_be_stopped_to_modify')}
disabled={$status.application.isRunning} disabled={isDisabled}
isCenter={false} isCenter={false}
bind:setting={dualCerts} bind:setting={dualCerts}
title={$t('application.ssl_www_and_non_www')} title={$t('application.ssl_www_and_non_www')}
description="Generate certificates for both www and non-www. <br>You need to have <span class='font-bold text-settings'>both DNS entries</span> set in advance.<br><br>Useful if you expect to have visitors on both." description="Generate certificates for both www and non-www. <br>You need to have <span class='font-bold text-settings'>both DNS entries</span> set in advance.<br><br>Useful if you expect to have visitors on both."
on:click={() => !$status.application.isRunning && changeSettings('dualCerts')} on:click={() => !isDisabled && changeSettings('dualCerts')}
/> />
</div> </div>
{#if isHttps && application.buildPack !== 'compose'} {#if isHttps && application.buildPack !== 'compose'}
@@ -552,7 +581,7 @@
{isDisabled} {isDisabled}
containerClasses={isDisabled && containerClass()} containerClasses={isDisabled && containerClass()}
id="baseBuildImages" id="baseBuildImages"
showIndicator={!$status.application.isRunning} showIndicator={!isDisabled}
items={application.baseBuildImages} items={application.baseBuildImages}
on:select={selectBaseBuildImage} on:select={selectBaseBuildImage}
value={application.baseBuildImage} value={application.baseBuildImage}
@@ -572,7 +601,7 @@
{isDisabled} {isDisabled}
containerClasses={isDisabled && containerClass()} containerClasses={isDisabled && containerClass()}
id="baseImages" id="baseImages"
showIndicator={!$status.application.isRunning} showIndicator={!isDisabled}
items={application.baseImages} items={application.baseImages}
on:select={selectBaseImage} on:select={selectBaseImage}
value={application.baseImage} value={application.baseImage}
@@ -594,7 +623,7 @@
{isDisabled} {isDisabled}
containerClasses={isDisabled && containerClass()} containerClasses={isDisabled && containerClass()}
id="deploymentTypes" id="deploymentTypes"
showIndicator={!$status.application.isRunning} showIndicator={!isDisabled}
items={['static', 'node']} items={['static', 'node']}
on:select={selectDeploymentType} on:select={selectDeploymentType}
value={application.deploymentType} value={application.deploymentType}
@@ -705,7 +734,9 @@
<div class="grid grid-cols-2 items-center pt-4"> <div class="grid grid-cols-2 items-center pt-4">
<label for="port" <label for="port"
>{$t('forms.port')} >{$t('forms.port')}
<Explainer explanation={'The port your application listens on.'} /></label <Explainer
explanation={'The port your application listens inside the docker container.'}
/></label
> >
<input <input
class="w-full" class="w-full"
@@ -726,7 +757,7 @@
> >
<input <input
class="w-full" class="w-full"
readonly={!$appSession.isAdmin && !$status.application.isRunning} readonly={!isDisabled}
disabled={isDisabled} disabled={isDisabled}
name="exposePort" name="exposePort"
id="exposePort" id="exposePort"
@@ -884,23 +915,29 @@
<button <button
class="btn btn-sm btn-primary" class="btn btn-sm btn-primary"
on:click|preventDefault={reloadCompose} on:click|preventDefault={reloadCompose}
class:loading
disabled={loading}>Reload Docker Compose File</button disabled={loading}>Reload Docker Compose File</button
> >
{/if} {/if}
</div> </div>
<div class="grid grid-flow-row gap-2"> <div class="grid grid-flow-row gap-2">
{#each dockerComposeServices as service} {#each dockerComposeServices as service}
<div class="grid items-center mb-6"> <div class="grid items-center bg-coolgray-100 rounded border border-coolgray-300 p-2 px-4">
<div class="text-xl font-bold uppercase">{service.name}</div> <div class="text-xl font-bold uppercase">
{#if service.data?.image} {service.name}
<div class="text-xs">{service.data.image}</div> <span
{:else} class="badge rounded text-white"
<div class="text-xs">No image, build required</div> class:text-red-500={statues[service.name] === 'Exited' ||
{/if} statues[service.name] === 'Stopped'}
class:text-yellow-400={statues[service.name] === 'Restarting'}
class:text-green-500={statues[service.name] === 'Running'}
>{statues[service.name] || 'Loading...'}</span
>
</div>
<div class="text-xs">{application.id}-{service.name}</div>
</div> </div>
<div class="grid grid-cols-2 items-center"> <div class="grid grid-cols-2 items-center px-8">
<label for="fqdn" <label for="fqdn"
>{$t('application.url_fqdn')} >{$t('application.url_fqdn')}
<Explainer <Explainer
@@ -910,6 +947,8 @@
<div> <div>
<input <input
class="w-full" class="w-full"
disabled={isDisabled}
readonly={!$appSession.isAdmin}
name="fqdn" name="fqdn"
id="fqdn" id="fqdn"
bind:value={dockerComposeConfiguration[service.name].fqdn} bind:value={dockerComposeConfiguration[service.name].fqdn}
@@ -918,6 +957,23 @@
/> />
</div> </div>
</div> </div>
<div class="grid grid-cols-2 items-center px-8 pb-4">
<label for="port"
>{$t('forms.port')}
<Explainer
explanation={'The port your application listens inside the docker container.'}
/></label
>
<input
class="w-full"
disabled={isDisabled}
readonly={!$appSession.isAdmin}
name="port"
id="port"
bind:value={dockerComposeConfiguration[service.name].port}
placeholder="{$t('forms.default')}: 3000"
/>
</div>
{/each} {/each}
</div> </div>
{/if} {/if}