tons of updates

This commit is contained in:
Andras Bacsai 2022-10-14 15:48:37 +02:00
parent 79c30dfc91
commit 462eea90c0
54 changed files with 1760 additions and 1427 deletions

View File

@ -111,13 +111,11 @@ ### Custom logo
> SVG is recommended, but you can use PNG as well. It should have the `isAbsolute` variable with the suitable CSS classes, primarily for sizing and positioning.
- At [here](apps/ui/src/lib/components/svg/services/ServiceIcons.svelte) include the new logo with `isAbsolute` property.
- At [here](apps/ui/src/routes/services/[id]/_ServiceLinks.svelte) add links to the documentation of the service.
### Custom fields on the UI
By default the URL and name are shown on the UI. Everything else needs to be added [here](apps/ui/src/routes/services/[id]/_Services/_Services.svelte)
By default the URL and name are shown on the UI. Everything else needs to be added [here](apps/ui/src/lib/components/Services)
> If you need to show more details on the frontend, such as users/passwords, you need to add Svelte component [here](apps/ui/src/routes/services/[id]/_Services) with an underscore. For example, see other [here](apps/ui/src/routes/services/[id]/_Services/_Umami.svelte).
> If you need to show more details on the frontend, such as users/passwords, you need to add Svelte component [here](apps/ui/src/lib/components/Services). For example, see other [here](apps/ui/src/lib/components/Services/Umami.svelte).
Good job! 👏

View File

