wip: trpc

This commit is contained in:
Andras Bacsai
2023-01-13 14:17:36 +01:00
parent c651570e62
commit 568ab24fd9
30 changed files with 9082 additions and 173 deletions

View File

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

View File

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

After

Width:  |  Height:  |  Size: 261 B

View File

@@ -5,6 +5,7 @@
const handleError = (ev: { target: { src: string } }) => (ev.target.src = fallback);
let extension = 'png';
let svgs = [
'directus',
'pocketbase',
'gitea',
'languagetool',

View File

@@ -171,3 +171,11 @@ export const setLocation = (resource: any, settings?: any) => {
}
};
export const selectedBuildId: any = writable(null)
export function checkIfDeploymentEnabledServices( service: any) {
return (
service.fqdn &&
service.destinationDocker &&
service.version &&
service.type
);
}

View File

@@ -0,0 +1,366 @@
<script lang="ts">
import { page } from '$app/stores';
import { status, trpc } from '$lib/store';
import { onDestroy, onMount } from 'svelte';
import type { LayoutData } from './$types';
export let data: LayoutData;
const id = $page.params.id;
let service = data.service;
let template = data.template;
import { errorNotification } from '$lib/common';
import {
appSession,
isDeploymentEnabled,
location,
setLocation,
checkIfDeploymentEnabledServices,
addToast
} from '$lib/store';
import { goto } from '$app/navigation';
import { saveForm } from './utils';
import Menu from './components/Menu.svelte';
$isDeploymentEnabled = checkIfDeploymentEnabledServices(service);
let statusInterval: any;
async function deleteService() {
const sure = confirm('Are you sure you want to delete this service?');
if (sure) {
$status.service.initialLoading = true;
try {
if (service.type && $status.service.isRunning) await trpc.services.stop.mutate({ id });
await trpc.services.delete.mutate({ id });
return await goto('/');
} catch (error) {
return errorNotification(error);
} finally {
$status.service.initialLoading = false;
}
}
}
async function restartService() {
const sure = confirm('Are you sure you want to restart this service?');
if (sure) {
await stopService(true);
await startService();
}
}
async function stopService(skip = false) {
if (skip) {
$status.service.initialLoading = true;
$status.service.loading = true;
try {
await trpc.services.stop.mutate({ id });
if (service.type.startsWith('wordpress')) {
await trpc.services.wordpress.mutate({ id, ftpEnabled: false });
service.wordpress?.ftpEnabled && window.location.reload();
}
} catch (error) {
return errorNotification(error);
} finally {
$status.service.initialLoading = false;
$status.service.loading = false;
await getStatus();
}
return;
}
const sure = confirm(
"Are you sure you want to stop this service? You won't be able to access it anymore."
);
if (sure) {
$status.service.initialLoading = true;
$status.service.loading = true;
try {
await trpc.services.stop.mutate({ id });
if (service.type.startsWith('wordpress')) {
await trpc.services.wordpress.mutate({ id, ftpEnabled: false });
service.wordpress?.ftpEnabled && window.location.reload();
}
} catch (error) {
return errorNotification(error);
} finally {
$status.service.initialLoading = false;
$status.service.loading = false;
await getStatus();
}
}
}
async function startService() {
$status.service.initialLoading = true;
$status.service.loading = true;
try {
const form: any = document.getElementById('saveForm');
if (form) {
const formData = new FormData(form);
service = await saveForm(formData, service);
}
await trpc.services.start.mutate({ id });
} catch (error) {
return errorNotification(error);
} finally {
$status.service.initialLoading = false;
$status.service.loading = false;
await getStatus();
}
}
async function getStatus() {
if ($status.service.loading) return;
$status.service.loading = true;
const data = await trpc.services.status.query({ id });
$status.service.statuses = data;
let numberOfServices = Object.keys(data).length;
if (Object.keys($status.service.statuses).length === 0) {
$status.service.overallStatus = 'stopped';
} else {
if (Object.keys($status.service.statuses).length !== numberOfServices) {
$status.service.overallStatus = 'degraded';
} else {
for (const oneService in $status.service.statuses) {
const { isExited, isRestarting, isRunning } = $status.service.statuses[oneService].status;
if (isExited || isRestarting) {
$status.service.overallStatus = 'degraded';
break;
}
if (isRunning) {
$status.service.overallStatus = 'healthy';
}
if (!isExited && !isRestarting && !isRunning) {
$status.service.overallStatus = 'stopped';
}
}
}
}
$status.service.loading = false;
$status.service.initialLoading = false;
}
onDestroy(() => {
$status.service.initialLoading = true;
$status.service.loading = false;
$status.service.statuses = [];
$status.service.overallStatus = 'stopped';
$location = null;
$isDeploymentEnabled = false;
clearInterval(statusInterval);
});
onMount(async () => {
setLocation(service);
$status.service.loading = false;
if ($isDeploymentEnabled) {
await getStatus();
statusInterval = setInterval(async () => {
await getStatus();
}, 2000);
} else {
$status.service.initialLoading = false;
}
});
</script>
<div class="mx-auto max-w-screen-2xl px-6 grid grid-cols-1 lg:grid-cols-2">
<nav class="header flex flex-col lg:flex-row order-2 lg:order-1 px-0 lg:px-4 items-start">
<div class="title lg:pb-10">
<div class="flex justify-center items-center space-x-2">
<div>
{#if $page.url.pathname === `/services/${id}/configuration/type`}
Select a Service Type
{:else if $page.url.pathname === `/services/${id}/configuration/version`}
Select a Service Version
{:else if $page.url.pathname === `/services/${id}/configuration/destination`}
Select a Destination
{:else}
<div class="flex justify-center items-center space-x-2">
<div>Configurations</div>
<div
class="badge badge-lg rounded uppercase"
class:text-green-500={$status.service.overallStatus === 'healthy'}
class:text-yellow-400={$status.service.overallStatus === 'degraded'}
class:text-red-500={$status.service.overallStatus === 'stopped'}
>
{$status.service.overallStatus === 'healthy'
? 'Healthy'
: $status.service.overallStatus === 'degraded'
? 'Degraded'
: 'Stopped'}
</div>
</div>
{/if}
</div>
</div>
</div>
<div class="flex flex-row space-x-2 lg:px-2">
{#if $page.url.pathname.startsWith(`/services/${id}/configuration/`)}
<button
on:click={() => deleteService()}
disabled={!$appSession.isAdmin}
class:bg-red-600={$appSession.isAdmin}
class:hover:bg-red-500={$appSession.isAdmin}
class="btn btn-sm btn-error text-sm"
>
Delete Service
</button>
{/if}
</div>
</nav>
<div
class="pt-4 flex flex-row items-start justify-center lg:justify-end space-x-2 order-1 lg:order-2"
>
{#if $status.service.initialLoading}
<button class="btn btn-ghost btn-sm gap-2">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6 animate-spin duration-500 ease-in-out"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M9 4.55a8 8 0 0 1 6 14.9m0 -4.45v5h5" />
<line x1="5.63" y1="7.16" x2="5.63" y2="7.17" />
<line x1="4.06" y1="11" x2="4.06" y2="11.01" />
<line x1="4.63" y1="15.1" x2="4.63" y2="15.11" />
<line x1="7.16" y1="18.37" x2="7.16" y2="18.38" />
<line x1="11" y1="19.94" x2="11" y2="19.95" />
</svg>
{$status.service.startup[id] || 'Loading...'}
</button>
{:else if $status.service.overallStatus === 'healthy'}
<button
disabled={!$isDeploymentEnabled || !$appSession.isAdmin}
class="btn btn-sm gap-2"
on:click={() => restartService()}
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="w-6 h-6"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path
d="M16.3 5h.7a2 2 0 0 1 2 2v10a2 2 0 0 1 -2 2h-10a2 2 0 0 1 -2 -2v-10a2 2 0 0 1 2 -2h5l-2.82 -2.82m0 5.64l2.82 -2.82"
transform="rotate(-45 12 12)"
/>
</svg>
Force Redeploy
</button>
<button
on:click={() => stopService(false)}
type="submit"
disabled={!$isDeploymentEnabled || !$appSession.isAdmin}
class="btn btn-sm gap-2"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="w-6 h-6 text-error "
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<rect x="6" y="5" width="4" height="14" rx="1" />
<rect x="14" y="5" width="4" height="14" rx="1" />
</svg>
Stop
</button>
{:else if $status.service.overallStatus === 'degraded'}
<button
on:click={() => stopService()}
type="submit"
disabled={!$isDeploymentEnabled || !$appSession.isAdmin}
class="btn btn-sm gap-2"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="w-6 h-6 text-error"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<rect x="6" y="5" width="4" height="14" rx="1" />
<rect x="14" y="5" width="4" height="14" rx="1" />
</svg> Stop
</button>
{:else if $status.service.overallStatus === 'stopped'}
{#if $status.service.overallStatus === 'degraded'}
<button
class="btn btn-sm gap-2"
disabled={!$isDeploymentEnabled || !$appSession.isAdmin}
on:click={() => restartService()}
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="w-6 h-6"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path
d="M16.3 5h.7a2 2 0 0 1 2 2v10a2 2 0 0 1 -2 2h-10a2 2 0 0 1 -2 -2v-10a2 2 0 0 1 2 -2h5l-2.82 -2.82m0 5.64l2.82 -2.82"
transform="rotate(-45 12 12)"
/>
</svg>
{$status.application.statuses.length === 1 ? 'Force Redeploy' : 'Redeploy Stack'}
</button>
{:else if $status.service.overallStatus === 'stopped'}
<button
class="btn btn-sm gap-2"
disabled={!$isDeploymentEnabled || !$appSession.isAdmin}
on:click={() => startService()}
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="w-6 h-6 text-pink-500"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M7 4v16l13 -8z" />
</svg>
Deploy
</button>
{/if}
{/if}
</div>
</div>
<div
class="mx-auto max-w-screen-2xl px-0 lg:px-10 grid grid-cols-1"
class:lg:grid-cols-4={!$page.url.pathname.startsWith(`/services/${id}/configuration/`)}
>
{#if !$page.url.pathname.startsWith(`/services/${id}/configuration/`)}
<nav class="header flex flex-col lg:pt-0 ">
<Menu {service} {template} />
</nav>
{/if}
<div class="pt-0 col-span-0 lg:col-span-3 pb-24 px-4 lg:px-0">
<slot />
</div>
</div>

View File

@@ -0,0 +1,46 @@
import { error } from '@sveltejs/kit';
import { trpc } from '$lib/store';
import type { LayoutLoad } from './$types';
import { redirect } from '@sveltejs/kit';
function checkConfiguration(service: any): string | null {
let configurationPhase = null;
if (!service.type) {
configurationPhase = 'type';
} else if (!service.destinationDockerId) {
configurationPhase = 'destination';
}
return configurationPhase;
}
export const load: LayoutLoad = async ({ params, url }) => {
const { pathname } = new URL(url);
const { id } = params;
try {
const service = await trpc.services.getServices.query({ id });
if (!service) {
throw redirect(307, '/services');
}
const configurationPhase = checkConfiguration(service);
console.log({ configurationPhase });
// if (
// configurationPhase &&
// pathname !== `/applications/${params.id}/configuration/${configurationPhase}`
// ) {
// throw redirect(302, `/applications/${params.id}/configuration/${configurationPhase}`);
// }
return {
...service.data
};
} catch (err) {
if (err instanceof Error) {
throw error(500, {
message: 'An unexpected error occurred, please try again later.' + '<br><br>' + err.message
});
}
throw error(500, {
message: 'An unexpected error occurred, please try again later.'
});
}
};

View File

@@ -0,0 +1,562 @@
<script lang="ts">
import type { PageData } from './$types';
export let data: PageData;
let service = data.service;
let template = data.template;
let tags = data.tags;
import cuid from 'cuid';
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { errorNotification, getDomain } from '$lib/common';
import {
appSession,
status,
setLocation,
addToast,
checkIfDeploymentEnabledServices,
isDeploymentEnabled
} from '$lib/store';
import CopyPasswordField from '$lib/components/CopyPasswordField.svelte';
import Setting from '$lib/components/Setting.svelte';
import DocLink from '$lib/components/DocLink.svelte';
import Explainer from '$lib/components/Explainer.svelte';
import ServiceStatus from './components/ServiceStatus.svelte';
import { saveForm } from './utils';
import Select from 'svelte-select';
import Wordpress from './components/Wordpress.svelte';
import { browser } from '$app/environment';
const { id } = $page.params;
let hostPorts = Object.keys(template).filter((key) => {
if (template[key]?.hostPorts?.length > 0) {
return true;
}
});
$: isDisabled =
!$appSession.isAdmin ||
$status.service.overallStatus === 'degraded' ||
$status.service.overallStatus === 'healthy' ||
$status.service.initialLoading;
let forceSave = false;
let loading = {
save: false,
verification: false,
cleanup: false
};
let dualCerts = service.dualCerts;
let nonWWWDomain = service.fqdn && getDomain(service.fqdn).replace(/^www\./, '');
let isNonWWWDomainOK = false;
let isWWWDomainOK = false;
function containerClass() {
return 'text-white bg-transparent font-thin px-0 w-full border border-dashed border-coolgray-200';
}
async function isDNSValid(domain: any, isWWW: any) {
try {
// await get(`/services/${id}/check?domain=${domain}`);
addToast({
message: 'DNS configuration is valid.',
type: 'success'
});
isWWW ? (isWWWDomainOK = true) : (isNonWWWDomainOK = true);
return true;
} catch (error) {
errorNotification(error);
isWWW ? (isWWWDomainOK = false) : (isNonWWWDomainOK = false);
return false;
}
}
async function handleSubmit(e: any) {
if (loading.save) return;
loading.save = true;
try {
const formData = new FormData(e.target);
// await post(`/services/${id}/check`, {
// fqdn: service.fqdn,
// forceSave,
// dualCerts,
// exposePort: service.exposePort
// });
for (const setting of service.serviceSetting) {
if (setting.variableName?.startsWith('$$config_coolify_fqdn') && setting.value) {
for (let field of formData) {
const [key, value] = field;
if (setting.name === key) {
if (setting.value !== value) {
// await post(`/services/${id}/check`, {
// fqdn: value,
// otherFqdn: true
// });
}
}
}
}
}
if (formData) service = await saveForm(formData, service);
setLocation(service);
forceSave = false;
$isDeploymentEnabled = checkIfDeploymentEnabledServices(service);
return addToast({
message: 'Configuration saved.',
type: 'success'
});
} catch (error) {
//@ts-ignore
if (error?.message.startsWith('DNS not set')) {
forceSave = true;
if (dualCerts) {
isNonWWWDomainOK = await isDNSValid(getDomain(nonWWWDomain), false);
isWWWDomainOK = await isDNSValid(getDomain(`www.${nonWWWDomain}`), true);
} else {
const isWWW = getDomain(service.fqdn).includes('www.');
if (isWWW) {
isWWWDomainOK = await isDNSValid(getDomain(`www.${nonWWWDomain}`), true);
} else {
isNonWWWDomainOK = await isDNSValid(getDomain(nonWWWDomain), false);
}
}
}
return errorNotification(error);
} finally {
loading.save = false;
}
}
async function setEmailsToVerified() {
loading.verification = true;
try {
// await post(`/services/${id}/${service.type}/activate`, { id: service.id });
return addToast({
message: 'Emails have been verified.',
type: 'success'
});
} catch (error) {
return errorNotification(error);
} finally {
loading.verification = false;
}
}
async function migrateAppwriteDB() {
loading.verification = true;
try {
// await post(`/services/${id}/${service.type}/migrate`, { id: service.id });
return addToast({
message: "Appwrite's database has been migrated.",
type: 'success'
});
} catch (error) {
return errorNotification(error);
} finally {
loading.verification = false;
}
}
async function changeSettings(name: any) {
if (!$appSession.isAdmin) return;
try {
if (name === 'dualCerts') {
dualCerts = !dualCerts;
}
// await post(`/services/${id}/settings`, { dualCerts });
return addToast({
message: 'Settings updated.',
type: 'success'
});
} catch (error) {
return errorNotification(error);
}
}
async function cleanupLogs() {
loading.cleanup = true;
try {
// await post(`/services/${id}/${service.type}/cleanup`, { id: service.id });
return addToast({
message: 'Cleared unnecessary database logs.',
type: 'success'
});
} catch (error) {
return errorNotification(error);
} finally {
loading.cleanup = false;
}
}
async function selectTag(event: any) {
service.version = event.detail.value;
}
onMount(async () => {
if (browser && window.location.hostname === 'demo.coolify.io' && !service.fqdn) {
service.fqdn = `http://${cuid()}.demo.coolify.io`;
// if (service.type === 'wordpress') {
// service.wordpress.mysqlDatabase = 'db';
// }
// if (service.type === 'plausibleanalytics') {
// service.plausibleAnalytics.email = 'noreply@demo.com';
// service.plausibleAnalytics.username = 'admin';
// }
// if (service.type === 'minio') {
// service.minio.apiFqdn = `http://${cuid()}.demo.coolify.io`;
// }
// if (service.type === 'ghost') {
// service.ghost.mariadbDatabase = 'db';
// }
// if (service.type === 'fider') {
// service.fider.emailNoreply = 'noreply@demo.com';
// }
// await handleSubmit();
}
});
</script>
<div class="w-full">
<form id="saveForm" on:submit|preventDefault={handleSubmit}>
<div class="mx-auto w-full">
<div class="flex flex-row border-b border-coolgray-500 mb-6 space-x-2">
<div class="title font-bold pb-3 ">General</div>
{#if $appSession.isAdmin}
<button
type="submit"
class="btn btn-sm"
class:bg-orange-600={forceSave}
class:hover:bg-orange-400={forceSave}
class:loading={loading.save}
class:btn-primary={!loading.save}
disabled={loading.save}
>{loading.save ? 'Saving...' : forceSave ? 'Continue' : 'Force Save'}</button
>
{/if}
{#if service.type === 'plausibleanalytics' && $status.service.overallStatus === 'healthy'}
<button
class="btn btn-sm"
on:click|preventDefault={setEmailsToVerified}
disabled={loading.verification}
class:loading={loading.verification}
>{loading.verification ? 'Verifying...' : 'Verify without SMTP'}</button
>
<button
class="btn btn-sm"
on:click|preventDefault={cleanupLogs}
disabled={loading.cleanup}
class:loading={loading.cleanup}>Cleanup Unnecessary Database Logs</button
>
{/if}
{#if service.type === 'appwrite' && $status.service.overallStatus === 'healthy'}
<button
class="btn btn-sm"
on:click|preventDefault={migrateAppwriteDB}
disabled={loading.verification}
class:loading={loading.verification}
>{loading.verification
? 'Migrating... it may take a while...'
: "Migrate Appwrite's Database"}</button
>
<div>
<DocLink url="https://appwrite.io/docs/upgrade#run-the-migration" />
</div>
{/if}
</div>
</div>
<div class="grid grid-flow-row gap-2 px-4">
<div class="mt-2 grid grid-cols-2 items-center">
<label for="name">Name</label>
<input
name="name"
id="name"
class="w-full"
disabled={!$appSession.isAdmin}
bind:value={service.name}
required
/>
</div>
<div class="grid grid-cols-2 items-center">
<label for="version">Version / Tag</label>
{#if tags.tags?.length > 0}
<div class="custom-select-wrapper w-full">
<Select
form="saveForm"
containerClasses={isDisabled && containerClass()}
{isDisabled}
id="version"
showIndicator={!isDisabled}
items={[...tags.tags]}
on:select={selectTag}
value={service.version}
isClearable={false}
/>
</div>
{:else}
<input class="w-full border-red-500" disabled placeholder="Error getting tags..." />
{/if}
</div>
<div class="grid grid-cols-2 items-center">
<label for="destination">Destination</label>
<div>
{#if service.destinationDockerId}
<div class="no-underline">
<input
value={service.destinationDocker.name}
id="destination"
disabled
class="bg-transparent w-full"
/>
</div>
{/if}
</div>
</div>
<div class="grid grid-cols-2 items-center">
<label for="fqdn"
>FQDN
<Explainer
explanation={"If you specify <span class='text-settings '>https</span>, the application will be accessible only over https. SSL certificate will be generated for you.<br>If you specify <span class='text-settings '>www</span>, the application will be redirected (302) from non-www and vice versa.<br><br>To modify the domain, you must first stop the application.<br><br><span class='text-white '>You must set your DNS to point to the server IP in advance.</span>"}
/>
</label>
<CopyPasswordField
placeholder="eg: https://coollabs.io"
readonly={isDisabled}
disabled={isDisabled}
name="fqdn"
id="fqdn"
pattern="^https?://([a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{'{'}2,{'}'}$"
bind:value={service.fqdn}
required
/>
</div>
{#each Object.keys(template) as oneService}
{#each template[oneService].fqdns as fqdn}
<div class="grid grid-cols-2 items-center py-1">
<label for={fqdn.name}>{fqdn.label || fqdn.name}</label>
<CopyPasswordField
placeholder="eg: https://coolify.io"
readonly={isDisabled}
disabled={isDisabled}
required={fqdn.required}
name={fqdn.name}
id={fqdn.name}
bind:value={fqdn.value}
/>
</div>
{/each}
{/each}
</div>
{#if forceSave}
<div class="flex-col space-y-2 pt-4 text-center">
{#if isNonWWWDomainOK}
<button
class="btn btn-sm bg-green-600 hover:bg-green-500"
on:click|preventDefault={() => isDNSValid(getDomain(nonWWWDomain), false)}
>DNS settings for {nonWWWDomain} is OK, click to recheck.</button
>
{:else}
<button
class="btn btn-sm bg-red-600 hover:bg-red-500"
on:click|preventDefault={() => isDNSValid(getDomain(nonWWWDomain), false)}
>DNS settings for {nonWWWDomain} is invalid, click to recheck.</button
>
{/if}
{#if dualCerts}
{#if isWWWDomainOK}
<button
class="btn btn-sm bg-green-600 hover:bg-green-500"
on:click|preventDefault={() => isDNSValid(getDomain(`www.${nonWWWDomain}`), true)}
>DNS settings for www.{nonWWWDomain} is OK, click to recheck.</button
>
{:else}
<button
class="btn btn-sm bg-red-600 hover:bg-red-500"
on:click|preventDefault={() => isDNSValid(getDomain(`www.${nonWWWDomain}`), true)}
>DNS settings for www.{nonWWWDomain} is invalid, click to recheck.</button
>
{/if}
{/if}
</div>
{/if}
<div class="grid grid-flow-row gap-2 px-4">
<div class="grid grid-cols-2 items-center">
<Setting
id="dualCerts"
disabled={$status.service.isRunning || !$appSession.isAdmin}
dataTooltip="You must stop the application to change this setting."
bind:setting={dualCerts}
title="Generate SSL for www and non-www?"
description={"It will generate certificates for both www and non-www. <br>You need to have <span class='text-settings'>both DNS entries</span> set in advance.<br><br>Service needs to be restarted."}
on:click={() => !$status.service.isRunning && changeSettings('dualCerts')}
/>
</div>
{#if hostPorts.length === 0}
<div class="grid grid-cols-2 items-center">
<label for="exposePort"
>Exposed Port <Explainer
explanation={'You can expose your application to a port on the host system.<br><br>Useful if you would like to use your own reverse proxy or tunnel and also in development mode. Otherwise leave empty.'}
/></label
>
<input
class="w-full"
readonly={isDisabled}
disabled={isDisabled}
name="exposePort"
id="exposePort"
bind:value={service.exposePort}
placeholder="12345"
/>
</div>
{/if}
</div>
<div class="pt-6">
{#each Object.keys(template) as oneService}
<div
class="flex flex-row my-2 space-x-2 mb-6"
class:my-6={template[oneService].environment.length > 0 &&
template[oneService].environment.find((env) => env.main === oneService)}
class:border-b={template[oneService].environment.length > 0 &&
template[oneService].environment.find((env) => env.main === oneService)}
class:border-coolgray-500={template[oneService].environment.length > 0 &&
template[oneService].environment.find((env) => env.main === oneService)}
>
<div class="title font-bold pb-3 capitalize">
{template[oneService].name ||
oneService.replace(`${id}-`, '').replace(id, service.type)}
</div>
<ServiceStatus id={oneService} />
</div>
<div class="grid grid-flow-row gap-2 px-4">
{#if template[oneService].environment.length > 0}
{#each template[oneService].environment as variable}
{#if variable.main === oneService}
<div class="grid grid-cols-2 items-center gap-2">
<label class="h-10" for={variable.name}
>{variable.label || variable.name}
{#if variable.description}
<Explainer explanation={variable.description} />
{/if}</label
>
{#if variable.defaultValue === '$$generate_fqdn'}
<CopyPasswordField
disabled
readonly
name={variable.name}
id={variable.name}
value={service.fqdn}
placeholder={variable.placeholder}
required={variable?.required}
/>
{:else if variable.defaultValue === '$$generate_fqdn_slash'}
<CopyPasswordField
disabled
readonly
name={variable.name}
id={variable.name}
value={service.fqdn + '/' || ''}
placeholder={variable.placeholder}
required={variable?.required}
/>
{:else if variable.defaultValue === '$$generate_domain'}
<CopyPasswordField
disabled
readonly
name={variable.name}
id={variable.name}
value={getDomain(service.fqdn) || ''}
placeholder={variable.placeholder}
required={variable?.required}
/>
{:else if variable.defaultValue === '$$generate_network'}
<CopyPasswordField
disabled
readonly
name={variable.name}
id={variable.name}
value={service.destinationDocker.network}
placeholder={variable.placeholder}
required={variable?.required}
/>
{:else if variable.defaultValue === 'true' || variable.defaultValue === 'false'}
{#if variable.value === 'true' || variable.value === 'false' || variable.value === 'invite_only'}
<select
class="w-full font-normal"
readonly={isDisabled}
disabled={isDisabled}
id={variable.name}
name={variable.name}
bind:value={variable.value}
form="saveForm"
placeholder={variable.placeholder}
required={variable?.required}
>
<option value="true">enabled</option>
<option value="false">disabled</option>
{#if service.type.startsWith('plausibleanalytics') && variable.id == 'config_disable_registration'}
<option value="invite_only">invite_only</option>
{/if}
</select>
{:else}
<select
class="w-full font-normal"
readonly={isDisabled}
disabled={isDisabled}
id={variable.name}
name={variable.name}
bind:value={variable.defaultValue}
form="saveForm"
placeholder={variable.placeholder}
required={variable?.required}
>
<option value="true">true</option>
<option value="false">false</option>
</select>
{/if}
{:else if variable.defaultValue === '$$generate_password'}
<CopyPasswordField
isPasswordField
readonly
disabled
name={variable.name}
id={variable.name}
value={variable.value}
placeholder={variable.placeholder}
required={variable?.required}
/>
{:else if variable.type === 'textarea'}
<textarea
class="w-full"
value={variable.value}
readonly={isDisabled}
disabled={isDisabled}
class:resize-none={$status.service.overallStatus === 'healthy'}
rows="5"
name={variable.name}
id={variable.name}
placeholder={variable.placeholder}
required={variable?.required}
/>
{:else}
<CopyPasswordField
isPasswordField={variable.id.startsWith('secret')}
required={variable?.required}
readonly={variable.readOnly || isDisabled}
disabled={variable.readOnly || isDisabled}
name={variable.name}
id={variable.name}
value={variable.value}
placeholder={variable.placeholder}
/>
{/if}
</div>
{/if}
{/each}
{#if template[oneService].name.toLowerCase() === 'wordpress' && service.type.startsWith('wordpress')}
<Wordpress {service} />
{/if}
{/if}
</div>
{/each}
</div>
</form>
</div>

View File

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

View File

@@ -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}

View File

@@ -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}

View File

@@ -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}

View File

@@ -0,0 +1,46 @@
<script lang="ts">
import type { PageData } from './$types';
export let data: PageData;
let service: any = data.service.data;
import { page } from '$app/stores';
import { appSession, status, trpc } from '$lib/store';
import { errorNotification } from '$lib/common';
import { goto } from '$app/navigation';
const { id } = $page.params;
async function deleteService() {
const sure = confirm('Are you sure you want to delete this service?');
if (sure) {
$status.service.initialLoading = true;
try {
if (service.type && $status.service.overallStatus !== 'stopped') {
await trpc.services.stop.mutate({ id });
}
await trpc.services.delete.mutate({ id });
return await goto('/');
} catch (error) {
return errorNotification(error);
} finally {
$status.service.initialLoading = false;
}
}
}
</script>
<div class="mx-auto w-full">
<div class="flex flex-row border-b border-coolgray-500 mb-6 space-x-2">
<div class="title font-bold pb-3">Danger Zone</div>
</div>
<button
id="forcedelete"
on:click={() => deleteService()}
type="submit"
disabled={!$appSession.isAdmin}
class:bg-red-600={$appSession.isAdmin}
class:hover:bg-red-500={$appSession.isAdmin}
class="btn btn-lg btn-error text-sm"
>
Delete Service
</button>
</div>

View File

@@ -0,0 +1,173 @@
<script lang="ts">
import { page } from '$app/stores';
import { errorNotification } from '$lib/common';
import { trpc } from '$lib/store';
import { onDestroy, onMount } from 'svelte';
let service: any = {};
let template: any = null;
let logsLoading = false;
let loadLogsInterval: any = null;
let logs: any = [];
let lastLog: any = null;
let followingInterval: any;
let followingLogs: any;
let logsEl: any;
let position = 0;
let selectedService: any = null;
let noContainer = false;
const { id } = $page.params;
onMount(async () => {
const { data } = await trpc.services.getServices.query({ id });
template = data.template;
service = data.service;
});
onDestroy(() => {
clearInterval(loadLogsInterval);
clearInterval(followingInterval);
});
async function loadLogs() {
if (logsLoading) return;
try {
const { data } = await trpc.services.getLogs.query({
id,
containerId: selectedService,
since: Number(lastLog?.split(' ')[0]) || 0
});
if (data.noContainer) {
noContainer = true;
logs = [];
if (logs.length > 0) {
clearInterval(loadLogsInterval);
selectedService = null;
}
return;
} else {
noContainer = false;
}
if (data?.logs && data.logs[data.logs.length - 1] !== logs[logs.length - 1]) {
logs = logs.concat(data.logs);
lastLog = data.logs[data.logs.length - 1];
}
} catch (error) {
return errorNotification(error);
}
}
function detect() {
if (position < logsEl.scrollTop) {
position = logsEl.scrollTop;
} else {
if (followingLogs) {
clearInterval(followingInterval);
followingLogs = false;
}
position = logsEl.scrollTop;
}
}
function followBuild() {
followingLogs = !followingLogs;
if (followingLogs) {
followingInterval = setInterval(() => {
logsEl.scrollTop = logsEl.scrollHeight;
window.scrollTo(0, document.body.scrollHeight);
}, 1000);
} else {
clearInterval(followingInterval);
}
}
async function selectService(service: any, init: boolean = false) {
if (loadLogsInterval) clearInterval(loadLogsInterval);
if (followingInterval) clearInterval(followingInterval);
logs = [];
lastLog = null;
followingLogs = false;
selectedService = service;
loadLogs();
loadLogsInterval = setInterval(() => {
loadLogs();
}, 1000);
}
</script>
<div class="mx-auto w-full">
<div class="flex flex-row border-b border-coolgray-500 mb-6 space-x-2">
<div class="title font-bold pb-3">Service Logs</div>
</div>
</div>
{#if template}
<div class="grid grid-cols-3 gap-2 lg:gap-8 pb-4">
{#each Object.keys(template) as service}
<button
on:click={() => selectService(service, true)}
class:bg-primary={selectedService === service}
class:bg-coolgray-200={selectedService !== service}
class="w-full rounded p-5 hover:bg-primary font-bold"
>
{#if template[service].name}
{template[service].name || ''} <br /><span class="text-xs">({service})</span>
{:else}
<span>{service}</span>
{/if}
</button>
{/each}
</div>
{:else}
<div class="w-full flex justify-center font-bold text-xl">Loading components...</div>
{/if}
{#if selectedService}
<div class="flex flex-row justify-center space-x-2">
{#if logs.length === 0}
{#if noContainer}
<div class="text-xl font-bold tracking-tighter">Container not found / exited.</div>
{/if}
{:else}
<div class="relative w-full">
<div class="flex justify-start sticky space-x-2 pb-2">
<button on:click={followBuild} class="btn btn-sm " class:bg-coollabs={followingLogs}>
<svg
xmlns="http://www.w3.org/2000/svg"
class="w-6 h-6 mr-2"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<circle cx="12" cy="12" r="9" />
<line x1="8" y1="12" x2="12" y2="16" />
<line x1="12" y1="8" x2="12" y2="16" />
<line x1="16" y1="12" x2="12" y2="16" />
</svg>
{followingLogs ? 'Following Logs...' : 'Follow Logs'}
</button>
{#if loadLogsInterval}
<button id="streaming" class="btn btn-sm bg-transparent border-none loading"
>Streaming logs</button
>
{/if}
</div>
<div
bind:this={logsEl}
on:scroll={detect}
class="font-mono w-full bg-coolgray-100 border border-coolgray-200 p-5 overflow-x-auto overflox-y-auto max-h-[80vh] rounded mb-20 flex flex-col scrollbar-thumb-coollabs scrollbar-track-coolgray-200 scrollbar-w-1"
>
{#each logs as log}
<p>{log + '\n'}</p>
{/each}
</div>
</div>
{/if}
</div>
{/if}

View File

@@ -0,0 +1,98 @@
<script lang="ts">
import type { PageData } from './$types';
export let data: PageData;
let secrets = data.secrets;
import Secret from './components/Secret.svelte';
import { page } from '$app/stores';
import pLimit from 'p-limit';
import { addToast, appSession, trpc } from '$lib/store';
import { saveSecret } from './utils';
const limit = pLimit(1);
const { id } = $page.params;
let batchSecrets = '';
async function refreshSecrets() {
const { data } = await trpc.services.getSecrets.query({ id });
secrets = [...data.secrets];
}
async function getValues() {
if (!batchSecrets) return;
const eachValuePair = batchSecrets.split('\n');
const batchSecretsPairs = eachValuePair
.filter((secret) => !secret.startsWith('#') && secret)
.map((secret) => {
const [name, ...rest] = secret.split('=');
const value = rest.join('=');
const cleanValue = value?.replaceAll('"', '') || '';
return {
name: name.trim(),
value: cleanValue.trim(),
isNew: !secrets.find((secret: any) => name === secret.name)
};
});
await Promise.all(
batchSecretsPairs.map(({ name, value, isNew }) =>
limit(() => saveSecret({ name, value, serviceId: id, isNew }))
)
);
batchSecrets = '';
await refreshSecrets();
addToast({
message: 'Secrets saved.',
type: 'success'
});
}
</script>
<div class="mx-auto w-full">
<div class="flex flex-row border-b border-coolgray-500 mb-6 space-x-2">
<div class="title font-bold pb-3">Secrets</div>
</div>
<div class="overflow-x-auto">
<table class="w-full border-separate text-left">
<thead>
<tr class="uppercase">
<th scope="col">Name</th>
<th scope="col uppercase">Value</th>
<th scope="col uppercase" class="w-96 text-center">Action</th>
</tr>
</thead>
<tbody class="space-y-2">
{#each secrets as secret}
{#key secret.id}
<tr>
<Secret
name={secret.name}
value={secret.value}
readonly={secret.readOnly}
on:refresh={refreshSecrets}
/>
</tr>
{/key}
{/each}
<tr>
<Secret isNewSecret on:refresh={refreshSecrets} />
</tr>
</tbody>
</table>
</div>
{#if $appSession.isAdmin}
<form on:submit|preventDefault={getValues} class="mb-12 w-full">
<div class="flex flex-row border-b border-coolgray-500 mb-6 space-x-2 pt-10">
<div class="flex flex-row space-x-2">
<div class="title font-bold pb-3 ">Paste <code>.env</code> file</div>
<button type="submit" class="btn btn-sm bg-primary">Add Secrets in Batch</button>
</div>
</div>
<textarea
placeholder={`PORT=1337\nPASSWORD=supersecret`}
bind:value={batchSecrets}
class="mb-2 min-h-[200px] w-full"
/>
</form>
{/if}
</div>

View File

@@ -0,0 +1,16 @@
import { error } from '@sveltejs/kit';
import { trpc } from '$lib/store';
import type { PageLoad } from './$types';
export const ssr = false;
export const load: PageLoad = async ({ params }) => {
try {
const { id } = params;
const { data } = await trpc.services.getSecrets.query({ id });
return data;
} catch (err) {
throw error(500, {
message: 'An unexpected error occurred, please try again later.'
});
}
};

View File

@@ -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}

View File

@@ -0,0 +1,78 @@
import { errorNotification } from '$lib/common';
import { trpc } from '$lib/store';
type Props = {
isNew: boolean;
name: string;
value: string;
isBuildSecret?: boolean;
isPRMRSecret?: boolean;
isNewSecret?: boolean;
serviceId: string;
};
export async function saveSecret({
isNew,
name,
value,
isBuildSecret,
isNewSecret
}: Props): Promise<void> {
if (!name) return errorNotification('Name is required');
if (!value) return errorNotification('Value is required');
try {
await trpc.services.createSecret.mutate({
name,
value,
isBuildSecret,
isNew: isNew || false
});
if (isNewSecret) {
name = '';
value = '';
isBuildSecret = false;
}
} catch (error) {
throw error;
}
}
export async function saveForm(formData: any, service: any) {
const settings = service.serviceSetting.map((setting: { name: string }) => setting.name);
const secrets = service.serviceSecret.map((secret: { name: string }) => secret.name);
const baseCoolifySetting = ['name', 'fqdn', 'exposePort', 'version'];
for (let field of formData) {
const [key, value] = field;
if (secrets.includes(key) && value) {
await trpc.services.createSecret.mutate({
name: key,
value
});
} else {
service.serviceSetting = service.serviceSetting.map((setting: any) => {
if (setting.name === key) {
setting.changed = true;
setting.value = value;
}
return setting;
});
if (!settings.includes(key) && !baseCoolifySetting.includes(key)) {
service.serviceSetting.push({
id: service.id,
name: key,
value: value,
isNew: true
});
}
if (baseCoolifySetting.includes(key)) {
service[key] = value;
}
}
}
await trpc.services.saveService.mutate(service);
const {
data: { service: reloadedService }
} = await trpc.services.getServices.query({ id: service.id });
return reloadedService;
}

View File

@@ -0,0 +1,73 @@
<script lang="ts">
import type { PageData } from './$types';
export let data: PageData;
let persistentStorages = data.persistentStorages;
let template = data.template;
import { page } from '$app/stores';
import Storage from './components/Storage.svelte';
import Explainer from '$lib/components/Explainer.svelte';
import { appSession, trpc } from '$lib/store';
const { id } = $page.params;
async function refreshStorage() {
const { data } = await trpc.services.getStorages.query({ id });
persistentStorages = [...data.persistentStorages];
}
let services = Object.keys(template).map((service) => {
if (template[service]?.name) {
return {
name: template[service].name,
id: service
};
} else {
return service;
}
});
</script>
<div class="w-full">
<div class="mx-auto w-full">
<div class="flex flex-row border-b border-coolgray-500 mb-6 space-x-2">
<div class="title font-bold pb-3">
Persistent Volumes <Explainer
position="dropdown-bottom"
explanation="You can specify any folder that you want to be persistent across deployments.<br><br><span class='text-settings '>/example</span> means it will preserve <span class='text-settings '>/example</span> between deployments.<br><br>Your application's data is copied to <span class='text-settings '>/app</span> inside the container, you can preserve data under it as well, like <span class='text-settings '>/app/db</span>.<br><br>This is useful for storing data such as a <span class='text-settings '>database (SQLite)</span> or a <span class='text-settings '>cache</span>."
/>
</div>
</div>
{#if persistentStorages.filter((s) => s.predefined).length > 0}
<div class="title">Predefined Volumes</div>
<div class="w-full lg:px-0 px-4">
<div class="grid grid-col-1 lg:grid-cols-2 pt-2 gap-2">
<div class="font-bold uppercase">Container</div>
<div class="font-bold uppercase">Volume ID : Mount Dir</div>
</div>
</div>
{#each persistentStorages.filter((s) => s.predefined) as storage}
{#key storage.id}
<Storage on:refresh={refreshStorage} {storage} {services} />
{/key}
{/each}
{/if}
{#if persistentStorages.filter((s) => !s.predefined).length > 0}
<div class="title" class:pt-10={persistentStorages.filter((s) => s.predefined).length > 0}>
Custom Volumes
</div>
{#each persistentStorages.filter((s) => !s.predefined) as storage}
{#key storage.id}
<Storage on:refresh={refreshStorage} {storage} {services} />
{/key}
{/each}
{/if}
{#if $appSession.isAdmin}
<div class="title" class:pt-10={persistentStorages.filter((s) => s.predefined).length > 0}>
Add New Volume
</div>
<Storage on:refresh={refreshStorage} isNew {services} />
{/if}
</div>
</div>

View File

@@ -0,0 +1,16 @@
import { error } from '@sveltejs/kit';
import { trpc } from '$lib/store';
import type { PageLoad } from './$types';
export const ssr = false;
export const load: PageLoad = async ({ params }) => {
try {
const { id } = params;
const { data } = await trpc.services.getStorages.query({ id });
return data;
} catch (err) {
throw error(500, {
message: 'An unexpected error occurred, please try again later.'
});
}
};

View File

@@ -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>

View File

@@ -0,0 +1,79 @@
import { errorNotification } from '$lib/common';
type Props = {
isNew: boolean;
name: string;
value: string;
isBuildSecret?: boolean;
isPRMRSecret?: boolean;
isNewSecret?: boolean;
serviceId: string;
};
export async function saveSecret({
isNew,
name,
value,
isBuildSecret,
isPRMRSecret,
isNewSecret,
serviceId
}: Props): Promise<void> {
if (!name) return errorNotification('Name is required');
if (!value) return errorNotification('Value is required');
try {
// await post(`/services/${serviceId}/secrets`, {
// name,
// value,
// isBuildSecret,
// isPRMRSecret,
// isNew: isNew || false
// });
if (isNewSecret) {
name = '';
value = '';
isBuildSecret = false;
}
} catch (error) {
throw error
}
}
export async function saveForm(formData: any, service: any) {
const settings = service.serviceSetting.map((setting: { name: string }) => setting.name);
const secrets = service.serviceSecret.map((secret: { name: string }) => secret.name);
const baseCoolifySetting = ['name', 'fqdn', 'exposePort', 'version'];
for (let field of formData) {
const [key, value] = field;
if (secrets.includes(key) && value) {
// await post(`/services/${service.id}/secrets`, {
// name: key,
// value,
// });
} else {
service.serviceSetting = service.serviceSetting.map((setting: any) => {
if (setting.name === key) {
setting.changed = true;
setting.value = value;
}
return setting;
});
if (!settings.includes(key) && !baseCoolifySetting.includes(key)) {
service.serviceSetting.push({
id: service.id,
name: key,
value: value,
isNew: true
});
}
if (baseCoolifySetting.includes(key)) {
service[key] = value;
}
}
}
// await post(`/services/${service.id}`, { ...service });
// const { service: reloadedService } = await get(`/services/${service.id}`);
// return reloadedService;
}