mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-06-16 20:55:21 +02:00
Compare commits
1 Commits
v0.26.0
...
feat/concu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2da2b2dd39 |
5
.github/pull_request_template.md
vendored
5
.github/pull_request_template.md
vendored
@@ -12,7 +12,10 @@ Before submitting this PR, please make sure that:
|
||||
|
||||
## Issues related (if applicable)
|
||||
|
||||
closes #123
|
||||
Close automatically the related issues using the keywords: `closes #ISSUE_NUMBER`, `fixes #ISSUE_NUMBER`, `resolves #ISSUE_NUMBER`
|
||||
|
||||
Example: `closes #123`
|
||||
|
||||
## Screenshots (if applicable)
|
||||
|
||||
If you include a video or screenshot, would be awesome so we can see the changes in action.
|
||||
BIN
.github/sponsors/tuple.png
vendored
BIN
.github/sponsors/tuple.png
vendored
Binary file not shown.
|
Before Width: | Height: | Size: 264 KiB |
26
.github/workflows/pull-request.yml
vendored
26
.github/workflows/pull-request.yml
vendored
@@ -20,32 +20,6 @@ jobs:
|
||||
with:
|
||||
node-version: 20.16.0
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Install Nixpacks
|
||||
if: matrix.job == 'test'
|
||||
run: |
|
||||
export NIXPACKS_VERSION=1.39.0
|
||||
curl -sSL https://nixpacks.com/install.sh | bash
|
||||
echo "Nixpacks installed $NIXPACKS_VERSION"
|
||||
|
||||
- name: Install Railpack
|
||||
if: matrix.job == 'test'
|
||||
run: |
|
||||
export RAILPACK_VERSION=0.15.0
|
||||
curl -sSL https://railpack.com/install.sh | bash
|
||||
echo "Railpack installed $RAILPACK_VERSION"
|
||||
|
||||
- name: Add build tools to PATH
|
||||
if: matrix.job == 'test'
|
||||
run: echo "$HOME/.local/bin" >> $GITHUB_PATH
|
||||
|
||||
- name: Initialize Docker Swarm
|
||||
if: matrix.job == 'test'
|
||||
run: |
|
||||
docker swarm init
|
||||
docker network create --driver overlay dokploy-network || true
|
||||
echo "✅ Docker Swarm initialized"
|
||||
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm server:build
|
||||
- run: pnpm ${{ matrix.job }}
|
||||
|
||||
70
.github/workflows/sync-openapi-docs.yml
vendored
70
.github/workflows/sync-openapi-docs.yml
vendored
@@ -1,70 +0,0 @@
|
||||
name: Generate and Sync OpenAPI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- canary
|
||||
- main
|
||||
paths:
|
||||
- 'apps/dokploy/server/api/routers/**'
|
||||
- 'packages/server/src/services/**'
|
||||
- 'packages/server/src/db/schema/**'
|
||||
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
generate-and-commit:
|
||||
name: Generate OpenAPI and commit to Dokploy repo
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Dokploy repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20.16.0
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Generate OpenAPI specification
|
||||
run: |
|
||||
pnpm generate:openapi
|
||||
|
||||
# Verifica que se generó correctamente
|
||||
if [ ! -f openapi.json ]; then
|
||||
echo "❌ openapi.json not found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ OpenAPI specification generated successfully"
|
||||
|
||||
- name: Sync to website repository
|
||||
run: |
|
||||
# Clona el repositorio de website
|
||||
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/website.git website-repo
|
||||
|
||||
cd website-repo
|
||||
|
||||
# Copia el openapi.json al website (sobrescribe)
|
||||
mkdir -p apps/docs/public
|
||||
cp -f ../openapi.json apps/docs/public/openapi.json
|
||||
|
||||
# Configura git
|
||||
git config user.name "Dokploy Bot"
|
||||
git config user.email "bot@dokploy.com"
|
||||
|
||||
# Agrega y commitea siempre
|
||||
git add apps/docs/public/openapi.json
|
||||
git commit -m "chore: sync OpenAPI specification [skip ci]" \
|
||||
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
|
||||
-m "Updated: $(date -u +'%Y-%m-%d %H:%M:%S UTC')" \
|
||||
--allow-empty
|
||||
|
||||
git push
|
||||
|
||||
echo "✅ OpenAPI synced to website successfully"
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -13,8 +13,6 @@ node_modules
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
openapi.json
|
||||
|
||||
# Testing
|
||||
coverage
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ COPY --from=build /prod/dokploy/node_modules ./node_modules
|
||||
|
||||
|
||||
# Install docker
|
||||
RUN curl -fsSL https://get.docker.com -o get-docker.sh && sh get-docker.sh --version 28.5.2 && rm get-docker.sh && curl https://rclone.org/install.sh | bash
|
||||
RUN curl -fsSL https://get.docker.com -o get-docker.sh && sh get-docker.sh && rm get-docker.sh && curl https://rclone.org/install.sh | bash
|
||||
|
||||
# Install Nixpacks and tsx
|
||||
# | VERBOSE=1 VERSION=1.21.0 bash
|
||||
|
||||
@@ -16,11 +16,11 @@ RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm --filter=@dokploy/server
|
||||
|
||||
|
||||
# Deploy only the dokploy app
|
||||
# ARG NEXT_PUBLIC_UMAMI_HOST
|
||||
# ENV NEXT_PUBLIC_UMAMI_HOST=$NEXT_PUBLIC_UMAMI_HOST
|
||||
ARG NEXT_PUBLIC_UMAMI_HOST
|
||||
ENV NEXT_PUBLIC_UMAMI_HOST=$NEXT_PUBLIC_UMAMI_HOST
|
||||
|
||||
# ARG NEXT_PUBLIC_UMAMI_WEBSITE_ID
|
||||
# ENV NEXT_PUBLIC_UMAMI_WEBSITE_ID=$NEXT_PUBLIC_UMAMI_WEBSITE_ID
|
||||
ARG NEXT_PUBLIC_UMAMI_WEBSITE_ID
|
||||
ENV NEXT_PUBLIC_UMAMI_WEBSITE_ID=$NEXT_PUBLIC_UMAMI_WEBSITE_ID
|
||||
|
||||
ARG NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY
|
||||
ENV NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=$NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY
|
||||
|
||||
21
README.md
21
README.md
@@ -11,25 +11,8 @@
|
||||
</div>
|
||||
<br />
|
||||
|
||||
|
||||
|
||||
<div align="center" markdown="1">
|
||||
<sup>Special thanks to:</sup>
|
||||
<br>
|
||||
<br>
|
||||
<a href="https://tuple.app/dokploy">
|
||||
<img src=".github/sponsors/tuple.png" alt="Tuple's sponsorship image" width="400"/>
|
||||
</a>
|
||||
|
||||
### [Tuple, the premier screen sharing app for developers](https://tuple.app/dokploy)
|
||||
[Available for MacOS & Windows](https://tuple.app/dokploy)<br>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
Dokploy is a free, self-hostable Platform as a Service (PaaS) that simplifies the deployment and management of applications and databases.
|
||||
|
||||
|
||||
## ✨ Features
|
||||
|
||||
Dokploy includes multiple features to make your life easier.
|
||||
@@ -77,10 +60,6 @@ For detailed documentation, visit [docs.dokploy.com](https://docs.dokploy.com).
|
||||
<div>
|
||||
<a href="https://www.hostinger.com/vps-hosting?ref=dokploy"><img src=".github/sponsors/hostinger.jpg" alt="Hostinger" width="300"/></a>
|
||||
<a href="https://www.lxaer.com/?ref=dokploy"><img src=".github/sponsors/lxaer.png" alt="LX Aer" width="100"/></a>
|
||||
<a href="https://www.lambdatest.com/?utm_source=dokploy&utm_medium=sponsor" target="_blank">
|
||||
<img src="https://www.lambdatest.com/blue-logo.png" width="450" height="100" />
|
||||
</a>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Premium Supporters 🥇 -->
|
||||
|
||||
@@ -5,11 +5,7 @@ import { zValidator } from "@hono/zod-validator";
|
||||
import { Inngest } from "inngest";
|
||||
import { serve as serveInngest } from "inngest/hono";
|
||||
import { logger } from "./logger.js";
|
||||
import {
|
||||
cancelDeploymentSchema,
|
||||
type DeployJob,
|
||||
deployJobSchema,
|
||||
} from "./schema.js";
|
||||
import { type DeployJob, deployJobSchema } from "./schema.js";
|
||||
import { deploy } from "./utils.js";
|
||||
|
||||
const app = new Hono();
|
||||
@@ -31,13 +27,6 @@ export const deploymentFunction = inngest.createFunction(
|
||||
},
|
||||
],
|
||||
retries: 0,
|
||||
cancelOn: [
|
||||
{
|
||||
event: "deployment/cancelled",
|
||||
if: "async.data.applicationId == event.data.applicationId || async.data.composeId == event.data.composeId",
|
||||
timeout: "1h", // Allow cancellation for up to 1 hour
|
||||
},
|
||||
],
|
||||
},
|
||||
{ event: "deployment/requested" },
|
||||
|
||||
@@ -130,48 +119,6 @@ app.post("/deploy", zValidator("json", deployJobSchema), async (c) => {
|
||||
}
|
||||
});
|
||||
|
||||
app.post(
|
||||
"/cancel-deployment",
|
||||
zValidator("json", cancelDeploymentSchema),
|
||||
async (c) => {
|
||||
const data = c.req.valid("json");
|
||||
logger.info("Received cancel deployment request", data);
|
||||
|
||||
try {
|
||||
// Send cancellation event to Inngest
|
||||
|
||||
await inngest.send({
|
||||
name: "deployment/cancelled",
|
||||
data,
|
||||
});
|
||||
|
||||
const identifier =
|
||||
data.applicationType === "application"
|
||||
? `applicationId: ${data.applicationId}`
|
||||
: `composeId: ${data.composeId}`;
|
||||
|
||||
logger.info("Deployment cancellation event sent", {
|
||||
...data,
|
||||
identifier,
|
||||
});
|
||||
|
||||
return c.json({
|
||||
message: "Deployment cancellation requested",
|
||||
applicationType: data.applicationType,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("Failed to send deployment cancellation event", error);
|
||||
return c.json(
|
||||
{
|
||||
message: "Failed to cancel deployment",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
500,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
app.get("/health", async (c) => {
|
||||
return c.json({ status: "ok" });
|
||||
});
|
||||
|
||||
@@ -3,8 +3,8 @@ import { z } from "zod";
|
||||
export const deployJobSchema = z.discriminatedUnion("applicationType", [
|
||||
z.object({
|
||||
applicationId: z.string(),
|
||||
titleLog: z.string().optional(),
|
||||
descriptionLog: z.string().optional(),
|
||||
titleLog: z.string(),
|
||||
descriptionLog: z.string(),
|
||||
server: z.boolean().optional(),
|
||||
type: z.enum(["deploy", "redeploy"]),
|
||||
applicationType: z.literal("application"),
|
||||
@@ -12,8 +12,8 @@ export const deployJobSchema = z.discriminatedUnion("applicationType", [
|
||||
}),
|
||||
z.object({
|
||||
composeId: z.string(),
|
||||
titleLog: z.string().optional(),
|
||||
descriptionLog: z.string().optional(),
|
||||
titleLog: z.string(),
|
||||
descriptionLog: z.string(),
|
||||
server: z.boolean().optional(),
|
||||
type: z.enum(["deploy", "redeploy"]),
|
||||
applicationType: z.literal("compose"),
|
||||
@@ -22,8 +22,8 @@ export const deployJobSchema = z.discriminatedUnion("applicationType", [
|
||||
z.object({
|
||||
applicationId: z.string(),
|
||||
previewDeploymentId: z.string(),
|
||||
titleLog: z.string().optional(),
|
||||
descriptionLog: z.string().optional(),
|
||||
titleLog: z.string(),
|
||||
descriptionLog: z.string(),
|
||||
server: z.boolean().optional(),
|
||||
type: z.enum(["deploy"]),
|
||||
applicationType: z.literal("application-preview"),
|
||||
@@ -32,16 +32,3 @@ export const deployJobSchema = z.discriminatedUnion("applicationType", [
|
||||
]);
|
||||
|
||||
export type DeployJob = z.infer<typeof deployJobSchema>;
|
||||
|
||||
export const cancelDeploymentSchema = z.discriminatedUnion("applicationType", [
|
||||
z.object({
|
||||
applicationId: z.string(),
|
||||
applicationType: z.literal("application"),
|
||||
}),
|
||||
z.object({
|
||||
composeId: z.string(),
|
||||
applicationType: z.literal("compose"),
|
||||
}),
|
||||
]);
|
||||
|
||||
export type CancelDeploymentJob = z.infer<typeof cancelDeploymentSchema>;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import {
|
||||
deployApplication,
|
||||
deployCompose,
|
||||
deployPreviewApplication,
|
||||
rebuildApplication,
|
||||
rebuildCompose,
|
||||
deployRemoteApplication,
|
||||
deployRemoteCompose,
|
||||
deployRemotePreviewApplication,
|
||||
rebuildRemoteApplication,
|
||||
rebuildRemoteCompose,
|
||||
updateApplicationStatus,
|
||||
updateCompose,
|
||||
updatePreviewDeployment,
|
||||
@@ -16,16 +16,16 @@ export const deploy = async (job: DeployJob) => {
|
||||
await updateApplicationStatus(job.applicationId, "running");
|
||||
if (job.server) {
|
||||
if (job.type === "redeploy") {
|
||||
await rebuildApplication({
|
||||
await rebuildRemoteApplication({
|
||||
applicationId: job.applicationId,
|
||||
titleLog: job.titleLog || "Rebuild deployment",
|
||||
descriptionLog: job.descriptionLog || "",
|
||||
titleLog: job.titleLog,
|
||||
descriptionLog: job.descriptionLog,
|
||||
});
|
||||
} else if (job.type === "deploy") {
|
||||
await deployApplication({
|
||||
await deployRemoteApplication({
|
||||
applicationId: job.applicationId,
|
||||
titleLog: job.titleLog || "Manual deployment",
|
||||
descriptionLog: job.descriptionLog || "",
|
||||
titleLog: job.titleLog,
|
||||
descriptionLog: job.descriptionLog,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -36,16 +36,16 @@ export const deploy = async (job: DeployJob) => {
|
||||
|
||||
if (job.server) {
|
||||
if (job.type === "redeploy") {
|
||||
await rebuildCompose({
|
||||
await rebuildRemoteCompose({
|
||||
composeId: job.composeId,
|
||||
titleLog: job.titleLog || "Rebuild deployment",
|
||||
descriptionLog: job.descriptionLog || "",
|
||||
titleLog: job.titleLog,
|
||||
descriptionLog: job.descriptionLog,
|
||||
});
|
||||
} else if (job.type === "deploy") {
|
||||
await deployCompose({
|
||||
await deployRemoteCompose({
|
||||
composeId: job.composeId,
|
||||
titleLog: job.titleLog || "Manual deployment",
|
||||
descriptionLog: job.descriptionLog || "",
|
||||
titleLog: job.titleLog,
|
||||
descriptionLog: job.descriptionLog,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -55,10 +55,10 @@ export const deploy = async (job: DeployJob) => {
|
||||
});
|
||||
if (job.server) {
|
||||
if (job.type === "deploy") {
|
||||
await deployPreviewApplication({
|
||||
await deployRemotePreviewApplication({
|
||||
applicationId: job.applicationId,
|
||||
titleLog: job.titleLog || "Preview Deployment",
|
||||
descriptionLog: job.descriptionLog || "",
|
||||
titleLog: job.titleLog,
|
||||
descriptionLog: job.descriptionLog,
|
||||
previewDeploymentId: job.previewDeploymentId,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
DATABASE_URL="postgres://dokploy:amukds4wi9001583845717ad2@localhost:5432/dokploy"
|
||||
PORT=3000
|
||||
NODE_ENV=development
|
||||
NODE_ENV=development
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ComposeSpecification } from "@dokploy/server";
|
||||
import { addSuffixToAllProperties } from "@dokploy/server";
|
||||
import { load } from "js-yaml";
|
||||
import { expect, test } from "vitest";
|
||||
import { parse } from "yaml";
|
||||
|
||||
const composeFile1 = `
|
||||
version: "3.8"
|
||||
@@ -61,7 +61,7 @@ secrets:
|
||||
file: ./db_password.txt
|
||||
`;
|
||||
|
||||
const expectedComposeFile1 = parse(`
|
||||
const expectedComposeFile1 = load(`
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
@@ -120,7 +120,7 @@ secrets:
|
||||
`) as ComposeSpecification;
|
||||
|
||||
test("Add suffix to all properties in compose file 1", () => {
|
||||
const composeData = parse(composeFile1) as ComposeSpecification;
|
||||
const composeData = load(composeFile1) as ComposeSpecification;
|
||||
const suffix = "testhash";
|
||||
|
||||
const updatedComposeData = addSuffixToAllProperties(composeData, suffix);
|
||||
@@ -185,7 +185,7 @@ secrets:
|
||||
file: ./db_password.txt
|
||||
`;
|
||||
|
||||
const expectedComposeFile2 = parse(`
|
||||
const expectedComposeFile2 = load(`
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
@@ -243,7 +243,7 @@ secrets:
|
||||
`) as ComposeSpecification;
|
||||
|
||||
test("Add suffix to all properties in compose file 2", () => {
|
||||
const composeData = parse(composeFile2) as ComposeSpecification;
|
||||
const composeData = load(composeFile2) as ComposeSpecification;
|
||||
const suffix = "testhash";
|
||||
|
||||
const updatedComposeData = addSuffixToAllProperties(composeData, suffix);
|
||||
@@ -308,7 +308,7 @@ secrets:
|
||||
file: ./service_secret.txt
|
||||
`;
|
||||
|
||||
const expectedComposeFile3 = parse(`
|
||||
const expectedComposeFile3 = load(`
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
@@ -366,7 +366,7 @@ secrets:
|
||||
`) as ComposeSpecification;
|
||||
|
||||
test("Add suffix to all properties in compose file 3", () => {
|
||||
const composeData = parse(composeFile3) as ComposeSpecification;
|
||||
const composeData = load(composeFile3) as ComposeSpecification;
|
||||
const suffix = "testhash";
|
||||
|
||||
const updatedComposeData = addSuffixToAllProperties(composeData, suffix);
|
||||
@@ -420,7 +420,7 @@ volumes:
|
||||
driver: local
|
||||
`;
|
||||
|
||||
const expectedComposeFile = parse(`
|
||||
const expectedComposeFile = load(`
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
@@ -467,7 +467,7 @@ volumes:
|
||||
`) as ComposeSpecification;
|
||||
|
||||
test("Add suffix to all properties in Plausible compose file", () => {
|
||||
const composeData = parse(composeFile) as ComposeSpecification;
|
||||
const composeData = load(composeFile) as ComposeSpecification;
|
||||
const suffix = "testhash";
|
||||
|
||||
const updatedComposeData = addSuffixToAllProperties(composeData, suffix);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ComposeSpecification } from "@dokploy/server";
|
||||
import { addSuffixToConfigsRoot, generateRandomHash } from "@dokploy/server";
|
||||
import { load } from "js-yaml";
|
||||
import { expect, test } from "vitest";
|
||||
import { parse } from "yaml";
|
||||
|
||||
test("Generate random hash with 8 characters", () => {
|
||||
const hash = generateRandomHash();
|
||||
@@ -23,7 +23,7 @@ configs:
|
||||
`;
|
||||
|
||||
test("Add suffix to configs in root property", () => {
|
||||
const composeData = parse(composeFile) as ComposeSpecification;
|
||||
const composeData = load(composeFile) as ComposeSpecification;
|
||||
|
||||
const suffix = generateRandomHash();
|
||||
|
||||
@@ -59,7 +59,7 @@ configs:
|
||||
`;
|
||||
|
||||
test("Add suffix to multiple configs in root property", () => {
|
||||
const composeData = parse(composeFileMultipleConfigs) as ComposeSpecification;
|
||||
const composeData = load(composeFileMultipleConfigs) as ComposeSpecification;
|
||||
|
||||
const suffix = generateRandomHash();
|
||||
|
||||
@@ -92,7 +92,7 @@ configs:
|
||||
`;
|
||||
|
||||
test("Add suffix to configs with different properties in root property", () => {
|
||||
const composeData = parse(
|
||||
const composeData = load(
|
||||
composeFileDifferentProperties,
|
||||
) as ComposeSpecification;
|
||||
|
||||
@@ -137,7 +137,7 @@ configs:
|
||||
`;
|
||||
|
||||
// Expected compose file con el prefijo `testhash`
|
||||
const expectedComposeFileConfigRoot = parse(`
|
||||
const expectedComposeFileConfigRoot = load(`
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
@@ -162,7 +162,7 @@ configs:
|
||||
`) as ComposeSpecification;
|
||||
|
||||
test("Add suffix to configs in root property", () => {
|
||||
const composeData = parse(composeFileConfigRoot) as ComposeSpecification;
|
||||
const composeData = load(composeFileConfigRoot) as ComposeSpecification;
|
||||
|
||||
const suffix = "testhash";
|
||||
|
||||
|
||||
@@ -3,8 +3,8 @@ import {
|
||||
addSuffixToConfigsInServices,
|
||||
generateRandomHash,
|
||||
} from "@dokploy/server";
|
||||
import { load } from "js-yaml";
|
||||
import { expect, test } from "vitest";
|
||||
import { parse } from "yaml";
|
||||
|
||||
const composeFile = `
|
||||
version: "3.8"
|
||||
@@ -22,7 +22,7 @@ configs:
|
||||
`;
|
||||
|
||||
test("Add suffix to configs in services", () => {
|
||||
const composeData = parse(composeFile) as ComposeSpecification;
|
||||
const composeData = load(composeFile) as ComposeSpecification;
|
||||
|
||||
const suffix = generateRandomHash();
|
||||
|
||||
@@ -54,7 +54,7 @@ configs:
|
||||
`;
|
||||
|
||||
test("Add suffix to configs in services with single config", () => {
|
||||
const composeData = parse(
|
||||
const composeData = load(
|
||||
composeFileSingleServiceConfig,
|
||||
) as ComposeSpecification;
|
||||
|
||||
@@ -108,7 +108,7 @@ configs:
|
||||
`;
|
||||
|
||||
test("Add suffix to configs in services with multiple configs", () => {
|
||||
const composeData = parse(
|
||||
const composeData = load(
|
||||
composeFileMultipleServicesConfigs,
|
||||
) as ComposeSpecification;
|
||||
|
||||
@@ -157,7 +157,7 @@ services:
|
||||
`;
|
||||
|
||||
// Expected compose file con el prefijo `testhash`
|
||||
const expectedComposeFileConfigServices = parse(`
|
||||
const expectedComposeFileConfigServices = load(`
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
@@ -182,7 +182,7 @@ services:
|
||||
`) as ComposeSpecification;
|
||||
|
||||
test("Add suffix to configs in services", () => {
|
||||
const composeData = parse(composeFileConfigServices) as ComposeSpecification;
|
||||
const composeData = load(composeFileConfigServices) as ComposeSpecification;
|
||||
|
||||
const suffix = "testhash";
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ComposeSpecification } from "@dokploy/server";
|
||||
import { addSuffixToAllConfigs, generateRandomHash } from "@dokploy/server";
|
||||
import { load } from "js-yaml";
|
||||
import { expect, test } from "vitest";
|
||||
import { parse } from "yaml";
|
||||
|
||||
test("Generate random hash with 8 characters", () => {
|
||||
const hash = generateRandomHash();
|
||||
@@ -43,7 +43,7 @@ configs:
|
||||
file: ./db-config.yml
|
||||
`;
|
||||
|
||||
const expectedComposeFileCombinedConfigs = parse(`
|
||||
const expectedComposeFileCombinedConfigs = load(`
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
@@ -77,7 +77,7 @@ configs:
|
||||
`) as ComposeSpecification;
|
||||
|
||||
test("Add suffix to all configs in root and services", () => {
|
||||
const composeData = parse(composeFileCombinedConfigs) as ComposeSpecification;
|
||||
const composeData = load(composeFileCombinedConfigs) as ComposeSpecification;
|
||||
|
||||
const suffix = "testhash";
|
||||
|
||||
@@ -122,7 +122,7 @@ configs:
|
||||
file: ./db-config.yml
|
||||
`;
|
||||
|
||||
const expectedComposeFileWithEnvAndExternal = parse(`
|
||||
const expectedComposeFileWithEnvAndExternal = load(`
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
@@ -159,7 +159,7 @@ configs:
|
||||
`) as ComposeSpecification;
|
||||
|
||||
test("Add suffix to configs with environment and external", () => {
|
||||
const composeData = parse(
|
||||
const composeData = load(
|
||||
composeFileWithEnvAndExternal,
|
||||
) as ComposeSpecification;
|
||||
|
||||
@@ -200,7 +200,7 @@ configs:
|
||||
file: ./db-config.yml
|
||||
`;
|
||||
|
||||
const expectedComposeFileWithTemplateDriverAndLabels = parse(`
|
||||
const expectedComposeFileWithTemplateDriverAndLabels = load(`
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
@@ -231,7 +231,7 @@ configs:
|
||||
`) as ComposeSpecification;
|
||||
|
||||
test("Add suffix to configs with template driver and labels", () => {
|
||||
const composeData = parse(
|
||||
const composeData = load(
|
||||
composeFileWithTemplateDriverAndLabels,
|
||||
) as ComposeSpecification;
|
||||
|
||||
|
||||
@@ -1,215 +0,0 @@
|
||||
import type { Domain } from "@dokploy/server";
|
||||
import { createDomainLabels } from "@dokploy/server";
|
||||
import { parse, stringify } from "yaml";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
/**
|
||||
* Regression tests for Traefik Host rule label format.
|
||||
*
|
||||
* These tests verify that the Host rule is generated with the correct format:
|
||||
* - Host(`domain.com`) - with opening and closing parentheses
|
||||
* - Host(`domain.com`) && PathPrefix(`/path`) - for path-based routing
|
||||
*
|
||||
* Issue: https://github.com/Dokploy/dokploy/issues/3161
|
||||
* The bug caused Host rules to be malformed as Host`domain.com`)
|
||||
* (missing opening parenthesis) which broke all domain routing.
|
||||
*/
|
||||
describe("Host rule format regression tests", () => {
|
||||
const baseDomain: Domain = {
|
||||
host: "example.com",
|
||||
port: 8080,
|
||||
https: false,
|
||||
uniqueConfigKey: 1,
|
||||
customCertResolver: null,
|
||||
certificateType: "none",
|
||||
applicationId: "",
|
||||
composeId: "",
|
||||
domainType: "compose",
|
||||
serviceName: "test-app",
|
||||
domainId: "",
|
||||
path: "/",
|
||||
createdAt: "",
|
||||
previewDeploymentId: "",
|
||||
internalPath: "/",
|
||||
stripPath: false,
|
||||
};
|
||||
|
||||
describe("Host rule format validation", () => {
|
||||
it("should generate Host rule with correct parentheses format", async () => {
|
||||
const labels = await createDomainLabels("test-app", baseDomain, "web");
|
||||
const ruleLabel = labels.find((l) => l.includes(".rule="));
|
||||
|
||||
expect(ruleLabel).toBeDefined();
|
||||
// Verify exact format: Host(`domain`)
|
||||
expect(ruleLabel).toMatch(/Host\(`[^`]+`\)/);
|
||||
// Ensure opening parenthesis is present after Host
|
||||
expect(ruleLabel).toContain("Host(`example.com`)");
|
||||
// Ensure it does NOT have the malformed format
|
||||
expect(ruleLabel).not.toMatch(/Host`[^`]+`\)/);
|
||||
});
|
||||
|
||||
it("should generate PathPrefix with correct parentheses format", async () => {
|
||||
const labels = await createDomainLabels(
|
||||
"test-app",
|
||||
{ ...baseDomain, path: "/api" },
|
||||
"web",
|
||||
);
|
||||
const ruleLabel = labels.find((l) => l.includes(".rule="));
|
||||
|
||||
expect(ruleLabel).toBeDefined();
|
||||
// Verify PathPrefix format
|
||||
expect(ruleLabel).toMatch(/PathPrefix\(`[^`]+`\)/);
|
||||
expect(ruleLabel).toContain("PathPrefix(`/api`)");
|
||||
// Ensure opening parenthesis is present
|
||||
expect(ruleLabel).not.toMatch(/PathPrefix`[^`]+`\)/);
|
||||
});
|
||||
|
||||
it("should generate combined Host and PathPrefix with correct format", async () => {
|
||||
const labels = await createDomainLabels(
|
||||
"test-app",
|
||||
{ ...baseDomain, path: "/api/v1" },
|
||||
"websecure",
|
||||
);
|
||||
const ruleLabel = labels.find((l) => l.includes(".rule="));
|
||||
|
||||
expect(ruleLabel).toBeDefined();
|
||||
expect(ruleLabel).toBe(
|
||||
"traefik.http.routers.test-app-1-websecure.rule=Host(`example.com`) && PathPrefix(`/api/v1`)",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("YAML serialization preserves Host rule format", () => {
|
||||
it("should preserve Host rule format through YAML stringify/parse", async () => {
|
||||
const labels = await createDomainLabels("test-app", baseDomain, "web");
|
||||
const ruleLabel = labels.find((l) => l.includes(".rule="));
|
||||
|
||||
// Simulate compose file structure
|
||||
const composeSpec = {
|
||||
services: {
|
||||
myapp: {
|
||||
image: "nginx",
|
||||
labels: labels,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Stringify to YAML
|
||||
const yamlOutput = stringify(composeSpec, { lineWidth: 1000 });
|
||||
|
||||
// Parse back
|
||||
const parsed = parse(yamlOutput) as typeof composeSpec;
|
||||
const parsedRuleLabel = parsed.services.myapp.labels.find((l: string) =>
|
||||
l.includes(".rule="),
|
||||
);
|
||||
|
||||
// Verify format is preserved
|
||||
expect(parsedRuleLabel).toBe(ruleLabel);
|
||||
expect(parsedRuleLabel).toContain("Host(`example.com`)");
|
||||
expect(parsedRuleLabel).not.toMatch(/Host`[^`]+`\)/);
|
||||
});
|
||||
|
||||
it("should preserve complex rule format through YAML serialization", async () => {
|
||||
const labels = await createDomainLabels(
|
||||
"test-app",
|
||||
{ ...baseDomain, path: "/api", https: true },
|
||||
"websecure",
|
||||
);
|
||||
|
||||
const composeSpec = {
|
||||
services: {
|
||||
myapp: {
|
||||
labels: labels,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const yamlOutput = stringify(composeSpec, { lineWidth: 1000 });
|
||||
const parsed = parse(yamlOutput) as typeof composeSpec;
|
||||
const parsedRuleLabel = parsed.services.myapp.labels.find((l: string) =>
|
||||
l.includes(".rule="),
|
||||
);
|
||||
|
||||
expect(parsedRuleLabel).toContain(
|
||||
"Host(`example.com`) && PathPrefix(`/api`)",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Edge cases for domain names", () => {
|
||||
const domainCases = [
|
||||
{ name: "simple domain", host: "example.com" },
|
||||
{ name: "subdomain", host: "app.example.com" },
|
||||
{ name: "deep subdomain", host: "api.v1.app.example.com" },
|
||||
{ name: "numeric domain", host: "123.example.com" },
|
||||
{ name: "hyphenated domain", host: "my-app.example-host.com" },
|
||||
{ name: "localhost", host: "localhost" },
|
||||
{ name: "IP address style", host: "192.168.1.100" },
|
||||
];
|
||||
|
||||
for (const { name, host } of domainCases) {
|
||||
it(`should generate correct Host rule for ${name}: ${host}`, async () => {
|
||||
const labels = await createDomainLabels(
|
||||
"test-app",
|
||||
{ ...baseDomain, host },
|
||||
"web",
|
||||
);
|
||||
const ruleLabel = labels.find((l) => l.includes(".rule="));
|
||||
|
||||
expect(ruleLabel).toBeDefined();
|
||||
expect(ruleLabel).toContain(`Host(\`${host}\`)`);
|
||||
// Verify parenthesis is present
|
||||
expect(ruleLabel).toMatch(
|
||||
new RegExp(`Host\\(\\\`${host.replace(/\./g, "\\.")}\\\`\\)`),
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("Multiple domains scenario", () => {
|
||||
it("should generate correct format for both web and websecure entrypoints", async () => {
|
||||
const webLabels = await createDomainLabels("test-app", baseDomain, "web");
|
||||
const websecureLabels = await createDomainLabels(
|
||||
"test-app",
|
||||
baseDomain,
|
||||
"websecure",
|
||||
);
|
||||
|
||||
const webRule = webLabels.find((l) => l.includes(".rule="));
|
||||
const websecureRule = websecureLabels.find((l) => l.includes(".rule="));
|
||||
|
||||
// Both should have correct format
|
||||
expect(webRule).toContain("Host(`example.com`)");
|
||||
expect(websecureRule).toContain("Host(`example.com`)");
|
||||
|
||||
// Neither should have malformed format
|
||||
expect(webRule).not.toMatch(/Host`[^`]+`\)/);
|
||||
expect(websecureRule).not.toMatch(/Host`[^`]+`\)/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Special characters in paths", () => {
|
||||
const pathCases = [
|
||||
{ name: "simple path", path: "/api" },
|
||||
{ name: "nested path", path: "/api/v1/users" },
|
||||
{ name: "path with hyphen", path: "/api-v1" },
|
||||
{ name: "path with underscore", path: "/api_v1" },
|
||||
];
|
||||
|
||||
for (const { name, path } of pathCases) {
|
||||
it(`should generate correct PathPrefix for ${name}: ${path}`, async () => {
|
||||
const labels = await createDomainLabels(
|
||||
"test-app",
|
||||
{ ...baseDomain, path },
|
||||
"web",
|
||||
);
|
||||
const ruleLabel = labels.find((l) => l.includes(".rule="));
|
||||
|
||||
expect(ruleLabel).toBeDefined();
|
||||
expect(ruleLabel).toContain(`PathPrefix(\`${path}\`)`);
|
||||
// Verify parenthesis is present
|
||||
expect(ruleLabel).not.toMatch(/PathPrefix`[^`]+`\)/);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ComposeSpecification } from "@dokploy/server";
|
||||
import { addSuffixToNetworksRoot, generateRandomHash } from "@dokploy/server";
|
||||
import { load } from "js-yaml";
|
||||
import { expect, test } from "vitest";
|
||||
import { parse } from "yaml";
|
||||
|
||||
const composeFile = `
|
||||
version: "3.8"
|
||||
@@ -35,7 +35,7 @@ test("Generate random hash with 8 characters", () => {
|
||||
});
|
||||
|
||||
test("Add suffix to networks root property", () => {
|
||||
const composeData = parse(composeFile) as ComposeSpecification;
|
||||
const composeData = load(composeFile) as ComposeSpecification;
|
||||
|
||||
const suffix = generateRandomHash();
|
||||
|
||||
@@ -79,7 +79,7 @@ networks:
|
||||
`;
|
||||
|
||||
test("Add suffix to advanced networks root property (2 TRY)", () => {
|
||||
const composeData = parse(composeFile2) as ComposeSpecification;
|
||||
const composeData = load(composeFile2) as ComposeSpecification;
|
||||
|
||||
const suffix = generateRandomHash();
|
||||
|
||||
@@ -120,7 +120,7 @@ networks:
|
||||
`;
|
||||
|
||||
test("Add suffix to networks with external properties", () => {
|
||||
const composeData = parse(composeFile3) as ComposeSpecification;
|
||||
const composeData = load(composeFile3) as ComposeSpecification;
|
||||
|
||||
const suffix = generateRandomHash();
|
||||
|
||||
@@ -160,7 +160,7 @@ networks:
|
||||
`;
|
||||
|
||||
test("Add suffix to networks with IPAM configurations", () => {
|
||||
const composeData = parse(composeFile4) as ComposeSpecification;
|
||||
const composeData = load(composeFile4) as ComposeSpecification;
|
||||
|
||||
const suffix = generateRandomHash();
|
||||
|
||||
@@ -201,7 +201,7 @@ networks:
|
||||
`;
|
||||
|
||||
test("Add suffix to networks with custom options", () => {
|
||||
const composeData = parse(composeFile5) as ComposeSpecification;
|
||||
const composeData = load(composeFile5) as ComposeSpecification;
|
||||
|
||||
const suffix = generateRandomHash();
|
||||
|
||||
@@ -264,7 +264,7 @@ networks:
|
||||
`;
|
||||
|
||||
test("Add suffix to networks with static suffix", () => {
|
||||
const composeData = parse(composeFile6) as ComposeSpecification;
|
||||
const composeData = load(composeFile6) as ComposeSpecification;
|
||||
|
||||
const suffix = "testhash";
|
||||
|
||||
@@ -273,7 +273,7 @@ test("Add suffix to networks with static suffix", () => {
|
||||
}
|
||||
const networks = addSuffixToNetworksRoot(composeData.networks, suffix);
|
||||
|
||||
const expectedComposeData = parse(
|
||||
const expectedComposeData = load(
|
||||
expectedComposeFile6,
|
||||
) as ComposeSpecification;
|
||||
expect(networks).toStrictEqual(expectedComposeData.networks);
|
||||
@@ -293,7 +293,7 @@ networks:
|
||||
`;
|
||||
|
||||
test("It shoudn't add suffix to dokploy-network", () => {
|
||||
const composeData = parse(composeFile7) as ComposeSpecification;
|
||||
const composeData = load(composeFile7) as ComposeSpecification;
|
||||
|
||||
const suffix = generateRandomHash();
|
||||
|
||||
|
||||
@@ -3,8 +3,8 @@ import {
|
||||
addSuffixToServiceNetworks,
|
||||
generateRandomHash,
|
||||
} from "@dokploy/server";
|
||||
import { load } from "js-yaml";
|
||||
import { expect, test } from "vitest";
|
||||
import { parse } from "yaml";
|
||||
|
||||
const composeFile = `
|
||||
version: "3.8"
|
||||
@@ -23,7 +23,7 @@ services:
|
||||
`;
|
||||
|
||||
test("Add suffix to networks in services", () => {
|
||||
const composeData = parse(composeFile) as ComposeSpecification;
|
||||
const composeData = load(composeFile) as ComposeSpecification;
|
||||
|
||||
const suffix = generateRandomHash();
|
||||
|
||||
@@ -67,7 +67,7 @@ networks:
|
||||
`;
|
||||
|
||||
test("Add suffix to networks in services with aliases", () => {
|
||||
const composeData = parse(composeFile2) as ComposeSpecification;
|
||||
const composeData = load(composeFile2) as ComposeSpecification;
|
||||
|
||||
const suffix = generateRandomHash();
|
||||
|
||||
@@ -107,7 +107,7 @@ networks:
|
||||
`;
|
||||
|
||||
test("Add suffix to networks in services (Object with simple networks)", () => {
|
||||
const composeData = parse(composeFile3) as ComposeSpecification;
|
||||
const composeData = load(composeFile3) as ComposeSpecification;
|
||||
|
||||
const suffix = generateRandomHash();
|
||||
|
||||
@@ -153,7 +153,7 @@ networks:
|
||||
`;
|
||||
|
||||
test("Add suffix to networks in services (combined case)", () => {
|
||||
const composeData = parse(composeFileCombined) as ComposeSpecification;
|
||||
const composeData = load(composeFileCombined) as ComposeSpecification;
|
||||
|
||||
const suffix = generateRandomHash();
|
||||
|
||||
@@ -196,7 +196,7 @@ services:
|
||||
`;
|
||||
|
||||
test("It shoudn't add suffix to dokploy-network in services", () => {
|
||||
const composeData = parse(composeFile7) as ComposeSpecification;
|
||||
const composeData = load(composeFile7) as ComposeSpecification;
|
||||
|
||||
const suffix = generateRandomHash();
|
||||
|
||||
@@ -245,7 +245,7 @@ services:
|
||||
`;
|
||||
|
||||
test("It shoudn't add suffix to dokploy-network in services multiples cases", () => {
|
||||
const composeData = parse(composeFile8) as ComposeSpecification;
|
||||
const composeData = load(composeFile8) as ComposeSpecification;
|
||||
|
||||
const suffix = generateRandomHash();
|
||||
|
||||
|
||||
@@ -5,8 +5,8 @@ import {
|
||||
addSuffixToServiceNetworks,
|
||||
generateRandomHash,
|
||||
} from "@dokploy/server";
|
||||
import { load } from "js-yaml";
|
||||
import { expect, test } from "vitest";
|
||||
import { parse } from "yaml";
|
||||
|
||||
const composeFileCombined = `
|
||||
version: "3.8"
|
||||
@@ -39,7 +39,7 @@ networks:
|
||||
`;
|
||||
|
||||
test("Add suffix to networks in services and root (combined case)", () => {
|
||||
const composeData = parse(composeFileCombined) as ComposeSpecification;
|
||||
const composeData = load(composeFileCombined) as ComposeSpecification;
|
||||
|
||||
const suffix = generateRandomHash();
|
||||
|
||||
@@ -89,7 +89,7 @@ test("Add suffix to networks in services and root (combined case)", () => {
|
||||
expect(redisNetworks).not.toHaveProperty("backend");
|
||||
});
|
||||
|
||||
const expectedComposeFile = parse(`
|
||||
const expectedComposeFile = load(`
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
@@ -120,7 +120,7 @@ networks:
|
||||
`);
|
||||
|
||||
test("Add suffix to networks in compose file", () => {
|
||||
const composeData = parse(composeFileCombined) as ComposeSpecification;
|
||||
const composeData = load(composeFileCombined) as ComposeSpecification;
|
||||
|
||||
const suffix = "testhash";
|
||||
if (!composeData?.networks) {
|
||||
@@ -156,7 +156,7 @@ networks:
|
||||
driver: bridge
|
||||
`;
|
||||
|
||||
const expectedComposeFile2 = parse(`
|
||||
const expectedComposeFile2 = load(`
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
@@ -182,7 +182,7 @@ networks:
|
||||
`);
|
||||
|
||||
test("Add suffix to networks in compose file with external and internal networks", () => {
|
||||
const composeData = parse(composeFile2) as ComposeSpecification;
|
||||
const composeData = load(composeFile2) as ComposeSpecification;
|
||||
|
||||
const suffix = "testhash";
|
||||
const updatedComposeData = addSuffixToAllNetworks(composeData, suffix);
|
||||
@@ -218,7 +218,7 @@ networks:
|
||||
com.docker.network.bridge.enable_icc: "true"
|
||||
`;
|
||||
|
||||
const expectedComposeFile3 = parse(`
|
||||
const expectedComposeFile3 = load(`
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
@@ -247,7 +247,7 @@ networks:
|
||||
`);
|
||||
|
||||
test("Add suffix to networks in compose file with multiple services and complex network configurations", () => {
|
||||
const composeData = parse(composeFile3) as ComposeSpecification;
|
||||
const composeData = load(composeFile3) as ComposeSpecification;
|
||||
|
||||
const suffix = "testhash";
|
||||
const updatedComposeData = addSuffixToAllNetworks(composeData, suffix);
|
||||
@@ -289,7 +289,7 @@ networks:
|
||||
|
||||
`;
|
||||
|
||||
const expectedComposeFile4 = parse(`
|
||||
const expectedComposeFile4 = load(`
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
@@ -326,7 +326,7 @@ networks:
|
||||
`);
|
||||
|
||||
test("Expect don't add suffix to dokploy-network in compose file with multiple services and complex network configurations", () => {
|
||||
const composeData = parse(composeFile4) as ComposeSpecification;
|
||||
const composeData = load(composeFile4) as ComposeSpecification;
|
||||
|
||||
const suffix = "testhash";
|
||||
const updatedComposeData = addSuffixToAllNetworks(composeData, suffix);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ComposeSpecification } from "@dokploy/server";
|
||||
import { addSuffixToSecretsRoot, generateRandomHash } from "@dokploy/server";
|
||||
import { load } from "js-yaml";
|
||||
import { expect, test } from "vitest";
|
||||
import { parse } from "yaml";
|
||||
|
||||
test("Generate random hash with 8 characters", () => {
|
||||
const hash = generateRandomHash();
|
||||
@@ -23,7 +23,7 @@ secrets:
|
||||
`;
|
||||
|
||||
test("Add suffix to secrets in root property", () => {
|
||||
const composeData = parse(composeFileSecretsRoot) as ComposeSpecification;
|
||||
const composeData = load(composeFileSecretsRoot) as ComposeSpecification;
|
||||
const suffix = generateRandomHash();
|
||||
|
||||
if (!composeData?.secrets) {
|
||||
@@ -52,7 +52,7 @@ secrets:
|
||||
`;
|
||||
|
||||
test("Add suffix to secrets in root property (Test 1)", () => {
|
||||
const composeData = parse(composeFileSecretsRoot1) as ComposeSpecification;
|
||||
const composeData = load(composeFileSecretsRoot1) as ComposeSpecification;
|
||||
const suffix = generateRandomHash();
|
||||
|
||||
if (!composeData?.secrets) {
|
||||
@@ -84,7 +84,7 @@ secrets:
|
||||
`;
|
||||
|
||||
test("Add suffix to secrets in root property (Test 2)", () => {
|
||||
const composeData = parse(composeFileSecretsRoot2) as ComposeSpecification;
|
||||
const composeData = load(composeFileSecretsRoot2) as ComposeSpecification;
|
||||
const suffix = generateRandomHash();
|
||||
|
||||
if (!composeData?.secrets) {
|
||||
|
||||
@@ -3,8 +3,8 @@ import {
|
||||
addSuffixToSecretsInServices,
|
||||
generateRandomHash,
|
||||
} from "@dokploy/server";
|
||||
import { load } from "js-yaml";
|
||||
import { expect, test } from "vitest";
|
||||
import { parse } from "yaml";
|
||||
|
||||
const composeFileSecretsServices = `
|
||||
version: "3.8"
|
||||
@@ -21,7 +21,7 @@ secrets:
|
||||
`;
|
||||
|
||||
test("Add suffix to secrets in services", () => {
|
||||
const composeData = parse(composeFileSecretsServices) as ComposeSpecification;
|
||||
const composeData = load(composeFileSecretsServices) as ComposeSpecification;
|
||||
const suffix = generateRandomHash();
|
||||
|
||||
if (!composeData.services) {
|
||||
@@ -54,9 +54,7 @@ secrets:
|
||||
`;
|
||||
|
||||
test("Add suffix to secrets in services (Test 1)", () => {
|
||||
const composeData = parse(
|
||||
composeFileSecretsServices1,
|
||||
) as ComposeSpecification;
|
||||
const composeData = load(composeFileSecretsServices1) as ComposeSpecification;
|
||||
const suffix = generateRandomHash();
|
||||
|
||||
if (!composeData.services) {
|
||||
@@ -95,9 +93,7 @@ secrets:
|
||||
`;
|
||||
|
||||
test("Add suffix to secrets in services (Test 2)", () => {
|
||||
const composeData = parse(
|
||||
composeFileSecretsServices2,
|
||||
) as ComposeSpecification;
|
||||
const composeData = load(composeFileSecretsServices2) as ComposeSpecification;
|
||||
const suffix = generateRandomHash();
|
||||
|
||||
if (!composeData.services) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ComposeSpecification } from "@dokploy/server";
|
||||
import { addSuffixToAllSecrets } from "@dokploy/server";
|
||||
import { load } from "js-yaml";
|
||||
import { expect, test } from "vitest";
|
||||
import { parse } from "yaml";
|
||||
|
||||
const composeFileCombinedSecrets = `
|
||||
version: "3.8"
|
||||
@@ -25,7 +25,7 @@ secrets:
|
||||
file: ./app_secret.txt
|
||||
`;
|
||||
|
||||
const expectedComposeFileCombinedSecrets = parse(`
|
||||
const expectedComposeFileCombinedSecrets = load(`
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
@@ -48,7 +48,7 @@ secrets:
|
||||
`) as ComposeSpecification;
|
||||
|
||||
test("Add suffix to all secrets", () => {
|
||||
const composeData = parse(composeFileCombinedSecrets) as ComposeSpecification;
|
||||
const composeData = load(composeFileCombinedSecrets) as ComposeSpecification;
|
||||
const suffix = "testhash";
|
||||
|
||||
const updatedComposeData = addSuffixToAllSecrets(composeData, suffix);
|
||||
@@ -77,7 +77,7 @@ secrets:
|
||||
file: ./cache_secret.txt
|
||||
`;
|
||||
|
||||
const expectedComposeFileCombinedSecrets3 = parse(`
|
||||
const expectedComposeFileCombinedSecrets3 = load(`
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
@@ -99,9 +99,7 @@ secrets:
|
||||
`) as ComposeSpecification;
|
||||
|
||||
test("Add suffix to all secrets (3rd Case)", () => {
|
||||
const composeData = parse(
|
||||
composeFileCombinedSecrets3,
|
||||
) as ComposeSpecification;
|
||||
const composeData = load(composeFileCombinedSecrets3) as ComposeSpecification;
|
||||
const suffix = "testhash";
|
||||
|
||||
const updatedComposeData = addSuffixToAllSecrets(composeData, suffix);
|
||||
@@ -130,7 +128,7 @@ secrets:
|
||||
file: ./db_password.txt
|
||||
`;
|
||||
|
||||
const expectedComposeFileCombinedSecrets4 = parse(`
|
||||
const expectedComposeFileCombinedSecrets4 = load(`
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
@@ -152,9 +150,7 @@ secrets:
|
||||
`) as ComposeSpecification;
|
||||
|
||||
test("Add suffix to all secrets (4th Case)", () => {
|
||||
const composeData = parse(
|
||||
composeFileCombinedSecrets4,
|
||||
) as ComposeSpecification;
|
||||
const composeData = load(composeFileCombinedSecrets4) as ComposeSpecification;
|
||||
const suffix = "testhash";
|
||||
|
||||
const updatedComposeData = addSuffixToAllSecrets(composeData, suffix);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ComposeSpecification } from "@dokploy/server";
|
||||
import { addSuffixToServiceNames, generateRandomHash } from "@dokploy/server";
|
||||
import { load } from "js-yaml";
|
||||
import { expect, test } from "vitest";
|
||||
import { parse } from "yaml";
|
||||
|
||||
const composeFile = `
|
||||
version: "3.8"
|
||||
@@ -27,7 +27,7 @@ test("Generate random hash with 8 characters", () => {
|
||||
});
|
||||
|
||||
test("Add suffix to service names with container_name in compose file", () => {
|
||||
const composeData = parse(composeFile) as ComposeSpecification;
|
||||
const composeData = load(composeFile) as ComposeSpecification;
|
||||
|
||||
const suffix = generateRandomHash();
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ComposeSpecification } from "@dokploy/server";
|
||||
import { addSuffixToServiceNames, generateRandomHash } from "@dokploy/server";
|
||||
import { load } from "js-yaml";
|
||||
import { expect, test } from "vitest";
|
||||
import { parse } from "yaml";
|
||||
|
||||
test("Generate random hash with 8 characters", () => {
|
||||
const hash = generateRandomHash();
|
||||
@@ -32,7 +32,7 @@ networks:
|
||||
`;
|
||||
|
||||
test("Add suffix to service names with depends_on (array) in compose file", () => {
|
||||
const composeData = parse(composeFile4) as ComposeSpecification;
|
||||
const composeData = load(composeFile4) as ComposeSpecification;
|
||||
|
||||
const suffix = generateRandomHash();
|
||||
|
||||
@@ -102,7 +102,7 @@ networks:
|
||||
`;
|
||||
|
||||
test("Add suffix to service names with depends_on (object) in compose file", () => {
|
||||
const composeData = parse(composeFile5) as ComposeSpecification;
|
||||
const composeData = load(composeFile5) as ComposeSpecification;
|
||||
|
||||
const suffix = generateRandomHash();
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ComposeSpecification } from "@dokploy/server";
|
||||
import { addSuffixToServiceNames, generateRandomHash } from "@dokploy/server";
|
||||
import { load } from "js-yaml";
|
||||
import { expect, test } from "vitest";
|
||||
import { parse } from "yaml";
|
||||
|
||||
test("Generate random hash with 8 characters", () => {
|
||||
const hash = generateRandomHash();
|
||||
@@ -30,7 +30,7 @@ networks:
|
||||
`;
|
||||
|
||||
test("Add suffix to service names with extends (string) in compose file", () => {
|
||||
const composeData = parse(composeFile6) as ComposeSpecification;
|
||||
const composeData = load(composeFile6) as ComposeSpecification;
|
||||
|
||||
const suffix = generateRandomHash();
|
||||
|
||||
@@ -90,7 +90,7 @@ networks:
|
||||
`;
|
||||
|
||||
test("Add suffix to service names with extends (object) in compose file", () => {
|
||||
const composeData = parse(composeFile7) as ComposeSpecification;
|
||||
const composeData = load(composeFile7) as ComposeSpecification;
|
||||
|
||||
const suffix = generateRandomHash();
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ComposeSpecification } from "@dokploy/server";
|
||||
import { addSuffixToServiceNames, generateRandomHash } from "@dokploy/server";
|
||||
import { load } from "js-yaml";
|
||||
import { expect, test } from "vitest";
|
||||
import { parse } from "yaml";
|
||||
|
||||
test("Generate random hash with 8 characters", () => {
|
||||
const hash = generateRandomHash();
|
||||
@@ -31,7 +31,7 @@ networks:
|
||||
`;
|
||||
|
||||
test("Add suffix to service names with links in compose file", () => {
|
||||
const composeData = parse(composeFile2) as ComposeSpecification;
|
||||
const composeData = load(composeFile2) as ComposeSpecification;
|
||||
|
||||
const suffix = generateRandomHash();
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ComposeSpecification } from "@dokploy/server";
|
||||
import { addSuffixToServiceNames, generateRandomHash } from "@dokploy/server";
|
||||
import { load } from "js-yaml";
|
||||
import { expect, test } from "vitest";
|
||||
import { parse } from "yaml";
|
||||
|
||||
test("Generate random hash with 8 characters", () => {
|
||||
const hash = generateRandomHash();
|
||||
@@ -26,7 +26,7 @@ networks:
|
||||
`;
|
||||
|
||||
test("Add suffix to service names in compose file", () => {
|
||||
const composeData = parse(composeFile) as ComposeSpecification;
|
||||
const composeData = load(composeFile) as ComposeSpecification;
|
||||
|
||||
const suffix = generateRandomHash();
|
||||
|
||||
|
||||
@@ -3,8 +3,8 @@ import {
|
||||
addSuffixToAllServiceNames,
|
||||
addSuffixToServiceNames,
|
||||
} from "@dokploy/server";
|
||||
import { load } from "js-yaml";
|
||||
import { expect, test } from "vitest";
|
||||
import { parse } from "yaml";
|
||||
|
||||
const composeFileCombinedAllCases = `
|
||||
version: "3.8"
|
||||
@@ -38,7 +38,7 @@ networks:
|
||||
driver: bridge
|
||||
`;
|
||||
|
||||
const expectedComposeFile = parse(`
|
||||
const expectedComposeFile = load(`
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
@@ -71,9 +71,7 @@ networks:
|
||||
`);
|
||||
|
||||
test("Add suffix to all service names in compose file", () => {
|
||||
const composeData = parse(
|
||||
composeFileCombinedAllCases,
|
||||
) as ComposeSpecification;
|
||||
const composeData = load(composeFileCombinedAllCases) as ComposeSpecification;
|
||||
|
||||
const suffix = "testhash";
|
||||
|
||||
@@ -133,7 +131,7 @@ networks:
|
||||
driver: bridge
|
||||
`;
|
||||
|
||||
const expectedComposeFile1 = parse(`
|
||||
const expectedComposeFile1 = load(`
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
@@ -178,7 +176,7 @@ networks:
|
||||
`) as ComposeSpecification;
|
||||
|
||||
test("Add suffix to all service names in compose file 1", () => {
|
||||
const composeData = parse(composeFile1) as ComposeSpecification;
|
||||
const composeData = load(composeFile1) as ComposeSpecification;
|
||||
const suffix = "testhash";
|
||||
|
||||
const updatedComposeData = addSuffixToAllServiceNames(composeData, suffix);
|
||||
@@ -229,7 +227,7 @@ networks:
|
||||
driver: bridge
|
||||
`;
|
||||
|
||||
const expectedComposeFile2 = parse(`
|
||||
const expectedComposeFile2 = load(`
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
@@ -273,7 +271,7 @@ networks:
|
||||
`) as ComposeSpecification;
|
||||
|
||||
test("Add suffix to all service names in compose file 2", () => {
|
||||
const composeData = parse(composeFile2) as ComposeSpecification;
|
||||
const composeData = load(composeFile2) as ComposeSpecification;
|
||||
const suffix = "testhash";
|
||||
|
||||
const updatedComposeData = addSuffixToAllServiceNames(composeData, suffix);
|
||||
@@ -324,7 +322,7 @@ networks:
|
||||
driver: bridge
|
||||
`;
|
||||
|
||||
const expectedComposeFile3 = parse(`
|
||||
const expectedComposeFile3 = load(`
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
@@ -368,7 +366,7 @@ networks:
|
||||
`) as ComposeSpecification;
|
||||
|
||||
test("Add suffix to all service names in compose file 3", () => {
|
||||
const composeData = parse(composeFile3) as ComposeSpecification;
|
||||
const composeData = load(composeFile3) as ComposeSpecification;
|
||||
const suffix = "testhash";
|
||||
|
||||
const updatedComposeData = addSuffixToAllServiceNames(composeData, suffix);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ComposeSpecification } from "@dokploy/server";
|
||||
import { addSuffixToServiceNames, generateRandomHash } from "@dokploy/server";
|
||||
import { load } from "js-yaml";
|
||||
import { expect, test } from "vitest";
|
||||
import { parse } from "yaml";
|
||||
|
||||
test("Generate random hash with 8 characters", () => {
|
||||
const hash = generateRandomHash();
|
||||
@@ -35,7 +35,7 @@ networks:
|
||||
`;
|
||||
|
||||
test("Add suffix to service names with volumes_from in compose file", () => {
|
||||
const composeData = parse(composeFile3) as ComposeSpecification;
|
||||
const composeData = load(composeFile3) as ComposeSpecification;
|
||||
|
||||
const suffix = generateRandomHash();
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@ import {
|
||||
addSuffixToVolumesRoot,
|
||||
generateRandomHash,
|
||||
} from "@dokploy/server";
|
||||
import { load } from "js-yaml";
|
||||
import { expect, test } from "vitest";
|
||||
import { parse } from "yaml";
|
||||
|
||||
const composeFile = `
|
||||
services:
|
||||
@@ -70,7 +70,7 @@ volumes:
|
||||
driver: local
|
||||
`;
|
||||
|
||||
const expectedDockerCompose = parse(`
|
||||
const expectedDockerCompose = load(`
|
||||
services:
|
||||
mail:
|
||||
image: bytemark/smtp
|
||||
@@ -143,7 +143,7 @@ test("Generate random hash with 8 characters", () => {
|
||||
// Docker compose needs unique names for services, volumes, networks and containers
|
||||
// So base on a input which is a dockercompose file, it should replace the name with a hash and return a new dockercompose file
|
||||
test("Add suffix to volumes root property", () => {
|
||||
const composeData = parse(composeFile) as ComposeSpecification;
|
||||
const composeData = load(composeFile) as ComposeSpecification;
|
||||
|
||||
const suffix = generateRandomHash();
|
||||
|
||||
@@ -165,7 +165,7 @@ test("Add suffix to volumes root property", () => {
|
||||
});
|
||||
|
||||
test("Expect to change the suffix in all the possible places", () => {
|
||||
const composeData = parse(composeFile) as ComposeSpecification;
|
||||
const composeData = load(composeFile) as ComposeSpecification;
|
||||
const suffix = "testhash";
|
||||
|
||||
const updatedComposeData = addSuffixToAllVolumes(composeData, suffix);
|
||||
@@ -195,7 +195,7 @@ volumes:
|
||||
mongo-data:
|
||||
`;
|
||||
|
||||
const expectedDockerCompose2 = parse(`
|
||||
const expectedDockerCompose2 = load(`
|
||||
version: '3.8'
|
||||
services:
|
||||
app:
|
||||
@@ -218,7 +218,7 @@ volumes:
|
||||
`) as ComposeSpecification;
|
||||
|
||||
test("Expect to change the suffix in all the possible places (2 Try)", () => {
|
||||
const composeData = parse(composeFile2) as ComposeSpecification;
|
||||
const composeData = load(composeFile2) as ComposeSpecification;
|
||||
const suffix = "testhash";
|
||||
|
||||
const updatedComposeData = addSuffixToAllVolumes(composeData, suffix);
|
||||
@@ -248,7 +248,7 @@ volumes:
|
||||
mongo-data:
|
||||
`;
|
||||
|
||||
const expectedDockerCompose3 = parse(`
|
||||
const expectedDockerCompose3 = load(`
|
||||
version: '3.8'
|
||||
services:
|
||||
app:
|
||||
@@ -271,7 +271,7 @@ volumes:
|
||||
`) as ComposeSpecification;
|
||||
|
||||
test("Expect to change the suffix in all the possible places (3 Try)", () => {
|
||||
const composeData = parse(composeFile3) as ComposeSpecification;
|
||||
const composeData = load(composeFile3) as ComposeSpecification;
|
||||
const suffix = "testhash";
|
||||
|
||||
const updatedComposeData = addSuffixToAllVolumes(composeData, suffix);
|
||||
@@ -645,7 +645,7 @@ volumes:
|
||||
db-config:
|
||||
`;
|
||||
|
||||
const expectedDockerComposeComplex = parse(`
|
||||
const expectedDockerComposeComplex = load(`
|
||||
version: "3.8"
|
||||
services:
|
||||
studio:
|
||||
@@ -1012,7 +1012,7 @@ volumes:
|
||||
`);
|
||||
|
||||
test("Expect to change the suffix in all the possible places (4 Try)", () => {
|
||||
const composeData = parse(composeFileComplex) as ComposeSpecification;
|
||||
const composeData = load(composeFileComplex) as ComposeSpecification;
|
||||
const suffix = "testhash";
|
||||
|
||||
const updatedComposeData = addSuffixToAllVolumes(composeData, suffix);
|
||||
@@ -1065,7 +1065,7 @@ volumes:
|
||||
db-data:
|
||||
`;
|
||||
|
||||
const expectedDockerComposeExample1 = parse(`
|
||||
const expectedDockerComposeExample1 = load(`
|
||||
version: "3.8"
|
||||
services:
|
||||
web:
|
||||
@@ -1111,7 +1111,7 @@ volumes:
|
||||
`) as ComposeSpecification;
|
||||
|
||||
test("Expect to change the suffix in all the possible places (5 Try)", () => {
|
||||
const composeData = parse(composeFileExample1) as ComposeSpecification;
|
||||
const composeData = load(composeFileExample1) as ComposeSpecification;
|
||||
const suffix = "testhash";
|
||||
|
||||
const updatedComposeData = addSuffixToAllVolumes(composeData, suffix);
|
||||
@@ -1143,7 +1143,7 @@ volumes:
|
||||
backrest-cache:
|
||||
`;
|
||||
|
||||
const expectedDockerComposeBackrest = parse(`
|
||||
const expectedDockerComposeBackrest = load(`
|
||||
services:
|
||||
backrest:
|
||||
image: garethgeorge/backrest:v1.7.3
|
||||
@@ -1168,7 +1168,7 @@ volumes:
|
||||
`) as ComposeSpecification;
|
||||
|
||||
test("Should handle volume paths with subdirectories correctly", () => {
|
||||
const composeData = parse(composeFileBackrest) as ComposeSpecification;
|
||||
const composeData = load(composeFileBackrest) as ComposeSpecification;
|
||||
const suffix = "testhash";
|
||||
|
||||
const updatedComposeData = addSuffixToAllVolumes(composeData, suffix);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ComposeSpecification } from "@dokploy/server";
|
||||
import { addSuffixToVolumesRoot, generateRandomHash } from "@dokploy/server";
|
||||
import { load } from "js-yaml";
|
||||
import { expect, test } from "vitest";
|
||||
import { parse } from "yaml";
|
||||
|
||||
const composeFile = `
|
||||
version: "3.8"
|
||||
@@ -29,7 +29,7 @@ test("Generate random hash with 8 characters", () => {
|
||||
});
|
||||
|
||||
test("Add suffix to volumes in root property", () => {
|
||||
const composeData = parse(composeFile) as ComposeSpecification;
|
||||
const composeData = load(composeFile) as ComposeSpecification;
|
||||
|
||||
const suffix = generateRandomHash();
|
||||
|
||||
@@ -67,7 +67,7 @@ networks:
|
||||
`;
|
||||
|
||||
test("Add suffix to volumes in root property (Case 2)", () => {
|
||||
const composeData = parse(composeFile2) as ComposeSpecification;
|
||||
const composeData = load(composeFile2) as ComposeSpecification;
|
||||
|
||||
const suffix = generateRandomHash();
|
||||
|
||||
@@ -101,7 +101,7 @@ networks:
|
||||
`;
|
||||
|
||||
test("Add suffix to volumes in root property (Case 3)", () => {
|
||||
const composeData = parse(composeFile3) as ComposeSpecification;
|
||||
const composeData = load(composeFile3) as ComposeSpecification;
|
||||
|
||||
const suffix = generateRandomHash();
|
||||
|
||||
@@ -148,7 +148,7 @@ volumes:
|
||||
`;
|
||||
|
||||
// Expected compose file con el prefijo `testhash`
|
||||
const expectedComposeFile4 = parse(`
|
||||
const expectedComposeFile4 = load(`
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
@@ -179,7 +179,7 @@ volumes:
|
||||
`) as ComposeSpecification;
|
||||
|
||||
test("Add suffix to volumes in root property", () => {
|
||||
const composeData = parse(composeFile4) as ComposeSpecification;
|
||||
const composeData = load(composeFile4) as ComposeSpecification;
|
||||
|
||||
const suffix = "testhash";
|
||||
|
||||
|
||||
@@ -3,8 +3,8 @@ import {
|
||||
addSuffixToVolumesInServices,
|
||||
generateRandomHash,
|
||||
} from "@dokploy/server";
|
||||
import { load } from "js-yaml";
|
||||
import { expect, test } from "vitest";
|
||||
import { parse } from "yaml";
|
||||
|
||||
test("Generate random hash with 8 characters", () => {
|
||||
const hash = generateRandomHash();
|
||||
@@ -24,7 +24,7 @@ services:
|
||||
`;
|
||||
|
||||
test("Add suffix to volumes declared directly in services", () => {
|
||||
const composeData = parse(composeFile1) as ComposeSpecification;
|
||||
const composeData = load(composeFile1) as ComposeSpecification;
|
||||
|
||||
const suffix = generateRandomHash();
|
||||
|
||||
@@ -59,7 +59,7 @@ volumes:
|
||||
`;
|
||||
|
||||
test("Add suffix to volumes declared directly in services (Case 2)", () => {
|
||||
const composeData = parse(composeFileTypeVolume) as ComposeSpecification;
|
||||
const composeData = load(composeFileTypeVolume) as ComposeSpecification;
|
||||
|
||||
const suffix = generateRandomHash();
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ComposeSpecification } from "@dokploy/server";
|
||||
import { addSuffixToAllVolumes } from "@dokploy/server";
|
||||
import { load } from "js-yaml";
|
||||
import { expect, test } from "vitest";
|
||||
import { parse } from "yaml";
|
||||
|
||||
const composeFileTypeVolume = `
|
||||
version: "3.8"
|
||||
@@ -23,7 +23,7 @@ volumes:
|
||||
driver: local
|
||||
`;
|
||||
|
||||
const expectedComposeFileTypeVolume = parse(`
|
||||
const expectedComposeFileTypeVolume = load(`
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
@@ -44,7 +44,7 @@ volumes:
|
||||
`) as ComposeSpecification;
|
||||
|
||||
test("Add suffix to volumes with type: volume in services", () => {
|
||||
const composeData = parse(composeFileTypeVolume) as ComposeSpecification;
|
||||
const composeData = load(composeFileTypeVolume) as ComposeSpecification;
|
||||
|
||||
const suffix = "testhash";
|
||||
|
||||
@@ -73,7 +73,7 @@ volumes:
|
||||
driver: local
|
||||
`;
|
||||
|
||||
const expectedComposeFileTypeVolume1 = parse(`
|
||||
const expectedComposeFileTypeVolume1 = load(`
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
@@ -93,7 +93,7 @@ volumes:
|
||||
`) as ComposeSpecification;
|
||||
|
||||
test("Add suffix to mixed volumes in services", () => {
|
||||
const composeData = parse(composeFileTypeVolume1) as ComposeSpecification;
|
||||
const composeData = load(composeFileTypeVolume1) as ComposeSpecification;
|
||||
|
||||
const suffix = "testhash";
|
||||
|
||||
@@ -128,7 +128,7 @@ volumes:
|
||||
device: /path/to/app/logs
|
||||
`;
|
||||
|
||||
const expectedComposeFileTypeVolume2 = parse(`
|
||||
const expectedComposeFileTypeVolume2 = load(`
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
@@ -154,7 +154,7 @@ volumes:
|
||||
`) as ComposeSpecification;
|
||||
|
||||
test("Add suffix to complex volume configurations in services", () => {
|
||||
const composeData = parse(composeFileTypeVolume2) as ComposeSpecification;
|
||||
const composeData = load(composeFileTypeVolume2) as ComposeSpecification;
|
||||
|
||||
const suffix = "testhash";
|
||||
|
||||
@@ -218,7 +218,7 @@ volumes:
|
||||
device: /path/to/shared/logs
|
||||
`;
|
||||
|
||||
const expectedComposeFileTypeVolume3 = parse(`
|
||||
const expectedComposeFileTypeVolume3 = load(`
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
@@ -273,7 +273,7 @@ volumes:
|
||||
`) as ComposeSpecification;
|
||||
|
||||
test("Add suffix to complex nested volumes configuration in services", () => {
|
||||
const composeData = parse(composeFileTypeVolume3) as ComposeSpecification;
|
||||
const composeData = load(composeFileTypeVolume3) as ComposeSpecification;
|
||||
|
||||
const suffix = "testhash";
|
||||
|
||||
|
||||
@@ -1,276 +0,0 @@
|
||||
import * as adminService from "@dokploy/server/services/admin";
|
||||
import * as applicationService from "@dokploy/server/services/application";
|
||||
import { deployApplication } from "@dokploy/server/services/application";
|
||||
import * as deploymentService from "@dokploy/server/services/deployment";
|
||||
import * as builders from "@dokploy/server/utils/builders";
|
||||
import * as notifications from "@dokploy/server/utils/notifications/build-success";
|
||||
import * as execProcess from "@dokploy/server/utils/process/execAsync";
|
||||
import * as gitProvider from "@dokploy/server/utils/providers/git";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@dokploy/server/db", () => {
|
||||
const createChainableMock = (): any => {
|
||||
const chain = {
|
||||
set: vi.fn(() => chain),
|
||||
where: vi.fn(() => chain),
|
||||
returning: vi.fn().mockResolvedValue([{}] as any),
|
||||
} as any;
|
||||
return chain;
|
||||
};
|
||||
|
||||
return {
|
||||
db: {
|
||||
select: vi.fn(),
|
||||
insert: vi.fn(),
|
||||
update: vi.fn(() => createChainableMock()),
|
||||
delete: vi.fn(),
|
||||
query: {
|
||||
applications: {
|
||||
findFirst: vi.fn(),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@dokploy/server/services/application", async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import("@dokploy/server/services/application")
|
||||
>("@dokploy/server/services/application");
|
||||
return {
|
||||
...actual,
|
||||
findApplicationById: vi.fn(),
|
||||
updateApplicationStatus: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@dokploy/server/services/admin", () => ({
|
||||
getDokployUrl: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/services/deployment", () => ({
|
||||
createDeployment: vi.fn(),
|
||||
updateDeploymentStatus: vi.fn(),
|
||||
updateDeployment: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/utils/providers/git", async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import("@dokploy/server/utils/providers/git")
|
||||
>("@dokploy/server/utils/providers/git");
|
||||
return {
|
||||
...actual,
|
||||
getGitCommitInfo: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@dokploy/server/utils/process/execAsync", () => ({
|
||||
execAsync: vi.fn(),
|
||||
ExecError: class ExecError extends Error {},
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/utils/builders", async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import("@dokploy/server/utils/builders")
|
||||
>("@dokploy/server/utils/builders");
|
||||
return {
|
||||
...actual,
|
||||
mechanizeDockerContainer: vi.fn(),
|
||||
getBuildCommand: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@dokploy/server/utils/notifications/build-success", () => ({
|
||||
sendBuildSuccessNotifications: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/utils/notifications/build-error", () => ({
|
||||
sendBuildErrorNotifications: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/services/rollbacks", () => ({
|
||||
createRollback: vi.fn(),
|
||||
}));
|
||||
|
||||
import { db } from "@dokploy/server/db";
|
||||
import { cloneGitRepository } from "@dokploy/server/utils/providers/git";
|
||||
|
||||
const createMockApplication = (overrides = {}) => ({
|
||||
applicationId: "test-app-id",
|
||||
name: "Test App",
|
||||
appName: "test-app",
|
||||
sourceType: "git" as const,
|
||||
customGitUrl: "https://github.com/Dokploy/examples.git",
|
||||
customGitBranch: "main",
|
||||
customGitSSHKeyId: null,
|
||||
buildType: "nixpacks" as const,
|
||||
buildPath: "/astro",
|
||||
env: "NODE_ENV=production",
|
||||
serverId: null,
|
||||
rollbackActive: false,
|
||||
enableSubmodules: false,
|
||||
environmentId: "env-id",
|
||||
environment: {
|
||||
projectId: "project-id",
|
||||
env: "",
|
||||
name: "production",
|
||||
project: {
|
||||
name: "Test Project",
|
||||
organizationId: "org-id",
|
||||
env: "",
|
||||
},
|
||||
},
|
||||
domains: [],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const createMockDeployment = () => ({
|
||||
deploymentId: "deployment-id",
|
||||
logPath: "/tmp/test-deployment.log",
|
||||
});
|
||||
|
||||
describe("deployApplication - Command Generation Tests", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(db.query.applications.findFirst).mockResolvedValue(
|
||||
createMockApplication() as any,
|
||||
);
|
||||
vi.mocked(applicationService.findApplicationById).mockResolvedValue(
|
||||
createMockApplication() as any,
|
||||
);
|
||||
vi.mocked(adminService.getDokployUrl).mockResolvedValue(
|
||||
"http://localhost:3000",
|
||||
);
|
||||
vi.mocked(deploymentService.createDeployment).mockResolvedValue(
|
||||
createMockDeployment() as any,
|
||||
);
|
||||
vi.mocked(execProcess.execAsync).mockResolvedValue({
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
} as any);
|
||||
vi.mocked(builders.mechanizeDockerContainer).mockResolvedValue(
|
||||
undefined as any,
|
||||
);
|
||||
vi.mocked(deploymentService.updateDeploymentStatus).mockResolvedValue(
|
||||
undefined as any,
|
||||
);
|
||||
vi.mocked(applicationService.updateApplicationStatus).mockResolvedValue(
|
||||
{} as any,
|
||||
);
|
||||
vi.mocked(notifications.sendBuildSuccessNotifications).mockResolvedValue(
|
||||
undefined as any,
|
||||
);
|
||||
vi.mocked(gitProvider.getGitCommitInfo).mockResolvedValue({
|
||||
message: "test commit",
|
||||
hash: "abc123",
|
||||
});
|
||||
vi.mocked(deploymentService.updateDeployment).mockResolvedValue({} as any);
|
||||
});
|
||||
|
||||
it("should generate correct git clone command for astro example", async () => {
|
||||
const app = createMockApplication();
|
||||
const command = await cloneGitRepository(app);
|
||||
console.log(command);
|
||||
|
||||
expect(command).toContain("https://github.com/Dokploy/examples.git");
|
||||
expect(command).not.toContain("--recurse-submodules");
|
||||
expect(command).toContain("--branch main");
|
||||
expect(command).toContain("--depth 1");
|
||||
expect(command).toContain("git clone");
|
||||
});
|
||||
|
||||
it("should generate git clone with submodules when enabled", async () => {
|
||||
const app = createMockApplication({ enableSubmodules: true });
|
||||
const command = await cloneGitRepository(app);
|
||||
|
||||
expect(command).toContain("--recurse-submodules");
|
||||
expect(command).toContain("https://github.com/Dokploy/examples.git");
|
||||
});
|
||||
|
||||
it("should verify nixpacks command is called with correct app", async () => {
|
||||
const mockNixpacksCommand = "nixpacks build /path/to/app --name test-app";
|
||||
vi.mocked(builders.getBuildCommand).mockResolvedValue(mockNixpacksCommand);
|
||||
|
||||
await deployApplication({
|
||||
applicationId: "test-app-id",
|
||||
titleLog: "Test deployment",
|
||||
descriptionLog: "",
|
||||
});
|
||||
|
||||
expect(builders.getBuildCommand).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
buildType: "nixpacks",
|
||||
customGitUrl: "https://github.com/Dokploy/examples.git",
|
||||
buildPath: "/astro",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(execProcess.execAsync).toHaveBeenCalledWith(
|
||||
expect.stringContaining("nixpacks build"),
|
||||
);
|
||||
});
|
||||
|
||||
it("should verify railpack command includes correct parameters", async () => {
|
||||
const mockApp = createMockApplication({ buildType: "railpack" });
|
||||
vi.mocked(db.query.applications.findFirst).mockResolvedValue(
|
||||
mockApp as any,
|
||||
);
|
||||
vi.mocked(applicationService.findApplicationById).mockResolvedValue(
|
||||
mockApp as any,
|
||||
);
|
||||
|
||||
const mockRailpackCommand = "railpack prepare /path/to/app";
|
||||
vi.mocked(builders.getBuildCommand).mockResolvedValue(mockRailpackCommand);
|
||||
|
||||
await deployApplication({
|
||||
applicationId: "test-app-id",
|
||||
titleLog: "Railpack test",
|
||||
descriptionLog: "",
|
||||
});
|
||||
|
||||
expect(builders.getBuildCommand).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
buildType: "railpack",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(execProcess.execAsync).toHaveBeenCalledWith(
|
||||
expect.stringContaining("railpack prepare"),
|
||||
);
|
||||
});
|
||||
|
||||
it("should execute commands in correct order", async () => {
|
||||
const mockNixpacksCommand = "nixpacks build";
|
||||
vi.mocked(builders.getBuildCommand).mockResolvedValue(mockNixpacksCommand);
|
||||
|
||||
await deployApplication({
|
||||
applicationId: "test-app-id",
|
||||
titleLog: "Test",
|
||||
descriptionLog: "",
|
||||
});
|
||||
|
||||
const execCalls = vi.mocked(execProcess.execAsync).mock.calls;
|
||||
expect(execCalls.length).toBeGreaterThan(0);
|
||||
|
||||
const fullCommand = execCalls[0]?.[0];
|
||||
expect(fullCommand).toContain("set -e");
|
||||
expect(fullCommand).toContain("git clone");
|
||||
expect(fullCommand).toContain("nixpacks build");
|
||||
});
|
||||
|
||||
it("should include log redirection in command", async () => {
|
||||
const mockCommand = "nixpacks build";
|
||||
vi.mocked(builders.getBuildCommand).mockResolvedValue(mockCommand);
|
||||
|
||||
await deployApplication({
|
||||
applicationId: "test-app-id",
|
||||
titleLog: "Test",
|
||||
descriptionLog: "",
|
||||
});
|
||||
|
||||
const execCalls = vi.mocked(execProcess.execAsync).mock.calls;
|
||||
const fullCommand = execCalls[0]?.[0];
|
||||
|
||||
expect(fullCommand).toContain(">> /tmp/test-deployment.log 2>&1");
|
||||
});
|
||||
});
|
||||
@@ -1,479 +0,0 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { ApplicationNested } from "@dokploy/server";
|
||||
import { paths } from "@dokploy/server/constants";
|
||||
import { execAsync } from "@dokploy/server/utils/process/execAsync";
|
||||
import { format } from "date-fns";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const REAL_TEST_TIMEOUT = 180000; // 3 minutes
|
||||
|
||||
// Mock ONLY database and notifications
|
||||
vi.mock("@dokploy/server/db", () => {
|
||||
const createChainableMock = (): any => {
|
||||
const chain: any = {
|
||||
set: vi.fn(() => chain),
|
||||
where: vi.fn(() => chain),
|
||||
returning: vi.fn().mockResolvedValue([{}]),
|
||||
};
|
||||
return chain;
|
||||
};
|
||||
|
||||
return {
|
||||
db: {
|
||||
select: vi.fn(),
|
||||
insert: vi.fn(),
|
||||
update: vi.fn(() => createChainableMock()),
|
||||
delete: vi.fn(),
|
||||
query: {
|
||||
applications: {
|
||||
findFirst: vi.fn(),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@dokploy/server/services/application", async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import("@dokploy/server/services/application")
|
||||
>("@dokploy/server/services/application");
|
||||
return {
|
||||
...actual,
|
||||
findApplicationById: vi.fn(),
|
||||
updateApplicationStatus: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@dokploy/server/services/admin", () => ({
|
||||
getDokployUrl: vi.fn().mockResolvedValue("http://localhost:3000"),
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/services/deployment", () => ({
|
||||
createDeployment: vi.fn(),
|
||||
updateDeploymentStatus: vi.fn(),
|
||||
updateDeployment: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/utils/notifications/build-success", () => ({
|
||||
sendBuildSuccessNotifications: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/utils/notifications/build-error", () => ({
|
||||
sendBuildErrorNotifications: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/services/rollbacks", () => ({
|
||||
createRollback: vi.fn(),
|
||||
}));
|
||||
|
||||
// NOT mocked (executed for real):
|
||||
// - execAsync
|
||||
// - cloneGitRepository
|
||||
// - getBuildCommand
|
||||
// - mechanizeDockerContainer (requires Docker Swarm)
|
||||
|
||||
import { db } from "@dokploy/server/db";
|
||||
import * as adminService from "@dokploy/server/services/admin";
|
||||
import * as applicationService from "@dokploy/server/services/application";
|
||||
import { deployApplication } from "@dokploy/server/services/application";
|
||||
import * as deploymentService from "@dokploy/server/services/deployment";
|
||||
|
||||
const createMockApplication = (
|
||||
overrides: Partial<ApplicationNested> = {},
|
||||
): ApplicationNested =>
|
||||
({
|
||||
applicationId: "test-app-id",
|
||||
name: "Real Test App",
|
||||
appName: `real-test-${Date.now()}`,
|
||||
sourceType: "git" as const,
|
||||
customGitUrl: "https://github.com/Dokploy/examples.git",
|
||||
customGitBranch: "main",
|
||||
customGitSSHKeyId: null,
|
||||
customGitBuildPath: "/astro",
|
||||
buildType: "nixpacks" as const,
|
||||
env: "NODE_ENV=production",
|
||||
serverId: null,
|
||||
rollbackActive: false,
|
||||
enableSubmodules: false,
|
||||
environmentId: "env-id",
|
||||
environment: {
|
||||
projectId: "project-id",
|
||||
env: "",
|
||||
name: "production",
|
||||
project: {
|
||||
name: "Test Project",
|
||||
organizationId: "org-id",
|
||||
env: "",
|
||||
},
|
||||
},
|
||||
domains: [],
|
||||
mounts: [],
|
||||
security: [],
|
||||
redirects: [],
|
||||
ports: [],
|
||||
registry: null,
|
||||
...overrides,
|
||||
}) as ApplicationNested;
|
||||
|
||||
const createMockDeployment = async (appName: string) => {
|
||||
const { LOGS_PATH } = paths(false); // false = local, no remote server
|
||||
const formattedDateTime = format(new Date(), "yyyy-MM-dd:HH:mm:ss");
|
||||
const fileName = `${appName}-${formattedDateTime}.log`;
|
||||
const logFilePath = path.join(LOGS_PATH, appName, fileName);
|
||||
|
||||
// Actually create the log directory
|
||||
await execAsync(`mkdir -p ${path.dirname(logFilePath)}`);
|
||||
await execAsync(`echo "Initializing deployment" > ${logFilePath}`);
|
||||
|
||||
return {
|
||||
deploymentId: "deployment-id",
|
||||
logPath: logFilePath,
|
||||
};
|
||||
};
|
||||
|
||||
async function cleanupDocker(appName: string) {
|
||||
try {
|
||||
await execAsync(`docker stop ${appName} 2>/dev/null || true`);
|
||||
await execAsync(`docker rm ${appName} 2>/dev/null || true`);
|
||||
await execAsync(`docker rmi ${appName} 2>/dev/null || true`);
|
||||
} catch (error) {
|
||||
console.log("Docker cleanup completed");
|
||||
}
|
||||
}
|
||||
|
||||
async function cleanupFiles(appName: string) {
|
||||
try {
|
||||
const { LOGS_PATH, APPLICATIONS_PATH } = paths(false);
|
||||
|
||||
// Clean cloned code directories
|
||||
const appPath = path.join(APPLICATIONS_PATH, appName);
|
||||
await execAsync(`rm -rf ${appPath} 2>/dev/null || true`);
|
||||
|
||||
// Clean logs for appName - removes entire folder
|
||||
const logPath = path.join(LOGS_PATH, appName);
|
||||
await execAsync(`rm -rf ${logPath} 2>/dev/null || true`);
|
||||
|
||||
console.log(`✅ Cleaned up files and logs for ${appName}`);
|
||||
} catch (error) {
|
||||
console.error(`⚠️ Error during cleanup for ${appName}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
describe(
|
||||
"deployApplication - REAL Execution Tests",
|
||||
() => {
|
||||
let currentAppName: string;
|
||||
let currentDeployment: any;
|
||||
const allTestAppNames: string[] = [];
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
currentAppName = `real-test-${Date.now()}`;
|
||||
currentDeployment = await createMockDeployment(currentAppName);
|
||||
allTestAppNames.push(currentAppName);
|
||||
|
||||
const mockApp = createMockApplication({ appName: currentAppName });
|
||||
|
||||
vi.mocked(db.query.applications.findFirst).mockResolvedValue(
|
||||
mockApp as any,
|
||||
);
|
||||
vi.mocked(applicationService.findApplicationById).mockResolvedValue(
|
||||
mockApp as any,
|
||||
);
|
||||
vi.mocked(adminService.getDokployUrl).mockResolvedValue(
|
||||
"http://localhost:3000",
|
||||
);
|
||||
vi.mocked(deploymentService.createDeployment).mockResolvedValue(
|
||||
currentDeployment as any,
|
||||
);
|
||||
vi.mocked(deploymentService.updateDeploymentStatus).mockResolvedValue(
|
||||
undefined as any,
|
||||
);
|
||||
vi.mocked(applicationService.updateApplicationStatus).mockResolvedValue(
|
||||
{} as any,
|
||||
);
|
||||
vi.mocked(deploymentService.updateDeployment).mockResolvedValue(
|
||||
{} as any,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// ALWAYS cleanup, even if test failed or passed
|
||||
console.log(`\n🧹 Cleaning up test: ${currentAppName}`);
|
||||
|
||||
// Clean current appName
|
||||
try {
|
||||
await cleanupDocker(currentAppName);
|
||||
await cleanupFiles(currentAppName);
|
||||
} catch (error) {
|
||||
console.error("⚠️ Error cleaning current app:", error);
|
||||
}
|
||||
|
||||
// Clean ALL test folders just in case
|
||||
try {
|
||||
const { LOGS_PATH, APPLICATIONS_PATH } = paths(false);
|
||||
await execAsync(`rm -rf ${LOGS_PATH}/real-* 2>/dev/null || true`);
|
||||
await execAsync(
|
||||
`rm -rf ${APPLICATIONS_PATH}/real-* 2>/dev/null || true`,
|
||||
);
|
||||
console.log("✅ Cleaned up all test artifacts");
|
||||
} catch (error) {
|
||||
console.error("⚠️ Error cleaning all artifacts:", error);
|
||||
}
|
||||
|
||||
console.log("✅ Cleanup completed\n");
|
||||
});
|
||||
|
||||
it(
|
||||
"should REALLY clone git repo and build with nixpacks",
|
||||
async () => {
|
||||
console.log(`\n🚀 Testing real deployment with app: ${currentAppName}`);
|
||||
|
||||
const result = await deployApplication({
|
||||
applicationId: "test-app-id",
|
||||
titleLog: "Real Nixpacks Test",
|
||||
descriptionLog: "Testing real execution",
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
|
||||
// Verify that Docker image was actually created
|
||||
const { stdout: dockerImages } = await execAsync(
|
||||
`docker images ${currentAppName} --format "{{.Repository}}"`,
|
||||
);
|
||||
console.log("dockerImages", dockerImages);
|
||||
expect(dockerImages.trim()).toBe(currentAppName);
|
||||
console.log(`✅ Docker image created: ${currentAppName}`);
|
||||
|
||||
// Verify log exists and has content
|
||||
expect(existsSync(currentDeployment.logPath)).toBe(true);
|
||||
const { stdout: logContent } = await execAsync(
|
||||
`cat ${currentDeployment.logPath}`,
|
||||
);
|
||||
expect(logContent).toContain("Cloning");
|
||||
expect(logContent).toContain("nixpacks");
|
||||
console.log(`✅ Build log created with ${logContent.length} chars`);
|
||||
|
||||
// Verify update functions were called
|
||||
expect(deploymentService.updateDeploymentStatus).toHaveBeenCalledWith(
|
||||
"deployment-id",
|
||||
"done",
|
||||
);
|
||||
},
|
||||
REAL_TEST_TIMEOUT,
|
||||
);
|
||||
|
||||
it.skip(
|
||||
"should REALLY build with railpack (SKIPPED: requires special permissions)",
|
||||
async () => {
|
||||
const railpackAppName = `real-railpack-${Date.now()}`;
|
||||
const railpackApp = createMockApplication({
|
||||
appName: railpackAppName,
|
||||
buildType: "railpack",
|
||||
railpackVersion: "3",
|
||||
});
|
||||
currentAppName = railpackAppName;
|
||||
allTestAppNames.push(railpackAppName);
|
||||
|
||||
vi.mocked(db.query.applications.findFirst).mockResolvedValue(
|
||||
railpackApp as any,
|
||||
);
|
||||
vi.mocked(applicationService.findApplicationById).mockResolvedValue(
|
||||
railpackApp as any,
|
||||
);
|
||||
|
||||
console.log(`\n🚀 Testing real railpack deployment: ${currentAppName}`);
|
||||
|
||||
const result = await deployApplication({
|
||||
applicationId: "test-app-id",
|
||||
titleLog: "Real Railpack Test",
|
||||
descriptionLog: "",
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
|
||||
const { stdout: dockerImages } = await execAsync(
|
||||
`docker images ${currentAppName} --format "{{.Repository}}"`,
|
||||
);
|
||||
expect(dockerImages.trim()).toBe(currentAppName);
|
||||
console.log(`✅ Railpack image created: ${currentAppName}`);
|
||||
|
||||
const { stdout: logContent } = await execAsync(
|
||||
`cat ${currentDeployment.logPath}`,
|
||||
);
|
||||
expect(logContent).toContain("railpack");
|
||||
console.log("✅ Railpack build completed");
|
||||
},
|
||||
REAL_TEST_TIMEOUT,
|
||||
);
|
||||
|
||||
it(
|
||||
"should handle REAL git clone errors",
|
||||
async () => {
|
||||
const errorAppName = `real-error-${Date.now()}`;
|
||||
const errorApp = createMockApplication({
|
||||
appName: errorAppName,
|
||||
customGitUrl:
|
||||
"https://github.com/invalid/nonexistent-repo-123456.git",
|
||||
});
|
||||
currentAppName = errorAppName;
|
||||
allTestAppNames.push(errorAppName);
|
||||
|
||||
vi.mocked(db.query.applications.findFirst).mockResolvedValue(
|
||||
errorApp as any,
|
||||
);
|
||||
vi.mocked(applicationService.findApplicationById).mockResolvedValue(
|
||||
errorApp as any,
|
||||
);
|
||||
|
||||
console.log(`\n🚀 Testing real error handling: ${currentAppName}`);
|
||||
|
||||
await expect(
|
||||
deployApplication({
|
||||
applicationId: "test-app-id",
|
||||
titleLog: "Real Error Test",
|
||||
descriptionLog: "",
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
|
||||
// Verify error status was called
|
||||
expect(deploymentService.updateDeploymentStatus).toHaveBeenCalledWith(
|
||||
"deployment-id",
|
||||
"error",
|
||||
);
|
||||
|
||||
// Verify log contains error
|
||||
const { stdout: logContent } = await execAsync(
|
||||
`cat ${currentDeployment.logPath}`,
|
||||
);
|
||||
expect(logContent.toLowerCase()).toContain("error");
|
||||
console.log("✅ Error handling verified");
|
||||
},
|
||||
REAL_TEST_TIMEOUT,
|
||||
);
|
||||
|
||||
it(
|
||||
"should REALLY clone with submodules when enabled",
|
||||
async () => {
|
||||
const submodulesAppName = `real-submodules-${Date.now()}`;
|
||||
const submodulesApp = createMockApplication({
|
||||
appName: submodulesAppName,
|
||||
enableSubmodules: true,
|
||||
});
|
||||
currentAppName = submodulesAppName;
|
||||
allTestAppNames.push(submodulesAppName);
|
||||
|
||||
vi.mocked(db.query.applications.findFirst).mockResolvedValue(
|
||||
submodulesApp as any,
|
||||
);
|
||||
vi.mocked(applicationService.findApplicationById).mockResolvedValue(
|
||||
submodulesApp as any,
|
||||
);
|
||||
|
||||
console.log(`\n🚀 Testing real submodules support: ${currentAppName}`);
|
||||
|
||||
const result = await deployApplication({
|
||||
applicationId: "test-app-id",
|
||||
titleLog: "Real Submodules Test",
|
||||
descriptionLog: "",
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
|
||||
// Verify deployment completed successfully
|
||||
const { stdout: logContent } = await execAsync(
|
||||
`cat ${currentDeployment.logPath}`,
|
||||
);
|
||||
expect(logContent).toContain("Cloning");
|
||||
expect(logContent.length).toBeGreaterThan(100);
|
||||
console.log("✅ Submodules deployment completed");
|
||||
|
||||
// Verify image
|
||||
const { stdout: dockerImages } = await execAsync(
|
||||
`docker images ${currentAppName} --format "{{.Repository}}"`,
|
||||
);
|
||||
expect(dockerImages.trim()).toBe(currentAppName);
|
||||
},
|
||||
REAL_TEST_TIMEOUT,
|
||||
);
|
||||
|
||||
it(
|
||||
"should verify REAL commit info extraction",
|
||||
async () => {
|
||||
console.log(`\n🚀 Testing real commit info: ${currentAppName}`);
|
||||
|
||||
await deployApplication({
|
||||
applicationId: "test-app-id",
|
||||
titleLog: "Real Commit Test",
|
||||
descriptionLog: "",
|
||||
});
|
||||
|
||||
// Verify updateDeployment was called with commit info
|
||||
expect(deploymentService.updateDeployment).toHaveBeenCalled();
|
||||
const updateCall = vi.mocked(deploymentService.updateDeployment).mock
|
||||
.calls[0];
|
||||
|
||||
// Real commit info should have title and hash
|
||||
expect(updateCall?.[1]).toHaveProperty("title");
|
||||
expect(updateCall?.[1]).toHaveProperty("description");
|
||||
expect(updateCall?.[1]?.description).toContain("Commit:");
|
||||
|
||||
console.log(
|
||||
`✅ Real commit extracted: ${updateCall?.[1]?.title?.substring(0, 50)}...`,
|
||||
);
|
||||
},
|
||||
REAL_TEST_TIMEOUT,
|
||||
);
|
||||
|
||||
it(
|
||||
"should REALLY build with Dockerfile",
|
||||
async () => {
|
||||
const dockerfileAppName = `real-dockerfile-${Date.now()}`;
|
||||
const dockerfileApp = createMockApplication({
|
||||
appName: dockerfileAppName,
|
||||
buildType: "dockerfile",
|
||||
customGitBuildPath: "/deno",
|
||||
dockerfile: "Dockerfile",
|
||||
});
|
||||
currentAppName = dockerfileAppName;
|
||||
allTestAppNames.push(dockerfileAppName);
|
||||
|
||||
vi.mocked(db.query.applications.findFirst).mockResolvedValue(
|
||||
dockerfileApp as any,
|
||||
);
|
||||
vi.mocked(applicationService.findApplicationById).mockResolvedValue(
|
||||
dockerfileApp as any,
|
||||
);
|
||||
|
||||
console.log(`\n🚀 Testing real Dockerfile build: ${currentAppName}`);
|
||||
|
||||
const result = await deployApplication({
|
||||
applicationId: "test-app-id",
|
||||
titleLog: "Real Dockerfile Test",
|
||||
descriptionLog: "",
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
|
||||
// Verify log
|
||||
const { stdout: logContent } = await execAsync(
|
||||
`cat ${currentDeployment.logPath}`,
|
||||
);
|
||||
expect(logContent).toContain("Building");
|
||||
expect(logContent).toContain(dockerfileAppName);
|
||||
console.log("✅ Dockerfile build log verified");
|
||||
|
||||
// Verify image
|
||||
const { stdout: dockerImages } = await execAsync(
|
||||
`docker images ${currentAppName} --format "{{.Repository}}"`,
|
||||
);
|
||||
console.log("dockerImages", dockerImages);
|
||||
expect(dockerImages.trim()).toBe(currentAppName);
|
||||
console.log(`✅ Docker image created: ${currentAppName}`);
|
||||
},
|
||||
REAL_TEST_TIMEOUT,
|
||||
);
|
||||
},
|
||||
REAL_TEST_TIMEOUT,
|
||||
);
|
||||
@@ -1,10 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
extractCommitMessage,
|
||||
extractImageName,
|
||||
extractImageTag,
|
||||
extractImageTagFromRequest,
|
||||
} from "@/pages/api/deploy/[refreshToken]";
|
||||
import { extractCommitMessage } from "@/pages/api/deploy/[refreshToken]";
|
||||
|
||||
describe("GitHub Webhook Skip CI", () => {
|
||||
const mockGithubHeaders = {
|
||||
@@ -101,308 +96,3 @@ describe("GitHub Webhook Skip CI", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GitHub Packages Docker Image Tag Extraction", () => {
|
||||
it("should extract tag from container_metadata", () => {
|
||||
const headers = { "x-github-event": "registry_package" };
|
||||
const body = {
|
||||
registry_package: {
|
||||
package_version: {
|
||||
version: "sha256:abc123...",
|
||||
container_metadata: {
|
||||
tag: {
|
||||
name: "v1.0.0",
|
||||
digest: "sha256:abc123...",
|
||||
},
|
||||
},
|
||||
package_url: "ghcr.io/owner/repo:v1.0.0",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const tag = extractImageTagFromRequest(headers, body);
|
||||
expect(tag).toBe("v1.0.0");
|
||||
});
|
||||
|
||||
it("should extract tag from package_url when container_metadata tag matches version", () => {
|
||||
const headers = { "x-github-event": "registry_package" };
|
||||
const body = {
|
||||
registry_package: {
|
||||
package_version: {
|
||||
version: "sha256:abc123...",
|
||||
container_metadata: {
|
||||
tag: {
|
||||
name: "sha256:abc123...",
|
||||
digest: "sha256:abc123...",
|
||||
},
|
||||
},
|
||||
package_url: "ghcr.io/owner/repo:latest",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const tag = extractImageTagFromRequest(headers, body);
|
||||
expect(tag).toBe("latest");
|
||||
});
|
||||
|
||||
it("should extract tag from package_url when container_metadata is missing", () => {
|
||||
const headers = { "x-github-event": "registry_package" };
|
||||
const body = {
|
||||
registry_package: {
|
||||
package_version: {
|
||||
version: "sha256:abc123...",
|
||||
package_url: "ghcr.io/owner/repo:1.2.3",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const tag = extractImageTagFromRequest(headers, body);
|
||||
expect(tag).toBe("1.2.3");
|
||||
});
|
||||
|
||||
it("should handle different tag formats in package_url", () => {
|
||||
const headers = { "x-github-event": "registry_package" };
|
||||
const testCases = [
|
||||
{ url: "ghcr.io/owner/repo:latest", expected: "latest" },
|
||||
{ url: "ghcr.io/owner/repo:v1.0.0", expected: "v1.0.0" },
|
||||
{ url: "ghcr.io/owner/repo:1.2.3", expected: "1.2.3" },
|
||||
{ url: "ghcr.io/owner/repo:dev", expected: "dev" },
|
||||
];
|
||||
|
||||
for (const testCase of testCases) {
|
||||
const body = {
|
||||
registry_package: {
|
||||
package_version: {
|
||||
version: "sha256:abc123...",
|
||||
package_url: testCase.url,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const tag = extractImageTagFromRequest(headers, body);
|
||||
expect(tag).toBe(testCase.expected);
|
||||
}
|
||||
});
|
||||
|
||||
it("should return null for non-registry_package events", () => {
|
||||
const headers = { "x-github-event": "push" };
|
||||
const body = {
|
||||
registry_package: {
|
||||
package_version: {
|
||||
package_url: "ghcr.io/owner/repo:latest",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const tag = extractImageTagFromRequest(headers, body);
|
||||
expect(tag).toBeNull();
|
||||
});
|
||||
|
||||
it("should return null when package_version is missing", () => {
|
||||
const headers = { "x-github-event": "registry_package" };
|
||||
const body = {
|
||||
registry_package: {},
|
||||
};
|
||||
|
||||
const tag = extractImageTagFromRequest(headers, body);
|
||||
expect(tag).toBeNull();
|
||||
});
|
||||
|
||||
it("should return null when package_url has no tag", () => {
|
||||
const headers = { "x-github-event": "registry_package" };
|
||||
const body = {
|
||||
registry_package: {
|
||||
package_version: {
|
||||
version: "sha256:abc123...",
|
||||
package_url: "ghcr.io/owner/repo",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const tag = extractImageTagFromRequest(headers, body);
|
||||
expect(tag).toBeNull();
|
||||
});
|
||||
|
||||
it("should return null when package_url ends with colon (no tag)", () => {
|
||||
const headers = { "x-github-event": "registry_package" };
|
||||
const body = {
|
||||
registry_package: {
|
||||
package_version: {
|
||||
version: "sha256:abc123...",
|
||||
package_url: "ghcr.io/owner/repo:",
|
||||
container_metadata: {
|
||||
tag: {
|
||||
name: "",
|
||||
digest: "sha256:abc123...",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const tag = extractImageTagFromRequest(headers, body);
|
||||
expect(tag).toBeNull();
|
||||
});
|
||||
|
||||
it("should return null when tag name is empty string", () => {
|
||||
const headers = { "x-github-event": "registry_package" };
|
||||
const body = {
|
||||
registry_package: {
|
||||
package_version: {
|
||||
version: "sha256:abc123...",
|
||||
container_metadata: {
|
||||
tag: {
|
||||
name: "",
|
||||
digest: "sha256:abc123...",
|
||||
},
|
||||
},
|
||||
package_url: "ghcr.io/owner/repo:",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const tag = extractImageTagFromRequest(headers, body);
|
||||
expect(tag).toBeNull();
|
||||
});
|
||||
|
||||
it("should ignore tag if it matches the version (digest)", () => {
|
||||
const headers = { "x-github-event": "registry_package" };
|
||||
const body = {
|
||||
registry_package: {
|
||||
package_version: {
|
||||
version: "sha256:abc123...",
|
||||
container_metadata: {
|
||||
tag: {
|
||||
name: "sha256:abc123...",
|
||||
digest: "sha256:abc123...",
|
||||
},
|
||||
},
|
||||
package_url: "ghcr.io/owner/repo:latest",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const tag = extractImageTagFromRequest(headers, body);
|
||||
expect(tag).toBe("latest");
|
||||
});
|
||||
|
||||
it("should handle registry_package commit message with package_url", () => {
|
||||
const headers = { "x-github-event": "registry_package" };
|
||||
const body = {
|
||||
registry_package: {
|
||||
package_version: {
|
||||
package_url: "ghcr.io/owner/repo:latest",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const message = extractCommitMessage(headers, body);
|
||||
expect(message).toBe("Docker GHCR image pushed: ghcr.io/owner/repo:latest");
|
||||
});
|
||||
|
||||
it("should handle registry_package commit message when package_url is missing", () => {
|
||||
const headers = { "x-github-event": "registry_package" };
|
||||
const body = {
|
||||
registry_package: {
|
||||
package_version: {
|
||||
version: "sha256:abc123...",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const message = extractCommitMessage(headers, body);
|
||||
expect(message).toBe("Docker GHCR image pushed");
|
||||
});
|
||||
|
||||
it("should handle registry_package commit message when package_version is missing", () => {
|
||||
const headers = { "x-github-event": "registry_package" };
|
||||
const body = {
|
||||
registry_package: {},
|
||||
};
|
||||
|
||||
const message = extractCommitMessage(headers, body);
|
||||
expect(message).toBe("NEW COMMIT");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Docker Image Name and Tag Extraction", () => {
|
||||
describe("extractImageName", () => {
|
||||
it("should return image name without tag", () => {
|
||||
expect(extractImageName("my-image:latest")).toBe("my-image");
|
||||
expect(extractImageName("my-image:1.0.0")).toBe("my-image");
|
||||
expect(extractImageName("ghcr.io/owner/repo:latest")).toBe(
|
||||
"ghcr.io/owner/repo",
|
||||
);
|
||||
});
|
||||
|
||||
it("should return full image name when no tag is present", () => {
|
||||
expect(extractImageName("my-image")).toBe("my-image");
|
||||
expect(extractImageName("ghcr.io/owner/repo")).toBe("ghcr.io/owner/repo");
|
||||
});
|
||||
|
||||
it("should handle images with port numbers correctly", () => {
|
||||
expect(extractImageName("registry:5000/image:tag")).toBe(
|
||||
"registry:5000/image",
|
||||
);
|
||||
expect(extractImageName("localhost:5000/my-app:latest")).toBe(
|
||||
"localhost:5000/my-app",
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle complex image paths", () => {
|
||||
expect(
|
||||
extractImageName("myregistryhost:5000/fedora/httpd:version1.0"),
|
||||
).toBe("myregistryhost:5000/fedora/httpd");
|
||||
expect(extractImageName("registry.example.com:8080/ns/app:v1.2.3")).toBe(
|
||||
"registry.example.com:8080/ns/app",
|
||||
);
|
||||
});
|
||||
|
||||
it("should return null for invalid inputs", () => {
|
||||
expect(extractImageName(null)).toBeNull();
|
||||
expect(extractImageName("")).toBeNull();
|
||||
});
|
||||
|
||||
it("should handle edge cases with multiple colons", () => {
|
||||
expect(extractImageName("image:tag:extra")).toBe("image:tag");
|
||||
expect(extractImageName("registry:5000:invalid")).toBe("registry:5000");
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractImageTag", () => {
|
||||
it("should extract tag from image with tag", () => {
|
||||
expect(extractImageTag("my-image:latest")).toBe("latest");
|
||||
expect(extractImageTag("my-image:1.0.0")).toBe("1.0.0");
|
||||
expect(extractImageTag("ghcr.io/owner/repo:v1.2.3")).toBe("v1.2.3");
|
||||
});
|
||||
|
||||
it("should return 'latest' when no tag is present", () => {
|
||||
expect(extractImageTag("my-image")).toBe("latest");
|
||||
expect(extractImageTag("ghcr.io/owner/repo")).toBe("latest");
|
||||
});
|
||||
|
||||
it("should handle complex image paths with tags", () => {
|
||||
expect(
|
||||
extractImageTag("myregistryhost:5000/fedora/httpd:version1.0"),
|
||||
).toBe("version1.0");
|
||||
expect(extractImageTag("registry.example.com:8080/ns/app:v1.2.3")).toBe(
|
||||
"v1.2.3",
|
||||
);
|
||||
});
|
||||
|
||||
it("should return null for invalid inputs", () => {
|
||||
expect(extractImageTag(null)).toBeNull();
|
||||
expect(extractImageTag("")).toBeNull();
|
||||
});
|
||||
|
||||
it("should handle edge cases with multiple colons", () => {
|
||||
expect(extractImageTag("image:tag:extra")).toBe("extra");
|
||||
expect(extractImageTag("registry:5000/image:tag")).toBe("tag");
|
||||
});
|
||||
|
||||
it("should handle numeric tags", () => {
|
||||
expect(extractImageTag("my-image:123")).toBe("123");
|
||||
expect(extractImageTag("my-image:1")).toBe("1");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -30,10 +30,6 @@ const baseApp: ApplicationNested = {
|
||||
previewLabels: [],
|
||||
herokuVersion: "",
|
||||
giteaBranch: "",
|
||||
buildServerId: "",
|
||||
buildRegistryId: "",
|
||||
buildRegistry: null,
|
||||
args: [],
|
||||
giteaBuildPath: "",
|
||||
previewRequireCollaboratorPermissions: false,
|
||||
giteaId: "",
|
||||
@@ -41,22 +37,17 @@ const baseApp: ApplicationNested = {
|
||||
giteaRepository: "",
|
||||
cleanCache: false,
|
||||
watchPaths: [],
|
||||
rollbackRegistryId: "",
|
||||
rollbackRegistry: null,
|
||||
deployments: [],
|
||||
enableSubmodules: false,
|
||||
applicationStatus: "done",
|
||||
triggerType: "push",
|
||||
appName: "",
|
||||
autoDeploy: true,
|
||||
endpointSpecSwarm: null,
|
||||
serverId: "",
|
||||
registryUrl: "",
|
||||
branch: null,
|
||||
dockerBuildStage: "",
|
||||
isPreviewDeploymentsActive: false,
|
||||
previewBuildArgs: null,
|
||||
previewBuildSecrets: null,
|
||||
previewCertificateType: "none",
|
||||
previewCustomCertResolver: null,
|
||||
previewEnv: null,
|
||||
@@ -65,24 +56,15 @@ const baseApp: ApplicationNested = {
|
||||
previewPort: 3000,
|
||||
previewLimit: 0,
|
||||
previewWildcard: "",
|
||||
environment: {
|
||||
project: {
|
||||
env: "",
|
||||
environmentId: "",
|
||||
organizationId: "",
|
||||
name: "",
|
||||
createdAt: "",
|
||||
description: "",
|
||||
createdAt: "",
|
||||
projectId: "",
|
||||
project: {
|
||||
env: "",
|
||||
organizationId: "",
|
||||
name: "",
|
||||
description: "",
|
||||
createdAt: "",
|
||||
projectId: "",
|
||||
},
|
||||
},
|
||||
buildArgs: null,
|
||||
buildSecrets: null,
|
||||
buildPath: "/",
|
||||
gitlabPathNamespace: "",
|
||||
buildType: "nixpacks",
|
||||
@@ -110,7 +92,6 @@ const baseApp: ApplicationNested = {
|
||||
dockerfile: null,
|
||||
dockerImage: null,
|
||||
dropBuildPath: null,
|
||||
environmentId: "",
|
||||
enabled: null,
|
||||
env: null,
|
||||
healthCheckSwarm: null,
|
||||
@@ -125,6 +106,7 @@ const baseApp: ApplicationNested = {
|
||||
password: null,
|
||||
placementSwarm: null,
|
||||
ports: [],
|
||||
projectId: "",
|
||||
publishDirectory: null,
|
||||
isStaticSpa: null,
|
||||
redirects: [],
|
||||
@@ -143,7 +125,6 @@ const baseApp: ApplicationNested = {
|
||||
username: null,
|
||||
dockerContextPath: null,
|
||||
rollbackActive: false,
|
||||
stopGracePeriodSwarm: null,
|
||||
};
|
||||
|
||||
describe("unzipDrop using real zip files", () => {
|
||||
|
||||
644
apps/dokploy/__test__/env/environment.test.ts
vendored
644
apps/dokploy/__test__/env/environment.test.ts
vendored
@@ -1,644 +0,0 @@
|
||||
import {
|
||||
prepareEnvironmentVariables,
|
||||
prepareEnvironmentVariablesForShell,
|
||||
} from "@dokploy/server/index";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const projectEnv = `
|
||||
ENVIRONMENT=staging
|
||||
DATABASE_URL=postgres://postgres:postgres@localhost:5432/project_db
|
||||
PORT=3000
|
||||
`;
|
||||
|
||||
const environmentEnv = `
|
||||
NODE_ENV=development
|
||||
API_URL=https://api.dev.example.com
|
||||
REDIS_URL=redis://localhost:6379
|
||||
DATABASE_NAME=dev_database
|
||||
SECRET_KEY=env-secret-123
|
||||
`;
|
||||
|
||||
describe("prepareEnvironmentVariables (environment variables)", () => {
|
||||
it("resolves environment variables correctly", () => {
|
||||
const serviceWithEnvVars = `
|
||||
NODE_ENV=\${{environment.NODE_ENV}}
|
||||
API_URL=\${{environment.API_URL}}
|
||||
SERVICE_PORT=4000
|
||||
`;
|
||||
|
||||
const resolved = prepareEnvironmentVariables(
|
||||
serviceWithEnvVars,
|
||||
"",
|
||||
environmentEnv,
|
||||
);
|
||||
|
||||
expect(resolved).toEqual([
|
||||
"NODE_ENV=development",
|
||||
"API_URL=https://api.dev.example.com",
|
||||
"SERVICE_PORT=4000",
|
||||
]);
|
||||
});
|
||||
|
||||
it("resolves both project and environment variables", () => {
|
||||
const serviceWithBoth = `
|
||||
ENVIRONMENT=\${{project.ENVIRONMENT}}
|
||||
NODE_ENV=\${{environment.NODE_ENV}}
|
||||
API_URL=\${{environment.API_URL}}
|
||||
DATABASE_URL=\${{project.DATABASE_URL}}
|
||||
SERVICE_PORT=4000
|
||||
`;
|
||||
|
||||
const resolved = prepareEnvironmentVariables(
|
||||
serviceWithBoth,
|
||||
projectEnv,
|
||||
environmentEnv,
|
||||
);
|
||||
|
||||
expect(resolved).toEqual([
|
||||
"ENVIRONMENT=staging",
|
||||
"NODE_ENV=development",
|
||||
"API_URL=https://api.dev.example.com",
|
||||
"DATABASE_URL=postgres://postgres:postgres@localhost:5432/project_db",
|
||||
"SERVICE_PORT=4000",
|
||||
]);
|
||||
});
|
||||
|
||||
it("handles undefined environment variables", () => {
|
||||
const serviceWithUndefined = `
|
||||
UNDEFINED_VAR=\${{environment.UNDEFINED_VAR}}
|
||||
`;
|
||||
|
||||
expect(() =>
|
||||
prepareEnvironmentVariables(serviceWithUndefined, "", environmentEnv),
|
||||
).toThrow("Invalid environment variable: environment.UNDEFINED_VAR");
|
||||
});
|
||||
|
||||
it("allows service variables to override environment variables", () => {
|
||||
const serviceOverrideEnv = `
|
||||
NODE_ENV=production
|
||||
API_URL=\${{environment.API_URL}}
|
||||
`;
|
||||
|
||||
const resolved = prepareEnvironmentVariables(
|
||||
serviceOverrideEnv,
|
||||
"",
|
||||
environmentEnv,
|
||||
);
|
||||
|
||||
expect(resolved).toEqual([
|
||||
"NODE_ENV=production", // Overrides environment variable
|
||||
"API_URL=https://api.dev.example.com",
|
||||
]);
|
||||
});
|
||||
|
||||
it("resolves complex references with project, environment, and service variables", () => {
|
||||
const complexServiceEnv = `
|
||||
FULL_DATABASE_URL=\${{project.DATABASE_URL}}/\${{environment.DATABASE_NAME}}
|
||||
API_ENDPOINT=\${{environment.API_URL}}/\${{project.ENVIRONMENT}}/api
|
||||
SERVICE_NAME=my-service
|
||||
COMPLEX_VAR=\${{SERVICE_NAME}}-\${{environment.NODE_ENV}}-\${{project.ENVIRONMENT}}
|
||||
`;
|
||||
|
||||
const resolved = prepareEnvironmentVariables(
|
||||
complexServiceEnv,
|
||||
projectEnv,
|
||||
environmentEnv,
|
||||
);
|
||||
|
||||
expect(resolved).toEqual([
|
||||
"FULL_DATABASE_URL=postgres://postgres:postgres@localhost:5432/project_db/dev_database",
|
||||
"API_ENDPOINT=https://api.dev.example.com/staging/api",
|
||||
"SERVICE_NAME=my-service",
|
||||
"COMPLEX_VAR=my-service-development-staging",
|
||||
]);
|
||||
});
|
||||
|
||||
it("handles environment variables with special characters", () => {
|
||||
const specialEnvVars = `
|
||||
SPECIAL_URL=https://special.com
|
||||
COMPLEX_KEY="key-with-@#$%^&*()"
|
||||
JWT_SECRET="secret-with-spaces and symbols!@#"
|
||||
`;
|
||||
|
||||
const serviceWithSpecial = `
|
||||
FULL_URL=\${{environment.SPECIAL_URL}}/path?key=\${{environment.COMPLEX_KEY}}
|
||||
AUTH_SECRET=\${{environment.JWT_SECRET}}
|
||||
`;
|
||||
|
||||
const resolved = prepareEnvironmentVariables(
|
||||
serviceWithSpecial,
|
||||
"",
|
||||
specialEnvVars,
|
||||
);
|
||||
|
||||
expect(resolved).toEqual([
|
||||
"FULL_URL=https://special.com/path?key=key-with-@#$%^&*()",
|
||||
"AUTH_SECRET=secret-with-spaces and symbols!@#",
|
||||
]);
|
||||
});
|
||||
|
||||
it("maintains precedence: service > environment > project", () => {
|
||||
const conflictingProjectEnv = `
|
||||
NODE_ENV=production-project
|
||||
API_URL=https://project.api.com
|
||||
DATABASE_NAME=project_db
|
||||
`;
|
||||
|
||||
const conflictingEnvironmentEnv = `
|
||||
NODE_ENV=development-environment
|
||||
API_URL=https://environment.api.com
|
||||
DATABASE_NAME=env_db
|
||||
`;
|
||||
|
||||
const serviceWithConflicts = `
|
||||
NODE_ENV=service-override
|
||||
PROJECT_ENV=\${{project.NODE_ENV}}
|
||||
ENV_VAR=\${{environment.API_URL}}
|
||||
DB_NAME=\${{environment.DATABASE_NAME}}
|
||||
`;
|
||||
|
||||
const resolved = prepareEnvironmentVariables(
|
||||
serviceWithConflicts,
|
||||
conflictingProjectEnv,
|
||||
conflictingEnvironmentEnv,
|
||||
);
|
||||
|
||||
expect(resolved).toEqual([
|
||||
"NODE_ENV=service-override", // Service wins
|
||||
"PROJECT_ENV=production-project", // Project reference
|
||||
"ENV_VAR=https://environment.api.com", // Environment reference
|
||||
"DB_NAME=env_db", // Environment reference
|
||||
]);
|
||||
});
|
||||
|
||||
it("handles empty environment variables", () => {
|
||||
const serviceWithEmpty = `
|
||||
SERVICE_VAR=test
|
||||
PROJECT_VAR=\${{project.ENVIRONMENT}}
|
||||
`;
|
||||
|
||||
const resolved = prepareEnvironmentVariables(
|
||||
serviceWithEmpty,
|
||||
projectEnv,
|
||||
"",
|
||||
);
|
||||
|
||||
expect(resolved).toEqual(["SERVICE_VAR=test", "PROJECT_VAR=staging"]);
|
||||
});
|
||||
|
||||
it("handles mixed quotes and environment variables", () => {
|
||||
const envWithQuotes = `
|
||||
QUOTED_VAR="development"
|
||||
SINGLE_QUOTED='https://api.dev.example.com'
|
||||
MIXED_VAR="value with 'single' quotes"
|
||||
`;
|
||||
|
||||
const serviceWithQuotes = `
|
||||
NODE_ENV=\${{environment.QUOTED_VAR}}
|
||||
API_URL=\${{environment.SINGLE_QUOTED}}
|
||||
COMPLEX="Prefix-\${{environment.MIXED_VAR}}-Suffix"
|
||||
`;
|
||||
|
||||
const resolved = prepareEnvironmentVariables(
|
||||
serviceWithQuotes,
|
||||
"",
|
||||
envWithQuotes,
|
||||
);
|
||||
|
||||
expect(resolved).toEqual([
|
||||
"NODE_ENV=development",
|
||||
"API_URL=https://api.dev.example.com",
|
||||
"COMPLEX=Prefix-value with 'single' quotes-Suffix",
|
||||
]);
|
||||
});
|
||||
|
||||
it("resolves multiple environment references in single value", () => {
|
||||
const multiRefEnv = `
|
||||
HOST=localhost
|
||||
PORT=5432
|
||||
USERNAME=postgres
|
||||
PASSWORD=secret123
|
||||
`;
|
||||
|
||||
const serviceWithMultiRefs = `
|
||||
DATABASE_URL=postgresql://\${{environment.USERNAME}}:\${{environment.PASSWORD}}@\${{environment.HOST}}:\${{environment.PORT}}/mydb
|
||||
CONNECTION_STRING=\${{environment.HOST}}:\${{environment.PORT}}
|
||||
`;
|
||||
|
||||
const resolved = prepareEnvironmentVariables(
|
||||
serviceWithMultiRefs,
|
||||
"",
|
||||
multiRefEnv,
|
||||
);
|
||||
|
||||
expect(resolved).toEqual([
|
||||
"DATABASE_URL=postgresql://postgres:secret123@localhost:5432/mydb",
|
||||
"CONNECTION_STRING=localhost:5432",
|
||||
]);
|
||||
});
|
||||
|
||||
it("handles nested references with environment and project variables", () => {
|
||||
const nestedProjectEnv = `
|
||||
BASE_DOMAIN=example.com
|
||||
PROTOCOL=https
|
||||
`;
|
||||
|
||||
const nestedEnvironmentEnv = `
|
||||
SUBDOMAIN=api.dev
|
||||
PATH_PREFIX=/v1
|
||||
`;
|
||||
|
||||
const serviceWithNested = `
|
||||
FULL_URL=\${{project.PROTOCOL}}://\${{environment.SUBDOMAIN}}.\${{project.BASE_DOMAIN}}\${{environment.PATH_PREFIX}}/endpoint
|
||||
API_BASE=\${{project.PROTOCOL}}://\${{environment.SUBDOMAIN}}.\${{project.BASE_DOMAIN}}
|
||||
`;
|
||||
|
||||
const resolved = prepareEnvironmentVariables(
|
||||
serviceWithNested,
|
||||
nestedProjectEnv,
|
||||
nestedEnvironmentEnv,
|
||||
);
|
||||
|
||||
expect(resolved).toEqual([
|
||||
"FULL_URL=https://api.dev.example.com/v1/endpoint",
|
||||
"API_BASE=https://api.dev.example.com",
|
||||
]);
|
||||
});
|
||||
|
||||
it("throws error for malformed environment variable references", () => {
|
||||
const serviceWithMalformed = `
|
||||
MALFORMED1=\${{environment.}}
|
||||
MALFORMED2=\${{environment}}
|
||||
VALID=\${{environment.NODE_ENV}}
|
||||
`;
|
||||
|
||||
// Should throw error for empty variable name after environment.
|
||||
expect(() =>
|
||||
prepareEnvironmentVariables(serviceWithMalformed, "", environmentEnv),
|
||||
).toThrow("Invalid environment variable: environment.");
|
||||
});
|
||||
|
||||
it("handles environment variables with numeric values", () => {
|
||||
const numericEnv = `
|
||||
PORT=8080
|
||||
TIMEOUT=30
|
||||
RETRY_COUNT=3
|
||||
PERCENTAGE=99.5
|
||||
`;
|
||||
|
||||
const serviceWithNumeric = `
|
||||
SERVER_PORT=\${{environment.PORT}}
|
||||
REQUEST_TIMEOUT=\${{environment.TIMEOUT}}
|
||||
MAX_RETRIES=\${{environment.RETRY_COUNT}}
|
||||
SUCCESS_RATE=\${{environment.PERCENTAGE}}
|
||||
`;
|
||||
|
||||
const resolved = prepareEnvironmentVariables(
|
||||
serviceWithNumeric,
|
||||
"",
|
||||
numericEnv,
|
||||
);
|
||||
|
||||
expect(resolved).toEqual([
|
||||
"SERVER_PORT=8080",
|
||||
"REQUEST_TIMEOUT=30",
|
||||
"MAX_RETRIES=3",
|
||||
"SUCCESS_RATE=99.5",
|
||||
]);
|
||||
});
|
||||
|
||||
it("handles boolean-like environment variables", () => {
|
||||
const booleanEnv = `
|
||||
DEBUG=true
|
||||
ENABLED=false
|
||||
PRODUCTION=1
|
||||
DEVELOPMENT=0
|
||||
`;
|
||||
|
||||
const serviceWithBoolean = `
|
||||
DEBUG_MODE=\${{environment.DEBUG}}
|
||||
FEATURE_ENABLED=\${{environment.ENABLED}}
|
||||
IS_PROD=\${{environment.PRODUCTION}}
|
||||
IS_DEV=\${{environment.DEVELOPMENT}}
|
||||
`;
|
||||
|
||||
const resolved = prepareEnvironmentVariables(
|
||||
serviceWithBoolean,
|
||||
"",
|
||||
booleanEnv,
|
||||
);
|
||||
|
||||
expect(resolved).toEqual([
|
||||
"DEBUG_MODE=true",
|
||||
"FEATURE_ENABLED=false",
|
||||
"IS_PROD=1",
|
||||
"IS_DEV=0",
|
||||
]);
|
||||
});
|
||||
|
||||
it("handles environment variables with single quotes in values", () => {
|
||||
const envWithSingleQuotes = `
|
||||
ENV_VARIABLE='ENVITONME'NT'
|
||||
ANOTHER_VAR='value with 'quotes' inside'
|
||||
SIMPLE_VAR=no-quotes
|
||||
`;
|
||||
|
||||
const serviceWithSingleQuotes = `
|
||||
TEST_VAR=\${{environment.ENV_VARIABLE}}
|
||||
ANOTHER_TEST=\${{environment.ANOTHER_VAR}}
|
||||
SIMPLE=\${{environment.SIMPLE_VAR}}
|
||||
`;
|
||||
|
||||
const resolved = prepareEnvironmentVariables(
|
||||
serviceWithSingleQuotes,
|
||||
"",
|
||||
envWithSingleQuotes,
|
||||
);
|
||||
|
||||
expect(resolved).toEqual([
|
||||
"TEST_VAR=ENVITONME'NT",
|
||||
"ANOTHER_TEST=value with 'quotes' inside",
|
||||
"SIMPLE=no-quotes",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("prepareEnvironmentVariablesForShell (shell escaping)", () => {
|
||||
it("escapes single quotes in environment variable values", () => {
|
||||
const serviceEnv = `
|
||||
ENV_VARIABLE='ENVITONME'NT'
|
||||
ANOTHER_VAR='value with 'quotes' inside'
|
||||
`;
|
||||
|
||||
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
|
||||
|
||||
// shell-quote should wrap these in double quotes
|
||||
expect(resolved).toEqual([
|
||||
`"ENV_VARIABLE=ENVITONME'NT"`,
|
||||
`"ANOTHER_VAR=value with 'quotes' inside"`,
|
||||
]);
|
||||
});
|
||||
|
||||
it("escapes double quotes in environment variable values", () => {
|
||||
const serviceEnv = `
|
||||
MESSAGE="Hello "World""
|
||||
QUOTED_PATH="/path/to/"file""
|
||||
`;
|
||||
|
||||
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
|
||||
|
||||
// shell-quote wraps in single quotes when there are double quotes inside
|
||||
expect(resolved).toEqual([
|
||||
`'MESSAGE=Hello "World"'`,
|
||||
`'QUOTED_PATH=/path/to/"file"'`,
|
||||
]);
|
||||
});
|
||||
|
||||
it("escapes dollar signs in environment variable values", () => {
|
||||
const serviceEnv = `
|
||||
PRICE=$100
|
||||
VARIABLE=$HOME/path
|
||||
TEMPLATE=Hello $USER
|
||||
`;
|
||||
|
||||
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
|
||||
|
||||
// Dollar signs should be escaped to prevent variable expansion
|
||||
for (const env of resolved) {
|
||||
expect(env).toContain("$");
|
||||
}
|
||||
});
|
||||
|
||||
it("escapes backticks in environment variable values", () => {
|
||||
const serviceEnv = `
|
||||
COMMAND=\`echo "test"\`
|
||||
NESTED=value with \`backticks\` inside
|
||||
`;
|
||||
|
||||
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
|
||||
|
||||
// Backticks are escaped/removed by dotenv parsing, but values should be safely quoted
|
||||
expect(resolved.length).toBe(2);
|
||||
expect(resolved[0]).toContain("COMMAND");
|
||||
expect(resolved[1]).toContain("NESTED");
|
||||
});
|
||||
|
||||
it("handles environment variables with spaces", () => {
|
||||
const serviceEnv = `
|
||||
FULL_NAME="John Doe"
|
||||
MESSAGE='Hello World'
|
||||
SENTENCE=This is a test
|
||||
`;
|
||||
|
||||
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
|
||||
|
||||
// shell-quote uses single quotes for strings with spaces
|
||||
expect(resolved).toEqual([
|
||||
`'FULL_NAME=John Doe'`,
|
||||
`'MESSAGE=Hello World'`,
|
||||
`'SENTENCE=This is a test'`,
|
||||
]);
|
||||
});
|
||||
|
||||
it("handles environment variables with backslashes", () => {
|
||||
const serviceEnv = `
|
||||
WINDOWS_PATH=C:\\Users\\Documents
|
||||
ESCAPED=value\\with\\backslashes
|
||||
`;
|
||||
|
||||
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
|
||||
|
||||
// Backslashes should be properly escaped
|
||||
expect(resolved.length).toBe(2);
|
||||
for (const env of resolved) {
|
||||
expect(env).toContain("\\");
|
||||
}
|
||||
});
|
||||
|
||||
it("handles simple environment variables without special characters", () => {
|
||||
const serviceEnv = `
|
||||
NODE_ENV=production
|
||||
PORT=3000
|
||||
DEBUG=true
|
||||
`;
|
||||
|
||||
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
|
||||
|
||||
// shell-quote escapes the = sign in some cases
|
||||
expect(resolved).toEqual([
|
||||
"NODE_ENV\\=production",
|
||||
"PORT\\=3000",
|
||||
"DEBUG\\=true",
|
||||
]);
|
||||
});
|
||||
|
||||
it("handles environment variables with mixed special characters", () => {
|
||||
const serviceEnv = `
|
||||
COMPLEX='value with "double" and 'single' quotes'
|
||||
BASH_COMMAND=echo "$HOME" && echo 'test'
|
||||
WEIRD=\`echo "$VAR"\` with 'quotes' and "more"
|
||||
`;
|
||||
|
||||
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
|
||||
|
||||
// All should be escaped, none should throw errors
|
||||
expect(resolved.length).toBe(3);
|
||||
// Verify each can be safely used in shell
|
||||
for (const env of resolved) {
|
||||
expect(typeof env).toBe("string");
|
||||
expect(env.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("handles environment variables with newlines", () => {
|
||||
const serviceEnv = `
|
||||
MULTILINE="line1
|
||||
line2
|
||||
line3"
|
||||
`;
|
||||
|
||||
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
|
||||
|
||||
expect(resolved.length).toBe(1);
|
||||
expect(resolved[0]).toContain("MULTILINE");
|
||||
});
|
||||
|
||||
it("handles empty environment variable values", () => {
|
||||
const serviceEnv = `
|
||||
EMPTY=
|
||||
EMPTY_QUOTED=""
|
||||
EMPTY_SINGLE=''
|
||||
`;
|
||||
|
||||
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
|
||||
|
||||
// shell-quote escapes the = sign for empty values
|
||||
expect(resolved).toEqual([
|
||||
"EMPTY\\=",
|
||||
"EMPTY_QUOTED\\=",
|
||||
"EMPTY_SINGLE\\=",
|
||||
]);
|
||||
});
|
||||
|
||||
it("handles environment variables with equals signs in values", () => {
|
||||
const serviceEnv = `
|
||||
EQUATION=a=b+c
|
||||
CONNECTION_STRING=user=admin;password=test
|
||||
`;
|
||||
|
||||
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
|
||||
|
||||
expect(resolved.length).toBe(2);
|
||||
expect(resolved[0]).toContain("EQUATION");
|
||||
expect(resolved[1]).toContain("CONNECTION_STRING");
|
||||
});
|
||||
|
||||
it("resolves and escapes environment variables together", () => {
|
||||
const projectEnv = `
|
||||
BASE_URL=https://example.com
|
||||
API_KEY='secret-key-with-quotes'
|
||||
`;
|
||||
|
||||
const environmentEnv = `
|
||||
ENV_NAME=production
|
||||
DB_PASS='pa$$word'
|
||||
`;
|
||||
|
||||
const serviceEnv = `
|
||||
FULL_URL=\${{project.BASE_URL}}/api
|
||||
AUTH_KEY=\${{project.API_KEY}}
|
||||
ENVIRONMENT=\${{environment.ENV_NAME}}
|
||||
DB_PASSWORD=\${{environment.DB_PASS}}
|
||||
CUSTOM='value with 'quotes' inside'
|
||||
`;
|
||||
|
||||
const resolved = prepareEnvironmentVariablesForShell(
|
||||
serviceEnv,
|
||||
projectEnv,
|
||||
environmentEnv,
|
||||
);
|
||||
|
||||
expect(resolved.length).toBe(5);
|
||||
// All resolved values should be properly escaped
|
||||
for (const env of resolved) {
|
||||
expect(typeof env).toBe("string");
|
||||
}
|
||||
});
|
||||
|
||||
it("handles environment variables with semicolons and ampersands", () => {
|
||||
const serviceEnv = `
|
||||
COMMAND=echo "test" && echo "test2"
|
||||
MULTIPLE=cmd1; cmd2; cmd3
|
||||
URL_WITH_PARAMS=https://example.com?a=1&b=2&c=3
|
||||
`;
|
||||
|
||||
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
|
||||
|
||||
expect(resolved.length).toBe(3);
|
||||
// These should be safely escaped to prevent command injection
|
||||
for (const env of resolved) {
|
||||
expect(typeof env).toBe("string");
|
||||
expect(env.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("handles environment variables with pipes and redirects", () => {
|
||||
const serviceEnv = `
|
||||
PIPE_COMMAND=cat file | grep test
|
||||
REDIRECT=echo "test" > output.txt
|
||||
BOTH=cat input.txt | grep pattern > output.txt
|
||||
`;
|
||||
|
||||
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
|
||||
|
||||
expect(resolved.length).toBe(3);
|
||||
// Pipes and redirects should be safely quoted
|
||||
expect(resolved[0]).toContain("PIPE_COMMAND");
|
||||
expect(resolved[1]).toContain("REDIRECT");
|
||||
expect(resolved[2]).toContain("BOTH");
|
||||
// At least one should contain a pipe
|
||||
const hasPipe = resolved.some((env) => env.includes("|"));
|
||||
expect(hasPipe).toBe(true);
|
||||
});
|
||||
|
||||
it("handles environment variables with parentheses and brackets", () => {
|
||||
const serviceEnv = `
|
||||
MATH=(a+b)*c
|
||||
ARRAY=[1,2,3]
|
||||
JSON={"key":"value"}
|
||||
`;
|
||||
|
||||
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
|
||||
|
||||
expect(resolved.length).toBe(3);
|
||||
expect(resolved[0]).toContain("(");
|
||||
expect(resolved[1]).toContain("[");
|
||||
expect(resolved[2]).toContain("{");
|
||||
});
|
||||
|
||||
it("handles very long environment variable values", () => {
|
||||
const longValue = "a".repeat(10000);
|
||||
const serviceEnv = `LONG_VAR=${longValue}`;
|
||||
|
||||
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
|
||||
|
||||
expect(resolved.length).toBe(1);
|
||||
expect(resolved[0]).toContain("LONG_VAR");
|
||||
expect(resolved[0]?.length).toBeGreaterThan(10000);
|
||||
});
|
||||
|
||||
it("handles special unicode characters in environment variables", () => {
|
||||
const serviceEnv = `
|
||||
EMOJI=Hello 🌍 World 🚀
|
||||
CHINESE=你好世界
|
||||
SPECIAL=café résumé naïve
|
||||
`;
|
||||
|
||||
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
|
||||
|
||||
expect(resolved.length).toBe(3);
|
||||
expect(resolved[0]).toContain("🌍");
|
||||
expect(resolved[1]).toContain("你好");
|
||||
expect(resolved[2]).toContain("café");
|
||||
});
|
||||
});
|
||||
@@ -1,109 +0,0 @@
|
||||
import type { ApplicationNested } from "@dokploy/server/utils/builders";
|
||||
import { mechanizeDockerContainer } from "@dokploy/server/utils/builders";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
type MockCreateServiceOptions = {
|
||||
TaskTemplate?: {
|
||||
ContainerSpec?: {
|
||||
StopGracePeriod?: number;
|
||||
};
|
||||
};
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
const { inspectMock, getServiceMock, createServiceMock, getRemoteDockerMock } =
|
||||
vi.hoisted(() => {
|
||||
const inspect = vi.fn<[], Promise<never>>();
|
||||
const getService = vi.fn(() => ({ inspect }));
|
||||
const createService = vi.fn<[MockCreateServiceOptions], Promise<void>>(
|
||||
async () => undefined,
|
||||
);
|
||||
const getRemoteDocker = vi.fn(async () => ({
|
||||
getService,
|
||||
createService,
|
||||
}));
|
||||
return {
|
||||
inspectMock: inspect,
|
||||
getServiceMock: getService,
|
||||
createServiceMock: createService,
|
||||
getRemoteDockerMock: getRemoteDocker,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@dokploy/server/utils/servers/remote-docker", () => ({
|
||||
getRemoteDocker: getRemoteDockerMock,
|
||||
}));
|
||||
|
||||
const createApplication = (
|
||||
overrides: Partial<ApplicationNested> = {},
|
||||
): ApplicationNested =>
|
||||
({
|
||||
appName: "test-app",
|
||||
buildType: "dockerfile",
|
||||
env: null,
|
||||
mounts: [],
|
||||
cpuLimit: null,
|
||||
memoryLimit: null,
|
||||
memoryReservation: null,
|
||||
cpuReservation: null,
|
||||
command: null,
|
||||
ports: [],
|
||||
sourceType: "docker",
|
||||
dockerImage: "example:latest",
|
||||
registry: null,
|
||||
environment: {
|
||||
project: { env: null },
|
||||
env: null,
|
||||
},
|
||||
replicas: 1,
|
||||
stopGracePeriodSwarm: 0n,
|
||||
serverId: "server-id",
|
||||
...overrides,
|
||||
}) as unknown as ApplicationNested;
|
||||
|
||||
describe("mechanizeDockerContainer", () => {
|
||||
beforeEach(() => {
|
||||
inspectMock.mockReset();
|
||||
inspectMock.mockRejectedValue(new Error("service not found"));
|
||||
getServiceMock.mockClear();
|
||||
createServiceMock.mockClear();
|
||||
getRemoteDockerMock.mockClear();
|
||||
getRemoteDockerMock.mockResolvedValue({
|
||||
getService: getServiceMock,
|
||||
createService: createServiceMock,
|
||||
});
|
||||
});
|
||||
|
||||
it("converts bigint stopGracePeriodSwarm to a number and keeps zero values", async () => {
|
||||
const application = createApplication({ stopGracePeriodSwarm: 0n });
|
||||
|
||||
await mechanizeDockerContainer(application);
|
||||
|
||||
expect(createServiceMock).toHaveBeenCalledTimes(1);
|
||||
const call = createServiceMock.mock.calls[0];
|
||||
if (!call) {
|
||||
throw new Error("createServiceMock should have been called once");
|
||||
}
|
||||
const [settings] = call;
|
||||
expect(settings.TaskTemplate?.ContainerSpec?.StopGracePeriod).toBe(0);
|
||||
expect(typeof settings.TaskTemplate?.ContainerSpec?.StopGracePeriod).toBe(
|
||||
"number",
|
||||
);
|
||||
});
|
||||
|
||||
it("omits StopGracePeriod when stopGracePeriodSwarm is null", async () => {
|
||||
const application = createApplication({ stopGracePeriodSwarm: null });
|
||||
|
||||
await mechanizeDockerContainer(application);
|
||||
|
||||
expect(createServiceMock).toHaveBeenCalledTimes(1);
|
||||
const call = createServiceMock.mock.calls[0];
|
||||
if (!call) {
|
||||
throw new Error("createServiceMock should have been called once");
|
||||
}
|
||||
const [settings] = call;
|
||||
expect(settings.TaskTemplate?.ContainerSpec).not.toHaveProperty(
|
||||
"StopGracePeriod",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -228,58 +228,5 @@ describe("helpers functions", () => {
|
||||
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE3MzU2ODk2MDAsImV4cCI6MTczNTY5MzIwMCwiaXNzIjoidGVzdC1pc3N1ZXIiLCJjdXN0b21wcm9wIjoiY3VzdG9tdmFsdWUifQ.m42U7PZSUSCf7gBOJrxJir0rQmyPq4rA59Dydr_QahI",
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle JWT payload with newlines and whitespace by trimming them", () => {
|
||||
const iat = Math.floor(new Date("2025-01-01T00:00:00Z").getTime() / 1000);
|
||||
const expiry = iat + 3600;
|
||||
const payloadWithNewlines = `{
|
||||
"role": "anon",
|
||||
"iss": "supabase",
|
||||
"exp": ${expiry}
|
||||
}
|
||||
`;
|
||||
const jwt = processValue(
|
||||
"${jwt:secret:payload}",
|
||||
{
|
||||
secret: "mysecret",
|
||||
payload: payloadWithNewlines,
|
||||
},
|
||||
mockSchema,
|
||||
);
|
||||
expect(jwt).toMatch(jwtMatchExp);
|
||||
const parts = jwt.split(".") as JWTParts;
|
||||
jwtCheckHeader(parts[0]);
|
||||
const decodedPayload = jwtBase64Decode(parts[1]);
|
||||
expect(decodedPayload).toHaveProperty("role");
|
||||
expect(decodedPayload.role).toEqual("anon");
|
||||
expect(decodedPayload).toHaveProperty("iss");
|
||||
expect(decodedPayload.iss).toEqual("supabase");
|
||||
expect(decodedPayload).toHaveProperty("exp");
|
||||
expect(decodedPayload.exp).toEqual(expiry);
|
||||
});
|
||||
|
||||
it("should handle JWT payload with leading and trailing whitespace", () => {
|
||||
const iat = Math.floor(new Date("2025-01-01T00:00:00Z").getTime() / 1000);
|
||||
const expiry = iat + 3600;
|
||||
const payloadWithWhitespace = ` {"role": "service_role", "iss": "supabase", "exp": ${expiry}} `;
|
||||
const jwt = processValue(
|
||||
"${jwt:secret:payload}",
|
||||
{
|
||||
secret: "mysecret",
|
||||
payload: payloadWithWhitespace,
|
||||
},
|
||||
mockSchema,
|
||||
);
|
||||
expect(jwt).toMatch(jwtMatchExp);
|
||||
const parts = jwt.split(".") as JWTParts;
|
||||
jwtCheckHeader(parts[0]);
|
||||
const decodedPayload = jwtBase64Decode(parts[1]);
|
||||
expect(decodedPayload).toHaveProperty("role");
|
||||
expect(decodedPayload.role).toEqual("service_role");
|
||||
expect(decodedPayload).toHaveProperty("iss");
|
||||
expect(decodedPayload.iss).toEqual("supabase");
|
||||
expect(decodedPayload).toHaveProperty("exp");
|
||||
expect(decodedPayload.exp).toEqual(expiry);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,8 +18,6 @@ const baseAdmin: User = {
|
||||
enablePaidFeatures: false,
|
||||
allowImpersonation: false,
|
||||
role: "user",
|
||||
firstName: "",
|
||||
lastName: "",
|
||||
metricsConfig: {
|
||||
containers: {
|
||||
refreshRate: 20,
|
||||
@@ -63,6 +61,7 @@ const baseAdmin: User = {
|
||||
expirationDate: "",
|
||||
id: "",
|
||||
isRegistered: false,
|
||||
name: "",
|
||||
createdAt2: new Date().toISOString(),
|
||||
emailVerified: false,
|
||||
image: "",
|
||||
|
||||
@@ -11,18 +11,10 @@ const baseApp: ApplicationNested = {
|
||||
giteaRepository: "",
|
||||
giteaOwner: "",
|
||||
giteaBranch: "",
|
||||
buildServerId: "",
|
||||
buildRegistryId: "",
|
||||
buildRegistry: null,
|
||||
giteaBuildPath: "",
|
||||
giteaId: "",
|
||||
args: [],
|
||||
rollbackRegistryId: "",
|
||||
rollbackRegistry: null,
|
||||
deployments: [],
|
||||
cleanCache: false,
|
||||
applicationStatus: "done",
|
||||
endpointSpecSwarm: null,
|
||||
appName: "",
|
||||
autoDeploy: true,
|
||||
enableSubmodules: false,
|
||||
@@ -33,10 +25,8 @@ const baseApp: ApplicationNested = {
|
||||
registryUrl: "",
|
||||
watchPaths: [],
|
||||
buildArgs: null,
|
||||
buildSecrets: null,
|
||||
isPreviewDeploymentsActive: false,
|
||||
previewBuildArgs: null,
|
||||
previewBuildSecrets: null,
|
||||
triggerType: "push",
|
||||
previewCertificateType: "none",
|
||||
previewEnv: null,
|
||||
@@ -46,22 +36,13 @@ const baseApp: ApplicationNested = {
|
||||
previewLimit: 0,
|
||||
previewCustomCertResolver: null,
|
||||
previewWildcard: "",
|
||||
environmentId: "",
|
||||
environment: {
|
||||
project: {
|
||||
env: "",
|
||||
environmentId: "",
|
||||
organizationId: "",
|
||||
name: "",
|
||||
createdAt: "",
|
||||
description: "",
|
||||
createdAt: "",
|
||||
projectId: "",
|
||||
project: {
|
||||
env: "",
|
||||
organizationId: "",
|
||||
name: "",
|
||||
description: "",
|
||||
createdAt: "",
|
||||
projectId: "",
|
||||
},
|
||||
},
|
||||
buildPath: "/",
|
||||
gitlabPathNamespace: "",
|
||||
@@ -104,6 +85,7 @@ const baseApp: ApplicationNested = {
|
||||
password: null,
|
||||
placementSwarm: null,
|
||||
ports: [],
|
||||
projectId: "",
|
||||
publishDirectory: null,
|
||||
isStaticSpa: null,
|
||||
redirects: [],
|
||||
@@ -121,7 +103,6 @@ const baseApp: ApplicationNested = {
|
||||
updateConfigSwarm: null,
|
||||
username: null,
|
||||
dockerContextPath: null,
|
||||
stopGracePeriodSwarm: null,
|
||||
};
|
||||
|
||||
const baseDomain: Domain = {
|
||||
|
||||
@@ -13,11 +13,7 @@ export default defineConfig({
|
||||
NODE: "test",
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
tsconfigPaths({
|
||||
projects: [path.resolve(__dirname, "../tsconfig.json")],
|
||||
}),
|
||||
],
|
||||
plugins: [tsconfigPaths()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@dokploy/server": path.resolve(
|
||||
|
||||
@@ -25,7 +25,6 @@ import {
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -122,22 +121,6 @@ const NetworkSwarmSchema = z.array(
|
||||
|
||||
const LabelsSwarmSchema = z.record(z.string());
|
||||
|
||||
const EndpointPortConfigSwarmSchema = z
|
||||
.object({
|
||||
Protocol: z.string().optional(),
|
||||
TargetPort: z.number().optional(),
|
||||
PublishedPort: z.number().optional(),
|
||||
PublishMode: z.string().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const EndpointSpecSwarmSchema = z
|
||||
.object({
|
||||
Mode: z.string().optional(),
|
||||
Ports: z.array(EndpointPortConfigSwarmSchema).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const createStringToJSONSchema = (schema: z.ZodTypeAny) => {
|
||||
return z
|
||||
.string()
|
||||
@@ -193,21 +176,10 @@ const addSwarmSettings = z.object({
|
||||
modeSwarm: createStringToJSONSchema(ServiceModeSwarmSchema).nullable(),
|
||||
labelsSwarm: createStringToJSONSchema(LabelsSwarmSchema).nullable(),
|
||||
networkSwarm: createStringToJSONSchema(NetworkSwarmSchema).nullable(),
|
||||
stopGracePeriodSwarm: z.bigint().nullable(),
|
||||
endpointSpecSwarm: createStringToJSONSchema(
|
||||
EndpointSpecSwarmSchema,
|
||||
).nullable(),
|
||||
});
|
||||
|
||||
type AddSwarmSettings = z.infer<typeof addSwarmSettings>;
|
||||
|
||||
const hasStopGracePeriodSwarm = (
|
||||
value: unknown,
|
||||
): value is { stopGracePeriodSwarm: bigint | number | string | null } =>
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
"stopGracePeriodSwarm" in value;
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
|
||||
@@ -252,23 +224,12 @@ export const AddSwarmSettings = ({ id, type }: Props) => {
|
||||
modeSwarm: null,
|
||||
labelsSwarm: null,
|
||||
networkSwarm: null,
|
||||
stopGracePeriodSwarm: null,
|
||||
endpointSpecSwarm: null,
|
||||
},
|
||||
resolver: zodResolver(addSwarmSettings),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
const stopGracePeriodValue = hasStopGracePeriodSwarm(data)
|
||||
? data.stopGracePeriodSwarm
|
||||
: null;
|
||||
const normalizedStopGracePeriod =
|
||||
stopGracePeriodValue === null || stopGracePeriodValue === undefined
|
||||
? null
|
||||
: typeof stopGracePeriodValue === "bigint"
|
||||
? stopGracePeriodValue
|
||||
: BigInt(stopGracePeriodValue);
|
||||
form.reset({
|
||||
healthCheckSwarm: data.healthCheckSwarm
|
||||
? JSON.stringify(data.healthCheckSwarm, null, 2)
|
||||
@@ -294,10 +255,6 @@ export const AddSwarmSettings = ({ id, type }: Props) => {
|
||||
networkSwarm: data.networkSwarm
|
||||
? JSON.stringify(data.networkSwarm, null, 2)
|
||||
: null,
|
||||
stopGracePeriodSwarm: normalizedStopGracePeriod,
|
||||
endpointSpecSwarm: data.endpointSpecSwarm
|
||||
? JSON.stringify(data.endpointSpecSwarm, null, 2)
|
||||
: null,
|
||||
});
|
||||
}
|
||||
}, [form, form.reset, data]);
|
||||
@@ -318,8 +275,6 @@ export const AddSwarmSettings = ({ id, type }: Props) => {
|
||||
modeSwarm: data.modeSwarm,
|
||||
labelsSwarm: data.labelsSwarm,
|
||||
networkSwarm: data.networkSwarm,
|
||||
stopGracePeriodSwarm: data.stopGracePeriodSwarm ?? null,
|
||||
endpointSpecSwarm: data.endpointSpecSwarm,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Swarm settings updated");
|
||||
@@ -397,9 +352,9 @@ export const AddSwarmSettings = ({ id, type }: Props) => {
|
||||
language="json"
|
||||
placeholder={`{
|
||||
"Test" : ["CMD-SHELL", "curl -f http://localhost:3000/health"],
|
||||
"Interval" : 10000000000,
|
||||
"Timeout" : 10000000000,
|
||||
"StartPeriod" : 10000000000,
|
||||
"Interval" : 10000,
|
||||
"Timeout" : 10000,
|
||||
"StartPeriod" : 10000,
|
||||
"Retries" : 10
|
||||
}`}
|
||||
className="h-[12rem] font-mono"
|
||||
@@ -452,9 +407,9 @@ export const AddSwarmSettings = ({ id, type }: Props) => {
|
||||
language="json"
|
||||
placeholder={`{
|
||||
"Condition" : "on-failure",
|
||||
"Delay" : 10000000000,
|
||||
"Delay" : 10000,
|
||||
"MaxAttempts" : 10,
|
||||
"Window" : 10000000000
|
||||
"Window" : 10000
|
||||
} `}
|
||||
className="h-[12rem] font-mono"
|
||||
{...field}
|
||||
@@ -574,9 +529,9 @@ export const AddSwarmSettings = ({ id, type }: Props) => {
|
||||
language="json"
|
||||
placeholder={`{
|
||||
"Parallelism" : 1,
|
||||
"Delay" : 10000000000,
|
||||
"Delay" : 10000,
|
||||
"FailureAction" : "continue",
|
||||
"Monitor" : 10000000000,
|
||||
"Monitor" : 10000,
|
||||
"MaxFailureRatio" : 10,
|
||||
"Order" : "start-first"
|
||||
}`}
|
||||
@@ -632,9 +587,9 @@ export const AddSwarmSettings = ({ id, type }: Props) => {
|
||||
language="json"
|
||||
placeholder={`{
|
||||
"Parallelism" : 1,
|
||||
"Delay" : 10000000000,
|
||||
"Delay" : 10000,
|
||||
"FailureAction" : "continue",
|
||||
"Monitor" : 10000000000,
|
||||
"Monitor" : 10000,
|
||||
"MaxFailureRatio" : 10,
|
||||
"Order" : "start-first"
|
||||
}`}
|
||||
@@ -819,118 +774,7 @@ export const AddSwarmSettings = ({ id, type }: Props) => {
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="stopGracePeriodSwarm"
|
||||
render={({ field }) => (
|
||||
<FormItem className="relative max-lg:px-4 lg:pl-6 ">
|
||||
<FormLabel>Stop Grace Period (nanoseconds)</FormLabel>
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<FormDescription className="break-all w-fit flex flex-row gap-1 items-center">
|
||||
Duration in nanoseconds
|
||||
<HelpCircle className="size-4 text-muted-foreground" />
|
||||
</FormDescription>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
className="w-full z-[999]"
|
||||
align="start"
|
||||
side="bottom"
|
||||
>
|
||||
<code>
|
||||
<pre>
|
||||
{`Enter duration in nanoseconds:
|
||||
• 30000000000 - 30 seconds
|
||||
• 120000000000 - 2 minutes
|
||||
• 3600000000000 - 1 hour
|
||||
• 0 - no grace period`}
|
||||
</pre>
|
||||
</code>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="30000000000"
|
||||
className="font-mono"
|
||||
{...field}
|
||||
value={field?.value?.toString() || ""}
|
||||
onChange={(e) =>
|
||||
field.onChange(
|
||||
e.target.value ? BigInt(e.target.value) : null,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<pre>
|
||||
<FormMessage />
|
||||
</pre>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="endpointSpecSwarm"
|
||||
render={({ field }) => (
|
||||
<FormItem className="relative ">
|
||||
<FormLabel>Endpoint Spec</FormLabel>
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<FormDescription className="break-all w-fit flex flex-row gap-1 items-center">
|
||||
Check the interface
|
||||
<HelpCircle className="size-4 text-muted-foreground" />
|
||||
</FormDescription>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
className="w-full z-[999]"
|
||||
align="start"
|
||||
side="bottom"
|
||||
>
|
||||
<code>
|
||||
<pre>
|
||||
{`{
|
||||
Mode?: string | undefined;
|
||||
Ports?: Array<{
|
||||
Protocol?: string | undefined;
|
||||
TargetPort?: number | undefined;
|
||||
PublishedPort?: number | undefined;
|
||||
PublishMode?: string | undefined;
|
||||
}> | undefined;
|
||||
}`}
|
||||
</pre>
|
||||
</code>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<FormControl>
|
||||
<CodeEditor
|
||||
language="json"
|
||||
placeholder={`{
|
||||
"Mode": "dnsrr",
|
||||
"Ports": [
|
||||
{
|
||||
"Protocol": "tcp",
|
||||
"TargetPort": 5432,
|
||||
"PublishedPort": 5432,
|
||||
"PublishMode": "host"
|
||||
}
|
||||
]
|
||||
}`}
|
||||
className="h-[17rem] font-mono"
|
||||
{...field}
|
||||
value={field?.value || ""}
|
||||
/>
|
||||
</FormControl>
|
||||
<pre>
|
||||
<FormMessage />
|
||||
</pre>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<DialogFooter className="flex w-full flex-row justify-end md:col-span-2 m-0 sticky bottom-0 right-0 bg-muted border">
|
||||
<Button
|
||||
isLoading={isLoading}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import { useFieldArray, useForm } from "react-hook-form";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -29,13 +28,6 @@ interface Props {
|
||||
|
||||
const AddRedirectSchema = z.object({
|
||||
command: z.string(),
|
||||
args: z
|
||||
.array(
|
||||
z.object({
|
||||
value: z.string().min(1, "Argument cannot be empty"),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
type AddCommand = z.infer<typeof AddRedirectSchema>;
|
||||
@@ -55,30 +47,22 @@ export const AddCommand = ({ applicationId }: Props) => {
|
||||
const form = useForm<AddCommand>({
|
||||
defaultValues: {
|
||||
command: "",
|
||||
args: [],
|
||||
},
|
||||
resolver: zodResolver(AddRedirectSchema),
|
||||
});
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control: form.control,
|
||||
name: "args",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
if (data?.command) {
|
||||
form.reset({
|
||||
command: data?.command || "",
|
||||
args: data?.args?.map((arg) => ({ value: arg })) || [],
|
||||
});
|
||||
}
|
||||
}, [data, form]);
|
||||
}, [form, form.reset, form.formState.isSubmitSuccessful, data?.command]);
|
||||
|
||||
const onSubmit = async (data: AddCommand) => {
|
||||
await mutateAsync({
|
||||
applicationId,
|
||||
command: data?.command,
|
||||
args: data?.args?.map((arg) => arg.value).filter(Boolean),
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Command Updated");
|
||||
@@ -116,65 +100,13 @@ export const AddCommand = ({ applicationId }: Props) => {
|
||||
<FormItem>
|
||||
<FormLabel>Command</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="/bin/sh" {...field} />
|
||||
<Input placeholder="Custom command" {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<FormLabel>Arguments (Args)</FormLabel>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => append({ value: "" })}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Add Argument
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{fields.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No arguments added yet. Click "Add Argument" to add one.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{fields.map((field, index) => (
|
||||
<FormField
|
||||
key={field.id}
|
||||
control={form.control}
|
||||
name={`args.${index}.value`}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="flex gap-2">
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={
|
||||
index === 0 ? "-c" : "echo Hello World"
|
||||
}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
onClick={() => remove(index)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button isLoading={isLoading} type="submit" className="w-fit">
|
||||
|
||||
@@ -1,248 +0,0 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Server } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
interface Props {
|
||||
applicationId: string;
|
||||
}
|
||||
|
||||
const schema = z.object({
|
||||
buildServerId: z.string().min(1, "Build server is required"),
|
||||
buildRegistryId: z.string().min(1, "Build registry is required"),
|
||||
});
|
||||
|
||||
type Schema = z.infer<typeof schema>;
|
||||
|
||||
export const ShowBuildServer = ({ applicationId }: Props) => {
|
||||
const { data, refetch } = api.application.one.useQuery(
|
||||
{ applicationId },
|
||||
{ enabled: !!applicationId },
|
||||
);
|
||||
const { data: buildServers } = api.server.buildServers.useQuery();
|
||||
const { data: registries } = api.registry.all.useQuery();
|
||||
|
||||
const { mutateAsync, isLoading } = api.application.update.useMutation();
|
||||
|
||||
const form = useForm<Schema>({
|
||||
defaultValues: {
|
||||
buildServerId: data?.buildServerId || "",
|
||||
buildRegistryId: data?.buildRegistryId || "",
|
||||
},
|
||||
resolver: zodResolver(schema),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
form.reset({
|
||||
buildServerId: data?.buildServerId || "",
|
||||
buildRegistryId: data?.buildRegistryId || "",
|
||||
});
|
||||
}
|
||||
}, [form, form.reset, data]);
|
||||
|
||||
const onSubmit = async (formData: Schema) => {
|
||||
await mutateAsync({
|
||||
applicationId,
|
||||
buildServerId:
|
||||
formData?.buildServerId === "none" || !formData?.buildServerId
|
||||
? null
|
||||
: formData?.buildServerId,
|
||||
buildRegistryId:
|
||||
formData?.buildRegistryId === "none" || !formData?.buildRegistryId
|
||||
? null
|
||||
: formData?.buildRegistryId,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Build Server Settings Updated");
|
||||
await refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error updating build server settings");
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="bg-background">
|
||||
<CardHeader>
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<Server className="size-6 text-muted-foreground" />
|
||||
<div>
|
||||
<CardTitle className="text-xl">Build Server</CardTitle>
|
||||
<CardDescription>
|
||||
Configure a dedicated server for building your application.
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<AlertBlock type="info">
|
||||
Build servers offload the build process from your deployment servers.
|
||||
Select a build server and registry to use for building your
|
||||
application.
|
||||
</AlertBlock>
|
||||
|
||||
<AlertBlock type="info">
|
||||
📊 <strong>Important:</strong> Once the build finishes, you'll need to
|
||||
wait a few seconds for the deployment server to download the image.
|
||||
These download logs will <strong>NOT</strong> appear in the build
|
||||
deployment logs. Check the <strong>Logs</strong> tab to see when the
|
||||
container starts running.
|
||||
</AlertBlock>
|
||||
|
||||
{!registries || registries.length === 0 ? (
|
||||
<AlertBlock type="warning">
|
||||
You need to add at least one registry to use build servers. Please
|
||||
go to{" "}
|
||||
<Link
|
||||
href="/dashboard/settings/registry"
|
||||
className="text-primary underline"
|
||||
>
|
||||
Settings
|
||||
</Link>{" "}
|
||||
to add a registry.
|
||||
</AlertBlock>
|
||||
) : null}
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="grid w-full gap-4"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="buildServerId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Build Server</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
value={field.value || "none"}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a build server" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="none">
|
||||
<span className="flex items-center gap-2">
|
||||
<span>None</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
{buildServers?.map((server) => (
|
||||
<SelectItem
|
||||
key={server.serverId}
|
||||
value={server.serverId}
|
||||
>
|
||||
<span className="flex items-center gap-2 justify-between w-full">
|
||||
<span>{server.name}</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{server.ipAddress}
|
||||
</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectLabel>
|
||||
Build Servers ({buildServers?.length || 0})
|
||||
</SelectLabel>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
Select a build server to handle the build process for this
|
||||
application.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="buildRegistryId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Build Registry</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
value={field.value || "none"}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a registry" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="none">
|
||||
<span className="flex items-center gap-2">
|
||||
<span>None</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
{registries?.map((registry) => (
|
||||
<SelectItem
|
||||
key={registry.registryId}
|
||||
value={registry.registryId}
|
||||
>
|
||||
{registry.registryName}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectLabel>
|
||||
Registries ({registries?.length || 0})
|
||||
</SelectLabel>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
Select a registry to store the built images from the build
|
||||
server.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex w-full justify-end">
|
||||
<Button isLoading={isLoading} type="submit">
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -150,10 +150,7 @@ export const ShowResources = ({ id, type }: Props) => {
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<div
|
||||
className="flex items-center gap-2"
|
||||
onClick={(e) => e.preventDefault()}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<FormLabel>Memory Limit</FormLabel>
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={0}>
|
||||
@@ -185,10 +182,7 @@ export const ShowResources = ({ id, type }: Props) => {
|
||||
name="memoryReservation"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div
|
||||
className="flex items-center gap-2"
|
||||
onClick={(e) => e.preventDefault()}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<FormLabel>Memory Reservation</FormLabel>
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={0}>
|
||||
@@ -221,10 +215,7 @@ export const ShowResources = ({ id, type }: Props) => {
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<div
|
||||
className="flex items-center gap-2"
|
||||
onClick={(e) => e.preventDefault()}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<FormLabel>CPU Limit</FormLabel>
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={0}>
|
||||
@@ -258,10 +249,7 @@ export const ShowResources = ({ id, type }: Props) => {
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<div
|
||||
className="flex items-center gap-2"
|
||||
onClick={(e) => e.preventDefault()}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<FormLabel>CPU Reservation</FormLabel>
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={0}>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import jsyaml from "js-yaml";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { parse, stringify, YAMLParseError } from "yaml";
|
||||
import { z } from "zod";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { CodeEditor } from "@/components/shared/code-editor";
|
||||
@@ -38,11 +38,11 @@ interface Props {
|
||||
|
||||
export const validateAndFormatYAML = (yamlText: string) => {
|
||||
try {
|
||||
const obj = parse(yamlText);
|
||||
const formattedYaml = stringify(obj, { indent: 4 });
|
||||
const obj = jsyaml.load(yamlText);
|
||||
const formattedYaml = jsyaml.dump(obj, { indent: 4 });
|
||||
return { valid: true, formattedYaml, error: null };
|
||||
} catch (error) {
|
||||
if (error instanceof YAMLParseError) {
|
||||
if (error instanceof jsyaml.YAMLException) {
|
||||
return {
|
||||
valid: false,
|
||||
formattedYaml: yamlText,
|
||||
@@ -89,7 +89,7 @@ export const UpdateTraefikConfig = ({ applicationId }: Props) => {
|
||||
if (!valid) {
|
||||
form.setError("traefikConfig", {
|
||||
type: "manual",
|
||||
message: (error as string) || "Invalid YAML",
|
||||
message: error || "Invalid YAML",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -59,13 +59,7 @@ const mySchema = z.discriminatedUnion("type", [
|
||||
z
|
||||
.object({
|
||||
type: z.literal("volume"),
|
||||
volumeName: z
|
||||
.string()
|
||||
.min(1, "Volume name required")
|
||||
.regex(
|
||||
/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/,
|
||||
"Invalid volume name. Use letters, numbers, '._-' and start with a letter/number.",
|
||||
),
|
||||
volumeName: z.string().min(1, "Volume name required"),
|
||||
})
|
||||
.merge(mountSchema),
|
||||
z
|
||||
@@ -324,7 +318,7 @@ export const AddVolumes = ({
|
||||
control={form.control}
|
||||
name="content"
|
||||
render={({ field }) => (
|
||||
<FormItem className="max-w-full max-w-[45rem]">
|
||||
<FormItem>
|
||||
<FormLabel>Content</FormLabel>
|
||||
<FormControl>
|
||||
<FormControl>
|
||||
@@ -333,7 +327,7 @@ export const AddVolumes = ({
|
||||
placeholder={`NODE_ENV=production
|
||||
PORT=3000
|
||||
`}
|
||||
className="h-96 font-mono "
|
||||
className="h-96 font-mono"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
@@ -41,13 +41,7 @@ const mySchema = z.discriminatedUnion("type", [
|
||||
z
|
||||
.object({
|
||||
type: z.literal("volume"),
|
||||
volumeName: z
|
||||
.string()
|
||||
.min(1, "Volume name required")
|
||||
.regex(
|
||||
/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/,
|
||||
"Invalid volume name. Use letters, numbers, '._-' and start with a letter/number.",
|
||||
),
|
||||
volumeName: z.string().min(1, "Volume name required"),
|
||||
})
|
||||
.merge(mountSchema),
|
||||
z
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
import { Scissors } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
type: "application" | "compose";
|
||||
}
|
||||
|
||||
export const KillBuild = ({ id, type }: Props) => {
|
||||
const { mutateAsync, isLoading } =
|
||||
type === "application"
|
||||
? api.application.killBuild.useMutation()
|
||||
: api.compose.killBuild.useMutation();
|
||||
|
||||
return (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="outline" className="w-fit" isLoading={isLoading}>
|
||||
Kill Build
|
||||
<Scissors className="size-4" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Are you sure to kill the build?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will kill the build process
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={async () => {
|
||||
await mutateAsync({
|
||||
applicationId: id || "",
|
||||
composeId: id || "",
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Build killed successfully");
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(err.message);
|
||||
});
|
||||
}}
|
||||
>
|
||||
Confirm
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
};
|
||||
@@ -1,8 +1,6 @@
|
||||
import copy from "copy-to-clipboard";
|
||||
import { Check, Copy, Loader2 } from "lucide-react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -31,10 +29,9 @@ export const ShowDeployment = ({
|
||||
const [data, setData] = useState("");
|
||||
const [showExtraLogs, setShowExtraLogs] = useState(false);
|
||||
const [filteredLogs, setFilteredLogs] = useState<LogLine[]>([]);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const wsRef = useRef<WebSocket | null>(null); // Ref to hold WebSocket instance
|
||||
const [autoScroll, setAutoScroll] = useState(true);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const scrollToBottom = () => {
|
||||
if (autoScroll && scrollRef.current) {
|
||||
@@ -109,20 +106,6 @@ export const ShowDeployment = ({
|
||||
}
|
||||
}, [filteredLogs, autoScroll]);
|
||||
|
||||
const handleCopy = () => {
|
||||
const logContent = filteredLogs
|
||||
.map(({ timestamp, message }: LogLine) =>
|
||||
`${timestamp?.toISOString() || ""} ${message}`.trim(),
|
||||
)
|
||||
.join("\n");
|
||||
|
||||
const success = copy(logContent);
|
||||
if (success) {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
const optionalErrors = parseLogs(errorMessage || "");
|
||||
|
||||
return (
|
||||
@@ -145,27 +128,13 @@ export const ShowDeployment = ({
|
||||
<DialogHeader>
|
||||
<DialogTitle>Deployment</DialogTitle>
|
||||
<DialogDescription className="flex items-center gap-2">
|
||||
<span className="flex items-center gap-2">
|
||||
<span>
|
||||
See all the details of this deployment |{" "}
|
||||
<Badge variant="blank" className="text-xs">
|
||||
{filteredLogs.length} lines
|
||||
</Badge>
|
||||
</span>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7"
|
||||
onClick={handleCopy}
|
||||
disabled={filteredLogs.length === 0}
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{serverId && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
|
||||
@@ -1,15 +1,6 @@
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Clock,
|
||||
Loader2,
|
||||
RefreshCcw,
|
||||
RocketIcon,
|
||||
Settings,
|
||||
} from "lucide-react";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { Clock, Loader2, RefreshCcw, RocketIcon, Settings } from "lucide-react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { DateTooltip } from "@/components/shared/date-tooltip";
|
||||
import { DialogAction } from "@/components/shared/dialog-action";
|
||||
import { StatusTooltip } from "@/components/shared/status-tooltip";
|
||||
@@ -25,7 +16,6 @@ import {
|
||||
import { api, type RouterOutputs } from "@/utils/api";
|
||||
import { ShowRollbackSettings } from "../rollbacks/show-rollback-settings";
|
||||
import { CancelQueues } from "./cancel-queues";
|
||||
import { KillBuild } from "./kill-build";
|
||||
import { RefreshToken } from "./refresh-token";
|
||||
import { ShowDeployment } from "./show-deployment";
|
||||
|
||||
@@ -71,65 +61,12 @@ export const ShowDeployments = ({
|
||||
},
|
||||
);
|
||||
|
||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||
|
||||
const { mutateAsync: rollback, isLoading: isRollingBack } =
|
||||
api.rollback.rollback.useMutation();
|
||||
const { mutateAsync: killProcess, isLoading: isKillingProcess } =
|
||||
api.deployment.killProcess.useMutation();
|
||||
|
||||
// Cancel deployment mutations
|
||||
const {
|
||||
mutateAsync: cancelApplicationDeployment,
|
||||
isLoading: isCancellingApp,
|
||||
} = api.application.cancelDeployment.useMutation();
|
||||
const {
|
||||
mutateAsync: cancelComposeDeployment,
|
||||
isLoading: isCancellingCompose,
|
||||
} = api.compose.cancelDeployment.useMutation();
|
||||
|
||||
const [url, setUrl] = React.useState("");
|
||||
const [expandedDescriptions, setExpandedDescriptions] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
|
||||
const MAX_DESCRIPTION_LENGTH = 200;
|
||||
|
||||
const truncateDescription = (description: string): string => {
|
||||
if (description.length <= MAX_DESCRIPTION_LENGTH) {
|
||||
return description;
|
||||
}
|
||||
const truncated = description.slice(0, MAX_DESCRIPTION_LENGTH);
|
||||
const lastSpace = truncated.lastIndexOf(" ");
|
||||
if (lastSpace > MAX_DESCRIPTION_LENGTH - 20 && lastSpace > 0) {
|
||||
return `${truncated.slice(0, lastSpace)}...`;
|
||||
}
|
||||
return `${truncated}...`;
|
||||
};
|
||||
|
||||
// Check for stuck deployment (more than 9 minutes) - only for the most recent deployment
|
||||
const stuckDeployment = useMemo(() => {
|
||||
if (!isCloud || !deployments || deployments.length === 0) return null;
|
||||
|
||||
const now = Date.now();
|
||||
const NINE_MINUTES = 10 * 60 * 1000; // 9 minutes in milliseconds
|
||||
|
||||
// Get the most recent deployment (first in the list since they're sorted by date)
|
||||
const mostRecentDeployment = deployments[0];
|
||||
|
||||
if (
|
||||
!mostRecentDeployment ||
|
||||
mostRecentDeployment.status !== "running" ||
|
||||
!mostRecentDeployment.startedAt
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const startTime = new Date(mostRecentDeployment.startedAt).getTime();
|
||||
const elapsed = now - startTime;
|
||||
|
||||
return elapsed > NINE_MINUTES ? mostRecentDeployment : null;
|
||||
}, [isCloud, deployments]);
|
||||
useEffect(() => {
|
||||
setUrl(document.location.origin);
|
||||
}, []);
|
||||
@@ -140,13 +77,10 @@ export const ShowDeployments = ({
|
||||
<div className="flex flex-col gap-2">
|
||||
<CardTitle className="text-xl">Deployments</CardTitle>
|
||||
<CardDescription>
|
||||
See the last 10 deployments for this {type}
|
||||
See all the 10 last deployments for this {type}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex flex-row items-center flex-wrap gap-2">
|
||||
{(type === "application" || type === "compose") && (
|
||||
<KillBuild id={id} type={type} />
|
||||
)}
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
{(type === "application" || type === "compose") && (
|
||||
<CancelQueues id={id} type={type} />
|
||||
)}
|
||||
@@ -160,54 +94,6 @@ export const ShowDeployments = ({
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
{stuckDeployment && (type === "application" || type === "compose") && (
|
||||
<AlertBlock
|
||||
type="warning"
|
||||
className="flex-col items-start w-full p-4"
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div>
|
||||
<div className="font-medium text-sm mb-1">
|
||||
Build appears to be stuck
|
||||
</div>
|
||||
<p className="text-sm">
|
||||
Hey! Looks like the build has been running for more than 10
|
||||
minutes. Would you like to cancel this deployment?
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="w-fit"
|
||||
isLoading={
|
||||
type === "application" ? isCancellingApp : isCancellingCompose
|
||||
}
|
||||
onClick={async () => {
|
||||
try {
|
||||
if (type === "application") {
|
||||
await cancelApplicationDeployment({
|
||||
applicationId: id,
|
||||
});
|
||||
} else if (type === "compose") {
|
||||
await cancelComposeDeployment({
|
||||
composeId: id,
|
||||
});
|
||||
}
|
||||
toast.success("Deployment cancellation requested");
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to cancel deployment",
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Cancel Deployment
|
||||
</Button>
|
||||
</div>
|
||||
</AlertBlock>
|
||||
)}
|
||||
{refreshToken && (
|
||||
<div className="flex flex-col gap-2 text-sm">
|
||||
<span>
|
||||
@@ -218,9 +104,7 @@ export const ShowDeployments = ({
|
||||
<span>Webhook URL: </span>
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<span className="break-all text-muted-foreground">
|
||||
{`${url}/api/deploy${
|
||||
type === "compose" ? "/compose" : ""
|
||||
}/${refreshToken}`}
|
||||
{`${url}/api/deploy${type === "compose" ? "/compose" : ""}/${refreshToken}`}
|
||||
</span>
|
||||
{(type === "application" || type === "compose") && (
|
||||
<RefreshToken id={id} type={type} />
|
||||
@@ -246,180 +130,122 @@ export const ShowDeployments = ({
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
{deployments?.map((deployment, index) => {
|
||||
const titleText = deployment?.title?.trim() || "";
|
||||
const needsTruncation = titleText.length > MAX_DESCRIPTION_LENGTH;
|
||||
const isExpanded = expandedDescriptions.has(
|
||||
deployment.deploymentId,
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={deployment.deploymentId}
|
||||
className="flex items-center justify-between rounded-lg border p-4 gap-2"
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<span className="flex items-center gap-4 font-medium capitalize text-foreground">
|
||||
{index + 1}. {deployment.status}
|
||||
<StatusTooltip
|
||||
status={deployment?.status}
|
||||
className="size-2.5"
|
||||
/>
|
||||
{deployments?.map((deployment, index) => (
|
||||
<div
|
||||
key={deployment.deploymentId}
|
||||
className="flex items-center justify-between rounded-lg border p-4 gap-2"
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<span className="flex items-center gap-4 font-medium capitalize text-foreground">
|
||||
{index + 1}. {deployment.status}
|
||||
<StatusTooltip
|
||||
status={deployment?.status}
|
||||
className="size-2.5"
|
||||
/>
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{deployment.title}
|
||||
</span>
|
||||
{deployment.description && (
|
||||
<span className="break-all text-sm text-muted-foreground">
|
||||
{deployment.description}
|
||||
</span>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="break-words text-sm text-muted-foreground whitespace-pre-wrap">
|
||||
{isExpanded || !needsTruncation
|
||||
? titleText
|
||||
: truncateDescription(titleText)}
|
||||
</span>
|
||||
{needsTruncation && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const next = new Set(expandedDescriptions);
|
||||
if (next.has(deployment.deploymentId)) {
|
||||
next.delete(deployment.deploymentId);
|
||||
} else {
|
||||
next.add(deployment.deploymentId);
|
||||
}
|
||||
setExpandedDescriptions(next);
|
||||
}}
|
||||
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors w-fit mt-1 cursor-pointer"
|
||||
aria-label={
|
||||
isExpanded
|
||||
? "Collapse commit message"
|
||||
: "Expand commit message"
|
||||
}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<>
|
||||
<ChevronUp className="size-3" />
|
||||
Show less
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronDown className="size-3" />
|
||||
Show more
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{/* Hash (from description) - shown in compact form */}
|
||||
{deployment.description?.trim() && (
|
||||
<span className="text-xs text-muted-foreground font-mono">
|
||||
{deployment.description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
<div className="text-sm capitalize text-muted-foreground flex items-center gap-2">
|
||||
<DateTooltip date={deployment.createdAt} />
|
||||
{deployment.startedAt && deployment.finishedAt && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] gap-1 flex items-center"
|
||||
>
|
||||
<Clock className="size-3" />
|
||||
{formatDuration(
|
||||
Math.floor(
|
||||
(new Date(deployment.finishedAt).getTime() -
|
||||
new Date(deployment.startedAt).getTime()) /
|
||||
1000,
|
||||
),
|
||||
)}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-2 max-w-[300px] w-full justify-start">
|
||||
<div className="text-sm capitalize text-muted-foreground flex items-center gap-2">
|
||||
<DateTooltip date={deployment.createdAt} />
|
||||
{deployment.startedAt && deployment.finishedAt && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] gap-1 flex items-center"
|
||||
>
|
||||
<Clock className="size-3" />
|
||||
{formatDuration(
|
||||
Math.floor(
|
||||
(new Date(deployment.finishedAt).getTime() -
|
||||
new Date(deployment.startedAt).getTime()) /
|
||||
1000,
|
||||
),
|
||||
)}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
{deployment.pid && deployment.status === "running" && (
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
{deployment.pid && deployment.status === "running" && (
|
||||
<DialogAction
|
||||
title="Kill Process"
|
||||
description="Are you sure you want to kill the process?"
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
await killProcess({
|
||||
deploymentId: deployment.deploymentId,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Process killed successfully");
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error killing process");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
isLoading={isKillingProcess}
|
||||
>
|
||||
Kill Process
|
||||
</Button>
|
||||
</DialogAction>
|
||||
)}
|
||||
<Button
|
||||
onClick={() => {
|
||||
setActiveLog(deployment);
|
||||
}}
|
||||
>
|
||||
View
|
||||
</Button>
|
||||
|
||||
{deployment?.rollback &&
|
||||
deployment.status === "done" &&
|
||||
type === "application" && (
|
||||
<DialogAction
|
||||
title="Kill Process"
|
||||
description="Are you sure you want to kill the process?"
|
||||
title="Rollback to this deployment"
|
||||
description="Are you sure you want to rollback to this deployment?"
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
await killProcess({
|
||||
deploymentId: deployment.deploymentId,
|
||||
await rollback({
|
||||
rollbackId: deployment.rollback.rollbackId,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Process killed successfully");
|
||||
toast.success(
|
||||
"Rollback initiated successfully",
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error killing process");
|
||||
toast.error("Error initiating rollback");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="destructive"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
isLoading={isKillingProcess}
|
||||
isLoading={isRollingBack}
|
||||
>
|
||||
Kill Process
|
||||
<RefreshCcw className="size-4 text-primary group-hover:text-red-500" />
|
||||
Rollback
|
||||
</Button>
|
||||
</DialogAction>
|
||||
)}
|
||||
<Button
|
||||
onClick={() => {
|
||||
setActiveLog(deployment);
|
||||
}}
|
||||
>
|
||||
View
|
||||
</Button>
|
||||
|
||||
{deployment?.rollback &&
|
||||
deployment.status === "done" &&
|
||||
type === "application" && (
|
||||
<DialogAction
|
||||
title="Rollback to this deployment"
|
||||
description={
|
||||
<div className="flex flex-col gap-3">
|
||||
<p>
|
||||
Are you sure you want to rollback to this
|
||||
deployment?
|
||||
</p>
|
||||
<AlertBlock type="info" className="text-sm">
|
||||
Please wait a few seconds while the image is
|
||||
pulled from the registry. Your application
|
||||
should be running shortly.
|
||||
</AlertBlock>
|
||||
</div>
|
||||
}
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
await rollback({
|
||||
rollbackId: deployment.rollback.rollbackId,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success(
|
||||
"Rollback initiated successfully",
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error initiating rollback");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
isLoading={isRollingBack}
|
||||
>
|
||||
<RefreshCcw className="size-4 text-primary group-hover:text-red-500" />
|
||||
Rollback
|
||||
</Button>
|
||||
</DialogAction>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<ShowDeployment
|
||||
serverId={activeLog?.buildServerId || serverId}
|
||||
serverId={serverId}
|
||||
open={Boolean(activeLog && activeLog.logPath !== null)}
|
||||
onClose={() => setActiveLog(null)}
|
||||
logPath={activeLog?.logPath || ""}
|
||||
|
||||
@@ -46,13 +46,7 @@ export type CacheType = "fetch" | "cache";
|
||||
|
||||
export const domain = z
|
||||
.object({
|
||||
host: z
|
||||
.string()
|
||||
.min(1, { message: "Add a hostname" })
|
||||
.refine((val) => val === val.trim(), {
|
||||
message: "Domain name cannot have leading or trailing spaces",
|
||||
})
|
||||
.transform((val) => val.trim()),
|
||||
host: z.string().min(1, { message: "Add a hostname" }),
|
||||
path: z.string().min(1).optional(),
|
||||
internalPath: z.string().optional(),
|
||||
stripPath: z.boolean().optional(),
|
||||
@@ -305,13 +299,6 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
||||
</DialogHeader>
|
||||
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
|
||||
|
||||
{type === "compose" && (
|
||||
<AlertBlock type="info" className="mb-4">
|
||||
Whenever you make changes to domains, remember to redeploy your
|
||||
compose to apply the changes.
|
||||
</AlertBlock>
|
||||
)}
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
id="hook-form"
|
||||
|
||||
@@ -108,21 +108,6 @@ export const ShowEnvironment = ({ id, type }: Props) => {
|
||||
});
|
||||
};
|
||||
|
||||
// Add keyboard shortcut for Ctrl+S/Cmd+S
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isLoading) {
|
||||
e.preventDefault();
|
||||
form.handleSubmit(onSubmit)();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [form, onSubmit, isLoading]);
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-5 ">
|
||||
<Card className="bg-background">
|
||||
|
||||
@@ -12,7 +12,6 @@ import { api } from "@/utils/api";
|
||||
const addEnvironmentSchema = z.object({
|
||||
env: z.string(),
|
||||
buildArgs: z.string(),
|
||||
buildSecrets: z.string(),
|
||||
});
|
||||
|
||||
type EnvironmentSchema = z.infer<typeof addEnvironmentSchema>;
|
||||
@@ -38,7 +37,6 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
|
||||
defaultValues: {
|
||||
env: "",
|
||||
buildArgs: "",
|
||||
buildSecrets: "",
|
||||
},
|
||||
resolver: zodResolver(addEnvironmentSchema),
|
||||
});
|
||||
@@ -46,18 +44,15 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
|
||||
// Watch form values
|
||||
const currentEnv = form.watch("env");
|
||||
const currentBuildArgs = form.watch("buildArgs");
|
||||
const currentBuildSecrets = form.watch("buildSecrets");
|
||||
const hasChanges =
|
||||
currentEnv !== (data?.env || "") ||
|
||||
currentBuildArgs !== (data?.buildArgs || "") ||
|
||||
currentBuildSecrets !== (data?.buildSecrets || "");
|
||||
currentBuildArgs !== (data?.buildArgs || "");
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
form.reset({
|
||||
env: data.env || "",
|
||||
buildArgs: data.buildArgs || "",
|
||||
buildSecrets: data.buildSecrets || "",
|
||||
});
|
||||
}
|
||||
}, [data, form]);
|
||||
@@ -66,7 +61,6 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
|
||||
mutateAsync({
|
||||
env: formData.env,
|
||||
buildArgs: formData.buildArgs,
|
||||
buildSecrets: formData.buildSecrets,
|
||||
applicationId,
|
||||
})
|
||||
.then(async () => {
|
||||
@@ -82,25 +76,9 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
|
||||
form.reset({
|
||||
env: data?.env || "",
|
||||
buildArgs: data?.buildArgs || "",
|
||||
buildSecrets: data?.buildSecrets || "",
|
||||
});
|
||||
};
|
||||
|
||||
// Add keyboard shortcut for Ctrl+S/Cmd+S
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isLoading) {
|
||||
e.preventDefault();
|
||||
form.handleSubmit(onSubmit)();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [form, onSubmit, isLoading]);
|
||||
|
||||
return (
|
||||
<Card className="bg-background px-6 pb-6">
|
||||
<Form {...form}>
|
||||
@@ -126,36 +104,13 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
|
||||
{data?.buildType === "dockerfile" && (
|
||||
<Secrets
|
||||
name="buildArgs"
|
||||
title="Build-time Arguments"
|
||||
title="Build-time Variables"
|
||||
description={
|
||||
<span>
|
||||
Arguments are available only at build-time. See
|
||||
documentation
|
||||
Available only at build-time. See documentation
|
||||
<a
|
||||
className="text-primary"
|
||||
href="https://docs.docker.com/build/building/variables/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
here
|
||||
</a>
|
||||
.
|
||||
</span>
|
||||
}
|
||||
placeholder="NPM_TOKEN=xyz"
|
||||
/>
|
||||
)}
|
||||
{data?.buildType === "dockerfile" && (
|
||||
<Secrets
|
||||
name="buildSecrets"
|
||||
title="Build-time Secrets"
|
||||
description={
|
||||
<span>
|
||||
Secrets are specially designed for sensitive information and
|
||||
are only available at build-time. See documentation
|
||||
<a
|
||||
className="text-primary"
|
||||
href="https://docs.docker.com/build/building/secrets/"
|
||||
href="https://docs.docker.com/build/guide/build-args/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
|
||||
@@ -150,7 +150,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
||||
enableSubmodules: data.enableSubmodules || false,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Service Provider Saved");
|
||||
toast.success("Service Provided Saved");
|
||||
await refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
|
||||
@@ -59,7 +59,7 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
|
||||
const router = useRouter();
|
||||
|
||||
const { mutateAsync, isLoading } =
|
||||
api.application.saveGitProvider.useMutation();
|
||||
api.application.saveGitProdiver.useMutation();
|
||||
|
||||
const form = useForm<GitProvider>({
|
||||
defaultValues: {
|
||||
|
||||
@@ -149,7 +149,7 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
|
||||
enableSubmodules: data.enableSubmodules,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Service Provider Saved");
|
||||
toast.success("Service Provided Saved");
|
||||
await refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
|
||||
@@ -167,7 +167,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
|
||||
enableSubmodules: data.enableSubmodules,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Service Provider Saved");
|
||||
toast.success("Service Provided Saved");
|
||||
await refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
|
||||
@@ -69,7 +69,7 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
|
||||
toast.success("Application deployed successfully");
|
||||
refetch();
|
||||
router.push(
|
||||
`/dashboard/project/${data?.environment.projectId}/environment/${data?.environmentId}/services/application/${applicationId}?tab=deployments`,
|
||||
`/dashboard/project/${data?.projectId}/services/application/${applicationId}?tab=deployments`,
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
@@ -79,7 +79,7 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
|
||||
>
|
||||
<Button
|
||||
variant="default"
|
||||
isLoading={data?.applicationStatus === "running"}
|
||||
// isLoading={data?.applicationStatus === "running"}
|
||||
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
<Tooltip>
|
||||
|
||||
@@ -182,16 +182,7 @@ export const ShowPreviewDeployments = ({ applicationId }: Props) => {
|
||||
id={deployment.previewDeploymentId}
|
||||
type="previewDeployment"
|
||||
serverId={data?.serverId || ""}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
>
|
||||
<RocketIcon className="size-4" />
|
||||
Deployments
|
||||
</Button>
|
||||
</ShowDeploymentsModal>
|
||||
/>
|
||||
|
||||
<AddPreviewDomain
|
||||
previewDeploymentId={`${deployment.previewDeploymentId}`}
|
||||
|
||||
@@ -46,7 +46,6 @@ const schema = z
|
||||
.object({
|
||||
env: z.string(),
|
||||
buildArgs: z.string(),
|
||||
buildSecrets: z.string(),
|
||||
wildcardDomain: z.string(),
|
||||
port: z.number(),
|
||||
previewLimit: z.number(),
|
||||
@@ -110,7 +109,6 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
|
||||
form.reset({
|
||||
env: data.previewEnv || "",
|
||||
buildArgs: data.previewBuildArgs || "",
|
||||
buildSecrets: data.previewBuildSecrets || "",
|
||||
wildcardDomain: data.previewWildcard || "*.traefik.me",
|
||||
port: data.previewPort || 3000,
|
||||
previewLabels: data.previewLabels || [],
|
||||
@@ -129,7 +127,6 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
|
||||
updateApplication({
|
||||
previewEnv: formData.env,
|
||||
previewBuildArgs: formData.buildArgs,
|
||||
previewBuildSecrets: formData.buildSecrets,
|
||||
previewWildcard: formData.wildcardDomain,
|
||||
previewPort: formData.port,
|
||||
previewLabels: formData.previewLabels,
|
||||
@@ -470,37 +467,13 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
|
||||
{data?.buildType === "dockerfile" && (
|
||||
<Secrets
|
||||
name="buildArgs"
|
||||
title="Build-time Arguments"
|
||||
title="Build-time Variables"
|
||||
description={
|
||||
<span>
|
||||
Arguments are available only at build-time. See
|
||||
documentation
|
||||
Available only at build-time. See documentation
|
||||
<a
|
||||
className="text-primary"
|
||||
href="https://docs.docker.com/build/building/variables/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
here
|
||||
</a>
|
||||
.
|
||||
</span>
|
||||
}
|
||||
placeholder="NPM_TOKEN=xyz"
|
||||
/>
|
||||
)}
|
||||
{data?.buildType === "dockerfile" && (
|
||||
<Secrets
|
||||
name="buildSecrets"
|
||||
title="Build-time Secrets"
|
||||
description={
|
||||
<span>
|
||||
Secrets are specially designed for sensitive information
|
||||
and are only available at build-time. See
|
||||
documentation
|
||||
<a
|
||||
className="text-primary"
|
||||
href="https://docs.docker.com/build/building/secrets/"
|
||||
href="https://docs.docker.com/build/guide/build-args/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
@@ -21,37 +20,13 @@ import {
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
const formSchema = z
|
||||
.object({
|
||||
rollbackActive: z.boolean(),
|
||||
rollbackRegistryId: z.string().optional(),
|
||||
})
|
||||
.superRefine((values, ctx) => {
|
||||
if (
|
||||
values.rollbackActive &&
|
||||
(!values.rollbackRegistryId || values.rollbackRegistryId === "none")
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["rollbackRegistryId"],
|
||||
message: "Registry is required when rollbacks are enabled",
|
||||
});
|
||||
}
|
||||
});
|
||||
const formSchema = z.object({
|
||||
rollbackActive: z.boolean(),
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
@@ -74,33 +49,17 @@ export const ShowRollbackSettings = ({ applicationId, children }: Props) => {
|
||||
const { mutateAsync: updateApplication, isLoading } =
|
||||
api.application.update.useMutation();
|
||||
|
||||
const { data: registries } = api.registry.all.useQuery();
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
rollbackActive: application?.rollbackActive ?? false,
|
||||
rollbackRegistryId: application?.rollbackRegistryId || "",
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (application) {
|
||||
form.reset({
|
||||
rollbackActive: application.rollbackActive ?? false,
|
||||
rollbackRegistryId: application.rollbackRegistryId || "",
|
||||
});
|
||||
}
|
||||
}, [application, form]);
|
||||
|
||||
const onSubmit = async (data: FormValues) => {
|
||||
await updateApplication({
|
||||
applicationId,
|
||||
rollbackActive: data.rollbackActive,
|
||||
rollbackRegistryId:
|
||||
data.rollbackRegistryId === "none" || !data.rollbackRegistryId
|
||||
? null
|
||||
: data.rollbackRegistryId,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Rollback settings updated");
|
||||
@@ -153,65 +112,6 @@ export const ShowRollbackSettings = ({ applicationId, children }: Props) => {
|
||||
)}
|
||||
/>
|
||||
|
||||
{form.watch("rollbackActive") && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="rollbackRegistryId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Rollback Registry</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
value={field.value || "none"}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a registry" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="none">
|
||||
<span className="flex items-center gap-2">
|
||||
<span>None</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
{registries?.map((registry) => (
|
||||
<SelectItem
|
||||
key={registry.registryId}
|
||||
value={registry.registryId}
|
||||
>
|
||||
{registry.registryName}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectLabel>
|
||||
Registries ({registries?.length || 0})
|
||||
</SelectLabel>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{!registries || registries.length === 0 ? (
|
||||
<FormDescription className="text-amber-600 dark:text-amber-500">
|
||||
No registries available. Please{" "}
|
||||
<Link
|
||||
href="/dashboard/settings/registry"
|
||||
className="underline font-medium hover:text-amber-700 dark:hover:text-amber-400"
|
||||
>
|
||||
configure a registry
|
||||
</Link>{" "}
|
||||
first to enable rollbacks.
|
||||
</FormDescription>
|
||||
) : (
|
||||
<FormDescription>
|
||||
Select a registry where rollback images will be stored.
|
||||
</FormDescription>
|
||||
)}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Button type="submit" className="w-full" isLoading={isLoading}>
|
||||
Save Settings
|
||||
</Button>
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
RefreshCw,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { type Control, useForm } from "react-hook-form";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
@@ -57,31 +57,6 @@ export const commonCronExpressions = [
|
||||
{ label: "Every month on the 1st at midnight", value: "0 0 1 * *" },
|
||||
{ label: "Every 15 minutes", value: "*/15 * * * *" },
|
||||
{ label: "Every weekday at midnight", value: "0 0 * * 1-5" },
|
||||
{ label: "Custom", value: "custom" },
|
||||
];
|
||||
|
||||
export const commonTimezones = [
|
||||
{ label: "UTC (Coordinated Universal Time)", value: "UTC" },
|
||||
{ label: "America/New_York (Eastern Time)", value: "America/New_York" },
|
||||
{ label: "America/Chicago (Central Time)", value: "America/Chicago" },
|
||||
{ label: "America/Denver (Mountain Time)", value: "America/Denver" },
|
||||
{ label: "America/Los_Angeles (Pacific Time)", value: "America/Los_Angeles" },
|
||||
{
|
||||
label: "America/Mexico_City (Central Mexico)",
|
||||
value: "America/Mexico_City",
|
||||
},
|
||||
{ label: "America/Sao_Paulo (Brasilia Time)", value: "America/Sao_Paulo" },
|
||||
{ label: "Europe/London (Greenwich Mean Time)", value: "Europe/London" },
|
||||
{ label: "Europe/Paris (Central European Time)", value: "Europe/Paris" },
|
||||
{ label: "Europe/Berlin (Central European Time)", value: "Europe/Berlin" },
|
||||
{ label: "Asia/Tokyo (Japan Standard Time)", value: "Asia/Tokyo" },
|
||||
{ label: "Asia/Shanghai (China Standard Time)", value: "Asia/Shanghai" },
|
||||
{ label: "Asia/Dubai (Gulf Standard Time)", value: "Asia/Dubai" },
|
||||
{ label: "Asia/Kolkata (India Standard Time)", value: "Asia/Kolkata" },
|
||||
{
|
||||
label: "Australia/Sydney (Australian Eastern Time)",
|
||||
value: "Australia/Sydney",
|
||||
},
|
||||
];
|
||||
|
||||
const formSchema = z
|
||||
@@ -99,7 +74,6 @@ const formSchema = z
|
||||
"dokploy-server",
|
||||
]),
|
||||
script: z.string(),
|
||||
timezone: z.string().optional(),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (data.scheduleType === "compose" && !data.serviceName) {
|
||||
@@ -141,91 +115,10 @@ interface Props {
|
||||
scheduleType?: "application" | "compose" | "server" | "dokploy-server";
|
||||
}
|
||||
|
||||
export const ScheduleFormField = ({
|
||||
name,
|
||||
formControl,
|
||||
}: {
|
||||
name: string;
|
||||
formControl: Control<any>;
|
||||
}) => {
|
||||
const [selectedOption, setSelectedOption] = useState("");
|
||||
|
||||
return (
|
||||
<FormField
|
||||
control={formControl}
|
||||
name={name}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="flex items-center gap-2">
|
||||
Schedule
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Info className="w-4 h-4 text-muted-foreground cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Cron expression format: minute hour day month weekday</p>
|
||||
<p>Example: 0 0 * * * (daily at midnight)</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</FormLabel>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Select
|
||||
value={selectedOption}
|
||||
onValueChange={(value) => {
|
||||
setSelectedOption(value);
|
||||
field.onChange(value === "custom" ? "" : value);
|
||||
}}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a predefined schedule" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{commonCronExpressions.map((expr) => (
|
||||
<SelectItem key={expr.value} value={expr.value}>
|
||||
{expr.label}
|
||||
{expr.value !== "custom" && ` (${expr.value})`}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="relative">
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Custom cron expression (e.g., 0 0 * * *)"
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
const commonExpression = commonCronExpressions.find(
|
||||
(expression) => expression.value === value,
|
||||
);
|
||||
if (commonExpression) {
|
||||
setSelectedOption(commonExpression.value);
|
||||
} else {
|
||||
setSelectedOption("custom");
|
||||
}
|
||||
field.onChange(e);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
</div>
|
||||
</div>
|
||||
<FormDescription>
|
||||
Choose a predefined schedule or enter a custom cron expression
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [cacheType, setCacheType] = useState<CacheType>("cache");
|
||||
|
||||
const utils = api.useUtils();
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
@@ -238,7 +131,6 @@ export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => {
|
||||
serviceName: "",
|
||||
scheduleType: scheduleType || "application",
|
||||
script: "",
|
||||
timezone: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -277,7 +169,6 @@ export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => {
|
||||
serviceName: schedule.serviceName || "",
|
||||
scheduleType: schedule.scheduleType,
|
||||
script: schedule.script || "",
|
||||
timezone: schedule.timezone || undefined,
|
||||
});
|
||||
}
|
||||
}, [form, schedule, scheduleId]);
|
||||
@@ -486,18 +377,13 @@ export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => {
|
||||
)}
|
||||
/>
|
||||
|
||||
<ScheduleFormField
|
||||
name="cronExpression"
|
||||
formControl={form.control}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="timezone"
|
||||
name="cronExpression"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="flex items-center gap-2">
|
||||
Timezone
|
||||
Schedule
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -505,34 +391,45 @@ export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => {
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
Select a timezone for the schedule. If not
|
||||
specified, UTC will be used.
|
||||
Cron expression format: minute hour day month
|
||||
weekday
|
||||
</p>
|
||||
<p>Example: 0 0 * * * (daily at midnight)</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</FormLabel>
|
||||
<Select
|
||||
onValueChange={(value) => {
|
||||
field.onChange(value);
|
||||
}}
|
||||
value={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="UTC (default)" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{commonTimezones.map((tz) => (
|
||||
<SelectItem key={tz.value} value={tz.value}>
|
||||
{tz.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Select
|
||||
onValueChange={(value) => {
|
||||
field.onChange(value);
|
||||
}}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a predefined schedule" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{commonCronExpressions.map((expr) => (
|
||||
<SelectItem key={expr.value} value={expr.value}>
|
||||
{expr.label} ({expr.value})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="relative">
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Custom cron expression (e.g., 0 0 * * *)"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
</div>
|
||||
</div>
|
||||
<FormDescription>
|
||||
Optional: Choose a timezone for the schedule execution time
|
||||
Choose a predefined schedule or enter a custom cron
|
||||
expression
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
Terminal,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { DialogAction } from "@/components/shared/dialog-action";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -34,9 +33,6 @@ interface Props {
|
||||
}
|
||||
|
||||
export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => {
|
||||
const [runningSchedules, setRunningSchedules] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
const {
|
||||
data: schedules,
|
||||
isLoading: isLoadingSchedules,
|
||||
@@ -50,32 +46,19 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => {
|
||||
enabled: !!id,
|
||||
},
|
||||
);
|
||||
|
||||
const utils = api.useUtils();
|
||||
|
||||
const { mutateAsync: deleteSchedule, isLoading: isDeleting } =
|
||||
api.schedule.delete.useMutation();
|
||||
const { mutateAsync: runManually } = api.schedule.runManually.useMutation();
|
||||
|
||||
const handleRunManually = async (scheduleId: string) => {
|
||||
setRunningSchedules((prev) => new Set(prev).add(scheduleId));
|
||||
try {
|
||||
await runManually({ scheduleId });
|
||||
toast.success("Schedule run successfully");
|
||||
await refetchSchedules();
|
||||
} catch {
|
||||
toast.error("Error running schedule");
|
||||
} finally {
|
||||
setRunningSchedules((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(scheduleId);
|
||||
return newSet;
|
||||
});
|
||||
}
|
||||
};
|
||||
const { mutateAsync: runManually, isLoading } =
|
||||
api.schedule.runManually.useMutation();
|
||||
|
||||
return (
|
||||
<Card className="border px-6 shadow-none bg-transparent h-full min-h-[50vh]">
|
||||
<CardHeader className="px-0">
|
||||
<div className="flex justify-between items-center gap-y-2 flex-wrap">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex flex-col gap-2">
|
||||
<CardTitle className="text-xl font-bold flex items-center gap-2">
|
||||
Scheduled Tasks
|
||||
@@ -84,6 +67,7 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => {
|
||||
Schedule tasks to run automatically at specified intervals.
|
||||
</CardDescription>
|
||||
</div>
|
||||
|
||||
{schedules && schedules.length > 0 && (
|
||||
<HandleSchedules id={id} scheduleType={scheduleType} />
|
||||
)}
|
||||
@@ -91,7 +75,7 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => {
|
||||
</CardHeader>
|
||||
<CardContent className="px-0">
|
||||
{isLoadingSchedules ? (
|
||||
<div className="flex gap-4 w-full items-center justify-center text-center mx-auto min-h-[45vh]">
|
||||
<div className="flex gap-4 w-full items-center justify-center text-center mx-auto min-h-[45vh]">
|
||||
<Loader2 className="size-4 text-muted-foreground/70 transition-colors animate-spin self-center" />
|
||||
<span className="text-sm text-muted-foreground/70">
|
||||
Loading scheduled tasks...
|
||||
@@ -107,15 +91,15 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => {
|
||||
return (
|
||||
<div
|
||||
key={schedule.scheduleId}
|
||||
className="flex flex-col sm:flex-row sm:items-center flex-wrap sm:flex-nowrap gap-y-2 justify-between rounded-lg border p-3 transition-colors bg-muted/50 w-full"
|
||||
className="flex items-center justify-between rounded-lg border p-3 transition-colors bg-muted/50"
|
||||
>
|
||||
<div className="flex items-start gap-3 w-full sm:w-auto">
|
||||
<div className="flex flex-shrink-0 h-9 w-9 items-center justify-center rounded-full bg-primary/5">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-primary/5">
|
||||
<Clock className="size-4 text-primary/70" />
|
||||
</div>
|
||||
<div className="space-y-1.5 w-full sm:w-auto">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h3 className="text-sm font-medium leading-none [overflow-wrap:anywhere] line-clamp-3">
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-sm font-medium leading-none">
|
||||
{schedule.name}
|
||||
</h3>
|
||||
<Badge
|
||||
@@ -125,7 +109,7 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => {
|
||||
{schedule.enabled ? "Enabled" : "Disabled"}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground flex-wrap">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="font-mono text-[10px] bg-transparent"
|
||||
@@ -148,25 +132,27 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => {
|
||||
)}
|
||||
</div>
|
||||
{schedule.command && (
|
||||
<div className="flex items-start gap-2 max-w-full">
|
||||
<Terminal className="size-3.5 text-muted-foreground/70 flex-shrink-0 mt-0.5" />
|
||||
<code className="font-mono text-[10px] text-muted-foreground/70 break-all max-w-[calc(100%-20px)]">
|
||||
<div className="flex items-center gap-2">
|
||||
<Terminal className="size-3.5 text-muted-foreground/70" />
|
||||
<code className="font-mono text-[10px] text-muted-foreground/70">
|
||||
{schedule.command}
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-0.5 md:gap-1.5">
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
<ShowDeploymentsModal
|
||||
id={schedule.scheduleId}
|
||||
type="schedule"
|
||||
serverId={serverId || undefined}
|
||||
>
|
||||
<Button variant="ghost" size="icon">
|
||||
<ClipboardList className="size-4 transition-colors" />
|
||||
<ClipboardList className="size-4 transition-colors " />
|
||||
</Button>
|
||||
</ShowDeploymentsModal>
|
||||
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -174,26 +160,37 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => {
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
disabled={runningSchedules.has(schedule.scheduleId)}
|
||||
onClick={() =>
|
||||
handleRunManually(schedule.scheduleId)
|
||||
}
|
||||
isLoading={isLoading}
|
||||
onClick={async () => {
|
||||
toast.success("Schedule run successfully");
|
||||
|
||||
await runManually({
|
||||
scheduleId: schedule.scheduleId,
|
||||
})
|
||||
.then(async () => {
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, 1500),
|
||||
);
|
||||
refetchSchedules();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error running schedule");
|
||||
});
|
||||
}}
|
||||
>
|
||||
{runningSchedules.has(schedule.scheduleId) ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Play className="size-4 transition-colors" />
|
||||
)}
|
||||
<Play className="size-4 transition-colors" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Run Manual Schedule</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<HandleSchedules
|
||||
scheduleId={schedule.scheduleId}
|
||||
id={id}
|
||||
scheduleType={scheduleType}
|
||||
/>
|
||||
|
||||
<DialogAction
|
||||
title="Delete Schedule"
|
||||
description="Are you sure you want to delete this schedule?"
|
||||
@@ -217,8 +214,8 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group hover:bg-red-500/10"
|
||||
disabled={isDeleting}
|
||||
className="group hover:bg-red-500/10 "
|
||||
isLoading={isDeleting}
|
||||
>
|
||||
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
||||
</Button>
|
||||
@@ -229,7 +226,7 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => {
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2 items-center justify-center py-12 rounded-lg">
|
||||
<div className="flex flex-col gap-2 items-center justify-center py-12 rounded-lg">
|
||||
<Clock className="size-8 mb-4 text-muted-foreground" />
|
||||
<p className="text-lg font-medium text-muted-foreground">
|
||||
No scheduled tasks
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { DatabaseZap, PenBoxIcon, PlusCircle, RefreshCw } from "lucide-react";
|
||||
import {
|
||||
DatabaseZap,
|
||||
Info,
|
||||
PenBoxIcon,
|
||||
PlusCircle,
|
||||
RefreshCw,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
@@ -41,19 +47,13 @@ import {
|
||||
import { cn } from "@/lib/utils";
|
||||
import { api } from "@/utils/api";
|
||||
import type { CacheType } from "../domains/handle-domain";
|
||||
import { ScheduleFormField } from "../schedules/handle-schedules";
|
||||
import { commonCronExpressions } from "../schedules/handle-schedules";
|
||||
|
||||
const formSchema = z
|
||||
.object({
|
||||
name: z.string().min(1, "Name is required"),
|
||||
cronExpression: z.string().min(1, "Cron expression is required"),
|
||||
volumeName: z
|
||||
.string()
|
||||
.min(1, "Volume name is required")
|
||||
.regex(
|
||||
/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/,
|
||||
"Invalid volume name. Use letters, numbers, '._-' and start with a letter/number.",
|
||||
),
|
||||
volumeName: z.string().min(1, "Volume name is required"),
|
||||
prefix: z.string(),
|
||||
keepLatestCount: z.coerce
|
||||
.number()
|
||||
@@ -306,9 +306,64 @@ export const HandleVolumeBackups = ({
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<ScheduleFormField
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="cronExpression"
|
||||
formControl={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="flex items-center gap-2">
|
||||
Schedule
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Info className="w-4 h-4 text-muted-foreground cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
Cron expression format: minute hour day month
|
||||
weekday
|
||||
</p>
|
||||
<p>Example: 0 0 * * * (daily at midnight)</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</FormLabel>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Select
|
||||
onValueChange={(value) => {
|
||||
field.onChange(value);
|
||||
}}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a predefined schedule" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{commonCronExpressions.map((expr) => (
|
||||
<SelectItem key={expr.value} value={expr.value}>
|
||||
{expr.label} ({expr.value})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="relative">
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Custom cron expression (e.g., 0 0 * * *)"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
</div>
|
||||
</div>
|
||||
<FormDescription>
|
||||
Choose a predefined schedule or enter a custom cron
|
||||
expression
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
Play,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { DialogAction } from "@/components/shared/dialog-action";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -39,7 +38,6 @@ export const ShowVolumeBackups = ({
|
||||
type = "application",
|
||||
serverId,
|
||||
}: Props) => {
|
||||
const [runningBackups, setRunningBackups] = useState<Set<string>>(new Set());
|
||||
const {
|
||||
data: volumeBackups,
|
||||
isLoading: isLoadingVolumeBackups,
|
||||
@@ -53,46 +51,34 @@ export const ShowVolumeBackups = ({
|
||||
enabled: !!id,
|
||||
},
|
||||
);
|
||||
|
||||
const utils = api.useUtils();
|
||||
|
||||
const { mutateAsync: deleteVolumeBackup, isLoading: isDeleting } =
|
||||
api.volumeBackups.delete.useMutation();
|
||||
const { mutateAsync: runManually } =
|
||||
api.volumeBackups.runManually.useMutation();
|
||||
|
||||
const handleRunManually = async (volumeBackupId: string) => {
|
||||
setRunningBackups((prev) => new Set(prev).add(volumeBackupId));
|
||||
try {
|
||||
await runManually({ volumeBackupId });
|
||||
toast.success("Volume backup run successfully");
|
||||
await refetchVolumeBackups();
|
||||
} catch {
|
||||
toast.error("Error running volume backup");
|
||||
} finally {
|
||||
setRunningBackups((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(volumeBackupId);
|
||||
return newSet;
|
||||
});
|
||||
}
|
||||
};
|
||||
const { mutateAsync: runManually, isLoading } =
|
||||
api.volumeBackups.runManually.useMutation();
|
||||
|
||||
return (
|
||||
<Card className="border px-6 shadow-none bg-transparent h-full min-h-[50vh]">
|
||||
<CardHeader className="px-0">
|
||||
<div className="flex justify-between items-center flex-wrap gap-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex flex-col gap-2">
|
||||
<CardTitle className="text-xl font-bold flex items-center gap-2">
|
||||
Volume Backups
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Schedule volume backups to run automatically at specified
|
||||
intervals
|
||||
intervals.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{volumeBackups && volumeBackups.length > 0 && (
|
||||
<>
|
||||
<HandleVolumeBackups id={id} volumeBackupType={type} />
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<RestoreVolumeBackups
|
||||
id={id}
|
||||
@@ -107,7 +93,7 @@ export const ShowVolumeBackups = ({
|
||||
</CardHeader>
|
||||
<CardContent className="px-0">
|
||||
{isLoadingVolumeBackups ? (
|
||||
<div className="flex gap-4 w-full items-center justify-center text-center mx-auto min-h-[45vh]">
|
||||
<div className="flex gap-4 w-full items-center justify-center text-center mx-auto min-h-[45vh]">
|
||||
<Loader2 className="size-4 text-muted-foreground/70 transition-colors animate-spin self-center" />
|
||||
<span className="text-sm text-muted-foreground/70">
|
||||
Loading volume backups...
|
||||
@@ -127,13 +113,13 @@ export const ShowVolumeBackups = ({
|
||||
return (
|
||||
<div
|
||||
key={volumeBackup.volumeBackupId}
|
||||
className="flex flex-col sm:flex-row sm:items-center flex-wrap sm:flex-nowrap gap-y-2 justify-between rounded-lg border p-3 transition-colors bg-muted/50 w-full"
|
||||
className="flex items-center justify-between rounded-lg border p-3 transition-colors bg-muted/50"
|
||||
>
|
||||
<div className="flex items-start gap-3 w-full sm:w-auto">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-primary/5">
|
||||
<DatabaseBackup className="size-4 text-primary/70" />
|
||||
</div>
|
||||
<div className="space-y-1.5 w-full sm:w-auto">
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-sm font-medium leading-none">
|
||||
{volumeBackup.name}
|
||||
@@ -157,16 +143,18 @@ export const ShowVolumeBackups = ({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 mt-2 sm:mt-0 sm:ml-3">
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
<ShowDeploymentsModal
|
||||
id={volumeBackup.volumeBackupId}
|
||||
type="volumeBackup"
|
||||
serverId={serverId || undefined}
|
||||
>
|
||||
<Button variant="ghost" size="icon">
|
||||
<ClipboardList className="size-4 transition-colors" />
|
||||
<ClipboardList className="size-4 transition-colors " />
|
||||
</Button>
|
||||
</ShowDeploymentsModal>
|
||||
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -174,18 +162,25 @@ export const ShowVolumeBackups = ({
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
disabled={runningBackups.has(
|
||||
volumeBackup.volumeBackupId,
|
||||
)}
|
||||
onClick={() =>
|
||||
handleRunManually(volumeBackup.volumeBackupId)
|
||||
}
|
||||
isLoading={isLoading}
|
||||
onClick={async () => {
|
||||
toast.success("Volume backup run successfully");
|
||||
|
||||
await runManually({
|
||||
volumeBackupId: volumeBackup.volumeBackupId,
|
||||
})
|
||||
.then(async () => {
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, 1500),
|
||||
);
|
||||
refetchVolumeBackups();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error running volume backup");
|
||||
});
|
||||
}}
|
||||
>
|
||||
{runningBackups.has(volumeBackup.volumeBackupId) ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Play className="size-4 transition-colors" />
|
||||
)}
|
||||
<Play className="size-4 transition-colors" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
@@ -193,11 +188,13 @@ export const ShowVolumeBackups = ({
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<HandleVolumeBackups
|
||||
volumeBackupId={volumeBackup.volumeBackupId}
|
||||
id={id}
|
||||
volumeBackupType={type}
|
||||
/>
|
||||
|
||||
<DialogAction
|
||||
title="Delete Volume Backup"
|
||||
description="Are you sure you want to delete this volume backup?"
|
||||
@@ -221,7 +218,7 @@ export const ShowVolumeBackups = ({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group hover:bg-red-500/10"
|
||||
className="group hover:bg-red-500/10 "
|
||||
isLoading={isDeleting}
|
||||
>
|
||||
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
||||
@@ -233,7 +230,7 @@ export const ShowVolumeBackups = ({
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2 items-center justify-center py-12 rounded-lg">
|
||||
<div className="flex flex-col gap-2 items-center justify-center py-12 rounded-lg">
|
||||
<DatabaseBackup className="size-8 mb-4 text-muted-foreground" />
|
||||
<p className="text-lg font-medium text-muted-foreground">
|
||||
No volume backups
|
||||
|
||||
@@ -101,10 +101,8 @@ export const DeleteService = ({ id, type }: Props) => {
|
||||
deleteVolumes,
|
||||
})
|
||||
.then((result) => {
|
||||
push(
|
||||
`/dashboard/project/${result?.environment?.projectId}/environment/${result?.environment?.environmentId}`,
|
||||
);
|
||||
toast.success("Service deleted successfully");
|
||||
push(`/dashboard/project/${result?.projectId}`);
|
||||
toast.success("deleted successfully");
|
||||
setIsOpen(false);
|
||||
})
|
||||
.catch(() => {
|
||||
|
||||
@@ -47,7 +47,7 @@ export const ComposeActions = ({ composeId }: Props) => {
|
||||
toast.success("Compose deployed successfully");
|
||||
refetch();
|
||||
router.push(
|
||||
`/dashboard/project/${data?.environment.projectId}/environment/${data?.environmentId}/services/compose/${composeId}?tab=deployments`,
|
||||
`/dashboard/project/${data?.project.projectId}/services/compose/${composeId}?tab=deployments`,
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
@@ -195,7 +195,6 @@ export const ComposeActions = ({ composeId }: Props) => {
|
||||
<DockerTerminalModal
|
||||
appName={data?.appName || ""}
|
||||
serverId={data?.serverId || ""}
|
||||
appType={data?.composeType || "docker-compose"}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
@@ -35,7 +35,6 @@ export const ComposeFileEditor = ({ composeId }: Props) => {
|
||||
);
|
||||
|
||||
const { mutateAsync, isLoading } = api.compose.update.useMutation();
|
||||
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
||||
|
||||
const form = useForm<AddComposeFile>({
|
||||
defaultValues: {
|
||||
@@ -54,12 +53,6 @@ export const ComposeFileEditor = ({ composeId }: Props) => {
|
||||
}
|
||||
}, [form, form.reset, data]);
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.composeFile !== undefined) {
|
||||
setHasUnsavedChanges(composeFile !== data.composeFile);
|
||||
}
|
||||
}, [composeFile, data?.composeFile]);
|
||||
|
||||
const onSubmit = async (data: AddComposeFile) => {
|
||||
const { valid, error } = validateAndFormatYAML(data.composeFile);
|
||||
if (!valid) {
|
||||
@@ -74,12 +67,10 @@ export const ComposeFileEditor = ({ composeId }: Props) => {
|
||||
await mutateAsync({
|
||||
composeId,
|
||||
composeFile: data.composeFile,
|
||||
composePath: "./docker-compose.yml",
|
||||
sourceType: "raw",
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Compose config Updated");
|
||||
setHasUnsavedChanges(false);
|
||||
refetch();
|
||||
await utils.compose.getConvertedCompose.invalidate({
|
||||
composeId,
|
||||
@@ -108,19 +99,6 @@ export const ComposeFileEditor = ({ composeId }: Props) => {
|
||||
return (
|
||||
<>
|
||||
<div className="w-full flex flex-col gap-4 ">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-medium">Compose File</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Configure your Docker Compose file for this service.
|
||||
{hasUnsavedChanges && (
|
||||
<span className="text-yellow-500 ml-2">
|
||||
(You have unsaved changes)
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Form {...form}>
|
||||
<form
|
||||
id="hook-form-save-compose-file"
|
||||
|
||||
@@ -152,7 +152,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
|
||||
enableSubmodules: data.enableSubmodules,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Service Provider Saved");
|
||||
toast.success("Service Provided Saved");
|
||||
await refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
|
||||
@@ -151,7 +151,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
|
||||
triggerType: data.triggerType,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Service Provider Saved");
|
||||
toast.success("Service Provided Saved");
|
||||
await refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
|
||||
@@ -160,7 +160,7 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
|
||||
enableSubmodules: data.enableSubmodules,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Service Provider Saved");
|
||||
toast.success("Service Provided Saved");
|
||||
await refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
|
||||
@@ -37,6 +37,8 @@ interface Props {
|
||||
serverId?: string;
|
||||
}
|
||||
|
||||
badgeStateColor;
|
||||
|
||||
export const ShowDockerLogsStack = ({ appName, serverId }: Props) => {
|
||||
const [option, setOption] = useState<"swarm" | "native">("native");
|
||||
const [containerId, setContainerId] = useState<string | undefined>();
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
CheckIcon,
|
||||
ChevronsUpDown,
|
||||
DatabaseZap,
|
||||
Info,
|
||||
PenBoxIcon,
|
||||
PlusIcon,
|
||||
RefreshCw,
|
||||
@@ -61,7 +62,7 @@ import {
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { api } from "@/utils/api";
|
||||
import { ScheduleFormField } from "../../application/schedules/handle-schedules";
|
||||
import { commonCronExpressions } from "../../application/schedules/handle-schedules";
|
||||
|
||||
type CacheType = "cache" | "fetch";
|
||||
|
||||
@@ -578,9 +579,66 @@ export const HandleBackup = ({
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<ScheduleFormField name="schedule" formControl={form.control} />
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="schedule"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel className="flex items-center gap-2">
|
||||
Schedule
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Info className="w-4 h-4 text-muted-foreground cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
Cron expression format: minute hour day month
|
||||
weekday
|
||||
</p>
|
||||
<p>Example: 0 0 * * * (daily at midnight)</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</FormLabel>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Select
|
||||
onValueChange={(value) => {
|
||||
field.onChange(value);
|
||||
}}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a predefined schedule" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{commonCronExpressions.map((expr) => (
|
||||
<SelectItem key={expr.value} value={expr.value}>
|
||||
{expr.label} ({expr.value})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="relative">
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Custom cron expression (e.g., 0 0 * * *)"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
</div>
|
||||
</div>
|
||||
<FormDescription>
|
||||
Choose a predefined schedule or enter a custom cron
|
||||
expression
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="prefix"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import copy from "copy-to-clipboard";
|
||||
import _ from "lodash";
|
||||
import { debounce } from "lodash";
|
||||
import {
|
||||
CheckIcon,
|
||||
ChevronsUpDown,
|
||||
@@ -236,7 +236,7 @@ export const RestoreBackup = ({
|
||||
const currentDatabaseType = form.watch("databaseType");
|
||||
const metadata = form.watch("metadata");
|
||||
|
||||
const debouncedSetSearch = _.debounce((value: string) => {
|
||||
const debouncedSetSearch = debounce((value: string) => {
|
||||
setDebouncedSearchTerm(value);
|
||||
}, 350);
|
||||
|
||||
|
||||
@@ -1,12 +1,4 @@
|
||||
import copy from "copy-to-clipboard";
|
||||
import {
|
||||
Check,
|
||||
Copy,
|
||||
Download as DownloadIcon,
|
||||
Loader2,
|
||||
Pause,
|
||||
Play,
|
||||
} from "lucide-react";
|
||||
import { Download as DownloadIcon, Loader2, Pause, Play } from "lucide-react";
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -75,7 +67,6 @@ export const DockerLogsId: React.FC<Props> = ({
|
||||
const isPausedRef = useRef(false);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [isLoading, setIsLoading] = React.useState(false);
|
||||
const [copied, setCopied] = React.useState(false);
|
||||
|
||||
const scrollToBottom = () => {
|
||||
if (autoScroll && scrollRef.current) {
|
||||
@@ -246,29 +237,6 @@ export const DockerLogsId: React.FC<Props> = ({
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const handleCopy = async () => {
|
||||
const logContent = filteredLogs
|
||||
.map(
|
||||
({
|
||||
timestamp,
|
||||
message,
|
||||
}: {
|
||||
timestamp: Date | null;
|
||||
message: string;
|
||||
}) =>
|
||||
showTimestamp
|
||||
? `${timestamp?.toISOString() || "No timestamp"} ${message}`
|
||||
: message,
|
||||
)
|
||||
.join("\n");
|
||||
|
||||
const success = copy(logContent);
|
||||
if (success) {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFilter = (logs: LogLine[]) => {
|
||||
return logs.filter((log) => {
|
||||
const logType = getLogType(log.message).type;
|
||||
@@ -352,21 +320,6 @@ export const DockerLogsId: React.FC<Props> = ({
|
||||
)}
|
||||
{isPaused ? "Resume" : "Pause"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-9"
|
||||
onClick={handleCopy}
|
||||
disabled={filteredLogs.length === 0}
|
||||
title="Copy logs to clipboard"
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="mr-2 h-4 w-4" />
|
||||
) : (
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Copy
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { FancyAnsi } from "fancy-ansi";
|
||||
import _ from "lodash";
|
||||
import { escapeRegExp } from "lodash";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Tooltip,
|
||||
@@ -47,7 +47,7 @@ export function TerminalLine({ log, noTimestamp, searchTerm }: LogLineProps) {
|
||||
}
|
||||
|
||||
const htmlContent = fancyAnsi.toHtml(text);
|
||||
const searchRegex = new RegExp(`(${_.escapeRegExp(term)})`, "gi");
|
||||
const searchRegex = new RegExp(`(${escapeRegExp(term)})`, "gi");
|
||||
|
||||
const modifiedContent = htmlContent.replace(
|
||||
searchRegex,
|
||||
|
||||
@@ -49,7 +49,7 @@ export function parseLogs(logString: string): LogLine[] {
|
||||
// { timestamp: new Date("2024-12-10T10:00:00.000Z"),
|
||||
// message: "The server is running on port 8080" }
|
||||
const logRegex =
|
||||
/^(?:(?<lineNumber>\d+)\s+)?(?<timestamp>(?:\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z|\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3} UTC))?\s*(?<message>[\s\S]*)$/;
|
||||
/^(?:(\d+)\s+)?(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z|\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3} UTC)?\s*(.*)$/;
|
||||
|
||||
return logString
|
||||
.split("\n")
|
||||
@@ -59,7 +59,7 @@ export function parseLogs(logString: string): LogLine[] {
|
||||
const match = line.match(logRegex);
|
||||
if (!match) return null;
|
||||
|
||||
const { timestamp, message } = match.groups ?? {};
|
||||
const [, , timestamp, message] = match;
|
||||
|
||||
if (!message?.trim()) return null;
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
containerId?: string;
|
||||
containerId: string;
|
||||
serverId?: string;
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ export const DockerTerminal: React.FC<Props> = ({
|
||||
},
|
||||
});
|
||||
const addonFit = new FitAddon();
|
||||
|
||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
|
||||
const wsUrl = `${protocol}//${window.location.host}/docker-container-terminal?containerId=${containerId}&activeWay=${activeWay}${serverId ? `&serverId=${serverId}` : ""}`;
|
||||
@@ -56,7 +57,7 @@ export const DockerTerminal: React.FC<Props> = ({
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2 mt-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<span>
|
||||
Select way to connect to <b>{containerId}</b>
|
||||
</span>
|
||||
|
||||
@@ -103,7 +103,7 @@ export const ImpersonationBar = () => {
|
||||
setOpen(false);
|
||||
|
||||
toast.success("Successfully impersonating user", {
|
||||
description: `You are now viewing as ${`${selectedUser.name} ${selectedUser.lastName}`.trim() || selectedUser.email}`,
|
||||
description: `You are now viewing as ${selectedUser.name || selectedUser.email}`,
|
||||
});
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
@@ -195,8 +195,7 @@ export const ImpersonationBar = () => {
|
||||
<UserIcon className="mr-2 h-4 w-4 flex-shrink-0" />
|
||||
<span className="truncate flex flex-col items-start">
|
||||
<span className="text-sm font-medium">
|
||||
{`${selectedUser.name} ${selectedUser.lastName}`.trim() ||
|
||||
""}
|
||||
{selectedUser.name || ""}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{selectedUser.email}
|
||||
@@ -243,8 +242,7 @@ export const ImpersonationBar = () => {
|
||||
<UserIcon className="h-4 w-4 flex-shrink-0" />
|
||||
<span className="flex flex-col items-start">
|
||||
<span className="text-sm font-medium">
|
||||
{`${user.name} ${user.lastName}`.trim() ||
|
||||
""}
|
||||
{user.name || ""}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{user.email} • {user.role}
|
||||
@@ -283,16 +281,11 @@ export const ImpersonationBar = () => {
|
||||
<div className="flex items-center gap-4 flex-1 flex-wrap">
|
||||
<Avatar className="h-10 w-10">
|
||||
<AvatarImage
|
||||
className="object-cover"
|
||||
src={data?.user?.image || ""}
|
||||
alt={
|
||||
`${data?.user?.firstName} ${data?.user?.lastName}`.trim() ||
|
||||
""
|
||||
}
|
||||
alt={data?.user?.name || ""}
|
||||
/>
|
||||
<AvatarFallback>
|
||||
{`${data?.user?.firstName?.[0] || ""}${data?.user?.lastName?.[0] || ""}`.toUpperCase() ||
|
||||
"U"}
|
||||
{data?.user?.name?.slice(0, 2).toUpperCase() || "U"}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex flex-col gap-1">
|
||||
@@ -305,8 +298,7 @@ export const ImpersonationBar = () => {
|
||||
Impersonating
|
||||
</Badge>
|
||||
<span className="font-medium">
|
||||
{`${data?.user?.firstName} ${data?.user?.lastName}`.trim() ||
|
||||
""}
|
||||
{data?.user?.name || ""}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-sm text-muted-foreground flex-wrap">
|
||||
|
||||
@@ -102,9 +102,9 @@ export const ShowExternalMariadbCredentials = ({ mariadbId }: Props) => {
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl">External Credentials</CardTitle>
|
||||
<CardDescription>
|
||||
In order to make the database reachable through the internet, you
|
||||
must set a port and ensure that the port is not being used by
|
||||
another application or database
|
||||
In order to make the database reachable trought internet is
|
||||
required to set a port, make sure the port is not used by another
|
||||
application or database
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex w-full flex-col gap-4">
|
||||
|
||||
@@ -102,9 +102,9 @@ export const ShowExternalMongoCredentials = ({ mongoId }: Props) => {
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl">External Credentials</CardTitle>
|
||||
<CardDescription>
|
||||
In order to make the database reachable through the internet, you
|
||||
must set a port and ensure that the port is not being used by
|
||||
another application or database
|
||||
In order to make the database reachable trought internet is
|
||||
required to set a port, make sure the port is not used by another
|
||||
application or database
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex w-full flex-col gap-4">
|
||||
|
||||
@@ -10,7 +10,7 @@ import { DockerNetworkChart } from "./docker-network-chart";
|
||||
|
||||
const defaultData = {
|
||||
cpu: {
|
||||
value: "0%",
|
||||
value: 0,
|
||||
time: "",
|
||||
},
|
||||
memory: {
|
||||
@@ -46,7 +46,7 @@ interface Props {
|
||||
}
|
||||
export interface DockerStats {
|
||||
cpu: {
|
||||
value: string;
|
||||
value: number;
|
||||
time: string;
|
||||
};
|
||||
memory: {
|
||||
@@ -220,13 +220,7 @@ export const ContainerFreeMonitoring = ({
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Used: {currentData.cpu.value}
|
||||
</span>
|
||||
<Progress
|
||||
value={Number.parseInt(
|
||||
currentData.cpu.value.replace("%", ""),
|
||||
10,
|
||||
)}
|
||||
className="w-[100%]"
|
||||
/>
|
||||
<Progress value={currentData.cpu.value} className="w-[100%]" />
|
||||
<DockerCpuChart acummulativeData={acummulativeData.cpu} />
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
@@ -102,9 +102,9 @@ export const ShowExternalMysqlCredentials = ({ mysqlId }: Props) => {
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl">External Credentials</CardTitle>
|
||||
<CardDescription>
|
||||
In order to make the database reachable through the internet, you
|
||||
must set a port and ensure that the port is not being used by
|
||||
another application or database
|
||||
In order to make the database reachable trought internet is
|
||||
required to set a port, make sure the port is not used by another
|
||||
application or database
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex w-full flex-col gap-4">
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import { useFieldArray, useForm } from "react-hook-form";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -21,13 +20,6 @@ import type { ServiceType } from "../../application/advanced/show-resources";
|
||||
const addDockerImage = z.object({
|
||||
dockerImage: z.string().min(1, "Docker image is required"),
|
||||
command: z.string(),
|
||||
args: z
|
||||
.array(
|
||||
z.object({
|
||||
value: z.string().min(1, "Argument cannot be empty"),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
interface Props {
|
||||
@@ -69,25 +61,18 @@ export const ShowCustomCommand = ({ id, type }: Props) => {
|
||||
defaultValues: {
|
||||
dockerImage: "",
|
||||
command: "",
|
||||
args: [],
|
||||
},
|
||||
resolver: zodResolver(addDockerImage),
|
||||
});
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control: form.control,
|
||||
name: "args",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
form.reset({
|
||||
dockerImage: data.dockerImage,
|
||||
command: data.command || "",
|
||||
args: data.args?.map((arg) => ({ value: arg })) || [],
|
||||
});
|
||||
}
|
||||
}, [data, form]);
|
||||
}, [data, form, form.reset]);
|
||||
|
||||
const onSubmit = async (formData: AddDockerImage) => {
|
||||
await mutateAsync({
|
||||
@@ -98,7 +83,6 @@ export const ShowCustomCommand = ({ id, type }: Props) => {
|
||||
mariadbId: id || "",
|
||||
dockerImage: formData?.dockerImage,
|
||||
command: formData?.command,
|
||||
args: formData?.args?.map((arg) => arg.value).filter(Boolean),
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Custom Command Updated");
|
||||
@@ -144,68 +128,13 @@ export const ShowCustomCommand = ({ id, type }: Props) => {
|
||||
<FormItem>
|
||||
<FormLabel>Command</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="/bin/sh" {...field} />
|
||||
<Input placeholder="Custom command" {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<FormLabel>Arguments (Args)</FormLabel>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => append({ value: "" })}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Add Argument
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{fields.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No arguments added yet. Click "Add Argument" to add one.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{fields.map((field, index) => (
|
||||
<FormField
|
||||
key={field.id}
|
||||
control={form.control}
|
||||
name={`args.${index}.value`}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="flex gap-2">
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={
|
||||
index === 0
|
||||
? "-c"
|
||||
: "redis-server --port 6379"
|
||||
}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
onClick={() => remove(index)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex w-full justify-end">
|
||||
<Button isLoading={form.formState.isSubmitting} type="submit">
|
||||
Save
|
||||
|
||||
@@ -104,9 +104,9 @@ export const ShowExternalPostgresCredentials = ({ postgresId }: Props) => {
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl">External Credentials</CardTitle>
|
||||
<CardDescription>
|
||||
In order to make the database reachable through the internet, you
|
||||
must set a port and ensure that the port is not being used by
|
||||
another application or database
|
||||
In order to make the database reachable trought internet is
|
||||
required to set a port, make sure the port is not used by another
|
||||
application or database
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex w-full flex-col gap-4">
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { TemplateGenerator } from "@/components/dashboard/project/ai/template-generator";
|
||||
|
||||
interface Props {
|
||||
environmentId: string;
|
||||
projectId: string;
|
||||
projectName?: string;
|
||||
}
|
||||
|
||||
export const AddAiAssistant = ({ environmentId }: Props) => {
|
||||
return <TemplateGenerator environmentId={environmentId} />;
|
||||
export const AddAiAssistant = ({ projectId }: Props) => {
|
||||
return <TemplateGenerator projectId={projectId} />;
|
||||
};
|
||||
|
||||
@@ -64,11 +64,11 @@ const AddTemplateSchema = z.object({
|
||||
type AddTemplate = z.infer<typeof AddTemplateSchema>;
|
||||
|
||||
interface Props {
|
||||
environmentId: string;
|
||||
projectId: string;
|
||||
projectName?: string;
|
||||
}
|
||||
|
||||
export const AddApplication = ({ environmentId, projectName }: Props) => {
|
||||
export const AddApplication = ({ projectId, projectName }: Props) => {
|
||||
const utils = api.useUtils();
|
||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||
const [visible, setVisible] = useState(false);
|
||||
@@ -76,10 +76,6 @@ export const AddApplication = ({ environmentId, projectName }: Props) => {
|
||||
const { data: servers } = api.server.withSSHKey.useQuery();
|
||||
|
||||
const hasServers = servers && servers.length > 0;
|
||||
// Show dropdown logic based on cloud environment
|
||||
// Cloud: show only if there are remote servers (no Dokploy option)
|
||||
// Self-hosted: show only if there are remote servers (Dokploy is default, hide if no remote servers)
|
||||
const shouldShowServerDropdown = hasServers;
|
||||
|
||||
const { mutateAsync, isLoading, error, isError } =
|
||||
api.application.create.useMutation();
|
||||
@@ -98,15 +94,15 @@ export const AddApplication = ({ environmentId, projectName }: Props) => {
|
||||
name: data.name,
|
||||
appName: data.appName,
|
||||
description: data.description,
|
||||
serverId: data.serverId === "dokploy" ? undefined : data.serverId,
|
||||
environmentId,
|
||||
projectId,
|
||||
serverId: data.serverId,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Service Created");
|
||||
form.reset();
|
||||
setVisible(false);
|
||||
await utils.environment.one.invalidate({
|
||||
environmentId,
|
||||
await utils.project.one.invalidate({
|
||||
projectId,
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
@@ -150,8 +146,8 @@ export const AddApplication = ({ environmentId, projectName }: Props) => {
|
||||
placeholder="Frontend"
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value || "";
|
||||
const serviceName = slugify(val.trim());
|
||||
const val = e.target.value?.trim() || "";
|
||||
const serviceName = slugify(val);
|
||||
form.setValue("appName", `${slug}-${serviceName}`);
|
||||
field.onChange(val);
|
||||
}}
|
||||
@@ -161,7 +157,7 @@ export const AddApplication = ({ environmentId, projectName }: Props) => {
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{shouldShowServerDropdown && (
|
||||
{hasServers && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="serverId"
|
||||
@@ -190,27 +186,13 @@ export const AddApplication = ({ environmentId, projectName }: Props) => {
|
||||
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={
|
||||
field.value || (!isCloud ? "dokploy" : undefined)
|
||||
}
|
||||
defaultValue={field.value}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={!isCloud ? "Dokploy" : "Select a Server"}
|
||||
/>
|
||||
<SelectValue placeholder="Select a Server" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{!isCloud && (
|
||||
<SelectItem value="dokploy">
|
||||
<span className="flex items-center gap-2 justify-between w-full">
|
||||
<span>Dokploy</span>
|
||||
<span className="text-muted-foreground text-xs self-center">
|
||||
Default
|
||||
</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
)}
|
||||
{servers?.map((server) => (
|
||||
<SelectItem
|
||||
key={server.serverId}
|
||||
@@ -224,9 +206,7 @@ export const AddApplication = ({ environmentId, projectName }: Props) => {
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectLabel>
|
||||
Servers ({servers?.length + (!isCloud ? 1 : 0)})
|
||||
</SelectLabel>
|
||||
<SelectLabel>Servers ({servers?.length})</SelectLabel>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
@@ -65,11 +65,11 @@ const AddComposeSchema = z.object({
|
||||
type AddCompose = z.infer<typeof AddComposeSchema>;
|
||||
|
||||
interface Props {
|
||||
environmentId: string;
|
||||
projectId: string;
|
||||
projectName?: string;
|
||||
}
|
||||
|
||||
export const AddCompose = ({ environmentId, projectName }: Props) => {
|
||||
export const AddCompose = ({ projectId, projectName }: Props) => {
|
||||
const utils = api.useUtils();
|
||||
const [visible, setVisible] = useState(false);
|
||||
const slug = slugify(projectName);
|
||||
@@ -78,14 +78,7 @@ export const AddCompose = ({ environmentId, projectName }: Props) => {
|
||||
const { mutateAsync, isLoading, error, isError } =
|
||||
api.compose.create.useMutation();
|
||||
|
||||
// Get environment data to extract projectId
|
||||
const { data: environment } = api.environment.one.useQuery({ environmentId });
|
||||
|
||||
const hasServers = servers && servers.length > 0;
|
||||
// Show dropdown logic based on cloud environment
|
||||
// Cloud: show only if there are remote servers (no Dokploy option)
|
||||
// Self-hosted: show only if there are remote servers (Dokploy is default, hide if no remote servers)
|
||||
const shouldShowServerDropdown = hasServers;
|
||||
|
||||
const form = useForm<AddCompose>({
|
||||
defaultValues: {
|
||||
@@ -105,17 +98,16 @@ export const AddCompose = ({ environmentId, projectName }: Props) => {
|
||||
await mutateAsync({
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
environmentId,
|
||||
projectId,
|
||||
composeType: data.composeType,
|
||||
appName: data.appName,
|
||||
serverId: data.serverId === "dokploy" ? undefined : data.serverId,
|
||||
serverId: data.serverId,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Compose Created");
|
||||
setVisible(false);
|
||||
// Invalidate the project query to refresh the environment data
|
||||
await utils.environment.one.invalidate({
|
||||
environmentId,
|
||||
await utils.project.one.invalidate({
|
||||
projectId,
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
@@ -161,8 +153,8 @@ export const AddCompose = ({ environmentId, projectName }: Props) => {
|
||||
placeholder="Frontend"
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value || "";
|
||||
const serviceName = slugify(val.trim());
|
||||
const val = e.target.value?.trim() || "";
|
||||
const serviceName = slugify(val);
|
||||
form.setValue("appName", `${slug}-${serviceName}`);
|
||||
field.onChange(val);
|
||||
}}
|
||||
@@ -173,7 +165,7 @@ export const AddCompose = ({ environmentId, projectName }: Props) => {
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{shouldShowServerDropdown && (
|
||||
{hasServers && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="serverId"
|
||||
@@ -202,27 +194,13 @@ export const AddCompose = ({ environmentId, projectName }: Props) => {
|
||||
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={
|
||||
field.value || (!isCloud ? "dokploy" : undefined)
|
||||
}
|
||||
defaultValue={field.value}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={!isCloud ? "Dokploy" : "Select a Server"}
|
||||
/>
|
||||
<SelectValue placeholder="Select a Server" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{!isCloud && (
|
||||
<SelectItem value="dokploy">
|
||||
<span className="flex items-center gap-2 justify-between w-full">
|
||||
<span>Dokploy</span>
|
||||
<span className="text-muted-foreground text-xs self-center">
|
||||
Default
|
||||
</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
)}
|
||||
{servers?.map((server) => (
|
||||
<SelectItem
|
||||
key={server.serverId}
|
||||
@@ -236,9 +214,7 @@ export const AddCompose = ({ environmentId, projectName }: Props) => {
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectLabel>
|
||||
Servers ({servers?.length + (!isCloud ? 1 : 0)})
|
||||
</SelectLabel>
|
||||
<SelectLabel>Servers ({servers?.length})</SelectLabel>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
@@ -55,7 +55,7 @@ import { api } from "@/utils/api";
|
||||
type DbType = typeof mySchema._type.type;
|
||||
|
||||
const dockerImageDefaultPlaceholder: Record<DbType, string> = {
|
||||
mongo: "mongo:7",
|
||||
mongo: "mongo:6",
|
||||
mariadb: "mariadb:11",
|
||||
mysql: "mysql:8",
|
||||
postgres: "postgres:15",
|
||||
@@ -83,12 +83,7 @@ const baseDatabaseSchema = z.object({
|
||||
message:
|
||||
"App name supports lowercase letters, numbers, '-' and can only start and end letters, and does not support continuous '-'",
|
||||
}),
|
||||
databasePassword: z
|
||||
.string()
|
||||
.regex(/^[a-zA-Z0-9@#%^&*()_+\-=[\]{}|;:,.<>?~`]*$/, {
|
||||
message:
|
||||
"Password contains invalid characters. Please avoid: $ ! ' \" \\ / and space characters for database compatibility",
|
||||
}),
|
||||
databasePassword: z.string(),
|
||||
dockerImage: z.string(),
|
||||
description: z.string().nullable(),
|
||||
serverId: z.string().nullable(),
|
||||
@@ -117,13 +112,7 @@ const mySchema = z.discriminatedUnion("type", [
|
||||
z
|
||||
.object({
|
||||
type: z.literal("mysql"),
|
||||
databaseRootPassword: z
|
||||
.string()
|
||||
.regex(/^[a-zA-Z0-9@#%^&*()_+\-=[\]{}|;:,.<>?~`]*$/, {
|
||||
message:
|
||||
"Password contains invalid characters. Please avoid: $ ! ' \" \\ / and space characters for database compatibility",
|
||||
})
|
||||
.optional(),
|
||||
databaseRootPassword: z.string().default(""),
|
||||
databaseUser: z.string().default("mysql"),
|
||||
databaseName: z.string().default("mysql"),
|
||||
})
|
||||
@@ -132,13 +121,7 @@ const mySchema = z.discriminatedUnion("type", [
|
||||
.object({
|
||||
type: z.literal("mariadb"),
|
||||
dockerImage: z.string().default("mariadb:4"),
|
||||
databaseRootPassword: z
|
||||
.string()
|
||||
.regex(/^[a-zA-Z0-9@#%^&*()_+\-=[\]{}|;:,.<>?~`]*$/, {
|
||||
message:
|
||||
"Password contains invalid characters. Please avoid: $ ! ' \" \\ / and space characters for database compatibility",
|
||||
})
|
||||
.optional(),
|
||||
databaseRootPassword: z.string().default(""),
|
||||
databaseUser: z.string().default("mariadb"),
|
||||
databaseName: z.string().default("mariadb"),
|
||||
})
|
||||
@@ -171,15 +154,14 @@ const databasesMap = {
|
||||
type AddDatabase = z.infer<typeof mySchema>;
|
||||
|
||||
interface Props {
|
||||
environmentId: string;
|
||||
projectId: string;
|
||||
projectName?: string;
|
||||
}
|
||||
|
||||
export const AddDatabase = ({ environmentId, projectName }: Props) => {
|
||||
export const AddDatabase = ({ projectId, projectName }: Props) => {
|
||||
const utils = api.useUtils();
|
||||
const [visible, setVisible] = useState(false);
|
||||
const slug = slugify(projectName);
|
||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||
const { data: servers } = api.server.withSSHKey.useQuery();
|
||||
const postgresMutation = api.postgres.create.useMutation();
|
||||
const mongoMutation = api.mongo.create.useMutation();
|
||||
@@ -187,14 +169,7 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
|
||||
const mariadbMutation = api.mariadb.create.useMutation();
|
||||
const mysqlMutation = api.mysql.create.useMutation();
|
||||
|
||||
// Get environment data to extract projectId
|
||||
const { data: environment } = api.environment.one.useQuery({ environmentId });
|
||||
|
||||
const hasServers = servers && servers.length > 0;
|
||||
// Show dropdown logic based on cloud environment
|
||||
// Cloud: show only if there are remote servers (no Dokploy option)
|
||||
// Self-hosted: show only if there are remote servers (Dokploy is default, hide if no remote servers)
|
||||
const shouldShowServerDropdown = hasServers;
|
||||
|
||||
const form = useForm<AddDatabase>({
|
||||
defaultValues: {
|
||||
@@ -228,8 +203,8 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
|
||||
name: data.name,
|
||||
appName: data.appName,
|
||||
dockerImage: defaultDockerImage,
|
||||
serverId: data.serverId === "dokploy" ? undefined : data.serverId,
|
||||
environmentId,
|
||||
projectId,
|
||||
serverId: data.serverId,
|
||||
description: data.description,
|
||||
};
|
||||
|
||||
@@ -241,7 +216,7 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
|
||||
|
||||
databaseUser:
|
||||
data.databaseUser || databasesUserDefaultPlaceholder[data.type],
|
||||
serverId: data.serverId === "dokploy" ? null : data.serverId,
|
||||
serverId: data.serverId,
|
||||
});
|
||||
} else if (data.type === "mongo") {
|
||||
promise = mongoMutation.mutateAsync({
|
||||
@@ -249,24 +224,25 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
|
||||
databasePassword: data.databasePassword,
|
||||
databaseUser:
|
||||
data.databaseUser || databasesUserDefaultPlaceholder[data.type],
|
||||
serverId: data.serverId === "dokploy" ? null : data.serverId,
|
||||
serverId: data.serverId,
|
||||
replicaSets: data.replicaSets,
|
||||
});
|
||||
} else if (data.type === "redis") {
|
||||
promise = redisMutation.mutateAsync({
|
||||
...commonParams,
|
||||
databasePassword: data.databasePassword,
|
||||
serverId: data.serverId === "dokploy" ? null : data.serverId,
|
||||
serverId: data.serverId,
|
||||
projectId,
|
||||
});
|
||||
} else if (data.type === "mariadb") {
|
||||
promise = mariadbMutation.mutateAsync({
|
||||
...commonParams,
|
||||
databasePassword: data.databasePassword,
|
||||
databaseRootPassword: data.databaseRootPassword || "",
|
||||
databaseRootPassword: data.databaseRootPassword,
|
||||
databaseName: data.databaseName || "mariadb",
|
||||
databaseUser:
|
||||
data.databaseUser || databasesUserDefaultPlaceholder[data.type],
|
||||
serverId: data.serverId === "dokploy" ? null : data.serverId,
|
||||
serverId: data.serverId,
|
||||
});
|
||||
} else if (data.type === "mysql") {
|
||||
promise = mysqlMutation.mutateAsync({
|
||||
@@ -275,8 +251,8 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
|
||||
databaseName: data.databaseName || "mysql",
|
||||
databaseUser:
|
||||
data.databaseUser || databasesUserDefaultPlaceholder[data.type],
|
||||
serverId: data.serverId === "dokploy" ? null : data.serverId,
|
||||
databaseRootPassword: data.databaseRootPassword || "",
|
||||
databaseRootPassword: data.databaseRootPassword,
|
||||
serverId: data.serverId,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -295,9 +271,8 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
|
||||
databaseUser: "",
|
||||
});
|
||||
setVisible(false);
|
||||
// Invalidate the project query to refresh the environment data
|
||||
await utils.environment.one.invalidate({
|
||||
environmentId,
|
||||
await utils.project.one.invalidate({
|
||||
projectId,
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
@@ -395,8 +370,8 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
|
||||
placeholder="Name"
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value || "";
|
||||
const serviceName = slugify(val.trim());
|
||||
const val = e.target.value?.trim() || "";
|
||||
const serviceName = slugify(val);
|
||||
form.setValue("appName", `${slug}-${serviceName}`);
|
||||
field.onChange(val);
|
||||
}}
|
||||
@@ -407,7 +382,7 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{shouldShowServerDropdown && (
|
||||
{hasServers && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="serverId"
|
||||
@@ -416,29 +391,13 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
|
||||
<FormLabel>Select a Server</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={
|
||||
field.value || (!isCloud ? "dokploy" : undefined)
|
||||
}
|
||||
defaultValue={field.value || ""}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={
|
||||
!isCloud ? "Dokploy" : "Select a Server"
|
||||
}
|
||||
/>
|
||||
<SelectValue placeholder="Select a Server" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{!isCloud && (
|
||||
<SelectItem value="dokploy">
|
||||
<span className="flex items-center gap-2 justify-between w-full">
|
||||
<span>Dokploy</span>
|
||||
<span className="text-muted-foreground text-xs self-center">
|
||||
Default
|
||||
</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
)}
|
||||
{servers?.map((server) => (
|
||||
<SelectItem
|
||||
key={server.serverId}
|
||||
@@ -448,7 +407,7 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectLabel>
|
||||
Servers ({servers?.length + (!isCloud ? 1 : 0)})
|
||||
Servers ({servers?.length})
|
||||
</SelectLabel>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
|
||||
@@ -73,11 +73,11 @@ import { api } from "@/utils/api";
|
||||
const TEMPLATE_BASE_URL_KEY = "dokploy_template_base_url";
|
||||
|
||||
interface Props {
|
||||
environmentId: string;
|
||||
projectId: string;
|
||||
baseUrl?: string;
|
||||
}
|
||||
|
||||
export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
|
||||
export const AddTemplate = ({ projectId, baseUrl }: Props) => {
|
||||
const [query, setQuery] = useState("");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [viewMode, setViewMode] = useState<"detailed" | "icon">("detailed");
|
||||
@@ -91,9 +91,6 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
|
||||
return undefined;
|
||||
});
|
||||
|
||||
// Get environment data to extract projectId
|
||||
const { data: environment } = api.environment.one.useQuery({ environmentId });
|
||||
|
||||
// Save to localStorage when customBaseUrl changes
|
||||
useEffect(() => {
|
||||
if (customBaseUrl) {
|
||||
@@ -141,10 +138,6 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
|
||||
}) || [];
|
||||
|
||||
const hasServers = servers && servers.length > 0;
|
||||
// Show dropdown logic based on cloud environment
|
||||
// Cloud: show only if there are remote servers (no Dokploy option)
|
||||
// Self-hosted: show only if there are remote servers (Dokploy is default, hide if no remote servers)
|
||||
const shouldShowServerDropdown = hasServers;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
@@ -171,7 +164,7 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
|
||||
<Input
|
||||
placeholder="Search Template"
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
className="w-full"
|
||||
className="w-full sm:w-[200px]"
|
||||
value={query}
|
||||
/>
|
||||
<Input
|
||||
@@ -248,7 +241,7 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
|
||||
onClick={() =>
|
||||
setViewMode(viewMode === "detailed" ? "icon" : "detailed")
|
||||
}
|
||||
className="h-9 w-9 flex-shrink-0"
|
||||
className="h-9 w-9"
|
||||
>
|
||||
{viewMode === "detailed" ? (
|
||||
<LayoutGrid className="size-4" />
|
||||
@@ -434,7 +427,7 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
|
||||
project.
|
||||
</AlertDialogDescription>
|
||||
|
||||
{shouldShowServerDropdown && (
|
||||
{hasServers && (
|
||||
<div>
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip>
|
||||
@@ -463,29 +456,12 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
|
||||
onValueChange={(e) => {
|
||||
setServerId(e);
|
||||
}}
|
||||
defaultValue={
|
||||
!isCloud ? "dokploy" : undefined
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={
|
||||
!isCloud ? "Dokploy" : "Select a Server"
|
||||
}
|
||||
/>
|
||||
<SelectValue placeholder="Select a Server" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{!isCloud && (
|
||||
<SelectItem value="dokploy">
|
||||
<span className="flex items-center gap-2 justify-between w-full">
|
||||
<span>Dokploy</span>
|
||||
<span className="text-muted-foreground text-xs self-center">
|
||||
Default
|
||||
</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
)}
|
||||
{servers?.map((server) => (
|
||||
<SelectItem
|
||||
key={server.serverId}
|
||||
@@ -500,8 +476,7 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectLabel>
|
||||
Servers (
|
||||
{servers?.length + (!isCloud ? 1 : 0)})
|
||||
Servers ({servers?.length})
|
||||
</SelectLabel>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
@@ -515,20 +490,16 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
|
||||
disabled={isLoading}
|
||||
onClick={async () => {
|
||||
const promise = mutateAsync({
|
||||
serverId:
|
||||
serverId === "dokploy"
|
||||
? undefined
|
||||
: serverId,
|
||||
environmentId,
|
||||
projectId,
|
||||
serverId: serverId || undefined,
|
||||
id: template.id,
|
||||
baseUrl: customBaseUrl,
|
||||
});
|
||||
toast.promise(promise, {
|
||||
loading: "Setting up...",
|
||||
success: () => {
|
||||
// Invalidate the project query to refresh the environment data
|
||||
utils.environment.one.invalidate({
|
||||
environmentId,
|
||||
utils.project.one.invalidate({
|
||||
projectId,
|
||||
});
|
||||
setOpen(false);
|
||||
return `${template.name} template created successfully`;
|
||||
|
||||
@@ -1,436 +0,0 @@
|
||||
import type { findEnvironmentsByProjectId } from "@dokploy/server";
|
||||
import { ChevronDownIcon, PencilIcon, PlusIcon, TrashIcon } from "lucide-react";
|
||||
import { useRouter } from "next/router";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
type Environment = Awaited<
|
||||
ReturnType<typeof findEnvironmentsByProjectId>
|
||||
>[number];
|
||||
interface AdvancedEnvironmentSelectorProps {
|
||||
projectId: string;
|
||||
currentEnvironmentId?: string;
|
||||
}
|
||||
|
||||
export const AdvancedEnvironmentSelector = ({
|
||||
projectId,
|
||||
currentEnvironmentId,
|
||||
}: AdvancedEnvironmentSelectorProps) => {
|
||||
const router = useRouter();
|
||||
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
|
||||
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
const [selectedEnvironment, setSelectedEnvironment] =
|
||||
useState<Environment | null>(null);
|
||||
|
||||
const { data: environments } = api.environment.byProjectId.useQuery(
|
||||
{ projectId: projectId },
|
||||
{
|
||||
enabled: !!projectId,
|
||||
},
|
||||
);
|
||||
|
||||
// Form states
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
|
||||
// Get current user's permissions
|
||||
const { data: currentUser } = api.user.get.useQuery();
|
||||
|
||||
// Check if user can create environments
|
||||
const canCreateEnvironments =
|
||||
currentUser?.role === "owner" ||
|
||||
currentUser?.role === "admin" ||
|
||||
currentUser?.canCreateEnvironments === true;
|
||||
|
||||
// Check if user can delete environments
|
||||
const canDeleteEnvironments =
|
||||
currentUser?.role === "owner" ||
|
||||
currentUser?.role === "admin" ||
|
||||
currentUser?.canDeleteEnvironments === true;
|
||||
|
||||
const haveServices =
|
||||
selectedEnvironment &&
|
||||
((selectedEnvironment?.mariadb?.length || 0) > 0 ||
|
||||
(selectedEnvironment?.mongo?.length || 0) > 0 ||
|
||||
(selectedEnvironment?.mysql?.length || 0) > 0 ||
|
||||
(selectedEnvironment?.postgres?.length || 0) > 0 ||
|
||||
(selectedEnvironment?.redis?.length || 0) > 0 ||
|
||||
(selectedEnvironment?.applications?.length || 0) > 0 ||
|
||||
(selectedEnvironment?.compose?.length || 0) > 0);
|
||||
const createEnvironment = api.environment.create.useMutation();
|
||||
const updateEnvironment = api.environment.update.useMutation();
|
||||
const deleteEnvironment = api.environment.remove.useMutation();
|
||||
const duplicateEnvironment = api.environment.duplicate.useMutation();
|
||||
|
||||
// Refetch project data
|
||||
const utils = api.useUtils();
|
||||
|
||||
const handleCreateEnvironment = async () => {
|
||||
try {
|
||||
await createEnvironment.mutateAsync({
|
||||
projectId,
|
||||
name: name.trim(),
|
||||
description: description.trim() || null,
|
||||
});
|
||||
|
||||
toast.success("Environment created successfully");
|
||||
utils.environment.byProjectId.invalidate({ projectId });
|
||||
setIsCreateDialogOpen(false);
|
||||
setName("");
|
||||
setDescription("");
|
||||
} catch (error) {
|
||||
toast.error("Failed to create environment");
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateEnvironment = async () => {
|
||||
if (!selectedEnvironment) return;
|
||||
|
||||
try {
|
||||
await updateEnvironment.mutateAsync({
|
||||
environmentId: selectedEnvironment.environmentId,
|
||||
name: name.trim(),
|
||||
description: description.trim() || null,
|
||||
});
|
||||
|
||||
toast.success("Environment updated successfully");
|
||||
utils.environment.byProjectId.invalidate({ projectId });
|
||||
setIsEditDialogOpen(false);
|
||||
setSelectedEnvironment(null);
|
||||
setName("");
|
||||
setDescription("");
|
||||
} catch (error) {
|
||||
toast.error("Failed to update environment");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteEnvironment = async () => {
|
||||
if (!selectedEnvironment) return;
|
||||
|
||||
try {
|
||||
await deleteEnvironment.mutateAsync({
|
||||
environmentId: selectedEnvironment.environmentId,
|
||||
});
|
||||
|
||||
toast.success("Environment deleted successfully");
|
||||
utils.environment.byProjectId.invalidate({ projectId });
|
||||
setIsDeleteDialogOpen(false);
|
||||
setSelectedEnvironment(null);
|
||||
|
||||
// Redirect to production if we deleted the current environment
|
||||
if (selectedEnvironment.environmentId === currentEnvironmentId) {
|
||||
const productionEnv = environments?.find(
|
||||
(env) => env.name === "production",
|
||||
);
|
||||
if (productionEnv) {
|
||||
router.push(
|
||||
`/dashboard/project/${projectId}/environment/${productionEnv.environmentId}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error("Failed to delete environment");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDuplicateEnvironment = async (environment: Environment) => {
|
||||
try {
|
||||
const result = await duplicateEnvironment.mutateAsync({
|
||||
environmentId: environment.environmentId,
|
||||
name: `${environment.name}-copy`,
|
||||
description: environment.description,
|
||||
});
|
||||
|
||||
toast.success("Environment duplicated successfully");
|
||||
utils.project.one.invalidate({ projectId });
|
||||
|
||||
// Navigate to the new duplicated environment
|
||||
router.push(
|
||||
`/dashboard/project/${projectId}/environment/${result.environmentId}`,
|
||||
);
|
||||
} catch (error) {
|
||||
toast.error("Failed to duplicate environment");
|
||||
}
|
||||
};
|
||||
|
||||
const openEditDialog = (environment: Environment) => {
|
||||
setSelectedEnvironment(environment);
|
||||
setName(environment.name);
|
||||
setDescription(environment.description || "");
|
||||
setIsEditDialogOpen(true);
|
||||
};
|
||||
|
||||
const openDeleteDialog = (environment: Environment) => {
|
||||
setSelectedEnvironment(environment);
|
||||
setIsDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
const currentEnv = environments?.find(
|
||||
(env) => env.environmentId === currentEnvironmentId,
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-auto p-2 font-normal">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-muted-foreground">/</span>
|
||||
<span>{currentEnv?.name || "Select Environment"}</span>
|
||||
<ChevronDownIcon className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-[300px]" align="start">
|
||||
<DropdownMenuLabel>Environments</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
{environments?.map((environment) => {
|
||||
const servicesCount =
|
||||
environment.mariadb.length +
|
||||
environment.mongo.length +
|
||||
environment.mysql.length +
|
||||
environment.postgres.length +
|
||||
environment.redis.length +
|
||||
environment.applications.length +
|
||||
environment.compose.length;
|
||||
return (
|
||||
<div
|
||||
key={environment.environmentId}
|
||||
className="flex items-center"
|
||||
>
|
||||
<DropdownMenuItem
|
||||
className="flex-1 cursor-pointer"
|
||||
onClick={() => {
|
||||
router.push(
|
||||
`/dashboard/project/${projectId}/environment/${environment.environmentId}`,
|
||||
);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<span>
|
||||
{environment.name} ({servicesCount})
|
||||
</span>
|
||||
{environment.environmentId === currentEnvironmentId && (
|
||||
<div className="w-2 h-2 bg-blue-500 rounded-full" />
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
{environment.name !== "production" && (
|
||||
<div className="flex items-center gap-1 px-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openEditDialog(environment);
|
||||
}}
|
||||
>
|
||||
<PencilIcon className="h-3 w-3" />
|
||||
</Button>
|
||||
|
||||
{canDeleteEnvironments && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 text-red-600 hover:text-red-700"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openDeleteDialog(environment);
|
||||
}}
|
||||
>
|
||||
<TrashIcon className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
{canCreateEnvironments && (
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer"
|
||||
onClick={() => setIsCreateDialogOpen(true)}
|
||||
>
|
||||
<PlusIcon className="h-4 w-4 mr-2" />
|
||||
Create Environment
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Environment</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a new environment for your project.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="name">Name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Environment name"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="description">Description (optional)</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Environment description"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setIsCreateDialogOpen(false);
|
||||
setName("");
|
||||
setDescription("");
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCreateEnvironment}
|
||||
disabled={!name.trim() || createEnvironment.isLoading}
|
||||
>
|
||||
{createEnvironment.isLoading ? "Creating..." : "Create"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Edit Environment Dialog */}
|
||||
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Environment</DialogTitle>
|
||||
<DialogDescription>
|
||||
Update the environment details.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="edit-name">Name</Label>
|
||||
<Input
|
||||
id="edit-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Environment name"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="edit-description">Description (optional)</Label>
|
||||
<Textarea
|
||||
id="edit-description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Environment description"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setIsEditDialogOpen(false);
|
||||
setSelectedEnvironment(null);
|
||||
setName("");
|
||||
setDescription("");
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleUpdateEnvironment}
|
||||
disabled={!name.trim() || updateEnvironment.isLoading}
|
||||
>
|
||||
{updateEnvironment.isLoading ? "Updating..." : "Update"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Environment Dialog */}
|
||||
<Dialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Environment</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete the environment "
|
||||
{selectedEnvironment?.name}"? This action cannot be undone and
|
||||
will also delete all services in this environment.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{haveServices && (
|
||||
<AlertBlock type="warning">
|
||||
This environment have active services, please delete them first.
|
||||
</AlertBlock>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setIsDeleteDialogOpen(false);
|
||||
setSelectedEnvironment(null);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDeleteEnvironment}
|
||||
disabled={
|
||||
deleteEnvironment.isLoading ||
|
||||
haveServices ||
|
||||
!selectedEnvironment
|
||||
}
|
||||
>
|
||||
{deleteEnvironment.isLoading ? "Deleting..." : "Delete"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -25,12 +25,7 @@ const examples = [
|
||||
export const StepOne = ({ setTemplateInfo, templateInfo }: any) => {
|
||||
// Get servers from the API
|
||||
const { data: servers } = api.server.withSSHKey.useQuery();
|
||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||
const hasServers = servers && servers.length > 0;
|
||||
// Show dropdown logic based on cloud environment
|
||||
// Cloud: show only if there are remote servers (no Dokploy option)
|
||||
// Self-hosted: show only if there are remote servers (Dokploy is default, hide if no remote servers)
|
||||
const shouldShowServerDropdown = hasServers;
|
||||
|
||||
const handleExampleClick = (example: string) => {
|
||||
setTemplateInfo({ ...templateInfo, userInput: example });
|
||||
@@ -53,58 +48,34 @@ export const StepOne = ({ setTemplateInfo, templateInfo }: any) => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{shouldShowServerDropdown && (
|
||||
{hasServers && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="server-deploy">
|
||||
Select the server where you want to deploy (optional)
|
||||
</Label>
|
||||
<Select
|
||||
value={
|
||||
templateInfo.server?.serverId ||
|
||||
(!isCloud ? "dokploy" : undefined)
|
||||
}
|
||||
value={templateInfo.server?.serverId}
|
||||
onValueChange={(value) => {
|
||||
if (value === "dokploy") {
|
||||
const server = servers?.find((s) => s.serverId === value);
|
||||
if (server) {
|
||||
setTemplateInfo({
|
||||
...templateInfo,
|
||||
server: undefined,
|
||||
server: server,
|
||||
});
|
||||
} else {
|
||||
const server = servers?.find((s) => s.serverId === value);
|
||||
if (server) {
|
||||
setTemplateInfo({
|
||||
...templateInfo,
|
||||
server: server,
|
||||
});
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue
|
||||
placeholder={!isCloud ? "Dokploy" : "Select a Server"}
|
||||
/>
|
||||
<SelectValue placeholder="Select a server" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{!isCloud && (
|
||||
<SelectItem value="dokploy">
|
||||
<span className="flex items-center gap-2 justify-between w-full">
|
||||
<span>Dokploy</span>
|
||||
<span className="text-muted-foreground text-xs self-center">
|
||||
Default
|
||||
</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
)}
|
||||
{servers?.map((server) => (
|
||||
<SelectItem key={server.serverId} value={server.serverId}>
|
||||
{server.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectLabel>
|
||||
Servers ({servers?.length + (!isCloud ? 1 : 0)})
|
||||
</SelectLabel>
|
||||
<SelectLabel>Servers ({servers?.length})</SelectLabel>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
@@ -88,7 +88,7 @@ export const StepThree = ({ templateInfo }: StepProps) => {
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">Configuration Files</h3>
|
||||
<ul className="list-disc pl-5">
|
||||
{templateInfo?.details?.configFiles?.map((file, index) => (
|
||||
{templateInfo?.details?.configFiles.map((file, index) => (
|
||||
<li key={index}>
|
||||
<strong className="text-sm font-semibold">
|
||||
{file.filePath}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Bot, PlusCircle, Trash2 } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import { Bot, Eye, EyeOff, PlusCircle, Trash2 } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import { toast } from "sonner";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
@@ -27,6 +27,7 @@ export interface StepProps {
|
||||
export const StepTwo = ({ templateInfo, setTemplateInfo }: StepProps) => {
|
||||
const suggestions = templateInfo.suggestions || [];
|
||||
const selectedVariant = templateInfo.details;
|
||||
const [showValues, setShowValues] = useState<Record<string, boolean>>({});
|
||||
|
||||
const { mutateAsync, isLoading, error, isError } =
|
||||
api.ai.suggest.useMutation();
|
||||
@@ -43,7 +44,7 @@ export const StepTwo = ({ templateInfo, setTemplateInfo }: StepProps) => {
|
||||
.then((data) => {
|
||||
setTemplateInfo({
|
||||
...templateInfo,
|
||||
suggestions: data || [],
|
||||
suggestions: data,
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
@@ -53,6 +54,10 @@ export const StepTwo = ({ templateInfo, setTemplateInfo }: StepProps) => {
|
||||
});
|
||||
}, [templateInfo.userInput]);
|
||||
|
||||
const toggleShowValue = (name: string) => {
|
||||
setShowValues((prev) => ({ ...prev, [name]: !prev[name] }));
|
||||
};
|
||||
|
||||
const handleEnvVariableChange = (
|
||||
index: number,
|
||||
field: "name" | "value",
|
||||
@@ -303,9 +308,11 @@ export const StepTwo = ({ templateInfo, setTemplateInfo }: StepProps) => {
|
||||
placeholder="Variable Name"
|
||||
className="flex-1"
|
||||
/>
|
||||
<div className="relative">
|
||||
<div className="flex-1 relative">
|
||||
<Input
|
||||
type={"password"}
|
||||
type={
|
||||
showValues[env.name] ? "text" : "password"
|
||||
}
|
||||
value={env.value}
|
||||
onChange={(e) =>
|
||||
handleEnvVariableChange(
|
||||
@@ -316,6 +323,19 @@ export const StepTwo = ({ templateInfo, setTemplateInfo }: StepProps) => {
|
||||
}
|
||||
placeholder="Variable Value"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-2 top-1/2 transform -translate-y-1/2"
|
||||
onClick={() => toggleShowValue(env.name)}
|
||||
>
|
||||
{showValues[env.name] ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -417,14 +437,13 @@ export const StepTwo = ({ templateInfo, setTemplateInfo }: StepProps) => {
|
||||
<AccordionContent>
|
||||
<ScrollArea className="w-full rounded-md border">
|
||||
<div className="p-4 space-y-4">
|
||||
{selectedVariant?.configFiles?.length &&
|
||||
selectedVariant?.configFiles?.length > 0 ? (
|
||||
{selectedVariant?.configFiles?.length > 0 ? (
|
||||
<>
|
||||
<div className="text-sm text-muted-foreground mb-4">
|
||||
This template requires the following
|
||||
configuration files to be mounted:
|
||||
</div>
|
||||
{selectedVariant?.configFiles?.map(
|
||||
{selectedVariant.configFiles.map(
|
||||
(config, index) => (
|
||||
<div
|
||||
key={index}
|
||||
|
||||
@@ -47,7 +47,7 @@ interface Details {
|
||||
envVariables: EnvVariable[];
|
||||
shortDescription: string;
|
||||
domains: Domain[];
|
||||
configFiles?: Mount[];
|
||||
configFiles: Mount[];
|
||||
}
|
||||
|
||||
interface Mount {
|
||||
@@ -90,11 +90,11 @@ export const { useStepper, steps, Scoped } = defineStepper(
|
||||
);
|
||||
|
||||
interface Props {
|
||||
environmentId: string;
|
||||
projectId: string;
|
||||
projectName?: string;
|
||||
}
|
||||
|
||||
export const TemplateGenerator = ({ environmentId }: Props) => {
|
||||
export const TemplateGenerator = ({ projectId }: Props) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const stepper = useStepper();
|
||||
const { data: aiSettings } = api.ai.getAll.useQuery();
|
||||
@@ -121,7 +121,7 @@ export const TemplateGenerator = ({ environmentId }: Props) => {
|
||||
|
||||
const onSubmit = async () => {
|
||||
await mutateAsync({
|
||||
environmentId: environmentId,
|
||||
projectId,
|
||||
id: templateInfo.details?.id || "",
|
||||
name: templateInfo?.details?.name || "",
|
||||
description: templateInfo?.details?.shortDescription || "",
|
||||
@@ -138,9 +138,8 @@ export const TemplateGenerator = ({ environmentId }: Props) => {
|
||||
.then(async () => {
|
||||
toast.success("Compose Created");
|
||||
setOpen(false);
|
||||
// Invalidate the project query to refresh the environment data
|
||||
await utils.environment.one.invalidate({
|
||||
environmentId,
|
||||
await utils.project.one.invalidate({
|
||||
projectId,
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
|
||||
@@ -15,13 +15,6 @@ import {
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
export type Services = {
|
||||
@@ -43,35 +36,23 @@ export type Services = {
|
||||
};
|
||||
|
||||
interface DuplicateProjectProps {
|
||||
environmentId: string;
|
||||
projectId: string;
|
||||
services: Services[];
|
||||
selectedServiceIds: string[];
|
||||
}
|
||||
|
||||
export const DuplicateProject = ({
|
||||
environmentId,
|
||||
projectId,
|
||||
services,
|
||||
selectedServiceIds,
|
||||
}: DuplicateProjectProps) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [duplicateType, setDuplicateType] = useState("new-project"); // "new-project" or "existing-environment"
|
||||
const [selectedTargetProject, setSelectedTargetProject] =
|
||||
useState<string>("");
|
||||
const [selectedTargetEnvironment, setSelectedTargetEnvironment] =
|
||||
useState<string>("");
|
||||
const [duplicateType, setDuplicateType] = useState("new-project"); // "new-project" or "same-project"
|
||||
const utils = api.useUtils();
|
||||
const router = useRouter();
|
||||
|
||||
// Queries for project and environment selection
|
||||
const { data: allProjects } = api.project.all.useQuery();
|
||||
const { data: selectedProjectEnvironments } =
|
||||
api.environment.byProjectId.useQuery(
|
||||
{ projectId: selectedTargetProject },
|
||||
{ enabled: !!selectedTargetProject },
|
||||
);
|
||||
|
||||
const selectedServices = services.filter((service) =>
|
||||
selectedServiceIds.includes(service.id),
|
||||
);
|
||||
@@ -80,29 +61,6 @@ export const DuplicateProject = ({
|
||||
api.project.duplicate.useMutation({
|
||||
onSuccess: async (newProject) => {
|
||||
await utils.project.all.invalidate();
|
||||
|
||||
// If duplicating to same project+environment, invalidate the environment query
|
||||
// to refresh the services list
|
||||
if (duplicateType === "existing-environment") {
|
||||
await utils.environment.one.invalidate({
|
||||
environmentId: selectedTargetEnvironment,
|
||||
});
|
||||
await utils.environment.byProjectId.invalidate({
|
||||
projectId: selectedTargetProject,
|
||||
});
|
||||
|
||||
// If duplicating to the same environment we're currently viewing,
|
||||
// also invalidate the current environment to refresh the services list
|
||||
if (selectedTargetEnvironment === environmentId) {
|
||||
await utils.environment.one.invalidate({ environmentId });
|
||||
// Also invalidate the project query to refresh the project data
|
||||
const projectId = router.query.projectId as string;
|
||||
if (projectId) {
|
||||
await utils.project.one.invalidate({ projectId });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
toast.success(
|
||||
duplicateType === "new-project"
|
||||
? "Project duplicated successfully"
|
||||
@@ -110,9 +68,7 @@ export const DuplicateProject = ({
|
||||
);
|
||||
setOpen(false);
|
||||
if (duplicateType === "new-project") {
|
||||
router.push(
|
||||
`/dashboard/project/${newProject?.projectId}/environment/${newProject?.environmentId}`,
|
||||
);
|
||||
router.push(`/dashboard/project/${newProject.projectId}`);
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
@@ -126,20 +82,8 @@ export const DuplicateProject = ({
|
||||
return;
|
||||
}
|
||||
|
||||
if (duplicateType === "existing-environment") {
|
||||
if (!selectedTargetProject) {
|
||||
toast.error("Please select a target project");
|
||||
return;
|
||||
}
|
||||
if (!selectedTargetEnvironment) {
|
||||
toast.error("Please select a target environment");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Update duplicate API to support targetProjectId and targetEnvironmentId
|
||||
await duplicateProject({
|
||||
sourceEnvironmentId: selectedTargetEnvironment,
|
||||
sourceProjectId: projectId,
|
||||
name,
|
||||
description,
|
||||
includeServices: true,
|
||||
@@ -147,7 +91,7 @@ export const DuplicateProject = ({
|
||||
id: service.id,
|
||||
type: service.type,
|
||||
})),
|
||||
duplicateInSameProject: duplicateType === "existing-environment",
|
||||
duplicateInSameProject: duplicateType === "same-project",
|
||||
});
|
||||
};
|
||||
|
||||
@@ -161,8 +105,6 @@ export const DuplicateProject = ({
|
||||
setName("");
|
||||
setDescription("");
|
||||
setDuplicateType("new-project");
|
||||
setSelectedTargetProject("");
|
||||
setSelectedTargetEnvironment("");
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -185,14 +127,7 @@ export const DuplicateProject = ({
|
||||
<Label>Duplicate to</Label>
|
||||
<RadioGroup
|
||||
value={duplicateType}
|
||||
onValueChange={(value) => {
|
||||
setDuplicateType(value);
|
||||
// Reset selections when changing type
|
||||
if (value !== "existing-environment") {
|
||||
setSelectedTargetProject("");
|
||||
setSelectedTargetEnvironment("");
|
||||
}
|
||||
}}
|
||||
onValueChange={setDuplicateType}
|
||||
className="grid gap-2"
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
@@ -200,13 +135,8 @@ export const DuplicateProject = ({
|
||||
<Label htmlFor="new-project">New project</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem
|
||||
value="existing-environment"
|
||||
id="existing-environment"
|
||||
/>
|
||||
<Label htmlFor="existing-environment">
|
||||
Existing environment
|
||||
</Label>
|
||||
<RadioGroupItem value="same-project" id="same-project" />
|
||||
<Label htmlFor="same-project">Same project</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
@@ -235,74 +165,6 @@ export const DuplicateProject = ({
|
||||
</>
|
||||
)}
|
||||
|
||||
{duplicateType === "existing-environment" && (
|
||||
<>
|
||||
{allProjects?.filter((p) => p.projectId !== environmentId)
|
||||
.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center gap-2 py-4 text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No other projects available. Create a new project first.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Step 1: Select Project */}
|
||||
<div className="grid gap-2">
|
||||
<Label>Target Project</Label>
|
||||
<Select
|
||||
value={selectedTargetProject}
|
||||
onValueChange={(value) => {
|
||||
setSelectedTargetProject(value);
|
||||
setSelectedTargetEnvironment(""); // Reset environment when project changes
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select target project" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{allProjects
|
||||
?.filter((p) => p.projectId !== environmentId)
|
||||
.map((project) => (
|
||||
<SelectItem
|
||||
key={project.projectId}
|
||||
value={project.projectId}
|
||||
>
|
||||
{project.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Step 2: Select Environment (only show if project is selected) */}
|
||||
{selectedTargetProject && (
|
||||
<div className="grid gap-2">
|
||||
<Label>Target Environment</Label>
|
||||
<Select
|
||||
value={selectedTargetEnvironment}
|
||||
onValueChange={setSelectedTargetEnvironment}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select target environment" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{selectedProjectEnvironments?.map((env) => (
|
||||
<SelectItem
|
||||
key={env.environmentId}
|
||||
value={env.environmentId}
|
||||
>
|
||||
{env.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label>Selected services to duplicate</Label>
|
||||
<div className="space-y-2 max-h-[200px] overflow-y-auto border rounded-md p-4">
|
||||
@@ -325,26 +187,18 @@ export const DuplicateProject = ({
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleDuplicate}
|
||||
disabled={
|
||||
isLoading ||
|
||||
(duplicateType === "new-project" && !name) ||
|
||||
(duplicateType === "existing-environment" &&
|
||||
(!selectedTargetProject || !selectedTargetEnvironment))
|
||||
}
|
||||
>
|
||||
<Button onClick={handleDuplicate} disabled={isLoading}>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
{duplicateType === "new-project"
|
||||
? "Duplicating to new project..."
|
||||
: "Duplicating to environment..."}
|
||||
? "Duplicating project..."
|
||||
: "Duplicating services..."}
|
||||
</>
|
||||
) : duplicateType === "new-project" ? (
|
||||
"Duplicate to new project"
|
||||
"Duplicate project"
|
||||
) : (
|
||||
"Duplicate to environment"
|
||||
"Duplicate services"
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user