fix: cleanupStuckedContainers
This commit is contained in:
14
apps/trpc-experimental/client/src/routes/+error.svelte
Normal file
14
apps/trpc-experimental/client/src/routes/+error.svelte
Normal file
@@ -0,0 +1,14 @@
|
||||
<script>
|
||||
import { page } from '$app/stores';
|
||||
</script>
|
||||
|
||||
<div class="mx-auto flex h-screen flex-col items-center justify-center px-4">
|
||||
<div class="text-3xl font-bold pb-4">Ooops, are you lost?</div>
|
||||
<a href="/" class="btn btn-sm bg-coollabs">Go back</a>
|
||||
{#if $page.error.message !== 'Not Found'}
|
||||
<div class="py-10 text-xs font-bold">
|
||||
<pre class="w-full whitespace-pre-wrap break-words text-left text-xs tracking-tighter">{$page
|
||||
.error.message}</pre>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
417
apps/trpc-experimental/client/src/routes/+layout.svelte
Normal file
417
apps/trpc-experimental/client/src/routes/+layout.svelte
Normal file
@@ -0,0 +1,417 @@
|
||||
<script lang="ts">
|
||||
export let data: LayoutData;
|
||||
import type { LayoutData } from './$types';
|
||||
export const ssr = false;
|
||||
import '../app.postcss';
|
||||
import { appSession } from '$lib/store';
|
||||
import Tooltip from '$lib/components/Tooltip.svelte';
|
||||
import { page } from '$app/stores';
|
||||
// import UpdateAvailable from '$lib/components/UpdateAvailable.svelte';
|
||||
import Cookies from 'js-cookie';
|
||||
import { errorNotification } from '$lib/common';
|
||||
import Toasts from '$lib/components/Toasts.svelte';
|
||||
|
||||
let sidedrawerToggler: HTMLInputElement;
|
||||
if (data.settings.success) {
|
||||
$appSession = {
|
||||
...$appSession,
|
||||
...data.settings.data
|
||||
};
|
||||
}
|
||||
|
||||
const closeDrawer = () => (sidedrawerToggler.checked = false);
|
||||
async function logout() {
|
||||
try {
|
||||
Cookies.remove('token');
|
||||
return window.location.replace('/login');
|
||||
} catch (error) {
|
||||
return errorNotification(error);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
{#if !$appSession.whiteLabeled}
|
||||
<title>Coolify</title>
|
||||
<link rel="icon" href="/favicon.png" />
|
||||
{:else if $appSession.whiteLabeledDetails.icon}
|
||||
<title>Coolify</title>
|
||||
<link rel="icon" href={$appSession.whiteLabeledDetails.icon} />
|
||||
{/if}
|
||||
</svelte:head>
|
||||
<Toasts />
|
||||
<div class="drawer">
|
||||
<input id="main-drawer" type="checkbox" class="drawer-toggle" bind:this={sidedrawerToggler} />
|
||||
<div class="drawer-content">
|
||||
{#if $appSession.userId}
|
||||
<Tooltip triggeredBy="#iam" placement="right" color="bg-iam">IAM</Tooltip>
|
||||
<Tooltip triggeredBy="#settings" placement="right" color="bg-settings text-black"
|
||||
>Settings</Tooltip
|
||||
>
|
||||
<Tooltip triggeredBy="#documentation" placement="right" color="bg-info">Documentation</Tooltip
|
||||
>
|
||||
<Tooltip triggeredBy="#logout" placement="right" color="bg-red-600">Logout</Tooltip>
|
||||
<nav class="nav-main hidden lg:block z-20">
|
||||
<div class="flex h-screen w-full flex-col items-center transition-all duration-100">
|
||||
{#if !$appSession.whiteLabeled}
|
||||
<div class="mb-2 mt-4 h-10 w-10">
|
||||
<img src="/favicon.png" alt="coolLabs logo" />
|
||||
</div>
|
||||
{:else if $appSession.whiteLabeledDetails.icon}
|
||||
<div class="mb-2 mt-4 h-10 w-10">
|
||||
<img src={$appSession.whiteLabeledDetails.icon} alt="White labeled logo" />
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex flex-col space-y-2 py-2" class:mt-2={$appSession.whiteLabeled}>
|
||||
<a
|
||||
id="dashboard"
|
||||
href="/"
|
||||
class="icons hover:text-pink-500"
|
||||
class:text-pink-500={$page.url.pathname === '/'}
|
||||
class:bg-coolgray-500={$page.url.pathname === '/'}
|
||||
class:bg-coolgray-200={!($page.url.pathname === '/')}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-9 w-9"
|
||||
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="M19 8.71l-5.333 -4.148a2.666 2.666 0 0 0 -3.274 0l-5.334 4.148a2.665 2.665 0 0 0 -1.029 2.105v7.2a2 2 0 0 0 2 2h12a2 2 0 0 0 2 -2v-7.2c0 -.823 -.38 -1.6 -1.03 -2.105"
|
||||
/>
|
||||
<path d="M16 15c-2.21 1.333 -5.792 1.333 -8 0" />
|
||||
</svg>
|
||||
</a>
|
||||
{#if $appSession.teamId === '0'}
|
||||
<a
|
||||
id="servers"
|
||||
href="/servers"
|
||||
class="icons hover:text-sky-500"
|
||||
class:text-sky-500={$page.url.pathname === '/servers'}
|
||||
class:bg-coolgray-500={$page.url.pathname === '/servers'}
|
||||
class:bg-coolgray-200={!($page.url.pathname === '/servers')}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="w-8 h-8 mx-auto"
|
||||
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="3" y="4" width="18" height="8" rx="3" />
|
||||
<rect x="3" y="12" width="18" height="8" rx="3" />
|
||||
<line x1="7" y1="8" x2="7" y2="8.01" />
|
||||
<line x1="7" y1="16" x2="7" y2="16.01" />
|
||||
</svg>
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
<Tooltip triggeredBy="#dashboard" placement="right">Dashboard</Tooltip>
|
||||
<Tooltip triggeredBy="#servers" placement="right">Servers</Tooltip>
|
||||
<div class="flex-1" />
|
||||
<div class="lg:block hidden">
|
||||
<!-- <UpdateAvailable /> -->
|
||||
</div>
|
||||
<div class="flex flex-col space-y-2 py-2">
|
||||
<a
|
||||
id="iam"
|
||||
href={$appSession.pendingInvitations.length > 0 ? '/iam/pending' : '/iam'}
|
||||
class="icons hover:text-iam indicator"
|
||||
class:text-iam={$page.url.pathname.startsWith('/iam')}
|
||||
class:bg-coolgray-500={$page.url.pathname.startsWith('/iam')}
|
||||
>
|
||||
{#if $appSession.pendingInvitations.length > 0}
|
||||
<span class="indicator-item rounded-full badge badge-primary mr-2"
|
||||
>{$appSession.pendingInvitations.length}</span
|
||||
>
|
||||
{/if}<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
class="h-9 w-9"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<circle cx="9" cy="7" r="4" />
|
||||
<path d="M3 21v-2a4 4 0 0 1 4 -4h4a4 4 0 0 1 4 4v2" />
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
||||
<path d="M21 21v-2a4 4 0 0 0 -3 -3.85" />
|
||||
</svg>
|
||||
</a>
|
||||
<a
|
||||
id="settings"
|
||||
href={$appSession.teamId === '0' ? '/settings/coolify' : '/settings/docker'}
|
||||
class="icons hover:text-settings"
|
||||
class:text-settings={$page.url.pathname.startsWith('/settings')}
|
||||
class:bg-coolgray-500={$page.url.pathname.startsWith('/settings')}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
class="h-9 w-9"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<path
|
||||
d="M10.325 4.317c.426 -1.756 2.924 -1.756 3.35 0a1.724 1.724 0 0 0 2.573 1.066c1.543 -.94 3.31 .826 2.37 2.37a1.724 1.724 0 0 0 1.065 2.572c1.756 .426 1.756 2.924 0 3.35a1.724 1.724 0 0 0 -1.066 2.573c.94 1.543 -.826 3.31 -2.37 2.37a1.724 1.724 0 0 0 -2.572 1.065c-.426 1.756 -2.924 1.756 -3.35 0a1.724 1.724 0 0 0 -2.573 -1.066c-1.543 .94 -3.31 -.826 -2.37 -2.37a1.724 1.724 0 0 0 -1.065 -2.572c-1.756 -.426 -1.756 -2.924 0 -3.35a1.724 1.724 0 0 0 1.066 -2.573c-.94 -1.543 .826 -3.31 2.37 -2.37c1 .608 2.296 .07 2.572 -1.065z"
|
||||
/>
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
</a>
|
||||
<a
|
||||
id="documentation"
|
||||
href="https://docs.coollabs.io/coolify/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="icons hover:text-info"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
class="w-9 h-9"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<div
|
||||
id="logout"
|
||||
class="icons bg-coolgray-200 hover:text-error cursor-pointer"
|
||||
on:click={logout}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="ml-1 h-8 w-8"
|
||||
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="M14 8v-2a2 2 0 0 0 -2 -2h-7a2 2 0 0 0 -2 2v12a2 2 0 0 0 2 2h7a2 2 0 0 0 2 -2v-2"
|
||||
/>
|
||||
<path d="M7 12h14l-3 -3m0 6l3 -3" />
|
||||
</svg>
|
||||
</div>
|
||||
<!-- <div class="lg:block">
|
||||
<LocalePicker/>
|
||||
</div> -->
|
||||
<div
|
||||
class="w-full text-center font-bold text-stone-400 hover:bg-coolgray-200 hover:text-white"
|
||||
>
|
||||
<a
|
||||
class="text-[10px] no-underline"
|
||||
href={`https://github.com/coollabsio/coolify/releases/tag/v${$appSession.version}`}
|
||||
target="_blank noreferrer">v{$appSession.version}</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
{#if $appSession.whiteLabeled}
|
||||
<span class="fixed bottom-0 left-[50px] z-50 m-2 px-4 text-xs text-stone-700"
|
||||
>Powered by <a href="https://coolify.io" target="_blank noreferrer">Coolify</a></span
|
||||
>
|
||||
{/if}
|
||||
{/if}
|
||||
<div
|
||||
class="navbar lg:hidden space-x-2 flex flex-row justify-between bg-coollabs"
|
||||
class:hidden={!$appSession.userId}
|
||||
>
|
||||
<div>
|
||||
<label for="main-drawer" class="drawer-button btn btn-square btn-ghost flex-col">
|
||||
<span class="burger bg-white" />
|
||||
<span class="burger bg-white" />
|
||||
<span class="burger bg-white" />
|
||||
</label>
|
||||
<div class="prose flex flex-row justify-between space-x-1 w-full items-center pr-3">
|
||||
{#if !$appSession.whiteLabeled}
|
||||
<h3 class="mb-0 text-white">Coolify</h3>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<!-- <LocalePicker /> -->
|
||||
</div>
|
||||
<main>
|
||||
<div class={$appSession.userId ? 'lg:pl-16' : null}>
|
||||
<slot />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<div class="drawer-side">
|
||||
<label for="main-drawer" class="drawer-overlay w-full" />
|
||||
<ul class="menu bg-coolgray-200 w-60 p-2 space-y-3 pt-4 ">
|
||||
<li>
|
||||
<a
|
||||
class="no-underline icons hover:text-white hover:bg-pink-500"
|
||||
href="/"
|
||||
class:bg-pink-500={$page.url.pathname === '/'}
|
||||
on:click={closeDrawer}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-8 w-8"
|
||||
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="M19 8.71l-5.333 -4.148a2.666 2.666 0 0 0 -3.274 0l-5.334 4.148a2.665 2.665 0 0 0 -1.029 2.105v7.2a2 2 0 0 0 2 2h12a2 2 0 0 0 2 -2v-7.2c0 -.823 -.38 -1.6 -1.03 -2.105"
|
||||
/>
|
||||
<path d="M16 15c-2.21 1.333 -5.792 1.333 -8 0" />
|
||||
</svg>
|
||||
Dashboard
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<a
|
||||
id="servers"
|
||||
class="no-underline icons hover:text-white hover:bg-sky-500"
|
||||
href="/servers"
|
||||
class:bg-sky-500={$page.url.pathname.startsWith('/servers')}
|
||||
on:click={closeDrawer}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="w-8 h-8"
|
||||
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="3" y="4" width="18" height="8" rx="3" />
|
||||
<rect x="3" y="12" width="18" height="8" rx="3" />
|
||||
<line x1="7" y1="8" x2="7" y2="8.01" />
|
||||
<line x1="7" y1="16" x2="7" y2="16.01" />
|
||||
</svg>
|
||||
Servers
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
class="no-underline icons hover:text-white hover:bg-iam"
|
||||
href="/iam"
|
||||
class:bg-iam={$page.url.pathname.startsWith('/iam')}
|
||||
on:click={closeDrawer}
|
||||
><svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
class="h-8 w-8"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<circle cx="9" cy="7" r="4" />
|
||||
<path d="M3 21v-2a4 4 0 0 1 4 -4h4a4 4 0 0 1 4 4v2" />
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
||||
<path d="M21 21v-2a4 4 0 0 0 -3 -3.85" />
|
||||
</svg>
|
||||
IAM {#if $appSession.pendingInvitations.length > 0}
|
||||
<span class="indicator-item rounded-full badge badge-primary"
|
||||
>{$appSession.pendingInvitations.length}</span
|
||||
>
|
||||
{/if}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
class="no-underline icons hover:text-black hover:bg-settings"
|
||||
href={$appSession.teamId === '0' ? '/settings/coolify' : '/settings/ssh'}
|
||||
class:bg-settings={$page.url.pathname.startsWith('/settings')}
|
||||
class:text-black={$page.url.pathname.startsWith('/settings')}
|
||||
on:click={closeDrawer}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
class="h-8 w-8"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<path
|
||||
d="M10.325 4.317c.426 -1.756 2.924 -1.756 3.35 0a1.724 1.724 0 0 0 2.573 1.066c1.543 -.94 3.31 .826 2.37 2.37a1.724 1.724 0 0 0 1.065 2.572c1.756 .426 1.756 2.924 0 3.35a1.724 1.724 0 0 0 -1.066 2.573c.94 1.543 -.826 3.31 -2.37 2.37a1.724 1.724 0 0 0 -2.572 1.065c-.426 1.756 -2.924 1.756 -3.35 0a1.724 1.724 0 0 0 -2.573 -1.066c-1.543 .94 -3.31 -.826 -2.37 -2.37a1.724 1.724 0 0 0 -1.065 -2.572c-1.756 -.426 -1.756 -2.924 0 -3.35a1.724 1.724 0 0 0 1.066 -2.573c-.94 -1.543 .826 -3.31 2.37 -2.37c1 .608 2.296 .07 2.572 -1.065z"
|
||||
/>
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
Settings
|
||||
</a>
|
||||
</li>
|
||||
<li class="flex-1 bg-transparent" />
|
||||
<div class="block lg:hidden">
|
||||
<!-- <UpdateAvailable /> -->
|
||||
</div>
|
||||
<li>
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<div class="no-underline icons hover:bg-error" on:click={logout}>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="ml-1 h-8 w-8"
|
||||
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="M14 8v-2a2 2 0 0 0 -2 -2h-7a2 2 0 0 0 -2 2v12a2 2 0 0 0 2 2h7a2 2 0 0 0 2 -2v-2"
|
||||
/>
|
||||
<path d="M7 12h14l-3 -3m0 6l3 -3" />
|
||||
</svg>
|
||||
<div class="-ml-1">Logout</div>
|
||||
</div>
|
||||
</li>
|
||||
<li class="w-full">
|
||||
<a
|
||||
class="text-xs hover:bg-coolgray-200 no-underline hover:text-white text-right"
|
||||
href={`https://github.com/coollabsio/coolify/releases/tag/v${$appSession.version}`}
|
||||
target="_blank noreferrer">v{$appSession.version}</a
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
43
apps/trpc-experimental/client/src/routes/+layout.ts
Normal file
43
apps/trpc-experimental/client/src/routes/+layout.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { trpc } from '$lib/store';
|
||||
import type { LayoutLoad } from './$types';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import Cookies from 'js-cookie';
|
||||
export const ssr = false;
|
||||
|
||||
export const load: LayoutLoad = async ({ url }) => {
|
||||
const { pathname } = new URL(url);
|
||||
|
||||
try {
|
||||
if (pathname === '/login' || pathname === '/register') {
|
||||
const baseSettings = await trpc.settings.getBaseSettings.query();
|
||||
return {
|
||||
settings: {
|
||||
...baseSettings
|
||||
}
|
||||
};
|
||||
}
|
||||
const settings = await trpc.settings.getInstanceSettings.query();
|
||||
if (settings.data.token) {
|
||||
Cookies.set('token', settings.data.token);
|
||||
}
|
||||
return {
|
||||
settings: {
|
||||
...settings
|
||||
}
|
||||
};
|
||||
} catch (err) {
|
||||
if (err?.data?.httpStatus == 401) {
|
||||
throw redirect(307, '/login');
|
||||
}
|
||||
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.'
|
||||
});
|
||||
}
|
||||
};
|
||||
1652
apps/trpc-experimental/client/src/routes/+page.svelte
Normal file
1652
apps/trpc-experimental/client/src/routes/+page.svelte
Normal file
File diff suppressed because it is too large
Load Diff
13
apps/trpc-experimental/client/src/routes/+page.ts
Normal file
13
apps/trpc-experimental/client/src/routes/+page.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { trpc } from '$lib/store';
|
||||
export const ssr = false;
|
||||
|
||||
export const load = async () => {
|
||||
try {
|
||||
return await trpc.dashboard.resources.query();
|
||||
} catch (err) {
|
||||
throw error(500, {
|
||||
message: 'An unexpected error occurred, please try again later.'
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,114 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { status, trpc } from '$lib/store';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import type { LayoutData } from './$types';
|
||||
import * as Buttons from './components/Buttons';
|
||||
import * as States from './components/States';
|
||||
|
||||
import Menu from './components/Menu.svelte';
|
||||
|
||||
export let data: LayoutData;
|
||||
const id = $page.params.id;
|
||||
const application = data.application.data;
|
||||
|
||||
$: isConfigurationView = $page.url.pathname.startsWith(`/applications/${id}/configuration/`);
|
||||
|
||||
let stopping = false;
|
||||
let statusInterval: NodeJS.Timeout;
|
||||
|
||||
onMount(async () => {
|
||||
await getStatus();
|
||||
statusInterval = setInterval(async () => {
|
||||
await getStatus();
|
||||
}, 2000);
|
||||
});
|
||||
onDestroy(() => {
|
||||
$status.application.initialLoading = true;
|
||||
$status.application.loading = false;
|
||||
$status.application.statuses = [];
|
||||
$status.application.overallStatus = 'stopped';
|
||||
clearInterval(statusInterval);
|
||||
});
|
||||
async function getStatus() {
|
||||
if (($status.application.loading && stopping) || $status.application.restarting === true)
|
||||
return;
|
||||
$status.application.loading = true;
|
||||
$status.application.statuses = await trpc.applications.status.query({ id });
|
||||
let numberOfApplications = 0;
|
||||
if (application.dockerComposeConfiguration) {
|
||||
numberOfApplications =
|
||||
application.buildPack === 'compose'
|
||||
? Object.entries(JSON.parse(application.dockerComposeConfiguration)).length
|
||||
: 1;
|
||||
} else {
|
||||
numberOfApplications = 1;
|
||||
}
|
||||
|
||||
if ($status.application.statuses.length === 0) {
|
||||
$status.application.overallStatus = 'stopped';
|
||||
} else {
|
||||
for (const oneStatus of $status.application.statuses) {
|
||||
if (oneStatus.status.isExited || oneStatus.status.isRestarting) {
|
||||
$status.application.overallStatus = 'degraded';
|
||||
break;
|
||||
}
|
||||
if (oneStatus.status.isRunning) {
|
||||
$status.application.overallStatus = 'healthy';
|
||||
}
|
||||
if (
|
||||
!oneStatus.status.isExited &&
|
||||
!oneStatus.status.isRestarting &&
|
||||
!oneStatus.status.isRunning
|
||||
) {
|
||||
$status.application.overallStatus = 'stopped';
|
||||
}
|
||||
}
|
||||
}
|
||||
$status.application.loading = false;
|
||||
$status.application.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-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>Configurations</div>
|
||||
</div>
|
||||
</div>
|
||||
{#if isConfigurationView}
|
||||
<Buttons.Delete {id} name={application.name} />
|
||||
{/if}
|
||||
</nav>
|
||||
<div
|
||||
class="pt-4 flex flex-row items-start justify-center lg:justify-end space-x-2 order-1 lg:order-2"
|
||||
>
|
||||
{#if $status.application.initialLoading}
|
||||
<States.Loading />
|
||||
{:else if $status.application.overallStatus === 'degraded'}
|
||||
<States.Degraded
|
||||
{id}
|
||||
on:stopping={() => (stopping = true)}
|
||||
on:stopped={() => (stopping = false)}
|
||||
/>
|
||||
{:else if $status.application.overallStatus === 'healthy'}
|
||||
<States.Healthy {id} isComposeBuildPack={application.buildPack === 'compose'} />
|
||||
{:else if $status.application.overallStatus === 'stopped'}
|
||||
<States.Stopped {id} />
|
||||
{/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={!isConfigurationView}
|
||||
>
|
||||
{#if !isConfigurationView}
|
||||
<nav class="header flex flex-col lg:pt-0 ">
|
||||
<Menu {application} />
|
||||
</nav>
|
||||
{/if}
|
||||
<div class="pt-0 col-span-0 lg:col-span-3 pb-24">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,56 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { trpc } from '$lib/store';
|
||||
import type { LayoutLoad } from './$types';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
|
||||
function checkConfiguration(application: any): string | null {
|
||||
let configurationPhase = null;
|
||||
if (!application.gitSourceId && !application.simpleDockerfile) {
|
||||
return (configurationPhase = 'source');
|
||||
}
|
||||
if (application.simpleDockerfile) {
|
||||
if (!application.destinationDockerId) {
|
||||
configurationPhase = 'destination';
|
||||
}
|
||||
return configurationPhase;
|
||||
} else if (!application.repository && !application.branch) {
|
||||
configurationPhase = 'repository';
|
||||
} else if (!application.destinationDockerId) {
|
||||
configurationPhase = 'destination';
|
||||
} else if (!application.buildPack) {
|
||||
configurationPhase = 'buildpack';
|
||||
}
|
||||
return configurationPhase;
|
||||
}
|
||||
|
||||
export const load: LayoutLoad = async ({ params, url }) => {
|
||||
const { pathname } = new URL(url);
|
||||
const { id } = params;
|
||||
try {
|
||||
const application = await trpc.applications.getApplicationById.query({ id });
|
||||
if (!application) {
|
||||
throw redirect(307, '/applications');
|
||||
}
|
||||
const configurationPhase = checkConfiguration(application);
|
||||
console.log({ configurationPhase });
|
||||
// if (
|
||||
// configurationPhase &&
|
||||
// pathname !== `/applications/${params.id}/configuration/${configurationPhase}`
|
||||
// ) {
|
||||
// throw redirect(302, `/applications/${params.id}/configuration/${configurationPhase}`);
|
||||
// }
|
||||
return {
|
||||
application
|
||||
};
|
||||
} 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.'
|
||||
});
|
||||
}
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,204 @@
|
||||
<script lang="ts">
|
||||
import type { PageData } from '../build/$types';
|
||||
|
||||
export let data: PageData;
|
||||
console.log(data);
|
||||
let builds = data.builds;
|
||||
const application = data.application.data;
|
||||
const buildCount = data.buildCount;
|
||||
|
||||
import { page } from '$app/stores';
|
||||
import { addToast, selectedBuildId, trpc } from '$lib/store';
|
||||
import BuildLog from './BuildLog.svelte';
|
||||
import { changeQueryParams, dateOptions, errorNotification, asyncSleep } from '$lib/common';
|
||||
import Tooltip from '$lib/components/Tooltip.svelte';
|
||||
import { day } from '$lib/dayjs';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
const { id } = $page.params;
|
||||
let debug = application.settings.debug;
|
||||
let loadBuildLogsInterval: any = null;
|
||||
|
||||
let skip = 0;
|
||||
let noMoreBuilds = buildCount < 5 || buildCount <= skip;
|
||||
let preselectedBuildId = $page.url.searchParams.get('buildId');
|
||||
if (preselectedBuildId) $selectedBuildId = preselectedBuildId;
|
||||
|
||||
onMount(async () => {
|
||||
getBuildLogs();
|
||||
loadBuildLogsInterval = setInterval(() => {
|
||||
getBuildLogs();
|
||||
}, 2000);
|
||||
});
|
||||
onDestroy(() => {
|
||||
clearInterval(loadBuildLogsInterval);
|
||||
});
|
||||
async function getBuildLogs() {
|
||||
const response = await trpc.applications.getBuilds.query({ id, skip });
|
||||
builds = response.builds;
|
||||
}
|
||||
|
||||
async function loadMoreBuilds() {
|
||||
if (buildCount >= skip) {
|
||||
skip = skip + 5;
|
||||
noMoreBuilds = buildCount <= skip;
|
||||
try {
|
||||
const data = await trpc.applications.getBuilds.query({ id, skip });
|
||||
builds = data.builds;
|
||||
return;
|
||||
} catch (error) {
|
||||
return errorNotification(error);
|
||||
}
|
||||
} else {
|
||||
noMoreBuilds = true;
|
||||
}
|
||||
}
|
||||
function loadBuild(build: any) {
|
||||
$selectedBuildId = build;
|
||||
return changeQueryParams($selectedBuildId);
|
||||
}
|
||||
async function resetQueue() {
|
||||
const sure = confirm(
|
||||
'It will reset all build queues for all applications. If something is queued, it will be canceled automatically. Are you sure? '
|
||||
);
|
||||
if (sure) {
|
||||
try {
|
||||
await trpc.applications.resetQueue.mutate();
|
||||
addToast({
|
||||
message: 'Queue reset done.',
|
||||
type: 'success'
|
||||
});
|
||||
await asyncSleep(500);
|
||||
return window.location.reload();
|
||||
} catch (error) {
|
||||
return errorNotification(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
function generateBadgeColors(status: string) {
|
||||
if (status === 'failed') {
|
||||
return 'text-red-500';
|
||||
} else if (status === 'running') {
|
||||
return 'text-yellow-300';
|
||||
} else if (status === 'success') {
|
||||
return 'text-green-500';
|
||||
} else if (status === 'canceled') {
|
||||
return 'text-orange-500';
|
||||
} else {
|
||||
return 'text-white';
|
||||
}
|
||||
}
|
||||
async function changeSettings(name: any) {
|
||||
if (name === 'debug') {
|
||||
debug = !debug;
|
||||
}
|
||||
try {
|
||||
trpc.applications.saveSettings.mutate({
|
||||
id,
|
||||
debug
|
||||
});
|
||||
return addToast({
|
||||
message: 'Settings saved.',
|
||||
type: 'success'
|
||||
});
|
||||
} catch (error) {
|
||||
if (name === 'debug') {
|
||||
debug = !debug;
|
||||
}
|
||||
|
||||
return errorNotification(error);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="mx-auto w-full lg:px-0 px-1">
|
||||
<div class="flex lg:flex-row flex-col border-b border-coolgray-500 mb-6 space-x-2">
|
||||
<div class="flex flex-row">
|
||||
<div class="title font-bold pb-3 pr-3">Build Logs</div>
|
||||
<button class="btn btn-sm bg-error" on:click={resetQueue}>Reset Build Queue</button>
|
||||
</div>
|
||||
<div class=" flex-1" />
|
||||
<div class="form-control">
|
||||
<label class="label cursor-pointer">
|
||||
<span class="label-text text-white pr-4 font-bold">Enable Debug Logs</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={debug}
|
||||
class="checkbox checkbox-success"
|
||||
on:click={() => changeSettings('debug')}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="justify-start space-x-5 flex flex-col-reverse lg:flex-row">
|
||||
<div class="flex-1 md:w-96">
|
||||
{#if $selectedBuildId}
|
||||
{#key $selectedBuildId}
|
||||
<svelte:component this={BuildLog} />
|
||||
{/key}
|
||||
{:else if buildCount === 0}
|
||||
Not build logs found.
|
||||
{:else}
|
||||
Select a build to see the logs.
|
||||
{/if}
|
||||
</div>
|
||||
<div class="mb-4 min-w-[16rem] space-y-2 md:mb-0 ">
|
||||
<div class="top-4 md:sticky">
|
||||
<div class="flex space-x-2 pb-2">
|
||||
<button
|
||||
disabled={noMoreBuilds}
|
||||
class:btn-primary={!noMoreBuilds}
|
||||
class=" btn btn-sm w-full"
|
||||
on:click={loadMoreBuilds}>Load more</button
|
||||
>
|
||||
</div>
|
||||
{#each builds as build, index (build.id)}
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<div
|
||||
id={`building-${build.id}`}
|
||||
on:click={() => loadBuild(build.id)}
|
||||
class:rounded-tr={index === 0}
|
||||
class:rounded-br={index === builds.length - 1}
|
||||
class="flex cursor-pointer items-center justify-center py-4 no-underline transition-all duration-150 hover:bg-coolgray-300 hover:shadow-xl"
|
||||
class:bg-coolgray-200={$selectedBuildId === build.id}
|
||||
>
|
||||
<div class="flex-col px-2 text-center">
|
||||
<div class="text-sm font-bold truncate">
|
||||
{build.branch || application.branch}
|
||||
</div>
|
||||
<div class="text-xs">
|
||||
{build.type}
|
||||
</div>
|
||||
<div
|
||||
class={`badge badge-sm text-xs uppercase rounded bg-coolgray-300 border-none font-bold ${generateBadgeColors(
|
||||
build.status
|
||||
)}`}
|
||||
>
|
||||
{build.status}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="w-32 text-center text-xs">
|
||||
{#if build.status === 'running'}
|
||||
<div>
|
||||
<span class="font-bold text-xl">{build.elapsed}s</span>
|
||||
</div>
|
||||
{:else if build.status !== 'queued'}
|
||||
<div>{day(build.updatedAt).utc().fromNow()}</div>
|
||||
<div>
|
||||
Finished in
|
||||
<span class="font-bold"
|
||||
>{day(build.updatedAt).utc().diff(day(build.createdAt)) / 1000}s</span
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<Tooltip triggeredBy={`#building-${build.id}`}
|
||||
>{new Intl.DateTimeFormat('default', dateOptions).format(new Date(build.createdAt)) +
|
||||
`\n`}</Tooltip
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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.applications.getBuilds.query({ id, skip: 0 });
|
||||
return data;
|
||||
} catch (err) {
|
||||
throw error(500, {
|
||||
message: 'An unexpected error occurred, please try again later.'
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,215 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { errorNotification } from '$lib/common';
|
||||
import Tooltip from '$lib/components/Tooltip.svelte';
|
||||
import { day } from '$lib/dayjs';
|
||||
import { selectedBuildId, trpc } from '$lib/store';
|
||||
import { dev } from '$app/environment';
|
||||
|
||||
let logs: any = [];
|
||||
let currentStatus: any;
|
||||
let streamInterval: any;
|
||||
let followingLogs: any;
|
||||
let followingInterval: any;
|
||||
let logsEl: any;
|
||||
let fromDb = false;
|
||||
let cancelInprogress = false;
|
||||
let position = 0;
|
||||
let loading = true;
|
||||
const { id } = $page.params;
|
||||
|
||||
const cleanAnsiCodes = (str: string) => str.replace(/\x1B\[(\d+)m/g, '');
|
||||
|
||||
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);
|
||||
}, 100);
|
||||
} else {
|
||||
window.clearInterval(followingInterval);
|
||||
}
|
||||
}
|
||||
async function streamLogs(sequence = 0) {
|
||||
try {
|
||||
loading = true;
|
||||
let {
|
||||
logs: responseLogs,
|
||||
status,
|
||||
fromDb: from
|
||||
} = await trpc.applications.getBuildLogs.query({ id, buildId: $selectedBuildId, sequence });
|
||||
|
||||
currentStatus = status;
|
||||
logs = logs.concat(
|
||||
responseLogs.map((log: any) => ({ ...log, line: cleanAnsiCodes(log.line) }))
|
||||
);
|
||||
fromDb = from;
|
||||
|
||||
streamInterval = setInterval(async () => {
|
||||
const nextSequence = logs[logs.length - 1]?.time || 0;
|
||||
if (status !== 'running' && status !== 'queued') {
|
||||
loading = false;
|
||||
try {
|
||||
const data = await trpc.applications.getBuildLogs.query({
|
||||
id,
|
||||
buildId: $selectedBuildId,
|
||||
sequence: nextSequence
|
||||
});
|
||||
status = data.status;
|
||||
currentStatus = status;
|
||||
fromDb = data.fromDb;
|
||||
|
||||
logs = logs.concat(
|
||||
data.logs.map((log: any) => ({ ...log, line: cleanAnsiCodes(log.line) }))
|
||||
);
|
||||
loading = false;
|
||||
} catch (error) {
|
||||
return errorNotification(error);
|
||||
}
|
||||
clearInterval(streamInterval);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const data = await trpc.applications.getBuildLogs.query({
|
||||
id,
|
||||
buildId: $selectedBuildId,
|
||||
sequence: nextSequence
|
||||
});
|
||||
status = data.status;
|
||||
currentStatus = status;
|
||||
fromDb = data.fromDb;
|
||||
|
||||
logs = logs.concat(
|
||||
data.logs.map((log: any) => ({ ...log, line: cleanAnsiCodes(log.line) }))
|
||||
);
|
||||
loading = false;
|
||||
} catch (error) {
|
||||
return errorNotification(error);
|
||||
}
|
||||
}, 1000);
|
||||
} catch (error) {
|
||||
return errorNotification(error);
|
||||
}
|
||||
}
|
||||
async function cancelBuild() {
|
||||
if (cancelInprogress) return;
|
||||
try {
|
||||
cancelInprogress = true;
|
||||
await trpc.applications.cancelBuild.mutate({
|
||||
buildId: $selectedBuildId,
|
||||
applicationId: id
|
||||
});
|
||||
} catch (error) {
|
||||
return errorNotification(error);
|
||||
}
|
||||
}
|
||||
onDestroy(() => {
|
||||
clearInterval(streamInterval);
|
||||
clearInterval(followingInterval);
|
||||
});
|
||||
onMount(async () => {
|
||||
window.scrollTo(0, 0);
|
||||
await streamLogs();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="flex justify-start top-0 pb-2 space-x-2">
|
||||
<button
|
||||
on:click={followBuild}
|
||||
class="btn btn-sm bg-coollabs"
|
||||
disabled={currentStatus !== 'running'}
|
||||
class:bg-coolgray-300={followingLogs || currentStatus !== 'running'}
|
||||
class:text-applications={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>
|
||||
|
||||
<button
|
||||
on:click={cancelBuild}
|
||||
class:animation-spin={cancelInprogress}
|
||||
class="btn btn-sm"
|
||||
disabled={currentStatus !== 'running'}
|
||||
class:bg-coolgray-300={cancelInprogress || currentStatus !== 'running'}
|
||||
>
|
||||
<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" />
|
||||
<path d="M10 10l4 4m0 -4l-4 4" />
|
||||
</svg>
|
||||
{cancelInprogress ? 'Cancelling...' : 'Cancel Build'}
|
||||
</button>
|
||||
{#if currentStatus === 'running'}
|
||||
<button id="streaming" class="btn btn-sm bg-transparent border-none loading" />
|
||||
<Tooltip triggeredBy="#streaming">Streaming logs</Tooltip>
|
||||
{/if}
|
||||
</div>
|
||||
{#if currentStatus === 'queued'}
|
||||
<div
|
||||
class="font-mono w-full bg-coolgray-200 p-5 overflow-x-auto overflox-y-auto max-h-[80vh] rounded mb-20 flex flex-col whitespace-nowrap scrollbar-thumb-coollabs scrollbar-track-coolgray-200 scrollbar-w-1"
|
||||
>
|
||||
Queued and waiting for execution.
|
||||
</div>
|
||||
{:else if logs.length > 0}
|
||||
<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 whitespace-pre"
|
||||
>
|
||||
{#each logs as log}
|
||||
{#if fromDb}
|
||||
{log.line + '\n'}
|
||||
{:else}
|
||||
[{day.unix(log.time).format('HH:mm:ss.SSS')}] {log.line + '\n'}
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
class="font-mono w-full bg-coolgray-200 p-5 overflow-x-auto overflox-y-auto max-h-[80vh] rounded mb-20 flex flex-col whitespace-nowrap scrollbar-thumb-coollabs scrollbar-track-coolgray-200 scrollbar-w-1"
|
||||
>
|
||||
{loading
|
||||
? 'Loading logs...'
|
||||
: dev
|
||||
? 'In development, logs are shown in the console.'
|
||||
: 'No logs found yet.'}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,29 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { errorNotification } from '$lib/common';
|
||||
import { appSession, trpc } from '$lib/store';
|
||||
|
||||
export let id: string;
|
||||
export let name: string;
|
||||
export let force: boolean = false;
|
||||
|
||||
async function handleSubmit() {
|
||||
const sure = confirm(`Are you sure you want to delete ${name}?`);
|
||||
if (sure) {
|
||||
try {
|
||||
await trpc.applications.delete.mutate({ id, force });
|
||||
return await goto('/');
|
||||
} catch (error) {
|
||||
return errorNotification(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<button
|
||||
on:click={handleSubmit}
|
||||
disabled={!$appSession.isAdmin}
|
||||
class="btn btn-sm btn-error hover:bg-red-700 text-sm w-64"
|
||||
>
|
||||
{force ? 'Force' : ''} Delete Application
|
||||
</button>
|
||||
@@ -0,0 +1,31 @@
|
||||
<script lang="ts">
|
||||
import { errorNotification } from '$lib/common';
|
||||
export let id: string;
|
||||
import { trpc } from '$lib/store';
|
||||
async function handleSubmit() {
|
||||
try {
|
||||
await trpc.applications.deploy.mutate({
|
||||
id
|
||||
});
|
||||
} catch (error) {
|
||||
return errorNotification(error);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<button class="btn btn-sm gap-2" on:click={handleSubmit}>
|
||||
<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>
|
||||
@@ -0,0 +1,34 @@
|
||||
<script lang="ts">
|
||||
import { errorNotification } from '$lib/common';
|
||||
export let id: string;
|
||||
import { trpc } from '$lib/store';
|
||||
async function handleSubmit() {
|
||||
try {
|
||||
await trpc.applications.forceRedeploy.mutate({
|
||||
id
|
||||
});
|
||||
} catch (error) {
|
||||
return errorNotification(error);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<button class="btn btn-sm gap-2" on:click={handleSubmit}>
|
||||
<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 re-Deploy
|
||||
</button>
|
||||
@@ -0,0 +1,21 @@
|
||||
<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>
|
||||
Loading...
|
||||
</button>
|
||||
@@ -0,0 +1,30 @@
|
||||
<script lang="ts">
|
||||
import { errorNotification } from '$lib/common';
|
||||
export let id: string;
|
||||
import { trpc } from '$lib/store';
|
||||
|
||||
async function handleSubmit() {
|
||||
try {
|
||||
return await trpc.applications.restart.mutate({ id });
|
||||
} catch (error) {
|
||||
return errorNotification(error);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<button on:click={handleSubmit} class="btn btn-sm gap-2">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="w-6 h-6"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M20 11a8.1 8.1 0 0 0 -15.5 -2m-.5 -4v4h4" />
|
||||
<path d="M4 13a8.1 8.1 0 0 0 15.5 2m.5 4v-4h-4" />
|
||||
</svg> Restart
|
||||
</button>
|
||||
@@ -0,0 +1,37 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
|
||||
import { errorNotification } from '$lib/common';
|
||||
import { trpc } from '$lib/store';
|
||||
|
||||
export let id: string;
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
async function handleSubmit() {
|
||||
try {
|
||||
dispatch('stopping');
|
||||
await trpc.applications.stop.mutate({ id });
|
||||
dispatch('stopped');
|
||||
} catch (error) {
|
||||
return errorNotification(error);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<button on:click={handleSubmit} 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>
|
||||
@@ -0,0 +1,6 @@
|
||||
export { default as Delete } from './Delete.svelte';
|
||||
export { default as Stop } from './Stop.svelte';
|
||||
export { default as Restart } from './Restart.svelte';
|
||||
export { default as Deploy } from './Deploy.svelte';
|
||||
export { default as ForceDeploy } from './ForceDeploy.svelte';
|
||||
export { default as Loading } from './Loading.svelte';
|
||||
@@ -0,0 +1,278 @@
|
||||
<script lang="ts">
|
||||
export let application: any;
|
||||
import { status } from '$lib/store';
|
||||
import { page } from '$app/stores';
|
||||
import * as Icons from '$lib/components/icons';
|
||||
</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>
|
||||
{#if application.gitSource?.htmlUrl && application.repository && application.branch}
|
||||
<li>
|
||||
<a
|
||||
id="git"
|
||||
href="{application.gitSource.htmlUrl}/{application.repository}/tree/{application.branch}"
|
||||
target="_blank noreferrer"
|
||||
class="no-underline"
|
||||
>
|
||||
{#if application.gitSource?.type === 'gitlab'}
|
||||
<Icons.Sources.GitHub small={true} />
|
||||
{:else if application.gitSource?.type === 'github'}
|
||||
<Icons.Sources.GitLab small={true} />
|
||||
{/if}
|
||||
Open on Git <Icons.RemoteLink />
|
||||
</a>
|
||||
</li>
|
||||
{/if}
|
||||
|
||||
<li class="rounded" class:bg-coollabs={$page.url.pathname === `/applications/${$page.params.id}`}>
|
||||
<a href={`/applications/${$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>Configuration</a
|
||||
>
|
||||
</li>
|
||||
<li
|
||||
class="rounded"
|
||||
class:bg-coollabs={$page.url.pathname === `/applications/${$page.params.id}/secrets`}
|
||||
>
|
||||
<a href={`/applications/${$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 === `/applications/${$page.params.id}/storages`}
|
||||
>
|
||||
<a href={`/applications/${$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>
|
||||
{#if !application.simpleDockerfile}
|
||||
<li
|
||||
class="rounded"
|
||||
class:bg-coollabs={$page.url.pathname === `/applications/${$page.params.id}/features`}
|
||||
>
|
||||
<a href={`/applications/${$page.params.id}/features`} 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" />
|
||||
<polyline points="13 3 13 10 19 10 11 21 11 14 5 14 13 3" />
|
||||
</svg>Features</a
|
||||
>
|
||||
</li>
|
||||
{/if}
|
||||
|
||||
<li class="menu-title">
|
||||
<span>Logs</span>
|
||||
</li>
|
||||
<li
|
||||
class:text-stone-600={$status.application.overallStatus === 'stopped'}
|
||||
class="rounded"
|
||||
class:bg-coollabs={$page.url.pathname === `/applications/${$page.params.id}/logs`}
|
||||
>
|
||||
<a
|
||||
href={$status.application.overallStatus !== 'stopped'
|
||||
? `/applications/${$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>Application</a
|
||||
>
|
||||
</li>
|
||||
<li
|
||||
class="rounded"
|
||||
class:bg-coollabs={$page.url.pathname === `/applications/${$page.params.id}/builds`}
|
||||
>
|
||||
<a href={`/applications/${$page.params.id}/builds`} 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" />
|
||||
<circle cx="19" cy="13" r="2" />
|
||||
<circle cx="4" cy="17" r="2" />
|
||||
<circle cx="13" cy="17" r="2" />
|
||||
<line x1="13" y1="19" x2="4" y2="19" />
|
||||
<line x1="4" y1="15" x2="13" y2="15" />
|
||||
<path d="M8 12v-5h2a3 3 0 0 1 3 3v5" />
|
||||
<path d="M5 15v-2a1 1 0 0 1 1 -1h7" />
|
||||
<path d="M19 11v-7l-6 7" />
|
||||
</svg>Build</a
|
||||
>
|
||||
</li>
|
||||
<li class="menu-title">
|
||||
<span>Advanced</span>
|
||||
</li>
|
||||
{#if application.gitSourceId}
|
||||
<li
|
||||
class="rounded"
|
||||
class:bg-coollabs={$page.url.pathname === `/applications/${$page.params.id}/revert`}
|
||||
>
|
||||
<a href={`/applications/${$page.params.id}/revert`} 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="M20 5v14l-12 -7z" />
|
||||
<line x1="4" y1="5" x2="4" y2="19" />
|
||||
</svg>
|
||||
Revert</a
|
||||
>
|
||||
</li>
|
||||
{/if}
|
||||
<li
|
||||
class="rounded"
|
||||
class:text-stone-600={$status.application.overallStatus !== 'healthy'}
|
||||
class:bg-coollabs={$page.url.pathname === `/applications/${$page.params.id}/usage`}
|
||||
>
|
||||
<a
|
||||
href={$status.application.overallStatus === 'healthy'
|
||||
? `/applications/${$page.params.id}/usage`
|
||||
: ''}
|
||||
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="M3 12h4l3 8l4 -16l3 8h4" />
|
||||
</svg>Monitoring</a
|
||||
>
|
||||
</li>
|
||||
{#if !application.settings.isBot && application.gitSourceId}
|
||||
<li
|
||||
class="rounded"
|
||||
class:bg-coollabs={$page.url.pathname === `/applications/${$page.params.id}/previews`}
|
||||
>
|
||||
<a href={`/applications/${$page.params.id}/previews`} 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" />
|
||||
<circle cx="7" cy="18" r="2" />
|
||||
<circle cx="7" cy="6" r="2" />
|
||||
<circle cx="17" cy="12" r="2" />
|
||||
<line x1="7" y1="8" x2="7" y2="16" />
|
||||
<path d="M7 8a4 4 0 0 0 4 4h4" />
|
||||
</svg>Preview Deployments</a
|
||||
>
|
||||
</li>
|
||||
{/if}
|
||||
<li
|
||||
class="rounded"
|
||||
class:bg-coollabs={$page.url.pathname === `/applications/${$page.params.id}/danger`}
|
||||
>
|
||||
<a href={`/applications/${$page.params.id}/danger`} class="no-underline w-full"
|
||||
><svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="w-6 h-6"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M12 9v2m0 4v.01" />
|
||||
<path
|
||||
d="M5 19h14a2 2 0 0 0 1.84 -2.75l-7.1 -12.25a2 2 0 0 0 -3.5 0l-7.1 12.25a2 2 0 0 0 1.75 2.75"
|
||||
/>
|
||||
</svg>Danger Zone</a
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -0,0 +1,26 @@
|
||||
<script lang="ts">
|
||||
export let id: string;
|
||||
import * as Buttons from '../Buttons';
|
||||
</script>
|
||||
|
||||
<a href={`/applications/${id}/logs`} class="btn btn-sm text-sm gap-2">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="w-6 h-6 text-red-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="M8.7 3h6.6c.3 0 .5 .1 .7 .3l4.7 4.7c.2 .2 .3 .4 .3 .7v6.6c0 .3 -.1 .5 -.3 .7l-4.7 4.7c-.2 .2 -.4 .3 -.7 .3h-6.6c-.3 0 -.5 -.1 -.7 -.3l-4.7 -4.7c-.2 -.2 -.3 -.4 -.3 -.7v-6.6c0 -.3 .1 -.5 .3 -.7l4.7 -4.7c.2 -.2 .4 -.3 .7 -.3z"
|
||||
/>
|
||||
<line x1="12" y1="8" x2="12" y2="12" />
|
||||
<line x1="12" y1="16" x2="12.01" y2="16" />
|
||||
</svg>
|
||||
Application Error (check logs)
|
||||
</a>
|
||||
<Buttons.Stop {id} />
|
||||
@@ -0,0 +1,11 @@
|
||||
<script lang="ts">
|
||||
export let id: string;
|
||||
export let isComposeBuildPack: boolean = false;
|
||||
import * as Buttons from '../Buttons';
|
||||
</script>
|
||||
|
||||
{#if !isComposeBuildPack}
|
||||
<Buttons.Restart {id} />
|
||||
{/if}
|
||||
<Buttons.ForceDeploy {id} />
|
||||
<Buttons.Stop {id} />
|
||||
@@ -0,0 +1,5 @@
|
||||
<script lang="ts">
|
||||
import * as Buttons from '../Buttons';
|
||||
</script>
|
||||
|
||||
<Buttons.Loading />
|
||||
@@ -0,0 +1,6 @@
|
||||
<script lang="ts">
|
||||
export let id: string;
|
||||
import * as Buttons from '../Buttons';
|
||||
</script>
|
||||
|
||||
<Buttons.Deploy {id} />
|
||||
@@ -0,0 +1,4 @@
|
||||
export { default as Loading } from './Loading.svelte';
|
||||
export { default as Degraded } from './Degraded.svelte';
|
||||
export { default as Healthy } from './Healthy.svelte';
|
||||
export { default as Stopped } from './Stopped.svelte';
|
||||
@@ -0,0 +1,60 @@
|
||||
<script lang="ts">
|
||||
import type { PageData } from './$types';
|
||||
|
||||
export let data: PageData;
|
||||
let application: any = data.application.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;
|
||||
|
||||
let forceDelete = false;
|
||||
async function deleteApplication(name: string, force: boolean) {
|
||||
const sure = confirm('Are you sure you want to delete this application?');
|
||||
if (sure) {
|
||||
$status.application.initialLoading = true;
|
||||
try {
|
||||
await trpc.applications.deleteApplication.mutate({ id, force });
|
||||
return await goto('/');
|
||||
} catch (error) {
|
||||
if (error.message.startsWith(`Command failed: SSH_AUTH_SOCK=/tmp/coolify-ssh-agent.pid`)) {
|
||||
forceDelete = true;
|
||||
}
|
||||
return errorNotification(error);
|
||||
} finally {
|
||||
$status.application.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>
|
||||
|
||||
{#if forceDelete}
|
||||
<button
|
||||
id="forcedelete"
|
||||
on:click={() => deleteApplication(application.name, true)}
|
||||
type="submit"
|
||||
disabled={!$appSession.isAdmin}
|
||||
class:bg-red-600={$appSession.isAdmin}
|
||||
class:hover:bg-red-500={$appSession.isAdmin}
|
||||
class="btn btn-lg btn-error hover:bg-red-700 text-sm w-64"
|
||||
>
|
||||
Force Delete Application
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
id="delete"
|
||||
on:click={() => deleteApplication(application.name, false)}
|
||||
type="submit"
|
||||
disabled={!$appSession.isAdmin}
|
||||
class="btn btn-lg btn-error hover:bg-red-700 text-sm w-64"
|
||||
>
|
||||
Delete Application
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,118 @@
|
||||
<script lang="ts">
|
||||
import type { PageParentData } from './$types';
|
||||
|
||||
export let data: PageParentData;
|
||||
const application = data.application.data;
|
||||
const settings = data.settings.data;
|
||||
import { page } from '$app/stores';
|
||||
const { id } = $page.params;
|
||||
import {
|
||||
addToast,
|
||||
appSession,
|
||||
checkIfDeploymentEnabledApplications,
|
||||
setLocation,
|
||||
status,
|
||||
isDeploymentEnabled,
|
||||
trpc
|
||||
} from '$lib/store';
|
||||
import { errorNotification } from '$lib/common';
|
||||
import Setting from '$lib/components/Setting.svelte';
|
||||
|
||||
let previews = application.settings.previews;
|
||||
let dualCerts = application.settings.dualCerts;
|
||||
let autodeploy = application.settings.autodeploy;
|
||||
let isBot = application.settings.isBot;
|
||||
let isDBBranching = application.settings.isDBBranching;
|
||||
|
||||
async function changeSettings(name: any) {
|
||||
if (name === 'previews') {
|
||||
previews = !previews;
|
||||
}
|
||||
if (name === 'dualCerts') {
|
||||
dualCerts = !dualCerts;
|
||||
}
|
||||
if (name === 'autodeploy') {
|
||||
autodeploy = !autodeploy;
|
||||
}
|
||||
if (name === 'isBot') {
|
||||
if ($status.application.isRunning) return;
|
||||
isBot = !isBot;
|
||||
application.settings.isBot = isBot;
|
||||
application.fqdn = null;
|
||||
setLocation(application, settings);
|
||||
}
|
||||
if (name === 'isDBBranching') {
|
||||
isDBBranching = !isDBBranching;
|
||||
}
|
||||
try {
|
||||
await trpc.applications.saveSettings.mutate({
|
||||
id,
|
||||
previews,
|
||||
dualCerts,
|
||||
isBot,
|
||||
autodeploy,
|
||||
isDBBranching
|
||||
});
|
||||
|
||||
return addToast({
|
||||
message: 'Settings saved',
|
||||
type: 'success'
|
||||
});
|
||||
} catch (error) {
|
||||
if (name === 'previews') {
|
||||
previews = !previews;
|
||||
}
|
||||
if (name === 'dualCerts') {
|
||||
dualCerts = !dualCerts;
|
||||
}
|
||||
if (name === 'autodeploy') {
|
||||
autodeploy = !autodeploy;
|
||||
}
|
||||
if (name === 'isBot') {
|
||||
isBot = !isBot;
|
||||
}
|
||||
if (name === 'isDBBranching') {
|
||||
isDBBranching = !isDBBranching;
|
||||
}
|
||||
return errorNotification(error);
|
||||
} finally {
|
||||
$isDeploymentEnabled = checkIfDeploymentEnabledApplications($appSession.isAdmin, application);
|
||||
}
|
||||
}
|
||||
</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">Features</div>
|
||||
</div>
|
||||
<div class="px-4 lg:pb-10 pb-6">
|
||||
{#if !application.settings.isPublicRepository}
|
||||
<div class="grid grid-cols-2 items-center">
|
||||
<Setting
|
||||
id="autodeploy"
|
||||
isCenter={false}
|
||||
bind:setting={autodeploy}
|
||||
on:click={() => changeSettings('autodeploy')}
|
||||
title="Enable Automatic Deployment"
|
||||
description="Enable automatic deployment through webhooks."
|
||||
/>
|
||||
</div>
|
||||
{#if !application.settings.isBot && !application.simpleDockerfile}
|
||||
<div class="grid grid-cols-2 items-center">
|
||||
<Setting
|
||||
id="previews"
|
||||
isCenter={false}
|
||||
bind:setting={previews}
|
||||
on:click={() => changeSettings('previews')}
|
||||
title="Enable MR/PR Previews"
|
||||
description="Enable preview deployments from pull or merge requests."
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
No features available for this application
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,176 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { errorNotification } from '$lib/common';
|
||||
import { trpc } from '$lib/store';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
|
||||
let application: any = {};
|
||||
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 services: any = [];
|
||||
let selectedService: any = null;
|
||||
let noContainer = false;
|
||||
|
||||
const { id } = $page.params;
|
||||
onMount(async () => {
|
||||
const { data } = await trpc.applications.getApplicationById.query({ id });
|
||||
application = data;
|
||||
if (application.dockerComposeFile && application.buildPack === 'compose') {
|
||||
services = normalizeDockerServices(JSON.parse(data.dockerComposeFile).services);
|
||||
} else {
|
||||
services = [
|
||||
{
|
||||
name: ''
|
||||
}
|
||||
];
|
||||
await selectService('');
|
||||
}
|
||||
});
|
||||
onDestroy(() => {
|
||||
clearInterval(loadLogsInterval);
|
||||
clearInterval(followingInterval);
|
||||
});
|
||||
function normalizeDockerServices(services: any[]) {
|
||||
const tempdockerComposeServices = [];
|
||||
for (const [name, data] of Object.entries(services)) {
|
||||
tempdockerComposeServices.push({
|
||||
name,
|
||||
data
|
||||
});
|
||||
}
|
||||
return tempdockerComposeServices;
|
||||
}
|
||||
async function loadLogs() {
|
||||
if (logsLoading) return;
|
||||
try {
|
||||
const newLogs = await trpc.applications.loadLogs.query({
|
||||
id,
|
||||
containerId: selectedService,
|
||||
since: Number(lastLog?.split(' ')[0]) || 0
|
||||
});
|
||||
|
||||
if (newLogs.noContainer) {
|
||||
noContainer = true;
|
||||
} else {
|
||||
noContainer = false;
|
||||
}
|
||||
if (newLogs?.logs && newLogs.logs[newLogs.logs.length - 1] !== logs[logs.length - 1]) {
|
||||
logs = logs.concat(newLogs.logs);
|
||||
lastLog = newLogs.logs[newLogs.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 = `${application.id}${service.name ? `-${service.name}` : ''}`;
|
||||
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">Application Logs</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2 lg:gap-8 pb-4">
|
||||
{#each services as service}
|
||||
<button
|
||||
on:click={() => selectService(service, true)}
|
||||
class:bg-primary={selectedService ===
|
||||
`${application.id}${service.name ? `-${service.name}` : ''}`}
|
||||
class:bg-coolgray-200={selectedService !==
|
||||
`${application.id}${service.name ? `-${service.name}` : ''}`}
|
||||
class="w-full rounded p-5 hover:bg-primary font-bold"
|
||||
>
|
||||
{application.id}{service.name ? `-${service.name}` : ''}</button
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#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}
|
||||
@@ -0,0 +1,323 @@
|
||||
<script lang="ts">
|
||||
import type { PageData } from './$types';
|
||||
|
||||
export let data: PageData;
|
||||
let application: any = data.application.data;
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { asyncSleep, errorNotification, getRndInteger } from '$lib/common';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { addToast, appSession, trpc } from '$lib/store';
|
||||
import Tooltip from '$lib/components/Tooltip.svelte';
|
||||
import * as Icons from '$lib/components/icons';
|
||||
|
||||
const { id } = $page.params;
|
||||
let loadBuildingStatusInterval: any = null;
|
||||
let loading = {
|
||||
init: true,
|
||||
restart: false,
|
||||
removing: false
|
||||
};
|
||||
let numberOfGetStatus = 0;
|
||||
let status: any = {};
|
||||
|
||||
async function removeApplication(preview: any) {
|
||||
try {
|
||||
loading.removing = true;
|
||||
await trpc.applications.stopPreview.mutate({
|
||||
id,
|
||||
pullmergeRequestId: preview.pullmergeRequestId
|
||||
});
|
||||
return window.location.reload();
|
||||
} catch (error) {
|
||||
return errorNotification(error);
|
||||
}
|
||||
}
|
||||
async function redeploy(preview: any) {
|
||||
try {
|
||||
const { buildId } = await trpc.applications.deploy.mutate({
|
||||
id,
|
||||
pullmergeRequestId: preview.pullmergeRequestId,
|
||||
branch: preview.sourceBranch
|
||||
});
|
||||
|
||||
addToast({
|
||||
message: 'Deployment queued.',
|
||||
type: 'success'
|
||||
});
|
||||
if ($page.url.pathname.startsWith(`/applications/${id}/logs/build`)) {
|
||||
return window.location.assign(`/applications/${id}/logs/build?buildId=${buildId}`);
|
||||
} else {
|
||||
return await goto(`/applications/${id}/logs/build?buildId=${buildId}`, {
|
||||
replaceState: true
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
return errorNotification(error);
|
||||
}
|
||||
}
|
||||
async function loadPreviewsFromDocker() {
|
||||
try {
|
||||
const { data } = await trpc.applications.loadPreviews.mutate({ id });
|
||||
application.previewApplication = data.previews;
|
||||
addToast({
|
||||
message: 'Previews loaded.',
|
||||
type: 'success'
|
||||
});
|
||||
} catch (error) {
|
||||
return errorNotification(error);
|
||||
}
|
||||
}
|
||||
async function getStatus(resources: any) {
|
||||
const { applicationId, pullmergeRequestId, id } = resources;
|
||||
if (status[id]) return status[id];
|
||||
while (numberOfGetStatus > 1) {
|
||||
await asyncSleep(getRndInteger(100, 200));
|
||||
}
|
||||
try {
|
||||
numberOfGetStatus++;
|
||||
let isRunning = false;
|
||||
let isBuilding = false;
|
||||
const { data } = await trpc.applications.getPreviewStatus.query({
|
||||
id: applicationId,
|
||||
pullmergeRequestId
|
||||
});
|
||||
|
||||
isRunning = data.isRunning;
|
||||
isBuilding = data.isBuilding;
|
||||
if (isBuilding) {
|
||||
status[id] = 'building';
|
||||
return 'building';
|
||||
} else if (isRunning) {
|
||||
status[id] = 'running';
|
||||
return 'running';
|
||||
} else {
|
||||
status[id] = 'stopped';
|
||||
return 'stopped';
|
||||
}
|
||||
} catch (error) {
|
||||
status[id] = 'error';
|
||||
return 'error';
|
||||
} finally {
|
||||
numberOfGetStatus--;
|
||||
status = status;
|
||||
}
|
||||
}
|
||||
async function restartPreview(preview: any) {
|
||||
try {
|
||||
loading.restart = true;
|
||||
const { pullmergeRequestId } = preview;
|
||||
await trpc.applications.restartPreview.mutate({ id, pullmergeRequestId });
|
||||
addToast({
|
||||
type: 'success',
|
||||
message: 'Restart successful.'
|
||||
});
|
||||
} catch (error) {
|
||||
return errorNotification(error);
|
||||
} finally {
|
||||
await getStatus(preview);
|
||||
loading.restart = false;
|
||||
}
|
||||
}
|
||||
onDestroy(() => {
|
||||
clearInterval(loadBuildingStatusInterval);
|
||||
});
|
||||
onMount(async () => {
|
||||
loadBuildingStatusInterval = setInterval(() => {
|
||||
application.previewApplication.forEach(async (preview: any) => {
|
||||
const { applicationId, pullmergeRequestId } = preview;
|
||||
if (status[preview.id] === 'building') {
|
||||
const { data } = await trpc.applications.getPreviewStatus.query({
|
||||
id: applicationId,
|
||||
pullmergeRequestId
|
||||
});
|
||||
if (data.isBuilding) {
|
||||
status[preview.id] = 'building';
|
||||
} else if (data.isRunning) {
|
||||
status[preview.id] = 'running';
|
||||
return 'running';
|
||||
} else {
|
||||
status[preview.id] = 'stopped';
|
||||
return 'stopped';
|
||||
}
|
||||
}
|
||||
});
|
||||
}, 2000);
|
||||
try {
|
||||
loading.init = true;
|
||||
loading.restart = true;
|
||||
} catch (error) {
|
||||
return errorNotification(error);
|
||||
} finally {
|
||||
loading.init = false;
|
||||
loading.restart = false;
|
||||
}
|
||||
});
|
||||
</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">Preview Deployments</div>
|
||||
<div class="text-center">
|
||||
<button class="btn btn-sm bg-coollabs" on:click={loadPreviewsFromDocker}
|
||||
>Load Previews</button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if loading.init}
|
||||
<div class="px-6 pt-4">
|
||||
<div class="flex justify-center py-4 text-center text-xl font-bold">Loading...</div>
|
||||
</div>
|
||||
{:else if application.previewApplication.length > 0}
|
||||
<div class="grid grid-col gap-4 auto-cols-max grid-cols-1 md:grid-cols-2 lg:grid-cols-2 px-6">
|
||||
{#each application.previewApplication as preview}
|
||||
<div class="no-underline mb-5 w-full">
|
||||
<div class="w-full rounded p-5 bg-coolgray-200 indicator">
|
||||
{#await getStatus(preview)}
|
||||
<span class="indicator-item badge bg-yellow-500 badge-sm" />
|
||||
{:then}
|
||||
{#if status[preview.id] === 'running'}
|
||||
<span class="indicator-item badge bg-success badge-sm" />
|
||||
{:else}
|
||||
<span class="indicator-item badge bg-error badge-sm" />
|
||||
{/if}
|
||||
{/await}
|
||||
<div class="w-full flex flex-row">
|
||||
<div class="w-full flex flex-col">
|
||||
<h1 class="font-bold text-lg lg:text-xl truncate">
|
||||
PR #{preview.pullmergeRequestId}
|
||||
{#if status[preview.id] === 'building'}
|
||||
<span
|
||||
class="badge badge-sm text-xs uppercase rounded bg-coolgray-300 text-green-500 border-none font-bold"
|
||||
>
|
||||
BUILDING
|
||||
</span>
|
||||
{/if}
|
||||
</h1>
|
||||
<div class="h-10 text-xs">
|
||||
<h2>{preview.customDomain.replace('https://', '').replace('http://', '')}</h2>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end items-end space-x-2 h-10">
|
||||
{#if preview.customDomain}
|
||||
<a
|
||||
id="openpreview"
|
||||
href={preview.customDomain}
|
||||
target="_blank noreferrer"
|
||||
class="icons"
|
||||
>
|
||||
<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="M11 7h-5a2 2 0 0 0 -2 2v9a2 2 0 0 0 2 2h9a2 2 0 0 0 2 -2v-5" />
|
||||
<line x1="10" y1="14" x2="20" y2="4" />
|
||||
<polyline points="15 4 20 4 20 9" />
|
||||
</svg>
|
||||
</a>
|
||||
{/if}
|
||||
<Tooltip triggeredBy="#openpreview">Open Preview</Tooltip>
|
||||
{#if loading.restart}
|
||||
<button
|
||||
class="icons flex animate-spin items-center space-x-2 bg-transparent text-sm duration-500 ease-in-out hover:bg-transparent"
|
||||
>
|
||||
<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="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>
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
id="restart"
|
||||
disabled={!$appSession.isAdmin}
|
||||
on:click={() => restartPreview(preview)}
|
||||
type="submit"
|
||||
class="icons bg-transparent text-sm flex items-center space-x-2"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="w-6 h-6"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M20 11a8.1 8.1 0 0 0 -15.5 -2m-.5 -4v4h4" />
|
||||
<path d="M4 13a8.1 8.1 0 0 0 15.5 2m.5 4v-4h-4" />
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<Tooltip triggeredBy="#restart">Restart (useful to change secrets)</Tooltip>
|
||||
<button
|
||||
id="forceredeploypreview"
|
||||
class="icons"
|
||||
disabled={!$appSession.isAdmin}
|
||||
on:click={() => redeploy(preview)}
|
||||
>
|
||||
<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></button
|
||||
>
|
||||
<Tooltip triggeredBy="#forceredeploypreview">Force redeploy (without cache)</Tooltip
|
||||
>
|
||||
<button
|
||||
id="deletepreview"
|
||||
class="icons"
|
||||
class:hover:text-error={!loading.removing}
|
||||
disabled={loading.removing || !$appSession.isAdmin}
|
||||
on:click={() => removeApplication(preview)}
|
||||
><Icons.Delete />
|
||||
</button>
|
||||
<Tooltip triggeredBy="#deletepreview">Delete Preview</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
No previews found.
|
||||
{/if}
|
||||
@@ -0,0 +1,151 @@
|
||||
<script lang="ts">
|
||||
import type { PageData } from './$types';
|
||||
|
||||
export let data: PageData;
|
||||
let application: any = data.application.data;
|
||||
let imagesAvailables: any = data.imagesAvailables;
|
||||
let runningImage: any = data.runningImage;
|
||||
|
||||
import { page } from '$app/stores';
|
||||
import { status, addToast, trpc } from '$lib/store';
|
||||
import { errorNotification } from '$lib/common';
|
||||
import Explainer from '$lib/components/Explainer.svelte';
|
||||
|
||||
const { id } = $page.params;
|
||||
let remoteImage: any = null;
|
||||
|
||||
async function revertToLocal(image: any) {
|
||||
const sure = confirm(`Are you sure you want to revert to ${image.tag} ?`);
|
||||
if (sure) {
|
||||
try {
|
||||
$status.application.initialLoading = true;
|
||||
$status.application.loading = true;
|
||||
const imageId = `${image.repository}:${image.tag}`;
|
||||
await trpc.applications.restart.mutate({ id, imageId });
|
||||
// await post(`/applications/${id}/restart`, { imageId });
|
||||
addToast({
|
||||
type: 'success',
|
||||
message: 'Revert successful.'
|
||||
});
|
||||
} catch (error) {
|
||||
return errorNotification(error);
|
||||
} finally {
|
||||
$status.application.initialLoading = false;
|
||||
$status.application.loading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
async function revertToRemote() {
|
||||
const sure = confirm(`Are you sure you want to revert to ${remoteImage} ?`);
|
||||
if (sure) {
|
||||
try {
|
||||
$status.application.initialLoading = true;
|
||||
$status.application.loading = true;
|
||||
$status.application.restarting = true;
|
||||
await trpc.applications.restart.mutate({ id, imageId: remoteImage });
|
||||
addToast({
|
||||
type: 'success',
|
||||
message: 'Revert successful.'
|
||||
});
|
||||
} catch (error) {
|
||||
return errorNotification(error);
|
||||
} finally {
|
||||
$status.application.initialLoading = false;
|
||||
$status.application.loading = false;
|
||||
$status.application.restarting = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
</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">
|
||||
Revert <Explainer
|
||||
position="dropdown-bottom"
|
||||
explanation="You can revert application to a previously built image. Currently only locally stored images
|
||||
supported."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pb-4 text-xs">
|
||||
If you do not want the next commit to overwrite the reverted application, temporary disable <span
|
||||
class="text-yellow-400 font-bold">Automatic Deployment</span
|
||||
>
|
||||
feature <a href={`/applications/${id}/features`}>here</a>.
|
||||
</div>
|
||||
{#if imagesAvailables.length > 0}
|
||||
<div class="text-xl font-bold pb-3">Local Images</div>
|
||||
<div
|
||||
class="px-4 lg:pb-10 pb-6 flex flex-wrap items-center justify-center lg:justify-start gap-8"
|
||||
>
|
||||
{#each imagesAvailables as image}
|
||||
<div class="gap-2 py-4 m-2">
|
||||
<div class="flex flex-col justify-center items-center">
|
||||
<div class="text-xl font-bold">
|
||||
{image.tag}
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
class="flex no-underline text-xs my-4"
|
||||
href="{application.gitSource.htmlUrl}/{application.repository}/commit/{image.tag}"
|
||||
target="_blank noreferrer"
|
||||
>
|
||||
<button class="btn btn-sm">
|
||||
Check Commit
|
||||
<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 ml-2"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M4.5 19.5l15-15m0 0H8.25m11.25 0v11.25"
|
||||
/>
|
||||
</svg>
|
||||
</button></a
|
||||
>
|
||||
{#if image.repository + ':' + image.tag !== runningImage}
|
||||
<button
|
||||
class="btn btn-sm btn-primary w-full"
|
||||
on:click={() => revertToLocal(image)}>Revert Now</button
|
||||
>
|
||||
{:else}
|
||||
<button
|
||||
class="btn btn-sm btn-primary w-full btn-disabled bg-transparent underline"
|
||||
>Currently Used</button
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-col pb-10">
|
||||
<div class="text-xl font-bold">No Local images available</div>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="text-xl font-bold pb-3">
|
||||
Remote Images (Docker Registry) <Explainer
|
||||
position="dropdown-bottom"
|
||||
explanation="If the image is not available or you are unauthorized to access it, you will not be able to revert to it."
|
||||
/>
|
||||
</div>
|
||||
<form on:submit|preventDefault={revertToRemote}>
|
||||
<input
|
||||
id="dockerImage"
|
||||
name="dockerImage"
|
||||
required
|
||||
placeholder="coollabsio/coolify:0.0.1"
|
||||
bind:value={remoteImage}
|
||||
/>
|
||||
<button class="btn btn-sm btn-primary" type="submit">Revert Now</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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.applications.getLocalImages.query({ id });
|
||||
return data;
|
||||
} catch (err) {
|
||||
throw error(500, {
|
||||
message: 'An unexpected error occurred, please try again later.'
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,138 @@
|
||||
<script lang="ts">
|
||||
import type { PageData } from './$types';
|
||||
|
||||
export let data: PageData;
|
||||
let secrets = data.secrets;
|
||||
let previewSecrets = data.previewSecrets;
|
||||
const application = data.application.data;
|
||||
|
||||
import pLimit from 'p-limit';
|
||||
import { page } from '$app/stores';
|
||||
import { addToast, trpc } from '$lib/store';
|
||||
import Secret from './components/Secret.svelte';
|
||||
import PreviewSecret from './components/PreviewSecret.svelte';
|
||||
import { errorNotification } from '$lib/common';
|
||||
import Explainer from '$lib/components/Explainer.svelte';
|
||||
|
||||
const limit = pLimit(1);
|
||||
const { id } = $page.params;
|
||||
|
||||
let batchSecrets = '';
|
||||
async function refreshSecrets() {
|
||||
const { data } = await trpc.applications.getSecrets.query({ id });
|
||||
previewSecrets = [...data.previewSecrets];
|
||||
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('=');
|
||||
return {
|
||||
name: name.trim(),
|
||||
value: value.trim(),
|
||||
createSecret: !secrets.find((secret: any) => name === secret.name)
|
||||
};
|
||||
});
|
||||
|
||||
await Promise.all(
|
||||
batchSecretsPairs.map(({ name, value, createSecret }) =>
|
||||
limit(async () => {
|
||||
try {
|
||||
if (!name || !value) return;
|
||||
if (createSecret) {
|
||||
await trpc.applications.newSecret.mutate({
|
||||
id,
|
||||
name,
|
||||
value
|
||||
});
|
||||
|
||||
addToast({
|
||||
message: 'Secret created.',
|
||||
type: 'success'
|
||||
});
|
||||
} else {
|
||||
await trpc.applications.updateSecret.mutate({
|
||||
id,
|
||||
name,
|
||||
value
|
||||
});
|
||||
|
||||
addToast({
|
||||
message: 'Secret updated.',
|
||||
type: 'success'
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
return errorNotification(error);
|
||||
}
|
||||
})
|
||||
)
|
||||
);
|
||||
batchSecrets = '';
|
||||
await refreshSecrets();
|
||||
}
|
||||
</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>
|
||||
{#each secrets as secret, index}
|
||||
{#key secret.id}
|
||||
<Secret
|
||||
{index}
|
||||
length={secrets.length}
|
||||
name={secret.name}
|
||||
value={secret.value}
|
||||
isBuildSecret={secret.isBuildSecret}
|
||||
on:refresh={refreshSecrets}
|
||||
/>
|
||||
{/key}
|
||||
{/each}
|
||||
<div class="lg:pt-0 pt-10">
|
||||
<Secret on:refresh={refreshSecrets} length={secrets.length} isNewSecret />
|
||||
</div>
|
||||
{#if !application.settings.isBot && !application.simpleDockerfile}
|
||||
<div class="flex flex-row border-b border-coolgray-500 mb-6 space-x-2">
|
||||
<div class="title font-bold pb-3 pt-8">
|
||||
Preview Secrets <Explainer
|
||||
explanation="These values overwrite application secrets in PR/MR deployments. <br>Useful for creating <span class='text-green-500 font-bold'>staging</span> environments."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{#if previewSecrets.length !== 0}
|
||||
{#each previewSecrets as secret, index}
|
||||
{#key index}
|
||||
<PreviewSecret
|
||||
{index}
|
||||
length={secrets.length}
|
||||
name={secret.name}
|
||||
value={secret.value}
|
||||
isBuildSecret={secret.isBuildSecret}
|
||||
on:refresh={refreshSecrets}
|
||||
/>
|
||||
{/key}
|
||||
{/each}
|
||||
{:else}
|
||||
Add secrets first to see Preview Secrets.
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
<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>
|
||||
@@ -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.applications.getSecrets.query({ id });
|
||||
return data;
|
||||
} catch (err) {
|
||||
throw error(500, {
|
||||
message: 'An unexpected error occurred, please try again later.'
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,131 @@
|
||||
<script lang="ts">
|
||||
export let length = 0;
|
||||
export let index: number = 0;
|
||||
export let name = '';
|
||||
export let value = '';
|
||||
export let isBuildSecret = false;
|
||||
|
||||
import { page } from '$app/stores';
|
||||
import { errorNotification } from '$lib/common';
|
||||
import CopyPasswordField from '$lib/components/CopyPasswordField.svelte';
|
||||
import { addToast, trpc } from '$lib/store';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
const { id } = $page.params;
|
||||
|
||||
async function updatePreviewSecret() {
|
||||
try {
|
||||
await trpc.applications.updateSecret.mutate({
|
||||
id,
|
||||
name: name.trim(),
|
||||
value: value.trim(),
|
||||
isPreview: true
|
||||
});
|
||||
addToast({
|
||||
message: 'Secret updated.',
|
||||
type: 'success'
|
||||
});
|
||||
} catch (error) {
|
||||
return errorNotification(error);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="w-full grid grid-cols-1 lg:grid-cols-4 gap-2 pb-2">
|
||||
<div class="flex flex-col">
|
||||
{#if index === 0 || length === 0}
|
||||
<label for="name" class="pb-2 uppercase font-bold">name</label>
|
||||
{/if}
|
||||
|
||||
<input
|
||||
id="secretName"
|
||||
readonly
|
||||
disabled
|
||||
value={name}
|
||||
required
|
||||
placeholder="EXAMPLE_VARIABLE"
|
||||
class=" w-full"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
{#if index === 0 || length === 0}
|
||||
<label for="value" class="pb-2 uppercase font-bold">value</label>
|
||||
{/if}
|
||||
|
||||
<CopyPasswordField
|
||||
id="secretValue"
|
||||
name="secretValue"
|
||||
isPasswordField={true}
|
||||
bind:value
|
||||
placeholder="J$#@UIO%HO#$U%H"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex lg:flex-col flex-row justify-start items-center pt-3 lg:pt-0">
|
||||
{#if index === 0 || length === 0}
|
||||
<label for="name" class="pb-2 uppercase lg:block hidden font-bold"
|
||||
>Need during buildtime?</label
|
||||
>
|
||||
{/if}
|
||||
<label for="name" class="pb-2 uppercase lg:hidden block font-bold">Need during buildtime?</label
|
||||
>
|
||||
|
||||
<div class="flex justify-center h-full items-center pt-0 lg:pt-0 pl-4 lg:pl-0">
|
||||
<button
|
||||
aria-pressed="false"
|
||||
class="opacity-50 cursor-pointer cursor-not-allowedrelative inline-flex h-6 w-11 flex-shrink-0 rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out "
|
||||
class:bg-green-600={isBuildSecret}
|
||||
class:bg-stone-700={!isBuildSecret}
|
||||
>
|
||||
<span class="sr-only">Is build secret?</span>
|
||||
<span
|
||||
class="pointer-events-none relative inline-block h-5 w-5 transform rounded-full bg-white shadow transition duration-200 ease-in-out"
|
||||
class:translate-x-5={isBuildSecret}
|
||||
class:translate-x-0={!isBuildSecret}
|
||||
>
|
||||
<span
|
||||
class="absolute inset-0 flex h-full w-full items-center justify-center transition-opacity duration-200 ease-in"
|
||||
class:opacity-0={isBuildSecret}
|
||||
class:opacity-100={!isBuildSecret}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<svg class="h-3 w-3 bg-white text-red-600" fill="none" viewBox="0 0 12 12">
|
||||
<path
|
||||
d="M4 8l2-2m0 0l2-2M6 6L4 4m2 2l2 2"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
<span
|
||||
class="absolute inset-0 flex h-full w-full items-center justify-center transition-opacity duration-100 ease-out"
|
||||
aria-hidden="true"
|
||||
class:opacity-100={isBuildSecret}
|
||||
class:opacity-0={!isBuildSecret}
|
||||
>
|
||||
<svg class="h-3 w-3 bg-white text-green-600" fill="currentColor" viewBox="0 0 12 12">
|
||||
<path
|
||||
d="M3.707 5.293a1 1 0 00-1.414 1.414l1.414-1.414zM5 8l-.707.707a1 1 0 001.414 0L5 8zm4.707-3.293a1 1 0 00-1.414-1.414l1.414 1.414zm-7.414 2l2 2 1.414-1.414-2-2-1.414 1.414zm3.414 2l4-4-1.414-1.414-4 4 1.414 1.414z"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-row lg:flex-col lg:items-center items-start">
|
||||
{#if index === 0 || length === 0}
|
||||
<label for="name" class="pb-5 uppercase lg:block hidden font-bold" />
|
||||
{/if}
|
||||
|
||||
<div class="flex justify-center h-full items-center pt-3">
|
||||
<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={updatePreviewSecret}>Update</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,193 @@
|
||||
<script lang="ts">
|
||||
export let length = 0;
|
||||
export let index: number = 0;
|
||||
export let name = '';
|
||||
export let value = '';
|
||||
export let isBuildSecret = false;
|
||||
export let isNewSecret = false;
|
||||
|
||||
import { page } from '$app/stores';
|
||||
import { errorNotification } from '$lib/common';
|
||||
import CopyPasswordField from '$lib/components/CopyPasswordField.svelte';
|
||||
import { addToast, trpc } from '$lib/store';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
const { id } = $page.params;
|
||||
function cleanupState() {
|
||||
if (isNewSecret) {
|
||||
name = '';
|
||||
value = '';
|
||||
isBuildSecret = false;
|
||||
}
|
||||
}
|
||||
async function removeSecret() {
|
||||
try {
|
||||
await trpc.applications.deleteSecret.mutate({ id, name });
|
||||
cleanupState();
|
||||
addToast({
|
||||
message: 'Secret removed.',
|
||||
type: 'success'
|
||||
});
|
||||
dispatch('refresh');
|
||||
} catch (error) {
|
||||
return errorNotification(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function addNewSecret() {
|
||||
try {
|
||||
if (!name.trim()) return errorNotification({ message: 'Name is required.' });
|
||||
if (!value.trim()) return errorNotification({ message: 'Value is required.' });
|
||||
await trpc.applications.newSecret.mutate({
|
||||
id,
|
||||
name: name.trim(),
|
||||
value: value.trim(),
|
||||
isBuildSecret
|
||||
});
|
||||
cleanupState();
|
||||
addToast({
|
||||
message: 'Secret added.',
|
||||
type: 'success'
|
||||
});
|
||||
dispatch('refresh');
|
||||
} catch (error) {
|
||||
return errorNotification(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function updateSecret({
|
||||
changeIsBuildSecret = false
|
||||
}: { changeIsBuildSecret?: boolean } = {}) {
|
||||
if (changeIsBuildSecret) isBuildSecret = !isBuildSecret;
|
||||
if (isNewSecret) return;
|
||||
try {
|
||||
await trpc.applications.updateSecret.mutate({
|
||||
id,
|
||||
name: name.trim(),
|
||||
value: value.trim(),
|
||||
isBuildSecret,
|
||||
isPreview: false
|
||||
});
|
||||
addToast({
|
||||
message: 'Secret updated.',
|
||||
type: 'success'
|
||||
});
|
||||
dispatch('refresh');
|
||||
} catch (error) {
|
||||
return errorNotification(error);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="w-full grid grid-cols-1 lg:grid-cols-4 gap-2 pb-2">
|
||||
<div class="flex flex-col">
|
||||
{#if (index === 0 && !isNewSecret) || length === 0}
|
||||
<label for="name" class="pb-2 uppercase font-bold">name</label>
|
||||
{/if}
|
||||
|
||||
<input
|
||||
id={isNewSecret ? 'secretName' : 'secretNameNew'}
|
||||
bind:value={name}
|
||||
required
|
||||
placeholder="EXAMPLE_VARIABLE"
|
||||
readonly={!isNewSecret}
|
||||
class="w-full"
|
||||
class:bg-coolblack={!isNewSecret}
|
||||
class:border={!isNewSecret}
|
||||
class:border-dashed={!isNewSecret}
|
||||
class:border-coolgray-300={!isNewSecret}
|
||||
class:cursor-not-allowed={!isNewSecret}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
{#if (index === 0 && !isNewSecret) || length === 0}
|
||||
<label for="value" class="pb-2 uppercase font-bold">value</label>
|
||||
{/if}
|
||||
|
||||
<CopyPasswordField
|
||||
id={isNewSecret ? 'secretValue' : 'secretValueNew'}
|
||||
name={isNewSecret ? 'secretValue' : 'secretValueNew'}
|
||||
isPasswordField={true}
|
||||
bind:value
|
||||
placeholder="J$#@UIO%HO#$U%H"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex lg:flex-col flex-row justify-start items-center pt-3 lg:pt-0">
|
||||
{#if (index === 0 && !isNewSecret) || length === 0}
|
||||
<label for="name" class="pb-2 uppercase lg:block hidden font-bold"
|
||||
>Need during buildtime?</label
|
||||
>
|
||||
{/if}
|
||||
<label for="name" class="pb-2 uppercase lg:hidden block font-bold">Need during buildtime?</label
|
||||
>
|
||||
|
||||
<div class="flex justify-center h-full items-center pt-0 lg:pt-0 pl-4 lg:pl-0">
|
||||
<button
|
||||
on:click={() => updateSecret({ changeIsBuildSecret: true })}
|
||||
aria-pressed="false"
|
||||
class="relative inline-flex h-6 w-11 flex-shrink-0 rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out "
|
||||
class:bg-green-600={isBuildSecret}
|
||||
class:bg-stone-700={!isBuildSecret}
|
||||
>
|
||||
<span class="sr-only">Is build secret?</span>
|
||||
<span
|
||||
class="pointer-events-none relative inline-block h-5 w-5 transform rounded-full bg-white shadow transition duration-200 ease-in-out"
|
||||
class:translate-x-5={isBuildSecret}
|
||||
class:translate-x-0={!isBuildSecret}
|
||||
>
|
||||
<span
|
||||
class="absolute inset-0 flex h-full w-full items-center justify-center transition-opacity duration-200 ease-in"
|
||||
class:opacity-0={isBuildSecret}
|
||||
class:opacity-100={!isBuildSecret}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<svg class="h-3 w-3 bg-white text-red-600" fill="none" viewBox="0 0 12 12">
|
||||
<path
|
||||
d="M4 8l2-2m0 0l2-2M6 6L4 4m2 2l2 2"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
<span
|
||||
class="absolute inset-0 flex h-full w-full items-center justify-center transition-opacity duration-100 ease-out"
|
||||
aria-hidden="true"
|
||||
class:opacity-100={isBuildSecret}
|
||||
class:opacity-0={!isBuildSecret}
|
||||
>
|
||||
<svg class="h-3 w-3 bg-white text-green-600" fill="currentColor" viewBox="0 0 12 12">
|
||||
<path
|
||||
d="M3.707 5.293a1 1 0 00-1.414 1.414l1.414-1.414zM5 8l-.707.707a1 1 0 001.414 0L5 8zm4.707-3.293a1 1 0 00-1.414-1.414l1.414 1.414zm-7.414 2l2 2 1.414-1.414-2-2-1.414 1.414zm3.414 2l4-4-1.414-1.414-4 4 1.414 1.414z"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-row lg:flex-col lg:items-center items-start">
|
||||
{#if (index === 0 && !isNewSecret) || length === 0}
|
||||
<label for="name" class="pb-5 uppercase lg:block hidden font-bold" />
|
||||
{/if}
|
||||
|
||||
<div class="flex justify-center h-full items-center pt-3">
|
||||
{#if isNewSecret}
|
||||
<div class="flex items-center justify-center">
|
||||
<button class="btn btn-sm btn-primary" on:click={addNewSecret}>Add</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-row justify-center space-x-2">
|
||||
<div class="flex items-center justify-center">
|
||||
<button class="btn btn-sm btn-primary" on:click={() => updateSecret()}>Set</button>
|
||||
</div>
|
||||
<div class="flex justify-center items-end">
|
||||
<button class="btn btn-sm btn-error" on:click={removeSecret}>Remove</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,78 @@
|
||||
<script lang="ts">
|
||||
import type { PageData } from './$types';
|
||||
|
||||
export let data: PageData;
|
||||
const application = data.application.data;
|
||||
let persistentStorages = data.persistentStorages;
|
||||
import { page } from '$app/stores';
|
||||
import Storage from './components/Storage.svelte';
|
||||
import Explainer from '$lib/components/Explainer.svelte';
|
||||
import { trpc } from '$lib/store';
|
||||
|
||||
let composeJson: any = JSON.parse(application?.dockerComposeFile || '{}');
|
||||
let predefinedVolumes: any[] = [];
|
||||
if (composeJson?.services) {
|
||||
for (const [_, service] of Object.entries(composeJson.services)) {
|
||||
if (service?.volumes) {
|
||||
for (const [_, volumeName] of Object.entries(service.volumes)) {
|
||||
let [volume, target] = volumeName.split(':');
|
||||
if (volume === '.') {
|
||||
volume = target;
|
||||
}
|
||||
if (!target) {
|
||||
target = volume;
|
||||
volume = `${application.id}${volume.replace(/\//gi, '-').replace(/\./gi, '')}`;
|
||||
} else {
|
||||
volume = `${application.id}${volume.replace(/\//gi, '-').replace(/\./gi, '')}`;
|
||||
}
|
||||
predefinedVolumes.push({ id: volume, path: target, predefined: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const { id } = $page.params;
|
||||
async function refreshStorage() {
|
||||
const { data } = await trpc.applications.getStorages.query({ id });
|
||||
persistentStorages = [...data.persistentStorages];
|
||||
}
|
||||
</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</div>
|
||||
</div>
|
||||
{#if predefinedVolumes.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 py-2 gap-2">
|
||||
<div class="font-bold uppercase">Volume Id</div>
|
||||
<div class="font-bold uppercase">Mount Dir</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="gap-4">
|
||||
{#each predefinedVolumes as storage}
|
||||
{#key storage.id}
|
||||
<Storage on:refresh={refreshStorage} {storage} />
|
||||
{/key}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if persistentStorages.length > 0}
|
||||
<div class="title" class:pt-10={predefinedVolumes.length > 0}>Custom Volumes</div>
|
||||
{/if}
|
||||
{#each persistentStorages as storage}
|
||||
{#key storage.id}
|
||||
<Storage on:refresh={refreshStorage} {storage} />
|
||||
{/key}
|
||||
{/each}
|
||||
<div class="Preview Secrets" class:pt-10={predefinedVolumes.length > 0}>
|
||||
Add New Volume <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>
|
||||
<Storage on:refresh={refreshStorage} isNew />
|
||||
</div>
|
||||
</div>
|
||||
@@ -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.applications.getStorages.query({ id });
|
||||
return data;
|
||||
} catch (err) {
|
||||
throw error(500, {
|
||||
message: 'An unexpected error occurred, please try again later.'
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,114 @@
|
||||
<script lang="ts">
|
||||
export let isNew = false;
|
||||
export let storage: any = {
|
||||
id: null,
|
||||
path: null
|
||||
};
|
||||
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(newStorage = false) {
|
||||
try {
|
||||
if (!storage.path) return errorNotification('Path is required');
|
||||
storage.path = storage.path.startsWith('/') ? storage.path : `/${storage.path}`;
|
||||
storage.path = storage.path.endsWith('/') ? storage.path.slice(0, -1) : storage.path;
|
||||
storage.path.replace(/\/\//g, '/');
|
||||
await trpc.applications.updateStorage.mutate({
|
||||
id,
|
||||
path: storage.path,
|
||||
storageId: storage.id,
|
||||
newStorage
|
||||
});
|
||||
|
||||
dispatch('refresh');
|
||||
if (isNew) {
|
||||
storage.path = null;
|
||||
storage.id = null;
|
||||
}
|
||||
if (newStorage) {
|
||||
addToast({
|
||||
message: 'Storage created',
|
||||
type: 'success'
|
||||
});
|
||||
} else {
|
||||
addToast({
|
||||
message: 'Storage updated',
|
||||
type: 'success'
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
return errorNotification(error);
|
||||
}
|
||||
}
|
||||
async function removeStorage() {
|
||||
try {
|
||||
await trpc.applications.deleteStorage.mutate({
|
||||
id,
|
||||
path: storage.path
|
||||
});
|
||||
dispatch('refresh');
|
||||
addToast({
|
||||
message: 'Storage removed',
|
||||
type: 'success'
|
||||
});
|
||||
} catch (error) {
|
||||
return errorNotification(error);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="w-full lg:px-0 px-4">
|
||||
{#if storage.predefined}
|
||||
<div class="flex flex-col lg:flex-row gap-4 pb-2">
|
||||
<input disabled readonly class="w-full" value={storage.id} />
|
||||
<input disabled readonly class="w-full" bind:value={storage.path} />
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex gap-4 pb-2" class:pt-8={isNew}>
|
||||
{#if storage.applicationId}
|
||||
{#if storage.oldPath}
|
||||
<input
|
||||
disabled
|
||||
readonly
|
||||
class="w-full"
|
||||
value="{storage.applicationId}{storage.path.replace(/\//gi, '-').replace('-app', '')}"
|
||||
/>
|
||||
{:else}
|
||||
<input
|
||||
disabled
|
||||
readonly
|
||||
class="w-full"
|
||||
value="{storage.applicationId}{storage.path.replace(/\//gi, '-')}"
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
<input
|
||||
disabled={!isNew}
|
||||
readonly={!isNew}
|
||||
class="w-full"
|
||||
bind:value={storage.path}
|
||||
required
|
||||
placeholder="eg: /data"
|
||||
/>
|
||||
|
||||
<div class="flex items-center justify-center">
|
||||
{#if isNew}
|
||||
<div class="w-full lg:w-64">
|
||||
<button class="btn btn-sm btn-primary w-full" on:click={() => saveStorage(true)}
|
||||
>Add</button
|
||||
>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex justify-center">
|
||||
<button class="btn btn-sm btn-error" on:click={removeStorage}>Remove</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,116 @@
|
||||
<script lang="ts">
|
||||
import type { PageParentData } from './$types';
|
||||
|
||||
export let data: PageParentData;
|
||||
let application: any = data.application.data;
|
||||
import { page } from '$app/stores';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import Tooltip from '$lib/components/Tooltip.svelte';
|
||||
import { trpc } from '$lib/store';
|
||||
|
||||
const { id } = $page.params;
|
||||
let services: any = [];
|
||||
let selectedService: any = null;
|
||||
let usageLoading = false;
|
||||
let usage = {
|
||||
MemUsage: 0,
|
||||
CPUPerc: 0,
|
||||
NetIO: 0
|
||||
};
|
||||
let usageInterval: any;
|
||||
|
||||
async function getUsage() {
|
||||
if (usageLoading) return;
|
||||
usageLoading = true;
|
||||
const { data } = await trpc.applications.getUsage.query({ id, containerId: selectedService });
|
||||
usage = data.usage;
|
||||
usageLoading = false;
|
||||
}
|
||||
function normalizeDockerServices(services: any[]) {
|
||||
const tempdockerComposeServices = [];
|
||||
for (const [name, data] of Object.entries(services)) {
|
||||
tempdockerComposeServices.push({
|
||||
name,
|
||||
data
|
||||
});
|
||||
}
|
||||
return tempdockerComposeServices;
|
||||
}
|
||||
async function selectService(service: any, init: boolean = false) {
|
||||
if (usageInterval) clearInterval(usageInterval);
|
||||
usageLoading = false;
|
||||
usage = {
|
||||
MemUsage: 0,
|
||||
CPUPerc: 0,
|
||||
NetIO: 0
|
||||
};
|
||||
selectedService = `${application.id}${service.name ? `-${service.name}` : ''}`;
|
||||
|
||||
await getUsage();
|
||||
usageInterval = setInterval(async () => {
|
||||
await getUsage();
|
||||
}, 1000);
|
||||
}
|
||||
onDestroy(() => {
|
||||
clearInterval(usageInterval);
|
||||
});
|
||||
onMount(async () => {
|
||||
if (application.dockerComposeFile && application.buildPack === 'compose') {
|
||||
services = normalizeDockerServices(JSON.parse(application.dockerComposeFile).services);
|
||||
} else {
|
||||
services = [
|
||||
{
|
||||
name: ''
|
||||
}
|
||||
];
|
||||
await selectService('');
|
||||
}
|
||||
});
|
||||
</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">Monitoring</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2 lg:gap-8 pb-4">
|
||||
{#each services as service}
|
||||
<button
|
||||
on:click={() => selectService(service, true)}
|
||||
class:bg-primary={selectedService ===
|
||||
`${application.id}${service.name ? `-${service.name}` : ''}`}
|
||||
class:bg-coolgray-200={selectedService !==
|
||||
`${application.id}${service.name ? `-${service.name}` : ''}`}
|
||||
class="w-full rounded p-5 hover:bg-primary font-bold"
|
||||
>
|
||||
{application.id}{service.name ? `-${service.name}` : ''}</button
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
{#if selectedService}
|
||||
<div class="mx-auto max-w-4xl px-6 py-4 bg-coolgray-100 border border-coolgray-200 relative">
|
||||
{#if usageLoading}
|
||||
<button
|
||||
id="streaming"
|
||||
class="btn btn-sm bg-transparent border-none loading absolute top-0 left-0 text-xs"
|
||||
/>
|
||||
<Tooltip triggeredBy="#streaming">Streaming logs</Tooltip>
|
||||
{/if}
|
||||
<div class="text-center">
|
||||
<div class="stat w-64">
|
||||
<div class="stat-title">Used Memory / Memory Limit</div>
|
||||
<div class="stat-value text-xl">{usage?.MemUsage}</div>
|
||||
</div>
|
||||
|
||||
<div class="stat w-64">
|
||||
<div class="stat-title">Used CPU</div>
|
||||
<div class="stat-value text-xl">{usage?.CPUPerc}</div>
|
||||
</div>
|
||||
|
||||
<div class="stat w-64">
|
||||
<div class="stat-title">Network IO</div>
|
||||
<div class="stat-value text-xl">{usage?.NetIO}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import { errorNotification } from '$lib/common';
|
||||
import { trpc } from '$lib/store';
|
||||
|
||||
export async function saveForm(id, application, baseDatabaseBranch, dockerComposeConfiguration) {
|
||||
let {
|
||||
name,
|
||||
buildPack,
|
||||
fqdn,
|
||||
port,
|
||||
exposePort,
|
||||
installCommand,
|
||||
buildCommand,
|
||||
startCommand,
|
||||
baseDirectory,
|
||||
publishDirectory,
|
||||
pythonWSGI,
|
||||
pythonModule,
|
||||
pythonVariable,
|
||||
dockerFileLocation,
|
||||
denoMainFile,
|
||||
denoOptions,
|
||||
gitCommitHash,
|
||||
baseImage,
|
||||
baseBuildImage,
|
||||
deploymentType,
|
||||
dockerComposeFile,
|
||||
dockerComposeFileLocation,
|
||||
simpleDockerfile,
|
||||
dockerRegistryImageName
|
||||
} = application;
|
||||
return await trpc.applications.save.mutate({
|
||||
id,
|
||||
name,
|
||||
buildPack,
|
||||
fqdn,
|
||||
port,
|
||||
exposePort,
|
||||
installCommand,
|
||||
buildCommand,
|
||||
startCommand,
|
||||
baseDirectory,
|
||||
publishDirectory,
|
||||
pythonWSGI,
|
||||
pythonModule,
|
||||
pythonVariable,
|
||||
dockerFileLocation,
|
||||
denoMainFile,
|
||||
denoOptions,
|
||||
gitCommitHash,
|
||||
baseImage,
|
||||
baseBuildImage,
|
||||
deploymentType,
|
||||
dockerComposeFile,
|
||||
dockerComposeFileLocation,
|
||||
simpleDockerfile,
|
||||
dockerRegistryImageName,
|
||||
baseDatabaseBranch,
|
||||
dockerComposeConfiguration: JSON.stringify(dockerComposeConfiguration)
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
async function newApplication() {
|
||||
// const { id } = await post('/applications/new', {});
|
||||
return await goto(`/applications/${id}`, { replaceState: true });
|
||||
}
|
||||
async function newService() {
|
||||
// const { id } = await post('/services/new', {});
|
||||
return await goto(`/services/${id}`, { replaceState: true });
|
||||
}
|
||||
async function newDatabase() {
|
||||
// const { id } = await post('/databases/new', {});
|
||||
return await goto(`/databases/${id}`, { replaceState: true });
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
|
||||
<div class="dropdown dropdown-bottom">
|
||||
<slot>
|
||||
<label for="new" tabindex="0" class="btn btn-sm text-sm bg-coollabs hover:bg-coollabs-100 w-64">
|
||||
<svg
|
||||
class="h-6 w-6"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
><path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 6v6m0 0v6m0-6h6m-6 0H6"
|
||||
/></svg
|
||||
> Create New Resource
|
||||
</label>
|
||||
</slot>
|
||||
|
||||
<ul id="new" tabindex="0" class="dropdown-content menu p-2 shadow bg-coolgray-300 rounded w-64">
|
||||
<li>
|
||||
<button
|
||||
on:click={newApplication}
|
||||
class="no-underline hover:bg-applications tracking-wide font-bold"
|
||||
>
|
||||
<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" />
|
||||
<rect x="4" y="4" width="6" height="6" rx="1" />
|
||||
<rect x="4" y="14" width="6" height="6" rx="1" />
|
||||
<rect x="14" y="14" width="6" height="6" rx="1" />
|
||||
<line x1="14" y1="7" x2="20" y2="7" />
|
||||
<line x1="17" y1="4" x2="17" y2="10" />
|
||||
</svg>Application</button
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<button on:click={newService} class="no-underline hover:bg-services tracking-wide font-bold">
|
||||
<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="M7 18a4.6 4.4 0 0 1 0 -9a5 4.5 0 0 1 11 2h1a3.5 3.5 0 0 1 0 7h-12" />
|
||||
</svg>Service</button
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<button
|
||||
on:click={newDatabase}
|
||||
class="no-underline hover:bg-databases tracking-wide font-bold"
|
||||
>
|
||||
<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" />
|
||||
<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>Database</button
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/sources/new" class="no-underline hover:bg-sources tracking-wide font-bold">
|
||||
<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" />
|
||||
<circle cx="6" cy="6" r="2" />
|
||||
<circle cx="18" cy="18" r="2" />
|
||||
<path d="M11 6h5a2 2 0 0 1 2 2v8" />
|
||||
<polyline points="14 9 11 6 14 3" />
|
||||
<path d="M13 18h-5a2 2 0 0 1 -2 -2v-8" />
|
||||
<polyline points="10 15 13 18 10 21" />
|
||||
</svg>Git Source</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="/destinations/new"
|
||||
class="no-underline hover:bg-destinations tracking-wide font-bold"
|
||||
>
|
||||
<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="M22 12.54c-1.804 -.345 -2.701 -1.08 -3.523 -2.94c-.487 .696 -1.102 1.568 -.92 2.4c.028 .238 -.32 1.002 -.557 1h-14c0 5.208 3.164 7 6.196 7c4.124 .022 7.828 -1.376 9.854 -5c1.146 -.101 2.296 -1.505 2.95 -2.46z"
|
||||
/>
|
||||
<path d="M5 10h3v3h-3z" />
|
||||
<path d="M8 10h3v3h-3z" />
|
||||
<path d="M11 10h3v3h-3z" />
|
||||
<path d="M8 7h3v3h-3z" />
|
||||
<path d="M11 7h3v3h-3z" />
|
||||
<path d="M11 4h3v3h-3z" />
|
||||
<path d="M4.571 18c1.5 0 2.047 -.074 2.958 -.78" />
|
||||
<line x1="10" y1="16" x2="10" y2="16.01" />
|
||||
</svg>Destination</a
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -0,0 +1,305 @@
|
||||
<script lang="ts">
|
||||
import type { LayoutData } from './$types';
|
||||
export let data: LayoutData;
|
||||
let database = data.database.data.database;
|
||||
|
||||
import { page } from '$app/stores';
|
||||
import { errorNotification } from '$lib/common';
|
||||
import { appSession, status, isDeploymentEnabled, trpc } from '$lib/store';
|
||||
import * as Icons from '$lib/components/icons';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import Tooltip from '$lib/components/Tooltip.svelte';
|
||||
import DatabaseLinks from './components/DatabaseLinks.svelte';
|
||||
const { id } = $page.params;
|
||||
|
||||
$status.database.isPublic = database.settings.isPublic || false;
|
||||
let statusInterval: any = false;
|
||||
let forceDelete = false;
|
||||
|
||||
$isDeploymentEnabled = !$appSession.isAdmin;
|
||||
|
||||
async function deleteDatabase(force: boolean) {
|
||||
const sure = confirm(`Are you sure you would like to delete '${database.name}'?`);
|
||||
if (sure) {
|
||||
$status.database.initialLoading = true;
|
||||
try {
|
||||
await trpc.databases.delete.mutate({ id, force });
|
||||
return await window.location.assign('/');
|
||||
} catch (error) {
|
||||
return errorNotification(error);
|
||||
} finally {
|
||||
$status.database.initialLoading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
async function stopDatabase() {
|
||||
const sure = confirm(
|
||||
"Are you sure you want to stop this database? You won't be able to access it until you start it again."
|
||||
);
|
||||
if (sure) {
|
||||
$status.database.initialLoading = true;
|
||||
$status.database.loading = true;
|
||||
try {
|
||||
await trpc.databases.stop.mutate({ id });
|
||||
$status.database.isPublic = false;
|
||||
} catch (error) {
|
||||
return errorNotification(error);
|
||||
} finally {
|
||||
$status.database.initialLoading = false;
|
||||
$status.database.loading = false;
|
||||
await getStatus();
|
||||
}
|
||||
}
|
||||
}
|
||||
async function startDatabase() {
|
||||
$status.database.initialLoading = true;
|
||||
$status.database.loading = true;
|
||||
try {
|
||||
await trpc.databases.start.mutate({ id });
|
||||
} catch (error) {
|
||||
return errorNotification(error);
|
||||
} finally {
|
||||
$status.database.initialLoading = false;
|
||||
$status.database.loading = false;
|
||||
await getStatus();
|
||||
}
|
||||
}
|
||||
async function getStatus() {
|
||||
if ($status.database.loading) return;
|
||||
$status.database.loading = true;
|
||||
const { data } = await trpc.databases.status.query({ id });
|
||||
$status.database.isRunning = data.isRunning;
|
||||
$status.database.initialLoading = false;
|
||||
$status.database.loading = false;
|
||||
}
|
||||
onDestroy(() => {
|
||||
$status.database.initialLoading = true;
|
||||
$status.database.isRunning = false;
|
||||
$status.database.isExited = false;
|
||||
$status.database.loading = false;
|
||||
clearInterval(statusInterval);
|
||||
});
|
||||
onMount(async () => {
|
||||
$status.database.isRunning = false;
|
||||
$status.database.loading = false;
|
||||
if (
|
||||
database.type &&
|
||||
database.destinationDockerId &&
|
||||
database.version &&
|
||||
database.defaultDatabase
|
||||
) {
|
||||
await getStatus();
|
||||
statusInterval = setInterval(async () => {
|
||||
await getStatus();
|
||||
}, 2000);
|
||||
} else {
|
||||
$status.database.initialLoading = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if id !== 'new'}
|
||||
<nav class="header lg:flex-row flex-col-reverse">
|
||||
<div class="flex flex-row space-x-2 font-bold pt-10 lg:pt-0">
|
||||
<div class="flex flex-col items-center justify-center">
|
||||
<div class="title">
|
||||
{#if $page.url.pathname === `/databases/${id}`}
|
||||
Configurations
|
||||
{:else if $page.url.pathname === `/databases/${id}/logs`}
|
||||
Database Logs
|
||||
{:else if $page.url.pathname === `/databases/${id}/configuration/type`}
|
||||
Select a Database Type
|
||||
{:else if $page.url.pathname === `/databases/${id}/configuration/version`}
|
||||
Select a Database Version
|
||||
{:else if $page.url.pathname === `/databases/${id}/configuration/destination`}
|
||||
Select a Destination
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<DatabaseLinks {database} />
|
||||
</div>
|
||||
<div class="lg:block hidden flex-1" />
|
||||
<div class="flex flex-row flex-wrap space-x-3 justify-center lg:justify-start lg:py-0">
|
||||
{#if database.type && database.destinationDockerId && database.version}
|
||||
{#if $status.database.isExited}
|
||||
<a
|
||||
id="exited"
|
||||
href={!$status.database.isRunning ? `/databases/${id}/logs` : null}
|
||||
class="icons bg-transparent text-red-500 tooltip-error"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="w-6 h-6"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentcolor"
|
||||
fill="none"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<path
|
||||
d="M8.7 3h6.6c.3 0 .5 .1 .7 .3l4.7 4.7c.2 .2 .3 .4 .3 .7v6.6c0 .3 -.1 .5 -.3 .7l-4.7 4.7c-.2 .2 -.4 .3 -.7 .3h-6.6c-.3 0 -.5 -.1 -.7 -.3l-4.7 -4.7c-.2 -.2 -.3 -.4 -.3 -.7v-6.6c0 -.3 .1 -.5 .3 -.7l4.7 -4.7c.2 -.2 .4 -.3 .7 -.3z"
|
||||
/>
|
||||
<line x1="12" y1="8" x2="12" y2="12" />
|
||||
<line x1="12" y1="16" x2="12.01" y2="16" />
|
||||
</svg>
|
||||
</a>
|
||||
<Tooltip triggeredBy="#exited">{'Service exited with an error!'}</Tooltip>
|
||||
{/if}
|
||||
{#if $status.database.initialLoading}
|
||||
<button class="icons flex animate-spin duration-500 ease-in-out">
|
||||
<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="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>
|
||||
</button>
|
||||
{:else if $status.database.isRunning}
|
||||
<button
|
||||
id="stop"
|
||||
on:click={stopDatabase}
|
||||
type="submit"
|
||||
disabled={!$appSession.isAdmin}
|
||||
class="icons bg-transparent text-red-500"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="w-6 h-6"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<rect x="6" y="5" width="4" height="14" rx="1" />
|
||||
<rect x="14" y="5" width="4" height="14" rx="1" />
|
||||
</svg>
|
||||
</button>
|
||||
<Tooltip triggeredBy="#stop">{'Stop'}</Tooltip>
|
||||
{:else}
|
||||
<button
|
||||
id="start"
|
||||
on:click={startDatabase}
|
||||
type="submit"
|
||||
disabled={!$appSession.isAdmin}
|
||||
class="icons bg-transparent text-sm flex items-center space-x-2 text-green-500"
|
||||
><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 4v16l13 -8z" />
|
||||
</svg>
|
||||
</button>
|
||||
<Tooltip triggeredBy="#start">{'Start'}</Tooltip>
|
||||
{/if}
|
||||
{/if}
|
||||
<div class="border border-stone-700 h-8" />
|
||||
<a
|
||||
id="configuration"
|
||||
href="/databases/{id}"
|
||||
class="hover:text-yellow-500 rounded"
|
||||
class:text-yellow-500={$page.url.pathname === `/databases/${id}`}
|
||||
class:bg-coolgray-500={$page.url.pathname === `/databases/${id}`}
|
||||
>
|
||||
<button class="icons bg-transparent m text-sm disabled:text-red-500">
|
||||
<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" />
|
||||
<rect x="4" y="8" width="4" height="4" />
|
||||
<line x1="6" y1="4" x2="6" y2="8" />
|
||||
<line x1="6" y1="12" x2="6" y2="20" />
|
||||
<rect x="10" y="14" width="4" height="4" />
|
||||
<line x1="12" y1="4" x2="12" y2="14" />
|
||||
<line x1="12" y1="18" x2="12" y2="20" />
|
||||
<rect x="16" y="5" width="4" height="4" />
|
||||
<line x1="18" y1="4" x2="18" y2="5" />
|
||||
<line x1="18" y1="9" x2="18" y2="20" />
|
||||
</svg></button
|
||||
></a
|
||||
>
|
||||
<Tooltip triggeredBy="#configuration">{'Configuration'}</Tooltip>
|
||||
<div class="border border-stone-700 h-8" />
|
||||
<a
|
||||
id="databaselogs"
|
||||
href={$status.database.isRunning ? `/databases/${id}/logs` : null}
|
||||
class="hover:text-pink-500 rounded"
|
||||
class:text-pink-500={$page.url.pathname === `/databases/${id}/logs`}
|
||||
class:bg-coolgray-500={$page.url.pathname === `/databases/${id}/logs`}
|
||||
>
|
||||
<button disabled={!$status.database.isRunning} class="icons bg-transparent text-sm">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-6 w-6"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M3 19a9 9 0 0 1 9 0a9 9 0 0 1 9 0" />
|
||||
<path d="M3 6a9 9 0 0 1 9 0a9 9 0 0 1 9 0" />
|
||||
<line x1="3" y1="6" x2="3" y2="19" />
|
||||
<line x1="12" y1="6" x2="12" y2="19" />
|
||||
<line x1="21" y1="6" x2="21" y2="19" />
|
||||
</svg></button
|
||||
></a
|
||||
>
|
||||
<Tooltip triggeredBy="#databaselogs">{'Logs'}</Tooltip>
|
||||
{#if forceDelete}
|
||||
<button
|
||||
on:click={() => deleteDatabase(true)}
|
||||
type="submit"
|
||||
disabled={!$appSession.isAdmin}
|
||||
class:hover:text-red-500={$appSession.isAdmin}
|
||||
class="icons bg-transparent text-sm"
|
||||
>
|
||||
Force Delete</button
|
||||
>{:else}
|
||||
<button
|
||||
id="delete"
|
||||
on:click={() => deleteDatabase(false)}
|
||||
type="submit"
|
||||
disabled={!$appSession.isAdmin}
|
||||
class:hover:text-red-500={$appSession.isAdmin}
|
||||
class="icons bg-transparent text-sm"><Icons.Delete /></button
|
||||
>
|
||||
{/if}
|
||||
|
||||
<Tooltip triggeredBy="#delete" placement="left">Delete</Tooltip>
|
||||
</div>
|
||||
</nav>
|
||||
{/if}
|
||||
<slot />
|
||||
@@ -0,0 +1,48 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { trpc } from '$lib/store';
|
||||
import type { LayoutLoad } from './$types';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
|
||||
function checkConfiguration(database: any): string | null {
|
||||
let configurationPhase = null;
|
||||
if (!database.type) {
|
||||
configurationPhase = 'type';
|
||||
} else if (!database.version) {
|
||||
configurationPhase = 'version';
|
||||
} else if (!database.destinationDockerId) {
|
||||
configurationPhase = 'destination';
|
||||
}
|
||||
return configurationPhase;
|
||||
}
|
||||
|
||||
export const load: LayoutLoad = async ({ params, url }) => {
|
||||
const { pathname } = new URL(url);
|
||||
const { id } = params;
|
||||
try {
|
||||
const database = await trpc.databases.getDatabaseById.query({ id });
|
||||
if (!database) {
|
||||
throw redirect(307, '/databases');
|
||||
}
|
||||
const configurationPhase = checkConfiguration(database);
|
||||
console.log({ configurationPhase });
|
||||
// if (
|
||||
// configurationPhase &&
|
||||
// pathname !== `/applications/${params.id}/configuration/${configurationPhase}`
|
||||
// ) {
|
||||
// throw redirect(302, `/applications/${params.id}/configuration/${configurationPhase}`);
|
||||
// }
|
||||
return {
|
||||
database
|
||||
};
|
||||
} 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.'
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,62 @@
|
||||
<script lang="ts">
|
||||
import type { LayoutData } from './$types';
|
||||
|
||||
export let data: LayoutData;
|
||||
let database = data.database.data.database;
|
||||
let privatePort = data.database.data.privatePort;
|
||||
|
||||
import { page } from '$app/stores';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import Databases from './components/Databases/Databases.svelte';
|
||||
import { status, trpc } from '$lib/store';
|
||||
|
||||
const { id } = $page.params;
|
||||
let loading = {
|
||||
usage: false
|
||||
};
|
||||
let usage = {
|
||||
MemUsage: 0,
|
||||
CPUPerc: 0,
|
||||
NetIO: 0
|
||||
};
|
||||
let usageInterval: any;
|
||||
|
||||
async function getUsage() {
|
||||
if (loading.usage) return;
|
||||
if (!$status.database.isRunning) return;
|
||||
loading.usage = true;
|
||||
const { data } = await trpc.databases.usage.query({ id });
|
||||
usage = data.usage;
|
||||
loading.usage = false;
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
clearInterval(usageInterval);
|
||||
});
|
||||
onMount(async () => {
|
||||
await getUsage();
|
||||
usageInterval = setInterval(async () => {
|
||||
await getUsage();
|
||||
}, 1500);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="mx-auto max-w-6xl p-5">
|
||||
<div class="text-center">
|
||||
<div class="stat w-64">
|
||||
<div class="stat-title">Used Memory / Memory Limit</div>
|
||||
<div class="stat-value text-xl">{usage?.MemUsage}</div>
|
||||
</div>
|
||||
|
||||
<div class="stat w-64">
|
||||
<div class="stat-title">Used CPU</div>
|
||||
<div class="stat-value text-xl">{usage?.CPUPerc}</div>
|
||||
</div>
|
||||
|
||||
<div class="stat w-64">
|
||||
<div class="stat-title">Network IO</div>
|
||||
<div class="stat-value text-xl">{usage?.NetIO}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Databases bind:database {privatePort} />
|
||||
@@ -0,0 +1,32 @@
|
||||
<script lang="ts">
|
||||
export let database: any;
|
||||
|
||||
import Clickhouse from '$lib/components/icons/databases/Clickhouse.svelte';
|
||||
import CouchDb from '$lib/components/icons/databases/CouchDB.svelte';
|
||||
import EdgeDb from '$lib/components/icons/databases/EdgeDB.svelte';
|
||||
import MariaDb from '$lib/components/icons/databases/MariaDB.svelte';
|
||||
import MongoDb from '$lib/components/icons/databases/MongoDB.svelte';
|
||||
import MySql from '$lib/components/icons/databases/MySQL.svelte';
|
||||
import PostgreSql from '$lib/components/icons/databases/PostgreSQL.svelte';
|
||||
import Redis from '$lib/components/icons/databases/Redis.svelte';
|
||||
</script>
|
||||
|
||||
<span class="relative">
|
||||
{#if database.type === 'clickhouse'}
|
||||
<Clickhouse />
|
||||
{:else if database.type === 'couchdb'}
|
||||
<CouchDb />
|
||||
{:else if database.type === 'mongodb'}
|
||||
<MongoDb />
|
||||
{:else if database.type === 'mysql'}
|
||||
<MySql />
|
||||
{:else if database.type === 'mariadb'}
|
||||
<MariaDb />
|
||||
{:else if database.type === 'postgresql'}
|
||||
<PostgreSql />
|
||||
{:else if database.type === 'redis'}
|
||||
<Redis />
|
||||
{:else if database.type === 'edgedb'}
|
||||
<EdgeDb />
|
||||
{/if}
|
||||
</span>
|
||||
@@ -0,0 +1,68 @@
|
||||
<script lang="ts">
|
||||
export let database: any;
|
||||
import CopyPasswordField from '$lib/components/CopyPasswordField.svelte';
|
||||
</script>
|
||||
|
||||
<div class="flex space-x-1 py-5 font-bold">
|
||||
<h1 class="title">CouchDB</h1>
|
||||
</div>
|
||||
<div class="space-y-2 lg:px-10 px-2">
|
||||
<div class="grid grid-cols-2 items-center">
|
||||
<label for="defaultDatabase">Default Database</label>
|
||||
<CopyPasswordField
|
||||
required
|
||||
readonly={database.defaultDatabase}
|
||||
disabled={database.defaultDatabase}
|
||||
placeholder="Example: mydb"
|
||||
id="defaultDatabase"
|
||||
name="defaultDatabase"
|
||||
bind:value={database.defaultDatabase}
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 items-center">
|
||||
<label for="dbUser">User</label>
|
||||
<CopyPasswordField
|
||||
readonly
|
||||
disabled
|
||||
placeholder="Generated automatically after start"
|
||||
id="dbUser"
|
||||
name="dbUser"
|
||||
value={database.dbUser}
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 items-center">
|
||||
<label for="dbUserPassword">Password</label>
|
||||
<CopyPasswordField
|
||||
readonly
|
||||
disabled
|
||||
placeholder="Generated automatically after start"
|
||||
isPasswordField
|
||||
id="dbUserPassword"
|
||||
name="dbUserPassword"
|
||||
value={database.dbUserPassword}
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 items-center">
|
||||
<label for="rootUser">Root User</label>
|
||||
<CopyPasswordField
|
||||
readonly
|
||||
disabled
|
||||
placeholder="Generated automatically after start"
|
||||
id="rootUser"
|
||||
name="rootUser"
|
||||
value={database.rootUser}
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 items-center">
|
||||
<label for="rootUserPassword">Root Password</label>
|
||||
<CopyPasswordField
|
||||
readonly
|
||||
disabled
|
||||
placeholder="Generated automatically after start"
|
||||
isPasswordField
|
||||
id="rootUserPassword"
|
||||
name="rootUserPassword"
|
||||
value={database.rootUserPassword}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,281 @@
|
||||
<script lang="ts">
|
||||
export let database: any;
|
||||
export let privatePort: any;
|
||||
|
||||
import { page } from '$app/stores';
|
||||
import CopyPasswordField from '$lib/components/CopyPasswordField.svelte';
|
||||
import Setting from '$lib/components/Setting.svelte';
|
||||
|
||||
import MySql from './MySQL.svelte';
|
||||
import MongoDb from './MongoDB.svelte';
|
||||
import MariaDb from './MariaDB.svelte';
|
||||
import PostgreSql from './PostgreSQL.svelte';
|
||||
import Redis from './Redis.svelte';
|
||||
import CouchDb from './CouchDb.svelte';
|
||||
import EdgeDB from './EdgeDB.svelte';
|
||||
import { errorNotification } from '$lib/common';
|
||||
import { addToast, appSession, status, trpc } from '$lib/store';
|
||||
import Explainer from '$lib/components/Explainer.svelte';
|
||||
|
||||
const { id } = $page.params;
|
||||
|
||||
let loading = {
|
||||
main: false,
|
||||
public: false
|
||||
};
|
||||
let publicUrl = '';
|
||||
let appendOnly = database.settings.appendOnly;
|
||||
|
||||
let databaseDefault: any;
|
||||
let databaseDbUser: any;
|
||||
let databaseDbUserPassword: any;
|
||||
|
||||
generateDbDetails();
|
||||
|
||||
function generateDbDetails() {
|
||||
databaseDefault = database.defaultDatabase;
|
||||
databaseDbUser = database.dbUser;
|
||||
databaseDbUserPassword = database.dbUserPassword;
|
||||
if (database.type === 'mongodb' || database.type === 'edgedb') {
|
||||
if (database.type === 'mongodb') {
|
||||
databaseDefault = '?readPreference=primary&ssl=false';
|
||||
}
|
||||
databaseDbUser = database.rootUser;
|
||||
databaseDbUserPassword = database.rootUserPassword;
|
||||
} else if (database.type === 'redis') {
|
||||
databaseDefault = '';
|
||||
databaseDbUser = '';
|
||||
}
|
||||
}
|
||||
function generateUrl() {
|
||||
const ipAddress = () => {
|
||||
if ($status.database.isPublic) {
|
||||
if (database.destinationDocker.remoteEngine) {
|
||||
return database.destinationDocker.remoteIpAddress;
|
||||
}
|
||||
if ($appSession.ipv6) {
|
||||
return $appSession.ipv6;
|
||||
}
|
||||
if ($appSession.ipv4) {
|
||||
return $appSession.ipv4;
|
||||
}
|
||||
return '<Cannot determine public IP address>';
|
||||
} else {
|
||||
return database.id;
|
||||
}
|
||||
};
|
||||
const user = () => {
|
||||
if (databaseDbUser) {
|
||||
return databaseDbUser + ':';
|
||||
}
|
||||
return '';
|
||||
};
|
||||
const port = () => {
|
||||
if ($status.database.isPublic) {
|
||||
return database.publicPort;
|
||||
} else {
|
||||
return privatePort;
|
||||
}
|
||||
};
|
||||
publicUrl = `${
|
||||
database.type
|
||||
}://${user()}${databaseDbUserPassword}@${ipAddress()}:${port()}/${databaseDefault}`;
|
||||
}
|
||||
|
||||
async function changeSettings(name: any) {
|
||||
if (name !== 'appendOnly') {
|
||||
if (loading.public || !$status.database.isRunning) return;
|
||||
}
|
||||
loading.public = true;
|
||||
let data = {
|
||||
isPublic: $status.database.isPublic,
|
||||
appendOnly
|
||||
};
|
||||
if (name === 'isPublic') {
|
||||
data.isPublic = !$status.database.isPublic;
|
||||
}
|
||||
if (name === 'appendOnly') {
|
||||
data.appendOnly = !appendOnly;
|
||||
}
|
||||
try {
|
||||
const { publicPort } = await trpc.databases.saveSettings.mutate({
|
||||
id,
|
||||
isPublic: data.isPublic,
|
||||
appendOnly: data.appendOnly
|
||||
});
|
||||
|
||||
$status.database.isPublic = data.isPublic;
|
||||
appendOnly = data.appendOnly;
|
||||
if ($status.database.isPublic) {
|
||||
database.publicPort = publicPort;
|
||||
}
|
||||
} catch (error) {
|
||||
return errorNotification(error);
|
||||
} finally {
|
||||
loading.public = false;
|
||||
}
|
||||
}
|
||||
async function handleSubmit() {
|
||||
try {
|
||||
loading.main = true;
|
||||
await trpc.databases.save.mutate({
|
||||
id,
|
||||
...database,
|
||||
isRunning: $status.database.isRunning
|
||||
});
|
||||
generateDbDetails();
|
||||
addToast({
|
||||
message: 'Configuration saved.',
|
||||
type: 'success'
|
||||
});
|
||||
} catch (error) {
|
||||
return errorNotification(error);
|
||||
} finally {
|
||||
loading.main = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="mx-auto max-w-6xl p-4">
|
||||
<form on:submit|preventDefault={handleSubmit} class="py-4">
|
||||
<div class="flex space-x-1 pb-5 items-center">
|
||||
<h1 class="title">General</h1>
|
||||
{#if $appSession.isAdmin}
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-sm"
|
||||
class:loading={loading.main}
|
||||
class:bg-databases={!loading.main}
|
||||
disabled={loading.main}>Save</button
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="grid gap-2 grid-cols-2 auto-rows-max lg:px-10 px-2">
|
||||
<label for="name">Name</label>
|
||||
<input
|
||||
class="w-full"
|
||||
readonly={!$appSession.isAdmin}
|
||||
name="name"
|
||||
id="name"
|
||||
bind:value={database.name}
|
||||
required
|
||||
/>
|
||||
<label for="destination">Destination</label>
|
||||
{#if database.destinationDockerId}
|
||||
<div class="no-underline">
|
||||
<input
|
||||
value={database.destinationDocker.name}
|
||||
id="destination"
|
||||
disabled
|
||||
readonly
|
||||
class="bg-transparent w-full"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
<label for="version">Version / Tag</label>
|
||||
<a
|
||||
href={$appSession.isAdmin && !$status.database.isRunning
|
||||
? `/databases/${id}/configuration/version?from=/databases/${id}`
|
||||
: ''}
|
||||
class="no-underline"
|
||||
>
|
||||
<input
|
||||
class="w-full"
|
||||
value={database.version}
|
||||
readonly
|
||||
disabled={$status.database.isRunning || $status.database.initialLoading}
|
||||
class:cursor-pointer={!$status.database.isRunning}
|
||||
/></a
|
||||
>
|
||||
<label for="host">Host</label>
|
||||
<CopyPasswordField
|
||||
placeholder="Generated automatically after start"
|
||||
isPasswordField={false}
|
||||
readonly
|
||||
disabled
|
||||
id="host"
|
||||
name="host"
|
||||
value={database.id}
|
||||
/>
|
||||
<label for="publicPort">Port</label>
|
||||
<CopyPasswordField
|
||||
placeholder="Generated automatically after set to public"
|
||||
id="publicPort"
|
||||
readonly
|
||||
disabled
|
||||
name="publicPort"
|
||||
value={loading.public
|
||||
? 'Loading...'
|
||||
: $status.database.isPublic
|
||||
? database.publicPort
|
||||
: privatePort}
|
||||
/>
|
||||
</div>
|
||||
{#if database.type === 'mysql'}
|
||||
<MySql bind:database />
|
||||
{:else if database.type === 'postgresql'}
|
||||
<PostgreSql bind:database />
|
||||
{:else if database.type === 'mongodb'}
|
||||
<MongoDb bind:database />
|
||||
{:else if database.type === 'mariadb'}
|
||||
<MariaDb bind:database />
|
||||
{:else if database.type === 'redis'}
|
||||
<Redis bind:database />
|
||||
{:else if database.type === 'couchdb'}
|
||||
<CouchDb {database} />
|
||||
{:else if database.type === 'edgedb'}
|
||||
<EdgeDB {database} />
|
||||
{/if}
|
||||
<div class="flex flex-col space-y-2 mt-5">
|
||||
<div class="grid gap-2 grid-cols-2 auto-rows-max lg:px-10 px-2">
|
||||
<label for="url"
|
||||
>Connection String
|
||||
{#if !$status.database.isPublic && database.destinationDocker.remoteEngine}
|
||||
<Explainer
|
||||
explanation="You can only access the database with this URL if your application is deployed to the same Destination."
|
||||
/>
|
||||
{/if}</label
|
||||
>
|
||||
<button class="btn btn-sm" on:click|preventDefault={generateUrl}
|
||||
>Show Connection String</button
|
||||
>
|
||||
</div>
|
||||
<div class="lg:px-10 px-2">
|
||||
{#if publicUrl}
|
||||
<CopyPasswordField
|
||||
placeholder="Click on the button to generate URL"
|
||||
id="url"
|
||||
name="url"
|
||||
readonly
|
||||
disabled
|
||||
value={loading.public ? 'Loading...' : publicUrl}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<div class="flex space-x-1 pb-5 font-bold">
|
||||
<h1 class="title">Features</h1>
|
||||
</div>
|
||||
<div class="grid gap-2 grid-cols-2 auto-rows-max lg:px-10 px-2">
|
||||
<Setting
|
||||
id="isPublic"
|
||||
loading={loading.public}
|
||||
bind:setting={$status.database.isPublic}
|
||||
on:click={() => changeSettings('isPublic')}
|
||||
title="Set it Public"
|
||||
description="Your database will be reachable over the internet. <br>Take security seriously in this case!"
|
||||
disabled={!$status.database.isRunning}
|
||||
/>
|
||||
{#if database.type === 'redis'}
|
||||
<Setting
|
||||
id="appendOnly"
|
||||
loading={loading.public}
|
||||
bind:setting={appendOnly}
|
||||
on:click={() => changeSettings('appendOnly')}
|
||||
title="Change append only mode"
|
||||
description="Useful if you would like to restore redis data from a backup.<br><span class=' text-white'>Database restart is required.</span>"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,50 @@
|
||||
<script lang="ts">
|
||||
export let database: any;
|
||||
import CopyPasswordField from '$lib/components/CopyPasswordField.svelte';
|
||||
import Explainer from '$lib/components/Explainer.svelte';
|
||||
</script>
|
||||
|
||||
<div class="flex space-x-1 py-5 font-bold">
|
||||
<div class="title">EdgeDB</div>
|
||||
</div>
|
||||
<div class="space-y-2 lg:px-10 px-2">
|
||||
<div class="grid grid-cols-2 items-center">
|
||||
<label for="defaultDatabase">Default Database</label>
|
||||
<CopyPasswordField
|
||||
required
|
||||
readonly={database.defaultDatabase}
|
||||
disabled={database.defaultDatabase}
|
||||
placeholder="Example: edgedb"
|
||||
id="defaultDatabase"
|
||||
name="defaultDatabase"
|
||||
bind:value={database.defaultDatabase}
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 items-center">
|
||||
<label for="rootUser">Root User</label>
|
||||
<CopyPasswordField
|
||||
readonly
|
||||
disabled
|
||||
placeholder="Generated automatically after start"
|
||||
id="rootUser"
|
||||
name="rootUser"
|
||||
value={database.rootUser}
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 items-center">
|
||||
<label for="rootUser"
|
||||
>Root Password <Explainer
|
||||
explanation="Could be changed while the database is running."
|
||||
/></label
|
||||
>
|
||||
<CopyPasswordField
|
||||
readonly
|
||||
disabled
|
||||
placeholder="Generated automatically after start"
|
||||
isPasswordField
|
||||
id="rootUserPassword"
|
||||
name="rootUserPassword"
|
||||
value={database.rootUserPassword}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,78 @@
|
||||
<script lang="ts">
|
||||
export let database: any;
|
||||
import { status } from '$lib/store';
|
||||
import CopyPasswordField from '$lib/components/CopyPasswordField.svelte';
|
||||
import Explainer from '$lib/components/Explainer.svelte';
|
||||
</script>
|
||||
|
||||
<div class="flex space-x-1 py-5 font-bold">
|
||||
<h1 class="title">MariaDB</h1>
|
||||
</div>
|
||||
<div class="space-y-2 lg:px-10 px-2">
|
||||
<div class="grid grid-cols-2 items-center">
|
||||
<label for="defaultDatabase"
|
||||
>Default Database</label
|
||||
>
|
||||
<CopyPasswordField
|
||||
required
|
||||
readonly={database.defaultDatabase}
|
||||
disabled={database.defaultDatabase}
|
||||
placeholder="Example: mydb"
|
||||
id="defaultDatabase"
|
||||
name="defaultDatabase"
|
||||
bind:value={database.defaultDatabase}
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 items-center">
|
||||
<label for="dbUser" >User</label>
|
||||
<CopyPasswordField
|
||||
readonly
|
||||
disabled
|
||||
placeholder="Generated automatically after start"
|
||||
id="dbUser"
|
||||
name="dbUser"
|
||||
value={database.dbUser}
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 items-center">
|
||||
<label for="dbUserPassword"
|
||||
>Password
|
||||
<Explainer explanation="Could be changed while the database is running." /></label
|
||||
>
|
||||
<CopyPasswordField
|
||||
disabled={!$status.database.isRunning}
|
||||
readonly={!$status.database.isRunning}
|
||||
placeholder="Generated automatically after start"
|
||||
isPasswordField
|
||||
id="dbUserPassword"
|
||||
name="dbUserPassword"
|
||||
bind:value={database.dbUserPassword}
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 items-center">
|
||||
<label for="rootUser" >Root User</label>
|
||||
<CopyPasswordField
|
||||
readonly
|
||||
disabled
|
||||
placeholder="Generated automatically after start"
|
||||
id="rootUser"
|
||||
name="rootUser"
|
||||
value={database.rootUser}
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 items-center">
|
||||
<label for="rootUserPassword"
|
||||
>Root Password
|
||||
<Explainer explanation="Could be changed while the database is running." /></label
|
||||
>
|
||||
<CopyPasswordField
|
||||
disabled={!$status.database.isRunning}
|
||||
readonly={!$status.database.isRunning}
|
||||
placeholder="Generated automatically after start"
|
||||
isPasswordField
|
||||
id="rootUserPassword"
|
||||
name="rootUserPassword"
|
||||
bind:value={database.rootUserPassword}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,38 @@
|
||||
<script lang="ts">
|
||||
export let database: any;
|
||||
import { status } from '$lib/store';
|
||||
import CopyPasswordField from '$lib/components/CopyPasswordField.svelte';
|
||||
import Explainer from '$lib/components/Explainer.svelte';
|
||||
</script>
|
||||
|
||||
<div class="flex space-x-1 py-5 font-bold">
|
||||
<h1 class="title">MongoDB</h1>
|
||||
</div>
|
||||
<div class="space-y-2 lg:px-10 px-2">
|
||||
<div class="grid grid-cols-2 items-center">
|
||||
<label for="rootUser">Root User</label>
|
||||
<CopyPasswordField
|
||||
placeholder="Generated automatically after start"
|
||||
id="rootUser"
|
||||
readonly
|
||||
disabled
|
||||
name="rootUser"
|
||||
value={database.rootUser}
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 items-center">
|
||||
<label for="rootUserPassword"
|
||||
>Root Password
|
||||
<Explainer explanation="Could be changed while the database is running." /></label
|
||||
>
|
||||
<CopyPasswordField
|
||||
disabled={!$status.database.isRunning}
|
||||
readonly={!$status.database.isRunning}
|
||||
placeholder="Generated automatically after start"
|
||||
isPasswordField={true}
|
||||
id="rootUserPassword"
|
||||
name="rootUserPassword"
|
||||
bind:value={database.rootUserPassword}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,76 @@
|
||||
<script lang="ts">
|
||||
export let database: any;
|
||||
import { status, appSession } from '$lib/store';
|
||||
import CopyPasswordField from '$lib/components/CopyPasswordField.svelte';
|
||||
import Explainer from '$lib/components/Explainer.svelte';
|
||||
</script>
|
||||
|
||||
<div class="flex space-x-1 py-5 font-bold">
|
||||
<h1 class="title">MySQL</h1>
|
||||
</div>
|
||||
<div class="space-y-2 lg:px-10 px-2">
|
||||
<div class="grid grid-cols-2 items-center">
|
||||
<label for="defaultDatabase">Default Database</label>
|
||||
<CopyPasswordField
|
||||
required
|
||||
readonly={database.defaultDatabase}
|
||||
disabled={database.defaultDatabase}
|
||||
placeholder="Example: mydb"
|
||||
id="defaultDatabase"
|
||||
name="defaultDatabase"
|
||||
bind:value={database.defaultDatabase}
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 items-center">
|
||||
<label for="dbUser">User</label>
|
||||
<CopyPasswordField
|
||||
readonly
|
||||
disabled
|
||||
placeholder="Generated automatically after start"
|
||||
id="dbUser"
|
||||
name="dbUser"
|
||||
value={database.dbUser}
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 items-center">
|
||||
<label for="dbUserPassword"
|
||||
>Password
|
||||
<Explainer explanation="Could be changed while the database is running." /></label
|
||||
>
|
||||
<CopyPasswordField
|
||||
disabled={!$status.database.isRunning}
|
||||
readonly={!$status.database.isRunning}
|
||||
placeholder="Generated automatically after start"
|
||||
isPasswordField
|
||||
id="dbUserPassword"
|
||||
name="dbUserPassword"
|
||||
bind:value={database.dbUserPassword}
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 items-center">
|
||||
<label for="rootUser">Root User</label>
|
||||
<CopyPasswordField
|
||||
readonly
|
||||
disabled
|
||||
placeholder="Generated automatically after start"
|
||||
id="rootUser"
|
||||
name="rootUser"
|
||||
value={$appSession.isARM ? 'root' : database.rootUser}
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 items-center">
|
||||
<label for="rootUserPassword"
|
||||
>Password
|
||||
<Explainer explanation="Could be changed while the database is running." /></label
|
||||
>
|
||||
<CopyPasswordField
|
||||
disabled={!$status.database.isRunning}
|
||||
readonly={!$status.database.isRunning}
|
||||
placeholder="Generated automatically after start"
|
||||
isPasswordField
|
||||
id="rootUserPassword"
|
||||
name="rootUserPassword"
|
||||
bind:value={database.rootUserPassword}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,68 @@
|
||||
<script lang="ts">
|
||||
export let database: any;
|
||||
import { status, appSession } from '$lib/store';
|
||||
import CopyPasswordField from '$lib/components/CopyPasswordField.svelte';
|
||||
import Explainer from '$lib/components/Explainer.svelte';
|
||||
</script>
|
||||
|
||||
<div class="flex space-x-1 py-5 font-bold">
|
||||
<h1 class="title">PostgreSQL</h1>
|
||||
</div>
|
||||
<div class="space-y-2 lg:px-10 px-2">
|
||||
<div class="grid grid-cols-2 items-center">
|
||||
<label for="defaultDatabase">Default Database</label>
|
||||
<CopyPasswordField
|
||||
required
|
||||
readonly={database.defaultDatabase}
|
||||
disabled={database.defaultDatabase}
|
||||
placeholder="Example: mydb"
|
||||
id="defaultDatabase"
|
||||
name="defaultDatabase"
|
||||
bind:value={database.defaultDatabase}
|
||||
/>
|
||||
</div>
|
||||
{#if !$appSession.isARM}
|
||||
<div class="grid grid-cols-2 items-center">
|
||||
<label for="rootUser"
|
||||
>Postgres User Password <Explainer
|
||||
explanation="Could be changed while the database is running."
|
||||
/></label
|
||||
>
|
||||
<CopyPasswordField
|
||||
disabled={!$status.database.isRunning}
|
||||
readonly={!$status.database.isRunning}
|
||||
placeholder="Generated automatically after start"
|
||||
isPasswordField
|
||||
id="rootUserPassword"
|
||||
name="rootUserPassword"
|
||||
bind:value={database.rootUserPassword}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="grid grid-cols-2 items-center">
|
||||
<label for="dbUser">User</label>
|
||||
<CopyPasswordField
|
||||
readonly
|
||||
disabled
|
||||
placeholder="Generated automatically after start"
|
||||
id="dbUser"
|
||||
name="dbUser"
|
||||
value={database.dbUser}
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 items-center">
|
||||
<label for="dbUserPassword"
|
||||
>Password
|
||||
<Explainer explanation="Could be changed while the database is running." /></label
|
||||
>
|
||||
<CopyPasswordField
|
||||
disabled={!$status.database.isRunning}
|
||||
readonly={!$status.database.isRunning}
|
||||
placeholder="Generated automatically after start"
|
||||
isPasswordField
|
||||
id="dbUserPassword"
|
||||
name="dbUserPassword"
|
||||
bind:value={database.dbUserPassword}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,27 @@
|
||||
<script lang="ts">
|
||||
export let database: any;
|
||||
import { status } from '$lib/store';
|
||||
import CopyPasswordField from '$lib/components/CopyPasswordField.svelte';
|
||||
import Explainer from '$lib/components/Explainer.svelte';
|
||||
</script>
|
||||
|
||||
<div class="flex space-x-1 py-5 font-bold">
|
||||
<h1 class="title">Redis</h1>
|
||||
</div>
|
||||
<div class="space-y-2 lg:px-10 px-2">
|
||||
<div class="grid grid-cols-2 items-center">
|
||||
<label for="dbUserPassword"
|
||||
>Password
|
||||
<Explainer explanation="Could be changed while the database is running." /></label
|
||||
>
|
||||
<CopyPasswordField
|
||||
disabled={!$status.database.isRunning}
|
||||
readonly={!$status.database.isRunning}
|
||||
placeholder="Generated automatically after start"
|
||||
isPasswordField
|
||||
id="dbUserPassword"
|
||||
name="dbUserPassword"
|
||||
bind:value={database.dbUserPassword}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,37 @@
|
||||
import { errorNotification } from '$lib/common';
|
||||
import { trpc } from '$lib/store';
|
||||
|
||||
type Props = {
|
||||
isNew: boolean;
|
||||
name: string;
|
||||
value: string;
|
||||
isBuildSecret?: boolean;
|
||||
isPRMRSecret?: boolean;
|
||||
isNewSecret?: boolean;
|
||||
databaseId: string;
|
||||
};
|
||||
|
||||
export async function saveSecret({
|
||||
isNew,
|
||||
name,
|
||||
value,
|
||||
isNewSecret,
|
||||
databaseId
|
||||
}: Props): Promise<void> {
|
||||
if (!name) return errorNotification('Name is required');
|
||||
if (!value) return errorNotification('Value is required');
|
||||
try {
|
||||
await trpc.databases.saveSecret.mutate({
|
||||
name,
|
||||
value,
|
||||
isNew: isNew || false
|
||||
});
|
||||
|
||||
if (isNewSecret) {
|
||||
name = '';
|
||||
value = '';
|
||||
}
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
<script lang="ts">
|
||||
import type { LayoutData } from './$types';
|
||||
export let data: LayoutData;
|
||||
let destination = data.destination.destination;
|
||||
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import { errorNotification } from '$lib/common';
|
||||
import { appSession, trpc } from '$lib/store';
|
||||
import * as Icons from '$lib/components/icons';
|
||||
import Tooltip from '$lib/components/Tooltip.svelte';
|
||||
|
||||
const isDestinationDeletable =
|
||||
(destination?.application.length === 0 &&
|
||||
destination?.database.length === 0 &&
|
||||
destination?.service.length === 0) ||
|
||||
true;
|
||||
|
||||
async function deleteDestination(destination: any) {
|
||||
if (!isDestinationDeletable) return;
|
||||
const sure = confirm("Are you sure you want to delete this destination? This can't be undone.");
|
||||
if (sure) {
|
||||
try {
|
||||
await trpc.destinations.delete.mutate({ id: destination.id });
|
||||
return await goto('/', { replaceState: true });
|
||||
} catch (error) {
|
||||
return errorNotification(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
function deletable() {
|
||||
if (!isDestinationDeletable) {
|
||||
return 'Please delete all resources before deleting this.';
|
||||
}
|
||||
if ($appSession.isAdmin) {
|
||||
return "Delete this destination. This can't be undone.";
|
||||
} else {
|
||||
return "You don't have permission to delete this destination.";
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if $page.params.id !== 'new'}
|
||||
<nav class="header lg:flex-row flex-col-reverse">
|
||||
<div class="flex flex-row space-x-2 font-bold pt-10 lg:pt-0">
|
||||
<div class="flex flex-col items-center justify-center title">
|
||||
{#if $page.url.pathname === `/destinations/${$page.params.id}`}
|
||||
Configurations
|
||||
{:else if $page.url.pathname.startsWith(`/destinations/${$page.params.id}/configuration/sshkey`)}
|
||||
Select a SSH Key
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="lg:block hidden flex-1" />
|
||||
<div class="flex flex-row flex-wrap space-x-3 justify-center lg:justify-start lg:py-0">
|
||||
<button
|
||||
id="delete"
|
||||
on:click={() => deleteDestination(destination)}
|
||||
type="submit"
|
||||
disabled={!$appSession.isAdmin && isDestinationDeletable}
|
||||
class:hover:text-red-500={$appSession.isAdmin && isDestinationDeletable}
|
||||
class="icons bg-transparent text-sm"
|
||||
class:text-stone-600={!isDestinationDeletable}><Icons.Delete /></button
|
||||
>
|
||||
<Tooltip triggeredBy="#delete">{deletable()}</Tooltip>
|
||||
</div>
|
||||
</nav>
|
||||
{/if}
|
||||
<slot />
|
||||
@@ -0,0 +1,45 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { trpc } from '$lib/store';
|
||||
import type { LayoutLoad } from './$types';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
|
||||
function checkConfiguration(destination: any): string | null {
|
||||
let configurationPhase = null;
|
||||
if (!destination?.remoteEngine) return configurationPhase;
|
||||
if (!destination?.sshKey) {
|
||||
configurationPhase = 'sshkey';
|
||||
}
|
||||
return configurationPhase;
|
||||
}
|
||||
|
||||
export const load: LayoutLoad = async ({ params, url }) => {
|
||||
const { pathname } = new URL(url);
|
||||
const { id } = params;
|
||||
try {
|
||||
const destination = await trpc.destinations.getDestinationById.query({ id });
|
||||
if (!destination) {
|
||||
throw redirect(307, '/destinations');
|
||||
}
|
||||
const configurationPhase = checkConfiguration(destination);
|
||||
console.log({ configurationPhase });
|
||||
// if (
|
||||
// configurationPhase &&
|
||||
// pathname !== `/applications/${params.id}/configuration/${configurationPhase}`
|
||||
// ) {
|
||||
// throw redirect(302, `/applications/${params.id}/configuration/${configurationPhase}`);
|
||||
// }
|
||||
return {
|
||||
destination
|
||||
};
|
||||
} 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.'
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
<script lang="ts">
|
||||
import type { LayoutData } from './$types';
|
||||
|
||||
export let data: LayoutData;
|
||||
let destination = data.destination.destination;
|
||||
let settings = data.destination.settings;
|
||||
|
||||
import { page } from '$app/stores';
|
||||
import New from './components/New.svelte';
|
||||
import Destination from './components/Destination.svelte';
|
||||
const { id } = $page.params;
|
||||
</script>
|
||||
|
||||
{#if id === 'new'}
|
||||
<New />
|
||||
{:else}
|
||||
<Destination bind:destination bind:settings />
|
||||
{/if}
|
||||
@@ -0,0 +1,14 @@
|
||||
<script lang="ts">
|
||||
export let destination: any;
|
||||
export let settings: any;
|
||||
import LocalDocker from './LocalDocker.svelte';
|
||||
import RemoteDocker from './RemoteDocker.svelte';
|
||||
</script>
|
||||
|
||||
<div class="mx-auto max-w-6xl px-6">
|
||||
{#if destination.remoteEngine}
|
||||
<RemoteDocker bind:destination {settings} />
|
||||
{:else}
|
||||
<LocalDocker bind:destination {settings} />
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,212 @@
|
||||
<script lang="ts">
|
||||
export let destination: any;
|
||||
export let settings: any;
|
||||
|
||||
import { page } from '$app/stores';
|
||||
import CopyPasswordField from '$lib/components/CopyPasswordField.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import { errorNotification } from '$lib/common';
|
||||
import { addToast, appSession, trpc } from '$lib/store';
|
||||
import Setting from '$lib/components/Setting.svelte';
|
||||
|
||||
const { id } = $page.params;
|
||||
let cannotDisable = settings.fqdn && destination.engine === '/var/run/docker.sock';
|
||||
let loading = {
|
||||
restart: false,
|
||||
proxy: false,
|
||||
save: false
|
||||
};
|
||||
|
||||
async function handleSubmit() {
|
||||
loading.save = true;
|
||||
try {
|
||||
await trpc.destinations.save.mutate({ ...destination });
|
||||
addToast({
|
||||
message: 'Configuration saved.',
|
||||
type: 'success'
|
||||
});
|
||||
} catch (error) {
|
||||
return errorNotification(error);
|
||||
} finally {
|
||||
loading.save = false;
|
||||
}
|
||||
}
|
||||
onMount(async () => {
|
||||
loading.proxy = true;
|
||||
const { isRunning } = await trpc.destinations.status.query({ id });
|
||||
let proxyUsed = !destination.isCoolifyProxyUsed;
|
||||
if (isRunning === false && destination.isCoolifyProxyUsed === true) {
|
||||
try {
|
||||
await trpc.destinations.saveSettings.mutate({
|
||||
id,
|
||||
isCoolifyProxyUsed: proxyUsed,
|
||||
engine: destination.engine
|
||||
});
|
||||
|
||||
await stopProxy();
|
||||
} catch (error) {
|
||||
return errorNotification(error);
|
||||
}
|
||||
} else if (isRunning === true && destination.isCoolifyProxyUsed === false) {
|
||||
try {
|
||||
await trpc.destinations.saveSettings.mutate({
|
||||
id,
|
||||
isCoolifyProxyUsed: proxyUsed,
|
||||
engine: destination.engine
|
||||
});
|
||||
await startProxy();
|
||||
destination.isCoolifyProxyUsed = proxyUsed;
|
||||
} catch (error) {
|
||||
return errorNotification(error);
|
||||
} finally {
|
||||
loading.proxy = false;
|
||||
}
|
||||
}
|
||||
loading.proxy = false;
|
||||
});
|
||||
async function changeProxySetting() {
|
||||
if (!cannotDisable) {
|
||||
const isProxyActivated = destination.isCoolifyProxyUsed;
|
||||
if (isProxyActivated) {
|
||||
const sure = confirm(
|
||||
`Are you sure you want to ${
|
||||
destination.isCoolifyProxyUsed ? 'disable' : 'enable'
|
||||
} Coolify proxy? It will remove the proxy for all configured networks and all deployments on '${
|
||||
destination.engine
|
||||
}'! Nothing will be reachable if you do it!`
|
||||
);
|
||||
if (!sure) return;
|
||||
}
|
||||
destination.isCoolifyProxyUsed = !destination.isCoolifyProxyUsed;
|
||||
try {
|
||||
loading.proxy = true;
|
||||
await trpc.destinations.saveSettings.mutate({
|
||||
id,
|
||||
isCoolifyProxyUsed: destination.isCoolifyProxyUsed,
|
||||
engine: destination.engine
|
||||
});
|
||||
|
||||
if (isProxyActivated) {
|
||||
await stopProxy();
|
||||
} else {
|
||||
await startProxy();
|
||||
}
|
||||
} catch (error) {
|
||||
return errorNotification(error);
|
||||
} finally {
|
||||
loading.proxy = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
async function stopProxy() {
|
||||
try {
|
||||
await trpc.destinations.stopProxy.mutate({ id });
|
||||
return addToast({
|
||||
message: 'Coolify proxy stopped.',
|
||||
type: 'success'
|
||||
});
|
||||
} catch (error) {
|
||||
return errorNotification(error);
|
||||
}
|
||||
}
|
||||
async function startProxy() {
|
||||
try {
|
||||
await trpc.destinations.startProxy.mutate({ id });
|
||||
return addToast({
|
||||
message: ' Coolify proxy started.',
|
||||
type: 'success'
|
||||
});
|
||||
} catch (error) {
|
||||
return errorNotification(error);
|
||||
}
|
||||
}
|
||||
async function forceRestartProxy() {
|
||||
const sure = confirm(
|
||||
"Are you sure you want to restart the proxy? It will remove the proxy for all configured networks and all deployments on '" +
|
||||
destination.engine +
|
||||
"'! Nothing will be reachable if you do it!"
|
||||
);
|
||||
if (sure) {
|
||||
try {
|
||||
loading.restart = true;
|
||||
addToast({
|
||||
message: 'Restarting proxy...',
|
||||
type: 'success'
|
||||
});
|
||||
await trpc.destinations.restartProxy.mutate({
|
||||
id
|
||||
});
|
||||
} catch (error) {
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 5000);
|
||||
} finally {
|
||||
loading.restart = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<form on:submit|preventDefault={handleSubmit} class="py-4">
|
||||
<div class="flex space-x-2">
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-sm"
|
||||
class:bg-destinations={!loading.save}
|
||||
class:loading={loading.save}
|
||||
disabled={loading.save}
|
||||
>Save
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-sm"
|
||||
class:loading={loading.restart}
|
||||
class:bg-error={!loading.restart}
|
||||
disabled={loading.restart}
|
||||
on:click|preventDefault={forceRestartProxy}>Force restart proxy</button
|
||||
>
|
||||
</div>
|
||||
<div class="grid gap-2 grid-cols-2 auto-rows-max mt-10 items-center">
|
||||
<label for="name">Name</label>
|
||||
<input
|
||||
class="w-full"
|
||||
name="name"
|
||||
placeholder="Name"
|
||||
disabled={!$appSession.isAdmin}
|
||||
readonly={!$appSession.isAdmin}
|
||||
bind:value={destination.name}
|
||||
/>
|
||||
<label for="engine">Engine</label>
|
||||
<CopyPasswordField
|
||||
id="engine"
|
||||
readonly
|
||||
disabled
|
||||
name="engine"
|
||||
placeholder="Example: /var/run/docker.sock"
|
||||
value={destination.engine}
|
||||
/>
|
||||
<label for="network">Netwokr</label>
|
||||
<CopyPasswordField
|
||||
id="network"
|
||||
readonly
|
||||
disabled
|
||||
name="network"
|
||||
placeholder="Default: coolify"
|
||||
value={destination.network}
|
||||
/>
|
||||
{#if $appSession.teamId === '0'}
|
||||
<Setting
|
||||
id="changeProxySetting"
|
||||
loading={loading.proxy}
|
||||
disabled={cannotDisable}
|
||||
bind:setting={destination.isCoolifyProxyUsed}
|
||||
on:click={changeProxySetting}
|
||||
title="Use Coolify Proxy?"
|
||||
description={`This will install a proxy on the destination to allow you to access your applications and services without any manual configuration.${
|
||||
cannotDisable
|
||||
? '<span class="font-bold text-white">You cannot disable this proxy as FQDN is configured for Coolify.</span>'
|
||||
: ''
|
||||
}`}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</form>
|
||||
@@ -0,0 +1,53 @@
|
||||
<script lang="ts">
|
||||
import cuid from 'cuid';
|
||||
import NewLocalDocker from './NewLocalDocker.svelte';
|
||||
import NewRemoteDocker from './NewRemoteDocker.svelte';
|
||||
let payload = {};
|
||||
let selected = 'localDocker';
|
||||
function setPredefined(type: any) {
|
||||
selected = type;
|
||||
switch (type) {
|
||||
case 'localDocker':
|
||||
payload = {
|
||||
name: 'Local Docker',
|
||||
engine: '/var/run/docker.sock',
|
||||
remoteEngine: false,
|
||||
network: cuid(),
|
||||
isCoolifyProxyUsed: true
|
||||
};
|
||||
break;
|
||||
case 'remoteDocker':
|
||||
payload = {
|
||||
name: 'Remote Docker',
|
||||
remoteEngine: true,
|
||||
remoteIpAddress: null,
|
||||
remoteUser: 'root',
|
||||
remotePort: 22,
|
||||
network: cuid(),
|
||||
isCoolifyProxyUsed: true
|
||||
};
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex space-x-1 p-6 font-bold">
|
||||
<div class="mr-4 text-2xl tracking-tight">Add New Destination</div>
|
||||
</div>
|
||||
<div class="flex-col space-y-2 pb-10 text-center">
|
||||
<div class="text-xl font-bold text-white">Predefined destinations</div>
|
||||
<div class="flex justify-center space-x-2">
|
||||
<button class="btn btn-sm" on:click={() => setPredefined('localDocker')}>Local Docker</button>
|
||||
<button class="btn btn-sm" on:click={() => setPredefined('remoteDocker')}>Remote Docker</button>
|
||||
<!-- <button class="w-32" on:click={() => setPredefined('kubernetes')}>Kubernetes</button> -->
|
||||
</div>
|
||||
</div>
|
||||
{#if selected === 'localDocker'}
|
||||
<NewLocalDocker {payload} />
|
||||
{:else if selected === 'remoteDocker'}
|
||||
<NewRemoteDocker {payload} />
|
||||
{:else}
|
||||
<div class="text-center font-bold text-4xl py-10">Not implemented yet</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,72 @@
|
||||
<script lang="ts">
|
||||
export let payload: any;
|
||||
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import { errorNotification } from '$lib/common';
|
||||
import Setting from '$lib/components/Setting.svelte';
|
||||
import { appSession, trpc } from '$lib/store';
|
||||
|
||||
const from = $page.url.searchParams.get('from');
|
||||
let loading = false;
|
||||
|
||||
async function handleSubmit() {
|
||||
if (loading) return;
|
||||
try {
|
||||
loading = true;
|
||||
await trpc.destinations.check.query({ network: payload.network });
|
||||
const { id } = await trpc.destinations.save.mutate({ id: 'new', ...payload });
|
||||
return await goto(from || `/destinations/${id}`);
|
||||
} catch (error) {
|
||||
return errorNotification(error);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex justify-center px-6 pb-8">
|
||||
<form on:submit|preventDefault={handleSubmit} class="grid grid-flow-row gap-2 py-4">
|
||||
<div
|
||||
class="flex items-start lg:items-center space-x-0 lg:space-x-4 pb-5 flex-col lg:flex-row space-y-4 lg:space-y-0"
|
||||
>
|
||||
<div class="title font-bold">Configuration</div>
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-sm bg-destinations w-full lg:w-fit"
|
||||
class:loading
|
||||
disabled={loading}
|
||||
>{loading ? (payload.isCoolifyProxyUsed ? 'Saving...' : 'Saving...') : 'Save'}</button
|
||||
>
|
||||
</div>
|
||||
<div class="mt-2 grid grid-cols-2 items-center lg:pl-10">
|
||||
<label for="name" class="text-base font-bold text-stone-100">Name</label>
|
||||
<input required name="name" placeholder="Name" bind:value={payload.name} />
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 items-center lg:pl-10">
|
||||
<label for="engine" class="text-base font-bold text-stone-100">Engine</label>
|
||||
<input
|
||||
required
|
||||
name="engine"
|
||||
placeholder="Example: /var/run/docker.sock"
|
||||
bind:value={payload.engine}
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 items-center lg:pl-10">
|
||||
<label for="network" class="text-base font-bold text-stone-100">Network</label>
|
||||
<input required name="network" placeholder="Default: coolify" bind:value={payload.network} />
|
||||
</div>
|
||||
{#if $appSession.teamId === '0'}
|
||||
<div class="grid grid-cols-2 items-center lg:pl-10">
|
||||
<Setting
|
||||
id="changeProxySetting"
|
||||
bind:setting={payload.isCoolifyProxyUsed}
|
||||
on:click={() => (payload.isCoolifyProxyUsed = !payload.isCoolifyProxyUsed)}
|
||||
title="Use Coolify Proxy?"
|
||||
description={'This will install a proxy on the destination to allow you to access your applications and services without any manual configuration.'}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</form>
|
||||
</div>
|
||||
@@ -0,0 +1,104 @@
|
||||
<script lang="ts">
|
||||
export let payload: any;
|
||||
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import { errorNotification } from '$lib/common';
|
||||
import SimpleExplainer from '$lib/components/SimpleExplainer.svelte';
|
||||
import Setting from '$lib/components/Setting.svelte';
|
||||
import { trpc } from '$lib/store';
|
||||
|
||||
const from = $page.url.searchParams.get('from');
|
||||
let loading = false;
|
||||
|
||||
async function handleSubmit() {
|
||||
if (loading) return;
|
||||
try {
|
||||
loading = true;
|
||||
await trpc.destinations.check.query({ network: payload.network });
|
||||
const { id } = await trpc.destinations.save.mutate({ id: 'new', ...payload });
|
||||
return await goto(from || `/destinations/${id}`);
|
||||
} catch (error) {
|
||||
return errorNotification(error);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="text-center flex justify-center">
|
||||
<SimpleExplainer
|
||||
customClass="max-w-[32rem]"
|
||||
text="Remote Docker Engines are using <span class='text-white font-bold'>SSH</span> to communicate with the remote docker engine.
|
||||
You need to setup an <span class='text-white font-bold'>SSH key</span> in advance on the server and install Docker.
|
||||
<br>See <a class='text-white' href='https://docs.coollabs.io/coolify/destinations#remote-docker-engine' target='blank'>docs</a> for more details."
|
||||
/>
|
||||
</div>
|
||||
<div class="flex justify-center px-6 pb-8">
|
||||
<form on:submit|preventDefault={handleSubmit} class="grid grid-flow-row gap-2 py-4">
|
||||
<div class="flex items-start lg:items-center space-x-0 lg:space-x-4 pb-5 flex-col lg:flex-row space-y-4 lg:space-y-0">
|
||||
<div class="title font-bold">Configuration</div>
|
||||
<button type="submit" class="btn btn-sm bg-destinations w-full lg:w-fit" class:loading disabled={loading}
|
||||
>{loading
|
||||
? payload.isCoolifyProxyUsed
|
||||
? 'Saving...'
|
||||
: 'Saving...'
|
||||
: "Save"}</button
|
||||
>
|
||||
</div>
|
||||
<div class="mt-2 grid grid-cols-2 items-center lg:pl-10">
|
||||
<label for="name" class="text-base font-bold text-stone-100">Name</label>
|
||||
<input required name="name" placeholder="Name" bind:value={payload.name} />
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 items-center lg:pl-10">
|
||||
<label for="remoteIpAddress" class="text-base font-bold text-stone-100"
|
||||
>IP Address</label
|
||||
>
|
||||
<input
|
||||
required
|
||||
name="remoteIpAddress"
|
||||
placeholder="Example: 192.168..."
|
||||
bind:value={payload.remoteIpAddress}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 items-center lg:pl-10">
|
||||
<label for="remoteUser" class="text-base font-bold text-stone-100">User</label>
|
||||
<input
|
||||
required
|
||||
name="remoteUser"
|
||||
placeholder="Example: root"
|
||||
bind:value={payload.remoteUser}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 items-center lg:pl-10">
|
||||
<label for="remotePort" class="text-base font-bold text-stone-100">Port</label>
|
||||
<input
|
||||
required
|
||||
name="remotePort"
|
||||
placeholder="Example: 22"
|
||||
bind:value={payload.remotePort}
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 items-center lg:pl-10">
|
||||
<label for="network" class="text-base font-bold text-stone-100">Network</label>
|
||||
<input
|
||||
required
|
||||
name="network"
|
||||
placeholder="Default: coolify"
|
||||
bind:value={payload.network}
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 items-center lg:pl-10">
|
||||
<Setting
|
||||
id="isCoolifyProxyUsed"
|
||||
bind:setting={payload.isCoolifyProxyUsed}
|
||||
on:click={() => (payload.isCoolifyProxyUsed = !payload.isCoolifyProxyUsed)}
|
||||
title="Use Coolify Proxy?"
|
||||
description={'This will install a proxy on the destination to allow you to access your applications and services without any manual configuration.'}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -0,0 +1,279 @@
|
||||
<script lang="ts">
|
||||
export let destination: any;
|
||||
export let settings: any;
|
||||
|
||||
import { page } from '$app/stores';
|
||||
import Setting from '$lib/components/Setting.svelte';
|
||||
import { get, post } from '$lib/api';
|
||||
import CopyPasswordField from '$lib/components/CopyPasswordField.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import { t } from '$lib/translations';
|
||||
import { errorNotification } from '$lib/common';
|
||||
import { addToast, appSession } from '$lib/store';
|
||||
|
||||
const { id } = $page.params;
|
||||
|
||||
let cannotDisable = settings.fqdn && destination.engine === '/var/run/docker.sock';
|
||||
let loading = {
|
||||
restart: false,
|
||||
proxy: true,
|
||||
save: false,
|
||||
verify: false
|
||||
};
|
||||
|
||||
$: isDisabled = !$appSession.isAdmin;
|
||||
|
||||
async function handleSubmit() {
|
||||
loading.save = true;
|
||||
try {
|
||||
await post(`/destinations/${id}`, { ...destination });
|
||||
addToast({
|
||||
message: 'Configuration saved.',
|
||||
type: 'success'
|
||||
});
|
||||
} catch (error) {
|
||||
return errorNotification(error);
|
||||
} finally {
|
||||
loading.save = false;
|
||||
}
|
||||
}
|
||||
onMount(async () => {
|
||||
if (destination.remoteEngine && destination.remoteVerified) {
|
||||
loading.proxy = true;
|
||||
const { isRunning } = await get(`/destinations/${id}/status`);
|
||||
if (isRunning === false && destination.isCoolifyProxyUsed === true) {
|
||||
destination.isCoolifyProxyUsed = !destination.isCoolifyProxyUsed;
|
||||
try {
|
||||
await post(`/destinations/${id}/settings`, {
|
||||
isCoolifyProxyUsed: destination.isCoolifyProxyUsed,
|
||||
engine: destination.engine
|
||||
});
|
||||
await stopProxy();
|
||||
} catch (error) {
|
||||
return errorNotification(error);
|
||||
}
|
||||
} else if (isRunning === true && destination.isCoolifyProxyUsed === false) {
|
||||
destination.isCoolifyProxyUsed = !destination.isCoolifyProxyUsed;
|
||||
try {
|
||||
await post(`/destinations/${id}/settings`, {
|
||||
isCoolifyProxyUsed: destination.isCoolifyProxyUsed,
|
||||
engine: destination.engine
|
||||
});
|
||||
await startProxy();
|
||||
} catch (error) {
|
||||
return errorNotification(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loading.proxy = false;
|
||||
});
|
||||
async function changeProxySetting() {
|
||||
if (!destination.remoteVerified) return;
|
||||
loading.proxy = true;
|
||||
if (!cannotDisable) {
|
||||
const isProxyActivated = destination.isCoolifyProxyUsed;
|
||||
if (isProxyActivated) {
|
||||
const sure = confirm(
|
||||
`Are you sure you want to ${
|
||||
destination.isCoolifyProxyUsed ? 'disable' : 'enable'
|
||||
} Coolify proxy? It will remove the proxy for all configured networks and all deployments! Nothing will be reachable if you do it!`
|
||||
);
|
||||
if (!sure) {
|
||||
loading.proxy = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
let proxyUsed = !destination.isCoolifyProxyUsed;
|
||||
try {
|
||||
await post(`/destinations/${id}/settings`, {
|
||||
isCoolifyProxyUsed: proxyUsed,
|
||||
engine: destination.engine
|
||||
});
|
||||
if (isProxyActivated) {
|
||||
await stopProxy();
|
||||
} else {
|
||||
await startProxy();
|
||||
}
|
||||
destination.isCoolifyProxyUsed = proxyUsed;
|
||||
} catch (error) {
|
||||
return errorNotification(error);
|
||||
} finally {
|
||||
loading.proxy = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
async function stopProxy() {
|
||||
try {
|
||||
await post(`/destinations/${id}/stop`, { engine: destination.engine });
|
||||
return addToast({
|
||||
message: $t('destination.coolify_proxy_stopped'),
|
||||
type: 'success'
|
||||
});
|
||||
} catch (error) {
|
||||
return errorNotification(error);
|
||||
}
|
||||
}
|
||||
async function startProxy() {
|
||||
try {
|
||||
await post(`/destinations/${id}/start`, { engine: destination.engine });
|
||||
return addToast({
|
||||
message: $t('destination.coolify_proxy_started'),
|
||||
type: 'success'
|
||||
});
|
||||
} catch (error) {
|
||||
return errorNotification(error);
|
||||
}
|
||||
}
|
||||
async function forceRestartProxy() {
|
||||
const sure = confirm($t('destination.confirm_restart_proxy'));
|
||||
if (sure) {
|
||||
try {
|
||||
loading.restart = true;
|
||||
addToast({
|
||||
message: $t('destination.coolify_proxy_restarting'),
|
||||
type: 'success'
|
||||
});
|
||||
await post(`/destinations/${id}/restart`, {
|
||||
engine: destination.engine,
|
||||
fqdn: settings.fqdn
|
||||
});
|
||||
} catch (error) {
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 5000);
|
||||
} finally {
|
||||
loading.restart = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
async function verifyRemoteDocker() {
|
||||
try {
|
||||
loading.verify = true;
|
||||
await post(`/destinations/${id}/verify`, {});
|
||||
destination.remoteVerified = true;
|
||||
return addToast({
|
||||
message: 'Remote Docker Engine verified!',
|
||||
type: 'success'
|
||||
});
|
||||
} catch (error) {
|
||||
return errorNotification(error);
|
||||
} finally {
|
||||
loading.verify = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<form on:submit|preventDefault={handleSubmit} class="grid grid-flow-row gap-2 py-4">
|
||||
<div class="flex space-x-1 pb-5">
|
||||
{#if $appSession.isAdmin}
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-sm"
|
||||
class:loading={loading.save}
|
||||
class:bg-destinations={!loading.save}
|
||||
disabled={loading.save}
|
||||
>{$t('forms.save')}
|
||||
</button>
|
||||
<button
|
||||
disabled={loading.verify}
|
||||
class="btn btn-sm"
|
||||
class:loading={loading.verify}
|
||||
on:click|preventDefault|stopPropagation={verifyRemoteDocker}
|
||||
>{!destination.remoteVerified
|
||||
? 'Verify Remote Docker Engine'
|
||||
: 'Check Remote Docker Engine'}</button
|
||||
>
|
||||
{#if destination.remoteVerified}
|
||||
<button
|
||||
class="btn btn-sm"
|
||||
class:loading={loading.restart}
|
||||
class:bg-error={!loading.restart}
|
||||
disabled={loading.restart}
|
||||
on:click|preventDefault={forceRestartProxy}
|
||||
>{$t('destination.force_restart_proxy')}</button
|
||||
>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
<div class="grid grid-cols-2 items-center px-10 ">
|
||||
<label for="name">{$t('forms.name')}</label>
|
||||
<input
|
||||
name="name"
|
||||
class="w-full"
|
||||
placeholder={$t('forms.name')}
|
||||
disabled={!$appSession.isAdmin}
|
||||
readonly={!$appSession.isAdmin}
|
||||
bind:value={destination.name}
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 items-center px-10">
|
||||
<label for="network">{$t('forms.network')}</label>
|
||||
<CopyPasswordField
|
||||
id="network"
|
||||
readonly
|
||||
disabled
|
||||
name="network"
|
||||
placeholder="{$t('forms.default')}: coolify"
|
||||
value={destination.network}
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 items-center px-10">
|
||||
<label for="remoteIpAddress">IP Address</label>
|
||||
<CopyPasswordField
|
||||
id="remoteIpAddress"
|
||||
readonly
|
||||
disabled
|
||||
name="remoteIpAddress"
|
||||
value={destination.remoteIpAddress}
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 items-center px-10">
|
||||
<label for="remoteUser">User</label>
|
||||
<CopyPasswordField
|
||||
id="remoteUser"
|
||||
readonly
|
||||
disabled
|
||||
name="remoteUser"
|
||||
value={destination.remoteUser}
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 items-center px-10">
|
||||
<label for="remotePort">Port</label>
|
||||
<CopyPasswordField
|
||||
id="remotePort"
|
||||
readonly
|
||||
disabled
|
||||
name="remotePort"
|
||||
value={destination.remotePort}
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 items-center px-10">
|
||||
<label for="sshKey">SSH Key</label>
|
||||
<a
|
||||
href={!isDisabled ? `/destinations/${id}/configuration/sshkey?from=/destinations/${id}` : ''}
|
||||
class="no-underline"
|
||||
><input
|
||||
value={destination.sshKey.name}
|
||||
readonly
|
||||
id="sshKey"
|
||||
class="cursor-pointer w-full"
|
||||
/></a
|
||||
>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 items-center px-10">
|
||||
<Setting
|
||||
id="changeProxySetting"
|
||||
disabled={cannotDisable || !destination.remoteVerified}
|
||||
loading={loading.proxy}
|
||||
bind:setting={destination.isCoolifyProxyUsed}
|
||||
on:click={changeProxySetting}
|
||||
title={$t('destination.use_coolify_proxy')}
|
||||
description={`Install & configure a proxy (based on Traefik) on the destination to allow you to access your applications and services without any manual configuration.${
|
||||
cannotDisable
|
||||
? '<span class="font-bold text-white">You cannot disable this proxy as FQDN is configured for Coolify.</span>'
|
||||
: ''
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
123
apps/trpc-experimental/client/src/routes/login/+page.svelte
Normal file
123
apps/trpc-experimental/client/src/routes/login/+page.svelte
Normal file
@@ -0,0 +1,123 @@
|
||||
<script lang="ts">
|
||||
import { browser } from '$app/environment';
|
||||
import Cookies from 'js-cookie';
|
||||
import { goto } from '$app/navigation';
|
||||
import { errorNotification } from '$lib/common';
|
||||
import { appSession, loginEmail, trpc } from '$lib/store';
|
||||
import { onMount } from 'svelte';
|
||||
let loading = false;
|
||||
let emailEl: HTMLInputElement;
|
||||
let email: string, password: string;
|
||||
|
||||
onMount(async () => {
|
||||
if ($appSession.userId) {
|
||||
return await goto('/');
|
||||
}
|
||||
emailEl.focus();
|
||||
});
|
||||
async function handleSubmit() {
|
||||
loading = true;
|
||||
try {
|
||||
const { token } = await trpc.auth.login.mutate({ email, password });
|
||||
Cookies.set('token', token, {
|
||||
path: '/'
|
||||
});
|
||||
return window.location.assign('/');
|
||||
} catch (error) {
|
||||
return errorNotification(error);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
async function gotoRegister() {
|
||||
$loginEmail = email?.toLowerCase();
|
||||
return await goto('/register');
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelt:head>
|
||||
<title>Login</title>
|
||||
</svelt:head>
|
||||
|
||||
<div class="flex lg:flex-row flex-col h-screen">
|
||||
<div class="bg-neutral-focus h-screen lg:flex hidden flex-col justify-end p-20 flex-1">
|
||||
<h1 class="title lg:text-6xl mb-5 border-gradient">Coolify</h1>
|
||||
<h3 class="title">Made self-hosting simple.</h3>
|
||||
</div>
|
||||
<div class="flex flex-1 flex-col lg:max-w-2xl">
|
||||
<div class="flex flex-row p-8 items-center space-x-3">
|
||||
{#if $appSession.whiteLabeledDetails.icon}
|
||||
<div class="avatar" style="width: 40px; height: 40px">
|
||||
<img
|
||||
src={$appSession.whiteLabeledDetails.icon}
|
||||
alt="Icon for white labeled version of Coolify"
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<div>
|
||||
<div class="avatar" style="width: 40px; height: 40px">
|
||||
<img src="favicon.png" alt="Coolify icon" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="prose">
|
||||
<h4>Coolify</h4>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div
|
||||
class="w-full md:px-20 lg:px-10 xl:px-20 p-6 flex flex-col h-full justify-center items-center"
|
||||
>
|
||||
<div class="mb-5 w-full prose prose-neutral">
|
||||
<h1 class="m-0 white">Welcome back</h1>
|
||||
<h5>Please login to continue.</h5>
|
||||
</div>
|
||||
<form on:submit|preventDefault={handleSubmit} class="flex flex-col py-4 space-y-3 w-full">
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
placeholder="Email"
|
||||
autocomplete="off"
|
||||
required
|
||||
bind:this={emailEl}
|
||||
bind:value={email}
|
||||
class="w-full"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
placeholder="Password"
|
||||
bind:value={password}
|
||||
required
|
||||
class="w-full"
|
||||
/>
|
||||
|
||||
<div class="flex space-y-3 flex-col pt-3">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
class="btn"
|
||||
class:loading
|
||||
class:bg-coollabs={!loading}>{loading ? 'Authenticating' : 'Login'}</button
|
||||
>
|
||||
{#if $appSession.isRegistrationEnabled}
|
||||
<button on:click|preventDefault={gotoRegister} class="btn btn-ghost">Register</button>
|
||||
{:else}
|
||||
<div class="text-stone-600 text-xs">
|
||||
Registration is disabled. Please ask an admin to activate it.
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</form>
|
||||
{#if browser && window.location.host === 'demo.coolify.io'}
|
||||
<div class="pt-5 font-bold">
|
||||
Registration is <span class="text-pink-500">open</span>, just fill in an email (does not
|
||||
need to be live email address for the demo instance) and a password.
|
||||
</div>
|
||||
<div class="pt-5 font-bold">
|
||||
All users gets an <span class="text-pink-500">own namespace</span>, so you won't be able
|
||||
to access other users data.
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
146
apps/trpc-experimental/client/src/routes/register/+page.svelte
Normal file
146
apps/trpc-experimental/client/src/routes/register/+page.svelte
Normal file
@@ -0,0 +1,146 @@
|
||||
<script lang="ts">
|
||||
export let userCount: number;
|
||||
import { goto } from '$app/navigation';
|
||||
import { errorNotification } from '$lib/common';
|
||||
import { appSession, loginEmail, trpc } from '$lib/store';
|
||||
import { onMount } from 'svelte';
|
||||
import Cookies from 'js-cookie';
|
||||
if (!$appSession.isRegistrationEnabled) {
|
||||
window.location.assign('/');
|
||||
}
|
||||
let loading = false;
|
||||
let emailEl: HTMLInputElement;
|
||||
let passwordEl: HTMLInputElement;
|
||||
let email: string | undefined, password: string, passwordCheck: string;
|
||||
|
||||
onMount(() => {
|
||||
email = $loginEmail;
|
||||
if (email) {
|
||||
passwordEl.focus();
|
||||
} else {
|
||||
emailEl.focus();
|
||||
}
|
||||
});
|
||||
async function handleSubmit() {
|
||||
if (loading) return;
|
||||
|
||||
if (password !== passwordCheck) {
|
||||
return errorNotification("Passwords don't match");
|
||||
}
|
||||
loading = true;
|
||||
try {
|
||||
const payload = await trpc.auth.register.mutate({ email, password });
|
||||
Cookies.set('token', payload.token, {
|
||||
path: '/'
|
||||
});
|
||||
$appSession.teamId = payload.teamId;
|
||||
$appSession.userId = payload.userId;
|
||||
$appSession.permission = payload.permission;
|
||||
$appSession.isAdmin = payload.isAdmin;
|
||||
return await goto('/');
|
||||
} catch (error) {
|
||||
return errorNotification(error);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex lg:flex-row flex-col h-screen">
|
||||
<div class="bg-neutral-focus h-screen lg:flex hidden flex-col justify-end p-20 flex-1">
|
||||
<h1 class="title lg:text-6xl mb-5 border-gradient">Coolify</h1>
|
||||
<h3 class="title">Made self-hosting simple.</h3>
|
||||
</div>
|
||||
<div class="flex flex-1 flex-col lg:max-w-2xl">
|
||||
<div class="flex flex-row p-8 items-center space-x-3 justify-between">
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<div class="icons cursor-pointer" on:click={() => goto('/')}>
|
||||
<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" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
<line x1="5" y1="12" x2="11" y2="18" />
|
||||
<line x1="5" y1="12" x2="11" y2="6" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex flex-row items-center space-x-3">
|
||||
{#if $appSession.whiteLabeledDetails.icon}
|
||||
<div class="avatar" style="width: 40px; height: 40px">
|
||||
<img
|
||||
src={$appSession.whiteLabeledDetails.icon}
|
||||
alt="Icon for white labeled version of Coolify"
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<div>
|
||||
<div class="avatar" style="width: 40px; height: 40px">
|
||||
<img src="favicon.png" alt="Coolify icon" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="prose">
|
||||
<h4>Coolify</h4>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="w-full md:px-20 lg:px-10 xl:px-20 p-6 flex flex-col h-full justify-center items-center"
|
||||
>
|
||||
<div class="mb-5 w-full prose prose-neutral">
|
||||
<h1 class="m-0 white">Get started</h1>
|
||||
<h5>Enter the required fields to complete the registration.</h5>
|
||||
</div>
|
||||
<form on:submit|preventDefault={handleSubmit} class="flex flex-col py-4 space-y-3 w-full">
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
placeholder="Email"
|
||||
autocomplete="off"
|
||||
required
|
||||
bind:this={emailEl}
|
||||
bind:value={email}
|
||||
class="w-full"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
placeholder="Password"
|
||||
bind:this={passwordEl}
|
||||
bind:value={password}
|
||||
required
|
||||
class="w-full"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
name="passwordCheck"
|
||||
placeholder="Confirm password"
|
||||
bind:value={passwordCheck}
|
||||
required
|
||||
class="w-full"
|
||||
/>
|
||||
|
||||
<div class="flex space-y-3 flex-col pt-3">
|
||||
<button
|
||||
type="submit"
|
||||
class="btn"
|
||||
disabled={loading}
|
||||
class:bg-transparent={loading}
|
||||
class:bg-coollabs={!loading}
|
||||
class:loading>{loading ? 'Registering' : 'Register'}</button
|
||||
>
|
||||
</div>
|
||||
</form>
|
||||
{#if userCount === 0}
|
||||
<div class="pt-5">"First user will be admin."</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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>
|
||||
@@ -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.'
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<script lang="ts">
|
||||
import type { LayoutData } from './$types';
|
||||
|
||||
export let data: LayoutData;
|
||||
let source = data.source.data.source;
|
||||
import { page } from '$app/stores';
|
||||
import { errorNotification } from '$lib/common';
|
||||
import { appSession, trpc } from '$lib/store';
|
||||
import * as Icons from '$lib/components/icons';
|
||||
import { goto } from '$app/navigation';
|
||||
import Tooltip from '$lib/components/Tooltip.svelte';
|
||||
const { id } = $page.params;
|
||||
|
||||
async function deleteSource(name: string) {
|
||||
const sure = confirm('Are you sure you want to delete ' + name + '?');
|
||||
if (sure) {
|
||||
try {
|
||||
await trpc.sources.delete.mutate({ id });
|
||||
await goto('/', { replaceState: true });
|
||||
} catch (error) {
|
||||
errorNotification(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if id !== 'new' && $appSession.teamId === '0'}
|
||||
<nav class="nav-side">
|
||||
<button
|
||||
id="delete"
|
||||
on:click={() => deleteSource(source.name)}
|
||||
type="submit"
|
||||
disabled={!$appSession.isAdmin}
|
||||
class:hover:text-red-500={$appSession.isAdmin}
|
||||
class="icons bg-transparent text-sm"><Icons.Delete /></button
|
||||
>
|
||||
</nav>
|
||||
<Tooltip triggeredBy="#delete">Delete</Tooltip>
|
||||
{/if}
|
||||
<slot />
|
||||
@@ -0,0 +1,35 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { trpc } from '$lib/store';
|
||||
import type { LayoutLoad } from './$types';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
|
||||
export const load: LayoutLoad = async ({ params, url }) => {
|
||||
const { pathname } = new URL(url);
|
||||
const { id } = params;
|
||||
try {
|
||||
const source = await trpc.sources.getSourceById.query({ id });
|
||||
if (!source) {
|
||||
throw redirect(307, '/sources');
|
||||
}
|
||||
|
||||
// if (
|
||||
// configurationPhase &&
|
||||
// pathname !== `/applications/${params.id}/configuration/${configurationPhase}`
|
||||
// ) {
|
||||
// throw redirect(302, `/applications/${params.id}/configuration/${configurationPhase}`);
|
||||
// }
|
||||
return {
|
||||
source
|
||||
};
|
||||
} 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.'
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import type { LayoutData } from './$types';
|
||||
|
||||
export let data: LayoutData;
|
||||
let source = data.source.data.source;
|
||||
let settings = data.source.data.settings;
|
||||
import { page } from '$app/stores';
|
||||
import Source from './components/Source.svelte';
|
||||
import New from './components/New.svelte';
|
||||
const { id } = $page.params;
|
||||
</script>
|
||||
|
||||
{#if id === 'new'}
|
||||
<New bind:source bind:settings />
|
||||
{:else}
|
||||
<Source bind:source bind:settings />
|
||||
{/if}
|
||||
@@ -0,0 +1,264 @@
|
||||
<script lang="ts">
|
||||
export let source: any;
|
||||
export let settings: any;
|
||||
import { page } from '$app/stores';
|
||||
import { dashify, errorNotification, getAPIUrl, getDomain, getWebhookUrl } from '$lib/common';
|
||||
import { addToast, appSession, trpc } from '$lib/store';
|
||||
import Explainer from '$lib/components/Explainer.svelte';
|
||||
import Setting from '$lib/components/Setting.svelte';
|
||||
import { dev } from '$app/environment';
|
||||
|
||||
const { id } = $page.params;
|
||||
|
||||
$: selfHosted = source.htmlUrl !== 'https://github.com';
|
||||
|
||||
let loading = false;
|
||||
|
||||
async function handleSubmit() {
|
||||
loading = true;
|
||||
try {
|
||||
await trpc.sources.save.mutate({
|
||||
id: source.id,
|
||||
name: source.name,
|
||||
htmlUrl: source.htmlUrl.replace(/\/$/, ''),
|
||||
apiUrl: source.apiUrl.replace(/\/$/, ''),
|
||||
isSystemWide: source.isSystemWide
|
||||
});
|
||||
|
||||
return addToast({
|
||||
message: 'Configuration saved.',
|
||||
type: 'success'
|
||||
});
|
||||
} catch (error) {
|
||||
return errorNotification(error);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function newGithubApp() {
|
||||
loading = true;
|
||||
try {
|
||||
const { id } = await trpc.sources.newGitHubApp.mutate({
|
||||
id: source.id,
|
||||
name: source.name,
|
||||
htmlUrl: source.htmlUrl.replace(/\/$/, ''),
|
||||
apiUrl: source.apiUrl.replace(/\/$/, ''),
|
||||
organization: source.organization,
|
||||
customPort: source.customPort,
|
||||
isSystemWide: source.isSystemWide
|
||||
});
|
||||
|
||||
const { organization, htmlUrl } = source;
|
||||
const { fqdn, ipv4, ipv6 } = settings;
|
||||
const host = dev ? getAPIUrl() : fqdn ? fqdn : `http://${ipv4 || ipv6}` || '';
|
||||
const domain = getDomain(fqdn);
|
||||
|
||||
let url = 'settings/apps/new';
|
||||
if (organization) url = `organizations/${organization}/settings/apps/new`;
|
||||
const name = dashify(domain) || 'app';
|
||||
const data = JSON.stringify({
|
||||
name: `coolify-${name}`,
|
||||
url: host,
|
||||
hook_attributes: {
|
||||
url: dev ? getWebhookUrl('github') : `${host}/webhooks/github/events`
|
||||
},
|
||||
redirect_url: `${host}/webhooks/github`,
|
||||
callback_urls: [`${host}/login/github/app`],
|
||||
public: false,
|
||||
request_oauth_on_install: false,
|
||||
setup_url: `${host}/webhooks/github/install?gitSourceId=${id}`,
|
||||
setup_on_update: true,
|
||||
default_permissions: {
|
||||
contents: 'read',
|
||||
metadata: 'read',
|
||||
pull_requests: 'read',
|
||||
emails: 'read'
|
||||
},
|
||||
default_events: ['pull_request', 'push']
|
||||
});
|
||||
const form = document.createElement('form');
|
||||
form.setAttribute('method', 'post');
|
||||
form.setAttribute('action', `${htmlUrl}/${url}?state=${id}`);
|
||||
const input = document.createElement('input');
|
||||
input.setAttribute('id', 'manifest');
|
||||
input.setAttribute('name', 'manifest');
|
||||
input.setAttribute('type', 'hidden');
|
||||
input.setAttribute('value', data);
|
||||
form.appendChild(input);
|
||||
document.getElementsByTagName('body')[0].appendChild(form);
|
||||
form.submit();
|
||||
} catch (error) {
|
||||
return errorNotification(error);
|
||||
}
|
||||
}
|
||||
async function changeSettings(name: any, save: boolean) {
|
||||
if ($appSession.teamId === '0') {
|
||||
if (name === 'isSystemWide') {
|
||||
source.isSystemWide = !source.isSystemWide;
|
||||
}
|
||||
if (save) {
|
||||
await handleSubmit();
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="mx-auto max-w-6xl lg:px-6 px-3">
|
||||
{#if !source.githubAppId}
|
||||
<form on:submit|preventDefault={newGithubApp} class="py-4">
|
||||
<div class="grid gap-1 lg:grid-flow-col pb-7">
|
||||
<h1 class="title">General</h1>
|
||||
{#if !source.githubAppId}
|
||||
<div class="w-full flex flex-rpw justify-end">
|
||||
<button class="btn btn-sm bg-sources mt-5 w-full lg:w-fit" type="submit"
|
||||
>Save & Redirect to GitHub</button
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="grid gap-2 grid-cols-2 auto-rows-max">
|
||||
<label for="name">Name</label>
|
||||
<input class="w-full" name="name" id="name" required bind:value={source.name} />
|
||||
<label for="htmlUrl">HTML URL</label>
|
||||
<input class="w-full" name="htmlUrl" id="htmlUrl" required bind:value={source.htmlUrl} />
|
||||
<label for="apiUrl">API URL</label>
|
||||
<input class="w-full" name="apiUrl" id="apiUrl" required bind:value={source.apiUrl} />
|
||||
<label for="customPort"
|
||||
>Custom SSH Port <Explainer
|
||||
explanation={'If you use a self-hosted version of Git, you can provide custom port for all the Git related actions.'}
|
||||
/></label
|
||||
>
|
||||
<input
|
||||
class="w-full"
|
||||
name="customPort"
|
||||
id="customPort"
|
||||
disabled={!selfHosted || source.githubAppId}
|
||||
readonly={!selfHosted || source.githubAppId}
|
||||
required
|
||||
value={source.customPort}
|
||||
/>
|
||||
<label for="organization" class="pt-2"
|
||||
>Organization
|
||||
<Explainer
|
||||
explanation={"Fill it if you would like to use an organization's as your Git Source. Otherwise your user will be used."}
|
||||
/></label
|
||||
>
|
||||
<input
|
||||
class="w-full"
|
||||
name="organization"
|
||||
id="organization"
|
||||
placeholder="eg: coollabsio"
|
||||
bind:value={source.organization}
|
||||
/>
|
||||
<Setting
|
||||
customClass="pt-4"
|
||||
id="autodeploy"
|
||||
isCenter={false}
|
||||
bind:setting={source.isSystemWide}
|
||||
on:click={() => changeSettings('isSystemWide', false)}
|
||||
title="System Wide Git"
|
||||
description="System Wide Git are available to all the users in your Coolify instance. <br><br> <span class='font-bold text-warning'>Use with caution, as it can be a security risk.</span>"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
{:else if source.githubApp?.installationId}
|
||||
<form on:submit|preventDefault={handleSubmit} class="py-4">
|
||||
<div class="flex lg:flex-row lg:justify-between flex-col space-y-3 w-full lg:items-center">
|
||||
<h1 class="title">General</h1>
|
||||
{#if $appSession.isAdmin && $appSession.teamId === '0'}
|
||||
<div
|
||||
class="flex flex-col lg:flex-row lg:space-x-4 lg:w-fit space-y-2 lg:space-y-0 w-full"
|
||||
>
|
||||
<button class="btn btn-sm bg-sources" type="submit" disabled={loading}
|
||||
>{loading ? 'Saving...' : 'Save'}</button
|
||||
>
|
||||
<a
|
||||
class="btn btn-sm"
|
||||
href={`${source.htmlUrl}/${
|
||||
source.htmlUrl === 'https://github.com' ? 'apps' : 'github-apps'
|
||||
}/${source.githubApp.name}/installations/new`}>Change GitHub App Settings</a
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="grid gap-2 grid-cols-2 auto-rows-max mt-4">
|
||||
<label for="name">Name</label>
|
||||
<input
|
||||
class="w-full"
|
||||
name="name"
|
||||
id="name"
|
||||
required
|
||||
bind:value={source.name}
|
||||
disabled={!$appSession.isAdmin}
|
||||
/>
|
||||
<label for="htmlUrl">HTML URL</label>
|
||||
<input
|
||||
class="w-full"
|
||||
name="htmlUrl"
|
||||
id="htmlUrl"
|
||||
disabled={source.githubAppId}
|
||||
readonly={source.githubAppId}
|
||||
required
|
||||
bind:value={source.htmlUrl}
|
||||
/>
|
||||
<label for="apiUrl">API URL</label>
|
||||
<input
|
||||
class="w-full"
|
||||
name="apiUrl"
|
||||
id="apiUrl"
|
||||
required
|
||||
disabled={source.githubAppId}
|
||||
readonly={source.githubAppId}
|
||||
bind:value={source.apiUrl}
|
||||
/>
|
||||
<label for="customPort"
|
||||
>Custom SSH Port <Explainer
|
||||
explanation="If you use a self-hosted version of Git, you can provide custom port for all the Git related actions."
|
||||
/></label
|
||||
>
|
||||
<input
|
||||
class="w-full"
|
||||
name="customPort"
|
||||
id="customPort"
|
||||
disabled={!selfHosted}
|
||||
readonly={!selfHosted}
|
||||
required
|
||||
value={source.customPort}
|
||||
/>
|
||||
<label for="organization" class="pt-2">Organization</label>
|
||||
<input
|
||||
class="w-full"
|
||||
readonly
|
||||
disabled
|
||||
name="organization"
|
||||
id="organization"
|
||||
placeholder="eg: coollabsio"
|
||||
bind:value={source.organization}
|
||||
/>
|
||||
{#if $appSession.isAdmin}
|
||||
<Setting
|
||||
customClass="pt-4"
|
||||
id="autodeploy"
|
||||
isCenter={false}
|
||||
disabled={$appSession.teamId !== '0'}
|
||||
bind:setting={source.isSystemWide}
|
||||
on:click={() => changeSettings('isSystemWide', true)}
|
||||
title="System Wide Git Source"
|
||||
description="System Wide Git Sources are available to all the users in your Coolify instance. <br><br> <span class='font-bold text-warning'>Use with caution, as it can be a security risk.</span>"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</form>
|
||||
{:else}
|
||||
<div class="text-center">
|
||||
<a
|
||||
href={`${source.htmlUrl}/${
|
||||
source.htmlUrl === 'https://github.com' ? 'apps' : 'github-apps'
|
||||
}/${source.githubApp.name}/installations/new`}
|
||||
>
|
||||
<button class="box-selection bg-sources text-xl font-bold">Install Repositories</button></a
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,333 @@
|
||||
<script lang="ts">
|
||||
export let source: any;
|
||||
export let settings: any;
|
||||
import { page } from '$app/stores';
|
||||
import { onMount } from 'svelte';
|
||||
import CopyPasswordField from '$lib/components/CopyPasswordField.svelte';
|
||||
|
||||
import { errorNotification, getAPIUrl } from '$lib/common';
|
||||
import { addToast, appSession, trpc } from '$lib/store';
|
||||
import Explainer from '$lib/components/Explainer.svelte';
|
||||
import { dev } from '$app/environment';
|
||||
const { id } = $page.params;
|
||||
|
||||
let url = settings.fqdn ? settings.fqdn : window.location.origin;
|
||||
|
||||
if (dev) {
|
||||
url = getAPIUrl();
|
||||
}
|
||||
let loading = false;
|
||||
|
||||
let oauthIdEl: HTMLInputElement;
|
||||
let applicationType: string;
|
||||
if (!source.gitlabAppId) {
|
||||
source.gitlabApp = {
|
||||
oauthId: null,
|
||||
groupName: null,
|
||||
appId: null,
|
||||
appSecret: null
|
||||
};
|
||||
}
|
||||
$: selfHosted = source.htmlUrl !== 'https://gitlab.com';
|
||||
|
||||
onMount(() => {
|
||||
oauthIdEl && oauthIdEl.focus();
|
||||
});
|
||||
|
||||
async function handleSubmit() {
|
||||
if (loading) return;
|
||||
loading = true;
|
||||
if (!source.gitlabAppId) {
|
||||
// New GitLab App
|
||||
try {
|
||||
const { id } = await trpc.sources.newGitLabApp.mutate({
|
||||
id: source.id,
|
||||
type: 'gitlab',
|
||||
name: source.name,
|
||||
htmlUrl: source.htmlUrl.replace(/\/$/, ''),
|
||||
apiUrl: source.apiUrl.replace(/\/$/, ''),
|
||||
oauthId: source.gitlabApp.oauthId,
|
||||
appId: source.gitlabApp.appId,
|
||||
appSecret: source.gitlabApp.appSecret,
|
||||
groupName: source.gitlabApp.groupName,
|
||||
customPort: source.customPort,
|
||||
customUser: source.customUser
|
||||
});
|
||||
|
||||
const from = $page.url.searchParams.get('from');
|
||||
if (from) {
|
||||
return window.location.assign(from);
|
||||
}
|
||||
return window.location.assign(`/sources/${id}`);
|
||||
} catch (error) {
|
||||
return errorNotification(error);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
} else {
|
||||
// Update GitLab App
|
||||
try {
|
||||
await trpc.sources.save.mutate({
|
||||
id,
|
||||
name: source.name,
|
||||
htmlUrl: source.htmlUrl.replace(/\/$/, ''),
|
||||
apiUrl: source.apiUrl.replace(/\/$/, ''),
|
||||
customPort: source.customPort,
|
||||
customUser: source.customUser
|
||||
});
|
||||
|
||||
return addToast({
|
||||
message: 'Configuration saved.',
|
||||
type: 'success'
|
||||
});
|
||||
} catch (error) {
|
||||
return errorNotification(error);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function changeSettings() {
|
||||
const {
|
||||
htmlUrl,
|
||||
gitlabApp: { oauthId }
|
||||
} = source;
|
||||
const left = screen.width / 2 - 1020 / 2;
|
||||
const top = screen.height / 2 - 1000 / 2;
|
||||
const newWindow = open(
|
||||
`${htmlUrl}/oauth/applications/${oauthId}`,
|
||||
'GitLab',
|
||||
'resizable=1, scrollbars=1, fullscreen=0, height=1000, width=1020,top=' +
|
||||
top +
|
||||
', left=' +
|
||||
left +
|
||||
', toolbar=0, menubar=0, status=0'
|
||||
);
|
||||
const timer = setInterval(() => {
|
||||
if (newWindow?.closed) {
|
||||
clearInterval(timer);
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
async function checkOauthId() {
|
||||
if (source.gitlabApp?.oauthId) {
|
||||
try {
|
||||
// await post(`/sources/${id}/check`, {
|
||||
// oauthId: source.gitlabApp?.oauthId
|
||||
// });
|
||||
} catch (error) {
|
||||
source.gitlabApp.oauthId = null;
|
||||
oauthIdEl.focus();
|
||||
return errorNotification(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
function newApp() {
|
||||
switch (applicationType) {
|
||||
case 'user':
|
||||
window.open(`${source.htmlUrl}/-/profile/applications`);
|
||||
break;
|
||||
case 'group':
|
||||
if (!source.gitlabApp.groupName) {
|
||||
return addToast({
|
||||
message: 'Please enter a group name first.',
|
||||
type: 'error'
|
||||
});
|
||||
}
|
||||
window.open(
|
||||
`${source.htmlUrl}/groups/${source.gitlabApp.groupName}/-/settings/applications`
|
||||
);
|
||||
break;
|
||||
case 'instance':
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="mx-auto max-w-6xl px-6">
|
||||
<form on:submit|preventDefault={handleSubmit} class="py-4">
|
||||
<div class="flex lg:flex-row lg:justify-between flex-col space-y-3 w-full lg:items-center">
|
||||
<h1 class="title">General</h1>
|
||||
<div class="flex flex-col lg:flex-row lg:space-x-4 lg:w-fit space-y-2 lg:space-y-0 w-full">
|
||||
{#if $appSession.isAdmin}
|
||||
<button type="submit" class="btn btn-sm bg-sources" disabled={loading}
|
||||
>{loading ? 'Saving...' : 'Save'}</button
|
||||
>
|
||||
{#if source.gitlabAppId}
|
||||
<button class="btn btn-sm" on:click|preventDefault={changeSettings}
|
||||
>Change GitLab App Settings</button
|
||||
>
|
||||
{:else}
|
||||
<button class="btn btn-sm" on:click|preventDefault|stopPropagation={newApp}
|
||||
>Create new GitLab App manually</button
|
||||
>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-flow-row gap-2 lg:px-10">
|
||||
{#if !source.gitlabAppId}
|
||||
<a
|
||||
href="https://docs.coollabs.io/coolify/sources#how-to-integrate-with-gitlab"
|
||||
class="font-bold "
|
||||
target="_blank noreferrer"
|
||||
rel="noopener noreferrer">Documentation and detailed instructions.</a
|
||||
>
|
||||
<div class="grid grid-cols-2 items-center">
|
||||
<label for="type" class="text-base font-bold text-stone-100">Application Type</label>
|
||||
<select name="type" id="type" class="lg:w-96 w-full" bind:value={applicationType}>
|
||||
<option value="user">User owned application</option>
|
||||
<option value="group">Group owned application</option>
|
||||
{#if source.htmlUrl !== 'https://gitlab.com'}
|
||||
<option value="instance">Instance-wide application (self-hosted)</option>
|
||||
{/if}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{#if applicationType === 'group'}
|
||||
<div class="grid grid-cols-2 items-center">
|
||||
<label for="groupName" class="text-base font-bold text-stone-100">Group Name</label>
|
||||
<input
|
||||
class="w-full"
|
||||
name="groupName"
|
||||
id="groupName"
|
||||
required
|
||||
bind:value={source.gitlabApp.groupName}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<div class="grid grid-flow-row gap-2">
|
||||
<div class="mt-2 grid grid-cols-2 items-center">
|
||||
<label for="name" class="text-base font-bold text-stone-100">Name</label>
|
||||
<input class="w-full" name="name" id="name" required bind:value={source.name} />
|
||||
</div>
|
||||
</div>
|
||||
{#if source.gitlabApp.groupName}
|
||||
<div class="grid grid-cols-2 items-center">
|
||||
<label for="groupName" class="text-base font-bold text-stone-100">Group Name</label>
|
||||
<input
|
||||
class="w-full"
|
||||
name="groupName"
|
||||
id="groupName"
|
||||
disabled={source.gitlabAppId}
|
||||
readonly={source.gitlabAppId}
|
||||
required
|
||||
bind:value={source.gitlabApp.groupName}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="grid grid-cols-2 items-center">
|
||||
<label for="htmlUrl" class="text-base font-bold text-stone-100">HTML URL</label>
|
||||
<input
|
||||
class="w-full"
|
||||
name="htmlUrl"
|
||||
id="htmlUrl"
|
||||
required
|
||||
disabled={source.gitlabAppId}
|
||||
readonly={source.gitlabAppId}
|
||||
bind:value={source.htmlUrl}
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 items-center">
|
||||
<label for="apiUrl" class="text-base font-bold text-stone-100">API URL</label>
|
||||
<input
|
||||
class="w-full"
|
||||
name="apiUrl"
|
||||
id="apiUrl"
|
||||
disabled={source.gitlabAppId}
|
||||
readonly={source.gitlabAppId}
|
||||
required
|
||||
bind:value={source.apiUrl}
|
||||
/>
|
||||
</div>
|
||||
{#if selfHosted}
|
||||
<div class="grid grid-cols-2 items-center">
|
||||
<label for="customPort" class="text-base font-bold text-stone-100"
|
||||
>Custom SSH User <Explainer
|
||||
explanation={'If you use a self-hosted version of Git, you can provide a custom SSH user for all the Git related actions.'}
|
||||
/></label
|
||||
>
|
||||
<input
|
||||
class="w-full"
|
||||
name="customUser"
|
||||
id="customUser"
|
||||
disabled={!selfHosted}
|
||||
readonly={!selfHosted}
|
||||
required
|
||||
bind:value={source.customUser}
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 items-center">
|
||||
<label for="customPort" class="text-base font-bold text-stone-100"
|
||||
>Custom SSH Port <Explainer
|
||||
explanation={'If you use a self-hosted version of Git, you can provide custom port for all the Git related actions.'}
|
||||
/></label
|
||||
>
|
||||
<input
|
||||
class="w-full"
|
||||
name="customPort"
|
||||
id="customPort"
|
||||
disabled={!selfHosted}
|
||||
readonly={!selfHosted}
|
||||
required
|
||||
bind:value={source.customPort}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="grid grid-cols-2 items-start">
|
||||
<div class="flex-col">
|
||||
<label for="oauthId" class="pt-2 text-base font-bold text-stone-100"
|
||||
>OAuth ID
|
||||
{#if !source.gitlabAppId}
|
||||
<Explainer
|
||||
explanation="The OAuth ID is the unique identifier of the GitLab application. <br>You can find it <span class=' text-settings' >in the URL</span> of your GitLab OAuth Application."
|
||||
/>
|
||||
{/if}</label
|
||||
>
|
||||
</div>
|
||||
<input
|
||||
class="w-full"
|
||||
disabled={source.gitlabAppId}
|
||||
readonly={source.gitlabAppId}
|
||||
on:change={checkOauthId}
|
||||
bind:this={oauthIdEl}
|
||||
name="oauthId"
|
||||
id="oauthId"
|
||||
type="number"
|
||||
required
|
||||
bind:value={source.gitlabApp.oauthId}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 items-center">
|
||||
<label for="appId" class="text-base font-bold text-stone-100">Application ID</label>
|
||||
<input
|
||||
class="w-full"
|
||||
name="appId"
|
||||
id="appId"
|
||||
disabled={source.gitlabAppId}
|
||||
readonly={source.gitlabAppId}
|
||||
required
|
||||
bind:value={source.gitlabApp.appId}
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 items-center">
|
||||
<label for="appSecret" class="text-base font-bold text-stone-100">Secret</label>
|
||||
<CopyPasswordField
|
||||
disabled={source.gitlabAppId}
|
||||
readonly={source.gitlabAppId}
|
||||
isPasswordField={true}
|
||||
name="appSecret"
|
||||
id="appSecret"
|
||||
required
|
||||
bind:value={source.gitlabApp.appSecret}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -0,0 +1,62 @@
|
||||
<script lang="ts">
|
||||
export let source: any;
|
||||
export let settings: any;
|
||||
import Github from './Github.svelte';
|
||||
import Gitlab from './Gitlab.svelte';
|
||||
function setPredefined(type: string) {
|
||||
switch (type) {
|
||||
case 'github':
|
||||
source.name = 'Github.com';
|
||||
source.type = 'github';
|
||||
source.htmlUrl = 'https://github.com';
|
||||
source.apiUrl = 'https://api.github.com';
|
||||
source.organization = undefined;
|
||||
|
||||
break;
|
||||
case 'gitlab':
|
||||
source.name = 'Gitlab.com';
|
||||
source.type = 'gitlab';
|
||||
source.htmlUrl = 'https://gitlab.com';
|
||||
source.apiUrl = 'https://gitlab.com/api';
|
||||
source.organization = undefined;
|
||||
|
||||
break;
|
||||
case 'bitbucket':
|
||||
source.name = 'Bitbucket.com';
|
||||
source.type = 'bitbucket';
|
||||
source.htmlUrl = 'https://bitbucket.com';
|
||||
source.apiUrl = 'https://api.bitbucket.org';
|
||||
source.organization = undefined;
|
||||
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex h-20 items-center space-x-2 p-5 px-6 font-bold">
|
||||
<div class="-mb-1 flex-col">
|
||||
<div class="md:max-w-64 truncate text-base tracking-tight md:text-2xl lg:block">
|
||||
New Git Source
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col justify-center">
|
||||
<div class="flex-col space-y-2 pb-10 text-center">
|
||||
<div class="text-xl font-bold text-white">Select a git type</div>
|
||||
<div class="flex justify-center space-x-2">
|
||||
<button class="btn btn-sm" on:click={() => setPredefined('github')}>GitHub</button>
|
||||
<button class="btn btn-sm" on:click={() => setPredefined('gitlab')}>GitLab</button>
|
||||
</div>
|
||||
</div>
|
||||
{#if source?.type}
|
||||
<div>
|
||||
{#if source.type === 'github'}
|
||||
<Github bind:source {settings} />
|
||||
{:else if source.type === 'gitlab'}
|
||||
<Gitlab bind:source {settings} />
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,58 @@
|
||||
<script lang="ts">
|
||||
import Github from './Github.svelte';
|
||||
import Gitlab from './Gitlab.svelte';
|
||||
|
||||
export let source: any;
|
||||
export let settings: any;
|
||||
</script>
|
||||
|
||||
<div class="flex h-20 items-center space-x-2 p-5 px-6 font-bold">
|
||||
<div class="-mb-5 flex-col">
|
||||
<div class="md:max-w-64 truncate text-base tracking-tight md:text-2xl lg:block">
|
||||
Configuration
|
||||
</div>
|
||||
<span class="text-xs">{source.name}</span>
|
||||
</div>
|
||||
{#if source?.type === 'gitlab'}
|
||||
<svg viewBox="0 0 128 128" class="w-8">
|
||||
<path
|
||||
fill="#FC6D26"
|
||||
d="M126.615 72.31l-7.034-21.647L105.64 7.76c-.716-2.206-3.84-2.206-4.556 0l-13.94 42.903H40.856L26.916 7.76c-.717-2.206-3.84-2.206-4.557 0L8.42 50.664 1.385 72.31a4.792 4.792 0 001.74 5.358L64 121.894l60.874-44.227a4.793 4.793 0 001.74-5.357"
|
||||
/><path fill="#E24329" d="M64 121.894l23.144-71.23H40.856L64 121.893z" /><path
|
||||
fill="#FC6D26"
|
||||
d="M64 121.894l-23.144-71.23H8.42L64 121.893z"
|
||||
/><path
|
||||
fill="#FCA326"
|
||||
d="M8.42 50.663L1.384 72.31a4.79 4.79 0 001.74 5.357L64 121.894 8.42 50.664z"
|
||||
/><path
|
||||
fill="#E24329"
|
||||
d="M8.42 50.663h32.436L26.916 7.76c-.717-2.206-3.84-2.206-4.557 0L8.42 50.664z"
|
||||
/><path fill="#FC6D26" d="M64 121.894l23.144-71.23h32.437L64 121.893z" /><path
|
||||
fill="#FCA326"
|
||||
d="M119.58 50.663l7.035 21.647a4.79 4.79 0 01-1.74 5.357L64 121.894l55.58-71.23z"
|
||||
/><path
|
||||
fill="#E24329"
|
||||
d="M119.58 50.663H87.145l13.94-42.902c.717-2.206 3.84-2.206 4.557 0l13.94 42.903z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if source?.type === 'github'}
|
||||
<svg viewBox="0 0 128 128" class="w-8">
|
||||
<g fill="#ffffff"
|
||||
><path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M64 5.103c-33.347 0-60.388 27.035-60.388 60.388 0 26.682 17.303 49.317 41.297 57.303 3.017.56 4.125-1.31 4.125-2.905 0-1.44-.056-6.197-.082-11.243-16.8 3.653-20.345-7.125-20.345-7.125-2.747-6.98-6.705-8.836-6.705-8.836-5.48-3.748.413-3.67.413-3.67 6.063.425 9.257 6.223 9.257 6.223 5.386 9.23 14.127 6.562 17.573 5.02.542-3.903 2.107-6.568 3.834-8.076-13.413-1.525-27.514-6.704-27.514-29.843 0-6.593 2.36-11.98 6.223-16.21-.628-1.52-2.695-7.662.584-15.98 0 0 5.07-1.623 16.61 6.19C53.7 35 58.867 34.327 64 34.304c5.13.023 10.3.694 15.127 2.033 11.526-7.813 16.59-6.19 16.59-6.19 3.287 8.317 1.22 14.46.593 15.98 3.872 4.23 6.215 9.617 6.215 16.21 0 23.194-14.127 28.3-27.574 29.796 2.167 1.874 4.097 5.55 4.097 11.183 0 8.08-.07 14.583-.07 16.572 0 1.607 1.088 3.49 4.148 2.897 23.98-7.994 41.263-30.622 41.263-57.294C124.388 32.14 97.35 5.104 64 5.104z"
|
||||
/><path
|
||||
d="M26.484 91.806c-.133.3-.605.39-1.035.185-.44-.196-.685-.605-.543-.906.13-.31.603-.395 1.04-.188.44.197.69.61.537.91zm2.446 2.729c-.287.267-.85.143-1.232-.28-.396-.42-.47-.983-.177-1.254.298-.266.844-.14 1.24.28.394.426.472.984.17 1.255zM31.312 98.012c-.37.258-.976.017-1.35-.52-.37-.538-.37-1.183.01-1.44.373-.258.97-.025 1.35.507.368.545.368 1.19-.01 1.452zm3.261 3.361c-.33.365-1.036.267-1.552-.23-.527-.487-.674-1.18-.343-1.544.336-.366 1.045-.264 1.564.23.527.486.686 1.18.333 1.543zm4.5 1.951c-.147.473-.825.688-1.51.486-.683-.207-1.13-.76-.99-1.238.14-.477.823-.7 1.512-.485.683.206 1.13.756.988 1.237zm4.943.361c.017.498-.563.91-1.28.92-.723.017-1.308-.387-1.315-.877 0-.503.568-.91 1.29-.924.717-.013 1.306.387 1.306.88zm4.598-.782c.086.485-.413.984-1.126 1.117-.7.13-1.35-.172-1.44-.653-.086-.498.422-.997 1.122-1.126.714-.123 1.354.17 1.444.663zm0 0"
|
||||
/></g
|
||||
>
|
||||
</svg>
|
||||
{/if}
|
||||
</div>
|
||||
<div>
|
||||
{#if source.type === 'github'}
|
||||
<Github bind:source {settings} />
|
||||
{:else if source.type === 'gitlab'}
|
||||
<Gitlab bind:source {settings} />
|
||||
{/if}
|
||||
</div>
|
||||
Reference in New Issue
Block a user