feat: previewApplications finalized

This commit is contained in:
Andras Bacsai 2022-09-13 15:50:20 +02:00
parent c40b80436a
commit d9908b3d61
24 changed files with 805 additions and 368 deletions

View File

@ -4,7 +4,8 @@ ALTER TABLE "Build" ADD COLUMN "previewApplicationId" TEXT;
-- CreateTable -- CreateTable
CREATE TABLE "PreviewApplication" ( CREATE TABLE "PreviewApplication" (
"id" TEXT NOT NULL PRIMARY KEY, "id" TEXT NOT NULL PRIMARY KEY,
"prMrId" TEXT NOT NULL, "pullmergeRequestId" TEXT NOT NULL,
"sourceBranch" TEXT NOT NULL,
"isRandomDomain" BOOLEAN NOT NULL DEFAULT false, "isRandomDomain" BOOLEAN NOT NULL DEFAULT false,
"customDomain" TEXT, "customDomain" TEXT,
"applicationId" TEXT NOT NULL, "applicationId" TEXT NOT NULL,

View File

@ -119,18 +119,19 @@ model Application {
secrets Secret[] secrets Secret[]
teams Team[] teams Team[]
connectedDatabase ApplicationConnectedDatabase? connectedDatabase ApplicationConnectedDatabase?
previewApplication PreviewApplication[] previewApplication PreviewApplication[]
} }
model PreviewApplication { model PreviewApplication {
id String @id @default(cuid()) id String @id @default(cuid())
prMrId String pullmergeRequestId String
isRandomDomain Boolean @default(false) sourceBranch String
customDomain String? isRandomDomain Boolean @default(false)
applicationId String @unique customDomain String?
application Application @relation(fields: [applicationId], references: [id]) applicationId String @unique
createdAt DateTime @default(now()) application Application @relation(fields: [applicationId], references: [id])
updatedAt DateTime @updatedAt createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
} }
model ApplicationConnectedDatabase { model ApplicationConnectedDatabase {
@ -222,22 +223,22 @@ model BuildLog {
} }
model Build { model Build {
id String @id @default(cuid()) id String @id @default(cuid())
type String type String
applicationId String? applicationId String?
destinationDockerId String? destinationDockerId String?
gitSourceId String? gitSourceId String?
githubAppId String? githubAppId String?
gitlabAppId String? gitlabAppId String?
commit String? commit String?
pullmergeRequestId String? pullmergeRequestId String?
previewApplicationId String? previewApplicationId String?
forceRebuild Boolean @default(false) forceRebuild Boolean @default(false)
sourceBranch String? sourceBranch String?
branch String? branch String?
status String? @default("queued") status String? @default("queued")
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
} }
model DestinationDocker { model DestinationDocker {

View File

@ -42,7 +42,7 @@ import * as buildpacks from '../lib/buildPacks';
application = decryptApplication(application) application = decryptApplication(application)
const originalApplicationId = application.id const originalApplicationId = application.id
if (pullmergeRequestId) { if (pullmergeRequestId) {
const previewApplications = await prisma.previewApplication.findMany({where: {applicationId: originalApplicationId, prMrId: pullmergeRequestId}}) const previewApplications = await prisma.previewApplication.findMany({where: {applicationId: originalApplicationId, pullmergeRequestId}})
if (previewApplications.length > 0) { if (previewApplications.length > 0) {
previewApplicationId = previewApplications[0].id previewApplicationId = previewApplications[0].id
} }

View File

@ -707,7 +707,6 @@ export async function buildCacheImageWithNode(data, imageForBuild) {
Dockerfile.push(`RUN ${installCommand}`); Dockerfile.push(`RUN ${installCommand}`);
} }
Dockerfile.push(`RUN ${buildCommand}`); Dockerfile.push(`RUN ${buildCommand}`);
console.log(Dockerfile.join('\n'))
await fs.writeFile(`${workdir}/Dockerfile-cache`, Dockerfile.join('\n')); await fs.writeFile(`${workdir}/Dockerfile-cache`, Dockerfile.join('\n'));
await buildImage({ ...data, isCache: true }); await buildImage({ ...data, isCache: true });
} }

View File

@ -21,7 +21,7 @@ import { scheduler } from './scheduler';
import { supportedServiceTypesAndVersions } from './services/supportedVersions'; import { supportedServiceTypesAndVersions } from './services/supportedVersions';
import { includeServices } from './services/common'; import { includeServices } from './services/common';
export const version = '3.10.3'; export const version = '3.10.4';
export const isDev = process.env.NODE_ENV === 'development'; export const isDev = process.env.NODE_ENV === 'development';
const algorithm = 'aes-256-ctr'; const algorithm = 'aes-256-ctr';

View File

@ -12,7 +12,7 @@ import { checkDomainsIsValidInDNS, checkDoubleBranch, checkExposedPort, createDi
import { checkContainer, formatLabelsOnDocker, isContainerExited, removeContainer } from '../../../../lib/docker'; import { checkContainer, formatLabelsOnDocker, isContainerExited, removeContainer } from '../../../../lib/docker';
import type { FastifyRequest } from 'fastify'; import type { FastifyRequest } from 'fastify';
import type { GetImages, CancelDeployment, CheckDNS, CheckRepository, DeleteApplication, DeleteSecret, DeleteStorage, GetApplicationLogs, GetBuildIdLogs, GetBuildLogs, SaveApplication, SaveApplicationSettings, SaveApplicationSource, SaveDeployKey, SaveDestination, SaveSecret, SaveStorage, DeployApplication, CheckDomain, StopPreviewApplication } from './types'; import type { GetImages, CancelDeployment, CheckDNS, CheckRepository, DeleteApplication, DeleteSecret, DeleteStorage, GetApplicationLogs, GetBuildIdLogs, GetBuildLogs, SaveApplication, SaveApplicationSettings, SaveApplicationSource, SaveDeployKey, SaveDestination, SaveSecret, SaveStorage, DeployApplication, CheckDomain, StopPreviewApplication, RestartPreviewApplication } from './types';
import { OnlyId } from '../../../../types'; import { OnlyId } from '../../../../types';
function filterObject(obj, callback) { function filterObject(obj, callback) {
@ -83,8 +83,6 @@ export async function getApplicationStatus(request: FastifyRequest<OnlyId>) {
isExited = status.status.isExited; isExited = status.status.isExited;
isRestarting = status.status.isRestarting isRestarting = status.status.isRestarting
} }
// isExited = await isContainerExited(application.destinationDocker.id, id);
} }
return { return {
isRunning, isRunning,
@ -164,7 +162,8 @@ export async function getApplicationFromDB(id: string, teamId: string) {
gitSource: { include: { githubApp: true, gitlabApp: true } }, gitSource: { include: { githubApp: true, gitlabApp: true } },
secrets: true, secrets: true,
persistentStorage: true, persistentStorage: true,
connectedDatabase: true connectedDatabase: true,
previewApplication: true
} }
}); });
if (!application) { if (!application) {
@ -350,6 +349,7 @@ export async function stopPreviewApplication(request: FastifyRequest<StopPreview
if (found) { if (found) {
await removeContainer({ id: container, dockerId: application.destinationDocker.id }); await removeContainer({ id: container, dockerId: application.destinationDocker.id });
} }
await prisma.previewApplication.deleteMany({ where: { applicationId: application.id, pullmergeRequestId } })
} }
return reply.code(201).send(); return reply.code(201).send();
} catch ({ status, message }) { } catch ({ status, message }) {
@ -617,7 +617,7 @@ export async function deployApplication(request: FastifyRequest<DeployApplicatio
githubAppId: application.gitSource?.githubApp?.id, githubAppId: application.gitSource?.githubApp?.id,
gitlabAppId: application.gitSource?.gitlabApp?.id, gitlabAppId: application.gitSource?.gitlabApp?.id,
status: 'queued', status: 'queued',
type: 'manual' type: pullmergeRequestId ? application.gitSource?.githubApp?.id ? 'manual_pr' : 'manual_mr' : 'manual'
} }
}); });
return { return {
@ -808,7 +808,6 @@ export async function saveSecret(request: FastifyRequest<SaveSecret>, reply: Fas
try { try {
const { id } = request.params const { id } = request.params
let { name, value, isBuildSecret, isPRMRSecret, isNew } = request.body let { name, value, isBuildSecret, isPRMRSecret, isNew } = request.body
if (isNew) { if (isNew) {
const found = await prisma.secret.findFirst({ where: { name, applicationId: id, isPRMRSecret } }); const found = await prisma.secret.findFirst({ where: { name, applicationId: id, isPRMRSecret } });
if (found) { if (found) {
@ -820,14 +819,24 @@ export async function saveSecret(request: FastifyRequest<SaveSecret>, reply: Fas
}); });
} }
} else { } else {
value = encrypt(value.trim()); if (value) {
value = encrypt(value.trim());
}
const found = await prisma.secret.findFirst({ where: { applicationId: id, name, isPRMRSecret } }); const found = await prisma.secret.findFirst({ where: { applicationId: id, name, isPRMRSecret } });
if (found) { if (found) {
await prisma.secret.updateMany({ if (!value && isPRMRSecret) {
where: { applicationId: id, name, isPRMRSecret }, await prisma.secret.deleteMany({
data: { value, isBuildSecret, isPRMRSecret } where: { applicationId: id, name, isPRMRSecret }
}); });
} else {
await prisma.secret.updateMany({
where: { applicationId: id, name, isPRMRSecret },
data: { value, isBuildSecret, isPRMRSecret }
});
}
} else { } else {
await prisma.secret.create({ await prisma.secret.create({
data: { name, value, isBuildSecret, isPRMRSecret, application: { connect: { id } } } data: { name, value, isBuildSecret, isPRMRSecret, application: { connect: { id } } }
@ -894,6 +903,181 @@ export async function deleteStorage(request: FastifyRequest<DeleteStorage>) {
} }
} }
export async function restartPreview(request: FastifyRequest<RestartPreviewApplication>, reply: FastifyReply) {
try {
const { id, pullmergeRequestId } = request.params
const { teamId } = request.user
let application: any = await getApplicationFromDB(id, teamId);
if (application?.destinationDockerId) {
const buildId = cuid();
const { id: dockerId, network } = application.destinationDocker;
const { secrets, port, repository, persistentStorage, id: applicationId, buildPack, exposePort } = application;
const envs = [
`PORT=${port}`
];
if (secrets.length > 0) {
secrets.forEach((secret) => {
if (pullmergeRequestId) {
const isSecretFound = secrets.filter(s => s.name === secret.name && s.isPRMRSecret)
if (isSecretFound.length > 0) {
envs.push(`${secret.name}=${isSecretFound[0].value}`);
} else {
envs.push(`${secret.name}=${secret.value}`);
}
} else {
if (!secret.isPRMRSecret) {
envs.push(`${secret.name}=${secret.value}`);
}
}
});
}
const { workdir } = await createDirectories({ repository, buildId });
const labels = []
let image = null
const { stdout: container } = await executeDockerCmd({ dockerId, command: `docker container ls --filter 'label=com.docker.compose.service=${id}-${pullmergeRequestId}' --format '{{json .}}'` })
const containersArray = container.trim().split('\n');
for (const container of containersArray) {
const containerObj = formatLabelsOnDocker(container);
image = containerObj[0].Image
Object.keys(containerObj[0].Labels).forEach(function (key) {
if (key.startsWith('coolify')) {
labels.push(`${key}=${containerObj[0].Labels[key]}`)
}
})
}
let imageFound = false;
try {
await executeDockerCmd({
dockerId,
command: `docker image inspect ${image}`
})
imageFound = true;
} catch (error) {
//
}
if (!imageFound) {
throw { status: 500, message: 'Image not found, cannot restart application.' }
}
await fs.writeFile(`${workdir}/.env`, envs.join('\n'));
let envFound = false;
try {
envFound = !!(await fs.stat(`${workdir}/.env`));
} catch (error) {
//
}
const volumes =
persistentStorage?.map((storage) => {
return `${applicationId}${storage.path.replace(/\//gi, '-')}:${buildPack !== 'docker' ? '/app' : ''
}${storage.path}`;
}) || [];
const composeVolumes = volumes.map((volume) => {
return {
[`${volume.split(':')[0]}`]: {
name: volume.split(':')[0]
}
};
});
const composeFile = {
version: '3.8',
services: {
[`${applicationId}-${pullmergeRequestId}`]: {
image,
container_name: `${applicationId}-${pullmergeRequestId}`,
volumes,
env_file: envFound ? [`${workdir}/.env`] : [],
labels,
depends_on: [],
expose: [port],
...(exposePort ? { ports: [`${exposePort}:${port}`] } : {}),
...defaultComposeConfiguration(network),
}
},
networks: {
[network]: {
external: true
}
},
volumes: Object.assign({}, ...composeVolumes)
};
await fs.writeFile(`${workdir}/docker-compose.yml`, yaml.dump(composeFile));
await executeDockerCmd({ dockerId, command: `docker stop -t 0 ${id}-${pullmergeRequestId}` })
await executeDockerCmd({ dockerId, command: `docker rm ${id}-${pullmergeRequestId}` })
await executeDockerCmd({ dockerId, command: `docker compose --project-directory ${workdir} up -d` })
return reply.code(201).send();
}
throw { status: 500, message: 'Application cannot be restarted.' }
} catch ({ status, message }) {
return errorHandler({ status, message })
}
}
export async function getPreviewStatus(request: FastifyRequest<RestartPreviewApplication>) {
try {
const { id, pullmergeRequestId } = request.params
const { teamId } = request.user
let isRunning = false;
let isExited = false;
let isRestarting = false;
let isBuilding = false
const application: any = await getApplicationFromDB(id, teamId);
if (application?.destinationDockerId) {
const status = await checkContainer({ dockerId: application.destinationDocker.id, container: `${id}-${pullmergeRequestId}` });
if (status?.found) {
isRunning = status.status.isRunning;
isExited = status.status.isExited;
isRestarting = status.status.isRestarting
}
const building = await prisma.build.findMany({ where: { applicationId: id, pullmergeRequestId, status: { in: ['queued', 'running'] } } })
isBuilding = building.length > 0
}
return {
isBuilding,
isRunning,
isRestarting,
isExited,
};
} catch ({ status, message }) {
return errorHandler({ status, message })
}
}
export async function loadPreviews(request: FastifyRequest<OnlyId>) {
try {
const { id } = request.params
const application = await prisma.application.findUnique({ where: { id }, include: { destinationDocker: true } });
const { stdout } = await executeDockerCmd({ dockerId: application.destinationDocker.id, command: `docker container ls --filter 'name=${id}-' --format "{{json .}}"` })
if (stdout === '') {
throw { status: 500, message: 'No previews found.' }
}
const containers = formatLabelsOnDocker(stdout).filter(container => container.Labels['coolify.configuration'] && container.Labels['coolify.type'] === 'standalone-application')
const jsonContainers = containers
.map((container) =>
JSON.parse(Buffer.from(container.Labels['coolify.configuration'], 'base64').toString())
)
.filter((container) => {
return container.pullmergeRequestId && container.applicationId === id;
});
for (const container of jsonContainers) {
const found = await prisma.previewApplication.findMany({ where: { applicationId: container.applicationId, pullmergeRequestId: container.pullmergeRequestId } })
if (found.length === 0) {
await prisma.previewApplication.create({
data: {
pullmergeRequestId: container.pullmergeRequestId,
sourceBranch: container.branch,
customDomain: container.fqdn,
application: { connect: { id: container.applicationId } }
}
})
}
}
return {
previews: await prisma.previewApplication.findMany({ where: { applicationId: id } })
}
} catch ({ status, message }) {
return errorHandler({ status, message })
}
}
export async function getPreviews(request: FastifyRequest<OnlyId>) { export async function getPreviews(request: FastifyRequest<OnlyId>) {
try { try {
const { id } = request.params const { id } = request.params
@ -909,26 +1093,7 @@ export async function getPreviews(request: FastifyRequest<OnlyId>) {
const applicationSecrets = secrets.filter((secret) => !secret.isPRMRSecret); const applicationSecrets = secrets.filter((secret) => !secret.isPRMRSecret);
const PRMRSecrets = secrets.filter((secret) => secret.isPRMRSecret); const PRMRSecrets = secrets.filter((secret) => secret.isPRMRSecret);
const application = await prisma.application.findUnique({ where: { id }, include: { destinationDocker: true } });
const { stdout } = await executeDockerCmd({ dockerId: application.destinationDocker.id, command: `docker container ls --filter 'name=${id}-' --format "{{json .}}"` })
if (stdout === '') {
return {
containers: [],
applicationSecrets: [],
PRMRSecrets: []
}
}
const containers = formatLabelsOnDocker(stdout).filter(container => container.Labels['coolify.configuration'] && container.Labels['coolify.type'] === 'standalone-application')
const jsonContainers = containers
.map((container) =>
JSON.parse(Buffer.from(container.Labels['coolify.configuration'], 'base64').toString())
)
.filter((container) => {
return container.pullmergeRequestId && container.applicationId === id;
});
return { return {
containers: jsonContainers,
applicationSecrets: applicationSecrets.sort((a, b) => { applicationSecrets: applicationSecrets.sort((a, b) => {
return ('' + a.name).localeCompare(b.name); return ('' + a.name).localeCompare(b.name);
}), }),
@ -1002,12 +1167,6 @@ export async function getBuildLogs(request: FastifyRequest<GetBuildLogs>) {
}); });
} }
builds = builds.map((build) => {
const updatedAt = day(build.updatedAt).utc();
build.took = updatedAt.diff(day(build.createdAt)) / 1000;
build.since = updatedAt.fromNow();
return build;
});
return { return {
builds, builds,
buildCount buildCount

View File

@ -1,8 +1,8 @@
import { FastifyPluginAsync } from 'fastify'; import { FastifyPluginAsync } from 'fastify';
import { OnlyId } from '../../../../types'; import { OnlyId } from '../../../../types';
import { cancelDeployment, checkDNS, checkDomain, checkRepository, deleteApplication, deleteSecret, deleteStorage, deployApplication, getApplication, getApplicationLogs, getApplicationStatus, getBuildIdLogs, getBuildLogs, getBuildPack, getGitHubToken, getGitLabSSHKey, getImages, getPreviews, getSecrets, getStorages, getUsage, listApplications, newApplication, restartApplication, saveApplication, saveApplicationSettings, saveApplicationSource, saveBuildPack, saveConnectedDatabase, saveDeployKey, saveDestination, saveGitLabSSHKey, saveRepository, saveSecret, saveStorage, stopApplication, stopPreviewApplication } from './handlers'; import { cancelDeployment, checkDNS, checkDomain, checkRepository, deleteApplication, deleteSecret, deleteStorage, deployApplication, getApplication, getApplicationLogs, getApplicationStatus, getBuildIdLogs, getBuildLogs, getBuildPack, getGitHubToken, getGitLabSSHKey, getImages, getPreviews, getPreviewStatus, getSecrets, getStorages, getUsage, listApplications, loadPreviews, newApplication, restartApplication, restartPreview, saveApplication, saveApplicationSettings, saveApplicationSource, saveBuildPack, saveConnectedDatabase, saveDeployKey, saveDestination, saveGitLabSSHKey, saveRepository, saveSecret, saveStorage, stopApplication, stopPreviewApplication } from './handlers';
import type { CancelDeployment, CheckDNS, CheckDomain, CheckRepository, DeleteApplication, DeleteSecret, DeleteStorage, DeployApplication, GetApplicationLogs, GetBuildIdLogs, GetBuildLogs, GetImages, SaveApplication, SaveApplicationSettings, SaveApplicationSource, SaveDeployKey, SaveDestination, SaveSecret, SaveStorage, StopPreviewApplication } from './types'; import type { CancelDeployment, CheckDNS, CheckDomain, CheckRepository, DeleteApplication, DeleteSecret, DeleteStorage, DeployApplication, GetApplicationLogs, GetBuildIdLogs, GetBuildLogs, GetImages, RestartPreviewApplication, SaveApplication, SaveApplicationSettings, SaveApplicationSource, SaveDeployKey, SaveDestination, SaveSecret, SaveStorage, StopPreviewApplication } from './types';
const root: FastifyPluginAsync = async (fastify): Promise<void> => { const root: FastifyPluginAsync = async (fastify): Promise<void> => {
fastify.addHook('onRequest', async (request) => { fastify.addHook('onRequest', async (request) => {
@ -37,6 +37,9 @@ const root: FastifyPluginAsync = async (fastify): Promise<void> => {
fastify.delete<DeleteStorage>('/:id/storages', async (request) => await deleteStorage(request)); fastify.delete<DeleteStorage>('/:id/storages', async (request) => await deleteStorage(request));
fastify.get<OnlyId>('/:id/previews', async (request) => await getPreviews(request)); fastify.get<OnlyId>('/:id/previews', async (request) => await getPreviews(request));
fastify.post<OnlyId>('/:id/previews/load', async (request) => await loadPreviews(request));
fastify.get<RestartPreviewApplication>('/:id/previews/:pullmergeRequestId/status', async (request) => await getPreviewStatus(request));
fastify.post<RestartPreviewApplication>('/:id/previews/:pullmergeRequestId/restart', async (request, reply) => await restartPreview(request, reply));
fastify.get<GetApplicationLogs>('/:id/logs', async (request) => await getApplicationLogs(request)); fastify.get<GetApplicationLogs>('/:id/logs', async (request) => await getApplicationLogs(request));
fastify.get<GetBuildLogs>('/:id/logs/build', async (request) => await getBuildLogs(request)); fastify.get<GetBuildLogs>('/:id/logs/build', async (request) => await getBuildLogs(request));

View File

@ -126,4 +126,10 @@ export interface StopPreviewApplication extends OnlyId {
Body: { Body: {
pullmergeRequestId: string | null, pullmergeRequestId: string | null,
} }
}
export interface RestartPreviewApplication {
Params: {
id: string,
pullmergeRequestId: string | null,
}
} }

View File

@ -1,7 +1,7 @@
import axios from "axios"; import axios from "axios";
import cuid from "cuid"; import cuid from "cuid";
import crypto from "crypto"; import crypto from "crypto";
import { encrypt, errorHandler, getUIUrl, isDev, prisma } from "../../../lib/common"; import { encrypt, errorHandler, getDomain, getUIUrl, isDev, prisma } from "../../../lib/common";
import { checkContainer, removeContainer } from "../../../lib/docker"; import { checkContainer, removeContainer } from "../../../lib/docker";
import { createdBranchDatabase, getApplicationFromDBWebhook, removeBranchDatabase } from "../../api/v1/applications/handlers"; import { createdBranchDatabase, getApplicationFromDBWebhook, removeBranchDatabase } from "../../api/v1/applications/handlers";
@ -175,15 +175,23 @@ export async function gitHubEvents(request: FastifyRequest<GitHubEvents>): Promi
data: { updatedAt: new Date() } data: { updatedAt: new Date() }
}); });
let previewApplicationId = undefined let previewApplicationId = undefined
if (pullmergeRequestId) { if (pullmergeRequestId) {
const foundPreviewApplications = await prisma.previewApplication.findMany({ where: { applicationId: application.id, prMrId: pullmergeRequestId } }) const foundPreviewApplications = await prisma.previewApplication.findMany({ where: { applicationId: application.id, pullmergeRequestId } })
if (foundPreviewApplications.length > 0) { if (foundPreviewApplications.length > 0) {
previewApplicationId = foundPreviewApplications[0].id previewApplicationId = foundPreviewApplications[0].id
} else { } else {
const previewApplication = await prisma.previewApplication.create({ data: { prMrId: pullmergeRequestId, application: { connect: { id: application.id } } } }) const protocol = application.fqdn.includes('https://') ? 'https://' : 'http://'
previewApplicationId = previewApplication.id const previewApplication = await prisma.previewApplication.create({
} data: {
} pullmergeRequestId,
sourceBranch,
customDomain: `${protocol}${pullmergeRequestId}.${getDomain(application.fqdn)}`,
application: { connect: { id: application.id } }
}
})
previewApplicationId = previewApplication.id
}
}
// if (application.connectedDatabase && pullmergeRequestAction === 'opened' || pullmergeRequestAction === 'reopened') { // if (application.connectedDatabase && pullmergeRequestAction === 'opened' || pullmergeRequestAction === 'reopened') {
// // Coolify hosted database // // Coolify hosted database
// if (application.connectedDatabase.databaseId) { // if (application.connectedDatabase.databaseId) {
@ -210,7 +218,9 @@ export async function gitHubEvents(request: FastifyRequest<GitHubEvents>): Promi
} }
}); });
return {
message: 'Queued. Thank you!'
};
} else if (pullmergeRequestAction === 'closed') { } else if (pullmergeRequestAction === 'closed') {
if (application.destinationDockerId) { if (application.destinationDockerId) {
const id = `${application.id}-${pullmergeRequestId}`; const id = `${application.id}-${pullmergeRequestId}`;
@ -218,12 +228,15 @@ export async function gitHubEvents(request: FastifyRequest<GitHubEvents>): Promi
await removeContainer({ id, dockerId: application.destinationDocker.id }); await removeContainer({ id, dockerId: application.destinationDocker.id });
} catch (error) { } } catch (error) { }
} }
const foundPreviewApplications = await prisma.previewApplication.findMany({ where: {applicationId: application.id, prMrId: pullmergeRequestId}}) const foundPreviewApplications = await prisma.previewApplication.findMany({ where: { applicationId: application.id, pullmergeRequestId } })
if (foundPreviewApplications.length > 0) { if (foundPreviewApplications.length > 0) {
for (const preview of foundPreviewApplications) { for (const preview of foundPreviewApplications) {
await prisma.previewApplication.delete({where: {id: preview.id}}) await prisma.previewApplication.delete({ where: { id: preview.id } })
} }
} }
return {
message: 'PR closed. Thank you!'
};
// if (application?.connectedDatabase?.databaseId) { // if (application?.connectedDatabase?.databaseId) {
// const databaseId = application.connectedDatabase.databaseId; // const databaseId = application.connectedDatabase.databaseId;
// const database = await prisma.database.findUnique({ where: { id: databaseId } }); // const database = await prisma.database.findUnique({ where: { id: databaseId } });

View File

@ -2,7 +2,7 @@ import axios from "axios";
import cuid from "cuid"; import cuid from "cuid";
import crypto from "crypto"; import crypto from "crypto";
import type { FastifyReply, FastifyRequest } from "fastify"; import type { FastifyReply, FastifyRequest } from "fastify";
import { errorHandler, getAPIUrl, getUIUrl, isDev, listSettings, prisma } from "../../../lib/common"; import { errorHandler, getAPIUrl, getDomain, getUIUrl, isDev, listSettings, prisma } from "../../../lib/common";
import { checkContainer, removeContainer } from "../../../lib/docker"; import { checkContainer, removeContainer } from "../../../lib/docker";
import { getApplicationFromDB, getApplicationFromDBWebhook } from "../../api/v1/applications/handlers"; import { getApplicationFromDB, getApplicationFromDBWebhook } from "../../api/v1/applications/handlers";
@ -91,8 +91,8 @@ export async function gitLabEvents(request: FastifyRequest<GitLabEvents>) {
} }
} }
} else if (objectKind === 'merge_request') { } else if (objectKind === 'merge_request') {
const { object_attributes: { work_in_progress: isDraft, action, source_branch: sourceBranch, target_branch: targetBranch, iid: pullmergeRequestId }, project: { id } } = request.body const { object_attributes: { work_in_progress: isDraft, action, source_branch: sourceBranch, target_branch: targetBranch }, project: { id } } = request.body
const pullmergeRequestId = request.body.object_attributes.iid.toString();
const projectId = Number(id); const projectId = Number(id);
if (!allowedActions.includes(action)) { if (!allowedActions.includes(action)) {
throw { status: 500, message: 'Action not allowed.' } throw { status: 500, message: 'Action not allowed.' }
@ -130,10 +130,29 @@ export async function gitLabEvents(request: FastifyRequest<GitLabEvents>) {
where: { id: application.id }, where: { id: application.id },
data: { updatedAt: new Date() } data: { updatedAt: new Date() }
}); });
let previewApplicationId = undefined
if (pullmergeRequestId) {
const foundPreviewApplications = await prisma.previewApplication.findMany({ where: { applicationId: application.id, pullmergeRequestId } })
if (foundPreviewApplications.length > 0) {
previewApplicationId = foundPreviewApplications[0].id
} else {
const protocol = application.fqdn.includes('https://') ? 'https://' : 'http://'
const previewApplication = await prisma.previewApplication.create({
data: {
pullmergeRequestId,
sourceBranch,
customDomain: `${protocol}${pullmergeRequestId}.${getDomain(application.fqdn)}`,
application: { connect: { id: application.id } }
}
})
previewApplicationId = previewApplication.id
}
}
await prisma.build.create({ await prisma.build.create({
data: { data: {
id: buildId, id: buildId,
pullmergeRequestId: pullmergeRequestId.toString(), pullmergeRequestId,
previewApplicationId,
sourceBranch, sourceBranch,
applicationId: application.id, applicationId: application.id,
destinationDockerId: application.destinationDocker.id, destinationDockerId: application.destinationDocker.id,
@ -150,8 +169,19 @@ export async function gitLabEvents(request: FastifyRequest<GitLabEvents>) {
} else if (action === 'close') { } else if (action === 'close') {
if (application.destinationDockerId) { if (application.destinationDockerId) {
const id = `${application.id}-${pullmergeRequestId}`; const id = `${application.id}-${pullmergeRequestId}`;
await removeContainer({ id, dockerId: application.destinationDocker.id }); try {
await removeContainer({ id, dockerId: application.destinationDocker.id });
} catch (error) { }
} }
const foundPreviewApplications = await prisma.previewApplication.findMany({ where: { applicationId: application.id, pullmergeRequestId } })
if (foundPreviewApplications.length > 0) {
for (const preview of foundPreviewApplications) {
await prisma.previewApplication.delete({ where: { id: preview.id } })
}
}
return {
message: 'MR closed. Thank you!'
};
} }
} }

View File

@ -42,6 +42,7 @@
}, },
"type": "module", "type": "module",
"dependencies": { "dependencies": {
"dayjs": "1.11.5",
"@sveltejs/adapter-static": "1.0.0-next.39", "@sveltejs/adapter-static": "1.0.0-next.39",
"@tailwindcss/typography": "^0.5.7", "@tailwindcss/typography": "^0.5.7",
"cuid": "2.1.8", "cuid": "2.1.8",

View File

@ -83,4 +83,8 @@ export function handlerNotFoundLoad(error: any, url: URL) {
status: 500, status: 500,
error: new Error(`Could not load ${url}`) error: new Error(`Could not load ${url}`)
}; };
}
export function getRndInteger(min: number, max: number) {
return Math.floor(Math.random() * (max - min + 1)) + min;
} }

View File

@ -108,21 +108,21 @@
<div class="stats stats-vertical min-w-[16rem] mb-5 rounded bg-transparent"> <div class="stats stats-vertical min-w-[16rem] mb-5 rounded bg-transparent">
<div class="stat"> <div class="stat">
<div class="stat-title">Total Memory</div> <div class="stat-title">Total Memory</div>
<div class="stat-value text-2xl"> <div class="stat-value text-2xl text-white">
{(usage?.memory?.totalMemMb).toFixed(0)}<span class="text-sm">MB</span> {(usage?.memory?.totalMemMb).toFixed(0)}<span class="text-sm">MB</span>
</div> </div>
</div> </div>
<div class="stat"> <div class="stat">
<div class="stat-title">Used Memory</div> <div class="stat-title">Used Memory</div>
<div class="stat-value text-2xl"> <div class="stat-value text-2xl text-white">
{(usage?.memory?.usedMemMb).toFixed(0)}<span class="text-sm">MB</span> {(usage?.memory?.usedMemMb).toFixed(0)}<span class="text-sm">MB</span>
</div> </div>
</div> </div>
<div class="stat"> <div class="stat">
<div class="stat-title">Free Memory</div> <div class="stat-title">Free Memory</div>
<div class="stat-value text-2xl"> <div class="stat-value text-2xl text-white">
{(usage?.memory?.freeMemPercentage).toFixed(0)}<span class="text-sm">%</span> {(usage?.memory?.freeMemPercentage).toFixed(0)}<span class="text-sm">%</span>
</div> </div>
</div> </div>
@ -131,41 +131,41 @@
<div class="stats stats-vertical min-w-[20rem] mb-5 bg-transparent rounded"> <div class="stats stats-vertical min-w-[20rem] mb-5 bg-transparent rounded">
<div class="stat"> <div class="stat">
<div class="stat-title">Total CPU</div> <div class="stat-title">Total CPU</div>
<div class="stat-value text-2xl"> <div class="stat-value text-2xl text-white">
{usage?.cpu?.count} {usage?.cpu?.count}
</div> </div>
</div> </div>
<div class="stat"> <div class="stat">
<div class="stat-title">CPU Usage</div> <div class="stat-title">CPU Usage</div>
<div class="stat-value text-2xl"> <div class="stat-value text-2xl text-white">
{usage?.cpu?.usage}<span class="text-sm">%</span> {usage?.cpu?.usage}<span class="text-sm">%</span>
</div> </div>
</div> </div>
<div class="stat"> <div class="stat">
<div class="stat-title">Load Average (5,10,30mins)</div> <div class="stat-title">Load Average (5,10,30mins)</div>
<div class="stat-value text-2xl">{usage?.cpu?.load}</div> <div class="stat-value text-2xl text-white">{usage?.cpu?.load}</div>
</div> </div>
</div> </div>
<div class="stats stats-vertical min-w-[16rem] mb-5 bg-transparent rounded"> <div class="stats stats-vertical min-w-[16rem] mb-5 bg-transparent rounded">
<div class="stat"> <div class="stat">
<div class="stat-title">Total Disk</div> <div class="stat-title">Total Disk</div>
<div class="stat-value text-2xl"> <div class="stat-value text-2xl text-white">
{usage?.disk?.totalGb}<span class="text-sm">GB</span> {usage?.disk?.totalGb}<span class="text-sm">GB</span>
</div> </div>
</div> </div>
<div class="stat"> <div class="stat">
<div class="stat-title">Used Disk</div> <div class="stat-title">Used Disk</div>
<div class="stat-value text-2xl"> <div class="stat-value text-2xl text-white">
{usage?.disk?.usedGb}<span class="text-sm">GB</span> {usage?.disk?.usedGb}<span class="text-sm">GB</span>
</div> </div>
</div> </div>
<div class="stat"> <div class="stat">
<div class="stat-title">Free Disk</div> <div class="stat-title">Free Disk</div>
<div class="stat-value text-2xl"> <div class="stat-value text-2xl text-white">
{usage?.disk?.freePercentage}<span class="text-sm">%</span> {usage?.disk?.freePercentage}<span class="text-sm">%</span>
</div> </div>
</div> </div>

7
apps/ui/src/lib/dayjs.ts Normal file
View File

@ -0,0 +1,7 @@
import dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc.js';
import relativeTime from 'dayjs/plugin/relativeTime.js';
dayjs.extend(utc);
dayjs.extend(relativeTime);
export { dayjs as day };

View File

@ -290,7 +290,7 @@
{/if} {/if}
{/if} {/if}
<main> <main>
<div class={$appSession.userId ? 'pl-14 lg:px-20' : null}> <div class={$appSession.userId ? 'pl-14 lg:pl-20' : null}>
<slot /> <slot />
</div> </div>
</main> </main>

View File

@ -5,7 +5,6 @@
export let isNewSecret = false; export let isNewSecret = false;
export let isPRMRSecret = false; export let isPRMRSecret = false;
export let PRMRSecret: any = {}; export let PRMRSecret: any = {};
if (isPRMRSecret) value = PRMRSecret.value; if (isPRMRSecret) value = PRMRSecret.value;
import { page } from '$app/stores'; import { page } from '$app/stores';
@ -39,7 +38,15 @@
async function createSecret(isNew: any) { async function createSecret(isNew: any) {
try { try {
if (!name || !value) return; if (isNew) {
if (!name || !value) return;
}
if (value === undefined && isPRMRSecret) {
return
}
if (value === '' && !isPRMRSecret) {
throw new Error('Value is required.')
}
await saveSecret({ await saveSecret({
isNew, isNew,
name, name,
@ -108,7 +115,6 @@
name={isNewSecret ? 'secretValue' : 'secretValueNew'} name={isNewSecret ? 'secretValue' : 'secretValueNew'}
isPasswordField={true} isPasswordField={true}
bind:value bind:value
required
placeholder="J$#@UIO%HO#$U%H" placeholder="J$#@UIO%HO#$U%H"
/> />
</td> </td>
@ -130,7 +136,7 @@
class:translate-x-0={!isBuildSecret} class:translate-x-0={!isBuildSecret}
> >
<span <span
class=" absolute inset-0 flex h-full w-full items-center justify-center transition-opacity duration-200 ease-in" 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-0={isBuildSecret}
class:opacity-100={!isBuildSecret} class:opacity-100={!isBuildSecret}
aria-hidden="true" aria-hidden="true"

View File

@ -23,34 +23,30 @@
export let application: any; export let application: any;
export let buildCount: any; export let buildCount: any;
import { page } from '$app/stores'; import { page } from '$app/stores';
console.log(builds[0].createdAt);
import {addToast} from '$lib/store'; import { addToast } from '$lib/store';
import BuildLog from './_BuildLog.svelte'; import BuildLog from './_BuildLog.svelte';
import { get, post } from '$lib/api'; import { get, post } from '$lib/api';
import { t } from '$lib/translations'; import { t } from '$lib/translations';
import { changeQueryParams, dateOptions, errorNotification, asyncSleep } from '$lib/common'; import { changeQueryParams, dateOptions, errorNotification, asyncSleep } from '$lib/common';
import Tooltip from '$lib/components/Tooltip.svelte'; import Tooltip from '$lib/components/Tooltip.svelte';
import { day } from '$lib/dayjs';
let buildId: any; let buildId: any;
let skip = 0; let skip = 0;
let noMoreBuilds = buildCount < 5 || buildCount <= skip; let noMoreBuilds = buildCount < 5 || buildCount <= skip;
let buildTook = 0;
const { id } = $page.params; const { id } = $page.params;
let preselectedBuildId = $page.url.searchParams.get('buildId'); let preselectedBuildId = $page.url.searchParams.get('buildId');
if (preselectedBuildId) buildId = preselectedBuildId; if (preselectedBuildId) buildId = preselectedBuildId;
async function updateBuildStatus({ detail }: { detail: any }) { async function updateBuildStatus({ detail }: { detail: any }) {
const { status, took } = detail; const { status } = detail;
if (status !== 'running') { if (status !== 'running') {
try { try {
const data = await get(`/applications/${id}/logs/build?buildId=${buildId}`); const data = await get(`/applications/${id}/logs/build?buildId=${buildId}`);
builds = builds.filter((build: any) => { builds = builds.filter((build: any) => {
if (build.id === data.builds[0].id) { if (build.id === data.builds[0].id) {
build.status = data.builds[0].status; build.status = data.builds[0].status;
build.took = data.builds[0].took;
build.since = data.builds[0].since;
} }
return build; return build;
}); });
@ -62,7 +58,6 @@ import {addToast} from '$lib/store';
if (build.id === buildId) build.status = status; if (build.id === buildId) build.status = status;
return build; return build;
}); });
buildTook = took;
} }
} }
async function loadMoreBuilds() { async function loadMoreBuilds() {
@ -84,23 +79,37 @@ import {addToast} from '$lib/store';
buildId = build; buildId = build;
return changeQueryParams(buildId); return changeQueryParams(buildId);
} }
async function resetQueue() { 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? '); 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) { if (sure) {
try {
try { await post(`/internal/resetQueue`, {});
await post(`/internal/resetQueue`, {}); addToast({
addToast({
message: 'Queue reset done.', message: 'Queue reset done.',
type: 'success' type: 'success'
}); });
await asyncSleep(500) await asyncSleep(500);
return window.location.reload() return window.location.reload();
} catch (error) { } catch (error) {
return errorNotification(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';
}
}
</script> </script>
<div class="flex items-center space-x-2 p-5 px-6 font-bold"> <div class="flex items-center space-x-2 p-5 px-6 font-bold">
@ -156,7 +165,9 @@ import {addToast} from '$lib/store';
</div> </div>
<div class="block flex-row justify-start space-x-2 px-5 pt-6 sm:px-10 md:flex"> <div class="block flex-row justify-start space-x-2 px-5 pt-6 sm:px-10 md:flex">
<div class="mb-4 min-w-[16rem] space-y-2 md:mb-0 "> <div class="mb-4 min-w-[16rem] space-y-2 md:mb-0 ">
<button class="btn btn-sm text-xs w-full bg-error" on:click={resetQueue}>Reset Build Queue</button> <button class="btn btn-sm text-xs w-full bg-error" on:click={resetQueue}
>Reset Build Queue</button
>
<div class="top-4 md:sticky"> <div class="top-4 md:sticky">
{#each builds as build, index (build.id)} {#each builds as build, index (build.id)}
<div <div
@ -164,8 +175,8 @@ import {addToast} from '$lib/store';
on:click={() => loadBuild(build.id)} on:click={() => loadBuild(build.id)}
class:rounded-tr={index === 0} class:rounded-tr={index === 0}
class:rounded-br={index === builds.length - 1} class:rounded-br={index === builds.length - 1}
class="flex cursor-pointer items-center justify-center py-4 no-underline transition-all duration-100 hover:bg-coolgray-400 hover:shadow-xl" class="flex cursor-pointer items-center justify-center py-4 no-underline transition-all duration-100 hover:bg-coolgray-300 hover:shadow-xl"
class:bg-coolgray-400={buildId === build.id} class:bg-coolgray-200={buildId === build.id}
> >
<div class="flex-col px-2 text-center min-w-[10rem]"> <div class="flex-col px-2 text-center min-w-[10rem]">
<div class="text-sm font-bold"> <div class="text-sm font-bold">
@ -174,41 +185,46 @@ import {addToast} from '$lib/store';
<div class="text-xs"> <div class="text-xs">
{build.type} {build.type}
</div> </div>
<div class="badge badge-sm text-xs text-white uppercase rounded bg-coolgray-300 border-none font-bold" <div
class:text-red-500={build.status === 'failed'} class={`badge badge-sm text-xs uppercase rounded bg-coolgray-300 border-none font-bold ${generateBadgeColors(
class:text-orange-500={build.status === 'canceled'} build.status
class:text-green-500={build.status === 'success'} )}`}
class:text-yellow-500={build.status === 'running'}>{build.status}</div> >
{build.status}
</div>
</div> </div>
<div class="w-48 text-center text-xs"> <div class="w-48 text-center text-xs">
{#if build.status === 'running'} {#if build.status === 'running'}
<div class="font-bold">{$t('application.build.running')}</div>
<div> <div>
Elapsed <span class="font-bold text-xl"
<span class="font-bold">{buildTook}s</span> >{(day().utc().diff(day(build.createdAt)) / 1000).toFixed(0)}s</span
>
</div> </div>
{:else if build.status === 'queued'}
<div class="font-bold">{$t('application.build.queued')}</div>
{:else} {:else}
<div>{build.since}</div> <div>{day(build.updatedAt).utc().fromNow()}</div>
<div> <div>
{$t('application.build.finished_in')} <span class="font-bold">{build.took}s</span> {$t('application.build.finished_in')}
<span class="font-bold"
>{day(build.updatedAt).utc().diff(day(build.createdAt)) / 1000}s</span
>
</div> </div>
{/if} {/if}
</div> </div>
</div> </div>
<Tooltip triggeredBy={`#building-${build.id}`} <Tooltip triggeredBy={`#building-${build.id}`}
>{new Intl.DateTimeFormat('default', dateOptions).format(new Date(build.createdAt)) + >{new Intl.DateTimeFormat('default', dateOptions).format(new Date(build.createdAt)) +
`\n${build.status}`}</Tooltip `\n`}</Tooltip
> >
{/each} {/each}
</div> </div>
{#if !noMoreBuilds} {#if !noMoreBuilds}
{#if buildCount > 5} {#if buildCount > 5}
<div class="flex space-x-2"> <div class="flex space-x-2">
<button disabled={noMoreBuilds} class=" btn btn-sm w-full text-xs" on:click={loadMoreBuilds} <button
>{$t('application.build.load_more')}</button disabled={noMoreBuilds}
class=" btn btn-sm w-full text-xs"
on:click={loadMoreBuilds}>{$t('application.build.load_more')}</button
> >
</div> </div>
{/if} {/if}

View File

@ -1,222 +0,0 @@
<script context="module" lang="ts">
import type { Load } from '@sveltejs/kit';
export const load: Load = async ({ stuff, url }) => {
try {
return {
props: {
application: stuff.application
}
};
} catch (error) {
return {
status: 500,
error: new Error(`Could not load ${url}`)
};
}
};
</script>
<script lang="ts">
export let application: any;
import Secret from './_Secret.svelte';
import { get, post } from '$lib/api';
import { page } from '$app/stores';
import { t } from '$lib/translations';
import { goto } from '$app/navigation';
import { errorNotification, getDomain } from '$lib/common';
import { onMount } from 'svelte';
import Loading from '$lib/components/Loading.svelte';
import { addToast } from '$lib/store';
import SimpleExplainer from '$lib/components/SimpleExplainer.svelte';
const { id } = $page.params;
let containers: any;
let PRMRSecrets: any;
let applicationSecrets: any;
let loading = {
init: true,
removing: false
};
async function refreshSecrets() {
const data = await get(`/applications/${id}/secrets`);
PRMRSecrets = [...data.secrets];
}
async function removeApplication(container: any) {
try {
loading.removing = true;
await post(`/applications/${id}/stop/preview`, {
pullmergeRequestId: container.pullmergeRequestId
});
return window.location.reload();
} catch (error) {
return errorNotification(error);
}
}
async function redeploy(container: any) {
try {
const { buildId } = await post(`/applications/${id}/deploy`, {
pullmergeRequestId: container.pullmergeRequestId,
branch: container.branch
});
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);
}
}
onMount(async () => {
try {
loading.init = true;
const response = await get(`/applications/${id}/previews`);
containers = response.containers;
PRMRSecrets = response.PRMRSecrets;
applicationSecrets = response.applicationSecrets;
} catch (error) {
return errorNotification(error);
} finally {
loading.init = false;
}
});
</script>
<div class="flex 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">
Preview Deployments
</div>
<span class="text-xs">{application?.name}</span>
</div>
{#if application.gitSource?.htmlUrl && application.repository && application.branch}
<a
href="{application.gitSource.htmlUrl}/{application.repository}/tree/{application.branch}"
target="_blank"
class="w-10"
>
{#if application.gitSource?.type === 'gitlab'}
<svg viewBox="0 0 128 128" class="icons">
<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 application.gitSource?.type === 'github'}
<svg viewBox="0 0 128 128" class="icons">
<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}
</a>
{/if}
</div>
{#if loading.init}
<Loading />
{:else}
<div class="mx-auto max-w-6xl px-6 pt-4">
<div class="flex justify-center py-4 text-center">
<SimpleExplainer
customClass="w-full"
text={applicationSecrets.length === 0
? "You can add secrets to PR/MR deployments. Please add secrets to the application first. <br>Useful for creating <span class='text-green-500 font-bold'>staging</span> environments."
: "These values overwrite application secrets in PR/MR deployments. <br>Useful for creating <span class='text-green-500 font-bold'>staging</span> environments."}
/>
</div>
{#if applicationSecrets.length !== 0}
<table class="mx-auto border-separate text-left">
<thead>
<tr class="h-12">
<th scope="col">{$t('forms.name')}</th>
<th scope="col">{$t('forms.value')}</th>
<th scope="col" class="w-64 text-center"
>{$t('application.preview.need_during_buildtime')}</th
>
<th scope="col" class="w-96 text-center">{$t('forms.action')}</th>
</tr>
</thead>
<tbody>
{#each applicationSecrets as secret}
{#key secret.id}
<tr>
<Secret
PRMRSecret={PRMRSecrets.find((s) => s.name === secret.name)}
isPRMRSecret
name={secret.name}
value={secret.value}
isBuildSecret={secret.isBuildSecret}
on:refresh={refreshSecrets}
/>
</tr>
{/key}
{/each}
</tbody>
</table>
{/if}
</div>
<div class="mx-auto max-w-4xl py-10">
<div class="flex flex-wrap justify-center space-x-2">
{#if containers.length > 0}
{#each containers as container}
<a href={container.fqdn} class="p-2 no-underline" target="_blank">
<div class="box-selection text-center hover:border-transparent hover:bg-green-600">
<div class="truncate text-center text-xl font-bold">{getDomain(container.fqdn)}</div>
</div>
</a>
<div class="flex items-center justify-center">
<button
class="btn btn-sm bg-coollabs hover:bg-coollabs-100"
on:click={() => redeploy(container)}>{$t('application.preview.redeploy')}</button
>
</div>
<div class="flex items-center justify-center">
<button
class="btn btn-sm"
class:bg-red-600={!loading.removing}
class:hover:bg-red-500={!loading.removing}
disabled={loading.removing}
on:click={() => removeApplication(container)}
>{loading.removing ? 'Removing...' : 'Remove Application'}
</button>
</div>
{/each}
{:else}
<div class="flex-col">
<div class="text-center font-bold text-xl">
{$t('application.preview.no_previews_available')}
</div>
</div>
{/if}
</div>
</div>
{/if}

View File

@ -0,0 +1,413 @@
<script context="module" lang="ts">
import type { Load } from '@sveltejs/kit';
export const load: Load = async ({ fetch, params, stuff, url }) => {
try {
return {
props: {
application: stuff.application
}
};
} catch (error) {
return {
status: 500,
error: new Error(`Could not load ${url}`)
};
}
};
</script>
<script lang="ts">
export let application: any;
import Secret from '../_Secret.svelte';
import { get, post } from '$lib/api';
import { page } from '$app/stores';
import { t } from '$lib/translations';
import { goto } from '$app/navigation';
import { asyncSleep, errorNotification, getDomain, getRndInteger } from '$lib/common';
import { onMount } from 'svelte';
import { addToast } from '$lib/store';
import SimpleExplainer from '$lib/components/SimpleExplainer.svelte';
import Tooltip from '$lib/components/Tooltip.svelte';
import DeleteIcon from '$lib/components/DeleteIcon.svelte';
const { id } = $page.params;
let PRMRSecrets: any;
let applicationSecrets: any;
let loading = {
init: true,
restart: false,
removing: false
};
let numberOfGetStatus = 0;
let status: any = {};
async function refreshSecrets() {
const data = await get(`/applications/${id}/secrets`);
PRMRSecrets = [...data.secrets];
}
async function removeApplication(preview: any) {
try {
loading.removing = true;
await post(`/applications/${id}/stop/preview`, {
pullmergeRequestId: preview.pullmergeRequestId
});
return window.location.reload();
} catch (error) {
return errorNotification(error);
}
}
async function redeploy(preview: any) {
try {
const { buildId } = await post(`/applications/${id}/deploy`, {
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 { previews } = await post(`/applications/${id}/previews/load`, {});
addToast({
message: 'Previews loaded.',
type: 'success'
});
application.previewApplication = previews;
} 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 response = await get(
`/applications/${applicationId}/previews/${pullmergeRequestId}/status`
);
isRunning = response.isRunning;
isBuilding = response.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--;
}
}
async function restartPreview(preview: any) {
try {
loading.restart = true;
const { pullmergeRequestId } = preview;
await post(`/applications/${id}/previews/${pullmergeRequestId}/restart`, {});
addToast({
type: 'success',
message: 'Restart successful.'
});
} catch (error) {
return errorNotification(error);
} finally {
await getStatus(preview);
loading.restart = false;
}
}
onMount(async () => {
try {
loading.init = true;
loading.restart = true;
const response = await get(`/applications/${id}/previews`);
PRMRSecrets = response.PRMRSecrets;
applicationSecrets = response.applicationSecrets;
} catch (error) {
return errorNotification(error);
} finally {
loading.init = false;
loading.restart = false;
}
});
</script>
<div class="flex 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">
Preview Deployments
</div>
<span class="text-xs">{application?.name}</span>
</div>
{#if application.gitSource?.htmlUrl && application.repository && application.branch}
<a
href="{application.gitSource.htmlUrl}/{application.repository}/tree/{application.branch}"
target="_blank"
class="w-10"
>
{#if application.gitSource?.type === 'gitlab'}
<svg viewBox="0 0 128 128" class="icons">
<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 application.gitSource?.type === 'github'}
<svg viewBox="0 0 128 128" class="icons">
<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}
</a>
{/if}
</div>
{#if loading.init}
<div class="mx-auto max-w-6xl px-6 pt-4">
<div class="flex justify-center py-4 text-center text-xl font-bold">Loading...</div>
</div>
{:else}
<div class="mx-auto max-w-6xl px-6 pt-4">
<div class="flex justify-center py-4 text-center">
<SimpleExplainer
customClass="w-full"
text={applicationSecrets.length === 0
? "You can add secrets to PR/MR deployments. Please add secrets to the application first. <br>Useful for creating <span class='text-green-500 font-bold'>staging</span> environments."
: "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 class="text-center">
<SimpleExplainer
customClass="w-full"
text={'If your preview is not shown, try load them directly from Docker Engine.<br>(Changed previews process flow in <span class="font-bold text-white">v3.10.4</span>)'}
/>
<button class="btn btn-sm bg-coollabs" on:click={loadPreviewsFromDocker}
>Fetch Previews</button
>
</div>
{#if applicationSecrets.length !== 0}
<table class="mx-auto border-separate text-left">
<thead>
<tr class="h-12">
<th scope="col">{$t('forms.name')}</th>
<th scope="col">{$t('forms.value')}</th>
<th scope="col" class="w-64 text-center"
>{$t('application.preview.need_during_buildtime')}</th
>
<th scope="col" class="w-96 text-center">{$t('forms.action')}</th>
</tr>
</thead>
<tbody>
{#each applicationSecrets as secret}
{#key secret.id}
<tr>
<Secret
PRMRSecret={PRMRSecrets.find((s) => s.name === secret.name)}
isPRMRSecret
name={secret.name}
value={secret.value}
isBuildSecret={secret.isBuildSecret}
on:refresh={refreshSecrets}
/>
</tr>
{/key}
{/each}
</tbody>
</table>
{/if}
</div>
<div class="container lg:mx-auto lg:p-0 px-8 p-5 lg:pt-10">
{#if application.previewApplication.length > 0}
<div
class="grid grid-col gap-8 auto-cols-max grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 p-4"
>
{#each application.previewApplication as preview}
<div class="no-underline mb-5">
<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 status}
{#if status === '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" 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>
<div class="border border-coolgray-500 h-8" />
{#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"
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"
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
>
<div class="border border-coolgray-500 h-8" />
<button
id="deletepreview"
class="icons"
class:hover:text-error={!loading.removing}
disabled={loading.removing}
on:click={() => removeApplication(preview)}
><DeleteIcon />
</button>
<Tooltip triggeredBy="#deletepreview">Delete Preview</Tooltip>
</div>
</div>
</div>
</div>
</div>
{/each}
</div>
{:else}
<div class="flex-col">
<div class="text-center font-bold text-xl pb-10">Previews will shown here.</div>
</div>
{/if}
</div>
{/if}

View File

@ -22,7 +22,7 @@ export async function saveSecret({
applicationId applicationId
}: Props): Promise<void> { }: Props): Promise<void> {
if (!name) return errorNotification(`${t.get('forms.name')} ${t.get('forms.is_required')}`); if (!name) return errorNotification(`${t.get('forms.name')} ${t.get('forms.is_required')}`);
if (!value) return errorNotification(`${t.get('forms.value')} ${t.get('forms.is_required')}`); if (!value && isNew) return errorNotification(`${t.get('forms.value')} ${t.get('forms.is_required')}`);
try { try {
await post(`/applications/${applicationId}/secrets`, { await post(`/applications/${applicationId}/secrets`, {
name, name,

View File

@ -31,7 +31,7 @@
import { get, post } from '$lib/api'; import { get, post } from '$lib/api';
import Usage from '$lib/components/Usage.svelte'; import Usage from '$lib/components/Usage.svelte';
import { t } from '$lib/translations'; import { t } from '$lib/translations';
import { asyncSleep } from '$lib/common'; import { asyncSleep, getRndInteger } from '$lib/common';
import { appSession, search, addToast} from '$lib/store'; import { appSession, search, addToast} from '$lib/store';
import ApplicationsIcons from '$lib/components/svg/applications/ApplicationIcons.svelte'; import ApplicationsIcons from '$lib/components/svg/applications/ApplicationIcons.svelte';
@ -87,9 +87,7 @@
filtered.destinations = []; filtered.destinations = [];
filtered.otherDestinations = []; filtered.otherDestinations = [];
} }
function getRndInteger(min: number, max: number) {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
async function getStatus(resources: any) { async function getStatus(resources: any) {
const { id, buildPack, dualCerts } = resources; const { id, buildPack, dualCerts } = resources;

View File

@ -37,7 +37,7 @@
<div class="grid grid-col gap-8 auto-cols-max grid-cols-1 p-4"> <div class="grid grid-col gap-8 auto-cols-max grid-cols-1 p-4">
{#each servers as server} {#each servers as server}
<div class="no-underline mb-5"> <div class="no-underline mb-5">
<div class="w-full rounded bg-coolgray-100 indicator"> <div class="w-full rounded bg-coolgray-200 indicator">
{#if $appSession.teamId === '0'} {#if $appSession.teamId === '0'}
<Usage {server} /> <Usage {server} />
{/if} {/if}

View File

@ -1,7 +1,7 @@
{ {
"name": "coolify", "name": "coolify",
"description": "An open-source & self-hostable Heroku / Netlify alternative.", "description": "An open-source & self-hostable Heroku / Netlify alternative.",
"version": "3.10.3", "version": "3.10.4",
"license": "Apache-2.0", "license": "Apache-2.0",
"repository": "github:coollabsio/coolify", "repository": "github:coollabsio/coolify",
"scripts": { "scripts": {

2
pnpm-lock.yaml generated
View File

@ -144,6 +144,7 @@ importers:
classnames: 2.3.1 classnames: 2.3.1
cuid: 2.1.8 cuid: 2.1.8
daisyui: 2.24.2 daisyui: 2.24.2
dayjs: 1.11.5
eslint: 8.23.0 eslint: 8.23.0
eslint-config-prettier: 8.5.0 eslint-config-prettier: 8.5.0
eslint-plugin-svelte3: 4.0.0 eslint-plugin-svelte3: 4.0.0
@ -169,6 +170,7 @@ importers:
'@tailwindcss/typography': 0.5.7_tailwindcss@3.1.8 '@tailwindcss/typography': 0.5.7_tailwindcss@3.1.8
cuid: 2.1.8 cuid: 2.1.8
daisyui: 2.24.2_25hquoklqeoqwmt7fwvvcyxm5e daisyui: 2.24.2_25hquoklqeoqwmt7fwvvcyxm5e
dayjs: 1.11.5
js-cookie: 3.0.1 js-cookie: 3.0.1
p-limit: 4.0.0 p-limit: 4.0.0
svelte-select: 4.4.7 svelte-select: 4.4.7