wip: trpc
This commit is contained in:
44
apps/client/src/lib/components/DocLink.svelte
Normal file
44
apps/client/src/lib/components/DocLink.svelte
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
<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
|
||||||
|
.split('')
|
||||||
|
.map((c) => c.charCodeAt(0).toString(16).padStart(2, '0'))
|
||||||
|
.join('')
|
||||||
|
.slice(-16);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<a
|
||||||
|
{id}
|
||||||
|
href={url}
|
||||||
|
target="_blank noreferrer"
|
||||||
|
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>
|
||||||
|
{#if !text}
|
||||||
|
<Tooltip triggeredBy={`#${id}`}>See details in the documentation</Tooltip>
|
||||||
|
{/if}
|
10
apps/client/src/lib/components/ExternalLink.svelte
Normal file
10
apps/client/src/lib/components/ExternalLink.svelte
Normal 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 |
@@ -5,6 +5,7 @@
|
|||||||
const handleError = (ev: { target: { src: string } }) => (ev.target.src = fallback);
|
const handleError = (ev: { target: { src: string } }) => (ev.target.src = fallback);
|
||||||
let extension = 'png';
|
let extension = 'png';
|
||||||
let svgs = [
|
let svgs = [
|
||||||
|
'directus',
|
||||||
'pocketbase',
|
'pocketbase',
|
||||||
'gitea',
|
'gitea',
|
||||||
'languagetool',
|
'languagetool',
|
||||||
|
@@ -171,3 +171,11 @@ export const setLocation = (resource: any, settings?: any) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
export const selectedBuildId: any = writable(null)
|
export const selectedBuildId: any = writable(null)
|
||||||
|
export function checkIfDeploymentEnabledServices( service: any) {
|
||||||
|
return (
|
||||||
|
service.fqdn &&
|
||||||
|
service.destinationDocker &&
|
||||||
|
service.version &&
|
||||||
|
service.type
|
||||||
|
);
|
||||||
|
}
|
366
apps/client/src/routes/services/[id]/+layout.svelte
Normal file
366
apps/client/src/routes/services/[id]/+layout.svelte
Normal file
@@ -0,0 +1,366 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import { status, trpc } from '$lib/store';
|
||||||
|
import { onDestroy, onMount } from 'svelte';
|
||||||
|
import type { LayoutData } from './$types';
|
||||||
|
|
||||||
|
export let data: LayoutData;
|
||||||
|
const id = $page.params.id;
|
||||||
|
let service = data.service;
|
||||||
|
let template = data.template;
|
||||||
|
import { errorNotification } from '$lib/common';
|
||||||
|
import {
|
||||||
|
appSession,
|
||||||
|
isDeploymentEnabled,
|
||||||
|
location,
|
||||||
|
setLocation,
|
||||||
|
checkIfDeploymentEnabledServices,
|
||||||
|
addToast
|
||||||
|
} from '$lib/store';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { saveForm } from './utils';
|
||||||
|
import Menu from './components/Menu.svelte';
|
||||||
|
|
||||||
|
$isDeploymentEnabled = checkIfDeploymentEnabledServices(service);
|
||||||
|
|
||||||
|
let statusInterval: any;
|
||||||
|
|
||||||
|
async function deleteService() {
|
||||||
|
const sure = confirm('Are you sure you want to delete this service?');
|
||||||
|
if (sure) {
|
||||||
|
$status.service.initialLoading = true;
|
||||||
|
try {
|
||||||
|
if (service.type && $status.service.isRunning) await trpc.services.stop.mutate({ id });
|
||||||
|
await trpc.services.delete.mutate({ id });
|
||||||
|
return await goto('/');
|
||||||
|
} catch (error) {
|
||||||
|
return errorNotification(error);
|
||||||
|
} finally {
|
||||||
|
$status.service.initialLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function restartService() {
|
||||||
|
const sure = confirm('Are you sure you want to restart this service?');
|
||||||
|
if (sure) {
|
||||||
|
await stopService(true);
|
||||||
|
await startService();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function stopService(skip = false) {
|
||||||
|
if (skip) {
|
||||||
|
$status.service.initialLoading = true;
|
||||||
|
$status.service.loading = true;
|
||||||
|
try {
|
||||||
|
await trpc.services.stop.mutate({ id });
|
||||||
|
if (service.type.startsWith('wordpress')) {
|
||||||
|
await trpc.services.wordpress.mutate({ id, ftpEnabled: false });
|
||||||
|
service.wordpress?.ftpEnabled && window.location.reload();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return errorNotification(error);
|
||||||
|
} finally {
|
||||||
|
$status.service.initialLoading = false;
|
||||||
|
$status.service.loading = false;
|
||||||
|
await getStatus();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const sure = confirm(
|
||||||
|
"Are you sure you want to stop this service? You won't be able to access it anymore."
|
||||||
|
);
|
||||||
|
if (sure) {
|
||||||
|
$status.service.initialLoading = true;
|
||||||
|
$status.service.loading = true;
|
||||||
|
try {
|
||||||
|
await trpc.services.stop.mutate({ id });
|
||||||
|
if (service.type.startsWith('wordpress')) {
|
||||||
|
await trpc.services.wordpress.mutate({ id, ftpEnabled: false });
|
||||||
|
|
||||||
|
service.wordpress?.ftpEnabled && window.location.reload();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return errorNotification(error);
|
||||||
|
} finally {
|
||||||
|
$status.service.initialLoading = false;
|
||||||
|
$status.service.loading = false;
|
||||||
|
await getStatus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function startService() {
|
||||||
|
$status.service.initialLoading = true;
|
||||||
|
$status.service.loading = true;
|
||||||
|
try {
|
||||||
|
const form: any = document.getElementById('saveForm');
|
||||||
|
if (form) {
|
||||||
|
const formData = new FormData(form);
|
||||||
|
service = await saveForm(formData, service);
|
||||||
|
}
|
||||||
|
await trpc.services.start.mutate({ id });
|
||||||
|
} catch (error) {
|
||||||
|
return errorNotification(error);
|
||||||
|
} finally {
|
||||||
|
$status.service.initialLoading = false;
|
||||||
|
$status.service.loading = false;
|
||||||
|
await getStatus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function getStatus() {
|
||||||
|
if ($status.service.loading) return;
|
||||||
|
$status.service.loading = true;
|
||||||
|
const data = await trpc.services.status.query({ id });
|
||||||
|
|
||||||
|
$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.loading = false;
|
||||||
|
$status.service.statuses = [];
|
||||||
|
$status.service.overallStatus = 'stopped';
|
||||||
|
$location = null;
|
||||||
|
$isDeploymentEnabled = false;
|
||||||
|
clearInterval(statusInterval);
|
||||||
|
});
|
||||||
|
onMount(async () => {
|
||||||
|
setLocation(service);
|
||||||
|
$status.service.loading = false;
|
||||||
|
if ($isDeploymentEnabled) {
|
||||||
|
await getStatus();
|
||||||
|
statusInterval = setInterval(async () => {
|
||||||
|
await getStatus();
|
||||||
|
}, 2000);
|
||||||
|
} else {
|
||||||
|
$status.service.initialLoading = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="mx-auto max-w-screen-2xl px-6 grid grid-cols-1 lg:grid-cols-2">
|
||||||
|
<nav class="header flex flex-col lg: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}/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}
|
||||||
|
<div class="flex justify-center items-center space-x-2">
|
||||||
|
<div>Configurations</div>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-row space-x-2 lg:px-2">
|
||||||
|
{#if $page.url.pathname.startsWith(`/services/${id}/configuration/`)}
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
Delete Service
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</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="btn btn-ghost btn-sm gap-2">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-6 w-6 animate-spin duration-500 ease-in-out"
|
||||||
|
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="M9 4.55a8 8 0 0 1 6 14.9m0 -4.45v5h5" />
|
||||||
|
<line x1="5.63" y1="7.16" x2="5.63" y2="7.17" />
|
||||||
|
<line x1="4.06" y1="11" x2="4.06" y2="11.01" />
|
||||||
|
<line x1="4.63" y1="15.1" x2="4.63" y2="15.11" />
|
||||||
|
<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>
|
||||||
|
{$status.service.startup[id] || 'Loading...'}
|
||||||
|
</button>
|
||||||
|
{:else if $status.service.overallStatus === 'healthy'}
|
||||||
|
<button
|
||||||
|
disabled={!$isDeploymentEnabled || !$appSession.isAdmin}
|
||||||
|
class="btn btn-sm gap-2"
|
||||||
|
on:click={() => restartService()}
|
||||||
|
>
|
||||||
|
<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="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>
|
||||||
|
<button
|
||||||
|
on:click={() => stopService(false)}
|
||||||
|
type="submit"
|
||||||
|
disabled={!$isDeploymentEnabled || !$appSession.isAdmin}
|
||||||
|
class="btn btn-sm gap-2"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="w-6 h-6 text-error "
|
||||||
|
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>
|
||||||
|
{:else if $status.service.overallStatus === 'degraded'}
|
||||||
|
<button
|
||||||
|
on:click={() => stopService()}
|
||||||
|
type="submit"
|
||||||
|
disabled={!$isDeploymentEnabled || !$appSession.isAdmin}
|
||||||
|
class="btn btn-sm gap-2"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="w-6 h-6 text-error"
|
||||||
|
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>
|
||||||
|
{:else if $status.service.overallStatus === 'stopped'}
|
||||||
|
{#if $status.service.overallStatus === 'degraded'}
|
||||||
|
<button
|
||||||
|
class="btn btn-sm gap-2"
|
||||||
|
disabled={!$isDeploymentEnabled || !$appSession.isAdmin}
|
||||||
|
on:click={() => restartService()}
|
||||||
|
>
|
||||||
|
<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="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>
|
||||||
|
{$status.application.statuses.length === 1 ? 'Force Redeploy' : 'Redeploy Stack'}
|
||||||
|
</button>
|
||||||
|
{:else if $status.service.overallStatus === 'stopped'}
|
||||||
|
<button
|
||||||
|
class="btn btn-sm gap-2"
|
||||||
|
disabled={!$isDeploymentEnabled || !$appSession.isAdmin}
|
||||||
|
on:click={() => startService()}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="w-6 h-6 text-pink-500"
|
||||||
|
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 4v16l13 -8z" />
|
||||||
|
</svg>
|
||||||
|
Deploy
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="mx-auto max-w-screen-2xl px-0 lg:px-10 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} {template} />
|
||||||
|
</nav>
|
||||||
|
{/if}
|
||||||
|
<div class="pt-0 col-span-0 lg:col-span-3 pb-24 px-4 lg:px-0">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</div>
|
46
apps/client/src/routes/services/[id]/+layout.ts
Normal file
46
apps/client/src/routes/services/[id]/+layout.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { error } from '@sveltejs/kit';
|
||||||
|
import { trpc } from '$lib/store';
|
||||||
|
import type { LayoutLoad } from './$types';
|
||||||
|
import { redirect } from '@sveltejs/kit';
|
||||||
|
|
||||||
|
function checkConfiguration(service: any): string | null {
|
||||||
|
let configurationPhase = null;
|
||||||
|
if (!service.type) {
|
||||||
|
configurationPhase = 'type';
|
||||||
|
} else if (!service.destinationDockerId) {
|
||||||
|
configurationPhase = 'destination';
|
||||||
|
}
|
||||||
|
return configurationPhase;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const load: LayoutLoad = async ({ params, url }) => {
|
||||||
|
const { pathname } = new URL(url);
|
||||||
|
const { id } = params;
|
||||||
|
try {
|
||||||
|
const service = await trpc.services.getServices.query({ id });
|
||||||
|
if (!service) {
|
||||||
|
throw redirect(307, '/services');
|
||||||
|
}
|
||||||
|
const configurationPhase = checkConfiguration(service);
|
||||||
|
console.log({ configurationPhase });
|
||||||
|
// if (
|
||||||
|
// configurationPhase &&
|
||||||
|
// pathname !== `/applications/${params.id}/configuration/${configurationPhase}`
|
||||||
|
// ) {
|
||||||
|
// throw redirect(302, `/applications/${params.id}/configuration/${configurationPhase}`);
|
||||||
|
// }
|
||||||
|
return {
|
||||||
|
...service.data
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof Error) {
|
||||||
|
throw error(500, {
|
||||||
|
message: 'An unexpected error occurred, please try again later.' + '<br><br>' + err.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error(500, {
|
||||||
|
message: 'An unexpected error occurred, please try again later.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
562
apps/client/src/routes/services/[id]/+page.svelte
Normal file
562
apps/client/src/routes/services/[id]/+page.svelte
Normal file
@@ -0,0 +1,562 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { PageData } from './$types';
|
||||||
|
|
||||||
|
export let data: PageData;
|
||||||
|
let service = data.service;
|
||||||
|
let template = data.template;
|
||||||
|
let tags = data.tags;
|
||||||
|
import cuid from 'cuid';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
|
||||||
|
import { errorNotification, getDomain } from '$lib/common';
|
||||||
|
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 DocLink from '$lib/components/DocLink.svelte';
|
||||||
|
import Explainer from '$lib/components/Explainer.svelte';
|
||||||
|
import ServiceStatus from './components/ServiceStatus.svelte';
|
||||||
|
import { saveForm } from './utils';
|
||||||
|
import Select from 'svelte-select';
|
||||||
|
import Wordpress from './components/Wordpress.svelte';
|
||||||
|
import { browser } from '$app/environment';
|
||||||
|
|
||||||
|
const { id } = $page.params;
|
||||||
|
let hostPorts = Object.keys(template).filter((key) => {
|
||||||
|
if (template[key]?.hostPorts?.length > 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
$: isDisabled =
|
||||||
|
!$appSession.isAdmin ||
|
||||||
|
$status.service.overallStatus === 'degraded' ||
|
||||||
|
$status.service.overallStatus === 'healthy' ||
|
||||||
|
$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;
|
||||||
|
|
||||||
|
function containerClass() {
|
||||||
|
return 'text-white bg-transparent font-thin px-0 w-full border border-dashed border-coolgray-200';
|
||||||
|
}
|
||||||
|
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(e: any) {
|
||||||
|
if (loading.save) return;
|
||||||
|
loading.save = true;
|
||||||
|
try {
|
||||||
|
const formData = new FormData(e.target);
|
||||||
|
// await post(`/services/${id}/check`, {
|
||||||
|
// fqdn: service.fqdn,
|
||||||
|
// forceSave,
|
||||||
|
// dualCerts,
|
||||||
|
// exposePort: service.exposePort
|
||||||
|
// });
|
||||||
|
for (const setting of service.serviceSetting) {
|
||||||
|
if (setting.variableName?.startsWith('$$config_coolify_fqdn') && setting.value) {
|
||||||
|
for (let field of formData) {
|
||||||
|
const [key, value] = field;
|
||||||
|
if (setting.name === key) {
|
||||||
|
if (setting.value !== value) {
|
||||||
|
// await post(`/services/${id}/check`, {
|
||||||
|
// fqdn: value,
|
||||||
|
// otherFqdn: true
|
||||||
|
// });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (formData) service = await saveForm(formData, service);
|
||||||
|
setLocation(service);
|
||||||
|
forceSave = false;
|
||||||
|
$isDeploymentEnabled = checkIfDeploymentEnabledServices(service);
|
||||||
|
return addToast({
|
||||||
|
message: 'Configuration saved.',
|
||||||
|
type: 'success'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
//@ts-ignore
|
||||||
|
if (error?.message.startsWith('DNS not set')) {
|
||||||
|
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: 'Emails have been 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) {
|
||||||
|
if (!$appSession.isAdmin) return;
|
||||||
|
try {
|
||||||
|
if (name === 'dualCerts') {
|
||||||
|
dualCerts = !dualCerts;
|
||||||
|
}
|
||||||
|
// await post(`/services/${id}/settings`, { dualCerts });
|
||||||
|
return addToast({
|
||||||
|
message: 'Settings updated.',
|
||||||
|
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 unnecessary database logs.',
|
||||||
|
type: 'success'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return errorNotification(error);
|
||||||
|
} finally {
|
||||||
|
loading.cleanup = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function selectTag(event: any) {
|
||||||
|
service.version = event.detail.value;
|
||||||
|
}
|
||||||
|
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="w-full">
|
||||||
|
<form id="saveForm" 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 ? 'Saving...' : forceSave ? 'Continue' : 'Force Save'}</button
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
{#if service.type === 'plausibleanalytics' && $status.service.overallStatus === 'healthy'}
|
||||||
|
<button
|
||||||
|
class="btn btn-sm"
|
||||||
|
on:click|preventDefault={setEmailsToVerified}
|
||||||
|
disabled={loading.verification}
|
||||||
|
class:loading={loading.verification}
|
||||||
|
>{loading.verification ? 'Verifying...' : 'Verify without SMTP'}</button
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="btn btn-sm"
|
||||||
|
on:click|preventDefault={cleanupLogs}
|
||||||
|
disabled={loading.cleanup}
|
||||||
|
class:loading={loading.cleanup}>Cleanup Unnecessary Database Logs</button
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
{#if service.type === 'appwrite' && $status.service.overallStatus === 'healthy'}
|
||||||
|
<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
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<DocLink url="https://appwrite.io/docs/upgrade#run-the-migration" />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-flow-row gap-2 px-4">
|
||||||
|
<div class="mt-2 grid grid-cols-2 items-center">
|
||||||
|
<label for="name">Name</label>
|
||||||
|
<input
|
||||||
|
name="name"
|
||||||
|
id="name"
|
||||||
|
class="w-full"
|
||||||
|
disabled={!$appSession.isAdmin}
|
||||||
|
bind:value={service.name}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 items-center">
|
||||||
|
<label for="version">Version / Tag</label>
|
||||||
|
{#if tags.tags?.length > 0}
|
||||||
|
<div class="custom-select-wrapper w-full">
|
||||||
|
<Select
|
||||||
|
form="saveForm"
|
||||||
|
containerClasses={isDisabled && containerClass()}
|
||||||
|
{isDisabled}
|
||||||
|
id="version"
|
||||||
|
showIndicator={!isDisabled}
|
||||||
|
items={[...tags.tags]}
|
||||||
|
on:select={selectTag}
|
||||||
|
value={service.version}
|
||||||
|
isClearable={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<input class="w-full border-red-500" disabled placeholder="Error getting tags..." />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 items-center">
|
||||||
|
<label for="destination">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>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 items-center">
|
||||||
|
<label for="fqdn"
|
||||||
|
>FQDN
|
||||||
|
<Explainer
|
||||||
|
explanation={"If you specify <span class='text-settings '>https</span>, the application will be accessible only over https. SSL certificate will be generated for you.<br>If you specify <span class='text-settings '>www</span>, the application will be redirected (302) from non-www and vice versa.<br><br>To modify the domain, you must first stop the application.<br><br><span class='text-white '>You must set your DNS to point to the server IP in advance.</span>"}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<CopyPasswordField
|
||||||
|
placeholder="eg: https://coollabs.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>
|
||||||
|
{#each Object.keys(template) as oneService}
|
||||||
|
{#each template[oneService].fqdns as fqdn}
|
||||||
|
<div class="grid grid-cols-2 items-center py-1">
|
||||||
|
<label for={fqdn.name}>{fqdn.label || fqdn.name}</label>
|
||||||
|
<CopyPasswordField
|
||||||
|
placeholder="eg: https://coolify.io"
|
||||||
|
readonly={isDisabled}
|
||||||
|
disabled={isDisabled}
|
||||||
|
required={fqdn.required}
|
||||||
|
name={fqdn.name}
|
||||||
|
id={fqdn.name}
|
||||||
|
bind:value={fqdn.value}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{/each}
|
||||||
|
</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 || !$appSession.isAdmin}
|
||||||
|
dataTooltip="You must stop the application to change this setting."
|
||||||
|
bind:setting={dualCerts}
|
||||||
|
title="Generate SSL for www and non-www?"
|
||||||
|
description={"It will generate certificates for both www and non-www. <br>You need to have <span class='text-settings'>both DNS entries</span> set in advance.<br><br>Service needs to be restarted."}
|
||||||
|
on:click={() => !$status.service.isRunning && changeSettings('dualCerts')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{#if hostPorts.length === 0}
|
||||||
|
<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={isDisabled}
|
||||||
|
disabled={isDisabled}
|
||||||
|
name="exposePort"
|
||||||
|
id="exposePort"
|
||||||
|
bind:value={service.exposePort}
|
||||||
|
placeholder="12345"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="pt-6">
|
||||||
|
{#each Object.keys(template) as oneService}
|
||||||
|
<div
|
||||||
|
class="flex flex-row my-2 space-x-2 mb-6"
|
||||||
|
class:my-6={template[oneService].environment.length > 0 &&
|
||||||
|
template[oneService].environment.find((env) => env.main === oneService)}
|
||||||
|
class:border-b={template[oneService].environment.length > 0 &&
|
||||||
|
template[oneService].environment.find((env) => env.main === oneService)}
|
||||||
|
class:border-coolgray-500={template[oneService].environment.length > 0 &&
|
||||||
|
template[oneService].environment.find((env) => env.main === oneService)}
|
||||||
|
>
|
||||||
|
<div class="title font-bold pb-3 capitalize">
|
||||||
|
{template[oneService].name ||
|
||||||
|
oneService.replace(`${id}-`, '').replace(id, service.type)}
|
||||||
|
</div>
|
||||||
|
<ServiceStatus id={oneService} />
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-flow-row gap-2 px-4">
|
||||||
|
{#if template[oneService].environment.length > 0}
|
||||||
|
{#each template[oneService].environment as variable}
|
||||||
|
{#if variable.main === oneService}
|
||||||
|
<div class="grid grid-cols-2 items-center gap-2">
|
||||||
|
<label class="h-10" for={variable.name}
|
||||||
|
>{variable.label || variable.name}
|
||||||
|
{#if variable.description}
|
||||||
|
<Explainer explanation={variable.description} />
|
||||||
|
{/if}</label
|
||||||
|
>
|
||||||
|
{#if variable.defaultValue === '$$generate_fqdn'}
|
||||||
|
<CopyPasswordField
|
||||||
|
disabled
|
||||||
|
readonly
|
||||||
|
name={variable.name}
|
||||||
|
id={variable.name}
|
||||||
|
value={service.fqdn}
|
||||||
|
placeholder={variable.placeholder}
|
||||||
|
required={variable?.required}
|
||||||
|
/>
|
||||||
|
{:else if variable.defaultValue === '$$generate_fqdn_slash'}
|
||||||
|
<CopyPasswordField
|
||||||
|
disabled
|
||||||
|
readonly
|
||||||
|
name={variable.name}
|
||||||
|
id={variable.name}
|
||||||
|
value={service.fqdn + '/' || ''}
|
||||||
|
placeholder={variable.placeholder}
|
||||||
|
required={variable?.required}
|
||||||
|
/>
|
||||||
|
{:else if variable.defaultValue === '$$generate_domain'}
|
||||||
|
<CopyPasswordField
|
||||||
|
disabled
|
||||||
|
readonly
|
||||||
|
name={variable.name}
|
||||||
|
id={variable.name}
|
||||||
|
value={getDomain(service.fqdn) || ''}
|
||||||
|
placeholder={variable.placeholder}
|
||||||
|
required={variable?.required}
|
||||||
|
/>
|
||||||
|
{:else if variable.defaultValue === '$$generate_network'}
|
||||||
|
<CopyPasswordField
|
||||||
|
disabled
|
||||||
|
readonly
|
||||||
|
name={variable.name}
|
||||||
|
id={variable.name}
|
||||||
|
value={service.destinationDocker.network}
|
||||||
|
placeholder={variable.placeholder}
|
||||||
|
required={variable?.required}
|
||||||
|
/>
|
||||||
|
{:else if variable.defaultValue === 'true' || variable.defaultValue === 'false'}
|
||||||
|
{#if variable.value === 'true' || variable.value === 'false' || variable.value === 'invite_only'}
|
||||||
|
<select
|
||||||
|
class="w-full font-normal"
|
||||||
|
readonly={isDisabled}
|
||||||
|
disabled={isDisabled}
|
||||||
|
id={variable.name}
|
||||||
|
name={variable.name}
|
||||||
|
bind:value={variable.value}
|
||||||
|
form="saveForm"
|
||||||
|
placeholder={variable.placeholder}
|
||||||
|
required={variable?.required}
|
||||||
|
>
|
||||||
|
<option value="true">enabled</option>
|
||||||
|
<option value="false">disabled</option>
|
||||||
|
{#if service.type.startsWith('plausibleanalytics') && variable.id == 'config_disable_registration'}
|
||||||
|
<option value="invite_only">invite_only</option>
|
||||||
|
{/if}
|
||||||
|
</select>
|
||||||
|
{:else}
|
||||||
|
<select
|
||||||
|
class="w-full font-normal"
|
||||||
|
readonly={isDisabled}
|
||||||
|
disabled={isDisabled}
|
||||||
|
id={variable.name}
|
||||||
|
name={variable.name}
|
||||||
|
bind:value={variable.defaultValue}
|
||||||
|
form="saveForm"
|
||||||
|
placeholder={variable.placeholder}
|
||||||
|
required={variable?.required}
|
||||||
|
>
|
||||||
|
<option value="true">true</option>
|
||||||
|
<option value="false">false</option>
|
||||||
|
</select>
|
||||||
|
{/if}
|
||||||
|
{:else if variable.defaultValue === '$$generate_password'}
|
||||||
|
<CopyPasswordField
|
||||||
|
isPasswordField
|
||||||
|
readonly
|
||||||
|
disabled
|
||||||
|
name={variable.name}
|
||||||
|
id={variable.name}
|
||||||
|
value={variable.value}
|
||||||
|
placeholder={variable.placeholder}
|
||||||
|
required={variable?.required}
|
||||||
|
/>
|
||||||
|
{:else if variable.type === 'textarea'}
|
||||||
|
<textarea
|
||||||
|
class="w-full"
|
||||||
|
value={variable.value}
|
||||||
|
readonly={isDisabled}
|
||||||
|
disabled={isDisabled}
|
||||||
|
class:resize-none={$status.service.overallStatus === 'healthy'}
|
||||||
|
rows="5"
|
||||||
|
name={variable.name}
|
||||||
|
id={variable.name}
|
||||||
|
placeholder={variable.placeholder}
|
||||||
|
required={variable?.required}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<CopyPasswordField
|
||||||
|
isPasswordField={variable.id.startsWith('secret')}
|
||||||
|
required={variable?.required}
|
||||||
|
readonly={variable.readOnly || isDisabled}
|
||||||
|
disabled={variable.readOnly || isDisabled}
|
||||||
|
name={variable.name}
|
||||||
|
id={variable.name}
|
||||||
|
value={variable.value}
|
||||||
|
placeholder={variable.placeholder}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
{#if template[oneService].name.toLowerCase() === 'wordpress' && service.type.startsWith('wordpress')}
|
||||||
|
<Wordpress {service} />
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
138
apps/client/src/routes/services/[id]/components/Menu.svelte
Normal file
138
apps/client/src/routes/services/[id]/components/Menu.svelte
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
export let service: any;
|
||||||
|
export let template: any;
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import { appSession } from '$lib/store';
|
||||||
|
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 {template} {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="rounded"
|
||||||
|
class:bg-coollabs={$page.url.pathname === `/services/${$page.params.id}/logs`}
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href={`/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>
|
||||||
|
{#if $appSession.isAdmin}
|
||||||
|
<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>
|
||||||
|
{/if}
|
||||||
|
</ul>
|
@@ -0,0 +1,14 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import DocLink from '$lib/components/DocLink.svelte';
|
||||||
|
import ServiceIcons from '$lib/components/icons/services/ServiceIcons.svelte';
|
||||||
|
export let service: any;
|
||||||
|
export let template: any;
|
||||||
|
export let linkToDocs: boolean = false;
|
||||||
|
const name: any = service.type && service.type[0].toUpperCase() + service.type.substring(1);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if linkToDocs}
|
||||||
|
<DocLink url={template[service?.id]?.documentation || 'https://docs.coollabs.io'} text={`Documentation`} isExternal={true} />
|
||||||
|
{:else}
|
||||||
|
<ServiceIcons type={service.type} />
|
||||||
|
{/if}
|
@@ -0,0 +1,37 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
export let id: any;
|
||||||
|
import { status } from '$lib/store';
|
||||||
|
let serviceStatus = {
|
||||||
|
isExcluded: false,
|
||||||
|
isExited: false,
|
||||||
|
isRunning: false,
|
||||||
|
isRestarting: false,
|
||||||
|
isStopped: false
|
||||||
|
};
|
||||||
|
|
||||||
|
$: if (Object.keys($status.service.statuses).length > 0 && $status.service.statuses[id]?.status) {
|
||||||
|
let { isExited, isRunning, isRestarting, isExcluded } = $status.service.statuses[id].status;
|
||||||
|
|
||||||
|
serviceStatus.isExited = isExited;
|
||||||
|
serviceStatus.isRunning = isRunning;
|
||||||
|
serviceStatus.isExcluded = isExcluded;
|
||||||
|
serviceStatus.isRestarting = isRestarting;
|
||||||
|
serviceStatus.isStopped = !isExited && !isRunning && !isRestarting;
|
||||||
|
} else {
|
||||||
|
serviceStatus.isExited = false;
|
||||||
|
serviceStatus.isRunning = false;
|
||||||
|
serviceStatus.isExcluded = false;
|
||||||
|
serviceStatus.isRestarting = false;
|
||||||
|
serviceStatus.isStopped = true;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if serviceStatus.isExcluded}
|
||||||
|
<span class="badge font-bold uppercase rounded text-orange-500 mt-2">Excluded</span>
|
||||||
|
{:else 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}
|
@@ -0,0 +1,85 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import { status } from '$lib/store';
|
||||||
|
import CopyPasswordField from '$lib/components/CopyPasswordField.svelte';
|
||||||
|
import Setting from '$lib/components/Setting.svelte';
|
||||||
|
import { errorNotification, getDomain } from '$lib/common';
|
||||||
|
import { browser } from '$app/environment';
|
||||||
|
|
||||||
|
export let service: any;
|
||||||
|
const { id } = $page.params;
|
||||||
|
const settings = service.settings;
|
||||||
|
const { ipv4, ipv6 } = settings;
|
||||||
|
|
||||||
|
let ftpUrl = generateUrl(service.wordpress?.ftpPublicPort) || '';
|
||||||
|
let ftpUser = service.wordpress?.ftpUser;
|
||||||
|
let ftpPassword = service.wordpress?.ftpPassword;
|
||||||
|
let ftpLoading = false;
|
||||||
|
let ftpEnabled = service.wordpress?.ftpEnabled || false;
|
||||||
|
|
||||||
|
function generateUrl(publicPort: any) {
|
||||||
|
return browser
|
||||||
|
? `sftp://${settings?.fqdn ? getDomain(settings.fqdn) : ipv4 || ipv6}:${publicPort}`
|
||||||
|
: 'Loading...';
|
||||||
|
}
|
||||||
|
async function changeSettings(name: any) {
|
||||||
|
if (ftpLoading) return;
|
||||||
|
if ($status.service.overallStatus === 'healthy') {
|
||||||
|
ftpLoading = true;
|
||||||
|
if (name === 'ftpEnabled') {
|
||||||
|
ftpEnabled = !ftpEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// const {
|
||||||
|
// publicPort,
|
||||||
|
// ftpUser: user,
|
||||||
|
// ftpPassword: password
|
||||||
|
// } = await post(`/services/${id}/wordpress/ftp`, {
|
||||||
|
// ftpEnabled
|
||||||
|
// });
|
||||||
|
// ftpUrl = generateUrl(publicPort);
|
||||||
|
// ftpUser = user;
|
||||||
|
// ftpPassword = password;
|
||||||
|
// service.wordpress.ftpEnabled = ftpEnabled;
|
||||||
|
} catch (error) {
|
||||||
|
return errorNotification(error);
|
||||||
|
} finally {
|
||||||
|
ftpLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 items-center">
|
||||||
|
<Setting
|
||||||
|
id="ftpEnabled"
|
||||||
|
bind:setting={ftpEnabled}
|
||||||
|
loading={ftpLoading}
|
||||||
|
disabled={$status.service.overallStatus !== 'healthy'}
|
||||||
|
on:click={() => changeSettings('ftpEnabled')}
|
||||||
|
title="Enable sFTP connection to WordPress data"
|
||||||
|
description="Enables an on-demand sFTP connection to the WordPress data directory. This is useful if you want to use sFTP to upload files."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{#if service.wordpress?.ftpEnabled}
|
||||||
|
<div class="grid grid-cols-2 items-center">
|
||||||
|
<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">
|
||||||
|
<label for="ftpUser">User</label>
|
||||||
|
<CopyPasswordField id="ftpUser" readonly disabled name="ftpUser" value={ftpUser} />
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 items-center">
|
||||||
|
<label for="ftpPassword">Password</label>
|
||||||
|
<CopyPasswordField
|
||||||
|
id="ftpPassword"
|
||||||
|
isPasswordField
|
||||||
|
readonly
|
||||||
|
disabled
|
||||||
|
name="ftpPassword"
|
||||||
|
value={ftpPassword}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
46
apps/client/src/routes/services/[id]/danger/+page.svelte
Normal file
46
apps/client/src/routes/services/[id]/danger/+page.svelte
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { PageData } from './$types';
|
||||||
|
|
||||||
|
export let data: PageData;
|
||||||
|
let service: any = data.service.data;
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import { appSession, status, trpc } from '$lib/store';
|
||||||
|
import { errorNotification } from '$lib/common';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
const { id } = $page.params;
|
||||||
|
|
||||||
|
async function deleteService() {
|
||||||
|
const sure = confirm('Are you sure you want to delete this service?');
|
||||||
|
if (sure) {
|
||||||
|
$status.service.initialLoading = true;
|
||||||
|
try {
|
||||||
|
if (service.type && $status.service.overallStatus !== 'stopped') {
|
||||||
|
await trpc.services.stop.mutate({ id });
|
||||||
|
}
|
||||||
|
await trpc.services.delete.mutate({ 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 Service
|
||||||
|
</button>
|
||||||
|
</div>
|
173
apps/client/src/routes/services/[id]/logs/+page.svelte
Normal file
173
apps/client/src/routes/services/[id]/logs/+page.svelte
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import { errorNotification } from '$lib/common';
|
||||||
|
import { trpc } from '$lib/store';
|
||||||
|
import { onDestroy, onMount } from 'svelte';
|
||||||
|
|
||||||
|
let service: any = {};
|
||||||
|
let template: any = null;
|
||||||
|
let logsLoading = false;
|
||||||
|
let loadLogsInterval: any = null;
|
||||||
|
let logs: any = [];
|
||||||
|
let lastLog: any = null;
|
||||||
|
let followingInterval: any;
|
||||||
|
let followingLogs: any;
|
||||||
|
let logsEl: any;
|
||||||
|
let position = 0;
|
||||||
|
let selectedService: any = null;
|
||||||
|
let noContainer = false;
|
||||||
|
|
||||||
|
const { id } = $page.params;
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
const { data } = await trpc.services.getServices.query({ id });
|
||||||
|
template = data.template;
|
||||||
|
service = data.service;
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
clearInterval(loadLogsInterval);
|
||||||
|
clearInterval(followingInterval);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadLogs() {
|
||||||
|
if (logsLoading) return;
|
||||||
|
try {
|
||||||
|
const { data } = await trpc.services.getLogs.query({
|
||||||
|
id,
|
||||||
|
containerId: selectedService,
|
||||||
|
since: Number(lastLog?.split(' ')[0]) || 0
|
||||||
|
});
|
||||||
|
|
||||||
|
if (data.noContainer) {
|
||||||
|
noContainer = true;
|
||||||
|
logs = [];
|
||||||
|
if (logs.length > 0) {
|
||||||
|
clearInterval(loadLogsInterval);
|
||||||
|
selectedService = null;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
noContainer = false;
|
||||||
|
}
|
||||||
|
if (data?.logs && data.logs[data.logs.length - 1] !== logs[logs.length - 1]) {
|
||||||
|
logs = logs.concat(data.logs);
|
||||||
|
lastLog = data.logs[data.logs.length - 1];
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return errorNotification(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function detect() {
|
||||||
|
if (position < logsEl.scrollTop) {
|
||||||
|
position = logsEl.scrollTop;
|
||||||
|
} else {
|
||||||
|
if (followingLogs) {
|
||||||
|
clearInterval(followingInterval);
|
||||||
|
followingLogs = false;
|
||||||
|
}
|
||||||
|
position = logsEl.scrollTop;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function followBuild() {
|
||||||
|
followingLogs = !followingLogs;
|
||||||
|
if (followingLogs) {
|
||||||
|
followingInterval = setInterval(() => {
|
||||||
|
logsEl.scrollTop = logsEl.scrollHeight;
|
||||||
|
window.scrollTo(0, document.body.scrollHeight);
|
||||||
|
}, 1000);
|
||||||
|
} else {
|
||||||
|
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="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">Service Logs</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if template}
|
||||||
|
<div class="grid grid-cols-3 gap-2 lg:gap-8 pb-4">
|
||||||
|
{#each Object.keys(template) 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"
|
||||||
|
>
|
||||||
|
{#if template[service].name}
|
||||||
|
{template[service].name || ''} <br /><span class="text-xs">({service})</span>
|
||||||
|
{:else}
|
||||||
|
<span>{service}</span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="w-full flex justify-center font-bold text-xl">Loading components...</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if selectedService}
|
||||||
|
<div class="flex flex-row justify-center space-x-2">
|
||||||
|
{#if logs.length === 0}
|
||||||
|
{#if noContainer}
|
||||||
|
<div class="text-xl font-bold tracking-tighter">Container not found / exited.</div>
|
||||||
|
{/if}
|
||||||
|
{: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 " class:bg-coollabs={followingLogs}>
|
||||||
|
<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>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
98
apps/client/src/routes/services/[id]/secrets/+page.svelte
Normal file
98
apps/client/src/routes/services/[id]/secrets/+page.svelte
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { PageData } from './$types';
|
||||||
|
|
||||||
|
export let data: PageData;
|
||||||
|
let secrets = data.secrets;
|
||||||
|
import Secret from './components/Secret.svelte';
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import pLimit from 'p-limit';
|
||||||
|
import { addToast, appSession, trpc } from '$lib/store';
|
||||||
|
import { saveSecret } from './utils';
|
||||||
|
const limit = pLimit(1);
|
||||||
|
|
||||||
|
const { id } = $page.params;
|
||||||
|
let batchSecrets = '';
|
||||||
|
|
||||||
|
async function refreshSecrets() {
|
||||||
|
const { data } = await trpc.services.getSecrets.query({ id });
|
||||||
|
secrets = [...data.secrets];
|
||||||
|
}
|
||||||
|
async function getValues() {
|
||||||
|
if (!batchSecrets) return;
|
||||||
|
const eachValuePair = batchSecrets.split('\n');
|
||||||
|
const batchSecretsPairs = eachValuePair
|
||||||
|
.filter((secret) => !secret.startsWith('#') && secret)
|
||||||
|
.map((secret) => {
|
||||||
|
const [name, ...rest] = secret.split('=');
|
||||||
|
const value = rest.join('=');
|
||||||
|
const cleanValue = value?.replaceAll('"', '') || '';
|
||||||
|
return {
|
||||||
|
name: name.trim(),
|
||||||
|
value: cleanValue.trim(),
|
||||||
|
isNew: !secrets.find((secret: any) => name === secret.name)
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
batchSecretsPairs.map(({ name, value, isNew }) =>
|
||||||
|
limit(() => saveSecret({ name, value, serviceId: id, isNew }))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
batchSecrets = '';
|
||||||
|
await refreshSecrets();
|
||||||
|
addToast({
|
||||||
|
message: 'Secrets saved.',
|
||||||
|
type: 'success'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</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">Secrets</div>
|
||||||
|
</div>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full border-separate text-left">
|
||||||
|
<thead>
|
||||||
|
<tr class="uppercase">
|
||||||
|
<th scope="col">Name</th>
|
||||||
|
<th scope="col uppercase">Value</th>
|
||||||
|
<th scope="col uppercase" class="w-96 text-center">Action</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="space-y-2">
|
||||||
|
{#each secrets as secret}
|
||||||
|
{#key secret.id}
|
||||||
|
<tr>
|
||||||
|
<Secret
|
||||||
|
name={secret.name}
|
||||||
|
value={secret.value}
|
||||||
|
readonly={secret.readOnly}
|
||||||
|
on:refresh={refreshSecrets}
|
||||||
|
/>
|
||||||
|
</tr>
|
||||||
|
{/key}
|
||||||
|
{/each}
|
||||||
|
<tr>
|
||||||
|
<Secret isNewSecret on:refresh={refreshSecrets} />
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{#if $appSession.isAdmin}
|
||||||
|
<form on:submit|preventDefault={getValues} class="mb-12 w-full">
|
||||||
|
<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>
|
||||||
|
{/if}
|
||||||
|
</div>
|
16
apps/client/src/routes/services/[id]/secrets/+page.ts
Normal file
16
apps/client/src/routes/services/[id]/secrets/+page.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { error } from '@sveltejs/kit';
|
||||||
|
import { trpc } from '$lib/store';
|
||||||
|
import type { PageLoad } from './$types';
|
||||||
|
export const ssr = false;
|
||||||
|
|
||||||
|
export const load: PageLoad = async ({ params }) => {
|
||||||
|
try {
|
||||||
|
const { id } = params;
|
||||||
|
const { data } = await trpc.services.getSecrets.query({ id });
|
||||||
|
return data;
|
||||||
|
} catch (err) {
|
||||||
|
throw error(500, {
|
||||||
|
message: 'An unexpected error occurred, please try again later.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
@@ -0,0 +1,101 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
export let name = '';
|
||||||
|
export let value = '';
|
||||||
|
export let readonly = false;
|
||||||
|
export let isNewSecret = false;
|
||||||
|
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import { errorNotification } from '$lib/common';
|
||||||
|
import CopyPasswordField from '$lib/components/CopyPasswordField.svelte';
|
||||||
|
import { addToast, appSession, trpc } from '$lib/store';
|
||||||
|
import { createEventDispatcher } from 'svelte';
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher();
|
||||||
|
const { id } = $page.params;
|
||||||
|
async function removeSecret() {
|
||||||
|
try {
|
||||||
|
await trpc.services.deleteSecret.mutate({
|
||||||
|
name,
|
||||||
|
id
|
||||||
|
});
|
||||||
|
dispatch('refresh');
|
||||||
|
if (isNewSecret) {
|
||||||
|
name = '';
|
||||||
|
value = '';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return errorNotification(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function saveSecret(isNew = false) {
|
||||||
|
if (!name) return errorNotification({ message: 'Name is required.' });
|
||||||
|
if (!value) return errorNotification({ message: 'Value is required.' });
|
||||||
|
try {
|
||||||
|
await trpc.services.createSecret.mutate({
|
||||||
|
name,
|
||||||
|
value,
|
||||||
|
id,
|
||||||
|
isNew
|
||||||
|
});
|
||||||
|
|
||||||
|
dispatch('refresh');
|
||||||
|
if (isNewSecret) {
|
||||||
|
name = '';
|
||||||
|
value = '';
|
||||||
|
}
|
||||||
|
addToast({
|
||||||
|
message: 'Secret saved.',
|
||||||
|
type: 'success'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return errorNotification(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
style="min-width: 350px !important;"
|
||||||
|
id={isNewSecret ? 'secretName' : 'secretNameNew'}
|
||||||
|
bind:value={name}
|
||||||
|
required
|
||||||
|
placeholder="EXAMPLE_VARIABLE"
|
||||||
|
readonly={!isNewSecret || readonly}
|
||||||
|
class="w-full"
|
||||||
|
class:bg-coolblack={!isNewSecret}
|
||||||
|
class:border={!isNewSecret}
|
||||||
|
class:border-dashed={!isNewSecret}
|
||||||
|
class:border-coolgray-300={!isNewSecret}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<CopyPasswordField
|
||||||
|
id={isNewSecret ? 'secretValue' : 'secretValueNew'}
|
||||||
|
name={isNewSecret ? 'secretValue' : 'secretValueNew'}
|
||||||
|
disabled={readonly}
|
||||||
|
{readonly}
|
||||||
|
isPasswordField={true}
|
||||||
|
bind:value
|
||||||
|
placeholder="J$#@UIO%HO#$U%H"
|
||||||
|
inputStyle="min-width: 350px; !important"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{#if $appSession.isAdmin}
|
||||||
|
<td>
|
||||||
|
{#if isNewSecret}
|
||||||
|
<div class="flex items-center justify-center">
|
||||||
|
<button class="btn btn-sm btn-primary" on:click={() => saveSecret(true)}>Add</button>
|
||||||
|
</div>
|
||||||
|
{:else if !readonly}
|
||||||
|
<div class="flex flex-row justify-center space-x-2">
|
||||||
|
<div class="flex items-center justify-center">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
{/if}
|
78
apps/client/src/routes/services/[id]/secrets/utils.ts
Normal file
78
apps/client/src/routes/services/[id]/secrets/utils.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { errorNotification } from '$lib/common';
|
||||||
|
import { trpc } from '$lib/store';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
isNew: boolean;
|
||||||
|
name: string;
|
||||||
|
value: string;
|
||||||
|
isBuildSecret?: boolean;
|
||||||
|
isPRMRSecret?: boolean;
|
||||||
|
isNewSecret?: boolean;
|
||||||
|
serviceId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function saveSecret({
|
||||||
|
isNew,
|
||||||
|
name,
|
||||||
|
value,
|
||||||
|
isBuildSecret,
|
||||||
|
isNewSecret
|
||||||
|
}: Props): Promise<void> {
|
||||||
|
if (!name) return errorNotification('Name is required');
|
||||||
|
if (!value) return errorNotification('Value is required');
|
||||||
|
try {
|
||||||
|
await trpc.services.createSecret.mutate({
|
||||||
|
name,
|
||||||
|
value,
|
||||||
|
isBuildSecret,
|
||||||
|
isNew: isNew || false
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isNewSecret) {
|
||||||
|
name = '';
|
||||||
|
value = '';
|
||||||
|
isBuildSecret = false;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveForm(formData: any, service: any) {
|
||||||
|
const settings = service.serviceSetting.map((setting: { name: string }) => setting.name);
|
||||||
|
const secrets = service.serviceSecret.map((secret: { name: string }) => secret.name);
|
||||||
|
const baseCoolifySetting = ['name', 'fqdn', 'exposePort', 'version'];
|
||||||
|
for (let field of formData) {
|
||||||
|
const [key, value] = field;
|
||||||
|
if (secrets.includes(key) && value) {
|
||||||
|
await trpc.services.createSecret.mutate({
|
||||||
|
name: key,
|
||||||
|
value
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
service.serviceSetting = service.serviceSetting.map((setting: any) => {
|
||||||
|
if (setting.name === key) {
|
||||||
|
setting.changed = true;
|
||||||
|
setting.value = value;
|
||||||
|
}
|
||||||
|
return setting;
|
||||||
|
});
|
||||||
|
if (!settings.includes(key) && !baseCoolifySetting.includes(key)) {
|
||||||
|
service.serviceSetting.push({
|
||||||
|
id: service.id,
|
||||||
|
name: key,
|
||||||
|
value: value,
|
||||||
|
isNew: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (baseCoolifySetting.includes(key)) {
|
||||||
|
service[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await trpc.services.saveService.mutate(service);
|
||||||
|
const {
|
||||||
|
data: { service: reloadedService }
|
||||||
|
} = await trpc.services.getServices.query({ id: service.id });
|
||||||
|
return reloadedService;
|
||||||
|
}
|
73
apps/client/src/routes/services/[id]/storages/+page.svelte
Normal file
73
apps/client/src/routes/services/[id]/storages/+page.svelte
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { PageData } from './$types';
|
||||||
|
|
||||||
|
export let data: PageData;
|
||||||
|
let persistentStorages = data.persistentStorages;
|
||||||
|
let template = data.template;
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import Storage from './components/Storage.svelte';
|
||||||
|
import Explainer from '$lib/components/Explainer.svelte';
|
||||||
|
import { appSession, trpc } from '$lib/store';
|
||||||
|
|
||||||
|
const { id } = $page.params;
|
||||||
|
async function refreshStorage() {
|
||||||
|
const { data } = await trpc.services.getStorages.query({ id });
|
||||||
|
persistentStorages = [...data.persistentStorages];
|
||||||
|
}
|
||||||
|
let services = Object.keys(template).map((service) => {
|
||||||
|
if (template[service]?.name) {
|
||||||
|
return {
|
||||||
|
name: template[service].name,
|
||||||
|
id: service
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return service;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<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="You can specify any folder that you want to be persistent across deployments.<br><br><span class='text-settings '>/example</span> means it will preserve <span class='text-settings '>/example</span> between deployments.<br><br>Your application's data is copied to <span class='text-settings '>/app</span> inside the container, you can preserve data under it as well, like <span class='text-settings '>/app/db</span>.<br><br>This is useful for storing data such as a <span class='text-settings '>database (SQLite)</span> or a <span class='text-settings '>cache</span>."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{#if persistentStorages.filter((s) => s.predefined).length > 0}
|
||||||
|
<div class="title">Predefined Volumes</div>
|
||||||
|
<div class="w-full lg:px-0 px-4">
|
||||||
|
<div class="grid grid-col-1 lg:grid-cols-2 pt-2 gap-2">
|
||||||
|
<div class="font-bold uppercase">Container</div>
|
||||||
|
<div class="font-bold uppercase">Volume ID : Mount Dir</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#each persistentStorages.filter((s) => s.predefined) as storage}
|
||||||
|
{#key storage.id}
|
||||||
|
<Storage on:refresh={refreshStorage} {storage} {services} />
|
||||||
|
{/key}
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if persistentStorages.filter((s) => !s.predefined).length > 0}
|
||||||
|
<div class="title" class:pt-10={persistentStorages.filter((s) => s.predefined).length > 0}>
|
||||||
|
Custom Volumes
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#each persistentStorages.filter((s) => !s.predefined) as storage}
|
||||||
|
{#key storage.id}
|
||||||
|
<Storage on:refresh={refreshStorage} {storage} {services} />
|
||||||
|
{/key}
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
{#if $appSession.isAdmin}
|
||||||
|
<div class="title" class:pt-10={persistentStorages.filter((s) => s.predefined).length > 0}>
|
||||||
|
Add New Volume
|
||||||
|
</div>
|
||||||
|
<Storage on:refresh={refreshStorage} isNew {services} />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
16
apps/client/src/routes/services/[id]/storages/+page.ts
Normal file
16
apps/client/src/routes/services/[id]/storages/+page.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { error } from '@sveltejs/kit';
|
||||||
|
import { trpc } from '$lib/store';
|
||||||
|
import type { PageLoad } from './$types';
|
||||||
|
export const ssr = false;
|
||||||
|
|
||||||
|
export const load: PageLoad = async ({ params }) => {
|
||||||
|
try {
|
||||||
|
const { id } = params;
|
||||||
|
const { data } = await trpc.services.getStorages.query({ id });
|
||||||
|
return data;
|
||||||
|
} catch (err) {
|
||||||
|
throw error(500, {
|
||||||
|
message: 'An unexpected error occurred, please try again later.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
@@ -0,0 +1,167 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
export let isNew = false;
|
||||||
|
export let storage: any = {};
|
||||||
|
export let services: any = [];
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import { createEventDispatcher } from 'svelte';
|
||||||
|
|
||||||
|
import { errorNotification } from '$lib/common';
|
||||||
|
import { addToast, trpc } from '$lib/store';
|
||||||
|
const { id } = $page.params;
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher();
|
||||||
|
async function saveStorage(e: any) {
|
||||||
|
try {
|
||||||
|
const formData = new FormData(e.target);
|
||||||
|
let isNewStorage = true;
|
||||||
|
let newStorage: any = {
|
||||||
|
id: null,
|
||||||
|
containerId: null,
|
||||||
|
path: null
|
||||||
|
};
|
||||||
|
for (let field of formData) {
|
||||||
|
const [key, value] = field;
|
||||||
|
newStorage[key] = value;
|
||||||
|
}
|
||||||
|
newStorage.path = newStorage.path.startsWith('/') ? newStorage.path : `/${newStorage.path}`;
|
||||||
|
newStorage.path = newStorage.path.endsWith('/')
|
||||||
|
? newStorage.path.slice(0, -1)
|
||||||
|
: newStorage.path;
|
||||||
|
newStorage.path.replace(/\/\//g, '/');
|
||||||
|
await trpc.services.saveStorage.mutate({
|
||||||
|
id,
|
||||||
|
path: newStorage.path,
|
||||||
|
storageId: newStorage.id,
|
||||||
|
containerId: newStorage.containerId,
|
||||||
|
isNewStorage
|
||||||
|
});
|
||||||
|
|
||||||
|
dispatch('refresh');
|
||||||
|
if (isNew) {
|
||||||
|
storage.path = null;
|
||||||
|
storage.id = null;
|
||||||
|
}
|
||||||
|
if (isNewStorage) {
|
||||||
|
addToast({
|
||||||
|
message: 'Storage added',
|
||||||
|
type: 'success'
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
addToast({
|
||||||
|
message: 'Storage updated',
|
||||||
|
type: 'success'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return errorNotification(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function removeStorage(removableStorage: any) {
|
||||||
|
try {
|
||||||
|
const { id: storageId, volumeName, path } = removableStorage;
|
||||||
|
const sure = confirm(
|
||||||
|
`Are you sure you want to delete this storage ${volumeName + ':' + path}?`
|
||||||
|
);
|
||||||
|
if (sure) {
|
||||||
|
await trpc.services.deleteStorage.mutate({ storageId });
|
||||||
|
dispatch('refresh');
|
||||||
|
addToast({
|
||||||
|
message: 'Storage deleted',
|
||||||
|
type: 'success'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return errorNotification(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="w-full lg:px-0 px-4">
|
||||||
|
{#if storage.predefined}
|
||||||
|
<div class="grid grid-col-1 lg:grid-cols-2 pt-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
id={storage.containerId}
|
||||||
|
disabled
|
||||||
|
readonly
|
||||||
|
class="w-full"
|
||||||
|
value={`${
|
||||||
|
services.find((s) => s.id === storage.containerId).name || storage.containerId
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
id={storage.volumeName}
|
||||||
|
disabled
|
||||||
|
readonly
|
||||||
|
class="w-full"
|
||||||
|
value={`${storage.volumeName}:${storage.path}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if isNew}
|
||||||
|
<form id="saveVolumesForm" on:submit|preventDefault={saveStorage}>
|
||||||
|
<div class="grid grid-col-1 lg:grid-cols-2 lg:space-x-4 pt-8">
|
||||||
|
<div class="flex flex-row">
|
||||||
|
<div class="flex flex-col w-full">
|
||||||
|
<label for="name" class="pb-2 uppercase font-bold">Container</label>
|
||||||
|
<select
|
||||||
|
form="saveVolumesForm"
|
||||||
|
name="containerId"
|
||||||
|
class="w-full lg:w-64"
|
||||||
|
disabled={storage.predefined}
|
||||||
|
readonly={storage.predefined}
|
||||||
|
bind:value={storage.containerId}
|
||||||
|
>
|
||||||
|
{#if services.length === 1}
|
||||||
|
{#if services[0].name}
|
||||||
|
<option selected value={services[0].id}>{services[0].name}</option>
|
||||||
|
{:else}
|
||||||
|
<option selected value={services[0]}>{services[0]}</option>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
{#each services as service}
|
||||||
|
{#if service.name}
|
||||||
|
<option value={service.id}>{service.name}</option>
|
||||||
|
{:else}
|
||||||
|
<option value={service}>{service}</option>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col w-full">
|
||||||
|
<label for="name" class="pb-2 uppercase font-bold">Path</label>
|
||||||
|
<input
|
||||||
|
name="path"
|
||||||
|
disabled={storage.predefined}
|
||||||
|
readonly={storage.predefined}
|
||||||
|
class="w-full lg:w-64"
|
||||||
|
bind:value={storage.path}
|
||||||
|
required
|
||||||
|
placeholder="eg: /sqlite.db"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="pt-8">
|
||||||
|
<button type="submit" class="btn btn-sm btn-primary w-full lg:w-64">Add</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{:else}
|
||||||
|
<div class="flex lg:flex-row flex-col items-center gap-2 py-1">
|
||||||
|
<input
|
||||||
|
disabled
|
||||||
|
readonly
|
||||||
|
class="w-full"
|
||||||
|
value={`${services.find((s) => s.id === storage.containerId).name || storage.containerId}`}
|
||||||
|
/>
|
||||||
|
<input disabled readonly class="w-full" value={`${storage.volumeName}:${storage.path}`} />
|
||||||
|
<button
|
||||||
|
class="btn btn-sm btn-error"
|
||||||
|
on:click|stopPropagation|preventDefault={() => removeStorage(storage)}>Remove</button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
79
apps/client/src/routes/services/[id]/utils.ts
Normal file
79
apps/client/src/routes/services/[id]/utils.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import { errorNotification } from '$lib/common';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
isNew: boolean;
|
||||||
|
name: string;
|
||||||
|
value: string;
|
||||||
|
isBuildSecret?: boolean;
|
||||||
|
isPRMRSecret?: boolean;
|
||||||
|
isNewSecret?: boolean;
|
||||||
|
serviceId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function saveSecret({
|
||||||
|
isNew,
|
||||||
|
name,
|
||||||
|
value,
|
||||||
|
isBuildSecret,
|
||||||
|
isPRMRSecret,
|
||||||
|
isNewSecret,
|
||||||
|
serviceId
|
||||||
|
}: Props): Promise<void> {
|
||||||
|
if (!name) return errorNotification('Name is required');
|
||||||
|
if (!value) return errorNotification('Value is required');
|
||||||
|
try {
|
||||||
|
// await post(`/services/${serviceId}/secrets`, {
|
||||||
|
// name,
|
||||||
|
// value,
|
||||||
|
// isBuildSecret,
|
||||||
|
// isPRMRSecret,
|
||||||
|
// isNew: isNew || false
|
||||||
|
// });
|
||||||
|
if (isNewSecret) {
|
||||||
|
name = '';
|
||||||
|
value = '';
|
||||||
|
isBuildSecret = false;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveForm(formData: any, service: any) {
|
||||||
|
const settings = service.serviceSetting.map((setting: { name: string }) => setting.name);
|
||||||
|
const secrets = service.serviceSecret.map((secret: { name: string }) => secret.name);
|
||||||
|
const baseCoolifySetting = ['name', 'fqdn', 'exposePort', 'version'];
|
||||||
|
for (let field of formData) {
|
||||||
|
const [key, value] = field;
|
||||||
|
if (secrets.includes(key) && value) {
|
||||||
|
// await post(`/services/${service.id}/secrets`, {
|
||||||
|
// name: key,
|
||||||
|
// value,
|
||||||
|
// });
|
||||||
|
} else {
|
||||||
|
service.serviceSetting = service.serviceSetting.map((setting: any) => {
|
||||||
|
if (setting.name === key) {
|
||||||
|
setting.changed = true;
|
||||||
|
setting.value = value;
|
||||||
|
}
|
||||||
|
return setting;
|
||||||
|
});
|
||||||
|
if (!settings.includes(key) && !baseCoolifySetting.includes(key)) {
|
||||||
|
service.serviceSetting.push({
|
||||||
|
id: service.id,
|
||||||
|
name: key,
|
||||||
|
value: value,
|
||||||
|
isNew: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (baseCoolifySetting.includes(key)) {
|
||||||
|
service[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
// await post(`/services/${service.id}`, { ...service });
|
||||||
|
// const { service: reloadedService } = await get(`/services/${service.id}`);
|
||||||
|
// return reloadedService;
|
||||||
|
|
||||||
|
}
|
1013
apps/server/devTags.json
Normal file
1013
apps/server/devTags.json
Normal file
File diff suppressed because it is too large
Load Diff
3582
apps/server/devTemplates.yaml
Normal file
3582
apps/server/devTemplates.yaml
Normal file
File diff suppressed because it is too large
Load Diff
@@ -665,4 +665,46 @@ export async function getContainerUsage(dockerId: string, container: string): Pr
|
|||||||
NetIO: 0
|
NetIO: 0
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
export function fixType(type) {
|
||||||
|
return type?.replaceAll(' ', '').toLowerCase() || null;
|
||||||
|
}
|
||||||
|
const compareSemanticVersions = (a: string, b: string) => {
|
||||||
|
const a1 = a.split('.');
|
||||||
|
const b1 = b.split('.');
|
||||||
|
const len = Math.min(a1.length, b1.length);
|
||||||
|
for (let i = 0; i < len; i++) {
|
||||||
|
const a2 = +a1[i] || 0;
|
||||||
|
const b2 = +b1[i] || 0;
|
||||||
|
if (a2 !== b2) {
|
||||||
|
return a2 > b2 ? 1 : -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return b1.length - a1.length;
|
||||||
|
};
|
||||||
|
export async function getTags(type: string) {
|
||||||
|
try {
|
||||||
|
if (type) {
|
||||||
|
const tagsPath = isDev ? './tags.json' : '/app/tags.json';
|
||||||
|
const data = await fs.readFile(tagsPath, 'utf8');
|
||||||
|
let tags = JSON.parse(data);
|
||||||
|
if (tags) {
|
||||||
|
tags = tags.find((tag: any) => tag.name.includes(type));
|
||||||
|
tags.tags = tags.tags.sort(compareSemanticVersions).reverse();
|
||||||
|
return tags;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export function makeLabelForServices(type) {
|
||||||
|
return [
|
||||||
|
'coolify.managed=true',
|
||||||
|
`coolify.version=${version}`,
|
||||||
|
`coolify.type=service`,
|
||||||
|
`coolify.service.type=${type}`
|
||||||
|
];
|
||||||
|
}
|
||||||
|
export const asyncSleep = (delay: number): Promise<unknown> =>
|
||||||
|
new Promise((resolve) => setTimeout(resolve, delay));
|
@@ -9,7 +9,7 @@ Bree.extend(TSBree);
|
|||||||
|
|
||||||
const options: any = {
|
const options: any = {
|
||||||
defaultExtension: 'js',
|
defaultExtension: 'js',
|
||||||
logger: new Cabin({}),
|
logger: false,
|
||||||
jobs: [{ name: 'applicationBuildQueue' }]
|
jobs: [{ name: 'applicationBuildQueue' }]
|
||||||
};
|
};
|
||||||
if (isDev) options.root = path.join(__dirname, './jobs');
|
if (isDev) options.root = path.join(__dirname, './jobs');
|
||||||
|
@@ -1,171 +0,0 @@
|
|||||||
import { z } from 'zod';
|
|
||||||
import { privateProcedure, router } from '../trpc';
|
|
||||||
import { decrypt, getTemplates, removeService } from '../../lib/common';
|
|
||||||
import { prisma } from '../../prisma';
|
|
||||||
import { executeCommand } from '../../lib/executeCommand';
|
|
||||||
|
|
||||||
export const servicesRouter = router({
|
|
||||||
status: privateProcedure.input(z.object({ id: z.string() })).query(async ({ ctx, input }) => {
|
|
||||||
const id = input.id;
|
|
||||||
const teamId = ctx.user?.teamId;
|
|
||||||
if (!teamId) {
|
|
||||||
throw { status: 400, message: 'Team not found.' };
|
|
||||||
}
|
|
||||||
const service = await getServiceFromDB({ id, teamId });
|
|
||||||
const { destinationDockerId } = service;
|
|
||||||
let payload = {};
|
|
||||||
if (destinationDockerId) {
|
|
||||||
const { stdout: containers } = await executeCommand({
|
|
||||||
dockerId: service.destinationDocker.id,
|
|
||||||
command: `docker ps -a --filter "label=com.docker.compose.project=${id}" --format '{{json .}}'`
|
|
||||||
});
|
|
||||||
if (containers) {
|
|
||||||
const containersArray = containers.trim().split('\n');
|
|
||||||
if (containersArray.length > 0 && containersArray[0] !== '') {
|
|
||||||
const templates = await getTemplates();
|
|
||||||
let template = templates.find((t: { type: string }) => t.type === service.type);
|
|
||||||
const templateStr = JSON.stringify(template);
|
|
||||||
if (templateStr) {
|
|
||||||
template = JSON.parse(templateStr.replaceAll('$$id', service.id));
|
|
||||||
}
|
|
||||||
for (const container of containersArray) {
|
|
||||||
let isRunning = false;
|
|
||||||
let isExited = false;
|
|
||||||
let isRestarting = false;
|
|
||||||
let isExcluded = false;
|
|
||||||
const containerObj = JSON.parse(container);
|
|
||||||
const exclude = template?.services[containerObj.Names]?.exclude;
|
|
||||||
if (exclude) {
|
|
||||||
payload[containerObj.Names] = {
|
|
||||||
status: {
|
|
||||||
isExcluded: true,
|
|
||||||
isRunning: false,
|
|
||||||
isExited: false,
|
|
||||||
isRestarting: false
|
|
||||||
}
|
|
||||||
};
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const status = containerObj.State;
|
|
||||||
if (status === 'running') {
|
|
||||||
isRunning = true;
|
|
||||||
}
|
|
||||||
if (status === 'exited') {
|
|
||||||
isExited = true;
|
|
||||||
}
|
|
||||||
if (status === 'restarting') {
|
|
||||||
isRestarting = true;
|
|
||||||
}
|
|
||||||
payload[containerObj.Names] = {
|
|
||||||
status: {
|
|
||||||
isExcluded,
|
|
||||||
isRunning,
|
|
||||||
isExited,
|
|
||||||
isRestarting
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return payload;
|
|
||||||
}),
|
|
||||||
cleanup: privateProcedure.query(async ({ ctx }) => {
|
|
||||||
const teamId = ctx.user?.teamId;
|
|
||||||
let services = await prisma.service.findMany({
|
|
||||||
where: { teams: { some: { id: teamId === '0' ? undefined : teamId } } },
|
|
||||||
include: { destinationDocker: true, teams: true }
|
|
||||||
});
|
|
||||||
for (const service of services) {
|
|
||||||
if (!service.fqdn) {
|
|
||||||
if (service.destinationDockerId) {
|
|
||||||
const { stdout: containers } = await executeCommand({
|
|
||||||
dockerId: service.destinationDockerId,
|
|
||||||
command: `docker ps -a --filter 'label=com.docker.compose.project=${service.id}' --format {{.ID}}`
|
|
||||||
});
|
|
||||||
if (containers) {
|
|
||||||
const containerArray = containers.split('\n');
|
|
||||||
if (containerArray.length > 0) {
|
|
||||||
for (const container of containerArray) {
|
|
||||||
await executeCommand({
|
|
||||||
dockerId: service.destinationDockerId,
|
|
||||||
command: `docker stop -t 0 ${container}`
|
|
||||||
});
|
|
||||||
await executeCommand({
|
|
||||||
dockerId: service.destinationDockerId,
|
|
||||||
command: `docker rm --force ${container}`
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await removeService({ id: service.id });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
delete: privateProcedure
|
|
||||||
.input(z.object({ force: z.boolean(), id: z.string() }))
|
|
||||||
.mutation(async ({ input }) => {
|
|
||||||
// todo: check if user is allowed to delete service
|
|
||||||
const { id } = input;
|
|
||||||
await prisma.serviceSecret.deleteMany({ where: { serviceId: id } });
|
|
||||||
await prisma.serviceSetting.deleteMany({ where: { serviceId: id } });
|
|
||||||
await prisma.servicePersistentStorage.deleteMany({ where: { serviceId: id } });
|
|
||||||
await prisma.meiliSearch.deleteMany({ where: { serviceId: id } });
|
|
||||||
await prisma.fider.deleteMany({ where: { serviceId: id } });
|
|
||||||
await prisma.ghost.deleteMany({ where: { serviceId: id } });
|
|
||||||
await prisma.umami.deleteMany({ where: { serviceId: id } });
|
|
||||||
await prisma.hasura.deleteMany({ where: { serviceId: id } });
|
|
||||||
await prisma.plausibleAnalytics.deleteMany({ where: { serviceId: id } });
|
|
||||||
await prisma.minio.deleteMany({ where: { serviceId: id } });
|
|
||||||
await prisma.vscodeserver.deleteMany({ where: { serviceId: id } });
|
|
||||||
await prisma.wordpress.deleteMany({ where: { serviceId: id } });
|
|
||||||
await prisma.glitchTip.deleteMany({ where: { serviceId: id } });
|
|
||||||
await prisma.moodle.deleteMany({ where: { serviceId: id } });
|
|
||||||
await prisma.appwrite.deleteMany({ where: { serviceId: id } });
|
|
||||||
await prisma.searxng.deleteMany({ where: { serviceId: id } });
|
|
||||||
await prisma.weblate.deleteMany({ where: { serviceId: id } });
|
|
||||||
await prisma.taiga.deleteMany({ where: { serviceId: id } });
|
|
||||||
|
|
||||||
await prisma.service.delete({ where: { id } });
|
|
||||||
return {};
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
export async function getServiceFromDB({
|
|
||||||
id,
|
|
||||||
teamId
|
|
||||||
}: {
|
|
||||||
id: string;
|
|
||||||
teamId: string;
|
|
||||||
}): Promise<any> {
|
|
||||||
const settings = await prisma.setting.findFirst();
|
|
||||||
const body = await prisma.service.findFirst({
|
|
||||||
where: { id, teams: { some: { id: teamId === '0' ? undefined : teamId } } },
|
|
||||||
include: {
|
|
||||||
destinationDocker: true,
|
|
||||||
persistentStorage: true,
|
|
||||||
serviceSecret: true,
|
|
||||||
serviceSetting: true,
|
|
||||||
wordpress: true,
|
|
||||||
plausibleAnalytics: true
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if (!body) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
// body.type = fixType(body.type);
|
|
||||||
|
|
||||||
if (body?.serviceSecret.length > 0) {
|
|
||||||
body.serviceSecret = body.serviceSecret.map((s) => {
|
|
||||||
s.value = decrypt(s.value);
|
|
||||||
return s;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (body.wordpress) {
|
|
||||||
body.wordpress.ftpPassword = decrypt(body.wordpress.ftpPassword);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { ...body, settings };
|
|
||||||
}
|
|
895
apps/server/src/trpc/routers/services/index.ts
Normal file
895
apps/server/src/trpc/routers/services/index.ts
Normal file
@@ -0,0 +1,895 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
import yaml from 'js-yaml';
|
||||||
|
import fs from 'fs/promises';
|
||||||
|
import path from 'path';
|
||||||
|
import { privateProcedure, router } from '../../trpc';
|
||||||
|
import {
|
||||||
|
createDirectories,
|
||||||
|
decrypt,
|
||||||
|
encrypt,
|
||||||
|
fixType,
|
||||||
|
getTags,
|
||||||
|
getTemplates,
|
||||||
|
isARM,
|
||||||
|
isDev,
|
||||||
|
listSettings,
|
||||||
|
makeLabelForServices,
|
||||||
|
removeService
|
||||||
|
} from '../../../lib/common';
|
||||||
|
import { prisma } from '../../../prisma';
|
||||||
|
import { executeCommand } from '../../../lib/executeCommand';
|
||||||
|
import {
|
||||||
|
generatePassword,
|
||||||
|
getFreePublicPort,
|
||||||
|
parseAndFindServiceTemplates,
|
||||||
|
persistentVolumes,
|
||||||
|
startServiceContainers,
|
||||||
|
verifyAndDecryptServiceSecrets
|
||||||
|
} from './lib';
|
||||||
|
import { checkContainer, defaultComposeConfiguration, stopTcpHttpProxy } from '../../../lib/docker';
|
||||||
|
import cuid from 'cuid';
|
||||||
|
import { day } from '../../../lib/dayjs';
|
||||||
|
|
||||||
|
export const servicesRouter = router({
|
||||||
|
getLogs: privateProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
id: z.string(),
|
||||||
|
containerId: z.string(),
|
||||||
|
since: z.number().optional().default(0)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.query(async ({ input, ctx }) => {
|
||||||
|
let { id, containerId, since } = input;
|
||||||
|
if (since !== 0) {
|
||||||
|
since = day(since).unix();
|
||||||
|
}
|
||||||
|
const {
|
||||||
|
destinationDockerId,
|
||||||
|
destinationDocker: { id: dockerId }
|
||||||
|
} = await prisma.service.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: { destinationDocker: true }
|
||||||
|
});
|
||||||
|
if (destinationDockerId) {
|
||||||
|
try {
|
||||||
|
const { default: ansi } = await import('strip-ansi');
|
||||||
|
const { stdout, stderr } = await executeCommand({
|
||||||
|
dockerId,
|
||||||
|
command: `docker logs --since ${since} --tail 5000 --timestamps ${containerId}`
|
||||||
|
});
|
||||||
|
const stripLogsStdout = stdout
|
||||||
|
.toString()
|
||||||
|
.split('\n')
|
||||||
|
.map((l) => ansi(l))
|
||||||
|
.filter((a) => a);
|
||||||
|
const stripLogsStderr = stderr
|
||||||
|
.toString()
|
||||||
|
.split('\n')
|
||||||
|
.map((l) => ansi(l))
|
||||||
|
.filter((a) => a);
|
||||||
|
const logs = stripLogsStderr.concat(stripLogsStdout);
|
||||||
|
const sortedLogs = logs.sort((a, b) =>
|
||||||
|
day(a.split(' ')[0]).isAfter(day(b.split(' ')[0])) ? 1 : -1
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
data: {
|
||||||
|
logs: sortedLogs
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// }
|
||||||
|
} catch (error) {
|
||||||
|
const { statusCode, stderr } = error;
|
||||||
|
if (stderr.startsWith('Error: No such container')) {
|
||||||
|
return {
|
||||||
|
data: {
|
||||||
|
logs: [],
|
||||||
|
noContainer: true
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (statusCode === 404) {
|
||||||
|
return {
|
||||||
|
data: {
|
||||||
|
logs: []
|
||||||
|
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
message: 'No logs found.'
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
deleteStorage: privateProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
storageId: z.string()
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
const { storageId } = input;
|
||||||
|
await prisma.servicePersistentStorage.deleteMany({ where: { id: storageId } });
|
||||||
|
}),
|
||||||
|
saveStorage: privateProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
id: z.string(),
|
||||||
|
path: z.string(),
|
||||||
|
isNewStorage: z.boolean(),
|
||||||
|
storageId: z.string().optional().nullable(),
|
||||||
|
containerId: z.string().optional()
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
const { id, path, isNewStorage, storageId, containerId } = input;
|
||||||
|
|
||||||
|
if (isNewStorage) {
|
||||||
|
const volumeName = `${id}-custom${path.replace(/\//gi, '-')}`;
|
||||||
|
const found = await prisma.servicePersistentStorage.findFirst({
|
||||||
|
where: { path, containerId }
|
||||||
|
});
|
||||||
|
if (found) {
|
||||||
|
throw {
|
||||||
|
status: 500,
|
||||||
|
message: 'Persistent storage already exists for this container and path.'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
await prisma.servicePersistentStorage.create({
|
||||||
|
data: { path, volumeName, containerId, service: { connect: { id } } }
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await prisma.servicePersistentStorage.update({
|
||||||
|
where: { id: storageId },
|
||||||
|
data: { path, containerId }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
getStorages: privateProcedure
|
||||||
|
.input(z.object({ id: z.string() }))
|
||||||
|
.query(async ({ input, ctx }) => {
|
||||||
|
const { id } = input;
|
||||||
|
const persistentStorages = await prisma.servicePersistentStorage.findMany({
|
||||||
|
where: { serviceId: id }
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
persistentStorages
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
deleteSecret: privateProcedure
|
||||||
|
.input(z.object({ id: z.string(), name: z.string() }))
|
||||||
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
const { id, name } = input;
|
||||||
|
await prisma.serviceSecret.deleteMany({ where: { serviceId: id, name } });
|
||||||
|
}),
|
||||||
|
saveService: privateProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
id: z.string(),
|
||||||
|
name: z.string(),
|
||||||
|
fqdn: z.string().optional(),
|
||||||
|
exposePort: z.string().optional(),
|
||||||
|
type: z.string(),
|
||||||
|
serviceSetting: z.any(),
|
||||||
|
version: z.string().optional()
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
const teamId = ctx.user?.teamId;
|
||||||
|
let { id, name, fqdn, exposePort, type, serviceSetting, version } = input;
|
||||||
|
if (fqdn) fqdn = fqdn.toLowerCase();
|
||||||
|
if (exposePort) exposePort = Number(exposePort);
|
||||||
|
type = fixType(type);
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
fqdn,
|
||||||
|
name,
|
||||||
|
exposePort,
|
||||||
|
version
|
||||||
|
};
|
||||||
|
const templates = await getTemplates();
|
||||||
|
const service = await prisma.service.findUnique({ where: { id } });
|
||||||
|
const foundTemplate = templates.find((t) => fixType(t.type) === fixType(service.type));
|
||||||
|
for (const setting of serviceSetting) {
|
||||||
|
let { id: settingId, name, value, changed = false, isNew = false, variableName } = setting;
|
||||||
|
if (value) {
|
||||||
|
if (changed) {
|
||||||
|
await prisma.serviceSetting.update({ where: { id: settingId }, data: { value } });
|
||||||
|
}
|
||||||
|
if (isNew) {
|
||||||
|
if (!variableName) {
|
||||||
|
variableName = foundTemplate?.variables.find((v) => v.name === name).id;
|
||||||
|
}
|
||||||
|
await prisma.serviceSetting.create({
|
||||||
|
data: { name, value, variableName, service: { connect: { id } } }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await prisma.service.update({
|
||||||
|
where: { id },
|
||||||
|
data
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
createSecret: privateProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
id: z.string(),
|
||||||
|
name: z.string(),
|
||||||
|
value: z.string(),
|
||||||
|
isBuildSecret: z.boolean().optional(),
|
||||||
|
isPRMRSecret: z.boolean().optional(),
|
||||||
|
isNew: z.boolean().optional()
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.mutation(async ({ input }) => {
|
||||||
|
let { id, name, value, isNew } = input;
|
||||||
|
if (isNew) {
|
||||||
|
const found = await prisma.serviceSecret.findFirst({ where: { name, serviceId: id } });
|
||||||
|
if (found) {
|
||||||
|
throw `Secret ${name} already exists.`;
|
||||||
|
} else {
|
||||||
|
value = encrypt(value.trim());
|
||||||
|
await prisma.serviceSecret.create({
|
||||||
|
data: { name, value, service: { connect: { id } } }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
value = encrypt(value.trim());
|
||||||
|
const found = await prisma.serviceSecret.findFirst({ where: { serviceId: id, name } });
|
||||||
|
|
||||||
|
if (found) {
|
||||||
|
await prisma.serviceSecret.updateMany({
|
||||||
|
where: { serviceId: id, name },
|
||||||
|
data: { value }
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await prisma.serviceSecret.create({
|
||||||
|
data: { name, value, service: { connect: { id } } }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
getSecrets: privateProcedure.input(z.object({ id: z.string() })).query(async ({ input, ctx }) => {
|
||||||
|
const { id } = input;
|
||||||
|
const teamId = ctx.user?.teamId;
|
||||||
|
const service = await getServiceFromDB({ id, teamId });
|
||||||
|
let secrets = await prisma.serviceSecret.findMany({
|
||||||
|
where: { serviceId: id },
|
||||||
|
orderBy: { createdAt: 'desc' }
|
||||||
|
});
|
||||||
|
const templates = await getTemplates();
|
||||||
|
if (!templates) throw new Error('No templates found. Please contact support.');
|
||||||
|
const foundTemplate = templates.find((t) => fixType(t.type) === service.type);
|
||||||
|
secrets = secrets.map((secret) => {
|
||||||
|
const foundVariable = foundTemplate?.variables?.find((v) => v.name === secret.name) || null;
|
||||||
|
if (foundVariable) {
|
||||||
|
secret.readOnly = foundVariable.readOnly;
|
||||||
|
}
|
||||||
|
secret.value = decrypt(secret.value);
|
||||||
|
return secret;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
secrets
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
wordpress: privateProcedure
|
||||||
|
.input(z.object({ id: z.string(), ftpEnabled: z.boolean() }))
|
||||||
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
const { id } = input;
|
||||||
|
const teamId = ctx.user?.teamId;
|
||||||
|
const {
|
||||||
|
service: {
|
||||||
|
destinationDocker: { engine, remoteEngine, remoteIpAddress }
|
||||||
|
}
|
||||||
|
} = await prisma.wordpress.findUnique({
|
||||||
|
where: { serviceId: id },
|
||||||
|
include: { service: { include: { destinationDocker: true } } }
|
||||||
|
});
|
||||||
|
|
||||||
|
const publicPort = await getFreePublicPort({ id, remoteEngine, engine, remoteIpAddress });
|
||||||
|
|
||||||
|
let ftpUser = cuid();
|
||||||
|
let ftpPassword = generatePassword({});
|
||||||
|
|
||||||
|
const hostkeyDir = isDev ? '/tmp/hostkeys' : '/app/ssl/hostkeys';
|
||||||
|
try {
|
||||||
|
const data = await prisma.wordpress.update({
|
||||||
|
where: { serviceId: id },
|
||||||
|
data: { ftpEnabled },
|
||||||
|
include: { service: { include: { destinationDocker: true } } }
|
||||||
|
});
|
||||||
|
const {
|
||||||
|
service: { destinationDockerId, destinationDocker },
|
||||||
|
ftpPublicPort,
|
||||||
|
ftpUser: user,
|
||||||
|
ftpPassword: savedPassword,
|
||||||
|
ftpHostKey,
|
||||||
|
ftpHostKeyPrivate
|
||||||
|
} = data;
|
||||||
|
const { network, engine } = destinationDocker;
|
||||||
|
if (ftpEnabled) {
|
||||||
|
if (user) ftpUser = user;
|
||||||
|
if (savedPassword) ftpPassword = decrypt(savedPassword);
|
||||||
|
|
||||||
|
// TODO: rewrite these to usable without shell
|
||||||
|
const { stdout: password } = await executeCommand({
|
||||||
|
command: `echo ${ftpPassword} | openssl passwd -1 -stdin`,
|
||||||
|
shell: true
|
||||||
|
});
|
||||||
|
if (destinationDockerId) {
|
||||||
|
try {
|
||||||
|
await fs.stat(hostkeyDir);
|
||||||
|
} catch (error) {
|
||||||
|
await executeCommand({ command: `mkdir -p ${hostkeyDir}` });
|
||||||
|
}
|
||||||
|
if (!ftpHostKey) {
|
||||||
|
await executeCommand({
|
||||||
|
command: `ssh-keygen -t ed25519 -f ssh_host_ed25519_key -N "" -q -f ${hostkeyDir}/${id}.ed25519`
|
||||||
|
});
|
||||||
|
const { stdout: ftpHostKey } = await executeCommand({
|
||||||
|
command: `cat ${hostkeyDir}/${id}.ed25519`
|
||||||
|
});
|
||||||
|
await prisma.wordpress.update({
|
||||||
|
where: { serviceId: id },
|
||||||
|
data: { ftpHostKey: encrypt(ftpHostKey) }
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await executeCommand({
|
||||||
|
command: `echo "${decrypt(ftpHostKey)}" > ${hostkeyDir}/${id}.ed25519`,
|
||||||
|
shell: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!ftpHostKeyPrivate) {
|
||||||
|
await executeCommand({
|
||||||
|
command: `ssh-keygen -t rsa -b 4096 -N "" -f ${hostkeyDir}/${id}.rsa`
|
||||||
|
});
|
||||||
|
const { stdout: ftpHostKeyPrivate } = await executeCommand({
|
||||||
|
command: `cat ${hostkeyDir}/${id}.rsa`
|
||||||
|
});
|
||||||
|
await prisma.wordpress.update({
|
||||||
|
where: { serviceId: id },
|
||||||
|
data: { ftpHostKeyPrivate: encrypt(ftpHostKeyPrivate) }
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await executeCommand({
|
||||||
|
command: `echo "${decrypt(ftpHostKeyPrivate)}" > ${hostkeyDir}/${id}.rsa`,
|
||||||
|
shell: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.wordpress.update({
|
||||||
|
where: { serviceId: id },
|
||||||
|
data: {
|
||||||
|
ftpPublicPort: publicPort,
|
||||||
|
ftpUser: user ? undefined : ftpUser,
|
||||||
|
ftpPassword: savedPassword ? undefined : encrypt(ftpPassword)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { found: isRunning } = await checkContainer({
|
||||||
|
dockerId: destinationDocker.id,
|
||||||
|
container: `${id}-ftp`
|
||||||
|
});
|
||||||
|
if (isRunning) {
|
||||||
|
await executeCommand({
|
||||||
|
dockerId: destinationDocker.id,
|
||||||
|
command: `docker stop -t 0 ${id}-ftp && docker rm ${id}-ftp`,
|
||||||
|
shell: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {}
|
||||||
|
const volumes = [
|
||||||
|
`${id}-wordpress-data:/home/${ftpUser}/wordpress`,
|
||||||
|
`${
|
||||||
|
isDev ? hostkeyDir : '/var/lib/docker/volumes/coolify-ssl-certs/_data/hostkeys'
|
||||||
|
}/${id}.ed25519:/etc/ssh/ssh_host_ed25519_key`,
|
||||||
|
`${
|
||||||
|
isDev ? hostkeyDir : '/var/lib/docker/volumes/coolify-ssl-certs/_data/hostkeys'
|
||||||
|
}/${id}.rsa:/etc/ssh/ssh_host_rsa_key`,
|
||||||
|
`${
|
||||||
|
isDev ? hostkeyDir : '/var/lib/docker/volumes/coolify-ssl-certs/_data/hostkeys'
|
||||||
|
}/${id}.sh:/etc/sftp.d/chmod.sh`
|
||||||
|
];
|
||||||
|
|
||||||
|
const compose = {
|
||||||
|
version: '3.8',
|
||||||
|
services: {
|
||||||
|
[`${id}-ftp`]: {
|
||||||
|
image: `atmoz/sftp:alpine`,
|
||||||
|
command: `'${ftpUser}:${password.replace('\n', '').replace(/\$/g, '$$$')}:e:33'`,
|
||||||
|
extra_hosts: ['host.docker.internal:host-gateway'],
|
||||||
|
container_name: `${id}-ftp`,
|
||||||
|
volumes,
|
||||||
|
networks: [network],
|
||||||
|
depends_on: [],
|
||||||
|
restart: 'always'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
networks: {
|
||||||
|
[network]: {
|
||||||
|
external: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
volumes: {
|
||||||
|
[`${id}-wordpress-data`]: {
|
||||||
|
external: true,
|
||||||
|
name: `${id}-wordpress-data`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
await fs.writeFile(
|
||||||
|
`${hostkeyDir}/${id}.sh`,
|
||||||
|
`#!/bin/bash\nchmod 600 /etc/ssh/ssh_host_ed25519_key /etc/ssh/ssh_host_rsa_key\nuserdel -f xfs\nchown -R 33:33 /home/${ftpUser}/wordpress/`
|
||||||
|
);
|
||||||
|
await executeCommand({ command: `chmod +x ${hostkeyDir}/${id}.sh` });
|
||||||
|
await fs.writeFile(`${hostkeyDir}/${id}-docker-compose.yml`, yaml.dump(compose));
|
||||||
|
await executeCommand({
|
||||||
|
dockerId: destinationDocker.id,
|
||||||
|
command: `docker compose -f ${hostkeyDir}/${id}-docker-compose.yml up -d`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
publicPort,
|
||||||
|
ftpUser,
|
||||||
|
ftpPassword
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
await prisma.wordpress.update({
|
||||||
|
where: { serviceId: id },
|
||||||
|
data: { ftpPublicPort: null }
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await executeCommand({
|
||||||
|
dockerId: destinationDocker.id,
|
||||||
|
command: `docker stop -t 0 ${id}-ftp && docker rm ${id}-ftp`,
|
||||||
|
shell: true
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
//
|
||||||
|
}
|
||||||
|
await stopTcpHttpProxy(id, destinationDocker, ftpPublicPort);
|
||||||
|
}
|
||||||
|
} catch ({ status, message }) {
|
||||||
|
throw message;
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
await executeCommand({
|
||||||
|
command: `rm -fr ${hostkeyDir}/${id}-docker-compose.yml ${hostkeyDir}/${id}.ed25519 ${hostkeyDir}/${id}.ed25519.pub ${hostkeyDir}/${id}.rsa ${hostkeyDir}/${id}.rsa.pub ${hostkeyDir}/${id}.sh`
|
||||||
|
});
|
||||||
|
} catch (error) {}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
start: privateProcedure.input(z.object({ id: z.string() })).mutation(async ({ input, ctx }) => {
|
||||||
|
const { id } = input;
|
||||||
|
const teamId = ctx.user?.teamId;
|
||||||
|
const service = await getServiceFromDB({ id, teamId });
|
||||||
|
const arm = isARM(service.arch);
|
||||||
|
const { type, destinationDockerId, destinationDocker, persistentStorage, exposePort } = service;
|
||||||
|
|
||||||
|
const { workdir } = await createDirectories({ repository: type, buildId: id });
|
||||||
|
const template: any = await parseAndFindServiceTemplates(service, workdir, true);
|
||||||
|
const network = destinationDockerId && destinationDocker.network;
|
||||||
|
const config = {};
|
||||||
|
for (const s in template.services) {
|
||||||
|
let newEnvironments = [];
|
||||||
|
if (arm) {
|
||||||
|
if (template.services[s]?.environmentArm?.length > 0) {
|
||||||
|
for (const environment of template.services[s].environmentArm) {
|
||||||
|
let [env, ...value] = environment.split('=');
|
||||||
|
value = value.join('=');
|
||||||
|
if (!value.startsWith('$$secret') && value !== '') {
|
||||||
|
newEnvironments.push(`${env}=${value}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (template.services[s]?.environment?.length > 0) {
|
||||||
|
for (const environment of template.services[s].environment) {
|
||||||
|
let [env, ...value] = environment.split('=');
|
||||||
|
value = value.join('=');
|
||||||
|
if (!value.startsWith('$$secret') && value !== '') {
|
||||||
|
newEnvironments.push(`${env}=${value}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const secrets = await verifyAndDecryptServiceSecrets(id);
|
||||||
|
for (const secret of secrets) {
|
||||||
|
const { name, value } = secret;
|
||||||
|
if (value) {
|
||||||
|
const foundEnv = !!template.services[s].environment?.find((env) =>
|
||||||
|
env.startsWith(`${name}=`)
|
||||||
|
);
|
||||||
|
const foundNewEnv = !!newEnvironments?.find((env) => env.startsWith(`${name}=`));
|
||||||
|
if (foundEnv && !foundNewEnv) {
|
||||||
|
newEnvironments.push(`${name}=${value}`);
|
||||||
|
}
|
||||||
|
if (!foundEnv && !foundNewEnv && s === id) {
|
||||||
|
newEnvironments.push(`${name}=${value}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const customVolumes = await prisma.servicePersistentStorage.findMany({
|
||||||
|
where: { serviceId: id }
|
||||||
|
});
|
||||||
|
let volumes = new Set();
|
||||||
|
if (arm) {
|
||||||
|
template.services[s]?.volumesArm &&
|
||||||
|
template.services[s].volumesArm.length > 0 &&
|
||||||
|
template.services[s].volumesArm.forEach((v) => volumes.add(v));
|
||||||
|
} else {
|
||||||
|
template.services[s]?.volumes &&
|
||||||
|
template.services[s].volumes.length > 0 &&
|
||||||
|
template.services[s].volumes.forEach((v) => volumes.add(v));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Workaround: old plausible analytics service wrong volume id name
|
||||||
|
if (service.type === 'plausibleanalytics' && service.plausibleAnalytics?.id) {
|
||||||
|
let temp = Array.from(volumes);
|
||||||
|
temp.forEach((a) => {
|
||||||
|
const t = a.replace(service.id, service.plausibleAnalytics.id);
|
||||||
|
volumes.delete(a);
|
||||||
|
volumes.add(t);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (customVolumes.length > 0) {
|
||||||
|
for (const customVolume of customVolumes) {
|
||||||
|
const { volumeName, path, containerId } = customVolume;
|
||||||
|
if (
|
||||||
|
volumes &&
|
||||||
|
volumes.size > 0 &&
|
||||||
|
!volumes.has(`${volumeName}:${path}`) &&
|
||||||
|
containerId === service
|
||||||
|
) {
|
||||||
|
volumes.add(`${volumeName}:${path}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let ports = [];
|
||||||
|
if (template.services[s].proxy?.length > 0) {
|
||||||
|
for (const proxy of template.services[s].proxy) {
|
||||||
|
if (proxy.hostPort) {
|
||||||
|
ports.push(`${proxy.hostPort}:${proxy.port}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (template.services[s].ports?.length === 1) {
|
||||||
|
for (const port of template.services[s].ports) {
|
||||||
|
if (exposePort) {
|
||||||
|
ports.push(`${exposePort}:${port}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let image = template.services[s].image;
|
||||||
|
if (arm && template.services[s].imageArm) {
|
||||||
|
image = template.services[s].imageArm;
|
||||||
|
}
|
||||||
|
config[s] = {
|
||||||
|
container_name: s,
|
||||||
|
build: template.services[s].build || undefined,
|
||||||
|
command: template.services[s].command,
|
||||||
|
entrypoint: template.services[s]?.entrypoint,
|
||||||
|
image,
|
||||||
|
expose: template.services[s].ports,
|
||||||
|
ports: ports.length > 0 ? ports : undefined,
|
||||||
|
volumes: Array.from(volumes),
|
||||||
|
environment: newEnvironments,
|
||||||
|
depends_on: template.services[s]?.depends_on,
|
||||||
|
ulimits: template.services[s]?.ulimits,
|
||||||
|
cap_drop: template.services[s]?.cap_drop,
|
||||||
|
cap_add: template.services[s]?.cap_add,
|
||||||
|
labels: makeLabelForServices(type),
|
||||||
|
...defaultComposeConfiguration(network)
|
||||||
|
};
|
||||||
|
// Generate files for builds
|
||||||
|
if (template.services[s]?.files?.length > 0) {
|
||||||
|
if (!config[s].build) {
|
||||||
|
config[s].build = {
|
||||||
|
context: workdir,
|
||||||
|
dockerfile: `Dockerfile.${s}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
let Dockerfile = `
|
||||||
|
FROM ${template.services[s].image}`;
|
||||||
|
for (const file of template.services[s].files) {
|
||||||
|
const { location, content } = file;
|
||||||
|
const source = path.join(workdir, location);
|
||||||
|
await fs.mkdir(path.dirname(source), { recursive: true });
|
||||||
|
await fs.writeFile(source, content);
|
||||||
|
Dockerfile += `
|
||||||
|
COPY .${location} ${location}`;
|
||||||
|
}
|
||||||
|
await fs.writeFile(`${workdir}/Dockerfile.${s}`, Dockerfile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const { volumeMounts } = persistentVolumes(id, persistentStorage, config);
|
||||||
|
const composeFile = {
|
||||||
|
version: '3.8',
|
||||||
|
services: config,
|
||||||
|
networks: {
|
||||||
|
[network]: {
|
||||||
|
external: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
volumes: volumeMounts
|
||||||
|
};
|
||||||
|
const composeFileDestination = `${workdir}/docker-compose.yaml`;
|
||||||
|
await fs.writeFile(composeFileDestination, yaml.dump(composeFile));
|
||||||
|
// TODO: TODO!
|
||||||
|
let fastify = null;
|
||||||
|
await startServiceContainers(fastify, id, teamId, destinationDocker.id, composeFileDestination);
|
||||||
|
|
||||||
|
// Workaround: Stop old minio proxies
|
||||||
|
if (service.type === 'minio') {
|
||||||
|
try {
|
||||||
|
const { stdout: containers } = await executeCommand({
|
||||||
|
dockerId: destinationDocker.id,
|
||||||
|
command: `docker container ls -a --filter 'name=${id}-' --format {{.ID}}`
|
||||||
|
});
|
||||||
|
if (containers) {
|
||||||
|
const containerArray = containers.split('\n');
|
||||||
|
if (containerArray.length > 0) {
|
||||||
|
for (const container of containerArray) {
|
||||||
|
await executeCommand({
|
||||||
|
dockerId: destinationDockerId,
|
||||||
|
command: `docker stop -t 0 ${container}`
|
||||||
|
});
|
||||||
|
await executeCommand({
|
||||||
|
dockerId: destinationDockerId,
|
||||||
|
command: `docker rm --force ${container}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {}
|
||||||
|
try {
|
||||||
|
const { stdout: containers } = await executeCommand({
|
||||||
|
dockerId: destinationDocker.id,
|
||||||
|
command: `docker container ls -a --filter 'name=${id}-' --format {{.ID}}`
|
||||||
|
});
|
||||||
|
if (containers) {
|
||||||
|
const containerArray = containers.split('\n');
|
||||||
|
if (containerArray.length > 0) {
|
||||||
|
for (const container of containerArray) {
|
||||||
|
await executeCommand({
|
||||||
|
dockerId: destinationDockerId,
|
||||||
|
command: `docker stop -t 0 ${container}`
|
||||||
|
});
|
||||||
|
await executeCommand({
|
||||||
|
dockerId: destinationDockerId,
|
||||||
|
command: `docker rm --force ${container}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
stop: privateProcedure.input(z.object({ id: z.string() })).mutation(async ({ input, ctx }) => {
|
||||||
|
const { id } = input;
|
||||||
|
const teamId = ctx.user?.teamId;
|
||||||
|
const { destinationDockerId } = await getServiceFromDB({ id, teamId });
|
||||||
|
if (destinationDockerId) {
|
||||||
|
const { stdout: containers } = await executeCommand({
|
||||||
|
dockerId: destinationDockerId,
|
||||||
|
command: `docker ps -a --filter 'label=com.docker.compose.project=${id}' --format {{.ID}}`
|
||||||
|
});
|
||||||
|
if (containers) {
|
||||||
|
const containerArray = containers.split('\n');
|
||||||
|
if (containerArray.length > 0) {
|
||||||
|
for (const container of containerArray) {
|
||||||
|
await executeCommand({
|
||||||
|
dockerId: destinationDockerId,
|
||||||
|
command: `docker stop -t 0 ${container}`
|
||||||
|
});
|
||||||
|
await executeCommand({
|
||||||
|
dockerId: destinationDockerId,
|
||||||
|
command: `docker rm --force ${container}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
getServices: privateProcedure
|
||||||
|
.input(z.object({ id: z.string() }))
|
||||||
|
.query(async ({ input, ctx }) => {
|
||||||
|
const { id } = input;
|
||||||
|
const teamId = ctx.user?.teamId;
|
||||||
|
const service = await getServiceFromDB({ id, teamId });
|
||||||
|
if (!service) {
|
||||||
|
throw { status: 404, message: 'Service not found.' };
|
||||||
|
}
|
||||||
|
let template = {};
|
||||||
|
let tags = [];
|
||||||
|
if (service.type) {
|
||||||
|
template = await parseAndFindServiceTemplates(service);
|
||||||
|
tags = await getTags(service.type);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
settings: await listSettings(),
|
||||||
|
service,
|
||||||
|
template,
|
||||||
|
tags
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
status: privateProcedure.input(z.object({ id: z.string() })).query(async ({ ctx, input }) => {
|
||||||
|
const id = input.id;
|
||||||
|
const teamId = ctx.user?.teamId;
|
||||||
|
if (!teamId) {
|
||||||
|
throw { status: 400, message: 'Team not found.' };
|
||||||
|
}
|
||||||
|
const service = await getServiceFromDB({ id, teamId });
|
||||||
|
const { destinationDockerId } = service;
|
||||||
|
let payload = {};
|
||||||
|
if (destinationDockerId) {
|
||||||
|
const { stdout: containers } = await executeCommand({
|
||||||
|
dockerId: service.destinationDocker.id,
|
||||||
|
command: `docker ps -a --filter "label=com.docker.compose.project=${id}" --format '{{json .}}'`
|
||||||
|
});
|
||||||
|
if (containers) {
|
||||||
|
const containersArray = containers.trim().split('\n');
|
||||||
|
if (containersArray.length > 0 && containersArray[0] !== '') {
|
||||||
|
const templates = await getTemplates();
|
||||||
|
let template = templates.find((t: { type: string }) => t.type === service.type);
|
||||||
|
const templateStr = JSON.stringify(template);
|
||||||
|
if (templateStr) {
|
||||||
|
template = JSON.parse(templateStr.replaceAll('$$id', service.id));
|
||||||
|
}
|
||||||
|
for (const container of containersArray) {
|
||||||
|
let isRunning = false;
|
||||||
|
let isExited = false;
|
||||||
|
let isRestarting = false;
|
||||||
|
let isExcluded = false;
|
||||||
|
const containerObj = JSON.parse(container);
|
||||||
|
const exclude = template?.services[containerObj.Names]?.exclude;
|
||||||
|
if (exclude) {
|
||||||
|
payload[containerObj.Names] = {
|
||||||
|
status: {
|
||||||
|
isExcluded: true,
|
||||||
|
isRunning: false,
|
||||||
|
isExited: false,
|
||||||
|
isRestarting: false
|
||||||
|
}
|
||||||
|
};
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = containerObj.State;
|
||||||
|
if (status === 'running') {
|
||||||
|
isRunning = true;
|
||||||
|
}
|
||||||
|
if (status === 'exited') {
|
||||||
|
isExited = true;
|
||||||
|
}
|
||||||
|
if (status === 'restarting') {
|
||||||
|
isRestarting = true;
|
||||||
|
}
|
||||||
|
payload[containerObj.Names] = {
|
||||||
|
status: {
|
||||||
|
isExcluded,
|
||||||
|
isRunning,
|
||||||
|
isExited,
|
||||||
|
isRestarting
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
|
}),
|
||||||
|
cleanup: privateProcedure.query(async ({ ctx }) => {
|
||||||
|
const teamId = ctx.user?.teamId;
|
||||||
|
let services = await prisma.service.findMany({
|
||||||
|
where: { teams: { some: { id: teamId === '0' ? undefined : teamId } } },
|
||||||
|
include: { destinationDocker: true, teams: true }
|
||||||
|
});
|
||||||
|
for (const service of services) {
|
||||||
|
if (!service.fqdn) {
|
||||||
|
if (service.destinationDockerId) {
|
||||||
|
const { stdout: containers } = await executeCommand({
|
||||||
|
dockerId: service.destinationDockerId,
|
||||||
|
command: `docker ps -a --filter 'label=com.docker.compose.project=${service.id}' --format {{.ID}}`
|
||||||
|
});
|
||||||
|
if (containers) {
|
||||||
|
const containerArray = containers.split('\n');
|
||||||
|
if (containerArray.length > 0) {
|
||||||
|
for (const container of containerArray) {
|
||||||
|
await executeCommand({
|
||||||
|
dockerId: service.destinationDockerId,
|
||||||
|
command: `docker stop -t 0 ${container}`
|
||||||
|
});
|
||||||
|
await executeCommand({
|
||||||
|
dockerId: service.destinationDockerId,
|
||||||
|
command: `docker rm --force ${container}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await removeService({ id: service.id });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
delete: privateProcedure
|
||||||
|
.input(z.object({ force: z.boolean(), id: z.string() }))
|
||||||
|
.mutation(async ({ input }) => {
|
||||||
|
// todo: check if user is allowed to delete service
|
||||||
|
const { id } = input;
|
||||||
|
await prisma.serviceSecret.deleteMany({ where: { serviceId: id } });
|
||||||
|
await prisma.serviceSetting.deleteMany({ where: { serviceId: id } });
|
||||||
|
await prisma.servicePersistentStorage.deleteMany({ where: { serviceId: id } });
|
||||||
|
await prisma.meiliSearch.deleteMany({ where: { serviceId: id } });
|
||||||
|
await prisma.fider.deleteMany({ where: { serviceId: id } });
|
||||||
|
await prisma.ghost.deleteMany({ where: { serviceId: id } });
|
||||||
|
await prisma.umami.deleteMany({ where: { serviceId: id } });
|
||||||
|
await prisma.hasura.deleteMany({ where: { serviceId: id } });
|
||||||
|
await prisma.plausibleAnalytics.deleteMany({ where: { serviceId: id } });
|
||||||
|
await prisma.minio.deleteMany({ where: { serviceId: id } });
|
||||||
|
await prisma.vscodeserver.deleteMany({ where: { serviceId: id } });
|
||||||
|
await prisma.wordpress.deleteMany({ where: { serviceId: id } });
|
||||||
|
await prisma.glitchTip.deleteMany({ where: { serviceId: id } });
|
||||||
|
await prisma.moodle.deleteMany({ where: { serviceId: id } });
|
||||||
|
await prisma.appwrite.deleteMany({ where: { serviceId: id } });
|
||||||
|
await prisma.searxng.deleteMany({ where: { serviceId: id } });
|
||||||
|
await prisma.weblate.deleteMany({ where: { serviceId: id } });
|
||||||
|
await prisma.taiga.deleteMany({ where: { serviceId: id } });
|
||||||
|
|
||||||
|
await prisma.service.delete({ where: { id } });
|
||||||
|
return {};
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function getServiceFromDB({
|
||||||
|
id,
|
||||||
|
teamId
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
teamId: string;
|
||||||
|
}): Promise<any> {
|
||||||
|
const settings = await prisma.setting.findFirst();
|
||||||
|
const body = await prisma.service.findFirst({
|
||||||
|
where: { id, teams: { some: { id: teamId === '0' ? undefined : teamId } } },
|
||||||
|
include: {
|
||||||
|
destinationDocker: true,
|
||||||
|
persistentStorage: true,
|
||||||
|
serviceSecret: true,
|
||||||
|
serviceSetting: true,
|
||||||
|
wordpress: true,
|
||||||
|
plausibleAnalytics: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!body) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// body.type = fixType(body.type);
|
||||||
|
|
||||||
|
if (body?.serviceSecret.length > 0) {
|
||||||
|
body.serviceSecret = body.serviceSecret.map((s) => {
|
||||||
|
s.value = decrypt(s.value);
|
||||||
|
return s;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (body.wordpress) {
|
||||||
|
body.wordpress.ftpPassword = decrypt(body.wordpress.ftpPassword);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...body, settings };
|
||||||
|
}
|
376
apps/server/src/trpc/routers/services/lib.ts
Normal file
376
apps/server/src/trpc/routers/services/lib.ts
Normal file
@@ -0,0 +1,376 @@
|
|||||||
|
import { asyncSleep, decrypt, fixType, generateRangeArray, getDomain, getTemplates } from '../../../lib/common';
|
||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
import { prisma } from '../../../prisma';
|
||||||
|
import crypto from 'crypto';
|
||||||
|
import { executeCommand } from '../../../lib/executeCommand';
|
||||||
|
|
||||||
|
export async function parseAndFindServiceTemplates(
|
||||||
|
service: any,
|
||||||
|
workdir?: string,
|
||||||
|
isDeploy: boolean = false
|
||||||
|
) {
|
||||||
|
const templates = await getTemplates();
|
||||||
|
const foundTemplate = templates.find((t) => fixType(t.type) === service.type);
|
||||||
|
let parsedTemplate = {};
|
||||||
|
if (foundTemplate) {
|
||||||
|
if (!isDeploy) {
|
||||||
|
for (const [key, value] of Object.entries(foundTemplate.services)) {
|
||||||
|
const realKey = key.replace('$$id', service.id);
|
||||||
|
let name = value.name;
|
||||||
|
if (!name) {
|
||||||
|
if (Object.keys(foundTemplate.services).length === 1) {
|
||||||
|
name = foundTemplate.name || service.name.toLowerCase();
|
||||||
|
} else {
|
||||||
|
if (key === '$$id') {
|
||||||
|
name =
|
||||||
|
foundTemplate.name || key.replaceAll('$$id-', '') || service.name.toLowerCase();
|
||||||
|
} else {
|
||||||
|
name = key.replaceAll('$$id-', '') || service.name.toLowerCase();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parsedTemplate[realKey] = {
|
||||||
|
value,
|
||||||
|
name,
|
||||||
|
documentation:
|
||||||
|
value.documentation || foundTemplate.documentation || 'https://docs.coollabs.io',
|
||||||
|
image: value.image,
|
||||||
|
files: value?.files,
|
||||||
|
environment: [],
|
||||||
|
fqdns: [],
|
||||||
|
hostPorts: [],
|
||||||
|
proxy: {}
|
||||||
|
};
|
||||||
|
if (value.environment?.length > 0) {
|
||||||
|
for (const env of value.environment) {
|
||||||
|
let [envKey, ...envValue] = env.split('=');
|
||||||
|
envValue = envValue.join('=');
|
||||||
|
let variable = null;
|
||||||
|
if (foundTemplate?.variables) {
|
||||||
|
variable =
|
||||||
|
foundTemplate?.variables.find((v) => v.name === envKey) ||
|
||||||
|
foundTemplate?.variables.find((v) => v.id === envValue);
|
||||||
|
}
|
||||||
|
if (variable) {
|
||||||
|
const id = variable.id.replaceAll('$$', '');
|
||||||
|
const label = variable?.label;
|
||||||
|
const description = variable?.description;
|
||||||
|
const defaultValue = variable?.defaultValue;
|
||||||
|
const main = variable?.main || '$$id';
|
||||||
|
const type = variable?.type || 'input';
|
||||||
|
const placeholder = variable?.placeholder || '';
|
||||||
|
const readOnly = variable?.readOnly || false;
|
||||||
|
const required = variable?.required || false;
|
||||||
|
if (envValue.startsWith('$$config') || variable?.showOnConfiguration) {
|
||||||
|
if (envValue.startsWith('$$config_coolify')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
parsedTemplate[realKey].environment.push({
|
||||||
|
id,
|
||||||
|
name: envKey,
|
||||||
|
value: envValue,
|
||||||
|
main,
|
||||||
|
label,
|
||||||
|
description,
|
||||||
|
defaultValue,
|
||||||
|
type,
|
||||||
|
placeholder,
|
||||||
|
required,
|
||||||
|
readOnly
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (value?.proxy && value.proxy.length > 0) {
|
||||||
|
for (const proxyValue of value.proxy) {
|
||||||
|
if (proxyValue.domain) {
|
||||||
|
const variable = foundTemplate?.variables.find((v) => v.id === proxyValue.domain);
|
||||||
|
if (variable) {
|
||||||
|
const { id, name, label, description, defaultValue, required = false } = variable;
|
||||||
|
const found = await prisma.serviceSetting.findFirst({
|
||||||
|
where: { serviceId: service.id, variableName: proxyValue.domain }
|
||||||
|
});
|
||||||
|
parsedTemplate[realKey].fqdns.push({
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
value: found?.value || '',
|
||||||
|
label,
|
||||||
|
description,
|
||||||
|
defaultValue,
|
||||||
|
required
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (proxyValue.hostPort) {
|
||||||
|
const variable = foundTemplate?.variables.find((v) => v.id === proxyValue.hostPort);
|
||||||
|
if (variable) {
|
||||||
|
const { id, name, label, description, defaultValue, required = false } = variable;
|
||||||
|
const found = await prisma.serviceSetting.findFirst({
|
||||||
|
where: { serviceId: service.id, variableName: proxyValue.hostPort }
|
||||||
|
});
|
||||||
|
parsedTemplate[realKey].hostPorts.push({
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
value: found?.value || '',
|
||||||
|
label,
|
||||||
|
description,
|
||||||
|
defaultValue,
|
||||||
|
required
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
parsedTemplate = foundTemplate;
|
||||||
|
}
|
||||||
|
let strParsedTemplate = JSON.stringify(parsedTemplate);
|
||||||
|
|
||||||
|
// replace $$id and $$workdir
|
||||||
|
strParsedTemplate = strParsedTemplate.replaceAll('$$id', service.id);
|
||||||
|
strParsedTemplate = strParsedTemplate.replaceAll(
|
||||||
|
'$$core_version',
|
||||||
|
service.version || foundTemplate.defaultVersion
|
||||||
|
);
|
||||||
|
|
||||||
|
// replace $$workdir
|
||||||
|
if (workdir) {
|
||||||
|
strParsedTemplate = strParsedTemplate.replaceAll('$$workdir', workdir);
|
||||||
|
}
|
||||||
|
|
||||||
|
// replace $$config
|
||||||
|
if (service.serviceSetting.length > 0) {
|
||||||
|
for (const setting of service.serviceSetting) {
|
||||||
|
const { value, variableName } = setting;
|
||||||
|
const regex = new RegExp(`\\$\\$config_${variableName.replace('$$config_', '')}\"`, 'gi');
|
||||||
|
if (value === '$$generate_fqdn') {
|
||||||
|
strParsedTemplate = strParsedTemplate.replaceAll(regex, service.fqdn + '"' || '' + '"');
|
||||||
|
} else if (value === '$$generate_fqdn_slash') {
|
||||||
|
strParsedTemplate = strParsedTemplate.replaceAll(regex, service.fqdn + '/' + '"');
|
||||||
|
} else if (value === '$$generate_domain') {
|
||||||
|
strParsedTemplate = strParsedTemplate.replaceAll(regex, getDomain(service.fqdn) + '"');
|
||||||
|
} else if (service.destinationDocker?.network && value === '$$generate_network') {
|
||||||
|
strParsedTemplate = strParsedTemplate.replaceAll(
|
||||||
|
regex,
|
||||||
|
service.destinationDocker.network + '"'
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
strParsedTemplate = strParsedTemplate.replaceAll(regex, value + '"');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// replace $$secret
|
||||||
|
if (service.serviceSecret.length > 0) {
|
||||||
|
for (const secret of service.serviceSecret) {
|
||||||
|
let { name, value } = secret;
|
||||||
|
name = name.toLowerCase();
|
||||||
|
const regexHashed = new RegExp(`\\$\\$hashed\\$\\$secret_${name}`, 'gi');
|
||||||
|
const regex = new RegExp(`\\$\\$secret_${name}`, 'gi');
|
||||||
|
if (value) {
|
||||||
|
strParsedTemplate = strParsedTemplate.replaceAll(
|
||||||
|
regexHashed,
|
||||||
|
bcrypt.hashSync(value.replaceAll('"', '\\"'), 10)
|
||||||
|
);
|
||||||
|
strParsedTemplate = strParsedTemplate.replaceAll(regex, value.replaceAll('"', '\\"'));
|
||||||
|
} else {
|
||||||
|
strParsedTemplate = strParsedTemplate.replaceAll(regexHashed, '');
|
||||||
|
strParsedTemplate = strParsedTemplate.replaceAll(regex, '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parsedTemplate = JSON.parse(strParsedTemplate);
|
||||||
|
}
|
||||||
|
return parsedTemplate;
|
||||||
|
}
|
||||||
|
export function generatePassword({
|
||||||
|
length = 24,
|
||||||
|
symbols = false,
|
||||||
|
isHex = false
|
||||||
|
}: { length?: number; symbols?: boolean; isHex?: boolean } | null): string {
|
||||||
|
if (isHex) {
|
||||||
|
return crypto.randomBytes(length).toString('hex');
|
||||||
|
}
|
||||||
|
const password = generator.generate({
|
||||||
|
length,
|
||||||
|
numbers: true,
|
||||||
|
strict: true,
|
||||||
|
symbols
|
||||||
|
});
|
||||||
|
|
||||||
|
return password;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getFreePublicPort({ id, remoteEngine, engine, remoteIpAddress }) {
|
||||||
|
const { default: isReachable } = await import('is-port-reachable');
|
||||||
|
const data = await prisma.setting.findFirst();
|
||||||
|
const { minPort, maxPort } = data;
|
||||||
|
if (remoteEngine) {
|
||||||
|
const dbUsed = await (
|
||||||
|
await prisma.database.findMany({
|
||||||
|
where: {
|
||||||
|
publicPort: { not: null },
|
||||||
|
id: { not: id },
|
||||||
|
destinationDocker: { remoteIpAddress }
|
||||||
|
},
|
||||||
|
select: { publicPort: true }
|
||||||
|
})
|
||||||
|
).map((a) => a.publicPort);
|
||||||
|
const wpFtpUsed = await (
|
||||||
|
await prisma.wordpress.findMany({
|
||||||
|
where: {
|
||||||
|
ftpPublicPort: { not: null },
|
||||||
|
id: { not: id },
|
||||||
|
service: { destinationDocker: { remoteIpAddress } }
|
||||||
|
},
|
||||||
|
select: { ftpPublicPort: true }
|
||||||
|
})
|
||||||
|
).map((a) => a.ftpPublicPort);
|
||||||
|
const wpUsed = await (
|
||||||
|
await prisma.wordpress.findMany({
|
||||||
|
where: {
|
||||||
|
mysqlPublicPort: { not: null },
|
||||||
|
id: { not: id },
|
||||||
|
service: { destinationDocker: { remoteIpAddress } }
|
||||||
|
},
|
||||||
|
select: { mysqlPublicPort: true }
|
||||||
|
})
|
||||||
|
).map((a) => a.mysqlPublicPort);
|
||||||
|
const minioUsed = await (
|
||||||
|
await prisma.minio.findMany({
|
||||||
|
where: {
|
||||||
|
publicPort: { not: null },
|
||||||
|
id: { not: id },
|
||||||
|
service: { destinationDocker: { remoteIpAddress } }
|
||||||
|
},
|
||||||
|
select: { publicPort: true }
|
||||||
|
})
|
||||||
|
).map((a) => a.publicPort);
|
||||||
|
const usedPorts = [...dbUsed, ...wpFtpUsed, ...wpUsed, ...minioUsed];
|
||||||
|
const range = generateRangeArray(minPort, maxPort);
|
||||||
|
const availablePorts = range.filter((port) => !usedPorts.includes(port));
|
||||||
|
for (const port of availablePorts) {
|
||||||
|
const found = await isReachable(port, { host: remoteIpAddress });
|
||||||
|
if (!found) {
|
||||||
|
return port;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
} else {
|
||||||
|
const dbUsed = await (
|
||||||
|
await prisma.database.findMany({
|
||||||
|
where: { publicPort: { not: null }, id: { not: id }, destinationDocker: { engine } },
|
||||||
|
select: { publicPort: true }
|
||||||
|
})
|
||||||
|
).map((a) => a.publicPort);
|
||||||
|
const wpFtpUsed = await (
|
||||||
|
await prisma.wordpress.findMany({
|
||||||
|
where: {
|
||||||
|
ftpPublicPort: { not: null },
|
||||||
|
id: { not: id },
|
||||||
|
service: { destinationDocker: { engine } }
|
||||||
|
},
|
||||||
|
select: { ftpPublicPort: true }
|
||||||
|
})
|
||||||
|
).map((a) => a.ftpPublicPort);
|
||||||
|
const wpUsed = await (
|
||||||
|
await prisma.wordpress.findMany({
|
||||||
|
where: {
|
||||||
|
mysqlPublicPort: { not: null },
|
||||||
|
id: { not: id },
|
||||||
|
service: { destinationDocker: { engine } }
|
||||||
|
},
|
||||||
|
select: { mysqlPublicPort: true }
|
||||||
|
})
|
||||||
|
).map((a) => a.mysqlPublicPort);
|
||||||
|
const minioUsed = await (
|
||||||
|
await prisma.minio.findMany({
|
||||||
|
where: {
|
||||||
|
publicPort: { not: null },
|
||||||
|
id: { not: id },
|
||||||
|
service: { destinationDocker: { engine } }
|
||||||
|
},
|
||||||
|
select: { publicPort: true }
|
||||||
|
})
|
||||||
|
).map((a) => a.publicPort);
|
||||||
|
const usedPorts = [...dbUsed, ...wpFtpUsed, ...wpUsed, ...minioUsed];
|
||||||
|
const range = generateRangeArray(minPort, maxPort);
|
||||||
|
const availablePorts = range.filter((port) => !usedPorts.includes(port));
|
||||||
|
for (const port of availablePorts) {
|
||||||
|
const found = await isReachable(port, { host: 'localhost' });
|
||||||
|
if (!found) {
|
||||||
|
return port;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyAndDecryptServiceSecrets(id: string) {
|
||||||
|
const secrets = await prisma.serviceSecret.findMany({ where: { serviceId: id } })
|
||||||
|
let decryptedSecrets = secrets.map(secret => {
|
||||||
|
const { name, value } = secret
|
||||||
|
if (value) {
|
||||||
|
let rawValue = decrypt(value)
|
||||||
|
rawValue = rawValue.replaceAll(/\$/gi, '$$$')
|
||||||
|
return { name, value: rawValue }
|
||||||
|
}
|
||||||
|
return { name, value }
|
||||||
|
|
||||||
|
})
|
||||||
|
return decryptedSecrets
|
||||||
|
}
|
||||||
|
|
||||||
|
export function persistentVolumes(id, persistentStorage, config) {
|
||||||
|
let volumeSet = new Set();
|
||||||
|
if (Object.keys(config).length > 0) {
|
||||||
|
for (const [key, value] of Object.entries(config)) {
|
||||||
|
if (value.volumes) {
|
||||||
|
for (const volume of value.volumes) {
|
||||||
|
if (!volume.startsWith('/')) {
|
||||||
|
volumeSet.add(volume);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const volumesArray = Array.from(volumeSet);
|
||||||
|
const persistentVolume =
|
||||||
|
persistentStorage?.map((storage) => {
|
||||||
|
return `${id}${storage.path.replace(/\//gi, '-')}:${storage.path}`;
|
||||||
|
}) || [];
|
||||||
|
|
||||||
|
let volumes = [...persistentVolume];
|
||||||
|
if (volumesArray) volumes = [...volumesArray, ...volumes];
|
||||||
|
const composeVolumes =
|
||||||
|
(volumes.length > 0 &&
|
||||||
|
volumes.map((volume) => {
|
||||||
|
return {
|
||||||
|
[`${volume.split(':')[0]}`]: {
|
||||||
|
name: volume.split(':')[0]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
})) ||
|
||||||
|
[];
|
||||||
|
|
||||||
|
const volumeMounts = Object.assign({}, ...composeVolumes) || {};
|
||||||
|
return { volumeMounts };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startServiceContainers(fastify, id, teamId, dockerId, composeFileDestination) {
|
||||||
|
try {
|
||||||
|
// fastify.io.to(teamId).emit(`start-service`, { serviceId: id, state: 'Pulling images...' })
|
||||||
|
await executeCommand({ dockerId, command: `docker compose -f ${composeFileDestination} pull` })
|
||||||
|
} catch (error) { }
|
||||||
|
// fastify.io.to(teamId).emit(`start-service`, { serviceId: id, state: 'Building images...' })
|
||||||
|
await executeCommand({ dockerId, command: `docker compose -f ${composeFileDestination} build --no-cache` })
|
||||||
|
// fastify.io.to(teamId).emit(`start-service`, { serviceId: id, state: 'Creating containers...' })
|
||||||
|
await executeCommand({ dockerId, command: `docker compose -f ${composeFileDestination} create` })
|
||||||
|
// fastify.io.to(teamId).emit(`start-service`, { serviceId: id, state: 'Starting containers...' })
|
||||||
|
await executeCommand({ dockerId, command: `docker compose -f ${composeFileDestination} start` })
|
||||||
|
await asyncSleep(1000);
|
||||||
|
await executeCommand({ dockerId, command: `docker compose -f ${composeFileDestination} up -d` })
|
||||||
|
// fastify.io.to(teamId).emit(`start-service`, { serviceId: id, state: 0 })
|
||||||
|
}
|
1013
apps/server/tags.json
Normal file
1013
apps/server/tags.json
Normal file
File diff suppressed because it is too large
Load Diff
1
apps/server/templates.json
Normal file
1
apps/server/templates.json
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user