@ -198,7 +198,7 @@ export const encrypt = (text: string) => {
if (text) {
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv(algorithm, process.env['COOLIFY_SECRET_KEY'], iv);
const encrypted = Buffer.concat([cipher.update(text), cipher.final()]);
const encrypted = Buffer.concat([cipher.update(text.trim()), cipher.final()]);
return JSON.stringify({
iv: iv.toString('hex'),
content: encrypted.toString('hex')
@ -1681,7 +1681,9 @@ export function persistentVolumes(id, persistentStorage, config) {
for (const [key, value] of Object.entries(config)) {
if (value.volumes) {
for (const volume of value.volumes) {
volumeSet.add(volume);
if (!volume.startsWith('/var/run/docker.sock')) {
volumeSet.add(volume);
}
}
}
}

View File

@ -6,82 +6,83 @@ import { ServiceStartStop } from '../../routes/api/v1/services/types';
import { asyncSleep, ComposeFile, createDirectories, defaultComposeConfiguration, errorHandler, executeDockerCmd, getDomain, getFreePublicPort, getServiceFromDB, getServiceImage, getServiceMainPort, isARM, isDev, makeLabelForServices, persistentVolumes, prisma } from '../common';
import { defaultServiceConfigurations } from '../services';
import { OnlyId } from '../../types';
import templates from '../templates'
export async function startService(request: FastifyRequest<ServiceStartStop>) {
try {
const { type } = request.params
if (type === 'plausibleanalytics') {
return await startPlausibleAnalyticsService(request)
}
if (type === 'nocodb') {
return await startNocodbService(request)
}
if (type === 'minio') {
return await startMinioService(request)
}
if (type === 'vscodeserver') {
return await startVscodeService(request)
}
if (type === 'wordpress') {
return await startWordpressService(request)
}
if (type === 'vaultwarden') {
return await startVaultwardenService(request)
}
if (type === 'languagetool') {
return await startLanguageToolService(request)
}
if (type === 'n8n') {
return await startN8nService(request)
}
if (type === 'uptimekuma') {
return await startUptimekumaService(request)
}
if (type === 'ghost') {
return await startGhostService(request)
}
if (type === 'meilisearch') {
return await startMeilisearchService(request)
}
if (type === 'umami') {
return await startUmamiService(request)
}
if (type === 'hasura') {
return await startHasuraService(request)
}
if (type === 'fider') {
return await startFiderService(request)
}
if (type === 'moodle') {
return await startMoodleService(request)
}
if (type === 'appwrite') {
return await startAppWriteService(request)
}
if (type === 'glitchTip') {
return await startGlitchTipService(request)
}
if (type === 'searxng') {
return await startSearXNGService(request)
}
if (type === 'weblate') {
return await startWeblateService(request)
}
if (type === 'taiga') {
return await startTaigaService(request)
}
if (type === 'grafana') {
return await startGrafanaService(request)
}
if (type === 'trilium') {
return await startTriliumService(request)
}
// export async function startService(request: FastifyRequest<ServiceStartStop>) {
// try {
// const { type } = request.params
// if (type === 'plausibleanalytics') {
// return await startPlausibleAnalyticsService(request)
// }
// if (type === 'nocodb') {
// return await startNocodbService(request)
// }
// if (type === 'minio') {
// return await startMinioService(request)
// }
// if (type === 'vscodeserver') {
// return await startVscodeService(request)
// }
// if (type === 'wordpress') {
// return await startWordpressService(request)
// }
// if (type === 'vaultwarden') {
// return await startVaultwardenService(request)
// }
// if (type === 'languagetool') {
// return await startLanguageToolService(request)
// }
// if (type === 'n8n') {
// return await startN8nService(request)
// }
// if (type === 'uptimekuma') {
// return await startUptimekumaService(request)
// }
// if (type === 'ghost') {
// return await startGhostService(request)
// }
// if (type === 'meilisearch') {
// return await startMeilisearchService(request)
// }
// if (type === 'umami') {
// return await startUmamiService(request)
// }
// if (type === 'hasura') {
// return await startHasuraService(request)
// }
// if (type === 'fider') {
// return await startFiderService(request)
// }
// if (type === 'moodle') {
// return await startMoodleService(request)
// }
// if (type === 'appwrite') {
// return await startAppWriteService(request)
// }
// if (type === 'glitchTip') {
// return await startGlitchTipService(request)
// }
// if (type === 'searxng') {
// return await startSearXNGService(request)
// }
// if (type === 'weblate') {
// return await startWeblateService(request)
// }
// if (type === 'taiga') {
// return await startTaigaService(request)
// }
// if (type === 'grafana') {
// return await startGrafanaService(request)
// }
// if (type === 'trilium') {
// return await startTriliumService(request)
// }
throw `Service type ${type} not supported.`
} catch (error) {
throw { status: 500, message: error?.message || error }
}
}
// throw `Service type ${type} not supported.`
// } catch (error) {
// throw { status: 500, message: error?.message || error }
// }
// }
export async function stopService(request: FastifyRequest<ServiceStartStop>) {
try {
return await stopServiceContainers(request)
@ -684,54 +685,54 @@ async function startLanguageToolService(request: FastifyRequest<ServiceStartStop
}
}
async function startN8nService(request: FastifyRequest<ServiceStartStop>) {
export async function startService(request: FastifyRequest<ServiceStartStop>) {
try {
const { id } = request.params;
const teamId = request.user.teamId;
const service = await getServiceFromDB({ id, teamId });
const { type, version, destinationDockerId, destinationDocker, serviceSecret, 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 network = destinationDockerId && destinationDocker.network;
const port = getServiceMainPort('n8n');
const { workdir } = await createDirectories({ repository: type, buildId: id });
const image = getServiceImage(type);
const config = {
n8n: {
image: `${image}:${version}`,
volumes: [`${id}-n8n:/root/.n8n`],
environmentVariables: {
WEBHOOK_URL: `${service.fqdn}`
}
const config = {};
for (const service in template.services) {
config[service] = {
container_name: id,
image: template.services[service].image.replace('$$core_version', version),
expose: template.services[service].ports,
// ...(exposePort ? { ports: [`${exposePort}:${port}`] } : {}),
volumes: template.services[service].volumes,
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;
});
}
};
if (serviceSecret.length > 0) {
serviceSecret.forEach((secret) => {
config.n8n.environmentVariables[secret.name] = secret.value;
});
}
const { workdir } = await createDirectories({ repository: type, buildId: id });
const { volumeMounts } = persistentVolumes(id, persistentStorage, config)
const composeFile: ComposeFile = {
version: '3.8',
services: {
[id]: {
container_name: id,
image: config.n8n.image,
volumes: config.n8n.volumes,
environment: config.n8n.environmentVariables,
labels: makeLabelForServices('n8n'),
...(exposePort ? { ports: [`${exposePort}:${port}`] } : {}),
...defaultComposeConfiguration(network),
}
},
services: config,
networks: {
[network]: {
external: true
}
},
volumes: volumeMounts
};
}
const composeFileDestination = `${workdir}/docker-compose.yaml`;
await fs.writeFile(composeFileDestination, yaml.dump(composeFile));
await startServiceContainers(destinationDocker.id, composeFileDestination)

View File

@ -0,0 +1,175 @@
export default [
{
"templateVersion": "1.0.0",
"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": {
"documentation": "Taken from https://hub.docker.com/r/n8nio/n8n",
"depends_on": [],
"image": "n8nio/n8n:$$core_version",
"volumes": [
"$$id-data:/root/.n8n",
"$$id-data-write:/files",
"/var/run/docker.sock:/var/run/docker.sock"
],
"environment": [
"WEBHOOK_URL=$$fqdn"
],
"ports": [
"5678"
]
}
},
"variables": []
},
{
"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": {
"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": [
"$$id-postgresql",
"$$id-clickhouse"
],
"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",
],
"ports": [
"8000"
],
},
"$$id-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",
],
},
"$$id-clickhouse": {
"documentation": "Taken from https://plausible.io/",
"build": "$$workdir",
"image": "yandex/clickhouse-server:21.3.2.5",
"ulimits": {
"nofile": {
"soft": 262144,
"hard": 262144
}
},
"extras": {
"files:": [
{
location: '$$workdir/clickhouse-config.xml',
content: '<yandex><logger><level>warning</level><console>true</console></logger><query_thread_log remove="remove"/><query_log remove="remove"/><text_log remove="remove"/><trace_log remove="remove"/><metric_log remove="remove"/><asynchronous_metric_log remove="remove"/><session_log remove="remove"/><part_log remove="remove"/></yandex>'
},
{
location: '$$workdir/clickhouse-user-config.xml',
content: '<yandex><profiles><default><log_queries>0</log_queries><log_query_threads>0</log_query_threads></default></profiles></yandex>'
},
{
location: '$$workdir/init.query',
content: 'CREATE DATABASE IF NOT EXISTS plausible;'
},
{
location: '$$workdir/init-db.sh',
content: 'clickhouse client --queries-file /docker-entrypoint-initdb.d/init.query'
}
]
}
},
},
"variables": [
{
"id": "$$secret_email",
"label": "Admin Email",
"defaultValue": "admin@example.com",
"description": "This is the admin email. Please change it.",
"validRegex": /^([^\s^\/])+$/
},
{
"id": "$$secret_name",
"label": "Admin Name",
"defaultValue": "admin",
"description": "This is the admin username. Please change it.",
"validRegex": /^([^\s^\/])+$/
},
{
"id": "$$secret_password",
"label": "Admin Password",
"description": "This is the admin password. Please change it.",
"validRegex": /^([^\s^\/])+$/
},
{
"id": "$$secret_secret_key_base",
"label": "Secret Key Base",
"description": "",
"validRegex": /^([^\s^\/])+$/
},
{
"id": "$$secret_disable_auth",
"label": "Disable Auth",
"defaultValue": "false",
"description": "",
"validRegex": /^([^\s^\/])+$/
},
{
"id": "$$secret_disable_registration",
"label": "Disable Registration",
"defaultValue": "true",
"description": "",
"validRegex": /^([^\s^\/])+$/
},
{
"id": "$$secret_disable_registration",
"label": "Disable Registration",
"defaultValue": "true",
"description": "",
"validRegex": /^([^\s^\/])+$/
},
{
"id": "$$secret_postgresql_username",
"label": "PostgreSQL Username",
"defaultValue": "postgresql",
"description": "",
"validRegex": /^([^\s^\/])+$/
},
{
"id": "$$secret_postgresql_password",
"label": "PostgreSQL Password",
"defaultValue": "postgresql",
"description": "",
"validRegex": /^([^\s^\/])+$/
}
,
{
"id": "$$secret_postgresql_database",
"label": "PostgreSQL Database",
"defaultValue": "plausible",
"description": "",
"validRegex": /^([^\s^\/])+$/
}
]
}
]

View File

@ -5,6 +5,7 @@ import { prisma, uniqueName, asyncExecShell, getServiceFromDB, getContainerUsage
import { day } from '../../../../lib/dayjs';
import { checkContainer, isContainerExited } from '../../../../lib/docker';
import cuid from 'cuid';
import templates from '../../../../lib/templates';
import type { OnlyId } from '../../../../types';
import type { ActivateWordpressFtp, CheckService, CheckServiceDomain, DeleteServiceSecret, DeleteServiceStorage, GetServiceLogs, SaveService, SaveServiceDestination, SaveServiceSecret, SaveServiceSettings, SaveServiceStorage, SaveServiceType, SaveServiceVersion, ServiceStartStop, SetGlitchTipSettings, SetWordpressSettings } from './types';
@ -73,25 +74,53 @@ export async function getServiceStatus(request: FastifyRequest<OnlyId>) {
let isRestarting = false;
const service = await getServiceFromDB({ id, teamId });
const { destinationDockerId, settings } = service;
let payload = {}
if (destinationDockerId) {
const status = await checkContainer({ dockerId: service.destinationDocker.id, container: id });
if (status?.found) {
isRunning = status.status.isRunning;
isExited = status.status.isExited;
isRestarting = status.status.isRestarting
const { stdout: containers } = await executeDockerCmd({
dockerId: service.destinationDocker.id,
command:
`docker ps -a --filter "label=com.docker.compose.project=${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[containerObj.Names] = {
status: {
isRunning,
isExited,
isRestarting
}
}
}
}
}
return {
isRunning,
isExited,
settings
}
return payload
} catch ({ status, message }) {
return errorHandler({ status, message })
}
}
function parseAndFindServiceTemplates(service: any) {
const foundTemplate = templates.find(t => t.name === service.type)
if (foundTemplate) {
return JSON.parse(JSON.stringify(foundTemplate).replaceAll('$$id', service.id).replaceAll('$$fqdn', service.fqdn))
}
}
export async function getService(request: FastifyRequest<OnlyId>) {
try {
const teamId = request.user.teamId;
@ -102,7 +131,8 @@ export async function getService(request: FastifyRequest<OnlyId>) {
}
return {
settings: await listSettings(),
service
service,
template: parseAndFindServiceTemplates(service)
}
} catch ({ status, message }) {
return errorHandler({ status, message })
@ -111,7 +141,7 @@ export async function getService(request: FastifyRequest<OnlyId>) {
export async function getServiceType(request: FastifyRequest) {
try {
return {
types: supportedServiceTypesAndVersions
services: templates
}
} catch ({ status, message }) {
return errorHandler({ status, message })
@ -120,8 +150,21 @@ export async function getServiceType(request: FastifyRequest) {
export async function saveServiceType(request: FastifyRequest<SaveServiceType>, reply: FastifyReply) {
try {
const { id } = request.params;
const { type } = request.body;
await configureServiceType({ id, type });
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 (secretValue) secretValue = encrypt(secretValue);
await prisma.serviceSecret.create({
data: { name: secretName, value: secretValue, service: { connect: { id } } }
})
}
}
}
await prisma.service.update({ where: { id }, data: { type: name, version: serviceDefaultVersion } })
return reply.code(201).send()
} catch ({ status, message }) {
return errorHandler({ status, message })

View File

@ -38,6 +38,8 @@
class={disabledClass}
class:pr-10={true}
class:pr-20={value && isHttps}
class:border={required && !value}
class:border-red-500={required && !value}
{placeholder}
type="text"
{id}
@ -54,6 +56,8 @@
type="text"
class:pr-10={true}
class:pr-20={value && isHttps}
class:border={required && !value}
class:border-red-500={required && !value}
{id}
{name}
{required}
@ -70,6 +74,8 @@
class={disabledClass}
class:pr-10={true}
class:pr-20={value && isHttps}
class:border={required && !value}
class:border-red-500={required && !value}
type="password"
{id}
{name}

View File

@ -1,6 +1,9 @@
<script lang="ts">
import ExternalLink from './ExternalLink.svelte';
import Tooltip from './Tooltip.svelte';
export let url = 'https://docs.coollabs.io';
export let text: any = '';
export let isExternal = false;
let id =
'cool-' +
url
@ -10,10 +13,32 @@
.slice(-16);
</script>
<a {id} href={url} target="_blank" class="icons inline-block cursor-pointer text-xs mx-2">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9 5.25h.008v.008H12v-.008z" />
</svg>
<a
{id}
href={url}
target="_blank"
class="flex no-underline inline-block cursor-pointer"
class:icons={!text}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-6 h-6"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9 5.25h.008v.008H12v-.008z"
/>
</svg>
{text}
{#if isExternal}
<ExternalLink />
{/if}
</a>
<Tooltip triggeredBy={`#${id}`}>See details in the documentation</Tooltip>
{#if !text}
<Tooltip triggeredBy={`#${id}`}>See details in the documentation</Tooltip>
{/if}

View File

@ -0,0 +1,10 @@
<svg
xmlns="http://www.w3.org/2000/svg"
fill="currentColor"
viewBox="0 0 24 24"
stroke-width="3"
stroke="currentColor"
class="w-3 h-3 text-white"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 19.5l15-15m0 0H8.25m11.25 0v11.25" />
</svg>

After

Width:  |  Height:  |  Size: 261 B

View File

@ -0,0 +1,31 @@
<script lang="ts">
export let id: any;
import { status } from '$lib/store';
let serviceStatus = {
isExited: false,
isRunning: false,
isRestarting: false,
isStopped: false
};
$: if (Object.keys($status.service.statuses).length > 0) {
let { isExited, isRunning, isRestarting } = $status.service.statuses[id].status;
serviceStatus.isExited = isExited;
serviceStatus.isRunning = isRunning;
serviceStatus.isRestarting = isRestarting;
serviceStatus.isStopped = !isExited && !isRunning && !isRestarting;
} else {
serviceStatus.isExited = false;
serviceStatus.isRunning = false;
serviceStatus.isRestarting = false;
serviceStatus.isStopped = true;
}
</script>
{#if serviceStatus.isRunning}
<span class="badge font-bold uppercase rounded text-green-500 mt-2">Running</span>
{:else if serviceStatus.isStopped || serviceStatus.isExited}
<span class="badge font-bold uppercase rounded text-red-500 mt-2">Stopped</span>
{:else if serviceStatus.isRestarting}
<span class="badge font-bold uppercase rounded text-yellow-500 mt-2">Restarting</span>
{/if}

View File

@ -6,12 +6,12 @@
export let readOnly: any;
</script>
<div class="flex space-x-1 py-5 font-bold">
<div class="title">Appwrite</div>
<div class="flex flex-row border-b border-coolgray-500 my-6 space-x-2">
<div class="title font-bold pb-3">Appwrite</div>
</div>
<div class="space-y-2">
<div class="grid grid-cols-2 items-center lg:px-10 px-2">
<div class="space-y-2 px-4">
<div class="grid grid-cols-2 items-center">
<label for="opensslKeyV1">Encryption Key</label>
<CopyPasswordField
name="opensslKeyV1"
@ -22,7 +22,7 @@
disabled
/>
</div>
<div class="grid grid-cols-2 items-center lg:px-10 px-2">
<div class="grid grid-cols-2 items-center">
<label for="executorSecret">Executor Secret</label>
<CopyPasswordField
name="executorSecret"
@ -34,11 +34,11 @@
/>
</div>
</div>
<div class="flex space-x-1 py-5 font-bold">
<div class="title">MariaDB</div>
<div class="flex flex-row border-b border-coolgray-500 my-6 space-x-2">
<div class="title font-bold pb-3">MariaDB</div>
</div>
<div class="space-y-2">
<div class="grid grid-cols-2 items-center lg:px-10 px-2">
<div class="space-y-2 px-4">
<div class="grid grid-cols-2 items-center">
<label for="mariadbUser">{$t('forms.username')}</label>
<CopyPasswordField
name="mariadbUser"
@ -48,7 +48,7 @@
disabled
/>
</div>
<div class="grid grid-cols-2 items-center lg:px-10 px-2 ">
<div class="grid grid-cols-2 items-center ">
<label for="mariadbPassword">{$t('forms.password')}</label>
<CopyPasswordField
id="mariadbPassword"
@ -59,7 +59,7 @@
value={service.appwrite.mariadbPassword}
/>
</div>
<div class="grid grid-cols-2 items-center lg:px-10 px-2">
<div class="grid grid-cols-2 items-center">
<label for="mariadbRootUser">Root User</label>
<CopyPasswordField
name="mariadbRootUser"
@ -69,7 +69,7 @@
disabled
/>
</div>
<div class="grid grid-cols-2 items-center lg:px-10 px-2 ">
<div class="grid grid-cols-2 items-center ">
<label for="mariadbRootUserPassword">Root Password</label>
<CopyPasswordField
id="mariadbRootUserPassword"
@ -80,7 +80,7 @@
value={service.appwrite.mariadbRootUserPassword}
/>
</div>
<div class="grid grid-cols-2 items-center lg:px-10 px-2">
<div class="grid grid-cols-2 items-center">
<label for="mariadbDatabase">{$t('index.database')}</label>
<CopyPasswordField
name="mariadbDatabase"

View File

@ -17,12 +17,12 @@
];
</script>
<div class="flex space-x-1 py-5 font-bold">
<div class="title">Fider</div>
<div class="flex flex-row border-b border-coolgray-500 my-6 space-x-2">
<div class="title font-bold pb-3">Fider</div>
</div>
<div class="space-y-2">
<div class="grid grid-cols-2 items-center lg:px-10 px-2">
<div class="space-y-2 px-4">
<div class="grid grid-cols-2 items-center">
<label for="jwtSecret">JWT Secret</label>
<CopyPasswordField
name="jwtSecret"
@ -34,7 +34,7 @@
/>
</div>
<div class="grid grid-cols-2 items-center lg:px-10 px-2">
<div class="grid grid-cols-2 items-center">
<label for="emailNoreply">Noreply Email</label>
<input
class="w-full"
@ -49,11 +49,11 @@
/>
</div>
</div>
<div class="flex space-x-1 py-5 font-bold">
<div class="title">Email</div>
<div class="flex flex-row border-b border-coolgray-500 my-6 space-x-2">
<div class="title font-bold pb-3">Email</div>
</div>
<div class="space-y-2">
<div class="grid grid-cols-2 items-center lg:px-10 px-2">
<div class="space-y-2 px-4">
<div class="grid grid-cols-2 items-center">
<label for="emailMailgunApiKey">Mailgun API Key</label>
<CopyPasswordField
name="emailMailgunApiKey"
@ -66,7 +66,7 @@
/>
</div>
<div class="grid grid-cols-2 items-center lg:px-10 px-2">
<div class="grid grid-cols-2 items-center">
<label for="emailMailgunDomain">Mailgun Domain</label>
<input
class="w-full"
@ -78,7 +78,7 @@
placeholder="{$t('forms.eg')}: yourdomain.com"
/>
</div>
<div class="grid grid-cols-2 items-center lg:px-10 px-2">
<div class="grid grid-cols-2 items-center">
<label for="emailMailgunRegion">Mailgun Region</label>
<div class="custom-select-wrapper">
<Select
@ -92,10 +92,10 @@
</div>
</div>
<div class="flex space-x-1 py-5 lg:px-10 px-2 font-bold">
<div class="flex space-x-1 py-5 font-bold">
<div class="text-lg">Or</div>
</div>
<div class="grid grid-cols-2 items-center lg:px-10 px-2">
<div class="grid grid-cols-2 items-center">
<label for="emailSmtpHost">SMTP Host</label>
<input
class="w-full"
@ -107,7 +107,7 @@
placeholder="{$t('forms.eg')}: smtp.yourdomain.com"
/>
</div>
<div class="grid grid-cols-2 items-center lg:px-10 px-2">
<div class="grid grid-cols-2 items-center">
<label for="emailSmtpPort">SMTP Port</label>
<input
class="w-full"
@ -119,7 +119,7 @@
placeholder="{$t('forms.eg')}: 587"
/>
</div>
<div class="grid grid-cols-2 items-center lg:px-10 px-2">
<div class="grid grid-cols-2 items-center">
<label for="emailSmtpUser">SMTP User</label>
<input
class="w-full"
@ -131,7 +131,7 @@
placeholder="{$t('forms.eg')}: user@yourdomain.com"
/>
</div>
<div class="grid grid-cols-2 items-center lg:px-10 px-2">
<div class="grid grid-cols-2 items-center">
<label for="emailSmtpPassword">SMTP Password</label>
<CopyPasswordField
name="emailSmtpPassword"
@ -143,7 +143,7 @@
placeholder="{$t('forms.eg')}: s0m3p4ssw0rd"
/>
</div>
<div class="grid grid-cols-2 items-center lg:px-10 px-2">
<div class="grid grid-cols-2 items-center">
<label for="emailSmtpEnableStartTls">SMTP Start TLS</label>
<input
class="w-full"
@ -156,11 +156,11 @@
/>
</div>
</div>
<div class="flex space-x-1 py-5 font-bold">
<div class="title">PostgreSQL</div>
<div class="flex flex-row border-b border-coolgray-500 my-6 space-x-2">
<div class="title font-bold pb-3">PostgreSQL</div>
</div>
<div class="space-y-2">
<div class="grid grid-cols-2 items-center lg:px-10 px-2">
<div class="space-y-2 px-4">
<div class="grid grid-cols-2 items-center">
<label for="postgresqlUser">{$t('forms.username')}</label>
<CopyPasswordField
name="postgresqlUser"
@ -170,7 +170,7 @@
disabled
/>
</div>
<div class="grid grid-cols-2 items-center lg:px-10 px-2">
<div class="grid grid-cols-2 items-center">
<label for="postgresqlPassword">{$t('forms.password')}</label>
<CopyPasswordField
id="postgresqlPassword"
@ -181,7 +181,7 @@
value={service.fider.postgresqlPassword}
/>
</div>
<div class="grid grid-cols-2 items-center lg:px-10 px-2">
<div class="grid grid-cols-2 items-center">
<label for="postgresqlDatabase">{$t('index.database')}</label>
<CopyPasswordField
name="postgresqlDatabase"

View File

@ -0,0 +1,101 @@
<script lang="ts">
import CopyPasswordField from '$lib/components/CopyPasswordField.svelte';
import Explainer from '$lib/components/Explainer.svelte';
import { t } from '$lib/translations';
export let readOnly: any;
export let service: any;
</script>
<div class="flex flex-row border-b border-coolgray-500 my-6 space-x-2">
<div class="title font-bold pb-3">
Ghost <Explainer
explanation="You can change these values in the <span class='text-settings'>Ghost admin panel<span>."
/>
</div>
</div>
<div class="space-y-2">
<div class="grid grid-cols-2 items-center lg:px-10 px-2">
<label for="email">{$t('forms.default_email_address')}</label>
<input
class="w-full"
name="email"
id="email"
disabled
readonly
placeholder={$t('forms.email')}
value={service.ghost.defaultEmail}
required
/>
</div>
<div class="grid grid-cols-2 items-center lg:px-10 px-2">
<label for="defaultPassword">{$t('forms.default_password')}</label>
<CopyPasswordField
id="defaultPassword"
isPasswordField
readonly
disabled
name="defaultPassword"
value={service.ghost.defaultPassword}
/>
</div>
</div>
<div class="flex flex-row border-b border-coolgray-500 my-6 space-x-2">
<div class="title font-bold pb-3">MariaDB</div>
</div>
<div class="space-y-2">
<div class="grid grid-cols-2 items-center lg:px-10 px-2">
<label for="mariadbUser">{$t('forms.username')}</label>
<CopyPasswordField
name="mariadbUser"
id="mariadbUser"
value={service.ghost.mariadbUser}
readonly
disabled
/>
</div>
<div class="grid grid-cols-2 items-center lg:px-10 px-2">
<label for="mariadbPassword">{$t('forms.password')}</label>
<CopyPasswordField
id="mariadbPassword"
isPasswordField
readonly
disabled
name="mariadbPassword"
value={service.ghost.mariadbPassword}
/>
</div>
<div class="grid grid-cols-2 items-center lg:px-10 px-2">
<label for="mariadbDatabase">{$t('index.database')}</label>
<input
class="w-full"
name="mariadbDatabase"
id="mariadbDatabase"
required
readonly={readOnly}
disabled={readOnly}
bind:value={service.ghost.mariadbDatabase}
placeholder="{$t('forms.eg')}: ghost_db"
/>
</div>
<div class="grid grid-cols-2 items-center lg:px-10 px-2">
<label for="mariadbRootUser">{$t('forms.root_db_user')}</label>
<CopyPasswordField
id="mariadbRootUser"
readonly
disabled
name="mariadbRootUser"
value={service.ghost.mariadbRootUser}
/>
</div>
<div class="grid grid-cols-2 items-center lg:px-10 px-2">
<label for="mariadbRootUserPassword">{$t('forms.root_db_password')}</label>
<CopyPasswordField
id="mariadbRootUserPassword"
isPasswordField
readonly
disabled
name="mariadbRootUserPassword"
value={service.ghost.mariadbRootUserPassword}
/>
</div>
</div>

View File

@ -49,11 +49,11 @@
}
</script>
<div class="flex space-x-1 py-5 font-bold">
<div class="title">GlitchTip</div>
<div class="flex flex-row border-b border-coolgray-500 my-6 space-x-2">
<div class="title font-bold pb-3">GlitchTip</div>
</div>
<div class="grid grid-cols-2 items-center lg:px-10 px-2">
<div class="grid grid-cols-2 items-center">
<Setting
id="enableOpenUserRegistration"
bind:setting={service.glitchTip.enableOpenUserRegistration}
@ -63,19 +63,13 @@
title="Enable Open User Registration"
description={''}
/>
<!-- <Setting
bind:setting={service.glitchTip.enableOpenUserRegistration}
on:click={toggleEnableOpenUserRegistration}
title={'Enable Open User Registration'}
description={''}
/> -->
</div>
<div class="flex space-x-1 py-2 font-bold">
<div class="subtitle">Email settings</div>
<div class="flex flex-row border-b border-coolgray-500 my-6 space-x-2">
<div class="title font-bold pb-3">Email Settings</div>
</div>
<div class="space-y-2">
<div class="grid grid-cols-2 items-center lg:px-10 px-2">
<div class="space-y-2 px-4">
<div class="grid grid-cols-2 items-center">
<Setting
id="emailSmtpUseTls"
bind:setting={service.glitchTip.emailSmtpUseTls}
@ -87,7 +81,7 @@
/>
</div>
<div class="grid grid-cols-2 items-center lg:px-10 px-2">
<div class="grid grid-cols-2 items-center">
<Setting
id="emailSmtpUseSsl"
bind:setting={service.glitchTip.emailSmtpUseSsl}
@ -98,7 +92,7 @@
description={''}
/>
</div>
<div class="grid grid-cols-2 items-center lg:px-10 px-2">
<div class="grid grid-cols-2 items-center">
<label for="defaultEmailFrom">Default Email From</label>
<CopyPasswordField
required
@ -108,7 +102,7 @@
/>
</div>
<div class="grid grid-cols-2 items-center lg:px-10 px-2">
<div class="grid grid-cols-2 items-center">
<label for="emailSmtpHost">SMTP Host</label>
<CopyPasswordField
name="emailSmtpHost"
@ -117,7 +111,7 @@
/>
</div>
<div class="grid grid-cols-2 items-center lg:px-10 px-2">
<div class="grid grid-cols-2 items-center">
<label for="emailSmtpPort">SMTP Port</label>
<CopyPasswordField
name="emailSmtpPort"
@ -126,7 +120,7 @@
/>
</div>
<div class="grid grid-cols-2 items-center lg:px-10 px-2">
<div class="grid grid-cols-2 items-center">
<label for="emailSmtpUser">SMTP User</label>
<CopyPasswordField
name="emailSmtpUser"
@ -135,7 +129,7 @@
/>
</div>
<div class="grid grid-cols-2 items-center lg:px-10 px-2">
<div class="grid grid-cols-2 items-center">
<label for="emailSmtpPassword">SMTP Password</label>
<CopyPasswordField
name="emailSmtpPassword"
@ -145,7 +139,7 @@
/>
</div>
<div class="grid grid-cols-2 items-center lg:px-10 px-2">
<div class="grid grid-cols-2 items-center">
<label for="emailBackend">Email Backend</label>
<CopyPasswordField
name="emailBackend"
@ -154,7 +148,7 @@
/>
</div>
<div class="grid grid-cols-2 items-center lg:px-10 px-2">
<div class="grid grid-cols-2 items-center">
<label for="mailgunApiKey">Mailgun API Key</label>
<CopyPasswordField
name="mailgunApiKey"
@ -163,7 +157,7 @@
/>
</div>
<div class="grid grid-cols-2 items-center lg:px-10 px-2">
<div class="grid grid-cols-2 items-center">
<label for="sendgridApiKey">SendGrid API Key</label>
<CopyPasswordField
name="sendgridApiKey"
@ -176,7 +170,7 @@
<div class="subtitle">Default User & Superuser</div>
</div>
<div class="grid grid-cols-2 items-center lg:px-10 px-2">
<div class="grid grid-cols-2 items-center">
<label for="defaultEmail">{$t('forms.email')}</label>
<CopyPasswordField
name="defaultEmail"
@ -186,7 +180,7 @@
disabled
/>
</div>
<div class="grid grid-cols-2 items-center lg:px-10 px-2">
<div class="grid grid-cols-2 items-center">
<label for="defaultUsername">{$t('forms.username')}</label>
<CopyPasswordField
name="defaultUsername"
@ -196,7 +190,7 @@
disabled
/>
</div>
<div class="grid grid-cols-2 items-center lg:px-10 px-2">
<div class="grid grid-cols-2 items-center">
<label for="defaultPassword">{$t('forms.password')}</label>
<CopyPasswordField
name="defaultPassword"
@ -208,11 +202,11 @@
/>
</div>
</div>
<div class="flex space-x-1 py-5 font-bold">
<div class="title">PostgreSQL</div>
<div class="flex flex-row border-b border-coolgray-500 my-6 space-x-2">
<div class="title font-bold pb-3">PostgreSQL</div>
</div>
<div class="space-y-2">
<div class="grid grid-cols-2 items-center lg:px-10 px-2">
<div class="space-y-2 px-4">
<div class="grid grid-cols-2 items-center">
<label for="postgresqlUser">{$t('forms.username')}</label>
<CopyPasswordField
name="postgresqlUser"
@ -222,7 +216,7 @@
disabled
/>
</div>
<div class="grid grid-cols-2 items-center lg:px-10 px-2">
<div class="grid grid-cols-2 items-center">
<label for="postgresqlPassword">{$t('forms.password')}</label>
<CopyPasswordField
id="postgresqlPassword"
@ -233,7 +227,7 @@
bind:value={service.glitchTip.postgresqlPassword}
/>
</div>
<div class="grid grid-cols-2 items-center lg:px-10 px-2">
<div class="grid grid-cols-2 items-center">
<label for="postgresqlDatabase">{$t('index.database')}</label>
<CopyPasswordField
name="postgresqlDatabase"

View File

@ -4,11 +4,11 @@
export let service: any;
</script>
<div class="flex space-x-1 py-5 font-bold">
<div class="title">Hasura</div>
<div class="flex flex-row border-b border-coolgray-500 my-6 space-x-2">
<div class="title font-bold pb-3">Hasura</div>
</div>
<div class="grid grid-cols-2 items-center lg:px-10 px-2">
<div class="grid grid-cols-2 items-center">
<label for="graphQLAdminPassword">GraphQL Admin Password</label>
<CopyPasswordField
name="graphQLAdminPassword"
@ -19,14 +19,17 @@
disabled
/>
</div>
<div class="lg:px-10 px-2 py-4">Hasura Console is <span class="text-warning">not enabled by default</span> for security reasons. <br>To enable it, add the following secret:<br><br> <code>HASURA_GRAPHQL_ENABLE_CONSOLE=true</code></div>
<div class="flex space-x-1 py-5 font-bold">
<div class="title">PostgreSQL</div>
<div class="px-4">
Hasura Console is <span class="text-warning">not enabled by default</span> for security reasons.
<br />To enable it, add the following secret:<br /><br />
<code>HASURA_GRAPHQL_ENABLE_CONSOLE=true</code>
</div>
<div class="flex flex-row border-b border-coolgray-500 my-6 space-x-2">
<div class="title font-bold pb-3">PostgreSQL</div>
</div>
<div class="space-y-2">
<div class="grid grid-cols-2 items-center lg:px-10 px-2">
<div class="space-y-2 px-4">
<div class="grid grid-cols-2 items-center">
<label for="postgresqlUser">{$t('forms.username')}</label>
<CopyPasswordField
name="postgresqlUser"
@ -36,7 +39,7 @@
disabled
/>
</div>
<div class="grid grid-cols-2 items-center lg:px-10 px-2">
<div class="grid grid-cols-2 items-center">
<label for="postgresqlPassword">{$t('forms.password')}</label>
<CopyPasswordField
id="postgresqlPassword"
@ -47,7 +50,7 @@
value={service.hasura.postgresqlPassword}
/>
</div>
<div class="grid grid-cols-2 items-center lg:px-10 px-2">
<div class="grid grid-cols-2 items-center">
<label for="postgresqlDatabase">{$t('index.database')}</label>
<CopyPasswordField
name="postgresqlDatabase"

View File

@ -4,10 +4,10 @@
export let service: any;
</script>
<div class="flex space-x-1 py-5 font-bold">
<div class="title">MeiliSearch</div>
<div class="flex flex-row border-b border-coolgray-500 my-6 space-x-2">
<div class="title font-bold pb-3">MeiliSearch</div>
</div>
<div class="grid grid-cols-2 items-center lg:px-10 px-2">
<div class="grid grid-cols-2 items-center px-4">
<label for="masterKey">{$t('forms.admin_api_key')}</label>
<CopyPasswordField
id="masterKey"

View File

@ -5,11 +5,11 @@
export let service: any;
</script>
<div class="flex space-x-1 py-5 font-bold">
<div class="title">MinIO</div>
<div class="flex flex-row border-b border-coolgray-500 my-6 space-x-2">
<div class="title font-bold pb-3">MinIO</div>
</div>
<div class="space-y-2">
<div class="grid grid-cols-2 items-center lg:px-10 px-2">
<div class="space-y-2 px-4">
<div class="grid grid-cols-2 items-center">
<label for="rootUser">{$t('forms.root_user')}</label>
<input
class="w-full"
@ -21,7 +21,7 @@
readonly
/>
</div>
<div class="grid grid-cols-2 items-center lg:px-10 px-2">
<div class="grid grid-cols-2 items-center">
<label for="rootUserPassword">{$t('forms.roots_password')}</label>
<CopyPasswordField
id="rootUserPassword"
@ -33,7 +33,7 @@
/>
</div>
{#if !service.minio.apiFqdn}
<div class="grid grid-cols-2 items-center lg:px-10 px-2">
<div class="grid grid-cols-2 items-center">
<label for="publicPort">{$t('forms.api_port')}</label>
<input
class="w-full"

View File

@ -5,10 +5,10 @@
export let service: any;
</script>
<div class="flex space-x-1 py-5 font-bold">
<div class="title">Moodle</div>
<div class="flex flex-row border-b border-coolgray-500 my-6 space-x-2">
<div class="title font-bold pb-3">Moodle</div>
</div>
<div class="grid grid-cols-2 items-center lg:px-10">
<div class="grid grid-cols-2 items-center">
<label class="text-base font-bold text-stone-100" for="email">{$t('forms.default_email_address')}</label>
<input
class="w-full"
@ -21,7 +21,7 @@
value={service.moodle.defaultEmail}
/>
</div>
<div class="grid grid-cols-2 items-center lg:px-10">
<div class="grid grid-cols-2 items-center">
<label class="text-base font-bold text-stone-100" for="defaultUsername">Default Username</label>
<CopyPasswordField
id="defaultUsername"
@ -32,7 +32,7 @@
value={service.moodle.defaultUsername}
/>
</div>
<div class="grid grid-cols-2 items-center lg:px-10">
<div class="grid grid-cols-2 items-center">
<label class="text-base font-bold text-stone-100" for="defaultPassword">{$t('forms.default_password')}</label>
<CopyPasswordField
id="defaultPassword"
@ -44,10 +44,10 @@
value={service.moodle.defaultPassword}
/>
</div>
<div class="flex space-x-1 py-5 font-bold">
<div class="title">MariaDB</div>
<div class="flex flex-row border-b border-coolgray-500 my-6 space-x-2">
<div class="title font-bold pb-3">MariaDB</div>
</div>
<div class="grid grid-cols-2 items-center lg:px-10">
<div class="grid grid-cols-2 items-center">
<label class="text-base font-bold text-stone-100" for="mariadbUser">{$t('forms.username')}</label>
<CopyPasswordField
name="mariadbUser"
@ -57,7 +57,7 @@
disabled
/>
</div>
<div class="grid grid-cols-2 items-center lg:px-10">
<div class="grid grid-cols-2 items-center">
<label class="text-base font-bold text-stone-100" for="mariadbPassword">{$t('forms.password')}</label>
<CopyPasswordField
id="mariadbPassword"
@ -68,7 +68,7 @@
value={service.moodle.mariadbPassword}
/>
</div>
<div class="grid grid-cols-2 items-center lg:px-10">
<div class="grid grid-cols-2 items-center">
<label class="text-base font-bold text-stone-100" for="mariadbDatabase">{$t('index.database')}</label>
<input
class="w-full"
@ -81,7 +81,7 @@
placeholder="{$t('forms.eg')}: moodle_db"
/>
</div>
<div class="grid grid-cols-2 items-center lg:px-10">
<div class="grid grid-cols-2 items-center">
<label class="text-base font-bold text-stone-100" for="mariadbRootUser">{$t('forms.root_db_user')}</label>
<CopyPasswordField
id="mariadbRootUser"
@ -91,7 +91,7 @@
value={service.moodle.mariadbRootUser}
/>
</div>
<div class="grid grid-cols-2 items-center lg:px-10">
<div class="grid grid-cols-2 items-center">
<label class="text-base font-bold text-stone-100" for="mariadbRootUserPassword">{$t('forms.root_db_password')}</label>
<CopyPasswordField
id="mariadbRootUserPassword"

View File

@ -1,17 +1,34 @@
<script lang="ts">
export let service: any;
export let readOnly: any;
import CopyPasswordField from '$lib/components/CopyPasswordField.svelte';
import Explainer from '$lib/components/Explainer.svelte';
import { appSession, status } from '$lib/store';
import { t } from '$lib/translations';
export let service: any;
export let readOnly: any;
import ServiceStatus from '../ServiceStatus.svelte';
let serviceStatus = {
isExited: false,
isRunning: false,
isRestarting: false,
isStopped: false
};
$: if (Object.keys($status.service.statuses).length > 0) {
let { isExited, isRunning, isRestarting } = $status.service.statuses[service.id].status;
serviceStatus.isExited = isExited;
serviceStatus.isRunning = isRunning;
serviceStatus.isRestarting = isRestarting;
serviceStatus.isStopped = !isExited && !isRunning && !isRestarting;
}
</script>
<div class="flex space-x-1 py-5 font-bold">
<div class="title">Plausible Analytics</div>
<div class="flex flex-row border-b border-coolgray-500 my-6 space-x-2">
<div class="title font-bold pb-3">Plausible Analytics</div>
<ServiceStatus id={service.id} />
</div>
<div class="space-y-2">
<div class="grid grid-cols-2 items-center lg:px-10 px-2">
<div class="space-y-2 px-4">
<div class="grid grid-cols-2 items-center ">
<label for="scriptName"
>Script Name <Explainer
explanation="Useful if you would like to rename the collector script to prevent it blocked by AdBlockers."
@ -28,7 +45,7 @@
required
/>
</div>
<div class="grid grid-cols-2 items-center lg:px-10 px-2">
<div class="grid grid-cols-2 items-center ">
<label for="email">{$t('forms.email')}</label>
<input
class="w-full"
@ -41,7 +58,7 @@
required
/>
</div>
<div class="grid grid-cols-2 items-center lg:px-10 px-2">
<div class="grid grid-cols-2 items-center ">
<label for="username">{$t('forms.username')}</label>
<CopyPasswordField
name="username"
@ -53,7 +70,7 @@
required
/>
</div>
<div class="grid grid-cols-2 items-center lg:px-10 px-2">
<div class="grid grid-cols-2 items-center ">
<label for="password">{$t('forms.password')}</label>
<CopyPasswordField
id="password"
@ -65,11 +82,13 @@
/>
</div>
</div>
<div class="flex space-x-1 py-5 font-bold">
<div class="title">PostgreSQL</div>
<div class="flex flex-row border-b border-coolgray-500 my-6 space-x-2">
<div class="title font-bold pb-3">PostgreSQL</div>
<ServiceStatus id={`${service.id}-postgresql`} />
</div>
<div class="space-y-2">
<div class="grid grid-cols-2 items-center lg:px-10 px-2">
<div class="space-y-2 px-4">
<div class="grid grid-cols-2 items-center ">
<label for="postgresqlUser">{$t('forms.username')}</label>
<CopyPasswordField
name="postgresqlUser"
@ -79,7 +98,7 @@
disabled
/>
</div>
<div class="grid grid-cols-2 items-center lg:px-10 px-2">
<div class="grid grid-cols-2 items-center ">
<label for="postgresqlPassword">{$t('forms.password')}</label>
<CopyPasswordField
id="postgresqlPassword"
@ -90,7 +109,7 @@
value={service.plausibleAnalytics.postgresqlPassword}
/>
</div>
<div class="grid grid-cols-2 items-center lg:px-10 px-2">
<div class="grid grid-cols-2 items-center ">
<label for="postgresqlDatabase">{$t('index.database')}</label>
<CopyPasswordField
name="postgresqlDatabase"
@ -101,3 +120,7 @@
/>
</div>
</div>
<div class="flex flex-row my-6 space-x-2">
<div class="title font-bold pb-3">ClickHouse</div>
<ServiceStatus id={`${service.id}-clickhouse`} />
</div>

View File

@ -4,11 +4,11 @@
export let service: any;
</script>
<div class="flex space-x-1 py-5 font-bold">
<div class="title">SearXNG</div>
<div class="flex flex-row border-b border-coolgray-500 my-6 space-x-2">
<div class="title font-bold pb-3">SearXNG</div>
</div>
<div class="grid grid-cols-2 items-center lg:px-10 px-2">
<div class="grid grid-cols-2 items-center px-4">
<label for="secretKey">Secret Key</label>
<CopyPasswordField
name="secretKey"
@ -19,11 +19,11 @@
disabled
/>
</div>
<div class="flex space-x-1 py-5 font-bold">
<div class="title">Redis</div>
<div class="flex flex-row border-b border-coolgray-500 my-6 space-x-2">
<div class="title font-bold pb-3">Redis</div>
</div>
<div class="grid grid-cols-2 items-center lg:px-10 px-2">
<div class="grid grid-cols-2 items-center px-4">
<label for="redisPassword">{$t('forms.password')}</label>
<CopyPasswordField
name="redisPassword"

View File

@ -4,11 +4,11 @@
export let service: any;
</script>
<div class="flex space-x-1 py-5 font-bold">
<div class="title">Taiga</div>
<div class="flex flex-row border-b border-coolgray-500 my-6 space-x-2">
<div class="title font-bold pb-3">Taiga</div>
</div>
<div class="grid grid-cols-2 items-center lg:px-10">
<div class="grid grid-cols-2 items-center px-4">
<label class="text-base font-bold text-stone-100" for="secretKey">Secret Key</label>
<CopyPasswordField
name="secretKey"
@ -20,11 +20,11 @@
/>
</div>
<div class="flex space-x-1 py-5 font-bold">
<div class="title">Django</div>
<div class="flex flex-row border-b border-coolgray-500 my-6 space-x-2">
<div class="title font-bold pb-3">Django</div>
</div>
<div class="grid grid-cols-2 items-center lg:px-10">
<div class="grid grid-cols-2 items-center px-4">
<label class="text-base font-bold text-stone-100" for="djangoAdminUser">Admin User</label>
<CopyPasswordField
name="djangoAdminUser"
@ -34,7 +34,7 @@
disabled
/>
</div>
<div class="grid grid-cols-2 items-center lg:px-10">
<div class="grid grid-cols-2 items-center px-4">
<label class="text-base font-bold text-stone-100" for="djangoAdminPassword">Admin Password</label>
<CopyPasswordField
name="djangoAdminPassword"
@ -45,11 +45,11 @@
disabled
/>
</div>
<div class="flex space-x-1 py-5 font-bold">
<div class="title">RabbitMQ</div>
<div class="flex flex-row border-b border-coolgray-500 my-6 space-x-2">
<div class="title font-bold pb-3">RabbitMQ</div>
</div>
<div class="grid grid-cols-2 items-center lg:px-10">
<div class="grid grid-cols-2 items-center px-4">
<label class="text-base font-bold text-stone-100" for="rabbitMQUser">User</label>
<CopyPasswordField
name="rabbitMQUser"
@ -59,7 +59,7 @@
disabled
/>
</div>
<div class="grid grid-cols-2 items-center lg:px-10">
<div class="grid grid-cols-2 items-center px-4">
<label class="text-base font-bold text-stone-100" for="rabbitMQPassword">Password</label>
<CopyPasswordField
name="rabbitMQPassword"
@ -71,11 +71,11 @@
/>
</div>
<div class="flex space-x-1 py-5 font-bold">
<div class="title">PostgreSQL</div>
<div class="flex flex-row border-b border-coolgray-500 my-6 space-x-2">
<div class="title font-bold pb-3">PostgreSQL</div>
</div>
<div class="grid grid-cols-2 items-center lg:px-10">
<div class="grid grid-cols-2 items-center px-4">
<label class="text-base font-bold text-stone-100" for="postgresqlHost">PostgreSQL Host</label>
<CopyPasswordField
name="postgresqlHost"
@ -85,7 +85,7 @@
disabled
/>
</div>
<div class="grid grid-cols-2 items-center lg:px-10">
<div class="grid grid-cols-2 items-center px-4">
<label class="text-base font-bold text-stone-100" for="postgresqlPort">PostgreSQL Port</label>
<CopyPasswordField
name="postgresqlPort"
@ -95,7 +95,7 @@
disabled
/>
</div>
<div class="grid grid-cols-2 items-center lg:px-10">
<div class="grid grid-cols-2 items-center px-4">
<label class="text-base font-bold text-stone-100" for="postgresqlUser">PostgreSQL User</label>
<CopyPasswordField
name="postgresqlUser"
@ -105,7 +105,7 @@
disabled
/>
</div>
<div class="grid grid-cols-2 items-center lg:px-10">
<div class="grid grid-cols-2 items-center px-4">
<label class="text-base font-bold text-stone-100" for="postgresqlPassword">PostgreSQL Password</label>
<CopyPasswordField
name="postgresqlPassword"

View File

@ -4,11 +4,11 @@
export let service: any;
</script>
<div class="flex space-x-1 py-5 font-bold">
<div class="title">Umami</div>
<div class="flex flex-row border-b border-coolgray-500 my-6 space-x-2">
<div class="title font-bold pb-3">Umami</div>
</div>
<div class="space-y-2">
<div class="grid grid-cols-2 items-center lg:px-10 px-2">
<div class="space-y-2 px-4">
<div class="grid grid-cols-2 items-center ">
<label for="adminUser">Admin User</label>
<input
class="w-full"
@ -20,7 +20,7 @@
readonly
/>
</div>
<div class="grid grid-cols-2 items-center lg:px-10 px-2">
<div class="grid grid-cols-2 items-center ">
<label for="umamiAdminPassword"
>Initial Admin Password <Explainer
explanation="It could be changed in Umami. <br>This is just the password set initially after the first start."

View File

@ -5,10 +5,10 @@
export let service: any;
</script>
<div class="flex space-x-1 py-5 font-bold">
<div class="title">VSCode Server</div>
<div class="flex flex-row border-b border-coolgray-500 my-6 space-x-2">
<div class="title font-bold pb-3">VScode Server</div>
</div>
<div class="grid grid-cols-2 items-center lg:px-10 px-2">
<div class="grid grid-cols-2 items-center px-4">
<label for="password">{$t('forms.password')}</label>
<CopyPasswordField
id="password"

View File

@ -3,11 +3,11 @@
export let service: any;
</script>
<div class="flex space-x-1 py-5 font-bold">
<div class="title">Weblate</div>
<div class="flex flex-row border-b border-coolgray-500 my-6 space-x-2">
<div class="title font-bold pb-3">Weblate</div>
</div>
<div class="grid grid-cols-2 items-center lg:px-10 px-2">
<div class="grid grid-cols-2 items-center px-4">
<label for="adminPassword">Admin password</label>
<CopyPasswordField
name="adminPassword"
@ -19,12 +19,12 @@
/>
</div>
<div class="flex space-x-1 py-5 font-bold">
<div class="title">PostgreSQL</div>
<div class="flex flex-row border-b border-coolgray-500 my-6 space-x-2">
<div class="title font-bold pb-3">PostgreSQL</div>
</div>
<div class="space-y-2">
<div class="grid grid-cols-2 items-center lg:px-10 px-2">
<label for="postgresqlHost">PostgreSQL Host</label>
<div class="grid grid-cols-2 items-center px-4">
<label for="postgresqlHost">Host</label>
<CopyPasswordField
name="postgresqlHost"
id="postgresqlHost"
@ -33,8 +33,8 @@
disabled
/>
</div>
<div class="grid grid-cols-2 items-center lg:px-10 px-2">
<label for="postgresqlPort">PostgreSQL Port</label>
<div class="grid grid-cols-2 items-center px-4">
<label for="postgresqlPort">Port</label>
<CopyPasswordField
name="postgresqlPort"
id="postgresqlPort"
@ -43,8 +43,8 @@
disabled
/>
</div>
<div class="grid grid-cols-2 items-center lg:px-10 px-2">
<label for="postgresqlUser">PostgreSQL User</label>
<div class="grid grid-cols-2 items-center px-4">
<label for="postgresqlUser">User</label>
<CopyPasswordField
name="postgresqlUser"
id="postgresqlUser"
@ -53,8 +53,8 @@
disabled
/>
</div>
<div class="grid grid-cols-2 items-center lg:px-10 px-2">
<label for="postgresqlPassword">PostgreSQL Password</label>
<div class="grid grid-cols-2 items-center px-4">
<label for="postgresqlPassword">Password</label>
<CopyPasswordField
name="postgresqlPassword"
id="postgresqlPassword"

View File

@ -66,11 +66,11 @@
}
</script>
<div class="flex space-x-1 py-5 font-bold">
<div class="title">Wordpress</div>
<div class="flex flex-row border-b border-coolgray-500 my-6 space-x-2">
<div class="title font-bold pb-3">WordPress</div>
</div>
<div class="grid grid-cols-2 items-center lg:px-10 px-2">
<div class="grid grid-cols-2 items-center px-4">
<label for="extraConfig">{$t('forms.extra_config')}</label>
<textarea
class="w-full"
@ -90,7 +90,7 @@ define('SUBDOMAIN_INSTALL', false);`
: 'N/A'}
/>
</div>
<div class="grid grid-cols-2 items-center lg:px-10 px-2">
<div class="grid grid-cols-2 items-center px-4">
<Setting
id="ftpEnabled"
bind:setting={service.wordpress.ftpEnabled}
@ -102,15 +102,15 @@ define('SUBDOMAIN_INSTALL', false);`
/>
</div>
{#if service.wordpress.ftpEnabled}
<div class="grid grid-cols-2 items-center lg:px-10 px-2">
<div class="grid grid-cols-2 items-center px-4">
<label for="ftpUrl">sFTP Connection URI</label>
<CopyPasswordField id="ftpUrl" readonly disabled name="ftpUrl" value={ftpUrl} />
</div>
<div class="grid grid-cols-2 items-center lg:px-10 px-2">
<div class="grid grid-cols-2 items-center px-4">
<label for="ftpUser">User</label>
<CopyPasswordField id="ftpUser" readonly disabled name="ftpUser" value={ftpUser} />
</div>
<div class="grid grid-cols-2 items-center lg:px-10 px-2">
<div class="grid grid-cols-2 items-center px-4">
<label for="ftpPassword">Password</label>
<CopyPasswordField
id="ftpPassword"
@ -122,11 +122,11 @@ define('SUBDOMAIN_INSTALL', false);`
/>
</div>
{/if}
<div class="flex space-x-1 py-5 font-bold">
<div class="title">MySQL</div>
<div class="flex flex-row border-b border-coolgray-500 my-6 space-x-2">
<div class="title font-bold pb-3">MySQL</div>
</div>
<div class="space-y-2">
<div class="grid grid-cols-2 items-center lg:px-10 px-2">
<div class="grid grid-cols-2 items-center px-4">
<Setting
id="ownMysql"
dataTooltip={$t('forms.must_be_stopped_to_modify')}
@ -138,7 +138,7 @@ define('SUBDOMAIN_INSTALL', false);`
/>
</div>
{#if service.wordpress.ownMysql}
<div class="grid grid-cols-2 items-center lg:px-10 px-2">
<div class="grid grid-cols-2 items-center px-4">
<label for="mysqlHost">Host</label>
<input
class="w-full"
@ -151,7 +151,7 @@ define('SUBDOMAIN_INSTALL', false);`
placeholder="{$t('forms.eg')}: db.coolify.io"
/>
</div>
<div class="grid grid-cols-2 items-center lg:px-10 px-2">
<div class="grid grid-cols-2 items-center px-4">
<label for="mysqlPort">Port</label>
<input
class="w-full"
@ -165,7 +165,7 @@ define('SUBDOMAIN_INSTALL', false);`
/>
</div>
{/if}
<div class="grid grid-cols-2 items-center lg:px-10 px-2">
<div class="grid grid-cols-2 items-center px-4">
<label for="mysqlDatabase">{$t('index.database')}</label>
<input
class="w-full"
@ -179,7 +179,7 @@ define('SUBDOMAIN_INSTALL', false);`
/>
</div>
{#if !service.wordpress.ownMysql}
<div class="grid grid-cols-2 items-center lg:px-10 px-2">
<div class="grid grid-cols-2 items-center px-4">
<label for="mysqlRootUser">{$t('forms.root_user')}</label>
<input
class="w-full"
@ -191,7 +191,7 @@ define('SUBDOMAIN_INSTALL', false);`
disabled={$status.service.isRunning || !service.wordpress.ownMysql}
/>
</div>
<div class="grid grid-cols-2 items-center lg:px-10 px-2">
<div class="grid grid-cols-2 items-center px-4">
<label for="mysqlRootUserPassword">{$t('forms.roots_password')}</label>
<CopyPasswordField
id="mysqlRootUserPassword"
@ -203,7 +203,7 @@ define('SUBDOMAIN_INSTALL', false);`
/>
</div>
{/if}
<div class="grid grid-cols-2 items-center lg:px-10 px-2">
<div class="grid grid-cols-2 items-center px-4">
<label for="mysqlUser">{$t('forms.user')}</label>
<input
class="w-full"
@ -214,7 +214,7 @@ define('SUBDOMAIN_INSTALL', false);`
disabled={$status.service.isRunning || !service.wordpress.ownMysql}
/>
</div>
<div class="grid grid-cols-2 items-center lg:px-10 px-2">
<div class="grid grid-cols-2 items-center px-4">
<label for="mysqlPassword">{$t('forms.password')}</label>
<CopyPasswordField
id="mysqlPassword"

View File

@ -0,0 +1,15 @@
//@ts-nocheck
export { default as Plausibleanalytics } from './PlausibleAnalytics.svelte';
export { default as Minio } from './MinIO.svelte';
export { default as Vscodeserver } from './VSCodeServer.svelte';
export { default as Wordpress } from './Wordpress.svelte';
export { default as Ghost } from './Ghost.svelte';
export { default as Meilisearch } from './MeiliSearch.svelte';
export { default as Umami } from './Umami.svelte';
export { default as Hasura } from './Hasura.svelte';
export { default as Fider } from './Fider.svelte';
export { default as Appwrite } from './Appwrite.svelte';
export { default as Moodle } from './Moodle.svelte';
export { default as Glitchtip } from './GlitchTip.svelte';
export { default as Searxng } from './Searxng.svelte';
export { default as Weblate } from './Weblate.svelte';

View File

@ -0,0 +1,15 @@
export function getStatusOfService(service: any) {
if (service) {
if (service.status.isRunning === 'running') {
return 'running';
}
if (service.status.isExited === 'exited') {
return 'stopped';
}
if (service.status.isRestarting === 'degraded') {
return 'degraded';
}
}
return 'stopped';
}

View File

@ -4,7 +4,7 @@
<svg
xmlns="http://www.w3.org/2000/svg"
class={isAbsolute ? 'w-14 h-14 absolute top-0 left-0 -m-5' : 'w-9 h-9 mx-auto'}
class={isAbsolute ? 'w-14 h-14 absolute top-0 left-0 -m-5' : 'w-7 h-7 mx-auto'}
viewBox="0 0 384 384"
fill="none"
>

View File

@ -5,7 +5,7 @@
<svg
viewBox="0 0 700 240"
xmlns="http://www.w3.org/2000/svg"
class={isAbsolute ? 'w-36 absolute top-0 left-0 -m-3 -mt-5' : 'w-full h-10 mx-auto'}
class={isAbsolute ? 'w-36 absolute top-0 left-0 -m-3 -mt-5' : 'w-16 h-10'}
><path fill="#FDBC3D" d="m90.694 107.498-.981.39-20.608 8.23 6.332 6.547z" /><path
fill="#8EC63F"
d="M61.139 77.914 46.632 93 56.9 103.547c8.649-7.169 17.832-10.502 18.653-10.789L61.139 77.914z"

View File

@ -2,48 +2,7 @@
export let type: string;
export let isAbsolute = true;
import * as Icons from '$lib/components/svg/services';
const name: any = type && type[0].toUpperCase() + type.substring(1).toLowerCase();
</script>
{#if type === 'plausibleanalytics'}
<Icons.PlausibleAnalytics {isAbsolute} />
{:else if type === 'nocodb'}
<Icons.NocoDb {isAbsolute} />
{:else if type === 'minio'}
<Icons.MinIo {isAbsolute} />
{:else if type === 'vscodeserver'}
<Icons.VsCodeServer {isAbsolute} />
{:else if type === 'wordpress'}
<Icons.Wordpress {isAbsolute} />
{:else if type === 'vaultwarden'}
<Icons.VaultWarden {isAbsolute} />
{:else if type === 'languagetool'}
<Icons.LanguageTool {isAbsolute} />
{:else if type === 'n8n'}
<Icons.N8n {isAbsolute} />
{:else if type === 'uptimekuma'}
<Icons.UptimeKuma {isAbsolute} />
{:else if type === 'ghost'}
<Icons.Ghost {isAbsolute} />
{:else if type === 'meilisearch'}
<Icons.MeiliSearch {isAbsolute} />
{:else if type === 'umami'}
<Icons.Umami {isAbsolute} />
{:else if type === 'hasura'}
<Icons.Hasura {isAbsolute} />
{:else if type === 'fider'}
<Icons.Fider {isAbsolute} />
{:else if type === 'appwrite'}
<Icons.Appwrite {isAbsolute} />
{:else if type === 'moodle'}
<Icons.Moodle {isAbsolute} />
{:else if type === 'glitchTip'}
<Icons.GlitchTip {isAbsolute} />
{:else if type === 'searxng'}
<Icons.Searxng {isAbsolute} />
{:else if type === 'weblate'}
<Icons.Weblate {isAbsolute} />
{:else if type === 'grafana'}
<Icons.Grafana {isAbsolute} />
{:else if type === 'trilium'}
<Icons.Trilium {isAbsolute} />
{/if}
<svelte:component this={Icons[name]} {isAbsolute} />

View File

@ -1,21 +1,21 @@
//@ts-nocheck
export { default as PlausibleAnalytics } from './PlausibleAnalytics.svelte';
export { default as NocoDb } from './NocoDB.svelte';
export { default as MinIo } from './MinIO.svelte';
export { default as VsCodeServer } from './VSCodeServer.svelte';
export { default as Plausibleanalytics } from './PlausibleAnalytics.svelte';
export { default as Nocodb } from './NocoDB.svelte';
export { default as Minio } from './MinIO.svelte';
export { default as Vscodeserver } from './VSCodeServer.svelte';
export { default as Wordpress } from './Wordpress.svelte';
export { default as VaultWarden } from './VaultWarden.svelte';
export { default as LanguageTool } from './LanguageTool.svelte';
export { default as Vaultwarden } from './VaultWarden.svelte';
export { default as Languagetool } from './LanguageTool.svelte';
export { default as N8n } from './N8n.svelte';
export { default as UptimeKuma } from './UptimeKuma.svelte';
export { default as Uptimekuma } from './UptimeKuma.svelte';
export { default as Ghost } from './Ghost.svelte';
export { default as MeiliSearch } from './MeiliSearch.svelte';
export { default as Meilisearch } from './MeiliSearch.svelte';
export { default as Umami } from './Umami.svelte';
export { default as Hasura } from './Hasura.svelte';
export { default as Fider } from './Fider.svelte';
export { default as Appwrite } from './Appwrite.svelte';
export { default as Moodle } from './Moodle.svelte';
export { default as GlitchTip } from './GlitchTip.svelte';
export { default as Glitchtip } from './GlitchTip.svelte';
export { default as Searxng } from './Searxng.svelte';
export { default as Weblate } from './Weblate.svelte';
export { default as Grafana } from './Grafana.svelte';

View File

@ -81,8 +81,8 @@ export const status: Writable<any> = writable({
initialLoading: true
},
service: {
isRunning: false,
isExited: false,
statuses: [],
overallStatus: 'stopped',
loading: false,
initialLoading: true
},

View File

@ -6,7 +6,7 @@
<ul class="menu border bg-coolgray-100 border-coolgray-200 rounded p-2 space-y-2 sticky top-4">
<li class="menu-title">
<span>Configuration</span>
<span>General</span>
</li>
{#if application.gitSource?.htmlUrl && application.repository && application.branch}
<li>

View File

@ -91,7 +91,7 @@
required
placeholder="EXAMPLE_VARIABLE"
readonly={!isNewSecret}
class=" w-full"
class="w-full"
class:bg-coolblack={!isNewSecret}
class:border={!isNewSecret}
class:border-dashed={!isNewSecret}
@ -166,7 +166,7 @@
</div>
<div class="flex flex-row lg:flex-col lg:items-center items-start">
{#if (index === 0 && !isNewSecret) || length === 0}
<label for="name" class="pb-2 uppercase lg:block hidden">Actions</label>
<label for="name" class="pb-2 uppercase lg:block hidden">Action</label>
{/if}
<div class="flex justify-center h-full items-center pt-3">

View File

@ -59,9 +59,8 @@
}
</script>
<div class="w-full font-bold grid gap-2">
<div class="w-full grid gap-2">
<div class="flex flex-col pb-2">
<div class="flex flex-col lg:flex-row lg:space-y-0 space-y-2">
<input
class="w-full lg:w-64"

View File

@ -233,7 +233,7 @@
class:text-red-500={$status.application.overallStatus === 'stopped'}
>
{$status.application.overallStatus === 'healthy'
? 'Running'
? 'Healthy'
: $status.application.overallStatus === 'degraded'
? 'Degraded'
: 'Stopped'}

View File

@ -244,7 +244,7 @@
{/if}
</div>
<div class="flex flex-row items-center">
<div class="title py-4">Public Repository</div>
<div class="title py-4 pr-4">Public Repository</div>
<DocLink url="https://docs.coollabs.io/coolify/applications/#public-repository" />
</div>
<PublicRepository />

View File

@ -61,7 +61,7 @@
disabled={!$appSession.isAdmin}
class:bg-red-600={$appSession.isAdmin}
class:hover:bg-red-500={$appSession.isAdmin}
class="btn btn-sm btn-error text-sm"
class="btn btn-lg btn-error text-sm"
>
Force Delete Application
</button>

View File

@ -459,7 +459,7 @@
<form on:submit|preventDefault={() => handleSubmit()}>
<div class="mx-auto w-full">
<div class="flex flex-row border-b border-coolgray-500 mb-6 space-x-2">
<div class="title font-bold pb-3 ">General</div>
<div class="title font-bold pb-3">General</div>
{#if $appSession.isAdmin}
<button
class="btn btn-sm btn-primary"

View File

@ -135,7 +135,7 @@
<div class="text-xl font-bold tracking-tighter">Container not found / exited.</div>
{/if}
{:else}
<div class="relative w-full">
<div class="relative w-full"></div>
<div class="flex justify-start sticky space-x-2 pb-2">
<button
on:click={followBuild}
@ -162,8 +162,9 @@
{followingLogs ? 'Following Logs...' : 'Follow Logs'}
</button>
{#if loadLogsInterval}
<button id="streaming" class="btn btn-sm bg-transparent border-none loading" />
<Tooltip triggeredBy="#streaming">Streaming logs</Tooltip>
<button id="streaming" class="btn btn-sm bg-transparent border-none loading"
>Streaming logs</button
>
{/if}
</div>
<div

View File

@ -88,10 +88,6 @@
);
batchSecrets = '';
await refreshSecrets();
// addToast({
// message: 'Secrets saved.',
// type: 'success'
// });
}
</script>

View File

@ -0,0 +1,136 @@
<script lang="ts">
export let service: any;
import { status } from '$lib/store';
import { page } from '$app/stores';
import ServiceLinks from './_ServiceLinks.svelte';
</script>
<ul class="menu border bg-coolgray-100 border-coolgray-200 rounded p-2 space-y-2 sticky top-4">
<li class="menu-title">
<span>General</span>
</li>
<li class="rounded">
<ServiceLinks {service} linkToDocs={true} />
</li>
<li class="rounded" class:bg-coollabs={$page.url.pathname === `/services/${$page.params.id}`}>
<a href={`/services/${$page.params.id}`} class="no-underline w-full"
><svg
xmlns="http://www.w3.org/2000/svg"
class="w-6 h-6"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path
d="M7 10h3v-3l-3.5 -3.5a6 6 0 0 1 8 8l6 6a2 2 0 0 1 -3 3l-6 -6a6 6 0 0 1 -8 -8l3.5 3.5"
/>
</svg>Configurations</a
>
</li>
<li
class="rounded"
class:bg-coollabs={$page.url.pathname === `/services/${$page.params.id}/secrets`}
>
<a href={`/services/${$page.params.id}/secrets`} class="no-underline w-full"
><svg
xmlns="http://www.w3.org/2000/svg"
class="w-6 h-6"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path
d="M12 3a12 12 0 0 0 8.5 3a12 12 0 0 1 -8.5 15a12 12 0 0 1 -8.5 -15a12 12 0 0 0 8.5 -3"
/>
<circle cx="12" cy="11" r="1" />
<line x1="12" y1="12" x2="12" y2="14.5" />
</svg>Secrets</a
>
</li>
<li
class="rounded"
class:bg-coollabs={$page.url.pathname === `/services/${$page.params.id}/storages`}
>
<a href={`/services/${$page.params.id}/storages`} class="no-underline w-full"
><svg
xmlns="http://www.w3.org/2000/svg"
class="w-6 h-6"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<ellipse cx="12" cy="6" rx="8" ry="3" />
<path d="M4 6v6a8 3 0 0 0 16 0v-6" />
<path d="M4 12v6a8 3 0 0 0 16 0v-6" />
</svg>Persistent Volumes</a
>
</li>
<li class="menu-title">
<span>Logs</span>
</li>
<li
class:text-stone-600={$status.service.overallStatus === 'stopped'}
class="rounded"
class:bg-coollabs={$page.url.pathname === `/services/${$page.params.id}/logs`}
>
<a
href={$status.service.overallStatus !== 'stopped' ? `/services/${$page.params.id}/logs` : ''}
class="no-underline w-full"
><svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M3 19a9 9 0 0 1 9 0a9 9 0 0 1 9 0" />
<path d="M3 6a9 9 0 0 1 9 0a9 9 0 0 1 9 0" />
<line x1="3" y1="6" x2="3" y2="19" />
<line x1="12" y1="6" x2="12" y2="19" />
<line x1="21" y1="6" x2="21" y2="19" />
</svg>Service</a
>
</li>
<li class="menu-title">
<span>Advanced</span>
</li>
<li
class="rounded"
class:bg-coollabs={$page.url.pathname === `/services/${$page.params.id}/danger`}
>
<a href={`/services/${$page.params.id}/danger`} class="no-underline w-full"
><svg
xmlns="http://www.w3.org/2000/svg"
class="w-6 h-6"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M12 9v2m0 4v.01" />
<path
d="M5 19h14a2 2 0 0 0 1.84 -2.75l-7.1 -12.25a2 2 0 0 0 -3.5 0l-7.1 12.25a2 2 0 0 0 1.75 2.75"
/>
</svg>Danger Zone</a
>
</li>
</ul>

View File

@ -50,14 +50,17 @@
<td>
<input
style="min-width: 350px !important;"
style="min-width: 350px !important;"
id={isNewSecret ? 'secretName' : 'secretNameNew'}
bind:value={name}
required
placeholder="EXAMPLE_VARIABLE"
readonly={!isNewSecret}
class:bg-transparent={!isNewSecret}
class="w-full"
class:bg-coolblack={!isNewSecret}
class:border={!isNewSecret}
class:border-dashed={!isNewSecret}
class:border-coolgray-300={!isNewSecret}
class:cursor-not-allowed={!isNewSecret}
/>
</td>
@ -67,7 +70,6 @@
name={isNewSecret ? 'secretValue' : 'secretValueNew'}
isPasswordField={true}
bind:value
required
placeholder="J$#@UIO%HO#$U%H"
inputStyle="min-width: 350px; !important"
/>
@ -76,12 +78,12 @@
<td>
{#if isNewSecret}
<div class="flex items-center justify-center">
<button class="btn btn-sm bg-services" on:click={() => saveSecret(true)}>Add</button>
<button class="btn btn-sm btn-primary" on:click={() => saveSecret(true)}>Add</button>
</div>
{:else}
<div class="flex flex-row justify-center space-x-2">
<div class="flex items-center justify-center">
<button class="btn btn-sm bg-services" on:click={() => saveSecret(false)}>Set</button>
<button class="btn btn-sm btn-primary" on:click={() => saveSecret(false)}>Set</button>
</div>
<div class="flex justify-center items-end">
<button class="btn btn-sm bg-error" on:click={removeSecret}>Remove</button>

View File

@ -1,86 +1,45 @@
<script lang="ts">
import DocLink from '$lib/components/DocLink.svelte';
export let service: any;
export let linkToDocs: boolean = false;
import ExternalLink from '$lib/components/ExternalLink.svelte';
import * as Icons from '$lib/components/svg/services';
const name: any = service.type && service.type[0].toUpperCase() + service.type.substring(1);
const links: any = {
plausible: 'https://plausible.io/doc/',
nocodb: 'https://docs.nocodb.com',
minio: 'https://min.io/docs/minio',
vscodeserver: 'https://coder.com/docs/coder-oss/latest',
wordpress: 'https://wordpress.org/',
vaultwarden: 'https://github.com/dani-garcia/vaultwarden',
languagetool: 'https://languagetool.org/dev',
n8n: 'https://docs.n8n.io',
uptimekuma: 'https://github.com/louislam/uptime-kuma',
ghost: 'https://ghost.org/resources/',
umami: 'https://umami.is/docs/getting-started',
hasura: 'https://hasura.io/docs/latest/index/',
fider: 'https://fider.io/docs',
appwrite: 'https://appwrite.io/docs',
moodle: 'https://docs.moodle.org/400/en/Main_page',
glitchtip: 'https://glitchtip.com/documentation',
searxng: 'https://searxng.org',
weblate: 'https://docs.weblate.org/en/latest/',
grafana: 'https://github.com/grafana/grafana',
trilium: 'https://github.com/zadam/trilium'
};
</script>
{#if service.type === 'plausibleanalytics'}
<a href="https://plausible.io" target="_blank">
<Icons.PlausibleAnalytics />
</a>
{:else if service.type === 'nocodb'}
<a href="https://nocodb.com" target="_blank">
<Icons.NocoDb />
</a>
{:else if service.type === 'minio'}
<a href="https://min.io" target="_blank">
<Icons.MinIo />
</a>
{:else if service.type === 'vscodeserver'}
<a href="https://coder.com" target="_blank">
<Icons.VsCodeServer />
</a>
{:else if service.type === 'wordpress'}
<a href="https://wordpress.org" target="_blank">
<Icons.Wordpress />
</a>
{:else if service.type === 'vaultwarden'}
<a href="https://github.com/dani-garcia/vaultwarden" target="_blank">
<Icons.VaultWarden />
</a>
{:else if service.type === 'languagetool'}
<a href="https://languagetool.org/dev" target="_blank">
<Icons.LanguageTool />
</a>
{:else if service.type === 'n8n'}
<a href="https://n8n.io" target="_blank">
<Icons.N8n />
</a>
{:else if service.type === 'uptimekuma'}
<a href="https://github.com/louislam/uptime-kuma" target="_blank">
<Icons.UptimeKuma />
</a>
{:else if service.type === 'ghost'}
<a href="https://ghost.org" target="_blank">
<Icons.Ghost />
</a>
{:else if service.type === 'umami'}
<a href="https://umami.is" target="_blank">
<Icons.Umami />
</a>
{:else if service.type === 'hasura'}
<a href="https://hasura.io" target="_blank">
<Icons.Hasura />
</a>
{:else if service.type === 'fider'}
<a href="https://fider.io" target="_blank">
<Icons.Fider />
</a>
{:else if service.type === 'appwrite'}
<a href="https://appwrite.io" target="_blank">
<Icons.Appwrite />
</a>
{:else if service.type === 'moodle'}
<a href="https://moodle.org" target="_blank">
<Icons.Moodle />
</a>
{:else if service.type === 'glitchTip'}
<a href="https://glitchtip.com" target="_blank">
<Icons.GlitchTip />
</a>
{:else if service.type === 'searxng'}
<a href="https://searxng.org" target="_blank">
<Icons.Searxng />
</a>
{:else if service.type === 'weblate'}
<a href="https://weblate.org" target="_blank">
<Icons.Weblate />
</a>
{:else if service.type === 'grafana'}
<a href="https://github.com/grafana/grafana" target="_blank">
<Icons.Grafana />
</a>
{:else if service.type === 'trilium'}
<a href="https://github.com/zadam/trilium" target="_blank">
<Icons.Trilium />
</a>
{#if linkToDocs}
<DocLink url={links[service.type]} text={`Open documentation`} isExternal={true} />
{:else}
<svelte:component this={Icons[name]} />
{/if}
<!-- <a href={links[service.type]} target="_blank" class="no-underline">
{#if linkToDocs}
Open Documentation
<ExternalLink />
{:else}
{/if}
</a> -->

View File

@ -1,99 +0,0 @@
<script lang="ts">
import CopyPasswordField from '$lib/components/CopyPasswordField.svelte';
import Explainer from '$lib/components/Explainer.svelte';
import { t } from '$lib/translations';
export let readOnly: any;
export let service: any;
</script>
<div class="flex space-x-1 py-5">
<div class="title">
Ghost <Explainer explanation="You can change these values in the <span class='text-settings'>Ghost admin panel<span>." />
</div>
</div>
<div class="space-y-2">
<div class="grid grid-cols-2 items-center lg:px-10 px-2">
<label for="email">{$t('forms.default_email_address')}</label>
<input
class="w-full"
name="email"
id="email"
disabled
readonly
placeholder={$t('forms.email')}
value={service.ghost.defaultEmail}
required
/>
</div>
<div class="grid grid-cols-2 items-center lg:px-10 px-2">
<label for="defaultPassword">{$t('forms.default_password')}</label>
<CopyPasswordField
id="defaultPassword"
isPasswordField
readonly
disabled
name="defaultPassword"
value={service.ghost.defaultPassword}
/>
</div>
</div>
<div class="flex space-x-1 py-5 font-bold">
<div class="title">MariaDB</div>
</div>
<div class="space-y-2">
<div class="grid grid-cols-2 items-center lg:px-10 px-2">
<label for="mariadbUser">{$t('forms.username')}</label>
<CopyPasswordField
name="mariadbUser"
id="mariadbUser"
value={service.ghost.mariadbUser}
readonly
disabled
/>
</div>
<div class="grid grid-cols-2 items-center lg:px-10 px-2">
<label for="mariadbPassword">{$t('forms.password')}</label>
<CopyPasswordField
id="mariadbPassword"
isPasswordField
readonly
disabled
name="mariadbPassword"
value={service.ghost.mariadbPassword}
/>
</div>
<div class="grid grid-cols-2 items-center lg:px-10 px-2">
<label for="mariadbDatabase">{$t('index.database')}</label>
<input
class="w-full"
name="mariadbDatabase"
id="mariadbDatabase"
required
readonly={readOnly}
disabled={readOnly}
bind:value={service.ghost.mariadbDatabase}
placeholder="{$t('forms.eg')}: ghost_db"
/>
</div>
<div class="grid grid-cols-2 items-center lg:px-10 px-2">
<label for="mariadbRootUser">{$t('forms.root_db_user')}</label>
<CopyPasswordField
id="mariadbRootUser"
readonly
disabled
name="mariadbRootUser"
value={service.ghost.mariadbRootUser}
/>
</div>
<div class="grid grid-cols-2 items-center lg:px-10 px-2">
<label for="mariadbRootUserPassword">{$t('forms.root_db_password')}</label>
<CopyPasswordField
id="mariadbRootUserPassword"
isPasswordField
readonly
disabled
name="mariadbRootUserPassword"
value={service.ghost.mariadbRootUserPassword}
/>
</div>
</div>

View File

@ -1,450 +0,0 @@
<script lang="ts">
export let service: any;
export let readOnly: any;
export let settings: any;
import cuid from 'cuid';
import { onMount } from 'svelte';
import { browser } from '$app/env';
import { page } from '$app/stores';
import { get, post } from '$lib/api';
import { errorNotification, getDomain } from '$lib/common';
import { t } from '$lib/translations';
import {
appSession,
status,
setLocation,
addToast,
checkIfDeploymentEnabledServices,
isDeploymentEnabled
} from '$lib/store';
import CopyPasswordField from '$lib/components/CopyPasswordField.svelte';
import Setting from '$lib/components/Setting.svelte';
import Fider from './_Fider.svelte';
import Ghost from './_Ghost.svelte';
import GlitchTip from './_GlitchTip.svelte';
import Hasura from './_Hasura.svelte';
import MeiliSearch from './_MeiliSearch.svelte';
import MinIo from './_MinIO.svelte';
import PlausibleAnalytics from './_PlausibleAnalytics.svelte';
import Umami from './_Umami.svelte';
import VsCodeServer from './_VSCodeServer.svelte';
import Wordpress from './_Wordpress.svelte';
import Appwrite from './_Appwrite.svelte';
import Moodle from './_Moodle.svelte';
import Searxng from './_Searxng.svelte';
import Weblate from './_Weblate.svelte';
import Explainer from '$lib/components/Explainer.svelte';
import Taiga from './_Taiga.svelte';
import DocLink from '$lib/components/DocLink.svelte';
const { id } = $page.params;
$: isDisabled =
!$appSession.isAdmin || $status.service.isRunning || $status.service.initialLoading;
let forceSave = false;
let loading = {
save: false,
verification: false,
cleanup: false
};
let dualCerts = service.dualCerts;
let nonWWWDomain = service.fqdn && getDomain(service.fqdn).replace(/^www\./, '');
let isNonWWWDomainOK = false;
let isWWWDomainOK = false;
async function isDNSValid(domain: any, isWWW: any) {
try {
await get(`/services/${id}/check?domain=${domain}`);
addToast({
message: 'DNS configuration is valid.',
type: 'success'
});
isWWW ? (isWWWDomainOK = true) : (isNonWWWDomainOK = true);
return true;
} catch (error) {
errorNotification(error);
isWWW ? (isWWWDomainOK = false) : (isNonWWWDomainOK = false);
return false;
}
}
async function handleSubmit() {
if (loading.save) return;
loading.save = true;
try {
await post(`/services/${id}/check`, {
fqdn: service.fqdn,
forceSave,
dualCerts,
otherFqdns: service.minio?.apiFqdn ? [service.minio?.apiFqdn] : [],
exposePort: service.exposePort
});
await post(`/services/${id}`, { ...service });
setLocation(service);
forceSave = false;
$isDeploymentEnabled = checkIfDeploymentEnabledServices($appSession.isAdmin, service);
return addToast({
message: 'Configuration saved.',
type: 'success'
});
} catch (error) {
//@ts-ignore
if (error?.message.startsWith($t('application.dns_not_set_partial_error'))) {
forceSave = true;
if (dualCerts) {
isNonWWWDomainOK = await isDNSValid(getDomain(nonWWWDomain), false);
isWWWDomainOK = await isDNSValid(getDomain(`www.${nonWWWDomain}`), true);
} else {
const isWWW = getDomain(service.fqdn).includes('www.');
if (isWWW) {
isWWWDomainOK = await isDNSValid(getDomain(`www.${nonWWWDomain}`), true);
} else {
isNonWWWDomainOK = await isDNSValid(getDomain(nonWWWDomain), false);
}
}
}
return errorNotification(error);
} finally {
loading.save = false;
}
}
async function setEmailsToVerified() {
loading.verification = true;
try {
await post(`/services/${id}/${service.type}/activate`, { id: service.id });
return addToast({
message: t.get('services.all_email_verified'),
type: 'success'
});
} catch (error) {
return errorNotification(error);
} finally {
loading.verification = false;
}
}
async function migrateAppwriteDB() {
loading.verification = true;
try {
await post(`/services/${id}/${service.type}/migrate`, { id: service.id });
return addToast({
message: "Appwrite's database has been migrated.",
type: 'success'
});
} catch (error) {
return errorNotification(error);
} finally {
loading.verification = false;
}
}
async function changeSettings(name: any) {
try {
if (name === 'dualCerts') {
dualCerts = !dualCerts;
}
await post(`/services/${id}/settings`, { dualCerts });
return addToast({
message: t.get('application.settings_saved'),
type: 'success'
});
} catch (error) {
return errorNotification(error);
}
}
async function cleanupLogs() {
loading.cleanup = true;
try {
await post(`/services/${id}/${service.type}/cleanup`, { id: service.id });
return addToast({
message: 'Cleared DB Logs',
type: 'success'
});
} catch (error) {
return errorNotification(error);
} finally {
loading.cleanup = false;
}
}
function doNothing() {
return;
}
onMount(async () => {
if (browser && window.location.hostname === 'demo.coolify.io' && !service.fqdn) {
service.fqdn = `http://${cuid()}.demo.coolify.io`;
if (service.type === 'wordpress') {
service.wordpress.mysqlDatabase = 'db';
}
if (service.type === 'plausibleanalytics') {
service.plausibleAnalytics.email = 'noreply@demo.com';
service.plausibleAnalytics.username = 'admin';
}
if (service.type === 'minio') {
service.minio.apiFqdn = `http://${cuid()}.demo.coolify.io`;
}
if (service.type === 'ghost') {
service.ghost.mariadbDatabase = 'db';
}
if (service.type === 'fider') {
service.fider.emailNoreply = 'noreply@demo.com';
}
await handleSubmit();
}
});
</script>
<div class="mx-auto max-w-6xl px-6 pb-12">
<form on:submit|preventDefault={handleSubmit} class="py-4">
<div class="flex space-x-1 pb-5 items-center">
<h1 class="title">{$t('general')}</h1>
<div class="flex flex-row space-x-2 items-center">
{#if $appSession.isAdmin}
<button
type="submit"
class="btn btn-sm"
class:bg-orange-600={forceSave}
class:hover:bg-orange-400={forceSave}
class:loading={loading.save}
class:bg-services={!loading.save}
disabled={loading.save}
>{loading.save
? $t('forms.save')
: forceSave
? $t('forms.confirm_continue')
: $t('forms.save')}</button
>
{/if}
{#if service.type === 'plausibleanalytics' && $status.service.isRunning}
<div class="btn-group">
<button
class="btn btn-sm"
on:click|preventDefault={setEmailsToVerified}
disabled={loading.verification}
class:loading={loading.verification}
>{loading.verification
? $t('forms.verifying')
: $t('forms.verify_emails_without_smtp')}</button
>
<button
class="btn btn-sm"
on:click|preventDefault={cleanupLogs}
disabled={loading.cleanup}
class:loading={loading.cleanup}>Cleanup Unnecessary Database Logs</button
>
</div>
{/if}
{#if service.type === 'appwrite' && $status.service.isRunning}
<button
class="btn btn-sm"
on:click|preventDefault={migrateAppwriteDB}
disabled={loading.verification}
class:loading={loading.verification}
>{loading.verification
? 'Migrating... it may take a while...'
: "Migrate Appwrite's Database"}</button
>
<DocLink url="https://appwrite.io/docs/upgrade#run-the-migration" />
{/if}
</div>
</div>
{#if service.type === 'minio' && !service.minio.apiFqdn && $status.service.isRunning}
<div class="py-5">
<span class="font-bold text-red-500">IMPORTANT!</span> There was a small modification with Minio
in the latest version of Coolify. Now you can separate the Console URL from the API URL, so you
could use both through SSL. But this proccess cannot be done automatically, so you have to stop
your Minio instance, configure the new domain and start it back. Sorry for any inconvenience.
</div>
{/if}
<div class="grid gap-2 grid-cols-1 grid-rows-1 lg:px-10 px-2">
<div class="mt-2 grid grid-cols-2 items-center">
<label for="name">{$t('forms.name')}</label>
<input name="name" id="name" class="w-full" bind:value={service.name} required />
</div>
<div class="grid grid-cols-2 items-center">
<label for="version">Version / Tag</label>
<a
href={$appSession.isAdmin && !$status.service.isRunning && !$status.service.initialLoading
? `/services/${id}/configuration/version?from=/services/${id}`
: ''}
class="no-underline"
>
<input
class="w-full"
value={service.version}
id="service"
readonly
disabled={$status.service.isRunning || $status.service.initialLoading}
class:cursor-pointer={!$status.service.isRunning}
/></a
>
</div>
<div class="grid grid-cols-2 items-center">
<label for="destination">{$t('application.destination')}</label>
<div>
{#if service.destinationDockerId}
<div class="no-underline">
<input
value={service.destinationDocker.name}
id="destination"
disabled
class="bg-transparent w-full"
/>
</div>
{/if}
</div>
</div>
{#if service.type === 'minio'}
<div class="grid grid-cols-2 items-center">
<label for="fqdn">Console URL</label>
<CopyPasswordField
placeholder="eg: https://console.min.io"
readonly={isDisabled}
disabled={isDisabled}
name="fqdn"
id="fqdn"
pattern="^https?://([a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{'{'}2,{'}'}$"
bind:value={service.fqdn}
required
/>
</div>
<div class="grid grid-cols-2 items-center">
<label for="apiFqdn"
>API URL <Explainer explanation={$t('application.https_explainer')} /></label
>
<CopyPasswordField
placeholder="eg: https://min.io"
readonly={!$appSession.isAdmin && !$status.service.isRunning}
disabled={isDisabled}
name="apiFqdn"
id="apiFqdn"
pattern="^https?://([a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{'{'}2,{'}'}$"
bind:value={service.minio.apiFqdn}
required
/>
</div>
{:else}
<div class="grid grid-cols-2 items-center">
<label for="fqdn"
>{$t('application.url_fqdn')}
<Explainer explanation={$t('application.https_explainer')} />
</label>
<CopyPasswordField
placeholder="eg: https://analytics.coollabs.io"
readonly={!$appSession.isAdmin && !$status.service.isRunning}
disabled={!$appSession.isAdmin ||
$status.service.isRunning ||
$status.service.initialLoading}
name="fqdn"
id="fqdn"
pattern="^https?://([a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{'{'}2,{'}'}$"
bind:value={service.fqdn}
required
/>
</div>
{/if}
</div>
{#if forceSave}
<div class="flex-col space-y-2 pt-4 text-center">
{#if isNonWWWDomainOK}
<button
class="btn btn-sm bg-green-600 hover:bg-green-500"
on:click|preventDefault={() => isDNSValid(getDomain(nonWWWDomain), false)}
>DNS settings for {nonWWWDomain} is OK, click to recheck.</button
>
{:else}
<button
class="btn btn-sm bg-red-600 hover:bg-red-500"
on:click|preventDefault={() => isDNSValid(getDomain(nonWWWDomain), false)}
>DNS settings for {nonWWWDomain} is invalid, click to recheck.</button
>
{/if}
{#if dualCerts}
{#if isWWWDomainOK}
<button
class="btn btn-sm bg-green-600 hover:bg-green-500"
on:click|preventDefault={() => isDNSValid(getDomain(`www.${nonWWWDomain}`), true)}
>DNS settings for www.{nonWWWDomain} is OK, click to recheck.</button
>
{:else}
<button
class="btn btn-sm bg-red-600 hover:bg-red-500"
on:click|preventDefault={() => isDNSValid(getDomain(`www.${nonWWWDomain}`), true)}
>DNS settings for www.{nonWWWDomain} is invalid, click to recheck.</button
>
{/if}
{/if}
</div>
{/if}
<div class="grid grid-flow-row gap-2 lg:px-10 px-2 pt-2">
<div class="grid grid-cols-2 items-center">
<Setting
id="dualCerts"
disabled={$status.service.isRunning}
dataTooltip={$t('forms.must_be_stopped_to_modify')}
bind:setting={dualCerts}
title={$t('application.ssl_www_and_non_www')}
description={$t('services.generate_www_non_www_ssl')}
on:click={() => !$status.service.isRunning && changeSettings('dualCerts')}
/>
</div>
<div class="grid grid-cols-2 items-center">
<label for="exposePort"
>Exposed Port <Explainer
explanation={'You can expose your application to a port on the host system.<br><br>Useful if you would like to use your own reverse proxy or tunnel and also in development mode. Otherwise leave empty.'}
/></label
>
<input
class="w-full"
readonly={!$appSession.isAdmin && !$status.service.isRunning}
disabled={!$appSession.isAdmin ||
$status.service.isRunning ||
$status.service.initialLoading}
name="exposePort"
id="exposePort"
bind:value={service.exposePort}
placeholder="12345"
/>
</div>
</div>
{#if service.type === 'plausibleanalytics'}
<PlausibleAnalytics bind:service {readOnly} />
{:else if service.type === 'minio'}
<MinIo {service} />
{:else if service.type === 'vscodeserver'}
<VsCodeServer {service} />
{:else if service.type === 'wordpress'}
<Wordpress bind:service {readOnly} {settings} />
{:else if service.type === 'ghost'}
<Ghost bind:service {readOnly} />
{:else if service.type === 'meilisearch'}
<MeiliSearch bind:service />
{:else if service.type === 'umami'}
<Umami bind:service />
{:else if service.type === 'hasura'}
<Hasura bind:service />
{:else if service.type === 'fider'}
<Fider bind:service {readOnly} />
{:else if service.type === 'appwrite'}
<Appwrite bind:service {readOnly} />
{:else if service.type === 'moodle'}
<Moodle bind:service {readOnly} />
{:else if service.type === 'glitchTip'}
<GlitchTip bind:service />
{:else if service.type === 'searxng'}
<Searxng bind:service />
{:else if service.type === 'weblate'}
<Weblate bind:service />
{:else if service.type === 'taiga'}
<Taiga bind:service />
{/if}
</form>
</div>

View File

@ -8,6 +8,7 @@
import { page } from '$app/stores';
import { createEventDispatcher } from 'svelte';
import { t } from '$lib/translations';
import { errorNotification } from '$lib/common';
import { addToast } from '$lib/store';
const { id } = $page.params;
@ -15,7 +16,7 @@
const dispatch = createEventDispatcher();
async function saveStorage(newStorage = false) {
try {
if (!storage.path) return errorNotification({message: "Path is required!"});
if (!storage.path) return errorNotification($t('application.storage.path_is_required'));
storage.path = storage.path.startsWith('/') ? storage.path : `/${storage.path}`;
storage.path = storage.path.endsWith('/') ? storage.path.slice(0, -1) : storage.path;
storage.path.replace(/\/\//g, '/');
@ -29,10 +30,17 @@
storage.path = null;
storage.id = null;
}
addToast({
message: 'Storage saved.',
type: 'success'
});
if (newStorage) {
addToast({
message: $t('application.storage.storage_saved'),
type: 'success'
});
} else {
addToast({
message: $t('application.storage.storage_updated'),
type: 'success'
});
}
} catch (error) {
return errorNotification(error);
}
@ -41,46 +49,45 @@
try {
await del(`/services/${id}/storages`, { path: storage.path });
dispatch('refresh');
return addToast({
message: 'Storage deleted.',
addToast({
message: $t('application.storage.storage_deleted'),
type: 'success'
});
} catch (error) {
return errorNotification(error);
}
}
async function handleSubmit() {
if (isNew) {
await saveStorage(true);
} else {
await saveStorage(false);
}
}
</script>
<td>
<form on:submit|preventDefault={handleSubmit}>
<input
bind:value={storage.path}
required
placeholder="eg: /data"
/>
</form>
</td>
<td>
{#if isNew}
<div class="flex items-center justify-center">
<button class="btn btn-sm btn-success" on:click={() => saveStorage(true)}>Add</button
>
<div class="w-fullgrid gap-2">
<div class="flex flex-col pb-2">
<div class="flex flex-col lg:flex-row lg:space-y-0 space-y-2">
<input
class="w-full lg:w-64"
bind:value={storage.path}
required
placeholder="eg: /sqlite.db"
/>
{#if isNew}
<div class="flex items-center justify-center w-full lg:w-64">
<button class="btn btn-sm btn-primary" on:click={() => saveStorage(true)}
>{$t('forms.add')}</button
>
</div>
{:else}
<div class="flex flex-row items-center justify-center space-x-2 w-full lg:w-64">
<div class="flex items-center justify-center">
<button class="btn btn-sm btn-primary" on:click={() => saveStorage(false)}
>{$t('forms.set')}</button
>
</div>
<div class="flex justify-center">
<button class="btn btn-sm btn-error" on:click={removeStorage}
>{$t('forms.remove')}</button
>
</div>
</div>
{/if}
</div>
{:else}
<div class="flex flex-row justify-center space-x-2">
<div class="flex items-center justify-center">
<button class="btn btn-sm btn-success" on:click={() => saveStorage(false)}>Set</button>
</div>
<div class="flex justify-center items-end">
<button class="btn btn-sm btn-error" on:click={removeStorage}>Remove</button>
</div>
</div>
{/if}
</td>
</div>
</div>

View File

@ -4,8 +4,6 @@
let configurationPhase = null;
if (!service.type) {
configurationPhase = 'type';
} else if (!service.version) {
configurationPhase = 'version';
} else if (!service.destinationDockerId) {
configurationPhase = 'destination';
}
@ -15,7 +13,7 @@
try {
let readOnly = false;
const response = await get(`/services/${params.id}`);
const { service, settings } = await response;
const { service, settings, template } = await response;
if (!service || Object.entries(service).length === 0) {
return {
status: 302,
@ -38,7 +36,8 @@
return {
props: {
service
service,
template
},
stuff: {
service,
@ -53,8 +52,9 @@
</script>
<script lang="ts">
export let service: any;
import { page } from '$app/stores';
import DeleteIcon from '$lib/components/DeleteIcon.svelte';
import { del, get, post } from '$lib/api';
import { t } from '$lib/translations';
import { errorNotification, handlerNotFoundLoad } from '$lib/common';
@ -67,13 +67,10 @@
checkIfDeploymentEnabledServices
} from '$lib/store';
import { onDestroy, onMount } from 'svelte';
import Tooltip from '$lib/components/Tooltip.svelte';
import ServiceLinks from './_ServiceLinks.svelte';
import { goto } from '$app/navigation';
import Menu from './_Menu.svelte';
const { id } = $page.params;
export let service: any;
$isDeploymentEnabled = checkIfDeploymentEnabledServices($appSession.isAdmin, service);
let statusInterval: any;
@ -127,25 +124,46 @@
if ($status.service.loading) return;
$status.service.loading = true;
const data = await get(`/services/${id}/status`);
$status.service.isRunning = data.isRunning;
$status.service.isExited = data.isExited;
$status.service.initialLoading = false;
$status.service.statuses = data;
let numberOfServices = Object.keys(data).length;
if (Object.keys($status.service.statuses).length === 0) {
$status.service.overallStatus = 'stopped';
} else {
if (Object.keys($status.service.statuses).length !== numberOfServices) {
$status.service.overallStatus = 'degraded';
} else {
for (const oneService in $status.service.statuses) {
const { isExited, isRestarting, isRunning } = $status.service.statuses[oneService].status;
if (isExited || isRestarting) {
$status.service.overallStatus = 'degraded';
break;
}
if (isRunning) {
$status.service.overallStatus = 'healthy';
}
if (!isExited && !isRestarting && !isRunning) {
$status.service.overallStatus = 'stopped';
}
}
}
}
$status.service.loading = false;
$status.service.initialLoading = false;
}
onDestroy(() => {
$status.service.initialLoading = true;
$status.service.isRunning = false;
$status.service.isExited = false;
$status.service.loading = false;
$status.service.statuses = [];
$location = null;
$isDeploymentEnabled = false;
clearInterval(statusInterval);
});
onMount(async () => {
setLocation(service);
$status.service.isRunning = false;
$status.service.loading = false;
if (service.type && service.destinationDockerId && service.version && service.fqdn) {
if ($isDeploymentEnabled) {
await getStatus();
statusInterval = setInterval(async () => {
await getStatus();
@ -156,90 +174,65 @@
});
</script>
<nav class="header lg:flex-row flex-col-reverse">
<div class="flex flex-row space-x-2 font-bold pt-10 lg:pt-0">
<div class="flex flex-col items-center justify-center">
<div class="title">
{#if $page.url.pathname === `/services/${id}`}
Configurations
{:else if $page.url.pathname === `/services/${id}/secrets`}
Secrets
{:else if $page.url.pathname === `/services/${id}/storages`}
Persistent Storages
{:else if $page.url.pathname === `/services/${id}/logs`}
Service Logs
{:else if $page.url.pathname === `/services/${id}/configuration/type`}
Select a Service Type
{:else if $page.url.pathname === `/services/${id}/configuration/version`}
Select a Service Version
{:else if $page.url.pathname === `/services/${id}/configuration/destination`}
Select a Destination
<div class="mx-auto max-w-screen-2xl px-6 grid grid-cols-1 lg:grid-cols-2">
<nav class="header flex flex-row order-2 lg:order-1 px-0 lg:px-4 items-start">
<div class="title lg:pb-10">
<div class="flex justify-center items-center space-x-2">
<div>
{#if $page.url.pathname === `/services/${id}/secrets`}
Secrets
{:else if $page.url.pathname === `/services/${id}/storages`}
Persistent Storages
{:else if $page.url.pathname === `/services/${id}/logs`}
Service Logs
{:else if $page.url.pathname === `/services/${id}/configuration/type`}
Select a Service Type
{:else if $page.url.pathname === `/services/${id}/configuration/version`}
Select a Service Version
{:else if $page.url.pathname === `/services/${id}/configuration/destination`}
Select a Destination
{:else}
Configurations
{/if}
</div>
{#if !$page.url.pathname.startsWith(`/services/${id}/configuration/`)}
<div
class="badge badge-lg rounded uppercase"
class:text-green-500={$status.service.overallStatus === 'healthy'}
class:text-yellow-400={$status.service.overallStatus === 'degraded'}
class:text-red-500={$status.service.overallStatus === 'stopped'}
>
{$status.service.overallStatus === 'healthy'
? 'Healthy'
: $status.service.overallStatus === 'degraded'
? 'Degraded'
: 'Stopped'}
</div>
{/if}
</div>
</div>
<ServiceLinks {service} />
</div>
<div class="lg:block hidden flex-1" />
<div class="flex flex-row flex-wrap space-x-3 justify-center lg:justify-start lg:py-0">
{#if $location}
<a
id="open"
href={$location}
target="_blank"
class="icons flex items-center bg-transparent text-sm"
><svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
{#if $page.url.pathname.startsWith(`/services/${id}/configuration/`)}
<div class="px-2">
<button
on:click={() => deleteService()}
disabled={!$appSession.isAdmin}
class:bg-red-600={$appSession.isAdmin}
class:hover:bg-red-500={$appSession.isAdmin}
class="btn btn-sm btn-error text-sm"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M11 7h-5a2 2 0 0 0 -2 2v9a2 2 0 0 0 2 2h9a2 2 0 0 0 2 -2v-5" />
<line x1="10" y1="14" x2="20" y2="4" />
<polyline points="15 4 20 4 20 9" />
</svg></a
>
<Tooltip triggeredBy="#open">Open</Tooltip>
<div class="hidden lg:block border border-coolgray-500 h-8" />
{/if}
{#if $status.service.isExited}
<a
id="error"
href={$isDeploymentEnabled ? `/services/${id}/logs` : null}
class="icons bg-transparent text-sm flex items-center text-red-500 tooltip-error"
sveltekit:prefetch
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="w-6 h-6"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentcolor"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path
d="M8.7 3h6.6c.3 0 .5 .1 .7 .3l4.7 4.7c.2 .2 .3 .4 .3 .7v6.6c0 .3 -.1 .5 -.3 .7l-4.7 4.7c-.2 .2 -.4 .3 -.7 .3h-6.6c-.3 0 -.5 -.1 -.7 -.3l-4.7 -4.7c-.2 -.2 -.3 -.4 -.3 -.7v-6.6c0 -.3 .1 -.5 .3 -.7l4.7 -4.7c.2 -.2 .4 -.3 .7 -.3z"
/>
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
</a>
<Tooltip triggeredBy="#error">Service exited with an error!</Tooltip>
Delete Service
</button>
</div>
{/if}
</nav>
<div
class="pt-4 flex flex-row items-start justify-center lg:justify-end space-x-2 order-1 lg:order-2"
>
{#if $status.service.initialLoading}
<button
class="icons flex animate-spin items-center space-x-2 bg-transparent text-sm duration-500 ease-in-out"
>
<button class="btn btn-ghost btn-sm gap-2">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6"
class="h-6 w-6 animate-spin duration-500 ease-in-out"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
@ -255,14 +248,34 @@
<line x1="7.16" y1="18.37" x2="7.16" y2="18.38" />
<line x1="11" y1="19.94" x2="11" y2="19.95" />
</svg>
Loading...
</button>
{:else if $status.service.isRunning}
{:else if $status.service.overallStatus === 'healthy'}
<button
id="stop"
on:click={stopService}
type="submit"
disabled={!$isDeploymentEnabled}
class="icons bg-transparent text-sm flex items-center space-x-2 text-red-500"
class="btn btn-sm btn-error gap-2"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="w-6 h-6 "
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<rect x="6" y="5" width="4" height="14" rx="1" />
<rect x="14" y="5" width="4" height="14" rx="1" />
</svg> Stop
</button>
<button
disabled={!$isDeploymentEnabled}
class="btn btn-sm gap-2"
on:click={() => startService()}
>
<svg
xmlns="http://www.w3.org/2000/svg"
@ -275,21 +288,24 @@
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<rect x="6" y="5" width="4" height="14" rx="1" />
<rect x="14" y="5" width="4" height="14" rx="1" />
<path
d="M16.3 5h.7a2 2 0 0 1 2 2v10a2 2 0 0 1 -2 2h-10a2 2 0 0 1 -2 -2v-10a2 2 0 0 1 2 -2h5l-2.82 -2.82m0 5.64l2.82 -2.82"
transform="rotate(-45 12 12)"
/>
</svg>
Force Redeploy
</button>
<Tooltip triggeredBy="#stop">Stop</Tooltip>
{:else}
{:else if $status.service.overallStatus === 'degraded'}
<button
id="start"
on:click={startService}
on:click={stopService}
type="submit"
disabled={!$isDeploymentEnabled}
class="icons bg-transparent text-sm flex items-center space-x-2 text-green-500"
><svg
class="btn btn-sm btn-error gap-2"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="w-6 h-6"
class="w-6 h-6 "
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
@ -298,29 +314,21 @@
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M7 4v16l13 -8z" />
</svg>
<rect x="6" y="5" width="4" height="14" rx="1" />
<rect x="14" y="5" width="4" height="14" rx="1" />
</svg> Stop
</button>
<Tooltip triggeredBy="#start">Start</Tooltip>
{/if}
{#if service.type && service.destinationDockerId && service.version}
<div class="hidden lg:block border border-coolgray-500 h-8" />
<a
href="/services/{id}"
sveltekit:prefetch
class="hover:text-yellow-500 rounded"
class:text-yellow-500={$page.url.pathname === `/services/${id}`}
class:bg-coolgray-500={$page.url.pathname === `/services/${id}`}
{:else if $status.service.overallStatus === 'stopped'}
<button
class="btn btn-sm gap-2"
class:btn-primary={$status.application.overallStatus !== 'degraded'}
disabled={!$isDeploymentEnabled}
on:click={() => startService()}
>
<button
id="configuration"
disabled={!$isDeploymentEnabled}
class="icons bg-transparent text-sm"
>
{#if $status.application.overallStatus !== 'degraded'}
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6"
class="w-6 h-6"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
@ -329,27 +337,9 @@
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<rect x="4" y="8" width="4" height="4" />
<line x1="6" y1="4" x2="6" y2="8" />
<line x1="6" y1="12" x2="6" y2="20" />
<rect x="10" y="14" width="4" height="4" />
<line x1="12" y1="4" x2="12" y2="14" />
<line x1="12" y1="18" x2="12" y2="20" />
<rect x="16" y="5" width="4" height="4" />
<line x1="18" y1="4" x2="18" y2="5" />
<line x1="18" y1="9" x2="18" y2="20" />
</svg></button
></a
>
<Tooltip triggeredBy="#configuration">Configuration</Tooltip>
<a
href="/services/{id}/secrets"
sveltekit:prefetch
class="hover:text-pink-500 rounded"
class:text-pink-500={$page.url.pathname === `/services/${id}/secrets`}
class:bg-coolgray-500={$page.url.pathname === `/services/${id}/secrets`}
>
<button id="secrets" disabled={!$isDeploymentEnabled} class="icons bg-transparent text-sm ">
<path d="M7 4v16l13 -8z" />
</svg>
{:else}
<svg
xmlns="http://www.w3.org/2000/svg"
class="w-6 h-6"
@ -362,88 +352,31 @@
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path
d="M12 3a12 12 0 0 0 8.5 3a12 12 0 0 1 -8.5 15a12 12 0 0 1 -8.5 -15a12 12 0 0 0 8.5 -3"
d="M16.3 5h.7a2 2 0 0 1 2 2v10a2 2 0 0 1 -2 2h-10a2 2 0 0 1 -2 -2v-10a2 2 0 0 1 2 -2h5l-2.82 -2.82m0 5.64l2.82 -2.82"
transform="rotate(-45 12 12)"
/>
<circle cx="12" cy="11" r="1" />
<line x1="12" y1="12" x2="12" y2="14.5" />
</svg></button
></a
>
<Tooltip triggeredBy="#secrets">Secrets</Tooltip>
<a
href="/services/{id}/storages"
sveltekit:prefetch
class="hover:text-pink-500 rounded"
class:text-pink-500={$page.url.pathname === `/services/${id}/storages`}
class:bg-coolgray-500={$page.url.pathname === `/services/${id}/storages`}
>
<button
id="persistentstorage"
disabled={!$isDeploymentEnabled}
class="icons bg-transparent text-sm"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="w-6 h-6"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<ellipse cx="12" cy="6" rx="8" ry="3" />
<path d="M4 6v6a8 3 0 0 0 16 0v-6" />
<path d="M4 12v6a8 3 0 0 0 16 0v-6" />
</svg>
</button></a
>
<Tooltip triggeredBy="#persistentstorage">Persistent Storages</Tooltip>
<div class="hidden lg:block border border-coolgray-500 h-8" />
<a
href={$isDeploymentEnabled && $status.service.isRunning ? `/services/${id}/logs` : null}
sveltekit:prefetch
class="hover:text-pink-500 rounded"
class:text-pink-500={$page.url.pathname === `/services/${id}/logs`}
class:bg-coolgray-500={$page.url.pathname === `/services/${id}/logs`}
>
<button
id="logs"
disabled={!$status.service.isRunning}
class="icons bg-transparent text-sm"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M3 19a9 9 0 0 1 9 0a9 9 0 0 1 9 0" />
<path d="M3 6a9 9 0 0 1 9 0a9 9 0 0 1 9 0" />
<line x1="3" y1="6" x2="3" y2="19" />
<line x1="12" y1="6" x2="12" y2="19" />
<line x1="21" y1="6" x2="21" y2="19" />
</svg></button
></a
>
<Tooltip triggeredBy="#logs">Logs</Tooltip>
{/if}
{$status.application.overallStatus === 'degraded'
? $status.application.statuses.length === 1
? 'Force Redeploy'
: 'Redeploy Stack'
: 'Deploy'}
</button>
{/if}
<div class="hidden lg:block border border-coolgray-500 h-8" />
<button
id="delete"
on:click={deleteService}
type="submit"
disabled={!$appSession.isAdmin}
class:hover:text-red-500={$appSession.isAdmin}
class="icons bg-transparent text-sm"><DeleteIcon /></button
>
<Tooltip triggeredBy="#delete" placement="left">Delete</Tooltip>
</div>
</nav>
<slot />
</div>
<div
class="mx-auto max-w-screen-2xl px-0 lg:px-2 grid grid-cols-1"
class:lg:grid-cols-4={!$page.url.pathname.startsWith(`/services/${id}/configuration/`)}
>
{#if !$page.url.pathname.startsWith(`/services/${id}/configuration/`)}
<nav class="header flex flex-col lg:pt-0 ">
<Menu {service} />
</nav>
{/if}
<div class="pt-0 col-span-0 lg:col-span-3 pb-24">
<slot />
</div>
</div>

View File

@ -81,7 +81,7 @@
{#each destinations as destination}
<div class="p-2">
<form on:submit|preventDefault={() => handleSubmit(destination.id)}>
<button type="submit" class="box-selection hover:bg-sky-700 font-bold">
<button type="submit" class="box-selection hover:bg-primary font-bold">
<div class="font-bold text-xl text-center truncate">{destination.name}</div>
<div class="text-center truncate">{destination.network}</div>
</button>

View File

@ -25,30 +25,29 @@
</script>
<script lang="ts">
export let types: any;
export let services: any;
let search = '';
let filteredTypes = types;
let filteredServices = services;
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { get, post } from '$lib/api';
import { errorNotification } from '$lib/common';
import ServiceIcons from '$lib/components/svg/services/ServiceIcons.svelte';
const { id } = $page.params;
const from = $page.url.searchParams.get('from');
async function handleSubmit(type: any) {
async function handleSubmit(service: any) {
try {
await post(`/services/${id}/configuration/type`, { type });
await post(`/services/${id}/configuration/type`, { ...service });
return await goto(from || `/services/${id}`);
} catch (error) {
return errorNotification(error);
}
}
function doSearch() {
filteredTypes = types.filter(
filteredServices = services.filter(
(type: any) =>
type.name.toLowerCase().includes(search.toLowerCase()) ||
type.labels.some((label: string) => label.toLowerCase().includes(search.toLowerCase()))
@ -56,7 +55,7 @@
}
function cleanupSearch() {
search = '';
filteredTypes = types;
filteredServices = services;
}
</script>
@ -89,16 +88,16 @@
</div>
</div>
<div class="container lg:mx-auto lg:pt-20 lg:p-0 px-8 pt-20">
<div class="flex flex-wrap justify-center gap-8">
{#each filteredTypes as type}
<div class="p-2">
<form on:submit|preventDefault={() => handleSubmit(type.name)}>
<button type="submit" class="box-selection relative text-xl font-bold hover:bg-pink-600">
<ServiceIcons type={type.name} />
{type.fancyName}
</button>
</form>
</div>
{/each}
</div>
<div class="flex flex-wrap justify-center gap-8">
{#each filteredServices as service}
<div class="p-2">
<form on:submit|preventDefault={() => handleSubmit(service)}>
<button type="submit" class="box-selection relative text-xl font-bold hover:bg-primary">
<!-- <ServiceIcons type={service.name} /> -->
{service.displayName}
</button>
</form>
</div>
{/each}
</div>
</div>

View File

@ -0,0 +1,66 @@
<script context="module" lang="ts">
import type { Load } from '@sveltejs/kit';
export const load: Load = async ({ fetch, params, stuff, url }) => {
try {
const response = await get(`/services/${params.id}`);
return {
props: {
application: stuff.application,
...response
}
};
} catch (error) {
return {
status: 500,
error: new Error(`Could not load ${url}`)
};
}
};
</script>
<script lang="ts">
export let service: any;
import { page } from '$app/stores';
import { del, get, post } from '$lib/api';
import { t } from '$lib/translations';
import { appSession, status } from '$lib/store';
import { errorNotification } from '$lib/common';
import { goto } from '$app/navigation';
const { id } = $page.params;
let forceDelete = false;
async function deleteService() {
const sure = confirm($t('application.confirm_to_delete', { name: service.name }));
if (sure) {
$status.service.initialLoading = true;
try {
if (service.type && $status.service.isRunning) {
await post(`/services/${service.id}/${service.type}/stop`, {});
}
await del(`/services/${service.id}`, { id: service.id });
return await goto('/');
} catch (error) {
return errorNotification(error);
} finally {
$status.service.initialLoading = false;
}
}
}
</script>
<div class="mx-auto w-full">
<div class="flex flex-row border-b border-coolgray-500 mb-6 space-x-2">
<div class="title font-bold pb-3">Danger Zone</div>
</div>
<button
id="forcedelete"
on:click={() => deleteService()}
type="submit"
disabled={!$appSession.isAdmin}
class:bg-red-600={$appSession.isAdmin}
class:hover:bg-red-500={$appSession.isAdmin}
class="btn btn-lg btn-error text-sm"
>
Delete Application
</button>
</div>

View File

@ -8,64 +8,410 @@
</script>
<script lang="ts">
import Services from './_Services/_Services.svelte';
import { get } from '$lib/api';
import { page } from '$app/stores';
import { status } from '$lib/store';
import { onDestroy, onMount } from 'svelte';
import ServiceLinks from './_ServiceLinks.svelte';
export let service: any;
export let readOnly: any;
export let settings: any;
const { id } = $page.params;
let loading = {
usage: false
};
let usage = {
MemUsage: 0,
CPUPerc: 0,
NetIO: 0
};
let usageInterval: any;
import cuid from 'cuid';
import { onMount } from 'svelte';
async function getUsage() {
if (loading.usage) return;
if (!$status.service.isRunning) return;
loading.usage = true;
const data = await get(`/services/${id}/usage`);
usage = data.usage;
loading.usage = false;
import { browser } from '$app/env';
import { page } from '$app/stores';
import { get, post } from '$lib/api';
import { errorNotification, getDomain } from '$lib/common';
import { t } from '$lib/translations';
import {
appSession,
status,
setLocation,
addToast,
checkIfDeploymentEnabledServices,
isDeploymentEnabled
} from '$lib/store';
import CopyPasswordField from '$lib/components/CopyPasswordField.svelte';
import Setting from '$lib/components/Setting.svelte';
import * as Services from '$lib/components/Services';
import DocLink from '$lib/components/DocLink.svelte';
import Explainer from '$lib/components/Explainer.svelte';
const { id } = $page.params;
let serviceName: any = service.type && service.type[0].toUpperCase() + service.type.substring(1);
$: isDisabled =
!$appSession.isAdmin || $status.service.isRunning || $status.service.initialLoading;
let forceSave = false;
let loading = {
save: false,
verification: false,
cleanup: false
};
let dualCerts = service.dualCerts;
let nonWWWDomain = service.fqdn && getDomain(service.fqdn).replace(/^www\./, '');
let isNonWWWDomainOK = false;
let isWWWDomainOK = false;
async function isDNSValid(domain: any, isWWW: any) {
try {
await get(`/services/${id}/check?domain=${domain}`);
addToast({
message: 'DNS configuration is valid.',
type: 'success'
});
isWWW ? (isWWWDomainOK = true) : (isNonWWWDomainOK = true);
return true;
} catch (error) {
errorNotification(error);
isWWW ? (isWWWDomainOK = false) : (isNonWWWDomainOK = false);
return false;
}
}
onDestroy(() => {
clearInterval(usageInterval);
});
async function handleSubmit() {
if (loading.save) return;
loading.save = true;
try {
await post(`/services/${id}/check`, {
fqdn: service.fqdn,
forceSave,
dualCerts,
otherFqdns: service.minio?.apiFqdn ? [service.minio?.apiFqdn] : [],
exposePort: service.exposePort
});
await post(`/services/${id}`, { ...service });
setLocation(service);
forceSave = false;
$isDeploymentEnabled = checkIfDeploymentEnabledServices($appSession.isAdmin, service);
return addToast({
message: 'Configuration saved.',
type: 'success'
});
} catch (error) {
//@ts-ignore
if (error?.message.startsWith($t('application.dns_not_set_partial_error'))) {
forceSave = true;
if (dualCerts) {
isNonWWWDomainOK = await isDNSValid(getDomain(nonWWWDomain), false);
isWWWDomainOK = await isDNSValid(getDomain(`www.${nonWWWDomain}`), true);
} else {
const isWWW = getDomain(service.fqdn).includes('www.');
if (isWWW) {
isWWWDomainOK = await isDNSValid(getDomain(`www.${nonWWWDomain}`), true);
} else {
isNonWWWDomainOK = await isDNSValid(getDomain(nonWWWDomain), false);
}
}
}
return errorNotification(error);
} finally {
loading.save = false;
}
}
async function setEmailsToVerified() {
loading.verification = true;
try {
await post(`/services/${id}/${service.type}/activate`, { id: service.id });
return addToast({
message: t.get('services.all_email_verified'),
type: 'success'
});
} catch (error) {
return errorNotification(error);
} finally {
loading.verification = false;
}
}
async function migrateAppwriteDB() {
loading.verification = true;
try {
await post(`/services/${id}/${service.type}/migrate`, { id: service.id });
return addToast({
message: "Appwrite's database has been migrated.",
type: 'success'
});
} catch (error) {
return errorNotification(error);
} finally {
loading.verification = false;
}
}
async function changeSettings(name: any) {
try {
if (name === 'dualCerts') {
dualCerts = !dualCerts;
}
await post(`/services/${id}/settings`, { dualCerts });
return addToast({
message: t.get('application.settings_saved'),
type: 'success'
});
} catch (error) {
return errorNotification(error);
}
}
async function cleanupLogs() {
loading.cleanup = true;
try {
await post(`/services/${id}/${service.type}/cleanup`, { id: service.id });
return addToast({
message: 'Cleared DB Logs',
type: 'success'
});
} catch (error) {
return errorNotification(error);
} finally {
loading.cleanup = false;
}
}
function doNothing() {
return;
}
onMount(async () => {
await getUsage();
usageInterval = setInterval(async () => {
await getUsage();
}, 1000);
if (browser && window.location.hostname === 'demo.coolify.io' && !service.fqdn) {
service.fqdn = `http://${cuid()}.demo.coolify.io`;
if (service.type === 'wordpress') {
service.wordpress.mysqlDatabase = 'db';
}
if (service.type === 'plausibleanalytics') {
service.plausibleAnalytics.email = 'noreply@demo.com';
service.plausibleAnalytics.username = 'admin';
}
if (service.type === 'minio') {
service.minio.apiFqdn = `http://${cuid()}.demo.coolify.io`;
}
if (service.type === 'ghost') {
service.ghost.mariadbDatabase = 'db';
}
if (service.type === 'fider') {
service.fider.emailNoreply = 'noreply@demo.com';
}
await handleSubmit();
}
});
</script>
<div class="mx-auto max-w-6xl px-6 lg:my-0 my-4 lg:pt-0 pt-4 rounded">
<div class="text-center">
<div class="stat w-64">
<div class="stat-title">Used Memory / Memory Limit</div>
<div class="stat-value text-xl">{usage?.MemUsage}</div>
<div class="w-full">
<form on:submit|preventDefault={() => handleSubmit()}>
<div class="mx-auto w-full">
<div class="flex flex-row border-b border-coolgray-500 mb-6 space-x-2">
<div class="title font-bold pb-3 ">General</div>
{#if $appSession.isAdmin}
<button
type="submit"
class="btn btn-sm"
class:bg-orange-600={forceSave}
class:hover:bg-orange-400={forceSave}
class:loading={loading.save}
class:btn-primary={!loading.save}
disabled={loading.save}
>{loading.save
? $t('forms.save')
: forceSave
? $t('forms.confirm_continue')
: $t('forms.save')}</button
>
{/if}
{#if service.type === 'plausibleanalytics' && $status.service.isRunning}
<div class="btn-group">
<button
class="btn btn-sm"
on:click|preventDefault={setEmailsToVerified}
disabled={loading.verification}
class:loading={loading.verification}
>{loading.verification
? $t('forms.verifying')
: $t('forms.verify_emails_without_smtp')}</button
>
<button
class="btn btn-sm"
on:click|preventDefault={cleanupLogs}
disabled={loading.cleanup}
class:loading={loading.cleanup}>Cleanup Unnecessary Database Logs</button
>
</div>
{/if}
{#if service.type === 'appwrite' && $status.service.isRunning}
<button
class="btn btn-sm"
on:click|preventDefault={migrateAppwriteDB}
disabled={loading.verification}
class:loading={loading.verification}
>{loading.verification
? 'Migrating... it may take a while...'
: "Migrate Appwrite's Database"}</button
>
<DocLink url="https://appwrite.io/docs/upgrade#run-the-migration" />
{/if}
</div>
</div>
<div class="stat w-64">
<div class="stat-title">Used CPU</div>
<div class="stat-value text-xl">{usage?.CPUPerc}</div>
</div>
{#if service.type === 'minio' && !service.minio.apiFqdn && $status.service.isRunning}
<div class="py-5">
<span class="font-bold text-red-500">IMPORTANT!</span> There was a small modification with Minio
in the latest version of Coolify. Now you can separate the Console URL from the API URL, so you
could use both through SSL. But this proccess cannot be done automatically, so you have to stop
your Minio instance, configure the new domain and start it back. Sorry for any inconvenience.
</div>
{/if}
<div class="stat w-64">
<div class="stat-title">Network IO</div>
<div class="stat-value text-xl">{usage?.NetIO}</div>
<div class="grid grid-flow-row gap-2 px-4">
<div class="mt-2 grid grid-cols-2 items-center">
<label for="name">{$t('forms.name')}</label>
<input name="name" id="name" class="w-full" bind:value={service.name} required />
</div>
<div class="grid grid-cols-2 items-center">
<label for="version">Version / Tag</label>
<a
href={$appSession.isAdmin && !$status.service.isRunning && !$status.service.initialLoading
? `/services/${id}/configuration/version?from=/services/${id}`
: ''}
class="no-underline"
>
<input
class="w-full"
value={service.version}
id="service"
readonly
disabled={$status.service.isRunning || $status.service.initialLoading}
class:cursor-pointer={!$status.service.isRunning}
/></a
>
</div>
<div class="grid grid-cols-2 items-center">
<label for="destination">{$t('application.destination')}</label>
<div>
{#if service.destinationDockerId}
<div class="no-underline">
<input
value={service.destinationDocker.name}
id="destination"
disabled
class="bg-transparent w-full"
/>
</div>
{/if}
</div>
</div>
{#if service.type === 'minio'}
<div class="grid grid-cols-2 items-center">
<label for="fqdn"
>Console URL <Explainer explanation={$t('application.https_explainer')} /></label
>
<CopyPasswordField
placeholder="eg: https://console.min.io"
readonly={isDisabled}
disabled={isDisabled}
name="fqdn"
id="fqdn"
pattern="^https?://([a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{'{'}2,{'}'}$"
bind:value={service.fqdn}
required
/>
</div>
<div class="grid grid-cols-2 items-center">
<label for="apiFqdn"
>API URL <Explainer explanation={$t('application.https_explainer')} /></label
>
<CopyPasswordField
placeholder="eg: https://min.io"
readonly={!$appSession.isAdmin && !$status.service.isRunning}
disabled={isDisabled}
name="apiFqdn"
id="apiFqdn"
pattern="^https?://([a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{'{'}2,{'}'}$"
bind:value={service.minio.apiFqdn}
required
/>
</div>
{:else}
<div class="grid grid-cols-2 items-center">
<label for="fqdn"
>{$t('application.url_fqdn')}
<Explainer explanation={$t('application.https_explainer')} />
</label>
<CopyPasswordField
placeholder="eg: https://analytics.coollabs.io"
readonly={!$appSession.isAdmin && !$status.service.isRunning}
disabled={!$appSession.isAdmin ||
$status.service.isRunning ||
$status.service.initialLoading}
name="fqdn"
id="fqdn"
pattern="^https?://([a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{'{'}2,{'}'}$"
bind:value={service.fqdn}
required
/>
</div>
{/if}
</div>
</div>
{#if forceSave}
<div class="flex-col space-y-2 pt-4 text-center">
{#if isNonWWWDomainOK}
<button
class="btn btn-sm bg-green-600 hover:bg-green-500"
on:click|preventDefault={() => isDNSValid(getDomain(nonWWWDomain), false)}
>DNS settings for {nonWWWDomain} is OK, click to recheck.</button
>
{:else}
<button
class="btn btn-sm bg-red-600 hover:bg-red-500"
on:click|preventDefault={() => isDNSValid(getDomain(nonWWWDomain), false)}
>DNS settings for {nonWWWDomain} is invalid, click to recheck.</button
>
{/if}
{#if dualCerts}
{#if isWWWDomainOK}
<button
class="btn btn-sm bg-green-600 hover:bg-green-500"
on:click|preventDefault={() => isDNSValid(getDomain(`www.${nonWWWDomain}`), true)}
>DNS settings for www.{nonWWWDomain} is OK, click to recheck.</button
>
{:else}
<button
class="btn btn-sm bg-red-600 hover:bg-red-500"
on:click|preventDefault={() => isDNSValid(getDomain(`www.${nonWWWDomain}`), true)}
>DNS settings for www.{nonWWWDomain} is invalid, click to recheck.</button
>
{/if}
{/if}
</div>
{/if}
<div class="grid grid-flow-row gap-2 px-4">
<div class="grid grid-cols-2 items-center">
<Setting
id="dualCerts"
disabled={$status.service.isRunning}
dataTooltip={$t('forms.must_be_stopped_to_modify')}
bind:setting={dualCerts}
title={$t('application.ssl_www_and_non_www')}
description={$t('services.generate_www_non_www_ssl')}
on:click={() => !$status.service.isRunning && changeSettings('dualCerts')}
/>
</div>
<div class="grid grid-cols-2 items-center">
<label for="exposePort"
>Exposed Port <Explainer
explanation={'You can expose your application to a port on the host system.<br><br>Useful if you would like to use your own reverse proxy or tunnel and also in development mode. Otherwise leave empty.'}
/></label
>
<input
class="w-full"
readonly={!$appSession.isAdmin && !$status.service.isRunning}
disabled={!$appSession.isAdmin ||
$status.service.isRunning ||
$status.service.initialLoading}
name="exposePort"
id="exposePort"
bind:value={service.exposePort}
placeholder="12345"
/>
</div>
</div>
<svelte:component this={Services[serviceName]} bind:service {readOnly} {settings} />
</form>
</div>
<Services bind:service bind:readOnly bind:settings />

View File

@ -8,6 +8,7 @@
import Tooltip from '$lib/components/Tooltip.svelte';
let service: any = {};
let template: any = null;
let logsLoading = false;
let loadLogsInterval: any = null;
let logs: any = [];
@ -16,11 +17,12 @@
let followingLogs: any;
let logsEl: any;
let position = 0;
let selectedService: any = null;
const { id } = $page.params;
onMount(async () => {
const response = await get(`/services/${id}`);
template = response.template;
service = response.service;
loadAllLogs();
loadLogsInterval = setInterval(() => {
@ -82,48 +84,83 @@
clearInterval(followingInterval);
}
}
async function selectService(service: any, init: boolean = false) {
if (loadLogsInterval) clearInterval(loadLogsInterval);
if (followingInterval) clearInterval(followingInterval);
logs = [];
lastLog = null;
followingLogs = false;
selectedService = service;
loadLogs();
loadLogsInterval = setInterval(() => {
loadLogs();
}, 1000);
}
</script>
<div class="flex flex-row justify-center space-x-2 px-10 pt-6">
{#if logs.length === 0}
<div class="text-xl font-bold tracking-tighter">{$t('application.build.waiting_logs')}</div>
{:else}
<div class="relative w-full">
<div class="text-right " />
{#if loadLogsInterval}
<LoadingLogs />
{/if}
<div class="flex justify-end sticky top-0 p-1 mx-1">
<button
id="follow"
on:click={followBuild}
class="bg-transparent btn btn-sm btn-link"
class:text-green-500={followingLogs}
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="w-6 h-6"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
{#if template}
<div class="flex gap-2 lg:gap-8 pb-4">
{#each Object.keys(template.services) as service}
<button
on:click={() => selectService(service, true)}
class:bg-primary={selectedService === service}
class:bg-coolgray-200={selectedService !== service}
class="w-full rounded p-5 hover:bg-primary font-bold"
>
{service}</button
>
{/each}
</div>
{/if}
{#if selectedService}
<div class="flex flex-row justify-center space-x-2">
{#if logs.length === 0}
<div class="text-xl font-bold tracking-tighter">{$t('application.build.waiting_logs')}</div>
{:else}
<div class="relative w-full">
<div class="flex justify-start sticky space-x-2 pb-2">
<button
on:click={followBuild}
class="btn btn-sm bg-coollabs"
class:bg-coolgray-300={followingLogs}
class:text-applications={followingLogs}
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<circle cx="12" cy="12" r="9" />
<line x1="8" y1="12" x2="12" y2="16" />
<line x1="12" y1="8" x2="12" y2="16" />
<line x1="16" y1="12" x2="12" y2="16" />
</svg>
</button>
<Tooltip triggeredBy="#follow">Follow Logs</Tooltip>
<svg
xmlns="http://www.w3.org/2000/svg"
class="w-6 h-6 mr-2"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<circle cx="12" cy="12" r="9" />
<line x1="8" y1="12" x2="12" y2="16" />
<line x1="12" y1="8" x2="12" y2="16" />
<line x1="16" y1="12" x2="12" y2="16" />
</svg>
{followingLogs ? 'Following Logs...' : 'Follow Logs'}
</button>
{#if loadLogsInterval}
<button id="streaming" class="btn btn-sm bg-transparent border-none loading"
>Streaming logs</button
>
{/if}
</div>
<div
bind:this={logsEl}
on:scroll={detect}
class="font-mono w-full bg-coolgray-100 border border-coolgray-200 p-5 overflow-x-auto overflox-y-auto max-h-[80vh] rounded mb-20 flex flex-col scrollbar-thumb-coollabs scrollbar-track-coolgray-200 scrollbar-w-1"
>
{#each logs as log}
<p>{log + '\n'}</p>
{/each}
</div>
</div>
<div class="font-mono w-full bg-coolgray-200 p-5 overflow-x-auto overflox-y-auto rounded mb-20 flex flex-col -mt-12 scrollbar-thumb-coollabs scrollbar-track-coolgray-200 scrollbar-w-1">
{#each logs as log}
<p>{log + '\n'}</p>
{/each}
</div>
</div>
{/if}
</div>
{/if}
</div>
{/if}

View File

@ -26,7 +26,6 @@
import { get } from '$lib/api';
import { t } from '$lib/translations';
import pLimit from 'p-limit';
import ServiceLinks from './_ServiceLinks.svelte';
import { addToast } from '$lib/store';
import { saveSecret } from './utils';
const limit = pLimit(1);
@ -38,8 +37,8 @@
const data = await get(`/services/${id}/secrets`);
secrets = [...data.secrets];
}
async function getValues(e: any) {
e.preventDefault();
async function getValues() {
if (!batchSecrets) return;
const eachValuePair = batchSecrets.split('\n');
const batchSecretsPairs = eachValuePair
.filter((secret) => !secret.startsWith('#') && secret)
@ -48,8 +47,8 @@
const value = rest.join('=');
const cleanValue = value?.replaceAll('"', '') || '';
return {
name,
value: cleanValue,
name: name.trim(),
value: cleanValue.trim(),
isNew: !secrets.find((secret: any) => name === secret.name)
};
});
@ -68,17 +67,20 @@
}
</script>
<div class="mx-auto max-w-6xl rounded-xl px-6 pt-4">
<div class="mx-auto w-full">
<div class="flex flex-row border-b border-coolgray-500 mb-6 space-x-2">
<div class="title font-bold pb-3">Secrets</div>
</div>
<div class="overflow-x-auto">
<table class="w-full border-separate text-left">
<thead>
<tr class="h-12">
<tr class="uppercase">
<th scope="col">{$t('forms.name')}</th>
<th scope="col">{$t('forms.value')}</th>
<th scope="col" class="w-96 text-center">{$t('forms.action')}</th>
<th scope="col uppercase">{$t('forms.value')}</th>
<th scope="col uppercase" class="w-96 text-center">{$t('forms.action')}</th>
</tr>
</thead>
<tbody>
<tbody class="space-y-2">
{#each secrets as secret}
{#key secret.id}
<tr>
@ -92,9 +94,18 @@
</tbody>
</table>
</div>
<h2 class="title my-6 font-bold">Paste .env file</h2>
<form on:submit|preventDefault={getValues} class="mb-12 w-full">
<textarea bind:value={batchSecrets} class="mb-2 min-h-[200px] w-full" />
<button class="btn btn-sm bg-services" type="submit">Batch add secrets</button>
<div class="flex flex-row border-b border-coolgray-500 mb-6 space-x-2 pt-10">
<div class="flex flex-row space-x-2">
<div class="title font-bold pb-3 ">Paste <code>.env</code> file</div>
<button type="submit" class="btn btn-sm bg-primary">Add Secrets in Batch</button>
</div>
</div>
<textarea
placeholder={`PORT=1337\nPASSWORD=supersecret`}
bind:value={batchSecrets}
class="mb-2 min-h-[200px] w-full"
/>
</form>
</div>

View File

@ -5,7 +5,6 @@
const response = await get(`/services/${params.id}/storages`);
return {
props: {
service: stuff.service,
...response
}
};
@ -19,14 +18,12 @@
</script>
<script lang="ts">
export let service: any;
export let persistentStorages: any;
import { page } from '$app/stores';
import Storage from './_Storage.svelte';
import { get } from '$lib/api';
import SimpleExplainer from '$lib/components/SimpleExplainer.svelte';
import ServiceLinks from './_ServiceLinks.svelte';
import { t } from '$lib/translations';
import Explainer from '$lib/components/Explainer.svelte';
const { id } = $page.params;
async function refreshStorage() {
@ -35,30 +32,22 @@
}
</script>
<div class="mx-auto max-w-6xl rounded-xl px-6 pt-4">
<div class="flex justify-center py-4 text-center">
<SimpleExplainer
customClass="w-full"
text={'You can specify any folder that you want to be persistent across restarts. <br>This is useful for storing data for VSCode server or WordPress.'}
/>
<div class="w-full">
<div class="mx-auto w-full">
<div class="flex flex-row border-b border-coolgray-500 mb-6 space-x-2">
<div class="title font-bold pb-3">
Persistent Volumes <Explainer
position="dropdown-bottom"
explanation={$t('application.storage.persistent_storage_explainer')}
/>
</div>
</div>
<label for="name" class="pb-2 uppercase font-bold">name</label>
{#each persistentStorages as storage}
{#key storage.id}
<Storage on:refresh={refreshStorage} {storage} />
{/key}
{/each}
<Storage on:refresh={refreshStorage} isNew />
</div>
<table class="mx-auto border-separate text-left">
<thead>
<tr class="h-12">
<th scope="col">Path</th>
</tr>
</thead>
<tbody>
{#each persistentStorages as storage}
{#key storage.id}
<tr>
<Storage on:refresh={refreshStorage} {storage} />
</tr>
{/key}
{/each}
<tr>
<Storage on:refresh={refreshStorage} isNew />
</tr>
</tbody>
</table>
</div>